feat(files): search the Files panel by name or path

GET /api/sessions/:id/files gains an optional `q`. With one, the endpoint
answers a FLAT match list instead of a nested tree; without one, the response is
exactly what it was, so every existing caller is untouched.

compileFileQuery() (src/utils/file-query.ts) turns the query string into a
reusable predicate, so the walk prunes as it goes rather than streaming the
whole tree to the client to be filtered there. An empty or whitespace-only
query compiles to null, which is what makes "no query" and "blank query" the
same thing.

The search walk deliberately recurses past directories that do not match — a
file whose ancestors don't match is exactly what people are searching for — so
it carries its own maxMatches cap on top of the existing maxFiles and maxDepth
ones, and reports `truncated` when it stops early. Hidden-file and
excluded-directory rules are the same ones tree mode already applies.

Tests: file-query.test.ts covers the matcher; routes/file-search-mode.test.ts
drives the endpoint against a real temp tree and pins the two properties worth
having — that the walk reaches a match under non-matching parents, and that an
absent or whitespace query leaves the tree response alone. Gating the recursion
on a match turns those red.
This commit is contained in:
Aamer Akhter
2026-08-19 09:17:20 -04:00
parent 5080390e2c
commit 5cc78669bd
5 changed files with 325 additions and 1 deletions
+58
View File
@@ -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;
}
+2
View File
@@ -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';
+91 -1
View File
@@ -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<void> => {
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<FileTreeNode[]> => {
if (currentDepth > maxDepth || totalFiles + totalDirectories > maxFiles) {
truncated = true;
+80
View File
@@ -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);
});
});
+94
View File
@@ -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' });
});
});