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';