| 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { useMemo, useState, type ReactNode } from "react"; |
| 3 | |
| 4 | import { useActiveProjectTarget, type ActiveProjectTarget } from "~/hooks/useActiveProjectTarget"; |
| 5 | import { useTheme } from "~/hooks/useTheme"; |
| 6 | import { useRightPanelStore } from "~/rightPanelStore"; |
| 7 | import { primaryServerKeybindingsAtom } from "~/state/server"; |
| 8 | |
| 9 | import { PierreEntryIcon } from "../chat/PierreEntryIcon"; |
| 10 | import { CommandPaletteContent } from "../CommandPaletteContent"; |
| 11 | import { type CommandPaletteActionItem } from "../CommandPalette.logic"; |
| 12 | import { CommandPaletteResults } from "../CommandPaletteResults"; |
| 13 | import { |
| 14 | getProjectFilePickerMatches, |
| 15 | PROJECT_FILE_PICKER_RESULT_LIMIT, |
| 16 | } from "./ProjectFilePicker.logic"; |
| 17 | import { useProjectFilePickerQuery } from "./projectFilesQueryState"; |
| 18 | |
| 19 | interface ProjectFilePickerProps { |
| 20 | readonly setOpen: (open: boolean) => void; |
| 21 | } |
| 22 | |
| 23 | function 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 | |
| 46 | function 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 | |
| 53 | function 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 | |
| 71 | function 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 | |
| 155 | export 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 | } |