Skip to content
45 changes: 45 additions & 0 deletions apps/web/src/components/CommandPalette.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import { makeThreadFixture } from "../test-fixtures";
import {
buildBrowseGroups,
buildCommandPaletteProjectMetadata,
buildCommandPaletteRows,
buildProjectActionItems,
buildThreadActionItems,
buildLinkedThreadActionItems,
enumerateCommandPaletteItems,
filterPinnedBrowseEntries,
filterCommandPaletteGroups,
findHighlightedCommandPaletteItem,
reduceCommandPaletteUiState,
type CommandPaletteActionItem,
type CommandPaletteGroup,
Expand Down Expand Up @@ -849,3 +851,46 @@ describe("filterCommandPaletteGroups", () => {
]);
});
});

describe("virtualized command palette rows", () => {
const action = (value: string, disabled = false): CommandPaletteActionItem => ({
kind: "action",
value,
searchTerms: [],
title: value,
icon: null,
...(disabled ? { disabled } : {}),
run: async () => {},
});
const groups: CommandPaletteGroup[] = [
{ value: "actions", label: "Actions", items: [action("new-thread"), action("offline", true)] },
{ value: "threads", label: "Threads", items: [action("thread-a"), action("thread-b")] },
];

it("keeps group order and headings while indexing only enabled items", () => {
const { rows, itemValues, rowIndexByItemIndex } = buildCommandPaletteRows(groups);

expect(rows.map((row) => (row.kind === "label" ? `# ${row.label}` : row.key))).toEqual([
"# Actions",
"actions:new-thread",
"actions:offline",
"# Threads",
"threads:thread-a",
"threads:thread-b",
]);
expect(itemValues).toEqual(["new-thread", "thread-a", "thread-b"]);
expect(rowIndexByItemIndex).toEqual([1, 4, 5]);
expect(rows.flatMap((row) => (row.kind === "item" ? [row.itemIndex] : []))).toEqual([
0,
null,
1,
2,
]);
});

it("resolves Enter to the highlighted item without needing its row mounted", () => {
expect(findHighlightedCommandPaletteItem(groups, "thread-b")?.value).toBe("thread-b");
expect(findHighlightedCommandPaletteItem(groups, "offline")).toBeNull();
expect(findHighlightedCommandPaletteItem(groups, null)).toBeNull();
});
});
59 changes: 59 additions & 0 deletions apps/web/src/components/CommandPalette.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,65 @@ export interface CommandPaletteView {
readonly initialQuery?: string;
}

export type CommandPaletteRow =
| {
readonly kind: "label";
readonly key: string;
readonly label: string;
readonly first: boolean;
}
| {
readonly kind: "item";
readonly key: string;
readonly item: CommandPaletteActionItem | CommandPaletteSubmenuItem;
/** Position among enabled items, or null for disabled rows the keyboard skips. */
readonly itemIndex: number | null;
};

/**
* Flattens groups into the rows a virtualized list renders. `itemValues` is the
* highlightable item order Base UI navigates; `rowIndexByItemIndex` maps a
* highlight back to its row for scrolling.
*/
export function buildCommandPaletteRows(groups: ReadonlyArray<CommandPaletteGroup>) {
const rows: CommandPaletteRow[] = [];
const itemValues: string[] = [];
const rowIndexByItemIndex: number[] = [];
for (const group of groups) {
if (group.label) {
rows.push({
kind: "label",
key: `group:${group.value}`,
label: group.label,
first: rows.length === 0,
});
}
for (const item of group.items) {
const itemIndex = item.disabled ? null : itemValues.length;
if (itemIndex !== null) {
itemValues.push(item.value);
rowIndexByItemIndex.push(rows.length);
}
rows.push({ kind: "item", key: `${group.value}:${item.value}`, item, itemIndex });
}
}
return { rows, itemValues, rowIndexByItemIndex };
}

/** The enabled item Enter should run for a highlight, whether or not its row is mounted. */
export function findHighlightedCommandPaletteItem(
groups: ReadonlyArray<CommandPaletteGroup>,
highlightedItemValue: string | null,
): CommandPaletteActionItem | CommandPaletteSubmenuItem | null {
if (highlightedItemValue === null) return null;
for (const group of groups) {
for (const item of group.items) {
if (item.value === highlightedItemValue && !item.disabled) return item;
}
}
return null;
}

