feat(desktop): add per-file open-with/preview to the turn changes card

Each file row in CurrentTurnChangeCard now has a sibling "open with" button
(open_in_new icon) that shows an OpenWithMenu, reusing buildOpenWithItems and
openWithContextForWorkspaceFile. HTML/XHTML files also get the in-app browser
option. Tests and i18n keys (openWith.title) included.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-05-29 16:20:27 +08:00
parent 0035e7df3f
commit 3f1617d139
6 changed files with 326 additions and 18 deletions

View File

@ -0,0 +1,211 @@
import '@testing-library/jest-dom'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { act } from 'react'
// ──────────────────────────────────────────────────────────────────────────────
// Hoisted mocks (vi.hoisted runs before module evaluation)
// ──────────────────────────────────────────────────────────────────────────────
const { openPreviewSpy, browserOpenSpy, openTargetSpy, ensureTargetsMock } = vi.hoisted(() => {
const openPreviewSpy = vi.fn().mockResolvedValue(undefined)
const browserOpenSpy = vi.fn()
const openTargetSpy = vi.fn().mockResolvedValue(undefined)
const ensureTargetsMock = vi.fn().mockResolvedValue(undefined)
return { openPreviewSpy, browserOpenSpy, openTargetSpy, ensureTargetsMock }
})
// Mock sessionsApi so diff calls don't run
vi.mock('../../api/sessions', () => ({
sessionsApi: {
getTurnCheckpointDiff: vi.fn().mockResolvedValue({ state: 'ok', diff: '--- a\n+++ b\n@@ -0,0 +1 @@\n+hello' }),
},
}))
// Mock openTargetStore
vi.mock('../../stores/openTargetStore', () => ({
useOpenTargetStore: Object.assign(
// Selector hook form: useOpenTargetStore((s) => s.xxx)
(selector: (s: { targets: unknown[]; ensureTargets: () => Promise<void>; openTarget: () => Promise<void> }) => unknown) =>
selector({
targets: [{ id: 'code', kind: 'ide', label: 'VS Code', icon: '', platform: 'darwin' }],
ensureTargets: ensureTargetsMock,
openTarget: openTargetSpy,
}),
{
// Static .getState() access
getState: vi.fn(() => ({
targets: [{ id: 'code', kind: 'ide', label: 'VS Code', icon: '', platform: 'darwin' }],
ensureTargets: ensureTargetsMock,
openTarget: openTargetSpy,
})),
},
),
}))
// Mock browserPanelStore
vi.mock('../../stores/browserPanelStore', () => ({
useBrowserPanelStore: Object.assign(
(selector: (s: { open: () => void }) => unknown) =>
selector({ open: browserOpenSpy }),
{
getState: vi.fn(() => ({ open: browserOpenSpy })),
},
),
}))
// Mock workspacePanelStore
vi.mock('../../stores/workspacePanelStore', () => ({
useWorkspacePanelStore: Object.assign(
(selector: (s: { openPreview: () => Promise<void> }) => unknown) =>
selector({ openPreview: openPreviewSpy }),
{
getState: vi.fn(() => ({ openPreview: openPreviewSpy })),
},
),
}))
// Mock @tauri-apps/plugin-shell
vi.mock('@tauri-apps/plugin-shell', () => ({
open: vi.fn().mockResolvedValue(undefined),
}))
// Mock desktopRuntime.getServerBaseUrl
vi.mock('../../lib/desktopRuntime', () => ({
getServerBaseUrl: vi.fn(() => 'http://127.0.0.1:4321'),
}))
// Mock useTranslation: returns identity-ish t function
vi.mock('../../i18n', () => ({
useTranslation: () => (key: string, params?: Record<string, string | number>) => {
if (params) {
return Object.entries(params).reduce<string>(
(acc, [k, v]) => acc.replace(`{${k}}`, String(v)),
key,
)
}
return key
},
}))
// ──────────────────────────────────────────────────────────────────────────────
// Import after mocks
// ──────────────────────────────────────────────────────────────────────────────
import { CurrentTurnChangeCard } from './CurrentTurnChangeCard'
import type { SessionTurnCheckpoint } from '../../api/sessions'
// ──────────────────────────────────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────────────────────────────────
function makeCheckpoint(filesChanged: string[]): SessionTurnCheckpoint {
return {
code: {
available: true,
filesChanged,
insertions: 10,
deletions: 0,
},
target: {
targetUserMessageId: 'msg-1',
userMessageIndex: 0,
userMessageCount: 1,
},
conversation: {
messagesRemoved: 0,
},
}
}
function renderCard(filesChanged: string[]) {
const checkpoint = makeCheckpoint(filesChanged)
return render(
<CurrentTurnChangeCard
sessionId="s1"
targetUserMessageId="msg-1"
checkpoint={checkpoint}
workDir="/w/proj"
error={null}
isUndoing={false}
isLatest={true}
onUndo={vi.fn()}
/>,
)
}
// ──────────────────────────────────────────────────────────────────────────────
// Tests
// ──────────────────────────────────────────────────────────────────────────────
describe('CurrentTurnChangeCard open-with buttons', () => {
beforeEach(() => {
vi.clearAllMocks()
ensureTargetsMock.mockResolvedValue(undefined)
openPreviewSpy.mockResolvedValue(undefined)
})
it('renders one "open-with" button per file', () => {
renderCard(['/w/proj/README.md', '/w/proj/index.html'])
// aria-label is the i18n key itself (identity mock)
const buttons = screen.getAllByRole('button', { name: 'openWith.title' })
expect(buttons).toHaveLength(2)
})
it('clicking README.md open-with opens menu with workspace preview item', async () => {
renderCard(['/w/proj/README.md'])
const [openWithBtn] = screen.getAllByRole('button', { name: 'openWith.title' })
await act(async () => {
fireEvent.click(openWithBtn!)
})
// The menu should show a workspace preview item (i18n key)
expect(await screen.findByText('openWith.workspacePreview')).toBeInTheDocument()
})
it('clicking workspace preview item in README.md menu calls openPreview', async () => {
renderCard(['/w/proj/README.md'])
const [openWithBtn] = screen.getAllByRole('button', { name: 'openWith.title' })
await act(async () => {
fireEvent.click(openWithBtn!)
})
const previewItem = await screen.findByText('openWith.workspacePreview')
await act(async () => {
fireEvent.click(previewItem)
})
expect(openPreviewSpy).toHaveBeenCalledWith('s1', 'README.md', 'file')
})
it('clicking index.html open-with opens menu with in-app browser item', async () => {
renderCard(['/w/proj/index.html'])
const [openWithBtn] = screen.getAllByRole('button', { name: 'openWith.title' })
await act(async () => {
fireEvent.click(openWithBtn!)
})
expect(await screen.findByText('openWith.inAppBrowser')).toBeInTheDocument()
})
it('ensureTargets is called when open-with button is clicked', async () => {
renderCard(['/w/proj/README.md'])
const [openWithBtn] = screen.getAllByRole('button', { name: 'openWith.title' })
await act(async () => {
fireEvent.click(openWithBtn!)
})
expect(ensureTargetsMock).toHaveBeenCalledTimes(1)
})
it('diff-toggle button still works (not nested button regression)', async () => {
renderCard(['/w/proj/README.md'])
// The diff toggle has aria-label from the i18n key + path
const diffBtn = screen.getByRole('button', { name: /turnChangesShowDiffAria/ })
expect(diffBtn).toBeInTheDocument()
// Clicking should not throw
await act(async () => {
fireEvent.click(diffBtn)
})
})
})

