feat: add global workspace search with regex and file filtering
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
|||||||
readFile,
|
readFile,
|
||||||
writeFile,
|
writeFile,
|
||||||
deleteFile,
|
deleteFile,
|
||||||
|
searchFiles,
|
||||||
} from "../services/editor.service.js";
|
} from "../services/editor.service.js";
|
||||||
import { lookupResref, getResrefCount } from "../nwscript/resref-index.js";
|
import { lookupResref, getResrefCount } from "../nwscript/resref-index.js";
|
||||||
import { lookupTlk, getTlkCount } from "../nwscript/tlk-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) => {
|
router.get("/resref/:resref", (req, res) => {
|
||||||
const entry = lookupResref(req.params.resref);
|
const entry = lookupResref(req.params.resref);
|
||||||
if (entry) {
|
if (entry) {
|
||||||
|
|||||||
@@ -1,8 +1,32 @@
|
|||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
|
import { createReadStream } from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import readline from "readline";
|
||||||
|
|
||||||
const WORKSPACE_PATH = process.env.WORKSPACE_PATH || "/workspace";
|
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 {
|
export interface FileNode {
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
@@ -74,6 +98,111 @@ export async function deleteFile(
|
|||||||
await fs.unlink(fullPath);
|
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 {
|
function resolveRepoPath(repo: string, filePath: string): string {
|
||||||
const repoRoot = path.resolve(path.join(WORKSPACE_PATH, "repos", repo));
|
const repoRoot = path.resolve(path.join(WORKSPACE_PATH, "repos", repo));
|
||||||
const resolved = path.resolve(repoRoot, filePath);
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -33,6 +33,12 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
deleteFile: (repo: string, filePath: string) =>
|
deleteFile: (repo: string, filePath: string) =>
|
||||||
request(`/editor/file/${repo}/${filePath}`, { method: "DELETE" }),
|
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: {
|
workspace: {
|
||||||
|
|||||||
Reference in New Issue
Block a user