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:
@@ -3,6 +3,7 @@ import { resolve, basename, relative } from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { z } from 'zod';
|
||||
import { pathGuard, PathScopeError } from './path_guard.js';
|
||||
import { grep as fileOpsGrep } from './file_ops.js';
|
||||
|
||||
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
||||
const DEFAULT_VIEW_LINES = 200;
|
||||
@@ -168,15 +169,6 @@ const GrepInput = z.object({
|
||||
});
|
||||
type GrepInputT = z.infer<typeof GrepInput>;
|
||||
|
||||
interface RipgrepMatch {
|
||||
type: string;
|
||||
data?: {
|
||||
path?: { text?: string };
|
||||
line_number?: number;
|
||||
lines?: { text?: string };
|
||||
};
|
||||
}
|
||||
|
||||
export const grep: ToolDef<GrepInputT> = {
|
||||
name: 'grep',
|
||||
description:
|
||||
@@ -203,73 +195,27 @@ export const grep: ToolDef<GrepInputT> = {
|
||||
},
|
||||
},
|
||||
async execute(input, projectRoot) {
|
||||
const target = await pathGuard(projectRoot, input.path ?? projectRoot);
|
||||
const limit = Math.min(
|
||||
Math.max(input.max_results ?? DEFAULT_GREP_RESULTS, 1),
|
||||
MAX_GREP_RESULTS
|
||||
);
|
||||
const args = [
|
||||
'--json',
|
||||
'--max-count',
|
||||
String(limit),
|
||||
'--max-columns',
|
||||
'300',
|
||||
];
|
||||
if (!input.case_sensitive) args.push('--ignore-case');
|
||||
if (input.hidden) args.push('--hidden');
|
||||
args.push('--', input.pattern, target);
|
||||
|
||||
return await new Promise((resolveP, rejectP) => {
|
||||
const child = spawn('rg', args, { cwd: projectRoot });
|
||||
const matches: Array<{ path: string; line: number; content: string }> = [];
|
||||
let buf = '';
|
||||
let stderr = '';
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk: string) => {
|
||||
buf += chunk;
|
||||
let idx;
|
||||
while ((idx = buf.indexOf('\n')) >= 0) {
|
||||
const line = buf.slice(0, idx);
|
||||
buf = buf.slice(idx + 1);
|
||||
if (!line) continue;
|
||||
if (matches.length >= limit) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(line) as RipgrepMatch;
|
||||
if (parsed.type !== 'match' || !parsed.data) continue;
|
||||
const path = parsed.data.path?.text ?? '';
|
||||
const lineNumber = parsed.data.line_number ?? 0;
|
||||
const content = parsed.data.lines?.text ?? '';
|
||||
matches.push({
|
||||
path: relative(projectRoot, path) || path,
|
||||
line: lineNumber,
|
||||
content: content.replace(/\n$/, ''),
|
||||
});
|
||||
} catch {
|
||||
/* ignore non-json */
|
||||
}
|
||||
}
|
||||
if (matches.length >= limit) {
|
||||
child.kill();
|
||||
}
|
||||
});
|
||||
child.stderr.on('data', (chunk: string) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.on('error', (err) => rejectP(err));
|
||||
child.on('close', (code) => {
|
||||
// rg exits 1 when no matches, 2 on real error
|
||||
if (code === 2 && matches.length === 0) {
|
||||
rejectP(new Error(`ripgrep failed: ${stderr.slice(0, 300)}`));
|
||||
return;
|
||||
}
|
||||
resolveP({
|
||||
matches,
|
||||
total: matches.length,
|
||||
truncated: matches.length >= limit,
|
||||
});
|
||||
});
|
||||
// Delegate to file_ops.grep; reshape match objects to preserve LLM output format
|
||||
// (file_ops uses {path, line, text}; tool output uses {path, line, content})
|
||||
const result = await fileOpsGrep(projectRoot, input.pattern, {
|
||||
path: input.path,
|
||||
max_matches: limit,
|
||||
case_sensitive: input.case_sensitive,
|
||||
hidden: input.hidden,
|
||||
});
|
||||
return {
|
||||
matches: result.matches.map((m) => ({
|
||||
path: m.path,
|
||||
line: m.line,
|
||||
content: m.text,
|
||||
})),
|
||||
total: result.matches.length,
|
||||
truncated: result.truncated,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user