diff --git a/apps/server/src/routes/projects.ts b/apps/server/src/routes/projects.ts index 50e72f4..931c5ce 100644 --- a/apps/server/src/routes/projects.ts +++ b/apps/server/src/routes/projects.ts @@ -7,6 +7,9 @@ import type { Config } from '../config.js'; import type { Broker } from '../services/broker.js'; import type { Project, AvailableProject } from '../types/api.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({ path: z.string().min(1), @@ -132,4 +135,125 @@ export function registerProjectRoutes( out.sort((a, b) => a.name.localeCompare(b.name)); return out; }); + + // GET /api/projects/:id/list_dir?path= + 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` + 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= + 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` + 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` + 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 }; + } + ); } diff --git a/apps/server/src/services/file_index.ts b/apps/server/src/services/file_index.ts new file mode 100644 index 0000000..c4273b7 --- /dev/null +++ b/apps/server/src/services/file_index.ts @@ -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(); // keyed by projectId + +export async function getProjectFiles(projectId: string, projectRoot: string): Promise { + 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 { + 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 { + 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)); + }); + }); +} diff --git a/apps/server/src/services/file_ops.ts b/apps/server/src/services/file_ops.ts new file mode 100644 index 0000000..541d6b9 --- /dev/null +++ b/apps/server/src/services/file_ops.ts @@ -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 { + 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 { + 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 { + 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 { + 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, + }); + }); + }); +} diff --git a/apps/server/src/services/tools.ts b/apps/server/src/services/tools.ts index aaf5e2b..84c2c90 100644 --- a/apps/server/src/services/tools.ts +++ b/apps/server/src/services/tools.ts @@ -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; -interface RipgrepMatch { - type: string; - data?: { - path?: { text?: string }; - line_number?: number; - lines?: { text?: string }; - }; -} - export const grep: ToolDef = { name: 'grep', description: @@ -203,73 +195,27 @@ export const grep: ToolDef = { }, }, 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, + }; }, }; diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index c4378b3..364564b 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -5,6 +5,8 @@ import type { Message, ModelInfo, SidebarResponse, + ListDirResult, + ViewFileResult, } from './types'; export class ApiError extends Error { @@ -47,6 +49,12 @@ export const api = { }), remove: (id: string) => request(`/api/projects/${id}`, { method: 'DELETE' }), + listDir: (id: string, path: string) => + request(`/api/projects/${id}/list_dir?path=${encodeURIComponent(path)}`), + viewFile: (id: string, path: string) => + request(`/api/projects/${id}/view_file?path=${encodeURIComponent(path)}`), + files: (id: string) => + request<{ files: string[] }>(`/api/projects/${id}/files`), }, sessions: { diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index 0f2ee74..8348185 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -77,6 +77,25 @@ export interface SidebarResponse { 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 = | { type: 'snapshot'; messages: Message[] } | { type: 'message_started'; message_id: string; role: MessageRole }