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.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-12 22:08:01 +08:00
parent 7f08e7a3bc
commit f41e786748
14 changed files with 624 additions and 98 deletions

View File

@ -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<BrowseResult>(`/api/filesystem/browse?${q}`)
},

View File

@ -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(' ')}
>
<span className="material-symbols-outlined shrink-0 text-[17px] text-[var(--color-text-tertiary)]">
{attachment.lineStart ? 'chat_bubble' : 'description'}
{attachment.lineStart ? 'chat_bubble' : attachment.isDirectory ? 'folder' : 'description'}
</span>
<span className="min-w-0 max-w-[220px] truncate text-[13px] font-medium leading-none text-[var(--color-text-primary)]">
{attachment.name}

View File

@ -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(<ChatInput compact />)
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({

View File

@ -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)

View File

@ -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(
<FileSearchMenu
@ -86,21 +93,34 @@ describe('FileSearchMenu', () => {
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(
<FileSearchMenu
cwd=""
filter="workspace"
onSelect={() => {}}
/>,
)
expect(await screen.findByText('workspace')).toBeInTheDocument()
rerender(
<FileSearchMenu
cwd=""
filter="workspace/"
onSelect={() => {}}
/>,
)
await waitFor(() => {
expect(filesystemApi.browse).toHaveBeenCalledWith('/Users/nanmi/workspace', { includeFiles: true })
})
})
})

View File

