Files
desktop/src/zen/sessionstore/ZenWindowSync.sys.mjs
mr. m 578c28df92 feat: Allow tabs to have custom icons and other cleanups, p=#11697
* feat: Allow tabs to have custom icons and other cleanups, b=closes #11686, closees https://github.com/zen-browser/desktop/issues/9972, closes https://github.com/zen-browser/desktop/issues/9251, c=folders, workspaces, tabs, common


* chore: Lint, b=no-bug, c=tabs
2025-12-22 20:26:44 +01:00

1034 lines
35 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/. */
import { XPCOMUtils } from 'resource://gre/modules/XPCOMUtils.sys.mjs';
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
BrowserWindowTracker: 'resource:///modules/BrowserWindowTracker.sys.mjs',
SessionStore: 'resource:///modules/sessionstore/SessionStore.sys.mjs',
TabStateFlusher: 'resource:///modules/sessionstore/TabStateFlusher.sys.mjs',
ZenSessionStore: 'resource:///modules/zen/ZenSessionManager.sys.mjs',
});
XPCOMUtils.defineLazyPreferenceGetter(lazy, 'gWindowSyncEnabled', 'zen.window-sync.enabled');
XPCOMUtils.defineLazyPreferenceGetter(lazy, 'gShouldLog', 'zen.window-sync.log', true);
const OBSERVING = ['browser-window-before-show'];
const INSTANT_EVENTS = ['SSWindowClosing'];
const EVENTS = [
'TabOpen',
'TabClose',
'ZenTabIconChanged',
'ZenTabLabelChanged',
'TabMove',
'TabPinned',
'TabUnpinned',
'TabAddedToEssentials',
'TabRemovedFromEssentials',
'TabGroupUpdate',
'TabGroupCreate',
'TabGroupRemoved',
'TabGroupMoved',
'ZenTabRemovedFromSplit',
'ZenSplitViewTabsSplit',
'TabSelect',
'focus',
...INSTANT_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 {
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(),
};
/**
* 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;
/**
* 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.gZenStartup.isReady &&
!window.gZenWorkspaces?.privateWindowOrDisabled
) {
yield window;
}
}
},
};
init() {
if (!lazy.gWindowSyncEnabled) {
return;
}
for (let topic of OBSERVING) {
Services.obs.addObserver(this, topic);
}
}
uninit() {
for (let topic of OBSERVING) {
Services.obs.removeObserver(this, topic);
}
}
log(...args) {
if (lazy.gShouldLog) {
console.info('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) {
// 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;
if (aWindow._zenStartupSyncFlag === 'synced') {
forcedSync = true;
} else if (aWindow._zenStartupSyncFlag === 'unsynced') {
hasUnsyncedArg = true;
}
delete aWindow._zenStartupSyncFlag;
if (
!forcedSync &&
(hasUnsyncedArg ||
(typeof aWindow.arguments[0] === 'string' &&
aWindow.arguments.length > 1 &&
[...this.#browserWindows].length > 0))
) {
this.log('Not syncing new window due to unsynced argument or existing synced windows');
aWindow.document.documentElement.setAttribute('zen-unsynced-window', 'true');
return;
}
aWindow.gZenWindowSync = this;
for (let eventName of EVENTS) {
aWindow.addEventListener(eventName, this, 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;
}
observe(aSubject, aTopic) {
switch (aTopic) {
case 'browser-window-before-show': {
this.#onWindowBeforeShow(aSubject);
break;
}
}
}
handleEvent(aEvent) {
const window = aEvent.currentTarget.ownerGlobal;
if (
!window.gZenStartup.isReady ||
window.gZenWorkspaces?.privateWindowOrDisabled ||
window._zenClosingWindow
) {
return;
}
if (INSTANT_EVENTS.includes(aEvent.type)) {
return this.#handleNextEvent(aEvent);
}
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();
});
});
}
/**
* Handles the next event by calling the appropriate handler method.
*
* @param {Event} aEvent - The event to handle.
*/
#handleNextEvent(aEvent) {
const handler = `on_${aEvent.type}`;
try {
if (typeof this[handler] === 'function') {
return this[handler](aEvent) || Promise.resolve();
} else {
throw new Error(`No handler for event type: ${aEvent.type}`);
}
} catch (e) {
return Promise.reject(e);
}
}
/**
* Ensures that all synced tabs with a given ID has the same permanentKey.
* @param {Object} aTab - The tab to ensure sync for.
*/
#makeSureTabSyncsPermanentKey(aTab) {
if (!aTab.id) {
return;
}
this.#runOnAllWindows(null, (win) => {
const tab = this.#getItemFromWindow(win, aTab.id);
if (tab) {
tab.permanentKey = aTab.linkedBrowser.permanentKey;
}
});
}
/**
* 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) {
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} aOriginalTab - The original tab to copy from.
* @param {Object} aTargetTab - The target tab 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.removeAttribute('zen-has-static-icon');
if (gBrowser.isTab(aOriginalItem)) {
gBrowser.setIcon(aTargetItem, gBrowser.getIcon(aOriginalItem));
} else if (aOriginalItem.isZenFolder) {
// Icons are a zen-only feature for tab groups.
gZenFolders.setFolderUserIcon(aTargetItem, aOriginalItem.iconURL);
}
this.#maybeSyncAttributeChange(aOriginalItem, aTargetItem, 'zen-has-static-icon');
}
if (flags & SYNC_FLAG_LABEL) {
if (gBrowser.isTab(aOriginalItem)) {
aTargetItem._zenChangeLabelFlag = true;
gBrowser._setTabLabel(aTargetItem, aOriginalItem.label);
delete aTargetItem._zenChangeLabelFlag;
this.#maybeSyncAttributeChange(aOriginalItem, aTargetItem, 'zen-has-static-label');
} 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 (gBrowser.isTab(aTargetItem)) {
lazy.TabStateFlusher.flush(aTargetItem.linkedBrowser);
}
}
/**
* 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 (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;
const originalSibling = aOriginalItem.previousElementSibling;
let isFirstTab = true;
if (gBrowser.isTabGroup(originalSibling) || gBrowser.isTab(originalSibling)) {
isFirstTab =
!originalSibling.hasAttribute('id') || originalSibling.hasAttribute('zen-empty-tab');
}
gBrowser.zenHandleTabMove(aOriginalItem, () => {
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.ownerGlobal.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) {
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.ownerGlobal;
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) {
await this.#styleSwapedBrowsers(aOurTab, aOtherTab, () => {
this.#swapBrowserDocSheellsInner(aOurTab, aOtherTab);
});
}
/**
* 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.ownerGlobal.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) {
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);
}
}
/**
* 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 {boolean} focus - Indicates if the tab should be focused after the swap.
* @param {boolean} onClose - Indicates if the swap is done during a tab close operation.
*/
#swapBrowserDocSheellsInner(aOurTab, aOtherTab, focus = true, onClose = false) {
// Can't swap between chrome and content processes.
if (aOurTab.linkedBrowser.isRemoteBrowser != aOtherTab.linkedBrowser.isRemoteBrowser) {
return false;
}
// 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 && aOurTab.linkedBrowser?.currentURI.spec !== 'about:blank') {
this.log(`Loading about:blank in our tab ${aOurTab.id} before swap`);
aOurTab.linkedBrowser.loadURI(Services.io.newURI('about:blank'), {
triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_REPLACE_HISTORY,
});
}
// 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}`);
aOurTab.ownerGlobal.gBrowser.swapBrowsersAndCloseOther(aOurTab, aOtherTab, false);
this.#makeSureTabSyncsPermanentKey(aOurTab);
if (!aOtherTab.hasAttribute('busy')) {
aOurTab.removeAttribute('busy');
}
},
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.ownerGlobal.gBrowser._adjustFocusAfterTabSwitch(aOurTab);
}
// Ensure the tab's state is flushed after the swap. By doing this,
// we can re-schedule another session store delayed process to fire.
// It's also important to note that if we don't flush the state here,
// we would start receiving invalid history changes from the the incorrect
// browser view that was just swapped out.
lazy.TabStateFlusher.flush(aOurTab.linkedBrowser);
return 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.
*/
async #styleSwapedBrowsers(aOurTab, aOtherTab, callback = undefined) {
const ourBrowser = aOurTab.linkedBrowser;
const otherBrowser = aOtherTab.linkedBrowser;
if (callback) {
const browserBlob = await aOtherTab.ownerGlobal.PageThumbs.captureToBlob(
aOtherTab.linkedBrowser,
{
fullScale: true,
fullViewport: true,
}
);
let mySrc = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(browserBlob);
reader.onloadend = function () {
// result includes identifier 'data:image/png;base64,' plus the base64 data
resolve(reader.result);
};
reader.onerror = function () {
reject(new Error('Failed to read blob as data URL'));
};
});
const [img, loadPromise] = this.#createPseudoImageForBrowser(otherBrowser, mySrc);
// Run a reflow to ensure the image is rendered before hiding the browser.
void img.getBoundingClientRect();
await loadPromise;
otherBrowser.setAttribute('zen-pseudo-hidden', 'true');
callback();
}
this.#maybeRemovePseudoImageForBrowser(ourBrowser);
ourBrowser.removeAttribute('zen-pseudo-hidden');
}
/**
* 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.
* @returns {Object} The created pseudo image element.
*/
#createPseudoImageForBrowser(aBrowser, aSrc) {
const doc = aBrowser.ownerDocument;
const img = doc.createElement('img');
img.className = 'zen-pseudo-browser-image';
aBrowser.after(img);
const loadPromise = new Promise((resolve) => {
img.onload = () => resolve();
img.src = aSrc;
});
return [img, loadPromise];
}
/**
* 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;
}
});
}
/**
* Moves all active tabs from the specified window to other windows.
*
* @param {Window} aWindow - The window to move active tabs from.
*/
#moveAllActiveTabsToOtherWindows(aWindow) {
const mostRecentWindow = [...this.#browserWindows].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) {
targetTab._zenContentsVisible = true;
this.log(`Moving active tab ${tab.id} to most recent window on close`);
this.#swapBrowserDocSheellsInner(targetTab, tab, targetTab.selected, /* onClose =*/ true);
// 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.
* @param {boolean} ignoreSameTab - Indicates if the same tab should be ignored.
*/
async #onTabSwitchOrWindowFocus(aWindow, aPreviousTab = null, ignoreSameTab = false) {
// On some occasions, such as when closing a window, this
// function might be called multiple times for the same tab.
if (aWindow.gBrowser.selectedTab === this.#lastSelectedTab && !ignoreSameTab) {
return;
}
if (aPreviousTab?._zenContentsVisible) {
const otherTabToShow = this.#getActiveTabFromOtherWindows(
aWindow,
aPreviousTab.id,
(tab) => tab?.selected
);
if (otherTabToShow) {
otherTabToShow._zenContentsVisible = true;
delete aPreviousTab._zenContentsVisible;
await this.#swapBrowserDocShellsAsync(otherTabToShow, aPreviousTab);
}
}
let promises = [];
for (const browserView of aWindow.gBrowser.selectedBrowsers) {
const selectedTab = aWindow.gBrowser.getTabForBrowser(browserView);
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;
this.#syncItemForAllWindows(item, flags);
}
/**
* Retrieves the tab state for a given tab.
*
* @param {Object} tab - The tab to retrieve the state for.
* @returns {Object} The tab state.
*/
#getTabState(tab) {
return JSON.parse(lazy.SessionStore.getTabState(tab));
}
/* 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.
*/
setPinnedTabState(aTab) {
const state = this.#getTabState(aTab);
const initialState = {
entry: state.entries[state.index - 1],
image: state.image,
};
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.#browserWindows][0];
const moveAllTabsToWindow = async (allowSelected = false) => {
const { gBrowser, gZenWorkspaces } = win;
win.focus();
let success = true;
for (const tab of tabsToMove) {
if (tab !== selectedTab || allowSelected) {
const newTab = gBrowser.adoptTab(tab, { tabIndex: Infinity });
if (!newTab) {
// The adoption failed. Restore "fadein" and don't increase the index.
tab.setAttribute('fadein', 'true');
success = false;
continue;
}
gZenWorkspaces.moveTabToWorkspace(newTab, aWorkspaceId);
}
}
if (success) {
aWindow.close();
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.gZenWorkspaces.promiseInitialized.then(() => {
moveAllTabsToWindow();
});
return;
}
moveAllTabsToWindow(true);
}
/* Mark: Event Handlers */
on_TabOpen(aEvent) {
const tab = aEvent.target;
const window = tab.ownerGlobal;
if (tab.id) {
// This tab was opened as part of a sync operation.
return;
}
tab._zenContentsVisible = true;
tab.id = this.#newTabSyncId;
this.#runOnAllWindows(window, (win) => {
const newTab = win.gBrowser.addTrustedTab('about:blank', {
animate: true,
createLazyBrowser: true,
zenWorkspaceId: tab.getAttribute('zen-workspace-id') || '',
_forZenEmptyTab: tab.hasAttribute('zen-empty-tab'),
});
newTab.id = tab.id;
this.#syncItemWithOriginal(
tab,
newTab,
win,
SYNC_FLAG_ICON | SYNC_FLAG_LABEL | SYNC_FLAG_MOVE
);
});
this.#makeSureTabSyncsPermanentKey(tab);
}
on_ZenTabIconChanged(aEvent) {
if (!aEvent.target?._zenContentsVisible) {
// No need to sync icon changes for tabs that aren't active in this window.
return;
}
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_TabMove(aEvent) {
return this.#delegateGenericSyncEvent(aEvent, SYNC_FLAG_MOVE);
}
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.
if (!tab._zenPinnedInitialState) {
this.setPinnedTabState(tab);
}
return this.on_TabMove(aEvent);
}
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);
}
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.ownerGlobal;
this.#runOnAllWindows(window, (win) => {
const targetTab = this.#getItemFromWindow(win, tab.id);
if (targetTab) {
win.gBrowser.removeTab(targetTab, { animate: true });
}
});
}
on_focus(aEvent) {
const { ownerGlobal: window } = aEvent.target;
if (
!window.gBrowser ||
this.#lastFocusedWindow?.deref() === window ||
window.closing ||
!window.toolbar.visible
) {
return;
}
this.#lastFocusedWindow = new WeakRef(window);
this.#lastSelectedTab = new WeakRef(window.gBrowser.selectedTab);
return this.#onTabSwitchOrWindowFocus(window);
}
on_TabSelect(aEvent) {
const tab = aEvent.target;
if (this.#lastSelectedTab?.deref() === tab) {
return;
}
this.#lastSelectedTab = new WeakRef(tab);
const previousTab = aEvent.detail.previousTab;
return this.#onTabSwitchOrWindowFocus(aEvent.target.ownerGlobal, previousTab);
}
on_SSWindowClosing(aEvent) {
const window = aEvent.target.ownerGlobal;
window._zenClosingWindow = true;
for (let eventName of EVENTS) {
window.removeEventListener(eventName, this);
}
delete window.gZenWindowSync;
this.#moveAllActiveTabsToOtherWindows(window);
}
on_TabGroupCreate(aEvent) {
const tabGroup = aEvent.target;
if (tabGroup.id && tabGroup.alreadySynced) {
// This tab group was opened as part of a sync operation.
return;
}
const window = tabGroup.ownerGlobal;
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) => {
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.ownerGlobal;
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_ZenTabRemovedFromSplit(aEvent) {
const tab = aEvent.target;
const window = tab.ownerGlobal;
this.#runOnAllWindows(window, (win) => {
const targetTab = this.#getItemFromWindow(win, tab.id);
if (targetTab && win.gZenViewSplitter) {
win.gZenViewSplitter.removeTabFromGroup(targetTab);
}
});
}
on_ZenSplitViewTabsSplit(aEvent) {
const tabGroup = aEvent.target;
const window = tabGroup.ownerGlobal;
const tabs = tabGroup.tabs;
this.#runOnAllWindows(window, (win) => {
const otherWindowTabs = tabs
.map((tab) => this.#getItemFromWindow(win, tab.id))
.filter(Boolean);
if (otherWindowTabs.length > 0 && win.gZenViewSplitter) {
const group = win.gZenViewSplitter.splitTabs(otherWindowTabs, 'grid', -1);
if (group) {
let otherTabGroup = group.tabs[0].group;
otherTabGroup.id = tabGroup.id;
this.#syncItemWithOriginal(aEvent.target, otherTabGroup, win, SYNC_FLAG_MOVE);
}
}
});
return this.#onTabSwitchOrWindowFocus(window, null, /* ignoreSameTab = */ true);
}
}
export const ZenWindowSync = new nsZenWindowSync();