diff --git a/src/utils/file-query.ts b/src/utils/file-query.ts new file mode 100644 index 00000000..3c64d211 --- /dev/null +++ b/src/utils/file-query.ts @@ -0,0 +1,58 @@ +/** + * @fileoverview Pure file-name/path query matcher for the Files panel search + * (COD-236). Compiles a user query string into a reusable predicate so the + * server-side file walk can prune to matching entries instead of streaming the + * whole tree. + * + * Semantics: + * - Empty / whitespace-only query → `compileFileQuery` returns `null` (the + * caller treats this as "no search", falling back to the full tree). + * - A query containing a glob metachar (`*` or `?`) compiles to an anchored, + * case-insensitive RegExp: `*` → `.*`, `?` → `.`, every other regex + * metacharacter is escaped so it matches literally. + * - Otherwise the query is a plain case-insensitive substring. + * - When the query contains a `/` it matches against the relative path; else it + * matches against the bare entry name. + * + * No fs / IO — safe to unit-test directly. + */ + +export type FileQueryMatcher = (name: string, relativePath: string) => boolean; + +// Escape every regex metacharacter. `*` and `?` are handled separately by the +// glob translation below, so they are intentionally NOT escaped here. +function escapeRegexExceptGlob(input: string): string { + return input.replace(/[.+^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Compile a query string into a matcher predicate, or `null` when the query is + * empty/whitespace (caller treats null as "no search"). + */ +export function compileFileQuery(query: string): FileQueryMatcher | null { + const trimmed = query.trim(); + if (trimmed === '') return null; + + const matchesPath = trimmed.includes('/'); + const isGlob = trimmed.includes('*') || trimmed.includes('?'); + + if (isGlob) { + // Translate the glob to an anchored, case-insensitive RegExp. Escape all + // regex metacharacters first (except * and ?), then expand the wildcards. + const pattern = escapeRegexExceptGlob(trimmed).replace(/\*/g, '.*').replace(/\?/g, '.'); + const regex = new RegExp(`^${pattern}$`, 'i'); + return (name, relativePath) => regex.test(matchesPath ? relativePath : name); + } + + const needle = trimmed.toLowerCase(); + return (name, relativePath) => (matchesPath ? relativePath : name).toLowerCase().includes(needle); +} + +/** + * Convenience: compile the query and apply it in one call. Returns false when + * the query compiles to null (empty). + */ +export function matchFileQuery(query: string, name: string, relativePath: string): boolean { + const matcher = compileFileQuery(query); + return matcher ? matcher(name, relativePath) : false; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index ba941c42..4533d901 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -35,3 +35,5 @@ export { resolveCodexDir, isCodexAvailable } from './codex-cli-resolver.js'; export { resolveGeminiDir, isGeminiAvailable } from './gemini-cli-resolver.js'; export { resolveAntigravityDir, isAntigravityAvailable } from './antigravity-cli-resolver.js'; export { resolvePiDir, isPiAvailable, getPiCliVersion } from './pi-cli-resolver.js'; +export { compileFileQuery, matchFileQuery } from './file-query.js'; +export type { FileQueryMatcher } from './file-query.js'; diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts index ccc4108e..20a567f3 100644 --- a/src/web/routes/file-routes.ts +++ b/src/web/routes/file-routes.ts @@ -21,6 +21,7 @@ import type { FileWriteData, } from '../../types.js'; import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js'; +import { compileFileQuery } from '../../utils/file-query.js'; import { fileStreamManager } from '../../file-stream-manager.js'; import { AUDIO_ATTACHMENT_EXTENSIONS, @@ -937,12 +938,15 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even // File tree listing app.get('/api/sessions/:id/files', async (req) => { const { id } = req.params as { id: string }; - const { depth, showHidden } = req.query as { depth?: string; showHidden?: string }; + const { depth, showHidden, q } = req.query as { depth?: string; showHidden?: string; q?: string }; const session = findSessionOrFail(ctx, id, req); const maxDepth = Math.min(parseInt(depth || '5', 10), 10); const includeHidden = showHidden === 'true'; const workingDir = session.workingDir; + // null for an empty/whitespace query, which is what keeps the default + // tree response byte-identical when no search is requested. + const matcher = compileFileQuery(q ?? ''); // Default excludes - large/generated directories const excludeDirs = new Set([ @@ -976,6 +980,92 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even let truncated = false; const maxFiles = 5000; + // ===== Search mode ===== + // A query turns this endpoint into a FLAT match list rather than a nested + // tree. It recurses past non-matching directories on purpose — the whole + // point of searching is to reach a file whose ancestors do not match — so + // it is bounded independently by maxMatches on top of the shared maxFiles + // and maxDepth caps, and reports `truncated` when it stops early. + if (matcher) { + const matches: FileTreeNode[] = []; + const maxMatches = 1000; + + const searchDirectory = async (dirPath: string, currentDepth: number): Promise => { + if (currentDepth > maxDepth || totalFiles + totalDirectories > maxFiles || matches.length >= maxMatches) { + truncated = true; + return; + } + + let entries: import('node:fs').Dirent[]; + try { + entries = await fs.readdir(dirPath, { withFileTypes: true }); + } catch { + // Can't read directory (permission denied, etc.) + return; + } + entries.sort((a, b) => { + if (a.isDirectory() && !b.isDirectory()) return -1; + if (!a.isDirectory() && b.isDirectory()) return 1; + return a.name.localeCompare(b.name); + }); + + for (const entry of entries) { + if (totalFiles + totalDirectories > maxFiles || matches.length >= maxMatches) { + truncated = true; + break; + } + if (!includeHidden && entry.name.startsWith('.')) continue; + if (entry.isDirectory() && excludeDirs.has(entry.name)) continue; + + const fullPath = join(dirPath, entry.name); + const relativePath = relative(workingDir, fullPath); + + if (entry.isDirectory()) { + totalDirectories++; + if (matcher(entry.name, relativePath)) { + matches.push({ name: entry.name, path: relativePath, type: 'directory' }); + } + // Always recurse, even when this directory does not match. + await searchDirectory(fullPath, currentDepth + 1); + } else { + totalFiles++; + if (matcher(entry.name, relativePath)) { + let size: number | undefined; + try { + size = (await fs.stat(fullPath)).size; + } catch { + // Skip size if we can't stat the match. + } + matches.push({ + name: entry.name, + path: relativePath, + type: 'file', + size, + extension: entry.name.includes('.') ? entry.name.split('.').pop()?.toLowerCase() : undefined, + }); + } + } + } + }; + + await searchDirectory(workingDir, 1); + + return { + success: true, + data: { + root: workingDir, + tree: [], + matches, + totalFiles, + totalDirectories, + truncated, + matchCount: matches.length, + query: (q ?? '').trim(), + mode: 'search' as const, + }, + }; + } + const scanDirectory = async (dirPath: string, currentDepth: number): Promise => { if (currentDepth > maxDepth || totalFiles + totalDirectories > maxFiles) { truncated = true; diff --git a/test/file-query.test.ts b/test/file-query.test.ts new file mode 100644 index 00000000..8e073a4f --- /dev/null +++ b/test/file-query.test.ts @@ -0,0 +1,80 @@ +/** + * @fileoverview Tests for the pure file-query matcher (COD-236). + * + * Node-safe: no fs, no ports, no jsdom. Just the compile/apply matcher. + */ + +import { describe, it, expect } from 'vitest'; +import { compileFileQuery, matchFileQuery } from '../src/utils/file-query.js'; + +describe('compileFileQuery', () => { + it('returns null for empty / whitespace-only queries', () => { + expect(compileFileQuery('')).toBeNull(); + expect(compileFileQuery(' ')).toBeNull(); + expect(compileFileQuery('\t\n')).toBeNull(); + }); + + it('does a case-insensitive substring match on the name by default', () => { + const m = compileFileQuery('Route'); + expect(m).not.toBeNull(); + expect(m!('file-routes.ts', 'src/web/file-routes.ts')).toBe(true); + expect(m!('FILE-ROUTES.TS', 'src/FILE-ROUTES.TS')).toBe(true); + expect(m!('session.ts', 'src/session.ts')).toBe(false); + }); + + it('matches against the relative path when the query contains a slash (substring)', () => { + const m = compileFileQuery('web/file'); + expect(m).not.toBeNull(); + // Name alone would not contain the slash — must match the relative path. + expect(m!('file-routes.ts', 'src/web/file-routes.ts')).toBe(true); + expect(m!('file-routes.ts', 'src/api/file-routes.ts')).toBe(false); + }); + + it('compiles an anchored, case-insensitive glob for * against the name', () => { + const m = compileFileQuery('*.ts'); + expect(m).not.toBeNull(); + expect(m!('session.ts', 'src/session.ts')).toBe(true); + expect(m!('SESSION.TS', 'src/SESSION.TS')).toBe(true); + // Anchored: .ts must be at the end, not merely contained. + expect(m!('session.tsx', 'src/session.tsx')).toBe(false); + expect(m!('notes.md', 'notes.md')).toBe(false); + }); + + it('treats ? as a single-character glob wildcard', () => { + const m = compileFileQuery('a?c.txt'); + expect(m).not.toBeNull(); + expect(m!('abc.txt', 'abc.txt')).toBe(true); + expect(m!('axc.txt', 'axc.txt')).toBe(true); + // ? matches exactly one char, not zero and not two. + expect(m!('ac.txt', 'ac.txt')).toBe(false); + expect(m!('abbc.txt', 'abbc.txt')).toBe(false); + }); + + it('escapes regex metacharacters other than * and ? in glob mode', () => { + // The dot is a literal, not "any char"; the + is literal too. + const m = compileFileQuery('v1.2+*.log'); + expect(m).not.toBeNull(); + expect(m!('v1.2+final.log', 'v1.2+final.log')).toBe(true); + expect(m!('v1X2Yfinal.log', 'v1X2Yfinal.log')).toBe(false); + }); + + it('matches a glob against the relative path when it contains a slash', () => { + const m = compileFileQuery('src/*.ts'); + expect(m).not.toBeNull(); + expect(m!('session.ts', 'src/session.ts')).toBe(true); + expect(m!('session.ts', 'lib/session.ts')).toBe(false); + }); +}); + +describe('matchFileQuery', () => { + it('compiles then applies in one call', () => { + expect(matchFileQuery('route', 'file-routes.ts', 'src/file-routes.ts')).toBe(true); + expect(matchFileQuery('*.md', 'readme.md', 'docs/readme.md')).toBe(true); + expect(matchFileQuery('*.md', 'readme.txt', 'docs/readme.txt')).toBe(false); + }); + + it('returns false when the query compiles to null (empty)', () => { + expect(matchFileQuery('', 'anything.ts', 'src/anything.ts')).toBe(false); + expect(matchFileQuery(' ', 'anything.ts', 'src/anything.ts')).toBe(false); + }); +}); diff --git a/test/routes/file-search-mode.test.ts b/test/routes/file-search-mode.test.ts new file mode 100644 index 00000000..52a0b37e --- /dev/null +++ b/test/routes/file-search-mode.test.ts @@ -0,0 +1,94 @@ +/** + * GET /api/sessions/:id/files?q=... — search mode. + * + * A query turns the endpoint from a nested tree into a flat match list. Two + * properties are worth pinning: the walk must recurse PAST non-matching + * directories (searching is pointless if a file whose parents don't match is + * unreachable), and an empty query must leave the default tree response exactly + * as it was, since that is every existing caller. + * + * Runs against a real temp directory rather than a mocked fs: the value here is + * the traversal, and a mocked readdir would just be asserting the mock. + * Port: none (app.inject). + */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeEach, describe, expect, it } from 'vitest'; + +import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; +import { registerFileRoutes } from '../../src/web/routes/file-routes.js'; + +const root = mkdtempSync(join(tmpdir(), 'codeman-file-search-')); +mkdirSync(join(root, 'src', 'deep', 'nested'), { recursive: true }); +mkdirSync(join(root, 'node_modules', 'pkg'), { recursive: true }); +writeFileSync(join(root, 'README.md'), '#\n'); +writeFileSync(join(root, 'src', 'widget.ts'), 'x\n'); +writeFileSync(join(root, 'src', 'deep', 'nested', 'widget-test.ts'), 'x\n'); +writeFileSync(join(root, 'node_modules', 'pkg', 'widget.ts'), 'x\n'); + +afterAll(() => rmSync(root, { recursive: true, force: true })); + +describe('files endpoint search mode', () => { + let harness: RouteTestHarness; + + beforeEach(async () => { + harness = await createRouteTestHarness(registerFileRoutes); + harness.ctx._session.workingDir = root; + }); + + const get = async (query: string) => { + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/files${query}`, + }); + expect(res.statusCode).toBe(200); + return JSON.parse(res.body); + }; + + it('returns a flat match list and reaches files under non-matching directories', () => { + return get('?q=widget').then((body) => { + expect(body.success).toBe(true); + expect(body.data.mode).toBe('search'); + + const paths = body.data.matches.map((m: { path: string }) => m.path).sort(); + // deep/nested matches only because the walk recursed through `src` and + // `deep`, neither of which matches 'widget' itself. + expect(paths).toEqual([join('src', 'deep', 'nested', 'widget-test.ts'), join('src', 'widget.ts')]); + expect(body.data.matchCount).toBe(2); + expect(body.data.query).toBe('widget'); + // The nested tree is not built in search mode. + expect(body.data.tree).toEqual([]); + }); + }); + + it('still honours the excluded-directory list while searching', async () => { + const body = await get('?q=widget'); + const paths = body.data.matches.map((m: { path: string }) => m.path); + + expect(paths.some((p: string) => p.includes('node_modules'))).toBe(false); + }); + + it('leaves the default tree response untouched when no query is given', async () => { + const body = await get(''); + + expect(body.data.mode).toBeUndefined(); + expect(body.data.matches).toBeUndefined(); + expect(Array.isArray(body.data.tree)).toBe(true); + expect(body.data.tree.length).toBeGreaterThan(0); + }); + + it('treats a whitespace-only query as no query at all', async () => { + const body = await get('?q=%20%20'); + + expect(body.data.mode).toBeUndefined(); + expect(Array.isArray(body.data.tree)).toBe(true); + }); + + it('reports directories that match as well as files', async () => { + const body = await get('?q=nested'); + + expect(body.data.matches).toHaveLength(1); + expect(body.data.matches[0]).toMatchObject({ name: 'nested', type: 'directory' }); + }); +});