diff --git a/src/_locales/dict.setup-page.ts b/src/_locales/dict.setup-page.ts
index 78cec0b7..19e90365 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_ancestor_tabs': {
+ en: 'Sticky ancestor tabs',
+ de: 'Fixierte übergeordnete Tabs',
+ fr: 'Onglets ancêtres fixes',
+ hu: 'Rögzített felmenő lapok',
+ pl: 'Przypięte karty nadrzędne',
+ ru: 'Закреплённые вкладки-предки',
+ zh_CN: '固定祖先标签页',
+ zh_TW: '固定祖先分頁',
+ ja: '先祖タブを固定表示',
+ },
+ 'settings.sticky_ancestor_tabs_limit': {
+ en: 'Sticky ancestor tabs depth limit',
+ de: 'Maximale Tiefe der fixierten übergeordneten Tabs',
+ fr: 'Limite de profondeur des onglets ancêtres fixes',
+ hu: 'A rögzített felmenő lapok mélységkorlátja',
+ pl: 'Limit głębokości przypiętych kart nadrzędnych',
+ ru: 'Максимальная глубина закреплённых вкладок-предков',
+ zh_CN: '固定祖先标签页深度上限',
+ zh_TW: '固定祖先分頁深度上限',
+ ja: '固定する先祖タブの深さ制限',
+ },
+ 'settings.sticky_ancestor_tabs_limit_1': {
+ en: '1',
+ },
+ 'settings.sticky_ancestor_tabs_limit_2': {
+ en: '2',
+ },
+ 'settings.sticky_ancestor_tabs_limit_3': {
+ en: '3',
+ },
+ 'settings.sticky_ancestor_tabs_limit_4': {
+ en: '4',
+ },
+ 'settings.sticky_ancestor_tabs_limit_5': {
+ en: '5',
+ },
+ 'settings.sticky_ancestor_tabs_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 c2677c45..5d7dd39e 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 65a0e730..b58969c5 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',
+ stickyAncestorTabs: false,
+ stickyAncestorTabsLimit: 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'],
+ stickyAncestorTabsLimit: [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 768cb4eb..d1380ec4 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_ancestor_tabs"
+ dbg="stickyAncestorTabs"
+ v-model:value="Settings.state.stickyAncestorTabs"
+ :default="DEFAULT_SETTINGS.stickyAncestorTabs"
+ :inactive="!Settings.state.tabsTree"
+ @update:value="Settings.saveDebounced(150)")
+ .sub-fields
+ SelectField(
+ label="settings.sticky_ancestor_tabs_limit"
+ optLabel="settings.sticky_ancestor_tabs_limit_"
+ dbg="stickyAncestorTabsLimit"
+ v-model:value="Settings.state.stickyAncestorTabsLimit"
+ :default="DEFAULT_SETTINGS.stickyAncestorTabsLimit"
+ :inactive="!Settings.state.tabsTree || !Settings.state.stickyAncestorTabs"
+ :opts="Settings.getOpts('stickyAncestorTabsLimit')"
+ @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 79cade5f..e8d45ef6 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.stickyAncestorTabs) {
+ 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 070c02fd..0ffabf6a 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.stickyAncestorTabs || !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.stickyAncestorTabsLimit
+ 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 d3384ec8..aa255849 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 57a96c0c..cebb4d93 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 df616e5c..e0fd6ea4 100644
--- a/src/sidebar/components/tab.vue
+++ b/src/sidebar/components/tab.vue
@@ -1,6 +1,7 @@
.Tab(
- :id="'tab' + tab.id"
+ :id="sticky ? undefined : 'tab' + tab.id"
+ :class="{ '-sticky': sticky }"
:data-pin="!!iconOnly"
:data-active="tab.reactive.active"
:data-loading="tab.reactive.status === TabStatus.Loading"
@@ -95,7 +96,7 @@ import * as Utils from 'src/utils'
import * as Logs from 'src/services/logs'
import * as Preview from 'src/services/tabs.fg.preview'
-const props = defineProps<{ tabId: ID }>()
+const props = defineProps<{ tabId: ID; sticky?: boolean }>()
const tab = Tabs.byId[props.tabId] as Tab
const iconOnly =
tab.pinned &&
@@ -124,6 +125,14 @@ const tabColor = computed(() => {
})
onMounted(() => {
+ // Sticky clones are a second view of the same tab. They must not overwrite the
+ // shared element refs on the tab object (those drive the real row's favicon/title/
+ // flash updates), so render the favicon into the clone's own elements instead.
+ if (props.sticky) {
+ Tabs.renderFaviconInto(tab, favImgEl.value ?? undefined, favSvgUseEl.value ?? undefined)
+ return
+ }
+
if (titleEl.value) tab.titleEl = titleEl.value
if (favImgEl.value) tab.favImgEl = favImgEl.value
if (favSvgUseEl.value) tab.favSvgUseEl = favSvgUseEl.value
@@ -734,6 +743,12 @@ function onExpandMouseUp(e: MouseEvent): void {
}
function onError(): void {
+ // For a sticky clone, fall back to the placeholder locally without mutating the
+ // shared tab (which would affect the real row).
+ if (props.sticky) {
+ Tabs.renderFaviconInto(tab, undefined, favSvgUseEl.value ?? undefined)
+ return
+ }
tab.favIconUrl = undefined
Tabs.renderFavicon(tab)
}
diff --git a/src/styles/sidebar/sidebar.styl b/src/styles/sidebar/sidebar.styl
index 23d85dc9..11aab9f1 100644
--- a/src/styles/sidebar/sidebar.styl
+++ b/src/styles/sidebar/sidebar.styl
@@ -19,6 +19,7 @@ vendors = official
@import '../popup.new-tab-shortcuts'
@import './tab'
+@import './sticky-tabs'
@import './bookmark-card'
@import './bar.pinned-tabs'
@import './bar.navigation'
diff --git a/src/styles/sidebar/sticky-tabs.styl b/src/styles/sidebar/sticky-tabs.styl
new file mode 100644
index 00000000..2f2839bb
--- /dev/null
+++ b/src/styles/sidebar/sticky-tabs.styl
@@ -0,0 +1,26 @@
+@import '../utils'
+
+// Zero-height sticky anchor pinned to the top of the scroll viewport.
+// Its rows overflow downward as an overlay, so they take no flow space
+// (no layout shift) and align automatically with the scrollable content.
+.StickyTabs
+ position: sticky
+ top: 0
+ left: 0
+ width: 100%
+ height: 0
+ z-index: 1000
+
+.StickyTabs > .Tab.-sticky
+ z-index: 1000
+ cursor: pointer
+ background-color: var(--frame-bg)
+ transition: none
+
+// Don't draw tree-connector lines between stacked sticky rows.
+.StickyTabs > .Tab.-sticky .body:after
+ display: none
+
+// Separator shadow under the lowest sticky row, against the scrolling list.
+.StickyTabs > .Tab.-sticky:last-child
+ box-shadow: 0 6px 8px -6px #00000060
diff --git a/src/types/settings.ts b/src/types/settings.ts
index 514dbb10..b3010a13 100644
--- a/src/types/settings.ts
+++ b/src/types/settings.ts
@@ -135,6 +135,8 @@ export interface SettingsState {
tabsTree: boolean
groupOnOpen?: boolean // DEPR
tabsTreeLimit: (typeof SETTINGS_OPTIONS.tabsTreeLimit)[number]
+ stickyAncestorTabs: boolean
+ stickyAncestorTabsLimit: (typeof SETTINGS_OPTIONS.stickyAncestorTabsLimit)[number]
autoFoldTabs: boolean
autoFoldTabsExcept: (typeof SETTINGS_OPTIONS.autoFoldTabsExcept)[number]
autoExpandTabs: boolean
diff --git a/src/types/sidebar.ts b/src/types/sidebar.ts
index 2cf696a0..9506da9f 100644
--- a/src/types/sidebar.ts
+++ b/src/types/sidebar.ts
@@ -147,6 +147,7 @@ export interface TabsPanel extends PanelCommonProps, TabsPanelConfig {
export interface TabsPanelReactiveProps extends PanelCommonReactiveProps {
visibleTabIds: ID[]
pinnedTabIds: ID[]
+ stickyTabIds: ID[]
updated: boolean
selNewTab: boolean