Skip to content
Merged
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
14 changes: 14 additions & 0 deletions src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5956,6 +5956,20 @@ ipcMain.handle('file:openFile', async (_event, filePath: string) => {
}
});

ipcMain.handle('file:existsPath', async (_event, filePath: string) => {
try {
const resolvedPath = expandLocalPathShorthand(
filePath,
normalizeLocalPathPlatform(process.platform),
app.getPath('home')
);
return { exists: existsSync(resolvedPath) };
} catch (error) {
console.error('Failed to check file existence:', error);
return { exists: false };
}
});

// File operations - write/save file content
ipcMain.handle(
'file:writeContent',
Expand Down
3 changes: 3 additions & 0 deletions src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1319,6 +1319,9 @@ const electronAPI = {
openFile: (filePath: string): Promise<{ success: boolean; error?: string }> => {
return ipcRenderer.invoke('file:openFile', filePath);
},
existsPath: (filePath: string): Promise<{ exists: boolean }> => {
return ipcRenderer.invoke('file:existsPath', filePath);
},
writeContent: (
filePath: string,
content: string
Expand Down
48 changes: 47 additions & 1 deletion src/renderer/components/MessageItem.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { memo, useMemo } from 'react';
import React, { memo, useEffect, useMemo, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { ClockIcon, FileIcon, VolumeMuteIcon, CodeBlockWithCopy } from './';
Expand Down Expand Up @@ -420,6 +420,8 @@ interface FilePathLinkProps {
variant?: 'default' | 'code';
}

const pathExistenceCache = new Map<string, boolean>();

function FilePathLink({
path,
platform,
Expand All @@ -428,6 +430,50 @@ function FilePathLink({
variant = 'default',
}: FilePathLinkProps): React.ReactElement {
const className = getLinkClassName(tone, variant);
const cacheKey = `${platform}:${homePath ?? ''}:${path}`;
const [pathExists, setPathExists] = useState<boolean | null>(
pathExistenceCache.has(cacheKey) ? pathExistenceCache.get(cacheKey)! : null
);

useEffect(() => {
const cachedValue = pathExistenceCache.get(cacheKey);
if (cachedValue !== undefined) {
setPathExists(cachedValue);
return;
}

let isActive = true;

window.electronAPI.file
.existsPath(path)
.then(({ exists }) => {
pathExistenceCache.set(cacheKey, exists);
if (isActive) {
setPathExists(exists);
}
})
.catch((error) => {
console.error('Failed to verify file path from message:', error);
pathExistenceCache.set(cacheKey, false);
if (isActive) {
setPathExists(false);
}
});

return () => {
isActive = false;
};
}, [cacheKey, path]);

if (pathExists !== true) {
return variant === 'code' ? (
<code className="bg-copilot-bg px-1 py-0.5 rounded text-copilot-warning text-xs break-all">
{path}
</code>
) : (
<>{path}</>
);
}

return (
<a
Expand Down
38 changes: 28 additions & 10 deletions tests/components/MessageItem.test.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { MessageItem } from '../../src/renderer/components/MessageItem';
import type { Message } from '../../src/renderer/types';

describe('MessageItem', () => {
const openFile = vi.fn().mockResolvedValue({ success: true });
const existsPath = vi.fn().mockResolvedValue({ exists: true });

beforeEach(() => {
vi.clearAllMocks();
Object.assign(window.electronAPI, {
platform: 'darwin',
homePath: '/Users/idofrizler',
file: {
existsPath,
openFile,
},
});
Expand Down Expand Up @@ -46,7 +48,7 @@ describe('MessageItem', () => {
const user = userEvent.setup();
renderMessage('Created /Users/idofrizler/project/src/MessageItem.tsx.');

const link = screen.getByRole('link', {
const link = await screen.findByRole('link', {
name: '/Users/idofrizler/project/src/MessageItem.tsx',
});

Expand All @@ -59,19 +61,20 @@ describe('MessageItem', () => {

it('renders inline-code full paths as clickable links', () => {
renderMessage('Open `/Users/idofrizler/project/src/MessageItem.tsx`.');
return waitFor(() => {
const link = screen.getByRole('link', {
name: '/Users/idofrizler/project/src/MessageItem.tsx',
});

const link = screen.getByRole('link', {
name: '/Users/idofrizler/project/src/MessageItem.tsx',
expect(link).toHaveClass('bg-copilot-bg');
});

expect(link).toHaveClass('bg-copilot-bg');
});

it('renders ~/ home-relative paths as clickable links', async () => {
const user = userEvent.setup();
renderMessage('Saved ~/temp/folder-contents.docx');

const link = screen.getByRole('link', { name: '~/temp/folder-contents.docx' });
const link = await screen.findByRole('link', { name: '~/temp/folder-contents.docx' });

expect(link).toHaveAttribute('href', 'file:///Users/idofrizler/temp/folder-contents.docx');

Expand All @@ -82,11 +85,26 @@ describe('MessageItem', () => {

it('uses a different link color in user messages', () => {
renderMessageWithRole('Check ~/temp/folder-contents.docx', 'user');
return waitFor(() => {
const link = screen.getByRole('link', { name: '~/temp/folder-contents.docx' });

expect(link).toHaveClass('text-amber-100');
expect(link).not.toHaveClass('text-copilot-accent');
});
});

it('keeps non-existent path-like text as plain text instead of a link', async () => {
existsPath.mockResolvedValueOnce({ exists: false });
renderMessage('Status is /not-loaded, which renders as a red dot.');

const link = screen.getByRole('link', { name: '~/temp/folder-contents.docx' });
await waitFor(() => {
expect(existsPath).toHaveBeenCalledWith('/not-loaded');
expect(screen.queryByRole('link', { name: '/not-loaded' })).not.toBeInTheDocument();
});

expect(link).toHaveClass('text-amber-100');
expect(link).not.toHaveClass('text-copilot-accent');
expect(
screen.getByText('Status is /not-loaded, which renders as a red dot.')
).toBeInTheDocument();
});

it('keeps normal markdown links intact', () => {
Expand Down
3 changes: 3 additions & 0 deletions tests/components/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ if (typeof window !== 'undefined') {
getTargetBranch: vi.fn().mockResolvedValue({ success: true, targetBranch: 'main' }),
setTargetBranch: vi.fn().mockResolvedValue({ success: true }),
},
file: {
existsPath: vi.fn().mockResolvedValue({ exists: false }),
},
},
});

Expand Down
Loading