mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-30 16:23:35 +08:00
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:
parent
55b9ccc276
commit
50dbb6c822
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
75
desktop/src/components/workspace/WorkspaceFileOpenWith.tsx
Normal file
75
desktop/src/components/workspace/WorkspaceFileOpenWith.tsx
Normal 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>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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 { useSettingsStore } from '../../stores/settingsStore'
|
||||||
import { useChatStore } from '../../stores/chatStore'
|
import { useChatStore } from '../../stores/chatStore'
|
||||||
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
|
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import {
|
|||||||
WorkspaceDiffSurface,
|
WorkspaceDiffSurface,
|
||||||
workspacePrismTheme,
|
workspacePrismTheme,
|
||||||
} from './WorkspaceCodeSurface'
|
} from './WorkspaceCodeSurface'
|
||||||
|
import { WorkspaceFileOpenWith } from './WorkspaceFileOpenWith'
|
||||||
|
|
||||||
type WorkspacePanelProps = {
|
type WorkspacePanelProps = {
|
||||||
sessionId: string
|
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 aria-hidden="true" className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">file_copy</span>
|
||||||
<span>{t('workspace.copyAbsolutePath')}</span>
|
<span>{t('workspace.copyAbsolutePath')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<WorkspaceFileOpenWith
|
||||||
|
absolutePath={resolveWorkspaceAttachmentPath(status?.workDir, fileContextMenu.path)}
|
||||||
|
onAfterSelect={() => setFileContextMenu(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user