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
16 changes: 16 additions & 0 deletions packages/hub-ui/src/client/components/dock/Dock.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,22 @@ export const LowerCapacity: Story = {
args: { maxVisibleItems: 3 },
}

/**
* 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.
*/
export const WithRecentDock: Story = {
...floatStory({
entries: overflowEntries,
selectedId: 'network',
session: { recentDockId: 'network' },
panel: { position: 'bottom', left: 50, top: 100, inactiveTimeout: -1 },
}),
args: { maxVisibleItems: 3 },
}

/** `maxVisibleItems: 9` — a higher capacity absorbs the overflow set inline. */
export const HigherCapacity: Story = {
...floatStory({ entries: overflowEntries, panel: { position: 'bottom', left: 50, top: 100, inactiveTimeout: -1 } }),
Expand Down
48 changes: 45 additions & 3 deletions packages/hub-ui/src/client/components/dock/Dock.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import type { DocksContext } from '@devframes/hub/client'
import type { CSSProperties } from 'vue'
import type { DockLayout } from './dock-layout'
import { useEventListener, useScreenSafeArea, whenever } from '@vueuse/core'
import { computed, onMounted, reactive, ref, useTemplateRef } from 'vue'
import { computed, onMounted, reactive, ref, useTemplateRef, watch } from 'vue'
import { BUILTIN_ENTRY_CLIENT_AUTH_NOTICE } from '../../constants'
import { docksSplitGroupsWithCapacity } from '../../state/dock-settings'
import { docksSplitGroupsWithCapacity, getEntryGroup, resolveNextRecentDockId, resolveRecentDockEntry } from '../../state/dock-settings'
import { sharedStateToRef } from '../../state/docks'
import { setDocksOverflowPanel, useDocksGroupPanel } from '../../state/floating-tooltip'
import { useIsRpcTrusted } from '../../utils/useIsRpcTrusted'
import BrandMark from '../icons/BrandMark.vue'
Expand All @@ -17,6 +18,7 @@ import {
resolveViewportMargins,
snapDockPercent,
} from './dock-layout'
import DockEntries from './DockEntries.vue'
import DockEntriesWithCategories from './DockEntriesWithCategories.vue'
import DockOverflowButton from './DockOverflowButton.vue'

Expand Down Expand Up @@ -92,14 +94,46 @@ 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 splitEntries = computed(() => {
return docksSplitGroupsWithCapacity(groupedEntries.value, layout.value.maxVisibleItems)
return docksSplitGroupsWithCapacity(groupedEntries.value, layout.value.maxVisibleItems, recentEntry.value)
})

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

// Remember the 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({
groups: groupedEntries.value,
capacity: layout.value.maxVisibleItems,
recentEntry: recentEntry.value,
selected: entry,
selectedIsGroupMember: !!getEntryGroup(context.docks.entries, entry),
})
})

