feat: add global workspace search with regex and file filtering
This commit is contained in:
@@ -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) =>
|
||||
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: {
|
||||
|
||||
Reference in New Issue
Block a user