Skip to content
Open
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
32 changes: 30 additions & 2 deletions docs/content/1.guide/16.hub.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,22 @@ ctx.commands.register({

`args` takes positional [Standard Schema](https://standardschema.dev/) schemas (a single `v.object(...)` unwraps into the input); omit for zero-arg. `safety` defaults to `'action'`; `when` clauses are unenforced for agent calls.

## Nested commands

A command's `children` nest arbitrarily deep. The palette drills into each level, and every command in the tree is bindable at any depth — a shortcut assigned to a leaf several levels down fires as directly as one on a top-level command, and each appears as its own row under **Settings → Shortcuts**, indented by nesting level.

```ts
ctx.commands.register({
id: 'app:cache',
title: 'Cache',
children: [
{ id: 'app:cache:clear', title: 'Clear', keybindings: [{ key: 'Mod+Shift+K' }], handler: clearCache },
],
})
```

Set `showInPalette: 'without-children'` on a parent to keep its whole subtree out of root search while leaving it reachable by drilling down.

## Cross-iframe dock activation

A mounted devframe's iframe uses `hub:docks:activate` to switch the active dock.
Expand Down Expand Up @@ -181,7 +197,7 @@ ctx.docks.register({
title: 'Nuxt',
icon: 'logos:nuxt-icon',
category: 'framework',
defaultChildId: 'nuxt:overview', // optional; popover-only when omitted
defaultChildId: 'nuxt:overview', // optional; see "Activating a group" below
})

ctx.docks.register({
Expand All @@ -194,7 +210,19 @@ ctx.docks.register({
})
```

Group and members stay independent top-level entries in `devframe:docks`. Activating the group reopens the member last opened in it (remembered per tab), and `defaultChildId` before any member has been opened. Grouping affects the dock rail, not iframes — to share **one** soft-navigated iframe, give docks a shared `frameId` and mark the anchor with `subTabs` ([Shared-iframe soft navigation](/guide/client-context#shared-iframe-soft-navigation)).
Group and members stay independent top-level entries in `devframe:docks`. Grouping affects the dock rail, not iframes — to share **one** soft-navigated iframe, give docks a shared `frameId` and mark the anchor with `subTabs` ([Shared-iframe soft navigation](/guide/client-context#shared-iframe-soft-navigation)).

### Activating a group

Activating a group resolves to one of its members.

The dock rail button reopens the member last opened in the group (remembered per tab), then `defaultChildId`; with neither, it reveals the member popover.

A group command — activated by its keyboard shortcut or a command-palette pick — opens that same remembered or default member, then the only visible member when there is exactly one. With several visible peers and no preferred member, it opens the command palette scoped to those members, so the choice stays with the user. Pressing the same shortcut again closes that palette.

`hub:docks:activate` follows programmatic dock switching: it opens the remembered or default member, then the first registered member.

Declare `defaultChildId` when one member is the natural landing spot; leave it off when the members are peers.

### The dual role of `category`

Expand Down
2 changes: 1 addition & 1 deletion docs/content/8.references/6.hub-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ The properties of `DevframeClientContext` — [The client context](/guide/client
| `clientType` | `'embedded'` (inside the user app) or `'standalone'` (independent hub page). |
| `docks` | `entries`, `selected`, `groupedEntries`, `switchEntry()`, `toggleEntry()`, `getStateById()`, `register()` / `update()` for [client-only docks](/guide/client-context#client-only-docks). |
| `panel` | Dock panel state: position, size, drag/resize. |
| `commands` | Command palette: `register()`, `execute()`, `getKeybindings()`. |
| `commands` | Command palette: `register()`, `execute()`, `getKeybindings()`, `paletteOpen`, `paletteScopeId`, `openPalette(atCommandId?)`. Passing an id opens the palette drilled into that command's children ([Activating a group](/guide/hub#activating-a-group)). |
| `renderers` | Dock-renderer registry — `register()`, `get()`, `has()`, `mount(entry, container)`. Routes a dock `type` to a renderer (local boot or the hub's [manifest](/guide/hub-initiate#renderer-modules); local wins). `mount()` resolves a `status`: `mounted` (with `dispose`), `missing-renderer`, or `load-error` (with `error`). |
| `when` | The [when-clause](/references/when-clauses) context. |
| `connection` | Live [connection status](/guide/client#handling-connection-and-auth-errors) — `status`, `error`, `events`. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,25 @@ export const Open: Story = {
),
}),
}

/**
* The palette opened *scoped* to a dock group, listing only that group's
* members — what activating a group with no `defaultChildId` does, so a group
* stays reachable by keyboard with the choice of member left to the user.
* Backspace or Escape steps back out to the root list.
*/
export const ScopedToGroup: Story = {
render: () => ({
setup: () => mountWithContext(
{ entries: groupedEntries },
ctx => h(defineComponent({
setup() {
onMounted(() => {
ctx.commands.openPalette('devframes:docks:playground')
})
return () => h(CommandPalette, { context: ctx })
},
})),
),
}),
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
<script setup lang="ts">
import type { DevframeClientCommand, DevframeCommandEntry } from '@devframes/hub'
import type { DevframeClientCommand } from '@devframes/hub'
import type { DocksContext } from '@devframes/hub/client'
import type { PaletteCrumb, PaletteFlatItem } from '../../state/palette'
import Fuse from 'fuse.js'
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
import { flattenPaletteCommands, paletteActionKeepsOpen, paletteScopeTrail, paletteTrailScopeId, reconcilePaletteTrail, resolvePaletteSelection } from '../../state/palette'
import BrandWordmark from '../icons/BrandWordmark.vue'
import CommandPaletteItem from './CommandPaletteItem.vue'

Expand All @@ -23,36 +25,14 @@ const listContainer = useTemplateRef<HTMLElement>('listContainer')
const visible = ref(false)

// Breadcrumb stack for sub-command drill-down
const breadcrumb = ref<Array<{ title: string, items: DevframeCommandEntry[] }>>([])
const breadcrumb = ref<PaletteCrumb[]>([])

// Flattened items for top-level search (includes children with parent prefix)
interface FlatItem {
entry: DevframeCommandEntry
parentTitle?: string
searchTitle: string
}

const flattenedItems = computed<FlatItem[]>(() => {
const result: FlatItem[] = []
for (const cmd of commandsCtx.value.paletteCommands) {
result.push({ entry: cmd, searchTitle: cmd.title })
if (cmd.children && cmd.showInPalette !== 'without-children') {
for (const child of cmd.children) {
if (child.showInPalette === false)
continue
result.push({
entry: child as DevframeCommandEntry,
parentTitle: cmd.title,
searchTitle: `${cmd.title} > ${child.title}`,
})
}
}
}
return result
})
const flattenedItems = computed<PaletteFlatItem[]>(
() => flattenPaletteCommands(commandsCtx.value.paletteCommands),
)

// Current items: either drilled-down sub-items or root items
const currentFlatItems = computed<FlatItem[]>(() => {
const currentFlatItems = computed<PaletteFlatItem[]>(() => {
if (breadcrumb.value.length > 0) {
const current = breadcrumb.value.at(-1)!
return current.items.map(entry => ({ entry, searchTitle: entry.title }))
Expand All @@ -62,7 +42,7 @@ const currentFlatItems = computed<FlatItem[]>(() => {

// Dynamic sub-items from action() return
const dynamicItems = ref<DevframeClientCommand[] | undefined>()
const activeItems = computed<FlatItem[]>(() => {
const activeItems = computed<PaletteFlatItem[]>(() => {
if (dynamicItems.value) {
return dynamicItems.value.map(entry => ({ entry, searchTitle: entry.title }))
}
Expand All @@ -85,12 +65,20 @@ watch(search, () => {
selectedIndex.value = 0
})

/** Show the rows at `scopeId`'s level, from a fresh search. */
function showScope(scopeId: string | null) {
search.value = ''
selectedIndex.value = 0
dynamicItems.value = undefined
const next = paletteScopeTrail(commandsCtx.value.paletteCommands, scopeId)
breadcrumb.value = next
if (scopeId != null && !next.some(crumb => crumb.id === scopeId))
commandsCtx.value.paletteScopeId = null
}

watch(show, (v) => {
if (v) {
search.value = ''
selectedIndex.value = 0
breadcrumb.value = []
dynamicItems.value = undefined
showScope(commandsCtx.value.paletteScopeId)
// Trigger enter animation
requestAnimationFrame(() => {
visible.value = true
Expand All @@ -99,9 +87,37 @@ watch(show, (v) => {
}
else {
visible.value = false
// Every close path funnels through `show` — Escape, the backdrop, running a
// command, and a bare `paletteOpen` toggle — so the scope is dropped here
// once rather than in each of them. A later Mod+K then opens at the root
// instead of resurrecting the group it was last scoped to.
commandsCtx.value.paletteScopeId = null
}
})

// A scope also arrives while the palette is already open — activating a dock
// group picked from the root list, say. `show` stays `true` throughout, so the
// drill-down follows the scope itself rather than the open transition.
watch(() => commandsCtx.value.paletteScopeId, (scopeId) => {
if (show.value)
showScope(scopeId)
})

// A command tree can change while the palette is open (dock registration,
// `when` context, or a client command update). Rebuild each crumb by id so the
// rendered rows and their actions always come from the live tree.
watch(() => commandsCtx.value.paletteCommands, (commands) => {
if (!show.value || breadcrumb.value.length === 0)
return
const scopeId = commandsCtx.value.paletteScopeId
const scopeWasActive = scopeId != null && breadcrumb.value.some(crumb => crumb.id === scopeId)
const next = reconcilePaletteTrail(commands, breadcrumb.value, scopeId)
breadcrumb.value = next
selectedIndex.value = Math.min(selectedIndex.value, Math.max(filtered.value.length - 1, 0))
if (scopeWasActive && scopeId != null && !next.some(crumb => crumb.id === scopeId))
commandsCtx.value.paletteScopeId = null
})

function moveSelected(delta: number) {
const len = filtered.value.length
if (len === 0)
Expand All @@ -121,18 +137,17 @@ function scrollToItem() {

const loadingId = ref<string | null>(null)

async function enterItem(flatItem: FlatItem) {
const entry = flatItem.entry
async function enterItem(flatItem: PaletteFlatItem) {
// The row may have been rendered just before the command tree changed. Look
// it up again so a removed entry no-ops and a replacement runs its new action.
const entry = activeItems.value.find(item => item.entry.id === flatItem.entry.id)?.entry
if (!entry)
return

// If has static children, drill down
if (entry.children && entry.children.length > 0) {
breadcrumb.value.push({
title: entry.title,
items: entry.children as DevframeCommandEntry[],
})
search.value = ''
selectedIndex.value = 0
dynamicItems.value = undefined
// Ordinary command parents drill down. Dock groups are actionable parents:
// their action opens a preferred member or scopes the palette for a choice.
if (resolvePaletteSelection(entry, props.context.docks.entries) === 'drill') {
commandsCtx.value.paletteScopeId = entry.id
return
}

Expand All @@ -151,6 +166,8 @@ async function enterItem(flatItem: FlatItem) {
catch (err) {
console.error(`[@devframes/hub-ui] Command "${entry.id}" failed:`, err)
}
if (paletteActionKeepsOpen(entry, props.context.docks.entries, commandsCtx.value.paletteOpen, commandsCtx.value.paletteScopeId))
return
close()
return
}
Expand Down Expand Up @@ -195,13 +212,22 @@ function goBack() {
}
if (breadcrumb.value.length > 0) {
breadcrumb.value.pop()
commandsCtx.value.paletteScopeId = paletteTrailScopeId(breadcrumb.value)
search.value = ''
selectedIndex.value = 0
return
}
close()
}

/** Jump to the level the crumb at `index` sits above. */
function goToCrumb(index: number) {
breadcrumb.value.splice(index)
commandsCtx.value.paletteScopeId = paletteTrailScopeId(breadcrumb.value)
search.value = ''
selectedIndex.value = 0
}

function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Backspace' && !search.value && (breadcrumb.value.length > 0 || dynamicItems.value)) {
e.preventDefault()
Expand Down Expand Up @@ -275,7 +301,7 @@ function getKeybindings(id: string) {
v-for="(crumb, i) in breadcrumb"
:key="i"
class="text-xs op60 hover:op80 mr-1 flex items-center gap-0.5"
@click="breadcrumb.splice(i); search = ''; selectedIndex = 0"
@click="goToCrumb(i)"
>
{{ crumb.title }}
<span class="op40">&rsaquo;</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { DevframeCommandEntry, DevframeCommandKeybinding } from '@devframes
import type { DocksContext } from '@devframes/hub/client'
import DisplayKbd from '@antfu/design/components/Display/DisplayKbd.vue'
import { computed, nextTick, ref, watch } from 'vue'
import { filterCommandsByWhen, formatKeybinding, isKeybindingOverrideDifferentFromDefault, isMac, KNOWN_BROWSER_SHORTCUTS } from '../../state/keybindings'
import { filterCommandsByWhen, findCommandDeep, formatKeybinding, isKeybindingOverrideDifferentFromDefault, isMac, KNOWN_BROWSER_SHORTCUTS, walkCommands } from '../../state/keybindings'
import { useSettings } from '../../state/settings-defaults'
import DockIcon from '../dock/DockIcon.vue'

Expand All @@ -19,7 +19,8 @@ const shortcutSearch = ref('')
interface ShortcutRow {
command: DevframeCommandEntry
parentTitle?: string
indent: boolean
/** Nesting level — 0 for a top-level command, +1 per ancestor. */
depth: number
}

// This page is only reachable with the dock open and the palette closed, so `when`
Expand All @@ -34,20 +35,24 @@ const availableCommands = computed(() => filterCommandsByWhen(
{ ...props.context.when.context, dockOpen: true, paletteOpen: false },
))

/**
* One row per command at every depth, in tree order, so anything the palette
* can run can be given a shortcut here.
*
* Nesting runs deeper than a parent and its children: a dock group's members sit
* two levels below the `Docks` command, and a devframe's own `children` go deeper
* still.
*/
const shortcutRows = computed<ShortcutRow[]>(() => {
const rows: ShortcutRow[] = []
for (const cmd of availableCommands.value) {
rows.push({ command: cmd, indent: false })
if (cmd.children) {
for (const child of cmd.children) {
rows.push({
command: child as DevframeCommandEntry,
parentTitle: cmd.title,
indent: true,
})
}
}
}
walkCommands(availableCommands.value, (cmd, ancestors) => {
const parentTitle = ancestors.at(-1)?.title
rows.push({
command: cmd,
...(parentTitle ? { parentTitle } : {}),
depth: ancestors.length,
})
})
return rows
})

Expand All @@ -66,6 +71,16 @@ function getEffectiveKeybindings(id: string): DevframeCommandKeybinding[] {
return commandsCtx.getKeybindings(id)
}

/**
* Indent one step per nesting level. An inline style rather than a class, since
* the depth is only known at runtime and UnoCSS generates utilities from source
* — a computed `ml-${depth * 6}` would never be emitted. One step is `ml-6`
* worth of space.
*/
function rowIndentStyle(row: ShortcutRow): Record<string, string> {
return row.depth > 0 ? { marginLeft: `${row.depth * 1.5}rem` } : {}
}

function isExecutable(command: DevframeCommandEntry): boolean {
return command.source === 'server' || !!command.action
}
Expand All @@ -75,16 +90,7 @@ function isOverridden(id: string): boolean {
}

function getDefaultKeybindings(id: string): DevframeCommandKeybinding[] {
for (const cmd of commandsCtx.commands) {
if (cmd.id === id)
return cmd.keybindings ?? []
if (cmd.children) {
const child = cmd.children.find(c => c.id === id)
if (child)
return child.keybindings ?? []
}
}
return []
return findCommandDeep(commandsCtx.commands, id)?.keybindings ?? []
}

function clearShortcut(commandId: string) {
Expand Down Expand Up @@ -281,9 +287,9 @@ watch(editorOpen, async (v) => {
v-if="row.command.icon"
:icon="row.command.icon"
class="w-4 h-4 shrink-0 op60"
:class="{ 'ml-6': row.indent }"
:style="rowIndentStyle(row)"
/>
<div v-else :class="{ 'ml-6': row.indent }" class="w-4 h-4 shrink-0" />
<div v-else :style="rowIndentStyle(row)" class="w-4 h-4 shrink-0" />
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<span class="truncate text-sm">{{ row.command.title }}</span>
Expand Down
Loading
Loading