1import { type ResolvedKeybindingsConfig } from "@t3tools/contracts";
2import { LegendList, type LegendListRef } from "@legendapp/list/react";
3import { ChevronRightIcon } from "lucide-react";
4import { type RefObject, useCallback, useState } from "react";
5import { cn } from "~/lib/utils";
6import { shortcutLabelForCommand } from "../keybindings";
7import {
8 type CommandPaletteActionItem,
9 type CommandPaletteGroup,
10 type CommandPaletteRow,
11 type CommandPaletteSubmenuItem,
12} from "./CommandPalette.logic";
13import {
14 CommandCollection,
15 CommandGroup,
16 CommandGroupLabel,
17 CommandItem,
18 CommandList,
19 CommandListHeading,
20 CommandListVirtualized,
21 CommandShortcut,
22} from "./ui/command";
23import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area";
24import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch";
25
26interface CommandPaletteResultsProps {
27 emptyStateMessage?: string;
28 groups: ReadonlyArray<CommandPaletteGroup>;
29 highlightedItemValue?: string | null;
30 isActionsOnly: boolean;
31 keybindings: ResolvedKeybindingsConfig;
32 onExecuteItem: (item: CommandPaletteActionItem | CommandPaletteSubmenuItem) => void;
33}
34
35function CommandPaletteEmptyState(props: { emptyStateMessage?: string; isActionsOnly: boolean }) {
36 return (
37 <div className="py-10 text-center text-sm text-muted-foreground">
38 {props.emptyStateMessage ??
39 (props.isActionsOnly
40 ? "No matching actions."
41 : "No matching commands, projects, or threads.")}
42 </div>
43 );
44}
45
46export function CommandPaletteResults(props: CommandPaletteResultsProps) {
47 if (props.groups.length === 0) {
48 return <CommandPaletteEmptyState {...props} />;
49 }
50
51 return (
52 <CommandList>
53 {props.groups.map((group) => (
54 <CommandGroup items={group.items} key={group.value}>
55 {group.label ? <CommandGroupLabel>{group.label}</CommandGroupLabel> : null}
56 <CommandCollection>
57 {(item) =>
58 item.disabled ? (
59 <DisabledCommandPaletteResultRow item={item} key={item.value} />
60 ) : (
61 <CommandPaletteResultRow
62 item={item}
63 key={item.value}
64 keybindings={props.keybindings}
65 isActive={props.highlightedItemValue === item.value}
66 onExecuteItem={props.onExecuteItem}
67 />
68 )
69 }
70 </CommandCollection>
71 </CommandGroup>
72 ))}
73 </CommandList>
74 );
75}
76
77/**
78 * Scrolls a keyboard highlight into view the way the unvirtualized list did:
79 * nearest edge, clear of the scroll fade. Rows outside the rendered window
80 * fall back to the list's own scrolling.
81 */
82export function scrollCommandPaletteRowIntoView(list: LegendListRef | null, rowIndex: number) {
83 const element = list?.getState?.().elementAtIndex(rowIndex);
84 if (element instanceof HTMLElement) {
85 element.scrollIntoView({ block: "nearest" });
86 return;
87 }
88 void list?.scrollIndexIntoView?.({ index: rowIndex, animated: false });
89}
90
91interface CommandPaletteVirtualizedResultsProps extends Omit<CommandPaletteResultsProps, "groups"> {
92 rows: ReadonlyArray<CommandPaletteRow>;
93 listRef: RefObject<LegendListRef | null>;
94}
95
96/**
97 * Renders only the visible rows. The parent passes the flat item order to the
98 * Command root as `items` with `virtualized`, scrolls keyboard highlights into
99 * view through `listRef`, and runs Enter itself since the row may be unmounted.
100 */
101export function CommandPaletteVirtualizedResults(props: CommandPaletteVirtualizedResultsProps) {
102 const { listRef } = props;
103 const [scrollFade, setScrollFade] = useState({ top: false, bottom: false });
104 const updateScrollFade = useCallback(() => {
105 const scrollElement = listRef.current?.getScrollableNode?.();
106 if (!(scrollElement instanceof HTMLElement)) return;
107 const top = scrollElement.scrollTop > 1;
108 const bottom =
109 scrollElement.scrollHeight - scrollElement.clientHeight - scrollElement.scrollTop > 1;
110 setScrollFade((current) =>
111 current.top === top && current.bottom === bottom ? current : { top, bottom },
112 );
113 }, [listRef]);
114
115 if (props.rows.length === 0) {
116 return <CommandPaletteEmptyState {...props} />;
117 }
118
119 return (
120 <CommandListVirtualized>
121 <LegendList<CommandPaletteRow>
122 ref={listRef}
123 data={props.rows}
124 keyExtractor={(row) => row.key}
125 getItemType={(row) => row.kind}
126 extraData={props.highlightedItemValue}
127 renderItem={({ item: row }) =>
128 row.kind === "label" ? (
129 <div className={row.first ? undefined : "pt-1.5"}>
130 <CommandListHeading>{row.label}</CommandListHeading>
131 </div>
132 ) : row.itemIndex === null ? (
133 <DisabledCommandPaletteResultRow item={row.item} />
134 ) : (
135 <CommandPaletteResultRow
136 index={row.itemIndex}
137 item={row.item}
138 keybindings={props.keybindings}
139 isActive={props.highlightedItemValue === row.item.value}
140 onExecuteItem={props.onExecuteItem}
141 />
142 )
143 }
144 estimatedItemSize={40}
145 drawDistance={400}
146 onLayout={updateScrollFade}
147 onScroll={updateScrollFade}
148 contentContainerClassName="px-2"
149 className={cn(
150 "min-h-0 scroll-py-6 overflow-x-hidden overscroll-y-contain py-2 [&::-webkit-scrollbar-track]:my-2",
151 getVirtualizedScrollFadeClassName(scrollFade),
152 )}
153 />
154 </CommandListVirtualized>
155 );
156}
157
158function DisabledCommandPaletteResultRow(props: {
159 item: CommandPaletteActionItem | CommandPaletteSubmenuItem;
160}) {
161 return (
162 <div className="flex min-h-8 select-none items-center gap-2 rounded-sm px-2 py-1.5 text-base opacity-64 sm:min-h-7 sm:text-sm">
163 {props.item.icon}
164 {props.item.description || props.item.threadContentMatch ? (
165 <span className="flex min-w-0 flex-1 flex-col">
166 <span className="flex min-w-0 items-center gap-1.5 text-sm text-foreground">
167 {props.item.titleLeadingContent}
168 <span className="truncate">{props.item.title}</span>
169 </span>
170 {props.item.threadContentMatch ? (
171 <ThreadSearchMatchExcerpt match={props.item.threadContentMatch} />
172 ) : null}
173 {props.item.description ? (
174 <span className="min-w-0 text-muted-foreground/70 text-xs">
175 {props.item.description}
176 </span>
177 ) : null}
178 </span>
179 ) : (
180 <span className="flex min-w-0 flex-1 items-center gap-1.5 text-sm text-foreground">
181 {props.item.titleLeadingContent}
182 <span className="truncate">{props.item.title}</span>
183 </span>
184 )}
185 {props.item.titleTrailingContent}
186 </div>
187 );
188}
189
190function CommandPaletteResultRow(props: {
191 index?: number;
192 item: CommandPaletteActionItem | CommandPaletteSubmenuItem;
193 isActive: boolean;
194 keybindings: ResolvedKeybindingsConfig;
195 onExecuteItem: (item: CommandPaletteActionItem | CommandPaletteSubmenuItem) => void;
196}) {
197 const shortcutLabel = props.item.shortcutCommand
198 ? shortcutLabelForCommand(props.keybindings, props.item.shortcutCommand)
199 : null;
200
201 return (
202 <CommandItem
203 {...(props.index === undefined ? {} : { index: props.index })}
204 value={props.item.value}
205 active={props.isActive}
206 onMouseDown={(event) => {
207 event.preventDefault();
208 }}
209 onClick={() => {
210 props.onExecuteItem(props.item);
211 }}
212 >
213 {props.item.icon}
214 {props.item.description || props.item.threadContentMatch ? (
215 <span className="flex min-w-0 flex-1 flex-col">
216 <span className="flex min-w-0 items-center gap-1.5 text-sm text-foreground">
217 {props.item.titleLeadingContent}
218 <span className="truncate">{props.item.title}</span>
219 </span>
220 {props.item.threadContentMatch ? (
221 <ThreadSearchMatchExcerpt match={props.item.threadContentMatch} />
222 ) : null}
223 {props.item.description ? (
224 <span className="min-w-0 text-muted-foreground/70 text-xs">
225 {props.item.description}
226 </span>
227 ) : null}
228 </span>
229 ) : (
230 <span className="flex min-w-0 flex-1 items-center gap-1.5 text-sm text-foreground">
231 {props.item.titleLeadingContent}
232 <span className="truncate">{props.item.title}</span>
233 </span>
234 )}
235 {props.item.titleTrailingContent}
236 {props.item.timestamp ? (
237 <span className="min-w-12 shrink-0 text-right text-xs tabular-nums text-muted-foreground/70">
238 {props.item.timestamp}
239 </span>
240 ) : null}
241 {shortcutLabel ? <CommandShortcut>{shortcutLabel}</CommandShortcut> : null}
242 {props.item.kind === "submenu" ? (
243 <ChevronRightIcon className="-me-0.5 ms-auto size-4 shrink-0 text-muted-foreground/70" />
244 ) : null}
245 </CommandItem>
246 );
247}