cc-haha/desktop/src/lib/openWithItems.ts
程序员阿江(Relakkes) 7ca702a253 fix(desktop): simplify changed-file open-with menus
Changed-file rows are easier to scan when previewable files are grouped before source-only files, and the file open-with menu should only expose useful preview, editor, and reveal actions. Output target cards also no longer need a separate copy control now that open/open-with are the primary actions.

Constraint: Keep the existing open-with API and menu components shared across changed files, workspace context menus, and output target cards.
Rejected: Keep system-default open for files | it duplicated less predictable platform behavior and added a low-value final menu item.
Confidence: high
Scope-risk: narrow
Directive: File open-with menus should stay ordered by immediate preview/open usefulness before editor and reveal actions.
Tested: cd desktop && bun run test src/lib/openWithItems.test.ts src/components/workspace/WorkspaceFileOpenWith.test.tsx src/components/chat/AssistantOutputTargetCard.test.tsx src/components/chat/CurrentTurnChangeCard.test.tsx
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run build
Tested: git diff --check
2026-06-01 01:43:36 +08:00

81 lines
4.0 KiB
TypeScript

import type { OpenTarget } from '../stores/openTargetStore'
// ─── File-type description ────────────────────────────────────────────────────
export type FileTypeInfo = { icon: string; categoryKey: string; ext: string }
const FILE_TYPE_RULES: Array<{ re: RegExp; key: string; icon: string }> = [
{ re: /\.(md|markdown|txt|rst)$/i, key: 'document', icon: 'description' },
{ re: /\.(html?|xhtml)$/i, key: 'web', icon: 'html' },
{ re: /\.(png|jpe?g|gif|svg|webp|avif|bmp|ico)$/i, key: 'image', icon: 'image' },
{ re: /\.(ts|tsx|js|jsx|mjs|cjs|json|css|scss|less|py|rs|go|java|rb|php|c|cc|cpp|h|hpp|sh|ya?ml|toml)$/i, key: 'code', icon: 'code' },
]
export function describeFileType(path: string): FileTypeInfo {
const ext = (path.split('.').pop() ?? '').toUpperCase()
for (const rule of FILE_TYPE_RULES) {
if (rule.re.test(path)) return { icon: rule.icon, categoryKey: `openWith.fileType.${rule.key}`, ext }
}
return { icon: 'insert_drive_file', categoryKey: 'openWith.fileType.file', ext }
}
const PREVIEWABLE_CHANGED_FILE_RE = /\.(md|markdown|html?|png|jpe?g|gif|webp|svg)$/i
/**
* True only for changed-file types with a meaningful *rendered* preview
* (markdown / html / image). Source files (.ts/.json/.css …) return false.
* Used to decide which change-card rows get the "open with" affordance —
* we don't want an open-with pill on every file when a turn touches many.
*/
export function isPreviewableChangedFile(path: string): boolean {
return PREVIEWABLE_CHANGED_FILE_RE.test(path)
}
// ─── Open-with items ──────────────────────────────────────────────────────────
export type OpenWithIcon = 'in-app-browser' | 'system' | 'ide' | 'file-manager' | 'preview'
export type OpenWithItem = {
id: string
label: string
icon: OpenWithIcon
target?: OpenTarget // present for ide/file-manager items (to render its favicon)
onSelect: () => void
}
export type OpenWithDeps = {
openInAppBrowser: (url: string) => void
openSystem: (urlOrPath: string) => void
openWorkspacePreview: (relPath: string) => void
openTarget: (targetId: string, absolutePath: string) => void
t: (key: string, vars?: Record<string, string>) => string
}
export type OpenWithContext =
| { kind: 'url'; url: string }
| { kind: 'file'; absolutePath: string; relPath?: string; previewable?: boolean; inAppBrowserUrl?: string }
export function buildOpenWithItems(ctx: OpenWithContext, targets: OpenTarget[], deps: OpenWithDeps): OpenWithItem[] {
const items: OpenWithItem[] = []
if (ctx.kind === 'url') {
items.push({ id: 'in-app', label: deps.t('openWith.inAppBrowser'), icon: 'in-app-browser', onSelect: () => deps.openInAppBrowser(ctx.url) })
items.push({ id: 'system', label: deps.t('openWith.systemBrowser'), icon: 'system', onSelect: () => deps.openSystem(ctx.url) })
return items
}
if (ctx.previewable && ctx.relPath != null) {
const relPath = ctx.relPath
items.push({ id: 'preview', label: deps.t('openWith.workspacePreview'), icon: 'preview', onSelect: () => deps.openWorkspacePreview(relPath) })
}
if (ctx.inAppBrowserUrl) {
const url = ctx.inAppBrowserUrl
items.push({ id: 'in-app', label: deps.t('openWith.inAppBrowser'), icon: 'in-app-browser', onSelect: () => deps.openInAppBrowser(url) })
}
for (const target of targets.filter((x) => x.kind === 'ide')) {
items.push({ id: `ide:${target.id}`, label: deps.t('openWith.openInTarget', { target: target.label }), icon: 'ide', target, onSelect: () => deps.openTarget(target.id, ctx.absolutePath) })
}
for (const target of targets.filter((x) => x.kind === 'file_manager')) {
items.push({ id: `fm:${target.id}`, label: deps.t('openWith.revealInTarget', { target: target.label }), icon: 'file-manager', target, onSelect: () => deps.openTarget(target.id, ctx.absolutePath) })
}
return items
}