Skip to content

feat: pinned tabs#1386

Merged
mathuo merged 13 commits into
v8-branchfrom
feat/pinned-tabs
Jul 1, 2026
Merged

feat: pinned tabs#1386
mathuo merged 13 commits into
v8-branchfrom
feat/pinned-tabs

Conversation

@mathuo

@mathuo mathuo commented Jul 1, 2026

Copy link
Copy Markdown
Owner

Adds a PinnedTabs module: pin a tab so it stays put — pinned tabs render before all unpinned tabs in their group, never overflow into the dropdown, resist reorder across the pin boundary, and (optionally) render on their own row. Modelled on VS Code / Chrome pinned tabs.

Additive and removable: every core seam defaults to a no-op, so a component built without the module is byte-identical to today, and the module is dormant unless pinnedTabs.enabled is set.

Capabilities

  • Ordering — pinned tabs sort to the front ([pinned][unpinned]), stable pin order, active panel preserved across the reorder.
  • Overflow — pinned tabs are excluded from the overflow dropdown (pure Set.has predicate; unpinned tabs overflow as before).
  • Reorder guard — a drag cannot land a tab on the wrong side of the pin boundary. Cross-boundary drag is clamped by default (togglePinOnCrossBoundaryDrag: false, Chrome-style); set it true for the VS-Code flip.
  • Serialization — pinned state round-trips through toJSON/fromJSON (GroupviewPanelState.pinned, emitted only when true; old layouts load unpinned).
  • Rendering — a pinned tab keeps its title plus an injected pin glyph and loses its close button (protected from a stray close; close via context menu / keyboard / unpin). compact: true opts into icon-only.
  • Second rowmode: 'separate-row' mounts a pinned row above the main strip; the header grows and content reflows; the row collapses when nothing is pinned.
  • Context menu — a built-in 'pin' tab-menu item renders "Pin tab"/"Unpin tab".

Public API

```ts
// options
interface DockviewOptions { pinnedTabs?: PinnedTabsOptions; }
interface PinnedTabsOptions {
enabled?: boolean; // master switch (dormant until set)
mode?: 'inline' | 'separate-row'; // default 'inline'
compact?: boolean; // default false (labelled + glyph)
togglePinOnCrossBoundaryDrag?: boolean; // default false (clamp)
contextMenuItem?: boolean;
}

// panel api
panel.api.isPinned: boolean
panel.api.setPinned(pinned: boolean): void
panel.api.onDidChangePinned: Event<{ isPinned: boolean }>

// component api
dockview.api.onDidPanelPinnedChange: Event<{ panel, isPinned }>
```

Core seams (all no-op by default → removable)

  • panel.api.isPinned/setPinned/onDidChangePinned + component onDidPanelPinnedChange; pinned state lives on the panel (mirrors title), mutated through a gated DockviewComponent.setPanelPinned.
  • Tabs.setOverflowExclude(fn), TabsContainer.setDropIndexResolver/resolveDropIndex, TabsContainer.setPinnedRow(el) — exposed on IHeader.
  • GroupviewPanelState.pinned serialization; built-in 'pin' context-menu token.

