batch3 T4: file_ops + file_index services; UI endpoints; tools refactor

- services/file_ops.ts: shared listDir/viewFile/grep/findFiles core
- services/file_index.ts: per-project flat file list cached on mtime of
  project root + .git/HEAD + .git/index (rg --files honors .gitignore)
- services/tools.ts: tools delegate to file_ops, output format unchanged
- routes/projects.ts: GET /list_dir, /view_file, /files endpoints
- web client: api.projects.listDir/viewFile/files + mirrored types

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 15:15:48 +00:00
parent 124beae2bc
commit 890d229875
6 changed files with 467 additions and 71 deletions

View File

@@ -0,0 +1,49 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { execFile } from 'node:child_process';
interface MtimeSnap {
root: number;
gitHead: number | null;
gitIndex: number | null;
}
interface CacheEntry {
files: string[];
mtimes: MtimeSnap;
}
const cache = new Map<string, CacheEntry>(); // keyed by projectId
export async function getProjectFiles(projectId: string, projectRoot: string): Promise<string[]> {
const current = await snapMtimes(projectRoot);
const cached = cache.get(projectId);
if (cached && eqMtimes(cached.mtimes, current)) {
return cached.files;
}
const files = await runRgFiles(projectRoot);
cache.set(projectId, { files, mtimes: current });
return files;
}
async function snapMtimes(root: string): Promise<MtimeSnap> {
const rootStat = await fs.stat(root);
let gitHead: number | null = null;
let gitIndex: number | null = null;
try { gitHead = (await fs.stat(path.join(root, '.git', 'HEAD'))).mtimeMs; } catch {}
try { gitIndex = (await fs.stat(path.join(root, '.git', 'index'))).mtimeMs; } catch {}
return { root: rootStat.mtimeMs, gitHead, gitIndex };
}
function eqMtimes(a: MtimeSnap, b: MtimeSnap): boolean {
return a.root === b.root && a.gitHead === b.gitHead && a.gitIndex === b.gitIndex;
}
function runRgFiles(root: string): Promise<string[]> {
return new Promise((resolve, reject) => {
execFile('rg', ['--files'], { cwd: root, maxBuffer: 32 * 1024 * 1024 }, (err, stdout) => {
if (err) return reject(err);
resolve(stdout.split('\n').filter(Boolean));
});
});
}