feat: add global workspace search with regex and file filtering

This commit is contained in:
plenarius
2026-04-20 19:30:42 -04:00
parent d7c5f18544
commit b36391b520
4 changed files with 440 additions and 0 deletions
+13
View File
@@ -4,6 +4,7 @@ import {
readFile,
writeFile,
deleteFile,
searchFiles,
} from "../services/editor.service.js";
import { lookupResref, getResrefCount } from "../nwscript/resref-index.js";
import { lookupTlk, getTlkCount } from "../nwscript/tlk-index.js";
@@ -51,6 +52,18 @@ router.delete("/file/:repo/*path", async (req, res) => {
}
});
router.post("/search", async (req, res) => {
const { repo, query, regex, caseSensitive, includePattern, excludePattern, maxResults } = req.body;
if (!repo || !query) return res.status(400).json({ error: "repo and query required" });
try {
const results = await searchFiles(repo, query, { regex, caseSensitive, includePattern, excludePattern, maxResults });
res.json(results);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
res.status(500).json({ error: message });
}
});
router.get("/resref/:resref", (req, res) => {
const entry = lookupResref(req.params.resref);
if (entry) {
@@ -1,8 +1,32 @@
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;
@@ -74,6 +98,111 @@ export async function deleteFile(
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);