cc-haha/desktop/src/lib/openWithItems.test.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

180 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, it, vi } from 'vitest'
import { buildOpenWithItems, describeFileType, isPreviewableChangedFile, type OpenWithContext, type OpenWithDeps } from './openWithItems'
import type { OpenTarget } from '../stores/openTargetStore'
// ──────────────────────────────────────────────────────────────────────────────
// describeFileType tests
// ──────────────────────────────────────────────────────────────────────────────
describe('describeFileType', () => {
it('markdown → document icon, document categoryKey, uppercased ext', () => {
expect(describeFileType('a.md')).toEqual({
icon: 'description',
categoryKey: 'openWith.fileType.document',
ext: 'MD',
})
})
it('HTML (uppercase path) → web icon, web categoryKey', () => {
expect(describeFileType('x.HTML')).toEqual({
icon: 'html',
categoryKey: 'openWith.fileType.web',
ext: 'HTML',
})
})
it('png → image icon, image categoryKey', () => {
expect(describeFileType('y.png')).toEqual({
icon: 'image',
categoryKey: 'openWith.fileType.image',
ext: 'PNG',
})
})
it('tsx → code icon, code categoryKey', () => {
expect(describeFileType('z.tsx')).toEqual({
icon: 'code',
categoryKey: 'openWith.fileType.code',
ext: 'TSX',
})
})
it('unknown extension → generic file icon, file categoryKey', () => {
expect(describeFileType('w.bin')).toEqual({
icon: 'insert_drive_file',
categoryKey: 'openWith.fileType.file',
ext: 'BIN',
})
})
})
// ──────────────────────────────────────────────────────────────────────────────
// isPreviewableChangedFile tests — only md/html/image get the open-with affordance
// ──────────────────────────────────────────────────────────────────────────────
describe('isPreviewableChangedFile', () => {
it.each([
'a.md', 'a.markdown', 'x.html', 'x.htm', 'X.HTML',
'y.png', 'y.JPG', 'z.jpeg', 'g.gif', 'w.webp', 'v.svg',
'docs/sub/readme.md',
])('previewable: %s → true', (p) => {
expect(isPreviewableChangedFile(p)).toBe(true)
})
it.each([
'main.ts', 'main.tsx', 'data.json', 'style.css', 'notes.txt',
'lib.rs', 'Makefile', 'archive.zip', 'no-ext', 'a.mdx',
])('non-previewable: %s → false', (p) => {
expect(isPreviewableChangedFile(p)).toBe(false)
})
})
function makeT() {
return (key: string, vars?: Record<string, string>) =>
vars?.target != null ? `${key}:${vars.target}` : key
}
function makeDeps(overrides?: Partial<OpenWithDeps>): OpenWithDeps {
return {
openInAppBrowser: vi.fn(),
openSystem: vi.fn(),
openWorkspacePreview: vi.fn(),
openTarget: vi.fn(),
t: makeT(),
...overrides,
}
}
const ideTarget: OpenTarget = { id: 'code', kind: 'ide', label: 'VS Code', icon: 'vscode', platform: 'darwin' }
const fmTarget: OpenTarget = { id: 'finder', kind: 'file_manager', label: 'Finder', icon: 'finder', platform: 'darwin' }
describe('buildOpenWithItems url context', () => {
it('returns exactly [in-app, system] for a url context', () => {
const deps = makeDeps()
const ctx: OpenWithContext = { kind: 'url', url: 'https://example.com' }
const items = buildOpenWithItems(ctx, [], deps)
expect(items.map((i) => i.id)).toEqual(['in-app', 'system'])
})
it('in-app calls openInAppBrowser with url', () => {
const deps = makeDeps()
const ctx: OpenWithContext = { kind: 'url', url: 'https://example.com' }
const items = buildOpenWithItems(ctx, [], deps)
items[0]!.onSelect()
expect(deps.openInAppBrowser).toHaveBeenCalledWith('https://example.com')
expect(deps.openSystem).not.toHaveBeenCalled()
})
it('system calls openSystem with url', () => {
const deps = makeDeps()
const ctx: OpenWithContext = { kind: 'url', url: 'https://example.com' }
const items = buildOpenWithItems(ctx, [], deps)
items[1]!.onSelect()
expect(deps.openSystem).toHaveBeenCalledWith('https://example.com')
expect(deps.openInAppBrowser).not.toHaveBeenCalled()
})
})
describe('buildOpenWithItems file context with targets', () => {
it('returns preview/open targets without a system-default fallback', () => {
const deps = makeDeps()
const ctx: OpenWithContext = { kind: 'file', absolutePath: '/w/a.md', relPath: 'a.md', previewable: true }
const items = buildOpenWithItems(ctx, [ideTarget, fmTarget], deps)
expect(items.map((i) => i.id)).toEqual(['preview', 'ide:code', 'fm:finder'])
})
it('preview calls openWorkspacePreview with relPath', () => {
const deps = makeDeps()
const ctx: OpenWithContext = { kind: 'file', absolutePath: '/w/a.md', relPath: 'a.md', previewable: true }
const items = buildOpenWithItems(ctx, [ideTarget, fmTarget], deps)
const preview = items.find((i) => i.id === 'preview')!
preview.onSelect()
expect(deps.openWorkspacePreview).toHaveBeenCalledWith('a.md')
})
it('ide:code calls openTarget with correct args', () => {
const deps = makeDeps()
const ctx: OpenWithContext = { kind: 'file', absolutePath: '/w/a.md', relPath: 'a.md', previewable: true }
const items = buildOpenWithItems(ctx, [ideTarget, fmTarget], deps)
const ideItem = items.find((i) => i.id === 'ide:code')!
ideItem.onSelect()
expect(deps.openTarget).toHaveBeenCalledWith('code', '/w/a.md')
})
it('does not include a system-default item for files', () => {
const deps = makeDeps()
const ctx: OpenWithContext = { kind: 'file', absolutePath: '/w/a.md', relPath: 'a.md', previewable: true }
const items = buildOpenWithItems(ctx, [ideTarget, fmTarget], deps)
expect(items.some((i) => i.id === 'system')).toBe(false)
expect(deps.openSystem).not.toHaveBeenCalled()
})
it('ide item carries the target object', () => {
const deps = makeDeps()
const ctx: OpenWithContext = { kind: 'file', absolutePath: '/w/a.md', relPath: 'a.md', previewable: true }
const items = buildOpenWithItems(ctx, [ideTarget, fmTarget], deps)
const ideItem = items.find((i) => i.id === 'ide:code')!
expect(ideItem.target).toBe(ideTarget)
})
})
describe('buildOpenWithItems file context with inAppBrowserUrl (no previewable)', () => {
it('returns only the browser preview item for no targets + inAppBrowserUrl', () => {
const deps = makeDeps()
const ctx: OpenWithContext = {
kind: 'file',
absolutePath: '/w/page.html',
inAppBrowserUrl: 'http://127.0.0.1:4321/preview-fs/s1/page.html',
}
const items = buildOpenWithItems(ctx, [], deps)
expect(items.map((i) => i.id)).toEqual(['in-app'])
})
it('in-app calls openInAppBrowser with inAppBrowserUrl', () => {
const deps = makeDeps()
const inAppBrowserUrl = 'http://127.0.0.1:4321/preview-fs/s1/page.html'
const ctx: OpenWithContext = { kind: 'file', absolutePath: '/w/page.html', inAppBrowserUrl }
const items = buildOpenWithItems(ctx, [], deps)
items.find((i) => i.id === 'in-app')!.onSelect()
expect(deps.openInAppBrowser).toHaveBeenCalledWith(inAppBrowserUrl)
})
})