diff --git a/desktop/src/components/chat/CurrentTurnChangeCard.test.tsx b/desktop/src/components/chat/CurrentTurnChangeCard.test.tsx new file mode 100644 index 00000000..3792e2e4 --- /dev/null +++ b/desktop/src/components/chat/CurrentTurnChangeCard.test.tsx @@ -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; openTarget: () => Promise }) => 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 }) => 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) => { + if (params) { + return Object.entries(params).reduce( + (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( + , + ) +} + +// ────────────────────────────────────────────────────────────────────────────── +// 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) + }) + }) +}) diff --git a/desktop/src/components/chat/CurrentTurnChangeCard.tsx b/desktop/src/components/chat/CurrentTurnChangeCard.tsx index dd4457cc..8405fed6 100644 --- a/desktop/src/components/chat/CurrentTurnChangeCard.tsx +++ b/desktop/src/components/chat/CurrentTurnChangeCard.tsx @@ -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(null) const [diffByPath, setDiffByPath] = useState>({}) + const [openWith, setOpenWith] = useState<{ items: OpenWithItem[]; anchor: DOMRect } | null>(null) const files = useMemo( () => checkpoint.code.filesChanged.map((filePath) => ({ @@ -91,6 +100,27 @@ export function CurrentTurnChangeCard({ }) }, [diffByPath, expandedPath, sessionId, t, targetUserMessageId]) + const handleOpenWith = useCallback((event: ReactMouseEvent, 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 (
- +
+ + +
{isExpanded && (
@@ -195,6 +235,8 @@ export function CurrentTurnChangeCard({ {error}
)} + + {openWith && setOpenWith(null)} />} ) } diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index b61c9928..7bd485a3 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -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', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index f361d783..f89addbb 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -94,6 +94,7 @@ export const zh: Record = { 'openProject.openFailed': '无法打开项目', // ─── Open With ───────────────────────────────────── + 'openWith.title': '打开方式', 'openWith.inAppBrowser': '应用内浏览器', 'openWith.systemBrowser': '系统浏览器', 'openWith.systemDefault': '系统默认打开', diff --git a/desktop/src/lib/openWithContextForHref.test.ts b/desktop/src/lib/openWithContextForHref.test.ts index 85c579c0..7b0ccd7b 100644 --- a/desktop/src/lib/openWithContextForHref.test.ts +++ b/desktop/src/lib/openWithContextForHref.test.ts @@ -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) + } + }) +}) diff --git a/desktop/src/lib/openWithContextForHref.ts b/desktop/src/lib/openWithContextForHref.ts index b982afe6..f5431623 100644 --- a/desktop/src/lib/openWithContextForHref.ts +++ b/desktop/src/lib/openWithContextForHref.ts @@ -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(/^[/\\]+/, '')}`