Align file mention search with CLI path display

Desktop search results were visually misleading when many directories shared the same basename, because the UI emphasized the basename and the server ranking allowed deep basename matches to outrank direct path-prefix matches. This makes search rows show the insertable relative path and ranks direct path-prefix matches before unrelated same-name directories.

Constraint: Desktop @ file suggestions should match the CLI mental model for path search.

Rejected: Keep basename plus parent-path rows | it hides the distinction between src/, src/hooks/, and desktop/src in the primary text.

Confidence: high

Scope-risk: narrow

Directive: Preserve path-prefix ranking ahead of basename-only matches for directory searches.

Tested: bun test src/server/__tests__/filesystem.test.ts

Tested: cd desktop && bun run test -- FileSearchMenu.test.tsx ChatInput.test.tsx

Tested: bun run check:server

Tested: bun run check:desktop
This commit is contained in:
程序员阿江(Relakkes) 2026-05-12 22:56:42 +08:00
parent 3f2ce2a6c7
commit 276b67083a
5 changed files with 100 additions and 16 deletions

View File

@ -520,7 +520,7 @@ describe('ChatInput file mentions', () => {
},
})
fireEvent.click(await screen.findByText('conditions.py'))
fireEvent.click(await screen.findByText('backend/src/conditions.py'))
await waitFor(() => {
expect(input.value).toBe('记一下这个文件讲了什么东西。')

View File

@ -91,7 +91,7 @@ describe('FileSearchMenu', () => {
/>,
)
fireEvent.click(await screen.findByText('backend'))
fireEvent.click(await screen.findByText('backend/'))
expect(onSelect).toHaveBeenCalledWith('/repo/backend', 'backend', true)
expect(onNavigate).not.toHaveBeenCalled()
@ -132,11 +132,36 @@ describe('FileSearchMenu', () => {
/>,
)
fireEvent.click(await screen.findByText('pictactic'))
fireEvent.click(await screen.findByText('backend/src/pictactic'))
expect(onSelect).toHaveBeenCalledWith('/repo/backend/src/pictactic', 'backend/src/pictactic', false)
})
it('renders search results as insertable paths instead of repeated basenames', async () => {
vi.mocked(filesystemApi.search).mockResolvedValueOnce({
currentPath: '/repo',
parentPath: '/',
query: 'src',
entries: [
{ name: 'src', path: '/repo/src', relativePath: 'src', isDirectory: true },
{ name: 'hooks', path: '/repo/src/hooks', relativePath: 'src/hooks', isDirectory: true },
{ name: 'src', path: '/repo/desktop/src', relativePath: 'desktop/src', isDirectory: true },
],
})
render(
<FileSearchMenu
cwd="/repo"
filter="src"
onSelect={() => {}}
/>,
)
expect(await screen.findByText('src/')).toBeInTheDocument()
expect(screen.getByText('src/hooks/')).toBeInTheDocument()
expect(screen.getByText('desktop/src/')).toBeInTheDocument()
})
it('uses the resolved home root for typed folder filters when no workspace is selected', async () => {
vi.mocked(filesystemApi.search).mockResolvedValueOnce({
currentPath: '/Users/nanmi',
@ -160,7 +185,7 @@ describe('FileSearchMenu', () => {
/>,
)
expect(await screen.findByText('workspace')).toBeInTheDocument()
expect(await screen.findByText('workspace/')).toBeInTheDocument()
rerender(
<FileSearchMenu

View File

@ -68,6 +68,12 @@ export const FileSearchMenu = forwardRef<FileSearchMenuHandle, Props>(({ cwd, fi
return entry.name
}, [cwd, rootPath])
const getDisplayPath = useCallback((entry: DirEntry) => {
const relativePath = getRelativePath(entry).replace(/\\/g, '/')
if (!entry.isDirectory) return relativePath
return `${relativePath.replace(/\/+$/, '')}/`
}, [getRelativePath])
const selectEntry = useCallback((entry: DirEntry) => {
onSelect(entry.path, getRelativePath(entry), entry.isDirectory)
}, [getRelativePath, onSelect])
@ -194,6 +200,7 @@ export const FileSearchMenu = forwardRef<FileSearchMenuHandle, Props>(({ cwd, fi
const renderEntry = (entry: DirEntry, index: number) => {
const relativePath = getRelativePath(entry)
const displayPath = getDisplayPath(entry)
const parentPath = relativePath.split('/').slice(0, -1).join('/')
const selected = selectedIndex === index
return (
@ -208,7 +215,9 @@ export const FileSearchMenu = forwardRef<FileSearchMenuHandle, Props>(({ cwd, fi
<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"
className={`flex min-w-0 flex-1 items-center rounded-lg px-2.5 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 ${
isSearchMode ? 'gap-2.5 py-2' : 'gap-3 py-2'
}`}
role="option"
aria-selected={selected}
>
@ -216,14 +225,27 @@ export const FileSearchMenu = forwardRef<FileSearchMenuHandle, Props>(({ cwd, fi
{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'))}
{isSearchMode ? (
<span
className="block truncate font-[var(--font-mono)] text-sm text-[var(--color-text-primary)]"
title={displayPath}
>
{displayPath}
</span>
) : (
<>
<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>
{!isSearchMode ? (
<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>
</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>
) : null}
</button>
{entry.isDirectory ? (
<button

View File

@ -56,6 +56,9 @@ describe('filesystem API', () => {
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, 'src', 'hooks'), { recursive: true })
await fsp.mkdir(path.join(homeFixtureDir, 'desktop', 'src'), { recursive: true })
await fsp.mkdir(path.join(homeFixtureDir, 'scripts', 'quality-gate', 'baseline', 'fixtures', 'cross-module-refactor', 'src'), { 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 })
@ -65,6 +68,9 @@ describe('filesystem API', () => {
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, 'src', 'hooks', 'useFileSearch.ts'), 'export {}')
await fsp.writeFile(path.join(homeFixtureDir, 'desktop', 'src', 'main.ts'), 'export {}')
await fsp.writeFile(path.join(homeFixtureDir, 'scripts', 'quality-gate', 'baseline', 'fixtures', 'cross-module-refactor', 'src', 'index.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'), '')
@ -94,6 +100,25 @@ describe('filesystem API', () => {
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)
const srcRes = await handleFilesystemRoute(
'/api/filesystem/browse',
makeUrl('/api/filesystem/browse', {
path: homeFixtureDir,
search: 'src',
includeFiles: 'true',
}),
)
expect(srcRes.status).toBe(200)
const srcBody = await srcRes.json() as { entries: Array<{ relativePath?: string }> }
const srcPaths = srcBody.entries.map(entry => entry.relativePath)
expect(srcPaths[0]).toBe('src')
expect(srcPaths.indexOf('src/hooks')).toBeGreaterThan(-1)
expect(srcPaths.indexOf('desktop/src')).toBeGreaterThan(-1)
expect(srcPaths.indexOf('scripts/quality-gate/baseline/fixtures/cross-module-refactor/src')).toBeGreaterThan(-1)
expect(srcPaths.indexOf('src/hooks')).toBeLessThan(srcPaths.indexOf('desktop/src'))
expect(srcPaths.indexOf('src/hooks')).toBeLessThan(srcPaths.indexOf('scripts/quality-gate/baseline/fixtures/cross-module-refactor/src'))
})
it('falls back to ripgrep search outside git and still respects ignore files', async () => {

View File

@ -204,6 +204,11 @@ async function searchFilesystemEntries(
.sort((a, b) => {
if (a.score !== b.score) return b.score - a.score
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1
const aPath = a.relativePath ?? a.name
const bPath = b.relativePath ?? b.name
const aDepth = pathDepth(aPath)
const bDepth = pathDepth(bPath)
if (aDepth !== bDepth) return aDepth - bDepth
return (a.relativePath ?? a.name).localeCompare(b.relativePath ?? b.name)
})
.slice(0, options.maxResults)
@ -386,13 +391,16 @@ function scoreFilesystemEntry(name: string, relativePath: string, query: string,
const normalizedName = normalizeSearchText(name)
const normalizedPath = normalizeSearchText(relativePath)
const pathNoExtension = normalizedPath.replace(/\.[^/.]+$/, '')
const pathPrefix = `${query}/`
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 (normalizedPath === query) return 150 + baseBoost - depthPenalty
if (pathNoExtension === query) return 144 + baseBoost - depthPenalty
if (normalizedPath.startsWith(pathPrefix)) return 136 + baseBoost - depthPenalty
if (normalizedPath.startsWith(query)) return 112 + baseBoost - depthPenalty
if (normalizedName === query) return 96 + baseBoost - depthPenalty
if (normalizedName.startsWith(query)) return 88 + baseBoost - depthPenalty
if (normalizedName.includes(query)) return 72 + baseBoost - depthPenalty
if (normalizedPath.includes(query)) return 60 + baseBoost - depthPenalty
@ -405,6 +413,10 @@ function scoreFilesystemEntry(name: string, relativePath: string, query: string,
return 0
}
function pathDepth(relativePath: string): number {
return relativePath.split('/').length
}
function fuzzyScore(value: string, query: string): number {
let queryIndex = 0
let runLength = 0