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
2 changes: 1 addition & 1 deletion docs/content/1.guide/16.hub.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ ctx.docks.register({
})
```

Group and members stay independent top-level entries in `devframe:docks`; `defaultChildId` opens on activation. 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`. 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)).

### The dual role of `category`

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const meta = {
parameters: {
docs: {
description: {
component: 'The dock-bar button representing a group. Click behaviour depends on the group: a group with `defaultChildId` opens that member directly, otherwise it reveals a popover of members. `FloatingElements` is mounted alongside so the popover renders.',
component: 'The dock-bar button representing a group. Clicking opens the member last opened in the group (remembered per tab), then the group\'s `defaultChildId`; with neither it reveals a popover of members. `FloatingElements` is mounted alongside so the popover renders.',
},
},
},
Expand All @@ -30,8 +30,9 @@ export default meta
type Story = StoryObj

/**
* A popover-only group (no `defaultChildId`): clicking reveals the member
* popover.
* A popover-only group (no `defaultChildId`): the first click reveals the
* member popover. Picking a member records it as the group's last-opened
* child, so later clicks reopen it directly.
*/
export const PopoverOnly: Story = {
render: () => ({
Expand All @@ -53,7 +54,8 @@ export const PopoverOnly: Story = {

/**
* A group with a `defaultChildId`: clicking opens that member straight away
* instead of showing the popover.
* instead of showing the popover — until another member becomes the group's
* last-opened child, which then takes precedence.
*/
export const WithDefaultChild: Story = {
render: () => ({
Expand Down
15 changes: 8 additions & 7 deletions packages/hub-ui/src/client/components/dock/DockGroupButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { DevframeDockEntry, DevframeViewGroup } from '@devframes/hub'
import type { DocksContext } from '@devframes/hub/client'
import { watchDebounced } from '@vueuse/core'
import { computed, h, ref, useTemplateRef } from 'vue'
import { getGroupMembers, getGroupMembersGrouped, resolveGroupDefaultChild } from '../../state/dock-settings'
import { getGroupMembers, getGroupMembersGrouped, resolveGroupPreferredChild } from '../../state/dock-settings'
import { setDocksGroupPanel, useDocksGroupPanel } from '../../state/floating-tooltip'
import { useSettings } from '../../state/settings-defaults'
import { accentVarStyle } from '../../utils/accent-color'
Expand Down Expand Up @@ -101,13 +101,14 @@ function onClick() {
emit('select', undefined!)
return
}
// `defaultChildId` opens its member directly; otherwise reveal the popover.
// Resolved regardless of the target's render-only `visibility` (a hidden
// button must still fire), but honoring its `when` clause.
const fallback = resolveGroupDefaultChild(
// The member last opened in this group this tab — then the author's
// `defaultChildId` — opens directly; otherwise reveal the popover. Resolved
// regardless of the target's render-only `visibility` (a hidden button must
// still fire), but honoring its `when` clause.
const fallback = resolveGroupPreferredChild(
props.context.docks.entries,
props.group.id,
props.group.defaultChildId,
props.group,
props.context.panel.session.groupLastChildIds?.[props.group.id],
props.context.when.context,
)
if (fallback) {
Expand Down
37 changes: 37 additions & 0 deletions packages/hub-ui/src/client/state/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,43 @@ describe('createDocksContext', () => {
expect(session.value.open).toBe(true)
})

it('reopens a group\'s last-opened member ahead of defaultChildId', async () => {
expect.assertions(4)

const { rpc, sharedStates, trust } = createStubRpc()
// No `groupLastChildIds` seed — mirrors a session store persisted before
// the field existed.
const session = ref<DockSessionStorage>({
open: false,
selectedDockId: null,
selectedDockRoute: null,
})
const context = await createDocksContext('embedded', rpc, undefined, session)

trust()
sharedStates.get('devframe:docks')!.push([
{ id: 'nuxt', type: 'group', title: 'Nuxt', icon: 'ph:cube-duotone', defaultChildId: 'nuxt:overview' },
{ id: 'nuxt:overview', type: 'iframe', url: '/', title: 'Overview', icon: 'ph:cube-duotone', groupId: 'nuxt' },
{ id: 'nuxt:modules', type: 'iframe', url: '/', title: 'Modules', icon: 'ph:cube-duotone', groupId: 'nuxt' },
] satisfies DevframeDockEntry[])
sharedStates.get('devframe:dock-renderers')!.push({})
await flushRestore()

// Without memory the group activation resolves to `defaultChildId`.
await context.docks.switchEntry('nuxt')
expect(context.docks.selected?.id).toBe('nuxt:overview')

// Opening another member records it as the group's last-opened child.
await context.docks.switchEntry('nuxt:modules')
expect(session.value.groupLastChildIds).toEqual({ nuxt: 'nuxt:modules' })

// Closing and re-activating the group reopens the remembered member.
await context.docks.switchEntry(null)
expect(context.docks.selected).toBeNull()
await context.docks.switchEntry('nuxt')
expect(context.docks.selected?.id).toBe('nuxt:modules')
})

it('keeps a dock closed when the user closes it before initialization finishes', async () => {
expect.assertions(2)

Expand Down
22 changes: 15 additions & 7 deletions packages/hub-ui/src/client/state/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { computed, markRaw, reactive, ref, toRefs, watch, watchEffect } from 'vu
import { BUILTIN_ENTRIES, BUILTIN_ENTRY_SETTINGS, DEFAULT_CATEGORIES_ORDER, HUB_UI_HIDE_EVENT } from '../constants'
import { useBranding } from './branding'
import { createCommandsContext } from './commands'
import { docksGroupByCategories, getCategoryLabel, getGroupMembers, getGroupMembersGrouped, getRegisteredGroupIds, resolveCommandIcon, resolveGroupDefaultChild } from './dock-settings'
import { docksGroupByCategories, getCategoryLabel, getGroupMembers, getGroupMembersGrouped, getRegisteredGroupIds, resolveCommandIcon, resolveGroupPreferredChild } from './dock-settings'
import { createDockEntryState, DEFAULT_DOCK_PANEL_STORE, DEFAULT_DOCK_SESSION_STORE, sharedStateToRef, useDocksEntries, waitForInitialSharedStateSync } from './docks'
import { createClientMessagesClient } from './messages-client'
import { registerMainFrameDockActionHandler, triggerMainFrameDockAction, useIsDockPopupOpen } from './popup'
Expand Down Expand Up @@ -233,13 +233,14 @@ export async function createDocksContext(
return false

// A group has no view of its own — resolve to the member it represents.
// Prefer the author's `defaultChildId` (honoring its `when` clause but
// ignoring its render-only `visibility` — see `resolveGroupDefaultChild`),
// otherwise the first member. With neither, the group is popover-only and
// selecting it is a no-op here (the dock-bar group button opens the
// member popover instead).
// Prefer the member last opened in this group this tab, then the author's
// `defaultChildId` (each honoring its `when` clause but ignoring its
// render-only `visibility` — see `resolveGroupPreferredChild`), otherwise
// the first member. With none, the group is popover-only and selecting it
// is a no-op here (the dock-bar group button opens the member popover
// instead).
if (entry.type === 'group') {
const target = resolveGroupDefaultChild(entries.value, entry.id, entry.defaultChildId, getWhenContext())?.id
const target = resolveGroupPreferredChild(entries.value, entry, sessionStore.value.groupLastChildIds?.[entry.id], getWhenContext())?.id
?? getGroupMembers(entries.value, entry.id)[0]?.id
if (!target)
return false
Expand Down Expand Up @@ -291,6 +292,13 @@ export async function createDocksContext(
if (entry.type === 'iframe' && entry.frameId && !entry.subTabs)
frameNavCurrentMember.set(entry.frameId, entry.id)

// Remember a grouped member as its group's last-opened child so the next
// activation of the group reopens it directly, ahead of `defaultChildId`
// (see `resolveGroupPreferredChild`). Guarded assignment: a session store
// persisted before this field existed has no map yet.
if (entry.groupId)
(sessionStore.value.groupLastChildIds ??= {})[entry.groupId] = entry.id

initialRestorePending.value = false
selectedDockId.value = entry.id
sessionStore.value.open = true
Expand Down
33 changes: 32 additions & 1 deletion packages/hub-ui/src/client/state/dock-settings.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { DevframeDockEntriesGrouped, DevframeDockEntry, DevframeViewGroup } from '@devframes/hub'
import type { WhenContext } from 'devframe/utils/when'
import { describe, expect, it } from 'vitest'
import { docksSplitGroupsWithCapacity, resolveNextRecentDockId, resolveRecentDockEntry } from './dock-settings'
import { docksSplitGroupsWithCapacity, resolveGroupPreferredChild, resolveNextRecentDockId, resolveRecentDockEntry } from './dock-settings'

function iframe(id: string, extra: Partial<DevframeDockEntry> = {}): DevframeDockEntry {
return { id, type: 'iframe', url: '/', title: id.toUpperCase(), icon: 'ph:cube-duotone', ...extra } as DevframeDockEntry
Expand Down Expand Up @@ -104,6 +105,36 @@ describe('resolveNextRecentDockId', () => {
})
})

describe('resolveGroupPreferredChild', () => {
const g = group('g', { defaultChildId: 'g:default' }) as DevframeViewGroup
const defaultMember = iframe('g:default', { groupId: 'g' })
const otherMember = iframe('g:other', { groupId: 'g' })
const entries = [a, g, defaultMember, otherMember]

it('prefers the last-opened member over defaultChildId', () => {
expect(resolveGroupPreferredChild(entries, g, 'g:other')).toBe(otherMember)
})

it('falls back to defaultChildId before any member has been opened', () => {
expect(resolveGroupPreferredChild(entries, g, undefined)).toBe(defaultMember)
})

it('falls back to defaultChildId when the remembered member is gone', () => {
expect(resolveGroupPreferredChild([a, g, defaultMember], g, 'g:other')).toBe(defaultMember)
})

it('falls back to defaultChildId when the remembered member fails its when clause', () => {
const whenContext: WhenContext = { clientType: 'standalone', dockOpen: false, paletteOpen: false, dockSelectedId: '' }
const gated = iframe('g:gated', { groupId: 'g', when: 'clientType == embedded' })
expect(resolveGroupPreferredChild([g, defaultMember, gated], g, 'g:gated', whenContext)).toBe(defaultMember)
})

it('resolves nothing for a popover-only group without memory', () => {
const bare = group('bare') as DevframeViewGroup
expect(resolveGroupPreferredChild([bare, iframe('bare:x', { groupId: 'bare' })], bare, undefined)).toBeUndefined()
})
})

describe('resolveRecentDockEntry', () => {
const g = group('g')
const member = iframe('g:member', { groupId: 'g' })
Expand Down
22 changes: 22 additions & 0 deletions packages/hub-ui/src/client/state/dock-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,28 @@ export function resolveGroupDefaultChild(
return member
}

/**
* Resolve the member a group activation opens, layering the per-tab "last
* opened member" memory (`DockSessionStorage.groupLastChildIds`) over the
* author's `defaultChildId`. The remembered member wins while it still
* resolves — it exists in the group and its `when` clause holds — so reopening
* a group lands back on the member the developer last used; otherwise the
* `defaultChildId` target is tried under the same rules (both via
* {@link resolveGroupDefaultChild}, so the render-only `visibility` clause is
* ignored for either candidate). Returns `undefined` when neither resolves —
* the caller falls back to its own behavior (the dock-bar group button opens
* the member popover; `switchEntry` picks the first member).
*/
export function resolveGroupPreferredChild(
entries: DevframeDockEntry[],
group: DevframeViewGroup,
lastChildId: string | undefined,
whenContext?: WhenContext,
): DevframeDockEntry | undefined {
return resolveGroupDefaultChild(entries, group.id, lastChildId, whenContext)
?? resolveGroupDefaultChild(entries, group.id, group.defaultChildId, whenContext)
}

/**
* Group and sort dock entries based on user settings.
* Filters out hidden entries and categories, then sorts by custom order and
Expand Down
1 change: 1 addition & 0 deletions packages/hub-ui/src/client/state/docks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export function DEFAULT_DOCK_SESSION_STORE(): DockSessionStorage {
selectedDockId: null,
selectedDockRoute: null,
recentDockId: null,
groupLastChildIds: {},
}
}

Expand Down
11 changes: 11 additions & 0 deletions packages/hub/src/client/docks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,17 @@ export interface DockSessionStorage {
* field existed) when no entry has been raised.
*/
recentDockId?: string | null
/**
* The member most recently opened in each dock group, keyed by group id.
* Recorded whenever a grouped member is selected (from the group popover,
* the group sidebar, the command palette, or an RPC activation), and read
* back when the group is activated again: the remembered member reopens
* directly, taking precedence over the group's own
* {@link import('../types/docks').DevframeViewGroup.defaultChildId defaultChildId}.
* A group is only listed once one of its members has been opened this tab
* (absent for stores persisted before this field existed).
*/
groupLastChildIds?: Record<string, string>
}

export type DockClientType = 'embedded' | 'standalone'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export interface DockSessionStorage {
selectedDockId: string | null;
selectedDockRoute: string | null;
recentDockId?: string | null;
groupLastChildIds?: Record<string, string>;
}
export interface DocksPanelContext {
store: DockPanelStorage;
Expand Down
Loading