mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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:
parent
3f2ce2a6c7
commit
276b67083a
@ -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('记一下这个文件讲了什么东西。')
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user