Files
desktop/src/zen/workspaces/ZenWorkspaces.mjs
2026-02-25 00:34:22 +01:00

3149 lines
105 KiB
JavaScript

/* 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();