onMounted(async () => {
windowSize.width = window.innerWidth
windowSize.height = window.innerHeight
Expand Down Expand Up @@ -311,6 +345,14 @@ onMounted(() => {

<template v-if="splitEntries.overflow.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]"
:is-vertical="context.panel.isVertical"
:selected="selectedEntry"
@select="(e) => context.docks.switchEntry(e?.id)"
/>
<DockOverflowButton
:context="context"
:is-vertical="context.panel.isVertical"
Expand Down
135 changes: 135 additions & 0 deletions packages/hub-ui/src/client/state/dock-settings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import type { DevframeDockEntriesGrouped, DevframeDockEntry, DevframeViewGroup } from '@devframes/hub'
import { describe, expect, it } from 'vitest'
import { docksSplitGroupsWithCapacity, 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
}

function group(id: string, extra: Partial<DevframeViewGroup> = {}): DevframeDockEntry {
return { id, type: 'group', title: id.toUpperCase(), icon: 'ph:folder-duotone', ...extra } as DevframeDockEntry
}

function ids(groups: DevframeDockEntriesGrouped): string[] {
return groups.flatMap(([, items]) => items.map(item => item.id))
}

// Five top-level docks on a 3-slot bar: [a] [b] [c] | [overflow: d, e]
const [a, b, c, d, e] = ['a', 'b', 'c', 'd', 'e'].map(id => iframe(id))
const rail: DevframeDockEntriesGrouped = [['default', [a, b, c, d, e]]]

describe('docksSplitGroupsWithCapacity', () => {
it('splits naturally without a recent entry', () => {
const split = docksSplitGroupsWithCapacity(rail, 3)
expect(ids(split.visible)).toEqual(['a', 'b', 'c'])
expect(ids(split.overflow)).toEqual(['d', 'e'])
expect(split.recent).toBeNull()
})

it('reserves a slot for a recent entry from the overflow', () => {
const split = docksSplitGroupsWithCapacity(rail, 3, d)
expect(ids(split.visible)).toEqual(['a', 'b'])
expect(ids(split.overflow)).toEqual(['c', 'e'])
expect(split.recent).toBe(d)
})

it('renders a recent entry inside the natural slice in place, releasing the slot', () => {
const split = docksSplitGroupsWithCapacity(rail, 3, c)
expect(ids(split.visible)).toEqual(['a', 'b', 'c'])
expect(ids(split.overflow)).toEqual(['d', 'e'])
expect(split.recent).toBeNull()
})

it('raises a grouped member without touching the rail items in the overflow', () => {
const g = group('g')
const member = iframe('g:member', { groupId: 'g' })
const groupedRail: DevframeDockEntriesGrouped = [['default', [a, b, g, c, d]]]
const split = docksSplitGroupsWithCapacity(groupedRail, 3, member)
expect(ids(split.visible)).toEqual(['a', 'b'])
expect(ids(split.overflow)).toEqual(['g', 'c', 'd'])
expect(split.recent).toBe(member)
})

it('skips the reservation when there is no overflow', () => {
const member = iframe('g:member', { groupId: 'g' })
const smallRail: DevframeDockEntriesGrouped = [['default', [a, b, c]]]
const split = docksSplitGroupsWithCapacity(smallRail, 3, member)
expect(ids(split.visible)).toEqual(['a', 'b', 'c'])
expect(split.overflow).toEqual([])
expect(split.recent).toBeNull()
})

it('keeps the lone-overflow fold ahead of the reservation', () => {
const foldRail: DevframeDockEntriesGrouped = [['default', [a, b, c, d]]]
const split = docksSplitGroupsWithCapacity(foldRail, 3, d)
expect(ids(split.visible)).toEqual(['a', 'b', 'c', 'd'])
expect(split.overflow).toEqual([])
expect(split.recent).toBeNull()
})
})

describe('resolveNextRecentDockId', () => {
function next(recentEntry: DevframeDockEntry | null, selected: DevframeDockEntry, selectedIsGroupMember = false) {
return resolveNextRecentDockId({ groups: rail, capacity: 3, recentEntry, selected, selectedIsGroupMember })
}

it('follows the a–e walkthrough on a 3-slot bar', () => {
// [a] [b] [c] | [O] — selecting d from the overflow raises it
expect(next(null, d)).toBe('d')
// [a] [b] | {d} [O] — selecting visible a/b (or d itself) keeps d raised
expect(next(d, a)).toBe('d')
expect(next(d, b)).toBe('d')
expect(next(d, d)).toBe('d')
// [a] [b] | [d] [O] — selecting e from the overflow replaces d
expect(next(d, e)).toBe('e')
// selecting c (folded out by the reserved slot) becomes recent, and the
// split renders it in its natural place — back to [a] [b] {c} | [O]
expect(next(d, c)).toBe('c')
expect(docksSplitGroupsWithCapacity(rail, 3, c).recent).toBeNull()
})

it('always raises a grouped member', () => {
const member = iframe('g:member', { groupId: 'g' })
expect(next(null, member, true)).toBe('g:member')
expect(next(d, member, true)).toBe('g:member')
})

it('keeps the recent dock when the selection is off the rail entirely', () => {
expect(next(d, iframe('~notice'))).toBe('d')
expect(next(null, iframe('~notice'))).toBeNull()
})

it('keeps a naturally-visible selection from claiming the slot', () => {
expect(next(null, a)).toBeNull()
})
})

describe('resolveRecentDockEntry', () => {
const g = group('g')
const member = iframe('g:member', { groupId: 'g' })
const entries = [a, b, c, d, e, g, member]
const groupedRail: DevframeDockEntriesGrouped = [['default', [a, b, c, d, e, g]]]

function resolve(recentId: string | null) {
return resolveRecentDockEntry({ entries, groups: groupedRail, recentId })
}

it('resolves a top-level rail entry', () => {
expect(resolve('d')).toBe(d)
})

it('resolves a grouped member', () => {
expect(resolve('g:member')).toBe(member)
})

it('returns null for no id, an unknown id, or a group button', () => {
expect(resolve(null)).toBeNull()
expect(resolve('missing')).toBeNull()
expect(resolve('g')).toBeNull()
})

it('returns null for an entry that is off the rail', () => {
const hidden = iframe('hidden')
expect(resolveRecentDockEntry({ entries: [...entries, hidden], groups: groupedRail, recentId: 'hidden' })).toBeNull()
})
})
Loading
Loading