Skip to content

Commit 7889f1a

Browse files
antfubotclaude
andauthored
feat(hub-ui): pin group members to the dock bar and keep recent tools (#470)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 0d6b575 commit 7889f1a

26 files changed

Lines changed: 501 additions & 209 deletions

File tree

‎docs/content/1.guide/16.hub.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,15 @@ The first time any member of a group opens in a session, the hub also loads the
245245

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

248+
### Pinned and recent tools
249+
250+
The reference hub UI (`@devframes/hub-ui`) lets users reach a group member in one click. A devframe does not need to set anything for this.
251+
252+
- **Pin.** Right-click a dock button (on the bar, in a group popover, or in the group sidebar) and pick **Pin**. Keyboard users press Shift+F10 or the Menu key on a focused button. A pinned entry moves to the front of the bar. A pinned group member also gets its own bar button, and it still leads inside its group. Opening it opens that member, and its group button shows as active. Pins are stored in the `docksPinned` user setting.
253+
- **Recent.** When a user opens a tool from a group popover or the overflow menu, the float bar keeps it in a slot after the bar items, newest first. The slots are added on top of the bar's capacity, so opening a tool never pushes another button into the overflow. A tool that is already on the bar (for example a pinned member) does not take a slot. The list is kept per browser tab, in the `recentDockIds` field of the session store.
254+
255+
The `docksRecentLimit` user setting sets how many recent slots the bar keeps. The default is `3`. `0` turns the slots off. Users change it under **Settings > Appearance > Recent tools on the dock bar**. The edge-mode toolbar shows pinned entries but no recent slots.
256+
248257
### The dual role of `category`
249258

250259
`category` (ordered by `DEFAULT_CATEGORIES_ORDER`, default `'default'`) sets an **ungrouped** entry's outer dock-rail bucket. A **grouped** entry takes its outer bucket from the **group's** `category`, and its own `category` becomes an **in-group sub-category**; a member whose `groupId` never resolves renders top-level under its own `category`.

‎packages/hub-ui/src/client/components/dock/Dock.stories.ts‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,18 @@ export const WithGroups: Story = floatStory(
9292
{ entries: groupedEntries, panel: { position: 'bottom', left: 50, top: 100, inactiveTimeout: -1 } },
9393
)
9494

95+
/**
96+
* Pinned and recent group members: pinning Nuxt > Components gives it its own
97+
* bar button (it also leads inside the Nuxt popover), and Nuxt > Pages, last
98+
* opened from the popover, keeps a recent slot after the divider.
99+
*/
100+
export const WithPinnedAndRecentMembers: Story = floatStory({
101+
entries: groupedEntries,
102+
settings: { docksPinned: ['nuxt:components'] },
103+
session: { recentDockIds: ['nuxt:pages'] },
104+
panel: { position: 'bottom', left: 50, top: 100, inactiveTimeout: -1 },
105+
})
106+
95107
/** Past capacity: an overflow button appears at the end of the bar. */
96108
export const WithOverflow: Story = floatStory(
97109
{ entries: overflowEntries, panel: { position: 'bottom', left: 50, top: 100, inactiveTimeout: -1 } },
@@ -137,15 +149,16 @@ export const LowerCapacity: Story = {
137149

138150
/**
139151
* A recent dock raised out of the overflow: selecting an entry from the
140-
* overflow popover reserves the slot between the divider and the overflow
141-
* button for it, and deselecting it (or selecting a visible neighbour) keeps
142-
* it there instead of folding it straight back into the overflow.
152+
* overflow popover gives it its own slot after the divider, and deselecting
153+
* it (or selecting a visible neighbour) keeps it there instead of folding it
154+
* straight back into the overflow. Up to `docksRecentLimit` (default 3) docks
155+
* keep a slot, newest first.
143156
*/
144157
export const WithRecentDock: Story = {
145158
...floatStory({
146159
entries: overflowEntries,
147160
selectedId: 'network',
148-
session: { recentDockId: 'network' },
161+
session: { recentDockIds: ['network'] },
149162
panel: { position: 'bottom', left: 50, top: 100, inactiveTimeout: -1 },
150163
}),
151164
args: { maxVisibleItems: 3 },

‎packages/hub-ui/src/client/components/dock/Dock.vue‎

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ import { useEventListener, useScreenSafeArea, whenever } from '@vueuse/core'
66
import { computed, onMounted, reactive, ref, useTemplateRef, watch } from 'vue'
77
import { BUILTIN_ENTRY_CLIENT_AUTH_NOTICE } from '../../constants'
88
import { t } from '../../i18n'
9-
import { docksSplitGroupsWithCapacity, getEntryGroup, resolveNextRecentDockId, resolveRecentDockEntry } from '../../state/dock-settings'
10-
import { sharedStateToRef } from '../../state/docks'
9+
import { docksSplitGroupsWithCapacity, getEntryGroup, resolveNextRecentDockIds, resolveRecentDockEntries } from '../../state/dock-settings'
1110
import { setDocksOverflowPanel, useDocksGroupPanel } from '../../state/floating-tooltip'
11+
import { useSettings } from '../../state/settings-defaults'
1212
import { useIsRpcTrusted } from '../../utils/useIsRpcTrusted'
1313
import BrandMark from '../icons/BrandMark.vue'
1414
import {
@@ -95,44 +95,51 @@ const isRpcTrusted = useIsRpcTrusted(context, (isTrusted) => {
9595
9696
const groupedEntries = computed(() => context.docks.groupedEntries)
9797
98-
const settings = sharedStateToRef(context.docks.settings)
99-
100-
// The recent dock: the entry last selected from beyond the bar (the overflow
101-
// popover or a group popover), raised into its own slot between the visible
102-
// items and the overflow button so deselecting it keeps it one click away.
103-
// Persisted per-tab in the session store; resolved to `null` when the id no
104-
// longer maps to a raisable entry.
105-
const recentEntry = computed(() => resolveRecentDockEntry({
106-
entries: context.docks.entries,
107-
groups: groupedEntries.value,
108-
recentId: context.panel.session.recentDockId,
109-
settings: settings.value,
110-
whenContext: context.when.context,
111-
}))
98+
const settings = useSettings(context)
99+
100+
// The recent docks: the entries last selected from beyond the bar (the
101+
// overflow popover or a group popover), raised into their own slots after the
102+
// visible items so they stay one click away. Persisted per-tab in the session
103+
// store (newest first); ids that no longer map to a raisable entry drop out.
104+
// `recentDockId` is the older single-entry field, read until a tab writes
105+
// `recentDockIds`.
106+
const recentEntries = computed(() => {
107+
const session = context.panel.session
108+
return resolveRecentDockEntries({
109+
entries: context.docks.entries,
110+
groups: groupedEntries.value,
111+
recentIds: session.recentDockIds ?? (session.recentDockId ? [session.recentDockId] : []),
112+
limit: settings.value.docksRecentLimit,
113+
settings: settings.value,
114+
whenContext: context.when.context,
115+
})
116+
})
112117
113118
const splitEntries = computed(() => {
114-
return docksSplitGroupsWithCapacity(groupedEntries.value, layout.value.maxVisibleItems, recentEntry.value)
119+
return docksSplitGroupsWithCapacity(groupedEntries.value, layout.value.maxVisibleItems, recentEntries.value)
115120
})
116121
117122
const selectedEntry = computed(() => {
118123
return context.docks.selected
119124
})
120125
121-
// Remember the recent dock whenever the selection lands on an entry that has
126+
// Remember a recent dock whenever the selection lands on an entry that has
122127
// no slot on the bar as currently rendered (grouped members always; top-level
123128
// entries when picked from the overflow popover). Observing the selection
124129
// (rather than the bar's own click handlers) also covers selections made via
125130
// the command palette, an RPC activation, or the session restore.
126131
watch(selectedEntry, (entry) => {
127132
if (!entry)
128133
return
129-
context.panel.session.recentDockId = resolveNextRecentDockId({
134+
context.panel.session.recentDockIds = resolveNextRecentDockIds({
130135
groups: groupedEntries.value,
131136
capacity: layout.value.maxVisibleItems,
132-
recentEntry: recentEntry.value,
137+
recentEntries: recentEntries.value,
133138
selected: entry,
134139
selectedIsGroupMember: !!getEntryGroup(context.docks.entries, entry),
140+
limit: settings.value.docksRecentLimit,
135141
})
142+
context.panel.session.recentDockId = undefined
136143
})
137144
138145
onMounted(async () => {
@@ -344,16 +351,18 @@ onMounted(() => {
344351
@select="(e) => context.docks.switchEntry(e?.id)"
345352
/>
346353

347-
<template v-if="splitEntries.overflow.length > 0">
354+
<template v-if="splitEntries.recent.length > 0">
348355
<div class="border-base m1 h-20px w-px border-r-1.5" />
349356
<DockEntries
350-
v-if="splitEntries.recent"
351357
:context="context"
352-
:entries="[splitEntries.recent]"
358+
:entries="splitEntries.recent"
353359
:is-vertical="context.panel.isVertical"
354360
:selected="selectedEntry"
355361
@select="(e) => context.docks.switchEntry(e?.id)"
356362
/>
363+
</template>
364+
<template v-if="splitEntries.overflow.length > 0">
365+
<div v-if="splitEntries.recent.length === 0" class="border-base m1 h-20px w-px border-r-1.5" />
357366
<DockOverflowButton
358367
:context="context"
359368
:is-vertical="context.panel.isVertical"

‎packages/hub-ui/src/client/components/dock/DockContextMenu.ts‎

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { DevframeDockEntry } from '@devframes/hub'
22
import type { DocksContext } from '@devframes/hub/client'
3-
import { h } from 'vue'
3+
import { defineComponent, h, nextTick, onMounted, ref } from 'vue'
44
import { t } from '../../i18n'
55
import { setDockContextMenu } from '../../state/floating-tooltip'
66
import { isDockPopupSupported, requestDockPopupOpen, useIsDockPopupOpen } from '../../state/popup'
@@ -16,14 +16,64 @@ interface DockMenuItem {
1616

1717
function renderMenuItem(item: DockMenuItem) {
1818
return h('button', {
19-
class: 'flex items-center gap-2 px3 py1.5 rounded hover:bg-active transition text-left',
19+
role: 'menuitem',
20+
class: 'flex items-center gap-2 px3 py1.5 rounded hover:bg-active focus-visible:bg-active outline-none transition text-left',
2021
onClick: item.action,
2122
}, [
2223
h('div', { class: `${item.icon} text-base op60` }),
2324
h('span', item.label),
2425
])
2526
}
2627

28+
/**
29+
* The menu body. Takes focus on open so a keyboard user (Shift+F10 or the
30+
* Menu key fire `contextmenu` on the focused dock button) can pick an item
31+
* right away: arrows and Home/End move between items, Escape closes the menu
32+
* and hands focus back to the trigger, Tab closes it.
33+
*/
34+
const DockContextMenuList = defineComponent({
35+
name: 'DockContextMenuList',
36+
props: {
37+
items: { type: Array as () => DockMenuItem[], required: true },
38+
trigger: { type: Object as () => HTMLElement, required: true },
39+
},
40+
setup(props) {
41+
const root = ref<HTMLElement>()
42+
const buttons = () => Array.from(root.value?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ?? [])
43+
44+
onMounted(() => nextTick(() => buttons()[0]?.focus({ preventScroll: true })))
45+
46+
function onKeydown(e: KeyboardEvent) {
47+
const list = buttons()
48+
const index = list.indexOf(e.target as HTMLButtonElement)
49+
const move = (to: number) => {
50+
e.preventDefault()
51+
list[(to + list.length) % list.length]?.focus()
52+
}
53+
switch (e.key) {
54+
case 'ArrowDown': return move(index + 1)
55+
case 'ArrowUp': return move(index - 1)
56+
case 'Home': return move(0)
57+
case 'End': return move(list.length - 1)
58+
case 'Escape':
59+
e.preventDefault()
60+
setDockContextMenu(null)
61+
props.trigger.focus({ preventScroll: true })
62+
return
63+
case 'Tab':
64+
setDockContextMenu(null)
65+
}
66+
}
67+
68+
return () => h('div', {
69+
ref: root,
70+
role: 'menu',
71+
class: 'devframes-dock-menu flex flex-col text-sm min-w-36 mx--1',
72+
onKeydown,
73+
}, props.items.map(renderMenuItem))
74+
},
75+
})
76+
2777
function hideDock(context: DocksContext, entry: DevframeDockEntry) {
2878
const settingsStore = context.docks.settings
2979
const id = entry.id
@@ -49,6 +99,22 @@ function refreshDock(context: DocksContext, entry: DevframeDockEntry) {
4999
setDockContextMenu(null)
50100
}
51101

102+
function togglePinDock(context: DocksContext, entry: DevframeDockEntry) {
103+
const id = entry.id
104+
context.docks.settings.mutate((state) => {
105+
state.docksPinned = state.docksPinned.includes(id)
106+
? state.docksPinned.filter(item => item !== id)
107+
: [...state.docksPinned, id]
108+
})
109+
setDockContextMenu(null)
110+
}
111+
112+
function canPin(context: DocksContext, entry: DevframeDockEntry) {
113+
if (entry.id.startsWith('~'))
114+
return false
115+
return context.docks.entries.some(item => item.id === entry.id)
116+
}
117+
52118
function canHide(context: DocksContext, entry: DevframeDockEntry) {
53119
if (entry.id === '~settings')
54120
return false
@@ -67,7 +133,14 @@ export function openDockContextMenu(options: {
67133
}) {
68134
const { context, entry, el, gap = 6 } = options
69135
const isEdgeMode = context.panel.store.mode === 'edge'
136+
const isPinned = context.docks.settings.value().docksPinned.includes(entry.id)
70137
const items: DockMenuItem[] = [
138+
{
139+
label: isPinned ? t('docks.unpin') : t('docks.pin'),
140+
icon: isPinned ? 'i-ph-push-pin-slash-duotone' : 'i-ph-push-pin-duotone',
141+
action: () => togglePinDock(context, entry),
142+
visible: canPin(context, entry),
143+
},
71144
{
72145
label: t('common.hide'),
73146
icon: 'i-ph-eye-slash-duotone',
@@ -131,6 +204,6 @@ export function openDockContextMenu(options: {
131204
setDockContextMenu({
132205
el,
133206
gap,
134-
content: () => h('div', { class: 'flex flex-col text-sm min-w-36 mx--1' }, items.map(renderMenuItem)),
207+
content: () => h(DockContextMenuList, { items, trigger: el }),
135208
})
136209
}

‎packages/hub-ui/src/client/components/dock/DockGroupPopover.vue‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { DocksContext } from '@devframes/hub/client'
44
import { computed } from 'vue'
55
import { t } from '../../i18n'
66
import { accentVarStyle } from '../../utils/accent-color'
7+
import { openDockContextMenu } from './DockContextMenu'
78
import DockIcon from './DockIcon.vue'
89
910
const props = defineProps<{
@@ -19,6 +20,15 @@ const emit = defineEmits<{
1920
}>()
2021
2122
const isEmpty = computed(() => props.members.every(([, items]) => items.length === 0))
23+
24+
function openContextMenu(e: MouseEvent, member: DevframeDockEntry) {
25+
e.preventDefault()
26+
openDockContextMenu({
27+
context: props.context,
28+
entry: member,
29+
el: e.currentTarget as HTMLElement,
30+
})
31+
}
2232
</script>
2333

2434
<template>
@@ -47,6 +57,7 @@ const isEmpty = computed(() => props.members.every(([, items]) => items.length =
4757
class="flex items-center gap-2 w-full px2 py1.5 rounded text-sm text-left transition"
4858
:class="selectedId === member.id ? 'text-primary bg-active' : 'op80 hover:op100 hover:bg-active'"
4959
@click="emit('select', member)"
60+
@contextmenu="openContextMenu($event, member)"
5061
>
5162
<DockIcon :icon="member.icon" class="w-4.5 h-4.5 flex-none" />
5263
<span class="truncate flex-1">{{ member.title }}</span>

‎packages/hub-ui/src/client/components/dock/DockGroupSidebar.vue‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { deriveSidebarCapacity, docksSplitGroupsWithCapacity, getGroupMembersGro
88
import { setDocksSidebarOverflowPanel, setFloatingTooltip, useDocksSidebarOverflowPanel } from '../../state/floating-tooltip'
99
import { useSettings } from '../../state/settings-defaults'
1010
import { accentVarStyle } from '../../utils/accent-color'
11+
import { openDockContextMenu } from './DockContextMenu'
1112
import DockGroupPopover from './DockGroupPopover.vue'
1213
import DockIcon from './DockIcon.vue'
1314
@@ -147,6 +148,16 @@ watchDebounced(
147148
{ debounce: 1000 },
148149
)
149150
151+
function openContextMenu(e: MouseEvent, member: DevframeDockEntry) {
152+
e.preventDefault()
153+
hideTooltip()
154+
openDockContextMenu({
155+
context: props.context,
156+
entry: member,
157+
el: e.currentTarget as HTMLElement,
158+
})
159+
}
160+
150161
// Highlight the button when the hidden selection lives here, or while open.
151162
const moreButtonActive = computed(() => selectedInOverflow.value || isOverflowPanelVisible.value)
152163
</script>
@@ -185,6 +196,7 @@ const moreButtonActive = computed(() => selectedInOverflow.value || isOverflowPa
185196
@pointerleave="hideTooltip"
186197
@pointerdown="hideTooltip"
187198
@click="select(member.id)"
199+
@contextmenu="openContextMenu($event, member)"
188200
>
189201
<DockIcon :icon="member.icon" class="w-5 h-5 flex-none" />
190202
<div

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,18 @@ const localeOptions = computed(() => [
4646
...Object.entries(HUB_UI_LOCALES).map(([value, label]) => ({ value, label })),
4747
])
4848
49+
// `0` turns the recent slots off; the upper bound keeps the bar short.
50+
const recentLimitOptions = computed(() => [
51+
{ value: '0', label: t('appearance.recentDocksOff') },
52+
...[1, 2, 3, 4, 5].map(count => ({ value: String(count), label: String(count) })),
53+
])
54+
55+
function onRecentLimitChange(value: string | undefined): void {
56+
const limit = Number(value)
57+
if (Number.isInteger(limit) && limit >= 0)
58+
settingsStore.mutate((s) => { s.docksRecentLimit = limit })
59+
}
60+
4961
function onLocaleChange(value: string | undefined): void {
5062
setLocalePreference(matchLocale(value) ?? 'auto')
5163
}
@@ -121,6 +133,21 @@ function setDockMode(mode: string) {
121133
</div>
122134
</div>
123135

136+
<!-- Recent docks on the float bar -->
137+
<div class="flex flex-col gap-2">
138+
<div class="flex flex-col">
139+
<span class="text-sm">{{ t('appearance.recentDocks') }}</span>
140+
<span class="text-xs op50">{{ t('appearance.recentDocksHint') }}</span>
141+
</div>
142+
<FormSelect
143+
:model-value="String(settings.docksRecentLimit)"
144+
:options="recentLimitOptions"
145+
:aria-label="t('appearance.recentDocks')"
146+
class="w-fit"
147+
@update:model-value="onRecentLimitChange"
148+
/>
149+
</div>
150+
124151
<!-- Show iframe address bar toggle -->
125152
<label class="flex items-center gap-3 cursor-pointer group">
126153
<button

‎packages/hub-ui/src/client/i18n/locales/de.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@
6868
"appearance.closeOutsideHint": "Schließt das {productName}-Panel bei einem Klick außerhalb (nur im eingebetteten Modus)",
6969
"appearance.autoCollapse": "Randleiste automatisch einklappen",
7070
"appearance.autoCollapseHint": "Verkleinert die am Rand angedockte Leiste zu einem kleinen Griff, wenn sie inaktiv und geschlossen ist (nur im Randmodus)",
71+
"appearance.recentDocks": "Zuletzt verwendete Tools in der Dock-Leiste",
72+
"appearance.recentDocksHint": "Zuletzt aus einer Gruppe oder dem Überlaufmenü geöffnete Tools in der Dock-Leiste behalten (nur im Float-Modus).",
73+
"appearance.recentDocksOff": "Aus",
7174

7275
"docks.intro": "Sichtbarkeit und Reihenfolge der Dock-Einträge verwalten. Ausgeblendete Einträge erscheinen nicht in der Dock-Leiste.",
7376
"docks.resetOrder": "Eigene Reihenfolge zurücksetzen",

0 commit comments

Comments
 (0)