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);
@@ -0,0 +1,292 @@
import { useState, useCallback, useRef } from "react";
import { api } from "../../services/api";
interface SearchMatch {
file: string;
line: number;
column: number;
text: string;
matchLength: number;
}
interface GroupedMatch {
file: string;
matches: SearchMatch[];
}
interface SearchPanelProps {
repo: string;
onResultClick: (file: string, line: number) => void;
}
export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
const [query, setQuery] = useState("");
const [regex, setRegex] = useState(false);
const [caseSensitive, setCaseSensitive] = useState(false);
const [includePattern, setIncludePattern] = useState("");
const [excludePattern, setExcludePattern] = useState("");
const [results, setResults] = useState<GroupedMatch[]>([]);
const [totalMatches, setTotalMatches] = useState(0);
const [filesSearched, setFilesSearched] = useState(0);
const [loading, setLoading] = useState(false);
const [searched, setSearched] = useState(false);
const [error, setError] = useState<string | null>(null);
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const inputRef = useRef<HTMLInputElement>(null);
const doSearch = useCallback(async () => {
if (!query.trim()) return;
setLoading(true);
setError(null);
setSearched(true);
try {
const opts: Record<string, unknown> = { regex, caseSensitive };
if (includePattern.trim()) opts.includePattern = includePattern.trim();
if (excludePattern.trim()) opts.excludePattern = excludePattern.trim();
const data = await api.editor.search(repo, query, opts);
setTotalMatches(data.totalMatches);
setFilesSearched(data.filesSearched);
const grouped = new Map<string, SearchMatch[]>();
for (const m of data.matches) {
const arr = grouped.get(m.file);
if (arr) arr.push(m);
else grouped.set(m.file, [m]);
}
setResults(
Array.from(grouped.entries()).map(([file, matches]) => ({ file, matches })),
);
} catch (err) {
setError(err instanceof Error ? err.message : "Search failed");
setResults([]);
} finally {
setLoading(false);
}
}, [query, regex, caseSensitive, includePattern, excludePattern, repo]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") doSearch();
},
[doSearch],
);
const toggleCollapsed = useCallback((file: string) => {
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(file)) next.delete(file);
else next.add(file);
return next;
});
}, []);
const toggleBtnStyle = (active: boolean): React.CSSProperties => ({
backgroundColor: active ? "var(--forge-accent)" : "transparent",
color: active ? "#121212" : "var(--forge-text-secondary)",
border: `1px solid ${active ? "var(--forge-accent)" : "var(--forge-border)"}`,
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: "12px",
lineHeight: "1",
});
return (
<div
className="flex h-full flex-col overflow-hidden"
style={{ backgroundColor: "var(--forge-bg)" }}
>
<div
className="flex items-center justify-between px-3 py-2"
style={{ borderBottom: "1px solid var(--forge-border)" }}
>
<span
className="text-xs font-semibold uppercase tracking-wider"
style={{ color: "var(--forge-text-secondary)" }}
>
Search
</span>
</div>
<div className="space-y-2 px-3 py-2" style={{ borderBottom: "1px solid var(--forge-border)" }}>
<div className="flex items-center gap-1">
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search..."
className="flex-1 rounded px-2 py-1 text-sm outline-none"
style={{
backgroundColor: "var(--forge-surface)",
color: "var(--forge-text)",
border: "1px solid var(--forge-border)",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: "13px",
}}
/>
<button
onClick={() => setRegex((v) => !v)}
className="rounded px-1.5 py-1"
style={toggleBtnStyle(regex)}
title="Use Regular Expression"
>
.*
</button>
<button
onClick={() => setCaseSensitive((v) => !v)}
className="rounded px-1.5 py-1"
style={toggleBtnStyle(caseSensitive)}
title="Match Case"
>
Aa
</button>
</div>
<div className="flex gap-1">
<input
value={includePattern}
onChange={(e) => setIncludePattern(e.target.value)}
placeholder="Include (e.g. *.nss)"
className="flex-1 rounded px-2 py-1 text-xs outline-none"
style={{
backgroundColor: "var(--forge-surface)",
color: "var(--forge-text)",
border: "1px solid var(--forge-border)",
}}
/>
<input
value={excludePattern}
onChange={(e) => setExcludePattern(e.target.value)}
placeholder="Exclude (e.g. *.json)"
className="flex-1 rounded px-2 py-1 text-xs outline-none"
style={{
backgroundColor: "var(--forge-surface)",
color: "var(--forge-text)",
border: "1px solid var(--forge-border)",
}}
/>
</div>
<button
onClick={doSearch}
disabled={loading || !query.trim()}
className="w-full rounded px-3 py-1 text-sm font-medium transition-colors disabled:opacity-50"
style={{
backgroundColor: "var(--forge-accent)",
color: "#121212",
}}
>
{loading ? "Searching..." : "Search"}
</button>
</div>
<div className="flex-1 overflow-y-auto">
{error && (
<div className="px-3 py-2 text-sm text-red-400">{error}</div>
)}
{searched && !loading && !error && (
<div
className="px-3 py-1.5 text-xs"
style={{
color: "var(--forge-text-secondary)",
borderBottom: "1px solid var(--forge-border)",
}}
>
{totalMatches > 0
? `${totalMatches} match${totalMatches !== 1 ? "es" : ""} in ${results.length} file${results.length !== 1 ? "s" : ""} (${filesSearched} searched)`
: "No results"}
</div>
)}
{results.map((group) => (
<div key={group.file}>
<button
onClick={() => toggleCollapsed(group.file)}
className="flex w-full items-center gap-1 px-3 py-1 text-left text-xs transition-colors hover:bg-white/5"
style={{ color: "var(--forge-text)" }}
>
<span
className="inline-block w-3 text-center"
style={{ color: "var(--forge-text-secondary)", fontSize: "10px" }}
>
{collapsed.has(group.file) ? "\u25B6" : "\u25BC"}
</span>
<span
className="flex-1 truncate font-medium"
style={{ fontFamily: "'JetBrains Mono', 'Fira Code', monospace", fontSize: "12px" }}
>
{group.file}
</span>
<span
className="rounded-full px-1.5 text-xs"
style={{
backgroundColor: "var(--forge-surface)",
color: "var(--forge-text-secondary)",
}}
>
{group.matches.length}
</span>
</button>
{!collapsed.has(group.file) &&
group.matches.map((match, i) => (
<button
key={`${match.line}-${match.column}-${i}`}
onClick={() => onResultClick(match.file, match.line)}
className="flex w-full items-start gap-2 px-3 py-0.5 text-left transition-colors hover:bg-white/5"
style={{ paddingLeft: "28px" }}
>
<span
className="shrink-0 text-xs"
style={{
color: "var(--forge-text-secondary)",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: "11px",
minWidth: "32px",
textAlign: "right",
}}
>
{match.line}
</span>
<span
className="truncate text-xs"
style={{
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: "12px",
color: "var(--forge-text-secondary)",
}}
>
<HighlightedLine text={match.text} column={match.column} matchLength={match.matchLength} />
</span>
</button>
))}
</div>
))}
</div>
</div>
);
}
function HighlightedLine({
text,
column,
matchLength,
}: {
text: string;
column: number;
matchLength: number;
}) {
const before = text.slice(0, column);
const matched = text.slice(column, column + matchLength);
const after = text.slice(column + matchLength);
return (
<>
<span>{before}</span>
<span className="font-bold" style={{ color: "var(--forge-accent)" }}>
{matched}
</span>
<span>{after}</span>
</>
);
}
+6
View File
@@ -33,6 +33,12 @@ export const api = {
}),
deleteFile: (repo: string, filePath: string) =>
request(`/editor/file/${repo}/${filePath}`, { method: "DELETE" }),
search: (repo: string, query: string, opts?: Record<string, unknown>) =>
request<{
matches: Array<{ file: string; line: number; column: number; text: string; matchLength: number }>;
totalMatches: number;
filesSearched: number;
}>("/editor/search", { method: "POST", body: JSON.stringify({ repo, query, ...opts }) }),
},
workspace: {