Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 45 additions & 15 deletions packages/editor/src/components/ui/command-palette/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
import type { AnyNodeId, LevelNode } from '@pascal-app/core'
import { useScene } from '@pascal-app/core'
import { markPerfAction, useViewer } from '@pascal-app/viewer'
import { Command, useCommandState } from 'cmdk'
import { Command, defaultFilter, useCommandState } from 'cmdk'
import { ChevronRight, Search } from 'lucide-react'
import type { ReactNode } from 'react'
import { useEffect, useState } from 'react'
import type { ReactNode, RefObject } from 'react'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { create } from 'zustand'
import { useShallow } from 'zustand/shallow'
import { Dialog, DialogContent, DialogTitle } from './../../../components/ui/primitives/dialog'
import { getLevelDisplayName } from '@pascal-app/core'
import { useCommandRegistry } from '../../../store/use-command-registry'
import { type CommandAction, useCommandRegistry } from '../../../store/use-command-registry'
import { usePaletteViewRegistry } from '../../../store/use-palette-view-registry'

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -54,6 +54,28 @@ function resolve(value: string | (() => string)): string {
return typeof value === 'function' ? value() : value
}

type ActionGroup = [group: string, actions: CommandAction[]]

/**
* The registered actions by group, in the order they were registered. A search puts the group
* holding the best match first: cmdk ranks the items inside a group but leaves the groups where
* they are, so a strong match could sit below a screen of weak ones.
*/
export function rankedGroups(actions: CommandAction[], search: string): ActionGroup[] {
const groups = new Map<string, CommandAction[]>()
for (const action of actions) {
groups.set(action.group, [...(groups.get(action.group) ?? []), action])
}
const ordered: ActionGroup[] = [...groups]
if (!search) return ordered
const bestMatch = ([, list]: ActionGroup) =>
Math.max(...list.map((action) => defaultFilter(resolve(action.label), search, action.keywords)))
return ordered
.map((entry) => ({ entry, score: bestMatch(entry) }))
.sort((a, b) => b.score - a.score)
.map(({ entry }) => entry)
}

function Shortcut({ keys }: { keys: string[] }) {
return (
<span className="ml-auto flex shrink-0 items-center gap-0.5">
Expand Down Expand Up @@ -162,6 +184,20 @@ export interface CommandPaletteEmptyAction {
onSelect: (query: string) => void
}

/**
* A search selects the first item; this scrolls the list back to the top to show it. cmdk scrolls
* before the list re-renders the selection, so once a search has moved the groups it scrolls to
* wherever the item selected before went. Rendered after the list, whose ref is set by then.
*/
function TopOnFirstItem({ list }: { list: RefObject<HTMLDivElement | null> }) {
const value = useCommandState((s) => s.value)
useLayoutEffect(() => {
const first = list.current?.querySelector('[cmdk-item]:not([aria-disabled="true"])')
if (value && first?.getAttribute('aria-selected') === 'true') list.current?.scrollTo({ top: 0 })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scroll reset misses selected match

Medium Severity

TopOnFirstItem only resets scroll when the first [cmdk-item] in the list is aria-selected. cmdk keeps non-matching items in the tree with hidden, and the best match is often not the first registered action in the winning group, so that node is usually not the selection. The list then stays at cmdk’s pre-reorder scroll position and the new first match can sit above the fold.

Fix in CursorΒ Fix in Web

Reviewed by Cursor Bugbot for commit 066cd82. Configure here.

}, [list, value])
return null
}

function EmptyActionItem({ action }: { action: CommandPaletteEmptyAction }) {
const count = useCommandState((s) => s.filtered.count)
const search = useCommandState((s) => s.search)
Expand Down Expand Up @@ -193,6 +229,7 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm

const [meta, setMeta] = useState('⌘')
const [isFullscreen, setIsFullscreen] = useState(false)
const listRef = useRef<HTMLDivElement>(null)

const page = pages[pages.length - 1]

Expand Down Expand Up @@ -255,15 +292,7 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
})
}

// ---------------------------------------------------------------------------
// Group registered actions by group (preserving insertion order)
// ---------------------------------------------------------------------------
const grouped = actions.reduce<Map<string, typeof actions>>((acc, action) => {
const list = acc.get(action.group) ?? []
list.push(action)
acc.set(action.group, list)
return acc
}, new Map())
const grouped = rankedGroups(actions, inputValue)

const onClose = () => setOpen(false)
const onBack = () => {
Expand Down Expand Up @@ -326,7 +355,7 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
/>
</div>

<Command.List className="max-h-100 overflow-y-auto p-1.5">
<Command.List className="max-h-100 overflow-y-auto p-1.5" ref={listRef}>
{(!emptyAction || page) && (
<Command.Empty className="py-8 text-center text-muted-foreground text-sm">
No commands found.
Expand All @@ -344,7 +373,7 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm

{/* ── Root view: render from registry ───────────────────────── */}
{!page &&
Array.from(grouped.entries()).map(([group, groupActions]) => (
grouped.map(([group, groupActions]) => (
<Command.Group heading={group} key={group}>
{groupActions.map((action) => (
<Item
Expand Down Expand Up @@ -439,6 +468,7 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
</Command.Group>
)}
</Command.List>
<TopOnFirstItem list={listRef} />

{/* Footer hint */}
<div className="flex items-center justify-between border-border/50 border-t px-3 py-2">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, test } from 'bun:test'
import { rankedGroups } from '.'

const action = (group: string, label: string, keywords?: string[]) => ({
id: label,
group,
label,
keywords,
execute: () => {},
})

// What a host's palette holds: weak fuzzy matches for "theme" registered before the strong one.
const actions = [
action('Generate', 'Generate house from template: The Poppy', ['template', 'adu', 'poppy']),
action('Viewer Controls', 'Switch to Solid', ['solid', 'shading', 'render', 'mode']),
action('View', 'Use dark theme', ['theme', 'appearance', 'interface']),
action('Export & Share', 'Take Screenshot', ['screenshot', 'capture', 'image']),
]

const order = (search: string) => rankedGroups(actions, search).map(([group]) => group)

describe('command palette groups', () => {
test('a search lists the group with the best match first', () => {
expect(order('theme')[0]).toBe('View')
expect(order('solid')[0]).toBe('Viewer Controls')
})

test('with no search the groups keep the order they were registered in', () => {
expect(order('')).toEqual(['Generate', 'Viewer Controls', 'View', 'Export & Share'])
})
})
Loading