@ -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<FileSearchMenuHandle, Props>(({ cwd, filter = '', compact = false, onSelect, onNavigate }, ref) => {
const t = useTranslation()
const [entries, setEntries] = useState<DirEntry[]>([])
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [errorKey, setErrorKey] = useState<TranslationKey | null>(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<HTMLDivElement>(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<FileSearchMenuHandle, Props>(({ 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<FileSearchMenuHandle, Props>(({ 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<FileSearchMenuHandle, Props>(({ 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<FileSearchMenuHandle, Props>(({ 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<FileSearchMenuHandle, Props>(({ 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 (
<div
key={entry.path}
data-index={index}
className={`group flex items-stretch px-1.5 py-0.5 ${
selected ? 'bg-[var(--color-surface-hover)]' : ''
}`}
onMouseEnter={() => setSelectedIndex(index)}
>
<button
type="button"
onClick={() => selectEntry(entry)}
className="flex min-w-0 flex-1 items-center gap-3 rounded-lg px-2.5 py-2 text-left transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/40"
role="option"
aria-selected={selected}
>
<span className={`material-symbols-outlined shrink-0 text-[17px] ${entry.isDirectory ? 'text-[var(--color-brand)]' : 'text-[var(--color-text-secondary)]'}`}>
{entry.isDirectory ? 'folder' : 'description'}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-[var(--color-text-primary)]">{entry.name}</span>
<span className="block truncate font-[var(--font-mono)] text-[11px] text-[var(--color-text-tertiary)]">
{parentPath || (entry.isDirectory ? t('fileSearch.directory') : t('fileSearch.currentDirectory'))}
</span>
</span>
<span className="shrink-0 rounded-md border border-[var(--color-border)] px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-[0.02em] text-[var(--color-text-tertiary)]">
{entry.isDirectory ? t('fileSearch.folderTag') : t('fileSearch.fileTag')}
</span>
</button>
{entry.isDirectory ? (
<button
type="button"
aria-label={t('fileSearch.openFolder')}
title={t('fileSearch.openFolder')}
onClick={(event) => {
event.stopPropagation()
navigateEntry(entry)
}}
className="my-1 flex w-9 shrink-0 items-center justify-center rounded-lg text-[var(--color-text-tertiary)] opacity-70 transition hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/40 group-hover:opacity-100"
>
<span className="material-symbols-outlined text-[16px]">chevron_right</span>
</button>
) : null}
</div>
)
}
return (
<div
@ -174,6 +261,9 @@ export const FileSearchMenu = forwardRef<FileSearchMenuHandle, Props>(({ cwd, fi
<span className="text-[var(--color-text-primary)] font-mono">{seg}</span>
</span>
))}
{isSearchMode && filter ? (
<span className="ml-auto truncate font-mono text-[11px] text-[var(--color-text-tertiary)]">@{filter}</span>
) : null}
{loading && (
<span className="material-symbols-outlined text-[12px] text-[var(--color-text-tertiary)] animate-spin ml-1">progress_activity</span>
)}
@ -193,43 +283,7 @@ export const FileSearchMenu = forwardRef<FileSearchMenuHandle, Props>(({ cwd, fi
</div>
) : (
<>
{/* Directories */}
{dirs.map((entry, i) => (
<button
key={entry.path}
data-index={i}
onClick={() => {
void loadDir(entry.path, '')
onNavigate?.(`${joinRelativePath(filter.slice(0, filter.lastIndexOf('/') + 1), entry.name)}/`)
}}
onMouseEnter={() => setSelectedIndex(i)}
className={`w-full flex items-center gap-3 px-3 py-2 text-left transition-colors ${
selectedIndex === i ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'
}`}
>
<span className="material-symbols-outlined text-[16px] text-[var(--color-brand)]">folder</span>
<span className="text-sm text-[var(--color-text-primary)] truncate">{entry.name}</span>
</button>
))}
{/* Files */}
{files.map((entry, i) => {
const idx = dirs.length + i
return (
<button
key={entry.path}
data-index={idx}
onClick={() => onSelect(entry.path, joinRelativePath(filter.slice(0, filter.lastIndexOf('/') + 1), entry.name))}
onMouseEnter={() => setSelectedIndex(idx)}
className={`w-full flex items-center gap-3 px-3 py-2 text-left transition-colors ${
selectedIndex === idx ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'
}`}
>
<span className="material-symbols-outlined text-[16px] text-[var(--color-text-secondary)]">description</span>
<span className="text-sm text-[var(--color-text-primary)] truncate">{entry.name}</span>
</button>
)
})}
{entries.map(renderEntry)}
</>
)}
</div>
@ -240,7 +294,9 @@ export const FileSearchMenu = forwardRef<FileSearchMenuHandle, Props>(({ cwd, fi
<kbd className="rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1 py-0.5 font-mono"></kbd>
<span>{t('fileSearch.navigate')}</span>
<kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1 py-0.5 font-mono">Enter</kbd>
<span>{t('fileSearch.attach')}</span>
<span>{t('fileSearch.select')}</span>
<kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1 py-0.5 font-mono"></kbd>
<span>{t('fileSearch.open')}</span>
<kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1 py-0.5 font-mono">Esc</kbd>
<span>{t('fileSearch.close')}</span>
</div>

View File

@ -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',

View File

@ -1186,7 +1186,14 @@ export const zh: Record<TranslationKey, string> = {
'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 返回主控',

View File

@ -8,6 +8,7 @@ export type WorkspaceChatReference = {
path: string
absolutePath?: string
name: string
isDirectory?: boolean
lineStart?: number
lineEnd?: number
note?: string

View File

@ -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

View File

@ -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

View File

@ -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<string, string> = {
'.png': 'image/png',
@ -119,29 +138,11 @@ async function handleBrowse(url: URL): Promise<Response> {
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<Response> {
})
}
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<Response> {
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<Response> {
}
}
async function searchFilesystemEntries(
rootPath: string,
searchQuery: string,
options: { includeFiles: boolean; maxResults: number },
): Promise<FilesystemEntry[]> {
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<FilesystemEntry[]> {
const files = await getProjectSearchFiles(rootPath)
const entries = new Map<string, FilesystemEntry>()
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<string, FilesystemEntry>, 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<string[]> {
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<string[] | null> {
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<string[]> {
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<typeof ignore> | 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,

View File

@ -34,6 +34,7 @@ type AttachmentRef = {
path?: string
data?: string
mimeType?: string
isDirectory?: boolean
}
type SessionProcess = {

View File

@ -34,6 +34,7 @@ export type AttachmentRef = {
path?: string
data?: string // base64 for images
mimeType?: string
isDirectory?: boolean
}
// ============================================================================