1import { Spinner } from "~/components/ui/spinner";
2import { AuthFilesystemReadScope, type ProjectContentMatch } from "@t3tools/contracts";
3
4import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
5
6import { useActiveProjectTarget, type ActiveProjectTarget } from "~/hooks/useActiveProjectTarget";
7import { useTheme } from "~/hooks/useTheme";
8import { cn } from "~/lib/utils";
9import { useRightPanelStore } from "~/rightPanelStore";
10import { useProjectContentSearch } from "~/state/queries";
11import { readEnvironmentScope } from "~/state/session";
12
13import { PierreEntryIcon } from "../chat/PierreEntryIcon";
14import { CommandPaletteContent } from "../CommandPaletteContent";
15import { ScrollArea } from "../ui/scroll-area";
16import { Toggle } from "../ui/toggle";
17import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
18import { HighlightedSearchLine } from "./HighlightedSearchLine";
19
20interface ProjectContentSearchDialogProps {
21 readonly onOpenChange: (open: boolean) => void;
22}
23
24/**
25 * Result rows are syntax highlighted, so mounting all 500 possible rows at
26 * once stalls the UI. Rows render in windows that grow as the sentinel at the
27 * bottom of the list scrolls into view (or keyboard navigation moves past
28 * the rendered window).
29 */
30const VISIBLE_MATCH_WINDOW = 100;
31
32interface MatchGroup {
33 readonly path: string;
34 readonly matches: ReadonlyArray<ProjectContentMatch & { readonly resultIndex: number }>;
35}
36
37function splitPath(path: string): { readonly name: string; readonly directory: string } {
38 const separator = path.lastIndexOf("/");
39 return separator === -1
40 ? { name: path, directory: "" }
41 : { name: path.slice(separator + 1), directory: path.slice(0, separator) };
42}
43
44function groupMatches(matches: ReadonlyArray<ProjectContentMatch>): MatchGroup[] {
45 const groups = new Map<string, Array<ProjectContentMatch & { readonly resultIndex: number }>>();
46 matches.forEach((match, resultIndex) => {
47 const group = groups.get(match.path);
48 const indexedMatch = { ...match, resultIndex };
49 if (group) {
50 group.push(indexedMatch);
51 } else {
52 groups.set(match.path, [indexedMatch]);
53 }
54 });
55 return [...groups].map(([path, groupedMatches]) => ({ path, matches: groupedMatches }));
56}
57
58function SearchOptionButton(props: {
59 readonly active: boolean;
60 readonly disabled: boolean;
61 readonly label: string;
62 readonly onClick: () => void;
63 readonly children: ReactNode;
64}) {
65 return (
66 <Tooltip>
67 <TooltipTrigger
68 render={
69 <Toggle
70 aria-label={props.label}
71 disabled={props.disabled}
72 pressed={props.active}
73 size="segmented"
74 variant="segmented"
75 onClick={props.onClick}
76 />
77 }
78 >
79 <span className="font-mono">{props.children}</span>
80 </TooltipTrigger>
81 <TooltipPopup side="top">{props.label}</TooltipPopup>
82 </Tooltip>
83 );
84}
85
86function EmptyContentSearchDialog() {
87 return (
88 <CommandPaletteContent
89 aria-label="Search project contents"
90 escapeLabel="Back"
91 footerActionLabel="Open file"
92 inputProps={{ disabled: true, placeholder: "Search project contents…" }}
93 mode="none"
94 panelSize="fill"
95 testId="project-content-search"
96 value=""
97 >
98 <p className="m-auto px-6 text-center text-muted-foreground text-sm">
99 Open a project to search its files.
100 </p>
101 </CommandPaletteContent>
102 );
103}
104
105function OpenContentSearchDialog(props: {
106 readonly onOpenChange: (open: boolean) => void;
107 readonly target: ActiveProjectTarget;
108}) {
109 const { target } = props;
110 const { resolvedTheme } = useTheme();
111 const [query, setQuery] = useState("");
112 const [caseSensitive, setCaseSensitive] = useState(false);
113 const [wholeWord, setWholeWord] = useState(false);
114 const [useRegex, setUseRegex] = useState(false);
115 const [selectedIndex, setSelectedIndex] = useState(0);
116
117 const [visibleCount, setVisibleCount] = useState(VISIBLE_MATCH_WINDOW);
118
119 const search = useProjectContentSearch({
120 environmentId: target.environmentId,
121 cwd: target.cwd,
122 query,
123 caseSensitive,
124 wholeWord,
125 useRegex,
126 });
127 const canOpenMatches = search.canReadFiles && !search.isPending && search.error === null;
128 const matches = search.matches;
129 const visibleMatches = useMemo(() => matches.slice(0, visibleCount), [matches, visibleCount]);
130 const groups = useMemo(() => groupMatches(visibleMatches), [visibleMatches]);
131
132 useEffect(() => {
133 setSelectedIndex(0);
134 setVisibleCount(VISIBLE_MATCH_WINDOW);
135 }, [matches]);
136
137 useEffect(() => {
138 if (selectedIndex >= visibleCount) {
139 setVisibleCount(selectedIndex + VISIBLE_MATCH_WINDOW);
140 return;
141 }
142 document
143 .querySelector<HTMLElement>(`[data-content-search-result="${selectedIndex}"]`)
144 ?.scrollIntoView({ block: "nearest" });
145 }, [selectedIndex, visibleCount]);
146
147 const observeLoadMoreSentinel = useCallback((sentinel: HTMLElement | null) => {
148 if (!sentinel) return;
149 const observer = new IntersectionObserver((entries) => {
150 if (entries.some((entry) => entry.isIntersecting)) {
151 setVisibleCount((current) => current + VISIBLE_MATCH_WINDOW);
152 }
153 });
154 observer.observe(sentinel);
155 return () => observer.disconnect();
156 }, []);
157
158 const openMatch = (match: ProjectContentMatch) => {
159 if (!canOpenMatches || !readEnvironmentScope(target.environmentId, AuthFilesystemReadScope)) {
160 return;
161 }
162 props.onOpenChange(false);
163 useRightPanelStore.getState().openFile(target.threadRef, match.path, match.lineNumber);
164 };
165 const fileCount = useMemo(() => new Set(matches.map((match) => match.path)).size, [matches]);
166 const showSearchStatus =
167 search.canReadFiles &&
168 (search.hasQuery || search.isPending || search.error !== null || search.invalidRegex);
169
170 return (
171 <CommandPaletteContent
172 aria-label={`Search file contents in ${target.projectName}`}
173 escapeLabel="Back"
174 footerActionLabel="Open file"
175 inputAccessory={
176 <div className="absolute inset-e-2.5 top-1/2 flex shrink-0 -translate-y-1/2 items-center gap-0.5 rounded-lg border bg-muted/30 p-0.5">
177 <SearchOptionButton
178 active={caseSensitive}
179 disabled={!search.canReadFiles}
180 label="Match case"
181 onClick={() => setCaseSensitive((current) => !current)}
182 >
183 Aa
184 </SearchOptionButton>
185 <SearchOptionButton
186 active={wholeWord}
187 disabled={!search.canReadFiles}
188 label="Match whole word"
189 onClick={() => setWholeWord((current) => !current)}
190 >
191 <span className="underline decoration-2 underline-offset-2">ab</span>
192 </SearchOptionButton>
193 <SearchOptionButton
194 active={useRegex}
195 disabled={!search.canReadFiles}
196 label="Use regular expression"
197 onClick={() => setUseRegex((current) => !current)}
198 >
199 .*
200 </SearchOptionButton>
201 </div>
202 }
203 inputProps={{
204 className: "pe-30",
205 disabled: !search.canReadFiles,
206 placeholder: `Search in ${target.projectName}`,
207 onKeyDown: (event) => {
208 if (event.key === "ArrowDown" && matches.length > 0) {
209 event.preventDefault();
210 setSelectedIndex((current) => (current + 1) % matches.length);
211 } else if (event.key === "ArrowUp" && matches.length > 0) {
212 event.preventDefault();
213 setSelectedIndex((current) => (current - 1 + matches.length) % matches.length);
214 } else if (event.key === "Enter") {
215 // While a newer query is debouncing or in flight, the visible
216 // matches belong to the previous query; opening one would jump
217 // to a result the user did not ask for.
218 if (!canOpenMatches) {
219 event.preventDefault();
220 return;
221 }
222 const match = matches[selectedIndex];
223 if (match) {
224 event.preventDefault();
225 openMatch(match);
226 }
227 }
228 },
229 }}
230 mode="none"
231 onValueChange={setQuery}
232 panelSize="fill"
233 testId="project-content-search"
234 value={query}
235 >
236 {showSearchStatus ? (
237 <div className="flex h-9 shrink-0 items-center border-b px-3 text-xs text-muted-foreground">
238 {search.isPending ? (
239 <span className="flex items-center gap-2">
240 <Spinner size="sm" /> Searching…
241 </span>
242 ) : search.error ? (
243 <span className="text-destructive">{search.error}</span>
244 ) : search.invalidRegex ? (
245 <span className="text-destructive">Invalid regular expression</span>
246 ) : (
247 `${matches.length.toLocaleString()}${search.truncated ? "+" : ""} results in ${fileCount.toLocaleString()} files`
248 )}
249 </div>
250 ) : null}
251
252 {matches.length === 0 ? (
253 <div className="flex flex-1 items-center justify-center px-6 text-center text-sm text-muted-foreground">
254 {search.isCheckingAccess
255 ? "Checking file access…"
256 : !search.canReadFiles
257 ? search.error
258 : search.hasQuery && !search.isPending && !search.error
259 ? "No results found."
260 : "Type to search across your project."}
261 </div>
262 ) : (
263 <ScrollArea className="min-h-0 flex-1" scrollFade>
264 <div className="py-2">
265 {groups.map((group) => {
266 const path = splitPath(group.path);
267 return (
268 <section className="pb-2" key={group.path}>
269 <div className="sticky top-0 z-10 flex h-8 items-center gap-2 bg-popover/95 px-3 text-xs backdrop-blur-sm">
270 <PierreEntryIcon
271 pathValue={group.path}
272 kind="file"
273 theme={resolvedTheme}
274 className="size-3.5"
275 />
276 <span className="font-medium text-foreground">{path.name}</span>
277 {path.directory ? (
278 <span className="min-w-0 truncate text-muted-foreground">
279 {path.directory}
280 </span>
281 ) : null}
282 <span className="ml-auto rounded-full bg-muted px-1.5 py-0.5 tabular-nums text-3xs text-muted-foreground">
283 {group.matches.length}
284 </span>
285 </div>
286 {group.matches.map((match) => (
287 <button
288 type="button"
289 key={`${match.path}:${match.lineNumber}:${match.resultIndex}`}
290 data-content-search-result={match.resultIndex}
291 className={cn(
292 "flex h-7 w-full min-w-0 items-center gap-3 px-3 text-left font-mono text-xs hover:bg-accent/60 disabled:pointer-events-none",
293 match.resultIndex === selectedIndex && "bg-accent text-accent-foreground",
294 )}
295 disabled={!canOpenMatches}
296 onMouseEnter={() => setSelectedIndex(match.resultIndex)}
297 onClick={() => openMatch(match)}
298 >
299 <span className="w-10 shrink-0 text-right tabular-nums text-muted-foreground/70">
300 {match.lineNumber}
301 </span>
302 <span className="min-w-0 flex-1 truncate whitespace-pre">
303 <HighlightedSearchLine
304 match={match}
305 path={group.path}
306 theme={resolvedTheme}
307 />
308 </span>
309 </button>
310 ))}
311 </section>
312 );
313 })}
314 {matches.length > visibleCount ? (
315 <div ref={observeLoadMoreSentinel} className="h-8" aria-hidden="true" />
316 ) : null}
317 </div>
318 </ScrollArea>
319 )}
320 </CommandPaletteContent>
321 );
322}
323
324export function ProjectContentSearchDialog(props: ProjectContentSearchDialogProps) {
325 const target = useActiveProjectTarget();
326
327 return target ? (
328 <OpenContentSearchDialog
329 // Reset the query and options whenever the workspace changes.
330 key={`${target.environmentId}:${target.cwd}`}
331 onOpenChange={props.onOpenChange}
332 target={target}
333 />
334 ) : (
335 <EmptyContentSearchDialog />
336 );
337}