feat(desktop): add open-with (IDE/file-manager/system) to workspace file context menu

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-05-29 15:19:11 +08:00
parent 55b9ccc276
commit 50dbb6c822
4 changed files with 178 additions and 0 deletions

View File

@ -0,0 +1,89 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom'
import { render, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
const openTarget = vi.hoisted(() => vi.fn())
const shellOpen = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
vi.mock('../../stores/openTargetStore', () => ({
useOpenTargetStore: (sel: (s: unknown) => unknown) =>
sel({
targets: [
{ id: 'code', kind: 'ide', label: 'VS Code', icon: '', platform: 'darwin' },
{ id: 'finder', kind: 'file_manager', label: 'Finder', icon: '', platform: 'darwin' },
],
ensureTargets: () => {},
openTarget,
}),
}))
vi.mock('@tauri-apps/plugin-shell', () => ({ open: shellOpen }))
vi.mock('../../i18n', () => ({
useTranslation: () => (k: string, v?: Record<string, string>) =>
v?.target ? `${k}:${v.target}` : k,
}))
import { WorkspaceFileOpenWith } from './WorkspaceFileOpenWith'
describe('WorkspaceFileOpenWith', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders an item for the IDE target, the file_manager target, and a system-default item', () => {
const { getAllByRole } = render(
<WorkspaceFileOpenWith absolutePath="/w/report.md" />,
)
const menuItems = getAllByRole('menuitem')
// IDE + file_manager + system = 3 items
expect(menuItems).toHaveLength(3)
const labels = menuItems.map((el) => el.textContent)
expect(labels.some((l) => l?.includes('VS Code'))).toBe(true)
expect(labels.some((l) => l?.includes('Finder'))).toBe(true)
// system default item uses the 'openWith.systemDefault' key (returned as-is by mock)
expect(labels.some((l) => l?.includes('openWith.systemDefault'))).toBe(true)
})
it('clicking the IDE item calls openTarget and onAfterSelect', () => {
const onAfter = vi.fn()
const { getAllByRole } = render(
<WorkspaceFileOpenWith absolutePath="/w/report.md" onAfterSelect={onAfter} />,
)
const menuItems = getAllByRole('menuitem')
const ideItem = menuItems.find((el) => el.textContent?.includes('VS Code'))
if (!ideItem) throw new Error('IDE menu item not found')
fireEvent.click(ideItem)
expect(openTarget).toHaveBeenCalledWith('code', '/w/report.md')
expect(onAfter).toHaveBeenCalledTimes(1)
})
it('clicking the system-default item calls shellOpen and onAfterSelect', async () => {
const onAfter = vi.fn()
const { getAllByRole } = render(
<WorkspaceFileOpenWith absolutePath="/w/report.md" onAfterSelect={onAfter} />,
)
const menuItems = getAllByRole('menuitem')
const systemItem = menuItems.find((el) => el.textContent?.includes('openWith.systemDefault'))
if (!systemItem) throw new Error('System default menu item not found')
fireEvent.click(systemItem)
// onAfterSelect is synchronous (called before the dynamic import chain)
expect(onAfter).toHaveBeenCalledTimes(1)
// Flush the microtask queue for the dynamic import + .then chain
for (let i = 0; i < 10; i++) {
await Promise.resolve()
}
expect(shellOpen).toHaveBeenCalledWith('/w/report.md')
})
})

View File

@ -0,0 +1,75 @@
import { useEffect } from 'react'
import { ExternalLink } from 'lucide-react'
import { useTranslation, type TranslationKey } from '../../i18n'
import { useOpenTargetStore } from '../../stores/openTargetStore'
import { buildOpenWithItems, type OpenWithItem, type OpenWithDeps } from '../../lib/openWithItems'
import { TargetIcon } from '../common/TargetIcon'
function openExternal(path: string) {
void import('@tauri-apps/plugin-shell').then((m) => m.open(path)).catch(() => {})
}
export function WorkspaceFileOpenWith({
absolutePath,
onAfterSelect,
}: {
absolutePath: string
onAfterSelect?: () => void
}) {
const t = useTranslation()
const targets = useOpenTargetStore((s) => s.targets)
const ensureTargets = useOpenTargetStore((s) => s.ensureTargets)
const openTarget = useOpenTargetStore((s) => s.openTarget)
useEffect(() => {
void ensureTargets()
}, [ensureTargets])
// Cast t: useTranslation returns (key: TranslationKey, params?: Record<string, string|number>) => string
// but OpenWithDeps.t expects (key: string, vars?: Record<string, string>) => string.
// All keys buildOpenWithItems calls are valid TranslationKeys, so the cast is safe.
const deps: OpenWithDeps = {
openInAppBrowser: () => {},
openWorkspacePreview: () => {},
openSystem: (p) => openExternal(p),
openTarget: (id, p) => {
void openTarget(id, p)
},
t: (key, vars) => t(key as TranslationKey, vars),
}
const items: OpenWithItem[] = buildOpenWithItems(
{ kind: 'file', absolutePath, previewable: false },
targets,
deps,
)
return (
<>
<div className="my-1 border-t border-[var(--color-border)]" role="separator" />
{items.map((item) => (
<button
key={item.id}
type="button"
role="menuitem"
onClick={() => {
item.onSelect()
onAfterSelect?.()
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
>
<span
aria-hidden="true"
className="flex h-[14px] w-[14px] items-center justify-center text-[var(--color-text-tertiary)]"
>
{item.target ? (
<TargetIcon target={item.target} size={14} />
) : (
<ExternalLink size={14} strokeWidth={1.9} />
)}
</span>
<span className="truncate">{item.label}</span>
</button>
))}
</>
)
}

View File

@ -241,6 +241,15 @@ vi.mock('../../api/sessions', () => ({
})(),
}))
vi.mock('../../api/openTargets', () => ({
openTargetsApi: {
list: vi.fn().mockResolvedValue({ platform: 'darwin', targets: [], primaryTargetId: null, cachedAt: 0, ttlMs: 60000 }),
open: vi.fn().mockResolvedValue({ ok: true, targetId: '', path: '' }),
},
}))
vi.mock('@tauri-apps/plugin-shell', () => ({ open: vi.fn().mockResolvedValue(undefined) }))
import { useSettingsStore } from '../../stores/settingsStore'
import { useChatStore } from '../../stores/chatStore'
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'

View File

@ -28,6 +28,7 @@ import {
WorkspaceDiffSurface,
workspacePrismTheme,
} from './WorkspaceCodeSurface'
import { WorkspaceFileOpenWith } from './WorkspaceFileOpenWith'
type WorkspacePanelProps = {
sessionId: string
@ -1501,6 +1502,10 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
<span aria-hidden="true" className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">file_copy</span>
<span>{t('workspace.copyAbsolutePath')}</span>
</button>
<WorkspaceFileOpenWith
absolutePath={resolveWorkspaceAttachmentPath(status?.workDir, fileContextMenu.path)}
onAfterSelect={() => setFileContextMenu(null)}
/>
</div>
)}
</aside>