Skip to content
Draft
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
30 changes: 28 additions & 2 deletions docs/content/1.guide/15.hub.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,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 @@ -199,7 +215,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 @@ -212,7 +228,17 @@ ctx.docks.register({
})
```

Group and members stay independent top-level entries in `devframe:docks`; `defaultChildId` opens on activation. Grouping affects the dock bar, 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 shapes the dock bar; each member keeps its own iframe — 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.

**Clicking** the dock-bar button opens `defaultChildId` when the group declares one, and reveals the member popover otherwise.

**By id** — a keyboard shortcut on the group, a command-palette pick, or a `hub:docks:activate` call — opens the member the group points at: `defaultChildId`, or the only visible member when there is exactly one. A group with several members and no `defaultChildId` opens the command palette listing just those members, so the choice stays with the user and the group remains reachable by keyboard alone. Pressing the same shortcut again closes that palette.

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/1.guide/16.client-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ A second boot replaces the context and warns; `dispose()` tears down listeners a
| `clientType` | `'embedded'` (inside your app) or `'standalone'` (independent hub page). |
| `docks` | `entries`, `selected`, `groupedEntries`, `switchEntry()`, `toggleEntry()`, `getStateById()`, `register()` / `update()` for [client-only docks](#client-only-docks). |
| `panel` | Dock panel state: position, size, drag/resize. |
| `commands` | Command palette: `register()`, `execute()`, `getKeybindings()`. |
| `commands` | Command palette: `register()`, `execute()`, `getKeybindings()`, `openPalette(atCommandId?)` — with an id, the palette opens drilled into that command's children and records it in `paletteScopeId` (how [activating a group](/guide/hub#activating-a-group) offers its members). |
| `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](/guide/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 { 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, paletteScopeTrail } 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,17 @@ 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
breadcrumb.value = paletteScopeTrail(commandsCtx.value.paletteCommands, scopeId)
}

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 +84,22 @@ 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 && scopeId)
showScope(scopeId)
})

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

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

async function enterItem(flatItem: FlatItem) {
async function enterItem(flatItem: PaletteFlatItem) {
const entry = flatItem.entry

// If has static children, drill down
Expand Down Expand Up @@ -195,13 +193,32 @@ function goBack() {
}
if (breadcrumb.value.length > 0) {
breadcrumb.value.pop()
dropScopeAtRoot()
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)
dropScopeAtRoot()
search.value = ''
selectedIndex.value = 0
}

/**
* Stepping back out to the root list leaves the palette unscoped, so the
* shortcut that scoped it drills back in instead of reading as "press again to
* close".
*/
function dropScopeAtRoot() {
if (breadcrumb.value.length === 0)
commandsCtx.value.paletteScopeId = null
}

function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Backspace' && !search.value && (breadcrumb.value.length > 0 || dynamicItems.value)) {
e.preventDefault()
Expand Down Expand Up @@ -275,7 +292,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 @@ -4,7 +4,7 @@ import type { DocksContext } from '@devframes/hub/client'
import DisplayKbd from '@antfu/design/components/Display/DisplayKbd.vue'
import { computed, nextTick, ref, watch } from 'vue'
import { sharedStateToRef } from '../../state/docks'
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 DockIcon from '../dock/DockIcon.vue'

const props = defineProps<{
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 host'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
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { DevframeViewBuiltin } from '@devframes/hub'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { h } from 'vue'
import { groupedEntries } from '../../stories/fixtures'
import { groupedEntries, subcategorizedGroupEntries } from '../../stories/fixtures'
import { mountWithContext } from '../../stories/story-helpers'
import ViewBuiltinSettings from './ViewBuiltinSettings.vue'

Expand Down Expand Up @@ -51,3 +51,19 @@ export const Standalone: Story = {
),
}),
}

/**
* A group whose members split into in-group sub-categories — open the
* **Shortcuts** tab to see them listed directly under their group (`Docks` ›
* Tools › a member), each indented by nesting level and bindable like any other
* command. The bar's sub-category dividers stay in the bar; every row here is
* something you can actually run.
*/
export const DeeplyNestedShortcuts: Story = {
render: () => ({
setup: () => mountWithContext(
{ entries: subcategorizedGroupEntries, clientType: 'embedded' },
ctx => stage(h(ViewBuiltinSettings, { context: ctx, entry })),
),
}),
}
Loading
Loading