Skip to content
Merged
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
9 changes: 9 additions & 0 deletions docs/content/1.guide/16.hub.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,15 @@ The first time any member of a group opens in a session, the hub also loads the

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

### Pinned and recent tools

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.

- **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.
- **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.

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.

### The dual role of `category`

`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`.
Expand Down
21 changes: 17 additions & 4 deletions packages/hub-ui/src/client/components/dock/Dock.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,18 @@ export const WithGroups: Story = floatStory(
{ entries: groupedEntries, panel: { position: 'bottom', left: 50, top: 100, inactiveTimeout: -1 } },
)

/**
* Pinned and recent group members: pinning Nuxt > Components gives it its own
* bar button (it also leads inside the Nuxt popover), and Nuxt > Pages, last
* opened from the popover, keeps a recent slot after the divider.
*/
export const WithPinnedAndRecentMembers: Story = floatStory({
entries: groupedEntries,
settings: { docksPinned: ['nuxt:components'] },
session: { recentDockIds: ['nuxt:pages'] },
panel: { position: 'bottom', left: 50, top: 100, inactiveTimeout: -1 },
})

/** Past capacity: an overflow button appears at the end of the bar. */
export const WithOverflow: Story = floatStory(
{ entries: overflowEntries, panel: { position: 'bottom', left: 50, top: 100, inactiveTimeout: -1 } },
Expand Down Expand Up @@ -137,15 +149,16 @@ export const LowerCapacity: Story = {

/**
* A recent dock raised out of the overflow: selecting an entry from the
* overflow popover reserves the slot between the divider and the overflow
* button for it, and deselecting it (or selecting a visible neighbour) keeps
* it there instead of folding it straight back into the overflow.
* overflow popover gives it its own slot after the divider, and deselecting
* it (or selecting a visible neighbour) keeps it there instead of folding it
* straight back into the overflow. Up to `docksRecentLimit` (default 3) docks
* keep a slot, newest first.
*/
export const WithRecentDock: Story = {
...floatStory({
entries: overflowEntries,
selectedId: 'network',
session: { recentDockId: 'network' },
session: { recentDockIds: ['network'] },
panel: { position: 'bottom', left: 50, top: 100, inactiveTimeout: -1 },
}),
args: { maxVisibleItems: 3 },
Expand Down
55 changes: 32 additions & 23 deletions packages/hub-ui/src/client/components/dock/Dock.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import { useEventListener, useScreenSafeArea, whenever } from '@vueuse/core'
import { computed, onMounted, reactive, ref, useTemplateRef, watch } from 'vue'
import { BUILTIN_ENTRY_CLIENT_AUTH_NOTICE } from '../../constants'
import { t } from '../../i18n'
import { docksSplitGroupsWithCapacity, getEntryGroup, resolveNextRecentDockId, resolveRecentDockEntry } from '../../state/dock-settings'
import { sharedStateToRef } from '../../state/docks'
import { docksSplitGroupsWithCapacity, getEntryGroup, resolveNextRecentDockIds, resolveRecentDockEntries } from '../../state/dock-settings'
import { setDocksOverflowPanel, useDocksGroupPanel } from '../../state/floating-tooltip'
import { useSettings } from '../../state/settings-defaults'
import { useIsRpcTrusted } from '../../utils/useIsRpcTrusted'
import BrandMark from '../icons/BrandMark.vue'
import {
Expand Down Expand Up @@ -95,44 +95,51 @@ const isRpcTrusted = useIsRpcTrusted(context, (isTrusted) => {

const groupedEntries = computed(() => context.docks.groupedEntries)

const settings = sharedStateToRef(context.docks.settings)

// The recent dock: the entry last selected from beyond the bar (the overflow
// popover or a group popover), raised into its own slot between the visible
// items and the overflow button so deselecting it keeps it one click away.
// Persisted per-tab in the session store; resolved to `null` when the id no
// longer maps to a raisable entry.
const recentEntry = computed(() => resolveRecentDockEntry({
entries: context.docks.entries,
groups: groupedEntries.value,
recentId: context.panel.session.recentDockId,
settings: settings.value,
whenContext: context.when.context,
}))
const settings = useSettings(context)

// The recent docks: the entries last selected from beyond the bar (the
// overflow popover or a group popover), raised into their own slots after the
// visible items so they stay one click away. Persisted per-tab in the session
// store (newest first); ids that no longer map to a raisable entry drop out.
// `recentDockId` is the older single-entry field, read until a tab writes
// `recentDockIds`.
const recentEntries = computed(() => {
const session = context.panel.session
return resolveRecentDockEntries({
entries: context.docks.entries,
groups: groupedEntries.value,
recentIds: session.recentDockIds ?? (session.recentDockId ? [session.recentDockId] : []),
limit: settings.value.docksRecentLimit,
settings: settings.value,
whenContext: context.when.context,
})
})

const splitEntries = computed(() => {
return docksSplitGroupsWithCapacity(groupedEntries.value, layout.value.maxVisibleItems, recentEntry.value)
return docksSplitGroupsWithCapacity(groupedEntries.value, layout.value.maxVisibleItems, recentEntries.value)
})

const selectedEntry = computed(() => {
return context.docks.selected
})

// Remember the recent dock whenever the selection lands on an entry that has
// Remember a recent dock whenever the selection lands on an entry that has
// no slot on the bar as currently rendered (grouped members always; top-level
// entries when picked from the overflow popover). Observing the selection
// (rather than the bar's own click handlers) also covers selections made via
// the command palette, an RPC activation, or the session restore.
watch(selectedEntry, (entry) => {
if (!entry)
return
context.panel.session.recentDockId = resolveNextRecentDockId({
context.panel.session.recentDockIds = resolveNextRecentDockIds({
groups: groupedEntries.value,
capacity: layout.value.maxVisibleItems,
recentEntry: recentEntry.value,
recentEntries: recentEntries.value,
selected: entry,
selectedIsGroupMember: !!getEntryGroup(context.docks.entries, entry),
limit: settings.value.docksRecentLimit,
})
context.panel.session.recentDockId = undefined
})

onMounted(async () => {
Expand Down Expand Up @@ -344,16 +351,18 @@ onMounted(() => {
@select="(e) => context.docks.switchEntry(e?.id)"
/>

<template v-if="splitEntries.overflow.length > 0">
<template v-if="splitEntries.recent.length > 0">
<div class="border-base m1 h-20px w-px border-r-1.5" />
<DockEntries
v-if="splitEntries.recent"
:context="context"
:entries="[splitEntries.recent]"
:entries="splitEntries.recent"
:is-vertical="context.panel.isVertical"
:selected="selectedEntry"
@select="(e) => context.docks.switchEntry(e?.id)"
/>
</template>
<template v-if="splitEntries.overflow.length > 0">
<div v-if="splitEntries.recent.length === 0" class="border-base m1 h-20px w-px border-r-1.5" />
<DockOverflowButton
:context="context"
:is-vertical="context.panel.isVertical"
Expand Down
79 changes: 76 additions & 3 deletions packages/hub-ui/src/client/components/dock/DockContextMenu.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { DevframeDockEntry } from '@devframes/hub'
import type { DocksContext } from '@devframes/hub/client'
import { h } from 'vue'
import { defineComponent, h, nextTick, onMounted, ref } from 'vue'
import { t } from '../../i18n'
import { setDockContextMenu } from '../../state/floating-tooltip'
import { isDockPopupSupported, requestDockPopupOpen, useIsDockPopupOpen } from '../../state/popup'
Expand All @@ -16,14 +16,64 @@ interface DockMenuItem {

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

/**
* The menu body. Takes focus on open so a keyboard user (Shift+F10 or the
* Menu key fire `contextmenu` on the focused dock button) can pick an item
* right away: arrows and Home/End move between items, Escape closes the menu
* and hands focus back to the trigger, Tab closes it.
*/
const DockContextMenuList = defineComponent({
name: 'DockContextMenuList',
props: {
items: { type: Array as () => DockMenuItem[], required: true },
trigger: { type: Object as () => HTMLElement, required: true },
},
setup(props) {
const root = ref<HTMLElement>()
const buttons = () => Array.from(root.value?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ?? [])

onMounted(() => nextTick(() => buttons()[0]?.focus({ preventScroll: true })))

function onKeydown(e: KeyboardEvent) {
const list = buttons()
const index = list.indexOf(e.target as HTMLButtonElement)
const move = (to: number) => {
e.preventDefault()
list[(to + list.length) % list.length]?.focus()
}
switch (e.key) {
case 'ArrowDown': return move(index + 1)
case 'ArrowUp': return move(index - 1)
case 'Home': return move(0)
case 'End': return move(list.length - 1)
case 'Escape':
e.preventDefault()
setDockContextMenu(null)
props.trigger.focus({ preventScroll: true })
return
case 'Tab':
setDockContextMenu(null)
}
}

return () => h('div', {
ref: root,
role: 'menu',
class: 'devframes-dock-menu flex flex-col text-sm min-w-36 mx--1',
onKeydown,
}, props.items.map(renderMenuItem))
},
})

function hideDock(context: DocksContext, entry: DevframeDockEntry) {
const settingsStore = context.docks.settings
const id = entry.id
Expand All @@ -49,6 +99,22 @@ function refreshDock(context: DocksContext, entry: DevframeDockEntry) {
setDockContextMenu(null)
}

function togglePinDock(context: DocksContext, entry: DevframeDockEntry) {
const id = entry.id
context.docks.settings.mutate((state) => {
state.docksPinned = state.docksPinned.includes(id)
? state.docksPinned.filter(item => item !== id)
: [...state.docksPinned, id]
})
setDockContextMenu(null)
}

function canPin(context: DocksContext, entry: DevframeDockEntry) {
if (entry.id.startsWith('~'))
return false
return context.docks.entries.some(item => item.id === entry.id)
}

function canHide(context: DocksContext, entry: DevframeDockEntry) {
if (entry.id === '~settings')
return false
Expand All @@ -67,7 +133,14 @@ export function openDockContextMenu(options: {
}) {
const { context, entry, el, gap = 6 } = options
const isEdgeMode = context.panel.store.mode === 'edge'
const isPinned = context.docks.settings.value().docksPinned.includes(entry.id)
const items: DockMenuItem[] = [
{
label: isPinned ? t('docks.unpin') : t('docks.pin'),
icon: isPinned ? 'i-ph-push-pin-slash-duotone' : 'i-ph-push-pin-duotone',
action: () => togglePinDock(context, entry),
visible: canPin(context, entry),
},
{
label: t('common.hide'),
icon: 'i-ph-eye-slash-duotone',
Expand Down Expand Up @@ -131,6 +204,6 @@ export function openDockContextMenu(options: {
setDockContextMenu({
el,
gap,
content: () => h('div', { class: 'flex flex-col text-sm min-w-36 mx--1' }, items.map(renderMenuItem)),
content: () => h(DockContextMenuList, { items, trigger: el }),
})
}
11 changes: 11 additions & 0 deletions packages/hub-ui/src/client/components/dock/DockGroupPopover.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { DocksContext } from '@devframes/hub/client'
import { computed } from 'vue'
import { t } from '../../i18n'
import { accentVarStyle } from '../../utils/accent-color'
import { openDockContextMenu } from './DockContextMenu'
import DockIcon from './DockIcon.vue'

const props = defineProps<{
Expand All @@ -19,6 +20,15 @@ const emit = defineEmits<{
}>()

const isEmpty = computed(() => props.members.every(([, items]) => items.length === 0))

function openContextMenu(e: MouseEvent, member: DevframeDockEntry) {
e.preventDefault()
openDockContextMenu({
context: props.context,
entry: member,
el: e.currentTarget as HTMLElement,
})
}
</script>

<template>
Expand Down Expand Up @@ -47,6 +57,7 @@ const isEmpty = computed(() => props.members.every(([, items]) => items.length =
class="flex items-center gap-2 w-full px2 py1.5 rounded text-sm text-left transition"
:class="selectedId === member.id ? 'text-primary bg-active' : 'op80 hover:op100 hover:bg-active'"
@click="emit('select', member)"
@contextmenu="openContextMenu($event, member)"
>
<DockIcon :icon="member.icon" class="w-4.5 h-4.5 flex-none" />
<span class="truncate flex-1">{{ member.title }}</span>
Expand Down
12 changes: 12 additions & 0 deletions packages/hub-ui/src/client/components/dock/DockGroupSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { deriveSidebarCapacity, docksSplitGroupsWithCapacity, getGroupMembersGro
import { setDocksSidebarOverflowPanel, setFloatingTooltip, useDocksSidebarOverflowPanel } from '../../state/floating-tooltip'
import { useSettings } from '../../state/settings-defaults'
import { accentVarStyle } from '../../utils/accent-color'
import { openDockContextMenu } from './DockContextMenu'
import DockGroupPopover from './DockGroupPopover.vue'
import DockIcon from './DockIcon.vue'

Expand Down Expand Up @@ -147,6 +148,16 @@ watchDebounced(
{ debounce: 1000 },
)

function openContextMenu(e: MouseEvent, member: DevframeDockEntry) {
e.preventDefault()
hideTooltip()
openDockContextMenu({
context: props.context,
entry: member,
el: e.currentTarget as HTMLElement,
})
}

// Highlight the button when the hidden selection lives here, or while open.
const moreButtonActive = computed(() => selectedInOverflow.value || isOverflowPanelVisible.value)
</script>
Expand Down Expand Up @@ -185,6 +196,7 @@ const moreButtonActive = computed(() => selectedInOverflow.value || isOverflowPa
@pointerleave="hideTooltip"
@pointerdown="hideTooltip"
@click="select(member.id)"
@contextmenu="openContextMenu($event, member)"
>
<DockIcon :icon="member.icon" class="w-5 h-5 flex-none" />
<div
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ const localeOptions = computed(() => [
...Object.entries(HUB_UI_LOCALES).map(([value, label]) => ({ value, label })),
])

// `0` turns the recent slots off; the upper bound keeps the bar short.
const recentLimitOptions = computed(() => [
{ value: '0', label: t('appearance.recentDocksOff') },
...[1, 2, 3, 4, 5].map(count => ({ value: String(count), label: String(count) })),
])

function onRecentLimitChange(value: string | undefined): void {
const limit = Number(value)
if (Number.isInteger(limit) && limit >= 0)
settingsStore.mutate((s) => { s.docksRecentLimit = limit })
}

function onLocaleChange(value: string | undefined): void {
setLocalePreference(matchLocale(value) ?? 'auto')
}
Expand Down Expand Up @@ -121,6 +133,21 @@ function setDockMode(mode: string) {
</div>
</div>

<!-- Recent docks on the float bar -->
<div class="flex flex-col gap-2">
<div class="flex flex-col">
<span class="text-sm">{{ t('appearance.recentDocks') }}</span>
<span class="text-xs op50">{{ t('appearance.recentDocksHint') }}</span>
</div>
<FormSelect
:model-value="String(settings.docksRecentLimit)"
:options="recentLimitOptions"
:aria-label="t('appearance.recentDocks')"
class="w-fit"
@update:model-value="onRecentLimitChange"
/>
</div>

<!-- Show iframe address bar toggle -->
<label class="flex items-center gap-3 cursor-pointer group">
<button
Expand Down
3 changes: 3 additions & 0 deletions packages/hub-ui/src/client/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@
"appearance.closeOutsideHint": "Schließt das {productName}-Panel bei einem Klick außerhalb (nur im eingebetteten Modus)",
"appearance.autoCollapse": "Randleiste automatisch einklappen",
"appearance.autoCollapseHint": "Verkleinert die am Rand angedockte Leiste zu einem kleinen Griff, wenn sie inaktiv und geschlossen ist (nur im Randmodus)",
"appearance.recentDocks": "Zuletzt verwendete Tools in der Dock-Leiste",
"appearance.recentDocksHint": "Zuletzt aus einer Gruppe oder dem Überlaufmenü geöffnete Tools in der Dock-Leiste behalten (nur im Float-Modus).",
"appearance.recentDocksOff": "Aus",

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