From 50dbb6c8226487b89267f2371a9d4dfaa56b7e84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 29 May 2026 15:19:11 +0800 Subject: [PATCH] feat(desktop): add open-with (IDE/file-manager/system) to workspace file context menu Co-Authored-By: Claude Sonnet 4.6 --- .../workspace/WorkspaceFileOpenWith.test.tsx | 89 +++++++++++++++++++ .../workspace/WorkspaceFileOpenWith.tsx | 75 ++++++++++++++++ .../workspace/WorkspacePanel.test.tsx | 9 ++ .../components/workspace/WorkspacePanel.tsx | 5 ++ 4 files changed, 178 insertions(+) create mode 100644 desktop/src/components/workspace/WorkspaceFileOpenWith.test.tsx create mode 100644 desktop/src/components/workspace/WorkspaceFileOpenWith.tsx diff --git a/desktop/src/components/workspace/WorkspaceFileOpenWith.test.tsx b/desktop/src/components/workspace/WorkspaceFileOpenWith.test.tsx new file mode 100644 index 00000000..057587d1 --- /dev/null +++ b/desktop/src/components/workspace/WorkspaceFileOpenWith.test.tsx @@ -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) => + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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') + }) +}) diff --git a/desktop/src/components/workspace/WorkspaceFileOpenWith.tsx b/desktop/src/components/workspace/WorkspaceFileOpenWith.tsx new file mode 100644 index 00000000..f3667450 --- /dev/null +++ b/desktop/src/components/workspace/WorkspaceFileOpenWith.tsx @@ -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 + // but OpenWithDeps.t expects (key: string, vars?: Record) => 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 ( + <> +
+ {items.map((item) => ( + + ))} + + ) +} diff --git a/desktop/src/components/workspace/WorkspacePanel.test.tsx b/desktop/src/components/workspace/WorkspacePanel.test.tsx index 2a42a58f..2ea4f967 100644 --- a/desktop/src/components/workspace/WorkspacePanel.test.tsx +++ b/desktop/src/components/workspace/WorkspacePanel.test.tsx @@ -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' diff --git a/desktop/src/components/workspace/WorkspacePanel.tsx b/desktop/src/components/workspace/WorkspacePanel.tsx index 47869208..ac3603ce 100644 --- a/desktop/src/components/workspace/WorkspacePanel.tsx +++ b/desktop/src/components/workspace/WorkspacePanel.tsx @@ -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) { {t('workspace.copyAbsolutePath')} + setFileContextMenu(null)} + />
)}