Files
layonara-forge/packages/backend/src/services/editor.service.ts
T

214 lines
5.7 KiB
TypeScript

import fs from "fs/promises";
import { createReadStream } from "fs";
import path from "path";
import readline from "readline";
const WORKSPACE_PATH = process.env.WORKSPACE_PATH || "/workspace";
export interface SearchMatch {
file: string;
line: number;
column: number;
text: string;
matchLength: number;
}
export interface SearchResult {
matches: SearchMatch[];
totalMatches: number;
filesSearched: number;
}
interface SearchOptions {
regex?: boolean;
caseSensitive?: boolean;
includePattern?: string;
excludePattern?: string;
maxResults?: number;
}
export interface FileNode {
name: string;
path: string;
type: "file" | "directory";
children?: FileNode[];
}
const IGNORED_DIRS = new Set(["node_modules", ".git", ".nasher"]);
export async function getDirectoryTree(
repo: string,
subPath: string = "",
): Promise<FileNode[]> {
const fullPath = resolveRepoPath(repo, subPath);
const entries = await fs.readdir(fullPath, { withFileTypes: true });
const nodes: FileNode[] = [];
const sorted = entries.sort((a, b) => {
if (a.isDirectory() && !b.isDirectory()) return -1;
if (!a.isDirectory() && b.isDirectory()) return 1;
return a.name.localeCompare(b.name);
});
for (const entry of sorted) {
if (entry.name.startsWith(".") && entry.name !== ".gitignore") continue;
const relativePath = subPath ? `${subPath}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
if (IGNORED_DIRS.has(entry.name)) continue;
const children = await getDirectoryTree(repo, relativePath);
nodes.push({
name: entry.name,
path: relativePath,
type: "directory",
children,
});
} else {
nodes.push({ name: entry.name, path: relativePath, type: "file" });
}
}
return nodes;
}
export async function readFile(
repo: string,
filePath: string,
): Promise<string> {
const fullPath = resolveRepoPath(repo, filePath);
return fs.readFile(fullPath, "utf-8");
}
export async function writeFile(
repo: string,
filePath: string,
content: string,
): Promise<void> {
const fullPath = resolveRepoPath(repo, filePath);
await fs.mkdir(path.dirname(fullPath), { recursive: true });
await fs.writeFile(fullPath, content, "utf-8");
}
export async function deleteFile(
repo: string,
filePath: string,
): Promise<void> {
const fullPath = resolveRepoPath(repo, filePath);
await fs.unlink(fullPath);
}
const BINARY_EXTENSIONS = new Set([
".mod", ".hak", ".bic", ".tlk", ".png", ".jpg", ".tga",
".wav", ".bmu", ".ncs", ".gic",
]);
function matchesExtPattern(filePath: string, pattern: string): boolean {
if (pattern.startsWith("*.")) {
return filePath.endsWith(pattern.slice(1));
}
return filePath.endsWith(pattern);
}
async function walkFiles(dir: string, relativeTo: string): Promise<string[]> {
const results: string[] = [];
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (IGNORED_DIRS.has(entry.name)) continue;
results.push(...(await walkFiles(fullPath, relativeTo)));
} else {
const ext = path.extname(entry.name).toLowerCase();
if (!BINARY_EXTENSIONS.has(ext)) {
results.push(path.relative(relativeTo, fullPath));
}
}
}
return results;
}
export async function searchFiles(
repo: string,
query: string,
options: SearchOptions = {},
): Promise<SearchResult> {
const maxResults = options.maxResults ?? 1000;
const repoRoot = resolveRepoPath(repo, "");
const files = await walkFiles(repoRoot, repoRoot);
const matches: SearchMatch[] = [];
let filesSearched = 0;
let pattern: RegExp | null = null;
if (options.regex) {
pattern = new RegExp(query, options.caseSensitive ? "g" : "gi");
}
for (const file of files) {
if (matches.length >= maxResults) break;
if (options.includePattern && !matchesExtPattern(file, options.includePattern)) continue;
if (options.excludePattern && matchesExtPattern(file, options.excludePattern)) continue;
const fullPath = path.join(repoRoot, file);
filesSearched++;
const rl = readline.createInterface({
input: createReadStream(fullPath, { encoding: "utf-8" }),
crlfDelay: Infinity,
});
let lineNum = 0;
for await (const line of rl) {
lineNum++;
if (matches.length >= maxResults) {
rl.close();
break;
}
if (pattern) {
pattern.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = pattern.exec(line)) !== null) {
matches.push({
file,
line: lineNum,
column: m.index,
text: line,
matchLength: m[0].length,
});
if (matches.length >= maxResults) break;
}
} else {
const haystack = options.caseSensitive ? line : line.toLowerCase();
const needle = options.caseSensitive ? query : query.toLowerCase();
let startIdx = 0;
let idx: number;
while ((idx = haystack.indexOf(needle, startIdx)) !== -1) {
matches.push({
file,
line: lineNum,
column: idx,
text: line,
matchLength: needle.length,
});
startIdx = idx + 1;
if (matches.length >= maxResults) break;
}
}
}
}
return { matches, totalMatches: matches.length, filesSearched };
}
function resolveRepoPath(repo: string, filePath: string): string {
const repoRoot = path.resolve(path.join(WORKSPACE_PATH, "repos", repo));
const resolved = path.resolve(repoRoot, filePath);
if (!resolved.startsWith(repoRoot)) {
throw new Error("Path traversal detected");
}
return resolved;
}