// 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/. import { nsZenDOMOperatedFeature } from "chrome://browser/content/zen-components/ZenCommonUtils.mjs"; const lazy = {}; class ZenPinnedTabsObserver { static ALL_EVENTS = ["TabPinned", "TabUnpinned"]; #listeners = []; constructor() { // eslint-disable-next-line mozilla/valid-lazy XPCOMUtils.defineLazyPreferenceGetter( lazy, "zenPinnedTabRestorePinnedTabsToPinnedUrl", "zen.pinned-tab-manager.restore-pinned-tabs-to-pinned-url", false ); XPCOMUtils.defineLazyPreferenceGetter( lazy, "zenPinnedTabCloseShortcutBehavior", "zen.pinned-tab-manager.close-shortcut-behavior", "switch" ); XPCOMUtils.defineLazyPreferenceGetter( lazy, "zenTabsEssentialsMax", "zen.tabs.essentials.max", 12 ); ChromeUtils.defineESModuleGetters(lazy, { // eslint-disable-next-line mozilla/valid-lazy E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs", TabStateCache: "resource:///modules/sessionstore/TabStateCache.sys.mjs", }); this.#listenPinnedTabEvents(); } #listenPinnedTabEvents() { const eventListener = this.#eventListener.bind(this); for (const event of ZenPinnedTabsObserver.ALL_EVENTS) { window.addEventListener(event, eventListener); } window.addEventListener("unload", () => { for (const event of ZenPinnedTabsObserver.ALL_EVENTS) { window.removeEventListener(event, eventListener); } }); } #eventListener(event) { for (const listener of this.#listeners) { listener(event.type, event); } } addPinnedTabListener(listener) { this.#listeners.push(listener); } } class nsZenPinnedTabManager extends nsZenDOMOperatedFeature { init() { if (!this.enabled) { return; } this._canLog = Services.prefs.getBoolPref("zen.pinned-tab-manager.debug", false); this.observer = new ZenPinnedTabsObserver(); this._initClosePinnedTabShortcut(); this._insertItemsIntoTabContextMenu(); this.observer.addPinnedTabListener(this._onPinnedTabEvent.bind(this)); this._zenClickEventListener = this._onTabClick.bind(this); gZenWorkspaces._resolvePinnedInitialized(); if (lazy.zenPinnedTabRestorePinnedTabsToPinnedUrl) { gZenWorkspaces.promiseInitialized.then(() => { for (const tab of gZenWorkspaces.allStoredTabs) { try { this.resetPinnedTab(tab); } catch (ex) { console.error("Error restoring pinned tab:", ex); } } }); } } log(message) { if (this._canLog) { /* eslint-disable-next-line no-console */ console.log(`[ZenPinnedTabManager] ${message}`); } } onTabIconChanged(tab, url = null) { tab.dispatchEvent(new CustomEvent("ZenTabIconChanged", { bubbles: true, detail: { tab } })); if (tab.hasAttribute("zen-essential")) { this.setEssentialTabIcon(tab, url); } } setEssentialTabIcon(tab, url = null) { const iconUrl = url ?? tab.getAttribute("image") ?? ""; tab.style.setProperty("--zen-essential-tab-icon", `url(${iconUrl})`); } _onTabResetPinButton(event, tab) { event.stopPropagation(); this._resetTabToStoredState(tab); } get enabled() { return !gZenWorkspaces.privateWindowOrDisabled; } get maxEssentialTabs() { return lazy.zenTabsEssentialsMax; } _onPinnedTabEvent(action, event) { if (!this.enabled) { return; } const tab = event.target; if (this._ignoreNextTabPinnedEvent) { delete this._ignoreNextTabPinnedEvent; return; } switch (action) { case "TabPinned": tab._zenClickEventListener = this._zenClickEventListener; tab.addEventListener("click", tab._zenClickEventListener); break; // [Fall through] case "TabUnpinned": if (tab._zenClickEventListener) { tab.removeEventListener("click", tab._zenClickEventListener); delete tab._zenClickEventListener; } this.resetPinChangedUrl(tab); break; default: console.warn("ZenPinnedTabManager: Unhandled tab event", action); break; } } #getTabState(tab) { return JSON.parse(SessionStore.getTabState(tab)); } async _onTabClick(e) { const tab = e.target?.closest("tab"); if (e.button === 1 && tab) { await this.onCloseTabShortcut(e, tab, { closeIfPending: Services.prefs.getBoolPref("zen.pinned-tab-manager.wheel-close-if-pending"), }); } } resetPinnedTab(tab) { if (!tab) { tab = TabContextMenu.contextTab; } if (!tab || !tab.pinned) { return; } this._resetTabToStoredState(tab); } replacePinnedUrlWithCurrent(tab = undefined) { tab ??= TabContextMenu.contextTab; if (!tab || !tab.pinned) { return; } window.gZenWindowSync.setPinnedTabState(tab); this.resetPinChangedUrl(tab); gZenUIManager.showToast("zen-pinned-tab-replaced"); } _initClosePinnedTabShortcut() { let cmdClose = document.getElementById("cmd_close"); if (cmdClose) { cmdClose.addEventListener("command", this.onCloseTabShortcut.bind(this)); } } // eslint-disable-next-line complexity async onCloseTabShortcut( event, selectedTab = gBrowser.selectedTab, { behavior = lazy.zenPinnedTabCloseShortcutBehavior, noClose = false, closeIfPending = false, alwaysUnload = false, folderToUnload = null, } = {} ) { try { const tabs = Array.isArray(selectedTab) ? selectedTab : [selectedTab]; const pinnedTabs = [ ...new Set( tabs .flatMap((tab) => { if (tab.group?.hasAttribute("split-view-group")) { return tab.group.tabs; } return tab; }) .filter((tab) => tab?.pinned) ), ]; if (!pinnedTabs.length) { return; } const selectedTabs = pinnedTabs.filter((tab) => tab.selected); event.stopPropagation(); event.preventDefault(); if (noClose && behavior === "close") { behavior = "unload-switch"; } if (alwaysUnload && ["close", "reset", "switch", "reset-switch"].includes(behavior)) { behavior = behavior.contains("reset") ? "reset-unload-switch" : "unload-switch"; } switch (behavior) { case "close": { for (const tab of pinnedTabs) { gBrowser.removeTab(tab, { animate: true }); } break; } case "reset-unload-switch": case "unload-switch": case "reset-switch": case "switch": if (behavior.includes("unload")) { for (const tab of pinnedTabs) { if (tab.hasAttribute("glance-id")) { // We have a glance tab inside the tab we are trying to unload, // before we used to just ignore it but now we need to fully close // it as well. gZenGlanceManager.manageTabClose(tab.glanceTab); await new Promise((resolve) => { let hasRan = false; const onGlanceClose = () => { hasRan = true; resolve(); }; window.addEventListener("GlanceClose", onGlanceClose, { once: true }); // Set a timeout to resolve the promise if the event doesn't fire. // We do this to prevent any future issues where glance woudnt close such as // glance requering to ask for permit unload. setTimeout(() => { if (!hasRan) { console.warn("GlanceClose event did not fire within 3 seconds"); resolve(); } }, 3000); }); return; } const isSpltView = tab.group?.hasAttribute("split-view-group"); const group = isSpltView ? tab.group.group : tab.group; if (!folderToUnload && tab.hasAttribute("folder-active")) { await gZenFolders.animateUnload(group, tab); } } if (folderToUnload) { await gZenFolders.animateUnloadAll(folderToUnload); } const allAreUnloaded = pinnedTabs.every( (tab) => tab.hasAttribute("pending") && !tab.hasAttribute("zen-essential") ); for (const tabItem of pinnedTabs) { if (allAreUnloaded && closeIfPending) { await this.onCloseTabShortcut(event, tabItem, { behavior: "close" }); return; } } await gBrowser.explicitUnloadTabs(pinnedTabs); for (const tab of pinnedTabs) { tab.removeAttribute("discarded"); } } if (selectedTabs.length) { this._handleTabSwitch(selectedTabs[0]); } if (behavior.includes("reset")) { for (const tab of pinnedTabs) { this._resetTabToStoredState(tab); } } break; case "reset": for (const tab of pinnedTabs) { this._resetTabToStoredState(tab); } break; default: } } catch (ex) { console.error("Error handling close tab shortcut for pinned tab:", ex); } } _handleTabSwitch(selectedTab) { if (selectedTab !== gBrowser.selectedTab) { return; } const findNextTab = (direction) => gBrowser.tabContainer.findNextTab(selectedTab, { direction, filter: (tab) => !tab.hidden && !tab.pinned, }); let nextTab = findNextTab(1) || findNextTab(-1); if (!nextTab) { gZenWorkspaces.selectEmptyTab(); return; } if (nextTab) { gBrowser.selectedTab = nextTab; } } _resetTabToStoredState(tab) { const state = this.#getTabState(tab); const initialState = tab._zenPinnedInitialState; if (!initialState?.entry) { return; } // Remove everything except the entry we want to keep state.entries = [initialState.entry]; state.image = tab.zenStaticIcon || initialState.image; state.index = 0; SessionStore.setTabState(tab, state); this.resetPinChangedUrl(tab); } async getFaviconAsBase64(pageUrl) { try { const faviconData = await PlacesUtils.favicons.getFaviconForPage(pageUrl); if (!faviconData) { // empty favicon return null; } return faviconData.dataURI; } catch (ex) { console.error("Failed to get favicon:", ex); return null; } } addToEssentials(tab) { // eslint-disable-next-line no-nested-ternary const tabs = tab ? // if it's already an array, dont make it [tab] tab?.length ? tab : [tab] : TabContextMenu.contextTab.multiselected ? gBrowser.selectedTabs : [TabContextMenu.contextTab]; let movedAll = true; for (let i = 0; i < tabs.length; i++) { // eslint-disable-next-line no-shadow let tab = tabs[i]; const section = gZenWorkspaces.getEssentialsSection(tab); if (!this.canEssentialBeAdded(tab)) { movedAll = false; continue; } if (tab.hasAttribute("zen-essential")) { continue; } tab.setAttribute("zen-essential", "true"); if (tab.hasAttribute("zen-workspace-id")) { tab.removeAttribute("zen-workspace-id"); } if (tab.pinned) { gBrowser.zenHandleTabMove(tab, () => { if (tab.ownerGlobal !== window) { tab = gBrowser.adoptTab(tab, { selectTab: tab.selected, }); tab.setAttribute("zen-essential", "true"); } section.appendChild(tab); }); } else { gBrowser.pinTab(tab); this._ignoreNextTabPinnedEvent = true; } tab.setAttribute("zenDefaultUserContextId", true); if (tab.selected) { gZenWorkspaces.switchTabIfNeeded(tab); } this.onTabIconChanged(tab); // Dispatch the event to update the UI const event = new CustomEvent("TabAddedToEssentials", { detail: { tab }, bubbles: true, cancelable: false, }); tab.dispatchEvent(event); } gZenUIManager.updateTabsToolbar(); return movedAll; } removeEssentials(tab, unpin = true) { // eslint-disable-next-line no-nested-ternary const tabs = tab ? [tab] : TabContextMenu.contextTab.multiselected ? gBrowser.selectedTabs : [TabContextMenu.contextTab]; for (let i = 0; i < tabs.length; i++) { // eslint-disable-next-line no-shadow const tab = tabs[i]; tab.removeAttribute("zen-essential"); if (gZenWorkspaces.workspaceEnabled && gZenWorkspaces.getActiveWorkspaceFromCache().uuid) { tab.setAttribute("zen-workspace-id", gZenWorkspaces.getActiveWorkspaceFromCache().uuid); } if (unpin) { gBrowser.unpinTab(tab); } else { gBrowser.zenHandleTabMove(tab, () => { const pinContainer = gZenWorkspaces.pinnedTabsContainer; pinContainer.prepend(tab); }); } // Dispatch the event to update the UI const event = new CustomEvent("TabRemovedFromEssentials", { detail: { tab }, bubbles: true, cancelable: false, }); tab.dispatchEvent(event); } gZenUIManager.updateTabsToolbar(); } _insertItemsIntoTabContextMenu() { if (!this.enabled) { return; } const elements = window.MozXULElement.parseXULToFragment(`