mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
0035e7df3f
commit
3f1617d139
211
desktop/src/components/chat/CurrentTurnChangeCard.test.tsx
Normal file
211
desktop/src/components/chat/CurrentTurnChangeCard.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -94,6 +94,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'openProject.openFailed': '无法打开项目',
|
||||
|
||||
// ─── Open With ─────────────────────────────────────
|
||||
'openWith.title': '打开方式',
|
||||
'openWith.inAppBrowser': '应用内浏览器',
|
||||
'openWith.systemBrowser': '系统浏览器',
|
||||
'openWith.systemDefault': '系统默认打开',
|
||||
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@ -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(/^[/\\]+/, '')}`
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user