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