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