mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
7f08e7a3bc
commit
f41e786748
@ -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}`)
|
||||
},
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 返回主控',
|
||||
|
||||
@ -8,6 +8,7 @@ export type WorkspaceChatReference = {
|
||||
path: string
|
||||
absolutePath?: string
|
||||
name: string
|
||||
isDirectory?: boolean
|
||||
lineStart?: number
|
||||
lineEnd?: number
|
||||
note?: string
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -34,6 +34,7 @@ type AttachmentRef = {
|
||||
path?: string
|
||||
data?: string
|
||||
mimeType?: string
|
||||
isDirectory?: boolean
|
||||
}
|
||||
|
||||
type SessionProcess = {
|
||||
|
||||
@ -34,6 +34,7 @@ export type AttachmentRef = {
|
||||
path?: string
|
||||
data?: string // base64 for images
|
||||
mimeType?: string
|
||||
isDirectory?: boolean
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user