From e66e8d620b00359f5eaa31b2389a4215ed805761 Mon Sep 17 00:00:00 2001 From: the-nelsonator Date: Wed, 24 Jun 2026 01:20:25 -0700 Subject: [PATCH 1/2] 2368: add sticky tabs --- src/_locales/dict.setup-page.ts | 48 +++++++++++++++ src/defaults/panels.ts | 1 + src/defaults/settings.ts | 3 + src/page.setup/components/settings.tabs.vue | 17 ++++++ src/services/tabs.fg.handlers.ts | 10 +++ src/services/tabs.fg.scroll.ts | 68 +++++++++++++++++++++ src/services/tabs.fg.ts | 10 ++- src/sidebar/components/panel.tabs.vue | 36 ++++++++++- src/sidebar/components/sticky-tabs.vue | 26 ++++++++ src/sidebar/components/tab.vue | 19 +++++- src/styles/sidebar/sidebar.styl | 1 + src/styles/sidebar/sticky-tabs.styl | 26 ++++++++ src/types/settings.ts | 2 + src/types/sidebar.ts | 1 + 14 files changed, 262 insertions(+), 6 deletions(-) create mode 100644 src/sidebar/components/sticky-tabs.vue create mode 100644 src/styles/sidebar/sticky-tabs.styl diff --git a/src/_locales/dict.setup-page.ts b/src/_locales/dict.setup-page.ts index 78cec0b78..334ab9f5c 100644 --- a/src/_locales/dict.setup-page.ts +++ b/src/_locales/dict.setup-page.ts @@ -3537,6 +3537,54 @@ Przykłady: "*", "ctrl+$", "ctrl+alt+g"`, zh_TW: '無', ja: 'なし', }, + 'settings.sticky_tabs_hierarchy': { + en: 'Sticky tabs hierarchy', + de: 'Fixierte Tab-Hierarchie', + fr: 'Hiérarchie d’onglets fixe', + hu: 'Rögzített laphierarchia', + pl: 'Przypięta hierarchia kart', + ru: 'Закреплённая иерархия вкладок', + zh_CN: '固定标签页层级', + zh_TW: '固定分頁階層', + ja: 'タブ階層を固定表示', + }, + 'settings.sticky_tabs_hierarchy_limit': { + en: 'Sticky hierarchy depth limit', + de: 'Maximale Anzahl fixierter Ebenen', + fr: 'Limite de profondeur de la hiérarchie fixe', + hu: 'A rögzített hierarchia mélységkorlátja', + pl: 'Limit głębokości przypiętej hierarchii', + ru: 'Максимум закреплённых уровней', + zh_CN: '固定层级深度上限', + zh_TW: '固定階層深度上限', + ja: '固定する階層の深さ制限', + }, + 'settings.sticky_tabs_hierarchy_limit_1': { + en: '1', + }, + 'settings.sticky_tabs_hierarchy_limit_2': { + en: '2', + }, + 'settings.sticky_tabs_hierarchy_limit_3': { + en: '3', + }, + 'settings.sticky_tabs_hierarchy_limit_4': { + en: '4', + }, + 'settings.sticky_tabs_hierarchy_limit_5': { + en: '5', + }, + 'settings.sticky_tabs_hierarchy_limit_none': { + en: 'none', + de: 'Unbegrenzt', + fr: 'aucun', + hu: 'nincs', + pl: 'brak', + ru: 'выкл', + zh_CN: '无', + zh_TW: '無', + ja: 'なし', + }, 'settings.auto_fold_tabs': { en: 'Auto fold tabs branch on expanding another branch', de: 'Zweig beim Öffnen eines anderen Zweiges automatisch einklappen', diff --git a/src/defaults/panels.ts b/src/defaults/panels.ts index c2677c450..5d7dd39ee 100644 --- a/src/defaults/panels.ts +++ b/src/defaults/panels.ts @@ -114,6 +114,7 @@ export const TABS_PANEL_STATE: T.TabsPanel = { visibleTabIds: [], pinnedTabIds: [], + stickyTabIds: [], updated: false, selNewTab: false, scrollRetainerHeight: 0, diff --git a/src/defaults/settings.ts b/src/defaults/settings.ts index 65a0e7309..44d985d44 100644 --- a/src/defaults/settings.ts +++ b/src/defaults/settings.ts @@ -136,6 +136,8 @@ export const DEFAULT_SETTINGS: SettingsState = { // Tabs tree tabsTree: true, tabsTreeLimit: 'none', + tabsStickyHierarchy: false, + tabsStickyHierarchyLimit: 3, autoFoldTabs: false, autoFoldTabsExcept: 'none', autoExpandTabs: false, @@ -335,6 +337,7 @@ export const SETTINGS_OPTIONS = { tabsUpdateMark: ['all', 'pin', 'norm', 'none'], pinnedTabsPosition: ['panel', 'top', 'left', 'right'], tabsTreeLimit: [1, 2, 3, 4, 5, 'none'], + tabsStickyHierarchyLimit: [1, 2, 3, 4, 5, 'none'], previewTabsMode: ['i', 'p'], previewTabsPageModeFallback: ['i', 'n'], previewTabsSide: ['right', 'left'], diff --git a/src/page.setup/components/settings.tabs.vue b/src/page.setup/components/settings.tabs.vue index 768cb4eb6..393c24e04 100644 --- a/src/page.setup/components/settings.tabs.vue +++ b/src/page.setup/components/settings.tabs.vue @@ -327,6 +327,23 @@ section(ref="el") :inactive="!Settings.state.tabsTree" :opts="Settings.getOpts('tabsTreeLimit')" @update:value="Settings.saveDebounced(150)") + ToggleField( + label="settings.sticky_tabs_hierarchy" + dbg="tabsStickyHierarchy" + v-model:value="Settings.state.tabsStickyHierarchy" + :default="DEFAULT_SETTINGS.tabsStickyHierarchy" + :inactive="!Settings.state.tabsTree" + @update:value="Settings.saveDebounced(150)") + .sub-fields + SelectField( + label="settings.sticky_tabs_hierarchy_limit" + optLabel="settings.sticky_tabs_hierarchy_limit_" + dbg="tabsStickyHierarchyLimit" + v-model:value="Settings.state.tabsStickyHierarchyLimit" + :default="DEFAULT_SETTINGS.tabsStickyHierarchyLimit" + :inactive="!Settings.state.tabsTree || !Settings.state.tabsStickyHierarchy" + :opts="Settings.getOpts('tabsStickyHierarchyLimit')" + @update:value="Settings.saveDebounced(150)") ToggleField( label="settings.auto_fold_tabs" :inactive="!Settings.state.tabsTree" diff --git a/src/services/tabs.fg.handlers.ts b/src/services/tabs.fg.handlers.ts index 79cade5f7..e49fad6a0 100644 --- a/src/services/tabs.fg.handlers.ts +++ b/src/services/tabs.fg.handlers.ts @@ -1733,6 +1733,16 @@ function onTabActivated(info: browser.tabs.ActiveInfo): void { const panel = Sidebar.panelsById[tab.panelId] if (!Utils.isTabsPanel(panel)) return + // Update the sticky tab hierarchy (ancestors of the active tab) for this panel, + // and clear it on the panel the active tab just left. + if (Settings.state.tabsStickyHierarchy) { + Tabs.calcStickyTabs(panel) + if (prevActive && prevActive.panelId !== panel.id) { + const prevPanel = Sidebar.panelsById[prevActive.panelId] + if (Utils.isTabsPanel(prevPanel)) Tabs.calcStickyTabs(prevPanel) + } + } + // Update succession Tabs.updateSuccessionDebounced(0) diff --git a/src/services/tabs.fg.scroll.ts b/src/services/tabs.fg.scroll.ts index 070c02fd1..688a82d9d 100644 --- a/src/services/tabs.fg.scroll.ts +++ b/src/services/tabs.fg.scroll.ts @@ -2,6 +2,8 @@ import { TabsPanel } from 'src/types' import * as Utils from 'src/utils' import * as Logs from 'src/services/logs' import * as Sidebar from 'src/services/sidebar.fg' +import * as Settings from 'src/services/settings' +import * as Tabs from 'src/services/tabs.fg' import { PRE_SCROLL } from 'src/defaults' export let blockedScrollPosition = false @@ -94,3 +96,69 @@ export function resetScrollRetainer(panel: TabsPanel) { panel.reactive.scrollRetainerHeight = 0 blockedScrollPosition = false } + +/** + * Recalculate the sticky tab hierarchy: the ancestor chain of the active tab, but only + * the ancestors whose row has scrolled above the top of the viewport (so an ancestor + * stops being sticky once it's visible in the list again), capped by the configured + * depth limit. Pinned only on the panel that contains the active tab. Writes the result + * to `panel.reactive.stickyTabIds` (only when it actually changes). + */ +export function calcStickyTabs(panel: TabsPanel): void { + const reactive = panel.reactive + + const reset = () => { + if (reactive.stickyTabIds.length) reactive.stickyTabIds = [] + } + + if (!Settings.state.tabsTree || !Settings.state.tabsStickyHierarchy || !panel.scrollEl) { + return reset() + } + + const activeTab = Tabs.byId[Tabs.activeId] + if (!activeTab || activeTab.pinned || activeTab.panelId !== panel.id) return reset() + + const tabFullHeight = Sidebar.tabHeight + Sidebar.tabMargin + if (tabFullHeight <= 0) return reset() + + // Build the ancestor chain (root-most first) of the active tab via parentId. + const ancestors: ID[] = [] + let parent = Tabs.byId[activeTab.parentId] + let guard = 0 + while (parent && guard++ < 256) { + ancestors.unshift(parent.id) + parent = Tabs.byId[parent.parentId] + } + + // Keep only ancestors that are hidden: either scrolled above the viewport top, or + // covered by the sticky rows already pinned above them. Each sticky row we add pushes + // the cutoff down by one row height (it hides the tab beneath it), so the bottom of + // the accumulated sticky stack is the real cutoff. Ancestors are ordered root -> active + // with increasing row position, so stop at the first one still fully visible below the + // stack. + const visibleIds = reactive.visibleTabIds + const scrollTop = panel.scrollEl.scrollTop + const sticky: ID[] = [] + let stackBottom = 0 // viewport-Y of the bottom of the accumulated sticky stack + for (const id of ancestors) { + const idx = visibleIds.indexOf(id) + if (idx === -1) continue + const rowViewportTop = idx * tabFullHeight - scrollTop + if (rowViewportTop < stackBottom) { + sticky.push(id) + stackBottom += tabFullHeight + } else break + } + + // Apply the depth cap (keep the deepest N ancestors, closest to the active tab). + const limit = Settings.state.tabsStickyHierarchyLimit + let result = sticky + if (typeof limit === 'number' && sticky.length > limit) { + result = sticky.slice(sticky.length - limit) + } + + // Skip the reactive write when nothing changed. + const cur = reactive.stickyTabIds + if (cur.length === result.length && cur.every((id, i) => id === result[i])) return + reactive.stickyTabIds = result +} diff --git a/src/services/tabs.fg.ts b/src/services/tabs.fg.ts index d3384ec86..aa2558490 100644 --- a/src/services/tabs.fg.ts +++ b/src/services/tabs.fg.ts @@ -2900,8 +2900,14 @@ export function renderTitle(tab: T.Tab, forcedTitle?: string) { } export function renderFavicon(tab: T.Tab) { - const imgEl = tab.favImgEl - const svgUseEl = tab.favSvgUseEl + renderFaviconInto(tab, tab.favImgEl, tab.favSvgUseEl) +} + +export function renderFaviconInto( + tab: T.Tab, + imgEl?: HTMLImageElement, + svgUseEl?: SVGElement +): void { if (tab.favIconUrl && imgEl) { // Set img imgEl.src = tab.favIconUrl diff --git a/src/sidebar/components/panel.tabs.vue b/src/sidebar/components/panel.tabs.vue index 57a96c0cb..d1128348f 100644 --- a/src/sidebar/components/panel.tabs.vue +++ b/src/sidebar/components/panel.tabs.vue @@ -9,6 +9,7 @@ @drop="onDrop") PinnedTabsBar(v-if="panel.reactive.pinnedTabIds.length" :panel="panel") ScrollBox(ref="scrollBox" :preScroll="D.PRE_SCROLL") + StickyTabs(:panel="panel") DragAndDropPointer(:panelId="panel.id" :subPanel="false") AnimatedTabList(:panel="panel") TabComponent(v-for="id in panel.reactive.visibleTabIds" :key="id" :tabId="id") @@ -30,7 +31,7 @@ diff --git a/src/sidebar/components/tab.vue b/src/sidebar/components/tab.vue index df616e5c3..e0fd6ea43 100644 --- a/src/sidebar/components/tab.vue +++ b/src/sidebar/components/tab.vue @@ -1,6 +1,7 @@