Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions apps/desktop/src/renderer/__tests__/fileChipCopyLocation.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <><button onContextMenu={onContextMenu}>file reference</button>{menu}</>;
}

async function openMenu(location?: FileLocation, directory = false) {
render(<FileReference location={location} directory={directory} />);
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();
});
});
45 changes: 45 additions & 0 deletions apps/desktop/src/renderer/__tests__/fileLocation.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
16 changes: 16 additions & 0 deletions apps/desktop/src/renderer/components/chat/MarkdownRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -945,13 +945,17 @@ function localKindFromAbsPath(absPath: string, fallback: MarkdownLocalKind): Mar
function FileTargetChip({
resolvedAbsPath,
localKind,
line,
column,
onOpen,
title,
children,
sessionId,
}: {
resolvedAbsPath: string;
localKind: MarkdownLocalKind;
line?: number;
column?: number;
onOpen: () => void | Promise<void>;
title?: string;
children: ReactNode;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1080,6 +1085,8 @@ function FileTargetChip({
function ResolvedLocalLink({
resolvedAbsPath,
localKind,
line,
column,
href,
onOpen,
anchorProps,
Expand All @@ -1088,6 +1095,8 @@ function ResolvedLocalLink({
}: {
resolvedAbsPath: string;
localKind: MarkdownLocalKind;
line?: number;
column?: number;
href: string;
onOpen: () => void | Promise<void>;
anchorProps: Record<string, unknown>;
Expand All @@ -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,
Expand Down Expand Up @@ -1483,6 +1493,8 @@ function MarkdownTargetLink({
<ResolvedLocalLink
resolvedAbsPath={target.absPath}
localKind={target.localKind}
line={target.line}
column={target.column}
href={target.href}
onOpen={openResolvedTarget}
anchorProps={anchorProps}
Expand All @@ -1496,6 +1508,8 @@ function MarkdownTargetLink({
<FileTargetChip
resolvedAbsPath={target.absPath}
localKind={target.localKind}
line={target.line}
column={target.column}
title={target.href}
onOpen={openResolvedTarget}
sessionId={sessionId}
Expand Down Expand Up @@ -1615,6 +1629,8 @@ function InlineCodeWithTarget({
<FileTargetChip
resolvedAbsPath={target.absPath}
localKind={target.localKind}
line={target.line}
column={target.column}
title={target.absPath}
onOpen={() =>
activateResolvedLocalTarget(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -101,12 +102,15 @@ export function useFileChipContextMenu({
sidebarFileBrowserKind = 'file',
sidebarOpenSessionId,
onViewSource,
location,
}: {
getAbsPath: () => Promise<string> | string;
canOpenInBrowser?: boolean;
sidebarFileBrowserKind?: 'file' | 'directory';
sidebarOpenSessionId?: string;
onViewSource?: () => void | Promise<void>;
/** 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);
Expand All @@ -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<void> {
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<void> {
setMenuPos(null);
Expand Down Expand Up @@ -372,6 +390,12 @@ export function useFileChipContextMenu({
<ClipboardCopy className="mr-2 h-4 w-4" />
{t('chat.markdownRenderer.copyFilePath')}
</DropdownMenuItem>
{copyLocation ? (
<DropdownMenuItem onClick={handleCopyLocation}>
<ClipboardCopy className="mr-2 h-4 w-4" />
{t('chat.markdownRenderer.copyLocation')}
</DropdownMenuItem>
) : null}
<DropdownMenuItem onClick={handleReveal}>
<FolderOpen className="mr-2 h-4 w-4" />
{remoteOrigin
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/renderer/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -8067,6 +8067,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",
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/renderer/i18n/locales/ja/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -8055,6 +8055,8 @@
"duplicateFiles": "同名のファイルが {{count}} 件見つかりました。絶対パスを使用してください",
"copyFile": "コピー",
"copyFilePath": "ファイルパスをコピー",
"copyLocation": "位置をコピー",
"locationCopied": "位置をコピーしました",
"openWith": "アプリで開く",
"openWithAppFailed": "選択したアプリで開けませんでした",
"revealFile": "ファイルの場所を開く",
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/renderer/i18n/locales/ko/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -8055,6 +8055,8 @@
"duplicateFiles": "동일한 이름의 파일 {{count}}개를 찾았습니다. 절대 경로를 사용해 주세요",
"copyFile": "복사",
"copyFilePath": "파일 경로 복사",
"copyLocation": "위치 복사",
"locationCopied": "위치를 복사했습니다",
"openWith": "앱으로 열기",
"openWithAppFailed": "선택한 앱으로 열 수 없습니다",
"revealFile": "파일 위치 열기",
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/renderer/i18n/locales/zh-CN/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -8047,6 +8047,8 @@
"duplicateFiles": "找到 {{count}} 个同名文件,请改用绝对路径",
"copyFile": "复制",
"copyFilePath": "复制文件路径",
"copyLocation": "复制位置",
"locationCopied": "位置已复制",
"openWith": "打开方式",
"openWithAppFailed": "无法用所选应用打开",
"revealFile": "打开文件所在目录",
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/renderer/i18n/locales/zh-TW/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -8046,6 +8046,8 @@
"duplicateFiles": "找到 {{count}} 個同名檔案,請改用絕對路徑",
"copyFile": "複製",
"copyFilePath": "複製檔案路徑",
"copyLocation": "複製位置",
"locationCopied": "位置已複製",
"openWith": "開啟方式",
"openWithAppFailed": "無法用所選應用開啟",
"revealFile": "開啟檔案所在目錄",
Expand Down
18 changes: 18 additions & 0 deletions apps/desktop/src/renderer/lib/fileLocation.ts
Original file line number Diff line number Diff line change
@@ -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}`}`;
}