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
4 changes: 3 additions & 1 deletion docs/kit/dock-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,8 @@ DevTools for Rolldown joins this group out of the box.

From the dock settings panel, users hide or reorder members within a group independently, and hide the whole group from its row. When a group's members span several sub-categories, each sub-category reorders on its own and shows its own header.

Pinning an entry moves it into a dedicated **Pinned** category that leads the dock bar ahead of every other category. A top-level entry (or a whole group button) pins to the bar-level Pinned bucket; a grouped member pins to a Pinned sub-category that leads its own group, staying inside the group rather than surfacing on the bar. A pinned entry shows even when its home category is hidden, and unpinning returns it to that category in its previous position.

## Common options

Every dock type accepts these base fields:
Expand All @@ -515,7 +517,7 @@ Every dock type accepts these base fields:
| `title` | `string` | Label shown in the dock. |
| `icon` | `string \| { light, dark }` | Iconify name, URL, data URI, or light/dark pair. |
| `category` | `'app' \| 'framework' \| 'web' \| 'advanced' \| 'default'` | Outer dock-bar bucket, or the in-group sub-category when `groupId` resolves to a group — see [Categories inside a group](#categories-inside-a-group). Defaults to `'default'`. |
| `defaultOrder` | `number` | Higher numbers appear first. Default `0`. |
| `defaultOrder` | `number` | Orders entries within a category; lower numbers appear first. Default `0`. |
| `when` | `string` | Visibility expression — see [When Clauses](/kit/when-clauses). |
| `badge` | `string` | Short text badge (e.g. unread count). |
| `groupId` | `string` | Collapse this entry under a group's button; the group's `category` becomes this entry's outer bucket — see [Docked groups](#docked-groups). |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { SharedState } from 'devframe/utils/shared-state'
import type { DevToolsDockEntriesGrouped, DevToolsDocksUserSettings } from '../../state/dock-settings'
import { useDraggable } from '@vueuse/core'
import { computed, ref, useTemplateRef } from 'vue'
import { docksGroupByCategories, getCategoryLabel, getGroupMembers, getGroupMembersGrouped } from '../../state/dock-settings'
import { docksGroupByCategories, getCategoryLabel, getGroupMembers, getGroupMembersGrouped, isCategoryHideable } from '../../state/dock-settings'
import { sharedStateToRef } from '../../state/docks'
import HashBadge from '../display/HashBadge.vue'
import DockIcon from '../dock/DockIcon.vue'
Expand Down Expand Up @@ -197,7 +197,7 @@ function toggleDock(id: string, visible?: boolean) {
}

function toggleCategory(category: string, visible?: boolean) {
if (category === '~builtin')
if (!isCategoryHideable(category))
return
const hidden = settings.value.docksCategoriesHidden
const isHidden = hidden.includes(category)
Expand Down Expand Up @@ -299,9 +299,9 @@ function resetCustomOrderForContainer(container: string) {
<button
class="w-5 h-5 flex items-center justify-center rounded transition-colors"
:class="[
category === '~builtin' ? 'bg-gray/20 cursor-not-allowed op50' : settings.docksCategoriesHidden.includes(category) ? 'bg-gray/20' : 'bg-primary/20 text-primary',
!isCategoryHideable(category) ? 'bg-gray/20 cursor-not-allowed op50' : settings.docksCategoriesHidden.includes(category) ? 'bg-gray/20' : 'bg-primary/20 text-primary',
]"
:disabled="category === '~builtin'"
:disabled="!isCategoryHideable(category)"
@click="toggleCategory(category)"
>
<div
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import {
getGroupMembers,
getGroupMembersGrouped,
getRegisteredGroupIds,
isCategoryHideable,
PINNED_CATEGORY,
PINNED_CATEGORY_ORDER,
resolveCommandIcon,
} from '../dock-settings'

Expand Down Expand Up @@ -175,6 +178,102 @@ describe('in-group sub-categories (dual role of `category`)', () => {
})
})

