diff --git a/.gitmodules b/.gitmodules
index 145ac86df..cfb9d2b93 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,6 +1,3 @@
-[submodule "src/browser/base/content/zen-components"]
- path = src/browser/base/content/zen-components
- url = https://github.com/zen-browser/components
[submodule "l10n"]
path = l10n
url = https://github.com/zen-browser/l10n-packs
diff --git a/src/browser/base/content/zen-assets.jar.inc.mn b/src/browser/base/content/zen-assets.jar.inc.mn
index 2f86ad5dd..db767710e 100644
--- a/src/browser/base/content/zen-assets.jar.inc.mn
+++ b/src/browser/base/content/zen-assets.jar.inc.mn
@@ -4,24 +4,24 @@
content/browser/ZenStartup.mjs (content/ZenStartup.mjs)
content/browser/ZenUIManager.mjs (content/ZenUIManager.mjs)
content/browser/ZenCustomizableUI.sys.mjs (content/ZenCustomizableUI.sys.mjs)
- content/browser/zen-components/ZenCompactMode.mjs (content/zen-components/src/ZenCompactMode.mjs)
- content/browser/zen-components/ZenViewSplitter.mjs (content/zen-components/src/ZenViewSplitter.mjs)
- content/browser/zen-components/ZenThemesCommon.mjs (content/zen-components/src/ZenThemesCommon.mjs)
- content/browser/zen-components/ZenWorkspaces.mjs (content/zen-components/src/ZenWorkspaces.mjs)
- content/browser/zen-components/ZenWorkspacesStorage.mjs (content/zen-components/src/ZenWorkspacesStorage.mjs)
- content/browser/zen-components/ZenWorkspacesSync.mjs (content/zen-components/src/ZenWorkspacesSync.mjs)
- content/browser/zen-components/ZenSidebarManager.mjs (content/zen-components/src/ZenSidebarManager.mjs)
- content/browser/zen-components/ZenProfileDialogUI.mjs (content/zen-components/src/ZenProfileDialogUI.mjs)
- content/browser/zen-components/ZenKeyboardShortcuts.mjs (content/zen-components/src/ZenKeyboardShortcuts.mjs)
- content/browser/zen-components/ZenThemeBuilder.mjs (content/zen-components/src/ZenThemeBuilder.mjs)
- content/browser/zen-components/ZenThemesImporter.mjs (content/zen-components/src/ZenThemesImporter.mjs)
- content/browser/zen-components/ZenTabUnloader.mjs (content/zen-components/src/ZenTabUnloader.mjs)
- content/browser/zen-components/ZenPinnedTabsStorage.mjs (content/zen-components/src/ZenPinnedTabsStorage.mjs)
- content/browser/zen-components/ZenPinnedTabManager.mjs (content/zen-components/src/ZenPinnedTabManager.mjs)
- content/browser/zen-components/ZenCommonUtils.mjs (content/zen-components/src/ZenCommonUtils.mjs)
- content/browser/zen-components/ZenGradientGenerator.mjs (content/zen-components/src/ZenGradientGenerator.mjs)
- content/browser/zen-components/ZenGlanceManager.mjs (content/zen-components/src/ZenGlanceManager.mjs)
- content/browser/zen-components/ZenActorsManager.mjs (content/zen-components/src/ZenActorsManager.mjs)
+ content/browser/zen-components/ZenCompactMode.mjs (zen-components/ZenCompactMode.mjs)
+ content/browser/zen-components/ZenViewSplitter.mjs (zen-components/ZenViewSplitter.mjs)
+ content/browser/zen-components/ZenThemesCommon.mjs (zen-components/ZenThemesCommon.mjs)
+ content/browser/zen-components/ZenWorkspaces.mjs (zen-components/ZenWorkspaces.mjs)
+ content/browser/zen-components/ZenWorkspacesStorage.mjs (zen-components/ZenWorkspacesStorage.mjs)
+ content/browser/zen-components/ZenWorkspacesSync.mjs (zen-components/ZenWorkspacesSync.mjs)
+ content/browser/zen-components/ZenSidebarManager.mjs (zen-components/ZenSidebarManager.mjs)
+ content/browser/zen-components/ZenProfileDialogUI.mjs (zen-components/ZenProfileDialogUI.mjs)
+ content/browser/zen-components/ZenKeyboardShortcuts.mjs (zen-components/ZenKeyboardShortcuts.mjs)
+ content/browser/zen-components/ZenThemeBuilder.mjs (zen-components/ZenThemeBuilder.mjs)
+ content/browser/zen-components/ZenThemesImporter.mjs (zen-components/ZenThemesImporter.mjs)
+ content/browser/zen-components/ZenTabUnloader.mjs (zen-components/ZenTabUnloader.mjs)
+ content/browser/zen-components/ZenPinnedTabsStorage.mjs (zen-components/ZenPinnedTabsStorage.mjs)
+ content/browser/zen-components/ZenPinnedTabManager.mjs (zen-components/ZenPinnedTabManager.mjs)
+ content/browser/zen-components/ZenCommonUtils.mjs (zen-components/ZenCommonUtils.mjs)
+ content/browser/zen-components/ZenGradientGenerator.mjs (zen-components/ZenGradientGenerator.mjs)
+ content/browser/zen-components/ZenGlanceManager.mjs (zen-components/ZenGlanceManager.mjs)
+ content/browser/zen-components/ZenActorsManager.mjs (zen-components/ZenActorsManager.mjs)
content/browser/zen-styles/zen-theme.css (content/zen-styles/zen-theme.css)
content/browser/zen-styles/zen-buttons.css (content/zen-styles/zen-buttons.css)
@@ -56,7 +56,7 @@
content/browser/zen-images/gradient-display.png (content/zen-images/gradient-display.png)
# Actors
- content/browser/zen-components/actors/ZenThemeMarketplaceParent.sys.mjs (content/zen-components/src/actors/ZenThemeMarketplaceParent.sys.mjs)
- content/browser/zen-components/actors/ZenThemeMarketplaceChild.sys.mjs (content/zen-components/src/actors/ZenThemeMarketplaceChild.sys.mjs)
- content/browser/zen-components/actors/ZenGlanceChild.sys.mjs (content/zen-components/src/actors/ZenGlanceChild.sys.mjs)
- content/browser/zen-components/actors/ZenGlanceParent.sys.mjs (content/zen-components/src/actors/ZenGlanceParent.sys.mjs)
+ content/browser/zen-components/actors/ZenThemeMarketplaceParent.sys.mjs (zen-components/actors/ZenThemeMarketplaceParent.sys.mjs)
+ content/browser/zen-components/actors/ZenThemeMarketplaceChild.sys.mjs (zen-components/actors/ZenThemeMarketplaceChild.sys.mjs)
+ content/browser/zen-components/actors/ZenGlanceChild.sys.mjs (zen-components/actors/ZenGlanceChild.sys.mjs)
+ content/browser/zen-components/actors/ZenGlanceParent.sys.mjs (zen-components/actors/ZenGlanceParent.sys.mjs)
diff --git a/src/browser/base/content/zen-components b/src/browser/base/content/zen-components
deleted file mode 160000
index 037547460..000000000
--- a/src/browser/base/content/zen-components
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit 037547460d0e25a276ad340e1351d3acd9d29f60
diff --git a/src/browser/base/zen-components/ZenActorsManager.mjs b/src/browser/base/zen-components/ZenActorsManager.mjs
new file mode 100644
index 000000000..404895247
--- /dev/null
+++ b/src/browser/base/zen-components/ZenActorsManager.mjs
@@ -0,0 +1,19 @@
+
+// Utility to register JSWindowActors
+var gZenActorsManager = {
+ _actors: new Set(),
+
+ addJSWindowActor(...args) {
+ if (this._actors.has(args[0])) {
+ // Actor already registered, nothing to do
+ return;
+ }
+
+ try {
+ ChromeUtils.registerWindowActor(...args);
+ this._actors.add(args[0]);
+ } catch (e) {
+ console.warn(`Failed to register JSWindowActor: ${e}`);
+ }
+ },
+}
diff --git a/src/browser/base/zen-components/ZenCommonUtils.mjs b/src/browser/base/zen-components/ZenCommonUtils.mjs
new file mode 100644
index 000000000..cf8aec999
--- /dev/null
+++ b/src/browser/base/zen-components/ZenCommonUtils.mjs
@@ -0,0 +1,55 @@
+var gZenOperatingSystemCommonUtils = {
+ kZenOSToSmallName: {
+ WINNT: 'windows',
+ Darwin: 'macos',
+ Linux: 'linux',
+ },
+
+ get currentOperatingSystem() {
+ let os = Services.appinfo.OS;
+ return this.kZenOSToSmallName[os];
+ },
+};
+
+class ZenMultiWindowFeature {
+ constructor() {}
+
+ static get browsers() {
+ return Services.wm.getEnumerator('navigator:browser');
+ }
+
+ static get currentBrowser() {
+ return Services.wm.getMostRecentWindow('navigator:browser');
+ }
+
+ isActiveWindow() {
+ return ZenMultiWindowFeature.currentBrowser === window;
+ }
+
+ async foreachWindowAsActive(callback) {
+ if (!this.isActiveWindow()) {
+ return;
+ }
+ for (const browser of ZenMultiWindowFeature.browsers) {
+ try {
+ await callback(browser);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ }
+}
+
+class ZenDOMOperatedFeature {
+ constructor() {
+ var initBound = this.init.bind(this);
+ document.addEventListener('DOMContentLoaded', initBound, { once: true });
+ }
+}
+
+class ZenPreloadedFeature {
+ constructor() {
+ var initBound = this.init.bind(this);
+ document.addEventListener('MozBeforeInitialXULLayout', initBound, { once: true });
+ }
+}
diff --git a/src/browser/base/zen-components/ZenCompactMode.mjs b/src/browser/base/zen-components/ZenCompactMode.mjs
new file mode 100644
index 000000000..9a446e4f8
--- /dev/null
+++ b/src/browser/base/zen-components/ZenCompactMode.mjs
@@ -0,0 +1,253 @@
+const lazyCompactMode = {};
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazyCompactMode,
+ 'COMPACT_MODE_FLASH_DURATION',
+ 'zen.view.compact.toolbar-flash-popup.duration',
+ 800
+);
+
+var gZenCompactModeManager = {
+ _flashTimeouts: {},
+ _evenListeners: [],
+ _removeHoverFrames: {},
+
+ init() {
+ Services.prefs.addObserver('zen.view.compact', this._updateEvent.bind(this));
+ Services.prefs.addObserver('zen.view.sidebar-expanded.on-hover', this._disableTabsOnHoverIfConflict.bind(this));
+ Services.prefs.addObserver('zen.tabs.vertical.right-side', this._updateSidebarIsOnRight.bind(this));
+
+ gZenUIManager.addPopupTrackingAttribute(this.sidebar);
+ gZenUIManager.addPopupTrackingAttribute(document.getElementById('zen-appcontent-navbar-container'));
+
+ this.addMouseActions();
+ this.addContextMenu();
+ },
+
+ get prefefence() {
+ return Services.prefs.getBoolPref('zen.view.compact');
+ },
+
+ set preference(value) {
+ Services.prefs.setBoolPref('zen.view.compact', value);
+ return value;
+ },
+
+ get sidebarIsOnRight() {
+ if (this._sidebarIsOnRight) {
+ return this._sidebarIsOnRight;
+ }
+ return Services.prefs.getBoolPref('zen.tabs.vertical.right-side');
+ },
+
+ get sidebar() {
+ if (!this._sidebar) {
+ this._sidebar = document.getElementById('navigator-toolbox');
+ }
+ return this._sidebar;
+ },
+
+ addContextMenu() {
+ const fragment = window.MozXULElement.parseXULToFragment(`
+
+ `);
+ document.getElementById('viewToolbarsMenuSeparator').before(fragment);
+ this.updateContextMenu();
+ },
+
+ hideSidebar() {
+ Services.prefs.setBoolPref('zen.view.compact.hide-tabbar', true);
+ Services.prefs.setBoolPref('zen.view.compact.hide-toolbar', false);
+ },
+
+ hideToolbar() {
+ Services.prefs.setBoolPref('zen.view.compact.hide-toolbar', true);
+ Services.prefs.setBoolPref('zen.view.compact.hide-tabbar', false);
+ },
+
+ hideBoth() {
+ Services.prefs.setBoolPref('zen.view.compact.hide-tabbar', true);
+ Services.prefs.setBoolPref('zen.view.compact.hide-toolbar', true);
+ },
+
+ addEventListener(callback) {
+ this._evenListeners.push(callback);
+ },
+
+ _updateEvent() {
+ this._evenListeners.forEach((callback) => callback());
+ this._disableTabsOnHoverIfConflict();
+ this.updateContextMenu();
+ },
+
+ updateContextMenu() {
+ document
+ .getElementById('zen-context-menu-compact-mode-toggle')
+ .setAttribute('checked', Services.prefs.getBoolPref('zen.view.compact'));
+
+ const hideTabBar = Services.prefs.getBoolPref('zen.view.compact.hide-tabbar');
+ const hideToolbar = Services.prefs.getBoolPref('zen.view.compact.hide-toolbar');
+ const hideBoth = hideTabBar && hideToolbar;
+
+ const idName = 'zen-context-menu-compact-mode-hide-';
+ document.getElementById(idName + 'sidebar').setAttribute('checked', !hideBoth && hideTabBar);
+ document.getElementById(idName + 'toolbar').setAttribute('checked', !hideBoth && hideToolbar);
+ document.getElementById(idName + 'both').setAttribute('checked', hideBoth);
+ },
+
+ _removeOpenStateOnUnifiedExtensions() {
+ // Fix for bug https://github.com/zen-browser/desktop/issues/1925
+ const buttons = document.querySelectorAll('toolbarbutton:is(#unified-extensions-button, .webextension-browser-action)');
+ for (let button of buttons) {
+ button.removeAttribute('open');
+ }
+ },
+
+ _disableTabsOnHoverIfConflict() {
+ if (Services.prefs.getBoolPref('zen.view.compact') && Services.prefs.getBoolPref('zen.view.compact.hide-tabbar')) {
+ Services.prefs.setBoolPref('zen.view.sidebar-expanded.on-hover', false);
+ }
+ },
+
+ toggle() {
+ return (this.preference = !this.prefefence);
+ },
+
+ _updateSidebarIsOnRight() {
+ this._sidebarIsOnRight = Services.prefs.getBoolPref('zen.tabs.vertical.right-side');
+ },
+
+ toggleSidebar() {
+ this.sidebar.toggleAttribute('zen-user-show');
+ },
+
+ get hideAfterHoverDuration() {
+ if (this._hideAfterHoverDuration) {
+ return this._hideAfterHoverDuration;
+ }
+ return Services.prefs.getIntPref('zen.view.compact.toolbar-hide-after-hover.duration');
+ },
+
+ get hoverableElements() {
+ return [
+ {
+ element: this.sidebar,
+ screenEdge: this.sidebarIsOnRight ? 'right' : 'left',
+ },
+ {
+ element: document.getElementById('zen-appcontent-navbar-container'),
+ screenEdge: 'top',
+ },
+ ];
+ },
+
+ flashSidebar(duration = lazyCompactMode.COMPACT_MODE_FLASH_DURATION) {
+ let tabPanels = document.getElementById('tabbrowser-tabpanels');
+ if (!tabPanels.matches("[zen-split-view='true']")) {
+ this.flashElement(this.sidebar, duration, this.sidebar.id);
+ }
+ },
+
+ flashElement(element, duration, id, attrName = 'flash-popup') {
+ if (element.matches(':hover')) {
+ return;
+ }
+ if (this._flashTimeouts[id]) {
+ clearTimeout(this._flashTimeouts[id]);
+ } else {
+ requestAnimationFrame(() => element.setAttribute(attrName, 'true'));
+ }
+ this._flashTimeouts[id] = setTimeout(() => {
+ window.requestAnimationFrame(() => {
+ element.removeAttribute(attrName);
+ this._flashTimeouts[id] = null;
+ });
+ }, duration);
+ },
+
+ clearFlashTimeout(id) {
+ clearTimeout(this._flashTimeouts[id]);
+ this._flashTimeouts[id] = null;
+ },
+
+ addMouseActions() {
+ for (let i = 0; i < this.hoverableElements.length; i++) {
+ let target = this.hoverableElements[i].element;
+ target.addEventListener('mouseenter', (event) => {
+ this.clearFlashTimeout('has-hover' + target.id);
+ window.requestAnimationFrame(() => target.setAttribute('zen-has-hover', 'true'));
+ });
+
+ target.addEventListener('mouseleave', (event) => {
+ // If on Mac, ignore mouseleave in the area of window buttons
+ if (AppConstants.platform == 'macosx') {
+ const MAC_WINDOW_BUTTONS_X_BORDER = 75;
+ const MAC_WINDOW_BUTTONS_Y_BORDER = 40;
+ if (event.clientX < MAC_WINDOW_BUTTONS_X_BORDER && event.clientY < MAC_WINDOW_BUTTONS_Y_BORDER) {
+ return;
+ }
+ }
+
+ if (this.hoverableElements[i].keepHoverDuration) {
+ this.flashElement(target, keepHoverDuration, 'has-hover' + target.id, 'zen-has-hover');
+ } else {
+ this._removeHoverFrames[target.id] = window.requestAnimationFrame(() => target.removeAttribute('zen-has-hover'));
+ }
+ });
+ }
+
+ document.documentElement.addEventListener('mouseleave', (event) => {
+ const screenEdgeCrossed = this._getCrossedEdge(event.pageX, event.pageY);
+ if (!screenEdgeCrossed) return;
+ for (let entry of this.hoverableElements) {
+ if (screenEdgeCrossed !== entry.screenEdge) continue;
+ const target = entry.element;
+ const boundAxis = entry.screenEdge === 'right' || entry.screenEdge === 'left' ? 'y' : 'x';
+ if (!this._positionInBounds(boundAxis, target, event.pageX, event.pageY, 7)) {
+ continue;
+ }
+ window.cancelAnimationFrame(this._removeHoverFrames[target.id]);
+
+ this.flashElement(target, this.hideAfterHoverDuration, 'has-hover' + target.id, 'zen-has-hover');
+ document.addEventListener(
+ 'mousemove',
+ () => {
+ if (target.matches(':hover')) return;
+ target.removeAttribute('zen-has-hover');
+ this.clearFlashTimeout('has-hover' + target.id);
+ },
+ { once: true }
+ );
+ }
+ });
+ },
+
+ _getCrossedEdge(posX, posY, element = document.documentElement, maxDistance = 10) {
+ const targetBox = element.getBoundingClientRect();
+ posX = Math.max(targetBox.left, Math.min(posX, targetBox.right));
+ posY = Math.max(targetBox.top, Math.min(posY, targetBox.bottom));
+ return ['top', 'bottom', 'left', 'right'].find((edge, i) => {
+ const distance = Math.abs((i < 2 ? posY : posX) - targetBox[edge]);
+ return distance <= maxDistance;
+ });
+ },
+
+ _positionInBounds(axis = 'x', element, x, y, error = 0) {
+ const bBox = element.getBoundingClientRect();
+ if (axis === 'y') return bBox.top - error < y && y < bBox.bottom + error;
+ else return bBox.left - error < x && x < bBox.right + error;
+ },
+
+ toggleToolbar() {
+ let toolbar = document.getElementById('zen-appcontent-navbar-container');
+ toolbar.toggleAttribute('zen-user-show');
+ },
+};
diff --git a/src/browser/base/zen-components/ZenGlanceManager.mjs b/src/browser/base/zen-components/ZenGlanceManager.mjs
new file mode 100644
index 000000000..f4bb5146c
--- /dev/null
+++ b/src/browser/base/zen-components/ZenGlanceManager.mjs
@@ -0,0 +1,352 @@
+
+{
+
+ class ZenGlanceManager extends ZenDOMOperatedFeature {
+ #currentBrowser = null;
+ #currentTab = null;
+
+ #animating = false;
+
+ init() {
+ document.documentElement.setAttribute("zen-glance-uuid", gZenUIManager.generateUuidv4());
+ window.addEventListener("keydown", this.onKeyDown.bind(this));
+ window.addEventListener("TabClose", this.onTabClose.bind(this));
+
+ ChromeUtils.defineLazyGetter(
+ this,
+ 'sidebarButtons',
+ () => document.getElementById('zen-glance-sidebar-container')
+ );
+
+ document.getElementById('tabbrowser-tabpanels').addEventListener("click", this.onOverlayClick.bind(this));
+
+ Services.obs.addObserver(this, "quit-application-requested");
+ }
+
+ onKeyDown(event) {
+ if (event.key === "Escape" && this.#currentBrowser) {
+ event.preventDefault();
+ event.stopPropagation();
+ this.closeGlance();
+ }
+ }
+
+ onOverlayClick(event) {
+ if (event.target === this.overlay && event.originalTarget !== this.contentWrapper) {
+ this.closeGlance();
+ }
+ }
+
+ observe(subject, topic) {
+ switch (topic) {
+ case "quit-application-requested":
+ this.onUnload();
+ break;
+ }
+ }
+
+ onUnload() {
+ // clear everything
+ if (this.#currentBrowser) {
+ gBrowser.removeTab(this.#currentTab);
+ }
+ }
+
+ createBrowserElement(url, currentTab) {
+ const newTabOptions = {
+ userContextId: currentTab.getAttribute("usercontextid") || "",
+ skipBackgroundNotify: true,
+ insertTab: true,
+ skipLoad: false,
+ index: currentTab._tPos + 1,
+ };
+ this.currentParentTab = currentTab;
+ const newTab = gBrowser.addTrustedTab(Services.io.newURI(url).spec, newTabOptions);
+
+ gBrowser.selectedTab = newTab;
+ currentTab.querySelector(".tab-content").appendChild(newTab);
+ newTab.setAttribute("zen-glance-tab", true);
+ this.#currentBrowser = newTab.linkedBrowser;
+ this.#currentTab = newTab;
+ return this.#currentBrowser;
+ }
+
+ openGlance(data) {
+ if (this.#currentBrowser) {
+ return;
+ }
+
+ const initialX = data.x;
+ const initialY = data.y;
+ const initialWidth = data.width;
+ const initialHeight = data.height;
+
+ this.browserWrapper?.removeAttribute("animate");
+ this.browserWrapper?.removeAttribute("animate-end");
+ this.browserWrapper?.removeAttribute("animate-full");
+ this.browserWrapper?.removeAttribute("animate-full-end");
+ this.browserWrapper?.removeAttribute("has-finished-animation");
+
+ const url = data.url;
+ const currentTab = gBrowser.selectedTab;
+
+ this.animatingOpen = true;
+ const browserElement = this.createBrowserElement(url, currentTab);
+
+ this.overlay = browserElement.closest(".browserSidebarContainer");
+ this.browserWrapper = browserElement.closest(".browserContainer");
+ this.contentWrapper = browserElement.closest(".browserStack");
+
+ this.browserWrapper.prepend(this.sidebarButtons);
+
+ this.overlay.classList.add("zen-glance-overlay");
+
+ this.browserWrapper.removeAttribute("animate-end");
+ window.requestAnimationFrame(() => {
+ this.quickOpenGlance();
+
+ this.browserWrapper.style.setProperty("--initial-x", `${initialX}px`);
+ this.browserWrapper.style.setProperty("--initial-y", `${initialY}px`);
+ this.browserWrapper.style.setProperty("--initial-width", initialWidth + "px");
+ this.browserWrapper.style.setProperty("--initial-height", initialHeight + "px");
+
+ this.overlay.removeAttribute("fade-out");
+ this.browserWrapper.setAttribute("animate", true);
+ this.#animating = true;
+ setTimeout(() => {
+ this.browserWrapper.setAttribute("animate-end", true);
+ this.browserWrapper.setAttribute("has-finished-animation", true);
+ this.#animating = false;
+ this.animatingOpen = false;
+ }, 400);
+ });
+ }
+
+ closeGlance({ noAnimation = false, onTabClose = false } = {}) {
+ if (this.#animating || !this.#currentBrowser || this.animatingOpen || this._duringOpening) {
+ return;
+ }
+
+ this.browserWrapper.removeAttribute("has-finished-animation");
+ if (noAnimation) {
+ this.quickCloseGlance({ closeCurrentTab: false });
+ this.#currentBrowser = null;
+ this.#currentTab = null;
+ return;
+ }
+
+ gBrowser._insertTabAtIndex(this.#currentTab, {
+ index: this.currentParentTab._tPos + 1,
+ });
+
+ let quikcCloseZen = false;
+ if (onTabClose) {
+ // Create new tab if no more ex
+ if (gBrowser.tabs.length === 1) {
+ gBrowser.selectedTab = gZenUIManager.openAndChangeToTab(Services.prefs.getStringPref('browser.startup.homepage'));
+ return;
+ } else if (gBrowser.selectedTab === this.#currentTab) {
+ this._duringOpening = true;
+ gBrowser.tabContainer.advanceSelectedTab(1, true); // to skip the current tab
+ this._duringOpening = false;
+ quikcCloseZen = true;
+ }
+ }
+
+ // do NOT touch here, I don't know what it does, but it works...
+ window.requestAnimationFrame(() => {
+ this.#currentTab.style.display = "none";
+ this.browserWrapper.removeAttribute("animate");
+ this.browserWrapper.removeAttribute("animate-end");
+ this.overlay.setAttribute("fade-out", true);
+ window.requestAnimationFrame(() => {
+ this.browserWrapper.setAttribute("animate", true);
+ setTimeout(() => {
+ if (!this.currentParentTab) {
+ return;
+ }
+
+ if (!onTabClose || quikcCloseZen) {
+ this.quickCloseGlance();
+ }
+ this.overlay.removeAttribute("fade-out");
+ this.browserWrapper.removeAttribute("animate");
+
+ this.lastCurrentTab = this.#currentTab;
+
+ this.overlay.classList.remove("zen-glance-overlay");
+ gBrowser._getSwitcher().setTabStateNoAction(this.lastCurrentTab, gBrowser.AsyncTabSwitcher.STATE_UNLOADED);
+
+ if (!onTabClose && gBrowser.selectedTab === this.lastCurrentTab) {
+ this._duringOpening = true;
+ gBrowser.selectedTab = this.currentParentTab;
+ }
+
+ // reset everything
+ this.currentParentTab = null;
+ this.browserWrapper = null;
+ this.overlay = null;
+ this.contentWrapper = null;
+
+ this.lastCurrentTab.removeAttribute("zen-glance-tab");
+
+ gBrowser.tabContainer._invalidateCachedTabs();
+ this.lastCurrentTab.closing = true;
+ gBrowser.removeTab(this.lastCurrentTab, { animate: false });
+
+ this.#currentTab = null;
+ this.#currentBrowser = null;
+
+ this.lastCurrentTab = null;
+ this._duringOpening = false;
+ }, 500);
+ });
+ });
+ }
+
+ quickOpenGlance() {
+ if (!this.#currentBrowser || this._duringOpening) {
+ return;
+ }
+ this._duringOpening = true;
+ try {
+ gBrowser.selectedTab = this.#currentTab;
+ } catch (e) {}
+
+ this.currentParentTab.linkedBrowser.closest(".browserSidebarContainer").classList.add("deck-selected", "zen-glance-background");
+ this.currentParentTab.linkedBrowser.closest(".browserSidebarContainer").classList.remove("zen-glance-overlay");
+ this.currentParentTab.linkedBrowser.zenModeActive = true;
+ this.#currentBrowser.zenModeActive = true;
+ this.currentParentTab.linkedBrowser.docShellIsActive = true;
+ this.#currentBrowser.docShellIsActive = true;
+ this.#currentBrowser.setAttribute("zen-glance-selected", true);
+
+ this.currentParentTab._visuallySelected = true;
+ this.overlay.classList.add("deck-selected");
+
+ this._duringOpening = false;
+ }
+
+ quickCloseGlance({ closeCurrentTab = true, closeParentTab = true } = {}) {
+ const parentHasBrowser = !!(this.currentParentTab.linkedBrowser);
+ if (parentHasBrowser) {
+ if (closeParentTab) {
+ this.currentParentTab.linkedBrowser.closest(".browserSidebarContainer").classList.remove("deck-selected");
+ }
+ this.currentParentTab.linkedBrowser.zenModeActive = false;
+ }
+ this.#currentBrowser.zenModeActive = false;
+ if (closeParentTab && parentHasBrowser) {
+ this.currentParentTab.linkedBrowser.docShellIsActive = false;
+ }
+ if (closeCurrentTab) {
+ this.#currentBrowser.docShellIsActive = false;
+ this.overlay.classList.remove("deck-selected");
+ }
+ if (!this.currentParentTab._visuallySelected && closeParentTab) {
+ this.currentParentTab._visuallySelected = false;
+ }
+ this.#currentBrowser.removeAttribute("zen-glance-selected");
+ if (parentHasBrowser) {
+ this.currentParentTab.linkedBrowser.closest(".browserSidebarContainer").classList.remove("zen-glance-background");
+ }
+ }
+
+ onLocationChange(_) {
+ if (this._duringOpening) {
+ return;
+ }
+ if (gBrowser.selectedTab === this.#currentTab && !this.animatingOpen && !this._duringOpening && this.#currentBrowser) {
+ this.quickOpenGlance();
+ return;
+ }
+ if (gBrowser.selectedTab === this.currentParentTab && this.#currentBrowser) {
+ this.quickOpenGlance();
+ } else if ((!this.animatingFullOpen || this.animatingOpen) && this.#currentBrowser) {
+ this.closeGlance();
+ }
+ }
+
+ onTabClose(event) {
+ if (event.target === this.currentParentTab) {
+ this.closeGlance({ onTabClose: true });
+ }
+ }
+
+ fullyOpenGlance() {
+ gBrowser._insertTabAtIndex(this.#currentTab, {
+ index: this.#currentTab._tPos + 1,
+ });
+
+ this.animatingFullOpen = true;
+ this.currentParentTab._visuallySelected = false;
+
+ this.browserWrapper.removeAttribute("has-finished-animation");
+ this.browserWrapper.setAttribute("animate-full", true);
+ this.#currentTab.removeAttribute("zen-glance-tab");
+ gBrowser.selectedTab = this.#currentTab;
+ this.currentParentTab.linkedBrowser.closest(".browserSidebarContainer").classList.remove("zen-glance-background");
+ setTimeout(() => {
+ window.requestAnimationFrame(() => {
+ this.browserWrapper.setAttribute("animate-full-end", true);
+ setTimeout(() => {
+ this.animatingFullOpen = false;
+ this.closeGlance({ noAnimation: true });
+ }, 600);
+ });
+ }, 300);
+ }
+
+ openGlanceForBookmark(event) {
+ const activationMethod = Services.prefs.getStringPref('zen.glance.activation-method', 'ctrl');
+
+ if (activationMethod === 'ctrl' && !event.ctrlKey) {
+ return;
+ } else if (activationMethod === 'alt' && !event.altKey) {
+ return;
+ } else if (activationMethod === 'shift' && !event.shiftKey) {
+ return;
+ } else if (activationMethod === 'meta' && !event.metaKey) {
+ return;
+ }else if (activationMethod === 'mantain' || typeof activationMethod === 'undefined') {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ const rect = event.target.getBoundingClientRect();
+ const data = {
+ url: event.target._placesNode.uri,
+ x: rect.left,
+ y: rect.top,
+ width: rect.width,
+ height: rect.height,
+ };
+
+ this.openGlance(data);
+
+ return false;
+ }
+ }
+
+ window.gZenGlanceManager = new ZenGlanceManager();
+
+ function registerWindowActors() {
+ if (Services.prefs.getBoolPref("zen.glance.enabled", true)) {
+ gZenActorsManager.addJSWindowActor("ZenGlance", {
+ parent: {
+ esModuleURI: "chrome://browser/content/zen-components/actors/ZenGlanceParent.sys.mjs",
+ },
+ child: {
+ esModuleURI: "chrome://browser/content/zen-components/actors/ZenGlanceChild.sys.mjs",
+ events: {
+ DOMContentLoaded: {},
+ },
+ },
+ });
+ }
+ }
+
+ registerWindowActors();
+}
diff --git a/src/browser/base/zen-components/ZenGradientGenerator.mjs b/src/browser/base/zen-components/ZenGradientGenerator.mjs
new file mode 100644
index 000000000..9fc0dd429
--- /dev/null
+++ b/src/browser/base/zen-components/ZenGradientGenerator.mjs
@@ -0,0 +1,722 @@
+
+{
+ class ZenThemePicker extends ZenMultiWindowFeature {
+ static GRADIENT_IMAGE_URL = 'chrome://browser/content/zen-images/gradient.png';
+ static GRADIENT_DISPLAY_URL = 'chrome://browser/content/zen-images/gradient-display.png';
+ static MAX_DOTS = 5;
+
+ currentOpacity = 0.5;
+ currentRotation = 45;
+
+ numberOfDots = 0;
+
+ constructor() {
+ super();
+ if (!Services.prefs.getBoolPref('zen.theme.gradient', true) || !ZenWorkspaces.shouldHaveWorkspaces) {
+ return;
+ }
+ this.dragStartPosition = null;
+
+ ChromeUtils.defineLazyGetter(this, 'panel', () => document.getElementById('PanelUI-zen-gradient-generator'));
+ ChromeUtils.defineLazyGetter(this, 'toolbox', () => document.getElementById('TabsToolbar'));
+ ChromeUtils.defineLazyGetter(this, 'customColorInput', () => document.getElementById('PanelUI-zen-gradient-generator-custom-input'));
+ ChromeUtils.defineLazyGetter(this, 'customColorList', () => document.getElementById('PanelUI-zen-gradient-generator-custom-list'));
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ 'allowWorkspaceColors',
+ 'zen.theme.color-prefs.use-workspace-colors',
+ true,
+ this.onDarkModeChange.bind(this)
+ )
+
+ this.initRotation();
+ this.initCanvas();
+
+ ZenWorkspaces.addChangeListeners(this.onWorkspaceChange.bind(this));
+ window.matchMedia('(prefers-color-scheme: dark)').addListener(this.onDarkModeChange.bind(this));
+ }
+
+ get isDarkMode() {
+ return window.matchMedia('(prefers-color-scheme: dark)').matches;
+ }
+
+ async onDarkModeChange(event, skipUpdate = false) {
+ const currentWorkspace = await ZenWorkspaces.getActiveWorkspace();
+ this.onWorkspaceChange(currentWorkspace, skipUpdate);
+ }
+
+ initContextMenu() {
+ const menu = window.MozXULElement.parseXULToFragment(`
+
+ `);
+ document.getElementById('toolbar-context-customize').before(menu);
+ }
+
+ openThemePicker(event) {
+ PanelMultiView.openPopup(this.panel, this.toolbox, {
+ position: 'topright topleft',
+ triggerEvent: event,
+ });
+ }
+
+ initCanvas() {
+ this.image = new Image();
+ this.image.src = ZenThemePicker.GRADIENT_IMAGE_URL;
+
+ this.canvas = document.createElement('canvas');
+ this.panel.appendChild(this.canvas);
+ this.canvasCtx = this.canvas.getContext('2d');
+
+ // wait for the image to load
+ this.image.onload = this.onImageLoad.bind(this);
+ }
+
+
+ onImageLoad() {
+ // resize the image to fit the panel
+ const imageSize = 300 - 20; // 20 is the padding (10px)
+ const scale = imageSize / Math.max(this.image.width, this.image.height);
+ this.image.width *= scale;
+ this.image.height *= scale;
+
+ this.canvas.width = this.image.width;
+ this.canvas.height = this.image.height;
+ this.canvasCtx.drawImage(this.image, 0, 0);
+
+ this.canvas.setAttribute('hidden', 'true');
+
+ // Call the rest of the initialization
+ this.initContextMenu();
+ this.initThemePicker();
+
+
+ this._hasInitialized = true;
+ this.onDarkModeChange(null);
+ }
+
+ initRotation() {
+ this.rotationInput = document.getElementById('PanelUI-zen-gradient-degrees');
+ this.rotationInputDot = this.rotationInput.querySelector('.dot');
+ this.rotationInputText = this.rotationInput.querySelector('.text');
+ this.rotationInputDot.addEventListener('mousedown', this.onRotationMouseDown.bind(this));
+ this.rotationInput.addEventListener('wheel', this.onRotationWheel.bind(this));
+ }
+
+ onRotationWheel(event) {
+ event.preventDefault();
+ const delta = event.deltaY;
+ const degrees = this.currentRotation + (delta > 0 ? 10 : -10);
+ this.setRotationInput(degrees);
+ this.updateCurrentWorkspace();
+ }
+
+ onRotationMouseDown(event) {
+ event.preventDefault();
+ this.rotationDragging = true;
+ this.rotationInputDot.style.zIndex = 2;
+ this.rotationInputDot.classList.add('dragging');
+ document.addEventListener('mousemove', this.onRotationMouseMove.bind(this));
+ document.addEventListener('mouseup', this.onRotationMouseUp.bind(this));
+ }
+
+ onRotationMouseUp(event) {
+ this.rotationDragging = false;
+ this.rotationInputDot.style.zIndex = 1;
+ this.rotationInputDot.classList.remove('dragging');
+ document.removeEventListener('mousemove', this.onRotationMouseMove.bind(this));
+ document.removeEventListener('mouseup', this.onRotationMouseUp.bind(this));
+ }
+
+ onRotationMouseMove(event) {
+ if (this.rotationDragging) {
+ event.preventDefault();
+ const rect = this.rotationInput.getBoundingClientRect();
+ // Make the dot follow the mouse in a circle, it can't go outside or inside the circle
+ const centerX = rect.left + rect.width / 2;
+ const centerY = rect.top + rect.height / 2;
+ const angle = Math.atan2(event.clientY - centerY, event.clientX - centerX);
+ const distance = Math.sqrt((event.clientX - centerX) ** 2 + (event.clientY - centerY) ** 2);
+ const radius = rect.width / 2;
+ let x = centerX + Math.cos(angle) * radius;
+ let y = centerY + Math.sin(angle) * radius;
+ if (distance > radius) {
+ x = event.clientX;
+ y = event.clientY;
+ }
+ const degrees = Math.round(Math.atan2(y - centerY, x - centerX) * 180 / Math.PI);
+ this.setRotationInput(degrees);
+ this.updateCurrentWorkspace();
+ }
+ }
+
+ setRotationInput(degrees) {
+ let fixedRotation = degrees;
+ while (fixedRotation < 0) {
+ fixedRotation += 360;
+ }
+ while (fixedRotation >= 360) {
+ fixedRotation -= 360;
+ }
+ this.currentRotation = degrees;
+ this.rotationInputDot.style.transform = `rotate(${degrees - 20}deg)`;
+ this.rotationInputText.textContent = `${fixedRotation}°`;
+ }
+
+ initThemePicker() {
+ const themePicker = this.panel.querySelector('.zen-theme-picker-gradient');
+ themePicker.style.setProperty('--zen-theme-picker-gradient-image', `url(${ZenThemePicker.GRADIENT_DISPLAY_URL})`);
+ themePicker.addEventListener('mousemove', this.onDotMouseMove.bind(this));
+ themePicker.addEventListener('mouseup', this.onDotMouseUp.bind(this));
+ themePicker.addEventListener('click', this.onThemePickerClick.bind(this));
+ }
+
+ calculateInitialPosition(color) {
+ const [r, g, b] = color.c;
+ const imageData = this.canvasCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
+ // Find all pixels that are at least 90% similar to the color
+ const similarPixels = [];
+ for (let i = 0; i < imageData.data.length; i += 4) {
+ const pixelR = imageData.data[i];
+ const pixelG = imageData.data[i + 1];
+ const pixelB = imageData.data[i + 2];
+ if (Math.abs(r - pixelR) < 25 && Math.abs(g - pixelG) < 25 && Math.abs(b - pixelB) < 25) {
+ similarPixels.push(i);
+ }
+ }
+ // Check if there's an exact match
+ for (const pixel of similarPixels) {
+ const x = (pixel / 4) % this.canvas.width;
+ const y = Math.floor((pixel / 4) / this.canvas.width);
+ const pixelColor = this.getColorFromPosition(x, y);
+ if (pixelColor[0] === r && pixelColor[1] === g && pixelColor[2] === b) {
+ return {x: x / this.canvas.width, y: y / this.canvas.height};
+ }
+ }
+ // If there's no exact match, return the first similar pixel
+ const pixel = similarPixels[0];
+ const x = (pixel / 4) % this.canvas.width;
+ const y = Math.floor((pixel / 4) / this.canvas.width);
+ return {x: x / this.canvas.width, y: y / this.canvas.height};
+ }
+
+ getColorFromPosition(x, y) {
+ // get the color from the x and y from the image
+ const imageData = this.canvasCtx.getImageData(x, y, 1, 1);
+ return imageData.data;
+ }
+
+ createDot(color, fromWorkspace = false) {
+ if (color.isCustom) {
+ this.addColorToCustomList(color.c);
+ }
+ const [r, g, b] = color.c;
+ const dot = document.createElement('div');
+ dot.classList.add('zen-theme-picker-dot');
+ if (color.isCustom) {
+ if (!color.c) {
+ return;
+ }
+ dot.classList.add('custom');
+ dot.style.opacity = 0;
+ dot.style.setProperty('--zen-theme-picker-dot-color', color.c);
+ } else {
+ dot.style.setProperty('--zen-theme-picker-dot-color', `rgb(${r}, ${g}, ${b})`);
+ const { x, y } = this.calculateInitialPosition(color);
+ dot.style.left = `${x * 100}%`;
+ dot.style.top = `${y * 100}%`;
+ dot.addEventListener('mousedown', this.onDotMouseDown.bind(this));
+ }
+ this.panel.querySelector('.zen-theme-picker-gradient').appendChild(dot);
+ if (!fromWorkspace) {
+ this.updateCurrentWorkspace(true);
+ }
+ }
+
+ onThemePickerClick(event) {
+ event.preventDefault();
+
+
+ if (event.button !== 0 || this.dragging ) return;
+
+ const gradient = this.panel.querySelector('.zen-theme-picker-gradient');
+ const rect = gradient.getBoundingClientRect();
+ const padding = 90; // each side
+
+ const centerX = rect.left + rect.width / 2;
+ const centerY = rect.top + rect.height / 2;
+ const radius = (rect.width - padding) / 2;
+ let pixelX = event.clientX;
+ let pixelY = event.clientY;
+
+ // Check if the click is within the circle
+ const distance = Math.sqrt((pixelX - centerX) ** 2 + (pixelY - centerY) ** 2);
+ if (distance > radius) {
+ return; // Don't create a dot if clicking outside the circle
+ }
+
+ // Check if we clicked on an existing dot
+ const clickedElement = event.target;
+ const isExistingDot = clickedElement.classList.contains('zen-theme-picker-dot');
+
+ // Only proceed if not clicking on an existing dot
+ if (!isExistingDot) {
+
+ const relativeX = event.clientX - rect.left;
+ const relativeY = event.clientY - rect.top;
+
+
+ const color = this.getColorFromPosition(relativeX, relativeY);
+
+ // Create new dot
+ const dot = document.createElement('div');
+ dot.classList.add('zen-theme-picker-dot');
+ dot.addEventListener('mousedown', this.onDotMouseDown.bind(this));
+
+ dot.style.left = `${relativeX}px`;
+ dot.style.top = `${relativeY}px`;
+ dot.style.setProperty('--zen-theme-picker-dot-color', `rgb(${color[0]}, ${color[1]}, ${color[2]})`);
+
+ gradient.appendChild(dot);
+
+ this.updateCurrentWorkspace(true);
+ }
+
+ }
+
+
+
+ onDotMouseDown(event) {
+ event.preventDefault();
+ if (event.button === 2) {
+ return;
+ }
+ this.dragging = true;
+ this.draggedDot = event.target;
+ this.draggedDot.style.zIndex = 1;
+ this.draggedDot.classList.add('dragging');
+
+ // Store the starting position of the drag
+ this.dragStartPosition = {
+ x: event.clientX,
+ y: event.clientY
+ };
+ }
+
+
+ onDotMouseMove(event) {
+ if (this.dragging) {
+ event.preventDefault();
+ const rect = this.panel.querySelector('.zen-theme-picker-gradient').getBoundingClientRect();
+ const padding = 90; // each side
+ // do NOT let the ball be draged outside of an imaginary circle. You can drag it anywhere inside the circle
+ // if the distance between the center of the circle and the dragged ball is bigger than the radius, then the ball
+ // should be placed on the edge of the circle. If it's inside the circle, then the ball just follows the mouse
+
+ const centerX = rect.left + rect.width / 2;
+ const centerY = rect.top + rect.height / 2;
+ const radius = (rect.width - padding) / 2;
+ let pixelX = event.clientX;
+ let pixelY = event.clientY;
+ const distance = Math.sqrt((pixelX - centerX) **2 + (pixelY - centerY) **2);
+ if (distance > radius) {
+ const angle = Math.atan2(pixelY - centerY, pixelX - centerX);
+ pixelX = centerX + Math.cos(angle) * radius;
+ pixelY = centerY + Math.sin(angle) * radius;
+ }
+
+ // set the location of the dot in pixels
+ const relativeX = pixelX - rect.left;
+ const relativeY = pixelY - rect.top;
+ this.draggedDot.style.left = `${relativeX}px`;
+ this.draggedDot.style.top = `${relativeY}px`;
+ const color = this.getColorFromPosition(relativeX, relativeY);
+ this.draggedDot.style.setProperty('--zen-theme-picker-dot-color', `rgb(${color[0]}, ${color[1]}, ${color[2]})`);
+ this.updateCurrentWorkspace();
+ }
+ }
+
+ addColorToCustomList(color) {
+ const listItems = window.MozXULElement.parseXULToFragment(`
+
+
+
+
+
+ `);
+ listItems.querySelector('.zen-theme-picker-custom-list-item').setAttribute('data-color', color);
+ listItems.querySelector('.zen-theme-picker-dot-custom').style.setProperty('--zen-theme-picker-dot-color', color);
+ listItems.querySelector('.zen-theme-picker-custom-list-item-label').textContent = color;
+ this.customColorList.appendChild(listItems);
+ }
+
+ async addCustomColor() {
+ const color = this.customColorInput.value;
+ if (!color) {
+ return;
+ }
+ // can be any color format, we just add it to the list as a dot, but hidden
+ const dot = document.createElement('div');
+ dot.classList.add('zen-theme-picker-dot', 'hidden', 'custom');
+ dot.style.opacity = 0;
+ dot.style.setProperty('--zen-theme-picker-dot-color', color);
+ this.panel.querySelector('.zen-theme-picker-gradient').appendChild(dot);
+ this.customColorInput.value = '';
+ await this.updateCurrentWorkspace();
+ }
+
+
+
+ onThemePickerClick(event) {
+ event.preventDefault();
+
+ if (event.button !== 0 || this.dragging) return;
+
+ const gradient = this.panel.querySelector('.zen-theme-picker-gradient');
+ const rect = gradient.getBoundingClientRect();
+ const padding = 90; // each side
+
+ const centerX = rect.left + rect.width / 2;
+ const centerY = rect.top + rect.height / 2;
+ const radius = (rect.width - padding) / 2;
+ let pixelX = event.clientX;
+ let pixelY = event.clientY;
+
+ // Check if the click is within the circle
+ const distance = Math.sqrt((pixelX - centerX) ** 2 + (pixelY - centerY) ** 2);
+ if (distance > radius) {
+ return;
+ }
+
+
+ const clickedElement = event.target;
+ const isExistingDot = clickedElement.classList.contains('zen-theme-picker-dot');
+
+
+ if (!isExistingDot && this.numberOfDots < ZenThemePicker.MAX_DOTS) {
+ const relativeX = event.clientX - rect.left;
+ const relativeY = event.clientY - rect.top;
+
+ const color = this.getColorFromPosition(relativeX, relativeY);
+
+ const dot = document.createElement('div');
+ dot.classList.add('zen-theme-picker-dot');
+ dot.addEventListener('mousedown', this.onDotMouseDown.bind(this));
+
+ dot.style.left = `${relativeX}px`;
+ dot.style.top = `${relativeY}px`;
+ dot.style.setProperty('--zen-theme-picker-dot-color', `rgb(${color[0]}, ${color[1]}, ${color[2]})`);
+
+ gradient.appendChild(dot);
+
+ this.updateCurrentWorkspace(true);
+ }
+ }
+
+ onDotMouseDown(event) {
+ event.preventDefault();
+ if (event.button === 2) {
+ return;
+ }
+ this.dragging = true;
+ this.draggedDot = event.target;
+ this.draggedDot.style.zIndex = 1;
+ this.draggedDot.classList.add('dragging');
+
+ // Store the starting position of the drag
+ this.dragStartPosition = {
+ x: event.clientX,
+ y: event.clientY
+ };
+ }
+
+ onDotMouseUp(event) {
+ if (event.button === 2) {
+ if (!event.target.classList.contains('zen-theme-picker-dot')) {
+ return;
+ }
+ event.target.remove();
+ this.updateCurrentWorkspace();
+ this.numberOfDots--;
+ return;
+ }
+
+ if (this.dragging) {
+ event.preventDefault();
+ event.stopPropagation();
+ this.dragging = false;
+ this.draggedDot.style.zIndex = 1;
+ this.draggedDot.classList.remove('dragging');
+ this.draggedDot = null;
+ this.dragStartPosition = null; // Reset the drag start position
+ return;
+ }
+
+ this.numberOfDots = this.panel.querySelectorAll('.zen-theme-picker-dot').length;
+ }
+
+
+ themedColors(colors) {
+ const isDarkMode = this.isDarkMode;
+ const factor = isDarkMode ? 0.5 : 1.1;
+ return colors.map(color => {
+ return {
+ c: color.isCustom ? color.c : [
+ Math.min(255, color.c[0] * factor),
+ Math.min(255, color.c[1] * factor),
+ Math.min(255, color.c[2] * factor),
+ ],
+ isCustom: color.isCustom,
+ }
+ });
+ }
+
+ onOpacityChange(event) {
+ this.currentOpacity = event.target.value;
+ this.updateCurrentWorkspace();
+ }
+
+ onTextureChange(event) {
+ this.currentTexture = event.target.value;
+ this.updateCurrentWorkspace();
+ }
+
+ getSingleRGBColor(color) {
+ if (color.isCustom) {
+ return color.c;
+ }
+ return `color-mix(in srgb, rgb(${color.c[0]}, ${color.c[1]}, ${color.c[2]}) ${this.currentOpacity * 100}%, var(--zen-themed-toolbar-bg) ${(1 - this.currentOpacity) * 100}%)`;
+ }
+
+
+ getGradient(colors) {
+ const themedColors = this.themedColors(colors);
+ if (themedColors.length === 0) {
+ return "var(--zen-themed-toolbar-bg)";
+ } else if (themedColors.length === 1) {
+ return this.getSingleRGBColor(themedColors[0]);
+ }
+ return `linear-gradient(${this.currentRotation}deg, ${themedColors.map(color => this.getSingleRGBColor(color)).join(', ')})`;
+ }
+
+ getTheme(colors, opacity = 0.5, rotation = 45, texture = 0) {
+ return {
+ type: 'gradient',
+ gradientColors: colors.filter(color => color), // remove undefined
+ opacity,
+ rotation,
+ texture,
+ };
+ }
+ //TODO: add a better noise system that adds noise not just changes transparency
+ updateNoise(texture) {
+ const wrapper = document.getElementById('zen-main-app-wrapper');
+ wrapper.style.setProperty('--zen-grainy-background-opacity', texture);
+ }
+
+ hexToRgb(hex) {
+ if (hex.startsWith('#')) {
+ hex = hex.substring(1);
+ }
+ if (hex.length === 3) {
+ hex = hex.split('').map(char => char + char).join('');
+ }
+ return [
+ parseInt(hex.substring(0, 2), 16),
+ parseInt(hex.substring(2, 4), 16),
+ parseInt(hex.substring(4, 6), 16),
+ ];
+ }
+
+ pSBC=(p,c0,c1,l)=>{
+ let r,g,b,P,f,t,h,i=parseInt,m=Math.round,a=typeof(c1)=="string";
+ if(typeof(p)!="number"||p<-1||p>1||typeof(c0)!="string"||(c0[0]!='r'&&c0[0]!='#')||(c1&&!a))return null;
+ if(!this.pSBCr)this.pSBCr=(d)=>{
+ let n=d.length,x={};
+ if(n>9){
+ [r,g,b,a]=d=d.split(","),n=d.length;
+ if(n<3||n>4)return null;
+ x.r=i(r[3]=="a"?r.slice(5):r.slice(4)),x.g=i(g),x.b=i(b),x.a=a?parseFloat(a):-1
+ }else{
+ if(n==8||n==6||n<4)return null;
+ if(n<6)d="#"+d[1]+d[1]+d[2]+d[2]+d[3]+d[3]+(n>4?d[4]+d[4]:"");
+ d=i(d.slice(1),16);
+ if(n==9||n==5)x.r=d>>24&255,x.g=d>>16&255,x.b=d>>8&255,x.a=m((d&255)/0.255)/1000;
+ else x.r=d>>16,x.g=d>>8&255,x.b=d&255,x.a=-1
+ }return x};
+ h=c0.length>9,h=a?c1.length>9?true:c1=="c"?!h:false:h,f=this.pSBCr(c0),P=p<0,t=c1&&c1!="c"?this.pSBCr(c1):P?{r:0,g:0,b:0,a:-1}:{r:255,g:255,b:255,a:-1},p=P?p*-1:p,P=1-p;
+ if(!f||!t)return null;
+ if(l)r=m(P*f.r+p*t.r),g=m(P*f.g+p*t.g),b=m(P*f.b+p*t.b);
+ else r=m((P*f.r**2+p*t.r**2)**0.5),g=m((P*f.g**2+p*t.g**2)**0.5),b=m((P*f.b**2+p*t.b**2)**0.5);
+ a=f.a,t=t.a,f=a>=0||t>=0,a=f?a<0?t:t<0?a:a*P+t*p:0;
+ if(h)return"rgb"+(f?"a(":"(")+r+","+g+","+b+(f?","+m(a*1000)/1000:"")+")";
+ else return"#"+(4294967296+r*16777216+g*65536+b*256+(f?m(a*255):0)).toString(16).slice(1,f?undefined:-2)
+ }
+
+ getMostDominantColor(allColors) {
+ const colors = this.themedColors(allColors);
+ const themedColors = colors.filter(color => !color.isCustom);
+ if (themedColors.length === 0 || !this.allowWorkspaceColors) {
+ return null;
+ }
+ // get the most dominant color in the gradient
+ let dominantColor = themedColors[0].c;
+ let dominantColorCount = 0;
+ for (const color of themedColors) {
+ const count = themedColors.filter(c => c.c[0] === color.c[0] && c.c[1] === color.c[1] && c.c[2] === color.c[2]).length;
+ if (count > dominantColorCount) {
+ dominantColorCount = count;
+ dominantColor = color.c;
+ }
+ }
+ const result = this.pSBC(
+ this.isDarkMode ? 0.5 : -0.5,
+ `rgb(${dominantColor[0]}, ${dominantColor[1]}, ${dominantColor[2]})`);
+ return result?.match(/\d+/g).map(Number);
+ }
+
+ async onWorkspaceChange(workspace, skipUpdate = false, theme = null) {
+ const uuid = workspace.uuid;
+ // Use theme from workspace object or passed theme
+ let workspaceTheme = theme || workspace.theme;
+
+ await this.foreachWindowAsActive(async (browser) => {
+ if (!browser.gZenThemePicker._hasInitialized) {
+ return;
+ }
+ // Do not rebuild if the workspace is not the same as the current one
+ const windowWorkspace = await browser.ZenWorkspaces.getActiveWorkspace();
+ if (windowWorkspace.uuid !== uuid && theme !== null) {
+ return;
+ }
+
+ // get the theme from the window
+ workspaceTheme = theme || windowWorkspace.theme;
+
+ const appWrapper = browser.document.getElementById('zen-main-app-wrapper');
+ if (!skipUpdate) {
+ appWrapper.removeAttribute('animating');
+ appWrapper.setAttribute('animating', 'true');
+ browser.document.body.style.setProperty('--zen-main-browser-background-old',
+ browser.document.body.style.getPropertyValue('--zen-main-browser-background')
+ );
+ browser.window.requestAnimationFrame(() => {
+ setTimeout(() => {
+ appWrapper.removeAttribute('animating');
+ }, 500);
+ });
+ }
+
+ browser.gZenThemePicker.resetCustomColorList();
+ if (!workspaceTheme || workspaceTheme.type !== 'gradient') {
+ browser.document.documentElement.style.removeProperty('--zen-main-browser-background');
+ browser.gZenThemePicker.updateNoise(0);
+ if (!skipUpdate) {
+ for (const dot of browser.gZenThemePicker.panel.querySelectorAll('.zen-theme-picker-dot')) {
+ dot.remove();
+ }
+ }
+ browser.document.documentElement.style.setProperty('--zen-primary-color', this.getNativeAccentColor());
+ return;
+ }
+
+ browser.gZenThemePicker.currentOpacity = workspaceTheme.opacity ?? 0.5;
+ browser.gZenThemePicker.currentRotation = workspaceTheme.rotation ?? 45;
+ browser.gZenThemePicker.currentTexture = workspaceTheme.texture ?? 0;
+
+ browser.gZenThemePicker.numberOfDots = workspaceTheme.gradientColors.length;
+
+ browser.document.getElementById('PanelUI-zen-gradient-generator-opacity').value = browser.gZenThemePicker.currentOpacity;
+ browser.document.getElementById('PanelUI-zen-gradient-generator-texture').value = browser.gZenThemePicker.currentTexture;
+ browser.gZenThemePicker.setRotationInput(browser.gZenThemePicker.currentRotation);
+
+ const gradient = browser.gZenThemePicker.getGradient(workspaceTheme.gradientColors);
+ browser.gZenThemePicker.updateNoise(workspaceTheme.texture);
+
+ for (const dot of workspaceTheme.gradientColors) {
+ if (dot.isCustom) {
+ browser.gZenThemePicker.addColorToCustomList(dot.c);
+ }
+ }
+
+ browser.document.documentElement.style.setProperty('--zen-main-browser-background', gradient);
+
+ const dominantColor = this.getMostDominantColor(workspaceTheme.gradientColors);
+ if (dominantColor) {
+ browser.document.documentElement.style.setProperty('--zen-primary-color', `rgb(${dominantColor[0]}, ${dominantColor[1]}, ${dominantColor[2]})`);
+ }
+
+ if (!skipUpdate) {
+ browser.gZenThemePicker.recalculateDots(workspaceTheme.gradientColors);
+ }
+ });
+ }
+
+ getNativeAccentColor() {
+ return Services.prefs.getStringPref('zen.theme.accent-color');
+ }
+
+ resetCustomColorList() {
+ this.customColorList.innerHTML = '';
+ }
+
+ removeCustomColor(event) {
+ const target = event.target.closest('.zen-theme-picker-custom-list-item');
+ const color = target.getAttribute('data-color');
+ const dots = this.panel.querySelectorAll('.zen-theme-picker-dot');
+ for (const dot of dots) {
+ if (dot.style.getPropertyValue('--zen-theme-picker-dot-color') === color) {
+ dot.remove();
+ break;
+ }
+ }
+ target.remove();
+ this.updateCurrentWorkspace();
+ }
+
+ recalculateDots(colors) {
+ const dots = this.panel.querySelectorAll('.zen-theme-picker-dot');
+ for (let i = 0; i < colors.length; i++) {
+ dots[i]?.remove();
+ }
+ for (const color of colors) {
+ this.createDot(color, true);
+ }
+ }
+
+ async updateCurrentWorkspace(skipSave = true) {
+ this.updated = skipSave;
+ const dots = this.panel.querySelectorAll('.zen-theme-picker-dot');
+ const colors = Array.from(dots).map(dot => {
+ const color = dot.style.getPropertyValue('--zen-theme-picker-dot-color');
+ if (color === 'undefined') {
+ return;
+ }
+ const isCustom = dot.classList.contains('custom');
+ return {c: isCustom ? color : color.match(/\d+/g).map(Number), isCustom};
+ });
+ const gradient = this.getTheme(colors, this.currentOpacity, this.currentRotation, this.currentTexture);
+ let currentWorkspace = await ZenWorkspaces.getActiveWorkspace();
+
+ if(!skipSave) {
+ await ZenWorkspacesStorage.saveWorkspaceTheme(currentWorkspace.uuid, gradient);
+ await ZenWorkspaces._propagateWorkspaceData();
+ ConfirmationHint.show(document.getElementById("PanelUI-menu-button"), "zen-panel-ui-gradient-generator-saved-message");
+ currentWorkspace = await ZenWorkspaces.getActiveWorkspace();
+ }
+
+ await this.onWorkspaceChange(currentWorkspace, true, skipSave ? gradient : null);
+ }
+
+ async handlePanelClose() {
+ if(this.updated) {
+ await this.updateCurrentWorkspace(false);
+ }
+
+ }
+ }
+
+ window.ZenThemePicker = ZenThemePicker;
+}
diff --git a/src/browser/base/zen-components/ZenKeyboardShortcuts.mjs b/src/browser/base/zen-components/ZenKeyboardShortcuts.mjs
new file mode 100644
index 000000000..2655ddd73
--- /dev/null
+++ b/src/browser/base/zen-components/ZenKeyboardShortcuts.mjs
@@ -0,0 +1,1014 @@
+const KEYCODE_MAP = {
+ F1: 'VK_F1',
+ F2: 'VK_F2',
+ F3: 'VK_F3',
+ F4: 'VK_F4',
+ F5: 'VK_F5',
+ F6: 'VK_F6',
+ F7: 'VK_F7',
+ F8: 'VK_F8',
+ F9: 'VK_F9',
+ F10: 'VK_F10',
+ F11: 'VK_F11',
+ F12: 'VK_F12',
+ TAB: 'VK_TAB',
+ ENTER: 'VK_RETURN',
+ ESCAPE: 'VK_ESCAPE',
+ SPACE: 'VK_SPACE',
+ ARROWLEFT: 'VK_LEFT',
+ ARROWRIGHT: 'VK_RIGHT',
+ ARROWUP: 'VK_UP',
+ ARROWDOWN: 'VK_DOWN',
+ DELETE: 'VK_DELETE',
+ BACKSPACE: 'VK_BACK',
+ HOME: 'VK_HOME',
+};
+
+const defaultKeyboardGroups = {
+ windowAndTabManagement: [
+ 'zen-window-new-shortcut',
+ 'zen-tab-new-shortcut',
+ 'zen-key-enter-full-screen',
+ 'zen-key-exit-full-screen',
+ 'zen-quit-app-shortcut',
+ 'zen-close-tab-shortcut',
+ 'zen-close-shortcut',
+ 'id:key_selectTab1',
+ 'id:key_selectTab2',
+ 'id:key_selectTab3',
+ 'id:key_selectTab4',
+ 'id:key_selectTab5',
+ 'id:key_selectTab6',
+ 'id:key_selectTab7',
+ 'id:key_selectTab8',
+ 'id:key_selectLastTab',
+ ],
+ navigation: [
+ 'zen-nav-back-shortcut-alt',
+ 'zen-nav-fwd-shortcut-alt',
+ 'zen-nav-reload-shortcut-2',
+ 'zen-nav-reload-shortcut-skip-cache',
+ 'zen-nav-reload-shortcut',
+ 'zen-key-stop',
+ 'zen-window-new-shortcut',
+ 'zen-private-browsing-shortcut',
+ 'id:goHome',
+ 'id:key_gotoHistory',
+ 'id:goBackKb',
+ 'id:goForwardKb',
+ ],
+ searchAndFind: [
+ 'zen-search-focus-shortcut',
+ 'zen-search-focus-shortcut-alt',
+ 'zen-find-shortcut',
+ 'zen-search-find-again-shortcut-2',
+ 'zen-search-find-again-shortcut',
+ 'zen-search-find-again-shortcut-prev',
+ ],
+ pageOperations: [
+ 'zen-location-open-shortcut',
+ 'zen-location-open-shortcut-alt',
+ 'zen-save-page-shortcut',
+ 'zen-print-shortcut',
+ 'zen-page-source-shortcut',
+ 'zen-page-info-shortcut',
+ 'zen-reader-mode-toggle-shortcut-other',
+ 'zen-picture-in-picture-toggle-shortcut',
+ ],
+ historyAndBookmarks: [
+ 'zen-history-show-all-shortcut',
+ 'zen-bookmark-this-page-shortcut',
+ 'zen-bookmark-show-library-shortcut',
+ ],
+ mediaAndDisplay: [
+ 'zen-mute-toggle-shortcut',
+ 'zen-full-zoom-reduce-shortcut',
+ 'zen-full-zoom-enlarge-shortcut',
+ 'zen-full-zoom-reset-shortcut',
+ 'zen-bidi-switch-direction-shortcut',
+ 'zen-screenshot-shortcut',
+ ],
+};
+
+const fixedL10nIds = {
+ cmd_findPrevious: 'zen-search-find-again-shortcut-prev',
+ 'Browser:ReloadSkipCache': 'zen-nav-reload-shortcut-skip-cache',
+ cmd_close: 'zen-close-tab-shortcut',
+ 'History:RestoreLastClosedTabOrWindowOrSession': 'zen-restore-last-closed-tab-shortcut',
+};
+
+const ZEN_MAIN_KEYSET_ID = 'mainKeyset';
+const ZEN_KEYSET_ID = 'zenKeyset';
+
+const ZEN_COMPACT_MODE_SHORTCUTS_GROUP = 'zen-compact-mode';
+const ZEN_WORKSPACE_SHORTCUTS_GROUP = 'zen-workspace';
+const ZEN_OTHER_SHORTCUTS_GROUP = 'zen-other';
+const ZEN_SPLIT_VIEW_SHORTCUTS_GROUP = 'zen-split-view';
+const FIREFOX_SHORTCUTS_GROUP = 'zen-kbs-invalid';
+const VALID_SHORTCUT_GROUPS = [
+ ZEN_COMPACT_MODE_SHORTCUTS_GROUP,
+ ZEN_WORKSPACE_SHORTCUTS_GROUP,
+ ZEN_SPLIT_VIEW_SHORTCUTS_GROUP,
+ ...Object.keys(defaultKeyboardGroups),
+ ZEN_OTHER_SHORTCUTS_GROUP,
+ 'other',
+];
+
+class KeyShortcutModifiers {
+ #control = false;
+ #alt = false;
+ #shift = false;
+ #meta = false;
+ #accel = false;
+
+ constructor(ctrl, alt, shift, meta, accel) {
+ this.#control = ctrl;
+ this.#alt = alt;
+ this.#shift = shift;
+ this.#meta = meta;
+ this.#accel = accel;
+
+ if (AppConstants.platform != 'macosx') {
+ // Replace control with accel, to make it more consistent
+ this.#accel = ctrl || accel;
+ this.#control = false;
+ }
+ }
+
+ static parseFromJSON(modifiers) {
+ if (!modifiers) {
+ return new KeyShortcutModifiers(false, false, false, false, false);
+ }
+
+ return new KeyShortcutModifiers(
+ modifiers['control'] == true,
+ modifiers['alt'] == true,
+ modifiers['shift'] == true,
+ modifiers['meta'] == true,
+ modifiers['accel'] == true
+ );
+ }
+
+ static parseFromXHTMLAttribute(modifiers) {
+ if (!modifiers) {
+ return new KeyShortcutModifiers(false, false, false, false, false);
+ }
+
+ return new KeyShortcutModifiers(
+ modifiers.includes('control'),
+ modifiers.includes('alt'),
+ modifiers.includes('shift'),
+ modifiers.includes('meta'),
+ modifiers.includes('accel')
+ );
+ }
+
+ // used to avoid any future changes to the object
+ static fromObject({ ctrl = false, alt = false, shift = false, meta = false, accel = false }) {
+ return new KeyShortcutModifiers(ctrl, alt, shift, meta, accel);
+ }
+
+ toUserString() {
+ let str = '';
+ if (this.#control && !this.#accel) {
+ str += 'Ctrl+';
+ }
+ if (this.#alt) {
+ str += AppConstants.platform == 'macosx' ? 'Option+' : 'Alt+';
+ }
+ if (this.#shift) {
+ str += 'Shift+';
+ }
+ if (this.#meta) {
+ str += AppConstants.platform == 'macosx' ? 'Cmd+' : 'Win+';
+ }
+ if (this.#accel) {
+ str += AppConstants.platform == 'macosx' ? 'Cmd+' : 'Ctrl+';
+ }
+ return str;
+ }
+
+ equals(other) {
+ if (!other) {
+ return false;
+ }
+ // If we are on macos, we can have accel and meta at the same time
+ return (
+ this.#alt == other.#alt &&
+ this.#shift == other.#shift &&
+ this.#control == other.#control &&
+ (AppConstants.platform == 'macosx'
+ ? ((this.#meta || this.#accel) == (other.#meta || other.#accel) && this.#control == other.#control)
+ // In other platforms, we can have control and accel counting as the same thing
+ : (this.#meta == other.#meta && (this.#control || this.#accel) == (other.#control || other.#accel)))
+ );
+ }
+
+ toString() {
+ let str = '';
+ if (this.#control) {
+ str += 'control,';
+ }
+ if (this.#alt) {
+ str += 'alt,';
+ }
+ if (this.#shift) {
+ str += 'shift,';
+ }
+ if (this.#accel) {
+ str += 'accel,';
+ }
+ if (this.#meta) {
+ str += 'meta,';
+ }
+ return str.slice(0, -1);
+ }
+
+ toJSONString() {
+ return {
+ control: this.#control,
+ alt: this.#alt,
+ shift: this.#shift,
+ meta: this.#meta,
+ accel: this.#accel,
+ };
+ }
+
+ areAnyActive() {
+ return this.#control || this.#alt || this.#shift || this.#meta || this.#accel;
+ }
+
+ get control() {
+ return this.#control;
+ }
+
+ get alt() {
+ return this.#alt;
+ }
+
+ get shift() {
+ return this.#shift;
+ }
+
+ get meta() {
+ return this.#meta;
+ }
+
+ get accel() {
+ return this.#accel;
+ }
+}
+
+class KeyShortcut {
+ #id = '';
+ #key = '';
+ #keycode = '';
+ #group = FIREFOX_SHORTCUTS_GROUP;
+ #modifiers = new KeyShortcutModifiers(false, false, false, false, false);
+ #action = '';
+ #l10nId = '';
+ #disabled = false;
+ #reserved = false;
+ #internal = false;
+
+ constructor(id, key, keycode, group, modifiers, action, l10nId, disabled = false, reserved = false, internal = false) {
+ this.#id = id;
+ this.#key = key?.toLowerCase();
+ this.#keycode = keycode;
+
+ if (!VALID_SHORTCUT_GROUPS.includes(group)) {
+ throw new Error('Illegal group value: ' + group);
+ }
+
+ this.#group = group;
+ this.#modifiers = modifiers;
+ this.#action = action;
+ this.#l10nId = KeyShortcut.sanitizeL10nId(l10nId, action);
+ this.#disabled = disabled;
+ this.#reserved = reserved;
+ this.#internal = internal;
+ }
+
+ isEmpty() {
+ return !this.#key && !this.getRealKeycode();
+ }
+
+ static parseFromSaved(json) {
+ let rv = [];
+
+ for (let key of json) {
+ rv.push(this.#parseFromJSON(key));
+ }
+
+ return rv;
+ }
+
+ static getGroupFromL10nId(l10nId, id) {
+ // Find inside defaultKeyboardGroups
+ for (let group of Object.keys(defaultKeyboardGroups)) {
+ for (let shortcut of defaultKeyboardGroups[group]) {
+ if (shortcut == l10nId || shortcut == 'id:' + id) {
+ return group;
+ }
+ }
+ }
+ return 'other';
+ }
+
+ static #parseFromJSON(json) {
+ return new KeyShortcut(
+ json['id'],
+ json['key'],
+ json['keycode'],
+ json['group'],
+ KeyShortcutModifiers.parseFromJSON(json['modifiers']),
+ json['action'],
+ json['l10nId'],
+ json['disabled'],
+ json['reserved'],
+ json['internal']
+ );
+ }
+
+ static parseFromXHTML(key) {
+ return new KeyShortcut(
+ key.getAttribute('id'),
+ key.getAttribute('key'),
+ key.getAttribute('keycode'),
+ KeyShortcut.getGroupFromL10nId(KeyShortcut.sanitizeL10nId(key.getAttribute('data-l10n-id')), key.getAttribute('id')),
+ KeyShortcutModifiers.parseFromXHTMLAttribute(key.getAttribute('modifiers')),
+ key.getAttribute('command'),
+ key.getAttribute('data-l10n-id'),
+ key.getAttribute('disabled') == 'true',
+ key.getAttribute('reserved') == 'true',
+ key.getAttribute('internal') == 'true'
+ );
+ }
+
+ static sanitizeL10nId(id, action) {
+ if (!id || id.startsWith('zen-')) {
+ return id;
+ }
+ // Check if any action is on the list of fixed l10n ids
+ if (fixedL10nIds[action]) {
+ return fixedL10nIds[action];
+ }
+ return `zen-${id}`;
+ }
+
+ toXHTMLElement(window) {
+ let key = window.document.createXULElement('key');
+ key.id = this.#id;
+ if (this.#keycode) {
+ key.setAttribute('keycode', this.#keycode);
+ } else {
+ // note to "mr. macos": Better use setAttribute, because without it, there's a
+ // risk of malforming the XUL element.
+ key.setAttribute('key', this.#key);
+ }
+ key.setAttribute('group', this.#group);
+
+ // note to "mr. macos": We add the `zen-` prefix because since firefox hasnt been built with the
+ // shortcuts in mind, it will siply just override the shortcuts with whatever the default is.
+ // note that this l10n id is not used for actually translating the key's label, but rather to
+ // identify the default keybinds.
+ if (this.#l10nId) {
+ key.setAttribute('data-l10n-id', this.#l10nId);
+ }
+ key.setAttribute('modifiers', this.#modifiers.toString());
+ if (this.#action) {
+ if (this.#action?.startsWith('code:')) {
+ key.setAttribute('oncommand', this.#action.substring(5));
+ } else {
+ key.setAttribute('command', this.#action);
+ }
+ }
+ if (this.#disabled) {
+ key.setAttribute('disabled', this.#disabled);
+ }
+ if (this.#reserved) {
+ key.setAttribute('reserved', this.#reserved);
+ }
+ if (this.#internal) {
+ key.setAttribute('internal', this.#internal);
+ }
+ key.setAttribute('zen-keybind', 'true');
+
+ return key;
+ }
+
+ _modifyInternalAttribute(value) {
+ this.#internal = value;
+ }
+
+ getRealKeycode() {
+ if (this.#keycode === '') {
+ return null;
+ }
+ return this.#keycode;
+ }
+
+ getID() {
+ return this.#id;
+ }
+
+ getAction() {
+ return this.#action;
+ }
+
+ getL10NID() {
+ return this.#l10nId;
+ }
+
+ getGroup() {
+ return this.#group;
+ }
+
+ getModifiers() {
+ return this.#modifiers;
+ }
+
+ getKeyName() {
+ return this.#key?.toLowerCase();
+ }
+
+ getKeyCode() {
+ return this.getRealKeycode();
+ }
+
+ getKeyNameOrCode() {
+ return this.#key ? this.getKeyName() : this.getKeyCode();
+ }
+
+ isDisabled() {
+ return this.#disabled;
+ }
+
+ isReserved() {
+ return this.#reserved;
+ }
+
+ isInternal() {
+ return this.#internal;
+ }
+
+ setModifiers(modifiers) {
+ if ((!modifiers) instanceof KeyShortcutModifiers) {
+ throw new Error('Only KeyShortcutModifiers allowed');
+ }
+ this.#modifiers = modifiers;
+ }
+
+ toJSONForm() {
+ return {
+ id: this.#id,
+ key: this.#key,
+ keycode: this.#keycode,
+ group: this.#group,
+ l10nId: this.#l10nId,
+ modifiers: this.#modifiers.toJSONString(),
+ action: this.#action,
+ disabled: this.#disabled,
+ reserved: this.#reserved,
+ internal: this.#internal,
+ };
+ }
+
+ toUserString() {
+ let str = this.#modifiers.toUserString();
+
+ if (this.#key) {
+ str += this.#key.toUpperCase();
+ } else if (this.#keycode) {
+ // Get the key from the value
+ for (let [key, value] of Object.entries(KEYCODE_MAP)) {
+ if (value == this.#keycode) {
+ str += key.toLowerCase();
+ break;
+ }
+ }
+ } else {
+ return '';
+ }
+ return str;
+ }
+
+ isUserEditable() {
+ if (!this.#id || this.#internal || (this.#group == FIREFOX_SHORTCUTS_GROUP && this.#disabled)) {
+ return false;
+ }
+ return true;
+ }
+
+ clearKeybind() {
+ this.#key = '';
+ this.#keycode = '';
+ this.#modifiers = new KeyShortcutModifiers(false, false, false, false);
+ }
+
+ setNewBinding(shortcut) {
+ for (let keycode of Object.keys(KEYCODE_MAP)) {
+ if (keycode == shortcut.toUpperCase()) {
+ this.#keycode = KEYCODE_MAP[keycode];
+ this.#key = '';
+ return;
+ }
+ }
+
+ this.#keycode = ''; // Clear the keycode
+ this.#key = shortcut;
+ }
+}
+
+class ZenKeyboardShortcutsLoader {
+ constructor() {}
+
+ get shortcutsFile() {
+ return PathUtils.join(PathUtils.profileDir, 'zen-keyboard-shortcuts.json');
+ }
+
+ async save(data) {
+ await IOUtils.writeJSON(this.shortcutsFile, data);
+ }
+
+ async loadObject() {
+ try {
+ return await IOUtils.readJSON(this.shortcutsFile);
+ } catch (e) {
+ // Recreate shortcuts file
+ Services.prefs.clearUserPref('zen.keyboard.shortcuts.version');
+ console.error('Error loading shortcuts file', e);
+ return null;
+ }
+ }
+
+ async load() {
+ return (await this.loadObject())?.shortcuts;
+ }
+
+ async remove() {
+ await IOUtils.remove(this.shortcutsFile);
+ }
+}
+
+function zenGetDefaultShortcuts() {
+ // DO NOT CHANGE ANYTHING HERE
+ // For adding new default shortcuts, add them to inside the migration function
+ // and increment the version number.
+
+ console.info('Zen CKS: Loading default shortcuts...');
+ let keySet = document.getElementById(ZEN_MAIN_KEYSET_ID);
+ let newShortcutList = [];
+
+ // Firefox's standard keyset. Reverse order to keep the order of the keys
+ for (let i = keySet.children.length - 1; i >= 0; i--) {
+ let key = keySet.children[i];
+ let parsed = KeyShortcut.parseFromXHTML(key);
+ newShortcutList.push(parsed);
+ }
+
+ // Compact mode's keyset
+ newShortcutList.push(
+ new KeyShortcut(
+ 'zen-compact-mode-toggle',
+ 'C',
+ '',
+ ZEN_COMPACT_MODE_SHORTCUTS_GROUP,
+ KeyShortcutModifiers.fromObject({ accel: true, alt: true }),
+ 'code:gZenCompactModeManager.toggle()',
+ 'zen-compact-mode-shortcut-toggle'
+ )
+ );
+ newShortcutList.push(
+ new KeyShortcut(
+ 'zen-compact-mode-show-sidebar',
+ 'S',
+ '',
+ ZEN_COMPACT_MODE_SHORTCUTS_GROUP,
+ KeyShortcutModifiers.fromObject({ accel: true, alt: true }),
+ 'code:gZenCompactModeManager.toggleSidebar()',
+ 'zen-compact-mode-shortcut-show-sidebar'
+ )
+ );
+ newShortcutList.push(
+ new KeyShortcut(
+ 'zen-compact-mode-show-toolbar',
+ 'T',
+ '',
+ ZEN_COMPACT_MODE_SHORTCUTS_GROUP,
+ KeyShortcutModifiers.fromObject({ accel: true, alt: true }),
+ 'code:gZenCompactModeManager.toggleToolbar()',
+ 'zen-compact-mode-shortcut-show-toolbar'
+ )
+ );
+
+ // Workspace's keyset
+ for (let i = 10; i > 0; i--) {
+ newShortcutList.push(
+ new KeyShortcut(
+ `zen-workspace-switch-${i}`,
+ '',
+ '',
+ ZEN_WORKSPACE_SHORTCUTS_GROUP,
+ KeyShortcutModifiers.fromObject({}),
+ `code:ZenWorkspaces.shortcutSwitchTo(${i - 1})`,
+ `zen-workspace-shortcut-switch-${i}`
+ )
+ );
+ }
+ newShortcutList.push(
+ new KeyShortcut(
+ 'zen-workspace-forward',
+ 'E',
+ '',
+ ZEN_WORKSPACE_SHORTCUTS_GROUP,
+ KeyShortcutModifiers.fromObject({ accel: true, alt: true }),
+ 'code:ZenWorkspaces.changeWorkspaceShortcut()',
+ 'zen-workspace-shortcut-forward'
+ )
+ );
+ newShortcutList.push(
+ new KeyShortcut(
+ 'zen-workspace-backward',
+ 'Q',
+ '',
+ ZEN_WORKSPACE_SHORTCUTS_GROUP,
+ KeyShortcutModifiers.fromObject({ accel: true, alt: true }),
+ 'code:ZenWorkspaces.changeWorkspaceShortcut(-1)',
+ 'zen-workspace-shortcut-backward'
+ )
+ );
+
+ // Other keyset
+ newShortcutList.push(
+ new KeyShortcut(
+ 'zen-toggle-web-panel',
+ 'P',
+ '',
+ ZEN_OTHER_SHORTCUTS_GROUP,
+ KeyShortcutModifiers.fromObject({ alt: true }),
+ 'code:gZenBrowserManagerSidebar.toggle()',
+ 'zen-web-panel-shortcut-toggle'
+ )
+ );
+ newShortcutList.push(
+ new KeyShortcut(
+ 'zen-toggle-sidebar',
+ 'B',
+ '',
+ ZEN_OTHER_SHORTCUTS_GROUP,
+ KeyShortcutModifiers.fromObject({ alt: true }),
+ 'code:gZenVerticalTabsManager.toggleExpand()',
+ 'zen-sidebar-shortcut-toggle'
+ )
+ );
+
+ // Split view
+ newShortcutList.push(
+ new KeyShortcut(
+ 'zen-split-view-grid',
+ 'G',
+ '',
+ ZEN_SPLIT_VIEW_SHORTCUTS_GROUP,
+ KeyShortcutModifiers.fromObject({ accel: true, alt: true }),
+ "code:gZenViewSplitter.toggleShortcut('grid')",
+ 'zen-split-view-shortcut-grid'
+ )
+ );
+ newShortcutList.push(
+ new KeyShortcut(
+ 'zen-split-view-vertical',
+ 'V',
+ '',
+ ZEN_SPLIT_VIEW_SHORTCUTS_GROUP,
+ KeyShortcutModifiers.fromObject({ accel: true, alt: true }),
+ "code:gZenViewSplitter.toggleShortcut('vsep')",
+ 'zen-split-view-shortcut-vertical'
+ )
+ );
+ newShortcutList.push(
+ new KeyShortcut(
+ 'zen-split-view-horizontal',
+ 'H',
+ '',
+ ZEN_SPLIT_VIEW_SHORTCUTS_GROUP,
+ KeyShortcutModifiers.fromObject({ accel: true, alt: true }),
+ "code:gZenViewSplitter.toggleShortcut('hsep')",
+ 'zen-split-view-shortcut-horizontal'
+ )
+ );
+ newShortcutList.push(
+ new KeyShortcut(
+ 'zen-split-view-unsplit',
+ 'U',
+ '',
+ ZEN_SPLIT_VIEW_SHORTCUTS_GROUP,
+ KeyShortcutModifiers.fromObject({ accel: true, alt: true }),
+ "code:gZenViewSplitter.toggleShortcut('unsplit')",
+ 'zen-split-view-shortcut-unsplit'
+ )
+ );
+
+ return newShortcutList;
+}
+
+class ZenKeyboardShortcutsVersioner {
+ static LATEST_KBS_VERSION = 3;
+
+ constructor() {}
+
+ get version() {
+ return Services.prefs.getIntPref('zen.keyboard.shortcuts.version', 0);
+ }
+
+ set version(version) {
+ Services.prefs.setIntPref('zen.keyboard.shortcuts.version', version);
+ }
+
+ getVersionedData(data) {
+ return {
+ shortcuts: data,
+ };
+ }
+
+ isVersionUpToDate() {
+ return this.version == ZenKeyboardShortcutsVersioner.LATEST_KBS_VERSION;
+ }
+
+ isVersionOutdated() {
+ return this.version < ZenKeyboardShortcutsVersioner.LATEST_KBS_VERSION;
+ }
+
+ migrateIfNeeded(data) {
+ if (!data) {
+ // Rebuid the shortcuts, just in case
+ this.version = 0;
+ }
+
+ if (this.isVersionUpToDate()) {
+ return data;
+ }
+
+ if (this.isVersionOutdated()) {
+ const version = this.version;
+ console.info(
+ 'Zen CKS: Migrating shortcuts from version',
+ version,
+ 'to',
+ ZenKeyboardShortcutsVersioner.LATEST_KBS_VERSION
+ );
+ const newData = this.migrate(data, version);
+ this.version = ZenKeyboardShortcutsVersioner.LATEST_KBS_VERSION;
+ return newData;
+ }
+
+ console.error('Unknown keyboar shortcuts version');
+ this.version = 0;
+ return this.migrateIfNeeded(data);
+ }
+
+ migrate(data, version) {
+ if (version < 1) {
+ // Migrate from 0 to 1
+ // Here, we do a complet reset of the shortcuts,
+ // since nothing seems to work properly.
+ data = zenGetDefaultShortcuts();
+ }
+ if (version < 2) {
+ // Migrate from 1 to 2
+ // In this new version, we are resolving the conflicts between
+ // shortcuts having keycode and key at the same time.
+ // If there's both, we remove the keycodes.
+ for (let shortcut of data) {
+ if (shortcut.getKeyCode() && shortcut.getKeyName()) {
+ shortcut.setNewBinding(shortcut.getKeyName());
+ }
+ }
+ data.push(
+ new KeyShortcut(
+ 'zen-pinned-tab-reset-shortcut',
+ '',
+ '',
+ ZEN_OTHER_SHORTCUTS_GROUP,
+ KeyShortcutModifiers.fromObject({}),
+ 'code:gZenPinnedTabManager.resetPinnedTab(gBrowser.selectedTab)',
+ 'zen-pinned-tab-shortcut-reset'
+ )
+ );
+ }
+ if (version < 3) {
+ // Migrate from 2 to 3
+ // In this new version, there was this *really* annoying bug. Shortcuts
+ // detection for internal keys was not working properly, so every internal
+ // shortcut was being saved as a user-editable shortcut.
+ // This migration will fix this issue.
+ const defaultShortcuts = zenGetDefaultShortcuts();
+ // Get the default shortcut, compare the id and set the internal flag if needed
+ for (let shortcut of data) {
+ for (let defaultShortcut of defaultShortcuts) {
+ if (shortcut.getID() == defaultShortcut.getID()) {
+ shortcut._modifyInternalAttribute(defaultShortcut.isInternal());
+ }
+ }
+ }
+ }
+ return data;
+ }
+}
+
+var gZenKeyboardShortcutsManager = {
+ loader: new ZenKeyboardShortcutsLoader(),
+ beforeInit() {
+ if (!this.inBrowserView) {
+ return;
+ }
+ // Create the main keyset before calling the async init function,
+ // This is because other browser-sets needs this element and the JS event
+ // handled wont wait for the async function to finish.
+ void(this.getZenKeyset());
+
+ this._hasCleared = Services.prefs.getBoolPref('zen.keyboard.shortcuts.disable-mainkeyset-clear', false);
+
+ this.init();
+ },
+
+ async init() {
+ if (this.inBrowserView) {
+ const loadedShortcuts = await this._loadSaved();
+
+ this._currentShortcutList = this.versioner.migrateIfNeeded(loadedShortcuts);
+ this._applyShortcuts();
+
+ await this._saveShortcuts();
+
+ console.info('Zen CKS: Initialized');
+ }
+ },
+
+ get inBrowserView() {
+ return window.location.href == 'chrome://browser/content/browser.xhtml';
+ },
+
+ async _loadSaved() {
+ var innerLoad = async () => {
+ let data = await this.loader.load();
+ if (!data || data.length == 0) {
+ return null;
+ }
+
+ try {
+ return KeyShortcut.parseFromSaved(data);
+ } catch (e) {
+ console.error('Zen CKS: Error parsing saved shortcuts. Resetting to defaults...', e);
+ return null;
+ }
+ };
+
+ const loadedShortcuts = await innerLoad();
+ this.versioner = new ZenKeyboardShortcutsVersioner(loadedShortcuts);
+ return loadedShortcuts;
+ },
+
+ getZenKeyset(browser = window) {
+ if (!browser.gZenKeyboardShortcutsManager._zenKeyset) {
+ const existingKeyset = browser.document.getElementById(ZEN_KEYSET_ID);
+ if (existingKeyset) {
+ browser.gZenKeyboardShortcutsManager._zenKeyset = existingKeyset;
+ return browser.gZenKeyboardShortcutsManager._zenKeyset;
+ }
+
+ browser.gZenKeyboardShortcutsManager._zenKeyset = browser.document.createXULElement('keyset');
+ browser.gZenKeyboardShortcutsManager._zenKeyset.id = ZEN_KEYSET_ID;
+
+ const mainKeyset = browser.document.getElementById(ZEN_MAIN_KEYSET_ID);
+ mainKeyset.after(browser.gZenKeyboardShortcutsManager._zenKeyset);
+ }
+ return browser.gZenKeyboardShortcutsManager._zenKeyset;
+ },
+
+ clearMainKeyset(element) {
+ if (this._hasCleared) {
+ return;
+ }
+ this._hasCleared = true;
+ const children = element.children;
+ for (let i = children.length - 1; i >= 0; i--) {
+ const key = children[i];
+ if (key.getAttribute('internal') == 'true') {
+ continue;
+ }
+ key.remove();
+ }
+
+ // Restore the keyset, https://searchfox.org/mozilla-central/rev/a59018f9ff34170810b43e12bf6f09a1512de7ab/dom/events/GlobalKeyListener.cpp#478
+ const parent = element.parentElement;
+ element.remove();
+ parent.prepend(element);
+ },
+
+ _applyShortcuts() {
+ for (const browser of ZenMultiWindowFeature.browsers) {
+ let mainKeyset = browser.document.getElementById(ZEN_MAIN_KEYSET_ID);
+ if (!mainKeyset) {
+ throw new Error('Main keyset not found');
+ }
+ browser.gZenKeyboardShortcutsManager.clearMainKeyset(mainKeyset);
+
+ const keyset = this.getZenKeyset(browser);
+ keyset.innerHTML = '';
+
+ // We dont check this anymore since we are skiping internal keys
+ //if (mainKeyset.children.length > 0) {
+ // throw new Error('Child list not empty');
+ //}
+
+ for (let key of this._currentShortcutList) {
+ if (key.isEmpty() || key.isInternal()) {
+ continue;
+ }
+ let child = key.toXHTMLElement(browser);
+ keyset.appendChild(child);
+ }
+
+ mainKeyset.after(keyset);
+ console.debug('Shortcuts applied...');
+ }
+ },
+
+ async resetAllShortcuts() {
+ await this.loader.remove();
+ Services.prefs.clearUserPref('zen.keyboard.shortcuts.version');
+ },
+
+ async _saveShortcuts() {
+ let json = [];
+ for (shortcut of this._currentShortcutList) {
+ json.push(shortcut.toJSONForm());
+ }
+
+ await this.loader.save(this.versioner.getVersionedData(json));
+ },
+
+ triggerShortcutRebuild() {
+ this._applyShortcuts();
+ },
+
+ async setShortcut(action, shortcut, modifiers) {
+ if (!action) {
+ throw new Error('Action cannot be null');
+ }
+
+ // Unsetting shortcut
+ for (let targetShortcut of this._currentShortcutList) {
+ if (targetShortcut.getID() != action) {
+ continue;
+ }
+ if (!shortcut && !modifiers) {
+ targetShortcut.clearKeybind();
+ } else {
+ targetShortcut.setNewBinding(shortcut);
+ targetShortcut.setModifiers(modifiers);
+ }
+ }
+
+ await this._saveShortcuts();
+ this.triggerShortcutRebuild();
+ },
+
+ async getModifiableShortcuts() {
+ let rv = [];
+
+ if (!this._currentShortcutList) {
+ this._currentShortcutList = await this._loadSaved();
+ }
+
+ for (let shortcut of this._currentShortcutList) {
+ if (shortcut.isUserEditable()) {
+ rv.push(shortcut);
+ }
+ }
+
+ return rv;
+ },
+
+ checkForConflicts(shortcut, modifiers, id) {
+ const realShortcut = shortcut.toLowerCase();
+ for (let targetShortcut of this._currentShortcutList) {
+ if (targetShortcut.getID() == id) {
+ continue;
+ }
+
+ if (targetShortcut.getModifiers().equals(modifiers) && targetShortcut.getKeyNameOrCode()?.toLowerCase() == realShortcut) {
+ return true;
+ }
+ }
+
+ return false;
+ },
+};
+
+document.addEventListener("MozBeforeInitialXULLayout", () => {
+ if (Services.prefs.getBoolPref('zen.keyboard.shortcuts.enabled', false)) {
+ gZenKeyboardShortcutsManager.beforeInit();
+ }
+}, { once: true });
diff --git a/src/browser/base/zen-components/ZenPinnedTabManager.mjs b/src/browser/base/zen-components/ZenPinnedTabManager.mjs
new file mode 100644
index 000000000..c626bfdd1
--- /dev/null
+++ b/src/browser/base/zen-components/ZenPinnedTabManager.mjs
@@ -0,0 +1,422 @@
+{
+ const lazy = {};
+
+ class ZenPinnedTabsObserver {
+ static ALL_EVENTS = ['TabPinned', 'TabUnpinned', 'TabClose'];
+
+ #listeners = [];
+
+ constructor() {
+ 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');
+ ChromeUtils.defineESModuleGetters(lazy, {E10SUtils: "resource://gre/modules/E10SUtils.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 ZenPinnedTabManager extends ZenDOMOperatedFeature {
+
+ init() {
+ this.observer = new ZenPinnedTabsObserver();
+ this._initClosePinnedTabShortcut();
+ this._insertItemsIntoTabContextMenu();
+ this.observer.addPinnedTabListener(this._onPinnedTabEvent.bind(this));
+
+ this._zenClickEventListener = this._onTabClick.bind(this);
+ }
+
+ async initTabs() {
+ await ZenPinnedTabsStorage.init();
+ await this._refreshPinnedTabs();
+ }
+
+ async _refreshPinnedTabs() {
+ await this._initializePinsCache();
+ this._initializePinnedTabs();
+ }
+
+ async _initializePinsCache() {
+ try {
+ // Get pin data
+ const pins = await ZenPinnedTabsStorage.getPins();
+
+ // Enhance pins with favicons
+ const enhancedPins = await Promise.all(pins.map(async pin => {
+ try {
+ const faviconData = await PlacesUtils.promiseFaviconData(pin.url);
+ return {
+ ...pin,
+ iconUrl: faviconData?.uri?.spec || null
+ };
+ } catch(ex) {
+ // If favicon fetch fails, continue without icon
+ return {
+ ...pin,
+ iconUrl: null
+ };
+ }
+ }));
+
+ this._pinsCache = enhancedPins.sort((a, b) => {
+ if (!a.workspaceUuid && b.workspaceUuid) return -1;
+ if (a.workspaceUuid && !b.workspaceUuid) return 1;
+ return 0;
+ });
+
+ } catch (ex) {
+ console.error("Failed to initialize pins cache:", ex);
+ this._pinsCache = [];
+ }
+
+ return this._pinsCache;
+ }
+
+ _initializePinnedTabs() {
+ const pins = this._pinsCache;
+ if (!pins?.length) {
+ // If there are no pins, we should remove any existing pinned tabs
+ for (let tab of gBrowser.tabs) {
+ if (tab.pinned && !tab.getAttribute("zen-pin-id")) {
+ gBrowser.removeTab(tab);
+ }
+ }
+ return;
+ }
+
+ const activeTab = gBrowser.selectedTab;
+ const pinnedTabsByUUID = new Map();
+ const pinsToCreate = new Set(pins.map(p => p.uuid));
+
+ // First pass: identify existing tabs and remove those without pins
+ for (let tab of gBrowser.tabs) {
+ const pinId = tab.getAttribute("zen-pin-id");
+ if (!pinId) {
+ continue;
+ }
+
+ if (pinsToCreate.has(pinId)) {
+ // This is a valid pinned tab that matches a pin
+ pinnedTabsByUUID.set(pinId, tab);
+ pinsToCreate.delete(pinId);
+ } else {
+ // This is a pinned tab that no longer has a corresponding pin
+ gBrowser.removeTab(tab);
+ }
+ }
+
+ // Second pass: create new tabs for pins that don't have tabs
+ for (let pin of pins) {
+ if (!pinsToCreate.has(pin.uuid)) {
+ continue; // Skip pins that already have tabs
+ }
+
+ let newTab = gBrowser.addTrustedTab(pin.url, {
+ skipAnimation: true,
+ userContextId: pin.containerTabId || 0,
+ allowInheritPrincipal: false,
+ createLazyBrowser: true,
+ skipLoad: true,
+ });
+
+ // Set the favicon from cache
+ if (!!pin.iconUrl) {
+ // TODO: Figure out if there is a better way -
+ // calling gBrowser.setIcon messes shit up and should be avoided. I think this works for now.
+ newTab.setAttribute("image", pin.iconUrl);
+ }
+
+ newTab.setAttribute("zen-pin-id", pin.uuid);
+ gBrowser.setInitialTabTitle(newTab, pin.title);
+
+ if (pin.workspaceUuid) {
+ newTab.setAttribute("zen-workspace-id", pin.workspaceUuid);
+ }
+
+ gBrowser.pinTab(newTab);
+ }
+
+ // Restore active tab
+ if (!activeTab.closing) {
+ gBrowser.selectedTab = activeTab;
+ }
+ }
+
+ _onPinnedTabEvent(action, event) {
+ const tab = event.target;
+ switch (action) {
+ case "TabPinned":
+ tab._zenClickEventListener = this._zenClickEventListener;
+ tab.addEventListener("click", tab._zenClickEventListener);
+ this._setPinnedAttributes(tab);
+ break;
+ case "TabUnpinned":
+ this._removePinnedAttributes(tab);
+ if (tab._zenClickEventListener) {
+ tab.removeEventListener("click", tab._zenClickEventListener);
+ delete tab._zenClickEventListener;
+ }
+ break;
+ // TODO: Do this in a better way. Closing a second window could trigger remove tab and delete it from db
+ // case "TabClose":
+ // this._removePinnedAttributes(tab);
+ // break;
+ default:
+ console.warn('ZenPinnedTabManager: Unhandled tab event', action);
+ break;
+ }
+ }
+
+ _onTabClick(e) {
+ const tab = e.target;
+ if (e.button === 1) {
+ this._onCloseTabShortcut(e, tab);
+ }
+ }
+
+ async resetPinnedTab(tab) {
+
+ if (!tab) {
+ tab = TabContextMenu.contextTab;
+ }
+
+ if (!tab || !tab.pinned) {
+ return;
+ }
+
+ await this._resetTabToStoredState(tab);
+ }
+
+ async replacePinnedUrlWithCurrent() {
+ const tab = TabContextMenu.contextTab;
+ if (!tab || !tab.pinned || !tab.getAttribute("zen-pin-id")) {
+ return;
+ }
+
+ const browser = tab.linkedBrowser;
+
+ const pin = this._pinsCache.find(pin => pin.uuid === tab.getAttribute("zen-pin-id"));
+
+ if (!pin) {
+ return;
+ }
+
+ pin.title = tab.label || browser.contentTitle;
+ pin.url = browser.currentURI.spec;
+ pin.workspaceUuid = tab.getAttribute("zen-workspace-id");
+ pin.userContextId = tab.getAttribute("userContextId");
+
+ await ZenPinnedTabsStorage.savePin(pin);
+ await this._refreshPinnedTabs();
+ }
+
+ async _setPinnedAttributes(tab) {
+
+ if (tab.hasAttribute("zen-pin-id")) {
+ return;
+ }
+
+ const browser = tab.linkedBrowser;
+
+ const uuid = gZenUIManager.generateUuidv4();
+
+ await ZenPinnedTabsStorage.savePin({
+ uuid,
+ title: tab.label || browser.contentTitle,
+ url: browser.currentURI.spec,
+ containerTabId: tab.getAttribute("userContextId"),
+ workspaceUuid: tab.getAttribute("zen-workspace-id")
+ });
+
+ tab.setAttribute("zen-pin-id", uuid);
+
+ await this._refreshPinnedTabs();
+ }
+
+ async _removePinnedAttributes(tab) {
+ if(!tab.getAttribute("zen-pin-id")) {
+ return;
+ }
+
+ await ZenPinnedTabsStorage.removePin(tab.getAttribute("zen-pin-id"));
+
+ tab.removeAttribute("zen-pin-id");
+
+ if(!tab.hasAttribute("zen-workspace-id") && ZenWorkspaces.workspaceEnabled) {
+ const workspace = await ZenWorkspaces.getActiveWorkspace();
+ tab.setAttribute("zen-workspace-id", workspace.uuid);
+ }
+
+ await this._refreshPinnedTabs();
+ }
+
+ _initClosePinnedTabShortcut() {
+ let cmdClose = document.getElementById('cmd_close');
+
+ if (cmdClose) {
+ cmdClose.addEventListener('command', this._onCloseTabShortcut.bind(this));
+ }
+ }
+
+ _onCloseTabShortcut(event, selectedTab = gBrowser.selectedTab) {
+ if (
+ !selectedTab?.pinned
+ ) {
+ return;
+ }
+
+ event.stopPropagation();
+ event.preventDefault();
+
+ const behavior = lazy.zenPinnedTabCloseShortcutBehavior;
+
+ switch (behavior) {
+ case 'close':
+ gBrowser.removeTab(selectedTab, { animate: true });
+ break;
+ case 'reset-unload-switch':
+ case 'unload-switch':
+ case 'reset-switch':
+ case 'switch':
+ this._handleTabSwitch(selectedTab);
+ if (behavior.includes('reset')) {
+ this._resetTabToStoredState(selectedTab);
+ }
+ if (behavior.includes('unload')) {
+ gBrowser.discardBrowser(selectedTab);
+ }
+ break;
+ case 'reset':
+ this._resetTabToStoredState(selectedTab);
+ break;
+ default:
+ return;
+ }
+
+
+ }
+
+ _handleTabSwitch(selectedTab) {
+ if(selectedTab !== gBrowser.selectedTab) {
+ return;
+ }
+ const findNextTab = (direction) =>
+ gBrowser.tabContainer.findNextTab(selectedTab, {
+ direction,
+ filter: tab => !tab.hidden && !tab.pinned,
+ });
+
+ let nextTab = findNextTab(1) || findNextTab(-1);
+
+ if (!nextTab) {
+ ZenWorkspaces._createNewTabForWorkspace({ uuid: ZenWorkspaces.activeWorkspace });
+
+ nextTab = findNextTab(1) || findNextTab(-1);
+ }
+
+ if (nextTab) {
+ gBrowser.selectedTab = nextTab;
+ }
+ }
+
+ async _resetTabToStoredState(tab) {
+ const id = tab.getAttribute("zen-pin-id");
+
+ if (!id) {
+ return;
+ }
+
+ const pin = this._pinsCache.find(pin => pin.uuid === id);
+
+ if (pin) {
+ const tabState = SessionStore.getTabState(tab);
+ const state = JSON.parse(tabState);
+ const icon = await PlacesUtils.promiseFaviconData(pin.url);
+
+ state.entries = [{
+ url: pin.url,
+ title: pin.title,
+ triggeringPrincipal_base64: lazy.E10SUtils.SERIALIZED_SYSTEMPRINCIPAL
+ }];
+ state.image = icon;
+ state.index = 0;
+
+ SessionStore.setTabState(tab, state);
+ }
+ }
+
+ _addGlobalPin() {
+ const tab = TabContextMenu.contextTab;
+ if (!tab || tab.pinned) {
+ return;
+ }
+
+ tab.removeAttribute("zen-workspace-id");
+
+ gBrowser.pinTab(tab);
+ }
+
+ _insertItemsIntoTabContextMenu() {
+ const elements = window.MozXULElement.parseXULToFragment(`
+
+
+
+ `);
+ document.getElementById('tabContextMenu').appendChild(elements);
+
+ const element = window.MozXULElement.parseXULToFragment(`
+
+ `);
+
+ document.getElementById('context_pinTab').after(element);
+ }
+
+ resetPinnedTabData(tabData) {
+ if (lazy.zenPinnedTabRestorePinnedTabsToPinnedUrl && tabData.pinned && tabData.zenPinnedEntry) {
+ tabData.entries = [JSON.parse(tabData.zenPinnedEntry)];
+ tabData.image = tabData.zenPinnedIcon;
+ tabData.index = 0;
+ }
+ }
+
+ updatePinnedTabContextMenu(contextTab) {
+ const isVisible = contextTab.pinned && !contextTab.multiselected;
+ document.getElementById("context_zen-reset-pinned-tab").hidden = !isVisible || !contextTab.getAttribute("zen-pin-id");
+ document.getElementById("context_zen-replace-pinned-url-with-current").hidden = !isVisible;
+ document.getElementById("context_zen-pin-tab-global").hidden = contextTab.pinned;
+ document.getElementById("context_zen-pinned-tab-separator").hidden = !isVisible;
+ }
+ }
+
+ window.gZenPinnedTabManager = new ZenPinnedTabManager();
+}
\ No newline at end of file
diff --git a/src/browser/base/zen-components/ZenPinnedTabsStorage.mjs b/src/browser/base/zen-components/ZenPinnedTabsStorage.mjs
new file mode 100644
index 000000000..d529ea822
--- /dev/null
+++ b/src/browser/base/zen-components/ZenPinnedTabsStorage.mjs
@@ -0,0 +1,332 @@
+var ZenPinnedTabsStorage = {
+ async init() {
+ console.log('ZenPinnedTabsStorage: Initializing...');
+ await this._ensureTable();
+ },
+
+ async _ensureTable() {
+ await PlacesUtils.withConnectionWrapper('ZenPinnedTabsStorage._ensureTable', async (db) => {
+ // Create the pins table if it doesn't exist
+ await db.execute(`
+ CREATE TABLE IF NOT EXISTS zen_pins (
+ id INTEGER PRIMARY KEY,
+ uuid TEXT UNIQUE NOT NULL,
+ title TEXT NOT NULL,
+ url TEXT,
+ container_id INTEGER,
+ workspace_uuid TEXT,
+ position INTEGER NOT NULL DEFAULT 0,
+ is_essential BOOLEAN NOT NULL DEFAULT 0,
+ is_group BOOLEAN NOT NULL DEFAULT 0,
+ parent_uuid TEXT,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL,
+ FOREIGN KEY (parent_uuid) REFERENCES zen_pins(uuid) ON DELETE SET NULL
+ )
+ `);
+
+
+ // Create indices
+ await db.execute(`
+ CREATE INDEX IF NOT EXISTS idx_zen_pins_uuid ON zen_pins(uuid)
+ `);
+
+ await db.execute(`
+ CREATE INDEX IF NOT EXISTS idx_zen_pins_parent_uuid ON zen_pins(parent_uuid)
+ `);
+
+ // Create the changes tracking table if it doesn't exist
+ await db.execute(`
+ CREATE TABLE IF NOT EXISTS zen_pins_changes (
+ uuid TEXT PRIMARY KEY,
+ timestamp INTEGER NOT NULL
+ )
+ `);
+
+ // Create an index on the uuid column for changes tracking table
+ await db.execute(`
+ CREATE INDEX IF NOT EXISTS idx_zen_pins_changes_uuid ON zen_pins_changes(uuid)
+ `);
+ });
+ },
+
+ /**
+ * Private helper method to notify observers with a list of changed UUIDs.
+ * @param {string} event - The observer event name.
+ * @param {Array} uuids - Array of changed workspace UUIDs.
+ */
+ _notifyPinsChanged(event, uuids) {
+ if (uuids.length === 0) return; // No changes to notify
+
+ // Convert the array of UUIDs to a JSON string
+ const data = JSON.stringify(uuids);
+
+ Services.obs.notifyObservers(null, event, data);
+ },
+
+ async savePin(pin, notifyObservers = true) {
+ const changedUUIDs = new Set();
+
+ await PlacesUtils.withConnectionWrapper('ZenPinnedTabsStorage.savePin', async (db) => {
+ await db.executeTransaction(async () => {
+ const now = Date.now();
+
+ let newPosition;
+ if ('position' in pin && Number.isFinite(pin.position)) {
+ newPosition = pin.position;
+ } else {
+ // Get the maximum position within the same parent group (or null for root level)
+ const maxPositionResult = await db.execute(`
+ SELECT MAX("position") as max_position
+ FROM zen_pins
+ WHERE COALESCE(parent_uuid, '') = COALESCE(:parent_uuid, '')
+ `, { parent_uuid: pin.parentUuid || null });
+ const maxPosition = maxPositionResult[0].getResultByName('max_position') || 0;
+ newPosition = maxPosition + 1000;
+ }
+
+ // Insert or replace the pin
+ await db.executeCached(`
+ INSERT OR REPLACE INTO zen_pins (
+ uuid, title, url, container_id, workspace_uuid, position,
+ is_essential, is_group, parent_uuid, created_at, updated_at
+ ) VALUES (
+ :uuid, :title, :url, :container_id, :workspace_uuid, :position,
+ :is_essential, :is_group, :parent_uuid,
+ COALESCE((SELECT created_at FROM zen_pins WHERE uuid = :uuid), :now),
+ :now
+ )
+ `, {
+ uuid: pin.uuid,
+ title: pin.title,
+ url: pin.isGroup ? null : pin.url,
+ container_id: pin.containerTabId || null,
+ workspace_uuid: pin.workspaceUuid || null,
+ position: newPosition,
+ is_essential: pin.isEssential || false,
+ is_group: pin.isGroup || false,
+ parent_uuid: pin.parentUuid || null,
+ now
+ });
+
+ await db.execute(`
+ INSERT OR REPLACE INTO zen_pins_changes (uuid, timestamp)
+ VALUES (:uuid, :timestamp)
+ `, {
+ uuid: pin.uuid,
+ timestamp: Math.floor(now / 1000)
+ });
+
+ changedUUIDs.add(pin.uuid);
+ await this.updateLastChangeTimestamp(db);
+ });
+ });
+
+ if (notifyObservers) {
+ this._notifyPinsChanged("zen-pin-updated", Array.from(changedUUIDs));
+ }
+ },
+
+ async getPins() {
+ const db = await PlacesUtils.promiseDBConnection();
+ const rows = await db.executeCached(`
+ SELECT * FROM zen_pins
+ ORDER BY parent_uuid NULLS FIRST, position ASC
+ `);
+ return rows.map((row) => ({
+ uuid: row.getResultByName('uuid'),
+ title: row.getResultByName('title'),
+ url: row.getResultByName('url'),
+ containerTabId: row.getResultByName('container_id'),
+ workspaceUuid: row.getResultByName('workspace_uuid'),
+ position: row.getResultByName('position'),
+ isEssential: Boolean(row.getResultByName('is_essential')),
+ isGroup: Boolean(row.getResultByName('is_group')),
+ parentUuid: row.getResultByName('parent_uuid')
+ }));
+ },
+
+ async getGroupChildren(groupUuid) {
+ const db = await PlacesUtils.promiseDBConnection();
+ const rows = await db.executeCached(`
+ SELECT * FROM zen_pins
+ WHERE parent_uuid = :groupUuid
+ ORDER BY position ASC
+ `, { groupUuid });
+
+ return rows.map((row) => ({
+ uuid: row.getResultByName('uuid'),
+ title: row.getResultByName('title'),
+ url: row.getResultByName('url'),
+ containerTabId: row.getResultByName('container_id'),
+ workspaceUuid: row.getResultByName('workspace_uuid'),
+ position: row.getResultByName('position'),
+ isEssential: Boolean(row.getResultByName('is_essential')),
+ isGroup: Boolean(row.getResultByName('is_group')),
+ parentUuid: row.getResultByName('parent_uuid')
+ }));
+ },
+
+ async removePin(uuid, notifyObservers = true) {
+ const changedUUIDs = [uuid];
+
+ await PlacesUtils.withConnectionWrapper('ZenPinnedTabsStorage.removePin', async (db) => {
+ await db.executeTransaction(async () => {
+ // Get all child UUIDs first for change tracking
+ const children = await db.execute(
+ `SELECT uuid FROM zen_pins WHERE parent_uuid = :uuid`,
+ { uuid }
+ );
+
+ // Add child UUIDs to changedUUIDs array
+ for (const child of children) {
+ changedUUIDs.push(child.getResultByName('uuid'));
+ }
+
+ // Delete all children in a single statement
+ await db.execute(
+ `DELETE FROM zen_pins WHERE parent_uuid = :uuid`,
+ { uuid }
+ );
+
+ // Delete the pin/group itself
+ await db.execute(
+ `DELETE FROM zen_pins WHERE uuid = :uuid`,
+ { uuid }
+ );
+
+ // Record the changes
+ const now = Math.floor(Date.now() / 1000);
+ for (const changedUuid of changedUUIDs) {
+ await db.execute(`
+ INSERT OR REPLACE INTO zen_pins_changes (uuid, timestamp)
+ VALUES (:uuid, :timestamp)
+ `, {
+ uuid: changedUuid,
+ timestamp: now
+ });
+ }
+
+ await this.updateLastChangeTimestamp(db);
+ });
+ });
+
+ if (notifyObservers) {
+ this._notifyPinsChanged("zen-pin-removed", changedUUIDs);
+ }
+ },
+
+ async wipeAllPins() {
+ await PlacesUtils.withConnectionWrapper('ZenPinnedTabsStorage.wipeAllPins', async (db) => {
+ await db.execute(`DELETE FROM zen_pins`);
+ await db.execute(`DELETE FROM zen_pins_changes`);
+ await this.updateLastChangeTimestamp(db);
+ });
+ },
+
+ async markChanged(uuid) {
+ await PlacesUtils.withConnectionWrapper('ZenPinnedTabsStorage.markChanged', async (db) => {
+ const now = Date.now();
+ await db.execute(`
+ INSERT OR REPLACE INTO zen_pins_changes (uuid, timestamp)
+ VALUES (:uuid, :timestamp)
+ `, {
+ uuid,
+ timestamp: Math.floor(now / 1000)
+ });
+ });
+ },
+
+ async getChangedIDs() {
+ const db = await PlacesUtils.promiseDBConnection();
+ const rows = await db.execute(`
+ SELECT uuid, timestamp FROM zen_pins_changes
+ `);
+ const changes = {};
+ for (const row of rows) {
+ changes[row.getResultByName('uuid')] = row.getResultByName('timestamp');
+ }
+ return changes;
+ },
+
+ async clearChangedIDs() {
+ await PlacesUtils.withConnectionWrapper('ZenPinnedTabsStorage.clearChangedIDs', async (db) => {
+ await db.execute(`DELETE FROM zen_pins_changes`);
+ });
+ },
+
+ shouldReorderPins(before, current, after) {
+ const minGap = 1; // Minimum allowed gap between positions
+ return (before !== null && current - before < minGap) || (after !== null && after - current < minGap);
+ },
+
+ async reorderAllPins(db, changedUUIDs) {
+ const pins = await db.execute(`
+ SELECT uuid
+ FROM zen_pins
+ ORDER BY position ASC
+ `);
+
+ for (let i = 0; i < pins.length; i++) {
+ const newPosition = (i + 1) * 1000; // Use large increments
+ await db.execute(`
+ UPDATE zen_pins
+ SET position = :newPosition
+ WHERE uuid = :uuid
+ `, { newPosition, uuid: pins[i].getResultByName('uuid') });
+ changedUUIDs.add(pins[i].getResultByName('uuid'));
+ }
+ },
+
+ async updateLastChangeTimestamp(db) {
+ const now = Date.now();
+ await db.execute(`
+ INSERT OR REPLACE INTO moz_meta (key, value)
+ VALUES ('zen_pins_last_change', :now)
+ `, { now });
+ },
+
+ async getLastChangeTimestamp() {
+ const db = await PlacesUtils.promiseDBConnection();
+ const result = await db.executeCached(`
+ SELECT value FROM moz_meta WHERE key = 'zen_pins_last_change'
+ `);
+ return result.length ? parseInt(result[0].getResultByName('value'), 10) : 0;
+ },
+
+ async updatePinPositions(pins) {
+ const changedUUIDs = new Set();
+
+ await PlacesUtils.withConnectionWrapper('ZenPinnedTabsStorage.updatePinPositions', async (db) => {
+ await db.executeTransaction(async () => {
+ const now = Date.now();
+
+ for (let i = 0; i < pins.length; i++) {
+ const pin = pins[i];
+ const newPosition = (i + 1) * 1000;
+
+ await db.execute(`
+ UPDATE zen_pins
+ SET position = :newPosition
+ WHERE uuid = :uuid
+ `, { newPosition, uuid: pin.uuid });
+
+ changedUUIDs.add(pin.uuid);
+
+ // Record the change
+ await db.execute(`
+ INSERT OR REPLACE INTO zen_pins_changes (uuid, timestamp)
+ VALUES (:uuid, :timestamp)
+ `, {
+ uuid: pin.uuid,
+ timestamp: Math.floor(now / 1000)
+ });
+ }
+
+ await this.updateLastChangeTimestamp(db);
+ });
+ });
+
+ this._notifyPinsChanged("zen-pin-updated", Array.from(changedUUIDs));
+ }
+};
diff --git a/src/browser/base/zen-components/ZenProfileDialogUI.mjs b/src/browser/base/zen-components/ZenProfileDialogUI.mjs
new file mode 100644
index 000000000..cdc8f7c3b
--- /dev/null
+++ b/src/browser/base/zen-components/ZenProfileDialogUI.mjs
@@ -0,0 +1,138 @@
+// 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/.
+
+var ZenProfileDialogUI = {
+ showSubView(parent, event) {
+ let element = parent.querySelector('.zen-side-bar-profiles-button-panel-correction') || parent;
+ PanelUI.showSubView('PanelUI-zen-profiles', element, event);
+ this._updateProfilesList();
+ this._updateCurentProfileId();
+ },
+
+ _updateProfilesList() {
+ let parentList = document.getElementById('PanelUI-zen-profiles-list');
+ this._emptyUserList(parentList);
+ if (this._getProfilesSize(ProfileService.profiles) <= 1) {
+ return;
+ }
+ parentList.appendChild(document.createElement('toolbarseparator'));
+ for (let profile of ProfileService.profiles) {
+ if (profile == ProfileService.currentProfile) {
+ continue;
+ }
+ let item = document.createElement('div');
+ item.onclick = () => this._openProfile(profile);
+ item.className = 'PanelUI-zen-profiles-item';
+ let avatar = document.createElement('img');
+ avatar.className = 'PanelUI-zen-profiles-item-avatar';
+ let name = document.createElement('div');
+ name.className = 'PanelUI-zen-profiles-item-name';
+ name.appendChild(document.createTextNode(profile.name));
+ name.container = true;
+ avatar.setAttribute('src', ZenThemeModifier._getThemedAvatar(profile.zenAvatarPath));
+ item.appendChild(avatar);
+ item.appendChild(name);
+ parentList.appendChild(item);
+ }
+ },
+
+ _emptyUserList(element) {
+ element.innerHTML = '';
+ },
+
+ _updateCurentProfileId() {
+ let currentProfile = ProfileService.currentProfile;
+ if (!currentProfile) return;
+ let nameContainer = document.getElementById('PanelUI-zen-profiles-current-name');
+ nameContainer.textContent = currentProfile.name;
+ },
+
+ _openProfile(profile) {
+ Services.startup.createInstanceWithProfile(profile);
+ },
+
+ _getProfilesSize(profiles) {
+ let size = 0;
+ for (let _ of profiles) {
+ size += 1;
+ }
+ return size;
+ },
+
+ createProfileWizard() {
+ // This should be rewritten in HTML eventually.
+ // TODO: it could be `window.browsingContext.topChromeWindow.gDialogBox.open` but it does not work with the callback?
+ window.browsingContext.topChromeWindow.openDialog(
+ 'chrome://mozapps/content/profile/createProfileWizard.xhtml',
+ '',
+ 'centerscreen,chrome,modal,titlebar',
+ ProfileService,
+ {
+ CreateProfile: async (profile) => {
+ try {
+ ProfileService.defaultProfile = profile;
+ this._flush();
+ this._openProfile(profile);
+ } catch (e) {
+ // This can happen on dev-edition.
+ let [title, msg] = await document.l10n.formatValues([
+ { id: 'profiles-cannot-set-as-default-title' },
+ { id: 'profiles-cannot-set-as-default-message' },
+ ]);
+
+ Services.prompt.alert(window, title, msg);
+ }
+ },
+ }
+ );
+ },
+
+ async _flush() {
+ try {
+ ProfileService.flush();
+ this._updateProfilesList();
+ } catch (e) {
+ let [title, msg, button] = await document.l10n.formatValues([
+ { id: 'profiles-flush-fail-title' },
+ {
+ id: e.result == Cr.NS_ERROR_DATABASE_CHANGED ? 'profiles-flush-conflict' : 'profiles-flush-failed',
+ },
+ { id: 'profiles-flush-restart-button' },
+ ]);
+
+ const PS = Ci.nsIPromptService;
+ let result = Services.prompt.confirmEx(
+ window,
+ title,
+ msg,
+ PS.BUTTON_POS_0 * PS.BUTTON_TITLE_CANCEL + PS.BUTTON_POS_1 * PS.BUTTON_TITLE_IS_STRING,
+ null,
+ button,
+ null,
+ null,
+ {}
+ );
+ if (result == 1) {
+ this._restart(false);
+ }
+ }
+ },
+
+ _restart(safeMode) {
+ let cancelQuit = Cc['@mozilla.org/supports-PRBool;1'].createInstance(Ci.nsISupportsPRBool);
+ Services.obs.notifyObservers(cancelQuit, 'quit-application-requested', 'restart');
+
+ if (cancelQuit.data) {
+ return;
+ }
+
+ let flags = Ci.nsIAppStartup.eAttemptQuit | Ci.nsIAppStartup.eRestart;
+
+ if (safeMode) {
+ Services.startup.restartInSafeMode(flags);
+ } else {
+ Services.startup.quit(flags);
+ }
+ },
+};
diff --git a/src/browser/base/zen-components/ZenSidebarManager.mjs b/src/browser/base/zen-components/ZenSidebarManager.mjs
new file mode 100644
index 000000000..1103b1beb
--- /dev/null
+++ b/src/browser/base/zen-components/ZenSidebarManager.mjs
@@ -0,0 +1,788 @@
+class ZenBrowserManagerSidebar extends ZenDOMOperatedFeature {
+ _sidebarElement = null;
+ _currentPanel = null;
+ _lastOpenedPanel = null;
+ _hasChangedConfig = true;
+ _splitterElement = null;
+ _hSplitterElement = null;
+ _hasRegisteredPinnedClickOutside = false;
+ _isDragging = false;
+ contextTab = null;
+ sidebar = null;
+ forwardButton = null;
+ backButton = null;
+ progressListener = null;
+ _tabBrowserSet = new WeakMap();
+ tabBox;
+
+ DEFAULT_MOBILE_USER_AGENT = `Mozilla/5.0 (Android 12; Mobile; rv:129.0) Gecko/20100101 Firefox/${AppConstants.ZEN_FIREFOX_VERSION}`;
+ MAX_SIDEBAR_PANELS = Services.prefs.getIntPref('zen.sidebar.max-webpanels');
+
+ init() {
+ ChromeUtils.defineLazyGetter(this, 'sidebar', () => document.getElementById('zen-sidebar-web-panel'));
+ ChromeUtils.defineLazyGetter(this, 'forwardButton', () => document.getElementById('zen-sidebar-web-panel-forward'));
+ ChromeUtils.defineLazyGetter(this, 'backButton', () => document.getElementById('zen-sidebar-web-panel-back'));
+ ChromeUtils.defineLazyGetter(this, 'tabBox', () => document.getElementById('tabbrowser-tabbox'));
+
+ this.onlySafeWidthAndHeight();
+
+ this.initProgressListener();
+ this.update();
+ this.close(); // avoid caching
+ this.listenForPrefChanges();
+ this.insertIntoContextMenu();
+ this.addPositioningListeners();
+ }
+
+ onlySafeWidthAndHeight() {
+ const panel = document.getElementById('zen-sidebar-web-panel');
+ const width = panel.style.width;
+ const height = panel.style.height;
+ panel.setAttribute('style', '');
+ panel.style.width = width;
+ panel.style.height = height;
+ }
+
+ initProgressListener() {
+ this.progressListener = {
+ QueryInterface: ChromeUtils.generateQI(['nsIWebProgressListener', 'nsISupportsWeakReference']),
+ onLocationChange: function (aWebProgress, aRequest, aLocation, aFlags) {
+ const browser = this._getCurrentBrowser();
+ if (!browser) return;
+ const forwardDisabled = this.forwardButton.hasAttribute('disabled');
+ const backDisabled = this.backButton.hasAttribute('disabled');
+
+ if (browser.canGoForward === forwardDisabled) {
+ if (browser.canGoForward) {
+ this.forwardButton.removeAttribute('disabled');
+ } else {
+ this.forwardButton.setAttribute('disabled', true);
+ }
+ }
+ if (browser.canGoBack === backDisabled) {
+ if (browser.canGoBack) {
+ this.backButton.removeAttribute('disabled');
+ } else {
+ this.backButton.setAttribute('disabled', true);
+ }
+ }
+ }.bind(gZenBrowserManagerSidebar),
+ };
+ }
+
+ get sidebarData() {
+ let services = Services.prefs.getStringPref('zen.sidebar.data');
+ if (services === '') {
+ return {};
+ }
+ return JSON.parse(services);
+ }
+
+ get shouldCloseOnBlur() {
+ return Services.prefs.getBoolPref('zen.sidebar.close-on-blur');
+ }
+
+ listenForPrefChanges() {
+ Services.prefs.addObserver('zen.sidebar.data', this.handleEvent.bind(this));
+ Services.prefs.addObserver('zen.sidebar.enabled', this.handleEvent.bind(this));
+
+ this.handleEvent();
+ }
+
+ addPositioningListeners() {
+ this.sidebar
+ .querySelectorAll('.zen-sidebar-web-panel-splitter')
+ .forEach((s) => s.addEventListener('mousedown', this.handleSplitterMouseDown.bind(this)));
+ this.sidebarHeader.addEventListener('mousedown', this.handleDragPanel.bind(this));
+ window.addEventListener('resize', this.onWindowResize.bind(this));
+ }
+
+ handleSplitterMouseDown(mouseDownEvent) {
+ if (this._isDragging) return;
+ this._isDragging = true;
+
+ const isHorizontal = mouseDownEvent.target.getAttribute('orient') === 'horizontal';
+ setCursor(isHorizontal ? 'n-resize' : 'ew-resize');
+ const reverse = ['left', 'top'].includes(mouseDownEvent.target.getAttribute('side'));
+ const direction = isHorizontal ? 'height' : 'width';
+ const axis = isHorizontal ? 'Y' : 'X';
+
+ const computedStyle = window.getComputedStyle(this.sidebar);
+ const maxSize = parseInt(computedStyle.getPropertyValue(`max-${direction}`).match(/(\d+)px/)?.[1]) || Infinity;
+ const minSize = parseInt(computedStyle.getPropertyValue(`min-${direction}`).match(/(\d+)px/)?.[1]) || 0;
+
+ const sidebarSizeStart = this.sidebar.getBoundingClientRect()[direction];
+
+ const startPos = mouseDownEvent[`screen${axis}`];
+
+ const toAdjust = isHorizontal ? 'top' : 'left';
+ const sidebarPosStart = parseInt(this.sidebar.style[toAdjust].match(/\d+/));
+
+ let mouseMove = function (e) {
+ let mouseMoved = e[`screen${axis}`] - startPos;
+ if (reverse) {
+ mouseMoved *= -1;
+ }
+ let newSize = sidebarSizeStart + mouseMoved;
+ let currentMax = maxSize;
+ const wrapperBox = this.sidebarWrapper.getBoundingClientRect();
+ let maxWrapperSize = Infinity;
+ if (this.isFloating) {
+ maxWrapperSize = reverse ? sidebarPosStart + sidebarSizeStart : wrapperBox[direction] - sidebarPosStart;
+ }
+ newSize = Math.max(minSize, Math.min(currentMax, maxWrapperSize, newSize));
+
+ window.requestAnimationFrame(() => {
+ if (reverse) {
+ const actualMoved = newSize - sidebarSizeStart;
+ this.sidebar.style[toAdjust] = sidebarPosStart - actualMoved + 'px';
+ }
+ this.sidebar.style[direction] = `${newSize}px`;
+ });
+ }.bind(this);
+
+ document.addEventListener('mousemove', mouseMove);
+ document.addEventListener(
+ 'mouseup',
+ () => {
+ document.removeEventListener('mousemove', mouseMove);
+ this._isDragging = false;
+ setCursor('auto');
+ },
+ { once: true }
+ );
+ }
+
+ handleDragPanel(mouseDownEvent) {
+ if (this.sidebarHeaderButtons.find((b) => b.contains(mouseDownEvent.target))) {
+ return;
+ }
+ this._isDragging = true;
+ const startTop = this.sidebar.style.top?.match(/\d+/)?.[0] || 0;
+ const startLeft = this.sidebar.style.left?.match(/\d+/)?.[0] || 0;
+
+ const sidebarBBox = this.sidebar.getBoundingClientRect();
+ const sideBarHeight = sidebarBBox.height;
+ const sideBarWidth = sidebarBBox.width;
+
+ const topMouseOffset = startTop - mouseDownEvent.screenY;
+ const leftMouseOffset = startLeft - mouseDownEvent.screenX;
+ const moveListener = (mouseMoveEvent) => {
+ window.requestAnimationFrame(() => {
+ let top = mouseMoveEvent.screenY + topMouseOffset;
+ let left = mouseMoveEvent.screenX + leftMouseOffset;
+
+ const wrapperBounds = this.sidebarWrapper.getBoundingClientRect();
+ top = Math.max(0, Math.min(top, wrapperBounds.height - sideBarHeight));
+ left = Math.max(0, Math.min(left, wrapperBounds.width - sideBarWidth));
+
+ this.sidebar.style.top = top + 'px';
+ this.sidebar.style.left = left + 'px';
+ });
+ };
+
+ document.addEventListener('mousemove', moveListener);
+ document.addEventListener(
+ 'mouseup',
+ () => {
+ document.removeEventListener('mousemove', moveListener);
+ this._isDragging = false;
+ },
+ { once: true }
+ );
+ }
+
+ onWindowResize() {
+ if (!this.isFloating) return;
+ const top = parseInt(this.sidebar.style.top?.match(/\d+/)?.[0] || 0);
+ const left = parseInt(this.sidebar.style.left?.match(/\d+/)?.[0] || 0);
+ const wrapperRect = this.sidebarWrapper.getBoundingClientRect();
+ const sidebarRect = this.sidebar.getBoundingClientRect();
+
+ if (sidebarRect.height < wrapperRect.height && top + sidebarRect.height > wrapperRect.height) {
+ this.sidebar.style.top = wrapperRect.height - sidebarRect.height + 'px';
+ }
+ if (sidebarRect.width < wrapperRect.width && left + sidebarRect.width > wrapperRect.width) {
+ this.sidebar.style.left = wrapperRect.width - sidebarRect.width + 'px';
+ }
+ }
+
+ get isFloating() {
+ return document.getElementById('zen-sidebar-web-panel').hasAttribute('pinned');
+ }
+
+ handleEvent() {
+ this._hasChangedConfig = true;
+ this.update();
+ this._hasChangedConfig = false;
+
+ // https://stackoverflow.com/questions/11565471/removing-event-listener-which-was-added-with-bind
+ var clickOutsideHandler = this._handleClickOutside.bind(this);
+ let isFloating = this.isFloating;
+ if (isFloating && !this._hasRegisteredPinnedClickOutside) {
+ document.addEventListener('mouseup', clickOutsideHandler);
+ this._hasRegisteredPinnedClickOutside = true;
+ } else if (!isFloating && this._hasRegisteredPinnedClickOutside) {
+ document.removeEventListener('mouseup', clickOutsideHandler);
+ this._hasRegisteredPinnedClickOutside = false;
+ }
+
+ const button = document.getElementById('zen-sidepanel-button');
+ if (!button) return;
+ if (Services.prefs.getBoolPref('zen.sidebar.enabled')) {
+ button.removeAttribute('hidden');
+ } else {
+ button.setAttribute('hidden', 'true');
+ this._closeSidebarPanel();
+ return;
+ }
+ }
+
+ _handleClickOutside(event) {
+ let sidebar = document.getElementById('zen-sidebar-web-panel');
+ if (!sidebar.hasAttribute('pinned') || this._isDragging || !this.shouldCloseOnBlur) {
+ return;
+ }
+ let target = event.target;
+ const closestSelector = [
+ '#zen-sidebar-web-panel',
+ '#zen-sidebar-panels-wrapper',
+ '#zenWebPanelContextMenu',
+ '#zen-sidebar-web-panel-splitter',
+ '#contentAreaContextMenu',
+ ].join(', ');
+ if (target.closest(closestSelector)) {
+ return;
+ }
+ this.close();
+ }
+
+ toggle() {
+ if (!this._currentPanel) {
+ this._currentPanel = this._lastOpenedPanel;
+ }
+ if (document.getElementById('zen-sidebar-web-panel').hasAttribute('hidden')) {
+ this.open();
+ return;
+ }
+ this.close();
+ }
+
+ open() {
+ let sidebar = document.getElementById('zen-sidebar-web-panel');
+ if (!this.sidebar.hasAttribute('pinned')) {
+ this.moveToTabBoxWrapper();
+ }
+ sidebar.removeAttribute('hidden');
+ this.update();
+ }
+
+ update() {
+ this._updateWebPanels();
+ this._updateSidebarButton();
+ this._updateWebPanel();
+ this._updateButtons();
+ }
+
+ _updateSidebarButton() {
+ let button = document.getElementById('zen-sidepanel-button');
+ if (!button) return;
+ if (!document.getElementById('zen-sidebar-web-panel').hasAttribute('hidden')) {
+ button.setAttribute('open', 'true');
+ } else {
+ button.removeAttribute('open');
+ }
+ }
+
+ _updateWebPanels() {
+ if (Services.prefs.getBoolPref('zen.sidebar.enabled')) {
+ this.sidebarElement.removeAttribute('hidden');
+ } else {
+ this.sidebarElement.setAttribute('hidden', 'true');
+ this._closeSidebarPanel();
+ return;
+ }
+
+ let data = this.sidebarData;
+ if (!data.data || !data.index) {
+ return;
+ }
+ this.sidebarElement.innerHTML = '';
+ for (let site of data.index) {
+ let panel = data.data[site];
+ if (!panel || !panel.url) {
+ continue;
+ }
+ let button = document.createXULElement('toolbarbutton');
+ button.classList.add('zen-sidebar-panel-button', 'toolbarbutton-1', 'chromeclass-toolbar-additional');
+ button.setAttribute('flex', '1');
+ button.setAttribute('zen-sidebar-id', site);
+ button.setAttribute('context', 'zenWebPanelContextMenu');
+ this._getWebPanelIcon(panel.url, button);
+ button.addEventListener('click', this._handleClick.bind(this));
+ button.addEventListener('dragstart', this._handleDragStart.bind(this));
+ button.addEventListener('dragover', this._handleDragOver.bind(this));
+ button.addEventListener('dragenter', this._handleDragEnter.bind(this));
+ button.addEventListener('dragend', this._handleDragEnd.bind(this));
+ this.sidebarElement.appendChild(button);
+ }
+ const addButton = document.getElementById('zen-sidebar-add-panel-button');
+ if (data.index.length < this.MAX_SIDEBAR_PANELS) {
+ addButton.removeAttribute('hidden');
+ } else {
+ addButton.setAttribute('hidden', 'true');
+ }
+ }
+
+ async _openAddPanelDialog() {
+ let dialogURL = 'chrome://browser/content/places/zenNewWebPanel.xhtml';
+ let features = 'centerscreen,chrome,modal,resizable=no';
+ let aParentWindow = Services.wm.getMostRecentWindow('navigator:browser');
+
+ if (aParentWindow?.gDialogBox) {
+ await aParentWindow.gDialogBox.open(dialogURL, {});
+ } else {
+ aParentWindow.openDialog(dialogURL, '', features, {});
+ }
+ }
+
+ _setPinnedToElements() {
+ let sidebar = document.getElementById('zen-sidebar-web-panel');
+ sidebar.setAttribute('pinned', 'true');
+ document.getElementById('zen-sidebar-web-panel-pinned').setAttribute('pinned', 'true');
+ }
+
+ _removePinnedFromElements() {
+ let sidebar = document.getElementById('zen-sidebar-web-panel');
+ sidebar.removeAttribute('pinned');
+ document.getElementById('zen-sidebar-web-panel-pinned').removeAttribute('pinned');
+ }
+
+ _closeSidebarPanel() {
+ let sidebar = document.getElementById('zen-sidebar-web-panel');
+ sidebar.setAttribute('hidden', 'true');
+ this._lastOpenedPanel = this._currentPanel;
+ this._currentPanel = null;
+ }
+
+ _handleClick(event) {
+ let target = event.target;
+ let panelId = target.getAttribute('zen-sidebar-id');
+ if (this._currentPanel === panelId) {
+ return;
+ }
+ this._currentPanel = panelId;
+ this._updateWebPanel();
+ }
+
+ _handleDragStart(event) {
+ this.__dragingElement = event.target;
+ this.__dragingIndex = Array.prototype.indexOf.call(event.target.parentNode.children, event.target);
+ event.target.style.opacity = '0.7';
+
+ event.dataTransfer.effectAllowed = 'move';
+ event.dataTransfer.setData('text/html', event.target.innerHTML);
+ event.dataTransfer.setData('text/plain', event.target.id);
+ }
+
+ _handleDragOver(event) {}
+
+ _handleDragEnter(event) {
+ if (typeof this.__dragingElement === 'undefined') {
+ return;
+ }
+ const target = event.target;
+ const elIndex = Array.prototype.indexOf.call(target.parentNode.children, target);
+ if (elIndex < this.__dragingIndex) {
+ target.before(this.__dragingElement);
+ this.__dragingIndex = elIndex - 1;
+ }
+ target.after(this.__dragingElement);
+ this.__dragingIndex = elIndex + 1;
+ }
+
+ _handleDragEnd(event) {
+ event.target.style.opacity = '1';
+
+ let data = this.sidebarData;
+ let newPos = [];
+ for (let element of this.__dragingElement.parentNode.children) {
+ let panelId = element.getAttribute('zen-sidebar-id');
+ newPos.push(panelId);
+ }
+ data.index = newPos;
+ Services.prefs.setStringPref('zen.sidebar.data', JSON.stringify(data));
+ this._currentPanel = this.__dragingElement.getAttribute('zen-sidebar-id');
+ this.open();
+ this.__dragingElement = undefined;
+ }
+
+ _createNewPanel(url) {
+ let data = this.sidebarData;
+ let newName = 'p' + new Date().getTime();
+ data.index.push(newName);
+ data.data[newName] = {
+ url: url,
+ ua: false,
+ };
+ Services.prefs.setStringPref('zen.sidebar.data', JSON.stringify(data));
+ this._currentPanel = newName;
+ this.open();
+ }
+
+ _updateButtons() {
+ for (let button of this.sidebarElement.querySelectorAll('.zen-sidebar-panel-button')) {
+ if (button.getAttribute('zen-sidebar-id') === this._currentPanel) {
+ button.setAttribute('selected', 'true');
+ } else {
+ button.removeAttribute('selected');
+ }
+ }
+ }
+
+ _hideAllWebPanels() {
+ let sidebar = document.getElementById('zen-sidebar-web-panel');
+ for (let browser of sidebar.querySelectorAll('browser[zen-sidebar-id]')) {
+ browser.setAttribute('hidden', 'true');
+ browser.docShellIsActive = false;
+ browser.zenModeActive = false;
+ }
+ }
+
+ get introductionPanel() {
+ return document.getElementById('zen-sidebar-introduction-panel');
+ }
+
+ _updateWebPanel() {
+ this._updateButtons();
+ // let sidebar = document.getElementById("zen-sidebar-web-panel");
+ this._hideAllWebPanels();
+ if (!this._currentPanel) {
+ this.introductionPanel.removeAttribute('hidden');
+ this.forwardButton.setAttribute('disabled', true);
+ this.backButton.setAttribute('disabled', true);
+ return;
+ }
+ this.introductionPanel.setAttribute('hidden', 'true');
+ let existantWebview = this._getCurrentBrowser();
+ if (existantWebview) {
+ existantWebview.docShellIsActive = true;
+ existantWebview.zenModeActive = true;
+ existantWebview.removeAttribute('hidden');
+ document.getElementById('zen-sidebar-web-panel-title').textContent = existantWebview.contentTitle;
+ return;
+ }
+ let data = this._getWebPanelData(this._currentPanel);
+ let browser = this._createWebPanelBrowser(data);
+ let browserContainers = document.getElementById('zen-sidebar-web-panel-browser-containers');
+ browserContainers.appendChild(browser);
+ browser.addProgressListener(this.progressListener, Ci.nsIWebProgress.NOTIFY_LOCATION);
+ if (data.ua) {
+ browser.browsingContext.customUserAgent = this.DEFAULT_MOBILE_USER_AGENT;
+ browser.reload();
+ }
+ browser.docShellIsActive = true;
+ browser.zenModeActive = true;
+ }
+
+ _getWebPanelData(id) {
+ let data = this.sidebarData;
+ let panel = data.data[id];
+ if (!panel || !panel.url) {
+ return {};
+ }
+ return {
+ id: id,
+ ...panel,
+ };
+ }
+
+ getTabForBrowser(browser) {
+ return this._tabBrowserSet.get(browser);
+ }
+
+ setTabForBrowser(browser, tab) {
+ this._tabBrowserSet.set(browser, tab);
+ }
+
+ removeTabForBrowser(browser) {
+ this._tabBrowserSet.delete(browser);
+ }
+
+ _createWebPanelBrowser(data) {
+ const titleContainer = document.getElementById('zen-sidebar-web-panel-title');
+ titleContainer.textContent = 'Loading...';
+ let browser = gBrowser.createBrowser({
+ userContextId: data.userContextId,
+ });
+ const tab = this.sidebar.querySelector(`[zen-sidebar-id='${data.id}']`);
+ this.setTabForBrowser(browser, tab);
+ tab.linkedBrowser = browser;
+ tab.permanentKey = browser.permanentKey;
+ browser.setAttribute('disablefullscreen', 'true');
+ browser.setAttribute('src', data.url);
+ browser.setAttribute('zen-sidebar-id', data.id);
+ browser.addEventListener(
+ 'pagetitlechanged',
+ function (event) {
+ let browser = event.target;
+ let title = browser.contentTitle;
+ if (!title) {
+ return;
+ }
+ let id = browser.getAttribute('zen-sidebar-id');
+ if (id === this._currentPanel) {
+ titleContainer.textContent = title;
+ }
+ }.bind(this)
+ );
+ return browser;
+ }
+
+ _getWebPanelIcon(url, element) {
+ let { preferredURI } = Services.uriFixup.getFixupURIInfo(url);
+ element.setAttribute('image', `page-icon:${preferredURI.spec}`);
+ fetch(`https://s2.googleusercontent.com/s2/favicons?domain_url=${preferredURI.spec}`).then(async (response) => {
+ if (response.ok) {
+ let blob = await response.blob();
+ let reader = new FileReader();
+ reader.onload = function () {
+ element.setAttribute('image', reader.result);
+ };
+ reader.readAsDataURL(blob);
+ }
+ });
+ }
+
+ _getBrowserById(id) {
+ let sidebar = document.getElementById('zen-sidebar-web-panel');
+ return sidebar.querySelector(`browser[zen-sidebar-id="${id}"]`);
+ }
+
+ _getCurrentBrowser() {
+ return this._getBrowserById(this._currentPanel);
+ }
+
+ reload() {
+ let browser = this._getCurrentBrowser();
+ if (browser) {
+ browser.reload();
+ }
+ }
+
+ forward() {
+ let browser = this._getCurrentBrowser();
+ if (browser) {
+ browser.goForward();
+ }
+ }
+
+ back() {
+ let browser = this._getCurrentBrowser();
+ if (browser) {
+ browser.goBack();
+ }
+ }
+
+ home() {
+ let browser = this._getCurrentBrowser();
+ if (browser) {
+ browser.gotoIndex();
+ }
+ }
+
+ close() {
+ this._hideAllWebPanels();
+ this._closeSidebarPanel();
+ this._updateSidebarButton();
+ }
+
+ moveToTabBoxWrapper() {
+ this.tabBox.before(this.sidebarWrapper);
+ this.sidebarWrapper.style.order = '';
+ }
+
+ moveToTabBox() {
+ this.tabBox.prepend(this.sidebarWrapper);
+ }
+
+ togglePinned(elem) {
+ if (this.sidebar.hasAttribute('pinned')) {
+ this._removePinnedFromElements();
+ //this.moveToTabBoxWrapper();
+ } else {
+ this._setPinnedToElements();
+ //this.moveToTabBox();
+ }
+ this.update();
+ }
+
+ get sidebarElement() {
+ if (!this._sidebarElement) {
+ this._sidebarElement = document.getElementById('zen-sidebar-panels-sites');
+ }
+ return this._sidebarElement;
+ }
+
+ get splitterElement() {
+ if (!this._splitterElement) {
+ this._splitterElement = document.getElementById('zen-sidebar-web-panel-splitter');
+ }
+ return this._splitterElement;
+ }
+
+ get hSplitterElement() {
+ if (!this._hSplitterElement) {
+ this._hSplitterElement = document.getElementById('zen-sidebar-web-panel-hsplitter');
+ }
+ return this._hSplitterElement;
+ }
+
+ get sidebarHeader() {
+ if (!this._sidebarHeader) {
+ this._sidebarHeader = document.getElementById('zen-sidebar-web-header');
+ }
+ return this._sidebarHeader;
+ }
+
+ get sidebarWrapper() {
+ if (!this._sideBarWrapper) {
+ this._sideBarWrapper = document.getElementById('zen-sidebar-web-panel-wrapper');
+ }
+ return this._sideBarWrapper;
+ }
+
+ get sidebarHeaderButtons() {
+ if (!this._sidebarHeaderButtons) {
+ this._sidebarHeaderButtons = [...this.sidebarHeader.querySelectorAll('.toolbarbutton-1')];
+ }
+ return this._sidebarHeaderButtons;
+ }
+
+ // Context menu
+
+ updateContextMenu(aPopupMenu) {
+ let panel =
+ aPopupMenu.triggerNode && (aPopupMenu.triggerNode || aPopupMenu.triggerNode.closest('toolbarbutton[zen-sidebar-id]'));
+ if (!panel) {
+ return;
+ }
+ let id = panel.getAttribute('zen-sidebar-id');
+ this.contextTab = id;
+ let data = this._getWebPanelData(id);
+ let browser = this._getBrowserById(id);
+ let isMuted = browser && browser.audioMuted;
+ let mutedContextItem = document.getElementById('context_zenToggleMuteWebPanel');
+ document.l10n.setAttributes(
+ mutedContextItem,
+ !isMuted ? 'zen-web-side-panel-context-mute-panel' : 'zen-web-side-panel-context-unmute-panel'
+ );
+ if (!isMuted) {
+ mutedContextItem.setAttribute('muted', 'true');
+ } else {
+ mutedContextItem.removeAttribute('muted');
+ }
+ document.l10n.setAttributes(
+ document.getElementById('context_zenToogleUAWebPanel'),
+ data.ua ? 'zen-web-side-panel-context-disable-ua' : 'zen-web-side-panel-context-enable-ua'
+ );
+ if (!browser) {
+ document.getElementById('context_zenUnloadWebPanel').setAttribute('disabled', 'true');
+ } else {
+ document.getElementById('context_zenUnloadWebPanel').removeAttribute('disabled');
+ }
+ }
+
+ createContainerTabMenu(event) {
+ let window = event.target.ownerGlobal;
+ let data = this.sidebarData;
+ let panelData = data.data[this.contextTab];
+ return window.createUserContextMenu(event, {
+ isContextMenu: true,
+ excludeUserContextId: panelData.userContextId,
+ showDefaultTab: true,
+ });
+ }
+
+ contextChangeContainerTab(event) {
+ let data = this.sidebarData;
+ let userContextId = parseInt(event.target.getAttribute('data-usercontextid'));
+ data.data[this.contextTab].userContextId = userContextId;
+ Services.prefs.setStringPref('zen.sidebar.data', JSON.stringify(data));
+ let browser = this._getBrowserById(this.contextTab);
+ if (browser) {
+ browser.remove();
+ // We need to re-apply a new browser so it takes the new userContextId
+ this._updateWebPanel();
+ }
+ }
+
+ contextOpenNewTab() {
+ let browser = this._getBrowserById(this.contextTab);
+ let data = this.sidebarData;
+ let panel = data.data[this.contextTab];
+ let url = browser == null ? panel.url : browser.currentURI.spec;
+ gZenUIManager.openAndChangeToTab(url);
+ this.close();
+ }
+
+ contextToggleMuteAudio() {
+ let browser = this._getBrowserById(this.contextTab);
+ if (browser.audioMuted) {
+ browser.unmute();
+ } else {
+ browser.mute();
+ }
+ }
+
+ contextToggleUserAgent() {
+ let browser = this._getBrowserById(this.contextTab);
+ browser.browsingContext.customUserAgent = browser.browsingContext.customUserAgent ? null : this.DEFAULT_MOBILE_USER_AGENT;
+ let data = this.sidebarData;
+ data.data[this.contextTab].ua = !data.data[this.contextTab].ua;
+ Services.prefs.setStringPref('zen.sidebar.data', JSON.stringify(data));
+ browser.reload();
+ }
+
+ contextDelete() {
+ let data = this.sidebarData;
+ delete data.data[this.contextTab];
+ data.index = data.index.filter((id) => id !== this.contextTab);
+ let browser = this._getBrowserById(this.contextTab);
+ if (browser) {
+ browser.remove();
+ document.getElementById('zen-sidebar-web-panel-title').textContent = '';
+ }
+ this._currentPanel = null;
+ this._lastOpenedPanel = null;
+ this.update();
+ Services.prefs.setStringPref('zen.sidebar.data', JSON.stringify(data));
+ }
+
+ contextUnload() {
+ let browser = this._getBrowserById(this.contextTab);
+ this.removeTabForBrowser(browser);
+ browser.remove();
+ document.getElementById('zen-sidebar-web-panel-title').textContent = '';
+ this._closeSidebarPanel();
+ this.close();
+ this._lastOpenedPanel = null;
+ }
+
+ insertIntoContextMenu() {
+ const sibling = document.getElementById('context-stripOnShareLink');
+ const menuitem = document.createXULElement('menuitem');
+ menuitem.setAttribute('id', 'context-zenAddToWebPanel');
+ menuitem.setAttribute('hidden', 'true');
+ menuitem.setAttribute('oncommand', 'gZenBrowserManagerSidebar.addPanelFromContextMenu();');
+ menuitem.setAttribute('data-l10n-id', 'zen-web-side-panel-context-add-to-panel');
+ sibling.insertAdjacentElement('afterend', menuitem);
+ }
+
+ addPanelFromContextMenu() {
+ const url = gContextMenu.linkURL || gContextMenu.target.ownerDocument.location.href;
+ this._createNewPanel(url);
+ }
+}
+
+window.gZenBrowserManagerSidebar = new ZenBrowserManagerSidebar();
diff --git a/src/browser/base/zen-components/ZenTabUnloader.mjs b/src/browser/base/zen-components/ZenTabUnloader.mjs
new file mode 100644
index 000000000..5780544eb
--- /dev/null
+++ b/src/browser/base/zen-components/ZenTabUnloader.mjs
@@ -0,0 +1,283 @@
+{
+ const lazy = {};
+
+ XPCOMUtils.defineLazyPreferenceGetter(lazy, 'zenTabUnloaderEnabled', 'zen.tab-unloader.enabled', false);
+
+ XPCOMUtils.defineLazyPreferenceGetter(lazy, 'zenTabUnloaderTimeout', 'zen.tab-unloader.timeout-minutes', 20);
+
+ XPCOMUtils.defineLazyPreferenceGetter(lazy, 'zenTabUnloaderExcludedUrls', 'zen.tab-unloader.excluded-urls', '');
+
+ const ZEN_TAB_UNLOADER_DEFAULT_EXCLUDED_URLS = [
+ '^about:',
+ '^chrome:',
+ '^devtools:',
+ '^file:',
+ '^resource:',
+ '^view-source:',
+ '^view-image:',
+ ];
+
+ class ZenTabsObserver {
+ static ALL_EVENTS = [
+ 'TabAttrModified',
+ 'TabPinned',
+ 'TabUnpinned',
+ 'TabBrowserInserted',
+ 'TabBrowserDiscarded',
+ 'TabShow',
+ 'TabHide',
+ 'TabOpen',
+ 'TabClose',
+ 'TabSelect',
+ 'TabMultiSelect',
+ ];
+
+ #listeners = [];
+
+ constructor() {
+ this.#listenAllEvents();
+ }
+
+ #listenAllEvents() {
+ const eventListener = this.#eventListener.bind(this);
+ for (const event of ZenTabsObserver.ALL_EVENTS) {
+ window.addEventListener(event, eventListener);
+ }
+ window.addEventListener('unload', () => {
+ for (const event of ZenTabsObserver.ALL_EVENTS) {
+ window.removeEventListener(event, eventListener);
+ }
+ });
+ }
+
+ #eventListener(event) {
+ for (const listener of this.#listeners) {
+ listener(event.type, event);
+ }
+ }
+
+ addTabsListener(listener) {
+ this.#listeners.push(listener);
+ }
+ }
+
+ class ZenTabsIntervalUnloader {
+ static INTERVAL = 1000 * 60; // 1 minute
+
+ interval = null;
+ unloader = null;
+
+ #excludedUrls = [];
+ #compiledExcludedUrls = [];
+
+ constructor(unloader) {
+ this.unloader = unloader;
+ this.interval = setInterval(this.intervalListener.bind(this), ZenTabsIntervalUnloader.INTERVAL);
+ this.#excludedUrls = this.lazyExcludeUrls;
+ }
+
+ get lazyExcludeUrls() {
+ return [
+ ...ZEN_TAB_UNLOADER_DEFAULT_EXCLUDED_URLS,
+ ...lazy.zenTabUnloaderExcludedUrls.split(',').map((url) => url.trim()),
+ ];
+ }
+
+ arraysEqual(a, b) {
+ if (a === b) return true;
+ if (a == null || b == null) return false;
+ if (a.length !== b.length) return false;
+
+ // If you don't care about the order of the elements inside
+ // the array, you should sort both arrays here.
+ // Please note that calling sort on an array will modify that array.
+ // you might want to clone your array first.
+
+ for (var i = 0; i < a.length; ++i) {
+ if (a[i] !== b[i]) return false;
+ }
+ return true;
+ }
+
+ get excludedUrls() {
+ // Check if excludedrls is the same as the pref value
+ const excludedUrls = this.lazyExcludeUrls;
+ if (!this.arraysEqual(this.#excludedUrls, excludedUrls) || !this.#compiledExcludedUrls.length) {
+ this.#excludedUrls = excludedUrls;
+ this.#compiledExcludedUrls = excludedUrls.map((url) => new RegExp(url));
+ }
+ return this.#compiledExcludedUrls;
+ }
+
+ intervalListener() {
+ if (!lazy.zenTabUnloaderEnabled) {
+ return;
+ }
+ const currentTimestamp = Date.now();
+ const excludedUrls = this.excludedUrls;
+ const tabs = gBrowser.tabs;
+ for (let i = 0; i < tabs.length; i++) {
+ const tab = tabs[i];
+ if (this.unloader.canUnloadTab(tab, currentTimestamp, excludedUrls)) {
+ this.unloader.unload(tab);
+ }
+ }
+ }
+ }
+
+ class ZenTabUnloader extends ZenDOMOperatedFeature {
+ static ACTIVITY_MODIFIERS = ['muted', 'soundplaying', 'label', 'attention'];
+
+ init() {
+ if (!lazy.zenTabUnloaderEnabled) {
+ return;
+ }
+ this.insertIntoContextMenu();
+ this.observer = new ZenTabsObserver();
+ this.intervalUnloader = new ZenTabsIntervalUnloader(this);
+ this.observer.addTabsListener(this.onTabEvent.bind(this));
+ }
+
+ onTabEvent(action, event) {
+ const tab = event.target;
+ switch (action) {
+ case 'TabPinned':
+ case 'TabUnpinned':
+ case 'TabBrowserInserted':
+ case 'TabBrowserDiscarded':
+ case 'TabShow':
+ case 'TabHide':
+ break;
+ case 'TabAttrModified':
+ this.handleTabAttrModified(tab, event);
+ break;
+ case 'TabOpen':
+ this.handleTabOpen(tab);
+ break;
+ case 'TabClose':
+ this.handleTabClose(tab);
+ break;
+ case 'TabSelect':
+ case 'TabMultiSelect':
+ this.updateTabActivity(tab);
+ break;
+ default:
+ console.warn('ZenTabUnloader: Unhandled tab event', action);
+ break;
+ }
+ }
+
+ onLocationChange(browser) {
+ const tab = browser.ownerGlobal.gBrowser.getTabForBrowser(browser);
+ this.updateTabActivity(tab);
+ }
+
+ handleTabClose(tab) {
+ // Nothing yet
+ }
+
+ handleTabOpen(tab) {
+ this.updateTabActivity(tab);
+ }
+
+ handleTabAttrModified(tab, event) {
+ for (const modifier of ZenTabUnloader.ACTIVITY_MODIFIERS) {
+ if (event.detail.changed.includes(modifier)) {
+ this.updateTabActivity(tab);
+ break;
+ }
+ }
+ }
+
+ updateTabActivity(tab) {
+ const currentTimestamp = Date.now();
+ tab.lastActivity = currentTimestamp;
+ }
+
+ get tabs() {
+ return gBrowser.tabs;
+ }
+
+ insertIntoContextMenu() {
+ const element = window.MozXULElement.parseXULToFragment(`
+
+
+
+ `);
+ document.getElementById('context_closeDuplicateTabs').parentNode.appendChild(element);
+ }
+
+ unload(tab) {
+ gBrowser.discardBrowser(tab);
+ tab.removeAttribute('linkedpanel');
+ }
+
+ unloadTab() {
+ const tabs = TabContextMenu.contextTab.multiselected ? gBrowser.selectedTabs : [TabContextMenu.contextTab];
+ for (let i = 0; i < tabs.length; i++) {
+ if (this.canUnloadTab(tabs[i], Date.now(), [], true)) {
+ this.unload(tabs[i]);
+ }
+ }
+ }
+
+ preventUnloadTab() {
+ const tabs = TabContextMenu.contextTab.multiselected ? gBrowser.selectedTabs : [TabContextMenu.contextTab];
+ for (let i = 0; i < tabs.length; i++) {
+ const tab = tabs[i];
+ tab.zenIgnoreUnload = true;
+ }
+ }
+
+ ignoreUnloadTab() {
+ const tabs = TabContextMenu.contextTab.multiselected ? gBrowser.selectedTabs : [TabContextMenu.contextTab];
+ for (let i = 0; i < tabs.length; i++) {
+ const tab = tabs[i];
+ tab.zenIgnoreUnload = false;
+ }
+ }
+
+ canUnloadTab(tab, currentTimestamp, excludedUrls, ignoreTimestamp = false) {
+ if (
+ tab.pinned ||
+ tab.selected ||
+ tab.multiselected ||
+ tab.hasAttribute('busy') ||
+ tab.hasAttribute('pending') ||
+ !tab.linkedPanel ||
+ tab.splitView ||
+ tab.attention ||
+ tab.linkedBrowser?.zenModeActive ||
+ tab.pictureinpicture ||
+ tab.soundPlaying ||
+ tab.zenIgnoreUnload ||
+ excludedUrls.some((url) => url.test(tab.linkedBrowser.currentURI.spec))
+ ) {
+ return false;
+ }
+ if (ignoreTimestamp) {
+ return true;
+ }
+ const lastActivity = tab.lastActivity;
+ if (!lastActivity) {
+ return false;
+ }
+ const diff = currentTimestamp - lastActivity;
+ // Check if the tab has been inactive for more than the timeout
+ return diff > lazy.zenTabUnloaderTimeout * 60 * 1000;
+ }
+ }
+
+ window.gZenTabUnloader = new ZenTabUnloader();
+}
diff --git a/src/browser/base/zen-components/ZenThemeBuilder.mjs b/src/browser/base/zen-components/ZenThemeBuilder.mjs
new file mode 100644
index 000000000..0c375b1e8
--- /dev/null
+++ b/src/browser/base/zen-components/ZenThemeBuilder.mjs
@@ -0,0 +1,176 @@
+const kZenAccentColorConfigKey = 'zen.theme.accent-color';
+
+var gZenThemeBuilder = {
+ init() {
+ return; // TODO:
+ this._mouseMoveListener = this._handleThumbMouseMove.bind(this);
+ setTimeout(() => {
+ this._initBuilderUI();
+ }, 500);
+ },
+
+ get _builderWrapper() {
+ if (this.__builderWrapper) {
+ return this.__builderWrapper;
+ }
+ this.__builderWrapper = document.getElementById('zen-theme-builder-wrapper');
+ return this.__builderWrapper;
+ },
+
+ _initBuilderUI() {
+ let wrapper = this._builderWrapper;
+ if (!wrapper) {
+ return;
+ }
+
+ console.info('gZenThemeBuilder: init builder UI');
+
+ const kTemplate = `
+
+
+
+
+
+
+
+
+ `;
+ wrapper.innerHTML = kTemplate;
+ this._initColorPicker();
+ },
+
+ _getPositionFromColor(ctx, color) {
+ var w = ctx.canvas.width,
+ h = ctx.canvas.height,
+ data = ctx.getImageData(0, 0, w, h), /// get image data
+ buffer = data.data, /// and its pixel buffer
+ len = buffer.length, /// cache length
+ x,
+ y = 0,
+ p,
+ px; /// for iterating
+ /// iterating x/y instead of forward to get position the easy way
+ for (; y < h; y++) {
+ /// common value for all x
+ p = y * 4 * w;
+ for (x = 0; x < w; x++) {
+ /// next pixel (skipping 4 bytes as each pixel is RGBA bytes)
+ px = p + x * 4;
+ /// if red component match check the others
+ if (buffer[px] === color[0]) {
+ if (buffer[px + 1] === color[1] && buffer[px + 2] === color[2]) {
+ return [x, y];
+ }
+ }
+ }
+ }
+ return null;
+ },
+
+ _hexToRgb(hex) {
+ var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
+ return [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)];
+ },
+
+ _componentToHex(c) {
+ var hex = c.toString(16);
+ return hex.length == 1 ? '0' + hex : hex;
+ },
+
+ _rgbToHex(r, g, b) {
+ return '#' + this._componentToHex(r) + this._componentToHex(g) + this._componentToHex(b);
+ },
+
+ _initColorPicker() {
+ const canvas = document.getElementById('zen-theme-builder-color-picker-canvas');
+ const thumb = document.getElementById('zen-theme-builder-color-picker-thumb');
+
+ // A all the main colors are all blended together towards the center.
+ // But we also add some random gradients to make it look more interesting.
+ // Instead of using a simple gradient, we use a radial gradient.
+ const ctx = canvas.getContext('2d');
+ const size = 180;
+ canvas.width = size;
+ canvas.height = size;
+ const center = size / 2;
+ const radius = size / 2;
+ const gradient = ctx.createConicGradient(0, center, center);
+ gradient.addColorStop(0, '#fff490');
+ gradient.addColorStop(1 / 12, '#f9e380');
+ gradient.addColorStop(2 / 12, '#fecc87');
+ gradient.addColorStop(3 / 12, '#ffa894');
+ gradient.addColorStop(4 / 12, '#f98089');
+ gradient.addColorStop(5 / 12, '#f9b7c5');
+ gradient.addColorStop(6 / 12, '#c193b8');
+ gradient.addColorStop(7 / 12, '#a8b7e0');
+ gradient.addColorStop(8 / 12, '#88d2f9');
+ gradient.addColorStop(9 / 12, '#81e8e5');
+ gradient.addColorStop(10 / 12, '#b7e5a5');
+ gradient.addColorStop(11 / 12, '#eaefac');
+ gradient.addColorStop(1, '#fff490');
+
+ const radialGradient = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
+ radialGradient.addColorStop(0, 'rgba(255,255,255,1)');
+ radialGradient.addColorStop(1, 'rgba(255,255,255,0)');
+
+ ctx.fillStyle = gradient;
+ ctx.fillRect(0, 0, size, size);
+
+ //ctx.fillStyle = radialGradient;
+ //ctx.fillRect(0, 0, size, size);
+
+ // Add the thumb.
+ const accentColor = Services.prefs.getStringPref(kZenAccentColorConfigKey, '#aac7ff');
+ const pos = this._getPositionFromColor(ctx, this._hexToRgb(accentColor));
+
+ let x = pos ? pos[0] : center;
+ let y = pos ? pos[1] : center;
+
+ thumb.style.left = `${x}px`;
+ thumb.style.top = `${y}px`;
+
+ thumb.addEventListener('mousedown', this._handleThumbMouseDown.bind(this));
+ document.addEventListener('mouseup', this._handleThumbMouseUp.bind(this));
+ },
+
+ _handleThumbMouseDown(e) {
+ document.addEventListener('mousemove', this._mouseMoveListener);
+ },
+
+ _handleThumbMouseUp(e) {
+ document.removeEventListener('mousemove', this._mouseMoveListener);
+ },
+
+ _handleThumbMouseMove(e) {
+ const kThumbOffset = 15;
+ const deck = document.getElementById('zen-theme-builder-color-picker-deck');
+
+ const thumb = document.getElementById('zen-theme-builder-color-picker-thumb');
+ const rect = deck.getBoundingClientRect();
+ let x = e.clientX - rect.left;
+ let y = e.clientY - rect.top;
+
+ if (x > rect.width - kThumbOffset) {
+ x = rect.width - kThumbOffset;
+ }
+ if (y > rect.height - kThumbOffset) {
+ y = rect.height - kThumbOffset;
+ }
+ if (x < kThumbOffset) {
+ x = kThumbOffset;
+ }
+ if (y < kThumbOffset) {
+ y = kThumbOffset;
+ }
+
+ thumb.style.left = `${x}px`;
+ thumb.style.top = `${y}px`;
+
+ const canvas = document.getElementById('zen-theme-builder-color-picker-canvas');
+ const ctx = canvas.getContext('2d');
+ const imageData = ctx.getImageData(x, y, 1, 1);
+
+ // Update the accent color.
+ Services.prefs.setStringPref(kZenAccentColorConfigKey, this._rgbToHex(...imageData.data));
+ },
+};
diff --git a/src/browser/base/zen-components/ZenThemesCommon.mjs b/src/browser/base/zen-components/ZenThemesCommon.mjs
new file mode 100644
index 000000000..11e030c21
--- /dev/null
+++ b/src/browser/base/zen-components/ZenThemesCommon.mjs
@@ -0,0 +1,120 @@
+var ZenThemesCommon = {
+ kZenColors: ['#aac7ff', '#74d7cb', '#a0d490', '#dec663', '#ffb787', '#dec1b1', '#ffb1c0', '#ddbfc3', '#f6b0ea', '#d4bbff'],
+
+ get browsers() {
+ return Services.wm.getEnumerator('navigator:browser');
+ },
+
+ get currentBrowser() {
+ return Services.wm.getMostRecentWindow('navigator:browser');
+ },
+
+ get themesRootPath() {
+ return PathUtils.join(PathUtils.profileDir, 'chrome', 'zen-themes');
+ },
+
+ get themesDataFile() {
+ return PathUtils.join(PathUtils.profileDir, 'zen-themes.json');
+ },
+
+ getThemeFolder(themeId) {
+ return PathUtils.join(this.themesRootPath, themeId);
+ },
+
+ resetThemesCache() {
+ this.themes = null;
+ },
+
+ async getThemes() {
+ if (!this.themes) {
+ if (!(await IOUtils.exists(this.themesDataFile))) {
+ await IOUtils.writeJSON(this.themesDataFile, {});
+ }
+
+ try {
+ this.themes = await IOUtils.readJSON(this.themesDataFile);
+ } catch (e) {
+ // If we have a corrupted file, reset it
+ await IOUtils.writeJSON(this.themesDataFile, {});
+ this.themes = {};
+ gNotificationBox.appendNotification(
+ "zen-themes-corrupted",
+ {
+ label: { "l10n-id": "zen-themes-corrupted" },
+ image: "chrome://browser/skin/notification-icons/persistent-storage-blocked.svg",
+ priority: gNotificationBox.PRIORITY_INFO_MEDIUM,
+ },
+ []
+ );
+ }
+ }
+
+ return this.themes;
+ },
+
+ async getThemePreferences(theme) {
+ const themePath = PathUtils.join(this.themesRootPath, theme.id, 'preferences.json');
+ if (!(await IOUtils.exists(themePath)) || !theme.preferences) {
+ return [];
+ }
+
+ const preferences = await IOUtils.readJSON(themePath);
+
+ // compat mode for old preferences, all of them can only be checkboxes
+ if (typeof preferences === 'object' && !Array.isArray(preferences)) {
+ console.warn(
+ `[ZenThemes]: Warning, ${theme.name} uses legacy preferences, please migrate them to the new preferences style, as legacy preferences might be removed at a future release. More information at: https://docs.zen-browser.app/themes-store/themes-marketplace-preferences`
+ );
+ const newThemePreferences = [];
+
+ for (let [entry, label] of Object.entries(preferences)) {
+ const [_, negation = '', os = '', property] = /(!?)(?:(macos|windows|linux):)?([A-z0-9-_.]+)/g.exec(entry);
+ const isNegation = negation === '!';
+
+ if (
+ (isNegation && os === gZenOperatingSystemCommonUtils.currentOperatingSystem) ||
+ (os !== '' && os !== gZenOperatingSystemCommonUtils.currentOperatingSystem && !isNegation)
+ ) {
+ continue;
+ }
+
+ newThemePreferences.push({
+ property,
+ label,
+ type: 'checkbox',
+ disabledOn: os !== '' ? [os] : [],
+ });
+ }
+
+ return newThemePreferences;
+ }
+
+ return preferences.filter(
+ ({ disabledOn = [] }) => !disabledOn.includes(gZenOperatingSystemCommonUtils.currentOperatingSystem)
+ );
+ },
+
+ throttle(mainFunction, delay) {
+ let timerFlag = null;
+
+ return (...args) => {
+ if (timerFlag === null) {
+ mainFunction(...args);
+ timerFlag = setTimeout(() => {
+ timerFlag = null;
+ }, delay);
+ }
+ };
+ },
+
+ debounce(mainFunction, wait) {
+ let timerFlag;
+
+ return (...args) => {
+ clearTimeout(timerFlag);
+ timerFlag = setTimeout(() => {
+ mainFunction(...args);
+ }, wait);
+ };
+ },
+};
diff --git a/src/browser/base/zen-components/ZenThemesImporter.mjs b/src/browser/base/zen-components/ZenThemesImporter.mjs
new file mode 100644
index 000000000..152d93bc8
--- /dev/null
+++ b/src/browser/base/zen-components/ZenThemesImporter.mjs
@@ -0,0 +1,320 @@
+const kZenStylesheetThemeHeader = '/* Zen Themes - Generated by ZenThemesImporter.';
+const kZenStylesheetThemeHeaderBody = `* DO NOT EDIT THIS FILE DIRECTLY!
+* Your changes will be overwritten.
+* Instead, go to the preferences and edit the themes there.
+*/
+`;
+const kenStylesheetFooter = `
+/* End of Zen Themes */
+`;
+const getCurrentDateTime = () =>
+ new Intl.DateTimeFormat('en-US', {
+ dateStyle: 'full',
+ timeStyle: 'full',
+ }).format(new Date().getTime());
+
+var gZenStylesheetManager = {
+ async writeStylesheet(path, themes) {
+ let content = kZenStylesheetThemeHeader;
+ content += `\n* FILE GENERATED AT: ${getCurrentDateTime()}\n`;
+ content += kZenStylesheetThemeHeaderBody;
+
+ for (let theme of themes) {
+ if (theme.enabled !== undefined && !theme.enabled) {
+ continue;
+ }
+
+ content += this.getThemeCSS(theme);
+ }
+
+ content += kenStylesheetFooter;
+
+ const buffer = new TextEncoder().encode(content);
+
+ await IOUtils.write(path, buffer);
+ },
+
+ getThemeCSS(theme) {
+ let css = '\n';
+
+ css += `/* Name: ${theme.name} */\n`;
+ css += `/* Description: ${theme.description} */\n`;
+ css += `/* Author: @${theme.author} */\n`;
+
+ if (theme._readmeURL) {
+ css += `/* Readme: ${theme.readme} */\n`;
+ }
+
+ css += `@import url("${theme._chromeURL}");\n`;
+
+ return css;
+ },
+};
+
+var gZenThemesImporter = new (class {
+ constructor() {
+ console.info('[ZenThemesImporter]: Initializing Zen Themes Importer');
+
+ try {
+ window.SessionStore.promiseInitialized.then(async () => {
+ if (Services.prefs.getBoolPref('zen.themes.disable-all', false)) {
+ console.log('[ZenThemesImporter]: Disabling all themes.');
+ return;
+ }
+
+ const themes = await this.getEnabledThemes();
+
+ const themesWithPreferences = await Promise.all(
+ themes.map(async (theme) => {
+ const preferences = await ZenThemesCommon.getThemePreferences(theme);
+
+ return {
+ name: theme.name,
+ enabled: theme.enabled,
+ preferences,
+ };
+ })
+ );
+
+ this.writeToDom(themesWithPreferences);
+
+ await this.insertStylesheet();
+ });
+ console.info('[ZenThemesImporter]: Zen Themes imported');
+ } catch (e) {
+ console.error('[ZenThemesImporter]: Error importing Zen Themes: ', e);
+ }
+
+ Services.prefs.addObserver('zen.themes.updated-value-observer', this.rebuildThemeStylesheet.bind(this), false);
+ Services.prefs.addObserver('zen.themes.disable-all', this.handleDisableThemes.bind(this), false);
+ }
+
+ get sss() {
+ if (!this._sss) {
+ this._sss = Cc['@mozilla.org/content/style-sheet-service;1'].getService(Ci.nsIStyleSheetService);
+ }
+ return this._sss;
+ }
+
+ get styleSheetPath() {
+ return PathUtils.join(PathUtils.profileDir, 'chrome', 'zen-themes.css');
+ }
+
+ async handleDisableThemes() {
+ if (Services.prefs.getBoolPref('zen.themes.disable-all', false)) {
+ console.log('[ZenThemesImporter]: Disabling themes module.');
+
+ await this.removeStylesheet();
+ } else {
+ console.log('[ZenThemesImporter]: Enabling themes module.');
+
+ await this.rebuildThemeStylesheet();
+ }
+ }
+
+ get styleSheetURI() {
+ if (!this._styleSheetURI) {
+ this._styleSheetURI = Services.io.newFileURI(new FileUtils.File(this.styleSheetPath));
+ }
+ return this._styleSheetURI;
+ }
+
+ getStylesheetURIForTheme(theme) {
+ return Services.io.newFileURI(new FileUtils.File(PathUtils.join(ZenThemesCommon.getThemeFolder(theme.id), 'chrome.css')));
+ }
+
+ async insertStylesheet() {
+ if (await IOUtils.exists(this.styleSheetPath)) {
+ await this.sss.loadAndRegisterSheet(this.styleSheetURI, this.sss.AGENT_SHEET);
+ }
+
+ if (this.sss.sheetRegistered(this.styleSheetURI, this.sss.AGENT_SHEET)) {
+ console.debug('[ZenThemesImporter]: Sheet successfully registered');
+ }
+ }
+
+ async removeStylesheet() {
+ await this.sss.unregisterSheet(this.styleSheetURI, this.sss.AGENT_SHEET);
+ await IOUtils.remove(this.styleSheetPath, { ignoreAbsent: true });
+
+ if (!this.sss.sheetRegistered(this.styleSheetURI, this.sss.AGENT_SHEET) && !(await IOUtils.exists(this.styleSheetPath))) {
+ console.debug('[ZenThemesImporter]: Sheet successfully unregistered');
+ }
+ }
+
+ async rebuildThemeStylesheet() {
+ if (Services.focus.activeWindow !== window) {
+ return;
+ }
+
+ ZenThemesCommon.themes = null;
+
+ await this.removeStylesheet();
+
+ const themes = await this.getEnabledThemes();
+
+ await this.writeStylesheet(themes);
+
+ const themesWithPreferences = await Promise.all(
+ themes.map(async (theme) => {
+ const preferences = await ZenThemesCommon.getThemePreferences(theme);
+
+ return {
+ name: theme.name,
+ enabled: theme.enabled,
+ preferences,
+ };
+ })
+ );
+
+ this.setDefaults(themesWithPreferences);
+ this.writeToDom(themesWithPreferences);
+
+ await this.insertStylesheet();
+ }
+
+ async getEnabledThemes() {
+ const themeObject = await ZenThemesCommon.getThemes();
+ const themes = Object.values(themeObject).filter((theme) => theme.enabled === undefined || theme.enabled);
+
+ const themeList = themes.map(({ name }) => name).join(', ');
+
+ const message =
+ themeList !== ''
+ ? `[ZenThemesImporter]: Loading enabled Zen themes: ${themeList}.`
+ : '[ZenThemesImporter]: No enabled Zen themes.';
+
+ console.log(message);
+
+ return themes;
+ }
+
+ setDefaults(themesWithPreferences) {
+ for (const { preferences, enabled } of themesWithPreferences) {
+ if (enabled !== undefined && !enabled) {
+ continue;
+ }
+
+ for (const { type, property, defaultValue } of preferences) {
+ if (defaultValue === undefined) {
+ continue;
+ }
+
+ switch (type) {
+ case 'checkbox': {
+ const value = Services.prefs.getBoolPref(property, false);
+ if (typeof defaultValue !== 'boolean') {
+ console.log(`[ZenThemesImporter]: Warning, invalid data type received for expected type boolean, skipping.`);
+ continue;
+ }
+
+ if (!value) {
+ Services.prefs.setBoolPref(property, defaultValue);
+ }
+ break;
+ }
+
+ default: {
+ const value = Services.prefs.getStringPref(property, 'zen-property-no-saved');
+
+ if (typeof defaultValue !== 'string' && typeof defaultValue !== 'number') {
+ console.log(`[ZenThemesImporter]: Warning, invalid data type received (${typeof defaultValue}), skipping.`);
+ continue;
+ }
+
+ if (value === 'zen-property-no-saved') {
+ Services.prefs.setStringPref(property, defaultValue.toString());
+ }
+ }
+ }
+ }
+ }
+ }
+
+ writeToDom(themesWithPreferences) {
+ for (const browser of ZenMultiWindowFeature.browsers) {
+ for (const { enabled, preferences, name } of themesWithPreferences) {
+ const sanitizedName = `theme-${name?.replaceAll(/\s/g, '-')?.replaceAll(/[^A-z_-]+/g, '')}`;
+
+ if (enabled !== undefined && !enabled) {
+ const element = browser.document.getElementById(sanitizedName);
+
+ if (element) {
+ element.remove();
+ }
+
+ for (const { property } of preferences.filter(({ type }) => type !== 'checkbox')) {
+ const sanitizedProperty = property?.replaceAll(/\./g, '-');
+
+ browser.document.querySelector(':root').style.removeProperty(`--${sanitizedProperty}`);
+ }
+
+ continue;
+ }
+
+ for (const { property, type } of preferences) {
+ const value = Services.prefs.getStringPref(property, '');
+ const sanitizedProperty = property?.replaceAll(/\./g, '-');
+
+ switch (type) {
+ case 'dropdown': {
+ if (value !== '') {
+ let element = browser.document.getElementById(sanitizedName);
+
+ if (!element) {
+ element = browser.document.createElement('div');
+
+ element.style.display = 'none';
+ element.setAttribute('id', sanitizedName);
+
+ browser.document.body.appendChild(element);
+ }
+
+ element.setAttribute(sanitizedProperty, value);
+ }
+ break;
+ }
+
+ case 'string': {
+ if (value === '') {
+ browser.document.querySelector(':root').style.removeProperty(`--${sanitizedProperty}`);
+ } else {
+ browser.document.querySelector(':root').style.setProperty(`--${sanitizedProperty}`, value);
+ }
+ break;
+ }
+
+ default: {
+ }
+ }
+ }
+ }
+ }
+ }
+
+ async writeStylesheet(themeList = []) {
+ const themes = [];
+ ZenThemesCommon.themes = null;
+
+ for (let theme of themeList) {
+ theme._chromeURL = this.getStylesheetURIForTheme(theme).spec;
+ themes.push(theme);
+ }
+
+ await gZenStylesheetManager.writeStylesheet(this.styleSheetPath, themes);
+ }
+})();
+
+gZenActorsManager.addJSWindowActor("ZenThemeMarketplace", {
+ parent: {
+ esModuleURI: "chrome://browser/content/zen-components/actors/ZenThemeMarketplaceParent.sys.mjs",
+ },
+ child: {
+ esModuleURI: "chrome://browser/content/zen-components/actors/ZenThemeMarketplaceChild.sys.mjs",
+ events: {
+ DOMContentLoaded: {},
+ },
+ },
+ matches: ["https://*.zen-browser.app/*", "about:preferences"],
+ allFrames: true,
+});
diff --git a/src/browser/base/zen-components/ZenViewSplitter.mjs b/src/browser/base/zen-components/ZenViewSplitter.mjs
new file mode 100644
index 000000000..8c3e32ec1
--- /dev/null
+++ b/src/browser/base/zen-components/ZenViewSplitter.mjs
@@ -0,0 +1,1146 @@
+class SplitLeafNode {
+ /**
+ * The percentage of the size of the parent the node takes up, dependent on parent direction this is either
+ * width or height.
+ * @type {number}
+ */
+ sizeInParent;
+ /**
+ * @type {Object}
+ */
+ positionToRoot; // position relative to root node
+ /**
+ * @type {SplitNode}
+ */
+ parent;
+ constructor(tab, sizeInParent) {
+ this.tab = tab;
+ this.sizeInParent = sizeInParent;
+ }
+
+ get heightInParent() {
+ return this.parent.direction === 'column' ? this.sizeInParent : 100;
+ }
+
+ get widthInParent() {
+ return this.parent.direction === 'row' ? this.sizeInParent : 100;
+ }
+}
+
+class SplitNode extends SplitLeafNode {
+ /**
+ * @type {string}
+ */
+ direction;
+ _children = [];
+
+ constructor(direction, sizeInParent) {
+ super(null, sizeInParent);
+ this.sizeInParent = sizeInParent;
+ this.direction = direction; // row or column
+ }
+
+ set children(children) {
+ if (children) children.forEach(c => c.parent = this);
+ this._children = children;
+ }
+
+ get children() {
+ return this._children;
+ }
+
+ addChild(child) {
+ child.parent = this;
+ this._children.push(child);
+ }
+}
+
+class ZenViewSplitter extends ZenDOMOperatedFeature {
+ currentView = -1;
+ canChangeTabOnHover = false;
+ _data = [];
+ _tabBrowserPanel = null;
+ __modifierElement = null;
+ __hasSetMenuListener = false;
+ overlay = null;
+ _splitNodeToSplitters = new Map();
+ _tabToSplitNode = new Map();
+ dropZone;
+ _edgeHoverSize;
+ minResizeWidth;
+
+ init() {
+ XPCOMUtils.defineLazyPreferenceGetter(this, 'canChangeTabOnHover', 'zen.splitView.change-on-hover', false);
+ XPCOMUtils.defineLazyPreferenceGetter(this, 'minResizeWidth', 'zen.splitView.min-resize-width', 7);
+ XPCOMUtils.defineLazyPreferenceGetter(this, '_edgeHoverSize', 'zen.splitView.rearrange-edge-hover-size', 24);
+
+ ChromeUtils.defineLazyGetter(
+ this,
+ 'overlay',
+ () => document.getElementById('zen-splitview-overlay')
+ );
+
+ ChromeUtils.defineLazyGetter(
+ this,
+ 'dropZone',
+ () => document.getElementById('zen-splitview-dropzone')
+ );
+
+ window.addEventListener('TabClose', this.handleTabClose.bind(this));
+ this.initializeContextMenu();
+ this.insertPageActionButton();
+ this.insertIntoContextMenu();
+ }
+
+ insertIntoContextMenu() {
+ const sibling = document.getElementById('context-sep-open');
+ const menuitem = document.createXULElement('menuitem');
+ menuitem.setAttribute('id', 'context-zenSplitLink');
+ menuitem.setAttribute('hidden', 'true');
+ menuitem.setAttribute('oncommand', 'gZenViewSplitter.splitLinkInNewTab();');
+ menuitem.setAttribute('data-l10n-id', 'zen-split-link');
+ sibling.insertAdjacentElement('beforebegin', menuitem);
+ }
+
+ /**
+ * @param {Event} event - The event that triggered the tab close.
+ * @description Handles the tab close event.7
+ */
+ handleTabClose(event) {
+ const tab = event.target;
+ const groupIndex = this._data.findIndex((group) => group.tabs.includes(tab));
+ if (groupIndex < 0) {
+ return;
+ }
+ this.removeTabFromGroup(tab, groupIndex, event.forUnsplit);
+ }
+
+ /**
+ * Removes a tab from a group.
+ *
+ * @param {Tab} tab - The tab to remove.
+ * @param {number} groupIndex - The index of the group.
+ * @param {boolean} forUnsplit - Indicates if the tab is being removed for unsplitting.
+ */
+ removeTabFromGroup(tab, groupIndex, forUnsplit) {
+ const group = this._data[groupIndex];
+ const tabIndex = group.tabs.indexOf(tab);
+ group.tabs.splice(tabIndex, 1);
+
+ this.resetTabState(tab, forUnsplit);
+
+ if (group.tabs.length < 2) {
+ this.removeGroup(groupIndex);
+ } else {
+ const node = this.getSplitNodeFromTab(tab);
+ const toUpdate = this.removeNode(node);
+ this.applyGridLayout(toUpdate);
+ }
+ }
+
+ /**
+ * Remove a SplitNode from its tree and the view
+ * @param {SplitNode} toRemove
+ * @return {SplitNode} that has to be updated
+ */
+ removeNode(toRemove) {
+ this._removeNodeSplitters(toRemove, true);
+ const parent = toRemove.parent;
+ const childIndex = parent.children.indexOf(toRemove);
+ parent.children.splice(childIndex, 1);
+ if (parent.children.length !== 1) {
+ const otherNodeIncrease = 100 / (100 - toRemove.sizeInParent);
+ parent.children.forEach(c => c.sizeInParent *= otherNodeIncrease);
+ return parent;
+ }
+ // node that is not a leaf cannot have less than 2 children, this makes for better resizing
+ // node takes place of parent
+ const leftOverChild = parent.children[0];
+ leftOverChild.sizeInParent = parent.sizeInParent;
+ if (parent.parent) {
+ const idx = parent.parent.children.indexOf(parent);
+ if (parent.parent.direction !== leftOverChild.direction) {
+ leftOverChild.parent = parent.parent;
+ parent.parent.children[idx] = leftOverChild;
+ } else {
+ // node cannot have same direction as it's parent
+ leftOverChild.children.forEach(c => {
+ c.sizeInParent *= leftOverChild.sizeInParent / 100
+ c.parent = parent.parent;
+ });
+ parent.parent.children.splice(idx, 1, ...leftOverChild.children);
+ this._removeNodeSplitters(leftOverChild, false);
+ }
+ this._removeNodeSplitters(parent, false);
+ return parent.parent;
+ } else {
+ const viewData = Object.values(this._data).find(s => s.layoutTree === parent);
+ viewData.layoutTree = leftOverChild;
+ leftOverChild.positionToRoot = null;
+ leftOverChild.parent = null;
+ return leftOverChild;
+ }
+ }
+
+ /**
+ * @param node
+ * @param {boolean} recursive
+ * @private
+ */
+ _removeNodeSplitters(node, recursive ) {
+ this.getSplitters(node)?.forEach(s => s.remove());
+ this._splitNodeToSplitters.delete(node);
+ if (!recursive) return;
+ if (node.children) node.children.forEach(c => this._removeNodeSplitters(c));
+ }
+
+ get rearangeActionTarget() {
+ return document.getElementById("urlbar-container");
+ }
+
+ afterRearangeAction() {
+ document.getElementById("zenSplitViewModifier").hidePopup();
+ ConfirmationHint.show(document.getElementById("zen-split-views-box"), "zen-split-view-modifier-enabled-toast", {
+ descriptionId: "zen-split-view-modifier-enabled-toast-description",
+ showDescription: true,
+ });
+ }
+
+ afterRearangeRemove() {
+ ConfirmationHint.show(document.getElementById("zen-split-views-box"), "zen-split-view-modifier-disabled-toast");
+ }
+
+ toggleWrapperDisplay(value) {
+ const wrapper = this.overlay?.parentNode;
+ if (!wrapper) return;
+
+ wrapper.setAttribute('hidden', !value);
+ }
+
+ enableTabRearrangeView() {
+ if (this.rearrangeViewEnabled) return;
+ this.rearrangeViewEnabled = true;
+ this.rearrangeViewView = this.currentView;
+ if (!this._thumnailCanvas) {
+ this._thumnailCanvas = document.createElement("canvas");
+ this._thumnailCanvas.width = 280 * devicePixelRatio;
+ this._thumnailCanvas.height = 140 * devicePixelRatio;
+ }
+
+ const browsers = this._data[this.currentView].tabs.map(t => t.linkedBrowser);
+ browsers.forEach(b => {
+ b.style.pointerEvents = 'none';
+ b.style.opacity = '.85';
+ });
+ this.tabBrowserPanel.addEventListener('dragstart', this.onBrowserDragStart);
+ this.tabBrowserPanel.addEventListener('dragover', this.onBrowserDragOver);
+ this.tabBrowserPanel.addEventListener('drop', this.onBrowserDrop);
+ this.tabBrowserPanel.addEventListener('dragend', this.onBrowserDragEnd)
+ this.tabBrowserPanel.addEventListener('click', this.disableTabRearrangeView);
+ window.addEventListener('keydown', this.disableTabRearrangeView);
+ this.afterRearangeAction();
+ }
+
+ disableTabRearrangeView = (event = null) => {
+ if (!this.rearrangeViewEnabled) return;
+ if (event) {
+ // Click or "ESC" key
+ if (event.type === 'click' && event.button !== 0
+ || event.type === 'keydown' && event.key !== 'Escape') {
+ return;
+ }
+ }
+
+ if (!this.rearrangeViewEnabled || (event && event.target.classList.contains('zen-split-view-splitter'))) {
+ return;
+ }
+
+ this.tabBrowserPanel.removeEventListener('dragstart', this.onBrowserDragStart);
+ this.tabBrowserPanel.removeEventListener('dragover', this.onBrowserDragOver);
+ this.tabBrowserPanel.removeEventListener('drop', this.onBrowserDrop);
+ this.tabBrowserPanel.removeEventListener('click', this.disableTabRearrangeView);
+ window.removeEventListener('keydown', this.disableTabRearrangeView);
+ const browsers = this._data[this.rearrangeViewView].tabs.map(t => t.linkedBrowser);
+ browsers.forEach(b => {
+ b.style.pointerEvents = '';
+ b.style.opacity = '';
+ });
+ this.rearrangeViewEnabled = false;
+ this.rearrangeViewView = null;
+ this.afterRearangeRemove();
+ }
+
+ onBrowserDragStart = (event) => {
+ if (!this.splitViewActive) return;
+ let browser = event.target.querySelector('browser');
+ if (!browser) {
+ return;
+ }
+ browser.style.opacity = '.2';
+ const browserContainer = browser.closest('.browserSidebarContainer');
+ event.dataTransfer.setData('text/plain', browserContainer.id);
+ this._draggingTab = gBrowser.getTabForBrowser(browser);
+
+ let dt = event.dataTransfer;
+ let scale = window.devicePixelRatio;
+ let canvas = this._dndCanvas;
+ if (!canvas) {
+ this._dndCanvas = canvas = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "canvas"
+ );
+ canvas.style.width = "100%";
+ canvas.style.height = "100%";
+ }
+
+ canvas.width = 160 * scale;
+ canvas.height = 90 * scale;
+ let toDrag = canvas;
+ let dragImageOffset = -16;
+ if (gMultiProcessBrowser) {
+ var context = canvas.getContext("2d");
+ context.fillStyle = "white";
+ context.fillRect(0, 0, canvas.width, canvas.height);
+
+ let captureListener;
+ let platform = AppConstants.platform;
+ // On Windows and Mac we can update the drag image during a drag
+ // using updateDragImage. On Linux, we can use a panel.
+ if (platform === "win" || platform === "macosx") {
+ captureListener = function () {
+ dt.updateDragImage(canvas, dragImageOffset, dragImageOffset);
+ };
+ } else {
+ // Create a panel to use it in setDragImage
+ // which will tell xul to render a panel that follows
+ // the pointer while a dnd session is on.
+ if (!this._dndPanel) {
+ this._dndCanvas = canvas;
+ this._dndPanel = document.createXULElement("panel");
+ this._dndPanel.className = "dragfeedback-tab";
+ this._dndPanel.setAttribute("type", "drag");
+ let wrapper = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "div"
+ );
+ wrapper.style.width = "160px";
+ wrapper.style.height = "90px";
+ wrapper.appendChild(canvas);
+ this._dndPanel.appendChild(wrapper);
+ document.documentElement.appendChild(this._dndPanel);
+ }
+ toDrag = this._dndPanel;
+ }
+ // PageThumb is async with e10s but that's fine
+ // since we can update the image during the dnd.
+ PageThumbs.captureToCanvas(browser, canvas)
+ .then(captureListener)
+ .catch(e => console.error(e));
+ } else {
+ // For the non e10s case we can just use PageThumbs
+ // sync, so let's use the canvas for setDragImage.
+ PageThumbs.captureToCanvas(browser, canvas).catch(e =>
+ console.error(e)
+ );
+ dragImageOffset = dragImageOffset * scale;
+ }
+ dt.setDragImage(toDrag, dragImageOffset, dragImageOffset);
+ return true;
+ }
+
+ onBrowserDragOver = (event) => {
+ event.preventDefault();
+ const browser = event.target.querySelector('browser');
+ if (!browser) return;
+ const tab = gBrowser.getTabForBrowser(browser);
+ if (tab === this._draggingTab) {
+ if (this.dropZone.hasAttribute('enabled')) {
+ this.dropZone.removeAttribute('enabled');
+ }
+ return;
+ }
+ if (!this.dropZone.hasAttribute('enabled')) {
+ this.dropZone.setAttribute('enabled', true);
+ }
+ const splitNode = this.getSplitNodeFromTab(tab);
+
+ const posToRoot = {...splitNode.positionToRoot};
+ const browserRect = browser.getBoundingClientRect();
+ const hoverSide = this.calculateHoverSide(event.clientX, event.clientY, browserRect);
+
+ if (hoverSide !== 'center') {
+ const isVertical = hoverSide === 'top' || hoverSide === 'bottom';
+ const browserSize = 100 - (isVertical ? posToRoot.top + posToRoot.bottom : posToRoot.right + posToRoot.left);
+ const reduce= browserSize * .5;
+
+ posToRoot[this._oppositeSide(hoverSide)] += reduce;
+ }
+ const newInset =
+ `${posToRoot.top}% ${posToRoot.right}% ${posToRoot.bottom}% ${posToRoot.left}%`;
+ if (this.dropZone.style.inset !== newInset) {
+ window.requestAnimationFrame(() => this.dropZone.style.inset = newInset);
+ }
+ }
+
+ onBrowserDragEnd = (event) => {
+ this.dropZone.removeAttribute('enabled');
+ const draggingBrowser = this._draggingTab.linkedBrowser;
+ draggingBrowser.style.opacity = '.85';
+ this._draggingTab = null;
+ }
+
+ _oppositeSide(side) {
+ if (side === 'top') return 'bottom';
+ if (side === 'bottom') return 'top';
+ if (side === 'left') return 'right';
+ if (side === 'right') return 'left';
+ }
+
+ calculateHoverSide(x, y, elementRect) {
+ const hPixelHoverSize = (elementRect.right - elementRect.left) * this._edgeHoverSize / 100;
+ const vPixelHoverSize = (elementRect.bottom - elementRect.top) * this._edgeHoverSize / 100;
+ if (x <= elementRect.left + hPixelHoverSize) return 'left';
+ if (x > elementRect.right - hPixelHoverSize) return 'right';
+ if (y <= elementRect.top + vPixelHoverSize) return 'top';
+ if (y > elementRect.bottom - vPixelHoverSize) return 'bottom';
+ return 'center';
+ }
+
+ onBrowserDrop = (event) => {
+ const browserDroppedOn = event.target.querySelector('browser');
+ if (!browserDroppedOn) return;
+
+ const droppedTab = this._draggingTab;
+ if (!droppedTab) return;
+ const droppedOnTab = gBrowser.getTabForBrowser(
+ event.target.querySelector('browser')
+ );
+ if (droppedTab === droppedOnTab) return;
+
+ const hoverSide = this.calculateHoverSide(event.clientX, event.clientY, browserDroppedOn.getBoundingClientRect());
+ const droppedSplitNode = this.getSplitNodeFromTab(droppedTab);
+ const droppedOnSplitNode = this.getSplitNodeFromTab(droppedOnTab);
+ if (hoverSide === 'center') {
+ this.swapNodes(droppedSplitNode, droppedOnSplitNode);
+ this.applyGridLayout(this._data[this.currentView].layoutTree);
+ return;
+ }
+ this.removeNode(droppedSplitNode);
+ this.splitIntoNode(droppedOnSplitNode, droppedSplitNode, hoverSide, .5);
+ this.activateSplitView(this._data[this.currentView], true);
+ }
+
+ /**
+ *
+ * @param node1
+ * @param node2
+ */
+ swapNodes(node1, node2) {
+ this._swapField('sizeInParent', node1, node2);
+
+ const node1Idx = node1.parent.children.indexOf(node1);
+ const node2Idx = node2.parent.children.indexOf(node2);
+ node1.parent.children[node1Idx] = node2;
+ node2.parent.children[node2Idx] = node1;
+
+ this._swapField('parent', node1, node2);
+ }
+
+ /**
+ *
+ * @param node
+ * @param nodeToInsert
+ * @param side
+ * @param sizeOfInsertedNode percentage of node width or height that nodeToInsert will take
+ */
+ splitIntoNode(node, nodeToInsert, side, sizeOfInsertedNode) {
+ const splitDirection = side === 'left' || side === 'right' ? 'row' : 'column';
+ const splitPosition = side === 'left' || side === 'top' ? 0 : 1;
+
+ let nodeSize;
+ let newParent;
+ if (splitDirection === node.parent?.direction) {
+ newParent = node.parent;
+ nodeSize = node.sizeInParent;
+ } else {
+ nodeSize = 100;
+ newParent = new SplitNode(splitDirection, node.sizeInParent);
+ if (node.parent) {
+ newParent.parent = node.parent;
+ const nodeIndex = node.parent.children.indexOf(node);
+ node.parent.children[nodeIndex] = newParent;
+ } else {
+ const viewData = Object.values(this._data).find(s => s.layoutTree === node);
+ viewData.layoutTree = newParent;
+ }
+ newParent.addChild(node);
+ }
+ node.sizeInParent = (1 - sizeOfInsertedNode) * nodeSize;
+ nodeToInsert.sizeInParent = nodeSize * sizeOfInsertedNode;
+
+ const index = newParent.children.indexOf(node);
+ newParent.children.splice(index + splitPosition, 0, nodeToInsert);
+ nodeToInsert.parent = newParent;
+ }
+
+ _swapField(fieldName, obj1, obj2) {
+ const swap = obj1[fieldName];
+ obj1[fieldName] = obj2[fieldName];
+ obj2[fieldName] = swap;
+ }
+
+ /**
+ * Resets the state of a tab.
+ *
+ * @param {Tab} tab - The tab to reset.
+ * @param {boolean} forUnsplit - Indicates if the tab is being reset for unsplitting.
+ */
+ resetTabState(tab, forUnsplit) {
+ tab.splitView = false;
+ tab.linkedBrowser.zenModeActive = false;
+ const container = tab.linkedBrowser.closest('.browserSidebarContainer');
+ this.resetContainerStyle(container);
+ container.removeEventListener('click', this.handleTabEvent);
+ container.removeEventListener('mouseover', this.handleTabEvent);
+ if (!forUnsplit) {
+ tab.linkedBrowser.docShellIsActive = false;
+ }
+ }
+
+ /**
+ * Removes a group.
+ *
+ * @param {number} groupIndex - The index of the group to remove.
+ */
+ removeGroup(groupIndex) {
+ if (this.currentView === groupIndex) {
+ this.deactivateCurrentSplitView();
+ }
+ for (const tab of this._data[groupIndex].tabs) {
+ this.resetTabState(tab, true);
+ }
+ this._data.splice(groupIndex, 1);
+ }
+
+ /**
+ * context menu item display update
+ */
+ insetUpdateContextMenuItems() {
+ const contentAreaContextMenu = document.getElementById('tabContextMenu');
+ contentAreaContextMenu.addEventListener('popupshowing', () => {
+ const tabCountInfo = JSON.stringify({
+ tabCount: window.gBrowser.selectedTabs.length,
+ });
+ document.getElementById('context_zenSplitTabs').setAttribute('data-l10n-args', tabCountInfo);
+ document.getElementById('context_zenSplitTabs').disabled = !this.contextCanSplitTabs();
+ });
+ }
+
+ /**
+ * Inserts the split view tab context menu item.
+ */
+ insertSplitViewTabContextMenu() {
+ const element = window.MozXULElement.parseXULToFragment(`
+
+
+
+ `);
+ document.getElementById('context_closeDuplicateTabs').after(element);
+ }
+
+ /**
+ * Initializes the context menu.
+ */
+ initializeContextMenu() {
+ this.insertSplitViewTabContextMenu();
+ this.insetUpdateContextMenuItems();
+ }
+
+ /**
+ * Insert Page Action button
+ */
+ insertPageActionButton() {
+ const element = window.MozXULElement.parseXULToFragment(`
+
+
+
+ `);
+ document.getElementById('star-button-box').after(element);
+ }
+
+ /**
+ * Gets the tab browser panel.
+ *
+ * @returns {Element} The tab browser panel.
+ */
+ get tabBrowserPanel() {
+ if (!this._tabBrowserPanel) {
+ this._tabBrowserPanel = document.getElementById('tabbrowser-tabpanels');
+ }
+ return this._tabBrowserPanel;
+ }
+
+ get splitViewActive() {
+ return this.currentView >= 0;
+ }
+
+ /**
+ * Splits a link in a new tab.
+ */
+ splitLinkInNewTab() {
+ const url = window.gContextMenu.linkURL || window.gContextMenu.mediaURL || window.gContextMenu.contentData.docLocation || window.gContextMenu.target.ownerDocument.location.href;
+ const currentTab = window.gBrowser.selectedTab;
+ const newTab = this.openAndSwitchToTab(url);
+ this.splitTabs([currentTab, newTab]);
+ }
+
+ /**
+ * Splits the selected tabs.
+ */
+ contextSplitTabs() {
+ const tabs = window.gBrowser.selectedTabs;
+ this.splitTabs(tabs);
+ }
+
+ /**
+ * Checks if the selected tabs can be split.
+ *
+ * @returns {boolean} True if the tabs can be split, false otherwise.
+ */
+ contextCanSplitTabs() {
+ if (window.gBrowser.selectedTabs.length < 2) {
+ return false;
+ }
+ for (const tab of window.gBrowser.selectedTabs) {
+ if (tab.splitView) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Handles the location change event.
+ *
+ * @param {Browser} browser - The browser instance.
+ */
+ async onLocationChange(browser) {
+ this.disableTabRearrangeView();
+ const tab = window.gBrowser.getTabForBrowser(browser);
+ this.updateSplitViewButton(!tab?.splitView);
+ if (tab) {
+ this.updateSplitView(tab);
+ tab.linkedBrowser.docShellIsActive = true;
+ }
+ }
+
+ /**
+ * Splits the given tabs.
+ *
+ * @param {Tab[]} tabs - The tabs to split.
+ * @param {string} gridType - The type of grid layout.
+ */
+ splitTabs(tabs, gridType) {
+ if (tabs.length < 2) {
+ return;
+ }
+
+ const existingSplitTab = tabs.find((tab) => tab.splitView);
+ if (existingSplitTab) {
+ const groupIndex = this._data.findIndex((group) => group.tabs.includes(existingSplitTab));
+ const group = this._data[groupIndex];
+ const gridTypeChange = gridType && (group.gridType !== gridType);
+ const newTabsAdded = tabs.find(t => !group.tabs.includes(t));
+ if (gridTypeChange || !newTabsAdded) {
+ // reset layout
+ group.gridType = gridType;
+ group.layoutTree = this.calculateLayoutTree([...new Set(group.tabs.concat(tabs))], gridType);
+ } else {
+ // Add any tabs that are not already in the group
+ for (const tab of tabs) {
+ if (!group.tabs.includes(tab)) {
+ group.tabs.push(tab);
+ this.addTabToSplit(tab, group.layoutTree);
+ }
+ }
+ }
+ this.activateSplitView(group, true);
+ return;
+ }
+ gridType ??= 'grid';
+
+ const splitData = {
+ tabs,
+ gridType,
+ layoutTree: this.calculateLayoutTree(tabs, gridType),
+ }
+ this._data.push(splitData);
+ window.gBrowser.selectedTab = tabs[0];
+ this.activateSplitView(splitData);
+ }
+
+ addTabToSplit(tab, splitNode) {
+ const reduce = splitNode.children.length / (splitNode.children.length + 1);
+ splitNode.children.forEach(c => c.sizeInParent *= reduce);
+ splitNode.addChild(new SplitLeafNode(tab, (1 - reduce) * 100));
+ }
+
+ /**
+ * Updates the split view.
+ *
+ * @param {Tab} tab - The tab to update the split view for.
+ */
+ updateSplitView(tab) {
+ const oldView = this.currentView;
+ const newView = this._data.findIndex((group) => group.tabs.includes(tab));
+
+ if (oldView === newView) return;
+ if (newView < 0 && oldView >= 0) {
+ this.updateSplitViewButton(true);
+ this.deactivateCurrentSplitView();
+ return;
+ }
+ this.disableTabRearrangeView();
+ this.activateSplitView(this._data[newView]);
+ }
+
+ /**
+ * Deactivates the split view.
+ */
+ deactivateCurrentSplitView() {
+ for (const tab of this._data[this.currentView].tabs) {
+ const container = tab.linkedBrowser.closest('.browserSidebarContainer');
+ this.resetContainerStyle(container);
+ }
+ this.removeSplitters();
+ this.tabBrowserPanel.removeAttribute('zen-split-view');
+ this.setTabsDocShellState(this._data[this.currentView].tabs, false);
+ this.updateSplitViewButton(true);
+ this.currentView = -1;
+ this.toggleWrapperDisplay(false);
+ }
+
+ /**
+ * Activates the split view.
+ *
+ * @param {object} splitData - The split data.
+ */
+ activateSplitView(splitData, reset = false) {
+ const oldView = this.currentView;
+ const newView = this._data.indexOf(splitData);
+ if (oldView >= 0 && oldView !== newView) this.deactivateCurrentSplitView();
+ this.currentView = newView;
+ if (reset) this.removeSplitters();
+ splitData.tabs.forEach((tab) => {
+ if (tab.hasAttribute('pending')) {
+ gBrowser.getBrowserForTab(tab).reload();
+ }
+ });
+
+ this.tabBrowserPanel.setAttribute('zen-split-view', 'true');
+
+ this.setTabsDocShellState(splitData.tabs, true);
+ this.updateSplitViewButton(false);
+ this.applyGridToTabs(splitData.tabs);
+ this.applyGridLayout(splitData.layoutTree);
+ this.toggleWrapperDisplay(true);
+ }
+
+ calculateLayoutTree(tabs, gridType) {
+ let rootNode;
+ if (gridType === 'vsep' || (tabs.length === 2 && gridType === 'grid')) {
+ rootNode = new SplitNode('row');
+ rootNode.children = tabs.map(tab => new SplitLeafNode(tab, 100 / tabs.length));
+ } else if (gridType === 'hsep') {
+ rootNode = new SplitNode('column');
+ rootNode.children = tabs.map(tab => new SplitLeafNode(tab, 100 / tabs.length));
+ } else if (gridType === 'grid') {
+ rootNode = new SplitNode('row');
+ const rowWidth = 100 / Math.ceil(tabs.length / 2);
+ for (let i = 0; i < tabs.length - 1; i += 2) {
+ const columnNode = new SplitNode('column', rowWidth, 100);
+ columnNode.children = [new SplitLeafNode(tabs[i], 50), new SplitLeafNode(tabs[i + 1], 50)];
+ rootNode.addChild(columnNode);
+ }
+ if (tabs.length % 2 !== 0) {
+ rootNode.addChild(new SplitLeafNode(tabs[tabs.length - 1], rowWidth));
+ }
+ }
+
+ return rootNode;
+ }
+
+ /**
+ * Applies the grid layout to the tabs.
+ *
+ * @param {Tab[]} tabs - The tabs to apply the grid layout to.
+ * @param {Tab} activeTab - The active tab.
+ */
+ applyGridToTabs(tabs) {
+ tabs.forEach((tab, index) => {
+ tab.splitView = true;
+ const container = tab.linkedBrowser.closest('.browserSidebarContainer');
+ this.styleContainer(container);
+ });
+ }
+
+ /**
+ * Apply grid layout to tabBrowserPanel
+ *
+ * @param {SplitNode} splitNode SplitNode
+ */
+ applyGridLayout(splitNode) {
+ if (!splitNode.positionToRoot) {
+ splitNode.positionToRoot = {top: 0, bottom: 0, left: 0, right: 0};
+ }
+ const nodeRootPosition = splitNode.positionToRoot;
+ if (!splitNode.children) {
+ const browserContainer = splitNode.tab.linkedBrowser.closest('.browserSidebarContainer');
+ browserContainer.style.inset = `${nodeRootPosition.top}% ${nodeRootPosition.right}% ${nodeRootPosition.bottom}% ${nodeRootPosition.left}%`;
+ this._tabToSplitNode.set(splitNode.tab, splitNode);
+ return;
+ }
+
+ const rootToNodeWidthRatio = ((100 - nodeRootPosition.right) - nodeRootPosition.left) / 100;
+ const rootToNodeHeightRatio = ((100 - nodeRootPosition.bottom) - nodeRootPosition.top) / 100;
+
+ const splittersNeeded = splitNode.children.length - 1;
+ const currentSplitters = this.getSplitters(splitNode, splittersNeeded);
+
+ let leftOffset = nodeRootPosition.left;
+ let topOffset = nodeRootPosition.top;
+ splitNode.children.forEach((childNode, i) => {
+ const childRootPosition = {top: topOffset, right: 100 - (leftOffset + childNode.widthInParent * rootToNodeWidthRatio), bottom: 100 - (topOffset + childNode.heightInParent * rootToNodeHeightRatio), left: leftOffset};
+ childNode.positionToRoot = childRootPosition;
+ this.applyGridLayout(childNode);
+
+ if (splitNode.direction === 'column') {
+ topOffset += childNode.sizeInParent * rootToNodeHeightRatio;
+ } else {
+ leftOffset += childNode.sizeInParent * rootToNodeWidthRatio;
+ }
+
+ if (i < splittersNeeded) {
+ const splitter = currentSplitters[i];
+ if (splitNode.direction === 'column') {
+ splitter.style.inset = `${100 - childRootPosition.bottom}% ${childRootPosition.right}% 0% ${childRootPosition.left}%`;
+ } else {
+ splitter.style.inset = `${childRootPosition.top}% 0% ${childRootPosition.bottom}% ${100 - childRootPosition.right}%`;
+ }
+ }
+ });
+ }
+
+ /**
+ *
+ * @param {String} orient
+ * @param {SplitNode} parentNode
+ * @param {Number} idx
+ */
+ createSplitter(orient, parentNode, idx) {
+ const splitter = document.createElement('div');
+ splitter.className = 'zen-split-view-splitter';
+ splitter.setAttribute('orient', orient);
+ splitter.setAttribute('gridIdx', idx);
+ this.overlay.insertAdjacentElement("afterbegin", splitter);
+
+ splitter.addEventListener('mousedown', this.handleSplitterMouseDown);
+ return splitter;
+ }
+
+ /**
+ * @param {SplitNode} parentNode
+ * @param {number|undefined} splittersNeeded if provided the amount of splitters for node will be adjusted to match
+ */
+ getSplitters(parentNode, splittersNeeded) {
+ let currentSplitters = this._splitNodeToSplitters.get(parentNode) || [];
+ if (!splittersNeeded || currentSplitters.length === splittersNeeded) return currentSplitters;
+ for (let i = currentSplitters?.length || 0; i < splittersNeeded; i++) {
+ currentSplitters.push(
+ this.createSplitter(parentNode.direction === 'column' ? 'horizontal' : 'vertical', parentNode, i)
+ );
+ currentSplitters[i].parentSplitNode = parentNode;
+ }
+ if (currentSplitters.length > splittersNeeded) {
+ currentSplitters.slice(splittersNeeded - currentSplitters.length).forEach(s => s.remove());
+ currentSplitters = currentSplitters.slice(0, splittersNeeded);
+ }
+ this._splitNodeToSplitters.set(parentNode, currentSplitters);
+ return currentSplitters;
+ }
+
+ removeSplitters() {
+ [...this.overlay.children].filter(c => c.classList.contains('zen-split-view-splitter')).forEach(s => s.remove());
+ this._splitNodeToSplitters.clear();
+ }
+
+ /**
+ * @param {Tab} tab
+ * @return {SplitNode} splitNode
+ */
+ getSplitNodeFromTab(tab) {
+ return this._tabToSplitNode.get(tab);
+ }
+
+ /**
+ * Styles the container for a tab.
+ *
+ * @param {Element} container - The container element.
+ */
+ styleContainer(container) {
+ container.setAttribute('zen-split-anim', 'true');
+ container.addEventListener('click', this.handleTabEvent);
+ container.addEventListener('mouseover', this.handleTabEvent);
+ }
+
+ /**
+ * Handles tab events.
+ *
+ * @param {Event} event - The event.
+ */
+ handleTabEvent = (event) => {
+ if (this.rearrangeViewEnabled || (event.type === 'mouseover' && !this.canChangeTabOnHover)) {
+ return;
+ }
+ const container = event.currentTarget;
+ const tab = window.gBrowser.tabs.find((t) => t.linkedBrowser.closest('.browserSidebarContainer') === container);
+ if (tab) {
+ window.gBrowser.selectedTab = tab;
+ }
+ };
+
+ handleSplitterMouseDown = (event) => {
+ this.tabBrowserPanel.setAttribute('zen-split-resizing', true);
+ const isVertical = event.target.getAttribute('orient') === 'vertical';
+ const dimension = isVertical ? 'width' : 'height';
+ const clientAxis = isVertical ? 'screenX' : 'screenY';
+
+ const gridIdx = parseInt(event.target.getAttribute('gridIdx'));
+ const startPosition = event[clientAxis];
+ const splitNode = event.target.parentSplitNode;
+ let rootToNodeSize;
+ if (isVertical) rootToNodeSize = 100 / (100 - splitNode.positionToRoot.right - splitNode.positionToRoot.left);
+ else rootToNodeSize = 100 / (100 - splitNode.positionToRoot.bottom - splitNode.positionToRoot.top);
+ const originalSizes = splitNode.children.map(c => c.sizeInParent);
+
+ const dragFunc = (dEvent) => {
+ requestAnimationFrame(() => {
+ originalSizes.forEach((s, i) => splitNode.children[i].sizeInParent = s); // reset changes
+
+ const movement = dEvent[clientAxis] - startPosition;
+ let movementPercent = (movement / this.tabBrowserPanel.getBoundingClientRect()[dimension] * rootToNodeSize) * 100;
+
+ let reducingMovement = Math.max(movementPercent, -movementPercent);
+ for (let i = gridIdx + (movementPercent < 0 ? 0 : 1); 0 <= i && i < originalSizes.length; i += movementPercent < 0 ? -1 : 1) {
+ const current = originalSizes[i];
+ const newSize = Math.max(this.minResizeWidth, current - reducingMovement);
+ splitNode.children[i].sizeInParent = newSize;
+ const amountReduced = current - newSize;
+ reducingMovement -= amountReduced;
+ if (reducingMovement <= 0) break;
+ }
+ const increasingMovement = Math.max(movementPercent, - movementPercent) - reducingMovement;
+ const increaseIndex = gridIdx + (movementPercent < 0 ? 1 : 0);
+ splitNode.children[increaseIndex].sizeInParent = originalSizes[increaseIndex] + increasingMovement;
+ this.applyGridLayout(splitNode);
+ });
+ }
+
+ setCursor(isVertical ? 'ew-resize' : 'n-resize');
+ document.addEventListener('mousemove', dragFunc);
+ document.addEventListener('mouseup', () => {
+ document.removeEventListener('mousemove', dragFunc);
+ setCursor('auto');
+ this.tabBrowserPanel.removeAttribute('zen-split-resizing');
+ }, {once: true});
+ }
+
+ /**
+ * Sets the docshell state for the tabs.
+ *
+ * @param {Tab[]} tabs - The tabs.
+ * @param {boolean} active - Indicates if the tabs are active.
+ */
+ setTabsDocShellState(tabs, active) {
+ for (const tab of tabs) {
+ // zenModeActive allow us to avoid setting docShellisActive to false later on,
+ // see browser-custom-elements.js's patch
+ tab.linkedBrowser.zenModeActive = active;
+ if (!active && tab === gBrowser.selectedTab) continue;
+ try {
+ tab.linkedBrowser.docShellIsActive = active;
+ } catch (e) {
+ console.error(e);
+ }
+ const browser = tab.linkedBrowser.closest('.browserSidebarContainer');
+ if (active) {
+ browser.setAttribute('zen-split', 'true');
+ } else {
+ browser.removeAttribute('zen-split');
+ browser.removeAttribute('style');
+ }
+ }
+ }
+
+ /**
+ * Resets the container style.
+ *
+ * @param {Element} container - The container element.
+ */
+ resetContainerStyle(container) {
+ container.removeAttribute('zen-split');
+ container.style.inset = '';
+ }
+
+ /**
+ * Updates the split view button visibility.
+ *
+ * @param {boolean} hidden - Indicates if the button should be hidden.
+ */
+ updateSplitViewButton(hidden) {
+ const button = document.getElementById('zen-split-views-box');
+ if (hidden) {
+ button?.setAttribute('hidden', 'true');
+ } else {
+ button?.removeAttribute('hidden');
+ }
+ }
+
+ /**
+ * Gets the modifier element.
+ *
+ * @returns {Element} The modifier element.
+ */
+ get modifierElement() {
+ if (!this.__modifierElement) {
+ const wrapper = document.getElementById('template-zen-split-view-modifier');
+ const panel = wrapper.content.firstElementChild;
+ wrapper.replaceWith(wrapper.content);
+ this.__modifierElement = panel;
+ }
+ return this.__modifierElement;
+ }
+
+ /**
+ * Opens the split view panel.
+ *
+ * @param {Event} event - The event that triggered the panel opening.
+ */
+ async openSplitViewPanel(event) {
+ const panel = this.modifierElement;
+ const target = event.target.parentNode;
+ this.updatePanelUI(panel);
+
+ if (!this.__hasSetMenuListener) {
+ this.setupPanelListeners(panel);
+ this.__hasSetMenuListener = true;
+ }
+
+ window.PanelMultiView.openPopup(panel, target, {
+ position: 'bottomright topright',
+ triggerEvent: event,
+ }).catch(console.error);
+ }
+
+ /**
+ * Updates the UI of the panel.
+ *
+ * @param {Element} panel - The panel element.
+ */
+ updatePanelUI(panel) {
+ for (const gridType of ['hsep', 'vsep', 'grid', 'unsplit']) {
+ const selector = panel.querySelector(`.zen-split-view-modifier-preview.${gridType}`);
+ selector.classList.remove('active');
+ if (this.currentView >= 0 && this._data[this.currentView].gridType === gridType) {
+ selector.classList.add('active');
+ }
+ }
+ }
+
+ /**
+ * @description sets up the listeners for the panel.
+ * @param {Element} panel - The panel element
+ */
+ setupPanelListeners(panel) {
+ for (const gridType of ['hsep', 'vsep', 'grid', 'unsplit']) {
+ const selector = panel.querySelector(`.zen-split-view-modifier-preview.${gridType}`);
+ selector.addEventListener('click', () => this.handlePanelSelection(gridType, panel));
+ }
+ }
+
+ /**
+ * @description handles the panel selection.
+ * @param {string} gridType - The grid type
+ * @param {Element} panel - The panel element
+ */
+ handlePanelSelection(gridType, panel) {
+ if (gridType === 'unsplit') {
+ this.unsplitCurrentView();
+ } else {
+ const group = this._data[this.currentView];
+ group.gridType = gridType;
+ group.layoutTree = this.calculateLayoutTree(group.tabs, gridType);
+ this.activateSplitView(group, true);
+ }
+ panel.hidePopup();
+ }
+
+ /**
+ * @description unsplit the current view.]
+ */
+ unsplitCurrentView() {
+ if (this.currentView < 0) return;
+ this.removeGroup(this.currentView);
+ const currentTab = window.gBrowser.selectedTab;
+ window.gBrowser.selectedTab = currentTab;
+ }
+
+ /**
+ * @description opens a new tab and switches to it.
+ * @param {string} url - The url to open
+ * @param {object} options - The options for the tab
+ * @returns {tab} The tab that was opened
+ */
+ openAndSwitchToTab(url, options) {
+ const parentWindow = window.ownerGlobal.parent;
+ const targetWindow = parentWindow || window;
+ const tab = targetWindow.gBrowser.addTrustedTab(url, options);
+ targetWindow.gBrowser.selectedTab = tab;
+ return tab;
+ }
+
+ toggleShortcut(gridType) {
+ if (gridType === 'unsplit') {
+ this.unsplitCurrentView();
+ return;
+ }
+ const tabs = gBrowser.visibleTabs;
+ if (tabs.length < 2) {
+ return;
+ }
+ let nextTabIndex = tabs.indexOf(gBrowser.selectedTab) + 1;
+ if (nextTabIndex >= tabs.length) {
+ // Find the first non-hidden tab
+ nextTabIndex = tabs.findIndex((tab) => !tab.hidden);
+ } else if (nextTabIndex < 0) {
+ // reverse find the first non-hidden tab
+ nextTabIndex = tabs
+ .slice()
+ .reverse()
+ .findIndex((tab) => !tab.hidden);
+ }
+ const selected_tabs = gBrowser.selectedTab.multiselected
+ ? gBrowser.selectedTabs
+ : [gBrowser.selectedTab, tabs[nextTabIndex]];
+ this.splitTabs(selected_tabs, gridType);
+ }
+}
+
+window.gZenViewSplitter = new ZenViewSplitter();
diff --git a/src/browser/base/zen-components/ZenWorkspaces.mjs b/src/browser/base/zen-components/ZenWorkspaces.mjs
new file mode 100644
index 000000000..b30bc5838
--- /dev/null
+++ b/src/browser/base/zen-components/ZenWorkspaces.mjs
@@ -0,0 +1,1211 @@
+var ZenWorkspaces = new (class extends ZenMultiWindowFeature {
+ /**
+ * Stores workspace IDs and their last selected tabs.
+ */
+ _lastSelectedWorkspaceTabs = {};
+ _inChangingWorkspace = false;
+ draggedElement = null;
+
+ async init() {
+ if (!this.shouldHaveWorkspaces) {
+ console.warn('ZenWorkspaces: !!! ZenWorkspaces is disabled in hidden windows !!!');
+ return; // We are in a hidden window, don't initialize ZenWorkspaces
+ }
+ this.ownerWindow = window;
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ 'shouldShowIconStrip',
+ 'zen.workspaces.show-icon-strip',
+ true,
+ this._expandWorkspacesStrip.bind(this)
+ );
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ 'shouldForceContainerTabsToWorkspace',
+ 'zen.workspaces.force-container-workspace',
+ true
+ );
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ 'shouldOpenNewTabIfLastUnpinnedTabIsClosed',
+ 'zen.workspaces.open-new-tab-if-last-unpinned-tab-is-closed',
+ false
+ );
+ ChromeUtils.defineLazyGetter(this, 'tabContainer', () => document.getElementById('tabbrowser-tabs'));
+ this._activeWorkspace = Services.prefs.getStringPref('zen.workspaces.active', '');
+ await ZenWorkspacesStorage.init();
+ if (!Weave.Service.engineManager.get('workspaces')) {
+ Weave.Service.engineManager.register(ZenWorkspacesEngine);
+ await ZenWorkspacesStorage.migrateWorkspacesFromJSON();
+ }
+ await this.initializeWorkspaces();
+ console.info('ZenWorkspaces: ZenWorkspaces initialized');
+
+ // Add observer for sync completion
+ Services.obs.addObserver(this, 'weave:engine:sync:finish');
+ }
+
+ get activeWorkspace() {
+ return this._activeWorkspace;
+ }
+
+ set activeWorkspace(value) {
+ this._activeWorkspace = value;
+ Services.prefs.setStringPref('zen.workspaces.active', value);
+ }
+
+ async observe(subject, topic, data) {
+ if (topic === 'weave:engine:sync:finish' && data === 'workspaces') {
+ try {
+ const lastChangeTimestamp = await ZenWorkspacesStorage.getLastChangeTimestamp();
+
+ if (
+ !this._workspaceCache ||
+ !this._workspaceCache.lastChangeTimestamp ||
+ lastChangeTimestamp > this._workspaceCache.lastChangeTimestamp
+ ) {
+ await this._propagateWorkspaceData();
+
+ const currentWorkspace = await this.getActiveWorkspace();
+ await gZenThemePicker.onWorkspaceChange(currentWorkspace);
+ }
+ } catch (error) {
+ console.error('Error updating workspaces after sync:', error);
+ }
+ }
+ }
+
+ get shouldHaveWorkspaces() {
+ if (typeof this._shouldHaveWorkspaces === 'undefined') {
+ let docElement = document.documentElement;
+ this._shouldHaveWorkspaces = !(
+ docElement.hasAttribute('privatebrowsingmode') ||
+ docElement.getAttribute('chromehidden').includes('toolbar') ||
+ docElement.getAttribute('chromehidden').includes('menubar')
+ );
+ return this._shouldHaveWorkspaces;
+ }
+ return this._shouldHaveWorkspaces;
+ }
+
+ get workspaceEnabled() {
+ if (typeof this._workspaceEnabled === 'undefined') {
+ this._workspaceEnabled = Services.prefs.getBoolPref('zen.workspaces.enabled', false) && this.shouldHaveWorkspaces;
+ return this._workspaceEnabled;
+ }
+ return this._workspaceEnabled;
+ }
+
+ getActiveWorkspaceFromCache() {
+ try {
+ return this._workspaceCache.workspaces.find((workspace) => workspace.uuid === this.activeWorkspace);
+ } catch (e) {
+ return null;
+ }
+ }
+
+ async _workspaces() {
+ if (this._workspaceCache) {
+ return this._workspaceCache;
+ }
+
+ const [workspaces, lastChangeTimestamp] = await Promise.all([
+ ZenWorkspacesStorage.getWorkspaces(),
+ ZenWorkspacesStorage.getLastChangeTimestamp(),
+ ]);
+
+ this._workspaceCache = { workspaces, lastChangeTimestamp };
+ // Get the active workspace ID from preferences
+ const activeWorkspaceId = this.activeWorkspace;
+
+ if (activeWorkspaceId) {
+ const activeWorkspace = this._workspaceCache.workspaces.find((w) => w.uuid === activeWorkspaceId);
+ // Set the active workspace ID to the first one if the one with selected id doesn't exist
+ if (!activeWorkspace) {
+ this.activeWorkspace = this._workspaceCache.workspaces[0]?.uuid;
+ }
+ } else {
+ // Set the active workspace ID to the first one if active workspace doesn't exist
+ this.activeWorkspace = this._workspaceCache.workspaces[0]?.uuid;
+ }
+ // sort by position
+ this._workspaceCache.workspaces.sort((a, b) => (a.position ?? Infinity) - (b.position ?? Infinity));
+
+ return this._workspaceCache;
+ }
+
+ async onWorkspacesEnabledChanged() {
+ if (this.workspaceEnabled) {
+ throw Error("Shoud've had reloaded the window");
+ } else {
+ this._workspaceCache = null;
+ document.getElementById('zen-workspaces-button')?.remove();
+ for (let tab of gBrowser.tabs) {
+ gBrowser.showTab(tab);
+ }
+ }
+ }
+
+ async initializeWorkspaces() {
+ Services.prefs.addObserver('zen.workspaces.enabled', this.onWorkspacesEnabledChanged.bind(this));
+
+ await this.initializeWorkspacesButton();
+ if (this.workspaceEnabled) {
+ this._initializeWorkspaceCreationIcons();
+ this._initializeWorkspaceTabContextMenus();
+ window.addEventListener('TabBrowserInserted', this.onTabBrowserInserted.bind(this));
+ await SessionStore.promiseInitialized;
+ let workspaces = await this._workspaces();
+ if (workspaces.workspaces.length === 0) {
+ await this.createAndSaveWorkspace('Default Workspace', true);
+ } else {
+ let activeWorkspace = await this.getActiveWorkspace();
+ if (!activeWorkspace) {
+ activeWorkspace = workspaces.workspaces.find((workspace) => workspace.default);
+ this.activeWorkspace = activeWorkspace.uuid;
+ }
+ if (!activeWorkspace) {
+ activeWorkspace = workspaces.workspaces[0];
+ this.activeWorkspace = activeWorkspace.uuid;
+ }
+ await this.changeWorkspace(activeWorkspace, true);
+ }
+ try {
+ window.gZenThemePicker = new ZenThemePicker();
+ } catch (e) {
+ console.error('ZenWorkspaces: Error initializing theme picker', e);
+ }
+ }
+ }
+
+ handleTabBeforeClose(tab) {
+ if (!this.workspaceEnabled || this.__contextIsDelete) {
+ return null;
+ }
+
+ let workspaceID = tab.getAttribute('zen-workspace-id');
+ if (!workspaceID) {
+ return null;
+ }
+
+ const shouldOpenNewTabIfLastUnpinnedTabIsClosed = this.shouldOpenNewTabIfLastUnpinnedTabIsClosed;
+
+ let tabs = gBrowser.tabs.filter(t =>
+ t.getAttribute('zen-workspace-id') === workspaceID &&
+ (!shouldOpenNewTabIfLastUnpinnedTabIsClosed ||!t.pinned || t.getAttribute("pending") !== "true")
+ );
+
+ if (tabs.length === 1 && tabs[0] === tab) {
+ let newTab = this._createNewTabForWorkspace({ uuid: workspaceID });
+ return newTab;
+ }
+
+ return null;
+ }
+
+ _createNewTabForWorkspace(window) {
+ let tab = gZenUIManager.openAndChangeToTab(Services.prefs.getStringPref('browser.startup.homepage'));
+
+ if(window.uuid){
+ tab.setAttribute('zen-workspace-id', window.uuid);
+ }
+ return tab;
+ }
+
+ _kIcons = JSON.parse(Services.prefs.getStringPref('zen.workspaces.icons')).map((icon) =>
+ typeof Intl.Segmenter !== 'undefined' ? new Intl.Segmenter().segment(icon).containing().segment : Array.from(icon)[0]
+ );
+
+ _initializeWorkspaceCreationIcons() {
+ let container = document.getElementById('PanelUI-zen-workspaces-icon-picker-wrapper');
+ for (let icon of this._kIcons) {
+ let button = document.createXULElement('toolbarbutton');
+ button.className = 'toolbarbutton-1';
+ button.setAttribute('label', icon);
+ button.onclick = (event) => {
+ const button = event.target;
+ let wasSelected = button.hasAttribute('selected');
+ for (let button of container.children) {
+ button.removeAttribute('selected');
+ }
+ if (!wasSelected) {
+ button.setAttribute('selected', 'true');
+ }
+ if (this.onIconChangeConnectedCallback) {
+ this.onIconChangeConnectedCallback(icon);
+ } else {
+ this.onWorkspaceIconChangeInner('create', icon);
+ }
+ };
+ container.appendChild(button);
+ }
+ }
+
+ async saveWorkspace(workspaceData) {
+ await ZenWorkspacesStorage.saveWorkspace(workspaceData);
+ await this._propagateWorkspaceData();
+ await this._updateWorkspacesChangeContextMenu();
+ }
+
+ async removeWorkspace(windowID) {
+ let workspacesData = await this._workspaces();
+ console.info('ZenWorkspaces: Removing workspace', windowID);
+ await this.changeWorkspace(workspacesData.workspaces.find((workspace) => workspace.uuid !== windowID));
+ this._deleteAllTabsInWorkspace(windowID);
+ delete this._lastSelectedWorkspaceTabs[windowID];
+ await ZenWorkspacesStorage.removeWorkspace(windowID);
+ await this._propagateWorkspaceData();
+ await this._updateWorkspacesChangeContextMenu();
+ }
+
+ isWorkspaceActive(workspace) {
+ return workspace.uuid === this.activeWorkspace;
+ }
+
+ async getActiveWorkspace() {
+ const workspaces = await this._workspaces();
+ return workspaces.workspaces.find((workspace) => workspace.uuid === this.activeWorkspace) ?? workspaces.workspaces[0];
+ }
+ // Workspaces dialog UI management
+
+ openSaveDialog() {
+ let parentPanel = document.getElementById('PanelUI-zen-workspaces-multiview');
+
+ // randomly select an icon
+ let icon = this._kIcons[Math.floor(Math.random() * this._kIcons.length)];
+ this._workspaceCreateInput.textContent = '';
+ this._workspaceCreateInput.value = '';
+ this._workspaceCreateInput.setAttribute('data-initial-value', '');
+ document.querySelectorAll('#PanelUI-zen-workspaces-icon-picker-wrapper toolbarbutton').forEach((button) => {
+ if (button.label === icon) {
+ button.setAttribute('selected', 'true');
+ } else {
+ button.removeAttribute('selected');
+ }
+ });
+ document.querySelector('.PanelUI-zen-workspaces-icons-container.create').textContent = icon;
+
+ PanelUI.showSubView('PanelUI-zen-workspaces-create', parentPanel);
+ }
+
+ async openEditDialog(workspaceUuid) {
+ this._workspaceEditDialog.setAttribute('data-workspace-uuid', workspaceUuid);
+ document.getElementById('PanelUI-zen-workspaces-edit-save').setAttribute('disabled', 'true');
+ let workspaces = (await this._workspaces()).workspaces;
+ let workspaceData = workspaces.find((workspace) => workspace.uuid === workspaceUuid);
+ this._workspaceEditInput.textContent = workspaceData.name;
+ this._workspaceEditInput.value = workspaceData.name;
+ this._workspaceEditInput.setAttribute('data-initial-value', workspaceData.name);
+ this._workspaceEditIconsContainer.setAttribute('data-initial-value', workspaceData.icon);
+ this.onIconChangeConnectedCallback = (...args) => {
+ this.onWorkspaceIconChangeInner('edit', ...args);
+ this.onWorkspaceEditChange(...args);
+ };
+ document.querySelectorAll('#PanelUI-zen-workspaces-icon-picker-wrapper toolbarbutton').forEach((button) => {
+ if (button.label === workspaceData.icon) {
+ button.setAttribute('selected', 'true');
+ } else {
+ button.removeAttribute('selected');
+ }
+ });
+ document.querySelector('.PanelUI-zen-workspaces-icons-container.edit').textContent = this.getWorkspaceIcon(workspaceData);
+ let parentPanel = document.getElementById('PanelUI-zen-workspaces-multiview');
+ PanelUI.showSubView('PanelUI-zen-workspaces-edit', parentPanel);
+ }
+
+ onWorkspaceIconChangeInner(type = 'create', icon) {
+ const container = document.querySelector(`.PanelUI-zen-workspaces-icons-container.${type}`);
+ if (container.textContent !== icon) {
+ container.textContent = icon;
+ }
+ this.goToPreviousSubView();
+ }
+
+ onWorkspaceIconContainerClick(event) {
+ event.preventDefault();
+ const parentPanel = document.getElementById('PanelUI-zen-workspaces-edit');
+ PanelUI.showSubView('PanelUI-zen-workspaces-icon-picker', parentPanel);
+ }
+
+ goToPreviousSubView() {
+ const parentPanel = document.getElementById('PanelUI-zen-workspaces-multiview');
+ parentPanel.goBack();
+ }
+
+ workspaceHasIcon(workspace) {
+ return workspace.icon && workspace.icon !== '';
+ }
+
+ getWorkspaceIcon(workspace) {
+ if (this.workspaceHasIcon(workspace)) {
+ return workspace.icon;
+ }
+ if (typeof Intl.Segmenter !== 'undefined') {
+ return new Intl.Segmenter().segment(workspace.name).containing().segment.toUpperCase();
+ }
+ return Array.from(workspace.name)[0].toUpperCase();
+ }
+
+ get shouldShowContainers() {
+ return (
+ Services.prefs.getBoolPref('privacy.userContext.ui.enabled') && ContextualIdentityService.getPublicIdentities().length > 0
+ );
+ }
+
+ async _propagateWorkspaceData({ ignoreStrip = false, clearCache = true } = {}) {
+ await this.foreachWindowAsActive(async (browser) => {
+ let workspaceList = browser.document.getElementById('PanelUI-zen-workspaces-list');
+ const createWorkspaceElement = (workspace) => {
+ let element = browser.document.createXULElement('toolbarbutton');
+ element.className = 'subviewbutton zen-workspace-button';
+ element.setAttribute('tooltiptext', workspace.name);
+ element.setAttribute('zen-workspace-id', workspace.uuid);
+ if (this.isWorkspaceActive(workspace)) {
+ element.setAttribute('active', 'true');
+ }
+ if (workspace.default) {
+ element.setAttribute('default', 'true');
+ }
+ const containerGroup = browser.ContextualIdentityService.getPublicIdentities().find(
+ (container) => container.userContextId === workspace.containerTabId
+ );
+ if (containerGroup) {
+ element.classList.add('identity-color-' + containerGroup.color);
+ element.setAttribute('data-usercontextid', containerGroup.userContextId);
+ }
+ if (this.isReorderModeOn(browser)) {
+ element.setAttribute('draggable', 'true');
+ }
+
+ element.addEventListener(
+ 'dragstart',
+ function (event) {
+ if (this.isReorderModeOn(browser)) {
+ this.draggedElement = element;
+ event.dataTransfer.effectAllowed = 'move';
+ event.dataTransfer.setData('text/plain', element.getAttribute('zen-workspace-id'));
+ element.classList.add('dragging');
+ } else {
+ event.preventDefault();
+ }
+ }.bind(browser.ZenWorkspaces)
+ );
+
+ element.addEventListener(
+ 'dragover',
+ function (event) {
+ if (this.isReorderModeOn(browser) && this.draggedElement) {
+ event.preventDefault();
+ event.dataTransfer.dropEffect = 'move';
+ }
+ }.bind(browser.ZenWorkspaces)
+ );
+
+ element.addEventListener(
+ 'dragenter',
+ function (event) {
+ if (this.isReorderModeOn(browser) && this.draggedElement) {
+ element.classList.add('dragover');
+ }
+ }.bind(browser.ZenWorkspaces)
+ );
+
+ element.addEventListener(
+ 'dragleave',
+ function (event) {
+ element.classList.remove('dragover');
+ }.bind(browser.ZenWorkspaces)
+ );
+
+ element.addEventListener(
+ 'drop',
+ async function (event) {
+ event.preventDefault();
+ element.classList.remove('dragover');
+ if (this.isReorderModeOn(browser)) {
+ const draggedWorkspaceId = event.dataTransfer.getData('text/plain');
+ const targetWorkspaceId = element.getAttribute('zen-workspace-id');
+ if (draggedWorkspaceId !== targetWorkspaceId) {
+ await this.moveWorkspace(draggedWorkspaceId, targetWorkspaceId);
+ }
+ if (this.draggedElement) {
+ this.draggedElement.classList.remove('dragging');
+ this.draggedElement = null;
+ }
+ }
+ }.bind(browser.ZenWorkspaces)
+ );
+
+ element.addEventListener(
+ 'dragend',
+ function (event) {
+ if (this.draggedElement) {
+ this.draggedElement.classList.remove('dragging');
+ this.draggedElement = null;
+ }
+ const workspaceElements = browser.document.querySelectorAll('.zen-workspace-button');
+ for (const elem of workspaceElements) {
+ elem.classList.remove('dragover');
+ }
+ }.bind(browser.ZenWorkspaces)
+ );
+
+ let childs = browser.MozXULElement.parseXULToFragment(`
+
+
+
+
+
+
+
+
+
+
+
+
+ `);
+
+ // use text content instead of innerHTML to avoid XSS
+ childs.querySelector('.zen-workspace-icon').textContent = browser.ZenWorkspaces.getWorkspaceIcon(workspace);
+ childs.querySelector('.zen-workspace-name').textContent = workspace.name;
+ if (containerGroup) {
+ childs.querySelector('.zen-workspace-container').textContent = ContextualIdentityService.getUserContextLabel(
+ containerGroup.userContextId
+ );
+ }
+
+ childs.querySelector('.zen-workspace-actions').addEventListener(
+ 'command',
+ ((event) => {
+ let button = event.target;
+ this._contextMenuId = button.closest('toolbarbutton[zen-workspace-id]').getAttribute('zen-workspace-id');
+ const popup = button.ownerDocument.getElementById('zenWorkspaceActionsMenu');
+ popup.openPopup(button, 'after_end');
+ }).bind(browser.ZenWorkspaces)
+ );
+ element.appendChild(childs);
+ element.onclick = (async () => {
+ if (this.isReorderModeOn(browser)) {
+ return; // Return early if reorder mode is on
+ }
+ if (event.target.closest('.zen-workspace-actions')) {
+ return; // Ignore clicks on the actions button
+ }
+ const workspaceId = element.getAttribute('zen-workspace-id');
+ const workspaces = await this._workspaces();
+ const workspace = workspaces.workspaces.find((w) => w.uuid === workspaceId);
+ await this.changeWorkspace(workspace);
+ let panel = this.ownerWindow.document.getElementById('PanelUI-zen-workspaces');
+ PanelMultiView.hidePopup(panel);
+ this.ownerWindow.document.getElementById('zen-workspaces-button').removeAttribute('open');
+ }).bind(browser.ZenWorkspaces);
+ return element;
+ };
+
+ const createLastPositionDropTarget = () => {
+ const element = browser.document.createXULElement('div');
+ element.className = 'zen-workspace-last-place-drop-target';
+
+ element.addEventListener(
+ 'dragover',
+ function (event) {
+ if (this.isReorderModeOn(browser) && this.draggedElement) {
+ event.preventDefault();
+ event.dataTransfer.dropEffect = 'move';
+ }
+ }.bind(browser.ZenWorkspaces)
+ );
+
+ element.addEventListener(
+ 'dragenter',
+ function (event) {
+ if (this.isReorderModeOn(browser) && this.draggedElement) {
+ element.classList.add('dragover');
+ }
+ }.bind(browser.ZenWorkspaces)
+ );
+
+ element.addEventListener(
+ 'dragleave',
+ function (event) {
+ element.classList.remove('dragover');
+ }.bind(browser.ZenWorkspaces)
+ );
+
+ element.addEventListener(
+ 'drop',
+ async function (event) {
+ event.preventDefault();
+ element.classList.remove('dragover');
+
+ if (this.isReorderModeOn(browser)) {
+ const draggedWorkspaceId = event.dataTransfer.getData('text/plain');
+ await this.moveWorkspaceToEnd(draggedWorkspaceId);
+
+ if (this.draggedElement) {
+ this.draggedElement.classList.remove('dragging');
+ this.draggedElement = null;
+ }
+ }
+ }.bind(browser.ZenWorkspaces)
+ );
+
+ return element;
+ };
+
+ if(clearCache) {
+ browser.ZenWorkspaces._workspaceCache = null;
+ }
+ let workspaces = await browser.ZenWorkspaces._workspaces();
+ workspaceList.innerHTML = '';
+ workspaceList.parentNode.style.display = 'flex';
+ if (workspaces.workspaces.length <= 0) {
+ workspaceList.innerHTML = 'No workspaces available';
+ workspaceList.setAttribute('empty', 'true');
+ } else {
+ workspaceList.removeAttribute('empty');
+ }
+
+ for (let workspace of workspaces.workspaces) {
+ let workspaceElement = createWorkspaceElement(workspace);
+ workspaceList.appendChild(workspaceElement);
+ }
+
+ workspaceList.appendChild(createLastPositionDropTarget());
+
+ if (!ignoreStrip) {
+ await browser.ZenWorkspaces._expandWorkspacesStrip(browser);
+ }
+ });
+ }
+
+ handlePanelHidden() {
+ const workspacesList = document.getElementById('PanelUI-zen-workspaces-list');
+ const reorderModeButton = document.getElementById('PanelUI-zen-workspaces-reorder-mode');
+
+ workspacesList?.removeAttribute('reorder-mode');
+ reorderModeButton?.removeAttribute('active');
+ }
+
+ async moveWorkspaceToEnd(draggedWorkspaceId) {
+ const workspaces = (await this._workspaces()).workspaces;
+ const draggedIndex = workspaces.findIndex((w) => w.uuid === draggedWorkspaceId);
+ const draggedWorkspace = workspaces.splice(draggedIndex, 1)[0];
+ workspaces.push(draggedWorkspace);
+
+ await ZenWorkspacesStorage.updateWorkspacePositions(workspaces);
+ await this._propagateWorkspaceData();
+ }
+
+ isReorderModeOn(browser) {
+ return browser.document.getElementById('PanelUI-zen-workspaces-list').getAttribute('reorder-mode') === 'true';
+ }
+
+ toggleReorderMode() {
+ const workspacesList = document.getElementById('PanelUI-zen-workspaces-list');
+ const reorderModeButton = document.getElementById('PanelUI-zen-workspaces-reorder-mode');
+ const isActive = workspacesList.getAttribute('reorder-mode') === 'true';
+ if (isActive) {
+ workspacesList.removeAttribute('reorder-mode');
+ reorderModeButton.removeAttribute('active');
+ } else {
+ workspacesList.setAttribute('reorder-mode', 'true');
+ reorderModeButton.setAttribute('active', 'true');
+ }
+
+ // Update draggable attribute
+ const workspaceElements = document.querySelectorAll('.zen-workspace-button');
+ workspaceElements.forEach((elem) => {
+ if (isActive) {
+ elem.removeAttribute('draggable');
+ } else {
+ elem.setAttribute('draggable', 'true');
+ }
+ });
+ }
+
+ async moveWorkspace(draggedWorkspaceId, targetWorkspaceId) {
+ const workspaces = (await this._workspaces()).workspaces;
+ const draggedIndex = workspaces.findIndex((w) => w.uuid === draggedWorkspaceId);
+ const draggedWorkspace = workspaces.splice(draggedIndex, 1)[0];
+ const targetIndex = workspaces.findIndex((w) => w.uuid === targetWorkspaceId);
+ workspaces.splice(targetIndex, 0, draggedWorkspace);
+
+ await ZenWorkspacesStorage.updateWorkspacePositions(workspaces);
+ await this._propagateWorkspaceData();
+ }
+
+ async openWorkspacesDialog(event) {
+ if (!this.workspaceEnabled) {
+ return;
+ }
+ let target = document.getElementById('zen-workspaces-button');
+ let panel = document.getElementById('PanelUI-zen-workspaces');
+ await this._propagateWorkspaceData({
+ ignoreStrip: true,
+ clearCache: false
+ });
+ PanelMultiView.openPopup(panel, target, {
+ position: 'bottomright topright',
+ triggerEvent: event,
+ }).catch(console.error);
+ }
+
+ async initializeWorkspacesButton() {
+ if (!this.workspaceEnabled) {
+ return;
+ } else if (document.getElementById('zen-workspaces-button')) {
+ let button = document.getElementById('zen-workspaces-button');
+ button.removeAttribute('hidden');
+ return;
+ }
+ await this._expandWorkspacesStrip();
+ }
+
+ async _expandWorkspacesStrip(browser = window) {
+ if (typeof browser.ZenWorkspaces === 'undefined') {
+ browser = window;
+ }
+ let button = browser.document.getElementById('zen-workspaces-button');
+
+ if (!button) {
+ button = browser.document.createXULElement('toolbarbutton');
+ button.id = 'zen-workspaces-button';
+ let navbar = browser.document.getElementById('nav-bar');
+ navbar.appendChild(button);
+ }
+
+ while (button.firstChild) {
+ button.firstChild.remove();
+ }
+
+ for (let attr of [...button.attributes]) {
+ if (attr.name !== 'id') {
+ button.removeAttribute(attr.name);
+ }
+ }
+
+ button.className = '';
+
+ if (this._workspacesButtonClickListener) {
+ button.removeEventListener('click', this._workspacesButtonClickListener);
+ this._workspacesButtonClickListener = null;
+ }
+ if (this._workspaceButtonContextMenuListener) {
+ button.removeEventListener('contextmenu', this._workspaceButtonContextMenuListener);
+ this._workspaceButtonContextMenuListener = null;
+ }
+
+ button.setAttribute('removable', 'true');
+ button.setAttribute('showInPrivateBrowsing', 'false');
+ button.setAttribute('tooltiptext', 'Workspaces');
+ if (this.shouldShowIconStrip) {
+ let workspaces = await this._workspaces();
+
+ for (let workspace of workspaces.workspaces) {
+ let workspaceButton = browser.document.createXULElement('toolbarbutton');
+ workspaceButton.className = 'subviewbutton';
+ workspaceButton.setAttribute('tooltiptext', workspace.name);
+ workspaceButton.setAttribute('zen-workspace-id', workspace.uuid);
+
+ if (this.isWorkspaceActive(workspace)) {
+ workspaceButton.setAttribute('active', 'true');
+ } else {
+ workspaceButton.removeAttribute('active');
+ }
+ if (workspace.default) {
+ workspaceButton.setAttribute('default', 'true');
+ } else {
+ workspaceButton.removeAttribute('default');
+ }
+
+ workspaceButton.addEventListener('click', async (event) => {
+ if (event.button !== 0) {
+ return;
+ }
+ await this.changeWorkspace(workspace);
+ });
+
+ let icon = browser.document.createXULElement('div');
+ icon.className = 'zen-workspace-icon';
+ icon.textContent = this.getWorkspaceIcon(workspace);
+ workspaceButton.appendChild(icon);
+ button.appendChild(workspaceButton);
+ }
+
+ this._workspaceButtonContextMenuListener = (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ this.openWorkspacesDialog(event);
+ };
+ button.addEventListener('contextmenu', this._workspaceButtonContextMenuListener.bind(browser.ZenWorkspaces));
+ } else {
+ let activeWorkspace = await this.getActiveWorkspace();
+ if (activeWorkspace) {
+ button.setAttribute('as-button', 'true');
+ button.classList.add('toolbarbutton-1', 'zen-sidebar-action-button');
+
+ this._workspacesButtonClickListener = browser.ZenWorkspaces.openWorkspacesDialog.bind(browser.ZenWorkspaces);
+ button.addEventListener('click', this._workspacesButtonClickListener);
+
+ const wrapper = browser.document.createXULElement('hbox');
+ wrapper.className = 'zen-workspace-sidebar-wrapper';
+
+ const icon = browser.document.createXULElement('div');
+ icon.className = 'zen-workspace-sidebar-icon';
+ icon.textContent = this.getWorkspaceIcon(activeWorkspace);
+
+ const name = browser.document.createXULElement('div');
+ name.className = 'zen-workspace-sidebar-name';
+ name.textContent = activeWorkspace.name;
+
+ if (!this.workspaceHasIcon(activeWorkspace)) {
+ icon.setAttribute('no-icon', 'true');
+ }
+
+ wrapper.appendChild(icon);
+ wrapper.appendChild(name);
+
+ button.appendChild(wrapper);
+ }
+ }
+ }
+
+ closeWorkspacesSubView() {
+ let parentPanel = document.getElementById('PanelUI-zen-workspaces-multiview');
+ parentPanel.goBack(parentPanel);
+ }
+
+ // Workspaces management
+
+ get _workspaceCreateInput() {
+ return document.getElementById('PanelUI-zen-workspaces-create-input');
+ }
+
+ get _workspaceEditDialog() {
+ return document.getElementById('PanelUI-zen-workspaces-edit');
+ }
+
+ get _workspaceEditInput() {
+ return document.getElementById('PanelUI-zen-workspaces-edit-input');
+ }
+
+ get _workspaceEditIconsContainer() {
+ return document.getElementById('PanelUI-zen-workspaces-icon-picker');
+ }
+
+ _deleteAllTabsInWorkspace(workspaceID) {
+ for (let tab of gBrowser.tabs) {
+ if (tab.getAttribute('zen-workspace-id') === workspaceID) {
+ gBrowser.removeTab(tab, {
+ animate: true,
+ skipSessionStore: true,
+ closeWindowWithLastTab: false,
+ });
+ }
+ }
+ }
+
+ _prepareNewWorkspace(window) {
+ document.documentElement.setAttribute('zen-workspace-id', window.uuid);
+ let tabCount = 0;
+ for (let tab of gBrowser.tabs) {
+ if (!tab.hasAttribute('zen-workspace-id') && !tab.pinned) {
+ tab.setAttribute('zen-workspace-id', window.uuid);
+ tabCount++;
+ }
+ }
+ if (tabCount === 0) {
+ this._createNewTabForWorkspace(window);
+ }
+ }
+
+ _createNewTabForWorkspace(window) {
+ let tab = gZenUIManager.openAndChangeToTab(Services.prefs.getStringPref('browser.startup.homepage'));
+
+ if(window.uuid){
+ tab.setAttribute('zen-workspace-id', window.uuid);
+ }
+ }
+
+ async saveWorkspaceFromCreate() {
+ let workspaceName = this._workspaceCreateInput.value;
+ if (!workspaceName) {
+ return;
+ }
+ this._workspaceCreateInput.value = '';
+ let icon = document.querySelector('#PanelUI-zen-workspaces-icon-picker-wrapper [selected]');
+ icon?.removeAttribute('selected');
+ await this.createAndSaveWorkspace(workspaceName, false, icon?.label);
+ this.goToPreviousSubView();
+ }
+
+ async saveWorkspaceFromEdit() {
+ let workspaceUuid = this._workspaceEditDialog.getAttribute('data-workspace-uuid');
+ let workspaceName = this._workspaceEditInput.value;
+ if (!workspaceName) {
+ return;
+ }
+ this._workspaceEditInput.value = '';
+ let icon = document.querySelector('#PanelUI-zen-workspaces-icon-picker-wrapper [selected]');
+ icon?.removeAttribute('selected');
+ let workspaces = (await this._workspaces()).workspaces;
+ let workspaceData = workspaces.find((workspace) => workspace.uuid === workspaceUuid);
+ workspaceData.name = workspaceName;
+ workspaceData.icon = icon?.label;
+ await this.saveWorkspace(workspaceData);
+ this.goToPreviousSubView();
+ }
+
+ onWorkspaceCreationNameChange(event) {
+ let button = document.getElementById('PanelUI-zen-workspaces-create-save');
+ if (this._workspaceCreateInput.value === '') {
+ button.setAttribute('disabled', 'true');
+ return;
+ }
+ button.removeAttribute('disabled');
+ }
+
+ onWorkspaceEditChange(icon) {
+ let button = document.getElementById('PanelUI-zen-workspaces-edit-save');
+ let name = this._workspaceEditInput.value;
+ if (
+ name === this._workspaceEditInput.getAttribute('data-initial-value') &&
+ icon === this._workspaceEditIconsContainer.getAttribute('data-initial-value')
+ ) {
+ button.setAttribute('disabled', 'true');
+ return;
+ }
+ button.removeAttribute('disabled');
+ }
+
+ addChangeListeners(func) {
+ if (!this._changeListeners) {
+ this._changeListeners = [];
+ }
+ this._changeListeners.push(func);
+ }
+
+ async changeWorkspace(window, onInit = false) {
+ if (!this.workspaceEnabled || this._inChangingWorkspace) {
+ return;
+ }
+
+ this._inChangingWorkspace = true;
+ this.activeWorkspace = window.uuid;
+
+ this.tabContainer._invalidateCachedTabs();
+ let firstTab = undefined;
+ for (let tab of gBrowser.tabs) {
+ if (tab.getAttribute('zen-workspace-id') === window.uuid || !tab.hasAttribute('zen-workspace-id')
+ ) {
+ if (!firstTab && (onInit || !tab.pinned)) {
+ firstTab = tab;
+ } else if (gBrowser.selectedTab === tab) {
+ // If the selected tab is already in the workspace, we don't want to change it
+ firstTab = null; // note: Do not add "undefined" here, a new tab would be created
+ }
+ gBrowser.showTab(tab);
+ if (!tab.hasAttribute('zen-workspace-id') && !tab.pinned) {
+ // We add the id to those tabs that got inserted before we initialize the workspaces
+ // example use case: opening a link from an external app
+ tab.setAttribute('zen-workspace-id', window.uuid);
+ }
+ }
+ }
+ if (firstTab) {
+ gBrowser.selectedTab = this._lastSelectedWorkspaceTabs[window.uuid] ?? firstTab;
+ }
+ if (typeof firstTab === 'undefined' && !onInit) {
+ this._createNewTabForWorkspace(window);
+ }
+ for (let tab of gBrowser.tabs) {
+ if (tab.getAttribute('zen-workspace-id') !== window.uuid && !(tab.pinned && !tab.hasAttribute('zen-workspace-id'))) {
+ gBrowser.hideTab(tab, undefined, true);
+ }
+ }
+ this.tabContainer._invalidateCachedTabs();
+ document.documentElement.setAttribute('zen-workspace-id', window.uuid);
+ await this._updateWorkspacesChangeContextMenu();
+
+ document.getElementById('tabbrowser-tabs')._positionPinnedTabs();
+
+ await this._propagateWorkspaceData({clearCache: onInit});
+ for (let listener of this._changeListeners ?? []) {
+ listener(window);
+ }
+ // reset bookmark toolbars
+ const placesToolbar = document.getElementById("PlacesToolbar");
+ if(placesToolbar?._placesView) {
+ placesToolbar._placesView.invalidateContainer(placesToolbar._placesView._resultNode);
+ }
+
+ const essentialsToolbar = document.getElementById("EssentialsToolbar");
+ if(essentialsToolbar?._placesView) {
+ essentialsToolbar._placesView.invalidateContainer(essentialsToolbar._placesView._resultNode);
+ }
+
+
+ this._inChangingWorkspace = false;
+ }
+
+ async _updateWorkspacesChangeContextMenu() {
+ const workspaces = await this._workspaces();
+
+ const menuPopup = document.getElementById('context-zen-change-workspace-tab-menu-popup');
+ if (!menuPopup) {
+ return;
+ }
+ menuPopup.innerHTML = '';
+
+ const activeWorkspace = await this.getActiveWorkspace();
+
+ for (let workspace of workspaces.workspaces) {
+ const menuItem = document.createXULElement('menuitem');
+ menuItem.setAttribute('label', workspace.name);
+ menuItem.setAttribute('zen-workspace-id', workspace.uuid);
+
+ if (workspace.uuid === activeWorkspace.uuid) {
+ menuItem.setAttribute('disabled', 'true');
+ }
+
+ menuPopup.appendChild(menuItem);
+ }
+ }
+
+ _createWorkspaceData(name, isDefault, icon) {
+ let window = {
+ uuid: gZenUIManager.generateUuidv4(),
+ default: isDefault,
+ icon: icon,
+ name: name,
+ };
+ this._prepareNewWorkspace(window);
+ return window;
+ }
+
+ async createAndSaveWorkspace(name = 'New Workspace', isDefault = false, icon = undefined) {
+ if (!this.workspaceEnabled) {
+ return;
+ }
+ let workspaceData = this._createWorkspaceData(name, isDefault, icon);
+ await this.saveWorkspace(workspaceData);
+ await this.changeWorkspace(workspaceData);
+ }
+
+ async onTabBrowserInserted(event) {
+ let tab = event.originalTarget;
+ if (tab.getAttribute('zen-workspace-id') || !this.workspaceEnabled) {
+ return;
+ }
+
+ let activeWorkspace = await this.getActiveWorkspace();
+ if (!activeWorkspace) {
+ return;
+ }
+ tab.setAttribute('zen-workspace-id', activeWorkspace.uuid);
+ }
+
+ async onLocationChange(browser) {
+ if (!this.workspaceEnabled || this._inChangingWorkspace) {
+ return;
+ }
+ const parent = browser.ownerGlobal;
+ let tab = gBrowser.getTabForBrowser(browser);
+ let workspaceID = tab.getAttribute('zen-workspace-id');
+ if (!workspaceID || tab.pinned) {
+ return;
+ }
+ let activeWorkspace = await parent.ZenWorkspaces.getActiveWorkspace();
+ this._lastSelectedWorkspaceTabs[workspaceID] = tab;
+ if (workspaceID === activeWorkspace.uuid) {
+ return;
+ }
+ await parent.ZenWorkspaces.changeWorkspace({ uuid: workspaceID });
+ }
+
+ // Context menu management
+
+ _contextMenuId = null;
+ async updateContextMenu(_) {
+ console.assert(this._contextMenuId, 'No context menu ID set');
+ document
+ .querySelector(`#PanelUI-zen-workspaces [zen-workspace-id="${this._contextMenuId}"] .zen-workspace-actions`)
+ .setAttribute('active', 'true');
+ const workspaces = await this._workspaces();
+ let deleteMenuItem = document.getElementById('context_zenDeleteWorkspace');
+ if (
+ workspaces.workspaces.length <= 1 ||
+ workspaces.workspaces.find((workspace) => workspace.uuid === this._contextMenuId).default
+ ) {
+ deleteMenuItem.setAttribute('disabled', 'true');
+ } else {
+ deleteMenuItem.removeAttribute('disabled');
+ }
+ let defaultMenuItem = document.getElementById('context_zenSetAsDefaultWorkspace');
+ if (workspaces.workspaces.find((workspace) => workspace.uuid === this._contextMenuId).default) {
+ defaultMenuItem.setAttribute('disabled', 'true');
+ } else {
+ defaultMenuItem.removeAttribute('disabled');
+ }
+ let openMenuItem = document.getElementById('context_zenOpenWorkspace');
+ if (
+ workspaces.workspaces.find((workspace) => workspace.uuid === this._contextMenuId && this.isWorkspaceActive(workspace))
+ ) {
+ openMenuItem.setAttribute('disabled', 'true');
+ } else {
+ openMenuItem.removeAttribute('disabled');
+ }
+ const openInContainerMenuItem = document.getElementById('context_zenWorkspacesOpenInContainerTab');
+ if (this.shouldShowContainers) {
+ openInContainerMenuItem.removeAttribute('hidden');
+ } else {
+ openInContainerMenuItem.setAttribute('hidden', 'true');
+ }
+ }
+
+ async contextChangeContainerTab(event) {
+ let workspaces = await this._workspaces();
+ let workspace = workspaces.workspaces.find((workspace) => workspace.uuid === this._contextMenuId);
+ let userContextId = parseInt(event.target.getAttribute('data-usercontextid'));
+ workspace.containerTabId = userContextId;
+ await this.saveWorkspace(workspace);
+ }
+
+ onContextMenuClose() {
+ let target = document.querySelector(
+ `#PanelUI-zen-workspaces [zen-workspace-id="${this._contextMenuId}"] .zen-workspace-actions`
+ );
+ if (target) {
+ target.removeAttribute('active');
+ }
+ this._contextMenuId = null;
+ }
+
+ async setDefaultWorkspace() {
+ await ZenWorkspacesStorage.setDefaultWorkspace(this._contextMenuId);
+ await this._propagateWorkspaceData();
+ }
+
+ async openWorkspace() {
+ let workspaces = await this._workspaces();
+ let workspace = workspaces.workspaces.find((workspace) => workspace.uuid === this._contextMenuId);
+ await this.changeWorkspace(workspace);
+ }
+
+ async contextDelete(event) {
+ this.__contextIsDelete = true;
+ event.stopPropagation();
+ await this.removeWorkspace(this._contextMenuId);
+ this.__contextIsDelete = false;
+ }
+
+ async contextEdit(event) {
+ event.stopPropagation();
+ await this.openEditDialog(this._contextMenuId);
+ }
+
+ async changeWorkspaceShortcut(offset = 1) {
+ // Cycle through workspaces
+ let workspaces = await this._workspaces();
+ let activeWorkspace = await this.getActiveWorkspace();
+ let workspaceIndex = workspaces.workspaces.indexOf(activeWorkspace);
+ // note: offset can be negative
+ let nextWorkspace =
+ workspaces.workspaces[(workspaceIndex + offset + workspaces.workspaces.length) % workspaces.workspaces.length];
+ await this.changeWorkspace(nextWorkspace);
+ }
+
+ _initializeWorkspaceTabContextMenus() {
+ const menu = document.createXULElement('menu');
+ menu.setAttribute('id', 'context-zen-change-workspace-tab');
+ menu.setAttribute('data-l10n-id', 'context-zen-change-workspace-tab');
+
+ const menuPopup = document.createXULElement('menupopup');
+ menuPopup.setAttribute('id', 'context-zen-change-workspace-tab-menu-popup');
+ menuPopup.setAttribute('oncommand', "ZenWorkspaces.changeTabWorkspace(event.target.getAttribute('zen-workspace-id'))");
+
+ menu.appendChild(menuPopup);
+
+ document.getElementById('context_closeDuplicateTabs').after(menu);
+ }
+
+ async changeTabWorkspace(workspaceID) {
+ const tabs = TabContextMenu.contextTab.multiselected ? gBrowser.selectedTabs : [TabContextMenu.contextTab];
+ const previousWorkspaceID = document.documentElement.getAttribute('zen-workspace-id');
+ for (let tab of tabs) {
+ tab.setAttribute('zen-workspace-id', workspaceID);
+ if (this._lastSelectedWorkspaceTabs[previousWorkspaceID] === tab) {
+ // This tab is no longer the last selected tab in the previous workspace because it's being moved to
+ // the current workspace
+ delete this._lastSelectedWorkspaceTabs[previousWorkspaceID];
+ }
+ }
+ const workspaces = await this._workspaces();
+ await this.changeWorkspace(workspaces.workspaces.find((workspace) => workspace.uuid === workspaceID));
+ }
+
+ // Tab browser utilities
+ createContainerTabMenu(event) {
+ let window = event.target.ownerGlobal;
+ const workspace = this._workspaceCache.workspaces.find((workspace) => this._contextMenuId === workspace.uuid);
+ let containerTabId = workspace.containerTabId;
+ return window.createUserContextMenu(event, {
+ isContextMenu: true,
+ excludeUserContextId: containerTabId,
+ showDefaultTab: true,
+ });
+ }
+
+ getContextIdIfNeeded(userContextId, fromExternal, allowInheritPrincipal) {
+ if (!this.workspaceEnabled) {
+ return [userContextId, false];
+ }
+
+ if (this.shouldForceContainerTabsToWorkspace && typeof userContextId !== 'undefined' && this._workspaceCache?.workspaces) {
+ // Find all workspaces that match the given userContextId
+ const matchingWorkspaces = this._workspaceCache.workspaces.filter((workspace) => workspace.containerTabId === userContextId);
+
+ // Check if exactly one workspace matches
+ if (matchingWorkspaces.length === 1) {
+ const workspace = matchingWorkspaces[0];
+ if (workspace.uuid !== this.getActiveWorkspaceFromCache().uuid) {
+ this.changeWorkspace(workspace);
+ return [userContextId, true];
+ }
+ }
+ }
+
+ const activeWorkspace = this.getActiveWorkspaceFromCache();
+ const activeWorkspaceUserContextId = activeWorkspace?.containerTabId;
+
+ if ((fromExternal || allowInheritPrincipal === false) && !!activeWorkspaceUserContextId) {
+ return [activeWorkspaceUserContextId, true];
+ }
+
+ if (typeof userContextId !== 'undefined' && userContextId !== activeWorkspaceUserContextId) {
+ return [userContextId, false];
+ }
+ return [activeWorkspaceUserContextId, true];
+ }
+
+ async shortcutSwitchTo(index) {
+ const workspaces = await this._workspaces();
+ // The index may be out of bounds, if it doesnt exist, don't do anything
+ if (index >= workspaces.workspaces.length || index < 0) {
+ return;
+ }
+ const workspaceToSwitch = workspaces.workspaces[index];
+ await this.changeWorkspace(workspaceToSwitch);
+ }
+
+ isBookmarkInAnotherWorkspace(bookmark) {
+ let tags = bookmark.tags;
+ // if any tag starts with "_workspace_id" and the workspace id doesnt match the active workspace id, return null
+ if (tags) {
+ for (let tag of tags.split(",")) {
+ return !!(tag.startsWith("zen_workspace_") && this.getActiveWorkspaceFromCache()?.uuid !== tag.split("_")[2]);
+ }
+ }
+ }
+})();
diff --git a/src/browser/base/zen-components/ZenWorkspacesStorage.mjs b/src/browser/base/zen-components/ZenWorkspacesStorage.mjs
new file mode 100644
index 000000000..2b6c9005e
--- /dev/null
+++ b/src/browser/base/zen-components/ZenWorkspacesStorage.mjs
@@ -0,0 +1,400 @@
+var ZenWorkspacesStorage = {
+ async init() {
+ console.log('ZenWorkspacesStorage: Initializing...');
+ await this._ensureTable();
+ },
+
+ async _ensureTable() {
+ await PlacesUtils.withConnectionWrapper('ZenWorkspacesStorage._ensureTable', async (db) => {
+ // Create the main workspaces table if it doesn't exist
+ await db.execute(`
+ CREATE TABLE IF NOT EXISTS zen_workspaces (
+ id INTEGER PRIMARY KEY,
+ uuid TEXT UNIQUE NOT NULL,
+ name TEXT NOT NULL,
+ icon TEXT,
+ is_default INTEGER NOT NULL DEFAULT 0,
+ container_id INTEGER,
+ position INTEGER NOT NULL DEFAULT 0,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+ )
+ `);
+
+ // Add new columns if they don't exist
+ // SQLite doesn't have a direct "ADD COLUMN IF NOT EXISTS" syntax,
+ // so we need to check if the columns exist first
+ const columns = await db.execute(`PRAGMA table_info(zen_workspaces)`);
+ const columnNames = columns.map(row => row.getResultByName('name'));
+
+ // Helper function to add column if it doesn't exist
+ const addColumnIfNotExists = async (columnName, definition) => {
+ if (!columnNames.includes(columnName)) {
+ await db.execute(`ALTER TABLE zen_workspaces ADD COLUMN ${columnName} ${definition}`);
+ }
+ };
+
+ // Add each new column if it doesn't exist
+ await addColumnIfNotExists('theme_type', 'TEXT');
+ await addColumnIfNotExists('theme_colors', 'TEXT');
+ await addColumnIfNotExists('theme_opacity', 'REAL');
+ await addColumnIfNotExists('theme_rotation', 'INTEGER');
+ await addColumnIfNotExists('theme_texture', 'REAL');
+
+ // Create an index on the uuid column
+ await db.execute(`
+ CREATE INDEX IF NOT EXISTS idx_zen_workspaces_uuid ON zen_workspaces(uuid)
+ `);
+
+ // Create the changes tracking table if it doesn't exist
+ await db.execute(`
+ CREATE TABLE IF NOT EXISTS zen_workspaces_changes (
+ uuid TEXT PRIMARY KEY,
+ timestamp INTEGER NOT NULL
+ )
+ `);
+
+ // Create an index on the uuid column for changes tracking table
+ await db.execute(`
+ CREATE INDEX IF NOT EXISTS idx_zen_workspaces_changes_uuid ON zen_workspaces_changes(uuid)
+ `);
+ });
+ },
+
+ async migrateWorkspacesFromJSON() {
+ const oldWorkspacesPath = PathUtils.join(PathUtils.profileDir, 'zen-workspaces', 'Workspaces.json');
+ if (await IOUtils.exists(oldWorkspacesPath)) {
+ console.info('ZenWorkspacesStorage: Migrating workspaces from JSON...');
+ const oldWorkspaces = await IOUtils.readJSON(oldWorkspacesPath);
+ if (oldWorkspaces.workspaces) {
+ for (const workspace of oldWorkspaces.workspaces) {
+ await this.saveWorkspace(workspace);
+ }
+ }
+ await IOUtils.remove(oldWorkspacesPath);
+ }
+ },
+
+ /**
+ * Private helper method to notify observers with a list of changed UUIDs.
+ * @param {string} event - The observer event name.
+ * @param {Array} uuids - Array of changed workspace UUIDs.
+ */
+ _notifyWorkspacesChanged(event, uuids) {
+ if (uuids.length === 0) return; // No changes to notify
+
+ // Convert the array of UUIDs to a JSON string
+ const data = JSON.stringify(uuids);
+
+ Services.obs.notifyObservers(null, event, data);
+ },
+
+ async saveWorkspace(workspace, notifyObservers = true) {
+ const changedUUIDs = new Set();
+
+ await PlacesUtils.withConnectionWrapper('ZenWorkspacesStorage.saveWorkspace', async (db) => {
+ await db.executeTransaction(async () => {
+ const now = Date.now();
+
+ // Handle default workspace
+ if (workspace.default) {
+ await db.execute(`UPDATE zen_workspaces SET is_default = 0 WHERE uuid != :uuid`, { uuid: workspace.uuid });
+ const unsetDefaultRows = await db.execute(`SELECT uuid FROM zen_workspaces WHERE is_default = 0 AND uuid != :uuid`, { uuid: workspace.uuid });
+ for (const row of unsetDefaultRows) {
+ changedUUIDs.add(row.getResultByName('uuid'));
+ }
+ }
+
+ let newPosition;
+ if ('position' in workspace && Number.isFinite(workspace.position)) {
+ newPosition = workspace.position;
+ } else {
+ // Get the maximum position
+ const maxPositionResult = await db.execute(`SELECT MAX("position") as max_position FROM zen_workspaces`);
+ const maxPosition = maxPositionResult[0].getResultByName('max_position') || 0;
+ newPosition = maxPosition + 1000; // Add a large increment to avoid frequent reordering
+ }
+
+ // Insert or replace the workspace
+ await db.executeCached(`
+ INSERT OR REPLACE INTO zen_workspaces (
+ uuid, name, icon, is_default, container_id, created_at, updated_at, "position",
+ theme_type, theme_colors, theme_opacity, theme_rotation, theme_texture
+ ) VALUES (
+ :uuid, :name, :icon, :is_default, :container_id,
+ COALESCE((SELECT created_at FROM zen_workspaces WHERE uuid = :uuid), :now),
+ :now,
+ :position,
+ :theme_type, :theme_colors, :theme_opacity, :theme_rotation, :theme_texture
+ )
+ `, {
+ uuid: workspace.uuid,
+ name: workspace.name,
+ icon: workspace.icon || null,
+ is_default: workspace.default ? 1 : 0,
+ container_id: workspace.containerTabId || null,
+ now,
+ position: newPosition,
+ theme_type: workspace.theme?.type || null,
+ theme_colors: workspace.theme ? JSON.stringify(workspace.theme.gradientColors) : null,
+ theme_opacity: workspace.theme?.opacity || null,
+ theme_rotation: workspace.theme?.rotation || null,
+ theme_texture: workspace.theme?.texture || null
+ });
+
+ // Record the change
+ await db.execute(`
+ INSERT OR REPLACE INTO zen_workspaces_changes (uuid, timestamp)
+ VALUES (:uuid, :timestamp)
+ `, {
+ uuid: workspace.uuid,
+ timestamp: Math.floor(now / 1000)
+ });
+
+ changedUUIDs.add(workspace.uuid);
+
+ await this.updateLastChangeTimestamp(db);
+ });
+ });
+
+ if (notifyObservers) {
+ this._notifyWorkspacesChanged("zen-workspace-updated", Array.from(changedUUIDs));
+ }
+ },
+
+ async getWorkspaces() {
+ const db = await PlacesUtils.promiseDBConnection();
+ const rows = await db.executeCached(`
+ SELECT * FROM zen_workspaces ORDER BY created_at ASC
+ `);
+ return rows.map((row) => ({
+ uuid: row.getResultByName('uuid'),
+ name: row.getResultByName('name'),
+ icon: row.getResultByName('icon'),
+ default: !!row.getResultByName('is_default'),
+ containerTabId: row.getResultByName('container_id'),
+ position: row.getResultByName('position'),
+ theme: row.getResultByName('theme_type') ? {
+ type: row.getResultByName('theme_type'),
+ gradientColors: JSON.parse(row.getResultByName('theme_colors')),
+ opacity: row.getResultByName('theme_opacity'),
+ rotation: row.getResultByName('theme_rotation'),
+ texture: row.getResultByName('theme_texture')
+ } : null
+ }));
+ },
+
+ async removeWorkspace(uuid, notifyObservers = true) {
+ const changedUUIDs = [uuid];
+
+ await PlacesUtils.withConnectionWrapper('ZenWorkspacesStorage.removeWorkspace', async (db) => {
+ await db.execute(
+ `
+ DELETE FROM zen_workspaces WHERE uuid = :uuid
+ `,
+ { uuid }
+ );
+
+ // Record the removal as a change
+ const now = Date.now();
+ await db.execute(`
+ INSERT OR REPLACE INTO zen_workspaces_changes (uuid, timestamp)
+ VALUES (:uuid, :timestamp)
+ `, {
+ uuid,
+ timestamp: Math.floor(now / 1000)
+ });
+
+ await this.updateLastChangeTimestamp(db);
+ });
+
+ if (notifyObservers) {
+ this._notifyWorkspacesChanged("zen-workspace-removed", changedUUIDs);
+ }
+ },
+
+ async wipeAllWorkspaces() {
+ await PlacesUtils.withConnectionWrapper('ZenWorkspacesStorage.wipeAllWorkspaces', async (db) => {
+ await db.execute(`DELETE FROM zen_workspaces`);
+ await db.execute(`DELETE FROM zen_workspaces_changes`);
+ await this.updateLastChangeTimestamp(db);
+ });
+ },
+
+ async setDefaultWorkspace(uuid, notifyObservers = true) {
+ const changedUUIDs = [];
+
+ await PlacesUtils.withConnectionWrapper('ZenWorkspacesStorage.setDefaultWorkspace', async (db) => {
+ await db.executeTransaction(async () => {
+ const now = Date.now();
+ // Unset the default flag for all other workspaces
+ await db.execute(`UPDATE zen_workspaces SET is_default = 0 WHERE uuid != :uuid`, { uuid });
+
+ // Collect UUIDs of workspaces that were unset as default
+ const unsetDefaultRows = await db.execute(`SELECT uuid FROM zen_workspaces WHERE is_default = 0 AND uuid != :uuid`, { uuid });
+ for (const row of unsetDefaultRows) {
+ changedUUIDs.push(row.getResultByName('uuid'));
+ }
+
+ // Set the default flag for the specified workspace
+ await db.execute(`UPDATE zen_workspaces SET is_default = 1 WHERE uuid = :uuid`, { uuid });
+
+ // Record the change for the specified workspace
+ await db.execute(`
+ INSERT OR REPLACE INTO zen_workspaces_changes (uuid, timestamp)
+ VALUES (:uuid, :timestamp)
+ `, {
+ uuid,
+ timestamp: Math.floor(now / 1000)
+ });
+
+ // Add the main workspace UUID to the changed set
+ changedUUIDs.push(uuid);
+
+ await this.updateLastChangeTimestamp(db);
+ });
+ });
+
+ if (notifyObservers) {
+ this._notifyWorkspacesChanged("zen-workspace-updated", changedUUIDs);
+ }
+ },
+
+ async markChanged(uuid) {
+ await PlacesUtils.withConnectionWrapper('ZenWorkspacesStorage.markChanged', async (db) => {
+ const now = Date.now();
+ await db.execute(`
+ INSERT OR REPLACE INTO zen_workspaces_changes (uuid, timestamp)
+ VALUES (:uuid, :timestamp)
+ `, {
+ uuid,
+ timestamp: Math.floor(now / 1000)
+ });
+ });
+ },
+
+ async saveWorkspaceTheme(uuid, theme, notifyObservers = true) {
+ const changedUUIDs = [uuid];
+ await PlacesUtils.withConnectionWrapper('saveWorkspaceTheme', async (db) => {
+ await db.execute(`
+ UPDATE zen_workspaces
+ SET
+ theme_type = :type,
+ theme_colors = :colors,
+ theme_opacity = :opacity,
+ theme_rotation = :rotation,
+ theme_texture = :texture,
+ updated_at = :now
+ WHERE uuid = :uuid
+ `, {
+ type: theme.type,
+ colors: JSON.stringify(theme.gradientColors),
+ opacity: theme.opacity,
+ rotation: theme.rotation,
+ texture: theme.texture,
+ now: Date.now(),
+ uuid
+ });
+
+ await this.markChanged(uuid);
+ await this.updateLastChangeTimestamp(db);
+ });
+
+ if (notifyObservers) {
+ this._notifyWorkspacesChanged("zen-workspace-updated", changedUUIDs);
+ }
+ },
+
+ async getChangedIDs() {
+ const db = await PlacesUtils.promiseDBConnection();
+ const rows = await db.execute(`
+ SELECT uuid, timestamp FROM zen_workspaces_changes
+ `);
+ const changes = {};
+ for (const row of rows) {
+ changes[row.getResultByName('uuid')] = row.getResultByName('timestamp');
+ }
+ return changes;
+ },
+
+ async clearChangedIDs() {
+ await PlacesUtils.withConnectionWrapper('ZenWorkspacesStorage.clearChangedIDs', async (db) => {
+ await db.execute(`DELETE FROM zen_workspaces_changes`);
+ });
+ },
+
+ shouldReorderWorkspaces(before, current, after) {
+ const minGap = 1; // Minimum allowed gap between positions
+ return (before !== null && current - before < minGap) || (after !== null && after - current < minGap);
+ },
+
+ async reorderAllWorkspaces(db, changedUUIDs) {
+ const workspaces = await db.execute(`
+ SELECT uuid
+ FROM zen_workspaces
+ ORDER BY "position" ASC
+ `);
+
+ for (let i = 0; i < workspaces.length; i++) {
+ const newPosition = (i + 1) * 1000; // Use large increments
+ await db.execute(`
+ UPDATE zen_workspaces
+ SET "position" = :newPosition
+ WHERE uuid = :uuid
+ `, { newPosition, uuid: workspaces[i].getResultByName('uuid') });
+ changedUUIDs.add(workspaces[i].getResultByName('uuid'));
+ }
+ },
+
+ async updateLastChangeTimestamp(db) {
+ const now = Date.now();
+ await db.execute(`
+ INSERT OR REPLACE INTO moz_meta (key, value)
+ VALUES ('zen_workspaces_last_change', :now)
+ `, { now });
+ },
+
+ async getLastChangeTimestamp() {
+ const db = await PlacesUtils.promiseDBConnection();
+ const result = await db.executeCached(`
+ SELECT value FROM moz_meta WHERE key = 'zen_workspaces_last_change'
+ `);
+ return result.length ? parseInt(result[0].getResultByName('value'), 10) : 0;
+ },
+
+ async updateWorkspacePositions(workspaces) {
+ const changedUUIDs = new Set();
+
+ await PlacesUtils.withConnectionWrapper('ZenWorkspacesStorage.updateWorkspacePositions', async (db) => {
+ await db.executeTransaction(async () => {
+ const now = Date.now();
+
+ for (let i = 0; i < workspaces.length; i++) {
+ const workspace = workspaces[i];
+ const newPosition = (i + 1) * 1000;
+
+ await db.execute(`
+ UPDATE zen_workspaces
+ SET "position" = :newPosition
+ WHERE uuid = :uuid
+ `, { newPosition, uuid: workspace.uuid });
+
+ changedUUIDs.add(workspace.uuid);
+
+ // Record the change
+ await db.execute(`
+ INSERT OR REPLACE INTO zen_workspaces_changes (uuid, timestamp)
+ VALUES (:uuid, :timestamp)
+ `, {
+ uuid: workspace.uuid,
+ timestamp: Math.floor(now / 1000)
+ });
+ }
+
+ await this.updateLastChangeTimestamp(db);
+ });
+ });
+
+ this._notifyWorkspacesChanged("zen-workspace-updated", Array.from(changedUUIDs));
+ },
+};
diff --git a/src/browser/base/zen-components/ZenWorkspacesSync.mjs b/src/browser/base/zen-components/ZenWorkspacesSync.mjs
new file mode 100644
index 000000000..822df6b21
--- /dev/null
+++ b/src/browser/base/zen-components/ZenWorkspacesSync.mjs
@@ -0,0 +1,452 @@
+var { Tracker, Store, SyncEngine } = ChromeUtils.importESModule("resource://services-sync/engines.sys.mjs");
+var { CryptoWrapper } = ChromeUtils.importESModule("resource://services-sync/record.sys.mjs");
+var { Utils } = ChromeUtils.importESModule("resource://services-sync/util.sys.mjs");
+var { SCORE_INCREMENT_XLARGE } = ChromeUtils.importESModule("resource://services-sync/constants.sys.mjs");
+
+
+
+// Define ZenWorkspaceRecord
+function ZenWorkspaceRecord(collection, id) {
+ CryptoWrapper.call(this, collection, id);
+}
+
+ZenWorkspaceRecord.prototype = Object.create(CryptoWrapper.prototype);
+ZenWorkspaceRecord.prototype.constructor = ZenWorkspaceRecord;
+
+ZenWorkspaceRecord.prototype._logName = "Sync.Record.ZenWorkspace";
+
+Utils.deferGetSet(ZenWorkspaceRecord, "cleartext", [
+ "name",
+ "icon",
+ "default",
+ "containerTabId",
+ "position",
+ "theme_type",
+ "theme_colors",
+ "theme_opacity",
+ "theme_rotation",
+ "theme_texture"
+]);
+
+// Define ZenWorkspacesStore
+function ZenWorkspacesStore(name, engine) {
+ Store.call(this, name, engine);
+}
+
+ZenWorkspacesStore.prototype = Object.create(Store.prototype);
+ZenWorkspacesStore.prototype.constructor = ZenWorkspacesStore;
+
+/**
+ * Initializes the store by loading the current changeset.
+ */
+ZenWorkspacesStore.prototype.initialize = async function () {
+ await Store.prototype.initialize.call(this);
+ // Additional initialization if needed
+};
+
+/**
+ * Retrieves all workspace IDs from the storage.
+ * @returns {Object} An object mapping workspace UUIDs to true.
+ */
+ZenWorkspacesStore.prototype.getAllIDs = async function () {
+ try {
+ const workspaces = await ZenWorkspacesStorage.getWorkspaces();
+ const ids = {};
+ for (const workspace of workspaces) {
+ ids[workspace.uuid] = true;
+ }
+ return ids;
+ } catch (error) {
+ this._log.error("Error fetching all workspace IDs", error);
+ throw error;
+ }
+};
+
+/**
+ * Handles changing the ID of a workspace.
+ * @param {String} oldID - The old UUID.
+ * @param {String} newID - The new UUID.
+ */
+ZenWorkspacesStore.prototype.changeItemID = async function (oldID, newID) {
+ try {
+ const workspaces = await ZenWorkspacesStorage.getWorkspaces();
+ const workspace = workspaces.find(ws => ws.uuid === oldID);
+ if (workspace) {
+ workspace.uuid = newID;
+ await ZenWorkspacesStorage.saveWorkspace(workspace,false);
+ // Mark the new ID as changed for sync
+ await ZenWorkspacesStorage.markChanged(newID);
+ }
+ } catch (error) {
+ this._log.error(`Error changing workspace ID from ${oldID} to ${newID}`, error);
+ throw error;
+ }
+};
+
+/**
+ * Checks if a workspace exists.
+ * @param {String} id - The UUID of the workspace.
+ * @returns {Boolean} True if the workspace exists, false otherwise.
+ */
+ZenWorkspacesStore.prototype.itemExists = async function (id) {
+ try {
+ const workspaces = await ZenWorkspacesStorage.getWorkspaces();
+ return workspaces.some(ws => ws.uuid === id);
+ } catch (error) {
+ this._log.error(`Error checking if workspace exists with ID ${id}`, error);
+ throw error;
+ }
+};
+
+/**
+ * Creates a record for a workspace.
+ * @param {String} id - The UUID of the workspace.
+ * @param {String} collection - The collection name.
+ * @returns {ZenWorkspaceRecord} The workspace record.
+ */
+ZenWorkspacesStore.prototype.createRecord = async function (id, collection) {
+ try {
+ const workspaces = await ZenWorkspacesStorage.getWorkspaces();
+ const workspace = workspaces.find(ws => ws.uuid === id);
+ const record = new ZenWorkspaceRecord(collection, id);
+
+ if (workspace) {
+ record.name = workspace.name;
+ record.icon = workspace.icon;
+ record.default = workspace.default;
+ record.containerTabId = workspace.containerTabId;
+ record.position = workspace.position;
+ if (workspace.theme) {
+ record.theme_type = workspace.theme.type;
+ record.theme_colors = JSON.stringify(workspace.theme.gradientColors);
+ record.theme_opacity = workspace.theme.opacity;
+ record.theme_rotation = workspace.theme.rotation;
+ record.theme_texture = workspace.theme.texture;
+ }
+ record.deleted = false;
+ } else {
+ record.deleted = true;
+ }
+
+ return record;
+ } catch (error) {
+ this._log.error(`Error creating record for workspace ID ${id}`, error);
+ throw error;
+ }
+};
+
+/**
+ * Creates a new workspace.
+ * @param {ZenWorkspaceRecord} record - The workspace record to create.
+ */
+ZenWorkspacesStore.prototype.create = async function (record) {
+ try {
+ this._validateRecord(record);
+ const workspace = {
+ uuid: record.id,
+ name: record.name,
+ icon: record.icon,
+ default: record.default,
+ containerTabId: record.containerTabId,
+ position: record.position,
+ theme: record.theme_type ? {
+ type: record.theme_type,
+ gradientColors: JSON.parse(record.theme_colors),
+ opacity: record.theme_opacity,
+ rotation: record.theme_rotation,
+ texture: record.theme_texture
+ } : null
+ };
+ await ZenWorkspacesStorage.saveWorkspace(workspace,false);
+ } catch (error) {
+ this._log.error(`Error creating workspace with ID ${record.id}`, error);
+ throw error;
+ }
+};
+
+/**
+ * Updates an existing workspace.
+ * @param {ZenWorkspaceRecord} record - The workspace record to update.
+ */
+ZenWorkspacesStore.prototype.update = async function (record) {
+ try {
+ this._validateRecord(record);
+ await this.create(record); // Reuse create for update
+ } catch (error) {
+ this._log.error(`Error updating workspace with ID ${record.id}`, error);
+ throw error;
+ }
+};
+
+/**
+ * Removes a workspace.
+ * @param {ZenWorkspaceRecord} record - The workspace record to remove.
+ */
+ZenWorkspacesStore.prototype.remove = async function (record) {
+ try {
+ await ZenWorkspacesStorage.removeWorkspace(record.id, false);
+ } catch (error) {
+ this._log.error(`Error removing workspace with ID ${record.id}`, error);
+ throw error;
+ }
+};
+
+/**
+ * Wipes all workspaces from the storage.
+ */
+ZenWorkspacesStore.prototype.wipe = async function () {
+ try {
+ await ZenWorkspacesStorage.wipeAllWorkspaces();
+ } catch (error) {
+ this._log.error("Error wiping all workspaces", error);
+ throw error;
+ }
+};
+
+/**
+ * Validates a workspace record.
+ * @param {ZenWorkspaceRecord} record - The workspace record to validate.
+ */
+ZenWorkspacesStore.prototype._validateRecord = function (record) {
+ if (!record.id || typeof record.id !== "string") {
+ throw new Error("Invalid workspace ID");
+ }
+ if (!record.name || typeof record.name !== "string") {
+ throw new Error(`Invalid workspace name for ID ${record.id}`);
+ }
+ if (typeof record.default !== "boolean") {
+ record.default = false;
+ }
+ if (record.icon != null && typeof record.icon !== "string") {
+ throw new Error(`Invalid icon for workspace ID ${record.id}`);
+ }
+ if (record.containerTabId != null && typeof record.containerTabId !== "number") {
+ throw new Error(`Invalid containerTabId for workspace ID ${record.id}`);
+ }
+ if(record.position != null && typeof record.position !== "number") {
+ throw new Error(`Invalid position for workspace ID ${record.id}`);
+ }
+
+ // Validate theme properties if they exist
+ if (record.theme_type) {
+ if (typeof record.theme_type !== "string") {
+ throw new Error(`Invalid theme_type for workspace ID ${record.id}`);
+ }
+ if (!record.theme_colors || typeof record.theme_colors !== "string") {
+ throw new Error(`Invalid theme_colors for workspace ID ${record.id}`);
+ }
+ try {
+ JSON.parse(record.theme_colors);
+ } catch (e) {
+ throw new Error(`Invalid theme_colors JSON for workspace ID ${record.id}`);
+ }
+ if (record.theme_opacity != null && typeof record.theme_opacity !== "number") {
+ throw new Error(`Invalid theme_opacity for workspace ID ${record.id}`);
+ }
+ if (record.theme_rotation != null && typeof record.theme_rotation !== "number") {
+ throw new Error(`Invalid theme_rotation for workspace ID ${record.id}`);
+ }
+ if (record.theme_texture != null && typeof record.theme_texture !== "number") {
+ throw new Error(`Invalid theme_texture for workspace ID ${record.id}`);
+ }
+ }
+};
+
+/**
+ * Retrieves changed workspace IDs since the last sync.
+ * @returns {Object} An object mapping workspace UUIDs to their change timestamps.
+ */
+ZenWorkspacesStore.prototype.getChangedIDs = async function () {
+ try {
+ return await ZenWorkspacesStorage.getChangedIDs();
+ } catch (error) {
+ this._log.error("Error retrieving changed IDs from storage", error);
+ throw error;
+ }
+};
+
+/**
+ * Clears all recorded changes after a successful sync.
+ */
+ZenWorkspacesStore.prototype.clearChangedIDs = async function () {
+ try {
+ await ZenWorkspacesStorage.clearChangedIDs();
+ } catch (error) {
+ this._log.error("Error clearing changed IDs in storage", error);
+ throw error;
+ }
+};
+
+/**
+ * Marks a workspace as changed.
+ * @param {String} uuid - The UUID of the workspace that changed.
+ */
+ZenWorkspacesStore.prototype.markChanged = async function (uuid) {
+ try {
+ await ZenWorkspacesStorage.markChanged(uuid);
+ } catch (error) {
+ this._log.error(`Error marking workspace ${uuid} as changed`, error);
+ throw error;
+ }
+};
+
+/**
+ * Finalizes the store by ensuring all pending operations are completed.
+ */
+ZenWorkspacesStore.prototype.finalize = async function () {
+ await Store.prototype.finalize.call(this);
+};
+
+
+// Define ZenWorkspacesTracker
+function ZenWorkspacesTracker(name, engine) {
+ Tracker.call(this, name, engine);
+ this._ignoreAll = false;
+
+ // Observe profile-before-change to stop the tracker gracefully
+ Services.obs.addObserver(this.asyncObserver, "profile-before-change");
+}
+
+ZenWorkspacesTracker.prototype = Object.create(Tracker.prototype);
+ZenWorkspacesTracker.prototype.constructor = ZenWorkspacesTracker;
+
+/**
+ * Retrieves changed workspace IDs by delegating to the store.
+ * @returns {Object} An object mapping workspace UUIDs to their change timestamps.
+ */
+ZenWorkspacesTracker.prototype.getChangedIDs = async function () {
+ try {
+ return await this.engine._store.getChangedIDs();
+ } catch (error) {
+ this._log.error("Error retrieving changed IDs from store", error);
+ throw error;
+ }
+};
+
+/**
+ * Clears all recorded changes after a successful sync.
+ */
+ZenWorkspacesTracker.prototype.clearChangedIDs = async function () {
+ try {
+ await this.engine._store.clearChangedIDs();
+ } catch (error) {
+ this._log.error("Error clearing changed IDs in store", error);
+ throw error;
+ }
+};
+
+/**
+ * Called when the tracker starts. Registers observers to listen for workspace changes.
+ */
+ZenWorkspacesTracker.prototype.onStart = function () {
+ if (this._started) {
+ return;
+ }
+ this._log.trace("Starting tracker");
+ // Register observers for workspace changes
+ Services.obs.addObserver(this.asyncObserver, "zen-workspace-added");
+ Services.obs.addObserver(this.asyncObserver, "zen-workspace-removed");
+ Services.obs.addObserver(this.asyncObserver, "zen-workspace-updated");
+ this._started = true;
+};
+
+/**
+ * Called when the tracker stops. Unregisters observers.
+ */
+ZenWorkspacesTracker.prototype.onStop = function () {
+ if (!this._started) {
+ return;
+ }
+ this._log.trace("Stopping tracker");
+ // Unregister observers for workspace changes
+ Services.obs.removeObserver(this.asyncObserver, "zen-workspace-added");
+ Services.obs.removeObserver(this.asyncObserver, "zen-workspace-removed");
+ Services.obs.removeObserver(this.asyncObserver, "zen-workspace-updated");
+ this._started = false;
+};
+
+/**
+ * Handles observed events and marks workspaces as changed accordingly.
+ * @param {nsISupports} subject - The subject of the notification.
+ * @param {String} topic - The topic of the notification.
+ * @param {String} data - Additional data (JSON stringified array of UUIDs).
+ */
+ZenWorkspacesTracker.prototype.observe = async function (subject, topic, data) {
+ if (this.ignoreAll) {
+ return;
+ }
+
+ try {
+ switch (topic) {
+ case "profile-before-change":
+ await this.stop();
+ break;
+ case "zen-workspace-removed":
+ case "zen-workspace-updated":
+ case "zen-workspace-added":
+ let workspaceIDs;
+ if (data) {
+ try {
+ workspaceIDs = JSON.parse(data);
+ if (!Array.isArray(workspaceIDs)) {
+ throw new Error("Parsed data is not an array");
+ }
+ } catch (parseError) {
+ this._log.error(`Failed to parse workspace UUIDs from data: ${data}`, parseError);
+ return;
+ }
+ } else {
+ this._log.error(`No data received for event ${topic}`);
+ return;
+ }
+
+ this._log.trace(`Observed ${topic} for UUIDs: ${workspaceIDs.join(", ")}`);
+
+ // Process each UUID
+ for (const workspaceID of workspaceIDs) {
+ if (typeof workspaceID === "string") {
+ // Inform the store about the change
+ await this.engine._store.markChanged(workspaceID);
+ } else {
+ this._log.warn(`Invalid workspace ID encountered: ${workspaceID}`);
+ }
+ }
+
+ // Bump the score once after processing all changes
+ if (workspaceIDs.length > 0) {
+ this.score += SCORE_INCREMENT_XLARGE;
+ }
+ break;
+ }
+ } catch (error) {
+ this._log.error(`Error handling ${topic} in observe method`, error);
+ }
+};
+
+/**
+ * Finalizes the tracker by ensuring all pending operations are completed.
+ */
+ZenWorkspacesTracker.prototype.finalize = async function () {
+ await Tracker.prototype.finalize.call(this);
+};
+
+
+// Define ZenWorkspacesEngine
+function ZenWorkspacesEngine(service) {
+ SyncEngine.call(this, "Workspaces", service);
+}
+
+ZenWorkspacesEngine.prototype = Object.create(SyncEngine.prototype);
+ZenWorkspacesEngine.prototype.constructor = ZenWorkspacesEngine;
+
+ZenWorkspacesEngine.prototype._storeObj = ZenWorkspacesStore;
+ZenWorkspacesEngine.prototype._trackerObj = ZenWorkspacesTracker;
+ZenWorkspacesEngine.prototype._recordObj = ZenWorkspaceRecord;
+ZenWorkspacesEngine.prototype.version = 2;
+
+ZenWorkspacesEngine.prototype.syncPriority = 10;
+ZenWorkspacesEngine.prototype.allowSkippedRecord = false;
+
+Object.setPrototypeOf(ZenWorkspacesEngine.prototype, SyncEngine.prototype);
+
+
diff --git a/src/browser/base/zen-components/actors/ZenGlanceChild.sys.mjs b/src/browser/base/zen-components/actors/ZenGlanceChild.sys.mjs
new file mode 100644
index 000000000..836558fe1
--- /dev/null
+++ b/src/browser/base/zen-components/actors/ZenGlanceChild.sys.mjs
@@ -0,0 +1,119 @@
+export class ZenGlanceChild extends JSWindowActorChild {
+ constructor() {
+ super();
+
+ this.mouseUpListener = this.handleMouseUp.bind(this);
+ this.mouseDownListener = this.handleMouseDown.bind(this);
+ this.clickListener = this.handleClick.bind(this);
+ }
+
+ async handleEvent(event) {
+ switch (event.type) {
+ case 'DOMContentLoaded':
+ await this.initiateGlance();
+ break;
+ default:
+ }
+ }
+
+ async getActivationMethod() {
+ if (this._activationMethod === undefined) {
+ this._activationMethod = await this.sendQuery('ZenGlance:GetActivationMethod');
+ }
+ return this._activationMethod;
+ }
+
+ async getHoverActivationDelay() {
+ if (this._hoverActivationDelay === undefined) {
+ this._hoverActivationDelay = await this.sendQuery('ZenGlance:GetHoverActivationDelay');
+ }
+ return this._hoverActivationDelay;
+ }
+
+ async receiveMessage(message) {
+ switch (message.name) {
+ }
+ }
+
+ async initiateGlance() {
+ this.mouseIsDown = false;
+ const activationMethod = await this.getActivationMethod();
+ if (activationMethod === 'mantain') {
+ this.contentWindow.addEventListener('mousedown', this.mouseDownListener);
+ this.contentWindow.addEventListener('mouseup', this.mouseUpListener);
+
+ this.contentWindow.document.removeEventListener('click', this.clickListener);
+ } else if (activationMethod === 'ctrl' || activationMethod === 'alt' || activationMethod === 'shift') {
+ this.contentWindow.document.addEventListener('click', this.clickListener);
+
+ this.contentWindow.removeEventListener('mousedown', this.mouseDownListener);
+ this.contentWindow.removeEventListener('mouseup', this.mouseUpListener);
+ }
+ }
+
+ ensureOnlyKeyModifiers(event) {
+ return !(event.ctrlKey ^ event.altKey ^ event.shiftKey ^ event.metaKey);
+ }
+
+ openGlance(target) {
+ const rect = target.getBoundingClientRect();
+ this.sendAsyncMessage('ZenGlance:OpenGlance', {
+ url: target.href,
+ x: rect.left,
+ y: rect.top,
+ width: rect.width,
+ height: rect.height,
+ });
+ }
+
+ handleMouseUp(event) {
+ if (this.hasClicked) {
+ event.preventDefault();
+ event.stopPropagation();
+ this.hasClicked = false;
+ }
+ this.mouseIsDown = null;
+ }
+
+ async handleMouseDown(event) {
+ const target = event.target.closest('A');
+ console.log('target', target);
+ if (!target) {
+ return;
+ }
+ this.mouseIsDown = target;
+ const hoverActivationDelay = await this.getHoverActivationDelay();
+ this.contentWindow.setTimeout(() => {
+ if (this.mouseIsDown === target) {
+ this.hasClicked = true;
+ this.openGlance(target);
+ }
+ }, hoverActivationDelay);
+ }
+
+ handleClick(event) {
+ if (this.ensureOnlyKeyModifiers(event)) {
+ return;
+ }
+ const activationMethod = this._activationMethod;
+ if (activationMethod === 'ctrl' && !event.ctrlKey) {
+ return;
+ } else if (activationMethod === 'alt' && !event.altKey) {
+ return;
+ } else if (activationMethod === 'shift' && !event.shiftKey) {
+ return;
+ } else if (activationMethod === 'meta' && !event.metaKey) {
+ return;
+ }else if (activationMethod === 'mantain' || typeof activationMethod === 'undefined') {
+ return;
+ }
+ // get closest A element
+ const target = event.target.closest('A');
+ if (target) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ this.openGlance(target);
+ }
+ }
+}
diff --git a/src/browser/base/zen-components/actors/ZenGlanceParent.sys.mjs b/src/browser/base/zen-components/actors/ZenGlanceParent.sys.mjs
new file mode 100644
index 000000000..edbb157b3
--- /dev/null
+++ b/src/browser/base/zen-components/actors/ZenGlanceParent.sys.mjs
@@ -0,0 +1,24 @@
+export class ZenGlanceParent extends JSWindowActorParent {
+ constructor() {
+ super();
+ }
+
+ async receiveMessage(message) {
+ switch (message.name) {
+ case 'ZenGlance:GetActivationMethod': {
+ return Services.prefs.getStringPref('zen.glance.activation-method', 'ctrl');
+ }
+ case 'ZenGlance:GetHoverActivationDelay': {
+ return Services.prefs.getIntPref('zen.glance.hold-duration', 500);
+ }
+ case 'ZenGlance:OpenGlance': {
+ this.openGlance(this.browsingContext.topChromeWindow, message.data);
+ break;
+ }
+ }
+ }
+
+ openGlance(window, data) {
+ window.gZenGlanceManager.openGlance(data);
+ }
+}
diff --git a/src/browser/base/zen-components/actors/ZenThemeMarketplaceChild.sys.mjs b/src/browser/base/zen-components/actors/ZenThemeMarketplaceChild.sys.mjs
new file mode 100644
index 000000000..80d87ebce
--- /dev/null
+++ b/src/browser/base/zen-components/actors/ZenThemeMarketplaceChild.sys.mjs
@@ -0,0 +1,142 @@
+export class ZenThemeMarketplaceChild extends JSWindowActorChild {
+ constructor() {
+ super();
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case 'DOMContentLoaded':
+ this.initiateThemeMarketplace();
+ this.contentWindow.document.addEventListener('ZenCheckForThemeUpdates', this.checkForThemeUpdates.bind(this));
+ break;
+ default:
+ }
+ }
+
+ // This function will be caleld from about:preferences
+ checkForThemeUpdates(event) {
+ event.preventDefault();
+ this.sendAsyncMessage('ZenThemeMarketplace:CheckForUpdates');
+ }
+
+ initiateThemeMarketplace() {
+ this.contentWindow.setTimeout(() => {
+ this.addIntallButtons();
+ }, 0);
+ }
+
+ get actionButton() {
+ return this.contentWindow.document.getElementById('install-theme');
+ }
+
+ get actionButtonUnnstall() {
+ return this.contentWindow.document.getElementById('install-theme-uninstall');
+ }
+
+ async receiveMessage(message) {
+ switch (message.name) {
+ case 'ZenThemeMarketplace:ThemeChanged': {
+ const themeId = message.data.themeId;
+ const actionButton = this.actionButton;
+ const actionButtonInstalled = this.actionButtonUnnstall;
+ if (actionButton && actionButtonInstalled) {
+ actionButton.disabled = false;
+ actionButtonInstalled.disabled = false;
+ if (await this.isThemeInstalled(themeId)) {
+ actionButton.classList.add('hidden');
+ actionButtonInstalled.classList.remove('hidden');
+ } else {
+ actionButton.classList.remove('hidden');
+ actionButtonInstalled.classList.add('hidden');
+ }
+ }
+ break;
+ }
+ case 'ZenThemeMarketplace:CheckForUpdatesFinished': {
+ const updates = message.data.updates;
+ this.contentWindow.document.dispatchEvent(
+ new CustomEvent('ZenThemeMarketplace:CheckForUpdatesFinished', { detail: { updates } })
+ );
+ break;
+ }
+ case 'ZenThemeMarketplace:GetThemeInfo': {
+ const themeId = message.data.themeId;
+ const theme = await this.getThemeInfo(themeId);
+ return theme;
+ }
+ }
+ }
+
+ async addIntallButtons() {
+ const actionButton = this.actionButton;
+ const actionButtonUnnstall = this.actionButtonUnnstall;
+ const errorMessage = this.contentWindow.document.getElementById('install-theme-error');
+ if (!actionButton || !actionButtonUnnstall) {
+ return;
+ }
+
+ errorMessage.classList.add('hidden');
+
+ const themeId = actionButton.getAttribute('zen-theme-id');
+ if (await this.isThemeInstalled(themeId)) {
+ actionButtonUnnstall.classList.remove('hidden');
+ } else {
+ actionButton.classList.remove('hidden');
+ }
+
+ actionButton.addEventListener('click', this.installTheme.bind(this));
+ actionButtonUnnstall.addEventListener('click', this.uninstallTheme.bind(this));
+ }
+
+ async isThemeInstalled(themeId) {
+ return await this.sendQuery('ZenThemeMarketplace:IsThemeInstalled', { themeId });
+ }
+
+ addTheme(theme) {
+ this.sendAsyncMessage('ZenThemeMarketplace:InstallTheme', { theme });
+ }
+
+ getThemeAPIUrl(themeId) {
+ return `https://zen-browser.github.io/theme-store/themes/${themeId}/theme.json`;
+ }
+
+ async getThemeInfo(themeId) {
+ const url = this.getThemeAPIUrl(themeId);
+ console.info('ZTM: Fetching theme info from: ', url);
+ const data = await fetch(url, {
+ mode: 'no-cors',
+ });
+
+ if (data.ok) {
+ try {
+ const obj = await data.json();
+ return obj;
+ } catch (e) {
+ console.error('ZTM: Error parsing theme info: ', e);
+ }
+ } else console.log(data.status);
+ return null;
+ }
+
+ async uninstallTheme(event) {
+ const button = event.target;
+ button.disabled = true;
+ const themeId = button.getAttribute('zen-theme-id');
+ console.info('ZTM: Uninstalling theme with id: ', themeId);
+ this.sendAsyncMessage('ZenThemeMarketplace:UninstallTheme', { themeId });
+ }
+
+ async installTheme(event) {
+ const button = event.target;
+ button.disabled = true;
+ const themeId = button.getAttribute('zen-theme-id');
+ console.info('ZTM: Installing theme with id: ', themeId);
+
+ const theme = await this.getThemeInfo(themeId);
+ if (!theme) {
+ console.error('ZTM: Error fetching theme info');
+ return;
+ }
+ this.addTheme(theme);
+ }
+}
diff --git a/src/browser/base/zen-components/actors/ZenThemeMarketplaceParent.sys.mjs b/src/browser/base/zen-components/actors/ZenThemeMarketplaceParent.sys.mjs
new file mode 100644
index 000000000..c2de04cac
--- /dev/null
+++ b/src/browser/base/zen-components/actors/ZenThemeMarketplaceParent.sys.mjs
@@ -0,0 +1,186 @@
+export class ZenThemeMarketplaceParent extends JSWindowActorParent {
+ constructor() {
+ super();
+ }
+
+ async receiveMessage(message) {
+ switch (message.name) {
+ case 'ZenThemeMarketplace:InstallTheme': {
+ console.info('ZenThemeMarketplaceParent: Updating themes');
+ const theme = message.data.theme;
+ theme.enabled = true;
+ const themes = await this.getThemes();
+ themes[theme.id] = theme;
+ this.updateThemes(themes);
+ this.updateChildProcesses(theme.id);
+ break;
+ }
+ case 'ZenThemeMarketplace:UninstallTheme': {
+ console.info('ZenThemeMarketplaceParent: Uninstalling theme');
+ const themeId = message.data.themeId;
+ const themes = await this.getThemes();
+ delete themes[themeId];
+ this.removeTheme(themeId);
+ this.updateThemes(themes);
+ this.updateChildProcesses(themeId);
+ break;
+ }
+ case 'ZenThemeMarketplace:IsThemeInstalled': {
+ const themeId = message.data.themeId;
+ const themes = await this.getThemes();
+ return themes[themeId] ? true : false;
+ }
+ case 'ZenThemeMarketplace:CheckForUpdates': {
+ this.checkForThemeUpdates();
+ break;
+ }
+ }
+ }
+
+ compareversion(version1, version2) {
+ var result = false;
+ if (typeof version1 !== 'object') {
+ version1 = version1.toString().split('.');
+ }
+ if (typeof version2 !== 'object') {
+ version2 = version2.toString().split('.');
+ }
+ for (var i = 0; i < Math.max(version1.length, version2.length); i++) {
+ if (version1[i] == undefined) {
+ version1[i] = 0;
+ }
+ if (version2[i] == undefined) {
+ version2[i] = 0;
+ }
+ if (Number(version1[i]) < Number(version2[i])) {
+ result = true;
+ break;
+ }
+ if (version1[i] != version2[i]) {
+ break;
+ }
+ }
+ return result;
+ }
+
+ async checkForThemeUpdates() {
+ console.info('ZenThemeMarketplaceParent: Checking for theme updates');
+ let updates = [];
+ this._themes = null;
+ for (const theme of Object.values(await this.getThemes())) {
+ const themeInfo = await this.sendQuery('ZenThemeMarketplace:GetThemeInfo', { themeId: theme.id });
+ if (!themeInfo) {
+ continue;
+ }
+ if (!this.compareversion(themeInfo.version, theme.version || '0.0.0') && themeInfo.version != theme.version) {
+ console.info('ZenThemeMarketplaceParent: Theme update found', theme.id, theme.version, themeInfo.version);
+ themeInfo.enabled = theme.enabled;
+ updates.push(themeInfo);
+ await this.removeTheme(theme.id, false);
+ this._themes[themeInfo.id] = themeInfo;
+ }
+ }
+ await this.updateThemes(this._themes);
+ this.sendAsyncMessage('ZenThemeMarketplace:CheckForUpdatesFinished', { updates });
+ }
+
+ async updateChildProcesses(themeId) {
+ this.sendAsyncMessage('ZenThemeMarketplace:ThemeChanged', { themeId });
+ }
+
+ async getThemes() {
+ if (!this._themes) {
+ if (!(await IOUtils.exists(this.themesDataFile))) {
+ await IOUtils.writeJSON(this.themesDataFile, {});
+ }
+ this._themes = await IOUtils.readJSON(this.themesDataFile);
+ }
+ return this._themes;
+ }
+
+ async updateThemes(themes) {
+ this._themes = themes;
+ await IOUtils.writeJSON(this.themesDataFile, themes);
+ await this.checkForThemeChanges();
+ }
+
+ getStyleSheetFullContent(style = '') {
+ let stylesheet = '@-moz-document url-prefix("chrome:") {';
+
+ for (const line of style.split('\n')) {
+ stylesheet += ` ${line}`;
+ }
+
+ stylesheet += '}';
+
+ return stylesheet;
+ }
+
+ async downloadUrlToFile(url, path, isStyleSheet = false) {
+ try {
+ const response = await fetch(url);
+ const data = await response.text();
+ const content = isStyleSheet ? this.getStyleSheetFullContent(data) : data;
+ // convert the data into a Uint8Array
+ let buffer = new TextEncoder().encode(content);
+ await IOUtils.write(path, buffer);
+ } catch (e) {
+ console.error('ZenThemeMarketplaceParent: Error downloading file', url, e);
+ }
+ }
+
+ async downloadThemeFileContents(theme) {
+ const themePath = PathUtils.join(this.themesRootPath, theme.id);
+ await IOUtils.makeDirectory(themePath, { ignoreExisting: true });
+ await this.downloadUrlToFile(theme.style, PathUtils.join(themePath, 'chrome.css'), true);
+ await this.downloadUrlToFile(theme.readme, PathUtils.join(themePath, 'readme.md'));
+ if (theme.preferences) {
+ await this.downloadUrlToFile(theme.preferences, PathUtils.join(themePath, 'preferences.json'));
+ }
+ }
+
+ get themesRootPath() {
+ return PathUtils.join(PathUtils.profileDir, 'chrome', 'zen-themes');
+ }
+
+ get themesDataFile() {
+ return PathUtils.join(PathUtils.profileDir, 'zen-themes.json');
+ }
+
+ triggerThemeUpdate() {
+ const pref = 'zen.themes.updated-value-observer';
+ Services.prefs.setBoolPref(pref, !Services.prefs.getBoolPref(pref));
+ }
+
+ async installTheme(theme) {
+ await this.downloadThemeFileContents(theme);
+ }
+
+ async checkForThemeChanges() {
+ const themes = await this.getThemes();
+ const themeIds = Object.keys(themes);
+ let changed = false;
+ for (const themeId of themeIds) {
+ const theme = themes[themeId];
+ if (!theme) {
+ continue;
+ }
+ const themePath = PathUtils.join(this.themesRootPath, themeId);
+ if (!(await IOUtils.exists(themePath))) {
+ await this.installTheme(theme);
+ changed = true;
+ }
+ }
+ if (changed) {
+ this.triggerThemeUpdate();
+ }
+ }
+
+ async removeTheme(themeId, triggerUpdate = true) {
+ const themePath = PathUtils.join(this.themesRootPath, themeId);
+ await IOUtils.remove(themePath, { recursive: true, ignoreAbsent: true });
+ if (triggerUpdate) {
+ this.triggerThemeUpdate();
+ }
+ }
+}