// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. var gZenWorkspaces = new (class extends ZenMultiWindowFeature { /** * Stores workspace IDs and their last selected tabs. */ _lastSelectedWorkspaceTabs = {}; _inChangingWorkspace = false; draggedElement = null; #canDebug = Services.prefs.getBoolPref('zen.workspaces.debug', false); _swipeState = { isGestureActive: true, lastDelta: 0, direction: null, }; _lastScrollTime = 0; bookmarkMenus = [ 'PlacesToolbar', 'bookmarks-menu-button', 'BMB_bookmarksToolbar', 'BMB_unsortedBookmarks', 'BMB_mobileBookmarks', ]; promiseDBInitialized = new Promise((resolve) => { this._resolveDBInitialized = resolve; }); promisePinnedInitialized = new Promise((resolve) => { this._resolvePinnedInitialized = resolve; }); promiseSectionsInitialized = new Promise((resolve) => { this._resolveSectionsInitialized = resolve; }); promiseInitialized = new Promise((resolve) => { this._resolveInitialized = resolve; }); workspaceIndicatorXUL = ` `; async waitForPromises() { if (this.privateWindowOrDisabled) { return; } await Promise.all([ this.promiseDBInitialized, this.promisePinnedInitialized, SessionStore.promiseAllWindowsRestored, ]); } async init() { // Initialize tab selection state this._tabSelectionState = { inProgress: false, lastSelectionTime: 0, debounceTime: 100, // ms to wait between tab selections }; // Initialize workspace change mutex this._workspaceChangeInProgress = false; if (!this.shouldHaveWorkspaces) { this._resolveInitialized(); console.warn('gZenWorkspaces: !!! gZenWorkspaces is disabled in hidden windows !!!'); return; // We are in a hidden window, don't initialize gZenWorkspaces } this.ownerWindow = window; XPCOMUtils.defineLazyPreferenceGetter( this, 'activationMethod', 'zen.workspaces.scroll-modifier-key', 'ctrl' ); XPCOMUtils.defineLazyPreferenceGetter( this, 'naturalScroll', 'zen.workspaces.natural-scroll', true ); XPCOMUtils.defineLazyPreferenceGetter( this, 'shouldWrapAroundNavigation', 'zen.workspaces.wrap-around-navigation', true ); XPCOMUtils.defineLazyPreferenceGetter( this, 'shouldForceContainerTabsToWorkspace', 'zen.workspaces.force-container-workspace', true ); XPCOMUtils.defineLazyPreferenceGetter( this, 'shouldOpenNewTabIfLastUnpinnedTabIsClosed', 'zen.workspaces.open-new-tab-if-last-unpinned-tab-is-closed', false ); this.containerSpecificEssentials = Services.prefs.getBoolPref( 'zen.workspaces.container-specific-essentials-enabled', false ); ChromeUtils.defineLazyGetter(this, 'tabContainer', () => document.getElementById('tabbrowser-tabs') ); ChromeUtils.defineLazyGetter(this, 'workspaceIcons', () => document.getElementById('zen-workspaces-button') ); this._activeWorkspace = Services.prefs.getStringPref('zen.workspaces.active', ''); if (this.isPrivateWindow) { document.documentElement.setAttribute('zen-private-window', 'true'); } window.addEventListener('resize', this.onWindowResize.bind(this)); this.addPopupListeners(); } log(...args) { if (this.#canDebug) { console.debug(`[gZenWorkspaces]:`, ...args); } } async afterLoadInit() { await SessionStore.promiseInitialized; if (!this._hasInitializedTabsStrip) { await this.delayedStartup(); } await this.promiseSectionsInitialized; this.log('gZenWorkspaces initialized'); await this.initializeWorkspaces(); if ( Services.prefs.getBoolPref('zen.workspaces.swipe-actions', false) && this.workspaceEnabled && !this.isPrivateWindow ) { this.initializeGestureHandlers(); this.initializeWorkspaceNavigation(); } if (!this.privateWindowOrDisabled) { Services.obs.addObserver(this, 'weave:engine:sync:finish'); Services.obs.addObserver( async function observe(subject) { this._workspaceBookmarksCache = null; await this.workspaceBookmarks(); this._invalidateBookmarkContainers(); }.bind(this), 'workspace-bookmarks-updated' ); } } // Validate browser state before tab operations _validateBrowserState() { // Check if browser window is still open if (window.closed) { return false; } // Check if gBrowser is available if (!gBrowser || !gBrowser.tabContainer) { return false; } // Check if URL bar is available if (!gURLBar) { return false; } return true; } // Safely select a tab with debouncing to prevent race conditions async _safelySelectTab(tab) { if (!tab || tab.closing || !tab.ownerGlobal || tab.ownerGlobal.closed) { return false; } // Check if we need to debounce const now = Date.now(); const timeSinceLastSelection = now - this._tabSelectionState.lastSelectionTime; if (timeSinceLastSelection < this._tabSelectionState.debounceTime) { await new Promise((resolve) => setTimeout(resolve, this._tabSelectionState.debounceTime - timeSinceLastSelection) ); } // Mark selection as in progress this._tabSelectionState.inProgress = true; try { gBrowser.selectedTab = tab; this._tabSelectionState.lastSelectionTime = Date.now(); return true; } catch (e) { console.error('Error selecting tab:', e); return false; } finally { this._tabSelectionState.inProgress = false; } } async selectEmptyTab(newTabTarget = null, selectURLBar = true) { // Validate browser state first if (!this._validateBrowserState()) { console.warn('Browser state invalid for empty tab selection'); return null; } if (gZenUIManager.testingEnabled) { return null; } try { // Check if we have a valid empty tab and can replace new tab if ( this._emptyTab && !this._emptyTab.closing && this._emptyTab.ownerGlobal && !this._emptyTab.ownerGlobal.closed && gZenVerticalTabsManager._canReplaceNewTab ) { // Only set up URL bar selection if we're switching to a different tab if (gBrowser.selectedTab !== this._emptyTab && selectURLBar) { // Use a Promise-based approach for better sequencing const urlBarSelectionPromise = new Promise((resolve) => { const tabSelectListener = () => { // Remove the event listener first to prevent any chance of multiple executions window.removeEventListener('TabSelect', tabSelectListener); // Use requestAnimationFrame to ensure DOM is updated requestAnimationFrame(() => { // Then use setTimeout to ensure browser has time to process tab switch setTimeout(() => { if (gURLBar) { try { gURLBar.select(); } catch (e) { console.warn('Error selecting URL bar:', e); } } resolve(); }, 50); }); }; window.addEventListener('TabSelect', tabSelectListener, { once: true }); }); } // Safely switch to the empty tab using our debounced method const success = await this._safelySelectTab(this._emptyTab); if (!success) { throw new Error('Failed to select empty tab'); } return this._emptyTab; } // Fall back to creating a new tab const newTabUrl = newTabTarget || Services.prefs.getStringPref('browser.startup.homepage'); let tab = gZenUIManager.openAndChangeToTab(newTabUrl); // Set workspace ID if available if (window.uuid) { tab.setAttribute('zen-workspace-id', this.activeWorkspace); } return tab; } catch (e) { console.error('Error in selectEmptyTab:', e); // Create a fallback tab as a last resort, with proper validation try { if (this._validateBrowserState()) { return gBrowser.addTrustedTab('about:blank'); } } catch (fallbackError) { console.error('Critical error creating fallback tab:', fallbackError); } return null; } } async delayedStartup() { if (!this.workspaceEnabled) { return; } this._pinnedTabsResizeObserver = new ResizeObserver(this.onPinnedTabsResize.bind(this)); await this.waitForPromises(); await this._createDefaultWorkspaceIfNeeded(); await this.initializeTabsStripSections(); this._resolveSectionsInitialized(); this._initializeEmptyTab(); } async _createDefaultWorkspaceIfNeeded() { const workspaces = await this._workspaces(); if (!workspaces.workspaces.length) { await this.createAndSaveWorkspace('Space', null, true); this._workspaceCache = null; } } _initializeEmptyTab() { this._emptyTab = gBrowser.addTrustedTab('about:blank', { inBackground: true, userContextId: 0, _forZenEmptyTab: true, }); } registerPinnedResizeObserver() { if (!this._hasInitializedTabsStrip) { return; } this._pinnedTabsResizeObserver.disconnect(); for (let element of document.querySelectorAll('.zen-workspace-pinned-tabs-section')) { this._pinnedTabsResizeObserver.observe(element, { box: 'border-box' }); } for (let element of document.getElementById('zen-essentials').children) { if (element.classList.contains('tabbrowser-tab')) { continue; } this._pinnedTabsResizeObserver.observe(element, { box: 'border-box' }); } } get activeWorkspaceStrip() { if (!this._hasInitializedTabsStrip) { return gBrowser.tabContainer.arrowScrollbox; } return document.querySelector(`zen-workspace[active]`)?.tabsContainer; } get pinnedTabsContainer() { if (!this.workspaceEnabled || !this._hasInitializedTabsStrip) { return document.getElementById('vertical-pinned-tabs-container'); } return document.querySelector(`zen-workspace[active]`)?.pinnedTabsContainer; } get activeWorkspaceIndicator() { return document.querySelector(`zen-workspace[active]`)?.indicator; } get activeScrollbox() { return ( document.querySelector(`zen-workspace[active]`)?.scrollbox ?? gBrowser.tabContainer.arrowScrollbox ); } get tabboxChildren() { return Array.from(this.activeWorkspaceStrip?.children || []); } get tabboxChildrenWithoutEmpty() { return this.tabboxChildren.filter((child) => !child.hasAttribute('zen-empty-tab')); } workspaceElement(workspaceId) { if (typeof workspaceId !== 'string') { workspaceId = workspaceId?.uuid; } return document.getElementById(workspaceId); } async initializeTabsStripSections() { const perifery = document.getElementById('tabbrowser-arrowscrollbox-periphery'); perifery.setAttribute('hidden', 'true'); const tabs = gBrowser.tabContainer.allTabs; const workspaces = await this._workspaces(); for (const workspace of workspaces.workspaces) { await this._createWorkspaceTabsSection(workspace, tabs); } if (tabs.length) { const defaultSelectedContainer = this.workspaceElement(this.activeWorkspace).querySelector( '.zen-workspace-normal-tabs-section' ); const pinnedContainer = this.workspaceElement(this.activeWorkspace).querySelector( '.zen-workspace-pinned-tabs-section' ); // New profile with no workspaces does not have a default selected container if (defaultSelectedContainer) { for (const tab of tabs) { if (tab.hasAttribute('zen-essential')) { this.getEssentialsSection(tab).appendChild(tab); continue; } else if (tab.pinned) { pinnedContainer.insertBefore(tab, pinnedContainer.lastChild); continue; } // before to the last child (perifery) defaultSelectedContainer.insertBefore(tab, defaultSelectedContainer.lastChild); } } gBrowser.tabContainer._invalidateCachedTabs(); } perifery.setAttribute('hidden', 'true'); this._hasInitializedTabsStrip = true; this.registerPinnedResizeObserver(); this._fixIndicatorsNames(workspaces); } getEssentialsSection(container = 0) { if (typeof container !== 'number') { container = container?.getAttribute('usercontextid'); } container ??= 0; if (!this.containerSpecificEssentials) { container = 0; } let essentialsContainer = document.querySelector( `.zen-essentials-container[container="${container}"]:not([cloned])` ); if (!essentialsContainer) { essentialsContainer = document.createXULElement('hbox'); essentialsContainer.className = 'zen-essentials-container zen-workspace-tabs-section'; essentialsContainer.setAttribute('flex', '1'); essentialsContainer.setAttribute('container', container); document.getElementById('zen-essentials').appendChild(essentialsContainer); // Set an initial hidden state if the essentials section is not supposed // to be shown on the current workspace if ( this.containerSpecificEssentials && this.getActiveWorkspaceFromCache()?.containerTabId != container ) { essentialsContainer.setAttribute('hidden', 'true'); } } return essentialsContainer; } getCurrentEssentialsContainer() { const currentWorkspace = this.getActiveWorkspaceFromCache(); return this.getEssentialsSection(currentWorkspace?.containerTabId); } async _createWorkspaceTabsSection(workspace, tabs) { const workspaceWrapper = document.createXULElement('zen-workspace'); const container = document.getElementById('tabbrowser-arrowscrollbox'); workspaceWrapper.id = workspace.uuid; if (this.activeWorkspace === workspace.uuid) { workspaceWrapper.active = true; } await new Promise((resolve) => { workspaceWrapper.addEventListener( 'ZenWorkspaceAttached', (event) => { this._organizeTabsToWorkspaceSections( workspace, workspaceWrapper.tabsContainer, workspaceWrapper.pinnedTabsContainer, tabs ); this.initIndicatorContextMenu(workspaceWrapper.indicator); resolve(); }, { once: true } ); container.appendChild(workspaceWrapper); }); } _organizeTabsToWorkspaceSections(workspace, section, pinnedSection, tabs) { const workspaceTabs = Array.from(tabs).filter( (tab) => tab.getAttribute('zen-workspace-id') === workspace.uuid ); let firstNormalTab = null; for (let tab of workspaceTabs) { if (tab.hasAttribute('zen-essential')) { continue; // Ignore essentials as they need to be in their own section } // remove tab from list tabs.splice(tabs.indexOf(tab), 1); tab = tab.group ?? tab; if (tab.pinned) { pinnedSection.insertBefore(tab, pinnedSection.lastChild); } else { if (!firstNormalTab) { firstNormalTab = tab; } section.insertBefore(tab, section.lastChild); } } // Kind of a hacky fix, but for some reason the first normal tab in the list // created by session restore is added the the last position of the tab list // let's just prepend it to the section if (firstNormalTab) { section.insertBefore(firstNormalTab, section.firstChild); } } initializeWorkspaceNavigation() { this._setupAppCommandHandlers(); this._setupSidebarHandlers(); } _setupAppCommandHandlers() { // Remove existing handler temporarily - this is needed so that _handleAppCommand is called before the original window.removeEventListener('AppCommand', HandleAppCommandEvent, true); // Add our handler first window.addEventListener('AppCommand', this._handleAppCommand.bind(this), true); // Re-add original handler window.addEventListener('AppCommand', HandleAppCommandEvent, true); } get _hoveringSidebar() { return gNavToolbox.hasAttribute('zen-has-hover'); } _handleAppCommand(event) { // note: Dont use this._hoveringSidebar as it's not as reliable as checking for :hover if (!this.workspaceEnabled || !gNavToolbox.matches(':hover')) { return; } const direction = this.naturalScroll ? -1 : 1; // event is forward or back switch (event.command) { case 'Forward': this.changeWorkspaceShortcut(1 * direction); event.stopImmediatePropagation(); event.preventDefault(); break; case 'Back': this.changeWorkspaceShortcut(-1 * direction); event.stopImmediatePropagation(); event.preventDefault(); break; } } _setupSidebarHandlers() { const toolbox = gNavToolbox; const scrollCooldown = 200; // Milliseconds to wait before allowing another scroll const scrollThreshold = 2; // Minimum scroll delta to trigger workspace change toolbox.addEventListener( 'wheel', async (event) => { if (this.privateWindowOrDisabled) return; // Only process non-gesture scrolls if (event.deltaMode !== 1) return; const isVerticalScroll = event.deltaY && !event.deltaX; const isHorizontalScroll = event.deltaX && !event.deltaY; //if the scroll is vertical this checks that a modifier key is used before proceeding if (isVerticalScroll) { const activationKeyMap = { ctrl: event.ctrlKey, alt: event.altKey, shift: event.shiftKey, meta: event.metaKey, }; if ( this.activationMethod in activationKeyMap && !activationKeyMap[this.activationMethod] ) { return; } } const currentTime = Date.now(); if (currentTime - this._lastScrollTime < scrollCooldown) return; //this decides which delta to use const delta = isVerticalScroll ? event.deltaY : event.deltaX; if (Math.abs(delta) < scrollThreshold) return; // Determine scroll direction let rawDirection = delta > 0 ? 1 : -1; let direction = this.naturalScroll ? -1 : 1; this.changeWorkspaceShortcut(rawDirection * direction); this._lastScrollTime = currentTime; }, { passive: true } ); } initializeGestureHandlers() { const elements = [ gNavToolbox, // event handlers do not work on elements inside shadow DOM so we need to attach them directly document.getElementById('tabbrowser-arrowscrollbox').shadowRoot.querySelector('scrollbox'), ]; // Attach gesture handlers to each element for (const element of elements) { if (!element) continue; this.attachGestureHandlers(element); } } attachGestureHandlers(element) { element.addEventListener('MozSwipeGestureMayStart', this._handleSwipeMayStart.bind(this), true); element.addEventListener('MozSwipeGestureStart', this._handleSwipeStart.bind(this), true); element.addEventListener('MozSwipeGestureUpdate', this._handleSwipeUpdate.bind(this), true); // Use MozSwipeGesture instead of MozSwipeGestureEnd because MozSwipeGestureEnd is fired after animation ends, // while MozSwipeGesture is fired immediately after swipe ends. element.addEventListener('MozSwipeGesture', this._handleSwipeEnd.bind(this), true); element.addEventListener( 'MozSwipeGestureEnd', (event) => { document.documentElement.removeAttribute('swipe-gesture'); gZenUIManager.tabsWrapper.style.removeProperty('scrollbar-width'); this.updateTabsContainers(); }, true ); } _handleSwipeMayStart(event) { if (this.privateWindowOrDisabled || this._inChangingWorkspace) return; if (event.target.closest('#zen-sidebar-bottom-buttons')) return; // Only handle horizontal swipes if (event.direction === event.DIRECTION_LEFT || event.direction === event.DIRECTION_RIGHT) { event.preventDefault(); event.stopPropagation(); // Set allowed directions based on available workspaces event.allowedDirections |= event.DIRECTION_LEFT | event.DIRECTION_RIGHT; } } _handleSwipeStart(event) { if (!this.workspaceEnabled) return; document.documentElement.setAttribute('swipe-gesture', 'true'); event.preventDefault(); event.stopPropagation(); this._swipeState = { isGestureActive: true, lastDelta: 0, direction: null, }; } _handleSwipeUpdate(event) { if (!this.workspaceEnabled || !this._swipeState?.isGestureActive) return; event.preventDefault(); event.stopPropagation(); const delta = event.delta * 300; const stripWidth = document.getElementById('tabbrowser-tabs').getBoundingClientRect().width; let translateX = this._swipeState.lastDelta + delta; // Add a force multiplier as we are translating the strip depending on how close to the edge we are let forceMultiplier = Math.min(1, 1 - Math.abs(translateX) / (stripWidth * 4.5)); // 4.5 instead of 4 to add a bit of a buffer if (forceMultiplier > 0.5) { translateX *= forceMultiplier; this._swipeState.lastDelta = delta + (translateX - delta) * 0.5; } else { translateX = this._swipeState.lastDelta; } if (Math.abs(delta) > 1) { this._swipeState.direction = delta > 0 ? 'left' : 'right'; } // Apply a translateX to the tab strip to give the user feedback on the swipe const currentWorkspace = this.getActiveWorkspaceFromCache(); this._organizeWorkspaceStripLocations(currentWorkspace, true, translateX); } async _handleSwipeEnd(event) { if (!this.workspaceEnabled) return; event.preventDefault(); event.stopPropagation(); const isRTL = document.documentElement.matches(':-moz-locale-dir(rtl)'); const moveForward = (event.direction === SimpleGestureEvent.DIRECTION_RIGHT) !== isRTL; const rawDirection = moveForward ? 1 : -1; const direction = this.naturalScroll ? -1 : 1; await this.changeWorkspaceShortcut(rawDirection * direction, true); // Reset swipe state this._swipeState = { isGestureActive: false, lastDelta: 0, direction: null, }; } get activeWorkspace() { return this._activeWorkspace; } set activeWorkspace(value) { this._activeWorkspace = value; if (this.privateWindowOrDisabled) { return; } Services.prefs.setStringPref('zen.workspaces.active', value); } async observe(subject, topic, data) { if (topic === 'weave:engine:sync:finish' && data === 'workspaces') { try { const lastChangeTimestamp = await ZenWorkspacesStorage.getLastChangeTimestamp(); if ( !this._workspaceCache || !this._workspaceCache.lastChangeTimestamp || lastChangeTimestamp > this._workspaceCache.lastChangeTimestamp ) { await this._propagateWorkspaceData(); const currentWorkspace = await this.getActiveWorkspace(); await gZenThemePicker.onWorkspaceChange(currentWorkspace); } } catch (error) { console.error('Error updating workspaces after sync:', error); } } } get shouldHaveWorkspaces() { if (typeof this._shouldHaveWorkspaces === 'undefined') { let docElement = document.documentElement; this._shouldHaveWorkspaces = !( docElement.getAttribute('chromehidden').includes('toolbar') || docElement.getAttribute('chromehidden').includes('menubar') ); return this._shouldHaveWorkspaces; } return this._shouldHaveWorkspaces; } get isPrivateWindow() { return PrivateBrowsingUtils.isWindowPrivate(window); } get privateWindowOrDisabled() { return this.isPrivateWindow || !this.shouldHaveWorkspaces; } get workspaceEnabled() { if (typeof this._workspaceEnabled === 'undefined') { this._workspaceEnabled = this.shouldHaveWorkspaces && !Services.prefs.getBoolPref('zen.testing.profiling.enabled', false); } return this._workspaceEnabled && !window.closed; } getActiveWorkspaceFromCache() { return this.getWorkspaceFromId(this.activeWorkspace); } getWorkspaceFromId(id) { try { return this._workspaceCache.workspaces.find((workspace) => workspace.uuid === id); } catch (e) { return null; } } async _workspaces() { if (this._workspaceCache) { return this._workspaceCache; } if (this.isPrivateWindow) { this._workspaceCache = { workspaces: this._privateWorkspace ? [this._privateWorkspace] : [], lastChangeTimestamp: 0, }; this._activeWorkspace = this._privateWorkspace?.uuid; return this._workspaceCache; } const [workspaces, lastChangeTimestamp] = await Promise.all([ ZenWorkspacesStorage.getWorkspaces(), ZenWorkspacesStorage.getLastChangeTimestamp(), ]); this._workspaceCache = { workspaces, lastChangeTimestamp }; // Get the active workspace ID from preferences const activeWorkspaceId = this.activeWorkspace; if (activeWorkspaceId) { const activeWorkspace = this.getWorkspaceFromId(activeWorkspaceId); // Set the active workspace ID to the first one if the one with selected id doesn't exist if (!activeWorkspace) { this.activeWorkspace = this._workspaceCache.workspaces[0]?.uuid; } } else { // Set the active workspace ID to the first one if active workspace doesn't exist this.activeWorkspace = this._workspaceCache.workspaces[0]?.uuid; } // sort by position this._workspaceCache.workspaces.sort( (a, b) => (a.position ?? Infinity) - (b.position ?? Infinity) ); return this._workspaceCache; } async workspaceBookmarks() { if (this._workspaceBookmarksCache) { return this._workspaceBookmarksCache; } const [bookmarks, lastChangeTimestamp] = await Promise.all([ ZenWorkspaceBookmarksStorage.getBookmarkGuidsByWorkspace(), ZenWorkspaceBookmarksStorage.getLastChangeTimestamp(), ]); this._workspaceBookmarksCache = { bookmarks, lastChangeTimestamp }; return this._workspaceCache; } async initializeWorkspaces() { if (this.workspaceEnabled) { this._initializeWorkspaceCreationIcons(); this._initializeWorkspaceTabContextMenus(); await this.workspaceBookmarks(); window.addEventListener('TabBrowserInserted', this.onTabBrowserInserted.bind(this)); let activeWorkspace = await this.getActiveWorkspace(); this.activeWorkspace = activeWorkspace?.uuid; try { if (activeWorkspace) { window.gZenThemePicker = new ZenThemePicker(); await this.changeWorkspace(activeWorkspace, { onInit: true }); gBrowser.tabContainer._positionPinnedTabs(); } } catch (e) { console.error('gZenWorkspaces: Error initializing theme picker', e); } this.onWindowResize(); await this._selectStartPage(); this._fixTabPositions(); this._resolveInitialized(); this._clearAnyZombieTabs(); // Dont call with await const tabUpdateListener = this.updateTabsContainers.bind(this); window.addEventListener('TabOpen', tabUpdateListener); window.addEventListener('TabClose', tabUpdateListener); window.addEventListener('TabAddedToEssentials', tabUpdateListener); window.addEventListener('TabRemovedFromEssentials', tabUpdateListener); window.addEventListener('TabPinned', tabUpdateListener); window.addEventListener('TabUnpinned', tabUpdateListener); window.addEventListener('aftercustomization', tabUpdateListener); } } async _selectStartPage() { if (gZenUIManager.testingEnabled) { return; } let showed = false; let resolveSelectPromise; let selectPromise = new Promise((resolve) => { resolveSelectPromise = resolve; }); const cleanup = () => { delete this._tabToSelect; delete this._tabToRemoveForEmpty; resolveSelectPromise(); }; let removedEmptyTab = false; if ( this._initialTab && !(this._initialTab._shouldRemove && this._initialTab._veryPossiblyEmpty) ) { gBrowser.selectedTab = this._initialTab; this.moveTabToWorkspace(this._initialTab, this.activeWorkspace); gBrowser.moveTabTo(this._initialTab, { forceUngrouped: true, tabIndex: 0 }); removedEmptyTab = true; delete this._initialTab; } if (this._tabToRemoveForEmpty && !removedEmptyTab) { const tabs = gBrowser.tabs.filter( (tab) => !tab.collapsed && !tab.hasAttribute('zen-empty-tab') ); if ( typeof this._tabToSelect === 'number' && this._tabToSelect >= 0 && tabs[this._tabToSelect] && (await this.#shouldShowTabInCurrentWorkspace(tabs[this._tabToSelect])) && tabs[this._tabToSelect] !== this._tabToRemoveForEmpty ) { this.log(`Found tab to select: ${this._tabToSelect}, ${tabs.length}`); setTimeout(() => { gBrowser.selectedTab = tabs[this._tabToSelect]; this._removedByStartupPage = true; gBrowser.removeTab(this._tabToRemoveForEmpty, { skipSessionStore: true, }); cleanup(); }, 0); } else { this.selectEmptyTab(); showed = true; setTimeout(() => { this._removedByStartupPage = true; gBrowser.removeTab(this._tabToRemoveForEmpty, { skipSessionStore: true, }); cleanup(); }, 0); } } else { setTimeout(() => { cleanup(); }, 0); } await selectPromise; if (this._initialTab) { this._removedByStartupPage = true; gBrowser.removeTab(this._initialTab, { skipSessionStore: true, }); delete this._initialTab; } if (gZenVerticalTabsManager._canReplaceNewTab && showed) { BrowserCommands.openTab(); } if ( !gZenVerticalTabsManager._canReplaceNewTab && !Services.prefs.getBoolPref('zen.workspaces.continue-where-left-off') ) { // Go through each tab and see if there's another tab with the same startup URL. // If we do find one, remove it. const newTabUrl = Services.prefs.getStringPref('browser.startup.homepage'); const tabs = gBrowser.tabs.filter( (tab) => !tab.collapsed && !tab.hasAttribute('zen-empty-tab') && !tab.pinned ); for (const tab of tabs) { if (tab._originalUrl === newTabUrl && tab !== gBrowser.selectedTab) { gBrowser.removeTab(tab, { skipSessionStore: true, }); } } } window.dispatchEvent(new CustomEvent('AfterWorkspacesSessionRestore', { bubbles: true })); } handleInitialTab(tab, isEmpty) { if (gZenUIManager.testingEnabled) { return; } // note: We cant access `gZenVerticalTabsManager._canReplaceNewTab` this early if (isEmpty && Services.prefs.getBoolPref('zen.urlbar.replace-newtab', true)) { this._tabToRemoveForEmpty = tab; } else { this._initialTab = tab; this._initialTab._veryPossiblyEmpty = isEmpty; } } initIndicatorContextMenu(indicator) { const th = (event) => { event.preventDefault(); event.stopPropagation(); this.openWorkspacesDialog(event); }; indicator.addEventListener('contextmenu', th); indicator.addEventListener('click', th); } shouldCloseWindow() { return ( !window.toolbar.visible || Services.prefs.getBoolPref('browser.tabs.closeWindowWithLastTab') ); } async _clearAnyZombieTabs() { const tabs = this.allStoredTabs; const workspaces = await this._workspaces(); for (let tab of tabs) { const workspaceID = tab.getAttribute('zen-workspace-id'); if ( workspaceID && !tab.hasAttribute('zen-essential') && !workspaces.workspaces.find((workspace) => workspace.uuid === workspaceID) ) { // Remove any tabs where their workspace doesn't exist anymore gBrowser.unpinTab(tab); gBrowser.removeTab(tab, { skipSessionStore: true, closeWindowWithLastTab: false, }); } } } handleTabBeforeClose(tab, closeWindowWithLastTab = false) { if (!this.workspaceEnabled || this.__contextIsDelete || this._removedByStartupPage) { return null; } let workspaceID = tab.getAttribute('zen-workspace-id'); if (!workspaceID) { return null; } let tabs = gBrowser.visibleTabs; let tabsPinned = tabs.filter( (t) => !this.shouldOpenNewTabIfLastUnpinnedTabIsClosed || !t.pinned ); const shouldCloseWindow = this.shouldCloseWindow() && closeWindowWithLastTab; if (tabs.length === 1 && tabs[0] === tab) { if (shouldCloseWindow) { // We've already called beforeunload on all the relevant tabs if we get here, // so avoid calling it again: window.skipNextCanClose = true; // Closing the tab and replacing it with a blank one is notably slower // than closing the window right away. If the caller opts in, take // the fast path. if (!gBrowser._removingTabs.size) { // This call actually closes the window, unless the user // cancels the operation. We are finished here in both cases. this._isClosingWindow = true; // Inside a setTimeout to avoid reentrancy issues. setTimeout(() => { document.getElementById('cmd_closeWindow').doCommand(); }, 100); } return null; } } else if (tabsPinned.length === 1 && tabsPinned[0] === tab) { return this.selectEmptyTab(); } return null; } addPopupListeners() { const popup = document.getElementById('PanelUI-zen-workspaces'); const contextMenu = document.getElementById('zenWorkspaceActionsMenu'); popup.addEventListener('popuphidden', this.handlePanelHidden.bind(this)); popup.addEventListener('command', this.handlePanelCommand.bind(this)); contextMenu.addEventListener('popuphidden', (event) => { if (event.target === contextMenu) { this.onContextMenuClose(event); } }); contextMenu.addEventListener('popupshowing', this.updateContextMenu.bind(this)); contextMenu.addEventListener('command', this.handleContextMenuCommand.bind(this)); const submenu = document.querySelector('#context_zenWorkspacesOpenInContainerTab > menupopup'); if (submenu) { submenu.addEventListener('popupshowing', this.createContainerTabMenu.bind(this)); submenu.addEventListener('command', this.contextChangeContainerTab.bind(this)); } const onWorkspaceIconContainerClick = this.onWorkspaceIconContainerClick.bind(this); for (const element of document.querySelectorAll('.PanelUI-zen-workspaces-icons-container')) { element.addEventListener('click', onWorkspaceIconContainerClick); } document .getElementById('PanelUI-zen-workspaces-create-input') .addEventListener('input', this.onWorkspaceCreationNameChange.bind(this)); document .getElementById('PanelUI-zen-workspaces-edit-input') .addEventListener('input', this.onWorkspaceEditChange.bind(this)); document .getElementById('PanelUI-zen-workspaces-icon-search-input') .addEventListener('input', this.conductSearch.bind(this)); } handlePanelCommand(event) { let target = event.target.closest('toolbarbutton'); target ??= event.target.closest('button'); if (!target) { return; } switch (target.id) { case 'PanelUI-zen-workspaces-reorder-mode': this.toggleReorderMode(); break; case 'PanelUI-zen-workspaces-new': this.openSaveDialog(); break; case 'PanelUI-zen-workspaces-create-save': this.saveWorkspaceFromCreate(); break; case 'PanelUI-zen-workspaces-edit-cancel': case 'PanelUI-zen-workspaces-create-cancel': this.closeWorkspacesSubView(); break; case 'PanelUI-zen-workspaces-edit-save': this.saveWorkspaceFromEdit(); break; } } handleContextMenuCommand(event) { const target = event.target.closest('menuitem'); if (!target) { return; } switch (target.id) { case 'context_zenOpenWorkspace': this.openWorkspace(); break; case 'context_zenEditWorkspace': this.contextEdit(event); break; case 'context_zenDeleteWorkspace': this.contextDelete(event); break; } } searchIcons(input, icons) { input = input.toLowerCase(); if (input === ':' || input === '') { return icons; } const emojiScores = []; function calculateSearchScore(inputLength, targetLength, weight = 100) { return parseInt((inputLength / targetLength) * weight); } for (let currentEmoji of icons) { let alignmentScore = -1; let normalizedEmojiName = currentEmoji[1].toLowerCase(); let keywordList = currentEmoji[2].split(',').map((keyword) => keyword.trim().toLowerCase()); if (input[0] === ':') { let searchTerm = input.slice(1); let nameMatchIndex = normalizedEmojiName.indexOf(searchTerm); if (nameMatchIndex !== -1 && nameMatchIndex === 0) { alignmentScore = calculateSearchScore(searchTerm.length, normalizedEmojiName.length, 100); } } else { if (input === currentEmoji[0]) { alignmentScore = 999; } let nameMatchIndex = normalizedEmojiName.replace(/_/g, ' ').indexOf(input); if (nameMatchIndex !== -1) { if (nameMatchIndex === 0) { alignmentScore = calculateSearchScore(input.length, normalizedEmojiName.length, 150); } else if (input[input.length - 1] !== ' ') { alignmentScore += calculateSearchScore(input.length, normalizedEmojiName.length, 40); } } for (let keyword of keywordList) { let keywordMatchIndex = keyword.indexOf(input); if (keywordMatchIndex !== -1) { if (keywordMatchIndex === 0) { alignmentScore += calculateSearchScore(input.length, keyword.length, 50); } else if (input[input.length - 1] !== ' ') { alignmentScore += calculateSearchScore(input.length, keyword.length, 5); } } } } //if match score is not -1, add it if (alignmentScore !== -1) { emojiScores.push({ emoji: currentEmoji[0], score: alignmentScore }); } } // Sort the emojis by their score in descending order emojiScores.sort((a, b) => b.score - a.score); // Return the emojis in the order of their rank let filteredEmojiScores = emojiScores; return filteredEmojiScores.map((score) => score.emoji); } resetWorkspaceIconSearch() { let container = document.getElementById('PanelUI-zen-workspaces-icon-picker-wrapper'); let searchInput = document.getElementById('PanelUI-zen-workspaces-icon-search-input'); // Clear the search input field searchInput.value = ''; for (let button of container.querySelectorAll('.toolbarbutton-1')) { button.style.display = ''; } } _initializeWorkspaceCreationIcons() { let container = document.getElementById('PanelUI-zen-workspaces-icon-picker-wrapper'); let searchInput = document.getElementById('PanelUI-zen-workspaces-icon-search-input'); searchInput.value = ''; for (let iconData of this.emojis) { const icon = iconData[0]; let button = document.createXULElement('toolbarbutton'); button.className = 'toolbarbutton-1 workspace-icon-button'; button.setAttribute('label', icon); button.onclick = (event) => { const button = event.target; let wasSelected = button.hasAttribute('selected'); for (let button of container.children) { button.removeAttribute('selected'); } if (!wasSelected) { button.setAttribute('selected', 'true'); } if (this.onIconChangeConnectedCallback) { this.onIconChangeConnectedCallback(icon); } else { this.onWorkspaceIconChangeInner('create', icon); } }; container.appendChild(button); } } conductSearch() { const container = document.getElementById('PanelUI-zen-workspaces-icon-picker-wrapper'); const searchInput = document.getElementById('PanelUI-zen-workspaces-icon-search-input'); const query = searchInput.value.toLowerCase(); if (query === '') { this.resetWorkspaceIconSearch(); return; } const buttons = Array.from(container.querySelectorAll('.toolbarbutton-1')); buttons.forEach((button) => (button.style.display = 'none')); const filteredIcons = this.searchIcons(query, this.emojis); filteredIcons.forEach((emoji) => { const matchingButton = buttons.find((button) => button.getAttribute('label') === emoji); if (matchingButton) { matchingButton.style.display = ''; container.appendChild(matchingButton); } }); } async saveWorkspace(workspaceData, preventPropagation = false) { if (this.privateWindowOrDisabled) { return; } await ZenWorkspacesStorage.saveWorkspace(workspaceData); if (!preventPropagation) { await this._propagateWorkspaceData(); await this._updateWorkspacesChangeContextMenu(); } } async removeWorkspace(windowID) { let workspacesData = await this._workspaces(); this._deleteAllTabsInWorkspace(windowID); await this.changeWorkspace( workspacesData.workspaces.find((workspace) => workspace.uuid !== windowID) ); delete this._lastSelectedWorkspaceTabs[windowID]; await ZenWorkspacesStorage.removeWorkspace(windowID); // Remove the workspace from the cache this._workspaceCache.workspaces = this._workspaceCache.workspaces.filter( (workspace) => workspace.uuid !== windowID ); await this._propagateWorkspaceData(); await this._updateWorkspacesChangeContextMenu(); this.onWindowResize(); for (let container of document.querySelectorAll( `.zen-workspace-tabs-section[zen-workspace-id="${windowID}"]` )) { container.remove(); } this.registerPinnedResizeObserver(); } isWorkspaceActive(workspace) { return workspace.uuid === this.activeWorkspace; } async getActiveWorkspace() { const workspaces = await this._workspaces(); return ( workspaces.workspaces.find((workspace) => workspace.uuid === this.activeWorkspace) ?? workspaces.workspaces[0] ); } // Workspaces dialog UI management openSaveDialog() { let parentPanel = document.getElementById('PanelUI-zen-workspaces-multiview'); // randomly select an icon let icon = this.emojis[Math.floor(Math.random() * (this.emojis.length - 257))][0]; this._workspaceCreateInput.textContent = ''; this._workspaceCreateInput.value = ''; this._workspaceCreateInput.setAttribute('data-initial-value', ''); document .querySelectorAll('#PanelUI-zen-workspaces-icon-picker-wrapper toolbarbutton') .forEach((button) => { if (button.label === icon) { button.setAttribute('selected', 'true'); } else { button.removeAttribute('selected'); } }); document.querySelector('.PanelUI-zen-workspaces-icons-container.create').textContent = icon; PanelUI.showSubView('PanelUI-zen-workspaces-create', parentPanel); } async openEditDialog(workspaceUuid) { this._workspaceEditDialog.setAttribute('data-workspace-uuid', workspaceUuid); document.getElementById('PanelUI-zen-workspaces-edit-save').setAttribute('disabled', 'true'); let workspaces = (await this._workspaces()).workspaces; let workspaceData = workspaces.find((workspace) => workspace.uuid === workspaceUuid); this._workspaceEditInput.textContent = workspaceData.name; this._workspaceEditInput.value = workspaceData.name; this._workspaceEditInput.setAttribute('data-initial-value', workspaceData.name); this._workspaceEditIconsContainer.setAttribute('data-initial-value', workspaceData.icon); this.onIconChangeConnectedCallback = (...args) => { this.onWorkspaceIconChangeInner('edit', ...args); this.onWorkspaceEditChange(...args); }; document .querySelectorAll('#PanelUI-zen-workspaces-icon-picker-wrapper toolbarbutton') .forEach((button) => { if (button.label === workspaceData.icon) { button.setAttribute('selected', 'true'); } else { button.removeAttribute('selected'); } }); document.querySelector('.PanelUI-zen-workspaces-icons-container.edit').textContent = this.getWorkspaceIcon(workspaceData); let parentPanel = document.getElementById('PanelUI-zen-workspaces-multiview'); PanelUI.showSubView('PanelUI-zen-workspaces-edit', parentPanel); } onWorkspaceIconChangeInner(type = 'create', icon) { const container = document.querySelector(`.PanelUI-zen-workspaces-icons-container.${type}`); if (container.textContent !== icon) { container.textContent = icon; } this.goToPreviousSubView(); } onWorkspaceIconContainerClick(event) { event.preventDefault(); const parentPanel = document.getElementById('PanelUI-zen-workspaces-edit'); PanelUI.showSubView('PanelUI-zen-workspaces-icon-picker', parentPanel); const container = parentPanel.parentNode.querySelector('.panel-viewcontainer'); setTimeout(() => { if (container) { container.style.minHeight = 'unset'; } }); } goToPreviousSubView() { const parentPanel = document.getElementById('PanelUI-zen-workspaces-multiview'); parentPanel.goBack(); } workspaceHasIcon(workspace) { return workspace.icon && workspace.icon !== ''; } getWorkspaceIcon(workspace) { if (this.workspaceHasIcon(workspace)) { return workspace.icon; } if (typeof Intl.Segmenter !== 'undefined') { return new Intl.Segmenter().segment(workspace.name).containing().segment.toUpperCase(); } return Array.from(workspace.name)[0].toUpperCase(); } get shouldShowContainers() { return ( Services.prefs.getBoolPref('privacy.userContext.ui.enabled') && ContextualIdentityService.getPublicIdentities().length > 0 ); } async _propagateWorkspaceData({ ignoreStrip = false, clearCache = true } = {}) { const currentWindowIsPrivate = this.isPrivateWindow; await this.foreachWindowAsActive(async (browser) => { // Do not update the window if workspaces are not enabled in it. // For example, when the window is in private browsing mode. if ( !browser.gZenWorkspaces.workspaceEnabled || browser.gZenWorkspaces.isPrivateWindow !== currentWindowIsPrivate ) { return; } let workspaceList = browser.document.getElementById('PanelUI-zen-workspaces-list'); const createWorkspaceElement = (workspace) => { let element = browser.document.createXULElement('toolbarbutton'); element.className = 'subviewbutton zen-workspace-button'; element.setAttribute('tooltiptext', workspace.name); element.setAttribute('zen-workspace-id', workspace.uuid); if (this.isWorkspaceActive(workspace)) { element.setAttribute('active', 'true'); } let containerGroup = undefined; try { containerGroup = browser.ContextualIdentityService.getPublicIdentities().find( (container) => container.userContextId === workspace.containerTabId ); } catch (e) { console.warn('gZenWorkspaces: Error setting container color', e); } if (containerGroup) { element.classList.add('identity-color-' + containerGroup.color); element.setAttribute('data-usercontextid', containerGroup.userContextId); } // Set draggable attribute based on reorder mode if (this.isReorderModeOn(browser)) { element.setAttribute('draggable', 'true'); } element.addEventListener( 'dragstart', function (event) { if (this.isReorderModeOn(browser)) { this.draggedElement = element; event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('text/plain', element.getAttribute('zen-workspace-id')); // Create a transparent drag image for Linux if (AppConstants.platform === 'linux') { const dragImage = document.createElement('canvas'); dragImage.width = 1; dragImage.height = 1; event.dataTransfer.setDragImage(dragImage, 0, 0); } element.classList.add('dragging'); } else { event.preventDefault(); } }.bind(browser.gZenWorkspaces) ); element.addEventListener( 'dragover', function (event) { if (this.isReorderModeOn(browser) && this.draggedElement) { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; // Ensure the dragover effect is visible on Linux if (AppConstants.platform === 'linux') { const targetId = element.getAttribute('zen-workspace-id'); const draggedId = this.draggedElement.getAttribute('zen-workspace-id'); if (targetId !== draggedId) { element.classList.add('dragover'); } } } }.bind(browser.gZenWorkspaces) ); element.addEventListener('dragenter', function (event) { if (this.isReorderModeOn(browser) && this.draggedElement) { element.classList.add('dragover'); } }); element.addEventListener('dragleave', function (event) { element.classList.remove('dragover'); }); element.addEventListener( 'drop', async function (event) { event.preventDefault(); element.classList.remove('dragover'); if (this.isReorderModeOn(browser)) { const draggedWorkspaceId = event.dataTransfer.getData('text/plain'); const targetWorkspaceId = element.getAttribute('zen-workspace-id'); if (draggedWorkspaceId !== targetWorkspaceId) { await this.moveWorkspace(draggedWorkspaceId, targetWorkspaceId); } if (this.draggedElement) { this.draggedElement.classList.remove('dragging'); this.draggedElement = null; } } }.bind(browser.gZenWorkspaces) ); element.addEventListener( 'dragend', function (event) { if (this.draggedElement) { this.draggedElement.classList.remove('dragging'); this.draggedElement = null; } const workspaceElements = browser.document.querySelectorAll('.zen-workspace-button'); for (const elem of workspaceElements) { elem.classList.remove('dragover'); } }.bind(browser.gZenWorkspaces) ); let childs = browser.MozXULElement.parseXULToFragment(`
`); // use text content instead of innerHTML to avoid XSS childs.querySelector('.zen-workspace-icon').textContent = browser.gZenWorkspaces.getWorkspaceIcon(workspace); childs.querySelector('.zen-workspace-name').textContent = workspace.name; if (containerGroup) { childs.querySelector('.zen-workspace-container').textContent = ContextualIdentityService.getUserContextLabel(containerGroup.userContextId); } childs.querySelector('.zen-workspace-actions').addEventListener( 'command', ((event) => { let button = event.target; this._contextMenuId = button .closest('toolbarbutton[zen-workspace-id]') .getAttribute('zen-workspace-id'); const popup = button.ownerDocument.getElementById('zenWorkspaceActionsMenu'); popup.openPopup(button, 'after_end'); }).bind(browser.gZenWorkspaces) ); element.appendChild(childs); element.onclick = (async () => { if (this.isReorderModeOn(browser)) { return; // Return early if reorder mode is on } if (event.target.closest('.zen-workspace-actions')) { return; // Ignore clicks on the actions button } const workspaceId = element.getAttribute('zen-workspace-id'); const workspaces = await this._workspaces(); const workspace = workspaces.workspaces.find((w) => w.uuid === workspaceId); await this.changeWorkspace(workspace); let panel = this.ownerWindow.document.getElementById('PanelUI-zen-workspaces'); PanelMultiView.hidePopup(panel); }).bind(browser.gZenWorkspaces); return element; }; const createLastPositionDropTarget = () => { const element = browser.document.createXULElement('div'); element.className = 'zen-workspace-last-place-drop-target'; element.addEventListener( 'dragover', function (event) { if (this.isReorderModeOn(browser) && this.draggedElement) { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; // Ensure the dragover effect is visible on Linux if (AppConstants.platform === 'linux') { element.classList.add('dragover'); } } }.bind(browser.gZenWorkspaces) ); element.addEventListener( 'dragenter', function (event) { if (this.isReorderModeOn(browser) && this.draggedElement) { element.classList.add('dragover'); } }.bind(browser.gZenWorkspaces) ); element.addEventListener( 'dragleave', function (event) { element.classList.remove('dragover'); }.bind(browser.gZenWorkspaces) ); element.addEventListener( 'drop', async function (event) { event.preventDefault(); element.classList.remove('dragover'); if (this.isReorderModeOn(browser)) { const draggedWorkspaceId = event.dataTransfer.getData('text/plain'); await this.moveWorkspaceToEnd(draggedWorkspaceId); if (this.draggedElement) { this.draggedElement.classList.remove('dragging'); this.draggedElement = null; } } }.bind(browser.gZenWorkspaces) ); return element; }; if (clearCache) { browser.gZenWorkspaces._workspaceCache = null; browser.gZenWorkspaces._workspaceBookmarksCache = null; } let workspaces = await browser.gZenWorkspaces._workspaces(); if (clearCache) { browser.dispatchEvent( new CustomEvent('ZenWorkspacesUIUpdate', { bubbles: true, detail: { activeIndex: browser.gZenWorkspaces.activeWorkspace }, }) ); } await browser.gZenWorkspaces.workspaceBookmarks(); workspaceList.innerHTML = ''; workspaceList.parentNode.style.display = 'flex'; if (workspaces.workspaces.length <= 0) { workspaceList.innerHTML = 'No workspaces available'; workspaceList.setAttribute('empty', 'true'); } else { workspaceList.removeAttribute('empty'); } for (let workspace of workspaces.workspaces) { let workspaceElement = createWorkspaceElement(workspace); workspaceList.appendChild(workspaceElement); } workspaceList.appendChild(createLastPositionDropTarget()); if (!ignoreStrip) { browser.gZenWorkspaces._fixIndicatorsNames(workspaces); } }); } handlePanelHidden() { const workspacesList = document.getElementById('PanelUI-zen-workspaces-list'); const reorderModeButton = document.getElementById('PanelUI-zen-workspaces-reorder-mode'); workspacesList?.removeAttribute('reorder-mode'); reorderModeButton?.removeAttribute('active'); this.resetWorkspaceIconSearch(); this.clearEmojis(); } async moveWorkspaceToEnd(draggedWorkspaceId) { const workspaces = (await this._workspaces()).workspaces; const draggedIndex = workspaces.findIndex((w) => w.uuid === draggedWorkspaceId); const draggedWorkspace = workspaces.splice(draggedIndex, 1)[0]; workspaces.push(draggedWorkspace); await ZenWorkspacesStorage.updateWorkspacePositions(workspaces); await this._propagateWorkspaceData(); } isReorderModeOn(browser) { return ( browser.document .getElementById('PanelUI-zen-workspaces-list') .getAttribute('reorder-mode') === 'true' ); } toggleReorderMode() { const workspacesList = document.getElementById('PanelUI-zen-workspaces-list'); const reorderModeButton = document.getElementById('PanelUI-zen-workspaces-reorder-mode'); const isActive = workspacesList.getAttribute('reorder-mode') === 'true'; if (isActive) { workspacesList.removeAttribute('reorder-mode'); reorderModeButton.removeAttribute('active'); } else { workspacesList.setAttribute('reorder-mode', 'true'); reorderModeButton.setAttribute('active', 'true'); } // Update draggable attribute const workspaceElements = document.querySelectorAll('.zen-workspace-button'); workspaceElements.forEach((elem) => { // When reorder mode is toggled off, remove draggable attribute // When reorder mode is toggled on, set draggable attribute if (isActive) { elem.removeAttribute('draggable'); } else { elem.setAttribute('draggable', 'true'); } }); } async moveWorkspace(draggedWorkspaceId, targetWorkspaceId) { const workspaces = (await this._workspaces()).workspaces; const draggedIndex = workspaces.findIndex((w) => w.uuid === draggedWorkspaceId); const draggedWorkspace = workspaces.splice(draggedIndex, 1)[0]; const targetIndex = workspaces.findIndex((w) => w.uuid === targetWorkspaceId); workspaces.splice(targetIndex, 0, draggedWorkspace); await ZenWorkspacesStorage.updateWorkspacePositions(workspaces); await this._propagateWorkspaceData(); } async openWorkspacesDialog(event) { if (!this.workspaceEnabled || this.isPrivateWindow) { return; } let target = event.target.closest('.zen-current-workspace-indicator'); let panel = document.getElementById('PanelUI-zen-workspaces'); await this._propagateWorkspaceData({ ignoreStrip: true, clearCache: false, }); PanelMultiView.openPopup(panel, target, { position: 'bottomright topright', triggerEvent: event, }).catch(console.error); } closeWorkspacesSubView() { let parentPanel = document.getElementById('PanelUI-zen-workspaces-multiview'); parentPanel.goBack(parentPanel); } // Workspaces management get _workspaceCreateInput() { return document.getElementById('PanelUI-zen-workspaces-create-input'); } get _workspaceEditDialog() { return document.getElementById('PanelUI-zen-workspaces-edit'); } get _workspaceEditInput() { return document.getElementById('PanelUI-zen-workspaces-edit-input'); } get _workspaceEditIconsContainer() { return document.getElementById('PanelUI-zen-workspaces-icon-picker'); } _deleteAllTabsInWorkspace(workspaceID) { gBrowser.removeTabs( Array.from(this.allStoredTabs).filter( (tab) => tab.getAttribute('zen-workspace-id') === workspaceID && !tab.hasAttribute('zen-empty-tab') ), { animate: false, skipSessionStore: true, closeWindowWithLastTab: false, } ); } moveTabToWorkspace(tab, workspaceID) { return this.moveTabsToWorkspace([tab], workspaceID); } moveTabsToWorkspace(tabs, workspaceID, justChangeId = false) { for (let tab of tabs) { const workspaceContainer = this.workspaceElement(workspaceID); const container = tab.pinned ? workspaceContainer?.pinnedTabsContainer : workspaceContainer?.tabsContainer; if (container?.contains(tab)) { continue; } tab.setAttribute('zen-workspace-id', workspaceID); if (tab.hasAttribute('zen-essential')) { continue; } if (container && !justChangeId) { if (tab.group?.hasAttribute('split-view-group')) { this.moveTabsToWorkspace(tab.group.tabs, workspaceID, true); container.insertBefore(tab.group, container.lastChild); continue; } container.insertBefore(tab, container.lastChild); } // also change glance tab if it's the same tab const glanceTab = tab.querySelector('.tabbrowser-tab[zen-glance-tab]'); if (glanceTab) { glanceTab.setAttribute('zen-workspace-id', workspaceID); } } return true; } _prepareNewWorkspace(window) { document.documentElement.setAttribute('zen-workspace-id', window.uuid); let tabCount = 0; for (let tab of gBrowser.tabs) { const isEssential = tab.getAttribute('zen-essential') === 'true'; if (!tab.hasAttribute('zen-workspace-id') && !tab.pinned && !isEssential) { this.moveTabToWorkspace(tab, window.uuid); tabCount++; } } if (tabCount === 0) { this.selectEmptyTab(); } } async saveWorkspaceFromCreate() { let workspaceName = this._workspaceCreateInput.value; if (!workspaceName) { return; } this._workspaceCreateInput.value = ''; let icon = document.querySelector('#PanelUI-zen-workspaces-icon-picker-wrapper [selected]'); icon?.removeAttribute('selected'); await this.createAndSaveWorkspace(workspaceName, icon?.label); this.goToPreviousSubView(); } async saveWorkspaceFromEdit() { let workspaceUuid = this._workspaceEditDialog.getAttribute('data-workspace-uuid'); let workspaceName = this._workspaceEditInput.value; if (!workspaceName) { return; } this._workspaceEditInput.value = ''; let icon = document.querySelector('#PanelUI-zen-workspaces-icon-picker-wrapper [selected]'); icon?.removeAttribute('selected'); let workspaces = (await this._workspaces()).workspaces; let workspaceData = workspaces.find((workspace) => workspace.uuid === workspaceUuid); workspaceData.name = workspaceName; workspaceData.icon = icon?.label; await this.saveWorkspace(workspaceData); this.goToPreviousSubView(); } onWorkspaceCreationNameChange() { let button = document.getElementById('PanelUI-zen-workspaces-create-save'); if (this._workspaceCreateInput.value === '') { button.setAttribute('disabled', 'true'); return; } button.removeAttribute('disabled'); } onWorkspaceEditChange(icon) { let button = document.getElementById('PanelUI-zen-workspaces-edit-save'); let name = this._workspaceEditInput.value; if ( name === this._workspaceEditInput.getAttribute('data-initial-value') && icon === this._workspaceEditIconsContainer.getAttribute('data-initial-value') ) { button.setAttribute('disabled', 'true'); return; } button.removeAttribute('disabled'); } addChangeListeners(func) { if (!this._changeListeners) { this._changeListeners = []; } this._changeListeners.push(func); } async changeWorkspaceWithID(workspaceID, ...args) { const workspace = this.getWorkspaceFromId(workspaceID); return await this.changeWorkspace(workspace, ...args); } async changeWorkspace(workspace, ...args) { if (!this.workspaceEnabled || this._inChangingWorkspace) { return; } this._inChangingWorkspace = true; try { this.log('Changing workspace to', workspace?.uuid); await this._performWorkspaceChange(workspace, ...args); } catch (e) { console.error('gZenWorkspaces: Error changing workspace', e); } this._inChangingWorkspace = false; } _cancelSwipeAnimation() { this._animateTabs(this.getActiveWorkspaceFromCache(), true); } async _performWorkspaceChange( workspace, { onInit = false, alwaysChange = false, whileScrolling = false } = {} ) { const previousWorkspace = await this.getActiveWorkspace(); alwaysChange = alwaysChange || onInit; this.activeWorkspace = workspace.uuid; if (previousWorkspace && previousWorkspace.uuid === workspace.uuid && !alwaysChange) { this._cancelSwipeAnimation(); return; } const workspaces = await this._workspaces(); // Refresh tab cache for (const otherWorkspace of workspaces.workspaces) { const container = this.workspaceElement(otherWorkspace.uuid); container.active = otherWorkspace.uuid === workspace.uuid; } gBrowser.verticalPinnedTabsContainer = this.pinnedTabsContainer || gBrowser.verticalPinnedTabsContainer; gBrowser.tabContainer.verticalPinnedTabsContainer = this.pinnedTabsContainer || gBrowser.tabContainer.verticalPinnedTabsContainer; // Move empty tab to the new workspace this._moveEmptyTabToWorkspace(workspace.uuid); this.tabContainer._invalidateCachedTabs(); if (!whileScrolling) { await this._organizeWorkspaceStripLocations(previousWorkspace); } // Second pass: Handle tab selection this.tabContainer._invalidateCachedTabs(); const tabToSelect = await this._handleTabSelection(workspace, onInit, previousWorkspace.uuid); gBrowser.warmupTab(tabToSelect); // Update UI and state const previousWorkspaceIndex = workspaces.workspaces.findIndex( (w) => w.uuid === previousWorkspace.uuid ); await this._updateWorkspaceState(workspace, onInit, tabToSelect, { previousWorkspaceIndex, previousWorkspace, }); } _moveEmptyTabToWorkspace(workspaceUuid) { this._makeSureEmptyTabIsLast(); } _makeSureEmptyTabIsLast() { const emptyTab = this._emptyTab; if (emptyTab) { const container = this.activeWorkspaceStrip; if (container) { container.insertBefore(emptyTab, container.lastChild); } } this._fixTabPositions(); } _fixTabPositions() { // Fix tabs _tPos values relative to the actual order const tabs = gBrowser.tabs; for (let i = 0; i < tabs.length; i++) { tabs[i]._tPos = i; } } _updatePaddingTopOnTabs( workspaceElement, essentialContainer, forAnimation = false, animateContainer = false ) { if ( workspaceElement && !(this._inChangingWorkspace && !forAnimation && !this._alwaysAnimatePaddingTop) ) { delete this._alwaysAnimatePaddingTop; const essentialsHeight = essentialContainer.getBoundingClientRect().height; if (!forAnimation && animateContainer) { gZenUIManager.motion.animate( workspaceElement, { paddingTop: [workspaceElement.style.paddingTop, essentialsHeight + 'px'], }, { type: 'spring', bounce: 0, duration: 0.2, } ); } else { workspaceElement.style.paddingTop = essentialsHeight + 'px'; } } } async _organizeWorkspaceStripLocations(workspace, justMove = false, offsetPixels = 0) { this._organizingWorkspaceStrip = true; const workspaces = await this._workspaces(); let workspaceIndex = workspaces.workspaces.findIndex((w) => w.uuid === workspace.uuid); if (!justMove) { this._fixIndicatorsNames(workspaces); } const otherContainersEssentials = document.querySelectorAll( `#zen-essentials .zen-workspace-tabs-section` ); const workspaceContextId = workspace.containerTabId; const nextWorkspaceContextId = workspaces.workspaces[workspaceIndex + (offsetPixels > 0 ? -1 : 1)]?.containerTabId; for (const otherWorkspace of workspaces.workspaces) { const element = this.workspaceElement(otherWorkspace.uuid); const newTransform = -(workspaceIndex - workspaces.workspaces.indexOf(otherWorkspace)) * 100; element.style.transform = `translateX(${newTransform + offsetPixels / 2}%)`; } // Hide other essentials with different containerTabId for (const container of otherContainersEssentials) { // Get the next workspace contextId, if it's the same, dont apply offsetPixels // if it's not we do apply it if ( container.getAttribute('container') != workspace.containerTabId && this.containerSpecificEssentials ) { container.setAttribute('hidden', 'true'); } else { container.removeAttribute('hidden'); } if ( nextWorkspaceContextId !== workspaceContextId && offsetPixels && this.containerSpecificEssentials && (container.getAttribute('container') == nextWorkspaceContextId || container.getAttribute('container') == workspaceContextId) ) { container.removeAttribute('hidden'); // Animate from the currently selected workspace if (container.getAttribute('container') == workspaceContextId) { container.style.transform = `translateX(${offsetPixels / 2}%)`; } else { // Animate from the next workspace, transitioning towards the current one container.style.transform = `translateX(${offsetPixels / 2 + (offsetPixels > 0 ? -100 : 100)}%)`; } } } delete this._organizingWorkspaceStrip; } updateWorkspaceIndicator(currentWorkspace, workspaceIndicator) { if (!workspaceIndicator) { return; } const indicatorName = workspaceIndicator.querySelector('.zen-current-workspace-indicator-name'); const indicatorIcon = workspaceIndicator.querySelector('.zen-current-workspace-indicator-icon'); if (this.workspaceHasIcon(currentWorkspace)) { indicatorIcon.removeAttribute('no-icon'); } else { indicatorIcon.setAttribute('no-icon', 'true'); } indicatorIcon.textContent = this.getWorkspaceIcon(currentWorkspace); indicatorName.textContent = currentWorkspace.name; } _fixIndicatorsNames(workspaces) { for (const workspace of workspaces.workspaces) { const workspaceIndicator = this.workspaceElement(workspace.uuid)?.indicator; this.updateWorkspaceIndicator(workspace, workspaceIndicator); } } async _animateTabs( newWorkspace, shouldAnimate, tabToSelect = null, { previousWorkspaceIndex = null, previousWorkspace = null, onInit = false } = {} ) { gZenUIManager.tabsWrapper.style.scrollbarWidth = 'none'; const kGlobalAnimationDuration = 0.3; this._animatingChange = true; const animations = []; const workspaces = await this._workspaces(); const newWorkspaceIndex = workspaces.workspaces.findIndex((w) => w.uuid === newWorkspace.uuid); const isGoingLeft = newWorkspaceIndex <= previousWorkspaceIndex; const clonedEssentials = []; if (shouldAnimate && this.containerSpecificEssentials && previousWorkspace) { for (const workspace of workspaces.workspaces) { const essentialsContainer = this.getEssentialsSection(workspace.containerTabId); if (clonedEssentials[clonedEssentials.length - 1]?.contextId == workspace.containerTabId) { clonedEssentials[clonedEssentials.length - 1].repeat++; clonedEssentials[clonedEssentials.length - 1].workspaces.push(workspace); continue; } essentialsContainer.setAttribute('hidden', 'true'); const essentialsClone = essentialsContainer.cloneNode(true); essentialsClone.removeAttribute('hidden'); essentialsClone.setAttribute('cloned', 'true'); clonedEssentials.push({ container: essentialsClone, workspaces: [workspace], contextId: workspace.containerTabId, originalContainer: essentialsContainer, repeat: 0, }); essentialsContainer.parentNode.appendChild(essentialsClone); } } document.documentElement.setAttribute('animating-background', 'true'); if (shouldAnimate && previousWorkspace) { let previousBackgroundOpacity = document.documentElement.style.getPropertyValue( '--zen-background-opacity' ); try { // Prevent NaN from being set if (previousBackgroundOpacity) { previousBackgroundOpacity = parseFloat(previousBackgroundOpacity); } } catch (e) { previousBackgroundOpacity = 1; } if (previousBackgroundOpacity == 1 || !previousBackgroundOpacity) { previousBackgroundOpacity = 0; } gZenThemePicker.previousBackgroundOpacity = previousBackgroundOpacity; await new Promise((resolve) => { requestAnimationFrame(() => { animations.push( gZenUIManager.motion.animate( document.documentElement, { '--zen-background-opacity': [previousBackgroundOpacity, 1], }, { type: 'spring', bounce: 0, duration: kGlobalAnimationDuration, } ) ); resolve(); }); }); } for (const element of document.querySelectorAll('zen-workspace')) { if (element.classList.contains('zen-essentials-container')) { continue; } const existingTransform = element.style.transform; const elementWorkspaceId = element.id; const elementWorkspaceIndex = workspaces.workspaces.findIndex( (w) => w.uuid === elementWorkspaceId ); const offset = -(newWorkspaceIndex - elementWorkspaceIndex) * 100; const newTransform = `translateX(${offset}%)`; if (shouldAnimate) { const existingPaddingTop = element.style.paddingTop; animations.push( gZenUIManager.motion.animate( element, { transform: existingTransform ? [existingTransform, newTransform] : newTransform, paddingTop: existingTransform ? [existingPaddingTop, existingPaddingTop] : existingPaddingTop, }, { type: 'spring', bounce: 0, duration: kGlobalAnimationDuration, } ) ); } element.active = offset === 0; if (offset === 0) { if (tabToSelect != gBrowser.selectedTab && !onInit) { gBrowser.selectedTab = tabToSelect; } } } if (this.containerSpecificEssentials && previousWorkspace) { // Animate essentials const newWorkspaceEssentialsContainer = clonedEssentials.find((cloned) => cloned.workspaces.some((w) => w.uuid === newWorkspace.uuid) ); for (const cloned of clonedEssentials) { const container = cloned.container; const essentialsWorkspaces = cloned.workspaces; const repeats = cloned.repeat; // Animate like the workspaces above expect essentials are a bit more // complicated because they are not based on workspaces but on containers // So, if we have the following arangement: // | [workspace1] [workspace2] [workspace3] [workspace4] // | [container1] [container1] [container2] [container1] // And if we are changing from workspace 1 to workspace 4, // we should be doing the following: // First container (repeat 2 times) will stay in place until // we reach container 3, then animate to the left and container 2 // also move to the left after that while container 1 in workspace 4 // will slide in from the right // Get the index from first and last workspace const firstWorkspaceIndex = workspaces.workspaces.findIndex( (w) => w.uuid === essentialsWorkspaces[0].uuid ); const lastWorkspaceIndex = workspaces.workspaces.findIndex( (w) => w.uuid === essentialsWorkspaces[essentialsWorkspaces.length - 1].uuid ); cloned.originalContainer.style.removeProperty('transform'); // Check if the container is even going to appear on the screen, to save on animation if ( (isGoingLeft && newWorkspaceIndex > lastWorkspaceIndex) || (!isGoingLeft && newWorkspaceIndex < firstWorkspaceIndex) ) { container.remove(); continue; } let stepsInBetween = Math.abs(newWorkspaceIndex - (isGoingLeft ? firstWorkspaceIndex : lastWorkspaceIndex)) + 1; const usingSameContainer = newWorkspaceEssentialsContainer.workspaces.some((w) => w.uuid === newWorkspace.uuid) && newWorkspaceEssentialsContainer.workspaces.some((w) => w.uuid === previousWorkspace.uuid); let newOffset = -( newWorkspaceIndex - (isGoingLeft ? firstWorkspaceIndex : lastWorkspaceIndex) + (!isGoingLeft ? repeats - 1 : -repeats + 1) ) * 100; let existingOffset = -( newWorkspaceIndex - (isGoingLeft ? lastWorkspaceIndex : firstWorkspaceIndex) + (isGoingLeft ? repeats - 1 : -repeats + 1) ) * 100; // If we are on the same container and both new and old workspace are in the same "essentialsWorkspaces" // we can simply not animate the essentials if ( usingSameContainer && essentialsWorkspaces.some((w) => w.uuid === newWorkspace.uuid) && essentialsWorkspaces.some((w) => w.uuid === previousWorkspace.uuid) ) { newOffset = 0; existingOffset = 0; } const needsOffsetAdjustment = stepsInBetween > essentialsWorkspaces.length || usingSameContainer; if (repeats > 0 && needsOffsetAdjustment) { if (!isGoingLeft) { if (existingOffset !== 0) existingOffset += 100; if (newOffset !== 0) newOffset += 100; } else { if (existingOffset !== 0) existingOffset -= 100; if (newOffset !== 0) newOffset -= 100; } } // Special case: going forward from single reused container to a new one if (!usingSameContainer && !isGoingLeft && lastWorkspaceIndex === newWorkspaceIndex - 1) { existingOffset = 0; newOffset = -100; stepsInBetween = 1; } if (!usingSameContainer && isGoingLeft && firstWorkspaceIndex === newWorkspaceIndex + 1) { existingOffset = 0; newOffset = 100; stepsInBetween = 1; } if ( !usingSameContainer && isGoingLeft && (firstWorkspaceIndex === newWorkspaceIndex - 1 || firstWorkspaceIndex === newWorkspaceIndex) ) { existingOffset = -100; newOffset = 0; stepsInBetween = 1; } if (!usingSameContainer && !isGoingLeft && firstWorkspaceIndex === newWorkspaceIndex) { existingOffset = 100; newOffset = 0; stepsInBetween = 1; } const newTransform = `translateX(${newOffset}%)`; let existingTransform = `translateX(${existingOffset}%)`; if (container.style.transform) { existingTransform = container.style.transform; } if (shouldAnimate) { container.style.transform = newTransform; animations.push( gZenUIManager.motion.animate( container, { transform: [ existingTransform, new Array(stepsInBetween).fill(newTransform).join(','), ], }, { type: 'spring', bounce: 0, duration: kGlobalAnimationDuration, } ) ); } } } if (shouldAnimate) { gZenUIManager._preventToolbarRebuild = true; gZenUIManager.updateTabsToolbar(); } await Promise.all(animations); document.documentElement.removeAttribute('animating-background'); if (shouldAnimate) { for (const cloned of clonedEssentials) { cloned.container.remove(); } this._alwaysAnimatePaddingTop = true; await this.updateTabsContainers(); } const essentialsContainer = this.getEssentialsSection(newWorkspace.containerTabId); essentialsContainer.removeAttribute('hidden'); essentialsContainer.style.transform = 'none'; gBrowser.tabContainer._invalidateCachedTabs(); gZenUIManager.tabsWrapper.style.removeProperty('scrollbar-width'); this._animatingChange = false; } _shouldChangeToTab(aTab) { return !( aTab?.hasAttribute('zen-essential') || (aTab?.pinned && aTab?.hasAttribute('pending')) ); } async #shouldShowTabInCurrentWorkspace(tab) { const currentWorkspace = this.getActiveWorkspaceFromCache(); return this._shouldShowTab( tab, currentWorkspace.uuid, currentWorkspace.containerTabId, await this._workspaces() ); } _shouldShowTab(tab, workspaceUuid, containerId, workspaces) { const isEssential = tab.getAttribute('zen-essential') === 'true'; const tabWorkspaceId = tab.getAttribute('zen-workspace-id'); const tabContextId = tab.getAttribute('usercontextid') ?? '0'; if (tab.hasAttribute('zen-glance-tab')) { return true; // Always show glance tabs } // Handle essential tabs if (isEssential) { if (!this.containerSpecificEssentials) { return true; // Show all essential tabs when containerSpecificEssentials is false } if (containerId) { // In workspaces with default container: Show essentials that match the container return tabContextId == containerId; } else { // In workspaces without a default container: Show essentials that aren't in container-specific workspaces // or have usercontextid="0" or no usercontextid return ( !tabContextId || tabContextId === '0' || !workspaces.workspaces.some( (workspace) => workspace.containerTabId === parseInt(tabContextId, 10) ) ); } } // For non-essential tabs (both normal and pinned) if (!tabWorkspaceId) { // Assign workspace ID to tabs without one this.moveTabToWorkspace(tab, workspaceUuid); return true; } // Show if tab belongs to current workspace return tabWorkspaceId === workspaceUuid; } async _handleTabSelection(workspace, onInit, previousWorkspaceId) { const currentSelectedTab = gBrowser.selectedTab; const oldWorkspaceId = previousWorkspaceId; const lastSelectedTab = this._lastSelectedWorkspaceTabs[workspace.uuid]; const containerId = workspace.containerTabId?.toString(); const workspaces = await this._workspaces(); // Save current tab as last selected for old workspace if it shouldn't be visible in new workspace if (oldWorkspaceId && oldWorkspaceId !== workspace.uuid) { this._lastSelectedWorkspaceTabs[oldWorkspaceId] = gZenGlanceManager.getTabOrGlanceParent(currentSelectedTab); } let tabToSelect = null; // Try last selected tab if it is visible if ( lastSelectedTab && this._shouldShowTab(lastSelectedTab, workspace.uuid, containerId, workspaces) ) { tabToSelect = lastSelectedTab; } // Find first suitable tab else { tabToSelect = gBrowser.visibleTabs.find((tab) => !tab.pinned); if (!tabToSelect && gBrowser.visibleTabs.length) { tabToSelect = gBrowser.visibleTabs[gBrowser.visibleTabs.length - 1]; } if (!tabToSelect || !this._shouldChangeToTab(tabToSelect)) { // Never select an essential tab tabToSelect = null; } } // If we found a tab to select, select it if (!onInit && !tabToSelect) { // Create new tab if needed and no suitable tab was found const newTab = this.selectEmptyTab(); tabToSelect = newTab; } if (tabToSelect && !onInit) { tabToSelect._visuallySelected = true; } // Always make sure we always unselect the tab from the old workspace if (currentSelectedTab && currentSelectedTab !== tabToSelect) { currentSelectedTab._selected = false; } return tabToSelect; } async _updateWorkspaceState( workspace, onInit, tabToSelect, { previousWorkspaceIndex, previousWorkspace } = {} ) { // Update document state document.documentElement.setAttribute('zen-workspace-id', workspace.uuid); // Recalculate new tab observers gBrowser.tabContainer.observe(null, 'nsPref:changed', 'privacy.userContext.enabled'); gBrowser.tabContainer.arrowScrollbox = this.activeScrollbox; // Update workspace UI await this._updateWorkspacesChangeContextMenu(); // gZenUIManager.updateTabsToolbar(); await this._propagateWorkspaceData({ clearCache: false }); gZenThemePicker.onWorkspaceChange(workspace); gZenUIManager.tabsWrapper.scrollbarWidth = 'none'; this.workspaceIcons.activeIndex = workspace.uuid; await this._animateTabs(workspace, !onInit && !this._animatingChange, tabToSelect, { previousWorkspaceIndex, previousWorkspace, onInit, }); await this._organizeWorkspaceStripLocations(workspace, true); gZenUIManager.tabsWrapper.style.scrollbarWidth = ''; // Notify listeners if (this._changeListeners?.length) { for (const listener of this._changeListeners) { await listener(workspace, onInit); } } // Reset bookmarks this._invalidateBookmarkContainers(); // Update workspace indicator await this.updateWorkspaceIndicator(workspace, this.workspaceIndicator); // Fix ctrl+tab behavior. Note, we dont call it with "await" because we dont want to wait for it this._fixCtrlTabBehavior(); // Bug: When updating from previous versions, we used to hide the tabs not used in the new workspace // we now need to show them again. // TODO: Remove this on future versions if (onInit) { for (const tab of this.allStoredTabs) { gBrowser.showTab(tab); } for (const tab of gBrowser.tabs) { if (!tab.hasAttribute('zen-workspace-id') && !tab.hasAttribute('zen-workspace-id')) { tab.setAttribute('zen-workspace-id', workspace.uuid); } } window.dispatchEvent( new CustomEvent('ZenWorkspacesUIUpdate', { bubbles: true, detail: { activeIndex: workspace.uuid }, }) ); } setTimeout(gURLBar.formatValue.bind(gURLBar), 0); } async _fixCtrlTabBehavior() { ctrlTab.uninit(); ctrlTab.readPref(); } _invalidateBookmarkContainers() { for (let i = 0, len = this.bookmarkMenus.length; i < len; i++) { const element = document.getElementById(this.bookmarkMenus[i]); if (element && element._placesView) { const placesView = element._placesView; placesView.invalidateContainer(placesView._resultNode); } } } async _updateWorkspacesChangeContextMenu() { if (gZenWorkspaces.privateWindowOrDisabled) return; const workspaces = await this._workspaces(); const menuPopup = document.getElementById('context-zen-change-workspace-tab-menu-popup'); if (!menuPopup) { return; } menuPopup.innerHTML = ''; const activeWorkspace = await this.getActiveWorkspace(); for (let workspace of workspaces.workspaces) { const menuItem = document.createXULElement('menuitem'); menuItem.setAttribute('label', workspace.name); menuItem.setAttribute('zen-workspace-id', workspace.uuid); menuItem.setAttribute('command', 'cmd_zenChangeWorkspaceTab'); if (workspace.uuid === activeWorkspace.uuid) { menuItem.setAttribute('disabled', 'true'); } menuPopup.appendChild(menuItem); } } async _createWorkspaceData(name, icon, tabs, moveTabs = true, containerTabId = 0) { let workspace = { uuid: gZenUIManager.generateUuidv4(), icon: icon, name: name, theme: ZenThemePicker.getTheme([]), containerTabId, }; if (moveTabs) { this._prepareNewWorkspace(workspace); await this._createWorkspaceTabsSection(workspace, tabs); await this._organizeWorkspaceStripLocations(workspace); } return workspace; } async createAndSaveWorkspace( name = 'Space', icon = undefined, dontChange = false, containerTabId = 0 ) { if (!this.workspaceEnabled) { return; } if (this.isPrivateWindow) { name = 'Private ' + name; icon = '🥸'; } // get extra tabs remaning (e.g. on new profiles) and just move them to the new workspace const extraTabs = Array.from(gBrowser.tabContainer.arrowScrollbox.children).filter( (child) => child.tagName === 'tab' && !child.hasAttribute('zen-workspace-id') && !child.hasAttribute('zen-empty-tab') && !child.hasAttribute('zen-essential') ); let workspaceData = await this._createWorkspaceData( name, icon, extraTabs, !dontChange, containerTabId ); if (this.isPrivateWindow) { this._privateWorkspace = workspaceData; } else { await this.saveWorkspace(workspaceData, dontChange); } if (!dontChange) { this.registerPinnedResizeObserver(); let changed = extraTabs.length > 0; if (changed) { gBrowser.tabContainer._invalidateCachedTabs(); gBrowser.selectedTab = extraTabs[0]; } await this.changeWorkspace(workspaceData); } this.onWindowResize(); return workspaceData; } async updateTabsContainers(target = undefined, forAnimation = false) { if (target && !target.target?.parentNode) { target = null; } // Only animate if it's from an event let animateContainer = target && target.target instanceof EventTarget; if (target?.type === 'TabClose') { animateContainer = target.target.pinned; } await this.onPinnedTabsResize( // This is what happens when we join a resize observer, an event listener // while using it as a method. [{ target: (target?.target ? target.target : target) ?? this.pinnedTabsContainer }], forAnimation, animateContainer ); } updateShouldHideSeparator(arrowScrollbox, pinnedContainer) { // <= 2 because we have the empty tab and the new tab button const shouldHideSeparator = pinnedContainer.children.length === 1 || Array.from(arrowScrollbox.children).filter( (child) => !child.hasAttribute('hidden') && !child.hasAttribute('zen-empty-tab') ).length <= 1; if (shouldHideSeparator) { pinnedContainer.setAttribute('hide-separator', 'true'); } else { pinnedContainer.removeAttribute('hide-separator'); } } async onPinnedTabsResize(entries, forAnimation = false, animateContainer = false) { if (!this._hasInitializedTabsStrip || (this._organizingWorkspaceStrip && !forAnimation)) { return; } if (document.documentElement.hasAttribute('customizing')) return; // forAnimation may be of type "ResizeObserver" if it's not a boolean, just ignore it if (typeof forAnimation !== 'boolean') { forAnimation = false; } for (const entry of entries) { let originalWorkspaceId = entry.target.getAttribute('zen-workspace-id'); if (!originalWorkspaceId) { originalWorkspaceId = entry.target.closest('zen-workspace')?.id; } const workspacesIds = []; if (entry.target.closest('#zen-essentials')) { // Get all workspaces that have the same userContextId const activeWorkspace = await this.getActiveWorkspace(); const userContextId = activeWorkspace.containerTabId; const workspaces = this._workspaceCache.workspaces.filter( (w) => w.containerTabId === userContextId && w.uuid !== originalWorkspaceId ); workspacesIds.push(...workspaces.map((w) => w.uuid)); } else { workspacesIds.push(originalWorkspaceId); } for (const workspaceId of workspacesIds) { const workspaceElement = this.workspaceElement(workspaceId); if (!workspaceElement) { continue; } const arrowScrollbox = workspaceElement.tabsContainer; const pinnedContainer = workspaceElement.pinnedTabsContainer; const workspaceObject = this.getWorkspaceFromId(workspaceId); const essentialContainer = this.getEssentialsSection(workspaceObject.containerTabId); const essentialNumChildren = essentialContainer.children.length; let essentialHackType = 0; if (essentialNumChildren === 6 || essentialNumChildren === 9) { essentialHackType = 1; } else if (essentialNumChildren % 2 === 0 && essentialNumChildren < 8) { essentialHackType = 2; } else if (essentialNumChildren === 5) { essentialHackType = 3; } if (essentialHackType > 0) { essentialContainer.setAttribute('data-hack-type', essentialHackType); } else { essentialContainer.removeAttribute('data-hack-type'); } this._updatePaddingTopOnTabs( workspaceElement, essentialContainer, forAnimation, animateContainer ); this.updateShouldHideSeparator(arrowScrollbox, pinnedContainer); } } } async onTabBrowserInserted(event) { let tab = event.originalTarget; const isEssential = tab.getAttribute('zen-essential') === 'true'; const workspaceID = tab.getAttribute('zen-workspace-id'); if (!this.workspaceEnabled || isEssential) { return; } if (workspaceID) { if (tab.hasAttribute('change-workspace') && this.moveTabToWorkspace(tab, workspaceID)) { this._lastSelectedWorkspaceTabs[workspaceID] = gZenGlanceManager.getTabOrGlanceParent(tab); tab.removeAttribute('change-workspace'); const workspace = this.getWorkspaceFromId(workspaceID); await this.changeWorkspace(workspace); } return; } let activeWorkspace = await this.getActiveWorkspace(); if (!activeWorkspace) { return; } tab.setAttribute('zen-workspace-id', activeWorkspace.uuid); } async onLocationChange(browser) { gZenCompactModeManager.sidebar.toggleAttribute( 'zen-has-empty-tab', gBrowser.selectedTab.hasAttribute('zen-empty-tab') ); if (!this.workspaceEnabled || this._inChangingWorkspace || this._isClosingWindow) { return; } let tab = gBrowser.getTabForBrowser(browser); if (tab.hasAttribute('zen-glance-tab')) { // Extract from parent node so we are not selecting the wrong (current) tab tab = tab.parentNode.closest('.tabbrowser-tab'); console.assert(tab, 'Tab not found for zen-glance-tab'); } const workspaceID = tab.getAttribute('zen-workspace-id'); const isEssential = tab.getAttribute('zen-essential') === 'true'; if (tab.hasAttribute('zen-empty-tab')) { return; } if (!isEssential) { const activeWorkspace = await this.getActiveWorkspace(); if (!activeWorkspace) { return; } // Only update last selected tab for non-essential tabs in their workspace if (workspaceID === activeWorkspace.uuid) { this._lastSelectedWorkspaceTabs[workspaceID] = gZenGlanceManager.getTabOrGlanceParent(tab); } // Switch workspace if needed if (workspaceID && workspaceID !== activeWorkspace.uuid && this._hasInitializedTabsStrip) { const workspaceToChange = this.getWorkspaceFromId(workspaceID); if (!workspaceToChange) { return; } await this.changeWorkspace(workspaceToChange); } } } makeSurePinTabIsInCorrectPosition() { if (!this.pinnedTabsContainer) { return 0; // until we initialize the pinned tabs container } const tabsInsidePinTab = Array.from(this.pinnedTabsContainer.parentElement.children).filter( (child) => child.tagName === 'tab' ); let changed = false; for (const tab of tabsInsidePinTab) { if (tab.getAttribute('zen-glance-tab') === 'true') { continue; } if (tab.getAttribute('zen-essential') === 'true') { const container = this.getCurrentEssentialsContainer(); container.appendChild(tab); changed = true; continue; } const workspaceId = tab.getAttribute('zen-workspace-id'); if (!workspaceId) { continue; } const contaienr = this.workspaceElement(workspaceId).pinnedTabsContainer; contaienr.insertBefore(tab, contaienr.lastChild); changed = true; } if (changed) { gBrowser.tabContainer._invalidateCachedTabs(); } // Return the number of essentials INSIDE the pinned tabs container so we can correctly change their parent return Array.from(this.pinnedTabsContainer.children).filter( (child) => child.getAttribute('zen-essential') === 'true' ).length; } // Context menu management _contextMenuId = null; async updateContextMenu(_) { console.assert(this._contextMenuId, 'No context menu ID set'); document .querySelector( `#PanelUI-zen-workspaces [zen-workspace-id="${this._contextMenuId}"] .zen-workspace-actions` ) .setAttribute('active', 'true'); const workspaces = await this._workspaces(); let deleteMenuItem = document.getElementById('context_zenDeleteWorkspace'); if (workspaces.workspaces.length <= 1) { deleteMenuItem.setAttribute('disabled', 'true'); } else { deleteMenuItem.removeAttribute('disabled'); } let openMenuItem = document.getElementById('context_zenOpenWorkspace'); if ( workspaces.workspaces.find( (workspace) => workspace.uuid === this._contextMenuId && this.isWorkspaceActive(workspace) ) ) { openMenuItem.setAttribute('disabled', 'true'); } else { openMenuItem.removeAttribute('disabled'); } const openInContainerMenuItem = document.getElementById( 'context_zenWorkspacesOpenInContainerTab' ); if (this.shouldShowContainers) { openInContainerMenuItem.removeAttribute('hidden'); } else { openInContainerMenuItem.setAttribute('hidden', 'true'); } } async contextChangeContainerTab(event) { this._organizingWorkspaceStrip = true; let workspaces = await this._workspaces(); let workspace = workspaces.workspaces.find( (workspace) => workspace.uuid === this._contextMenuId ); let userContextId = parseInt(event.target.getAttribute('data-usercontextid')); workspace.containerTabId = userContextId + 0; // +0 to convert to number await this.saveWorkspace(workspace); window.requestAnimationFrame(async () => { if (workspace.uuid === this.activeWorkspace) { await this.changeWorkspace(workspace, { alwaysChange: true, }); } }, 0); } onContextMenuClose() { let target = document.querySelector( `#PanelUI-zen-workspaces [zen-workspace-id="${this._contextMenuId}"] .zen-workspace-actions` ); if (target) { target.removeAttribute('active'); } this._contextMenuId = null; } findTabToBlur(tab) { if ((!this._shouldChangeToTab(tab) || !tab) && this._emptyTab) { return this._emptyTab; } return tab; } async openWorkspace() { let workspaces = await this._workspaces(); let workspace = workspaces.workspaces.find( (workspace) => workspace.uuid === this._contextMenuId ); await this.changeWorkspace(workspace); } async contextDelete(event) { this.__contextIsDelete = true; event.stopPropagation(); await this.removeWorkspace(this._contextMenuId); this.__contextIsDelete = false; } async contextEdit(event) { event.stopPropagation(); await this.openEditDialog(this._contextMenuId); } get emojis() { if (this._emojis) { return this._emojis; } const lazy = {}; Services.scriptloader.loadSubScript( 'chrome://browser/content/zen-components/ZenEmojies.mjs', lazy ); this._emojis = lazy.zenGlobalEmojis(); return this._emojis; } clearEmojis() { // Unload from memory this._emojis = null; } async changeWorkspaceShortcut(offset = 1, whileScrolling = false) { // Cycle through workspaces let workspaces = await this._workspaces(); let activeWorkspace = await this.getActiveWorkspace(); let workspaceIndex = workspaces.workspaces.indexOf(activeWorkspace); // note: offset can be negative let targetIndex = workspaceIndex + offset; if (this.shouldWrapAroundNavigation) { // Add length to handle negative indices and loop targetIndex = (targetIndex + workspaces.workspaces.length) % workspaces.workspaces.length; } else { // Clamp within bounds to disable looping targetIndex = Math.max(0, Math.min(workspaces.workspaces.length - 1, targetIndex)); } let nextWorkspace = workspaces.workspaces[targetIndex]; await this.changeWorkspace(nextWorkspace, { whileScrolling }); } _initializeWorkspaceTabContextMenus() { const menu = document.createXULElement('menu'); menu.setAttribute('id', 'context-zen-change-workspace-tab'); menu.setAttribute('data-l10n-id', 'context-zen-change-workspace-tab'); const menuPopup = document.createXULElement('menupopup'); menuPopup.setAttribute('id', 'context-zen-change-workspace-tab-menu-popup'); menu.appendChild(menuPopup); document.getElementById('context_closeDuplicateTabs').after(menu); } async changeTabWorkspace(workspaceID) { const tabs = TabContextMenu.contextTab.multiselected ? gBrowser.selectedTabs : [TabContextMenu.contextTab]; document.getElementById('tabContextMenu').hidePopup(); const previousWorkspaceID = document.documentElement.getAttribute('zen-workspace-id'); for (let tab of tabs) { this.moveTabToWorkspace(tab, workspaceID); if (this._lastSelectedWorkspaceTabs[previousWorkspaceID] === tab) { // This tab is no longer the last selected tab in the previous workspace because it's being moved to // the current workspace delete this._lastSelectedWorkspaceTabs[previousWorkspaceID]; } } // Make sure we select the last tab in the new workspace this._lastSelectedWorkspaceTabs[workspaceID] = gZenGlanceManager.getTabOrGlanceParent( tabs[tabs.length - 1] ); const workspaces = await this._workspaces(); await this.changeWorkspace( workspaces.workspaces.find((workspace) => workspace.uuid === workspaceID) ); } // Tab browser utilities createContainerTabMenu(event) { let window = event.target.ownerGlobal; const workspace = this.getWorkspaceFromId(this._contextMenuId); let containerTabId = workspace.containerTabId; return window.createUserContextMenu(event, { isContextMenu: true, excludeUserContextId: containerTabId, showDefaultTab: true, }); } getContextIdIfNeeded(userContextId, fromExternal, allowInheritPrincipal) { if (!this.workspaceEnabled) { return [userContextId, false, undefined]; } if ( this.shouldForceContainerTabsToWorkspace && typeof userContextId !== 'undefined' && this._workspaceCache?.workspaces && !fromExternal ) { // Find all workspaces that match the given userContextId const matchingWorkspaces = this._workspaceCache.workspaces.filter( (workspace) => workspace.containerTabId === userContextId ); // Check if exactly one workspace matches if (matchingWorkspaces.length === 1) { const workspace = matchingWorkspaces[0]; if (workspace.uuid !== this.getActiveWorkspaceFromCache().uuid) { return [userContextId, true, workspace.uuid]; } } } const activeWorkspace = this.getActiveWorkspaceFromCache(); const activeWorkspaceUserContextId = activeWorkspace?.containerTabId; if ( fromExternal !== true && typeof userContextId !== 'undefined' && userContextId !== activeWorkspaceUserContextId ) { return [userContextId, false, undefined]; } return [activeWorkspaceUserContextId, true, undefined]; } getTabsToExclude(aTab) { const tabWorkspaceId = aTab.getAttribute('zen-workspace-id'); // Return all tabs that are not on the same workspace return this.allStoredTabs.filter( (tab) => tab.getAttribute('zen-workspace-id') !== tabWorkspaceId && !( this.containerSpecificEssentials && tab.getAttribute('container') !== aTab.getAttribute('container') ) && !tab.hasAttribute('zen-empty-tab') ); } async shortcutSwitchTo(index) { const workspaces = await this._workspaces(); // The index may be out of bounds, if it doesnt exist, don't do anything if (index >= workspaces.workspaces.length || index < 0) { return; } const workspaceToSwitch = workspaces.workspaces[index]; await this.changeWorkspace(workspaceToSwitch); } isBookmarkInAnotherWorkspace(bookmark) { if (!this._workspaceBookmarksCache?.bookmarks) return false; const bookmarkGuid = bookmark.bookmarkGuid; const activeWorkspaceUuid = this.activeWorkspace; let isInActiveWorkspace = false; let isInOtherWorkspace = false; for (const [workspaceUuid, bookmarkGuids] of Object.entries( this._workspaceBookmarksCache.bookmarks )) { if (bookmarkGuids.includes(bookmarkGuid)) { if (workspaceUuid === activeWorkspaceUuid) { isInActiveWorkspace = true; } else { isInOtherWorkspace = true; } } } // Return true only if the bookmark is in another workspace and not in the active one return isInOtherWorkspace && !isInActiveWorkspace; } // Session restore functions get allStoredTabs() { if (this._allStoredTabs) { return this._allStoredTabs; } const tabs = []; // we need to go through each tab in each container const essentialsContainer = document.querySelectorAll( '#zen-essentials .zen-workspace-tabs-section' ); let pinnedContainers = []; let normalContainers = []; if (!this._hasInitializedTabsStrip) { pinnedContainers = [document.getElementById('vertical-pinned-tabs-container')]; normalContainers = [this.activeWorkspaceStrip]; } else { for (const workspace of this._workspaceCache.workspaces) { const container = this.workspaceElement(workspace.uuid); if (container) { pinnedContainers.push(container.pinnedTabsContainer); normalContainers.push(container.tabsContainer); } } } const containers = [...essentialsContainer, ...pinnedContainers, ...normalContainers]; for (const container of containers) { if (container.hasAttribute('cloned')) { continue; } for (const tab of container.children) { if (tab.tagName === 'tab') { tabs.push(tab); const glance = tab.querySelector('.tabbrowser-tab[glance-id]'); if (glance) { tabs.push(glance); } } else if (tab.tagName == 'tab-group') { for (const groupTab of tab.tabs) { tabs.push(groupTab); const glance = groupTab.querySelector('.tabbrowser-tab[glance-id]'); if (glance) { tabs.push(glance); } } } } } this._allStoredTabs = tabs; return this._allStoredTabs; } get allTabGroups() { if (!this._hasInitializedTabsStrip) { let children = this.tabboxChildren; return children.filter((node) => node.tagName == 'tab-group'); } const pinnedContainers = []; const normalContainers = []; for (const workspace of this._workspaceCache.workspaces) { const container = this.workspaceElement(workspace.uuid); if (container) { pinnedContainers.push(container.pinnedTabsContainer); normalContainers.push(container.tabsContainer); } } const containers = [...pinnedContainers, ...normalContainers]; const tabGroups = []; for (const container of containers) { for (const tabGroup of container.querySelectorAll('tab-group')) { tabGroups.push(tabGroup); } } return tabGroups; } get allUsedBrowsers() { if (!this._hasInitializedTabsStrip) { return gBrowser.browsers; } const browsers = Array.from(gBrowser.tabpanels.querySelectorAll('browser')); // Sort browsers by making the current workspace first const currentWorkspace = this.activeWorkspace; const sortedBrowsers = browsers.sort((a, b) => { const aTab = gBrowser.getTabForBrowser(a); const bTab = gBrowser.getTabForBrowser(b); if (!bTab || !aTab) { return 0; } const aWorkspaceId = aTab.getAttribute('zen-workspace-id'); const bWorkspaceId = bTab.getAttribute('zen-workspace-id'); return aWorkspaceId === currentWorkspace ? -1 : bWorkspaceId === currentWorkspace ? 1 : 0; }); return sortedBrowsers; } get pinnedTabCount() { return this.pinnedTabsContainer.children.length - 1; } get allWorkspaceTabs() { const currentWorkspace = this.activeWorkspace; return this.allStoredTabs.filter( (tab) => tab.hasAttribute('zen-essential') || tab.getAttribute('zen-workspace-id') === currentWorkspace ); } reorganizeTabsAfterWelcome() { const children = gBrowser.tabContainer.arrowScrollbox.children; const remainingTabs = Array.from(children).filter((child) => child.tagName === 'tab'); for (const tab of remainingTabs) { this.moveTabToWorkspace(tab, this.activeWorkspace); } } async switchIfNeeded(browser, i) { const tab = gBrowser.getTabForBrowser(browser); await this.switchTabIfNeeded(tab); } async switchTabIfNeeded(tab) { // Validate browser state first if (!this._validateBrowserState()) { console.warn('Browser state invalid for tab switching'); return; } if (!tab) { console.warn('switchTabIfNeeded called with null tab'); return; } // Validate tab state if (tab.closing || !tab.ownerGlobal || tab.ownerGlobal.closed || !tab.linkedBrowser) { console.warn('Tab is no longer valid, cannot select it'); return; } try { const currentWorkspace = this.getActiveWorkspaceFromCache(); // Check if we need to change workspace if ( (tab.getAttribute('zen-workspace-id') !== this.activeWorkspace && !tab.hasAttribute('zen-essential')) || (currentWorkspace.containerTabId !== parseInt(tab.parentNode.getAttribute('container')) && this.containerSpecificEssentials) ) { // Use a mutex-like approach to prevent concurrent workspace changes if (this._workspaceChangeInProgress) { console.warn('Workspace change already in progress, deferring tab switch'); return; } let workspaceToSwitch = undefined; if (tab.hasAttribute('zen-essential')) { // Find first workspace with the same container const containerTabId = parseInt(tab.parentNode.getAttribute('container')); // +0 to convert to number workspaceToSwitch = this._workspaceCache.workspaces.find( (workspace) => workspace.containerTabId + 0 === containerTabId ); } else { workspaceToSwitch = this.getWorkspaceFromId(tab.getAttribute('zen-workspace-id')); } if (!workspaceToSwitch) { console.error('No workspace found for tab, cannot switch'); await this._safelySelectTab(tab); return; } this._workspaceChangeInProgress = true; try { this._lastSelectedWorkspaceTabs[workspaceToSwitch.uuid] = gZenGlanceManager.getTabOrGlanceParent(tab); await this.changeWorkspace(workspaceToSwitch); } finally { this._workspaceChangeInProgress = false; } } // Safely switch to the tab using our debounced method await this._safelySelectTab(tab); } catch (e) { console.error('Error in switchTabIfNeeded:', e); } } getDefaultContainer() { if (!this.workspaceEnabled) { return 0; } const workspaces = this._workspaceCache; if (!workspaces) { return 0; } const activeWorkspace = this.activeWorkspace; const workspace = workspaces.workspaces.find((workspace) => workspace.uuid === activeWorkspace); return workspace.containerTabId; } onWindowResize(event = undefined) { if (!(!event || event.target === window)) return; // Check if workspace icons overflow the parent container const parent = this.workspaceIcons; if (!parent || this._processingResize) { return; } this._processingResize = true; // Once we are overflowing, we align the buttons to always stay inside the container, // meaning we need to remove the overflow attribute to reset the width parent.removeAttribute('icons-overflow'); requestAnimationFrame(() => { const overflow = parent.scrollWidth > parent.clientWidth; parent.toggleAttribute('icons-overflow', overflow); // The maximum width a button has when it overflows based on the number of buttons const numButtons = parent.children.length + 1; // +1 to exclude the active button const maxWidth = 100 / numButtons; parent.style.setProperty('--zen-overflowed-workspace-button-width', `${maxWidth}%`); this._processingResize = false; // Scroll to the active workspace button if it's not visible const activeButton = parent.querySelector('.zen-workspace-button.active'); if (!activeButton) { return; } const parentRect = parent.getBoundingClientRect(); const activeRect = activeButton.getBoundingClientRect(); if (activeRect.left < parentRect.left || activeRect.right > parentRect.right) { parent.scrollLeft = activeButton.offsetLeft; } }); } })();