export function enumerateCommandPaletteItems(
items: ReadonlyArray<CommandPaletteActionItem>,
): CommandPaletteActionItem[] {
Expand Down
83 changes: 73 additions & 10 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ import {
type KeyboardEvent,
type ReactNode,
} from "react";
import type { LegendListRef } from "@legendapp/list/react";
import { useAtomValue } from "@effect/atom-react";

import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal";
Expand Down Expand Up @@ -157,7 +158,9 @@ import {
buildRootGroups,
buildThreadActionItems,
buildLinkedThreadActionItems,
buildCommandPaletteRows,
enumerateCommandPaletteItems,
findHighlightedCommandPaletteItem,
type CommandPaletteActionItem,
type CommandPaletteOpenIntent,
type CommandPaletteSubmenuItem,
Expand All @@ -174,7 +177,10 @@ import {
import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic";
import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";
import { CommandPaletteContent } from "./CommandPaletteContent";
import { CommandPaletteResults } from "./CommandPaletteResults";
import {
CommandPaletteVirtualizedResults,
scrollCommandPaletteRowIntoView,
} from "./CommandPaletteResults";
import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon, ForgejoIcon } from "./Icons";
import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
import { Checkbox } from "./ui/checkbox";
Expand Down Expand Up @@ -694,6 +700,15 @@ function OpenCommandPaletteDialog(props: {
const deferredQuery = useDeferredValue(query);
const isActionsOnly = deferredQuery.startsWith(">");
const [highlightedItemValue, setHighlightedItemValue] = useState<string | null>(null);
const resultListRef = useRef<LegendListRef | null>(null);
// Typing or entering a submenu clears the highlight. Base UI keeps its own on the
// first row, but the palette shows none until the user navigates, and the first
// ArrowDown lands on it.
const highlightClearedRef = useRef(false);
function clearTypedHighlight(): void {
highlightClearedRef.current = true;
setHighlightedItemValue(null);
}
const clientSettings = useClientSettings();
const createProject = useAtomCommand(projectEnvironment.create, {
reportFailure: false,
Expand Down Expand Up @@ -1425,7 +1440,7 @@ function OpenCommandPaletteDialog(props: {
? {
source: match.source,
snippet: match.snippet,
query: threadSearchQuery,
query: threadSearch.query,
}
: undefined;
},
Expand All @@ -1446,7 +1461,7 @@ function OpenCommandPaletteDialog(props: {
projectTitleById,
providerEntryByEnvironmentAndInstanceId,
threadContentMatchByKey,
threadSearchQuery,
threadSearch.query,
threads,
],
);
Expand All @@ -1463,6 +1478,7 @@ function OpenCommandPaletteDialog(props: {
...(view.initialQuery ? { initialQuery: view.initialQuery } : {}),
},
]);
highlightClearedRef.current = true;
setHighlightedItemValue(null);
setQuery(view.initialQuery ?? "");
},
Expand Down Expand Up @@ -1494,7 +1510,7 @@ function OpenCommandPaletteDialog(props: {

function handleQueryChange(nextQuery: string): void {
browseNavigation.invalidate();
setHighlightedItemValue(null);
clearTypedHighlight();
setQuery(nextQuery);
if (nextQuery === "" && currentView?.initialQuery) {
popView();
Expand Down Expand Up @@ -2938,6 +2954,9 @@ function OpenCommandPaletteDialog(props: {
} else if (isBrowsing) {
displayedGroups = relativePathNeedsActiveProject ? [] : browseGroups;
}
const resultRows = buildCommandPaletteRows(displayedGroups);
const autoHighlightsFirstRow =
!isBrowsing && !isRemoteProjectCloneFlow && newProjectFlow === null;

const inputPlaceholder =
newProjectFlow !== null
Expand Down Expand Up @@ -3083,6 +3102,43 @@ function OpenCommandPaletteDialog(props: {
if (event.key === "Backspace" && query === "" && isSubmenu) {
event.preventDefault();
popView();
return;
}

// Base UI ignores navigation keys with modifiers, so these fallbacks do too.
if (event.ctrlKey || event.shiftKey || event.altKey || event.metaKey) return;
// Base UI only keeps a hidden highlight on the first row when it auto-highlights.
const firstItemValue = autoHighlightsFirstRow ? resultRows.itemValues[0] : undefined;
if (
event.key === "ArrowDown" &&
highlightClearedRef.current &&
firstItemValue &&
!event.nativeEvent.isComposing
) {
(event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.();
event.preventDefault();
highlightClearedRef.current = false;
setHighlightedItemValue(firstItemValue);
scrollCommandPaletteRowIntoView(
resultListRef.current,
resultRows.rowIndexByItemIndex[0] ?? 0,
);
return;
}

// Base UI clicks the highlighted row on Enter, which does nothing once the
// virtualized list has unmounted it, so run the tracked highlight directly.
if (event.key === "Enter" && !event.nativeEvent.isComposing && event.keyCode !== 229) {
const highlightedItem = findHighlightedCommandPaletteItem(
displayedGroups,
highlightedItemValue ?? firstItemValue ?? null,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);
if (highlightedItem) {
(event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.();
event.preventDefault();
event.stopPropagation();
executeItem(highlightedItem);
}
}
}

Expand Down Expand Up @@ -3349,9 +3405,7 @@ function OpenCommandPaletteDialog(props: {
<CommandPaletteContent
key={`${viewStack.length}-${browseGeneration}-${isBrowsing}-${newProjectFlow ? "new-project" : (addProjectCloneFlow?.step ?? "none")}`}
aria-label="Command palette"
autoHighlight={
isBrowsing || isRemoteProjectCloneFlow || newProjectFlow !== null ? false : "always"
}
autoHighlight={autoHighlightsFirstRow ? "always" : false}
footerActionLabel={footerActionLabel}
footerTrailing={footerTrailing}
inputAccessory={inputAccessory}
Expand Down Expand Up @@ -3387,8 +3441,16 @@ function OpenCommandPaletteDialog(props: {
onKeyDown: handleKeyDown,
}}
mode="none"
onItemHighlighted={(value) => {
items={resultRows.itemValues}
virtualized
onItemHighlighted={(value, eventDetails) => {
if (eventDetails.reason === "none" && highlightClearedRef.current) return;
highlightClearedRef.current = false;
setHighlightedItemValue(typeof value === "string" ? value : null);
const rowIndex = resultRows.rowIndexByItemIndex[eventDetails.index];
if (eventDetails.reason === "keyboard" && rowIndex !== undefined) {
scrollCommandPaletteRowIntoView(resultListRef.current, rowIndex);
}
}}
onValueChange={handleQueryChange}
showBackHint={isSubmenu}
Expand Down Expand Up @@ -3426,8 +3488,9 @@ function OpenCommandPaletteDialog(props: {
</div>
</div>
) : null}
<CommandPaletteResults
groups={displayedGroups}
<CommandPaletteVirtualizedResults
rows={resultRows.rows}
listRef={resultListRef}
highlightedItemValue={highlightedItemValue}
isActionsOnly={isActionsOnly}
keybindings={keybindings}
Expand Down
Loading
Loading