Testing

  • Unit (jsdom): ordering math, reorder-guard clamp/flip matrix, overflow-exclusion seam, serialization round-trip, class/glyph application, second-row DOM, context-menu item. Auto-generated removability test passes.
  • e2e (real browser): separate-row grows the header + reflows content (jsdom can't see layout — this caught a real CSS specificity bug), compact hides the title, the pin glyph is actually visible, the close button is hidden, and pinned tabs stay out of the overflow dropdown.
  • Green: dockview-core 1107, dockview-modules 236, e2e 30; format + lint clean.

Deferred follow-ups

  • Cross-row drag-and-drop and custom tab renderers in the separate row (the row shows the title today).
  • "Sticky-to-the-left" pinned tabs on horizontal scroll (currently flex-shrink: 0 + dropdown-exclusion).
  • A dedicated pinned section in the overflow dropdown when pinned tabs alone exceed the strip.
  • Auto-injecting the context-menu item (dockview's menu is app-callback-driven; the 'pin' token is opt-in via getTabContextMenuItems).

🤖 Generated with Claude Code

mathuo and others added 11 commits June 30, 2026 22:44
Add an additive, removable PinnedTabs module that keeps pinned tabs ordered
before unpinned tabs within a group's strip.

Core seams (no-op when the module is absent):
- panel.api.isPinned / setPinned / onDidChangePinned — pinned state lives on
  DockviewPanel (mirrors title); setPinned routes through the gated
  DockviewComponent.setPanelPinned (silent no-op unless pinnedTabs.enabled,
  warn-once no-op when the module is unregistered).
- component-level onDidPanelPinnedChange + DockviewApi getter.
- PinnedTabsOptions, IPinnedTabsHost/IPinnedTabsService contracts, the
  pinnedTabsService ServiceCollection slot.

Module (dockview-modules):
- PinnedTabsService reacts to onDidPanelPinnedChange and reorders the strip via
  panel.api.moveTo({ index }), enforcing [pinned (pin order)][unpinned] and
  preserving the active panel. Pure computePinnedFirstOrder helper is unit
  tested; registered in the Modules bundle (auto-covered by removability).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pinned tabs are kept out of the overflow dropdown so they never scroll away.

Core seam (no-op default → removable):
- Tabs.setOverflowExclude(fn): a pure predicate consulted in toggleDropdown's
  filter (and the collapsed tab-group re-add path) so excluded panels never
  enter the overflow set; re-runs the dropdown immediately when set. Proxied
  through TabsContainer and exposed on IHeader, so the module reaches it via the
  public group.model.header.

Module:
- PinnedTabsService keeps a flat Set of pinned panel ids and feeds each group's
  strip isExcludedFromOverflow (a pure Set.has lookup, false while dormant),
  wired on group add and refreshed on each pin/unpin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e 3)

Constrain header DnD so it cannot violate the pinned-first invariant.

Core seam (identity default → removable):
- TabsContainer.setDropIndexResolver(fn) / resolveDropIndex(panelId, index),
  exposed on IHeader. dockviewGroupPanelModel routes a same-panel header drop's
  index through the resolver before handleDropEvent.

Module:
- PinnedTabsService.resolveDropIndex clamps a drop against the group's pin
  boundary (computed excluding the dragged panel, so it matches the post-removal
  index): an unpinned tab cannot land left of the pinned block and vice versa.
  With togglePinOnCrossBoundaryDrag (default on) a boundary-crossing drop flips
  the dragged panel's pinned state instead — deferred to a microtask so it
  settles after the move, then the pinned-change handler re-orders the strip.
  Wired onto every group alongside the overflow predicate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
togglePinOnCrossBoundaryDrag now defaults to false: dragging a tab across the
pin boundary is clamped back rather than flipping the pinned state, matching
Chrome (where pinning is an explicit action only). Set it to true for the
VS-Code-style flip behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pinned state survives toJSON/fromJSON.

- GroupviewPanelState.pinned?: boolean. DockviewPanel.toJSON emits it only when
  true (undefined otherwise → omitted), so existing layouts stay byte-stable.
  Both restore paths read it back onto the panel: updateFromStateModel and the
  deserializer.
- The per-group pinned order rides along in the already-pinned-first `views`
  array, so no separate order field is needed.
- Restore writes panel.isPinned directly (not via the gated setter), so
  PinnedTabsService seeds its pinned store from the restored panels on
  onDidLayoutFromJSON (host now exposes it), then re-asserts the invariant and
  the overflow predicate per group.

Round-trip preserves the pinned set + pinned-first order; layouts without a
pinned key load unpinned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e 5)

Compact (icon-only) pinned tabs and a built-in Pin/Unpin menu item.

- Core Tab applies `dv-tab--pinned` when the panel is pinned and
  `dv-tab--pinned-compact` unless `pinnedTabs.compact` is false. Driven off
  panel pinned state (inert when nothing is pinned) and re-applied on
  onDidChangePinned — needed because a reorder recreates the Tab. Reads are
  guarded so partial test mocks without a full panel api still construct.
- tabs.scss: `--pinned` shows a leading pin glyph and keeps the tab from
  clipping; `--pinned-compact` hides the title + close button (Chrome-style)
  and shows a standalone pin glyph. Presentation only; masked from currentColor.
- ContextMenu: new built-in `'pin'` tab-menu token (alongside 'close' etc.)
  renders "Pin tab"/"Unpin tab" and toggles panel.api.setPinned. Apps opt in
  via getTabContextMenuItems — dockview's menu is app-callback-driven, so there
  is no module auto-injection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
VS-Code-style pinned tabs on their own row above the main strip.

- Core seam TabsContainer.setPinnedRow(el | undefined) (on IHeader): mounts the
  module-owned row and flips the header to a wrapping, auto-height layout so the
  row sits full-width above the main strip (order:-1); pinned tabs are hidden in
  the main strip via the modifier class. Default: no row (removable).
- Module SecondRowController (per group, only when mode === 'separate-row'):
  renders each pinned panel as a lightweight tab (title + pin glyph), click to
  activate, click the glyph to unpin; tracks title/active/remove; collapses
  (unmounts) when the group has no pinned tabs and relayouts so content reflows
  for the taller header.
- SCSS for .dv-pinned-row / .dv-pinned-tab and the main-strip hide rule.

MVP: cross-row drag-and-drop and custom tab renderers in the row are deferred;
the row shows the panel title only. Layout/reflow correctness needs real-browser
verification (e2e/demo) — jsdom asserts the DOM structure only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- e2e/fixtures/index.html: enable pinnedTabs (mode from ?pinned= query param,
  default inline) + setupPinned/setPinned/isPinned handles.
