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
@@ -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: {