mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user