/* 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/. */ /* eslint-disable no-shadow */ import { nsZenThemePicker } from "chrome://browser/content/zen-components/ZenGradientGenerator.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { ZenSessionStore: "resource:///modules/zen/ZenSessionManager.sys.mjs", }); /** * Zen Spaces manager. This class is mainly responsible for the UI * and user interactions but it also contains some logic to manage * the workspaces and their tabs. * * For window sync, please @see ZenWindowSync */ class nsZenWorkspaces { /** * Stores workspace IDs and their last selected tabs. */ lastSelectedWorkspaceTabs = {}; #inChangingWorkspace = false; draggedElement = null; #hasInitialized = false; #canDebug = Services.prefs.getBoolPref("zen.workspaces.debug", false); #activeWorkspace = ""; _swipeState = { isGestureActive: true, lastDelta: 0, direction: null, }; _workspaceCache = []; #lastScrollTime = 0; bookmarkMenus = [ "PlacesToolbar", "bookmarks-menu-button", "BMB_bookmarksToolbar", "BMB_unsortedBookmarks", "BMB_mobileBookmarks", ]; promisePinnedInitialized = new Promise((resolve) => { this._resolvePinnedInitialized = resolve; }); promiseInitialized = new Promise((resolve) => { this._resolveInitialized = resolve; }); async #waitForPromises() { if (this.privateWindowOrDisabled) { return; } await Promise.all([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.separate-essentials", 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"); } this.popupOpenHandler = this._popupOpenHandler.bind(this); window.addEventListener("resize", this.onWindowResize.bind(this)); this.addPopupListeners(); if (this.privateWindowOrDisabled) { await this.#waitForPromises(); await this.restoreWorkspacesFromSessionStore({}); } if (!this.privateWindowOrDisabled) { const observerFunction = async () => { delete this._workspaceBookmarksCache; await this.workspaceBookmarks(); this._invalidateBookmarkContainers(); }; Services.obs.addObserver(observerFunction, "workspace-bookmarks-updated"); window.addEventListener("unload", () => { Services.obs.removeObserver(observerFunction, "workspace-bookmarks-updated"); }); } } log(...args) { if (this.#canDebug) { /* eslint-disable no-console */ console.debug(`[gZenWorkspaces]:`, ...args); } } #afterLoadInit() { const onResize = (...args) => { requestAnimationFrame(() => { this.onPinnedTabsResize(...args); }); }; this._pinnedTabsResizeObserver = new ResizeObserver(onResize); this.registerPinnedResizeObserver(); this.#initializeWorkspaceTabContextMenus(); // Non UI related initializations if ( Services.prefs.getBoolPref("zen.workspaces.swipe-actions", false) && this.workspaceEnabled && !this.isPrivateWindow ) { this.initializeGestureHandlers(); this.initializeWorkspaceNavigation(); } } // 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) { // 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 ) { // 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; } } #initializeEmptyTab() { for (const tab of gBrowser.tabs) { // Check if session store has an empty tab if (tab.hasAttribute("zen-empty-tab") && !tab.pinned) { this.log("Found existing empty tab from session store!"); this._emptyTab = tab; return; } } this._emptyTab = gBrowser.addTrustedTab("about:blank", { inBackground: true, userContextId: 0, _forZenEmptyTab: true, }); } registerPinnedResizeObserver() { if (!this._hasInitializedTabsStrip || !this._pinnedTabsResizeObserver) { return; } this._pinnedTabsResizeObserver.disconnect(); 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 this.activeWorkspaceElement?.tabsContainer; } get pinnedTabsContainer() { if (!this.workspaceEnabled || !this._hasInitializedTabsStrip) { return document.getElementById("pinned-tabs-container"); } return this.activeWorkspaceElement?.pinnedTabsContainer; } get activeWorkspaceIndicator() { return this.activeWorkspaceElement?.indicator; } get activeScrollbox() { return this.activeWorkspaceElement?.scrollbox ?? gBrowser.tabContainer.arrowScrollbox; } get tabboxChildren() { return Array.from(this.activeWorkspaceStrip?.children || []); } get tabboxChildrenWithoutEmpty() { return this.tabboxChildren.filter((child) => !child.hasAttribute("zen-empty-tab")); } get shouldAnimateEssentials() { return ( this.containerSpecificEssentials || document.documentElement.hasAttribute("zen-creating-workspace") ); } get activeWorkspaceElement() { return this.workspaceElement(this.activeWorkspace); } workspaceElement(workspaceId) { if (typeof workspaceId !== "string") { workspaceId = workspaceId?.uuid; } return document.getElementById(workspaceId); } #initializeTabsStripSections() { const perifery = document.getElementById("tabbrowser-arrowscrollbox-periphery"); perifery.setAttribute("hidden", "true"); const tabs = gBrowser.tabContainer.allTabs; const workspaces = this.getWorkspaces(); for (const workspace of workspaces) { 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._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; } getCurrentSpaceContainerId() { const currentWorkspace = this.getActiveWorkspaceFromCache(); return typeof currentWorkspace?.containerTabId === "number" ? currentWorkspace.containerTabId : 0; } getCurrentEssentialsContainer() { return this.getEssentialsSection(this.getCurrentSpaceContainerId()); } #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; } if (document.documentElement.hasAttribute("zen-creating-workspace")) { workspaceWrapper.hidden = true; // Hide workspace while creating it } container.appendChild(workspaceWrapper); this.#organizeTabsToWorkspaceSections( workspace, workspaceWrapper.tabsContainer, workspaceWrapper.pinnedTabsContainer, tabs ); workspaceWrapper.checkPinsExistence(); } #organizeTabsToWorkspaceSections(workspace, section, pinnedSection, tabs) { const workspaceTabs = Array.from(tabs).filter( (tab) => tab.getAttribute("zen-workspace-id") === workspace.uuid && !tab.hasAttribute("zen-essential") ); let folders = new Set(); const getFolderRoot = (tab) => { let root = tab?.group; while (root?.group) { root = root?.group; } return root || tab; }; for (let i = workspaceTabs.length - 1; i >= 0; i--) { let tab = workspaceTabs[i]; 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 (gBrowser.isTabGroup(tab)) { let rootGroup = getFolderRoot(tab); if (folders.has(rootGroup)) { continue; } folders.add(rootGroup); tab = rootGroup; } if (tab.pinned) { pinnedSection.insertBefore(tab, pinnedSection.firstChild); } else { section.insertBefore(tab, 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-implicit-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; } requestAnimationFrame(() => { requestAnimationFrame(() => { gNavToolbox.setAttribute("zen-has-hover", "true"); }); }); } #setupSidebarHandlers() { const toolbox = gNavToolbox; const scrollCooldown = 200; // Milliseconds to wait before allowing another scroll const scrollThreshold = 1; // Minimum scroll delta to trigger workspace change toolbox.addEventListener( "wheel", (event) => { if (this.privateWindowOrDisabled) { return; } // Only process non-gesture scrolls if (event.deltaMode !== 1) { return; } const isVerticalScroll = event.deltaY && !event.deltaX; //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; } } let 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", () => { Services.prefs.setBoolPref("zen.swipe.is-fast-swipe", false); document.documentElement.removeAttribute("swipe-gesture"); gZenUIManager.tabsWrapper.style.removeProperty("scrollbar-width"); document.documentElement.style.setProperty("--zen-background-opacity", "1"); delete this._hasAnimatedBackgrounds; this.updateTabsContainers(); document.removeEventListener("popupshown", this.popupOpenHandler, { once: true }); }, true ); } _popupOpenHandler() { // If a popup is opened, we should stop the swipe gesture if (this._swipeState?.isGestureActive) { document.documentElement.removeAttribute("swipe-gesture"); gZenUIManager.tabsWrapper.style.removeProperty("scrollbar-width"); this.updateTabsContainers(); this._cancelSwipeAnimation(); } } _handleSwipeMayStart(event) { if (this.privateWindowOrDisabled || this.#inChangingWorkspace) { return; } if ( event.target.closest("#zen-sidebar-foot-buttons") || event.target.closest('#urlbar[zen-floating-urlbar="true"]') ) { 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; } gZenFolders.cancelPopupTimer(); document.documentElement.setAttribute("swipe-gesture", "true"); document.addEventListener("popupshown", this.popupOpenHandler, { once: true }); event.preventDefault(); event.stopPropagation(); this._swipeState = { isGestureActive: true, lastDelta: 0, direction: null, }; Services.prefs.setBoolPref("zen.swipe.is-fast-swipe", true); } _handleSwipeUpdate(event) { if (!this.workspaceEnabled || !this._swipeState?.isGestureActive) { return; } event.preventDefault(); event.stopPropagation(); const delta = event.delta * 300; const stripWidth = window.windowUtils.getBoundsWithoutFlushing(document.getElementById("navigator-toolbox")) .width + window.windowUtils.getBoundsWithoutFlushing(document.getElementById("zen-sidebar-splitter")) .width * 2; 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) > 0.8) { 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) { const spaces = this.getWorkspaces(); if (!spaces.some((ws) => ws.uuid === value)) { value = spaces[0]?.uuid || ""; } if (value === this.#activeWorkspace) { return; } this.#activeWorkspace = value; if (this.privateWindowOrDisabled) { return; } Services.prefs.setStringPref("zen.workspaces.active", value); } get shouldHaveWorkspaces() { if (typeof this._shouldHaveWorkspaces === "undefined") { let chromeFlags = window.docShell.treeOwner .QueryInterface(Ci.nsIInterfaceRequestor) .getInterface(Ci.nsIAppWindow).chromeFlags; this._shouldHaveWorkspaces = chromeFlags & Ci.nsIWebBrowserChrome.CHROME_TOOLBAR || chromeFlags & Ci.nsIWebBrowserChrome.CHROME_MENUBAR; return this._shouldHaveWorkspaces; } return this._shouldHaveWorkspaces && !document.documentElement.hasAttribute("taskbartab"); } get isPrivateWindow() { return PrivateBrowsingUtils.isWindowPrivate(window); } get currentWindowIsSyncing() { return ( !document.documentElement.hasAttribute("zen-unsynced-window") && window._zenStartupSyncFlag !== "unsynced" && !this.isPrivateWindow ); } get privateWindowOrDisabled() { return !this.shouldHaveWorkspaces || !this.currentWindowIsSyncing; } get workspaceEnabled() { return this.shouldHaveWorkspaces && !window.closed; } getActiveWorkspaceFromCache() { return this.getWorkspaceFromId(this.activeWorkspace); } getWorkspaceFromId(id) { try { return this.getWorkspaces().find((workspace) => workspace.uuid === id); } catch { return null; } } getWorkspaces(lieToMe = false) { if (lieToMe) { const { ZenSessionStore } = ChromeUtils.importESModule( "resource:///modules/zen/ZenSessionManager.sys.mjs" ); return ZenSessionStore.getClonedSpaces(); } return [...this._workspaceCache]; } getWorkspacesForSessionStore() { const spaces = this.getWorkspaces(); let spacesForSS = []; for (const space of spaces) { let newSpace = { ...space }; const element = this.workspaceElement(space.uuid); if (element) { newSpace.hasCollapsedPinnedTabs = element.hasCollapsedPinnedTabs; } spacesForSS.push(newSpace); } return spacesForSS; } async workspaceBookmarks() { if (this.privateWindowOrDisabled) { this._workspaceBookmarksCache = { bookmarks: [], lastChangeTimestamp: 0, }; return this._workspaceBookmarksCache; } if (this._workspaceBookmarksCache) { return this._workspaceBookmarksCache; } const [bookmarks, lastChangeTimestamp] = await Promise.all([ ZenWorkspaceBookmarksStorage.getBookmarkGuidsByWorkspace(), ZenWorkspaceBookmarksStorage.getLastChangeTimestamp(), ]); this._workspaceBookmarksCache = { bookmarks, lastChangeTimestamp }; return this._workspaceCache; } restoreWorkspacesFromSessionStore(aWinData = {}) { if (this.#hasInitialized || !this.workspaceEnabled) { return Promise.resolve(); } const spacesFromStore = aWinData.spaces || []; if ( !this.privateWindowOrDisabled && spacesFromStore.length === 0 && lazy.ZenSessionStore._migrationData ) { spacesFromStore.push(...lazy.ZenSessionStore._migrationData.spaces); } this._workspaceCache = spacesFromStore.length ? [...spacesFromStore] : [this.#createWorkspaceData("Space", undefined)]; this.activeWorkspace = aWinData.activeZenSpace || this._workspaceCache[0].uuid; let promise = this.#initializeWorkspaces(); for (const workspace of spacesFromStore) { const element = this.workspaceElement(workspace.uuid); let wasCollapsed = workspace.hasCollapsedPinnedTabs || false; if (element) { // A bit of a hacky soltuion to ensure that the height // when collapsing is calculated correctly after restoring from session store setTimeout(() => { setTimeout(() => { element.collapsiblePins.collapsed = wasCollapsed; }, 0); }, 0); } } for (const workspace of this._workspaceCache) { // We don't want to depend on this by mistake delete workspace.hasCollapsedPinnedTabs; } this.#hasInitialized = true; return promise; } #initializeWorkspaces() { let activeWorkspace = this.getActiveWorkspace(); this.activeWorkspace = activeWorkspace?.uuid; try { if (activeWorkspace) { window.gZenThemePicker = new nsZenThemePicker(); gZenThemePicker.onWorkspaceChange(activeWorkspace); } } catch (e) { console.error("gZenWorkspaces: Error initializing theme picker", e); } this.#initializeTabsStripSections(); this.#initializeEmptyTab(); return (async () => { await this.#waitForPromises(); this.#afterLoadInit(); await this.workspaceBookmarks(); await this.changeWorkspace(activeWorkspace, { onInit: true }); this.#fixTabPositions(); this.onWindowResize(); this._resolveInitialized(); this.#clearAnyZombieTabs(); // Dont call with await delete this._resolveInitialized; 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); window.addEventListener("TabSelect", this.onLocationChange.bind(this)); window.addEventListener("TabBrowserInserted", this.onTabBrowserInserted.bind(this)); this.updateWorkspacesChangeContextMenu(); })(); } async selectStartPage() { if (!this.workspaceEnabled || gZenUIManager.testingEnabled) { return; } await this.promiseInitialized; let showed = false; let resolveSelectPromise; let selectPromise = new Promise((resolve) => { resolveSelectPromise = resolve; }); const cleanup = () => { delete this._tabToSelect; delete this._tabToRemoveForEmpty; delete this._shouldOverrideTabs; 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 && !this._shouldOverrideTabs) { const tabs = gBrowser.tabs.filter((tab) => !tab.collapsed); 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(() => { let tabToUse = gZenGlanceManager.getTabOrGlanceParent( tabs[this._tabToSelect + 1] || this._emptyTab ); gBrowser.selectedTab = tabToUse; 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, animate: false, }); cleanup(); }, 0); } } else { setTimeout(() => { cleanup(); }, 0); } await selectPromise; if (this._initialTab) { this._removedByStartupPage = true; gBrowser.removeTab(this._initialTab, { skipSessionStore: true, }); delete this._initialTab; } showed &&= Services.prefs.getBoolPref("zen.urlbar.open-on-startup", true); // Wait for the next event loop to ensure that the startup focus logic by // firefox has finished doing it's thing. setTimeout(() => { setTimeout(() => { if (gZenVerticalTabsManager._canReplaceNewTab && showed) { BrowserCommands.openTab(); } else if (!showed) { gBrowser.selectedBrowser.focus(); } }); }); 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 || !this.workspaceEnabled) { 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; } } changeWorkspaceIcon() { let anchor = this.activeWorkspaceIndicator?.querySelector( ".zen-current-workspace-indicator-icon" ); if (this.#contextMenuData?.workspaceId) { anchor = this.#contextMenuData.originalTarget; } const workspaceId = this.#contextMenuData?.workspaceId || this.activeWorkspace; if (!anchor) { return; } const hasNoIcon = anchor.hasAttribute("no-icon"); anchor.removeAttribute("no-icon"); if (hasNoIcon) { anchor.textContent = ""; } gZenEmojiPicker.open(anchor, { closeOnSelect: false, allowNone: hasNoIcon, onSelect: async (icon) => { const workspace = this.getWorkspaceFromId(workspaceId); if (!workspace) { console.warn("No active workspace found to change icon"); return; } workspace.icon = icon; await this.saveWorkspace(workspace); }, }); } shouldCloseWindow() { return ( !window.toolbar.visible || Services.prefs.getBoolPref("browser.tabs.closeWindowWithLastTab") || (this.privateWindowOrDisabled && !this.isPrivateWindow) ); } async #clearAnyZombieTabs() { const tabs = this.allStoredTabs; const workspaces = this.getWorkspaces(); for (let tab of tabs) { const workspaceID = tab.getAttribute("zen-workspace-id"); if ( (workspaceID && !tab.hasAttribute("zen-essential") && !workspaces.find((workspace) => workspace.uuid === workspaceID)) || // Also remove empty tabs that are supposed to be from parent folders but // they dont exist anymore (tab.pinned && tab.hasAttribute("zen-empty-tab") && !tab.group) ) { // Remove any tabs where their workspace doesn't exist anymore this.log("Removed zombie tab from non-existing workspace", tab); gBrowser.unpinTab(tab); gBrowser.removeTab(tab, { skipSessionStore: true, closeWindowWithLastTab: false, }); } } } handleTabBeforeClose(tab, closeWindowWithLastTab) { 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 = closeWindowWithLastTab != null ? closeWindowWithLastTab : this.shouldCloseWindow(); 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 workspaceActions = document.getElementById("zenWorkspaceMoreActions"); workspaceActions.addEventListener("popupshowing", this.updateWorkspaceActionsMenu.bind(this)); workspaceActions.addEventListener("popuphidden", () => { setTimeout(() => { setTimeout(() => { this.#contextMenuData = null; }, 0); }, 0); // Delay to ensure the context menu data is cleared after the popup is hidden }); const contextChangeContainerTabMenu = document.getElementById( "context_zenWorkspacesOpenInContainerTab" ); contextChangeContainerTabMenu.addEventListener( "popupshowing", this.updateWorkspaceActionsMenuContainer.bind(this) ); contextChangeContainerTabMenu.addEventListener( "command", this.contextChangeContainerTab.bind(this) ); } generateMenuItemForWorkspace(workspace, disableCurrent = false) { const item = document.createXULElement("menuitem"); item.className = "zen-workspace-context-menu-item"; item.setAttribute("zen-workspace-id", workspace.uuid); if (!disableCurrent) { item.setAttribute("type", "radio"); } if (workspace.uuid === this.activeWorkspace) { item.setAttribute(disableCurrent ? "disabled" : "checked", true); } let name = workspace.name; const iconIsSvg = workspace.icon && workspace.icon.endsWith(".svg"); if (workspace.icon && workspace.icon !== "" && !iconIsSvg) { name = `${workspace.icon} ${name}`; } item.setAttribute("label", name); if (iconIsSvg) { item.setAttribute("image", workspace.icon); item.classList.add("zen-workspace-context-icon"); } return item; } #contextMenuData = null; updateWorkspaceActionsMenu(event) { if (event.target.id !== "zenWorkspaceMoreActions") { return; } const openInContainerMenuItem = document.getElementById( "context_zenWorkspacesOpenInContainerTab" ); if (this.shouldShowContainers) { openInContainerMenuItem.removeAttribute("hidden"); } else { openInContainerMenuItem.setAttribute("hidden", "true"); } // Call parent node as on windows, the text can be double clicked let target; try { target = event.explicitOriginalTarget?.closest("toolbarbutton"); } catch (e) { console.error("Error getting explicitOriginalTarget in context menu:", e); } this.#contextMenuData = { workspaceId: target?.getAttribute("zen-workspace-id"), originalTarget: target, }; const workspaceName = document.getElementById("context_zenEditWorkspace"); const themePicker = document.getElementById("context_zenChangeWorkspaceTheme"); /* We can't show the rename input properly in collapsed state, so hide the workspace edit input */ const isCollapsed = !Services.prefs.getBoolPref("zen.view.sidebar-expanded"); workspaceName.hidden = isCollapsed || (this.#contextMenuData.workspaceId && this.#contextMenuData.workspaceId !== this.activeWorkspace); themePicker.hidden = this.#contextMenuData.workspaceId && this.#contextMenuData.workspaceId !== this.activeWorkspace; const separator = document.getElementById("context_zenWorkspacesSeparator"); for (const item of event.target.querySelectorAll(".zen-workspace-context-menu-item")) { item.remove(); } if (!this.#contextMenuData.workspaceId) { separator.hidden = false; for (const workspace of this.getWorkspaces().reverse()) { const item = this.generateMenuItemForWorkspace(workspace); item.addEventListener("command", (e) => { this.changeWorkspaceWithID(e.target.closest("menuitem").getAttribute("zen-workspace-id")); }); separator.after(item); } } else { separator.hidden = true; } event.target.addEventListener( "popuphidden", () => { this.#contextMenuData = null; }, { once: true } ); } updateWorkspaceActionsMenuContainer(event) { let workspace; if (this.#contextMenuData?.workspaceId) { workspace = this.getWorkspaceFromId(this.#contextMenuData.workspaceId); } else { workspace = this.getActiveWorkspaceFromCache(); } let containerTabId = workspace.containerTabId; return window.createUserContextMenu(event, { isContextMenu: true, excludeUserContextId: containerTabId, showDefaultTab: true, }); } saveWorkspace(workspaceData) { if (this.privateWindowOrDisabled) { return; } const workspacesData = this._workspaceCache; const index = workspacesData.findIndex((ws) => ws.uuid === workspaceData.uuid); if (index !== -1) { workspacesData[index] = workspaceData; } else { workspacesData.push(workspaceData); } this.#propagateWorkspaceData(); } removeWorkspace(windowID) { let workspacesData = this.getWorkspaces(); // Remove the workspace from the cache workspacesData = workspacesData.filter((workspace) => workspace.uuid !== windowID); this.#propagateWorkspaceData(workspacesData); } isWorkspaceActive(workspace) { return workspace.uuid === this.activeWorkspace; } getActiveWorkspace() { return this.getActiveWorkspaceFromCache(); } workspaceHasIcon(workspace) { return workspace.icon && workspace.icon !== ""; } getWorkspaceIcon(workspace) { if (this.workspaceHasIcon(workspace)) { return workspace.icon; } try { return new Intl.Segmenter().segment(workspace.name).containing().segment.toUpperCase(); } catch { return Array.from(workspace.name)[0]?.toUpperCase(); } } get shouldShowContainers() { return ( Services.prefs.getBoolPref("privacy.userContext.ui.enabled") && !!ContextualIdentityService.getPublicIdentities().length ); } #propagateWorkspaceData(aSpaceData = null) { if (!this.#hasInitialized || this.privateWindowOrDisabled) { return; } window.dispatchEvent(new CustomEvent("ZenWorkspaceDataChanged"), { bubbles: true }); window.gZenWindowSync.propagateWorkspacesToAllWindows(aSpaceData ?? this._workspaceCache); } propagateWorkspaces(aWorkspaces) { const previousWorkspaces = this._workspaceCache || []; let promises = []; let hasChanged = false; // Remove any workspace elements here that no longer exist for (const previousWorkspace of previousWorkspaces) { if ( this.workspaceElement(previousWorkspace.uuid) && !aWorkspaces.find((w) => w.uuid === previousWorkspace.uuid) ) { let promise = Promise.resolve(); if (this.isWorkspaceActive(previousWorkspace)) { // If the removed workspace was active, switch to another one const newActiveWorkspace = aWorkspaces.find((w) => w.uuid !== previousWorkspace.uuid) || null; promise = this.changeWorkspace(newActiveWorkspace); } promise = promise.then(() => { this.workspaceElement(previousWorkspace.uuid)?.remove(); delete this.lastSelectedWorkspaceTabs[previousWorkspace.uuid]; }); promises.push(promise); hasChanged = true; } } // Add any new workspace elements here for (const workspace of aWorkspaces) { if (!this.workspaceElement(workspace.uuid)) { this.#createWorkspaceTabsSection(workspace); hasChanged = true; } } // Order the workspace elements correctly let previousElement = null; const arrowScrollbox = document.getElementById("tabbrowser-arrowscrollbox"); for (const workspace of aWorkspaces) { const workspaceElement = this.workspaceElement(workspace.uuid); if (workspaceElement) { if (previousElement === null) { arrowScrollbox.moveBefore(workspaceElement, arrowScrollbox.firstChild); hasChanged = true; } else if (previousElement.nextSibling !== workspaceElement) { arrowScrollbox.moveBefore(workspaceElement, previousElement.nextSibling); hasChanged = true; } previousElement = workspaceElement; } } return Promise.all(promises).then(() => { this._workspaceCache = aWorkspaces; if (hasChanged) { this.#fireSpaceUIUpdate(); } this._organizeWorkspaceStripLocations(this.getActiveWorkspaceFromCache()); this.updateTabsContainers(); this.updateWorkspacesChangeContextMenu(); }); } async reorderWorkspace(id, newPosition) { if (this.privateWindowOrDisabled) { return; } const workspaces = this._workspaceCache; const workspace = workspaces.find((w) => w.uuid === id); if (!workspace) { console.warn(`Workspace with ID ${id} not found for reordering.`); return; } // Remove the workspace from its current position const currentIndex = workspaces.indexOf(workspace); if (currentIndex === -1) { console.warn(`Workspace with ID ${id} not found in the list.`); return; } workspaces.splice(currentIndex, 1); // Insert the workspace at the new position if (newPosition < 0 || newPosition > workspaces.length) { console.warn(`Invalid position ${newPosition} for reordering workspace with ID ${id}.`); return; } workspaces.splice(newPosition, 0, workspace); // Propagate the changes if the order has changed if (currentIndex !== newPosition) { this.#propagateWorkspaceData(); } } async openWorkspaceCreation() { let createForm; const previousWorkspace = this.getActiveWorkspace(); document.documentElement.setAttribute("zen-creating-workspace", "true"); await this.createAndSaveWorkspace("Space", undefined, false, 0, { beforeChangeCallback: async (workspace) => { createForm = document.createXULElement("zen-workspace-creation"); createForm.setAttribute("workspace-id", workspace.uuid); createForm.setAttribute("previous-workspace-id", previousWorkspace?.uuid || ""); gBrowser.tabContainer.after(createForm); await createForm.promiseInitialized; }, }); createForm.finishSetup(); } #unpinnedTabsInWorkspace(workspaceID) { return Array.from(this.allStoredTabs).filter( (tab) => tab.getAttribute("zen-workspace-id") === workspaceID && tab.visible && !tab.pinned ); } #getClosableTabs(tabs) { const remainingTabs = tabs.filter((tab) => { const attributes = ["selected", "multiselected", "pictureinpicture", "soundplaying"]; for (const attr of attributes) { if (tab.hasAttribute(attr)) { return false; } } const browser = tab.linkedBrowser; if ( window.webrtcUI.browserHasStreams(browser) || browser?.browsingContext?.currentWindowGlobal?.hasActivePeerConnections() ) { return false; } return true; }); if (remainingTabs.length === 0) { return tabs; // If no tabs are safe to close, return all to force close } return remainingTabs; } #deleteAllUnpinnedTabsInWorkspace(tabs) { gBrowser.removeTabs(tabs, { closeWindowWithLastTab: false, }); } async unloadWorkspace() { const workspaceId = this.#contextMenuData?.workspaceId || this.activeWorkspace; const tabsToUnload = this.allStoredTabs.filter( (tab) => tab.getAttribute("zen-workspace-id") === workspaceId && !tab.hasAttribute("zen-empty-tab") && !tab.hasAttribute("zen-essential") && !tab.hasAttribute("pending") ); if (tabsToUnload.length === 0) { return; } this.log("Unloading workspace", workspaceId); await gBrowser.explicitUnloadTabs(tabsToUnload); // TODO: unit test this } moveTabToWorkspace(tab, workspaceID) { return this.moveTabsToWorkspace([tab], workspaceID); } moveTabsToWorkspace(tabs, workspaceID) { for (let tab of tabs) { const workspaceContainer = this.workspaceElement(workspaceID); const container = tab.pinned ? workspaceContainer?.pinnedTabsContainer : workspaceContainer?.tabsContainer; if (container?.contains(tab)) { continue; } if (tab.hasAttribute("zen-essential")) { continue; } tab.owner = null; if (container) { if (tab.group?.hasAttribute("split-view-group")) { gBrowser.zenHandleTabMove(tab.group, () => { for (const subTab of tab.group.tabs) { subTab.setAttribute("zen-workspace-id", workspaceID); } container.insertBefore(tab.group, container.lastChild); }); continue; } gBrowser.zenHandleTabMove(tab, () => { tab.setAttribute("zen-workspace-id", workspaceID); 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(space) { document.documentElement.setAttribute("zen-workspace-id", space.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, space.uuid); tabCount++; } } if (tabCount === 0) { this.selectEmptyTab(); } } addChangeListeners( func, opts = { once: false, } ) { if (!this._changeListeners) { this._changeListeners = []; } this._changeListeners.push({ func, opts }); } removeChangeListeners(func) { if (!this._changeListeners) { return; } this._changeListeners = this._changeListeners.filter((listener) => listener.func !== func); } async changeWorkspaceWithID(workspaceID, ...args) { const workspace = this.getWorkspaceFromId(workspaceID); 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 = this.getActiveWorkspace(); alwaysChange = alwaysChange || onInit; this.activeWorkspace = workspace.uuid; if (previousWorkspace && previousWorkspace.uuid === workspace.uuid && !alwaysChange) { this._cancelSwipeAnimation(); return; } const workspaces = this.getWorkspaces(); gZenFolders.cancelPopupTimer(); // Refresh tab cache for (const otherWorkspace of workspaces) { const container = this.workspaceElement(otherWorkspace.uuid); container.active = otherWorkspace.uuid === workspace.uuid; } // note: We are calling this even though it is also called in `updateTabsContainers`. This is mostly // due to a race condition where the workspace strip is not updated before the tabs are moved. this.makeSureEmptyTabIsFirst(); gBrowser.pinnedTabsContainer = this.pinnedTabsContainer || gBrowser.pinnedTabsContainer; gBrowser.tabContainer.pinnedTabsContainer = this.pinnedTabsContainer || gBrowser.tabContainer.pinnedTabsContainer; this.tabContainer._invalidateCachedTabs(); if (!whileScrolling) { this._organizeWorkspaceStripLocations(previousWorkspace); } // Second pass: Handle tab selection this.tabContainer._invalidateCachedTabs(); const tabToSelect = await this._handleTabSelection(workspace, onInit, previousWorkspace.uuid); if (tabToSelect.linkedBrowser) { gBrowser.warmupTab(tabToSelect); } // Update UI and state const previousWorkspaceIndex = workspaces.findIndex((w) => w.uuid === previousWorkspace.uuid); await this._updateWorkspaceState(workspace, onInit, tabToSelect, { previousWorkspaceIndex, previousWorkspace, }); } makeSureEmptyTabIsFirst() { const emptyTab = this._emptyTab; if (emptyTab) { emptyTab.setAttribute("zen-workspace-id", this.activeWorkspace); if (emptyTab.linkedBrowser) { gBrowser.TabStateFlusher.flush(emptyTab.linkedBrowser); } const container = this.activeWorkspaceStrip; if (container) { container.insertBefore(emptyTab, container.firstChild); } } this.#fixTabPositions(); } #fixTabPositions() { // See issue https://github.com/zen-browser/desktop/issues/10157 if (this.tabContainer) { this.tabContainer._invalidateCachedTabs(); } // Fix tabs _tPos values relative to the actual order const tabs = gBrowser.tabs; const usedGroups = new Set(); let tPos = 0; // _tPos is used for the session store, not needed for folders let pPos = 0; // _pPos is used for the pinned tabs manager const recurseFolder = (tab) => { if (tab.group) { recurseFolder(tab.group); if (!usedGroups.has(tab.group.id)) { usedGroups.add(tab.group.id); tab.group._pPos = pPos++; } } }; for (const tab of tabs) { recurseFolder(tab); tab._tPos = tPos++; if (!tab.hasAttribute("zen-empty-tab")) { tab._pPos = pPos++; } } } #updatePaddingTopOnTabs(workspaceElement, essentialContainer, forAnimation = false) { if ( workspaceElement && !(this.#inChangingWorkspace && !forAnimation && !this._alwaysAnimatePaddingTop) ) { delete this._alwaysAnimatePaddingTop; const essentialsHeight = window.windowUtils.getBoundsWithoutFlushing(essentialContainer).height; requestAnimationFrame(() => { workspaceElement.style.paddingTop = essentialsHeight + "px"; }); } } _organizeWorkspaceStripLocations(workspace, justMove = false, offsetPixels = 0) { if (document.documentElement.hasAttribute("zen-creating-workspace")) { // If we are creating a workspace, we don't want to animate the strip return; } this._organizingWorkspaceStrip = true; const workspaces = this.getWorkspaces(); let workspaceIndex = 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[workspaceIndex + (offsetPixels > 0 ? -1 : 1)]?.containerTabId; for (const otherWorkspace of workspaces) { const element = this.workspaceElement(otherWorkspace.uuid); const newTransform = -(workspaceIndex - 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.shouldAnimateEssentials ) { container.setAttribute("hidden", "true"); } else { container.removeAttribute("hidden"); } if ( nextWorkspaceContextId !== workspaceContextId && offsetPixels && this.shouldAnimateEssentials && (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)}%)`; } } } if (offsetPixels) { // Find the next workspace we are scrolling to const nextWorkspace = workspaces[workspaceIndex + (offsetPixels > 0 ? -1 : 1)]; if (nextWorkspace) { const { gradient: nextGradient, grain: nextGrain, toolbarGradient: nextToolbarGradient, } = gZenThemePicker.getGradientForWorkspace(nextWorkspace); const existingGrain = gZenThemePicker.getGradientForWorkspace(workspace).grain; const percentage = Math.abs(offsetPixels) / 200; document.documentElement.style.setProperty("--zen-background-opacity", 1 - percentage); if (!this._hasAnimatedBackgrounds) { this._hasAnimatedBackgrounds = true; document.documentElement.style.setProperty( "--zen-main-browser-background-old", nextGradient ); document.documentElement.style.setProperty( "--zen-main-browser-background-toolbar-old", nextToolbarGradient ); document.documentElement.setAttribute("animating-background", "true"); } // Fit the offsetPixels into the grain limits. Both ends may be nextGrain and existingGrain, // so we need to use the min and max of both. For example, existing may be 0.2 and next may be 0.5, // meaning we should convert the offset to a percentage between 0.2 and 0.5. BUT if existingGrain // is 0.5 and nextGrain is 0.2, we should still convert the offset to a percentage between 0.2 and 0.5. const minGrain = Math.min(existingGrain, nextGrain); const maxGrain = Math.max(existingGrain, nextGrain); const grainValue = minGrain + (maxGrain - minGrain) * (existingGrain > nextGrain ? 1 - percentage : percentage); if (!this.#inChangingWorkspace) { gZenThemePicker.updateNoise(grainValue); } } } else { delete this._hasAnimatedBackgrounds; } 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"); const iconStack = workspaceIndicator.querySelector(".zen-current-workspace-indicator-stack"); if (this.workspaceHasIcon(currentWorkspace)) { indicatorIcon.removeAttribute("no-icon"); iconStack.removeAttribute("no-icon"); } else { indicatorIcon.setAttribute("no-icon", "true"); iconStack.setAttribute("no-icon", "true"); } const icon = this.getWorkspaceIcon(currentWorkspace); indicatorIcon.innerHTML = ""; if (icon?.endsWith(".svg")) { const img = document.createElement("img"); img.src = icon; indicatorIcon.appendChild(img); } else { indicatorIcon.textContent = icon; } indicatorName.textContent = currentWorkspace.name; } _fixIndicatorsNames(workspaces) { for (const workspace of workspaces) { const workspaceIndicator = this.workspaceElement(workspace.uuid)?.indicator; this.updateWorkspaceIndicator(workspace, workspaceIndicator); } } /* eslint-disable complexity */ async _animateTabs( newWorkspace, shouldAnimate, tabToSelect = null, { previousWorkspaceIndex = null, previousWorkspace = null, onInit = false } = {} ) { gZenUIManager.tabsWrapper.style.scrollbarWidth = "none"; const kGlobalAnimationDuration = 0.2; this._animatingChange = true; const animations = []; const workspaces = this.getWorkspaces(); const newWorkspaceIndex = workspaces.findIndex((w) => w.uuid === newWorkspace.uuid); const isGoingLeft = newWorkspaceIndex <= previousWorkspaceIndex; const clonedEssentials = []; if (shouldAnimate && this.shouldAnimateEssentials && previousWorkspace) { for (const workspace of 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, }); } } 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 { previousBackgroundOpacity = 1; } if (previousBackgroundOpacity == 1 || !previousBackgroundOpacity) { previousBackgroundOpacity = 0; } else { previousBackgroundOpacity = 1 - previousBackgroundOpacity; } gZenThemePicker.previousBackgroundOpacity = previousBackgroundOpacity; document.documentElement.style.setProperty( "--zen-background-opacity", previousBackgroundOpacity ); animations.push( gZenUIManager.motion.animate( document.documentElement, { "--zen-background-opacity": [previousBackgroundOpacity, 1], }, { type: "spring", bounce: 0, duration: kGlobalAnimationDuration, } ) ); } 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.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.shouldAnimateEssentials && previousWorkspace) { // Animate essentials const newWorkspaceEssentialsContainer = clonedEssentials.find((cloned) => cloned.workspaces.some((w) => w.uuid === newWorkspace.uuid) ); // Get a list of essentials containers that are in between the first and last workspace const essentialsContainersInBetween = clonedEssentials.filter((cloned) => { const essentialsWorkspaces = cloned.workspaces; const firstIndex = workspaces.findIndex((w) => w.uuid === essentialsWorkspaces[0].uuid); const lastIndex = workspaces.findIndex( (w) => w.uuid === essentialsWorkspaces[essentialsWorkspaces.length - 1].uuid ); const [start, end] = [ Math.min(previousWorkspaceIndex, newWorkspaceIndex), Math.max(previousWorkspaceIndex, newWorkspaceIndex), ]; // Check if any part of the container overlaps with the movement range return firstIndex <= end && lastIndex >= start; }); 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.findIndex( (w) => w.uuid === essentialsWorkspaces[0].uuid ); const lastWorkspaceIndex = 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 ( // We also need to check if the container is even going to appear on the screen. // In order to do this, we need to check if the container is between the first and last workspace. // Note that essential containers can have multiple workspaces, // so we need to check if any of the workspaces in the container are between the // first and last workspace. !essentialsContainersInBetween.find( (ce) => ce.workspaces.some((w) => w.uuid === essentialsWorkspaces[0].uuid) && ce.workspaces.some( (w) => w.uuid === essentialsWorkspaces[essentialsWorkspaces.length - 1].uuid ) ) ) { continue; } cloned.originalContainer.parentNode.appendChild(container); 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 && container.style.transform !== "none") { existingTransform = container.style.transform; } if (shouldAnimate) { container.style.transform = existingTransform; 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(); } let promiseTimeout = new Promise((resolve) => setTimeout(resolve, kGlobalAnimationDuration * 1000 + 50) ); // See issue https://github.com/zen-browser/desktop/issues/9334, we need to add // some sort of timeout to the animation promise, just in case it gets stuck. // We are doing a race between the timeout and the animations finishing. await Promise.race([Promise.all(animations), promiseTimeout]).catch(console.error); document.documentElement.removeAttribute("animating-background"); if (shouldAnimate) { for (const cloned of clonedEssentials) { cloned.container.remove(); } this._alwaysAnimatePaddingTop = true; 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?.pinned && aTab?.hasAttribute("pending")); } async #shouldShowTabInCurrentWorkspace(tab) { const currentWorkspace = this.getActiveWorkspaceFromCache(); return this._shouldShowTab( tab, currentWorkspace.uuid, currentWorkspace.containerTabId, this.getWorkspaces() ); } _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 } // See https://github.com/zen-browser/desktop/issues/10666, we should never // show closing tabs and consider them as not part of any workspace. This will // invalidate the `lastSelectedTab[previousWorkspaceId]` logic in `_handleTabSelection` if (tab.closing) { return false; // Never show closing 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; } // 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.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 = this.getWorkspaces(); // 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 requestAnimationFrame(() => { gZenThemePicker.onWorkspaceChange(workspace); }); gZenUIManager.tabsWrapper.scrollbarWidth = "none"; this.workspaceIcons.activeIndex = workspace.uuid; await this._animateTabs(workspace, !onInit && !this._animatingChange, tabToSelect, { previousWorkspaceIndex, previousWorkspace, onInit, }); this._organizeWorkspaceStripLocations(workspace, true); gZenUIManager.tabsWrapper.style.scrollbarWidth = ""; // Notify listeners if (this._changeListeners?.length) { for (const listener of this._changeListeners) { const { func, opts } = listener; await func({ workspace, onInit }); if (opts.once) { this.removeChangeListeners(func); } } } // 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); } } this.#fireSpaceUIUpdate(); } } #fireSpaceUIUpdate() { window.dispatchEvent( new CustomEvent("ZenWorkspacesUIUpdate", { bubbles: true, detail: { activeIndex: this.activeWorkspace }, }) ); } 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); } } BookmarkingUI.updateEmptyToolbarMessage(); } updateWorkspacesChangeContextMenu() { if (gZenWorkspaces.privateWindowOrDisabled) { return; } const workspaces = this.getWorkspaces(); const ctxCommand = document.getElementById("cmd_zenCtxDeleteWorkspace"); if (workspaces.length <= 1) { ctxCommand.setAttribute("disabled", "true"); } else { ctxCommand.removeAttribute("disabled"); } let menuPopupID = "moveTabOptionsMenu"; let menuPopup = document.getElementById(menuPopupID); let menubar = document.getElementById("zen-spaces-menubar"); if (!menuPopup || !menubar) { return; } let itemsToFill = [menubar.querySelector("menupopup"), menuPopup]; for (const popup of itemsToFill) { let isMoveTabPopup = popup.id === menuPopupID; for (const item of popup.querySelectorAll(".zen-workspace-context-menu-item")) { item.remove(); } const separator = document.createXULElement("menuseparator"); separator.classList.add("zen-workspace-context-menu-item"); if (isMoveTabPopup) { popup.prepend(separator); } else { popup.appendChild(separator); } let i = 0; for (let workspace of isMoveTabPopup ? workspaces.reverse() : workspaces) { const menuItem = this.generateMenuItemForWorkspace( workspace, /* disableCurrent = */ isMoveTabPopup ); if (isMoveTabPopup) { popup.prepend(menuItem); menuItem.setAttribute("command", "cmd_zenChangeWorkspaceTab"); } else { if (i < 10) { menuItem.setAttribute("key", `zen-workspace-switch-${i + 1}`); } menuItem.addEventListener("command", () => { this.changeWorkspace(workspace); }); popup.appendChild(menuItem); } i++; } } } #createWorkspaceData(name, icon, containerTabId = 0) { if (!this.currentWindowIsSyncing) { containerTabId = parseInt(gBrowser.selectedTab.getAttribute("usercontextid")) || 0; let label = ContextualIdentityService.getUserContextLabel(containerTabId) || "Default"; name = this.isPrivateWindow ? "Incognito" : label; if (this.isPrivateWindow) { icon = gZenEmojiPicker.getSVGURL("eye.svg"); } } let workspace = { uuid: gZenUIManager.generateUuidv4(), icon, name, theme: nsZenThemePicker.getTheme([]), containerTabId, }; return workspace; } async createAndSaveWorkspace( name = "Space", icon = undefined, dontChange = false, containerTabId = 0, { beforeChangeCallback } = { beforeChangeCallback: null } // Callback to run before changing workspace ) { if (!this.workspaceEnabled) { return null; } // 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) => gBrowser.isTab(child) && !child.hasAttribute("zen-workspace-id") && !child.hasAttribute("zen-empty-tab") && !child.hasAttribute("zen-essential") ); let workspaceData = this.#createWorkspaceData(name, icon, containerTabId); if (!dontChange) { this.#prepareNewWorkspace(workspaceData); this.#createWorkspaceTabsSection(workspaceData, extraTabs); this._organizeWorkspaceStripLocations(workspaceData); } this.saveWorkspace(workspaceData); if (!dontChange) { if (beforeChangeCallback) { try { await beforeChangeCallback(workspaceData); } catch (e) { console.error("Error in beforeChangeCallback:", e); } } this.registerPinnedResizeObserver(); this.updateTabsContainers({ target: this.workspaceElement(workspaceData.uuid).pinnedTabsContainer, }); let changed = !!extraTabs.length; if (changed) { gBrowser.tabContainer._invalidateCachedTabs(); gBrowser.selectedTab = extraTabs[0]; } await this.changeWorkspace(workspaceData); } this.onWindowResize(); return workspaceData; } updateTabsContainers(target = undefined, forAnimation = false) { this.makeSureEmptyTabIsFirst(); if (target && !target.target?.parentNode) { target = null; } 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 ); } updateShouldHideSeparator(arrowScrollbox, pinnedContainer, fromTabSelection = false) { const visibleTabsFound = () => { let count = 0; for (const child of arrowScrollbox.children) { if ( !child.hasAttribute("hidden") && !child.closing && !child.hasAttribute("zen-empty-tab") ) { count++; if (count > 1) { // Early return return true; } } } return false; }; // <= 2 because we have the empty tab and the new tab button const shouldHideSeparator = fromTabSelection ? pinnedContainer.hasAttribute("hide-separator") : !visibleTabsFound(); if (shouldHideSeparator) { pinnedContainer.setAttribute("hide-separator", "true"); } else { const workspaceID = pinnedContainer.getAttribute("zen-workspace-id"); const tabs = this.#unpinnedTabsInWorkspace(workspaceID); const closableTabs = this.#getClosableTabs(tabs); const button = pinnedContainer.querySelector(".zen-workspace-close-unpinned-tabs-button"); if (tabs.length === closableTabs.length) { button.setAttribute("can-close", "true"); } else { button.removeAttribute("can-close"); } pinnedContainer.removeAttribute("hide-separator"); } } onPinnedTabsResize(entries, forAnimation = false) { if ( document.documentElement.hasAttribute("inDOMFullscreen") || !this._hasInitializedTabsStrip || (this._organizingWorkspaceStrip && !forAnimation) || document.documentElement.hasAttribute("zen-creating-workspace") || 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 || this.activeWorkspace; } const workspacesIds = []; if (entry.target.closest("#zen-essentials")) { // Get all workspaces that have the same userContextId const userContextId = parseInt(entry.target.getAttribute("container") || "0"); const workspaces = this.getWorkspaces().filter((w) => w.containerTabId === userContextId); workspacesIds.push(...workspaces.map((w) => w.uuid)); } else { workspacesIds.push(originalWorkspaceId); } for (const workspaceId of workspacesIds) { const workspaceElement = this.workspaceElement(workspaceId); const workspaceObject = this.getWorkspaceFromId(workspaceId); if (!workspaceElement || !workspaceObject) { console.warn("Workspace element or object not found for id", workspaceId); continue; } const arrowScrollbox = workspaceElement.tabsContainer; const pinnedContainer = workspaceElement.pinnedTabsContainer; 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); 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); setTimeout(() => { this.changeWorkspace(workspace); }, 0); } return; } let activeWorkspace = this.getActiveWorkspace(); if (!activeWorkspace) { return; } if (tab.hasAttribute("zen-workspace-id")) { const tabWorkspaceId = tab.getAttribute("zen-workspace-id"); this.moveTabToWorkspace(tab, tabWorkspaceId); await this.changeWorkspaceWithID(tabWorkspaceId); } else { tab.setAttribute("zen-workspace-id", activeWorkspace.uuid); } } #changeToEmptyTab() { const isEmpty = gBrowser.selectedTab.hasAttribute("zen-empty-tab"); gZenCompactModeManager.sidebar.toggleAttribute("zen-has-empty-tab", isEmpty); document.documentElement.setAttribute("zen-has-empty-tab", isEmpty); } async onLocationChange(event) { let tab = event.target; this.#changeToEmptyTab(); if (!this.workspaceEnabled || this.#inChangingWorkspace || this._isClosingWindow) { return; } 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 = 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); } } } // Context menu management async contextChangeContainerTab(event) { this._organizingWorkspaceStrip = true; let workspaces = this.getWorkspaces(); let workspace = workspaces.find( (w) => w.uuid === (this.#contextMenuData?.workspaceId || this.activeWorkspace) ); let userContextId = parseInt(event.target.getAttribute("data-usercontextid")); workspace.containerTabId = userContextId + 0; // +0 to convert to number this.saveWorkspace(workspace); } async closeAllUnpinnedTabs() { const workspaceId = this.#contextMenuData?.workspaceId || this.activeWorkspace; const unpinnedTabs = this.#unpinnedTabsInWorkspace(workspaceId); const closableTabs = this.#getClosableTabs(unpinnedTabs); if (!closableTabs.length) { return; } this.#deleteAllUnpinnedTabsInWorkspace(closableTabs); const restoreClosedTabsShortcut = gZenKeyboardShortcutsManager.getShortcutDisplayFromCommand( "History:RestoreLastClosedTabOrWindowOrSession" ); gZenUIManager.showToast("zen-workspaces-close-all-unpinned-tabs-toast", { l10nArgs: { shortcut: restoreClosedTabsShortcut, }, }); } async contextDeleteWorkspace() { const workspaceId = this.#contextMenuData?.workspaceId || this.activeWorkspace; const [title, body] = await document.l10n.formatValues([ { id: "zen-workspaces-delete-workspace-title" }, { id: "zen-workspaces-delete-workspace-body", args: { name: this.getWorkspaceFromId(workspaceId).name }, }, ]); if (Services.prompt.confirm(null, title, body)) { this.removeWorkspace(workspaceId); } } findTabToBlur(tab) { if ((!this._shouldChangeToTab(tab) || !tab) && this._emptyTab) { return this._emptyTab; } return tab; } async changeWorkspaceShortcut(offset = 1, whileScrolling = false, disableWrap = false) { // Cycle through workspaces let workspaces = this.getWorkspaces(); let activeWorkspace = this.getActiveWorkspace(); let workspaceIndex = workspaces.indexOf(activeWorkspace); // note: offset can be negative let targetIndex = workspaceIndex + offset; if (this.shouldWrapAroundNavigation && !disableWrap) { // Add length to handle negative indices and loop targetIndex = (targetIndex + workspaces.length) % workspaces.length; } else { // Clamp within bounds to disable looping targetIndex = Math.max(0, Math.min(workspaces.length - 1, targetIndex)); } let nextWorkspace = workspaces[targetIndex]; await this.changeWorkspace(nextWorkspace, { whileScrolling }); return nextWorkspace; } #initializeWorkspaceTabContextMenus() { if (this.privateWindowOrDisabled) { const commandsToDisable = [ "cmd_zenOpenFolderCreation", "cmd_zenOpenWorkspaceCreation", "zen-context-menu-new-folder-toolbar", ]; commandsToDisable.forEach((cmd) => { const element = document.getElementById(cmd); if (element) { element.setAttribute("disabled", true); } }); } } 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 = this.getWorkspaces(); await this.changeWorkspace(workspaces.find((workspace) => workspace.uuid === workspaceID)); } // Tab browser utilities getContextIdIfNeeded(userContextId, fromExternal) { if (!this.workspaceEnabled) { return [userContextId, false, undefined]; } if ( this.shouldForceContainerTabsToWorkspace && typeof userContextId !== "undefined" && this._workspaceCache && !fromExternal ) { // Find all workspaces that match the given userContextId const matchingWorkspaces = this._workspaceCache.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"); const containerId = aTab.getAttribute("usercontextid") ?? "0"; // Return all tabs that are not on the same workspace return gBrowser.tabs.filter( (tab) => !this._shouldShowTab(tab, tabWorkspaceId, containerId, this._workspaceCache) && !tab.hasAttribute("zen-empty-tab") ); } async shortcutSwitchTo(index) { const workspaces = this.getWorkspaces(); // The index may be out of bounds, if it doesnt exist, don't do anything if (index >= workspaces.length || index < 0) { return; } const workspaceToSwitch = 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("pinned-tabs-container")]; normalContainers = [this.activeWorkspaceStrip]; } else { let workspaces = Array.from(this._workspaceCache || []); // Make the active workspace first workspaces = workspaces.sort((a, b) => /* eslint-disable no-nested-ternary */ a.uuid === this.activeWorkspace ? -1 : b.uuid === this.activeWorkspace ? 1 : 0 ); for (const workspace of 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 (gBrowser.isTab(tab)) { tabs.push(tab); const glance = tab.querySelector(".tabbrowser-tab[glance-id]"); if (glance) { tabs.push(glance); } } else if (gBrowser.isTabGroup(tab)) { for (const groupTab of tab.tabs) { tabs.push(groupTab); const glance = groupTab.querySelector(".tabbrowser-tab[glance-id]"); if (glance) { tabs.push(glance); } } } } } return (this._allStoredTabs = tabs); } get allTabGroups() { if (!this._hasInitializedTabsStrip) { let children = this.tabboxChildren; return children.filter((node) => gBrowser.isTabGroup(node)); } const pinnedContainers = []; const normalContainers = []; for (const workspace of this._workspaceCache) { 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); } for (const tabGroup of container.querySelectorAll("zen-folder")) { tabGroups.push(tabGroup); } } return tabGroups; } get allUsedBrowsers() { if (!this._hasInitializedTabsStrip) { return gBrowser.browsers; } const browsers = []; for (const tab of this.allStoredTabs) { const browser = tab.linkedBrowser; if (browser) { browsers.push(browser); } } return browsers; } get pinnedTabCount() { return this.pinnedTabsContainer.children.length - 1; } reorganizeTabsAfterWelcome() { const children = gBrowser.tabContainer.arrowScrollbox.children; const remainingTabs = Array.from(children).filter((child) => gBrowser.isTab(child)); for (const tab of remainingTabs) { this.moveTabToWorkspace(tab, this.activeWorkspace); } } async switchIfNeeded(browser) { 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.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.find((w) => w.uuid === activeWorkspace); return workspace.containerTabId; } onWindowResize(event = undefined) { if (!(!event || event.target === window)) { return; } gZenUIManager.updateTabsToolbar(); // Check if workspace icons overflow the parent container let parent = this.workspaceIcons; if (!parent || this._processingResize) { return; } if (!gZenPinnedTabManager.expandedSidebarMode) { for (const icon of parent.children) { if (icon.tagName === "toolbarbutton") { icon.style.width = ""; // Reset to default size when in expanded mode } } parent.removeAttribute("icons-overflow"); return; } const maxButtonSize = 32; // IMPORTANT: This should match the CSS size of the icons const minButtonSize = maxButtonSize / 2; // Minimum size for icons when space is limited const separation = 3; // Space between icons // Calculate the total width needed for all icons const totalWidth = Array.from(parent.children).reduce((width, icon) => { if (icon.tagName === "toolbarbutton") { return width + minButtonSize + separation; } return width; }, 0); // Check if the total width exceeds the parent's width if (totalWidth > parent.clientWidth) { parent.setAttribute("icons-overflow", "true"); } else { parent.removeAttribute("icons-overflow"); } // Set the width of each icon to the maximum size they can fit on const widthPerButton = Math.max( (parent.clientWidth - separation * (parent.children.length - 1)) / parent.children.length, minButtonSize ); for (const icon of parent.children) { if (icon.tagName === "toolbarbutton") { icon.style.width = `${Math.min(widthPerButton, maxButtonSize)}px`; } } } fixTabInsertLocation(tab) { if (tab.hasAttribute("zen-essential")) { // Essential tabs should always be inserted at the end of the essentials section const essentialsSection = this.getEssentialsSection(tab); if (essentialsSection) { essentialsSection.appendChild(tab); } } else if (tab.pinned) { // Pinned tabs should always be inserted at the end of the pinned tabs container const pinnedContainer = this.pinnedTabsContainer; if (pinnedContainer) { pinnedContainer.insertBefore(tab, pinnedContainer.lastChild); } } } updateOverflowingTabs() { if (!this._hasInitializedTabsStrip) { return; } const currentWorkspaceStrip = this.workspaceElement(this.activeWorkspace); if (!currentWorkspaceStrip) { return; } if (currentWorkspaceStrip.overflows) { gBrowser.tabContainer.setAttribute("overflow", "true"); } else { gBrowser.tabContainer.removeAttribute("overflow"); } } handleTabCloseWindow() { if (Services.prefs.getBoolPref("zen.tabs.close-window-with-empty")) { document.getElementById("cmd_closeWindow").doCommand(); } } } window.gZenWorkspaces = new nsZenWorkspaces();