mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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
203 lines
6.0 KiB
TypeScript
203 lines
6.0 KiB
TypeScript
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
import '@testing-library/jest-dom'
|
|
import { FileSearchMenu } from './FileSearchMenu'
|
|
import { ApiError } from '../../api/client'
|
|
import { filesystemApi } from '../../api/filesystem'
|
|
import { useSettingsStore } from '../../stores/settingsStore'
|
|
|
|
vi.mock('../../api/filesystem', () => ({
|
|
filesystemApi: {
|
|
browse: vi.fn(),
|
|
search: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
describe('FileSearchMenu', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
useSettingsStore.setState({ locale: 'en' })
|
|
})
|
|
|
|
it('shows an explicit error when directory browsing is denied', async () => {
|
|
vi.mocked(filesystemApi.browse).mockRejectedValueOnce(
|
|
new ApiError(403, { error: 'Access denied: path outside allowed directory' }),
|
|
)
|
|
|
|
render(
|
|
<FileSearchMenu
|
|
cwd="/private/tmp"
|
|
onSelect={() => {}}
|
|
/>,
|
|
)
|
|
|
|
expect(await screen.findByText('Cannot access this directory')).toBeInTheDocument()
|
|
expect(screen.queryByText('No files in this directory')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('renders returned files when browsing succeeds', async () => {
|
|
vi.mocked(filesystemApi.browse).mockResolvedValueOnce({
|
|
currentPath: '/tmp',
|
|
parentPath: '/',
|
|
entries: [
|
|
{ name: 'preview.png', path: '/tmp/preview.png', isDirectory: false },
|
|
],
|
|
})
|
|
|
|
render(
|
|
<FileSearchMenu
|
|
cwd="/tmp"
|
|
onSelect={() => {}}
|
|
/>,
|
|
)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('preview.png')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
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({
|
|
currentPath: '/repo',
|
|
parentPath: '/',
|
|
query: 'backend',
|
|
entries: [
|
|
{ name: 'backend', path: '/repo/backend', relativePath: 'backend', isDirectory: true },
|
|
],
|
|
})
|
|
vi.mocked(filesystemApi.browse).mockResolvedValueOnce({
|
|
currentPath: '/repo/backend',
|
|
parentPath: '/repo',
|
|
entries: [
|
|
{ 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
|
|
cwd="/repo"
|
|
filter="backend"
|
|
onSelect={onSelect}
|
|
onNavigate={onNavigate}
|
|
/>,
|
|
)
|
|
|
|
fireEvent.click(await screen.findByText('backend/'))
|
|
|
|
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',
|
|
parentPath: '/',
|
|
query: 'pictactic',
|
|
entries: [
|
|
{ name: 'pictactic', path: '/repo/backend/src/pictactic', relativePath: 'backend/src/pictactic', isDirectory: false },
|
|
],
|
|
})
|
|
|
|
render(
|
|
<FileSearchMenu
|
|
cwd="/repo"
|
|
filter="backend/src/pictactic"
|
|
onSelect={onSelect}
|
|
/>,
|
|
)
|
|
|
|
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',
|
|
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 })
|
|
})
|
|
})
|
|
})
|