// 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: "moz-src:///browser/components/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(); gZenWorkspaces.promiseInitialized.then(() => { gBrowser.addTabsProgressListener(this); if (lazy.zenPinnedTabRestorePinnedTabsToPinnedUrl) { 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(); if (event.getModifierState("Accel")) { let newTab = gBrowser.duplicateTab(tab, true); newTab.addEventListener( "SSTabRestored", () => { this.#resetTabToStoredState(tab); }, { once: true } ); } else { this.#resetTabToStoredState(tab); } gBrowser.selectedTab = 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" ), }); } } _onAccelKeyChange(e) { let tab = this._tabWithResetPinButtonHovered; if (!tab) { return; } let accelHeld = e.getModifierState("Accel") || (e.metaKey && e.type == "keydown"); this._setResetPinSublabel(tab, accelHeld); // Up <-> down events until the mouse leaves the button. // When hovered with accelHeld, we should listen to the next keyup event let nextEvent = accelHeld ? "keyup" : "keydown"; let handler = nextE => this._onAccelKeyChange(nextE); window.addEventListener(nextEvent, handler, { once: true }); } _setResetPinSublabel(tab, accelHeld) { let label = tab.querySelector(".zen-tab-sublabel"); const getLabel = b => (b ? "zen-default-pinned-cmd" : "zen-default-pinned"); // We might not want to change the sublabel if it was already customized by, // for example, live folders, so only change it if it's currently the default one. if ( document.l10n.getAttributes(label).args.tabSubtitle != getLabel(!accelHeld) ) { return; } document.l10n.setArgs(label, { tabSubtitle: getLabel(accelHeld), }); } onResetPinButtonMouseOver(tab, event) { this._tabWithResetPinButtonHovered = tab; this._onAccelKeyChange(event); } onResetPinButtonMouseOut(tab) { this._setResetPinSublabel(tab, false); delete this._tabWithResetPinButtonHovered; } 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"); } async editPinnedUrl(tab = undefined) { tab ??= TabContextMenu.contextTab; if (!tab || !tab.pinned) { return; } const initialUrl = tab._zenPinnedInitialState?.entry?.url || tab.linkedBrowser?.currentURI?.spec; const [title, label] = await document.l10n.formatValues([ { id: "zen-pinned-tab-edit-url-title" }, { id: "zen-pinned-tab-edit-url-label" }, ]); const result = { value: initialUrl ?? "" }; const confirmed = Services.prompt.prompt( window, title, label, result, null, { value: false } ); if (!confirmed) { return; } let uri; try { uri = Services.uriFixup.getFixupURIInfo( result.value.trim(), Ci.nsIURIFixup.FIXUP_FLAG_FIX_SCHEME_TYPOS ).preferredURI; } catch (_) {} if (!uri) { gZenUIManager.showToast("zen-pinned-tab-url-invalid"); return; } const url = uri.spec; // Skip when the value wasn't actually changed from what was prefilled. if (!url || url === initialUrl) { return; } const image = tab.zenStaticIcon || (await this.#getCachedFavicon(uri)); window.gZenWindowSync.setPinnedUrl(tab, url, image); this.#resetTabToStoredState(tab); gZenUIManager.showToast("zen-pinned-tab-url-edited"); } async #getCachedFavicon(uri) { try { const favicon = await PlacesUtils.favicons.getFaviconForPage(uri); return favicon?.dataURI?.spec; } catch (ex) { console.error("Failed to get favicon for edited pinned url:", ex); return null; } } _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; } event.stopPropagation(); event.preventDefault(); if (noClose && behavior === "close") { behavior = "unload-switch"; } if ( alwaysUnload && ["close", "reset", "switch", "reset-switch"].includes(behavior) ) { behavior = behavior.includes("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")) { if (pinnedTabs.some(tab => tab.selected)) { const selectedTabs = pinnedTabs.filter(tab => tab.selected); const tabToBlurTo = gBrowser._findTabToBlurTo( selectedTabs[0], pinnedTabs ); gBrowser.selectedTab = tabToBlurTo; } 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; } } let successful = await gBrowser.explicitUnloadTabs(pinnedTabs); if (!successful) { return; } for (const tab of pinnedTabs) { tab.removeAttribute("discarded"); } } else if (pinnedTabs.some(tab => tab.selected)) { const selectedTabs = pinnedTabs.filter(tab => tab.selected); gBrowser.selectedTab = gBrowser._findTabToBlurTo( selectedTabs[0], selectedTabs ); } 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); } } #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, triggeringPrincipal_base64: E10SUtils.serializePrincipal( Services.scriptSecurityManager.createContentPrincipal( Services.io.newURI(initialState.entry.url), {} ) ), }, ]; state.image = tab.zenStaticIcon || initialState.image; state.index = 0; // See gh-13024, we need to remove the scroll position from the state, // otherwise when we reset the pinned tab, it will scroll to the previous position // which can be confusing for the user, especially if they have a long page. delete state.scroll; 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, { replicating = false } = {}) { // 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); // canEssentialBeAdded gates user-initiated adds on the *active* space's // container. A replicated add mirrors a decision already made in another // window or on another device, always into the tab's own container // section. if (!replicating && !this.canEssentialBeAdded(tab)) { this.log(`addToEssentials rejected for ${tab.id}`); 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.documentGlobal !== 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]; if (this._canLog) { // eslint-disable-next-line no-console console.trace( `ZenPinnedTabManager: removing tab ${tab.id} from essentials ` + `(unpin=${unpin})` ); } 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(`