describe('pinning re-buckets into the ~pinned category', () => {
function categoryOf(grouped: ReturnType<typeof docksGroupByCategories>, id: string): string | undefined {
return grouped.find(([, items]) => items.some(i => i.id === id))?.[0]
}

it('moves a pinned top-level entry into the ~pinned category', () => {
const entries: DevToolsDockEntry[] = [
iframe('a', { category: 'app' }),
iframe('b', { category: 'app' }),
]
const pinned = { ...settings, docksPinned: ['b'] }
const result = docksGroupByCategories(entries, pinned)
expect(categoryOf(result, 'b')).toBe(PINNED_CATEGORY)
expect(categoryOf(result, 'a')).toBe('app')
})

it('sorts the ~pinned category ahead of every real category', () => {
const entries: DevToolsDockEntry[] = [
iframe('fw', { category: 'framework' }), // framework leads the real table (-100)
iframe('pinme', { category: 'advanced' }),
]
const pinned = { ...settings, docksPinned: ['pinme'] }
const result = docksGroupByCategories(entries, pinned)
expect(result[0]![0]).toBe(PINNED_CATEGORY)
expect(PINNED_CATEGORY_ORDER).toBeLessThan(-100)
})

it('pins a group button to the top-level ~pinned category', () => {
const entries: DevToolsDockEntry[] = [
group('nuxt', { category: 'framework' }),
iframe('nuxt:overview', { groupId: 'nuxt' }),
]
const pinned = { ...settings, docksPinned: ['nuxt'] }
const grouped = docksGroupByCategories(entries, pinned, { collapseGroups: true })
expect(categoryOf(grouped, 'nuxt')).toBe(PINNED_CATEGORY)
})

it('pins a grouped member into a ~pinned SUB-category inside its group (no promotion to the bar)', () => {
const entries: DevToolsDockEntry[] = [
group('nuxt', { category: 'framework' }),
iframe('nuxt:overview', { groupId: 'nuxt', category: 'app' }),
iframe('nuxt:graph', { groupId: 'nuxt', category: 'advanced' }),
]
const pinned = { ...settings, docksPinned: ['nuxt:graph'] }

// The member stays folded in its group on the bar — no top-level ~pinned bucket.
const bar = docksGroupByCategories(entries, pinned, { collapseGroups: true })
expect(bar.map(([c]) => c)).not.toContain(PINNED_CATEGORY)

// Inside the group it leads via a ~pinned sub-category.
const sub = getGroupMembersGrouped(entries, 'nuxt', pinned)
expect(sub[0]![0]).toBe(PINNED_CATEGORY)
expect(categoryOf(sub, 'nuxt:graph')).toBe(PINNED_CATEGORY)
expect(categoryOf(sub, 'nuxt:overview')).toBe('app')
})

it('keeps a pinned entry visible even when its home category is hidden', () => {
const entries: DevToolsDockEntry[] = [
iframe('a', { category: 'advanced' }),
iframe('b', { category: 'advanced' }),
]
const hiddenCat = { ...settings, docksCategoriesHidden: ['advanced'], docksPinned: ['b'] }
const grouped = docksGroupByCategories(entries, hiddenCat)
const ids = grouped.flatMap(([, items]) => items.map(i => i.id))
// 'a' is hidden with its category; the pinned 'b' survives in ~pinned
expect(ids).not.toContain('a')
expect(ids).toContain('b')
expect(categoryOf(grouped, 'b')).toBe(PINNED_CATEGORY)
})

it('orders multiple pinned entries by custom then default order', () => {
const entries: DevToolsDockEntry[] = [
iframe('x', { defaultOrder: 1 }),
iframe('y', { defaultOrder: 0 }),
iframe('z', { defaultOrder: 2 }),
]
const pinned = { ...settings, docksPinned: ['x', 'y', 'z'], docksCustomOrder: { x: 5, y: 6, z: 1 } }
const grouped = docksGroupByCategories(entries, pinned)
const pinnedItems = grouped.find(([c]) => c === PINNED_CATEGORY)![1].map(e => e.id)
// custom order wins over defaultOrder: z(1) < x(5) < y(6)
expect(pinnedItems).toEqual(['z', 'x', 'y'])
})
})

