From f41e7867488f0b493943632185f5eecf98c21fc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 12 May 2026 22:08:01 +0800 Subject: [PATCH] Align desktop file mentions with CLI search Desktop @ file search was recursively walking the filesystem with a local skip list, which let Python and Node generated directories leak into results and diverged from CLI behavior. Route candidate discovery through the same git-first model: tracked files, untracked files with exclude-standard, and ripgrep fallback for non-git folders, then derive selectable directories from those candidates. Constraint: Desktop picker must select both files and directories without surfacing ignored project artifacts. Constraint: No new dependencies; reuse the existing git, ripgrep, settings, and ignore utilities. Rejected: Maintain a hardcoded directory denylist | it would drift from CLI and miss project-specific ignore rules. Rejected: Full recursive readdir scanning | it ignores git index semantics and makes large dependency trees visible. Confidence: high Scope-risk: moderate Directive: Keep desktop @ file candidate discovery aligned with src/hooks/fileSuggestions.ts before changing ranking or ignore behavior. Tested: bun test src/server/__tests__/filesystem.test.ts Tested: cd desktop && bun run test -- FileSearchMenu.test.tsx ChatInput.test.tsx Tested: cd desktop && bun run lint && bun run test -- --run && bun run build Tested: bun run check:server Tested: bun run check:coverage Tested: bun run verify Not-tested: Native Windows/Linux manual UI smoke; path handling relies on cross-platform git/ripgrep wrappers and normalized relative paths. --- desktop/src/api/filesystem.ts | 3 +- .../src/components/chat/AttachmentGallery.tsx | 3 +- .../src/components/chat/ChatInput.test.tsx | 54 ++++ desktop/src/components/chat/ChatInput.tsx | 11 +- .../components/chat/FileSearchMenu.test.tsx | 72 ++++- .../src/components/chat/FileSearchMenu.tsx | 184 +++++++---- desktop/src/i18n/locales/en.ts | 7 + desktop/src/i18n/locales/zh.ts | 7 + .../src/stores/workspaceChatContextStore.ts | 1 + desktop/src/types/chat.ts | 2 + src/server/__tests__/filesystem.test.ts | 84 +++++ src/server/api/filesystem.ts | 292 ++++++++++++++++-- src/server/services/conversationService.ts | 1 + src/server/ws/events.ts | 1 + 14 files changed, 624 insertions(+), 98 deletions(-) diff --git a/desktop/src/api/filesystem.ts b/desktop/src/api/filesystem.ts index 7ef57735..30883ff9 100644 --- a/desktop/src/api/filesystem.ts +++ b/desktop/src/api/filesystem.ts @@ -4,6 +4,7 @@ type DirEntry = { name: string path: string isDirectory: boolean + relativePath?: string } type BrowseResult = { @@ -23,7 +24,7 @@ export const filesystemApi = { }, search(query: string, cwd?: string) { - const q = new URLSearchParams({ search: query, maxResults: '200' }) + const q = new URLSearchParams({ search: query, maxResults: '200', includeFiles: 'true' }) if (cwd) q.set('path', cwd) return api.get(`/api/filesystem/browse?${q}`) }, diff --git a/desktop/src/components/chat/AttachmentGallery.tsx b/desktop/src/components/chat/AttachmentGallery.tsx index 2d83e685..7733766d 100644 --- a/desktop/src/components/chat/AttachmentGallery.tsx +++ b/desktop/src/components/chat/AttachmentGallery.tsx @@ -8,6 +8,7 @@ export type AttachmentPreview = { path?: string data?: string previewUrl?: string + isDirectory?: boolean lineStart?: number lineEnd?: number note?: string @@ -92,7 +93,7 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove } ].join(' ')} > - {attachment.lineStart ? 'chat_bubble' : 'description'} + {attachment.lineStart ? 'chat_bubble' : attachment.isDirectory ? 'folder' : 'description'} {attachment.name} diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx index 0603d952..80185257 100644 --- a/desktop/src/components/chat/ChatInput.test.tsx +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -536,6 +536,7 @@ describe('ChatInput file mentions', () => { type: 'file', name: 'conditions.py', path: '/repo/backend/src/conditions.py', + isDirectory: false, lineStart: undefined, lineEnd: undefined, note: undefined, @@ -551,6 +552,59 @@ describe('ChatInput file mentions', () => { }) }) + it('turns a selected @ directory into a workspace chip and model path reference', async () => { + mocks.search.mockResolvedValueOnce({ + currentPath: '/repo', + parentPath: '/', + query: 'backend', + entries: [ + { name: 'backend', path: '/repo/backend', relativePath: 'backend', isDirectory: true }, + ], + }) + + render() + + const input = screen.getByRole('textbox') as HTMLTextAreaElement + fireEvent.change(input, { + target: { + value: '@backend 讲一下这个目录。', + selectionStart: '@backend'.length, + }, + }) + + fireEvent.click(await screen.findByRole('option', { name: /backend/i })) + + await waitFor(() => { + expect(input.value).toBe('讲一下这个目录。') + }) + expect(screen.getByText('backend/')).toBeInTheDocument() + expect(screen.getByText('folder')).toBeInTheDocument() + + fireEvent.keyDown(input, { key: 'Enter' }) + + expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, { + type: 'user_message', + content: '讲一下这个目录。', + attachments: [{ + type: 'file', + name: 'backend/', + path: '/repo/backend', + isDirectory: true, + lineStart: undefined, + lineEnd: undefined, + note: undefined, + quote: undefined, + }], + }) + const messages = useChatStore.getState().sessions[sessionId]?.messages ?? [] + expect(messages[messages.length - 1]).toMatchObject({ + type: 'user_text', + content: '讲一下这个目录。', + modelContent: '@"/repo/backend" 讲一下这个目录。', + attachments: [{ name: 'backend/', path: '/repo/backend' }], + }) + }) + it('uses larger icon-only mobile action buttons for browser H5 access', async () => { viewportMocks.isMobile = true mocks.search.mockResolvedValueOnce({ diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index d583f294..44a2a068 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -42,6 +42,7 @@ type Attachment = { mimeType?: string previewUrl?: string data?: string + isDirectory?: boolean lineStart?: number lineEnd?: number note?: string @@ -66,6 +67,7 @@ function workspaceReferenceToAttachment(reference: WorkspaceChatReference): Atta name: reference.name, type: 'file', path: reference.path, + isDirectory: reference.isDirectory, lineStart: reference.lineStart, lineEnd: reference.lineEnd, note: reference.note, @@ -535,6 +537,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro type: 'file' as const, name: reference.name, path: reference.absolutePath ?? reference.path, + isDirectory: reference.isDirectory, lineStart: reference.lineStart, lineEnd: reference.lineEnd, note: reference.note, @@ -546,6 +549,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro type: 'file' as const, name: reference.name, path: reference.path, + isDirectory: reference.isDirectory, lineStart: reference.lineStart, lineEnd: reference.lineEnd, note: reference.note, @@ -602,7 +606,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro // Route file search navigation keys to FileSearchMenu if (fileSearchOpen) { const key = event.key - if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Enter' || key === 'Tab' || key === 'Escape') { + if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'ArrowRight' || key === 'Enter' || key === 'Tab' || key === 'Escape') { event.preventDefault() if (key === 'Escape') { setFileSearchOpen(false) @@ -821,7 +825,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos) }) }} - onSelect={(path, name) => { + onSelect={(path, name, isDirectory) => { if (atCursorPos >= 0) { const referenceName = name.split('/').filter(Boolean).pop() ?? name const tokenEnd = atCursorPos + 1 + atFilter.length @@ -835,7 +839,8 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro kind: 'file', path, absolutePath: path, - name: referenceName, + name: isDirectory ? `${referenceName}/` : referenceName, + isDirectory, }) } setComposerInput(newValue) diff --git a/desktop/src/components/chat/FileSearchMenu.test.tsx b/desktop/src/components/chat/FileSearchMenu.test.tsx index 00aed914..b5a70cef 100644 --- a/desktop/src/components/chat/FileSearchMenu.test.tsx +++ b/desktop/src/components/chat/FileSearchMenu.test.tsx @@ -56,7 +56,7 @@ describe('FileSearchMenu', () => { }) }) - it('navigates directories without selecting them as attachments', async () => { + it('selects directories from search results and keeps a separate drill-in action', async () => { const onSelect = vi.fn() const onNavigate = vi.fn() vi.mocked(filesystemApi.search).mockResolvedValueOnce({ @@ -64,7 +64,7 @@ describe('FileSearchMenu', () => { parentPath: '/', query: 'backend', entries: [ - { name: 'backend', path: '/repo/backend', isDirectory: true }, + { name: 'backend', path: '/repo/backend', relativePath: 'backend', isDirectory: true }, ], }) vi.mocked(filesystemApi.browse).mockResolvedValueOnce({ @@ -74,6 +74,13 @@ describe('FileSearchMenu', () => { { name: 'src', path: '/repo/backend/src', isDirectory: true }, ], }) + vi.mocked(filesystemApi.browse).mockResolvedValueOnce({ + currentPath: '/repo/backend/src', + parentPath: '/repo/backend', + entries: [ + { name: 'commands', path: '/repo/backend/src/commands', isDirectory: true }, + ], + }) render( { fireEvent.click(await screen.findByText('backend')) - expect(onSelect).not.toHaveBeenCalled() + expect(onSelect).toHaveBeenCalledWith('/repo/backend', 'backend', true) + expect(onNavigate).not.toHaveBeenCalled() + + fireEvent.click(screen.getByLabelText('Open folder')) + expect(onNavigate).toHaveBeenCalledWith('backend/') await waitFor(() => { expect(filesystemApi.browse).toHaveBeenCalledWith('/repo/backend', { includeFiles: true }) }) + + fireEvent.click(await screen.findByText('src')) + expect(onSelect).toHaveBeenLastCalledWith('/repo/backend/src', 'backend/src', true) + + fireEvent.click(screen.getByLabelText('Open folder')) + expect(onNavigate).toHaveBeenLastCalledWith('backend/src/') + await waitFor(() => { + expect(filesystemApi.browse).toHaveBeenCalledWith('/repo/backend/src', { includeFiles: true }) + }) }) it('passes nested relative file paths when selecting a file', async () => { const onSelect = vi.fn() vi.mocked(filesystemApi.search).mockResolvedValueOnce({ - currentPath: '/repo/backend/src', - parentPath: '/repo/backend', + currentPath: '/repo', + parentPath: '/', query: 'pictactic', entries: [ - { name: 'pictactic', path: '/repo/backend/src/pictactic', isDirectory: false }, + { name: 'pictactic', path: '/repo/backend/src/pictactic', relativePath: 'backend/src/pictactic', isDirectory: false }, ], }) @@ -114,6 +134,44 @@ describe('FileSearchMenu', () => { fireEvent.click(await screen.findByText('pictactic')) - expect(onSelect).toHaveBeenCalledWith('/repo/backend/src/pictactic', 'backend/src/pictactic') + expect(onSelect).toHaveBeenCalledWith('/repo/backend/src/pictactic', 'backend/src/pictactic', false) + }) + + it('uses the resolved home root for typed folder filters when no workspace is selected', async () => { + vi.mocked(filesystemApi.search).mockResolvedValueOnce({ + currentPath: '/Users/nanmi', + parentPath: '/', + query: 'workspace', + entries: [ + { name: 'workspace', path: '/Users/nanmi/workspace', relativePath: 'workspace', isDirectory: true }, + ], + }) + vi.mocked(filesystemApi.browse).mockResolvedValueOnce({ + currentPath: '/Users/nanmi/workspace', + parentPath: '/Users/nanmi', + entries: [], + }) + + const { rerender } = render( + {}} + />, + ) + + expect(await screen.findByText('workspace')).toBeInTheDocument() + + rerender( + {}} + />, + ) + + await waitFor(() => { + expect(filesystemApi.browse).toHaveBeenCalledWith('/Users/nanmi/workspace', { includeFiles: true }) + }) }) }) diff --git a/desktop/src/components/chat/FileSearchMenu.tsx b/desktop/src/components/chat/FileSearchMenu.tsx index 716f482b..7b065f3b 100644 --- a/desktop/src/components/chat/FileSearchMenu.tsx +++ b/desktop/src/components/chat/FileSearchMenu.tsx @@ -8,6 +8,7 @@ type DirEntry = { name: string path: string isDirectory: boolean + relativePath?: string } export type FileSearchMenuHandle = { @@ -18,24 +19,23 @@ type Props = { cwd: string filter?: string compact?: boolean - onSelect: (path: string, relativePath: string) => void + onSelect: (path: string, relativePath: string, isDirectory: boolean) => void onNavigate?: (relativePath: string) => void } -function joinRelativePath(base: string, name: string) { - return [base.replace(/\/+$/, ''), name].filter(Boolean).join('/') -} - export const FileSearchMenu = forwardRef(({ cwd, filter = '', compact = false, onSelect, onNavigate }, ref) => { const t = useTranslation() const [entries, setEntries] = useState([]) const [errorMessage, setErrorMessage] = useState(null) const [errorKey, setErrorKey] = useState(null) const [currentPath, setCurrentPath] = useState(cwd) + const [isSearchMode, setIsSearchMode] = useState(false) const [loading, setLoading] = useState(false) const [selectedIndex, setSelectedIndex] = useState(0) + const [rootPath, setRootPath] = useState(cwd) const listRef = useRef(null) const currentPathRef = useRef(cwd) + const rootPathRef = useRef(cwd) const getErrorState = (error: unknown): { errorKey: TranslationKey | null; errorMessage: string | null } => { if (error instanceof ApiError) { @@ -61,18 +61,26 @@ export const FileSearchMenu = forwardRef(({ cwd, fi return { errorKey: 'fileSearch.loadFailed', errorMessage: null } } - // Parse filter: if it contains '/', navigate to that subdir and search the rest - // Uses currentPathRef as base so nested paths navigate from current depth + const getRelativePath = useCallback((entry: DirEntry) => { + const basePath = (cwd || rootPath).replace(/\/+$/, '') + if (entry.path.startsWith(`${basePath}/`)) return entry.path.slice(basePath.length + 1) + if (entry.relativePath) return entry.relativePath + return entry.name + }, [cwd, rootPath]) + + const selectEntry = useCallback((entry: DirEntry) => { + onSelect(entry.path, getRelativePath(entry), entry.isDirectory) + }, [getRelativePath, onSelect]) + const parseFilter = (rawFilter: string): { navigateTo: string; searchQuery: string } => { - const base = currentPathRef.current - if (!rawFilter || !rawFilter.includes('/')) { - return { navigateTo: base, searchQuery: rawFilter } + const trimmed = rawFilter.trim() + const basePath = (cwd || rootPathRef.current).replace(/\/+$/, '') + if (!trimmed) return { navigateTo: basePath, searchQuery: '' } + if (trimmed.endsWith('/')) { + if (!basePath) return { navigateTo: '', searchQuery: trimmed.replace(/\/+$/, '') } + return { navigateTo: `${basePath}/${trimmed.replace(/\/+$/, '')}`, searchQuery: '' } } - const lastSlash = rawFilter.lastIndexOf('/') - const dirPart = rawFilter.slice(0, lastSlash + 1) - const searchPart = rawFilter.slice(lastSlash + 1) - const navigateTo = dirPart === '' ? base : `${base}/${dirPart}` - return { navigateTo, searchQuery: searchPart } + return { navigateTo: basePath, searchQuery: trimmed } } // Load directory entries @@ -87,10 +95,24 @@ export const FileSearchMenu = forwardRef(({ cwd, fi } try { if (searchQuery) { + setIsSearchMode(true) const result = await filesystemApi.search(searchQuery, dirPath) + setCurrentPath(result.currentPath) + currentPathRef.current = result.currentPath + if (!cwd) { + setRootPath(result.currentPath) + rootPathRef.current = result.currentPath + } setEntries(result.entries) } else { + setIsSearchMode(false) const result = await filesystemApi.browse(dirPath, { includeFiles: true }) + setCurrentPath(result.currentPath) + currentPathRef.current = result.currentPath + if (!cwd) { + setRootPath(result.currentPath) + rootPathRef.current = result.currentPath + } setEntries(result.entries) } setSelectedIndex(0) @@ -101,11 +123,25 @@ export const FileSearchMenu = forwardRef(({ cwd, fi setErrorMessage(nextError.errorMessage) } setLoading(false) - }, []) + }, [cwd]) + + const navigateEntry = useCallback((entry: DirEntry) => { + if (!entry.isDirectory) return + const relativePath = `${getRelativePath(entry).replace(/\/+$/, '')}/` + void loadDir(entry.path, '') + onNavigate?.(relativePath) + }, [getRelativePath, loadDir, onNavigate]) + + // Keep the explicit workspace root stable when the host session changes. + useEffect(() => { + currentPathRef.current = cwd + rootPathRef.current = cwd + setRootPath(cwd) + setCurrentPath(cwd) + }, [cwd]) // Initial load: parse filter path and navigate accordingly useEffect(() => { - currentPathRef.current = cwd const { navigateTo, searchQuery } = parseFilter(filter) void loadDir(navigateTo, searchQuery) }, [cwd, filter, loadDir]) @@ -126,17 +162,20 @@ export const FileSearchMenu = forwardRef(({ cwd, fi e.preventDefault() const selected = entries[selectedIndex] if (selected) { - if (selected.isDirectory) { - void loadDir(selected.path, '') - onNavigate?.(`${joinRelativePath(filter.slice(0, filter.lastIndexOf('/') + 1), selected.name)}/`) - } else { - onSelect(selected.path, joinRelativePath(filter.slice(0, filter.lastIndexOf('/') + 1), selected.name)) - } + selectEntry(selected) + } + return + } + if (e.key === 'ArrowRight') { + const selected = entries[selectedIndex] + if (selected?.isDirectory) { + e.preventDefault() + navigateEntry(selected) } return } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [entries, selectedIndex, filter, loadDir, onNavigate, onSelect]) + }, [entries, selectedIndex, selectEntry, navigateEntry]) useImperativeHandle(ref, () => ({ handleKeyDown }), [handleKeyDown]) @@ -153,8 +192,56 @@ export const FileSearchMenu = forwardRef(({ cwd, fi if (rel) breadcrumbs.push(...rel.split('/')) } - const dirs = entries.filter((e) => e.isDirectory) - const files = entries.filter((e) => !e.isDirectory) + const renderEntry = (entry: DirEntry, index: number) => { + const relativePath = getRelativePath(entry) + const parentPath = relativePath.split('/').slice(0, -1).join('/') + const selected = selectedIndex === index + return ( +
setSelectedIndex(index)} + > + + {entry.isDirectory ? ( + + ) : null} +
+ ) + } return (
(({ cwd, fi {seg} ))} + {isSearchMode && filter ? ( + @{filter} + ) : null} {loading && ( progress_activity )} @@ -193,43 +283,7 @@ export const FileSearchMenu = forwardRef(({ cwd, fi
) : ( <> - {/* Directories */} - {dirs.map((entry, i) => ( - - ))} - - {/* Files */} - {files.map((entry, i) => { - const idx = dirs.length + i - return ( - - ) - })} + {entries.map(renderEntry)} )} @@ -240,7 +294,9 @@ export const FileSearchMenu = forwardRef(({ cwd, fi ↑↓ {t('fileSearch.navigate')} Enter - {t('fileSearch.attach')} + {t('fileSearch.select')} + + {t('fileSearch.open')} Esc {t('fileSearch.close')} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 7589cc80..e1a33b42 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -1184,7 +1184,14 @@ export const en = { 'fileSearch.loadFailed': 'Failed to load directory', 'fileSearch.navigate': 'navigate', 'fileSearch.attach': 'attach', + 'fileSearch.select': 'select', + 'fileSearch.open': 'open folder', 'fileSearch.close': 'close', + 'fileSearch.directory': 'folder', + 'fileSearch.currentDirectory': 'current folder', + 'fileSearch.folderTag': 'folder', + 'fileSearch.fileTag': 'file', + 'fileSearch.openFolder': 'Open folder', // ─── Teams ────────────────────────────────────── 'teams.backToLeader': '\u2190 Back to Leader', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index d8ec51f9..7fc9a244 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -1186,7 +1186,14 @@ export const zh: Record = { 'fileSearch.loadFailed': '目录加载失败', 'fileSearch.navigate': '导航', 'fileSearch.attach': '附加', + 'fileSearch.select': '选择', + 'fileSearch.open': '打开目录', 'fileSearch.close': '关闭', + 'fileSearch.directory': '目录', + 'fileSearch.currentDirectory': '当前目录', + 'fileSearch.folderTag': '目录', + 'fileSearch.fileTag': '文件', + 'fileSearch.openFolder': '打开目录', // ─── Teams ────────────────────────────────────── 'teams.backToLeader': '\u2190 返回主控', diff --git a/desktop/src/stores/workspaceChatContextStore.ts b/desktop/src/stores/workspaceChatContextStore.ts index 8d055019..ab889563 100644 --- a/desktop/src/stores/workspaceChatContextStore.ts +++ b/desktop/src/stores/workspaceChatContextStore.ts @@ -8,6 +8,7 @@ export type WorkspaceChatReference = { path: string absolutePath?: string name: string + isDirectory?: boolean lineStart?: number lineEnd?: number note?: string diff --git a/desktop/src/types/chat.ts b/desktop/src/types/chat.ts index cd2b63da..b1f073de 100644 --- a/desktop/src/types/chat.ts +++ b/desktop/src/types/chat.ts @@ -31,6 +31,7 @@ export type AttachmentRef = { path?: string data?: string mimeType?: string + isDirectory?: boolean lineStart?: number lineEnd?: number note?: string @@ -43,6 +44,7 @@ export type UIAttachment = { path?: string data?: string mimeType?: string + isDirectory?: boolean lineStart?: number lineEnd?: number note?: string diff --git a/src/server/__tests__/filesystem.test.ts b/src/server/__tests__/filesystem.test.ts index ca53d838..02ccaf98 100644 --- a/src/server/__tests__/filesystem.test.ts +++ b/src/server/__tests__/filesystem.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it } from 'bun:test' +import { execFileSync } from 'child_process' import * as fs from 'fs' import * as fsp from 'fs/promises' import * as os from 'os' @@ -22,6 +23,13 @@ afterEach(async () => { cleanupDirs.clear() }) +function git(cwd: string, ...args: string[]): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + }) +} + describe('filesystem API', () => { it('allows browsing a directory under the user home directory', async () => { const homeFixtureDir = await fsp.mkdtemp(path.join(os.homedir(), 'claude-filesystem-test-')) @@ -41,6 +49,82 @@ describe('filesystem API', () => { expect(body.entries.some((entry) => entry.name === 'note.txt')).toBe(true) }) + it('fuzzy searches files and directories below the selected root', async () => { + const homeFixtureDir = await fsp.mkdtemp(path.join(os.homedir(), 'claude-filesystem-test-')) + cleanupDirs.add(homeFixtureDir) + git(homeFixtureDir, 'init') + await fsp.mkdir(path.join(homeFixtureDir, 'src', 'commands'), { recursive: true }) + await fsp.mkdir(path.join(homeFixtureDir, 'src', 'commands', 'files'), { recursive: true }) + await fsp.mkdir(path.join(homeFixtureDir, 'src', 'constants'), { recursive: true }) + await fsp.mkdir(path.join(homeFixtureDir, '__pycache__'), { recursive: true }) + await fsp.mkdir(path.join(homeFixtureDir, 'node_modules', 'pkg'), { recursive: true }) + await fsp.mkdir(path.join(homeFixtureDir, '.venv', 'lib'), { recursive: true }) + await fsp.mkdir(path.join(homeFixtureDir, 'tmp-ignore'), { recursive: true }) + await fsp.writeFile(path.join(homeFixtureDir, '.gitignore'), ['__pycache__/', 'node_modules/', '.venv/', 'venv/'].join('\n')) + await fsp.writeFile(path.join(homeFixtureDir, '.ignore'), 'tmp-ignore/') + await fsp.writeFile(path.join(homeFixtureDir, 'src', 'commands', 'files.ts'), 'export {}') + await fsp.writeFile(path.join(homeFixtureDir, 'src', 'commands', 'files', 'index.ts'), 'export {}') + await fsp.writeFile(path.join(homeFixtureDir, 'src', 'constants', 'fileSearch.ts'), 'export {}') + await fsp.writeFile(path.join(homeFixtureDir, '__pycache__', 'fileSearch.cpython-311.pyc'), '') + await fsp.writeFile(path.join(homeFixtureDir, 'node_modules', 'pkg', 'files.js'), '') + await fsp.writeFile(path.join(homeFixtureDir, '.venv', 'lib', 'files.py'), '') + await fsp.writeFile(path.join(homeFixtureDir, 'tmp-ignore', 'files.tmp'), '') + + const res = await handleFilesystemRoute( + '/api/filesystem/browse', + makeUrl('/api/filesystem/browse', { + path: homeFixtureDir, + search: 'files', + includeFiles: 'true', + }), + ) + + expect(res.status).toBe(200) + const body = await res.json() as { entries: Array<{ name: string; relativePath?: string; isDirectory: boolean }> } + expect(body.entries).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: 'files.ts', + relativePath: 'src/commands/files.ts', + isDirectory: false, + }), + ])) + expect(body.entries.some((entry) => entry.relativePath === 'src/constants/fileSearch.ts')).toBe(true) + expect(body.entries.find((entry) => entry.relativePath === 'src/commands/files')?.isDirectory).toBe(true) + expect(body.entries.some((entry) => entry.relativePath === '__pycache__/fileSearch.cpython-311.pyc')).toBe(false) + expect(body.entries.some((entry) => entry.relativePath === 'node_modules/pkg/files.js')).toBe(false) + expect(body.entries.some((entry) => entry.relativePath === '.venv/lib/files.py')).toBe(false) + expect(body.entries.some((entry) => entry.relativePath === 'tmp-ignore/files.tmp')).toBe(false) + }) + + it('falls back to ripgrep search outside git and still respects ignore files', async () => { + const homeFixtureDir = await fsp.mkdtemp(path.join(os.homedir(), 'claude-filesystem-test-')) + cleanupDirs.add(homeFixtureDir) + await fsp.mkdir(path.join(homeFixtureDir, 'app'), { recursive: true }) + await fsp.mkdir(path.join(homeFixtureDir, 'node_modules', 'pkg'), { recursive: true }) + await fsp.writeFile(path.join(homeFixtureDir, '.gitignore'), 'node_modules/') + await fsp.writeFile(path.join(homeFixtureDir, 'app', 'cache-result.ts'), 'export {}') + await fsp.writeFile(path.join(homeFixtureDir, 'node_modules', 'pkg', 'cache-result.js'), '') + + const res = await handleFilesystemRoute( + '/api/filesystem/browse', + makeUrl('/api/filesystem/browse', { + path: homeFixtureDir, + search: 'cache', + includeFiles: 'true', + }), + ) + + expect(res.status).toBe(200) + const body = await res.json() as { entries: Array<{ relativePath?: string; isDirectory: boolean }> } + expect(body.entries).toEqual(expect.arrayContaining([ + expect.objectContaining({ + relativePath: 'app/cache-result.ts', + isDirectory: false, + }), + ])) + expect(body.entries.some((entry) => entry.relativePath === 'node_modules/pkg/cache-result.js')).toBe(false) + }) + it('accepts /private/tmp aliases on macOS for browsing and file serving', async () => { if (process.platform !== 'darwin') return diff --git a/src/server/api/filesystem.ts b/src/server/api/filesystem.ts index 252bd01f..a691cdb8 100644 --- a/src/server/api/filesystem.ts +++ b/src/server/api/filesystem.ts @@ -6,6 +6,25 @@ import * as path from 'path' import * as fs from 'fs' import * as os from 'os' +import ignore from 'ignore' +import { getGlobalConfig } from '../../utils/config.js' +import { execFileNoThrowWithCwd } from '../../utils/execFileNoThrow.js' +import { findGitRoot, gitExe } from '../../utils/git.js' +import { ripGrep } from '../../utils/ripgrep.js' +import { getInitialSettings } from '../../utils/settings/settings.js' + +type FilesystemEntry = { + name: string + path: string + isDirectory: boolean + relativePath?: string +} + +type ScoredFilesystemEntry = FilesystemEntry & { + score: number +} + +const FILE_SEARCH_TIMEOUT_MS = 10_000 const IMAGE_MIME_TYPES: Record = { '.png': 'image/png', @@ -119,29 +138,11 @@ async function handleBrowse(url: URL): Promise { return json({ error: 'Not a directory', path: resolvedPath }, 400) } - const entries = fs.readdirSync(resolvedPath, { withFileTypes: true }) - if (searchQuery) { - // Search mode: filter by filename, include both dirs and files - const query = searchQuery.toLowerCase() - const results = entries - .filter((e) => { - if (e.name.startsWith('.')) return false - if (e.isDirectory()) return e.name.toLowerCase().includes(query) - if (!includeFiles) return false - return e.name.toLowerCase().includes(query) - }) - .slice(0, maxResults) - .map((e) => ({ - name: e.name, - path: path.join(resolvedPath, e.name), - isDirectory: e.isDirectory(), - })) - .sort((a, b) => { - // Directories first, then alphabetically - if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1 - return a.name.localeCompare(b.name) - }) + const results = await searchFilesystemEntries(resolvedPath, searchQuery, { + includeFiles, + maxResults, + }) return json({ currentPath: resolvedPath, @@ -151,6 +152,8 @@ async function handleBrowse(url: URL): Promise { }) } + const entries = fs.readdirSync(resolvedPath, { withFileTypes: true }) + // Browse mode: show all directories (and optionally files) const filtered = entries.filter((e) => { if (e.name.startsWith('.')) return false @@ -163,6 +166,7 @@ async function handleBrowse(url: URL): Promise { name: e.name, path: path.join(resolvedPath, e.name), isDirectory: e.isDirectory(), + relativePath: e.name, })) .sort((a, b) => { if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1 @@ -179,6 +183,250 @@ async function handleBrowse(url: URL): Promise { } } +async function searchFilesystemEntries( + rootPath: string, + searchQuery: string, + options: { includeFiles: boolean; maxResults: number }, +): Promise { + const normalizedQuery = normalizeSearchText(searchQuery) + if (!normalizedQuery) return [] + + const candidates = await getSearchCandidates(rootPath, options.includeFiles) + const results = candidates + .map((entry): ScoredFilesystemEntry | null => { + const relativePath = entry.relativePath ?? entry.name + const score = scoreFilesystemEntry(entry.name, relativePath, normalizedQuery, entry.isDirectory) + return score > 0 ? { ...entry, score } : null + }) + .filter((entry): entry is ScoredFilesystemEntry => entry !== null) + + return results + .sort((a, b) => { + if (a.score !== b.score) return b.score - a.score + if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1 + return (a.relativePath ?? a.name).localeCompare(b.relativePath ?? b.name) + }) + .slice(0, options.maxResults) + .map(({ score: _score, ...entry }) => entry) +} + +async function getSearchCandidates(rootPath: string, includeFiles: boolean): Promise { + const files = await getProjectSearchFiles(rootPath) + const entries = new Map() + + for (const filePath of files) { + const normalizedFile = normalizeRelativePath(filePath) + if (!normalizedFile || !isRelativeInsideRoot(normalizedFile)) continue + + let currentDir = path.posix.dirname(normalizedFile) + while (currentDir !== '.') { + addCandidate(entries, rootPath, currentDir, true) + const parent = path.posix.dirname(currentDir) + if (parent === currentDir) break + currentDir = parent + } + + if (includeFiles) { + addCandidate(entries, rootPath, normalizedFile, false) + } + } + + return [...entries.values()] +} + +function addCandidate(entries: Map, rootPath: string, relativePath: string, isDirectory: boolean): void { + if (entries.has(relativePath)) return + entries.set(relativePath, { + name: path.posix.basename(relativePath), + path: path.join(rootPath, ...relativePath.split('/')), + isDirectory, + relativePath, + }) +} + +async function getProjectSearchFiles(rootPath: string): Promise { + const respectGitignore = shouldRespectGitignore() + const gitFiles = await getFilesUsingGit(rootPath, respectGitignore) + if (gitFiles !== null) { + return gitFiles + } + + return getFilesUsingRipgrep(rootPath, respectGitignore) +} + +function shouldRespectGitignore(): boolean { + const projectSettings = getInitialSettings() + const globalConfig = getGlobalConfig() + return projectSettings.respectGitignore ?? globalConfig.respectGitignore ?? true +} + +async function getFilesUsingGit(rootPath: string, respectGitignore: boolean): Promise { + const repoRoot = findGitRoot(rootPath) + if (!repoRoot) return null + + const trackedResult = await execFileNoThrowWithCwd( + gitExe(), + ['-c', 'core.quotepath=false', 'ls-files', '--recurse-submodules'], + { timeout: FILE_SEARCH_TIMEOUT_MS, cwd: repoRoot }, + ) + if (trackedResult.code !== 0) return null + + const untrackedArgs = respectGitignore + ? ['-c', 'core.quotepath=false', 'ls-files', '--others', '--exclude-standard'] + : ['-c', 'core.quotepath=false', 'ls-files', '--others'] + const untrackedResult = await execFileNoThrowWithCwd(gitExe(), untrackedArgs, { + timeout: FILE_SEARCH_TIMEOUT_MS, + cwd: repoRoot, + }) + + const files = [ + ...lines(trackedResult.stdout), + ...(untrackedResult.code === 0 ? lines(untrackedResult.stdout) : []), + ] + let normalized = files + .map(filePath => normalizeGitPath(filePath, repoRoot, rootPath)) + .filter((filePath): filePath is string => filePath !== null) + + const ignorePatterns = loadSearchIgnorePatterns(repoRoot, rootPath, false) + if (ignorePatterns) { + normalized = ignorePatterns.filter(normalized) + } + + return [...new Set(normalized)] +} + +async function getFilesUsingRipgrep(rootPath: string, respectGitignore: boolean): Promise { + const rgArgs = [ + '--files', + '--follow', + '--hidden', + '--glob', + '!.git/', + '--glob', + '!.svn/', + '--glob', + '!.hg/', + '--glob', + '!.bzr/', + '--glob', + '!.jj/', + '--glob', + '!.sl/', + ] + if (!respectGitignore) { + rgArgs.push('--no-ignore-vcs') + } + + const files = await ripGrep(rgArgs, rootPath, AbortSignal.timeout(FILE_SEARCH_TIMEOUT_MS)) + let normalized = files + .map(filePath => normalizeRipgrepPath(filePath, rootPath)) + .filter((filePath): filePath is string => filePath !== null) + + const ignorePatterns = loadSearchIgnorePatterns(rootPath, rootPath, true) + if (ignorePatterns) { + normalized = ignorePatterns.filter(normalized) + } + + return normalized +} + +function normalizeGitPath(filePath: string, repoRoot: string, rootPath: string): string | null { + const relativePath = path.relative(rootPath, path.join(repoRoot, filePath)) + const normalized = normalizeRelativePath(relativePath) + return isRelativeInsideRoot(normalized) ? normalized : null +} + +function normalizeRipgrepPath(filePath: string, rootPath: string): string | null { + const relativePath = path.isAbsolute(filePath) ? path.relative(rootPath, filePath) : filePath + const normalized = normalizeRelativePath(relativePath) + return isRelativeInsideRoot(normalized) ? normalized : null +} + +function normalizeRelativePath(filePath: string): string { + return filePath.replace(/\\/g, '/').replace(/^\.\/+/, '') +} + +function isRelativeInsideRoot(filePath: string): boolean { + return !!filePath && filePath !== '.' && !filePath.startsWith('../') && !path.isAbsolute(filePath) +} + +function lines(output: string): string[] { + return output.trim().split('\n').map(line => line.trim()).filter(Boolean) +} + +function loadSearchIgnorePatterns(repoRoot: string, rootPath: string, includeGitignore: boolean): ReturnType | null { + const ig = ignore() + let hasPatterns = false + const ignoreFiles = includeGitignore ? ['.gitignore', '.ignore', '.rgignore'] : ['.ignore', '.rgignore'] + const paths = [...new Set([repoRoot, rootPath])].flatMap(dir => ignoreFiles.map(fileName => path.join(dir, fileName))) + + for (const ignorePath of paths) { + try { + ig.add(fs.readFileSync(ignorePath, 'utf8')) + hasPatterns = true + } catch { + // Missing or unreadable ignore files should not break suggestions. + } + } + + return hasPatterns ? ig : null +} + +function normalizeSearchText(value: string): string { + return value + .trim() + .replace(/\\/g, '/') + .replace(/^@+/, '') + .replace(/^\.\/+/, '') + .replace(/\/+$/, '') + .toLowerCase() +} + +function scoreFilesystemEntry(name: string, relativePath: string, query: string, isDirectory: boolean): number { + const normalizedName = normalizeSearchText(name) + const normalizedPath = normalizeSearchText(relativePath) + const pathNoExtension = normalizedPath.replace(/\.[^/.]+$/, '') + const baseBoost = isDirectory ? 4 : 0 + const depthPenalty = Math.min(relativePath.split('/').length - 1, 8) * 2 + + if (normalizedName === query) return 120 + baseBoost - depthPenalty + if (pathNoExtension === query || normalizedPath === query) return 112 + baseBoost - depthPenalty + if (normalizedName.startsWith(query)) return 96 + baseBoost - depthPenalty + if (normalizedPath.startsWith(query)) return 88 + baseBoost - depthPenalty + if (normalizedName.includes(query)) return 72 + baseBoost - depthPenalty + if (normalizedPath.includes(query)) return 60 + baseBoost - depthPenalty + + const nameFuzzy = fuzzyScore(normalizedName, query) + if (nameFuzzy > 0) return 44 + nameFuzzy + baseBoost - depthPenalty + + const pathFuzzy = fuzzyScore(normalizedPath, query) + if (pathFuzzy > 0) return 28 + pathFuzzy + baseBoost - depthPenalty + + return 0 +} + +function fuzzyScore(value: string, query: string): number { + let queryIndex = 0 + let runLength = 0 + let score = 0 + + for (let valueIndex = 0; valueIndex < value.length && queryIndex < query.length; valueIndex += 1) { + if (value[valueIndex] !== query[queryIndex]) { + runLength = 0 + continue + } + + const boundaryBoost = valueIndex === 0 || ['/', '-', '_', '.', ' '].includes(value[valueIndex - 1] ?? '') + ? 3 + : 0 + runLength += 1 + score += 2 + boundaryBoost + Math.min(runLength, 4) + queryIndex += 1 + } + + return queryIndex === query.length ? score : 0 +} + function json(data: unknown, status = 200): Response { return new Response(JSON.stringify(data), { status, diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index 62a7e4cb..70389b5e 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -34,6 +34,7 @@ type AttachmentRef = { path?: string data?: string mimeType?: string + isDirectory?: boolean } type SessionProcess = { diff --git a/src/server/ws/events.ts b/src/server/ws/events.ts index 16278eb9..49ede43e 100644 --- a/src/server/ws/events.ts +++ b/src/server/ws/events.ts @@ -34,6 +34,7 @@ export type AttachmentRef = { path?: string data?: string // base64 for images mimeType?: string + isDirectory?: boolean } // ============================================================================