Skip to content

Commit 1dbec7f

Browse files
committed
fix(hub-ui): bind shortcuts to nested commands at any depth
Every consumer of the command tree walked a single level of `children`, so anything deeper was unreachable: a dock group's members had no row in the shortcut settings to bind, no entry in the keybinding collector to fire, and no id the dispatcher could resolve. Traversal now lives in one place. `walkCommands` visits the tree depth-first with a `'skip'`/`'stop'` visitor signal, and `findCommandDeep`, `collectAllKeybindings`, the shortcut settings rows and the palette's root flatten all share it. `filterCommandsByWhen` keeps its own recursion since it rebuilds a cloned tree per level. The palette's flatten and drill-down stack move to `state/palette.ts`, where `showInPalette: 'without-children'` prunes a whole subtree rather than one level, and a row carries its full path for search while displaying only its immediate parent. Shortcut rows indent by nesting level instead of a boolean.
1 parent fa37a62 commit 1dbec7f

7 files changed

Lines changed: 534 additions & 87 deletions

File tree

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

Lines changed: 51 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
<script setup lang="ts">
22
import type { DevframeClientCommand, DevframeCommandEntry } from '@devframes/hub'
33
import type { DocksContext } from '@devframes/hub/client'
4+
import type { PaletteCrumb, PaletteFlatItem } from '../../state/palette'
45
import Fuse from 'fuse.js'
56
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
7+
import { flattenPaletteCommands, paletteScopeTrail } from '../../state/palette'
68
import BrandWordmark from '../icons/BrandWordmark.vue'
79
import CommandPaletteItem from './CommandPaletteItem.vue'
810
@@ -23,36 +25,14 @@ const listContainer = useTemplateRef<HTMLElement>('listContainer')
2325
const visible = ref(false)
2426
2527
// Breadcrumb stack for sub-command drill-down
26-
const breadcrumb = ref<Array<{ title: string, items: DevframeCommandEntry[] }>>([])
28+
const breadcrumb = ref<PaletteCrumb[]>([])
2729
28-
// Flattened items for top-level search (includes children with parent prefix)
29-
interface FlatItem {
30-
entry: DevframeCommandEntry
31-
parentTitle?: string
32-
searchTitle: string
33-
}
34-
35-
const flattenedItems = computed<FlatItem[]>(() => {
36-
const result: FlatItem[] = []
37-
for (const cmd of commandsCtx.value.paletteCommands) {
38-
result.push({ entry: cmd, searchTitle: cmd.title })
39-
if (cmd.children && cmd.showInPalette !== 'without-children') {
40-
for (const child of cmd.children) {
41-
if (child.showInPalette === false)
42-
continue
43-
result.push({
44-
entry: child as DevframeCommandEntry,
45-
parentTitle: cmd.title,
46-
searchTitle: `${cmd.title} > ${child.title}`,
47-
})
48-
}
49-
}
50-
}
51-
return result
52-
})
30+
const flattenedItems = computed<PaletteFlatItem[]>(
31+
() => flattenPaletteCommands(commandsCtx.value.paletteCommands),
32+
)
5333
5434
// Current items: either drilled-down sub-items or root items
55-
const currentFlatItems = computed<FlatItem[]>(() => {
35+
const currentFlatItems = computed<PaletteFlatItem[]>(() => {
5636
if (breadcrumb.value.length > 0) {
5737
const current = breadcrumb.value.at(-1)!
5838
return current.items.map(entry => ({ entry, searchTitle: entry.title }))
@@ -62,7 +42,7 @@ const currentFlatItems = computed<FlatItem[]>(() => {
6242
6343
// Dynamic sub-items from action() return
6444
const dynamicItems = ref<DevframeClientCommand[] | undefined>()
65-
const activeItems = computed<FlatItem[]>(() => {
45+
const activeItems = computed<PaletteFlatItem[]>(() => {
6646
if (dynamicItems.value) {
6747
return dynamicItems.value.map(entry => ({ entry, searchTitle: entry.title }))
6848
}
@@ -85,12 +65,17 @@ watch(search, () => {
8565
selectedIndex.value = 0
8666
})
8767
68+
/** Show the rows at `scopeId`'s level, from a fresh search. */
69+
function showScope(scopeId: string | null) {
70+
search.value = ''
71+
selectedIndex.value = 0
72+
dynamicItems.value = undefined
73+
breadcrumb.value = paletteScopeTrail(commandsCtx.value.paletteCommands, scopeId)
74+
}
75+
8876
watch(show, (v) => {
8977
if (v) {
90-
search.value = ''
91-
selectedIndex.value = 0
92-
breadcrumb.value = []
93-
dynamicItems.value = undefined
78+
showScope(commandsCtx.value.paletteScopeId)
9479
// Trigger enter animation
9580
requestAnimationFrame(() => {
9681
visible.value = true
@@ -99,9 +84,22 @@ watch(show, (v) => {
9984
}
10085
else {
10186
visible.value = false
87+
// Every close path funnels through `show` — Escape, the backdrop, running a
88+
// command, and a bare `paletteOpen` toggle — so the scope is dropped here
89+
// once rather than in each of them. A later Mod+K then opens at the root
90+
// instead of resurrecting the group it was last scoped to.
91+
commandsCtx.value.paletteScopeId = null
10292
}
10393
})
10494
95+
// A scope also arrives while the palette is already open — activating a dock
96+
// group picked from the root list, say. `show` stays `true` throughout, so the
97+
// drill-down follows the scope itself rather than the open transition.
98+
watch(() => commandsCtx.value.paletteScopeId, (scopeId) => {
99+
if (show.value && scopeId)
100+
showScope(scopeId)
101+
})
102+
105103
function moveSelected(delta: number) {
106104
const len = filtered.value.length
107105
if (len === 0)
@@ -121,7 +119,7 @@ function scrollToItem() {
121119
122120
const loadingId = ref<string | null>(null)
123121
124-
async function enterItem(flatItem: FlatItem) {
122+
async function enterItem(flatItem: PaletteFlatItem) {
125123
const entry = flatItem.entry
126124
127125
// If has static children, drill down
@@ -195,13 +193,32 @@ function goBack() {
195193
}
196194
if (breadcrumb.value.length > 0) {
197195
breadcrumb.value.pop()
196+
dropScopeAtRoot()
198197
search.value = ''
199198
selectedIndex.value = 0
200199
return
201200
}
202201
close()
203202
}
204203
204+
/** Jump to the level the crumb at `index` sits above. */
205+
function goToCrumb(index: number) {
206+
breadcrumb.value.splice(index)
207+
dropScopeAtRoot()
208+
search.value = ''
209+
selectedIndex.value = 0
210+
}
211+
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+
205222
function onKeyDown(e: KeyboardEvent) {
206223
if (e.key === 'Backspace' && !search.value && (breadcrumb.value.length > 0 || dynamicItems.value)) {
207224
e.preventDefault()
@@ -275,7 +292,7 @@ function getKeybindings(id: string) {
275292
v-for="(crumb, i) in breadcrumb"
276293
:key="i"
277294
class="text-xs op60 hover:op80 mr-1 flex items-center gap-0.5"
278-
@click="breadcrumb.splice(i); search = ''; selectedIndex = 0"
295+
@click="goToCrumb(i)"
279296
>
280297
{{ crumb.title }}
281298
<span class="op40">&rsaquo;</span>

packages/hub-ui/src/client/components/views-builtin/SettingsShortcuts.vue

Lines changed: 32 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { DevframeCommandEntry, DevframeCommandKeybinding } from '@devframes
33
import type { DocksContext } from '@devframes/hub/client'
44
import DisplayKbd from '@antfu/design/components/Display/DisplayKbd.vue'
55
import { computed, nextTick, ref, watch } from 'vue'
6-
import { filterCommandsByWhen, formatKeybinding, isKeybindingOverrideDifferentFromDefault, isMac, KNOWN_BROWSER_SHORTCUTS } from '../../state/keybindings'
6+
import { filterCommandsByWhen, findCommandDeep, formatKeybinding, isKeybindingOverrideDifferentFromDefault, isMac, KNOWN_BROWSER_SHORTCUTS, walkCommands } from '../../state/keybindings'
77
import { useSettings } from '../../state/settings-defaults'
88
import DockIcon from '../dock/DockIcon.vue'
99
@@ -19,7 +19,8 @@ const shortcutSearch = ref('')
1919
interface ShortcutRow {
2020
command: DevframeCommandEntry
2121
parentTitle?: string
22-
indent: boolean
22+
/** Nesting level — 0 for a top-level command, +1 per ancestor. */
23+
depth: number
2324
}
2425
2526
// This page is only reachable with the dock open and the palette closed, so `when`
@@ -34,20 +35,24 @@ const availableCommands = computed(() => filterCommandsByWhen(
3435
{ ...props.context.when.context, dockOpen: true, paletteOpen: false },
3536
))
3637
38+
/**
39+
* One row per command at every depth, in tree order, so anything the palette
40+
* can run can be given a shortcut here.
41+
*
42+
* Nesting runs deeper than a parent and its children: a dock group's members sit
43+
* two levels below the `Docks` command, and a devframe's own `children` go deeper
44+
* still.
45+
*/
3746
const shortcutRows = computed<ShortcutRow[]>(() => {
3847
const rows: ShortcutRow[] = []
39-
for (const cmd of availableCommands.value) {
40-
rows.push({ command: cmd, indent: false })
41-
if (cmd.children) {
42-
for (const child of cmd.children) {
43-
rows.push({
44-
command: child as DevframeCommandEntry,
45-
parentTitle: cmd.title,
46-
indent: true,
47-
})
48-
}
49-
}
50-
}
48+
walkCommands(availableCommands.value, (cmd, ancestors) => {
49+
const parentTitle = ancestors.at(-1)?.title
50+
rows.push({
51+
command: cmd,
52+
...(parentTitle ? { parentTitle } : {}),
53+
depth: ancestors.length,
54+
})
55+
})
5156
return rows
5257
})
5358
@@ -66,6 +71,16 @@ function getEffectiveKeybindings(id: string): DevframeCommandKeybinding[] {
6671
return commandsCtx.getKeybindings(id)
6772
}
6873
74+
/**
75+
* Indent one step per nesting level. An inline style rather than a class, since
76+
* the depth is only known at runtime and UnoCSS generates utilities from source
77+
* — a computed `ml-${depth * 6}` would never be emitted. One step is `ml-6`
78+
* worth of space.
79+
*/
80+
function rowIndentStyle(row: ShortcutRow): Record<string, string> {
81+
return row.depth > 0 ? { marginLeft: `${row.depth * 1.5}rem` } : {}
82+
}
83+
6984
function isExecutable(command: DevframeCommandEntry): boolean {
7085
return command.source === 'server' || !!command.action
7186
}
@@ -75,16 +90,7 @@ function isOverridden(id: string): boolean {
7590
}
7691
7792
function getDefaultKeybindings(id: string): DevframeCommandKeybinding[] {
78-
for (const cmd of commandsCtx.commands) {
79-
if (cmd.id === id)
80-
return cmd.keybindings ?? []
81-
if (cmd.children) {
82-
const child = cmd.children.find(c => c.id === id)
83-
if (child)
84-
return child.keybindings ?? []
85-
}
86-
}
87-
return []
93+
return findCommandDeep(commandsCtx.commands, id)?.keybindings ?? []
8894
}
8995
9096
function clearShortcut(commandId: string) {
@@ -281,9 +287,9 @@ watch(editorOpen, async (v) => {
281287
v-if="row.command.icon"
282288
:icon="row.command.icon"
283289
class="w-4 h-4 shrink-0 op60"
284-
:class="{ 'ml-6': row.indent }"
290+
:style="rowIndentStyle(row)"
285291
/>
286-
<div v-else :class="{ 'ml-6': row.indent }" class="w-4 h-4 shrink-0" />
292+
<div v-else :style="rowIndentStyle(row)" class="w-4 h-4 shrink-0" />
287293
<div class="flex-1 min-w-0">
288294
<div class="flex items-center gap-1.5">
289295
<span class="truncate text-sm">{{ row.command.title }}</span>

0 commit comments

Comments
 (0)