| author | |
| committer | |
| log | fffe6e6c82c12769e0cbc4d89a23ec845c5abed1 |
| tree | 7470f9cac23c4e224933e81d1868e89d1ff5f44d |
| parent | 71dbaf1f94b580491d033f2e347a9b1c507fd793 |
| signature | Signed by PGP key B5690EEEBB952194 |
8 files changed, 400 insertions(+), 19 deletions(-)
apps/web/src/components/CommandPalette.logic.test.ts+45| ... | ... | @@ -5,12 +5,14 @@ import { makeThreadFixture } from "../test-fixtures"; |
| 5 | 5 | import { |
| 6 | 6 | buildBrowseGroups, |
| 7 | 7 | buildCommandPaletteProjectMetadata, |
| 8 | buildCommandPaletteRows, | |
| 8 | 9 | buildProjectActionItems, |
| 9 | 10 | buildThreadActionItems, |
| 10 | 11 | buildLinkedThreadActionItems, |
| 11 | 12 | enumerateCommandPaletteItems, |
| 12 | 13 | filterPinnedBrowseEntries, |
| 13 | 14 | filterCommandPaletteGroups, |
| 15 | findHighlightedCommandPaletteItem, | |
| 14 | 16 | reduceCommandPaletteUiState, |
| 15 | 17 | type CommandPaletteActionItem, |
| 16 | 18 | type CommandPaletteGroup, |
| ... | ... | @@ -849,3 +851,46 @@ describe("filterCommandPaletteGroups", () => { |
| 849 | 851 | ]); |
| 850 | 852 | }); |
| 851 | 853 | }); |
| 854 | ||
| 855 | describe("virtualized command palette rows", () => { | |
| 856 | const action = (value: string, disabled = false): CommandPaletteActionItem => ({ | |
| 857 | kind: "action", | |
| 858 | value, | |
| 859 | searchTerms: [], | |
| 860 | title: value, | |
| 861 | icon: null, | |
| 862 | ...(disabled ? { disabled } : {}), | |
| 863 | run: async () => {}, | |
| 864 | }); | |
| 865 | const groups: CommandPaletteGroup[] = [ | |
| 866 | { value: "actions", label: "Actions", items: [action("new-thread"), action("offline", true)] }, | |
| 867 | { value: "threads", label: "Threads", items: [action("thread-a"), action("thread-b")] }, | |
| 868 | ]; | |
| 869 | ||
| 870 | it("keeps group order and headings while indexing only enabled items", () => { | |
| 871 | const { rows, itemValues, rowIndexByItemIndex } = buildCommandPaletteRows(groups); | |
| 872 | ||
| 873 | expect(rows.map((row) => (row.kind === "label" ? `# ${row.label}` : row.key))).toEqual([ | |
| 874 | "# Actions", | |
| 875 | "actions:new-thread", | |
| 876 | "actions:offline", | |
| 877 | "# Threads", | |
| 878 | "threads:thread-a", | |
| 879 | "threads:thread-b", | |
| 880 | ]); | |
| 881 | expect(itemValues).toEqual(["new-thread", "thread-a", "thread-b"]); | |
| 882 | expect(rowIndexByItemIndex).toEqual([1, 4, 5]); | |
| 883 | expect(rows.flatMap((row) => (row.kind === "item" ? [row.itemIndex] : []))).toEqual([ | |
| 884 | 0, | |
| 885 | null, | |
| 886 | 1, | |
| 887 | 2, | |
| 888 | ]); | |
| 889 | }); | |
| 890 | ||
| 891 | it("resolves Enter to the highlighted item without needing its row mounted", () => { | |
| 892 | expect(findHighlightedCommandPaletteItem(groups, "thread-b")?.value).toBe("thread-b"); | |
| 893 | expect(findHighlightedCommandPaletteItem(groups, "offline")).toBeNull(); | |
| 894 | expect(findHighlightedCommandPaletteItem(groups, null)).toBeNull(); | |
| 895 | }); | |
| 896 | }); |
apps/web/src/components/CommandPalette.logic.ts+59| ... | ... | @@ -172,6 +172,65 @@ export interface CommandPaletteView { |
| 172 | 172 | readonly initialQuery?: string; |
| 173 | 173 | } |
| 174 | 174 | |
| 175 | export type CommandPaletteRow = | |
| 176 | | { | |
| 177 | readonly kind: "label"; | |
| 178 | readonly key: string; | |
| 179 | readonly label: string; | |
| 180 | readonly first: boolean; | |
| 181 | } | |
| 182 | | { | |
| 183 | readonly kind: "item"; | |
| 184 | readonly key: string; | |
| 185 | readonly item: CommandPaletteActionItem | CommandPaletteSubmenuItem; | |
| 186 | /** Position among enabled items, or null for disabled rows the keyboard skips. */ | |
| 187 | readonly itemIndex: number | null; | |
| 188 | }; | |
| 189 | ||
| 190 | /** | |
| 191 | * Flattens groups into the rows a virtualized list renders. `itemValues` is the | |
| 192 | * highlightable item order Base UI navigates; `rowIndexByItemIndex` maps a | |
| 193 | * highlight back to its row for scrolling. | |
| 194 | */ | |
| 195 | export function buildCommandPaletteRows(groups: ReadonlyArray<CommandPaletteGroup>) { | |
| 196 | const rows: CommandPaletteRow[] = []; | |
| 197 | const itemValues: string[] = []; | |
| 198 | const rowIndexByItemIndex: number[] = []; | |
| 199 | for (const group of groups) { | |
| 200 | if (group.label) { | |
| 201 | rows.push({ | |
| 202 | kind: "label", | |
| 203 | key: `group:${group.value}`, | |
| 204 | label: group.label, | |
| 205 | first: rows.length === 0, | |
| 206 | }); | |
| 207 | } | |
| 208 | for (const item of group.items) { | |
| 209 | const itemIndex = item.disabled ? null : itemValues.length; | |
| 210 | if (itemIndex !== null) { | |
| 211 | itemValues.push(item.value); | |
| 212 | rowIndexByItemIndex.push(rows.length); | |
| 213 | } | |
| 214 | rows.push({ kind: "item", key: `${group.value}:${item.value}`, item, itemIndex }); | |
| 215 | } | |
| 216 | } | |
| 217 | return { rows, itemValues, rowIndexByItemIndex }; | |
| 218 | } | |
| 219 | ||
| 220 | /** The enabled item Enter should run for a highlight, whether or not its row is mounted. */ | |
| 221 | export function findHighlightedCommandPaletteItem( | |
| 222 | groups: ReadonlyArray<CommandPaletteGroup>, | |
| 223 | highlightedItemValue: string | null, | |
| 224 | ): CommandPaletteActionItem | CommandPaletteSubmenuItem | null { | |
| 225 | if (highlightedItemValue === null) return null; | |
| 226 | for (const group of groups) { | |
| 227 | for (const item of group.items) { | |
| 228 | if (item.value === highlightedItemValue && !item.disabled) return item; | |
| 229 | } | |
| 230 | } | |
| 231 | return null; | |
| 232 | } | |
| 233 | ||
| 175 | 234 | export function enumerateCommandPaletteItems( |
| 176 | 235 | items: ReadonlyArray<CommandPaletteActionItem>, |
| 177 | 236 | ): CommandPaletteActionItem[] { |
apps/web/src/components/CommandPalette.tsx+73-10| ... | ... | @@ -77,6 +77,7 @@ import { |
| 77 | 77 | type KeyboardEvent, |
| 78 | 78 | type ReactNode, |
| 79 | 79 | } from "react"; |
| 80 | import type { LegendListRef } from "@legendapp/list/react"; | |
| 80 | 81 | import { useAtomValue } from "@effect/atom-react"; |
| 81 | 82 | |
| 82 | 83 | import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; |
| ... | ... | @@ -157,7 +158,9 @@ import { |
| 157 | 158 | buildRootGroups, |
| 158 | 159 | buildThreadActionItems, |
| 159 | 160 | buildLinkedThreadActionItems, |
| 161 | buildCommandPaletteRows, | |
| 160 | 162 | enumerateCommandPaletteItems, |
| 163 | findHighlightedCommandPaletteItem, | |
| 161 | 164 | type CommandPaletteActionItem, |
| 162 | 165 | type CommandPaletteOpenIntent, |
| 163 | 166 | type CommandPaletteSubmenuItem, |
| ... | ... | @@ -174,7 +177,10 @@ import { |
| 174 | 177 | import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; |
| 175 | 178 | import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; |
| 176 | 179 | import { CommandPaletteContent } from "./CommandPaletteContent"; |
| 177 | import { CommandPaletteResults } from "./CommandPaletteResults"; | |
| 180 | import { | |
| 181 | CommandPaletteVirtualizedResults, | |
| 182 | scrollCommandPaletteRowIntoView, | |
| 183 | } from "./CommandPaletteResults"; | |
| 178 | 184 | import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon, ForgejoIcon } from "./Icons"; |
| 179 | 185 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; |
| 180 | 186 | import { Checkbox } from "./ui/checkbox"; |
| ... | ... | @@ -694,6 +700,15 @@ function OpenCommandPaletteDialog(props: { |
| 694 | 700 | const deferredQuery = useDeferredValue(query); |
| 695 | 701 | const isActionsOnly = deferredQuery.startsWith(">"); |
| 696 | 702 | const [highlightedItemValue, setHighlightedItemValue] = useState<string | null>(null); |
| 703 | const resultListRef = useRef<LegendListRef | null>(null); | |
| 704 | // Typing or entering a submenu clears the highlight. Base UI keeps its own on the | |
| 705 | // first row, but the palette shows none until the user navigates, and the first | |
| 706 | // ArrowDown lands on it. | |
| 707 | const highlightClearedRef = useRef(false); | |
| 708 | function clearTypedHighlight(): void { | |
| 709 | highlightClearedRef.current = true; | |
| 710 | setHighlightedItemValue(null); | |
| 711 | } | |
| 697 | 712 | const clientSettings = useClientSettings(); |
| 698 | 713 | const createProject = useAtomCommand(projectEnvironment.create, { |
| 699 | 714 | reportFailure: false, |
| ... | ... | @@ -1425,7 +1440,7 @@ function OpenCommandPaletteDialog(props: { |
| 1425 | 1440 | ? { |
| 1426 | 1441 | source: match.source, |
| 1427 | 1442 | snippet: match.snippet, |
| 1428 | query: threadSearchQuery, | |
| 1443 | query: threadSearch.query, | |
| 1429 | 1444 | } |
| 1430 | 1445 | : undefined; |
| 1431 | 1446 | }, |
| ... | ... | @@ -1446,7 +1461,7 @@ function OpenCommandPaletteDialog(props: { |
| 1446 | 1461 | projectTitleById, |
| 1447 | 1462 | providerEntryByEnvironmentAndInstanceId, |
| 1448 | 1463 | threadContentMatchByKey, |
| 1449 | threadSearchQuery, | |
| 1464 | threadSearch.query, | |
| 1450 | 1465 | threads, |
| 1451 | 1466 | ], |
| 1452 | 1467 | ); |
| ... | ... | @@ -1463,6 +1478,7 @@ function OpenCommandPaletteDialog(props: { |
| 1463 | 1478 | ...(view.initialQuery ? { initialQuery: view.initialQuery } : {}), |
| 1464 | 1479 | }, |
| 1465 | 1480 | ]); |
| 1481 | highlightClearedRef.current = true; | |
| 1466 | 1482 | setHighlightedItemValue(null); |
| 1467 | 1483 | setQuery(view.initialQuery ?? ""); |
| 1468 | 1484 | }, |
| ... | ... | @@ -1494,7 +1510,7 @@ function OpenCommandPaletteDialog(props: { |
| 1494 | 1510 | |
| 1495 | 1511 | function handleQueryChange(nextQuery: string): void { |
| 1496 | 1512 | browseNavigation.invalidate(); |
| 1497 | setHighlightedItemValue(null); | |
| 1513 | clearTypedHighlight(); | |
| 1498 | 1514 | setQuery(nextQuery); |
| 1499 | 1515 | if (nextQuery === "" && currentView?.initialQuery) { |
| 1500 | 1516 | popView(); |
| ... | ... | @@ -2938,6 +2954,9 @@ function OpenCommandPaletteDialog(props: { |
| 2938 | 2954 | } else if (isBrowsing) { |
| 2939 | 2955 | displayedGroups = relativePathNeedsActiveProject ? [] : browseGroups; |
| 2940 | 2956 | } |
| 2957 | const resultRows = buildCommandPaletteRows(displayedGroups); | |
| 2958 | const autoHighlightsFirstRow = | |
| 2959 | !isBrowsing && !isRemoteProjectCloneFlow && newProjectFlow === null; | |
| 2941 | 2960 | |
| 2942 | 2961 | const inputPlaceholder = |
| 2943 | 2962 | newProjectFlow !== null |
| ... | ... | @@ -3083,6 +3102,43 @@ function OpenCommandPaletteDialog(props: { |
| 3083 | 3102 | if (event.key === "Backspace" && query === "" && isSubmenu) { |
| 3084 | 3103 | event.preventDefault(); |
| 3085 | 3104 | popView(); |
| 3105 | return; | |
| 3106 | } | |
| 3107 | ||
| 3108 | // Base UI ignores navigation keys with modifiers, so these fallbacks do too. | |
| 3109 | if (event.ctrlKey || event.shiftKey || event.altKey || event.metaKey) return; | |
| 3110 | // Base UI only keeps a hidden highlight on the first row when it auto-highlights. | |
| 3111 | const firstItemValue = autoHighlightsFirstRow ? resultRows.itemValues[0] : undefined; | |
| 3112 | if ( | |
| 3113 | event.key === "ArrowDown" && | |
| 3114 | highlightClearedRef.current && | |
| 3115 | firstItemValue && | |
| 3116 | !event.nativeEvent.isComposing | |
| 3117 | ) { | |
| 3118 | (event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.(); | |
| 3119 | event.preventDefault(); | |
| 3120 | highlightClearedRef.current = false; | |
| 3121 | setHighlightedItemValue(firstItemValue); | |
| 3122 | scrollCommandPaletteRowIntoView( | |
| 3123 | resultListRef.current, | |
| 3124 | resultRows.rowIndexByItemIndex[0] ?? 0, | |
| 3125 | ); | |
| 3126 | return; | |
| 3127 | } | |
| 3128 | ||
| 3129 | // Base UI clicks the highlighted row on Enter, which does nothing once the | |
| 3130 | // virtualized list has unmounted it, so run the tracked highlight directly. | |
| 3131 | if (event.key === "Enter" && !event.nativeEvent.isComposing && event.keyCode !== 229) { | |
| 3132 | const highlightedItem = findHighlightedCommandPaletteItem( | |
| 3133 | displayedGroups, | |
| 3134 | highlightedItemValue ?? firstItemValue ?? null, | |
| 3135 | ); | |
| 3136 | if (highlightedItem) { | |
| 3137 | (event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.(); | |
| 3138 | event.preventDefault(); | |
| 3139 | event.stopPropagation(); | |
| 3140 | executeItem(highlightedItem); | |
| 3141 | } | |
| 3086 | 3142 | } |
| 3087 | 3143 | } |
| 3088 | 3144 | |
| ... | ... | @@ -3349,9 +3405,7 @@ function OpenCommandPaletteDialog(props: { |
| 3349 | 3405 | <CommandPaletteContent |
| 3350 | 3406 | key={`${viewStack.length}-${browseGeneration}-${isBrowsing}-${newProjectFlow ? "new-project" : (addProjectCloneFlow?.step ?? "none")}`} |
| 3351 | 3407 | aria-label="Command palette" |
| 3352 | autoHighlight={ | |
| 3353 | isBrowsing || isRemoteProjectCloneFlow || newProjectFlow !== null ? false : "always" | |
| 3354 | } | |
| 3408 | autoHighlight={autoHighlightsFirstRow ? "always" : false} | |
| 3355 | 3409 | footerActionLabel={footerActionLabel} |
| 3356 | 3410 | footerTrailing={footerTrailing} |
| 3357 | 3411 | inputAccessory={inputAccessory} |
| ... | ... | @@ -3387,8 +3441,16 @@ function OpenCommandPaletteDialog(props: { |
| 3387 | 3441 | onKeyDown: handleKeyDown, |
| 3388 | 3442 | }} |
| 3389 | 3443 | mode="none" |
| 3390 | onItemHighlighted={(value) => { | |
| 3444 | items={resultRows.itemValues} | |
| 3445 | virtualized | |
| 3446 | onItemHighlighted={(value, eventDetails) => { | |
| 3447 | if (eventDetails.reason === "none" && highlightClearedRef.current) return; | |
| 3448 | highlightClearedRef.current = false; | |
| 3391 | 3449 | setHighlightedItemValue(typeof value === "string" ? value : null); |
| 3450 | const rowIndex = resultRows.rowIndexByItemIndex[eventDetails.index]; | |
| 3451 | if (eventDetails.reason === "keyboard" && rowIndex !== undefined) { | |
| 3452 | scrollCommandPaletteRowIntoView(resultListRef.current, rowIndex); | |
| 3453 | } | |
| 3392 | 3454 | }} |
| 3393 | 3455 | onValueChange={handleQueryChange} |
| 3394 | 3456 | showBackHint={isSubmenu} |
| ... | ... | @@ -3426,8 +3488,9 @@ function OpenCommandPaletteDialog(props: { |
| 3426 | 3488 | </div> |
| 3427 | 3489 | </div> |
| 3428 | 3490 | ) : null} |
| 3429 | <CommandPaletteResults | |
| 3430 | groups={displayedGroups} | |
| 3491 | <CommandPaletteVirtualizedResults | |
| 3492 | rows={resultRows.rows} | |
| 3493 | listRef={resultListRef} | |
| 3431 | 3494 | highlightedItemValue={highlightedItemValue} |
| 3432 | 3495 | isActionsOnly={isActionsOnly} |
| 3433 | 3496 | keybindings={keybindings} |
apps/web/src/components/CommandPaletteResults.tsx+102-8| ... | ... | @@ -1,9 +1,13 @@ |
| 1 | 1 | import { type ResolvedKeybindingsConfig } from "@t3tools/contracts"; |
| 2 | import { LegendList, type LegendListRef } from "@legendapp/list/react"; | |
| 2 | 3 | import { ChevronRightIcon } from "lucide-react"; |
| 4 | import { type RefObject, useCallback, useState } from "react"; | |
| 5 | import { cn } from "~/lib/utils"; | |
| 3 | 6 | import { shortcutLabelForCommand } from "../keybindings"; |
| 4 | 7 | import { |
| 5 | 8 | type CommandPaletteActionItem, |
| 6 | 9 | type CommandPaletteGroup, |
| 10 | type CommandPaletteRow, | |
| 7 | 11 | type CommandPaletteSubmenuItem, |
| 8 | 12 | } from "./CommandPalette.logic"; |
| 9 | 13 | import { |
| ... | ... | @@ -12,8 +16,11 @@ import { |
| 12 | 16 | CommandGroupLabel, |
| 13 | 17 | CommandItem, |
| 14 | 18 | CommandList, |
| 19 | CommandListHeading, | |
| 20 | CommandListVirtualized, | |
| 15 | 21 | CommandShortcut, |
| 16 | 22 | } from "./ui/command"; |
| 23 | import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area"; | |
| 17 | 24 | import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch"; |
| 18 | 25 | |
| 19 | 26 | interface CommandPaletteResultsProps { |
| ... | ... | @@ -25,16 +32,20 @@ interface CommandPaletteResultsProps { |
| 25 | 32 | onExecuteItem: (item: CommandPaletteActionItem | CommandPaletteSubmenuItem) => void; |
| 26 | 33 | } |
| 27 | 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 | ||
| 28 | 46 | export function CommandPaletteResults(props: CommandPaletteResultsProps) { |
| 29 | 47 | if (props.groups.length === 0) { |
| 30 | return ( | |
| 31 | <div className="py-10 text-center text-sm text-muted-foreground"> | |
| 32 | {props.emptyStateMessage ?? | |
| 33 | (props.isActionsOnly | |
| 34 | ? "No matching actions." | |
| 35 | : "No matching commands, projects, or threads.")} | |
| 36 | </div> | |
| 37 | ); | |
| 48 | return <CommandPaletteEmptyState {...props} />; | |
| 38 | 49 | } |
| 39 | 50 | |
| 40 | 51 | return ( |
| ... | ... | @@ -63,6 +74,87 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { |
| 63 | 74 | ); |
| 64 | 75 | } |
| 65 | 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", | |
| 151 | getVirtualizedScrollFadeClassName(scrollFade), | |
| 152 | )} | |
| 153 | /> | |
| 154 | </CommandListVirtualized> | |
| 155 | ); | |
| 156 | } | |
| 157 | ||
| 66 | 158 | function DisabledCommandPaletteResultRow(props: { |
| 67 | 159 | item: CommandPaletteActionItem | CommandPaletteSubmenuItem; |
| 68 | 160 | }) { |
| ... | ... | @@ -96,6 +188,7 @@ function DisabledCommandPaletteResultRow(props: { |
| 96 | 188 | } |
| 97 | 189 | |
| 98 | 190 | function CommandPaletteResultRow(props: { |
| 191 | index?: number; | |
| 99 | 192 | item: CommandPaletteActionItem | CommandPaletteSubmenuItem; |
| 100 | 193 | isActive: boolean; |
| 101 | 194 | keybindings: ResolvedKeybindingsConfig; |
| ... | ... | @@ -107,6 +200,7 @@ function CommandPaletteResultRow(props: { |
| 107 | 200 | |
| 108 | 201 | return ( |
| 109 | 202 | <CommandItem |
| 203 | {...(props.index === undefined ? {} : { index: props.index })} | |
| 110 | 204 | value={props.item.value} |
| 111 | 205 | active={props.isActive} |
| 112 | 206 | onMouseDown={(event) => { |
apps/web/src/components/ui/autocomplete.tsx+27-1| ... | ... | @@ -146,11 +146,35 @@ function AutocompleteGroup({ className, ...props }: AutocompletePrimitive.Group. |
| 146 | 146 | ); |
| 147 | 147 | } |
| 148 | 148 | |
| 149 | const GROUP_LABEL_CLASS_NAME = "px-2 py-1.5 font-medium text-muted-foreground text-xs"; | |
| 150 | ||
| 149 | 151 | function AutocompleteGroupLabel({ className, ...props }: AutocompletePrimitive.GroupLabel.Props) { |
| 150 | 152 | return ( |
| 151 | 153 | <AutocompletePrimitive.GroupLabel |
| 152 | className={cn("px-2 py-1.5 font-medium text-muted-foreground text-xs", className)} | |
| 154 | className={cn(GROUP_LABEL_CLASS_NAME, className)} | |
| 155 | data-slot="autocomplete-group-label" | |
| 156 | {...props} | |
| 157 | /> | |
| 158 | ); | |
| 159 | } | |
| 160 | ||
| 161 | // A group label for virtualized lists, where rows cannot be wrapped in a group. | |
| 162 | function AutocompleteListHeading({ className, ...props }: React.ComponentProps<"div">) { | |
| 163 | return ( | |
| 164 | <div | |
| 165 | className={cn(GROUP_LABEL_CLASS_NAME, className)} | |
| 153 | 166 | data-slot="autocomplete-group-label" |
| 167 | role="presentation" | |
| 168 | {...props} | |
| 169 | /> | |
| 170 | ); | |
| 171 | } | |
| 172 | ||
| 173 | function AutocompleteListVirtualized({ className, ...props }: AutocompletePrimitive.List.Props) { | |
| 174 | return ( | |
| 175 | <AutocompletePrimitive.List | |
| 176 | className={cn("size-full min-w-0", className)} | |
| 177 | data-slot="autocomplete-list" | |
| 154 | 178 | {...props} |
| 155 | 179 | /> |
| 156 | 180 | ); |
| ... | ... | @@ -226,6 +250,8 @@ export { |
| 226 | 250 | AutocompleteGroupLabel, |
| 227 | 251 | AutocompleteEmpty, |
| 228 | 252 | AutocompleteList, |
| 253 | AutocompleteListHeading, | |
| 254 | AutocompleteListVirtualized, | |
| 229 | 255 | AutocompleteClear, |
| 230 | 256 | AutocompleteCollection, |
| 231 | 257 | }; |
apps/web/src/components/ui/command.tsx+27| ... | ... | @@ -12,6 +12,8 @@ import { |
| 12 | 12 | AutocompleteInput, |
| 13 | 13 | AutocompleteItem, |
| 14 | 14 | AutocompleteList, |
| 15 | AutocompleteListHeading, | |
| 16 | AutocompleteListVirtualized, | |
| 15 | 17 | } from "~/components/ui/autocomplete"; |
| 16 | 18 | import { DIALOG_BACKDROP_CLASS, DIALOG_POPUP_CLASS } from "~/components/ui/dialog-styles"; |
| 17 | 19 | import { Button } from "~/components/ui/button"; |
| ... | ... | @@ -123,6 +125,29 @@ function CommandList({ className, ...props }: React.ComponentProps<typeof Autoco |
| 123 | 125 | ); |
| 124 | 126 | } |
| 125 | 127 | |
| 128 | // Pair with a LegendList child; the list owns scrolling and spacing. | |
| 129 | function CommandListVirtualized({ | |
| 130 | className, | |
| 131 | ...props | |
| 132 | }: React.ComponentProps<typeof AutocompleteListVirtualized>) { | |
| 133 | return ( | |
| 134 | <AutocompleteListVirtualized | |
| 135 | className={cn("flex max-h-[inherit] flex-col", className)} | |
| 136 | data-slot="command-list" | |
| 137 | {...props} | |
| 138 | /> | |
| 139 | ); | |
| 140 | } | |
| 141 | ||
| 142 | function CommandListHeading({ | |
| 143 | className, | |
| 144 | ...props | |
| 145 | }: React.ComponentProps<typeof AutocompleteListHeading>) { | |
| 146 | return ( | |
| 147 | <AutocompleteListHeading className={className} data-slot="command-group-label" {...props} /> | |
| 148 | ); | |
| 149 | } | |
| 150 | ||
| 126 | 151 | function CommandPanel({ className, ...props }: React.ComponentProps<"div">) { |
| 127 | 152 | return ( |
| 128 | 153 | <div |
| ... | ... | @@ -229,6 +254,8 @@ export { |
| 229 | 254 | CommandInput, |
| 230 | 255 | CommandItem, |
| 231 | 256 | CommandList, |
| 257 | CommandListHeading, | |
| 258 | CommandListVirtualized, | |
| 232 | 259 | CommandPanel, |
| 233 | 260 | CommandShortcut, |
| 234 | 261 | }; |
apps/web/src/state/queries.threadSearch.test.tsx created+64| ... | ... | @@ -0,0 +1,64 @@ |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import { act } from "react"; | |
| 3 | import { create, type ReactTestRenderer } from "react-test-renderer"; | |
| 4 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 5 | ||
| 6 | import { useThreadSearch } from "./queries"; | |
| 7 | ||
| 8 | const searchResults = vi.hoisted(() => ({ | |
| 9 | matches: [{ threadId: "thread-1", source: "user", snippet: "a link" }], | |
| 10 | isLoading: false, | |
| 11 | })); | |
| 12 | vi.mock("@effect/atom-react", async (importOriginal) => ({ | |
| 13 | ...(await importOriginal<typeof import("@effect/atom-react")>()), | |
| 14 | useAtomValue: (atom: { label?: ReadonlyArray<unknown> }) => | |
| 15 | String(atom.label?.[0]).includes("empty") ? { matches: [], isLoading: false } : searchResults, | |
| 16 | })); | |
| 17 | ||
| 18 | const environmentIds = [EnvironmentId.make("local")]; | |
| 19 | type ThreadSearch = ReturnType<typeof useThreadSearch>; | |
| 20 | ||
| 21 | function Probe(props: { query: string; onResult: (result: ThreadSearch) => void }) { | |
| 22 | props.onResult(useThreadSearch(environmentIds, props.query)); | |
| 23 | return null; | |
| 24 | } | |
| 25 | ||
| 26 | describe("useThreadSearch", () => { | |
| 27 | let renderer: ReactTestRenderer | null = null; | |
| 28 | beforeEach(() => { | |
| 29 | vi.useFakeTimers(); | |
| 30 | vi.stubGlobal("window", globalThis); | |
| 31 | }); | |
| 32 | afterEach(() => { | |
| 33 | act(() => renderer?.unmount()); | |
| 34 | renderer = null; | |
| 35 | vi.unstubAllGlobals(); | |
| 36 | vi.useRealTimers(); | |
| 37 | }); | |
| 38 | ||
| 39 | it("keeps the settled query and matches stable while typing, then reports the settled query", () => { | |
| 40 | const results: ThreadSearch[] = []; | |
| 41 | const render = (query: string) => { | |
| 42 | const element = <Probe query={query} onResult={(result) => results.push(result)} />; | |
| 43 | act(() => { | |
| 44 | if (renderer) renderer.update(element); | |
| 45 | else renderer = create(element); | |
| 46 | }); | |
| 47 | return results.at(-1)!; | |
| 48 | }; | |
| 49 | ||
| 50 | render(""); | |
| 51 | const typing = ["li", "lin", "link "].map(render); | |
| 52 | expect(typing.map((result) => result.query)).toEqual(["", "", ""]); | |
| 53 | expect(new Set(typing.map((result) => result.matches)).size).toBe(1); | |
| 54 | expect(typing.every((result) => result.isPending)).toBe(true); | |
| 55 | ||
| 56 | act(() => { | |
| 57 | vi.advanceTimersByTime(200); | |
| 58 | }); | |
| 59 | const settled = results.at(-1)!; | |
| 60 | expect(settled.query).toBe("link"); | |
| 61 | expect(settled.matches).toBe(searchResults.matches); | |
| 62 | expect(settled.isPending).toBe(false); | |
| 63 | }); | |
| 64 | }); |
apps/web/src/state/queries.ts+3| ... | ... | @@ -72,6 +72,8 @@ export function useThreadSearch( |
| 72 | 72 | query: string, |
| 73 | 73 | ): { |
| 74 | 74 | readonly matches: ReadonlyArray<EnvironmentThreadSearchMatch>; |
| 75 | /** The settled query `matches` came from; it only changes when results do. */ | |
| 76 | readonly query: string; | |
| 75 | 77 | readonly isPending: boolean; |
| 76 | 78 | } { |
| 77 | 79 | const normalizedQuery = query.trim(); |
| ... | ... | @@ -88,6 +90,7 @@ export function useThreadSearch( |
| 88 | 90 | const isDebouncing = canSearch && normalizedQuery !== debouncedQuery; |
| 89 | 91 | return { |
| 90 | 92 | matches: isDebouncing ? EMPTY_THREAD_SEARCH_MATCHES : result.matches, |
| 93 | query: settledQuery ?? "", | |
| 91 | 94 | isPending: canSearch && (isDebouncing || result.isLoading), |
| 92 | 95 | }; |
| 93 | 96 | } |