| 1 | import { type ResolvedKeybindingsConfig } from "@t3tools/contracts"; |
| 2 | import { LegendList, type LegendListRef } from "@legendapp/list/react"; |
| 3 | import { ChevronRightIcon } from "lucide-react"; |
| 4 | import { type RefObject, useCallback, useState } from "react"; |
| 5 | import { cn } from "~/lib/utils"; |
| 6 | import { shortcutLabelForCommand } from "../keybindings"; |
| 7 | import { |
| 8 | type CommandPaletteActionItem, |
| 9 | type CommandPaletteGroup, |
| 10 | type CommandPaletteRow, |
| 11 | type CommandPaletteSubmenuItem, |
| 12 | } from "./CommandPalette.logic"; |
| 13 | import { |
| 14 | CommandCollection, |
| 15 | CommandGroup, |
| 16 | CommandGroupLabel, |
| 17 | CommandItem, |
| 18 | CommandList, |
| 19 | CommandListHeading, |
| 20 | CommandListVirtualized, |
| 21 | CommandShortcut, |
| 22 | } from "./ui/command"; |
| 23 | import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area"; |
| 24 | import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch"; |
| 25 | |
| 26 | interface 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 | |
| 35 | function 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 | |
| 46 | export 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 | */ |
| 82 | export 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 | |
| 91 | interface 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 | */ |
| 101 | export 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 | |
| 158 | function 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 | |
| 190 | function 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 | } |