mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +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 { useChatStore } from '../../stores/chatStore'
|
||||
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user