diff --git a/packages/backend/src/routes/editor.ts b/packages/backend/src/routes/editor.ts index 73e05d8..d9926f3 100644 --- a/packages/backend/src/routes/editor.ts +++ b/packages/backend/src/routes/editor.ts @@ -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) { diff --git a/packages/backend/src/services/editor.service.ts b/packages/backend/src/services/editor.service.ts index 2b997d8..792f9e1 100644 --- a/packages/backend/src/services/editor.service.ts +++ b/packages/backend/src/services/editor.service.ts @@ -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 { + 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 { + 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); diff --git a/packages/frontend/src/components/editor/SearchPanel.tsx b/packages/frontend/src/components/editor/SearchPanel.tsx new file mode 100644 index 0000000..481bad7 --- /dev/null +++ b/packages/frontend/src/components/editor/SearchPanel.tsx @@ -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([]); + const [totalMatches, setTotalMatches] = useState(0); + const [filesSearched, setFilesSearched] = useState(0); + const [loading, setLoading] = useState(false); + const [searched, setSearched] = useState(false); + const [error, setError] = useState(null); + const [collapsed, setCollapsed] = useState>(new Set()); + const inputRef = useRef(null); + + const doSearch = useCallback(async () => { + if (!query.trim()) return; + setLoading(true); + setError(null); + setSearched(true); + try { + const opts: Record = { 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(); + 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 ( +
+
+ + Search + +
+ +
+
+ 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", + }} + /> + + +
+ +
+ 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)", + }} + /> + 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)", + }} + /> +
+ + +
+ +
+ {error && ( +
{error}
+ )} + + {searched && !loading && !error && ( +
+ {totalMatches > 0 + ? `${totalMatches} match${totalMatches !== 1 ? "es" : ""} in ${results.length} file${results.length !== 1 ? "s" : ""} (${filesSearched} searched)` + : "No results"} +
+ )} + + {results.map((group) => ( +
+ + + {!collapsed.has(group.file) && + group.matches.map((match, i) => ( + + ))} +
+ ))} +
+
+ ); +} + +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 ( + <> + {before} + + {matched} + + {after} + + ); +} diff --git a/packages/frontend/src/services/api.ts b/packages/frontend/src/services/api.ts index 9f00223..c8c6ab9 100644 --- a/packages/frontend/src/services/api.ts +++ b/packages/frontend/src/services/api.ts @@ -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) => + 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: {