Skip to content

feat: sticky ancestor tabs#2424

Open
the-nelsonator wants to merge 2 commits into
mbnuqw:v5from
the-nelsonator:2368-sticky-heirarchy
Open

feat: sticky ancestor tabs#2424
the-nelsonator wants to merge 2 commits into
mbnuqw:v5from
the-nelsonator:2368-sticky-heirarchy

Conversation

@the-nelsonator

@the-nelsonator the-nelsonator commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Closes #2368

Summary

Adds an optional sticky ancestor tab section to tabs panels. When enabled, the ancestor chain (parent → grandparent → …) of the active tab is pinned to the top of the panel as you scroll, so you never lose track of where you are in a large tree. Clicking a sticky row activates that ancestor (and scrolls to it).

Controlled by settings Tabs > Tabs tree > Sticky ancestor tabs (off by default) and Sticky ancestor tabs depth limit (default 3).

Before scroll down After scroll down
image image

Demo: https://pixeldrain.com/u/pcLTdKNC

Behaviour

  • Pins the active tab's ancestors to a fixed/sticky section at the top of the panel that contains the active tab. Clicking activates the ancestor tab.
  • Only ancestors that are actually hidden are shown — i.e. scrolled above the viewport top or covered by the sticky rows already pinned above them. Scrolled fully to the top → nothing is pinned.
  • The pinned rows are real tabs: favicons, context menu, drag, close/expand, container colours, and active styling all work exactly like normal tabs.
  • Depth (amount of parents above active tab) is capped by a configurable limit (default 3, options 1–5 / none) so deep trees don't fill the viewport.
  • Honours tree layout: the feature and its sub-option are inactive when "Tabs tree structure" is off, and hidden during search.

Implementation notes

ComputeTabs.calcStickyTabs(panel) (src/services/tabs.fg.scroll.ts):

  • Starts from Tabs.byId[Tabs.activeId]; bails unless the active tab is in this panel and not pinned.
  • Walks the parentId chain (root → active).
  • Filters to hidden ancestors by accumulating the sticky-stack height: walking root→active, include while idx*rowH - scrollTop < stackBottom, then stackBottom += rowH. Each pinned row pushes the cutoff down by one row height (it hides the tab beneath it). Short-circuits at the first fully-visible ancestor since rows are monotonically ordered.
  • Applies the depth cap (keeps the deepest N) and writes panel.reactive.stickyTabIds only when it changes.

Recompute triggers:

  • Scroll — passive scroll listener on the panel's scroll element.
  • Active-tab switch — pushed from onTabActivated (tabs.fg.handlers.ts) for the new panel and the panel the active tab left (Tabs.activeId isn't Vue-reactive).
  • Structure / toggle — deep watch on visibleTabIds + the feature setting in panel.tabs.vue, plus an initial compute on mount.

Rendering:

  • New sticky-tabs.vue overlay renders the real tab.vue component with a new :sticky prop, one per id.
  • tab.vue gains sticky?: boolean: omits the id="tab<id>" attribute (no duplicate DOM ids), adds a -sticky class, and — crucially — does not write the shared element refs (titleEl/favImgEl/favSvgUseEl/flashFxEl) onto the tab object, which would hijack the real row's imperative favicon/title/flash updates. The favicon is instead rendered into the clone's own elements via the new Tabs.renderFaviconInto(tab, imgEl, svgUseEl) (extracted from renderFavicon); onError falls back locally too.
  • Styled via src/styles/sidebar/sticky-tabs.styl: a zero-height position: sticky; top: 0 anchor inside the scroll viewport (same mechanism as the existing new-tab bar), so it takes no flow space (no layout shift) and aligns automatically. Solid backdrop + a separator shadow under the lowest row.

Files changed

  • Settings: types/settings.ts, defaults/settings.ts, page.setup/components/settings.tabs.vue, _locales/dict.setup-page.ts
  • Panel state: types/sidebar.ts, defaults/panels.ts
  • Logic: services/tabs.fg.scroll.ts (calcStickyTabs), services/tabs.fg.ts (renderFaviconInto), services/tabs.fg.handlers.ts (recompute on activation)
  • UI: sidebar/components/sticky-tabs.vue (new), sidebar/components/tab.vue (sticky prop), sidebar/components/panel.tabs.vue (overlay + triggers)
  • Styles: styles/sidebar/sticky-tabs.styl (new) + import in styles/sidebar/sidebar.styl

@the-nelsonator

Copy link
Copy Markdown
Contributor Author
> sidebery@5.5.2 lint.types
> vue-tsc --noemit

Error: src/defaults/mocks.tabs.fg.ts(67,5): error TS2353: Object literal may only specify known properties, and 'preview' does not exist in type 'ReactiveTabProps'.
Error: src/defaults/mocks.tabs.fg.ts(109,23): error TS7053: Element implicitly has an 'any' type because expression of type '"folded" | "hidden" | "url" | "title" | "isParent" | "autoUnloadFoldedTimeout" | "invisible" | "parentId" | "panelId" | "prevPanelId" | "lvl" | "sel" | "selLock" | "updated" | ... 54 more ... | "groupId"' can't be used to index type 'MTab'.
  Property 'splitViewId' does not exist on type 'MTab'.

Not related to these changes

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.

[Enhancement] Sticky/fixed tab hierarchy when scrolling

1 participant