353 lines
12 KiB
TypeScript
353 lines
12 KiB
TypeScript
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<string> {
|
|
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[];
|
|
} {
|
|
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();
|
|
for (let foldedIndex = 0; foldedIndex < folded.length; foldedIndex += 1) {
|
|
starts.push(index);
|
|
ends.push(end);
|
|
}
|
|
index = end;
|
|
}
|
|
return { text: value.toLowerCase(), starts, ends };
|
|
}
|
|
|
|
async function fallbackFileList(projectPath: string): Promise<string[]> {
|
|
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<CodingProjectFileEntry[]> {
|
|
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<CodingProjectFileEntry[]> {
|
|
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<CodingProjectFileContent> {
|
|
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<CodingTextSearchResult[]> {
|
|
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<string[]> {
|
|
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<GitCommandResult | null> {
|
|
try {
|
|
return await this.git.run(projectPath, args);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
}
|