Skip to content

Commit 7ccd698

Browse files
authored
feat(core): re-bucket pinned docks into a dedicated ~pinned category (#466)
1 parent 081279a commit 7ccd698

4 files changed

Lines changed: 185 additions & 23 deletions

File tree

docs/kit/dock-system.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,8 @@ DevTools for Rolldown joins this group out of the box.
527527

528528
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.
529529

530+
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.
531+
530532
## Common options
531533

532534
Every dock type accepts these base fields:
@@ -537,7 +539,7 @@ Every dock type accepts these base fields:
537539
| `title` | `string` | Label shown in the dock. |
538540
| `icon` | `string \| { light, dark }` | Iconify name, URL, data URI, or light/dark pair. |
539541
| `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'`. |
540-
| `defaultOrder` | `number` | Higher numbers appear first. Default `0`. |
542+
| `defaultOrder` | `number` | Orders entries within a category; lower numbers appear first. Default `0`. |
541543
| `when` | `string` | Visibility expression — see [When Clauses](/kit/when-clauses). |
542544
| `badge` | `string` | Short text badge (e.g. unread count). |
543545
| `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). |

packages/core/src/client/webcomponents/components/views-builtin/SettingsDocks.vue

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { SharedState } from 'devframe/utils/shared-state'
55
import type { DevToolsDockEntriesGrouped, DevToolsDocksUserSettings } from '../../state/dock-settings'
66
import { useDraggable } from '@vueuse/core'
77
import { computed, ref, useTemplateRef } from 'vue'
8-
import { docksGroupByCategories, getCategoryLabel, getGroupMembers, getGroupMembersGrouped } from '../../state/dock-settings'
8+
import { docksGroupByCategories, getCategoryLabel, getGroupMembers, getGroupMembersGrouped, isCategoryHideable } from '../../state/dock-settings'
99
import { sharedStateToRef } from '../../state/docks'
1010
import HashBadge from '../display/HashBadge.vue'
1111
import DockIcon from '../dock/DockIcon.vue'
@@ -197,7 +197,7 @@ function toggleDock(id: string, visible?: boolean) {
197197
}
198198
199199
function toggleCategory(category: string, visible?: boolean) {
200-
if (category === '~builtin')
200+
if (!isCategoryHideable(category))
201201
return
202202
const hidden = settings.value.docksCategoriesHidden
203203
const isHidden = hidden.includes(category)
@@ -299,9 +299,9 @@ function resetCustomOrderForContainer(container: string) {
299299
<button
300300
class="w-5 h-5 flex items-center justify-center rounded transition-colors"
301301
:class="[
302-
category === '~builtin' ? 'bg-gray/20 cursor-not-allowed op50' : settings.docksCategoriesHidden.includes(category) ? 'bg-gray/20' : 'bg-primary/20 text-primary',
302+
!isCategoryHideable(category) ? 'bg-gray/20 cursor-not-allowed op50' : settings.docksCategoriesHidden.includes(category) ? 'bg-gray/20' : 'bg-primary/20 text-primary',
303303
]"
304-
:disabled="category === '~builtin'"
304+
:disabled="!isCategoryHideable(category)"
305305
@click="toggleCategory(category)"
306306
>
307307
<div

packages/core/src/client/webcomponents/state/__tests__/dock-groups.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import {
88
getGroupMembers,
99
getGroupMembersGrouped,
1010
getRegisteredGroupIds,
11+
isCategoryHideable,
12+
PINNED_CATEGORY,
13+
PINNED_CATEGORY_ORDER,
1114
resolveCommandIcon,
1215
} from '../dock-settings'
1316

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

181+
describe('pinning re-buckets into the ~pinned category', () => {
182+
function categoryOf(grouped: ReturnType<typeof docksGroupByCategories>, id: string): string | undefined {
183+
return grouped.find(([, items]) => items.some(i => i.id === id))?.[0]
184+
}
185+
186+
it('moves a pinned top-level entry into the ~pinned category', () => {
187+
const entries: DevToolsDockEntry[] = [
188+
iframe('a', { category: 'app' }),
189+
iframe('b', { category: 'app' }),
190+
]
191+
const pinned = { ...settings, docksPinned: ['b'] }
192+
const result = docksGroupByCategories(entries, pinned)
193+
expect(categoryOf(result, 'b')).toBe(PINNED_CATEGORY)
194+
expect(categoryOf(result, 'a')).toBe('app')
195+
})
196+
197+
it('sorts the ~pinned category ahead of every real category', () => {
198+
const entries: DevToolsDockEntry[] = [
199+
iframe('fw', { category: 'framework' }), // framework leads the real table (-100)
200+
iframe('pinme', { category: 'advanced' }),
201+
]
202+
const pinned = { ...settings, docksPinned: ['pinme'] }
203+
const result = docksGroupByCategories(entries, pinned)
204+
expect(result[0]![0]).toBe(PINNED_CATEGORY)
205+
expect(PINNED_CATEGORY_ORDER).toBeLessThan(-100)
206+
})
207+
208+
it('pins a group button to the top-level ~pinned category', () => {
209+
const entries: DevToolsDockEntry[] = [
210+
group('nuxt', { category: 'framework' }),
211+
iframe('nuxt:overview', { groupId: 'nuxt' }),
212+
]
213+
const pinned = { ...settings, docksPinned: ['nuxt'] }
214+
const grouped = docksGroupByCategories(entries, pinned, { collapseGroups: true })
215+
expect(categoryOf(grouped, 'nuxt')).toBe(PINNED_CATEGORY)
216+
})
217+
218+
it('pins a grouped member into a ~pinned SUB-category inside its group (no promotion to the bar)', () => {
219+
const entries: DevToolsDockEntry[] = [
220+
group('nuxt', { category: 'framework' }),
221+
iframe('nuxt:overview', { groupId: 'nuxt', category: 'app' }),
222+
iframe('nuxt:graph', { groupId: 'nuxt', category: 'advanced' }),
223+
]
224+
const pinned = { ...settings, docksPinned: ['nuxt:graph'] }
225+
226+
// The member stays folded in its group on the bar — no top-level ~pinned bucket.
227+
const bar = docksGroupByCategories(entries, pinned, { collapseGroups: true })
228+
expect(bar.map(([c]) => c)).not.toContain(PINNED_CATEGORY)
229+
230+
// Inside the group it leads via a ~pinned sub-category.
231+
const sub = getGroupMembersGrouped(entries, 'nuxt', pinned)
232+
expect(sub[0]![0]).toBe(PINNED_CATEGORY)
233+
expect(categoryOf(sub, 'nuxt:graph')).toBe(PINNED_CATEGORY)
234+
expect(categoryOf(sub, 'nuxt:overview')).toBe('app')
235+
})
236+
237+
it('keeps a pinned entry visible even when its home category is hidden', () => {
238+
const entries: DevToolsDockEntry[] = [
239+
iframe('a', { category: 'advanced' }),
240+
iframe('b', { category: 'advanced' }),
241+
]
242+
const hiddenCat = { ...settings, docksCategoriesHidden: ['advanced'], docksPinned: ['b'] }
243+
const grouped = docksGroupByCategories(entries, hiddenCat)
244+
const ids = grouped.flatMap(([, items]) => items.map(i => i.id))
245+
// 'a' is hidden with its category; the pinned 'b' survives in ~pinned
246+
expect(ids).not.toContain('a')
247+
expect(ids).toContain('b')
248+
expect(categoryOf(grouped, 'b')).toBe(PINNED_CATEGORY)
249+
})
250+
251+
it('orders multiple pinned entries by custom then default order', () => {
252+
const entries: DevToolsDockEntry[] = [
253+
iframe('x', { defaultOrder: 1 }),
254+
iframe('y', { defaultOrder: 0 }),
255+
iframe('z', { defaultOrder: 2 }),
256+
]
257+
const pinned = { ...settings, docksPinned: ['x', 'y', 'z'], docksCustomOrder: { x: 5, y: 6, z: 1 } }
258+
const grouped = docksGroupByCategories(entries, pinned)
259+
const pinnedItems = grouped.find(([c]) => c === PINNED_CATEGORY)![1].map(e => e.id)
260+
// custom order wins over defaultOrder: z(1) < x(5) < y(6)
261+
expect(pinnedItems).toEqual(['z', 'x', 'y'])
262+
})
263+
})
264+
265+
describe('isCategoryHideable', () => {
266+
it('marks ~builtin and ~pinned as non-hideable', () => {
267+
expect(isCategoryHideable('~builtin')).toBe(false)
268+
expect(isCategoryHideable(PINNED_CATEGORY)).toBe(false)
269+
})
270+
271+
it('marks ordinary categories as hideable', () => {
272+
expect(isCategoryHideable('app')).toBe(true)
273+
expect(isCategoryHideable('framework')).toBe(true)
274+
})
275+
})
276+
178277
describe('getCategoryLabel', () => {
179278
it('maps known category ids to human labels', () => {
180279
expect(getCategoryLabel('framework')).toBe('Framework')

packages/core/src/client/webcomponents/state/dock-settings.ts

Lines changed: 79 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,34 @@ import { DEFAULT_CATEGORIES_ORDER } from '../constants'
88
export type { DevToolsDocksUserSettings }
99
export type { DevToolsDockEntriesGrouped }
1010

11+
/**
12+
* Synthetic category that collects pinned dock entries. Pinning re-buckets an
13+
* entry here instead of merely floating it to the top of its home category, so
14+
* pinned entries lead the dock bar (and, for grouped members, lead inside their
15+
* group). The `~` prefix marks it internal, mirroring `~builtin`; it is never
16+
* user-hideable and does not exist upstream in `DEFAULT_CATEGORIES_ORDER`.
17+
*/
18+
export const PINNED_CATEGORY = '~pinned'
19+
20+
/**
21+
* Order weight for {@link PINNED_CATEGORY}. Strongly negative so the Pinned
22+
* bucket always sorts before every real category (`framework` leads the
23+
* upstream table at `-100`). Applied as a local override in the sort rather
24+
* than added to the upstream `DEFAULT_CATEGORIES_ORDER` table, keeping the pin
25+
* feature entirely client-side.
26+
*/
27+
export const PINNED_CATEGORY_ORDER = -100000
28+
29+
/**
30+
* Resolve a category's sort weight, layering the local {@link PINNED_CATEGORY}
31+
* override on top of the upstream {@link DEFAULT_CATEGORIES_ORDER} table.
32+
*/
33+
function categoryOrder(category: string): number {
34+
if (category === PINNED_CATEGORY)
35+
return PINNED_CATEGORY_ORDER
36+
return DEFAULT_CATEGORIES_ORDER[category] || 0
37+
}
38+
1139
export interface SplitGroupsResult {
1240
visible: DevToolsDockEntriesGrouped
1341
overflow: DevToolsDockEntriesGrouped
@@ -35,6 +63,17 @@ const CATEGORY_LABELS: Record<string, string> = {
3563
'web': 'Web',
3664
'advanced': 'Advanced',
3765
'~builtin': 'Built-in',
66+
[PINNED_CATEGORY]: 'Pinned',
67+
}
68+
69+
/**
70+
* Internal categories the user cannot hide via `docksCategoriesHidden`. Both
71+
* are `~`-prefixed synthetic buckets: `~builtin` (always-present built-ins) and
72+
* `~pinned` (the pinned bucket, whose membership the user controls per-entry
73+
* via the pin toggle instead).
74+
*/
75+
export function isCategoryHideable(category: string): boolean {
76+
return category !== '~builtin' && category !== PINNED_CATEGORY
3877
}
3978

4079
/**
@@ -79,16 +118,18 @@ export function getEntryGroup(
79118

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

124165
/**
125166
* Group and sort dock entries based on user settings.
126-
* Filters out hidden entries and categories, sorts by pinned status, custom order, and default order.
167+
* Filters out hidden entries and categories, then sorts by custom order and
168+
* default order within each category.
127169
*
128170
* Outer bucketing follows the dual role of `category`: a grouped member whose
129171
* `groupId` resolves to a registered group takes that **group's** `category` as
@@ -133,6 +175,17 @@ export function getGroupMembers(
133175
* bar, so the outer bucket is always the group's category. Orphan members
134176
* (whose `groupId` references no registered group) fall back to their own
135177
* `category`.
178+
*
179+
* Pinning re-buckets an entry into {@link PINNED_CATEGORY} in place of the
180+
* category slot it would otherwise occupy — the outer bucket for a top-level
181+
* entry or group button, or the in-group sub-category for a member (the
182+
* members-only in-group split has no group entries, so `resolvedGroupCategory`
183+
* is undefined there and the member's own category slot is the one replaced).
184+
* A grouped member's outer bucket is never re-pinned, so pinning a member
185+
* reorders it inside its group rather than promoting it onto the top-level bar.
186+
* Because the pinned bucket is chosen before the category-hide check and is
187+
* itself never hideable, a pinned entry stays visible even when its original
188+
* category is hidden.
136189
*/
137190
export function docksGroupByCategories(
138191
entries: DevToolsDockEntry[],
@@ -180,8 +233,20 @@ export function docksGroupByCategories(
180233
// Outer bucket: the group's category for grouped members, else the entry's
181234
// own category. Orphans (groupId with no registered group) fall through to
182235
// their own `category`.
183-
const category = resolvedGroupCategory ?? entry.category ?? 'default'
184-
// Skip if category is hidden (an outer-bar concern; not applied in-group)
236+
const ownCategory = resolvedGroupCategory ?? entry.category ?? 'default'
237+
// A pinned entry re-buckets into `~pinned` in place of the category slot it
238+
// occupies — but only that slot. Top-level entries and group buttons
239+
// (`resolvedGroupCategory === undefined`) move to the top-level `~pinned`
240+
// bucket; members in the in-group split (also undefined there) move to a
241+
// `~pinned` sub-category. A grouped member's OUTER bucket
242+
// (`resolvedGroupCategory` defined) is left alone, so pin never promotes it
243+
// off its group and onto the bar.
244+
const category = docksPinned.includes(entry.id) && resolvedGroupCategory === undefined
245+
? PINNED_CATEGORY
246+
: ownCategory
247+
// Skip if category is hidden (an outer-bar concern; not applied in-group).
248+
// `~pinned` is never hideable, so a pinned entry survives its original
249+
// category being hidden.
185250
if (!includeHidden && !ignoreCategoryHidden && docksCategoriesHidden.includes(category))
186251
continue
187252

@@ -193,26 +258,22 @@ export function docksGroupByCategories(
193258
const grouped = Array
194259
.from(map.entries())
195260
.sort(([a], [b]) => {
196-
const ia = DEFAULT_CATEGORIES_ORDER[a] || 0
197-
const ib = DEFAULT_CATEGORIES_ORDER[b] || 0
261+
const ia = categoryOrder(a)
262+
const ib = categoryOrder(b)
198263
return ib === ia ? b.localeCompare(a) : ia - ib
199264
})
200265

201266
grouped.forEach(([_, items]) => {
267+
// Ordering within a category (including the `~pinned` bucket, where every
268+
// entry is pinned): custom order first, then default order, then title.
202269
items.sort((a, b) => {
203-
// Pinned entries come first
204-
const aPinned = docksPinned.includes(a.id)
205-
const bPinned = docksPinned.includes(b.id)
206-
if (aPinned !== bPinned)
207-
return aPinned ? -1 : 1
208-
209-
// Then sort by custom order
270+
// Custom order
210271
const customOrderA = docksCustomOrder[a.id] ?? 0
211272
const customOrderB = docksCustomOrder[b.id] ?? 0
212273
if (customOrderA !== customOrderB)
213274
return customOrderA - customOrderB
214275

215-
// Finally by default order
276+
// Default order
216277
const ia = a.defaultOrder ?? 0
217278
const ib = b.defaultOrder ?? 0
218279
return ib === ia ? b.title.localeCompare(a.title) : ia - ib

0 commit comments

Comments
 (0)