// 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(`