describe('isCategoryHideable', () => {
it('marks ~builtin and ~pinned as non-hideable', () => {
expect(isCategoryHideable('~builtin')).toBe(false)
expect(isCategoryHideable(PINNED_CATEGORY)).toBe(false)
})

it('marks ordinary categories as hideable', () => {
expect(isCategoryHideable('app')).toBe(true)
expect(isCategoryHideable('framework')).toBe(true)
})
})

describe('getCategoryLabel', () => {
it('maps known category ids to human labels', () => {
expect(getCategoryLabel('framework')).toBe('Framework')
Expand Down
97 changes: 79 additions & 18 deletions packages/core/src/client/webcomponents/state/dock-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,34 @@ import { DEFAULT_CATEGORIES_ORDER } from '../constants'
export type { DevToolsDocksUserSettings }
export type { DevToolsDockEntriesGrouped }

/**
* Synthetic category that collects pinned dock entries. Pinning re-buckets an
* entry here instead of merely floating it to the top of its home category, so
* pinned entries lead the dock bar (and, for grouped members, lead inside their
* group). The `~` prefix marks it internal, mirroring `~builtin`; it is never
* user-hideable and does not exist upstream in `DEFAULT_CATEGORIES_ORDER`.
*/
export const PINNED_CATEGORY = '~pinned'

/**
* Order weight for {@link PINNED_CATEGORY}. Strongly negative so the Pinned
* bucket always sorts before every real category (`framework` leads the
* upstream table at `-100`). Applied as a local override in the sort rather
* than added to the upstream `DEFAULT_CATEGORIES_ORDER` table, keeping the pin
* feature entirely client-side.
*/
export const PINNED_CATEGORY_ORDER = -100000

/**
* Resolve a category's sort weight, layering the local {@link PINNED_CATEGORY}
* override on top of the upstream {@link DEFAULT_CATEGORIES_ORDER} table.
*/
function categoryOrder(category: string): number {
if (category === PINNED_CATEGORY)
return PINNED_CATEGORY_ORDER
return DEFAULT_CATEGORIES_ORDER[category] || 0
}

export interface SplitGroupsResult {
visible: DevToolsDockEntriesGrouped
overflow: DevToolsDockEntriesGrouped
Expand Down Expand Up @@ -35,6 +63,17 @@ const CATEGORY_LABELS: Record<string, string> = {
'web': 'Web',
'advanced': 'Advanced',
'~builtin': 'Built-in',
[PINNED_CATEGORY]: 'Pinned',
}

/**
* Internal categories the user cannot hide via `docksCategoriesHidden`. Both
* are `~`-prefixed synthetic buckets: `~builtin` (always-present built-ins) and
* `~pinned` (the pinned bucket, whose membership the user controls per-entry
* via the pin toggle instead).
*/
export function isCategoryHideable(category: string): boolean {
return category !== '~builtin' && category !== PINNED_CATEGORY
}

/**
Expand Down Expand Up @@ -79,16 +118,18 @@ export function getEntryGroup(

/**
* Group a group's members by their **in-group sub-category** and sort them the
* same way the dock bar sorts (pinned, custom order, default order). Members
* same way the dock bar sorts (custom order, then default order). Members
* hidden by user settings or a falsy `when` clause are filtered out unless
* `includeHidden`.
*
* A member's own `category` field is its in-group sub-category (defaulting to
* `'default'`) — the group's `category` is the *outer* bucket the whole group
* lives in, so it never bleeds into the sub-category split here. Sub-categories
* are ordered by the same {@link DEFAULT_CATEGORIES_ORDER} table as top-level
* categories, but they are not independently hideable (the outer category-hide
* toggle does not apply inside a group).
* lives in, so it never bleeds into the sub-category split here. A pinned member
* moves to a `~pinned` sub-category (leading the group, via
* {@link PINNED_CATEGORY_ORDER}). Sub-categories are ordered by the same
* {@link DEFAULT_CATEGORIES_ORDER} table as top-level categories, but they are
* not independently hideable (the outer category-hide toggle does not apply
* inside a group).
*/
export function getGroupMembersGrouped(
entries: DevToolsDockEntry[],
Expand Down Expand Up @@ -123,7 +164,8 @@ export function getGroupMembers(

/**
* Group and sort dock entries based on user settings.
* Filters out hidden entries and categories, sorts by pinned status, custom order, and default order.
* Filters out hidden entries and categories, then sorts by custom order and
* default order within each category.
*
* Outer bucketing follows the dual role of `category`: a grouped member whose
* `groupId` resolves to a registered group takes that **group's** `category` as
Expand All @@ -133,6 +175,17 @@ export function getGroupMembers(
* bar, so the outer bucket is always the group's category. Orphan members
* (whose `groupId` references no registered group) fall back to their own
* `category`.
*
* Pinning re-buckets an entry into {@link PINNED_CATEGORY} in place of the
* category slot it would otherwise occupy — the outer bucket for a top-level
* entry or group button, or the in-group sub-category for a member (the
* members-only in-group split has no group entries, so `resolvedGroupCategory`
* is undefined there and the member's own category slot is the one replaced).
* A grouped member's outer bucket is never re-pinned, so pinning a member
* reorders it inside its group rather than promoting it onto the top-level bar.
* Because the pinned bucket is chosen before the category-hide check and is
* itself never hideable, a pinned entry stays visible even when its original
* category is hidden.
*/
export function docksGroupByCategories(
entries: DevToolsDockEntry[],
Expand Down Expand Up @@ -180,8 +233,20 @@ export function docksGroupByCategories(
// Outer bucket: the group's category for grouped members, else the entry's
// own category. Orphans (groupId with no registered group) fall through to
// their own `category`.
const category = resolvedGroupCategory ?? entry.category ?? 'default'
// Skip if category is hidden (an outer-bar concern; not applied in-group)
const ownCategory = resolvedGroupCategory ?? entry.category ?? 'default'
// A pinned entry re-buckets into `~pinned` in place of the category slot it
// occupies — but only that slot. Top-level entries and group buttons
// (`resolvedGroupCategory === undefined`) move to the top-level `~pinned`
// bucket; members in the in-group split (also undefined there) move to a
// `~pinned` sub-category. A grouped member's OUTER bucket
// (`resolvedGroupCategory` defined) is left alone, so pin never promotes it
// off its group and onto the bar.
const category = docksPinned.includes(entry.id) && resolvedGroupCategory === undefined
? PINNED_CATEGORY
: ownCategory
// Skip if category is hidden (an outer-bar concern; not applied in-group).
// `~pinned` is never hideable, so a pinned entry survives its original
// category being hidden.
if (!includeHidden && !ignoreCategoryHidden && docksCategoriesHidden.includes(category))
continue

Expand All @@ -193,26 +258,22 @@ export function docksGroupByCategories(
const grouped = Array
.from(map.entries())
.sort(([a], [b]) => {
const ia = DEFAULT_CATEGORIES_ORDER[a] || 0
const ib = DEFAULT_CATEGORIES_ORDER[b] || 0
const ia = categoryOrder(a)
const ib = categoryOrder(b)
return ib === ia ? b.localeCompare(a) : ia - ib
})

grouped.forEach(([_, items]) => {
// Ordering within a category (including the `~pinned` bucket, where every
// entry is pinned): custom order first, then default order, then title.
items.sort((a, b) => {
// Pinned entries come first
const aPinned = docksPinned.includes(a.id)
const bPinned = docksPinned.includes(b.id)
if (aPinned !== bPinned)
return aPinned ? -1 : 1

// Then sort by custom order
// Custom order
const customOrderA = docksCustomOrder[a.id] ?? 0
const customOrderB = docksCustomOrder[b.id] ?? 0
if (customOrderA !== customOrderB)
return customOrderA - customOrderB

// Finally by default order
// Default order
const ia = a.defaultOrder ?? 0
const ib = b.defaultOrder ?? 0
return ib === ia ? b.title.localeCompare(a.title) : ia - ib
Expand Down
Loading