mirror of
https://github.com/zen-browser/desktop.git
synced 2026-07-01 14:56:37 +00:00
1741 lines
55 KiB
JavaScript
1741 lines
55 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 consistent-return */
|
|
|
|
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
|
|
|
|
const lazy = {};
|
|
|
|
ChromeUtils.defineESModuleGetters(lazy, {
|
|
BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs",
|
|
TabStateFlusher: "resource:///modules/sessionstore/TabStateFlusher.sys.mjs",
|
|
// eslint-disable-next-line mozilla/valid-lazy
|
|
ZenSessionStore: "resource:///modules/zen/ZenSessionManager.sys.mjs",
|
|
TabStateCache: "resource:///modules/sessionstore/TabStateCache.sys.mjs",
|
|
setTimeout: "resource://gre/modules/Timer.sys.mjs",
|
|
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
|
|
RunState: "resource:///modules/sessionstore/RunState.sys.mjs",
|
|
});
|
|
|
|
XPCOMUtils.defineLazyPreferenceGetter(
|
|
lazy,
|
|
"gWindowSyncEnabled",
|
|
"zen.window-sync.enabled",
|
|
true
|
|
);
|
|
XPCOMUtils.defineLazyPreferenceGetter(
|
|
lazy,
|
|
"gSyncOnlyPinnedTabs",
|
|
"zen.window-sync.sync-only-pinned-tabs",
|
|
true
|
|
);
|
|
XPCOMUtils.defineLazyPreferenceGetter(
|
|
lazy,
|
|
"gShouldLog",
|
|
"zen.window-sync.log",
|
|
true
|
|
);
|
|
|
|
const OBSERVING = [
|
|
"browser-window-before-show",
|
|
"sessionstore-windows-restored",
|
|
];
|
|
const INSTANT_EVENTS = ["SSWindowClosing", "TabSelect", "focus"];
|
|
const UNSYNCED_WINDOW_EVENTS = ["TabOpen"];
|
|
const EVENTS = [
|
|
"TabClose",
|
|
|
|
"ZenTabIconChanged",
|
|
"ZenTabLabelChanged",
|
|
|
|
"TabMove",
|
|
"TabPinned",
|
|
"TabUnpinned",
|
|
"TabAddedToEssentials",
|
|
"TabRemovedFromEssentials",
|
|
|
|
"TabUngrouped",
|
|
"TabGroupUpdate",
|
|
"TabGroupCreate",
|
|
"TabGroupRemoved",
|
|
"TabGroupMoved",
|
|
|
|
"TabHide",
|
|
"TabShow",
|
|
|
|
"ZenTabRemovedFromSplit",
|
|
"ZenSplitViewTabsSplit",
|
|
|
|
...INSTANT_EVENTS,
|
|
...UNSYNCED_WINDOW_EVENTS,
|
|
];
|
|
|
|
// Flags acting as an enum for sync types.
|
|
const SYNC_FLAG_LABEL = 1 << 0;
|
|
const SYNC_FLAG_ICON = 1 << 1;
|
|
const SYNC_FLAG_MOVE = 1 << 2;
|
|
|
|
class nsZenWindowSync {
|
|
#initialized = false;
|
|
constructor() {}
|
|
|
|
/**
|
|
* Context about the currently handled event.
|
|
* Used to avoid re-entrancy issues.
|
|
*
|
|
* We do still want to keep a stack of these in order
|
|
* to handle consecutive events properly. For example,
|
|
* loading a webpage will call IconChanged and TitleChanged
|
|
* events one after another.
|
|
*/
|
|
#eventHandlingContext = {
|
|
window: null,
|
|
eventCount: 0,
|
|
lastHandlerPromise: Promise.resolve(),
|
|
};
|
|
|
|
/**
|
|
* Promise|null that resolves when the current docshell swap operation is finished.
|
|
* Used to avoid multiple simultaneous swap operations that could interfere with each other.
|
|
* For example, when focusing a window AND selecting a tab at the same time.
|
|
*/
|
|
#docShellSwitchPromise = null;
|
|
|
|
/**
|
|
* Map of sync handlers for different event types.
|
|
* Each handler is a function that takes the event as an argument.
|
|
*/
|
|
#syncHandlers = new Set();
|
|
|
|
/**
|
|
* Last focused window.
|
|
* Used to determine which window to sync tab contents visibility from.
|
|
*/
|
|
#lastFocusedWindow = null;
|
|
|
|
/**
|
|
* Last selected tab.
|
|
* Used to determine if we should run another sync operation
|
|
* when switching browser views.
|
|
*/
|
|
#lastSelectedTab = null;
|
|
|
|
/**
|
|
* A list containing all swaped tabs with their respective browser permanent
|
|
* keys. This is used in between SSWindowClosing and WindowCloseAndBrowserFlushed.
|
|
*
|
|
* When we close windows, there's a small chance that browsers havent't been flushed
|
|
* yet when we try to move active tabs to other windows. This map allows us to
|
|
* retrieve the correct tab entries from the cache in order to avoid losing
|
|
* tab history.
|
|
*
|
|
* @type {WeakMap<object, MozTabbrowserTab>}
|
|
*/
|
|
#swapedTabsEntriesForWC = new WeakMap();
|
|
|
|
/**
|
|
* Iterator that yields all currently opened browser windows.
|
|
* (Might miss the most recent one.)
|
|
* This list is in focus order, but may include minimized windows
|
|
* before non-minimized windows.
|
|
*/
|
|
#browserWindows = {
|
|
*[Symbol.iterator]() {
|
|
for (let window of lazy.BrowserWindowTracker.orderedWindows) {
|
|
if (
|
|
window.__SSi &&
|
|
!window.closed &&
|
|
!window.gZenWorkspaces?.privateWindowOrDisabled
|
|
) {
|
|
yield window;
|
|
}
|
|
}
|
|
},
|
|
};
|
|
|
|
/**
|
|
* @returns {Array<Window>} A list of all currently opened browser windows.
|
|
*/
|
|
get #browserWindowsList() {
|
|
return Array.from(this.#browserWindows);
|
|
}
|
|
|
|
/**
|
|
* @returns {Window|null} The first opened browser window, or null if none exist.
|
|
*/
|
|
get firstSyncedWindow() {
|
|
for (let window of this.#browserWindows) {
|
|
return window;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
init() {
|
|
if (this.#initialized) {
|
|
return;
|
|
}
|
|
this.#initialized = true;
|
|
for (let topic of OBSERVING) {
|
|
Services.obs.addObserver(this, topic);
|
|
}
|
|
}
|
|
|
|
uninit() {
|
|
if (!this.#initialized) {
|
|
return;
|
|
}
|
|
this.#initialized = false;
|
|
for (let topic of OBSERVING) {
|
|
Services.obs.removeObserver(this, topic);
|
|
}
|
|
}
|
|
|
|
log(...args) {
|
|
if (lazy.gShouldLog) {
|
|
// eslint-disable-next-line no-console
|
|
console.debug("ZenWindowSync:", ...args);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Called when a browser window is about to be shown.
|
|
* Adds event listeners for the specified events.
|
|
*
|
|
* @param {Window} aWindow - The browser window that is about to be shown.
|
|
*/
|
|
#onWindowBeforeShow(aWindow) {
|
|
if (
|
|
aWindow.gZenWindowSync ||
|
|
aWindow.document.documentElement.hasAttribute("zen-unsynced-window")
|
|
) {
|
|
return;
|
|
}
|
|
this.log("Setting up window sync for window", aWindow);
|
|
// There are 2 possibilities to know if we are trying to open
|
|
// a new *unsynced* window:
|
|
// 1. We are passing `zen-unsynced` in the window arguments.
|
|
// 2. We are trying to open a link in a new window where other synced
|
|
// windows already exist
|
|
// Note, we force syncing if the window is private or workspaces is disabled
|
|
// to avoid confusing the old private window behavior.
|
|
let forcedSync = !aWindow.gZenWorkspaces?.privateWindowOrDisabled;
|
|
let hasUnsyncedArg = false;
|
|
// See issue https://github.com/zen-browser/desktop/issues/12211
|
|
if (lazy.PrivateBrowsingUtils.isWindowPrivate(aWindow)) {
|
|
aWindow._zenStartupSyncFlag = "synced";
|
|
}
|
|
if (aWindow._zenStartupSyncFlag === "synced") {
|
|
forcedSync = true;
|
|
} else if (aWindow._zenStartupSyncFlag === "unsynced") {
|
|
hasUnsyncedArg = true;
|
|
}
|
|
delete aWindow._zenStartupSyncFlag;
|
|
if (
|
|
!forcedSync &&
|
|
(hasUnsyncedArg ||
|
|
!aWindow.gZenWorkspaces.shouldHaveWorkspaces ||
|
|
(typeof aWindow.arguments?.[0] === "string" &&
|
|
aWindow.arguments.length > 1 &&
|
|
!!this.#browserWindowsList.length))
|
|
) {
|
|
this.log(
|
|
"Not syncing new window due to unsynced argument or existing synced windows"
|
|
);
|
|
aWindow.document.documentElement.setAttribute(
|
|
"zen-unsynced-window",
|
|
"true"
|
|
);
|
|
for (let eventName of UNSYNCED_WINDOW_EVENTS) {
|
|
aWindow.addEventListener(eventName, this, true);
|
|
}
|
|
return;
|
|
}
|
|
aWindow.gZenWindowSync = this;
|
|
for (let eventName of EVENTS) {
|
|
aWindow.addEventListener(eventName, this, true);
|
|
}
|
|
this.#maybeTriggerInitialTabSync(aWindow);
|
|
}
|
|
|
|
/**
|
|
* Determines if the initial tab should be synced for the given window
|
|
* and triggers the sync if necessary. See gh-12258 for more details.
|
|
*
|
|
* @param {Window} aWindow - The browser window to check and potentially sync.
|
|
*/
|
|
#maybeTriggerInitialTabSync(aWindow) {
|
|
let initialTab = aWindow.gBrowser?.selectedTab;
|
|
aWindow.gZenStartup.promiseInitialized.then(() => {
|
|
if (initialTab && !initialTab.closing) {
|
|
// If the initial tab is still open after startup, we trigger a fake TabSelect event
|
|
// to ensure the tab gets synced properly. This is needed in cases where the window
|
|
// is opened with a URL and the TabSelect event happens before the window sync is fully initialized.
|
|
this.log("Triggering initial tab sync for window", initialTab);
|
|
this.on_TabOpen({ target: initialTab }, { ignoreExistingId: true });
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Called when the session store has finished initializing for a window.
|
|
*/
|
|
async #onSessionStoreInitialized() {
|
|
// For every tab we have in where there's no sync ID, we need to
|
|
// assign one and sync it to other windows.
|
|
// This should only happen really when updating from an older version
|
|
// that didn't have this feature.
|
|
await this.#runOnAllWindowsAsync(null, async aWindow => {
|
|
const { gZenWorkspaces } = aWindow;
|
|
this.#onWindowBeforeShow(aWindow);
|
|
await gZenWorkspaces.promiseInitialized;
|
|
for (let tab of gZenWorkspaces.allStoredTabs) {
|
|
if (!tab.id) {
|
|
tab.id = this.#newTabSyncId;
|
|
}
|
|
if (tab.pinned && !tab._zenPinnedInitialState) {
|
|
await this.setPinnedTabState(tab);
|
|
}
|
|
// Lets clear extra values to save some memory, we only really
|
|
// care about the URL and title for the initial state, and we want
|
|
// to avoid keeping the whole session history around.
|
|
if (tab._zenPinnedInitialState) {
|
|
tab._zenPinnedInitialState = {
|
|
...tab._zenPinnedInitialState,
|
|
entry: {
|
|
url: tab._zenPinnedInitialState.entry.url,
|
|
title: tab._zenPinnedInitialState.entry.title,
|
|
},
|
|
};
|
|
}
|
|
if (
|
|
!lazy.gWindowSyncEnabled ||
|
|
(lazy.gSyncOnlyPinnedTabs && !tab.pinned)
|
|
) {
|
|
tab._zenContentsVisible = true;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @returns {string} A unique tab ID.
|
|
*/
|
|
get #newTabSyncId() {
|
|
// Note: If this changes, make sure to also update the
|
|
// getExtTabGroupIdForInternalTabGroupId implementation in
|
|
// browser/components/extensions/parent/ext-browser.js.
|
|
// See: Bug 1960104 - Improve tab group ID generation in addTabGroup
|
|
// This is implemented from gBrowser.addTabGroup.
|
|
return `${Date.now()}-${Math.round(Math.random() * 100)}`;
|
|
}
|
|
|
|
/**
|
|
* Runs a callback function on all browser windows except the specified one.
|
|
*
|
|
* @param {Window} aWindow - The browser window to exclude.
|
|
* @param {Function} aCallback - The callback function to run on each window.
|
|
* @returns {any} The value returned by the callback function, if any.
|
|
*/
|
|
#runOnAllWindows(aWindow, aCallback) {
|
|
for (let window of this.#browserWindows) {
|
|
if (window !== aWindow && !window._zenClosingWindow) {
|
|
let value = aCallback(window);
|
|
if (value) {
|
|
return value;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Runs a callback function on all browser windows except the specified one.
|
|
* This version supports asynchronous callbacks.
|
|
*
|
|
* @see #runOnAllWindows - Make sure functionality is the same.
|
|
* @param {Window} aWindow - The browser window to exclude.
|
|
* @param {Function} aCallback - The asynchronous callback function to run on each window.
|
|
*/
|
|
async #runOnAllWindowsAsync(aWindow, aCallback) {
|
|
for (let window of this.#browserWindows) {
|
|
if (window !== aWindow && !window._zenClosingWindow) {
|
|
await aCallback(window);
|
|
}
|
|
}
|
|
}
|
|
|
|
observe(aSubject, aTopic) {
|
|
switch (aTopic) {
|
|
case "browser-window-before-show": {
|
|
this.#onWindowBeforeShow(aSubject);
|
|
break;
|
|
}
|
|
case "sessionstore-windows-restored": {
|
|
this.#onSessionStoreInitialized();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
handleEvent(aEvent) {
|
|
const window = aEvent.currentTarget.documentGlobal ?? aEvent.currentTarget;
|
|
if (
|
|
!window.gZenStartup.isReady ||
|
|
!window.gZenWorkspaces?.shouldHaveWorkspaces ||
|
|
window._zenClosingWindow
|
|
) {
|
|
return;
|
|
}
|
|
if (
|
|
!lazy.gWindowSyncEnabled &&
|
|
!UNSYNCED_WINDOW_EVENTS.includes(aEvent.type)
|
|
) {
|
|
return;
|
|
}
|
|
if (INSTANT_EVENTS.includes(aEvent.type)) {
|
|
this.#handleNextEventInternal(aEvent);
|
|
return;
|
|
}
|
|
if (
|
|
this.#eventHandlingContext.window &&
|
|
this.#eventHandlingContext.window !== window
|
|
) {
|
|
// We're already handling an event for another window.
|
|
// To avoid re-entrancy issues, we skip this event.
|
|
return;
|
|
}
|
|
const lastHandlerPromise = this.#eventHandlingContext.lastHandlerPromise;
|
|
this.#eventHandlingContext.eventCount++;
|
|
this.#eventHandlingContext.window = window;
|
|
let resolveNewPromise;
|
|
this.#eventHandlingContext.lastHandlerPromise = new Promise(resolve => {
|
|
resolveNewPromise = resolve;
|
|
});
|
|
// Wait for the last handler to finish before processing the next event.
|
|
lastHandlerPromise.then(() => {
|
|
this.#handleNextEvent(aEvent).finally(() => {
|
|
if (--this.#eventHandlingContext.eventCount === 0) {
|
|
this.#eventHandlingContext.window = null;
|
|
}
|
|
resolveNewPromise();
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Adds a sync handler for a specific event type.
|
|
*
|
|
* @param {Function} aHandler - The sync handler function to add.
|
|
*/
|
|
addSyncHandler(aHandler) {
|
|
if (!aHandler || this.#syncHandlers.has(aHandler)) {
|
|
return;
|
|
}
|
|
this.#syncHandlers.add(aHandler);
|
|
}
|
|
|
|
/**
|
|
* Removes a sync handler for a specific event type.
|
|
*
|
|
* @param {Function} aHandler - The sync handler function to remove.
|
|
*/
|
|
removeSyncHandler(aHandler) {
|
|
this.#syncHandlers.delete(aHandler);
|
|
}
|
|
|
|
#handleNextEventInternal(aEvent) {
|
|
const handler = `on_${aEvent.type}`;
|
|
if (typeof this[handler] !== "function") {
|
|
throw new Error(`No handler for event type: ${aEvent.type}`);
|
|
}
|
|
return this[handler](aEvent);
|
|
}
|
|
|
|
/**
|
|
* Handles the next event by calling the appropriate handler method.
|
|
*
|
|
* @param {Event} aEvent - The event to handle.
|
|
*/
|
|
async #handleNextEvent(aEvent) {
|
|
try {
|
|
await this.#handleNextEventInternal(aEvent);
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
for (let syncHandler of this.#syncHandlers) {
|
|
try {
|
|
syncHandler(aEvent);
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retrieves a item element from a window by its ID.
|
|
*
|
|
* @param {Window} aWindow - The window containing the item.
|
|
* @param {string} aItemId - The ID of the item to retrieve.
|
|
* @returns {MozTabbrowserTab|MozTabbrowserTabGroup|null} The item element if found, otherwise null.
|
|
*/
|
|
getItemFromWindow(aWindow, aItemId) {
|
|
if (!aItemId) {
|
|
return null;
|
|
}
|
|
return aWindow.document.getElementById(aItemId);
|
|
}
|
|
|
|
/**
|
|
* Synchronizes a specific attribute from the original item to the target item.
|
|
*
|
|
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} aOriginalItem - The original item to copy from.
|
|
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} aTargetItem - The target item to copy to.
|
|
* @param {string} aAttributeName - The name of the attribute to synchronize.
|
|
*/
|
|
#maybeSyncAttributeChange(aOriginalItem, aTargetItem, aAttributeName) {
|
|
if (aOriginalItem.hasAttribute(aAttributeName)) {
|
|
aTargetItem.setAttribute(
|
|
aAttributeName,
|
|
aOriginalItem.getAttribute(aAttributeName)
|
|
);
|
|
} else {
|
|
aTargetItem.removeAttribute(aAttributeName);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Synchronizes the icon and label of the target tab with the original tab.
|
|
*
|
|
* @param {object} aOriginalItem - The original item to copy from.
|
|
* @param {object} aTargetItem - The target item to copy to.
|
|
* @param {Window} aWindow - The window containing the tabs.
|
|
* @param {number} flags - The sync flags indicating what to synchronize.
|
|
*/
|
|
#syncItemWithOriginal(aOriginalItem, aTargetItem, aWindow, flags = 0) {
|
|
if (!aOriginalItem || !aTargetItem) {
|
|
return;
|
|
}
|
|
const { gBrowser, gZenFolders } = aWindow;
|
|
if (flags & SYNC_FLAG_ICON) {
|
|
aTargetItem.zenStaticIcon = aOriginalItem.zenStaticIcon;
|
|
if (gBrowser.isTab(aOriginalItem)) {
|
|
try {
|
|
gBrowser.setIcon(
|
|
aTargetItem,
|
|
aOriginalItem.getAttribute("image") ||
|
|
gBrowser.getIcon(aOriginalItem)
|
|
);
|
|
} catch {}
|
|
} else if (aOriginalItem.isZenFolder) {
|
|
// Icons are a zen-only feature for tab groups.
|
|
gZenFolders.setFolderUserIcon(aTargetItem, aOriginalItem.iconURL);
|
|
}
|
|
}
|
|
if (flags & SYNC_FLAG_LABEL) {
|
|
if (gBrowser.isTab(aOriginalItem)) {
|
|
aTargetItem._zenChangeLabelFlag = true;
|
|
aTargetItem.zenStaticLabel = aOriginalItem.zenStaticLabel;
|
|
gBrowser._setTabLabel(aTargetItem, aOriginalItem.label);
|
|
delete aTargetItem._zenChangeLabelFlag;
|
|
} else if (gBrowser.isTabGroup(aOriginalItem)) {
|
|
aTargetItem.label = aOriginalItem.label;
|
|
}
|
|
}
|
|
if (flags & SYNC_FLAG_MOVE && !aTargetItem.hasAttribute("zen-empty-tab")) {
|
|
this.#maybeSyncAttributeChange(
|
|
aOriginalItem,
|
|
aTargetItem,
|
|
"zen-workspace-id"
|
|
);
|
|
this.#syncItemPosition(aOriginalItem, aTargetItem, aWindow);
|
|
}
|
|
if (aOriginalItem.hasAttribute("zen-live-folder-item-id")) {
|
|
this.#maybeSyncAttributeChange(
|
|
aOriginalItem,
|
|
aTargetItem,
|
|
"zen-live-folder-item-id"
|
|
);
|
|
this.#maybeSyncAttributeChange(
|
|
aOriginalItem,
|
|
aTargetItem,
|
|
"zen-show-sublabel"
|
|
);
|
|
this.#syncTabSubtitle(aWindow, aOriginalItem, aTargetItem);
|
|
} else if (aTargetItem.hasAttribute("zen-live-folder-item-id")) {
|
|
aTargetItem.removeAttribute("zen-live-folder-item-id");
|
|
if (aTargetItem.hasAttribute("zen-show-sublabel")) {
|
|
this.#syncTabSubtitle(aWindow, aOriginalItem, aTargetItem);
|
|
aTargetItem.removeAttribute("zen-show-sublabel");
|
|
}
|
|
}
|
|
}
|
|
|
|
#syncTabSubtitle(aWindow, aOriginalItem, aTargetItem) {
|
|
const subLabel = aOriginalItem.getAttribute("zen-show-sublabel");
|
|
const targetLabel = aTargetItem.querySelector(".zen-tab-sublabel");
|
|
if (targetLabel) {
|
|
aWindow.document.l10n.setArgs(targetLabel, {
|
|
tabSubtitle: subLabel || "zen-default-pinned",
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Synchronizes the position of the target item with the original item.
|
|
*
|
|
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} aOriginalItem - The original item to copy from.
|
|
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} aTargetItem - The target item to copy to.
|
|
* @param {Window} aWindow - The window containing the items.
|
|
*/
|
|
#syncItemPosition(aOriginalItem, aTargetItem, aWindow) {
|
|
const { gBrowser, gZenPinnedTabManager } = aWindow;
|
|
const originalIsEssential = aOriginalItem.hasAttribute("zen-essential");
|
|
const targetIsEssential = aTargetItem.hasAttribute("zen-essential");
|
|
const originalIsPinned = aOriginalItem.pinned;
|
|
const targetIsPinned = aTargetItem.pinned;
|
|
|
|
const isGroup = gBrowser.isTabGroup(aOriginalItem);
|
|
const isTab = !isGroup;
|
|
|
|
if (aOriginalItem.hasAttribute("zen-glance-tab")) {
|
|
return;
|
|
}
|
|
|
|
if (isTab) {
|
|
if (originalIsEssential !== targetIsEssential) {
|
|
if (originalIsEssential) {
|
|
gZenPinnedTabManager.addToEssentials(aTargetItem);
|
|
} else {
|
|
gZenPinnedTabManager.removeEssentials(
|
|
aTargetItem,
|
|
/* unpin= */ !targetIsPinned
|
|
);
|
|
}
|
|
} else if (originalIsPinned !== targetIsPinned) {
|
|
if (originalIsPinned) {
|
|
gBrowser.pinTab(aTargetItem);
|
|
} else {
|
|
gBrowser.unpinTab(aTargetItem);
|
|
}
|
|
}
|
|
} else {
|
|
aTargetItem.pinned = aOriginalItem.pinned;
|
|
}
|
|
|
|
this.#moveItemToMatchOriginal(aOriginalItem, aTargetItem, aWindow, {
|
|
isEssential: originalIsEssential,
|
|
isPinned: originalIsPinned,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Moves the target item to match the position of the original item.
|
|
*
|
|
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} aOriginalItem - The original item to match.
|
|
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} aTargetItem - The target item to move.
|
|
* @param {Window} aWindow - The window containing the items.
|
|
* @param {object} options - Additional options for moving the item.
|
|
* @param {boolean} options.isEssential - Indicates if the item is essential.
|
|
* @param {boolean} options.isPinned - Indicates if the item is pinned.
|
|
*/
|
|
#moveItemToMatchOriginal(
|
|
aOriginalItem,
|
|
aTargetItem,
|
|
aWindow,
|
|
{ isEssential, isPinned }
|
|
) {
|
|
const { gBrowser, gZenWorkspaces } = aWindow;
|
|
let originalSibling = aOriginalItem.previousElementSibling;
|
|
if (originalSibling?.classList.contains("space-fake-collapsible-start")) {
|
|
// Skip space fake elements.
|
|
originalSibling = originalSibling.previousElementSibling;
|
|
}
|
|
let isFirstTab = true;
|
|
if (
|
|
gBrowser.isTabGroup(originalSibling) ||
|
|
gBrowser.isTab(originalSibling)
|
|
) {
|
|
isFirstTab =
|
|
!originalSibling.hasAttribute("id") ||
|
|
originalSibling.hasAttribute("zen-empty-tab");
|
|
}
|
|
|
|
gBrowser.zenHandleTabMove(aTargetItem, () => {
|
|
if (isFirstTab) {
|
|
let container;
|
|
const parentGroup = aOriginalItem.group;
|
|
if (parentGroup?.hasAttribute("id")) {
|
|
container = this.getItemFromWindow(
|
|
aWindow,
|
|
parentGroup.getAttribute("id")
|
|
);
|
|
if (container) {
|
|
if (container?.tabs?.length) {
|
|
// First tab in folders is the empty tab placeholder.
|
|
container.tabs[0].after(aTargetItem);
|
|
} else {
|
|
container.appendChild(aTargetItem);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
if (isEssential) {
|
|
container = gZenWorkspaces.getEssentialsSection(aTargetItem);
|
|
} else {
|
|
const workspaceId =
|
|
aTargetItem.getAttribute("zen-workspace-id") ||
|
|
aOriginalItem.documentGlobal.gZenWorkspaces.activeWorkspace;
|
|
const workspaceElement = gZenWorkspaces.workspaceElement(workspaceId);
|
|
container = isPinned
|
|
? workspaceElement?.pinnedTabsContainer
|
|
: workspaceElement?.tabsContainer;
|
|
}
|
|
if (container) {
|
|
container.insertBefore(aTargetItem, container.firstChild);
|
|
}
|
|
return;
|
|
}
|
|
const relativeTab = this.getItemFromWindow(aWindow, originalSibling.id);
|
|
if (relativeTab) {
|
|
gBrowser.tabContainer.tabDragAndDrop.handle_drop_transition(
|
|
relativeTab,
|
|
aTargetItem,
|
|
[aTargetItem],
|
|
false
|
|
);
|
|
relativeTab.after(aTargetItem);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Synchronizes a item across all browser windows.
|
|
*
|
|
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} aItem - The item to synchronize.
|
|
* @param {number} flags - The sync flags indicating what to synchronize.
|
|
*/
|
|
#syncItemForAllWindows(aItem, flags = 0) {
|
|
const window = aItem.documentGlobal;
|
|
this.#runOnAllWindows(window, win => {
|
|
this.#syncItemWithOriginal(
|
|
aItem,
|
|
this.getItemFromWindow(win, aItem.id),
|
|
win,
|
|
flags
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Swaps the browser docshells between two tabs.
|
|
*
|
|
* @param {object} aOurTab - The tab in the current window.
|
|
* @param {object} aOtherTab - The tab in the other window.
|
|
*/
|
|
async #swapBrowserDocShellsAsync(aOurTab, aOtherTab) {
|
|
if (!this.#canSwapBrowsers(aOurTab, aOtherTab)) {
|
|
this.log(
|
|
`Cannot swap browsers between tabs ${aOurTab.id} and ${aOtherTab.id} due to process mismatch`
|
|
);
|
|
return;
|
|
}
|
|
if (aOtherTab.closing) {
|
|
this.log(`Cannot swap browsers, other tab ${aOtherTab.id} is closing`);
|
|
return;
|
|
}
|
|
await this.#styleSwapedBrowsers(aOurTab, aOtherTab, () => {
|
|
try {
|
|
this.#swapBrowserDocShellsInner(aOurTab, aOtherTab);
|
|
} catch (e) {
|
|
console.error(
|
|
`Error swapping browsers for tabs ${aOurTab.id} and ${aOtherTab.id}:`,
|
|
e
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Restores the tab progress listener for a given tab.
|
|
*
|
|
* @param {object} aTab - The tab to restore the progress listener for.
|
|
* @param {Function} callback - The callback function to execute while the listener is removed.
|
|
* @param {boolean} onClose - Indicates if the swap is done during a tab close operation.
|
|
*/
|
|
#withRestoreTabProgressListener(aTab, callback, onClose = false) {
|
|
const otherTabBrowser = aTab.documentGlobal.gBrowser;
|
|
const otherBrowser = aTab.linkedBrowser;
|
|
|
|
// We aren't closing the other tab so, we also need to swap its tablisteners.
|
|
let filter = otherTabBrowser._tabFilters.get(aTab);
|
|
let tabListener = otherTabBrowser._tabListeners.get(aTab);
|
|
try {
|
|
otherBrowser.webProgress.removeProgressListener(filter);
|
|
filter.removeProgressListener(tabListener);
|
|
} catch {
|
|
/* ignore errors, we might have already removed them */
|
|
}
|
|
|
|
try {
|
|
callback();
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
|
|
// Restore the listeners for the swapped in tab.
|
|
if (!onClose && filter) {
|
|
tabListener = new otherTabBrowser.zenTabProgressListener(
|
|
aTab,
|
|
otherBrowser,
|
|
true,
|
|
false
|
|
);
|
|
otherTabBrowser._tabListeners.set(aTab, tabListener);
|
|
|
|
const notifyAll = Ci.nsIWebProgress.NOTIFY_ALL;
|
|
filter.addProgressListener(tabListener, notifyAll);
|
|
otherBrowser.webProgress.addProgressListener(filter, notifyAll);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if two tabs can have their browsers swapped.
|
|
*
|
|
* @param {object} aOurTab - The tab in the current window.
|
|
* @param {object} aOtherTab - The tab in the other window.
|
|
* @returns {boolean} True if the tabs can be swapped, false otherwise.
|
|
*/
|
|
#canSwapBrowsers(aOurTab, aOtherTab) {
|
|
// In this case, the other tab is most likely discarded or pending.
|
|
// We *shouldn't* care about this scenario since the remoteness should be
|
|
// the same anyways.
|
|
if (!aOurTab.linkedBrowser || !aOtherTab.linkedBrowser) {
|
|
this.log(
|
|
`Cannot swap browsers between tabs ${aOurTab.id} and ${aOtherTab.id} because one of them doesn't have a linked browser`
|
|
);
|
|
return false;
|
|
}
|
|
// Theoretical case where we are trying to swap two tabs in the same window.
|
|
// There has been some reports of this happening in the wild, and while it shouldn't
|
|
// cause any critical issues, it can cause some weird states and we should avoid it.
|
|
// For example, see gh-13149
|
|
if (aOtherTab.documentGlobal === aOurTab.documentGlobal) {
|
|
this.log(
|
|
`Cannot swap browsers between tabs ${aOurTab.id} and ${aOtherTab.id} because they are in the same window`
|
|
);
|
|
return false;
|
|
}
|
|
// Can't swap between chrome and content processes.
|
|
if (
|
|
aOurTab.linkedBrowser.isRemoteBrowser !=
|
|
aOtherTab.linkedBrowser.isRemoteBrowser
|
|
) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Swaps the browser docshells between two tabs.
|
|
*
|
|
* @param {object} aOurTab - The tab in the current window.
|
|
* @param {object} aOtherTab - The tab in the other window.
|
|
* @param {object} options - Options object.
|
|
* @param {boolean} options.focus - Indicates if the tab should be focused after the swap.
|
|
* @param {boolean} options.onClose - Indicates if the swap is done during a tab close operation.
|
|
*/
|
|
#swapBrowserDocShellsInner(
|
|
aOurTab,
|
|
aOtherTab,
|
|
{ focus = true, onClose = false } = {}
|
|
) {
|
|
// Can't swap between chrome and content processes.
|
|
if (!this.#canSwapBrowsers(aOurTab, aOtherTab)) {
|
|
this.log(
|
|
`Cannot swap browsers between tabs ${aOurTab.id} and ${aOtherTab.id} due to process mismatch`
|
|
);
|
|
return;
|
|
}
|
|
// Running `swapBrowsersAndCloseOther` doesn't expect us to use the tab after
|
|
// the operation, so it doesn't really care about cleaning up the other tab.
|
|
// We need to make a new tab progress listener for the other tab after the swap.
|
|
this.#withRestoreTabProgressListener(
|
|
aOtherTab,
|
|
() => {
|
|
this.log(`Swapping docshells between windows for tab ${aOurTab.id}`);
|
|
try {
|
|
aOurTab.documentGlobal.gBrowser.swapBrowsersAndCloseOther(
|
|
aOurTab,
|
|
aOtherTab,
|
|
false
|
|
);
|
|
} catch (e) {
|
|
console.error(
|
|
`Error swapping browsers for tabs ${aOurTab.id} and ${aOtherTab.id}:`,
|
|
e
|
|
);
|
|
}
|
|
|
|
// Swap permanent keys
|
|
if (!onClose) {
|
|
const ourPermanentKey = aOurTab.linkedBrowser.permanentKey;
|
|
const otherPermanentKey = aOtherTab.linkedBrowser.permanentKey;
|
|
aOurTab.linkedBrowser.permanentKey = otherPermanentKey;
|
|
aOtherTab.linkedBrowser.permanentKey = ourPermanentKey;
|
|
aOurTab.permanentKey = otherPermanentKey;
|
|
aOtherTab.permanentKey = ourPermanentKey;
|
|
}
|
|
|
|
// Since we are moving progress listeners around, there's a chance that we
|
|
// trigger a load while making the switch, and since we remove the previous
|
|
// tab's listeners, the other browser window will never get the 'finish load' event
|
|
// and will stay in a 'busy' state forever.
|
|
// To avoid this, we manually check if the other tab is still busy after the swap,
|
|
// and if not, we remove the busy attribute from our tab.
|
|
if (!aOtherTab.hasAttribute("busy")) {
|
|
aOurTab.removeAttribute("busy");
|
|
}
|
|
// Load about:blank if by any chance we loaded the previous tab's URL.
|
|
// TODO: We should maybe start using a singular about:blank preloaded view
|
|
// to avoid loading a full blank page each time and wasting resources.
|
|
// We do need to do this though instead of just unloading the browser because
|
|
// firefox doesn't expect an unloaded + selected tab, so we need to get
|
|
// around this limitation somehow.
|
|
if (
|
|
!onClose &&
|
|
(aOtherTab.linkedBrowser?.currentURI.spec !== "about:blank" ||
|
|
aOtherTab.hasAttribute("busy"))
|
|
) {
|
|
this.log(
|
|
`Loading about:blank in our tab ${aOtherTab.id} before swap`
|
|
);
|
|
aOtherTab.linkedBrowser.loadURI(Services.io.newURI("about:blank"), {
|
|
triggeringPrincipal:
|
|
Services.scriptSecurityManager.getSystemPrincipal(),
|
|
loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_REPLACE_HISTORY,
|
|
});
|
|
}
|
|
},
|
|
onClose
|
|
);
|
|
const kAttributesToRemove = [
|
|
"muted",
|
|
"soundplaying",
|
|
"sharing",
|
|
"pictureinpicture",
|
|
"busy",
|
|
];
|
|
// swapBrowsersAndCloseOther already takes care of transferring attributes like 'muted',
|
|
// but we need to manually remove some attributes from the other tab.
|
|
for (let attr of kAttributesToRemove) {
|
|
aOtherTab.removeAttribute(attr);
|
|
}
|
|
if (focus) {
|
|
// Recalculate the focus in order to allow the user to continue typing
|
|
// inside the web content area without having to click outside and back in.
|
|
aOurTab.linkedBrowser.blur();
|
|
aOurTab.documentGlobal.gBrowser._adjustFocusAfterTabSwitch(aOurTab);
|
|
aOurTab.linkedBrowser.docShellIsActive = true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Styles the swapped browsers to ensure proper visibility and layout.
|
|
*
|
|
* @param {object} aOurTab - The tab in the current window.
|
|
* @param {object} aOtherTab - The tab in the other window.
|
|
* @param {Function|undefined} callback - The callback function to execute after styling.
|
|
*/
|
|
#styleSwapedBrowsers(aOurTab, aOtherTab, callback = undefined) {
|
|
const ourBrowser = aOurTab.linkedBrowser;
|
|
const otherBrowser = aOtherTab.linkedBrowser;
|
|
// eslint-disable-next-line no-async-promise-executor
|
|
return new Promise(async resolve => {
|
|
if (callback) {
|
|
const browserBlob =
|
|
await aOtherTab.documentGlobal.PageThumbs.captureToBlob(
|
|
aOtherTab.linkedBrowser,
|
|
{
|
|
fullScale: true,
|
|
fullViewport: true,
|
|
backgroundColor: "transparent",
|
|
}
|
|
);
|
|
|
|
let mySrc = await new Promise(r => {
|
|
const reader = new FileReader();
|
|
if (!browserBlob) {
|
|
r("");
|
|
return;
|
|
}
|
|
reader.readAsDataURL(browserBlob);
|
|
reader.onloadend = function () {
|
|
// result includes identifier 'data:image/png;base64,' plus the base64 data
|
|
r(reader.result);
|
|
};
|
|
reader.onerror = function () {
|
|
r("");
|
|
};
|
|
});
|
|
|
|
await this.#createPseudoImageForBrowser(otherBrowser, mySrc);
|
|
callback();
|
|
lazy.setTimeout(() => {
|
|
otherBrowser.setAttribute("zen-pseudo-hidden", "true");
|
|
ourBrowser.removeAttribute("zen-pseudo-hidden");
|
|
this.#maybeRemovePseudoImageForBrowser(ourBrowser);
|
|
ourBrowser.focus();
|
|
resolve();
|
|
});
|
|
return;
|
|
}
|
|
ourBrowser.removeAttribute("zen-pseudo-hidden");
|
|
this.#maybeRemovePseudoImageForBrowser(ourBrowser);
|
|
resolve();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create and insert a new pseudo image for a browser element.
|
|
*
|
|
* @param {object} aBrowser - The browser element to create the pseudo image for.
|
|
* @param {string} aSrc - The source URL of the image.
|
|
*/
|
|
#createPseudoImageForBrowser(aBrowser, aSrc) {
|
|
const doc = aBrowser.ownerDocument;
|
|
const win = aBrowser.documentGlobal;
|
|
const img = doc.createElement("img");
|
|
img.className = "zen-pseudo-browser-image";
|
|
img.src = aSrc;
|
|
let promise = new Promise(resolve => {
|
|
if (img.complete) {
|
|
resolve();
|
|
return;
|
|
}
|
|
let finish = () => {
|
|
win.requestAnimationFrame(() => {
|
|
resolve();
|
|
});
|
|
};
|
|
img.onload = finish;
|
|
img.onerror = finish;
|
|
});
|
|
aBrowser.after(img);
|
|
return promise;
|
|
}
|
|
|
|
/**
|
|
* Removes the pseudo image element for a browser if it exists.
|
|
*
|
|
* @param {object} aBrowser - The browser element to remove the pseudo image for.
|
|
*/
|
|
#maybeRemovePseudoImageForBrowser(aBrowser) {
|
|
const elements = aBrowser.parentNode?.querySelectorAll(
|
|
".zen-pseudo-browser-image"
|
|
);
|
|
if (elements) {
|
|
elements.forEach(element => element.remove());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retrieves the active tab, where the web contents are being viewed
|
|
* from other windows by its ID.
|
|
*
|
|
* @param {Window} aWindow - The window to exclude.
|
|
* @param {string} aTabId - The ID of the tab to retrieve.
|
|
* @param {Function} filter - A function to filter the tabs.
|
|
* @returns {object | null} The active tab from other windows if found, otherwise null.
|
|
*/
|
|
#getActiveTabFromOtherWindows(
|
|
aWindow,
|
|
aTabId,
|
|
filter = tab => tab?._zenContentsVisible
|
|
) {
|
|
return this.#runOnAllWindows(aWindow, win => {
|
|
const tab = this.getItemFromWindow(win, aTabId);
|
|
if (filter(tab)) {
|
|
return tab;
|
|
}
|
|
return undefined;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Moves all active tabs from the specified window to other windows.
|
|
*
|
|
* @param {Window} aWindow - The window to move active tabs from.
|
|
*/
|
|
#moveAllActiveTabsToOtherWindowsForClose(aWindow) {
|
|
const mostRecentWindow = this.#browserWindowsList.find(
|
|
win => win !== aWindow
|
|
);
|
|
if (!mostRecentWindow || !aWindow.gZenWorkspaces) {
|
|
return;
|
|
}
|
|
const activeTabsOnClosedWindow =
|
|
aWindow.gZenWorkspaces.allStoredTabs.filter(
|
|
tab => tab._zenContentsVisible
|
|
);
|
|
for (let tab of activeTabsOnClosedWindow) {
|
|
const targetTab = this.getItemFromWindow(mostRecentWindow, tab.id);
|
|
if (targetTab) {
|
|
this.log(`Moving active tab ${tab.id} to most recent window on close`);
|
|
targetTab._zenContentsVisible = true;
|
|
if (!tab.linkedBrowser) {
|
|
continue;
|
|
}
|
|
delete tab._zenContentsVisible;
|
|
try {
|
|
this.#swapBrowserDocShellsInner(targetTab, tab, {
|
|
focus: targetTab.selected,
|
|
onClose: true,
|
|
});
|
|
} catch (e) {
|
|
console.error(
|
|
`Error swapping browsers for tabs ${tab.id} and ${targetTab.id} during close:`,
|
|
e
|
|
);
|
|
}
|
|
this.#swapedTabsEntriesForWC.set(
|
|
tab.linkedBrowser.permanentKey,
|
|
targetTab
|
|
);
|
|
// We can animate later, whats important is to always stay on the same
|
|
// process and avoid async operations here to avoid the closed window
|
|
// being unloaded before the swap is done.
|
|
this.#styleSwapedBrowsers(targetTab, tab);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles tab switch or window focus events to synchronize tab contents visibility.
|
|
*
|
|
* @param {Window} aWindow - The window that triggered the event.
|
|
* @param {object} aPreviousTab - The previously selected tab.
|
|
*/
|
|
async #onTabSwitchOrWindowFocus(aWindow, aPreviousTab = null) {
|
|
let activeBrowsers = aWindow.gBrowser.selectedBrowsers;
|
|
let activeTabs = activeBrowsers
|
|
.map(browser => aWindow.gBrowser.getTabForBrowser(browser))
|
|
.filter(tab => tab);
|
|
// Ignore previous tabs that are still "active". These scenarios could happen for example,
|
|
// when selecting on a split view tab that was already active.
|
|
if (
|
|
aPreviousTab?._zenContentsVisible &&
|
|
!activeTabs.includes(aPreviousTab)
|
|
) {
|
|
let tabsToSwap = aPreviousTab.group?.hasAttribute("split-view-group")
|
|
? aPreviousTab.group.tabs
|
|
: [aPreviousTab];
|
|
for (const tab of tabsToSwap) {
|
|
const otherTabToShow = this.#getActiveTabFromOtherWindows(
|
|
aWindow,
|
|
tab.id,
|
|
t =>
|
|
t?.splitView ? t.group.tabs.some(st => st.selected) : t?.selected
|
|
);
|
|
if (otherTabToShow) {
|
|
otherTabToShow._zenContentsVisible = true;
|
|
delete tab._zenContentsVisible;
|
|
await this.#swapBrowserDocShellsAsync(otherTabToShow, tab);
|
|
}
|
|
}
|
|
}
|
|
let promises = [];
|
|
for (const selectedTab of activeTabs) {
|
|
if (
|
|
selectedTab._zenContentsVisible ||
|
|
selectedTab.hasAttribute("zen-empty-tab")
|
|
) {
|
|
continue;
|
|
}
|
|
const otherSelectedTab = this.#getActiveTabFromOtherWindows(
|
|
aWindow,
|
|
selectedTab.id
|
|
);
|
|
selectedTab._zenContentsVisible = true;
|
|
if (otherSelectedTab) {
|
|
delete otherSelectedTab._zenContentsVisible;
|
|
promises.push(
|
|
this.#swapBrowserDocShellsAsync(selectedTab, otherSelectedTab)
|
|
);
|
|
}
|
|
}
|
|
await Promise.all(promises);
|
|
}
|
|
|
|
/**
|
|
* Delegates generic sync events to synchronize tabs across windows.
|
|
*
|
|
* @param {Event} aEvent - The event to delegate.
|
|
* @param {number} flags - The sync flags indicating what to synchronize.
|
|
*/
|
|
#delegateGenericSyncEvent(aEvent, flags = 0) {
|
|
const item = aEvent.target;
|
|
if (lazy.gSyncOnlyPinnedTabs && !item.pinned) {
|
|
return;
|
|
}
|
|
this.#syncItemForAllWindows(item, flags);
|
|
}
|
|
|
|
/**
|
|
* Retrieves the tab state entries from the cache for a given tab.
|
|
*
|
|
* @param {object} aTab - The tab to retrieve the state for.
|
|
* @returns {object} The tab state entries.
|
|
*/
|
|
#getTabEntriesFromCache(aTab) {
|
|
let cachedState;
|
|
if (aTab.linkedBrowser) {
|
|
cachedState = lazy.TabStateCache.get(aTab.linkedBrowser.permanentKey);
|
|
}
|
|
return cachedState?.history?.entries
|
|
? Cu.cloneInto(cachedState.history, {})
|
|
: { entries: [] };
|
|
}
|
|
|
|
/**
|
|
* Flushes the tab state for a given tab if it has a linked browser.
|
|
*
|
|
* @param {object} aTab - The tab to flush the state for.
|
|
* @returns {Promise} A promise that resolves when the operation is complete.
|
|
*/
|
|
#maybeFlushTabState(aTab) {
|
|
if (!aTab.linkedBrowser || aTab.hasAttribute("pending")) {
|
|
return Promise.resolve();
|
|
}
|
|
return lazy.TabStateFlusher.flush(aTab.linkedBrowser);
|
|
}
|
|
|
|
/* Mark: Public API */
|
|
|
|
/**
|
|
* Sets the initial pinned state for a tab across all windows.
|
|
*
|
|
* @param {object} aTab - The tab to set the pinned state for.
|
|
* @returns {Promise} A promise that resolves when the operation is complete.
|
|
*/
|
|
setPinnedTabState(aTab) {
|
|
return this.#maybeFlushTabState(aTab).finally(() => {
|
|
this.log(`Setting pinned initial state for tab ${aTab.id}`);
|
|
let { entries, index } = this.#getTabEntriesFromCache(aTab);
|
|
let image =
|
|
aTab.getAttribute("image") ||
|
|
aTab.documentGlobal.gBrowser.getIcon(aTab);
|
|
let activeIndex = typeof index === "number" ? index : entries.length;
|
|
// Tab state cache gives us the index starting from 1 instead of 0.
|
|
activeIndex--;
|
|
activeIndex = Math.min(activeIndex, entries.length - 1);
|
|
activeIndex = Math.max(activeIndex, 0);
|
|
let entryToUse = (entries[activeIndex] || entries[0]) ?? null;
|
|
this.#setPinnedInitialState(
|
|
aTab,
|
|
{ url: entryToUse?.url, title: entryToUse?.title },
|
|
image
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Sets the canonical pinned URL for a tab across all windows. Used to let the
|
|
* user edit a pinned tab's URL directly.
|
|
*
|
|
* @param {object} aTab - The tab to set the pinned URL for.
|
|
* @param {string} aUrl - The URL to store as the canonical pinned URL.
|
|
* @param {string} [aImage] - Optional Icon to store.
|
|
*/
|
|
setPinnedUrl(aTab, aUrl, aImage) {
|
|
this.log(`Setting pinned url for tab ${aTab.id}`);
|
|
this.#setPinnedInitialState(
|
|
aTab,
|
|
{ url: aUrl, title: aTab.zenStaticLabel },
|
|
aImage
|
|
);
|
|
}
|
|
|
|
#setPinnedInitialState(aTab, aEntry, aImage) {
|
|
const initialState = { entry: aEntry, image: aImage };
|
|
this.#runOnAllWindows(null, win => {
|
|
const targetTab = this.getItemFromWindow(win, aTab.id);
|
|
if (targetTab) {
|
|
targetTab._zenPinnedInitialState = initialState;
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Propagates the workspaces to all windows.
|
|
*
|
|
* @param {Array} aWorkspaces - The workspaces to propagate.
|
|
*/
|
|
propagateWorkspacesToAllWindows(aWorkspaces) {
|
|
this.#runOnAllWindows(null, win => {
|
|
win.gZenWorkspaces.propagateWorkspaces(aWorkspaces);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Moves all tabs from a window to a synced workspace in another window.
|
|
* If no synced window exists, creates a new one.
|
|
*
|
|
* @param {Window} aWindow - The window to move tabs from.
|
|
* @param {string} aWorkspaceId - The ID of the workspace to move tabs to.
|
|
*/
|
|
moveTabsToSyncedWorkspace(aWindow, aWorkspaceId) {
|
|
const tabsToMove = aWindow.gZenWorkspaces.allStoredTabs.filter(
|
|
tab => !tab.hasAttribute("zen-empty-tab")
|
|
);
|
|
const selectedTab = aWindow.gBrowser.selectedTab;
|
|
let win = this.firstSyncedWindow;
|
|
const moveAllTabsToWindow = async (allowSelected = false) => {
|
|
const { gBrowser, gZenWorkspaces } = win;
|
|
win.focus();
|
|
let tabToSelect;
|
|
for (const tab of tabsToMove) {
|
|
if (tab !== selectedTab || allowSelected) {
|
|
const newTab = gBrowser.adoptTab(tab, { tabIndex: Infinity });
|
|
gZenWorkspaces.moveTabToWorkspace(newTab, aWorkspaceId);
|
|
if (!tabToSelect) {
|
|
tabToSelect = newTab;
|
|
}
|
|
}
|
|
}
|
|
aWindow.close();
|
|
if (tabToSelect) {
|
|
gBrowser.selectedTab = tabToSelect;
|
|
}
|
|
await gZenWorkspaces.changeWorkspaceWithID(aWorkspaceId);
|
|
gBrowser.selectedBrowser.focus();
|
|
};
|
|
if (!win) {
|
|
this.log("No synced window found, creating a new one");
|
|
win = aWindow.gBrowser.replaceTabWithWindow(
|
|
selectedTab,
|
|
{},
|
|
/* zenForceSync = */ true
|
|
);
|
|
win.addEventListener(
|
|
"MozBeforeInitialXULLayout",
|
|
() => {
|
|
win.gZenStartup.promiseInitialized.then(() => {
|
|
moveAllTabsToWindow();
|
|
});
|
|
},
|
|
{ once: true }
|
|
);
|
|
return;
|
|
}
|
|
moveAllTabsToWindow(true);
|
|
}
|
|
|
|
/**
|
|
* Updates the initial pinned tab image for all windows if not already set.
|
|
*
|
|
* @param {object} aTab - The tab to update the image for.
|
|
*/
|
|
#maybeEditAllTabsEntryImage(aTab) {
|
|
if (!aTab?._zenPinnedInitialState || aTab._zenPinnedInitialState.image) {
|
|
return;
|
|
}
|
|
let image =
|
|
aTab.getAttribute("image") || aTab.documentGlobal.gBrowser.getIcon(aTab);
|
|
this.#runOnAllWindows(null, win => {
|
|
const targetTab = this.getItemFromWindow(win, aTab.id);
|
|
if (targetTab) {
|
|
targetTab._zenPinnedInitialState.image = image;
|
|
}
|
|
});
|
|
}
|
|
|
|
/* Mark: Event Handlers */
|
|
|
|
on_TabOpen(aEvent, { ignoreExistingId = false } = {}) {
|
|
const tab = aEvent.target;
|
|
const window = tab.documentGlobal;
|
|
const isUnsyncedWindow = window.gZenWorkspaces.privateWindowOrDisabled;
|
|
if (tab.id && !ignoreExistingId) {
|
|
// This tab was opened as part of a sync operation.
|
|
return;
|
|
}
|
|
tab._zenContentsVisible = true;
|
|
tab.id = this.#newTabSyncId;
|
|
if (lazy.gSyncOnlyPinnedTabs && !tab.pinned) {
|
|
return;
|
|
}
|
|
if (isUnsyncedWindow || !lazy.gWindowSyncEnabled) {
|
|
return;
|
|
}
|
|
this.#runOnAllWindows(window, win => {
|
|
const newTab = win.gBrowser.addTrustedTab("about:blank", {
|
|
animate: true,
|
|
createLazyBrowser: true,
|
|
_forZenEmptyTab: tab.hasAttribute("zen-empty-tab"),
|
|
});
|
|
newTab.id = tab.id;
|
|
if (!tab.hasAttribute("pending")) {
|
|
newTab.removeAttribute("pending");
|
|
}
|
|
this.#syncItemWithOriginal(
|
|
tab,
|
|
newTab,
|
|
win,
|
|
SYNC_FLAG_ICON | SYNC_FLAG_LABEL | SYNC_FLAG_MOVE
|
|
);
|
|
});
|
|
if (ignoreExistingId && tab?.splitView) {
|
|
this.on_ZenSplitViewTabsSplit({ target: tab.group });
|
|
}
|
|
}
|
|
|
|
on_ZenTabIconChanged(aEvent) {
|
|
if (!aEvent.target?._zenContentsVisible) {
|
|
// No need to sync icon changes for tabs that aren't active in this window.
|
|
return;
|
|
}
|
|
this.#maybeEditAllTabsEntryImage(aEvent.target);
|
|
return this.#delegateGenericSyncEvent(aEvent, SYNC_FLAG_ICON);
|
|
}
|
|
|
|
on_ZenTabLabelChanged(aEvent) {
|
|
if (!aEvent.target?._zenContentsVisible) {
|
|
// No need to sync label changes for tabs that aren't active in this window.
|
|
return;
|
|
}
|
|
return this.#delegateGenericSyncEvent(aEvent, SYNC_FLAG_LABEL);
|
|
}
|
|
|
|
on_TabHide(aEvent) {
|
|
const tab = aEvent.target;
|
|
const window = tab.documentGlobal;
|
|
if (lazy.gSyncOnlyPinnedTabs && !tab.pinned) {
|
|
return;
|
|
}
|
|
this.#runOnAllWindows(window, win => {
|
|
const targetTab = this.getItemFromWindow(win, tab.id);
|
|
if (targetTab) {
|
|
targetTab.documentGlobal.gBrowser.hideTab(targetTab);
|
|
}
|
|
});
|
|
}
|
|
|
|
on_TabShow(aEvent) {
|
|
const tab = aEvent.target;
|
|
const window = tab.documentGlobal;
|
|
if (lazy.gSyncOnlyPinnedTabs && !tab.pinned) {
|
|
return;
|
|
}
|
|
this.#runOnAllWindows(window, win => {
|
|
const targetTab = this.getItemFromWindow(win, tab.id);
|
|
if (targetTab) {
|
|
targetTab.documentGlobal.gBrowser.showTab(targetTab);
|
|
}
|
|
});
|
|
}
|
|
|
|
on_TabMove(aEvent) {
|
|
this.#delegateGenericSyncEvent(aEvent, SYNC_FLAG_MOVE);
|
|
return Promise.resolve();
|
|
}
|
|
|
|
on_TabPinned(aEvent) {
|
|
const tab = aEvent.target;
|
|
// There are cases where the pinned state is changed but we don't
|
|
// wan't to override the initial state we stored when the tab was created.
|
|
// For example, when session restore pins a tab again.
|
|
let tabStatePromise;
|
|
if (!tab._zenPinnedInitialState) {
|
|
tabStatePromise = this.setPinnedTabState(tab);
|
|
}
|
|
return Promise.all([
|
|
tabStatePromise,
|
|
this.on_TabMove(aEvent).then(() => {
|
|
if (lazy.gSyncOnlyPinnedTabs) {
|
|
this.on_TabOpen({ target: tab }, { ignoreExistingId: true });
|
|
}
|
|
}),
|
|
]);
|
|
}
|
|
|
|
on_TabUnpinned(aEvent) {
|
|
const tab = aEvent.target;
|
|
this.#runOnAllWindows(null, win => {
|
|
const targetTab = this.getItemFromWindow(win, tab.id);
|
|
if (targetTab) {
|
|
delete targetTab._zenPinnedInitialState;
|
|
}
|
|
});
|
|
return this.on_TabMove(aEvent).then(() => {
|
|
if (lazy.gSyncOnlyPinnedTabs) {
|
|
this.on_TabClose({ target: tab });
|
|
}
|
|
});
|
|
}
|
|
|
|
on_TabAddedToEssentials(aEvent) {
|
|
return this.on_TabMove(aEvent);
|
|
}
|
|
|
|
on_TabRemovedFromEssentials(aEvent) {
|
|
return this.on_TabMove(aEvent);
|
|
}
|
|
|
|
on_TabClose(aEvent) {
|
|
const tab = aEvent.target;
|
|
const window = tab.documentGlobal;
|
|
this.#runOnAllWindows(window, win => {
|
|
const targetTab = this.getItemFromWindow(win, tab.id);
|
|
if (targetTab) {
|
|
win.gBrowser.removeTab(targetTab, { animate: true });
|
|
}
|
|
});
|
|
}
|
|
|
|
on_focus(aEvent) {
|
|
if (typeof aEvent.target !== "object") {
|
|
return;
|
|
}
|
|
const window = Services.focus.activeWindow;
|
|
if (
|
|
!window?.gBrowser ||
|
|
this.#lastFocusedWindow?.deref() === window ||
|
|
window.closing ||
|
|
!window.toolbar.visible ||
|
|
lazy.RunState.isQuitting
|
|
) {
|
|
return;
|
|
}
|
|
if (this.#docShellSwitchPromise) {
|
|
return;
|
|
}
|
|
const onTabSelect = event => {
|
|
if (event.detail?.previousTab === event.target) {
|
|
return;
|
|
}
|
|
this.#lastSelectedTab = null;
|
|
this.on_TabSelect(event, { ignorePromise: true });
|
|
};
|
|
this.#lastFocusedWindow = new WeakRef(window);
|
|
this.#lastSelectedTab = new WeakRef(window.gBrowser.selectedTab);
|
|
window.addEventListener("TabSelect", onTabSelect, { once: true });
|
|
// eslint-disable-next-line no-async-promise-executor
|
|
this.#docShellSwitchPromise = new Promise(async resolve => {
|
|
await this.#onTabSwitchOrWindowFocus(window);
|
|
window.removeEventListener("TabSelect", onTabSelect);
|
|
resolve();
|
|
this.#docShellSwitchPromise = null;
|
|
});
|
|
}
|
|
|
|
on_TabSelect(aEvent, { ignorePromise = false } = {}) {
|
|
const tab = aEvent.target;
|
|
if (this.#lastSelectedTab?.deref() === tab) {
|
|
return;
|
|
}
|
|
this.#lastSelectedTab = new WeakRef(tab);
|
|
const previousTab = aEvent.detail.previousTab;
|
|
let promise = this.#docShellSwitchPromise;
|
|
if (promise && !ignorePromise) {
|
|
return;
|
|
}
|
|
// eslint-disable-next-line no-async-promise-executor
|
|
this.#docShellSwitchPromise = new Promise(async resolve => {
|
|
await promise;
|
|
await this.#onTabSwitchOrWindowFocus(tab.documentGlobal, previousTab);
|
|
resolve();
|
|
this.#docShellSwitchPromise = null;
|
|
});
|
|
}
|
|
|
|
on_SSWindowClosing(aEvent) {
|
|
const window = aEvent.target.documentGlobal ?? aEvent.target;
|
|
window._zenClosingWindow = true;
|
|
for (let eventName of EVENTS) {
|
|
window.removeEventListener(eventName, this);
|
|
}
|
|
delete window.gZenWindowSync;
|
|
const { promise, resolve } = Promise.withResolvers();
|
|
this.#docShellSwitchPromise = promise;
|
|
try {
|
|
this.#moveAllActiveTabsToOtherWindowsForClose(window);
|
|
} catch (e) {
|
|
console.error(`Error moving active tabs to other windows on close:`, e);
|
|
}
|
|
resolve();
|
|
this.#docShellSwitchPromise = null;
|
|
}
|
|
|
|
on_WindowCloseAndBrowserFlushed(aBrowsers) {
|
|
if (this.#swapedTabsEntriesForWC.size === 0) {
|
|
return;
|
|
}
|
|
for (let browser of aBrowsers) {
|
|
const tab = this.#swapedTabsEntriesForWC.get(browser.permanentKey);
|
|
if (tab) {
|
|
try {
|
|
let win = tab.documentGlobal;
|
|
this.log(`Finalizing swap for tab ${tab.id} on window close`);
|
|
lazy.TabStateCache.update(
|
|
tab.linkedBrowser.permanentKey,
|
|
lazy.TabStateCache.get(browser.permanentKey)
|
|
);
|
|
let tabData = this.#getTabEntriesFromCache(tab);
|
|
let activePageData = tabData.entries[tabData.index - 1] || null;
|
|
|
|
// If the page has a title, set it. When doing a swap and we still didn't
|
|
// flush the tab state, the title might not be correct.
|
|
if (activePageData && win?.gBrowser) {
|
|
win.gBrowser.setInitialTabTitle(tab, activePageData.title, {
|
|
isContentTitle:
|
|
activePageData.title &&
|
|
activePageData.title != activePageData.url,
|
|
});
|
|
}
|
|
} catch (e) {
|
|
// We might have already closed the window at this point, so just ignore any error.
|
|
console.error(e);
|
|
}
|
|
}
|
|
}
|
|
// We don't need to keep these references anymore.
|
|
// and weak maps don't have a clear method, they get cleared automatically.
|
|
this.#swapedTabsEntriesForWC = new WeakMap();
|
|
}
|
|
|
|
on_TabGroupCreate(aEvent) {
|
|
const tabGroup = aEvent.target;
|
|
// See gh-12841, when creating a new space, the tab group create
|
|
// event is fired for the zen-workspace-collapsible-pins element, but
|
|
// its not something we want to sync across windows, so we can just ignore it.
|
|
if (tabGroup.tagName === "zen-workspace-collapsible-pins") {
|
|
return;
|
|
}
|
|
if (tabGroup.id && tabGroup.alreadySynced) {
|
|
// This tab group was opened as part of a sync operation.
|
|
return;
|
|
}
|
|
const window = tabGroup.documentGlobal;
|
|
const isFolder = tabGroup.isZenFolder;
|
|
const isSplitView = tabGroup.hasAttribute("split-view-group");
|
|
if (isSplitView) {
|
|
return; // Split view groups are synced via ZenSplitViewTabsSplit event.
|
|
}
|
|
// Tab groups already have an ID upon creation.
|
|
this.#runOnAllWindows(window, win => {
|
|
// Check if a group with this ID already exists in the target window.
|
|
const existingGroup = this.getItemFromWindow(win, tabGroup.id);
|
|
if (existingGroup) {
|
|
this.log(
|
|
`Attempted to create group ${tabGroup.id} in window ${win}, but it already exists.`
|
|
);
|
|
return; // Do not proceed with creation.
|
|
}
|
|
|
|
const newGroup = isFolder
|
|
? win.gZenFolders.createFolder([], {})
|
|
: win.gBrowser.addTabGroup([]);
|
|
newGroup.id = tabGroup.id;
|
|
newGroup.alreadySynced = true;
|
|
this.#syncItemWithOriginal(
|
|
tabGroup,
|
|
newGroup,
|
|
win,
|
|
SYNC_FLAG_ICON | SYNC_FLAG_LABEL | SYNC_FLAG_MOVE
|
|
);
|
|
});
|
|
}
|
|
|
|
on_TabGroupRemoved(aEvent) {
|
|
const tabGroup = aEvent.target;
|
|
const window = tabGroup.documentGlobal;
|
|
this.#runOnAllWindows(window, win => {
|
|
const targetGroup = this.getItemFromWindow(win, tabGroup.id);
|
|
if (targetGroup) {
|
|
if (targetGroup.isZenFolder) {
|
|
targetGroup.delete();
|
|
} else {
|
|
win.gBrowser.removeTabGroup(targetGroup, { isUserTriggered: true });
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
on_TabGroupMoved(aEvent) {
|
|
return this.on_TabMove(aEvent);
|
|
}
|
|
|
|
on_TabGroupUpdate(aEvent) {
|
|
return this.#delegateGenericSyncEvent(
|
|
aEvent,
|
|
SYNC_FLAG_ICON | SYNC_FLAG_LABEL
|
|
);
|
|
}
|
|
|
|
on_TabUngrouped() {
|
|
// No need to sync anything when a tab is ungrouped, since on_TabMove will take
|
|
// care of moving the tab to the correct position. We still need to listen to this
|
|
// in order to throw sync events for other components such as live folders to
|
|
// update their state, but we don't need to do anything here.
|
|
return Promise.resolve();
|
|
}
|
|
|
|
on_ZenTabRemovedFromSplit(aEvent) {
|
|
const tab = aEvent.target;
|
|
const window = tab.documentGlobal;
|
|
this.#runOnAllWindows(window, win => {
|
|
const targetTab = this.getItemFromWindow(win, tab.id);
|
|
if (targetTab && win.gZenViewSplitter) {
|
|
win.gZenViewSplitter.removeTabFromGroup(targetTab, undefined, {
|
|
changeTab: false,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
on_ZenSplitViewTabsSplit(aEvent) {
|
|
const tabGroup = aEvent.target;
|
|
const window = tabGroup.documentGlobal;
|
|
const tabs = tabGroup.tabs;
|
|
this.#runOnAllWindows(window, win => {
|
|
const otherWindowTabs = tabs
|
|
.map(tab => this.getItemFromWindow(win, tab.id))
|
|
.filter(Boolean);
|
|
if (otherWindowTabs.length && win.gZenViewSplitter) {
|
|
const group = win.gZenViewSplitter.splitTabs(
|
|
otherWindowTabs,
|
|
undefined,
|
|
-1,
|
|
{
|
|
groupFetchId: tabGroup.id,
|
|
}
|
|
);
|
|
if (group) {
|
|
let otherTabGroup = group.tabs[0].group;
|
|
otherTabGroup.id = tabGroup.id;
|
|
this.#syncItemWithOriginal(
|
|
aEvent.target,
|
|
otherTabGroup,
|
|
win,
|
|
SYNC_FLAG_MOVE
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
return new Promise(resolve => {
|
|
lazy.setTimeout(() => {
|
|
this.#onTabSwitchOrWindowFocus(window, null).finally(resolve);
|
|
}, 0);
|
|
});
|
|
}
|
|
}
|
|
|
|
// eslint-disable-next-line mozilla/valid-lazy
|
|
export const gWindowSyncEnabled = lazy.gWindowSyncEnabled;
|
|
// eslint-disable-next-line mozilla/valid-lazy
|
|
export const gSyncOnlyPinnedTabs = lazy.gSyncOnlyPinnedTabs;
|
|
export const ZenWindowSync = new nsZenWindowSync();
|