1import { useAtomValue } from "@effect/atom-react";
2import { useMemo, useState, type ReactNode } from "react";
3
4import { useActiveProjectTarget, type ActiveProjectTarget } from "~/hooks/useActiveProjectTarget";
5import { useTheme } from "~/hooks/useTheme";
6import { useRightPanelStore } from "~/rightPanelStore";
7import { primaryServerKeybindingsAtom } from "~/state/server";
8
9import { PierreEntryIcon } from "../chat/PierreEntryIcon";
10import { CommandPaletteContent } from "../CommandPaletteContent";
11import { type CommandPaletteActionItem } from "../CommandPalette.logic";
12import { CommandPaletteResults } from "../CommandPaletteResults";
13import {
14 getProjectFilePickerMatches,
15 PROJECT_FILE_PICKER_RESULT_LIMIT,
16} from "./ProjectFilePicker.logic";
17import { useProjectFilePickerQuery } from "./projectFilesQueryState";
18
19interface ProjectFilePickerProps {
20 readonly setOpen: (open: boolean) => void;
21}
22
23function HighlightedFuzzyText(props: {
24 readonly active: boolean;
25 readonly indices: ReadonlyArray<number>;
26 readonly value: string;
27}) {
28 if (!props.active) return props.value;
29
30 const parts: ReactNode[] = [];
31 let start = 0;
32 for (const index of props.indices) {
33 if (start < index) parts.push(props.value.slice(start, index));
34 parts.push(
35 <strong className="font-semibold text-foreground" key={index}>
36 {props.value[index]}
37 </strong>,
38 );
39 start = index + 1;
40 }
41 if (start < props.value.length) parts.push(props.value.slice(start));
42
43 return <span className="text-muted-foreground">{parts}</span>;
44}
45
46function getEmptyStateMessage(query: string, error: string | null, isPending: boolean): string {
47 if (error) return error;
48 const isSearching = query.trim().length > 0;
49 if (isPending) return isSearching ? "Searching workspace files…" : "Indexing workspace files…";
50 return isSearching ? "No matching files." : "No files found.";
51}
52
53function EmptyProjectFilePicker() {
54 return (
55 <CommandPaletteContent
56 aria-label="File picker"
57 escapeLabel="Back"
58 footerActionLabel="Open file"
59 inputProps={{ disabled: true, placeholder: "Search files…" }}
60 mode="none"
61 testId="project-file-picker"
62 value=""
63 >
64 <div className="py-10 text-center text-sm text-muted-foreground">
65 Open a project to search its files.
66 </div>
67 </CommandPaletteContent>
68 );
69}
70
71function OpenProjectFilePicker(props: ProjectFilePickerProps & { target: ActiveProjectTarget }) {
72 const { target } = props;
73 const [query, setQuery] = useState("");
74 const [highlightedItemValue, setHighlightedItemValue] = useState<string | null>(null);
75 const result = useProjectFilePickerQuery(
76 target.environmentId,
77 target.cwd,
78 query,
79 PROJECT_FILE_PICKER_RESULT_LIMIT,
80 );
81 const { resolvedTheme } = useTheme();
82 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
83 const matches = useMemo(
84 () => getProjectFilePickerMatches(result.entries, result.matchedQuery),
85 [result.entries, result.matchedQuery],
86 );
87 const hasMatchedQuery = /\S/.test(result.matchedQuery);
88 const items = useMemo<CommandPaletteActionItem[]>(
89 () =>
90 matches.map((match) => ({
91 kind: "action",
92 value: `file:${match.path}`,
93 searchTerms: [match.name, match.path],
94 title: (
95 <HighlightedFuzzyText
96 active={hasMatchedQuery}
97 value={match.name}
98 indices={match.nameMatchIndices}
99 />
100 ),
101 description: (
102 <HighlightedFuzzyText
103 active={hasMatchedQuery}
104 value={match.path}
105 indices={match.pathMatchIndices}
106 />
107 ),
108 icon: <PierreEntryIcon pathValue={match.path} kind="file" theme={resolvedTheme} />,
109 run: async () => {
110 useRightPanelStore.getState().openFile(target.threadRef, match.path);
111 },
112 })),
113 [hasMatchedQuery, matches, resolvedTheme, target.threadRef],
114 );
115
116 const emptyStateMessage = getEmptyStateMessage(query, result.error, result.isPending);
117
118 return (
119 <CommandPaletteContent
120 aria-label="File picker"
121 autoHighlight="always"
122 escapeLabel="Back"
123 footerActionLabel="Open file"
124 inputProps={{ placeholder: "Search files…" }}
125 mode="none"
126 onItemHighlighted={(value) => {
127 setHighlightedItemValue(typeof value === "string" ? value : null);
128 }}
129 onValueChange={(value) => {
130 setHighlightedItemValue(null);
131 setQuery(value);
132 }}
133 panelSize="tall-list"
134 testId="project-file-picker"
135 value={query}
136 >
137 <CommandPaletteResults
138 groups={
139 items.length > 0 ? [{ value: "project-files", label: target.projectName, items }] : []
140 }
141 highlightedItemValue={highlightedItemValue}
142 isActionsOnly={false}
143 keybindings={keybindings}
144 onExecuteItem={(item) => {
145 if (item.kind !== "action") return;
146 props.setOpen(false);
147 void item.run();
148 }}
149 emptyStateMessage={emptyStateMessage}
150 />
151 </CommandPaletteContent>
152 );
153}
154
155export function ProjectFilePicker(props: ProjectFilePickerProps) {
156 const target = useActiveProjectTarget();
157
158 if (!target) {
159 return <EmptyProjectFilePicker />;
160 }
161
162 return <OpenProjectFilePicker setOpen={props.setOpen} target={target} />;
163}