import { Spinner } from "~/components/ui/spinner"; import type { ProjectContentMatch } from "@t3tools/contracts"; import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { useActiveProjectTarget, type ActiveProjectTarget } from "~/hooks/useActiveProjectTarget"; import { useTheme } from "~/hooks/useTheme"; import { cn } from "~/lib/utils"; import { useRightPanelStore } from "~/rightPanelStore"; import { useProjectContentSearch } from "~/state/queries"; import { PierreEntryIcon } from "../chat/PierreEntryIcon"; import { CommandPaletteContent } from "../CommandPaletteContent"; import { ScrollArea } from "../ui/scroll-area"; import { Toggle } from "../ui/toggle"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { HighlightedSearchLine } from "./HighlightedSearchLine"; interface ProjectContentSearchDialogProps { readonly onOpenChange: (open: boolean) => void; } /** * Result rows are syntax highlighted, so mounting all 500 possible rows at * once stalls the UI. Rows render in windows that grow as the sentinel at the * bottom of the list scrolls into view (or keyboard navigation moves past * the rendered window). */ const VISIBLE_MATCH_WINDOW = 100; interface MatchGroup { readonly path: string; readonly matches: ReadonlyArray; } function splitPath(path: string): { readonly name: string; readonly directory: string } { const separator = path.lastIndexOf("/"); return separator === -1 ? { name: path, directory: "" } : { name: path.slice(separator + 1), directory: path.slice(0, separator) }; } function groupMatches(matches: ReadonlyArray): MatchGroup[] { const groups = new Map>(); matches.forEach((match, resultIndex) => { const group = groups.get(match.path); const indexedMatch = { ...match, resultIndex }; if (group) { group.push(indexedMatch); } else { groups.set(match.path, [indexedMatch]); } }); return [...groups].map(([path, groupedMatches]) => ({ path, matches: groupedMatches })); } function SearchOptionButton(props: { readonly active: boolean; readonly label: string; readonly onClick: () => void; readonly children: ReactNode; }) { return ( } > {props.children} {props.label} ); } function EmptyContentSearchDialog() { return ( Open a project to search its files. ); } function OpenContentSearchDialog(props: { readonly onOpenChange: (open: boolean) => void; readonly target: ActiveProjectTarget; }) { const { target } = props; const { resolvedTheme } = useTheme(); const [query, setQuery] = useState(""); const [caseSensitive, setCaseSensitive] = useState(false); const [wholeWord, setWholeWord] = useState(false); const [useRegex, setUseRegex] = useState(false); const [selectedIndex, setSelectedIndex] = useState(0); const [visibleCount, setVisibleCount] = useState(VISIBLE_MATCH_WINDOW); const search = useProjectContentSearch({ environmentId: target.environmentId, cwd: target.cwd, query, caseSensitive, wholeWord, useRegex, }); const canOpenMatches = !search.isPending; const matches = search.matches; const visibleMatches = useMemo(() => matches.slice(0, visibleCount), [matches, visibleCount]); const groups = useMemo(() => groupMatches(visibleMatches), [visibleMatches]); useEffect(() => { setSelectedIndex(0); setVisibleCount(VISIBLE_MATCH_WINDOW); }, [matches]); useEffect(() => { if (selectedIndex >= visibleCount) { setVisibleCount(selectedIndex + VISIBLE_MATCH_WINDOW); return; } document .querySelector(`[data-content-search-result="${selectedIndex}"]`) ?.scrollIntoView({ block: "nearest" }); }, [selectedIndex, visibleCount]); const observeLoadMoreSentinel = useCallback((sentinel: HTMLElement | null) => { if (!sentinel) return; const observer = new IntersectionObserver((entries) => { if (entries.some((entry) => entry.isIntersecting)) { setVisibleCount((current) => current + VISIBLE_MATCH_WINDOW); } }); observer.observe(sentinel); return () => observer.disconnect(); }, []); const openMatch = (match: ProjectContentMatch) => { if (!canOpenMatches) return; props.onOpenChange(false); useRightPanelStore.getState().openFile(target.threadRef, match.path, match.lineNumber); }; const fileCount = useMemo(() => new Set(matches.map((match) => match.path)).size, [matches]); const showSearchStatus = search.hasQuery || search.isPending || search.error !== null || search.invalidRegex; return ( setCaseSensitive((current) => !current)} > Aa setWholeWord((current) => !current)} > ab setUseRegex((current) => !current)} > .* } inputProps={{ className: "pe-30", placeholder: `Search in ${target.projectName}`, onKeyDown: (event) => { if (event.key === "ArrowDown" && matches.length > 0) { event.preventDefault(); setSelectedIndex((current) => (current + 1) % matches.length); } else if (event.key === "ArrowUp" && matches.length > 0) { event.preventDefault(); setSelectedIndex((current) => (current - 1 + matches.length) % matches.length); } else if (event.key === "Enter") { // While a newer query is debouncing or in flight, the visible // matches belong to the previous query; opening one would jump // to a result the user did not ask for. if (!canOpenMatches) { event.preventDefault(); return; } const match = matches[selectedIndex]; if (match) { event.preventDefault(); openMatch(match); } } }, }} mode="none" onValueChange={setQuery} panelClassName="flex min-h-0 flex-1 flex-col" testId="project-content-search" value={query} > {showSearchStatus ? (
{search.isPending ? ( Searching… ) : search.error ? ( {search.error} ) : search.invalidRegex ? ( Invalid regular expression ) : ( `${matches.length.toLocaleString()}${search.truncated ? "+" : ""} results in ${fileCount.toLocaleString()} files` )}
) : null} {matches.length === 0 ? (
{search.hasQuery && !search.isPending && !search.error ? "No results found." : "Type to search across your project."}
) : (
{groups.map((group) => { const path = splitPath(group.path); return (
{path.name} {path.directory ? ( {path.directory} ) : null} {group.matches.length}
{group.matches.map((match) => ( ))}
); })} {matches.length > visibleCount ? ( )} ); } export function ProjectContentSearchDialog(props: ProjectContentSearchDialogProps) { const target = useActiveProjectTarget(); return target ? ( ) : ( ); }