View File

@ -1,7 +1,15 @@
import { useCallback, useMemo, useState } from 'react'
import type { MouseEvent as ReactMouseEvent } from 'react'
import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions'
import { useTranslation } from '../../i18n'
import { useTranslation, type TranslationKey } from '../../i18n'
import { WorkspaceDiffSurface } from '../workspace/WorkspaceCodeSurface'
import { OpenWithMenu } from '../common/OpenWithMenu'
import { buildOpenWithItems, type OpenWithItem } from '../../lib/openWithItems'
import { openWithContextForWorkspaceFile } from '../../lib/openWithContextForHref'
import { getServerBaseUrl } from '../../lib/desktopRuntime'
import { useOpenTargetStore } from '../../stores/openTargetStore'
import { useBrowserPanelStore } from '../../stores/browserPanelStore'
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
type DiffPreviewState = {
loading: boolean
@ -38,6 +46,7 @@ export function CurrentTurnChangeCard({
const t = useTranslation()
const [expandedPath, setExpandedPath] = useState<string | null>(null)
const [diffByPath, setDiffByPath] = useState<Record<string, DiffPreviewState>>({})
const [openWith, setOpenWith] = useState<{ items: OpenWithItem[]; anchor: DOMRect } | null>(null)
const files = useMemo<ChangedFileEntry[]>(
() => checkpoint.code.filesChanged.map((filePath) => ({
@ -91,6 +100,27 @@ export function CurrentTurnChangeCard({
})
}, [diffByPath, expandedPath, sessionId, t, targetUserMessageId])
const handleOpenWith = useCallback((event: ReactMouseEvent<HTMLButtonElement>, fileEntry: ChangedFileEntry) => {
event.stopPropagation()
const rect = event.currentTarget.getBoundingClientRect()
void (async () => {
await useOpenTargetStore.getState().ensureTargets()
const targets = useOpenTargetStore.getState().targets
const ctx = openWithContextForWorkspaceFile(fileEntry.displayPath, fileEntry.apiPath, {
sessionId,
serverBaseUrl: getServerBaseUrl(),
})
const items = buildOpenWithItems(ctx, targets, {
openInAppBrowser: (url) => useBrowserPanelStore.getState().open(sessionId, url),
openSystem: (p) => { void import('@tauri-apps/plugin-shell').then((m) => m.open(p)).catch(() => {}) },
openWorkspacePreview: (rel) => { void useWorkspacePanelStore.getState().openPreview(sessionId, rel, 'file') },
openTarget: (id, abs) => { void useOpenTargetStore.getState().openTarget(id, abs) },
t: (k, v) => t(k as TranslationKey, v),
})
setOpenWith({ items, anchor: rect })
})()
}, [sessionId, t])
const cardLabel = isLatest
? t('chat.turnChangesLatestCardLabel')
: t('chat.turnChangesHistoricalCardLabel')
@ -145,22 +175,32 @@ export function CurrentTurnChangeCard({
const diffState = diffByPath[fileEntry.apiPath]
return (
<div key={fileEntry.apiPath}>
<button
type="button"
onClick={() => toggleDiff(fileEntry)}
aria-label={t(
isExpanded ? 'chat.turnChangesHideDiffAria' : 'chat.turnChangesShowDiffAria',
{ path: fileEntry.displayPath },
)}
className="flex min-h-11 w-full items-center gap-3 px-4 text-left text-sm text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35"
>
<span className="material-symbols-outlined shrink-0 text-[17px] text-[var(--color-text-tertiary)]">
{isExpanded ? 'keyboard_arrow_down' : 'chevron_right'}
</span>
<span className="min-w-0 flex-1 truncate font-mono text-[13px]">
{fileEntry.displayPath}
</span>
</button>
<div className="flex items-center">
<button
type="button"
onClick={() => toggleDiff(fileEntry)}
aria-label={t(
isExpanded ? 'chat.turnChangesHideDiffAria' : 'chat.turnChangesShowDiffAria',
{ path: fileEntry.displayPath },
)}
className="flex min-h-11 flex-1 items-center gap-3 px-4 text-left text-sm text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35"
>
<span className="material-symbols-outlined shrink-0 text-[17px] text-[var(--color-text-tertiary)]">
{isExpanded ? 'keyboard_arrow_down' : 'chevron_right'}
</span>
<span className="min-w-0 flex-1 truncate font-mono text-[13px]">
{fileEntry.displayPath}
</span>
</button>
<button
type="button"
aria-label={t('openWith.title')}
onClick={(event) => handleOpenWith(event, fileEntry)}
className="mr-2 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-[var(--radius-md)] text-[var(--color-text-tertiary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
>
<span className="material-symbols-outlined text-[18px]">open_in_new</span>
</button>
</div>
{isExpanded && (
<div className="border-t border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-4 py-3">
@ -195,6 +235,8 @@ export function CurrentTurnChangeCard({
{error}
</div>
)}
{openWith && <OpenWithMenu items={openWith.items} anchor={openWith.anchor} onClose={() => setOpenWith(null)} />}
</section>
)
}

View File

@ -92,6 +92,7 @@ export const en = {
'openProject.openFailed': 'Could not open project',
// ─── Open With ─────────────────────────────────────
'openWith.title': 'Open with',
'openWith.inAppBrowser': 'In-app browser',
'openWith.systemBrowser': 'System browser',
'openWith.systemDefault': 'Open with system default',

View File

@ -94,6 +94,7 @@ export const zh: Record<TranslationKey, string> = {
'openProject.openFailed': '无法打开项目',
// ─── Open With ─────────────────────────────────────
'openWith.title': '打开方式',
'openWith.inAppBrowser': '应用内浏览器',
'openWith.systemBrowser': '系统浏览器',
'openWith.systemDefault': '系统默认打开',

View File

@ -5,7 +5,7 @@ vi.mock('./desktopRuntime', () => ({
isLoopbackHostname: (h: string) => h === 'localhost' || h === '127.0.0.1' || h === '::1',
}))
import { openWithContextForHref } from './openWithContextForHref'
import { openWithContextForHref, openWithContextForWorkspaceFile } from './openWithContextForHref'
import { previewFsUrl } from './handlePreviewLink'
const BASE = 'http://127.0.0.1:4321'
@ -51,3 +51,39 @@ describe('openWithContextForHref', () => {
expect(result).toEqual({ kind: 'file', absolutePath: '/proj/src/index.ts', relPath: 'src/index.ts', previewable: true })
})
})
describe('openWithContextForWorkspaceFile', () => {
it('.md rel path → { kind:"file", absolutePath, relPath, previewable:true } with no inAppBrowserUrl', () => {
const result = openWithContextForWorkspaceFile('README.md', '/w/proj/README.md', { sessionId: SESSION, serverBaseUrl: BASE })
expect(result).toEqual({ kind: 'file', absolutePath: '/w/proj/README.md', relPath: 'README.md', previewable: true })
})
it('index.html rel path → also has inAppBrowserUrl equal to previewFsUrl', () => {
const result = openWithContextForWorkspaceFile('index.html', '/w/proj/index.html', { sessionId: SESSION, serverBaseUrl: BASE })
expect(result).toEqual({
kind: 'file',
absolutePath: '/w/proj/index.html',
relPath: 'index.html',
previewable: true,
inAppBrowserUrl: previewFsUrl(BASE, SESSION, 'index.html'),
})
})
it('.htm extension → also has inAppBrowserUrl', () => {
const result = openWithContextForWorkspaceFile('page.htm', '/w/proj/page.htm', { sessionId: SESSION, serverBaseUrl: BASE })
expect(result.kind).toBe('file')
if (result.kind === 'file') {
expect(result.inAppBrowserUrl).toBeDefined()
expect(result.inAppBrowserUrl).toBe(previewFsUrl(BASE, SESSION, 'page.htm'))
}
})
it('.ts rel path → no inAppBrowserUrl', () => {
const result = openWithContextForWorkspaceFile('src/app.ts', '/w/proj/src/app.ts', { sessionId: SESSION, serverBaseUrl: BASE })
expect(result.kind).toBe('file')
if (result.kind === 'file') {
expect(result.inAppBrowserUrl).toBeUndefined()
expect(result.previewable).toBe(true)
}
})
})

View File

@ -2,6 +2,23 @@ import { classifyPreviewLink } from './previewLinkRouter'
import { previewFsUrl } from './handlePreviewLink'
import type { OpenWithContext } from './openWithItems'
const HTML_EXT = /\.(html?|xhtml)$/i
/** Build an open-with context for a workspace file (we have both its relative + absolute path). */
export function openWithContextForWorkspaceFile(
relPath: string,
absolutePath: string,
opts: { sessionId: string; serverBaseUrl: string },
): OpenWithContext {
return {
kind: 'file',
absolutePath,
relPath,
previewable: true,
inAppBrowserUrl: HTML_EXT.test(relPath) ? previewFsUrl(opts.serverBaseUrl, opts.sessionId, relPath) : undefined,
}
}
function resolveAbsolute(workDir: string | undefined, p: string): string {
if (!workDir || p.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(p)) return p
return `${workDir.replace(/[\\/]+$/, '')}/${p.replace(/^[/\\]+/, '')}`