feat: pinned tabs#1386
Merged
Merged
Conversation
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>
- 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>
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



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.enabledis set.Capabilities
[pinned][unpinned]), stable pin order, active panel preserved across the reorder.…overflow dropdown (pureSet.haspredicate; unpinned tabs overflow as before).togglePinOnCrossBoundaryDrag: false, Chrome-style); set ittruefor the VS-Code flip.toJSON/fromJSON(GroupviewPanelState.pinned, emitted only when true; old layouts load unpinned).compact: trueopts into icon-only.mode: 'separate-row'mounts a pinned row above the main strip; the header grows and content reflows; the row collapses when nothing is pinned.'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+ componentonDidPanelPinnedChange; pinned state lives on the panel (mirrorstitle), mutated through a gatedDockviewComponent.setPanelPinned.Tabs.setOverflowExclude(fn),TabsContainer.setDropIndexResolver/resolveDropIndex,TabsContainer.setPinnedRow(el)— exposed onIHeader.GroupviewPanelState.pinnedserialization; built-in'pin'context-menu token.Testing
Deferred follow-ups
flex-shrink: 0+ dropdown-exclusion).'pin'token is opt-in viagetTabContextMenuItems).🤖 Generated with Claude Code