diff --git a/apps/desktop/src/renderer/__tests__/fileChipCopyLocation.test.tsx b/apps/desktop/src/renderer/__tests__/fileChipCopyLocation.test.tsx new file mode 100644 index 00000000000..08eb87a2101 --- /dev/null +++ b/apps/desktop/src/renderer/__tests__/fileChipCopyLocation.test.tsx @@ -0,0 +1,106 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + writeText: vi.fn(), + success: vi.fn(), + error: vi.fn(), + getAbsPath: vi.fn(), + context: { + sessionId: undefined as string | undefined, + workingDir: '/repo', + origin: { kind: 'local' } as { kind: string; deviceId?: string; remoteHostId?: string }, + }, +})); + +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +vi.mock('@/lib/toast', () => ({ toast: { success: mocks.success, error: mocks.error } })); +vi.mock('@/components/chat/ChatSessionFileContext', () => ({ useChatSessionFile: () => mocks.context })); +vi.mock('@/features/cc-agent/embeddedSessionNavigation', () => ({ + useSidebarTargetSessionId: (id: string | undefined) => id, +})); +vi.mock('@/features/right-sidebar/lib/openInSidebarBrowser', () => ({ + openUrlInSidebarBrowser: vi.fn(), pathToFileUrl: vi.fn(), +})); +vi.mock('@/features/right-sidebar/lib/openInSidebarFileBrowser', () => ({ + openDirInSidebarFileBrowser: vi.fn(), + openExternalFileInSidebarFileBrowser: vi.fn(), + openFileInSidebarFileBrowser: vi.fn(), +})); +vi.mock('@/lib/remoteFileOpen', () => ({ copyRemoteChatFile: vi.fn(), revealRemoteChatFile: vi.fn() })); + +import { useFileChipContextMenu } from '../components/chat/useFileChipContextMenu'; +import type { FileLocation } from '../lib/fileLocation'; + +function FileReference({ location, directory = false }: { location?: FileLocation; directory?: boolean }) { + const { onContextMenu, menu } = useFileChipContextMenu({ + getAbsPath: mocks.getAbsPath, + sidebarFileBrowserKind: directory ? 'directory' : 'file', + location, + }); + return <>{menu}; +} + +async function openMenu(location?: FileLocation, directory = false) { + render(); + fireEvent.contextMenu(screen.getByRole('button', { name: 'file reference' }), { clientX: 10, clientY: 10 }); + await screen.findByRole('menuitem', { name: 'chat.markdownRenderer.copyFilePath' }); +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.context.workingDir = '/repo'; + mocks.context.origin = { kind: 'local' }; + mocks.getAbsPath.mockResolvedValue('/repo/src/example.ts'); + mocks.writeText.mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, value: { writeText: mocks.writeText }, + }); +}); +afterEach(cleanup); + +describe('file chip Copy Location action', () => { + it('copies relative path, line and column without resolving or opening the file again', async () => { + await openMenu({ absPath: '/repo/src/example.ts', line: 42, column: 7 }); + fireEvent.click(screen.getByRole('menuitem', { name: 'chat.markdownRenderer.copyLocation' })); + await waitFor(() => expect(mocks.success).toHaveBeenCalledWith('chat.markdownRenderer.locationCopied')); + expect(mocks.writeText).toHaveBeenCalledWith('src/example.ts:42:7'); + expect(mocks.getAbsPath).not.toHaveBeenCalled(); + }); + + it('retains the separate absolute-path copy action', async () => { + await openMenu({ absPath: '/repo/src/example.ts', line: 42 }); + fireEvent.click(screen.getByRole('menuitem', { name: 'chat.markdownRenderer.copyFilePath' })); + await waitFor(() => expect(mocks.writeText).toHaveBeenCalledWith('/repo/src/example.ts')); + expect(mocks.success).toHaveBeenCalledWith('chat.markdownRenderer.pathCopied'); + }); + + it.each([ + [undefined, false], + [{ absPath: '/repo/src/example.ts' }, false], + [{ absPath: '/outside/example.ts', line: 42 }, false], + [{ absPath: '/repo/src', line: 42 }, true], + ] as const)('omits Copy Location for missing coordinates, outside files and directories (%j)', async (location, directory) => { + await openMenu(location, directory); + expect(screen.queryByRole('menuitem', { name: 'chat.markdownRenderer.copyLocation' })).toBeNull(); + }); + + it('uses the remote source workdir, not the local clipboard host', async () => { + mocks.context.workingDir = 'D:\\Remote'; + mocks.context.origin = { kind: 'device', deviceId: 'remote-device' }; + await openMenu({ absPath: 'd:\\remote\\src\\example.ts', line: 42 }); + fireEvent.click(screen.getByRole('menuitem', { name: 'chat.markdownRenderer.copyLocation' })); + await waitFor(() => expect(mocks.writeText).toHaveBeenCalledWith('src/example.ts:42')); + expect(mocks.getAbsPath).not.toHaveBeenCalled(); + }); + + it('reports clipboard failure without reporting success', async () => { + mocks.writeText.mockRejectedValueOnce(new Error('Clipboard unavailable')); + await openMenu({ absPath: '/repo/src/example.ts', line: 42 }); + fireEvent.click(screen.getByRole('menuitem', { name: 'chat.markdownRenderer.copyLocation' })); + await waitFor(() => expect(mocks.error).toHaveBeenCalledWith('chat.media.copyFailed')); + expect(mocks.success).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/renderer/__tests__/fileLocation.test.ts b/apps/desktop/src/renderer/__tests__/fileLocation.test.ts new file mode 100644 index 00000000000..abbd32023db --- /dev/null +++ b/apps/desktop/src/renderer/__tests__/fileLocation.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; + +import { formatFileLocation } from '../lib/fileLocation'; +import { splitLocalLineSuffix } from '../lib/markdownTarget'; + +describe('file reference clipboard locations', () => { + // These are logical paths belonging to the source machine, not the test host. + it.each([ + ['/repo', '/repo/src/example.ts', 42, undefined, 'src/example.ts:42'], + ['/repo/', '/repo/./src/example.ts', 42, 7, 'src/example.ts:42:7'], + ['C:\\Repo', 'c:\\repo\\src\\example.ts', 42, 7, 'src/example.ts:42:7'], + ['D:/Repo/', 'D:/Repo/src/中文 文件.ts', 1, undefined, 'src/中文 文件.ts:1'], + ['/remote/project', '/remote/project/src/main.ts', 12, 3, 'src/main.ts:12:3'], + ])('formats %s / %s without using host path semantics', (workingDir, absPath, line, column, expected) => { + expect(formatFileLocation(workingDir, { absPath, line, column })).toBe(expected); + }); + + it.each([ + ['', '/repo/a.ts'], + ['/repo', '/other/a.ts'], + ['/repo', '/repo-other/a.ts'], + ['/repo', '/repo'], + ['/repo', '/repo/../other/a.ts'], + ['/repo', 'src/a.ts'], + ['C:/repo', 'D:/repo/a.ts'], + ['C:/repo', '/repo/a.ts'], + ])('does not invent a relative location for %s / %s', (workingDir, absPath) => { + expect(formatFileLocation(workingDir, { absPath, line: 1 })).toBeNull(); + }); + + it.each([undefined, 0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1])( + 'requires a valid line number: %s', (line) => { + expect(formatFileLocation('/repo', { absPath: '/repo/a.ts', line })).toBeNull(); + }, + ); + + it.each([0, -1, 1.5, NaN, Infinity])('rejects an invalid column: %s', (column) => { + expect(formatFileLocation('/repo', { absPath: '/repo/a.ts', line: 1, column })).toBeNull(); + }); + + it('preserves the existing start-line semantics for a line range', () => { + const { href, line, column } = splitLocalLineSuffix('/repo/src/a.ts:42-50'); + expect(formatFileLocation('/repo', { absPath: href, line, column })).toBe('src/a.ts:42'); + }); +}); diff --git a/apps/desktop/src/renderer/components/chat/MarkdownRenderer.tsx b/apps/desktop/src/renderer/components/chat/MarkdownRenderer.tsx index 1bf99b28ffa..af5059350d7 100644 --- a/apps/desktop/src/renderer/components/chat/MarkdownRenderer.tsx +++ b/apps/desktop/src/renderer/components/chat/MarkdownRenderer.tsx @@ -945,6 +945,8 @@ function localKindFromAbsPath(absPath: string, fallback: MarkdownLocalKind): Mar function FileTargetChip({ resolvedAbsPath, localKind, + line, + column, onOpen, title, children, @@ -952,6 +954,8 @@ function FileTargetChip({ }: { resolvedAbsPath: string; localKind: MarkdownLocalKind; + line?: number; + column?: number; onOpen: () => void | Promise; title?: string; children: ReactNode; @@ -986,6 +990,7 @@ function FileTargetChip({ const sidebarTargetSessionId = useSidebarTargetSessionId(htmlWithSession); const ctxMenu = useFileChipContextMenu({ getAbsPath: async () => resolvedAbsPath, + location: { absPath: resolvedAbsPath, line, column }, canOpenInBrowser: localKind !== 'directory' && isBrowserOpenablePath(resolvedAbsPath), sidebarFileBrowserKind: localKind === 'directory' ? 'directory' : 'file', sidebarOpenSessionId: htmlWithSession, @@ -1080,6 +1085,8 @@ function FileTargetChip({ function ResolvedLocalLink({ resolvedAbsPath, localKind, + line, + column, href, onOpen, anchorProps, @@ -1088,6 +1095,8 @@ function ResolvedLocalLink({ }: { resolvedAbsPath: string; localKind: MarkdownLocalKind; + line?: number; + column?: number; href: string; onOpen: () => void | Promise; anchorProps: Record; @@ -1105,6 +1114,7 @@ function ResolvedLocalLink({ const sidebarTargetSessionId = useSidebarTargetSessionId(htmlWithSession); const ctxMenu = useFileChipContextMenu({ getAbsPath: () => resolvedAbsPath, + location: { absPath: resolvedAbsPath, line, column }, canOpenInBrowser: localKind !== 'directory' && isBrowserOpenablePath(resolvedAbsPath), sidebarFileBrowserKind: localKind === 'directory' ? 'directory' : 'file', sidebarOpenSessionId: htmlWithSession, @@ -1483,6 +1493,8 @@ function MarkdownTargetLink({ activateResolvedLocalTarget( diff --git a/apps/desktop/src/renderer/components/chat/useFileChipContextMenu.tsx b/apps/desktop/src/renderer/components/chat/useFileChipContextMenu.tsx index a6ce0a8a661..9e16023505d 100644 --- a/apps/desktop/src/renderer/components/chat/useFileChipContextMenu.tsx +++ b/apps/desktop/src/renderer/components/chat/useFileChipContextMenu.tsx @@ -45,6 +45,7 @@ import { import { useTranslation } from 'react-i18next'; import { toast } from '@/lib/toast'; +import { formatFileLocation, type FileLocation } from '@/lib/fileLocation'; import { mapIpcErrorToI18nKey } from '@/utils/ipcError'; import { DropdownMenu, @@ -101,12 +102,15 @@ export function useFileChipContextMenu({ sidebarFileBrowserKind = 'file', sidebarOpenSessionId, onViewSource, + location, }: { getAbsPath: () => Promise | string; canOpenInBrowser?: boolean; sidebarFileBrowserKind?: 'file' | 'directory'; sidebarOpenSessionId?: string; onViewSource?: () => void | Promise; + /** Only supplied by resolved Markdown references; existing path-copy stays absolute. */ + location?: FileLocation; }): UseFileChipContextMenu { const { t } = useTranslation(); const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null); @@ -123,6 +127,20 @@ export function useFileChipContextMenu({ const remoteOrigin = isRemoteFileOrigin(sessionFileCtx.origin) ? sessionFileCtx.origin : null; const sidebarFileTargetSessionId = useSidebarTargetSessionId(sessionFileCtx.sessionId); const sidebarBrowserTargetSessionId = useSidebarTargetSessionId(sidebarOpenSessionId); + const copyLocation = location && sidebarFileBrowserKind === 'file' + ? formatFileLocation(sessionFileCtx.workingDir, location) + : null; + + async function handleCopyLocation(): Promise { + setMenuPos(null); + if (!copyLocation) return; + try { + await navigator.clipboard.writeText(copyLocation); + toast.success(t('chat.markdownRenderer.locationCopied')); + } catch { + toast.error(t('chat.media.copyFailed')); + } + } async function handleCopyFile(): Promise { setMenuPos(null); @@ -372,6 +390,12 @@ export function useFileChipContextMenu({ {t('chat.markdownRenderer.copyFilePath')} + {copyLocation ? ( + + + {t('chat.markdownRenderer.copyLocation')} + + ) : null} {remoteOrigin diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index 654c41bf897..2849e879821 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -8728,6 +8728,8 @@ "duplicateFiles": "Found {{count}} files with the same name, please use an absolute path", "copyFile": "Copy", "copyFilePath": "Copy file path", + "copyLocation": "Copy Location", + "locationCopied": "Location copied", "openWith": "Open with", "openWithAppFailed": "Couldn't open with the selected app", "revealFile": "Reveal in folder", diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json index 9846c6a192b..79beceeb619 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -8722,6 +8722,8 @@ "duplicateFiles": "同名のファイルが {{count}} 件見つかりました。絶対パスを使用してください", "copyFile": "コピー", "copyFilePath": "ファイルパスをコピー", + "copyLocation": "位置をコピー", + "locationCopied": "位置をコピーしました", "openWith": "アプリで開く", "openWithAppFailed": "選択したアプリで開けませんでした", "revealFile": "ファイルの場所を開く", diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json index b3a13b89f93..8d53bc5bfa8 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -8722,6 +8722,8 @@ "duplicateFiles": "동일한 이름의 파일 {{count}}개를 찾았습니다. 절대 경로를 사용해 주세요", "copyFile": "복사", "copyFilePath": "파일 경로 복사", + "copyLocation": "위치 복사", + "locationCopied": "위치를 복사했습니다", "openWith": "앱으로 열기", "openWithAppFailed": "선택한 앱으로 열 수 없습니다", "revealFile": "파일 위치 열기", diff --git a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json index a78ef6b66f4..ff006f34730 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -8722,6 +8722,8 @@ "duplicateFiles": "找到 {{count}} 个同名文件,请改用绝对路径", "copyFile": "复制", "copyFilePath": "复制文件路径", + "copyLocation": "复制位置", + "locationCopied": "位置已复制", "openWith": "打开方式", "openWithAppFailed": "无法用所选应用打开", "revealFile": "打开文件所在目录", diff --git a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json index f0e2e9c6f5f..70cb170488f 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json @@ -8721,6 +8721,8 @@ "duplicateFiles": "找到 {{count}} 個同名檔案,請改用絕對路徑", "copyFile": "複製", "copyFilePath": "複製檔案路徑", + "copyLocation": "複製位置", + "locationCopied": "位置已複製", "openWith": "開啟方式", "openWithAppFailed": "無法用所選應用開啟", "revealFile": "開啟檔案所在目錄", diff --git a/apps/desktop/src/renderer/lib/fileLocation.ts b/apps/desktop/src/renderer/lib/fileLocation.ts new file mode 100644 index 00000000000..3903fd70d8e --- /dev/null +++ b/apps/desktop/src/renderer/lib/fileLocation.ts @@ -0,0 +1,18 @@ +import { toWorkdirRel } from '../../shared/workdirPath'; + +/** A resolved file reference; coordinates are one-based, as in Markdown targets. */ +export interface FileLocation { + absPath: string; + line?: number; + column?: number; +} + +/** Clipboard locations use workspace-relative POSIX separators on every host. */ +export function formatFileLocation(workingDir: string, location: FileLocation): string | null { + const { absPath, line, column } = location; + if (line === undefined || !Number.isSafeInteger(line) || line < 1) return null; + if (column !== undefined && (!Number.isSafeInteger(column) || column < 1)) return null; + const relativePath = toWorkdirRel(workingDir, absPath); + if (!relativePath) return null; + return `${relativePath}:${line}${column === undefined ? '' : `:${column}`}`; +}