1import { startPaletteThreadDrag } from "./CommandPalette.drag";
2import { useLegendListRef } from "~/hooks/useLegendListRef";
3import { type ResolvedKeybindingsConfig } from "@t3tools/contracts";
4import { LegendList, type LegendListRef } from "@legendapp/list/react";
5import { ChevronRightIcon } from "lucide-react";
6import { type ReactNode, type RefObject, useState } from "react";
7import { cn } from "~/lib/utils";
8import { textDirection } from "~/textDirection";
9import { shortcutLabelForCommand } from "../keybindings";
10import {
11 type CommandPaletteActionItem,
12 type CommandPaletteGroup,
13 type CommandPaletteRow,
14 type CommandPaletteSubmenuItem,
15} from "./CommandPalette.logic";
16import {
17 CommandCollection,
18 CommandGroup,
19 CommandGroupLabel,
20 CommandItem,
21 CommandList,
22 CommandListHeading,
23 CommandListVirtualized,
24 CommandShortcut,
25} from "./ui/command";
26import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area";
27import { MENU_ROW_TEXT_CLASS } from "./ui/menu-row-text";
28import { ProjectPinButton } from "./ProjectPinButton";
29import { SearchMatchHintLine, ThreadSearchMatchExcerpt } from "./ThreadSearchMatch";
30import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
31
32type CommandPaletteResultItem = CommandPaletteActionItem | CommandPaletteSubmenuItem;
33
34interface CommandPaletteResultsProps {
35 emptyStateMessage?: string;
36 groups: ReadonlyArray<CommandPaletteGroup>;
37 highlightedItemValue?: string | null;
38 isActionsOnly: boolean;
39 keybindings: ResolvedKeybindingsConfig;
40 onExecuteItem: (item: CommandPaletteResultItem) => void;
41}
42
43function CommandPaletteEmptyState(props: { emptyStateMessage?: string; isActionsOnly: boolean }) {
44 return (
45 <div className="py-10 text-center text-sm text-muted-foreground">
46 {props.emptyStateMessage ??
47 (props.isActionsOnly
48 ? "No matching actions."
49 : "No matching commands, projects, or threads.")}
50 </div>
51 );
52}
53
54export function CommandPaletteResults(props: CommandPaletteResultsProps) {
55 if (props.groups.length === 0) {
56 return <CommandPaletteEmptyState {...props} />;
57 }
58
59 return (
60 <CommandList>
61 {props.groups.map((group) => (
62 <CommandGroup items={group.items} key={group.value}>
63 {group.label ? <CommandGroupLabel>{group.label}</CommandGroupLabel> : null}
64 <CommandCollection>
65 {(item) =>
66 item.disabled ? (
67 <DisabledCommandPaletteResultRow item={item} key={item.value} />
68 ) : (
69 <CommandPaletteResultRow
70 item={item}
71 key={item.value}
72 keybindings={props.keybindings}
73 isActive={props.highlightedItemValue === item.value}
74 onExecuteItem={props.onExecuteItem}
75 />
76 )
77 }
78 </CommandCollection>
79 </CommandGroup>
80 ))}
81 </CommandList>
82 );
83}
84
85export function scrollCommandPaletteRowIntoView(
86 list: LegendListRef | null,
87 rowIndex: number,
88 isFirstItem: boolean,
89) {
90 if (isFirstItem) {
91 void list?.scrollToOffset({ offset: 0, animated: false });
92 return;
93 }
94 void list?.scrollIndexIntoView({
95 index: rowIndex,
96 animated: false,
97 viewportMargin: { start: 24, end: 24 },
98 });
99}
100
101interface CommandPaletteVirtualizedResultsProps extends Omit<CommandPaletteResultsProps, "groups"> {
102 rows: ReadonlyArray<CommandPaletteRow>;
103 listRef: RefObject<LegendListRef | null>;
104}
105
106/** Pass flat items with virtualized to Command, scroll highlights via listRef, and handle Enter for
107 * unmounted rows. */
108export function CommandPaletteVirtualizedResults(props: CommandPaletteVirtualizedResultsProps) {
109 const { listRef } = props;
110 const [scrollFade, setScrollFade] = useState({ top: false, bottom: false });
111 const updateScrollFade = () => {
112 const metrics = listRef.current?.getState();
113 if (!metrics) return;
114 const top = !metrics.isAtStart;
115 const bottom = !metrics.isAtEnd;
116 setScrollFade((current) =>
117 current.top === top && current.bottom === bottom ? current : { top, bottom },
118 );
119 };
120 const scrollRef = useLegendListRef(listRef, updateScrollFade);
121
122 if (props.rows.length === 0) {
123 return <CommandPaletteEmptyState {...props} />;
124 }
125
126 return (
127 <CommandListVirtualized>
128 <LegendList<CommandPaletteRow>
129 ref={scrollRef}
130 data={props.rows}
131 initialScrollOffset={0}
132 keyExtractor={(row) => row.key}
133 getItemType={(row) => row.kind}
134 extraData={props.highlightedItemValue}
135 renderItem={({ item: row }) =>
136 row.kind === "label" ? (
137 <div className={row.first ? undefined : "pt-1.5"}>
138 <CommandListHeading>{row.label}</CommandListHeading>
139 </div>
140 ) : row.itemIndex === null ? (
141 <DisabledCommandPaletteResultRow item={row.item} />
142 ) : (
143 <CommandPaletteResultRow
144 index={row.itemIndex}
145 item={row.item}
146 keybindings={props.keybindings}
147 isActive={props.highlightedItemValue === row.item.value}
148 onExecuteItem={props.onExecuteItem}
149 />
150 )
151 }
152 estimatedItemSize={34}
153 drawDistance={400}
154 onLayout={updateScrollFade}
155 onScroll={updateScrollFade}
156 onReady={updateScrollFade}
157 // Legend counts style padding (not class padding) in its size, end state and row positions.
158 style={{ paddingTop: 8, paddingBottom: 8 }}
159 contentContainerClassName="px-1.5"
160 className={cn(
161 "min-h-0 scroll-py-6 overflow-x-hidden overscroll-y-contain [&::-webkit-scrollbar-track]:my-2",
162 getVirtualizedScrollFadeClassName(scrollFade),
163 )}
164 />
165 </CommandListVirtualized>
166 );
167}
168
169export function CommandPaletteTitleIcon(props: { children: ReactNode }) {
170 return <span className="flex h-5 shrink-0 items-center [&_svg]:size-3.5">{props.children}</span>;
171}
172
173function CommandPaletteTitle({
174 item,
175}: {
176 item: Pick<CommandPaletteActionItem | CommandPaletteSubmenuItem, "title">;
177}) {
178 const { title } = item;
179 return (
180 <span className="truncate" dir={typeof title === "string" ? textDirection(title) : undefined}>
181 {title}
182 </span>
183 );
184}
185
186/**
187 * Project and environment for a thread row whose second line shows a message
188 * snippet or match hint instead, so similar rows can still be told apart.
189 */
190function ThreadRowContext({
191 item,
192}: {
193 item: CommandPaletteActionItem | CommandPaletteSubmenuItem;
194}) {
195 if (!item.threadSearch || !(item.threadContentMatch || item.matchHint)) return null;
196 // A "Project: …" hint already names the project.
197 const context = [item.threadSearch.projectTitle, item.threadSearch.environmentLabel].filter(
198 (part) => part && part !== item.matchHint?.text,
199 );
200 return context.length > 0 ? (
201 <Tooltip>
202 <TooltipTrigger
203 render={
204 <span
205 tabIndex={0}
206 className="max-w-[45%] shrink-0 truncate text-dim-foreground text-caption outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
207 />
208 }
209 >
210 {context[0]}
211 </TooltipTrigger>
212 <TooltipPopup>
213 {context.map((part) => (
214 <span className="block" key={part}>
215 {part}
216 </span>
217 ))}
218 </TooltipPopup>
219 </Tooltip>
220 ) : null;
221}
222
223function DisabledCommandPaletteResultRow(props: { item: CommandPaletteResultItem }) {
224 return (
225 <div className="flex min-h-8 select-none items-center gap-2 rounded-control px-2 py-1.5 text-base opacity-64 sm:min-h-7 sm:text-sm">
226 <CommandPaletteRowContent item={props.item} />
227 </div>
228 );
229}
230
231/** Icon, title (with optional description or thread match below it) and trailing title content. */
232function CommandPaletteRowContent({ item }: { item: CommandPaletteResultItem }) {
233 const title = (
234 <>
235 {item.titleLeadingContent}
236 <CommandPaletteTitle item={item} />
237 </>
238 );
239 const stacked = item.threadContentMatch || item.matchHint || item.descriptionBelow;
240 return (
241 <>
242 <CommandPaletteTitleIcon>{item.icon}</CommandPaletteTitleIcon>
243 {item.description || item.threadContentMatch || item.matchHint ? (
244 <span className={cn("flex min-w-0 flex-1", stacked ? "flex-col" : "items-center gap-2")}>
245 <span
246 className={cn(
247 "flex min-w-0 items-center gap-1.5",
248 !stacked && "shrink-0",
249 MENU_ROW_TEXT_CLASS,
250 "text-foreground in-data-[active=true]:text-strong-foreground",
251 )}
252 >
253 {title}
254 <ThreadRowContext item={item} />
255 </span>
256 {item.threadContentMatch ? (
257 <ThreadSearchMatchExcerpt match={item.threadContentMatch} />
258 ) : item.matchHint ? (
259 <SearchMatchHintLine hint={item.matchHint} />
260 ) : item.description ? (
261 <span
262 className={cn(
263 "min-w-0 text-dim-foreground text-meta font-normal",
264 !stacked && "truncate",
265 )}
266 >
267 {item.description}
268 </span>
269 ) : null}
270 </span>
271 ) : (
272 <span
273 className={cn(
274 "flex min-w-0 flex-1 items-center gap-1.5",
275 MENU_ROW_TEXT_CLASS,
276 "text-foreground in-data-[active=true]:text-strong-foreground",
277 )}
278 >
279 {title}
280 </span>
281 )}
282 {item.titleTrailingContent}
283 </>
284 );
285}
286
287function CommandPaletteResultRow(props: {
288 index?: number;
289 item: CommandPaletteResultItem;
290 isActive: boolean;
291 keybindings: ResolvedKeybindingsConfig;
292 onExecuteItem: (item: CommandPaletteResultItem) => void;
293}) {
294 const shortcutLabel = props.item.shortcutCommand
295 ? shortcutLabelForCommand(props.keybindings, props.item.shortcutCommand)
296 : null;
297
298 return (
299 <CommandItem
300 {...(props.index === undefined ? {} : { index: props.index })}
301 value={props.item.value}
302 active={props.isActive}
303 onPointerDown={(event) => {
304 if (props.item.threadRef) {
305 startPaletteThreadDrag(
306 event.nativeEvent,
307 props.item.threadRef,
308 props.item.searchTitle ??
309 (typeof props.item.title === "string" ? props.item.title : "Thread"),
310 );
311 }
312 }}
313 onMouseDown={(event) => {
314 event.preventDefault();
315 }}
316 onClick={() => {
317 props.onExecuteItem(props.item);
318 }}
319 >
320 <CommandPaletteRowContent item={props.item} />
321 {props.item.kind === "action" && props.item.pin ? (
322 <ProjectPinButton
323 name={props.item.pin.name}
324 pinned={props.item.pin.pinned}
325 onToggle={props.item.pin.toggle}
326 shortcutLabel={shortcutLabelForCommand(props.keybindings, "thread.pin")}
327 />
328 ) : null}
329 {props.item.timestamp ? (
330 <span className="min-w-12 shrink-0 text-right text-xs tabular-nums text-dim-foreground">
331 {props.item.timestamp}
332 </span>
333 ) : null}
334 {shortcutLabel ? <CommandShortcut>{shortcutLabel}</CommandShortcut> : null}
335 {props.item.kind === "submenu" ? (
336 <ChevronRightIcon className="-me-0.5 ms-auto size-3.5 shrink-0 text-dim-foreground" />
337 ) : null}
338 </CommandItem>
339 );
340}