- e2e/tests/pinned-tabs.spec.ts: three real-browser tests jsdom can't do —
  separate-row grows the header and reflows content (and collapses back on
  unpin), compact hides the title and shrinks the tab, and a pinned tab is
  excluded from the overflow dropdown when the strip clips.
- Fix: the separate-row header-grow rule needed a double-class selector to beat
  the base fixed-height rule regardless of stylesheet order (caught by e2e; the
  jsdom tests don't exercise layout).
- Demo (app.tsx): pinnedTabs={{ enabled: true }} + a 'pin' tab context-menu
  item so pinning is reachable in the live demo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The pin glyph was a CSS mask over a data-URI SVG, which rendered inconsistently
(often invisible), and compact-by-default left dockview's iconless default tabs
showing nothing but that (missing) glyph — pinned tabs were unidentifiable.

- Pin glyph is now a real inline SVG (createPinButton → .dv-tab-pin, inherits
  currentColor via .dv-svg), injected by the tab on pin and removed on unpin.
  Matches how the close button is rendered; themeable and reliable.
- compact now defaults to false: pinned tabs keep their title (plus the glyph)
  unless `pinnedTabs.compact: true` is set (best with a custom icon tab
  renderer). Icon-only is opt-in.
- SCSS: .dv-tab-pin styling + pinned tab is flex so the glyph sits before the
  title; compact still hides title + close. Dropped the fragile mask approach.

e2e now asserts the glyph is actually visible (real browser) in both labelled
and compact modes, and that a labelled pinned tab keeps its title.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pinning now removes the tab's close (×) button (Chrome / VS-Code style), so a
pinned tab can't be closed with a stray click — reinforcing that pinning
protects the tab. Applies to labelled and compact pinned tabs alike. Closing is
still available via the context menu and the keyboard close binding, or by
unpinning first. e2e asserts the close button is hidden on a labelled pinned tab.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the new public exports (PinnedTabsOptions, PinnedChangeEvent,
DockviewPanelPinnedChangeEvent, IPinnedTabsHost, IPinnedTabsService) so
gen:check passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mathuo mathuo marked this pull request as ready for review July 1, 2026 16:25
mathuo and others added 2 commits July 1, 2026 18:34
- Restore is now gated on pinnedTabs.enabled (deserializer + updateFromStateModel):
  a layout saved with pinned tabs loads UNPINNED when pinning is disabled/absent,
  so it can't strand an uncloseable + unpinnable tab. isPinned doc corrected.
- SecondRowController re-renders on onDidAddPanel, so a pinned panel moved into a
  separate-row group appears in that group's row (previously vanished).
- Collapsed tab-group overflow: pinned members are no longer excluded from the
  re-add loop — a collapsed group's members are only reachable via the dropdown,
  so excluding a pinned one made it unreachable.
- PinnedTabsService prunes _pinnedIds on onDidRemovePanel (new host event), so a
  closed pinned panel's id can't linger in the overflow-exclusion set (leak /
  mis-exclusion of a reused id).
- resolveDropIndex JSDoc corrected to say togglePinOnCrossBoundaryDrag defaults
  off (clamp), matching the option + code.

Tests: +3 (disabled restore loads unpinned; moved pinned panel renders in the
new row; closing a pinned panel prunes the exclusion set). core 1107 / modules
239 / e2e 30 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- #7: custom tab renderers are no longer disrupted when pinned — the pin glyph
  + flex layout are only applied to the default renderer (skipped when
  panel.api.tabComponent is set; display:flex scoped under :has(.dv-tab-pin)).
  A custom tab still gets the dv-tab--pinned class to style itself.
- #5 + #10: pinned order now follows the current strip order (enforceOrder
  passes an empty canonical order), so a manual drag-reorder within the pinned
  block is preserved instead of being reverted on the next pin. This removes the
  _pinnedOrder map + _orderFor/_prune/_isPinned bookkeeping entirely.
- #8: enforceOrder is O(n) — a single Map<id,panel> + a local live-order array
  replace the per-slot panels.find/indexOf scans.
- #9: SecondRowController rebuilds the row + re-subscribes titles only when the
  pinned set/order changes; active-panel changes flip the class surgically and a
  title change updates one label in place.
- #10: added a refreshOverflow() header seam so the service stops re-registering
  the identical predicate just to force a dropdown recompute.

Tests: +1 (a custom tab renderer is intact when pinned: pinned class applied, no
glyph injected, custom markup preserved). core 1107 / modules 240 / e2e 30 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Jul 1, 2026

Copy link
Copy Markdown

@mathuo mathuo merged commit 4269305 into v8-branch Jul 1, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant