import { open, lstat, readdir, realpath } from 'node:fs/promises'; import path from 'node:path'; import type { CodingProjectFileContent, CodingProjectFileEntry, CodingProjectFileStatus, CodingTextSearchResult, } from '../../shared/coding-product-tools'; import { ProcessConversationGitAdapter, type ConversationGitAdapter, type GitCommandResult, } from './conversation-change-tracker'; const MAX_STATUS_RESULTS = 200; const DEFAULT_FIND_RESULTS = 20; const MAX_FIND_RESULTS = 200; const MAX_DISCOVERED_FILES = 20_000; const MAX_CONTENT_BYTES = 256 * 1024; const MAX_SEARCH_FILE_BYTES = 1024 * 1024; const MAX_SEARCH_RESULTS = 200; const MAX_SEARCH_LINE_CHARS = 2_048; const SKIPPED_DIRECTORIES = new Set([ '.git', '.next', '.nuxt', '.svelte-kit', 'build', 'coverage', 'dist', 'node_modules', 'out', ]); function normalizeRelativePath(value: string): string { const raw = value.trim(); const slashPath = raw.replaceAll('\\', '/'); if (!slashPath || slashPath.includes('\0') || path.isAbsolute(raw) || path.win32.isAbsolute(raw) || path.posix.isAbsolute(slashPath)) { throw new Error('Project file path must be relative'); } const normalized = path.posix.normalize(slashPath).replace(/^\.\//, ''); if (!normalized || normalized === '.' || normalized === '..' || normalized.startsWith('../')) { throw new Error('Project file path escapes the active project'); } return normalized; } function projectTarget(projectPath: string, relativePath: string): string { const root = path.resolve(projectPath); const target = path.resolve(root, ...relativePath.split('/')); const relative = path.relative(root, target); if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { throw new Error('Project file path escapes the active project'); } return target; } async function containedExistingTarget(projectPath: string, relativePath: string): Promise { const root = await realpath(path.resolve(projectPath)); const target = await realpath(projectTarget(root, relativePath)); const relative = path.relative(root, target); if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { throw new Error('Project file path escapes the active project'); } return target; } function statusFromSignature( signature: string, renamed: boolean, conflicted = false, ): CodingProjectFileStatus { if (signature === '??') return 'untracked'; if (conflicted) return 'conflicted'; if (renamed || signature.includes('R')) return 'renamed'; if (signature.includes('D')) return 'deleted'; if (signature.includes('A')) return 'added'; return 'modified'; } function parseStatus(value: string): CodingProjectFileEntry[] { const records = value.split('\0'); const files: CodingProjectFileEntry[] = []; for (let index = 0; index < records.length; index += 1) { const record = records[index]; if (!record || record.startsWith('! ')) continue; let filePath: string | undefined; let status: CodingProjectFileStatus; if (record.startsWith('? ')) { filePath = record.slice(2); status = 'untracked'; } else { const renamed = record.startsWith('2 '); const conflicted = record.startsWith('u '); const match = conflicted ? record.match(/^u ([^ ]+) [^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ (.*)$/s) : renamed ? record.match(/^2 ([^ ]+) [^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ (.*)$/s) : record.match(/^1 ([^ ]+) [^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ (.*)$/s); if (!match) continue; filePath = match[2]; status = statusFromSignature(match[1], renamed, conflicted); if (renamed) index += 1; } const normalized = normalizeRelativePath(filePath); files.push({ path: normalized, name: path.posix.basename(normalized), type: 'file', status, }); if (files.length >= MAX_STATUS_RESULTS) break; } return files; } async function readBoundedFile( projectPath: string, relativePath: string, maxBytes: number, ): Promise<{ data: Buffer; truncated: boolean }> { const target = await containedExistingTarget(projectPath, relativePath); const metadata = await lstat(target); if (!metadata.isFile()) throw new Error('Project file path is not a file'); const handle = await open(target, 'r'); const data = Buffer.alloc(Math.min(metadata.size, maxBytes + 1)); let bytesRead: number; try { ({ bytesRead } = await handle.read(data, 0, data.byteLength, 0)); } finally { await handle.close(); } return { data: data.subarray(0, Math.min(bytesRead, maxBytes)), truncated: metadata.size > maxBytes || bytesRead > maxBytes, }; } function decodeText(data: Buffer, allowIncompleteSuffix = false): string { if (data.includes(0)) throw new Error('Binary project files cannot be previewed'); try { return new TextDecoder('utf-8', { fatal: true }).decode( data, allowIncompleteSuffix ? { stream: true } : undefined, ); } catch { throw new Error('Project file is not valid UTF-8 text'); } } function foldedTextWithOffsets(value: string): { text: string; starts: number[]; ends: number[]; } { let text = ''; const starts: number[] = []; const ends: number[] = []; for (let index = 0; index < value.length;) { const codePoint = value.codePointAt(index); if (codePoint === undefined) break; const character = String.fromCodePoint(codePoint); const end = index + character.length; const folded = character.toLowerCase(); text += folded; for (let foldedIndex = 0; foldedIndex < folded.length; foldedIndex += 1) { starts.push(index); ends.push(end); } index = end; } return { text, starts, ends }; } async function fallbackFileList(projectPath: string): Promise { const root = path.resolve(projectPath); const files: string[] = []; const pending = ['']; while (pending.length > 0 && files.length < MAX_DISCOVERED_FILES) { const relativeDirectory = pending.shift() as string; const target = relativeDirectory ? path.join(root, ...relativeDirectory.split('/')) : root; let entries; try { entries = await readdir(target, { withFileTypes: true }); } catch { continue; } entries.sort((left, right) => left.name.localeCompare(right.name)); for (const entry of entries) { const relativePath = relativeDirectory ? `${relativeDirectory}/${entry.name}` : entry.name; if (entry.isDirectory()) { if (!SKIPPED_DIRECTORIES.has(entry.name)) pending.push(relativePath); } else if (entry.isFile()) { files.push(relativePath); if (files.length >= MAX_DISCOVERED_FILES) break; } } } return files; } export class CodingProjectFileService { constructor( private readonly git: ConversationGitAdapter = new ProcessConversationGitAdapter(), ) {} async status(projectPath: string): Promise { const result = await this.gitResult(projectPath, [ 'status', '--porcelain=v2', '-z', '--untracked-files=all', '--', '.', ]); return result?.code === 0 ? parseStatus(result.stdout) : []; } async find( projectPath: string, query: string, requestedLimit = DEFAULT_FIND_RESULTS, ): Promise { const normalizedQuery = query.trim().toLocaleLowerCase(); if (!normalizedQuery) throw new Error('File query is required'); if (normalizedQuery.length > 200) throw new Error('File query is too long'); const limit = Math.min( Number.isSafeInteger(requestedLimit) && requestedLimit > 0 ? requestedLimit : DEFAULT_FIND_RESULTS, MAX_FIND_RESULTS, ); const paths = await this.listFiles(projectPath); const matches = paths .filter((filePath) => filePath.toLocaleLowerCase().includes(normalizedQuery)) .sort((left, right) => { const leftName = path.posix.basename(left).toLocaleLowerCase(); const rightName = path.posix.basename(right).toLocaleLowerCase(); const leftPrefix = leftName.startsWith(normalizedQuery) ? 0 : 1; const rightPrefix = rightName.startsWith(normalizedQuery) ? 0 : 1; return leftPrefix - rightPrefix || left.length - right.length || left.localeCompare(right); }) .slice(0, limit); return await Promise.all(matches.map(async (filePath) => { let size: number | undefined; try { const metadata = await lstat(projectTarget(projectPath, filePath)); if (metadata.isFile()) size = metadata.size; } catch { // A concurrently removed result remains useful by path. } return { path: filePath, name: path.posix.basename(filePath), type: 'file' as const, ...(size === undefined ? {} : { size }), }; })); } async content(projectPath: string, requestedPath: string): Promise { const relativePath = normalizeRelativePath(requestedPath); const result = await readBoundedFile(projectPath, relativePath, MAX_CONTENT_BYTES); return { path: relativePath, content: decodeText(result.data, result.truncated), truncated: result.truncated, }; } async search(projectPath: string, pattern: string): Promise { const needle = pattern.trim(); if (!needle) throw new Error('Search pattern is required'); if (needle.length > 512) throw new Error('Search pattern is too long'); const foldedNeedle = foldedTextWithOffsets(needle).text; const matches: CodingTextSearchResult[] = []; for (const filePath of await this.listFiles(projectPath)) { if (matches.length >= MAX_SEARCH_RESULTS) break; let content: string; try { const bounded = await readBoundedFile(projectPath, filePath, MAX_SEARCH_FILE_BYTES); if (bounded.truncated) continue; content = decodeText(bounded.data); } catch { continue; } const lines = content.split(/\r?\n/); for (let lineIndex = 0; lineIndex < lines.length && matches.length < MAX_SEARCH_RESULTS; lineIndex += 1) { const line = lines[lineIndex]; const foldedLine = foldedTextWithOffsets(line); const first = foldedLine.text.indexOf(foldedNeedle); if (first < 0) continue; const firstOriginalIndex = foldedLine.starts[first] ?? 0; const windowStart = Math.max(0, firstOriginalIndex - 256); const lineText = line.slice(windowStart, windowStart + MAX_SEARCH_LINE_CHARS); const foldedText = foldedTextWithOffsets(lineText); const submatches = []; let offset = 0; while (submatches.length < 20) { const foldedStart = foldedText.text.indexOf(foldedNeedle, offset); if (foldedStart < 0) break; const foldedEnd = foldedStart + foldedNeedle.length; const start = foldedText.starts[foldedStart]; const end = foldedText.ends[foldedEnd - 1]; if (start === undefined || end === undefined) break; submatches.push({ text: lineText.slice(start, end), start, end }); offset = Math.max(foldedEnd, foldedStart + 1); } matches.push({ path: filePath, name: path.posix.basename(filePath), lineNumber: lineIndex + 1, lineText, submatches, }); } } return matches; } private async listFiles(projectPath: string): Promise { const repository = await this.gitResult(projectPath, ['rev-parse', '--is-inside-work-tree']); if (repository?.code === 0 && repository.stdout.trim() === 'true') { const listed = await this.gitResult(projectPath, [ 'ls-files', '-co', '--exclude-standard', '-z', '--', '.', ]); if (listed?.code === 0) { return [...new Set(listed.stdout .split('\0') .filter(Boolean) .map(normalizeRelativePath))] .sort() .slice(0, MAX_DISCOVERED_FILES); } } return await fallbackFileList(projectPath); } private async gitResult(projectPath: string, args: readonly string[]): Promise { try { return await this.git.run(projectPath, args); } catch { return null; } } }