Skip to content

Commit 9646fe7

Browse files
authored
feat(hub-ui): reopen a group's last-opened member ahead of defaultChildId (#309)
1 parent 5f38376 commit 9646fe7

10 files changed

Lines changed: 134 additions & 20 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ ctx.docks.register({
214214
})
215215
```
216216

217-
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)).
217+
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)).
218218

219219
### The dual role of `category`
220220

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

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ const meta = {
2020
parameters: {
2121
docs: {
2222
description: {
23-
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.',
23+
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.',
2424
},
2525
},
2626
},
@@ -30,8 +30,9 @@ export default meta
3030
type Story = StoryObj
3131

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

5455
/**
5556
* A group with a `defaultChildId`: clicking opens that member straight away
56-
* instead of showing the popover.
57+
* instead of showing the popover — until another member becomes the group's
58+
* last-opened child, which then takes precedence.
5759
*/
5860
export const WithDefaultChild: Story = {
5961
render: () => ({

packages/hub-ui/src/client/components/dock/DockGroupButton.vue

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { DevframeDockEntry, DevframeViewGroup } from '@devframes/hub'
33
import type { DocksContext } from '@devframes/hub/client'
44
import { watchDebounced } from '@vueuse/core'
55
import { computed, h, ref, useTemplateRef } from 'vue'
6-
import { getGroupMembers, getGroupMembersGrouped, resolveGroupDefaultChild } from '../../state/dock-settings'
6+
import { getGroupMembers, getGroupMembersGrouped, resolveGroupPreferredChild } from '../../state/dock-settings'
77
import { setDocksGroupPanel, useDocksGroupPanel } from '../../state/floating-tooltip'
88
import { useSettings } from '../../state/settings-defaults'
99
import { accentVarStyle } from '../../utils/accent-color'
@@ -101,13 +101,14 @@ function onClick() {
101101
emit('select', undefined!)
102102
return
103103
}
104-
// `defaultChildId` opens its member directly; otherwise reveal the popover.
105-
// Resolved regardless of the target's render-only `visibility` (a hidden
106-
// button must still fire), but honoring its `when` clause.
107-
const fallback = resolveGroupDefaultChild(
104+
// The member last opened in this group this tab — then the author's
105+
// `defaultChildId` — opens directly; otherwise reveal the popover. Resolved
106+
// regardless of the target's render-only `visibility` (a hidden button must
107+
// still fire), but honoring its `when` clause.
108+
const fallback = resolveGroupPreferredChild(
108109
props.context.docks.entries,
109-
props.group.id,
110-
props.group.defaultChildId,
110+
props.group,
111+
props.context.panel.session.groupLastChildIds?.[props.group.id],
111112
props.context.when.context,
112113
)
113114
if (fallback) {

packages/hub-ui/src/client/state/context.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,43 @@ describe('createDocksContext', () => {
131131
expect(session.value.open).toBe(true)
132132
})
133133

134+
it('reopens a group\'s last-opened member ahead of defaultChildId', async () => {
135+
expect.assertions(4)
136+
137+
const { rpc, sharedStates, trust } = createStubRpc()
138+
// No `groupLastChildIds` seed — mirrors a session store persisted before
139+
// the field existed.
140+
const session = ref<DockSessionStorage>({
141+
open: false,
142+
selectedDockId: null,
143+
selectedDockRoute: null,
144+
})
145+
const context = await createDocksContext('embedded', rpc, undefined, session)
146+
147+
trust()
148+
sharedStates.get('devframe:docks')!.push([
149+
{ id: 'nuxt', type: 'group', title: 'Nuxt', icon: 'ph:cube-duotone', defaultChildId: 'nuxt:overview' },
150+
{ id: 'nuxt:overview', type: 'iframe', url: '/', title: 'Overview', icon: 'ph:cube-duotone', groupId: 'nuxt' },
151+
{ id: 'nuxt:modules', type: 'iframe', url: '/', title: 'Modules', icon: 'ph:cube-duotone', groupId: 'nuxt' },
152+
] satisfies DevframeDockEntry[])
153+
sharedStates.get('devframe:dock-renderers')!.push({})
154+
await flushRestore()
155+
156+
// Without memory the group activation resolves to `defaultChildId`.
157+
await context.docks.switchEntry('nuxt')
158+
expect(context.docks.selected?.id).toBe('nuxt:overview')
159+
160+
// Opening another member records it as the group's last-opened child.
161+
await context.docks.switchEntry('nuxt:modules')
162+
expect(session.value.groupLastChildIds).toEqual({ nuxt: 'nuxt:modules' })
163+
164+
// Closing and re-activating the group reopens the remembered member.
165+
await context.docks.switchEntry(null)
166+
expect(context.docks.selected).toBeNull()
167+
await context.docks.switchEntry('nuxt')
168+
expect(context.docks.selected?.id).toBe('nuxt:modules')
169+
})
170+
134171
it('keeps a dock closed when the user closes it before initialization finishes', async () => {
135172
expect.assertions(2)
136173

packages/hub-ui/src/client/state/context.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { computed, markRaw, reactive, ref, toRefs, watch, watchEffect } from 'vu
1111
import { BUILTIN_ENTRIES, BUILTIN_ENTRY_SETTINGS, DEFAULT_CATEGORIES_ORDER, HUB_UI_HIDE_EVENT } from '../constants'
1212
import { useBranding } from './branding'
1313
import { createCommandsContext } from './commands'
14-
import { docksGroupByCategories, getCategoryLabel, getGroupMembers, getGroupMembersGrouped, getRegisteredGroupIds, resolveCommandIcon, resolveGroupDefaultChild } from './dock-settings'
14+
import { docksGroupByCategories, getCategoryLabel, getGroupMembers, getGroupMembersGrouped, getRegisteredGroupIds, resolveCommandIcon, resolveGroupPreferredChild } from './dock-settings'
1515
import { createDockEntryState, DEFAULT_DOCK_PANEL_STORE, DEFAULT_DOCK_SESSION_STORE, sharedStateToRef, useDocksEntries, waitForInitialSharedStateSync } from './docks'
1616
import { createClientMessagesClient } from './messages-client'
1717
import { registerMainFrameDockActionHandler, triggerMainFrameDockAction, useIsDockPopupOpen } from './popup'
@@ -233,13 +233,14 @@ export async function createDocksContext(
233233
return false
234234

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

295+
// Remember a grouped member as its group's last-opened child so the next
296+
// activation of the group reopens it directly, ahead of `defaultChildId`
297+
// (see `resolveGroupPreferredChild`). Guarded assignment: a session store
298+
// persisted before this field existed has no map yet.
299+
if (entry.groupId)
300+
(sessionStore.value.groupLastChildIds ??= {})[entry.groupId] = entry.id
301+
294302
initialRestorePending.value = false
295303
selectedDockId.value = entry.id
296304
sessionStore.value.open = true

packages/hub-ui/src/client/state/dock-settings.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { DevframeDockEntriesGrouped, DevframeDockEntry, DevframeViewGroup } from '@devframes/hub'
2+
import type { WhenContext } from 'devframe/utils/when'
23
import { describe, expect, it } from 'vitest'
3-
import { docksSplitGroupsWithCapacity, resolveNextRecentDockId, resolveRecentDockEntry } from './dock-settings'
4+
import { docksSplitGroupsWithCapacity, resolveGroupPreferredChild, resolveNextRecentDockId, resolveRecentDockEntry } from './dock-settings'
45

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

108+
describe('resolveGroupPreferredChild', () => {
109+
const g = group('g', { defaultChildId: 'g:default' }) as DevframeViewGroup
110+
const defaultMember = iframe('g:default', { groupId: 'g' })
111+
const otherMember = iframe('g:other', { groupId: 'g' })
112+
const entries = [a, g, defaultMember, otherMember]
113+
114+
it('prefers the last-opened member over defaultChildId', () => {
115+
expect(resolveGroupPreferredChild(entries, g, 'g:other')).toBe(otherMember)
116+
})
117+
118+
it('falls back to defaultChildId before any member has been opened', () => {
119+
expect(resolveGroupPreferredChild(entries, g, undefined)).toBe(defaultMember)
120+
})
121+
122+
it('falls back to defaultChildId when the remembered member is gone', () => {
123+
expect(resolveGroupPreferredChild([a, g, defaultMember], g, 'g:other')).toBe(defaultMember)
124+
})
125+
126+
it('falls back to defaultChildId when the remembered member fails its when clause', () => {
127+
const whenContext: WhenContext = { clientType: 'standalone', dockOpen: false, paletteOpen: false, dockSelectedId: '' }
128+
const gated = iframe('g:gated', { groupId: 'g', when: 'clientType == embedded' })
129+
expect(resolveGroupPreferredChild([g, defaultMember, gated], g, 'g:gated', whenContext)).toBe(defaultMember)
130+
})
131+
132+
it('resolves nothing for a popover-only group without memory', () => {
133+
const bare = group('bare') as DevframeViewGroup
134+
expect(resolveGroupPreferredChild([bare, iframe('bare:x', { groupId: 'bare' })], bare, undefined)).toBeUndefined()
135+
})
136+
})
137+
107138
describe('resolveRecentDockEntry', () => {
108139
const g = group('g')
109140
const member = iframe('g:member', { groupId: 'g' })

packages/hub-ui/src/client/state/dock-settings.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,28 @@ export function resolveGroupDefaultChild(
219219
return member
220220
}
221221

222+
/**
223+
* Resolve the member a group activation opens, layering the per-tab "last
224+
* opened member" memory (`DockSessionStorage.groupLastChildIds`) over the
225+
* author's `defaultChildId`. The remembered member wins while it still
226+
* resolves — it exists in the group and its `when` clause holds — so reopening
227+
* a group lands back on the member the developer last used; otherwise the
228+
* `defaultChildId` target is tried under the same rules (both via
229+
* {@link resolveGroupDefaultChild}, so the render-only `visibility` clause is
230+
* ignored for either candidate). Returns `undefined` when neither resolves —
231+
* the caller falls back to its own behavior (the dock-bar group button opens
232+
* the member popover; `switchEntry` picks the first member).
233+
*/
234+
export function resolveGroupPreferredChild(
235+
entries: DevframeDockEntry[],
236+
group: DevframeViewGroup,
237+
lastChildId: string | undefined,
238+
whenContext?: WhenContext,
239+
): DevframeDockEntry | undefined {
240+
return resolveGroupDefaultChild(entries, group.id, lastChildId, whenContext)
241+
?? resolveGroupDefaultChild(entries, group.id, group.defaultChildId, whenContext)
242+
}
243+
222244
/**
223245
* Group and sort dock entries based on user settings.
224246
* Filters out hidden entries and categories, then sorts by custom order and

packages/hub-ui/src/client/state/docks.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export function DEFAULT_DOCK_SESSION_STORE(): DockSessionStorage {
3030
selectedDockId: null,
3131
selectedDockRoute: null,
3232
recentDockId: null,
33+
groupLastChildIds: {},
3334
}
3435
}
3536

packages/hub/src/client/docks.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,17 @@ export interface DockSessionStorage {
5050
* field existed) when no entry has been raised.
5151
*/
5252
recentDockId?: string | null
53+
/**
54+
* The member most recently opened in each dock group, keyed by group id.
55+
* Recorded whenever a grouped member is selected (from the group popover,
56+
* the group sidebar, the command palette, or an RPC activation), and read
57+
* back when the group is activated again: the remembered member reopens
58+
* directly, taking precedence over the group's own
59+
* {@link import('../types/docks').DevframeViewGroup.defaultChildId defaultChildId}.
60+
* A group is only listed once one of its members has been opened this tab
61+
* (absent for stores persisted before this field existed).
62+
*/
63+
groupLastChildIds?: Record<string, string>
5364
}
5465

5566
export type DockClientType = 'embedded' | 'standalone'

tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ export interface DockSessionStorage {
111111
selectedDockId: string | null;
112112
selectedDockRoute: string | null;
113113
recentDockId?: string | null;
114+
groupLastChildIds?: Record<string, string>;
114115
}
115116
export interface DocksPanelContext {
116117
store: DockPanelStorage;

0 commit comments

Comments
 (0)