Skip to content

Commit e22596d

Browse files
committed
fix(hub): handle nested command trees consistently
1 parent 4a56a98 commit e22596d

7 files changed

Lines changed: 257 additions & 35 deletions

File tree

packages/hub-ui/src/client/components/command-palette/CommandPalette.vue

Lines changed: 35 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
<script setup lang="ts">
2-
import type { DevframeClientCommand, DevframeCommandEntry } from '@devframes/hub'
2+
import type { DevframeClientCommand } from '@devframes/hub'
33
import type { DocksContext } from '@devframes/hub/client'
44
import type { PaletteCrumb, PaletteFlatItem } from '../../state/palette'
55
import Fuse from 'fuse.js'
66
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
7-
import { flattenPaletteCommands, paletteScopeTrail } from '../../state/palette'
7+
import { flattenPaletteCommands, paletteActionKeepsOpen, paletteScopeTrail, paletteTrailScopeId, reconcilePaletteTrail, resolvePaletteSelection } from '../../state/palette'
88
import BrandWordmark from '../icons/BrandWordmark.vue'
99
import CommandPaletteItem from './CommandPaletteItem.vue'
1010
@@ -70,7 +70,10 @@ function showScope(scopeId: string | null) {
7070
search.value = ''
7171
selectedIndex.value = 0
7272
dynamicItems.value = undefined
73-
breadcrumb.value = paletteScopeTrail(commandsCtx.value.paletteCommands, scopeId)
73+
const next = paletteScopeTrail(commandsCtx.value.paletteCommands, scopeId)
74+
breadcrumb.value = next
75+
if (scopeId != null && !next.some(crumb => crumb.id === scopeId))
76+
commandsCtx.value.paletteScopeId = null
7477
}
7578
7679
watch(show, (v) => {
@@ -96,10 +99,25 @@ watch(show, (v) => {
9699
// group picked from the root list, say. `show` stays `true` throughout, so the
97100
// drill-down follows the scope itself rather than the open transition.
98101
watch(() => commandsCtx.value.paletteScopeId, (scopeId) => {
99-
if (show.value && scopeId)
102+
if (show.value)
100103
showScope(scopeId)
101104
})
102105
106+
// A command tree can change while the palette is open (dock registration,
107+
// `when` context, or a client command update). Rebuild each crumb by id so the
108+
// rendered rows and their actions always come from the live tree.
109+
watch(() => commandsCtx.value.paletteCommands, (commands) => {
110+
if (!show.value || breadcrumb.value.length === 0)
111+
return
112+
const scopeId = commandsCtx.value.paletteScopeId
113+
const scopeWasActive = scopeId != null && breadcrumb.value.some(crumb => crumb.id === scopeId)
114+
const next = reconcilePaletteTrail(commands, breadcrumb.value, scopeId)
115+
breadcrumb.value = next
116+
selectedIndex.value = Math.min(selectedIndex.value, Math.max(filtered.value.length - 1, 0))
117+
if (scopeWasActive && scopeId != null && !next.some(crumb => crumb.id === scopeId))
118+
commandsCtx.value.paletteScopeId = null
119+
})
120+
103121
function moveSelected(delta: number) {
104122
const len = filtered.value.length
105123
if (len === 0)
@@ -120,17 +138,16 @@ function scrollToItem() {
120138
const loadingId = ref<string | null>(null)
121139
122140
async function enterItem(flatItem: PaletteFlatItem) {
123-
const entry = flatItem.entry
141+
// The row may have been rendered just before the command tree changed. Look
142+
// it up again so a removed entry no-ops and a replacement runs its new action.
143+
const entry = activeItems.value.find(item => item.entry.id === flatItem.entry.id)?.entry
144+
if (!entry)
145+
return
124146
125-
// If has static children, drill down
126-
if (entry.children && entry.children.length > 0) {
127-
breadcrumb.value.push({
128-
title: entry.title,
129-
items: entry.children as DevframeCommandEntry[],
130-
})
131-
search.value = ''
132-
selectedIndex.value = 0
133-
dynamicItems.value = undefined
147+
// Ordinary command parents drill down. Dock groups are actionable parents:
148+
// their action opens a preferred member or scopes the palette for a choice.
149+
if (resolvePaletteSelection(entry, props.context.docks.entries) === 'drill') {
150+
commandsCtx.value.paletteScopeId = entry.id
134151
return
135152
}
136153
@@ -149,6 +166,8 @@ async function enterItem(flatItem: PaletteFlatItem) {
149166
catch (err) {
150167
console.error(`[@devframes/hub-ui] Command "${entry.id}" failed:`, err)
151168
}
169+
if (paletteActionKeepsOpen(entry, props.context.docks.entries, commandsCtx.value.paletteOpen, commandsCtx.value.paletteScopeId))
170+
return
152171
close()
153172
return
154173
}
@@ -193,7 +212,7 @@ function goBack() {
193212
}
194213
if (breadcrumb.value.length > 0) {
195214
breadcrumb.value.pop()
196-
dropScopeAtRoot()
215+
commandsCtx.value.paletteScopeId = paletteTrailScopeId(breadcrumb.value)
197216
search.value = ''
198217
selectedIndex.value = 0
199218
return
@@ -204,21 +223,11 @@ function goBack() {
204223
/** Jump to the level the crumb at `index` sits above. */
205224
function goToCrumb(index: number) {
206225
breadcrumb.value.splice(index)
207-
dropScopeAtRoot()
226+
commandsCtx.value.paletteScopeId = paletteTrailScopeId(breadcrumb.value)
208227
search.value = ''
209228
selectedIndex.value = 0
210229
}
211230
212-
/**
213-
* Stepping back out to the root list leaves the palette unscoped, so the
214-
* shortcut that scoped it drills back in instead of reading as "press again to
215-
* close".
216-
*/
217-
function dropScopeAtRoot() {
218-
if (breadcrumb.value.length === 0)
219-
commandsCtx.value.paletteScopeId = null
220-
}
221-
222231
function onKeyDown(e: KeyboardEvent) {
223232
if (e.key === 'Backspace' && !search.value && (breadcrumb.value.length > 0 || dynamicItems.value)) {
224233
e.preventDefault()

packages/hub-ui/src/client/state/context.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,20 @@ describe('dock group command activation', () => {
259259
expect(context.commands.paletteScopeId).toBe('devframes:docks:tools')
260260
})
261261

262+
it('re-scopes after the palette steps back to the Docks parent', async () => {
263+
expect.assertions(2)
264+
265+
const context = await createGroupedContext()
266+
await context.commands.execute('devframes:docks:tools')
267+
// The palette keeps its scope aligned with the breadcrumb whose children
268+
// are currently visible.
269+
context.commands.paletteScopeId = 'devframes:docks'
270+
await context.commands.execute('devframes:docks:tools')
271+
272+
expect(context.commands.paletteOpen).toBe(true)
273+
expect(context.commands.paletteScopeId).toBe('devframes:docks:tools')
274+
})
275+
262276
it('opens the sole visible member directly instead of a one-item palette', async () => {
263277
expect.assertions(2)
264278

packages/hub-ui/src/client/state/context.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { createCommandsContext } from './commands'
1414
import { docksGroupByCategories, getGroupMembers, getRegisteredGroupIds, resolveCommandIcon, resolveGroupPreferredChild } from './dock-settings'
1515
import { createDockEntryState, DEFAULT_DOCK_PANEL_STORE, DEFAULT_DOCK_SESSION_STORE, sharedStateToRef, useDocksEntries, waitForInitialSharedStateSync } from './docks'
1616
import { createClientMessagesClient } from './messages-client'
17+
import { dockCommandId } from './palette'
1718
import { registerMainFrameDockActionHandler, triggerMainFrameDockAction, useIsDockPopupOpen } from './popup'
1819
import { executeSetupScript } from './setup-script'
1920

@@ -515,7 +516,7 @@ export async function createDocksContext(
515516
cleanupDocksCommand?.()
516517

517518
const toCommand = (entry: DevframeDockEntry): DevframeClientCommand => ({
518-
id: `devframes:docks:${entry.id}`,
519+
id: dockCommandId(entry.id),
519520
source: 'client' as const,
520521
title: entry.title,
521522
icon: resolveCommandIcon(entry.icon),
@@ -576,7 +577,7 @@ export async function createDocksContext(
576577
// dead row in the palette and the shortcut settings.
577578
if (visibleMembers.length === 0 && !preferredChildId)
578579
return []
579-
const commandId = `devframes:docks:${entry.id}`
580+
const commandId = dockCommandId(entry.id)
580581
return [{
581582
...toCommand(entry),
582583
action: () => activateGroup(

packages/hub-ui/src/client/state/palette.test.ts

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import type { DevframeCommandEntry } from '@devframes/hub'
1+
import type { DevframeClientCommand, DevframeCommandEntry, DevframeDockEntry } from '@devframes/hub'
22
import { describe, expect, it } from 'vitest'
3-
import { flattenPaletteCommands, paletteScopeTrail } from './palette'
3+
import { flattenPaletteCommands, paletteActionKeepsOpen, paletteScopeTrail, paletteTrailScopeId, reconcilePaletteTrail, resolvePaletteSelection } from './palette'
44

55
/**
66
* The shape the palette actually sees: dock navigation two deep (`Docks` › a
@@ -47,6 +47,10 @@ const commands = [
4747
},
4848
] as unknown as DevframeCommandEntry[]
4949

50+
const docks = [
51+
{ id: 'tools', type: 'group' },
52+
] as DevframeDockEntry[]
53+
5054
describe('flattenPaletteCommands', () => {
5155
it('lists a nested command with its full path for search and its parent for display', () => {
5256
const rows = flattenPaletteCommands(commands)
@@ -81,6 +85,40 @@ describe('flattenPaletteCommands', () => {
8185
})
8286
})
8387

88+
describe('resolvePaletteSelection', () => {
89+
it('executes a dock group while ordinary command parents still drill down', () => {
90+
const action = () => {}
91+
const dockGroup = {
92+
...commands[0]!.children![1],
93+
action,
94+
} as DevframeCommandEntry
95+
const ordinaryParent = {
96+
...commands[1],
97+
action,
98+
} as DevframeCommandEntry
99+
100+
expect(resolvePaletteSelection(dockGroup, docks)).toBe('execute')
101+
expect(resolvePaletteSelection(ordinaryParent, docks)).toBe('drill')
102+
103+
const namespacedParent = {
104+
...ordinaryParent,
105+
id: 'devframes:docks:not-a-group',
106+
} as DevframeCommandEntry
107+
expect(resolvePaletteSelection(namespacedParent, docks)).toBe('drill')
108+
})
109+
110+
it('keeps the palette open only when a dock group action scoped it to itself', () => {
111+
const dockGroup = {
112+
...commands[0]!.children![1],
113+
action: () => {},
114+
} as DevframeCommandEntry
115+
116+
expect(paletteActionKeepsOpen(dockGroup, docks, true, dockGroup.id)).toBe(true)
117+
expect(paletteActionKeepsOpen(dockGroup, docks, true, null)).toBe(false)
118+
expect(paletteActionKeepsOpen(commands[1]!, docks, true, commands[1]!.id)).toBe(false)
119+
})
120+
})
121+
84122
describe('paletteScopeTrail', () => {
85123
it('builds the crumb stack a user would have clicked to reach the scope', () => {
86124
const trail = paletteScopeTrail(commands, 'devframes:docks:tools')
@@ -103,4 +141,39 @@ describe('paletteScopeTrail', () => {
103141
it('opens the root list for a command with nothing to drill into', () => {
104142
expect(paletteScopeTrail(commands, 'devframes:docks:overview')).toEqual([])
105143
})
144+
145+
it('tracks the scope as the user backs out through each breadcrumb level', () => {
146+
const trail = paletteScopeTrail(commands, 'devframes:docks:tools')
147+
148+
expect(paletteTrailScopeId(trail)).toBe('devframes:docks:tools')
149+
trail.pop()
150+
expect(paletteTrailScopeId(trail)).toBe('devframes:docks')
151+
trail.pop()
152+
expect(paletteTrailScopeId(trail)).toBeNull()
153+
})
154+
})
155+
156+
describe('reconcilePaletteTrail', () => {
157+
it('replaces scoped rows with the live command entries', () => {
158+
const current = paletteScopeTrail(commands, 'devframes:docks:tools')
159+
const replacementAction = () => {}
160+
const replacement = structuredClone(commands) as DevframeCommandEntry[]
161+
const tools = replacement[0]!.children![1] as DevframeClientCommand
162+
tools.children![0] = {
163+
...tools.children![0]!,
164+
action: replacementAction,
165+
}
166+
167+
const reconciled = reconcilePaletteTrail(replacement, current, 'devframes:docks:tools')
168+
169+
expect((reconciled.at(-1)!.items[0] as DevframeClientCommand).action).toBe(replacementAction)
170+
})
171+
172+
it('returns to the root when the scoped command is removed', () => {
173+
const current = paletteScopeTrail(commands, 'devframes:docks:tools')
174+
const withoutTools = structuredClone(commands) as DevframeCommandEntry[]
175+
withoutTools[0]!.children!.splice(1, 1)
176+
177+
expect(reconcilePaletteTrail(withoutTools, current, 'devframes:docks:tools')).toEqual([])
178+
})
106179
})

packages/hub-ui/src/client/state/palette.ts

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,70 @@
1-
import type { DevframeCommandEntry } from '@devframes/hub'
1+
import type { DevframeCommandEntry, DevframeDockEntry } from '@devframes/hub'
22
import { walkCommands } from './keybindings'
33

4+
const DOCKS_COMMAND_ID = 'devframes:docks'
5+
6+
/** Build the command id used for one dock entry. */
7+
export function dockCommandId(dockId: string): string {
8+
return `${DOCKS_COMMAND_ID}:${dockId}`
9+
}
10+
411
/**
512
* One level of the palette's drill-down stack: the command the user stepped
613
* into, and the rows that level shows.
714
*/
815
export interface PaletteCrumb {
16+
id: string
917
title: string
1018
items: DevframeCommandEntry[]
1119
}
1220

21+
/** The command whose children the current breadcrumb level displays. */
22+
export function paletteTrailScopeId(trail: PaletteCrumb[]): string | null {
23+
return trail.at(-1)?.id ?? null
24+
}
25+
1326
/** A palette row, flattened out of the command tree for root search. */
1427
export interface PaletteFlatItem {
1528
entry: DevframeCommandEntry
1629
parentTitle?: string
1730
searchTitle: string
1831
}
1932

33+
export type PaletteSelection = 'drill' | 'execute'
34+
35+
/** Dock groups are the actionable parents directly below the Docks command. */
36+
function isDockGroupCommand(
37+
entry: DevframeCommandEntry,
38+
docks: readonly DevframeDockEntry[],
39+
): boolean {
40+
return entry.source === 'client'
41+
&& !!entry.action
42+
&& !!entry.children?.length
43+
&& docks.some(dock => dock.type === 'group' && dockCommandId(dock.id) === entry.id)
44+
}
45+
46+
/** Decide whether selecting a row navigates into it or executes it. */
47+
export function resolvePaletteSelection(
48+
entry: DevframeCommandEntry,
49+
docks: readonly DevframeDockEntry[],
50+
): PaletteSelection {
51+
if (isDockGroupCommand(entry, docks))
52+
return 'execute'
53+
return entry.children?.length ? 'drill' : 'execute'
54+
}
55+
56+
/** An ambiguous dock group keeps the palette open by scoping it to itself. */
57+
export function paletteActionKeepsOpen(
58+
entry: DevframeCommandEntry,
59+
docks: readonly DevframeDockEntry[],
60+
paletteOpen: boolean,
61+
paletteScopeId: string | null,
62+
): boolean {
63+
return isDockGroupCommand(entry, docks)
64+
&& paletteOpen
65+
&& paletteScopeId === entry.id
66+
}
67+
2068
/**
2169
* Every command at every depth, so root search finds a nested entry — a dock
2270
* group's members, and anything a devframe nests below them — without drilling.
@@ -73,9 +121,38 @@ export function paletteScopeTrail(
73121
if (cmd.children?.length) {
74122
crumbs = [...ancestors, cmd]
75123
.filter(c => c.children?.length)
76-
.map(c => ({ title: c.title, items: c.children as DevframeCommandEntry[] }))
124+
.map(c => ({ id: c.id, title: c.title, items: c.children as DevframeCommandEntry[] }))
77125
}
78126
return 'stop'
79127
})
80128
return crumbs
81129
}
130+
131+
/**
132+
* Rebuild an open drill-down trail from the live command tree. Command ids keep
133+
* the user's current level stable while every row is replaced with its latest
134+
* entry object, so an unregistered or updated action cannot linger in a crumb.
135+
*/
136+
export function reconcilePaletteTrail(
137+
commands: DevframeCommandEntry[],
138+
current: PaletteCrumb[],
139+
scopeId: string | null,
140+
): PaletteCrumb[] {
141+
const scopeWasActive = scopeId != null && current.some(crumb => crumb.id === scopeId)
142+
const result: PaletteCrumb[] = []
143+
let level = commands
144+
145+
for (const crumb of current) {
146+
const entry = level.find(command => command.id === crumb.id)
147+
if (!entry?.children?.length)
148+
break
149+
const items = entry.children as DevframeCommandEntry[]
150+
result.push({ id: entry.id, title: entry.title, items })
151+
level = items
152+
}
153+
154+
if (scopeWasActive && !result.some(crumb => crumb.id === scopeId))
155+
return paletteScopeTrail(commands, scopeId)
156+
157+
return result
158+
}

0 commit comments

Comments
 (0)