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:
@@ -7,6 +7,9 @@ import type { Config } from '../config.js';
|
|||||||
import type { Broker } from '../services/broker.js';
|
import type { Broker } from '../services/broker.js';
|
||||||
import type { Project, AvailableProject } from '../types/api.js';
|
import type { Project, AvailableProject } from '../types/api.js';
|
||||||
import { requireUser } from '../auth.js';
|
import { requireUser } from '../auth.js';
|
||||||
|
import { resolveProjectRoot, PathScopeError } from '../services/path_guard.js';
|
||||||
|
import { listDir, viewFile } from '../services/file_ops.js';
|
||||||
|
import { getProjectFiles } from '../services/file_index.js';
|
||||||
|
|
||||||
const AddProjectBody = z.object({
|
const AddProjectBody = z.object({
|
||||||
path: z.string().min(1),
|
path: z.string().min(1),
|
||||||
@@ -132,4 +135,125 @@ export function registerProjectRoutes(
|
|||||||
out.sort((a, b) => a.name.localeCompare(b.name));
|
out.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/projects/:id/list_dir?path=<relpath>
|
||||||
|
app.get<{ Params: { id: string }; Querystring: { path?: string } }>(
|
||||||
|
'/api/projects/:id/list_dir',
|
||||||
|
async (req, reply) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const relPath = req.query.path ?? '.';
|
||||||
|
|
||||||
|
const rows = await sql<Project[]>`
|
||||||
|
SELECT id, name, path, added_at, last_session_id
|
||||||
|
FROM projects WHERE id = ${id}
|
||||||
|
`;
|
||||||
|
if (rows.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'not found' };
|
||||||
|
}
|
||||||
|
const project = rows[0]!;
|
||||||
|
let projectRoot: string;
|
||||||
|
try {
|
||||||
|
projectRoot = await resolveProjectRoot(project.path);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof PathScopeError) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await listDir(projectRoot, relPath);
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof PathScopeError) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /api/projects/:id/view_file?path=<relpath>
|
||||||
|
app.get<{ Params: { id: string }; Querystring: { path?: string } }>(
|
||||||
|
'/api/projects/:id/view_file',
|
||||||
|
async (req, reply) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const relPath = req.query.path;
|
||||||
|
|
||||||
|
if (!relPath) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: 'path is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await sql<Project[]>`
|
||||||
|
SELECT id, name, path, added_at, last_session_id
|
||||||
|
FROM projects WHERE id = ${id}
|
||||||
|
`;
|
||||||
|
if (rows.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'not found' };
|
||||||
|
}
|
||||||
|
const project = rows[0]!;
|
||||||
|
let projectRoot: string;
|
||||||
|
try {
|
||||||
|
projectRoot = await resolveProjectRoot(project.path);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof PathScopeError) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await viewFile(projectRoot, relPath);
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof PathScopeError) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
// File not found (pathGuard throws PathScopeError for non-existent paths)
|
||||||
|
if (err instanceof Error && err.message.includes('does not exist')) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /api/projects/:id/files
|
||||||
|
app.get<{ Params: { id: string } }>(
|
||||||
|
'/api/projects/:id/files',
|
||||||
|
async (req, reply) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const rows = await sql<Project[]>`
|
||||||
|
SELECT id, name, path, added_at, last_session_id
|
||||||
|
FROM projects WHERE id = ${id}
|
||||||
|
`;
|
||||||
|
if (rows.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'not found' };
|
||||||
|
}
|
||||||
|
const project = rows[0]!;
|
||||||
|
let projectRoot: string;
|
||||||
|
try {
|
||||||
|
projectRoot = await resolveProjectRoot(project.path);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof PathScopeError) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = await getProjectFiles(id, projectRoot);
|
||||||
|
return { files };
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
49
apps/server/src/services/file_index.ts
Normal file
49
apps/server/src/services/file_index.ts
Normal 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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
250
apps/server/src/services/file_ops.ts
Normal file
250
apps/server/src/services/file_ops.ts
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
import { readFile, readdir, stat } from 'node:fs/promises';
|
||||||
|
import { resolve, basename, relative } from 'node:path';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import type { Stats } from 'node:fs';
|
||||||
|
import { pathGuard, PathScopeError } from './path_guard.js';
|
||||||
|
|
||||||
|
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
||||||
|
const DEFAULT_VIEW_LINES = 200;
|
||||||
|
const MAX_GREP_RESULTS = 200;
|
||||||
|
const DEFAULT_GREP_RESULTS = 100;
|
||||||
|
const MAX_FIND_RESULTS = 1000;
|
||||||
|
const DEFAULT_FIND_RESULTS = 100;
|
||||||
|
const MAX_DIR_ENTRIES = 500;
|
||||||
|
|
||||||
|
export interface FileEntry {
|
||||||
|
name: string;
|
||||||
|
kind: 'file' | 'dir';
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListDirResult {
|
||||||
|
entries: FileEntry[];
|
||||||
|
truncated: boolean;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ViewFileResult {
|
||||||
|
content: string;
|
||||||
|
truncated: boolean;
|
||||||
|
total_bytes: number;
|
||||||
|
bytes_returned: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GrepMatch {
|
||||||
|
path: string;
|
||||||
|
line: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GrepResult {
|
||||||
|
matches: GrepMatch[];
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FindFilesResult {
|
||||||
|
files: string[];
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suppress unused import warning — Stats is part of the public API surface
|
||||||
|
void (undefined as unknown as Stats);
|
||||||
|
|
||||||
|
export async function listDir(projectRoot: string, relPath: string): Promise<ListDirResult> {
|
||||||
|
const real = await pathGuard(projectRoot, relPath);
|
||||||
|
const s = await stat(real);
|
||||||
|
if (!s.isDirectory()) {
|
||||||
|
throw new PathScopeError(`not a directory: ${relPath}`);
|
||||||
|
}
|
||||||
|
const entries = await readdir(real, { withFileTypes: true });
|
||||||
|
const total = entries.length;
|
||||||
|
const slice = entries.slice(0, MAX_DIR_ENTRIES);
|
||||||
|
const out: FileEntry[] = await Promise.all(
|
||||||
|
slice.map(async (e) => {
|
||||||
|
const child = resolve(real, e.name);
|
||||||
|
let size: number | undefined;
|
||||||
|
if (e.isFile()) {
|
||||||
|
try {
|
||||||
|
const cs = await stat(child);
|
||||||
|
size = cs.size;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: e.name,
|
||||||
|
kind: e.isDirectory() ? ('dir' as const) : ('file' as const),
|
||||||
|
...(size != null ? { size } : {}),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
entries: out,
|
||||||
|
total,
|
||||||
|
truncated: total > MAX_DIR_ENTRIES,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function viewFile(projectRoot: string, relPath: string): Promise<ViewFileResult> {
|
||||||
|
const real = await pathGuard(projectRoot, relPath);
|
||||||
|
const s = await stat(real);
|
||||||
|
if (!s.isFile()) {
|
||||||
|
throw new PathScopeError(`not a file: ${relPath}`);
|
||||||
|
}
|
||||||
|
if (s.size > MAX_FILE_BYTES) {
|
||||||
|
throw new Error(`file too large (${s.size} bytes, max ${MAX_FILE_BYTES})`);
|
||||||
|
}
|
||||||
|
const raw = await readFile(real, 'utf8');
|
||||||
|
const lines = raw.split('\n');
|
||||||
|
const total = lines.length;
|
||||||
|
const end = Math.min(total, DEFAULT_VIEW_LINES);
|
||||||
|
const slice = lines.slice(0, end);
|
||||||
|
const content = slice.join('\n');
|
||||||
|
const truncated = total > end;
|
||||||
|
const bytes_returned = Buffer.byteLength(content, 'utf8');
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
truncated,
|
||||||
|
total_bytes: s.size,
|
||||||
|
bytes_returned,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RipgrepMatch {
|
||||||
|
type: string;
|
||||||
|
data?: {
|
||||||
|
path?: { text?: string };
|
||||||
|
line_number?: number;
|
||||||
|
lines?: { text?: string };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function grep(
|
||||||
|
projectRoot: string,
|
||||||
|
pattern: string,
|
||||||
|
opts?: { path?: string; max_matches?: number; case_sensitive?: boolean; hidden?: boolean }
|
||||||
|
): Promise<GrepResult> {
|
||||||
|
const targetPath = opts?.path ?? projectRoot;
|
||||||
|
const target = await pathGuard(projectRoot, targetPath);
|
||||||
|
const limit = Math.min(
|
||||||
|
Math.max(opts?.max_matches ?? DEFAULT_GREP_RESULTS, 1),
|
||||||
|
MAX_GREP_RESULTS
|
||||||
|
);
|
||||||
|
const args = [
|
||||||
|
'--json',
|
||||||
|
'--max-count',
|
||||||
|
String(limit),
|
||||||
|
'--max-columns',
|
||||||
|
'300',
|
||||||
|
];
|
||||||
|
if (!opts?.case_sensitive) args.push('--ignore-case');
|
||||||
|
if (opts?.hidden) args.push('--hidden');
|
||||||
|
args.push('--', pattern, target);
|
||||||
|
|
||||||
|
return new Promise((resolveP, rejectP) => {
|
||||||
|
const child = spawn('rg', args, { cwd: projectRoot });
|
||||||
|
const matches: GrepMatch[] = [];
|
||||||
|
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 filePath = parsed.data.path?.text ?? '';
|
||||||
|
const lineNumber = parsed.data.line_number ?? 0;
|
||||||
|
const content = parsed.data.lines?.text ?? '';
|
||||||
|
matches.push({
|
||||||
|
path: relative(projectRoot, filePath) || filePath,
|
||||||
|
line: lineNumber,
|
||||||
|
text: 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) => {
|
||||||
|
if (code === 2 && matches.length === 0) {
|
||||||
|
rejectP(new Error(`ripgrep failed: ${stderr.slice(0, 300)}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolveP({
|
||||||
|
matches,
|
||||||
|
truncated: matches.length >= limit,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findFiles(
|
||||||
|
projectRoot: string,
|
||||||
|
pattern?: string,
|
||||||
|
opts?: { type?: 'file' | 'dir'; max_results?: number }
|
||||||
|
): Promise<FindFilesResult> {
|
||||||
|
const limit = Math.min(
|
||||||
|
Math.max(opts?.max_results ?? DEFAULT_FIND_RESULTS, 1),
|
||||||
|
MAX_FIND_RESULTS
|
||||||
|
);
|
||||||
|
const args = ['--files'];
|
||||||
|
if (pattern) args.push('--glob', pattern);
|
||||||
|
args.push(projectRoot);
|
||||||
|
|
||||||
|
return new Promise((resolveP, rejectP) => {
|
||||||
|
const child = spawn('rg', args, { cwd: projectRoot });
|
||||||
|
const files: string[] = [];
|
||||||
|
let total = 0;
|
||||||
|
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;
|
||||||
|
total++;
|
||||||
|
if (files.length < limit) {
|
||||||
|
files.push(relative(projectRoot, line) || line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.stderr.on('data', (chunk: string) => {
|
||||||
|
stderr += chunk;
|
||||||
|
});
|
||||||
|
child.on('error', (err) => rejectP(err));
|
||||||
|
child.on('close', (code) => {
|
||||||
|
if (code === 2) {
|
||||||
|
rejectP(new Error(`ripgrep failed: ${stderr.slice(0, 300)}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (buf.length > 0) {
|
||||||
|
total++;
|
||||||
|
if (files.length < limit) {
|
||||||
|
files.push(relative(projectRoot, buf) || buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolveP({
|
||||||
|
files,
|
||||||
|
truncated: total > files.length,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { resolve, basename, relative } from 'node:path';
|
|||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { pathGuard, PathScopeError } from './path_guard.js';
|
import { pathGuard, PathScopeError } from './path_guard.js';
|
||||||
|
import { grep as fileOpsGrep } from './file_ops.js';
|
||||||
|
|
||||||
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
||||||
const DEFAULT_VIEW_LINES = 200;
|
const DEFAULT_VIEW_LINES = 200;
|
||||||
@@ -168,15 +169,6 @@ const GrepInput = z.object({
|
|||||||
});
|
});
|
||||||
type GrepInputT = z.infer<typeof GrepInput>;
|
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> = {
|
export const grep: ToolDef<GrepInputT> = {
|
||||||
name: 'grep',
|
name: 'grep',
|
||||||
description:
|
description:
|
||||||
@@ -203,73 +195,27 @@ export const grep: ToolDef<GrepInputT> = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
async execute(input, projectRoot) {
|
async execute(input, projectRoot) {
|
||||||
const target = await pathGuard(projectRoot, input.path ?? projectRoot);
|
|
||||||
const limit = Math.min(
|
const limit = Math.min(
|
||||||
Math.max(input.max_results ?? DEFAULT_GREP_RESULTS, 1),
|
Math.max(input.max_results ?? DEFAULT_GREP_RESULTS, 1),
|
||||||
MAX_GREP_RESULTS
|
MAX_GREP_RESULTS
|
||||||
);
|
);
|
||||||
const args = [
|
// Delegate to file_ops.grep; reshape match objects to preserve LLM output format
|
||||||
'--json',
|
// (file_ops uses {path, line, text}; tool output uses {path, line, content})
|
||||||
'--max-count',
|
const result = await fileOpsGrep(projectRoot, input.pattern, {
|
||||||
String(limit),
|
path: input.path,
|
||||||
'--max-columns',
|
max_matches: limit,
|
||||||
'300',
|
case_sensitive: input.case_sensitive,
|
||||||
];
|
hidden: input.hidden,
|
||||||
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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
return {
|
||||||
|
matches: result.matches.map((m) => ({
|
||||||
|
path: m.path,
|
||||||
|
line: m.line,
|
||||||
|
content: m.text,
|
||||||
|
})),
|
||||||
|
total: result.matches.length,
|
||||||
|
truncated: result.truncated,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import type {
|
|||||||
Message,
|
Message,
|
||||||
ModelInfo,
|
ModelInfo,
|
||||||
SidebarResponse,
|
SidebarResponse,
|
||||||
|
ListDirResult,
|
||||||
|
ViewFileResult,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
@@ -47,6 +49,12 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
remove: (id: string) =>
|
remove: (id: string) =>
|
||||||
request<void>(`/api/projects/${id}`, { method: 'DELETE' }),
|
request<void>(`/api/projects/${id}`, { method: 'DELETE' }),
|
||||||
|
listDir: (id: string, path: string) =>
|
||||||
|
request<ListDirResult>(`/api/projects/${id}/list_dir?path=${encodeURIComponent(path)}`),
|
||||||
|
viewFile: (id: string, path: string) =>
|
||||||
|
request<ViewFileResult>(`/api/projects/${id}/view_file?path=${encodeURIComponent(path)}`),
|
||||||
|
files: (id: string) =>
|
||||||
|
request<{ files: string[] }>(`/api/projects/${id}/files`),
|
||||||
},
|
},
|
||||||
|
|
||||||
sessions: {
|
sessions: {
|
||||||
|
|||||||
@@ -77,6 +77,25 @@ export interface SidebarResponse {
|
|||||||
projects: SidebarProject[];
|
projects: SidebarProject[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FileEntry {
|
||||||
|
name: string;
|
||||||
|
kind: 'file' | 'dir';
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListDirResult {
|
||||||
|
entries: FileEntry[];
|
||||||
|
truncated: boolean;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ViewFileResult {
|
||||||
|
content: string;
|
||||||
|
truncated: boolean;
|
||||||
|
total_bytes: number;
|
||||||
|
bytes_returned: number;
|
||||||
|
}
|
||||||
|
|
||||||
export type WsFrame =
|
export type WsFrame =
|
||||||
| { type: 'snapshot'; messages: Message[] }
|
| { type: 'snapshot'; messages: Message[] }
|
||||||
| { type: 'message_started'; message_id: string; role: MessageRole }
|
| { type: 'message_started'; message_id: string; role: MessageRole }
|
||||||
|
|||||||
Reference in New Issue
Block a user