diff --git a/src/main/main.ts b/src/main/main.ts index c7af597..15f9d51 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -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', diff --git a/src/preload/preload.ts b/src/preload/preload.ts index ce985da..e6ffef8 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -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 diff --git a/src/renderer/components/MessageItem.tsx b/src/renderer/components/MessageItem.tsx index 3f0d28c..a1f03a8 100644 --- a/src/renderer/components/MessageItem.tsx +++ b/src/renderer/components/MessageItem.tsx @@ -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 './'; @@ -420,6 +420,8 @@ interface FilePathLinkProps { variant?: 'default' | 'code'; } +const pathExistenceCache = new Map(); + function FilePathLink({ path, platform, @@ -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( + 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' ? ( + + {path} + + ) : ( + <>{path} + ); + } return ( { const openFile = vi.fn().mockResolvedValue({ success: true }); + const existsPath = vi.fn().mockResolvedValue({ exists: true }); beforeEach(() => { vi.clearAllMocks(); @@ -14,6 +15,7 @@ describe('MessageItem', () => { platform: 'darwin', homePath: '/Users/idofrizler', file: { + existsPath, openFile, }, }); @@ -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', }); @@ -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'); @@ -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', () => { diff --git a/tests/components/setup.ts b/tests/components/setup.ts index 6a6c833..394427e 100644 --- a/tests/components/setup.ts +++ b/tests/components/setup.ts @@ -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 }), + }, }, });