}|null>}
+ * Once open, the glance's browser and a promise for its closing, which
+ */
+ async openDetachedGlance(data) {
+ if (this.#detached || !this.#isGlanceLoadAllowed(data)) {
+ return null;
+ }
+ const host = this.#detachedHost;
+ const wrapper = host.querySelector(".browserContainer");
+ const browser = document.createXULElement("browser");
+ browser.setAttribute("type", "content");
+ browser.setAttribute("remote", "true");
+ browser.setAttribute("maychangeremoteness", "true");
+ browser.setAttribute("disableglobalhistory", "true");
+ browser.setAttribute("messagemanagergroup", "browsers");
+ browser.setAttribute("nodefaultsrc", "true");
+ if (data.userContextId) {
+ browser.setAttribute("usercontextid", data.userContextId);
+ }
+ host.querySelector(".browserStack").appendChild(browser);
+ wrapper.appendChild(this.#createNewOverlayButtons());
+ for (const selector of [
+ ".zen-glance-sidebar-open",
+ ".zen-glance-sidebar-split",
+ ]) {
+ wrapper.querySelector(selector)?.remove();
+ }
+
+ host.hidden = false;
+ const hostRect = host.getBoundingClientRect();
+ const startPoint = {
+ clientX: data.clientX - hostRect.left,
+ clientY: data.clientY - hostRect.top,
+ width: 0,
+ height: 0,
+ };
+ let resolveClosed;
+ const whenClosed = new Promise(resolve => {
+ resolveClosed = resolve;
+ });
+ this.#detached = {
+ host,
+ wrapper,
+ browser,
+ startPoint,
+ closing: false,
+ resolveClosed,
+ };
+
+ browser.loadURI(Services.io.newURI(data.url), {
+ triggeringPrincipal: data.triggeringPrincipal,
+ });
+ wrapper.setAttribute("animate", true);
+ await gZenUIManager.elementAnimate(
+ wrapper,
+ this.#createGlanceArcSequence(startPoint, "opening", null, hostRect),
+ {
+ duration: gZenUIManager.testingEnabled
+ ? 0
+ : this.#GLANCE_ANIMATION_DURATION,
+ easing: "ease-in-out",
+ }
+ );
+ wrapper.removeAttribute("animate");
+ wrapper.setAttribute("has-finished-animation", true);
+ return { browser, closed: whenClosed };
+ }
+
+ /**
+ * @param {object} [options]
+ * @param {boolean} [options.skipPermitUnload] - Close even if the page
+ * would rather not unload
+ */
+ async closeDetachedGlance({ skipPermitUnload = false } = {}) {
+ const detached = this.#detached;
+ if (!detached || detached.closing) {
+ return;
+ }
+ if (!skipPermitUnload && !detached.browser.permitUnload().permitUnload) {
+ return;
+ }
+ detached.closing = true;
+ const { host, wrapper, browser } = detached;
+ detached.resolveClosed();
+ this.#animateSidebarButtons(
+ wrapper.querySelector(".zen-glance-sidebar-container")
+ );
+ host.setAttribute("fade-out", true);
+ wrapper.removeAttribute("has-finished-animation");
+ wrapper.setAttribute("animate", true);
+ try {
+ await gZenUIManager.elementAnimate(
+ wrapper,
+ this.#createGlanceArcSequence(
+ detached.startPoint,
+ "closing",
+ null,
+ host.getBoundingClientRect()
+ ),
+ {
+ duration: gZenUIManager.testingEnabled
+ ? 0
+ : this.#GLANCE_ANIMATION_DURATION,
+ easing: "ease-out",
+ }
+ );
+ } finally {
+ browser.destroy();
+ browser.remove();
+ wrapper.removeAttribute("animate");
+ host.removeAttribute("fade-out");
+ host.hidden = true;
+ this.#detached = null;
+ }
+ }
+
#setupPreferences() {
XPCOMUtils.defineLazyPreferenceGetter(
this._lazyPref,
@@ -102,7 +249,10 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature {
handleMainCommandSet(event) {
const command = event.target;
const commandHandlers = {
- cmd_zenGlanceClose: () => this.closeGlance({ onTabClose: true }),
+ cmd_zenGlanceClose: () =>
+ this.#detached
+ ? this.closeDetachedGlance()
+ : this.closeGlance({ onTabClose: true }),
cmd_zenGlanceExpand: () => this.fullyOpenGlance(),
cmd_zenGlanceSplit: () => this.splitGlance(),
};
@@ -617,9 +767,16 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature {
* @param {object} data - Glance data with position and dimensions
* @param {string} direction - 'opening' or 'closing'
* @param {Element|null} imageDataElement - The image data element for preview (optional)
+ * @param {DOMRect|null} referenceRect - Bounds the glance opens within;
+ * defaults to the tab panels
* @returns {object} Animation sequence object
*/
- #createGlanceArcSequence(data, direction, imageDataElement = null) {
+ #createGlanceArcSequence(
+ data,
+ direction,
+ imageDataElement = null,
+ referenceRect = null
+ ) {
let { clientX, clientY, width, height } = data;
if (imageDataElement?.parentElement) {
// Since we are animating scale transforms on the wrapper, we need to
@@ -649,9 +806,9 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature {
// Calculate start and end positions based on direction
let startPosition, endPosition;
- const tabPanelsRect = window.windowUtils.getBoundsWithoutFlushing(
- gBrowser.tabpanels
- );
+ const tabPanelsRect =
+ referenceRect ??
+ window.windowUtils.getBoundsWithoutFlushing(gBrowser.tabpanels);
const widthPercent = 0.8;
if (direction === "opening") {
diff --git a/src/zen/glance/zen-glance.css b/src/zen/glance/zen-glance.css
index ffef7fb0d..9c3c2ab49 100644
--- a/src/zen/glance/zen-glance.css
+++ b/src/zen/glance/zen-glance.css
@@ -103,7 +103,8 @@
}
.browserSidebarContainer.zen-glance-background,
-.browserSidebarContainer.zen-glance-overlay .browserContainer:not([fade-out="true"]) {
+:is(.browserSidebarContainer.zen-glance-overlay, #zen-glance-detached)
+ .browserContainer:not([fade-out="true"]) {
border-radius: var(--zen-native-inner-radius);
@media not (-moz-pref("zen.theme.squircle-browser-view")) {
/* stylelint-disable-next-line property-no-unknown */
@@ -112,7 +113,7 @@
}
}
-.browserSidebarContainer.zen-glance-overlay {
+:is(.browserSidebarContainer.zen-glance-overlay, #zen-glance-detached) {
box-shadow: none !important;
visibility: inherit;
z-index: 1;
@@ -198,3 +199,19 @@
max-height: 100%;
}
}
+
+#zen-main-app-wrapper > #zen-glance-detached {
+ position: fixed;
+ inset: 24px;
+ z-index: 100;
+ display: flex;
+
+ &[hidden] {
+ display: none;
+ }
+
+ & .zen-glance-detached-backdrop {
+ position: fixed;
+ inset: 0;
+ }
+}
diff --git a/src/zen/library/ZenLibrary.mjs b/src/zen/library/ZenLibrary.mjs
new file mode 100644
index 000000000..85d1909c1
--- /dev/null
+++ b/src/zen/library/ZenLibrary.mjs
@@ -0,0 +1,592 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { html } from "chrome://global/content/vendor/lit.all.mjs";
+import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
+
+const { ZenLibraryWidget } = ChromeUtils.importESModule(
+ "moz-src:///zen/library/ZenLibraryWidget.sys.mjs"
+);
+
+let lazy = {};
+
+ChromeUtils.defineESModuleGetters(
+ lazy,
+ {
+ ZenLibraryHistorySection:
+ "moz-src:///zen/library/sections/ZenLibraryHistorySection.mjs",
+ ZenLibraryDownloadsSection:
+ "moz-src:///zen/library/sections/ZenLibraryDownloadsSection.mjs",
+ ZenLibraryBoostsSection:
+ "moz-src:///zen/library/sections/ZenLibraryBoostsSection.mjs",
+ ZenLibrarySpacesSection:
+ "moz-src:///zen/library/sections/ZenLibrarySpacesSection.mjs",
+ },
+ { global: "current" }
+);
+
+const LAST_TAB_PREF = "zen.library.last-tab";
+
+ChromeUtils.defineLazyGetter(lazy, "appContentWrapper", function () {
+ return document.getElementById("zen-appcontent-wrapper");
+});
+
+export class ZenLibrary extends MozLitElement {
+ static instance = null;
+ #progress = 0;
+
+ #springControls = null;
+
+ #toolboxWidth = 0;
+
+ #originalButtonsNextSibling = null;
+
+ get #hasAdoptedButtons() {
+ return this.#originalButtonsNextSibling !== null;
+ }
+
+ #canSwipe = false;
+ #isOpen = false;
+
+ #isWrapperSwipeAttached = false;
+ #wrapperGestureControl = null;
+
+ #resizeObserver = new ResizeObserver(() => {
+ this.openProgress = this.#progress;
+ });
+
+ static queries = {
+ _content: "#zen-library-content",
+ _header: "#zen-library-header",
+ _footer: "#zen-library-footer",
+ };
+
+ static properties = {
+ _activeTab: { type: String },
+ };
+
+ constructor() {
+ super();
+ this.zenLibrarySections = {
+ history: lazy.ZenLibraryHistorySection,
+ downloads: lazy.ZenLibraryDownloadsSection,
+ boosts: lazy.ZenLibraryBoostsSection,
+ spaces: lazy.ZenLibrarySpacesSection,
+ };
+ const lastTab = Services.prefs.getStringPref(LAST_TAB_PREF, "history");
+ this.activeTab = lastTab in this.zenLibrarySections ? lastTab : "history";
+ this.#hijackFirefoxCommands();
+ }
+
+ static get isLibraryOpen() {
+ const lib = this.getInstance();
+ return lib.#isOpen;
+ }
+
+ static get isLibrarySlightlyOpen() {
+ const lib = this.getInstance(/* createIfMissing = */ false);
+ if (!lib) {
+ return false;
+ }
+ // Due to calculation inaccuracies assume
+ // that openProgress never goes back to 0
+ return lib.openProgress > 0.001;
+ }
+
+ set activeTab(value) {
+ if (this._activeTab === value) {
+ return;
+ }
+ this._activeTab = value;
+ Services.prefs.setStringPref(LAST_TAB_PREF, value);
+ }
+
+ get activeTab() {
+ return this._activeTab;
+ }
+
+ get activeSection() {
+ return this.zenLibrarySections[this.activeTab];
+ }
+
+ set openProgress(value) {
+ const p = value;
+ const stealWindowButtonsPastPoint = 0.6;
+ const wasOpen = this.#progress > 0.001;
+ this.#progress = p;
+ const isPastWindowButtonSwitchPoint = p > stealWindowButtonsPastPoint;
+ const isOpen = p > 0.001;
+
+ let libraryWidth = window.windowUtils.getBoundsWithoutFlushing(this).width;
+ const compactModeOffsetDirection = this.#libraryOnRight
+ ? -this.#toolboxWidth
+ : this.#toolboxWidth;
+ const compactModeOffset = this.#isCompactMode
+ ? compactModeOffsetDirection
+ : 0;
+ let webOffset =
+ (this.#libraryOnRight ? -1 : 1) * (libraryWidth - this.#toolboxWidth) +
+ compactModeOffset;
+
+ lazy.appContentWrapper?.style.setProperty(
+ "--library-wrapper-target-px",
+ `${webOffset}px`
+ );
+ [this, lazy.appContentWrapper, gNavToolbox].forEach(elem => {
+ elem?.style.setProperty("--library-progress", String(p));
+ });
+
+ if (isOpen && !wasOpen) {
+ this.setAttribute("open", "true");
+ document.documentElement.setAttribute("zen-library-open", "true");
+ } else if (!isOpen && wasOpen) {
+ this.removeAttribute("open");
+ document.documentElement.removeAttribute("zen-library-open");
+ }
+
+ if (isPastWindowButtonSwitchPoint && this.#coversWindowButtons) {
+ this.#adoptWindowButtons();
+ } else if (!isPastWindowButtonSwitchPoint) {
+ this.#restoreWindowButtons();
+ }
+ }
+
+ /**
+ * Whether the window buttons sit in the sidebar column the library covers.
+ */
+ get #coversWindowButtons() {
+ if (!gZenVerticalTabsManager.isWindowsStyledButtons) {
+ return !this.#libraryOnRight;
+ }
+ return this.#libraryOnRight && !this.#isCompactMode;
+ }
+
+ get openProgress() {
+ return this.#progress;
+ }
+
+ #hijackFirefoxCommands() {
+ document
+ .getElementById("Browser:ShowAllHistory")
+ .addEventListener("command", event => {
+ event.stopPropagation();
+ event.stopImmediatePropagation();
+
+ ZenLibrary.toggle("history");
+ });
+ }
+
+ #adoptWindowButtons() {
+ if (this.#hasAdoptedButtons) {
+ return;
+ }
+
+ const realButtons = gZenVerticalTabsManager.actualWindowButtons;
+ if (!this.#originalButtonsNextSibling) {
+ this.#originalButtonsNextSibling = {
+ isNext: realButtons.nextSibling,
+ sibling: realButtons.nextSibling || realButtons.previousSibling,
+ clone: realButtons.cloneNode(true),
+ };
+
+ this.#originalButtonsNextSibling.clone.classList.add(
+ "zen-library-window-buttons-clone"
+ );
+ if (this.#originalButtonsNextSibling.isNext) {
+ this.#originalButtonsNextSibling.sibling.before(
+ this.#originalButtonsNextSibling.clone
+ );
+ } else {
+ this.#originalButtonsNextSibling.sibling.after(
+ this.#originalButtonsNextSibling.clone
+ );
+ }
+
+ this._header.appendChild(realButtons);
+ }
+ }
+
+ #restoreWindowButtons() {
+ if (!this.#hasAdoptedButtons) {
+ return;
+ }
+
+ const realButtons = gZenVerticalTabsManager.actualWindowButtons;
+ if (this.#originalButtonsNextSibling) {
+ this.#originalButtonsNextSibling.clone.remove();
+ if (this.#originalButtonsNextSibling.isNext) {
+ this.#originalButtonsNextSibling.sibling.before(realButtons);
+ } else {
+ this.#originalButtonsNextSibling.sibling.after(realButtons);
+ }
+ this.#originalButtonsNextSibling = null;
+ }
+ }
+
+ #stylesLoaded = null;
+
+ #whenStylesLoaded() {
+ this.#stylesLoaded ??= this.updateComplete.then(() => {
+ const link = this.querySelector("link[rel='stylesheet']");
+ if (!link || link.sheet) {
+ return undefined;
+ }
+ return new Promise(resolve => {
+ link.addEventListener("load", resolve, { once: true });
+ link.addEventListener("error", resolve, { once: true });
+ });
+ });
+ return this.#stylesLoaded;
+ }
+
+ #idleCleanup = null;
+
+ #scheduleIdleCleanup() {
+ this.#idleCleanup = window.requestIdleCallback(() => {
+ this.#idleCleanup = null;
+ this.#stylesLoaded = null;
+ ZenLibrary.instance = null;
+ this.remove();
+ });
+ }
+
+ #cancelIdleCleanup() {
+ if (this.#idleCleanup) {
+ window.cancelIdleCallback(this.#idleCleanup);
+ this.#idleCleanup = null;
+ }
+ }
+
+ /**
+ * Opens or closes the library. With a tab id, opens the library on that
+ * tab, switches to it if already open on another, or closes if it is
+ * already the open one. Without one, plainly toggles open and closed.
+ *
+ * @param {string?} [tab] - A section id to open on
+ */
+ static toggle(tab = undefined) {
+ if (!Services.prefs.getBoolPref("zen.library.enabled")) {
+ return;
+ }
+
+ const lib = this.getInstance();
+ if (tab && tab in lib.zenLibrarySections) {
+ if (lib.#isOpen && lib.activeTab === tab) {
+ this.animateProgress(0);
+ } else if (lib.#isOpen) {
+ lib.activeTab = tab;
+ } else {
+ lib.activeTab = tab;
+ this.animateProgress(1);
+ }
+ return;
+ }
+ this.animateProgress(lib.#isOpen ? 0 : 1);
+ }
+
+ static async animateProgress(target) {
+ const lib = this.getInstance();
+ lib.#detachWrapperOfSwipe();
+ lib.#cancelIdleCleanup();
+ await lib.#whenStylesLoaded();
+ lib.style.visibility = "";
+ await window.promiseDocumentFlushed(() => {});
+
+ if (lib.#springControls) {
+ lib.#springControls.stop();
+ lib.#springControls = null;
+ }
+
+ if (target === 1) {
+ lib.#onOpenLibrary();
+ lib.#isOpen = true;
+ } else if (target === 0) {
+ lib.#isOpen = false;
+ }
+
+ lib.setAttribute("transitioning", "true");
+ lib.#springControls = gZenUIManager.motion.animate(
+ lib.openProgress,
+ target,
+ {
+ type: "spring",
+ stiffness: 720,
+ damping: 47,
+ mass: 1.2,
+ onUpdate: latest => {
+ lib.openProgress = latest;
+ },
+ onComplete: () => {
+ lib.openProgress = target;
+ lib.#springControls = null;
+ lib.removeAttribute("transitioning");
+ if (target === 0) {
+ lib.#scheduleIdleCleanup();
+ }
+ },
+ }
+ );
+ }
+
+ static async startSwipe() {
+ const lib = this.getInstance();
+ lib.#cancelIdleCleanup();
+ lib.#canSwipe = true;
+ await lib.#whenStylesLoaded();
+ lib.style.visibility = "";
+ await window.promiseDocumentFlushed(() => {});
+ if (!lib.#canSwipe) {
+ return;
+ }
+
+ lib.#onOpenLibrary();
+
+ if (lib.#springControls) {
+ lib.#springControls.stop();
+ lib.#springControls = null;
+ }
+
+ lib.style.setProperty("pointer-events", "none");
+ lib.#attachWrapperToSwipe();
+ }
+
+ static stopSwipe(direction) {
+ const lib = this.getInstance();
+ lib.style.setProperty("pointer-events", "unset");
+ lib.#canSwipe = false;
+
+ if (lib.#libraryOnRight) {
+ direction = direction * -1;
+ }
+
+ if (direction) {
+ const target = Math.max(-direction, 0);
+ this.animateProgress(target);
+ }
+ lib.#detachWrapperOfSwipe();
+
+ // Return library open state
+ return lib.#isOpen;
+ }
+
+ static swipeProgress(target) {
+ const lib = this.getInstance();
+ if (!lib.#canSwipe) {
+ return;
+ }
+
+ lib.openProgress = target;
+ }
+
+ #attachWrapperToSwipe() {
+ if (!this.#isWrapperSwipeAttached) {
+ const appWrapper = document.getElementById("zen-main-app-wrapper");
+ this.#wrapperGestureControl =
+ window.gZenWorkspaces._swipeManager.attachWorkspaceSwipeGestures(
+ appWrapper
+ );
+ this.#isWrapperSwipeAttached = true;
+ }
+ }
+
+ #detachWrapperOfSwipe() {
+ if (this.#isWrapperSwipeAttached || this.#wrapperGestureControl) {
+ const appWrapper = document.getElementById("zen-main-app-wrapper");
+ window.gZenWorkspaces._swipeManager.detachWorkspaceSwipeGestures(
+ appWrapper,
+ this.#wrapperGestureControl
+ );
+ this.#wrapperGestureControl = null;
+ this.#isWrapperSwipeAttached = false;
+ }
+ }
+
+ #onOpenLibrary() {
+ gURLBar.view.close();
+ // Get the width from the css property,
+ // getBoundsWithoutFlushing will fail as it takes the
+ // toolbox transformation during the animation into account
+ this.#toolboxWidth = parseFloat(
+ gNavToolbox.style
+ .getPropertyValue("--actual-zen-sidebar-width")
+ .replace("/\D/g", "")
+ );
+ if (document.documentElement.hasAttribute("zen-sidebar-expanded")) {
+ this.#toolboxWidth += window.windowUtils.getBoundsWithoutFlushing(
+ document.getElementById("zen-sidebar-splitter")
+ ).width;
+ }
+ }
+
+ static getInstance(createIfMissing = true) {
+ if (!this.instance && createIfMissing) {
+ this.instance = new ZenLibrary();
+ this.instance.style.visibility = "collapse";
+ const mountRoot = document.getElementById("zen-main-app-wrapper");
+ mountRoot.append(this.instance);
+ }
+ return this.instance;
+ }
+
+ get #libraryOnRight() {
+ return gZenVerticalTabsManager._prefsRightSide;
+ }
+
+ static get libraryOnRight() {
+ const lib = this.getInstance();
+ return lib.#libraryOnRight;
+ }
+
+ createRenderRoot() {
+ return this;
+ }
+
+ connectedCallback() {
+ if (super.connectedCallback) {
+ super.connectedCallback();
+ }
+ this.onKeyDown = this.onKeyDown.bind(this);
+ document.addEventListener("keydown", this.onKeyDown, true);
+ this.#resizeObserver.observe(this);
+
+ window.gZenWorkspaces._swipeManager.attachWorkspaceSwipeGestures(this);
+
+ this._tabOpen = this.onTabOpen.bind(this);
+ window.addEventListener("TabOpen", this._tabOpen);
+
+ ZenLibraryWidget.attachLibrary(this);
+ }
+
+ disconnectedCallback() {
+ this.#cancelIdleCleanup();
+ if (this.#springControls) {
+ this.#springControls.stop();
+ this.#springControls = null;
+ }
+
+ this.#restoreWindowButtons();
+ ZenLibraryWidget.detachLibrary(this);
+
+ super.disconnectedCallback();
+ document.removeEventListener("keydown", this.onKeyDown, true);
+ this.#resizeObserver.disconnect();
+
+ if (this._tabOpen) {
+ window.removeEventListener("TabOpen", this._tabOpen);
+ this._tabOpen = null;
+ }
+ }
+
+ get #isCompactMode() {
+ return (
+ window.gZenCompactModeManager.preference &&
+ Services.prefs.getBoolPref("zen.view.compact.hide-tabbar")
+ );
+ }
+
+ onTabOpen() {
+ if (this.#isOpen) {
+ ZenLibrary.animateProgress(0);
+ }
+ }
+
+ onKeyDown(e) {
+ if (!this.hasAttribute("open")) {
+ return;
+ }
+ if (e.key === "Escape") {
+ ZenLibrary.animateProgress(0);
+ }
+ }
+
+ firstUpdated() {
+ if (super.firstUpdated) {
+ super.firstUpdated();
+ }
+ this.#buildFooterButtons();
+ }
+
+ #buildFooterButtons() {
+ const footer = this.querySelector("#zen-library-footer");
+
+ const buttons = [
+ {
+ image: "chrome://browser/skin/zen-icons/back.svg",
+ l10nId: "library-footer-close-button",
+ command: () => ZenLibrary.animateProgress(0),
+ },
+ {
+ image: "chrome://browser/skin/zen-icons/heart-circle-fill.svg",
+ l10nId: "library-footer-donate-button",
+ command: () => {
+ window.openTrustedLinkIn("https://www.zen-browser.app/donate", "tab");
+ ZenLibrary.animateProgress(0);
+ },
+ },
+ ];
+
+ for (const { image, l10nId, command } of buttons) {
+ const button = document.createXULElement("toolbarbutton");
+ button.className = "toolbarbutton-1";
+ button.setAttribute("image", image);
+ button.setAttribute("data-l10n-id", l10nId);
+ button.addEventListener("command", command);
+ footer.appendChild(button);
+ }
+ }
+
+ #animateTabIcon(tab) {
+ tab.removeAttribute("animate");
+ // Flush styles so re-adding the attribute restarts the animation.
+ void tab.offsetWidth;
+ tab.setAttribute("animate", "true");
+ }
+
+ render() {
+ return html`
+
+
+
+
+
+
+
+
+ ${this.activeSection.render(this)}
+
+
+ `;
+ }
+}
+
+customElements.define("zen-library", ZenLibrary);
diff --git a/src/zen/library/ZenLibraryDragAndDrop.mjs b/src/zen/library/ZenLibraryDragAndDrop.mjs
new file mode 100644
index 000000000..52e4e859d
--- /dev/null
+++ b/src/zen/library/ZenLibraryDragAndDrop.mjs
@@ -0,0 +1,347 @@
+/* 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/. */
+
+export class ZenLibraryDragAndDrop extends window.ZenDragAndDrop {
+ #section;
+ /** @type {{element: Element, dropBefore: boolean, intoFolder: boolean}|null} */
+ #target = null;
+ #highlightedLabel = null;
+ // The space whose theme the drag image currently wears, so it is only
+ // restyled when the pointer crosses into a different card.
+ #dragImageSpace = null;
+
+ constructor(section) {
+ super(section);
+ this.#section = section;
+ }
+
+ _targetForDrop(element) {
+ return this.#section.realElementFor(element) ?? element;
+ }
+
+ /**
+ * @param {DragEvent} event
+ * @returns {Element|null} The dragged tab, or the label of the dragged
+ * split view, when it comes from this window
+ */
+ #draggedItem(event) {
+ if (this.getDropEffectForTabDrag(event) !== "move") {
+ return null;
+ }
+ const item = event.dataTransfer.mozGetDataAt(TAB_DROP_TYPE, 0);
+ if (!item || item.documentGlobal !== window) {
+ return null;
+ }
+ return gBrowser.isTab(item) || this.#isSplitViewLabel(item) ? item : null;
+ }
+
+ #isSplitViewLabel(item) {
+ return (
+ gBrowser.isTabGroupLabel(item) &&
+ !!item.group?.hasAttribute("split-view-group")
+ );
+ }
+
+ /**
+ * The tabs and split views a drag moves, from the drag data set on the
+ * dragged item.
+ *
+ * @param {Element} item - The dragged tab or split view label
+ * @returns {Element[]} Tabs and split view groups
+ */
+ #movingElements(item) {
+ const moving = item._dragData?.movingTabs ?? [item];
+ return moving.map(element =>
+ gBrowser.isTabGroupLabel(element) ? element.group : element
+ );
+ }
+
+ /**
+ * @param {DragEvent} event
+ * @param {Element} copy - The copied tab being dragged
+ */
+ startTabDrag(event, copy) {
+ let item = this._targetForDrop(copy);
+ if (!gBrowser.isTab(item)) {
+ return;
+ }
+ let shown = copy;
+ if (item.group?.hasAttribute("split-view-group")) {
+ item = item.group.labelElement;
+ shown = copy.group;
+ }
+ const strip = gBrowser.tabContainer.tabDragAndDrop;
+ strip.startTabDrag(event, item, {
+ fromTabList: true,
+ dragImageSource: shown,
+ armLanding: this.#landingSupported,
+ });
+ this.#dragImageSpace = copy.closest(".zen-library-space")?.dataset.uuid;
+ this.#styleDragImage(strip, this.#dragImageSpace);
+ }
+
+ #styleDragImage(strip, uuid) {
+ const workspace = uuid && gZenWorkspaces.getWorkspaceFromId(uuid);
+ if (!workspace) {
+ return;
+ }
+ for (const clone of strip.originalDragImageArgs?.[0]?.querySelectorAll(
+ "tab"
+ ) ?? []) {
+ clone.toggleAttribute("visuallyselected", true);
+ clone.toggleAttribute("selected", true);
+ }
+ strip._recolorDragImage(workspace);
+ }
+
+ handle_dragover(event) {
+ const item = this.#draggedItem(event);
+ if (!item) {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ event.dataTransfer.dropEffect = "move";
+ this.#updateDragImageSpace(event);
+ this._handle_tabDragOverToSplit(event);
+ this.#updateDropTarget(event, item);
+ }
+
+ /**
+ * Recolours the drag image to match the card under the pointer, so it reads
+ * as the space it would drop into.
+ *
+ * @param {DragEvent} event
+ */
+ #updateDragImageSpace(event) {
+ const uuid = event.target.closest(".zen-library-space")?.dataset.uuid;
+ if (!uuid || uuid === this.#dragImageSpace) {
+ return;
+ }
+ this.#dragImageSpace = uuid;
+ const strip = gBrowser.tabContainer.tabDragAndDrop;
+ this.#styleDragImage(strip, uuid);
+ strip._refreshDragImage(event.dataTransfer);
+ }
+
+ handle_dragleave(event) {
+ if (!event.currentTarget.contains(event.relatedTarget)) {
+ this.clearDragOverVisuals();
+ }
+ }
+
+ handle_drop(event) {
+ const item = this.#draggedItem(event);
+ if (!item) {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ event.dataTransfer.dropEffect = "move";
+ const target = this.#target;
+ const uuid = event.currentTarget.closest(".zen-library-space").dataset.uuid;
+ const moving = this.#movingElements(item);
+ const tabs = moving.flatMap(element =>
+ gBrowser.isTab(element) ? [element] : element.tabs
+ );
+ const fromElsewhere = tabs.filter(
+ tab => tab.getAttribute("zen-workspace-id") !== uuid
+ );
+ const strip = gBrowser.tabContainer.tabDragAndDrop;
+ const copyBefore = this.#section.copyForTab(tabs[0]);
+ const placeBefore = copyBefore && strip._placeOf(copyBefore);
+ if (fromElsewhere.length) {
+ gZenWorkspaces.moveTabsToWorkspace(fromElsewhere, uuid);
+ }
+ const split = this._handle_dropCreateSplit(event, { activate: false });
+ this.clearDragOverVisuals();
+ if (!split && target) {
+ const { element, dropBefore, intoFolder } = target;
+ if (intoFolder) {
+ element.addTabs(moving);
+ const firstExisting = element.tabs.find(tab => !tabs.includes(tab));
+ if (firstExisting) {
+ gBrowser.moveTabsBefore(moving, firstExisting);
+ }
+ } else {
+ const pinned = gBrowser.isTab(element)
+ ? element.pinned
+ : (element.tabs?.[0]?.pinned ?? element.pinned);
+ for (const tab of tabs) {
+ if (pinned && !tab.pinned) {
+ gBrowser.pinTab(tab);
+ } else if (!pinned && tab.pinned) {
+ gBrowser.unpinTab(tab);
+ }
+ }
+ if (dropBefore) {
+ gBrowser.moveTabsBefore(moving, element);
+ } else {
+ gBrowser.moveTabsAfter(moving, element);
+ }
+ }
+ }
+ this.#land(tabs[0], placeBefore);
+ }
+
+ get #landingSupported() {
+ return AppConstants.platform === "macosx" && !gReduceMotion;
+ }
+
+ /**
+ * @param {Element} tab - The dropped tab
+ * @param {string|null} placeBefore - Where its copy was before the drop;
+ * a copy still there stays in sight under the image
+ */
+ #land(tab, placeBefore) {
+ if (!this.#landingSupported) {
+ return;
+ }
+ const copy = this.#section.beginTabLanding(tab);
+ if (copy) {
+ gBrowser.tabContainer.tabDragAndDrop._landDragImageOnElements(
+ [copy],
+ new Map([[copy, placeBefore]])
+ );
+ }
+ }
+
+ clearDragOverVisuals(options) {
+ super.clearDragOverVisuals(options);
+ gZenFolders.highlightGroupOnDragOver(null);
+ this.#highlightedLabel?.removeAttribute("dragover");
+ this.#highlightedLabel = null;
+ this.#target = null;
+ }
+
+ /**
+ * @param {Element|null} strip - A card's tab strip
+ * @returns {Element|null} The last tab or folder label shown in it
+ */
+ #lastRow(strip) {
+ if (!strip) {
+ return null;
+ }
+ const rows = strip.querySelectorAll(
+ "tab:not([zen-empty-tab]):not([zen-glance-tab]), .tab-group-label-container"
+ );
+ for (let i = rows.length - 1; i >= 0; i--) {
+ if (rows[i].getBoundingClientRect().height > 0) {
+ return rows[i];
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Works out what a drop at the pointer would do and shows it on the copies.
+ *
+ * @param {DragEvent} event
+ * @param {Element} item - The dragged tab or split view label
+ */
+ #updateDropTarget(event, item) {
+ if (this._splitDropReady) {
+ return;
+ }
+ const movingTabs = item._dragData?.movingTabs ?? [item];
+ const moving = this.#movingElements(item);
+ let copy = event.target.closest("tab, .tab-group-label-container");
+ if (!copy) {
+ copy = this.#lastRow(event.target.closest(".zen-library-space-tabs"));
+ }
+ if (copy?.hasAttribute("zen-glance-tab")) {
+ copy = copy.parentElement.closest("tab");
+ }
+ if (copy?.classList.contains("tab-group-label-container")) {
+ copy = copy.parentElement;
+ } else if (copy?.group?.hasAttribute("split-view-group")) {
+ copy = copy.group;
+ }
+ const element = copy && this._targetForDrop(copy);
+ if (!element) {
+ // A card with nothing in it takes the drop at its start.
+ const strip = event.target.closest(".zen-library-space-tabs");
+ if (strip && !this.#lastRow(strip)) {
+ this.#target = null;
+ gZenFolders.highlightGroupOnDragOver(null);
+ this.#placeIndicatorIn(strip, 0);
+ return;
+ }
+ this.clearDragOverVisuals();
+ return;
+ }
+ if (moving.some(moved => moved === element || moved.contains(element))) {
+ this.clearDragOverVisuals();
+ return;
+ }
+ const isFolder = !!element.isZenFolder;
+ const box = isFolder ? copy.labelContainerElement : copy;
+ const rect = box.getBoundingClientRect();
+ const overlap = (event.clientY - rect.top) / rect.height;
+ let dropBefore;
+ let intoFolder = false;
+ if (isFolder) {
+ const threshold =
+ Services.prefs.getIntPref(
+ "zen.tabs.folder-dragover-threshold-percent"
+ ) / 100;
+ if (overlap < threshold) {
+ dropBefore = true;
+ } else if (
+ overlap > 1 - threshold &&
+ (copy.collapsed || element.childGroupsAndTabs.length < 2)
+ ) {
+ dropBefore = false;
+ } else {
+ intoFolder = true;
+ }
+ } else {
+ dropBefore = this._dropsBefore(event, rect);
+ }
+
+ const previous = this.#target;
+ this.#target = { element, dropBefore, intoFolder };
+ const changed =
+ previous?.element !== element ||
+ previous.dropBefore !== dropBefore ||
+ previous.intoFolder !== intoFolder;
+ if (!changed) {
+ return;
+ }
+ if (previous) {
+ // eslint-disable-next-line mozilla/valid-services
+ Services.zen.playHapticFeedback();
+ }
+
+ this.#highlightedLabel?.removeAttribute("dragover");
+ this.#highlightedLabel = null;
+ if (intoFolder) {
+ gZenPinnedTabManager.removeTabContainersDragoverClass();
+ gZenFolders.highlightGroupOnDragOver(copy, movingTabs);
+ this.#highlightedLabel = box;
+ box.setAttribute("dragover", "true");
+ return;
+ }
+ gZenFolders.highlightGroupOnDragOver(null);
+ const strip = copy.closest(".zen-library-space-tabs");
+ this.#placeIndicatorIn(
+ strip,
+ (dropBefore ? rect.top : rect.bottom) - strip.getBoundingClientRect().top
+ );
+ }
+
+ /**
+ * @param {Element} strip - A card's tab strip
+ * @param {number} offset - Where in the strip's view the drop would go
+ */
+ #placeIndicatorIn(strip, offset) {
+ const inset = 14;
+ this._placeDropIndicator({
+ parent: strip,
+ left: inset,
+ width: strip.getBoundingClientRect().width - 2 * inset,
+ top: offset + strip.scrollTop,
+ });
+ }
+}
diff --git a/src/zen/library/ZenLibraryWidget.sys.mjs b/src/zen/library/ZenLibraryWidget.sys.mjs
new file mode 100644
index 000000000..33894111c
--- /dev/null
+++ b/src/zen/library/ZenLibraryWidget.sys.mjs
@@ -0,0 +1,611 @@
+/* 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/. */
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ DownloadsCommon:
+ "moz-src:///browser/components/downloads/DownloadsCommon.sys.mjs",
+ BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs",
+ DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs",
+ DownloadsViewUI:
+ "moz-src:///browser/components/downloads/DownloadsViewUI.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+});
+
+const ENTRIES = 4;
+const CLOSE_DELAY_MS = 200;
+const FILE_MIME = "application/x-moz-file";
+const BADGE_MARKUP = `
+
+
+
+`;
+
+class ZenLibraryDownloadStack {
+ #button;
+ #badge;
+ #list;
+ #library = null;
+ #entries = [];
+ #data;
+ #downloads = [];
+ #inBatch = false;
+ #loaded = false;
+ #recentNewDownload = false;
+ #closeTimer = null;
+ #contextMenuOpen = false;
+ #secondsLeft = new WeakMap();
+
+ /**
+ * @param {Element} button - The library toolbar button
+ */
+ constructor(button) {
+ this.#button = button;
+ button.setAttribute("command", "cmd_zenToggleLibrary");
+ button.classList.add("toolbarbutton-badge-stack-host");
+ button.appendChild(
+ this.#parse(
+ `${BADGE_MARKUP}`
+ )
+ );
+ this.#badge = button.querySelector(".zen-library-download-badge");
+ this.#badge.id = "library-button-badge";
+ this.#list = this.#buildList();
+
+ button.addEventListener("mouseenter", this);
+ button.addEventListener("mouseleave", this);
+ this.#list.addEventListener("mouseenter", this);
+ this.#list.addEventListener("mouseleave", this);
+
+ this.#data = lazy.DownloadsCommon.getData(this.#window, true);
+ this.#data.addView(this);
+ }
+
+ destroy() {
+ this.#data.removeView(this);
+ this.#window.clearTimeout(this.#closeTimer);
+ this.#list.remove();
+ }
+
+ get #window() {
+ return this.#button.documentGlobal;
+ }
+
+ get #footButtons() {
+ return this.#list.parentElement;
+ }
+
+ /** The tab strip, once the window has it. */
+ get #tabs() {
+ return this.#window.gBrowser?.tabContainer ?? null;
+ }
+
+ #parse(markup) {
+ return this.#window.MozXULElement.parseXULToFragment(markup);
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "mouseenter":
+ this.#window.clearTimeout(this.#closeTimer);
+ if (event.currentTarget === this.#button) {
+ this.#open();
+ }
+ break;
+ case "mouseleave":
+ this.#scheduleClose();
+ break;
+ }
+ }
+
+ #scheduleClose() {
+ this.#window.clearTimeout(this.#closeTimer);
+ this.#closeTimer = this.#window.setTimeout(() => {
+ if (!this.#contextMenuOpen) {
+ this.#close();
+ }
+ }, CLOSE_DELAY_MS);
+ }
+
+ #open() {
+ if (this.#footButtons.hasAttribute("zen-library-stack-open")) {
+ return;
+ }
+ this.#aimBadge();
+ this.#tabs?.removeAttribute("zen-library-stack-closing");
+ for (const host of [this.#footButtons, this.#tabs]) {
+ host?.setAttribute("zen-library-stack-open", "true");
+ }
+ }
+
+ #close() {
+ if (!this.#footButtons.hasAttribute("zen-library-stack-open")) {
+ return;
+ }
+ this.#recentNewDownload = false;
+ this.#updateBadgeShowing();
+ for (const host of [this.#footButtons, this.#tabs]) {
+ host?.removeAttribute("zen-library-stack-open");
+ }
+ // The strip's fade stays until its progress is back at zero.
+ const tabs = this.#tabs;
+ if (!tabs) {
+ return;
+ }
+ tabs.setAttribute("zen-library-stack-closing", "true");
+ tabs.addEventListener("transitionend", function onEnd(event) {
+ if (event.propertyName === "--zen-library-progress") {
+ tabs.removeEventListener("transitionend", onEnd);
+ tabs.removeAttribute("zen-library-stack-closing");
+ }
+ });
+ }
+
+ /**
+ * Points the button's badge at the newest entry's badge, where it flies
+ * to as the list opens, from wherever it is right now.
+ */
+ #aimBadge() {
+ const entry = this.#entries.at(-1);
+ if (entry.hidden) {
+ return;
+ }
+ const target = entry.querySelector(".zen-library-download-badge");
+ const from = this.#badge.getBoundingClientRect();
+ const to = target.getBoundingClientRect();
+ const style = this.#window.getComputedStyle(this.#badge);
+ // The entry is still translated down while closed; land where it ends.
+ const entryTransform = this.#window.getComputedStyle(entry).transform;
+ const rise =
+ entryTransform === "none"
+ ? 0
+ : new this.#window.DOMMatrixReadOnly(entryTransform).f;
+ this.#badge.style.setProperty(
+ "--zen-library-badge-to-x",
+ `${parseFloat(style.left) + to.left - from.left}px`
+ );
+ this.#badge.style.setProperty(
+ "--zen-library-badge-to-y",
+ `${parseFloat(style.top) + to.top - from.top - rise}px`
+ );
+ }
+
+ #buildList() {
+ const document = this.#window.document;
+ const footButtons = document.getElementById("zen-sidebar-foot-buttons");
+ // Not a widget of the toolbar, so its customization leaves it alone.
+ footButtons.appendChild(
+ this.#parse(
+ ``
+ )
+ );
+ const list = document.getElementById("zen-library-download-list");
+ for (let i = 0; i < ENTRIES; i++) {
+ list.appendChild(
+ this.#parse(`
+
+ ${BADGE_MARKUP}
+
+
+
+
+
+
+ `)
+ );
+ }
+ this.#entries = [...list.children];
+ for (const entry of this.#entries) {
+ entry.setAttribute("draggable", "true");
+ entry.addEventListener("dragstart", event =>
+ this.#onDragStart(event, entry.download)
+ );
+ entry.addEventListener("click", event => {
+ if (event.button === 0) {
+ this.#openDownload(entry.download);
+ }
+ });
+ entry.addEventListener("contextmenu", event =>
+ this.#showContextMenu(event, entry.download)
+ );
+ const action = entry.querySelector(".zen-library-download-action");
+ action.addEventListener("click", event => event.stopPropagation());
+ action.addEventListener("command", () =>
+ this.#cancelDownload(entry.download)
+ );
+ }
+ return list;
+ }
+
+ /**
+ * Cancels a download under way, partial file and all.
+ *
+ * @param {Download} download
+ */
+ #cancelDownload(download) {
+ if (!download || download.stopped) {
+ return;
+ }
+ download.cancel().catch(() => {});
+ download
+ .removePartialData()
+ .catch(console.error)
+ .finally(() => download.target.refresh());
+ }
+
+ #openDownload(download) {
+ if (download.succeeded) {
+ lazy.DownloadsCommon.openDownload(download).catch(console.error);
+ } else if (download.source?.url) {
+ this.#window.openTrustedLinkIn(download.source.url, "tab");
+ }
+ }
+
+ #onDragStart(event, download) {
+ if (!download?.succeeded || download.deleted || !download.target?.exists) {
+ event.preventDefault();
+ return;
+ }
+ const file = new lazy.FileUtils.File(download.target.path);
+ const { dataTransfer } = event;
+ dataTransfer.mozSetDataAt(FILE_MIME, file, 0);
+ dataTransfer.effectAllowed = "copyMove";
+ dataTransfer.setData("text/uri-list", Services.io.newFileURI(file).spec);
+ dataTransfer.addElement(event.currentTarget);
+ // eslint-disable-next-line mozilla/valid-services
+ Services.zen.playHapticFeedback();
+ }
+
+ #updateList() {
+ const shown = this.#downloads.slice(-ENTRIES);
+ const unused = ENTRIES - shown.length;
+ this.#entries.forEach((entry, i) => {
+ const download = shown[i - unused];
+ entry.hidden = !download;
+ entry.download = download ?? null;
+ if (!download) {
+ return;
+ }
+ this.#updateBadge(
+ entry.querySelector(".zen-library-download-badge"),
+ download
+ );
+ entry.querySelector(".zen-library-download-list-title").textContent =
+ this.#fileName(download);
+ entry.querySelector(".zen-library-download-list-subtitle").textContent =
+ this.#statusText(download);
+ entry.toggleAttribute("downloading", !download.stopped);
+ });
+ this.#tabs?.style.setProperty(
+ "--zen-library-stack-height",
+ `${this.#list.getBoundingClientRect().height}px`
+ );
+ }
+
+ #updateBadge(badge, download) {
+ const pending = this.#isPending(download);
+ badge.toggleAttribute("downloading", pending);
+ if (badge === this.#badge) {
+ badge.parentElement.toggleAttribute("downloading", pending);
+ }
+ if (download) {
+ badge.style.setProperty(
+ "--download-image",
+ `url('${this.#iconUrl(download)}')`
+ );
+ }
+ badge
+ .querySelector(".zen-library-download-progress")
+ .style.setProperty(
+ "--value",
+ download?.hasProgress ? download.progress : 0
+ );
+ }
+
+ #updateBadgeShowing() {
+ this.#footButtons.toggleAttribute(
+ "zen-library-badge",
+ this.#isPending(this.#downloads.at(-1)) || this.#recentNewDownload
+ );
+ }
+
+ // DownloadList view
+
+ onDownloadBatchStarting() {
+ this.#inBatch = true;
+ }
+
+ onDownloadBatchEnded() {
+ this.#inBatch = false;
+ this.#loaded = true;
+ this.#update();
+ }
+
+ onDownloadAdded(download, { insertBefore } = {}) {
+ const index = insertBefore ? this.#downloads.indexOf(insertBefore) : -1;
+ if (index === -1) {
+ this.#downloads.push(download);
+ } else {
+ this.#downloads.splice(index, 0, download);
+ }
+ if (this.#loaded) {
+ this.#recentNewDownload = true;
+ }
+ this.#update();
+ }
+
+ onDownloadChanged() {
+ this.#update();
+ }
+
+ onDownloadRemoved(download) {
+ const index = this.#downloads.indexOf(download);
+ if (index !== -1) {
+ this.#downloads.splice(index, 1);
+ }
+ this.#update();
+ }
+
+ #update() {
+ if (this.#inBatch) {
+ return;
+ }
+ const newest = this.#downloads.at(-1);
+ this.#updateList();
+ this.#updateBadge(this.#badge, newest);
+ this.#updateBadgeShowing();
+ this.#applyDownloadState();
+ }
+
+ /**
+ * Keeps the newest download's state on the library while it is open, for
+ * its downloads tab to show.
+ *
+ * @param {Element} library
+ */
+ attachLibrary(library) {
+ this.#library = library;
+ this.#applyDownloadState();
+ }
+
+ detachLibrary(library) {
+ if (this.#library === library) {
+ this.#library = null;
+ }
+ }
+
+ #applyDownloadState() {
+ if (!this.#library) {
+ return;
+ }
+ const newest = this.#downloads.at(-1);
+ this.#library.toggleAttribute(
+ "zen-library-downloading",
+ this.#isPending(newest)
+ );
+ this.#library.style.setProperty(
+ "--zen-library-download-progress",
+ `${newest?.hasProgress ? Math.round(newest.progress) : 0}%`
+ );
+ }
+
+ // Helpers
+
+ #fileName(download) {
+ return download.target.path
+ ? PathUtils.filename(download.target.path)
+ : download.source.url;
+ }
+
+ #isPending(download) {
+ return (
+ !!download &&
+ (!download.stopped || (download.canceled && download.hasPartialData))
+ );
+ }
+
+ #joinStatus(...parts) {
+ return parts
+ .filter(Boolean)
+ .reduce((a, b) => lazy.DownloadsCommon.strings.statusSeparator(a, b));
+ }
+
+ #iconUrl(download) {
+ if (!download.target.path) {
+ return "moz-icon://.unknown?size=32";
+ }
+ return `moz-icon://${download.target.path}?size=32${
+ download.succeeded ? "&state=normal" : ""
+ }`;
+ }
+
+ #statusText(download) {
+ const strings = lazy.DownloadsCommon.strings;
+ const totalBytes = download.hasProgress ? download.totalBytes : -1;
+ if (!download.stopped) {
+ const [statusText, secondsLeft] = lazy.DownloadUtils.getDownloadStatus(
+ download.currentBytes,
+ totalBytes,
+ download.speed,
+ this.#secondsLeft.get(download) ?? Infinity
+ );
+ this.#secondsLeft.set(download, secondsLeft);
+ return statusText;
+ }
+ this.#secondsLeft.delete(download);
+ if (download.deleted) {
+ return strings.fileDeleted;
+ }
+ if (download.succeeded) {
+ if (!download.target.exists) {
+ return strings.fileMovedOrMissing;
+ }
+ const parsed = URL.parse(download.source.url);
+ const uri = parsed && Services.io.newURI(parsed.href);
+ const host = uri
+ ? lazy.BrowserUtils.formatURIForDisplay(uri, { onlyBaseDomain: true })
+ : "";
+ const [date] = lazy.DownloadUtils.getReadableDates(
+ new Date(download.endTime)
+ );
+ return this.#joinStatus(
+ lazy.DownloadsViewUI.getSizeWithUnits(download),
+ host,
+ date
+ );
+ }
+ if (download.canceled && download.hasPartialData) {
+ return this.#joinStatus(
+ strings.statePaused,
+ lazy.DownloadUtils.getTransferTotal(download.currentBytes, totalBytes)
+ );
+ }
+ if (download.error?.becauseBlockedByParentalControls) {
+ return strings.stateBlockedParentalControls;
+ }
+ if (download.error?.becauseBlockedByReputationCheck) {
+ return strings.blockedMalware;
+ }
+ return download.canceled ? strings.stateCanceled : strings.stateFailed;
+ }
+
+ #contextMenuItems(download) {
+ const C = lazy.DownloadsCommon;
+ const state = C.stateOfDownload(download);
+ const isActive =
+ state === C.DOWNLOAD_DOWNLOADING || state === C.DOWNLOAD_PAUSED;
+ const fileExists =
+ state === C.DOWNLOAD_FINISHED &&
+ download.target?.exists !== false &&
+ !download.deleted;
+ const sourceUrl = download.source?.originalUrl || download.source?.url;
+ const items = [];
+ if (state === C.DOWNLOAD_DOWNLOADING) {
+ items.push({
+ l10nId: "downloads-cmd-pause",
+ onClick: () => download.cancel().catch(() => {}),
+ });
+ } else if (state === C.DOWNLOAD_PAUSED) {
+ items.push({
+ l10nId: "downloads-cmd-resume",
+ onClick: () => download.start?.().catch(() => {}),
+ });
+ }
+ if (fileExists) {
+ items.push({
+ l10nId: "downloads-cmd-show-menuitem-2",
+ onClick: () =>
+ C.showDownloadedFile(new lazy.FileUtils.File(download.target.path)),
+ });
+ }
+ if (sourceUrl) {
+ items.push({
+ l10nId: "downloads-cmd-go-to-download-page",
+ onClick: () => this.#window.openTrustedLinkIn(sourceUrl, "tab"),
+ });
+ items.push({
+ l10nId: "downloads-cmd-copy-download-link",
+ onClick: () =>
+ Cc["@mozilla.org/widget/clipboardhelper;1"]
+ .getService(Ci.nsIClipboardHelper)
+ .copyString(sourceUrl),
+ });
+ }
+ items.push({ separator: true });
+ if (fileExists) {
+ items.push({
+ l10nId: "downloads-cmd-delete-file",
+ onClick: () =>
+ C.deleteDownloadFiles(
+ download,
+ lazy.DownloadsViewUI.clearHistoryOnDelete
+ ).catch(console.error),
+ });
+ }
+ if (!isActive) {
+ items.push({
+ l10nId: "downloads-cmd-remove-from-history",
+ onClick: () => C.deleteDownload(download).catch(console.error),
+ });
+ }
+ return items;
+ }
+
+ #showContextMenu(event, download) {
+ if (!download) {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+
+ const document = this.#window.document;
+ const popup = document.createXULElement("menupopup");
+ for (const item of this.#contextMenuItems(download)) {
+ if (item.separator) {
+ if (popup.lastChild && popup.lastChild.tagName !== "menuseparator") {
+ popup.appendChild(document.createXULElement("menuseparator"));
+ }
+ continue;
+ }
+ const menuitem = document.createXULElement("menuitem");
+ menuitem.setAttribute("data-l10n-id", item.l10nId);
+ menuitem.addEventListener(
+ "command",
+ () => {
+ try {
+ item.onClick();
+ } catch (ex) {
+ console.error(ex);
+ }
+ },
+ { once: true }
+ );
+ popup.appendChild(menuitem);
+ }
+ if (popup.lastChild?.tagName === "menuseparator") {
+ popup.lastChild.remove();
+ }
+ if (!popup.childElementCount) {
+ return;
+ }
+
+ this.#contextMenuOpen = true;
+ popup.addEventListener(
+ "popuphidden",
+ () => {
+ this.#contextMenuOpen = false;
+ popup.remove();
+ this.#scheduleClose();
+ },
+ { once: true }
+ );
+ document.getElementById("mainPopupSet").appendChild(popup);
+ popup.openPopupAtScreen(event.screenX, event.screenY, true);
+ }
+}
+
+const stacks = new WeakMap();
+
+export const ZenLibraryWidget = {
+ id: "zen-library-button",
+ l10nId: "zen-library-button",
+ _introducedByPref: "zen.library.enabled",
+
+ onCreated(node) {
+ stacks.set(node.ownerDocument, new ZenLibraryDownloadStack(node));
+ },
+
+ onDestroyed(document) {
+ stacks.get(document)?.destroy();
+ stacks.delete(document);
+ },
+
+ attachLibrary(library) {
+ stacks.get(library.ownerDocument)?.attachLibrary(library);
+ },
+
+ detachLibrary(library) {
+ stacks.get(library.ownerDocument)?.detachLibrary(library);
+ },
+};
diff --git a/src/zen/library/jar.inc.mn b/src/zen/library/jar.inc.mn
new file mode 100644
index 000000000..e5f1c182d
--- /dev/null
+++ b/src/zen/library/jar.inc.mn
@@ -0,0 +1,5 @@
+# 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/.
+
+ content/browser/zen-styles/zen-library.css (../../zen/library/zen-library.css)
\ No newline at end of file
diff --git a/src/zen/library/moz.build b/src/zen/library/moz.build
new file mode 100644
index 000000000..464d390cd
--- /dev/null
+++ b/src/zen/library/moz.build
@@ -0,0 +1,15 @@
+# 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/.
+
+MOZ_SRC_FILES += [
+ "sections/ZenLibraryBoostsSection.mjs",
+ "sections/ZenLibraryDownloadsSection.mjs",
+ "sections/ZenLibraryHistorySection.mjs",
+ "sections/ZenLibrarySearchSection.mjs",
+ "sections/ZenLibrarySpacesSection.mjs",
+
+ "ZenLibrary.mjs",
+ "ZenLibraryDragAndDrop.mjs",
+ "ZenLibraryWidget.sys.mjs",
+]
\ No newline at end of file
diff --git a/src/zen/library/sections/ZenLibraryBoostsSection.mjs b/src/zen/library/sections/ZenLibraryBoostsSection.mjs
new file mode 100644
index 000000000..0be8e4ecc
--- /dev/null
+++ b/src/zen/library/sections/ZenLibraryBoostsSection.mjs
@@ -0,0 +1,278 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { html, repeat } from "chrome://global/content/vendor/lit.all.mjs";
+import { ZenLibrarySearchSection } from "moz-src:///zen/library/sections/ZenLibrarySearchSection.mjs";
+
+let lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ gZenBoostsManager: "resource:///modules/zen/boosts/ZenBoostsManager.sys.mjs",
+});
+
+const BOOST_TOPICS = ["zen-boosts-update", "zen-boosts-active-change"];
+
+const boostKey = boost => `${boost.domain}/${boost.id}`;
+
+export class ZenLibraryBoostsSection extends ZenLibrarySearchSection {
+ static id = "boosts";
+ static label = "library-boosts-section-title";
+
+ static render(library) {
+ return html`
+
+ `;
+ }
+
+ #observer = { observe: () => this.requestUpdate() };
+ #glanceBrowser = null;
+ #editor = null;
+ #menu = null;
+ #menuBoost = null;
+ #menuRow = null;
+
+ connectedCallback() {
+ super.connectedCallback();
+ for (const topic of BOOST_TOPICS) {
+ Services.obs.addObserver(this.#observer, topic);
+ }
+ this.#menu = this.#buildMenu();
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ for (const topic of BOOST_TOPICS) {
+ Services.obs.removeObserver(this.#observer, topic);
+ }
+ this.#menu?.hidePopup();
+ this.#menu?.remove();
+ this.#menu = null;
+ this.#glanceBrowser = null;
+ }
+
+ get searchPlaceholderL10nId() {
+ return "library-boosts-search-placeholder";
+ }
+
+ onSearchChanged() {
+ this.requestUpdate();
+ }
+
+ #boosts() {
+ const boosts = [];
+ const query = this.searchQuery.toLowerCase();
+ for (const [domain, entry] of lazy.gZenBoostsManager.registeredDomains) {
+ for (const [id, boostEntry] of entry.boostEntries) {
+ const { boostData } = boostEntry;
+ if (!boostData.changeWasMade) {
+ continue;
+ }
+ if (
+ query &&
+ !boostData.boostName.toLowerCase().includes(query) &&
+ !domain.toLowerCase().includes(query)
+ ) {
+ continue;
+ }
+ boosts.push({
+ id,
+ domain,
+ name: boostData.boostName,
+ enabled: entry.activeBoostId === id,
+ });
+ }
+ }
+ return boosts.sort(
+ (a, b) => a.name.localeCompare(b.name) || a.domain.localeCompare(b.domain)
+ );
+ }
+
+ // Actions
+
+ #toggle(boost) {
+ lazy.gZenBoostsManager.toggleBoostActiveForDomain(boost.domain, boost.id);
+ }
+
+ #onRowClick(boost, row) {
+ if (boost.enabled) {
+ this.#edit(boost, row);
+ } else {
+ this.#toggle(boost);
+ }
+ }
+
+ async #edit(boost, row) {
+ if (this.#glanceBrowser) {
+ return;
+ }
+ const url = `https://${boost.domain}/`;
+ const uri = Services.io.newURI(url);
+ if (!lazy.gZenBoostsManager.canBoostSite(uri)) {
+ return;
+ }
+ const rowRect = row.getBoundingClientRect();
+ const glance = await gZenGlanceManager.openDetachedGlance({
+ url,
+ clientX: rowRect.left + rowRect.width / 2,
+ clientY: rowRect.top + rowRect.height / 2,
+ userContextId: gZenWorkspaces.getActiveWorkspace()?.containerTabId,
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+ if (!glance) {
+ return;
+ }
+ const { browser } = glance;
+ this.#glanceBrowser = browser;
+ glance.closed.then(() => {
+ this.#glanceBrowser = null;
+ this.#editor?.close();
+ this.#editor = null;
+ });
+ await this.#whenNavigated(browser);
+ if (this.#glanceBrowser !== browser) {
+ return;
+ }
+ const stored = lazy.gZenBoostsManager.loadBoostFromStore(
+ boost.domain,
+ boost.id
+ );
+ this.#editor = lazy.gZenBoostsManager.openBoostWindow(window, stored, uri, {
+ browser,
+ });
+ }
+
+ #whenNavigated(browser) {
+ if (browser.currentURI?.spec !== "about:blank") {
+ return Promise.resolve();
+ }
+ return new Promise(resolve => {
+ const listener = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+ ]),
+ onLocationChange(webProgress) {
+ if (webProgress.isTopLevel) {
+ browser.removeProgressListener(listener);
+ resolve();
+ }
+ },
+ };
+ browser.addProgressListener(listener, Ci.nsIWebProgress.NOTIFY_LOCATION);
+ });
+ }
+
+ async #export(boost) {
+ const { boostEntry } = lazy.gZenBoostsManager.loadBoostFromStore(
+ boost.domain,
+ boost.id
+ );
+ await lazy.gZenBoostsManager.exportBoost(window, boostEntry.boostData);
+ }
+
+ #delete(boost) {
+ lazy.gZenBoostsManager.deleteBoost({ domain: boost.domain, id: boost.id });
+ }
+
+ // Context menu
+
+ #buildMenu() {
+ const menu = window.MozXULElement.parseXULToFragment(`
+
+ `).firstElementChild;
+ menu.addEventListener("command", event => {
+ const boost = this.#menuBoost;
+ const row = this.#menuRow;
+ if (!boost) {
+ return;
+ }
+ switch (event.target.dataset.action) {
+ case "edit":
+ this.#edit(boost, row);
+ break;
+ case "export":
+ this.#export(boost);
+ break;
+ case "delete":
+ this.#delete(boost);
+ break;
+ }
+ });
+ menu.addEventListener("popuphidden", () => {
+ this.#menuRow?.removeAttribute("menu-open");
+ this.#menuRow = null;
+ this.#menuBoost = null;
+ });
+ document.getElementById("mainPopupSet").appendChild(menu);
+ return menu;
+ }
+
+ #openMenu(boost, row, event) {
+ this.#menuRow?.removeAttribute("menu-open");
+ this.#menuBoost = boost;
+ this.#menuRow = row;
+ row.setAttribute("menu-open", "true");
+ this.#menu.openPopupAtScreen(event.screenX, event.screenY, true, event);
+ }
+
+ // Rendering
+
+ #renderBoost(boost) {
+ return html`
+ this.#onRowClick(boost, event.currentTarget)}
+ @contextmenu=${event => {
+ event.preventDefault();
+ this.#openMenu(boost, event.currentTarget, event);
+ }}
+ >
+
+

+
+
+ ${boost.name}
+ ${boost.domain}
+
+
+ event.stopPropagation()}
+ @toggle=${() => this.#toggle(boost)}
+ >
+
+
+ `;
+ }
+
+ renderItems() {
+ const boosts = this.#boosts();
+ if (!boosts.length) {
+ return html`
+
+ `;
+ }
+ return html`
+
+ ${repeat(boosts, boostKey, boost => this.#renderBoost(boost))}
+
+ `;
+ }
+}
+
+customElements.define("zen-library-boosts-section", ZenLibraryBoostsSection);
diff --git a/src/zen/library/sections/ZenLibraryDownloadsSection.mjs b/src/zen/library/sections/ZenLibraryDownloadsSection.mjs
new file mode 100644
index 000000000..4baf76afc
--- /dev/null
+++ b/src/zen/library/sections/ZenLibraryDownloadsSection.mjs
@@ -0,0 +1,664 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { html, repeat, when } from "chrome://global/content/vendor/lit.all.mjs";
+import {
+ MS_PER_DAY,
+ PAGE_SIZE,
+ ZenLibrarySearchSection,
+ whenFilterGroup,
+} from "moz-src:///zen/library/sections/ZenLibrarySearchSection.mjs";
+
+let lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs",
+ DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs",
+ DownloadsCommon:
+ "moz-src:///browser/components/downloads/DownloadsCommon.sys.mjs",
+ DownloadsViewUI:
+ "moz-src:///browser/components/downloads/DownloadsViewUI.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+});
+
+const FILE_MIME = "application/x-moz-file";
+const OPENING_FEEDBACK_MS = 1500;
+
+const FILE_TYPES = {
+ images: "png jpg jpeg gif webp svg bmp tif tiff heic heif avif ico",
+ video: "mp4 mkv mov avi webm m4v wmv flv mpg mpeg",
+ audio: "mp3 wav flac aac ogg oga m4a opus wma aiff",
+ documents:
+ "pdf doc docx xls xlsx ppt pptx txt md rtf odt ods odp csv epub pages numbers key",
+ archives: "zip rar 7z tar gz bz2 xz tgz zst",
+ apps: "dmg pkg exe msi app deb rpm appimage apk jar",
+};
+const EXTENSION_TYPES = new Map();
+for (const [type, extensions] of Object.entries(FILE_TYPES)) {
+ for (const extension of extensions.split(" ")) {
+ EXTENSION_TYPES.set(extension, type);
+ }
+}
+
+export class ZenLibraryDownloadsSection extends ZenLibrarySearchSection {
+ static id = "downloads";
+ static label = "library-downloads-section-title";
+
+ static render(library) {
+ return html`
+
+ `;
+ }
+
+ // Oldest to newest, mirroring the order of the underlying download list.
+ #downloads = [];
+ #data = null;
+ #loaded = false;
+ #inBatch = false;
+ #limit = PAGE_SIZE;
+ #visible = [];
+ #refreshed = new WeakSet();
+ #secondsLeft = new WeakMap();
+
+ #menu = null;
+ #menuDownload = null;
+ #menuRow = null;
+ #openingDownload = null;
+ #openingTimer = null;
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.#data = lazy.DownloadsCommon.getData(window, true);
+ this.#data.addView(this);
+ this.#menu = this.#buildMenu();
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ this.#data?.removeView(this);
+ this.#data = null;
+ this.#menu?.hidePopup();
+ this.#menu?.remove();
+ this.#menu = null;
+ clearTimeout(this.#openingTimer);
+ this.#openingTimer = null;
+ this.#openingDownload = null;
+ }
+
+ get searchPlaceholderL10nId() {
+ return "places-search-downloads";
+ }
+
+ get filterTitleL10nId() {
+ return "library-downloads-filter-title";
+ }
+
+ get filterGroups() {
+ return [
+ {
+ id: "type",
+ titleL10nId: "library-downloads-filter-type",
+ options: Object.keys(FILE_TYPES).map(id => ({
+ id,
+ l10nId: `library-downloads-type-${id}`,
+ })),
+ },
+ whenFilterGroup("library-downloads-filter-when"),
+ ];
+ }
+
+ onSearchChanged() {
+ this.#limit = PAGE_SIZE;
+ this.requestUpdate();
+ }
+
+ onFiltersChanged() {
+ this.#limit = PAGE_SIZE;
+ this.requestUpdate();
+ }
+
+ onListScrolledToEnd() {
+ if (this.#visible.length < this.#limit) {
+ return;
+ }
+ this.#limit += PAGE_SIZE;
+ this.requestUpdate();
+ }
+
+ // DownloadList view
+
+ onDownloadBatchStarting() {
+ this.#inBatch = true;
+ }
+
+ onDownloadBatchEnded() {
+ this.#inBatch = false;
+ this.#loaded = true;
+ this.requestUpdate();
+ }
+
+ onDownloadAdded(download, { insertBefore } = {}) {
+ const index = insertBefore ? this.#downloads.indexOf(insertBefore) : -1;
+ if (index === -1) {
+ this.#downloads.push(download);
+ } else {
+ this.#downloads.splice(index, 0, download);
+ }
+ this.#scheduleUpdate();
+ }
+
+ onDownloadChanged() {
+ this.#scheduleUpdate();
+ }
+
+ onDownloadRemoved(download) {
+ const index = this.#downloads.indexOf(download);
+ if (index !== -1) {
+ this.#downloads.splice(index, 1);
+ }
+ if (this.#menuDownload === download) {
+ this.#menu?.hidePopup();
+ }
+ this.#scheduleUpdate();
+ }
+
+ #scheduleUpdate() {
+ if (!this.#inBatch) {
+ this.requestUpdate();
+ }
+ }
+
+ updated(changedProperties) {
+ super.updated(changedProperties);
+ // Check that the files of rendered downloads still exist, like the
+ // downloads panel does when it opens.
+ for (const download of this.#visible) {
+ if (download.succeeded && !this.#refreshed.has(download)) {
+ this.#refreshed.add(download);
+ download.refresh().catch(console.error);
+ }
+ }
+ }
+
+ // Helpers
+
+ #fileName(download) {
+ return download.target.path
+ ? PathUtils.filename(download.target.path)
+ : download.source.url;
+ }
+
+ #hasFile(download) {
+ return download.succeeded && !download.deleted && download.target.exists;
+ }
+
+ #file(download) {
+ return new lazy.FileUtils.File(download.target.path);
+ }
+
+ #formatUrl(url) {
+ return url.replace(/^https?:\/\/(www\.)?/, "").replace(/\/$/, "");
+ }
+
+ #matchesQuery(download) {
+ const query = this.searchQuery.toLowerCase();
+ return (
+ this.#fileName(download).toLowerCase().includes(query) ||
+ download.source.url.toLowerCase().includes(query)
+ );
+ }
+
+ #fileType(download) {
+ const extension = this.#fileName(download).match(/\.([^.]+)$/)?.[1];
+ return extension ? EXTENSION_TYPES.get(extension.toLowerCase()) : undefined;
+ }
+
+ #activeTypes() {
+ return Object.keys(FILE_TYPES).filter(id =>
+ this.isFilterActive("type", id)
+ );
+ }
+
+ #whenCutoff() {
+ const days = this.activeWhenDays;
+ return days ? Date.now() - days * MS_PER_DAY : 0;
+ }
+
+ #computeVisible() {
+ const visible = [];
+ const types = this.#activeTypes();
+ const cutoff = this.#whenCutoff();
+ for (let i = this.#downloads.length - 1; i >= 0; i--) {
+ const download = this.#downloads[i];
+ if (types.length && !types.includes(this.#fileType(download))) {
+ continue;
+ }
+ if (cutoff && !(download.endTime >= cutoff)) {
+ continue;
+ }
+ if (!this.searchQuery || this.#matchesQuery(download)) {
+ visible.push(download);
+ if (visible.length >= this.#limit) {
+ break;
+ }
+ }
+ }
+ return visible;
+ }
+
+ #iconUrl(download) {
+ if (!download.target.path) {
+ return "moz-icon://.unknown?size=32";
+ }
+ return `moz-icon://${download.target.path}?size=32${
+ download.succeeded ? "&state=normal" : ""
+ }`;
+ }
+
+ #isPending(download) {
+ return !download.stopped || (download.canceled && download.hasPartialData);
+ }
+
+ #joinStatus(...parts) {
+ return parts
+ .filter(Boolean)
+ .reduce((a, b) => lazy.DownloadsCommon.strings.statusSeparator(a, b));
+ }
+
+ #statusText(download) {
+ const strings = lazy.DownloadsCommon.strings;
+ const totalBytes = download.hasProgress ? download.totalBytes : -1;
+ if (!download.stopped) {
+ const [statusText, secondsLeft] = lazy.DownloadUtils.getDownloadStatus(
+ download.currentBytes,
+ totalBytes,
+ download.speed,
+ this.#secondsLeft.get(download) ?? Infinity
+ );
+ this.#secondsLeft.set(download, secondsLeft);
+ return statusText;
+ }
+ this.#secondsLeft.delete(download);
+ if (download.deleted) {
+ return strings.fileDeleted;
+ }
+ if (download.succeeded) {
+ if (!download.target.exists) {
+ return strings.fileMovedOrMissing;
+ }
+ const parsed = URL.parse(download.source.url);
+ const uri = parsed && Services.io.newURI(parsed.href);
+ const host = uri
+ ? lazy.BrowserUtils.formatURIForDisplay(uri, { onlyBaseDomain: true })
+ : "";
+ const [date] = lazy.DownloadUtils.getReadableDates(
+ new Date(download.endTime)
+ );
+ return this.#joinStatus(
+ lazy.DownloadsViewUI.getSizeWithUnits(download),
+ host,
+ date
+ );
+ }
+ if (download.canceled && download.hasPartialData) {
+ return this.#joinStatus(
+ strings.statePaused,
+ lazy.DownloadUtils.getTransferTotal(download.currentBytes, totalBytes)
+ );
+ }
+ if (download.error?.becauseBlockedByParentalControls) {
+ return strings.stateBlockedParentalControls;
+ }
+ if (download.error?.becauseBlockedByReputationCheck) {
+ return strings.blockedMalware;
+ }
+ return download.canceled ? strings.stateCanceled : strings.stateFailed;
+ }
+
+ // Actions
+
+ #showInFolder(download) {
+ if (!this.#hasFile(download)) {
+ return;
+ }
+ lazy.DownloadsCommon.showDownloadedFile(this.#file(download));
+ }
+
+ #onRowClick(download) {
+ if (!this.#hasFile(download)) {
+ return;
+ }
+ this.#showInFolder(download);
+ clearTimeout(this.#openingTimer);
+ this.#openingDownload = download;
+ this.#openingTimer = setTimeout(() => {
+ this.#openingTimer = null;
+ this.#openingDownload = null;
+ this.requestUpdate();
+ }, OPENING_FEEDBACK_MS);
+ this.requestUpdate();
+ }
+
+ #canRetry(download) {
+ return (
+ download.stopped &&
+ !download.succeeded &&
+ (download.canceled || !!download.error)
+ );
+ }
+
+ #retryDownload(download) {
+ if (download.start) {
+ // Errors when retrying are already reported as download failures.
+ download.start().catch(() => {});
+ return;
+ }
+ // History-only entries have no session object, so download the URL again.
+ const targetName = download.target.path
+ ? PathUtils.filename(download.target.path)
+ : null;
+ window.DownloadURL(download.source.url, targetName, document);
+ }
+
+ #cancelDownload(download) {
+ download.cancel().catch(() => {});
+ download
+ .removePartialData()
+ .catch(console.error)
+ .finally(() => download.target.refresh());
+ }
+
+ #openDownload(download) {
+ if (!this.#hasFile(download)) {
+ return;
+ }
+ lazy.DownloadsCommon.openDownload(download, { openWhere: "tab" });
+ }
+
+ #copyFile(download) {
+ if (!this.#hasFile(download)) {
+ return;
+ }
+ const transferable = Cc[
+ "@mozilla.org/widget/transferable;1"
+ ].createInstance(Ci.nsITransferable);
+ transferable.init(window.docShell.QueryInterface(Ci.nsILoadContext));
+ transferable.addDataFlavor(FILE_MIME);
+ transferable.setTransferData(FILE_MIME, this.#file(download));
+ Services.clipboard.setData(
+ transferable,
+ null,
+ Services.clipboard.kGlobalClipboard
+ );
+ }
+
+ #hideDownload(download) {
+ lazy.DownloadsCommon.deleteDownload(download).catch(console.error);
+ }
+
+ #trashDownload(download) {
+ lazy.DownloadsCommon.deleteDownloadFiles(
+ download,
+ lazy.DownloadsViewUI.clearHistoryOnDelete
+ ).catch(console.error);
+ }
+
+ #isActionEnabled(action, download) {
+ switch (action) {
+ case "open":
+ case "copy":
+ case "show":
+ return this.#hasFile(download);
+ case "copy-link":
+ return !download.source.isDataURICleared;
+ case "trash":
+ return (
+ this.#hasFile(download) ||
+ !download.stopped ||
+ download.hasPartialData
+ );
+ default:
+ return true;
+ }
+ }
+
+ #doAction(action, download) {
+ switch (action) {
+ case "open":
+ this.#openDownload(download);
+ break;
+ case "copy":
+ this.#copyFile(download);
+ break;
+ case "copy-link":
+ lazy.DownloadsCommon.copyDownloadLink(download);
+ break;
+ case "show":
+ this.#showInFolder(download);
+ break;
+ case "hide":
+ this.#hideDownload(download);
+ break;
+ case "trash":
+ this.#trashDownload(download);
+ break;
+ }
+ }
+
+ #onDragStart(event, download) {
+ if (!this.#hasFile(download) || !this.#file(download).exists()) {
+ event.preventDefault();
+ return;
+ }
+ const file = this.#file(download);
+ const { dataTransfer } = event;
+ dataTransfer.mozSetDataAt(FILE_MIME, file, 0);
+ dataTransfer.effectAllowed = "copyMove";
+ dataTransfer.setData("text/uri-list", Services.io.newFileURI(file).spec);
+ dataTransfer.addElement(event.currentTarget);
+ // eslint-disable-next-line mozilla/valid-services
+ Services.zen.playHapticFeedback();
+ }
+
+ // Context menu
+
+ #buildMenu() {
+ const menu = window.MozXULElement.parseXULToFragment(`
+
+ `).firstElementChild;
+ menu.addEventListener("command", event => {
+ if (this.#menuDownload) {
+ this.#doAction(event.target.dataset.action, this.#menuDownload);
+ }
+ });
+ menu.addEventListener("popuphidden", () => {
+ this.#menuRow?.removeAttribute("menu-open");
+ this.#menuRow = null;
+ this.#menuDownload = null;
+ });
+ document.getElementById("mainPopupSet").appendChild(menu);
+ return menu;
+ }
+
+ #openMenu(download, row, anchor, event) {
+ const fileName = this.#fileName(download);
+ for (const item of this.#menu.querySelectorAll("menuitem")) {
+ const { action } = item.dataset;
+ if (action === "open" || action === "copy") {
+ document.l10n.setAttributes(item, item.dataset.l10nId, {
+ name: fileName,
+ });
+ }
+ item.disabled = !this.#isActionEnabled(action, download);
+ }
+ this.#menuRow?.removeAttribute("menu-open");
+ this.#menuRow = row;
+ this.#menuDownload = download;
+ row.setAttribute("menu-open", "true");
+ if (anchor) {
+ this.#menu.openPopup(anchor, "after_end", 0, 4, false, false, event);
+ } else {
+ this.#menu.openPopupAtScreen(event.screenX, event.screenY, true, event);
+ }
+ }
+
+ // Rendering
+
+ #renderSubtitle(download) {
+ const statusNode =
+ download === this.#openingDownload
+ ? html``
+ : html`${this.#statusText(download)}`;
+ return html`
+
+ ${statusNode}
+ ${this.#formatUrl(download.source.url)}
+
+ `;
+ }
+
+ #renderCancelButton(download) {
+ return html`
+ {
+ event.stopPropagation();
+ this.#cancelDownload(download);
+ }}
+ >
+
+
+ `;
+ }
+
+ #renderRetryButton(download) {
+ return html`
+ {
+ event.stopPropagation();
+ this.#retryDownload(download);
+ }}
+ >
+
+
+ `;
+ }
+
+ #renderDownload(download) {
+ const pending = this.#isPending(download);
+ const progress = download.hasProgress ? download.progress : 0;
+ return html`
+ this.#onRowClick(download)}
+ @contextmenu=${event => {
+ event.preventDefault();
+ this.#openMenu(download, event.currentTarget, null, event);
+ }}
+ @dragstart=${event => this.#onDragStart(event, download)}
+ >
+
})
+
+ ${this.#fileName(download)}
+ ${this.#renderSubtitle(download)}
+
+
+ ${when(!download.stopped, () => this.#renderCancelButton(download))}
+ ${when(this.#canRetry(download), () =>
+ this.#renderRetryButton(download)
+ )}
+
{
+ event.stopPropagation();
+ this.#openMenu(
+ download,
+ event.currentTarget.closest(".zen-library-row"),
+ event.currentTarget,
+ event
+ );
+ }}
+ >
+
+
+
+
+ `;
+ }
+
+ renderItems() {
+ if (!this.#loaded) {
+ return null;
+ }
+ this.#visible = this.#computeVisible();
+ if (!this.#visible.length) {
+ return html`
+
+ `;
+ }
+ return html`
+
+ ${repeat(
+ this.#visible,
+ download => download,
+ download => this.#renderDownload(download)
+ )}
+
+ `;
+ }
+}
+
+customElements.define(
+ "zen-library-downloads-section",
+ ZenLibraryDownloadsSection
+);
diff --git a/src/zen/library/sections/ZenLibraryHistorySection.mjs b/src/zen/library/sections/ZenLibraryHistorySection.mjs
new file mode 100644
index 000000000..ea6389950
--- /dev/null
+++ b/src/zen/library/sections/ZenLibraryHistorySection.mjs
@@ -0,0 +1,361 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { html, repeat } from "chrome://global/content/vendor/lit.all.mjs";
+import {
+ MS_PER_DAY,
+ PAGE_SIZE,
+ ZenLibrarySearchSection,
+ whenFilterGroup,
+} from "moz-src:///zen/library/sections/ZenLibrarySearchSection.mjs";
+
+let lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ PlacesQuery: "resource://gre/modules/PlacesQuery.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(
+ lazy,
+ "relativeDayFormat",
+ () => new Intl.RelativeTimeFormat(undefined, { numeric: "auto" })
+);
+
+ChromeUtils.defineLazyGetter(
+ lazy,
+ "dateFormat",
+ () => new Intl.DateTimeFormat(undefined, { dateStyle: "medium" })
+);
+
+const HISTORY_DAYS_OLD = 120;
+const SORT_OPTIONS = ["date", "site", "mostvisited", "lastvisited"];
+
+const visitKey = visit => `${visit.guid}-${visit.date.getTime()}`;
+
+export class ZenLibraryHistorySection extends ZenLibrarySearchSection {
+ static id = "history";
+ static label = "library-history-section-title";
+
+ static render(library) {
+ return html`
+
+ `;
+ }
+
+ static properties = {
+ visits: { state: true },
+ };
+
+ #placesQuery = null;
+ #limit = PAGE_SIZE;
+ #fetchGeneration = 0;
+ #exhausted = false;
+
+ constructor() {
+ super();
+ this.visits = null;
+ this.activeFilters.add("sort:date");
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.#placesQuery = new lazy.PlacesQuery();
+ this.#placesQuery.observeHistory(() => this.#fetch());
+ this.#fetch();
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ this.#placesQuery?.close();
+ this.#placesQuery = null;
+ }
+
+ get searchPlaceholderL10nId() {
+ return "library-history-search-placeholder";
+ }
+
+ get filterTitleL10nId() {
+ return "library-history-filter-title";
+ }
+
+ get filterGroups() {
+ return [
+ whenFilterGroup("library-history-filter-when"),
+ {
+ id: "sort",
+ titleL10nId: "library-history-filter-sort",
+ exclusive: true,
+ options: [
+ { id: "date", l10nId: "library-history-sort-date" },
+ { id: "site", l10nId: "library-history-sort-site" },
+ { id: "mostvisited", l10nId: "library-history-sort-most-visited" },
+ { id: "lastvisited", l10nId: "library-history-sort-last-visited" },
+ ],
+ },
+ ];
+ }
+
+ get #activeDaysOld() {
+ return this.activeWhenDays ?? HISTORY_DAYS_OLD;
+ }
+
+ get #activeSort() {
+ return SORT_OPTIONS.find(id => this.isFilterActive("sort", id)) ?? "date";
+ }
+
+ onSearchChanged() {
+ this.#resetAndFetch();
+ }
+
+ onFiltersChanged() {
+ if (!SORT_OPTIONS.some(id => this.isFilterActive("sort", id))) {
+ this.activeFilters.add("sort:date");
+ this.requestUpdate();
+ }
+ this.#resetAndFetch();
+ }
+
+ onListScrolledToEnd() {
+ if (this.#exhausted || !this.#placesQuery) {
+ return;
+ }
+ this.#limit += PAGE_SIZE;
+ this.#fetch();
+ }
+
+ #resetAndFetch() {
+ this.#limit = PAGE_SIZE;
+ this.#exhausted = false;
+ this.#fetch();
+ }
+
+ async #fetch() {
+ if (!this.#placesQuery) {
+ return;
+ }
+ const generation = ++this.#fetchGeneration;
+ const daysOld = this.#activeDaysOld;
+ let visits;
+ if (this.searchQuery) {
+ visits =
+ (await this.#placesQuery.searchHistory(
+ this.searchQuery,
+ this.#limit
+ )) ?? [];
+ } else if (this.#activeSort === "mostvisited") {
+ visits = this.#fetchMostVisited(daysOld, this.#limit);
+ } else {
+ visits = await this.#placesQuery.getHistory({
+ daysOld,
+ limit: this.#limit,
+ sortBy: this.#activeSort,
+ });
+ }
+ if (generation !== this.#fetchGeneration || !this.#placesQuery) {
+ return;
+ }
+ this.#exhausted = this.#countVisits(visits) < this.#limit;
+ if (this.searchQuery && daysOld !== HISTORY_DAYS_OLD) {
+ const cutoff = Date.now() - daysOld * MS_PER_DAY;
+ visits = visits.filter(visit => visit.date.getTime() >= cutoff);
+ }
+ this.visits = visits;
+ // The cached Map is mutated in place, so its identity may not change.
+ this.requestUpdate();
+ }
+
+ #countVisits(visits) {
+ if (Array.isArray(visits)) {
+ return visits.length;
+ }
+ let count = 0;
+ for (const groupVisits of visits.values()) {
+ count += groupVisits.length;
+ }
+ return count;
+ }
+
+ #fetchMostVisited(daysOld, limit) {
+ const query = lazy.PlacesUtils.history.getNewQuery();
+ query.beginTime = lazy.PlacesUtils.toPRTime(
+ Date.now() - daysOld * MS_PER_DAY
+ );
+ const options = lazy.PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = options.SORT_BY_VISITCOUNT_DESCENDING;
+ options.maxResults = limit;
+ const root = lazy.PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+ const visits = [];
+ for (let i = 0; i < root.childCount; i++) {
+ const node = root.getChild(i);
+ visits.push({
+ url: node.uri,
+ title: node.title,
+ date: lazy.PlacesUtils.toDate(node.time),
+ guid: node.pageGuid,
+ });
+ }
+ root.containerOpen = false;
+ return visits;
+ }
+
+ #openVisit(visit) {
+ window.openTrustedLinkIn(visit.url, "tab");
+ this.library?.constructor.toggle();
+ }
+
+ #forgetVisit(visit) {
+ lazy.PlacesUtils.history.remove(visit.url);
+ this.visits = this.#withoutUrl(this.visits, visit.url);
+ }
+
+ #withoutUrl(container, url) {
+ if (Array.isArray(container)) {
+ return container.filter(visit => visit.url !== url);
+ }
+ const remaining = new Map();
+ for (const [key, groupVisits] of container) {
+ const filtered = groupVisits.filter(visit => visit.url !== url);
+ if (filtered.length) {
+ remaining.set(key, filtered);
+ }
+ }
+ return remaining;
+ }
+
+ #formatDay(day) {
+ const daysAgo = Math.round(
+ (this.#placesQuery.getStartOfDayTimestamp(new Date()) - day) / MS_PER_DAY
+ );
+ const formatted =
+ daysAgo <= 6
+ ? lazy.relativeDayFormat.format(-daysAgo, "day")
+ : lazy.dateFormat.format(day);
+ return formatted.charAt(0).toLocaleUpperCase() + formatted.slice(1);
+ }
+
+ #formatUrl(url) {
+ return url.replace(/^https?:\/\/(www\.)?/, "").replace(/\/$/, "");
+ }
+
+ /**
+ * A visit drags as a link, so it can be dropped onto content, the tab
+ * strip or another app.
+ *
+ * @param {DragEvent} event
+ * @param {object} visit
+ */
+ #onDragStart(event, visit) {
+ const { dataTransfer } = event;
+ const title = visit.title || visit.url;
+ dataTransfer.setData("text/x-moz-url", `${visit.url}\n${title}`);
+ dataTransfer.setData("text/uri-list", visit.url);
+ dataTransfer.setData("text/plain", visit.url);
+ dataTransfer.effectAllowed = "copyLink";
+ dataTransfer.addElement(event.currentTarget);
+ // eslint-disable-next-line mozilla/valid-services
+ Services.zen.playHapticFeedback();
+ }
+
+ #renderVisit(visit) {
+ return html`
+ this.#openVisit(visit)}
+ @dragstart=${event => this.#onDragStart(event, visit)}
+ >
+

+
+ ${visit.title || visit.url}
+ ${this.#formatUrl(visit.url)}
+
+
+
{
+ event.stopPropagation();
+ this.#forgetVisit(visit);
+ }}
+ >
+
+
+
{
+ event.stopPropagation();
+ this.#openVisit(visit);
+ }}
+ >
+
+
+
+
+ `;
+ }
+
+ #renderVisits(visits) {
+ return repeat(visits, visitKey, visit => this.#renderVisit(visit));
+ }
+
+ #renderGroupHeader(key) {
+ if (typeof key === "number") {
+ return html`${this.#formatDay(key)}
`;
+ }
+ return key
+ ? html`${key}
`
+ : html``;
+ }
+
+ #renderEmpty() {
+ return html`
+
+ `;
+ }
+
+ renderItems() {
+ if (!this.visits) {
+ return null;
+ }
+ if (Array.isArray(this.visits)) {
+ if (!this.visits.length) {
+ return this.#renderEmpty();
+ }
+ return html`
+ ${this.#renderVisits(this.visits)}
+ `;
+ }
+ if (!this.visits.size) {
+ return this.#renderEmpty();
+ }
+ return repeat(
+ this.visits.entries(),
+ ([key]) => key,
+ ([key, groupVisits]) => html`
+
+ ${this.#renderGroupHeader(key)} ${this.#renderVisits(groupVisits)}
+
+ `
+ );
+ }
+}
+
+customElements.define("zen-library-history-section", ZenLibraryHistorySection);
diff --git a/src/zen/library/sections/ZenLibrarySearchSection.mjs b/src/zen/library/sections/ZenLibrarySearchSection.mjs
new file mode 100644
index 000000000..23668400b
--- /dev/null
+++ b/src/zen/library/sections/ZenLibrarySearchSection.mjs
@@ -0,0 +1,265 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { html, when } from "chrome://global/content/vendor/lit.all.mjs";
+import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
+
+const SEARCH_DEBOUNCE_MS = 250;
+
+export const PAGE_SIZE = 100;
+export const MS_PER_DAY = 86400000;
+const WHEN_DAYS = { today: 1, week: 7, month: 30 };
+
+/**
+ * Builds the exclusive "when" filter group shared by time-based sections.
+ *
+ * @param {string} titleL10nId - Fluent id of the group title
+ */
+export function whenFilterGroup(titleL10nId) {
+ return {
+ id: "when",
+ titleL10nId,
+ exclusive: true,
+ options: Object.keys(WHEN_DAYS).map(id => ({
+ id,
+ l10nId: `library-filter-${id}`,
+ })),
+ };
+}
+
+export class ZenLibrarySearchSection extends MozLitElement {
+ static properties = {
+ searchQuery: { type: String, state: true },
+ filtersOpen: { type: Boolean, state: true },
+ };
+
+ #searchDebounce = null;
+ #sentinelObserver = null;
+
+ constructor() {
+ super();
+ this.searchQuery = "";
+ this.filtersOpen = false;
+ this.activeFilters = new Set();
+ }
+
+ createRenderRoot() {
+ return this;
+ }
+
+ get searchPlaceholderL10nId() {
+ return "";
+ }
+
+ get filterTitleL10nId() {
+ return "";
+ }
+
+ /**
+ * Filter groups shown in the filter panel. Sections without filters keep
+ * this empty, which also hides the filter button. An `exclusive` group
+ * behaves like a radio group where the active option can also be toggled
+ * back off.
+ *
+ * @returns {{id: string, titleL10nId: string, exclusive?: boolean,
+ * options: {id: string, l10nId?: string, label?: string,
+ * icon?: string}[]}[]}
+ */
+ get filterGroups() {
+ return [];
+ }
+
+ renderItems() {
+ return null;
+ }
+
+ onSearchChanged() {}
+ onFiltersChanged() {}
+ onListScrolledToEnd() {}
+
+ isFilterActive(groupId, optionId) {
+ return this.activeFilters.has(`${groupId}:${optionId}`);
+ }
+
+ /**
+ * Number of days selected in the "when" filter group, or null when no
+ * option is active.
+ */
+ get activeWhenDays() {
+ for (const [id, days] of Object.entries(WHEN_DAYS)) {
+ if (this.isFilterActive("when", id)) {
+ return days;
+ }
+ }
+ return null;
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ if (this.#searchDebounce) {
+ clearTimeout(this.#searchDebounce);
+ this.#searchDebounce = null;
+ }
+ this.#sentinelObserver?.disconnect();
+ this.#sentinelObserver = null;
+ }
+
+ firstUpdated() {
+ if (super.firstUpdated) {
+ super.firstUpdated();
+ }
+
+ const results = this.querySelector(".zen-library-search-results");
+ this.#sentinelObserver = new IntersectionObserver(
+ entries => {
+ if (entries.some(entry => entry.isIntersecting)) {
+ this.onListScrolledToEnd();
+ }
+ },
+ { root: results, rootMargin: "200px" }
+ );
+ this.#sentinelObserver.observe(
+ this.querySelector(".zen-library-search-sentinel")
+ );
+
+ results.addEventListener(
+ "scroll",
+ () => results.toggleAttribute("scrolled", results.scrollTop > 0),
+ { passive: true }
+ );
+ }
+
+ updated(changedProperties) {
+ if (super.updated) {
+ super.updated(changedProperties);
+ }
+ if (changedProperties.has("filtersOpen") && this.filtersOpen) {
+ const inner = this.querySelector(".zen-library-filter-panel-inner");
+ this.style.setProperty(
+ "--zen-library-filter-height",
+ `${inner.scrollHeight + 8}px`
+ );
+ }
+ }
+
+ #onSearchInput(event) {
+ const { value } = event.target;
+ if (this.#searchDebounce) {
+ clearTimeout(this.#searchDebounce);
+ }
+ this.#searchDebounce = setTimeout(() => {
+ this.#searchDebounce = null;
+ const query = value.trim();
+ if (query !== this.searchQuery) {
+ this.searchQuery = query;
+ this.onSearchChanged();
+ }
+ }, SEARCH_DEBOUNCE_MS);
+ }
+
+ #toggleFilter(group, optionId) {
+ const key = `${group.id}:${optionId}`;
+ const wasActive = this.activeFilters.has(key);
+ if (group.exclusive) {
+ for (const option of group.options) {
+ this.activeFilters.delete(`${group.id}:${option.id}`);
+ }
+ }
+ if (wasActive) {
+ this.activeFilters.delete(key);
+ } else {
+ this.activeFilters.add(key);
+ }
+ this.requestUpdate();
+ this.onFiltersChanged();
+ }
+
+ #renderFilterOption(group, option) {
+ return html`
+
+ `;
+ }
+
+ #renderFilterPanel() {
+ return html`
+
+
+
+ ${this.filterGroups.map(
+ group => html`
+
+
+
+ ${group.options.map(option =>
+ this.#renderFilterOption(group, option)
+ )}
+
+
+ `
+ )}
+
+
+ `;
+ }
+
+ render() {
+ const hasFilters = !!this.filterGroups.length;
+ return html`
+
+
+ ${when(hasFilters, () => this.#renderFilterPanel())}
+
+
+ ${this.renderItems()}
+
+
+ `;
+ }
+}
diff --git a/src/zen/library/sections/ZenLibrarySpacesSection.mjs b/src/zen/library/sections/ZenLibrarySpacesSection.mjs
new file mode 100644
index 000000000..df23e1c8d
--- /dev/null
+++ b/src/zen/library/sections/ZenLibrarySpacesSection.mjs
@@ -0,0 +1,820 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import {
+ html,
+ repeat,
+ styleMap,
+} from "chrome://global/content/vendor/lit.all.mjs";
+import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
+import { ZenLibraryDragAndDrop } from "moz-src:///zen/library/ZenLibraryDragAndDrop.mjs";
+
+const GRADIENT_TOPIC = "zen-space-gradient-update";
+const SCROLL_EDGE_PX = 48;
+const SCROLL_STEP_PX = 12;
+
+// Events the copies fire while being built that the rest of the browser must
+// not mistake for real tab strip changes.
+// Changes to the real strips that the copies must follow. Attribute changes
+// count only for what a copy shows.
+const STRIP_EVENTS = [
+ "TabOpen",
+ "TabClose",
+ "TabMove",
+ "TabPinned",
+ "TabUnpinned",
+ "TabHide",
+ "TabShow",
+ "TabAttrModified",
+ "TabGrouped",
+ "TabUngrouped",
+ "TabGroupCreate",
+ "TabGroupRemoved",
+ "TabGroupMoved",
+ "TabGroupCollapse",
+ "TabGroupExpand",
+];
+const SHOWN_ATTRIBUTES = ["label", "image", "pending", "muted", "soundplaying"];
+
+const CONTAINED_EVENTS = [
+ "TabGrouped",
+ "TabUngrouped",
+ "TabGroupCreate",
+ "TabGroupRemovedFromDOM",
+ "FolderGrouped",
+ "FolderUngrouped",
+
+ "TabGroupCollapse",
+ "TabGroupExpand",
+];
+const GROUP_TAGS = ["tab-group", "zen-folder", "tab-split-view-wrapper"];
+
+export class ZenLibrarySpacesSection extends MozLitElement {
+ static id = "spaces";
+ static label = "library-spaces-section-title";
+
+ static render(library) {
+ return html`
+
+ `;
+ }
+
+ static properties = {
+ renaming: { state: true },
+ };
+
+ #observer = { observe: () => this.requestUpdate() };
+ #onDataChanged = () => this.requestUpdate();
+ #onStripEvent = event => {
+ // The copies raise the same events as they are built; those are not
+ // changes to the real strips.
+ if (this.contains(event.target)) {
+ return;
+ }
+ if (
+ event.type === "TabAttrModified" &&
+ !event.detail?.changed?.some(attr => SHOWN_ATTRIBUTES.includes(attr))
+ ) {
+ return;
+ }
+ this.#scheduleStripsRefresh(
+ event.type === "TabAttrModified"
+ ? event.target.getAttribute("zen-workspace-id")
+ : null
+ );
+ };
+ #refreshTimer = null;
+ /** @type {Set|null} The spaces to rebuild, or null for every card */
+ #pendingSpaces = new Set();
+ #resizeObserver = new ResizeObserver(() => this.#updateLibraryWidth());
+ #onScroll = event => {
+ if (event.target.classList.contains("zen-library-space-body")) {
+ this.#updateScrollBorders(event.target);
+ }
+ };
+ #onDragEnd = () => {
+ this.#landing = false;
+ this.#dnd.clearDragOverVisuals();
+ this.#refreshStrips();
+ };
+ // A drag from a copy ran on the strip's drag and drop, whose own drag end
+ // only hears drags from the strip; it gets to finish this one too. The
+ // event comes to the copy itself, which the drop's rebuild has taken out
+ // of the document by then, so it never reaches the window.
+ #onCopyDragEnd = event => {
+ gBrowser.tabContainer.tabDragAndDrop.handle_dragend(event);
+ this.#onDragEnd();
+ };
+ #landing = false;
+ /** @type {WeakMap} copied tab or group to the real one */
+ #realElements = new WeakMap();
+ #dnd = new ZenLibraryDragAndDrop(this);
+ #dragIndex = -1;
+ #dropIndex = -1;
+ #dragStartX = 0;
+ #dragCenterX = 0;
+ #dragStartScroll = 0;
+ #lastPointerX = 0;
+ #scrollFrame = null;
+ #slotCenters = [];
+
+ constructor() {
+ super();
+ this.renaming = null;
+ }
+
+ createRenderRoot() {
+ return this;
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ window.addEventListener("ZenWorkspaceDataChanged", this.#onDataChanged);
+ window.addEventListener("ZenWorkspacesUIUpdate", this.#onDataChanged);
+ Services.obs.addObserver(this.#observer, GRADIENT_TOPIC);
+ this.#resizeObserver.observe(this);
+ window.addEventListener("dragend", this.#onDragEnd);
+ this.addEventListener("scroll", this.#onScroll, true);
+ for (const type of CONTAINED_EVENTS) {
+ this.addEventListener(type, this.#containEvent, true);
+ }
+ for (const type of STRIP_EVENTS) {
+ window.addEventListener(type, this.#onStripEvent);
+ }
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ window.removeEventListener("ZenWorkspaceDataChanged", this.#onDataChanged);
+ window.removeEventListener("ZenWorkspacesUIUpdate", this.#onDataChanged);
+ Services.obs.removeObserver(this.#observer, GRADIENT_TOPIC);
+ this.#resizeObserver.disconnect();
+ window.removeEventListener("dragend", this.#onDragEnd);
+ this.removeEventListener("scroll", this.#onScroll, true);
+ for (const type of CONTAINED_EVENTS) {
+ this.removeEventListener(type, this.#containEvent, true);
+ }
+ for (const type of STRIP_EVENTS) {
+ window.removeEventListener(type, this.#onStripEvent);
+ }
+ clearTimeout(this.#refreshTimer);
+ this.#refreshTimer = null;
+ this.library?.style.removeProperty("--zen-library-content-width");
+ }
+
+ updated(changedProperties) {
+ super.updated(changedProperties);
+ this.#fillStrips();
+ this.#updateLibraryWidth();
+ for (const card of this.#cards) {
+ this.#updateScrollBorders(card.querySelector(".zen-library-space-body"));
+ }
+ if (changedProperties.has("renaming") && this.renaming) {
+ const input = this.querySelector(".zen-library-space-name-input");
+ input?.focus();
+ input?.select();
+ }
+ }
+
+ #updateLibraryWidth() {
+ const side = this.library?.querySelector("#zen-library-side");
+ const list = this.querySelector(".zen-library-spaces");
+ if (!side || !list) {
+ return;
+ }
+ const sideWidth = window.windowUtils.getBoundsWithoutFlushing(side).width;
+ this.library.style.setProperty(
+ "--zen-library-content-width",
+ `${sideWidth + list.scrollWidth}px`
+ );
+ }
+
+ get #cards() {
+ return [...this.querySelectorAll(".zen-library-space")];
+ }
+
+ /**
+ * Marks a strip as scrolled past its top or bottom edge so the matching
+ * edge border shows.
+ *
+ * @param {Element|null} body - A card's scrolling body
+ */
+ #updateScrollBorders(body) {
+ if (!body) {
+ return;
+ }
+ const max = body.scrollHeight - body.clientHeight;
+ body.toggleAttribute("scrolled-top", body.scrollTop > 1);
+ body.toggleAttribute(
+ "scrolled-bottom",
+ max > 1 && body.scrollTop < max - 1
+ );
+ }
+
+ /**
+ * Rebuilds copies shortly, once a burst of changes has settled.
+ *
+ * @param {string|null} uuid - The space that changed, or null for all
+ */
+ #scheduleStripsRefresh(uuid) {
+ if (this.#pendingSpaces) {
+ if (uuid) {
+ this.#pendingSpaces.add(uuid);
+ } else {
+ this.#pendingSpaces = null;
+ }
+ }
+ if (this.#refreshTimer) {
+ return;
+ }
+ this.#refreshTimer = setTimeout(() => {
+ this.#refreshTimer = null;
+ const uuids = this.#pendingSpaces;
+ this.#pendingSpaces = new Set();
+ this.#refreshStrips(uuids);
+ }, 100);
+ }
+
+ /**
+ * Rebuilds copies right away, unless a dropped tab is mid-landing.
+ *
+ * @param {Set|null} uuids - The spaces to rebuild, or null for every
+ * card
+ */
+ #refreshStrips(uuids = null) {
+ if (this.#landing) {
+ return;
+ }
+ this.#fillStrips(true, uuids);
+ }
+
+ /**
+ * @param {boolean} rebuild - Redo copies that already exist, not only
+ * fill in cards that have none yet
+ * @param {Set|null} uuids - When rebuilding, limit to these spaces.
+ */
+ #fillStrips(rebuild = false, uuids = null) {
+ for (const card of this.#cards) {
+ if (rebuild && uuids && !uuids.has(card.dataset.uuid)) {
+ continue;
+ }
+ const strip = card.querySelector(".zen-library-space-tabs");
+ if (!rebuild && strip.childElementCount) {
+ continue;
+ }
+ const before = rebuild ? this.#rowPositions(strip) : null;
+ strip.textContent = "";
+ const space = gZenWorkspaces.workspaceElement(card.dataset.uuid);
+ if (!space) {
+ continue;
+ }
+ for (const section of [space.pinnedTabsContainer, space.tabsContainer]) {
+ this.#appendCopy(strip, section);
+ }
+ // Rows slide to their new places, unless the drag image is landing on
+ // the moved one, which is motion enough.
+ if (before && !this.#landing) {
+ this.#animateRows(strip, before);
+ }
+ }
+ }
+
+ /**
+ * The top-left of every row in a strip, keyed by the copy's stable id, so a
+ * row can be matched across a rebuild.
+ *
+ * @param {Element} strip
+ * @returns {Map}
+ */
+ #rowPositions(strip) {
+ const positions = new Map();
+ for (const row of strip.querySelectorAll(
+ "tab, .tab-group-label-container"
+ )) {
+ const id = row.id || row.closest("[id]")?.id;
+ if (id) {
+ const rect = row.getBoundingClientRect();
+ positions.set(id, { left: rect.left, top: rect.top });
+ }
+ }
+ return positions;
+ }
+
+ /**
+ * Slides each rebuilt row from where its old copy was to where it is now.
+ *
+ * @param {Element} strip
+ * @param {Map} before - Old positions
+ */
+ #animateRows(strip, before) {
+ if (gReduceMotion) {
+ return;
+ }
+ for (const row of strip.querySelectorAll(
+ "tab, .tab-group-label-container"
+ )) {
+ const id = row.id || row.closest("[id]")?.id;
+ const old = id && before.get(id);
+ if (!old) {
+ continue;
+ }
+ const rect = row.getBoundingClientRect();
+ const dx = old.left - rect.left;
+ const dy = old.top - rect.top;
+ if (dx || dy) {
+ gZenUIManager.elementAnimate(
+ row,
+ { x: [dx, 0], y: [dy, 0] },
+ { duration: 180, easing: "ease-out" }
+ );
+ }
+ }
+ }
+
+ /**
+ * Copies must never be mistaken for the originals by id lookups, so every
+ * id in a copy gets a suffix.
+ *
+ * @param {Element} copy - The copied element, before it is connected
+ */
+ #renameIds(copy) {
+ for (const element of [copy, ...copy.querySelectorAll("[id]")]) {
+ if (element.id) {
+ element.id = `${element.id}-copy`;
+ }
+ }
+ }
+
+ #appendCopy(container, node) {
+ // A closing tab stays in the strip through its animation, but is gone.
+ if (
+ node.nodeType !== Node.ELEMENT_NODE ||
+ node.hasAttribute("hidden") ||
+ node.closing
+ ) {
+ return;
+ }
+ if (gBrowser.isTab(node)) {
+ const copy = node.cloneNode(false);
+ this.#renameIds(copy);
+ for (const attribute of [
+ "selected",
+ "visuallyselected",
+ "multiselected",
+ ]) {
+ copy.removeAttribute(attribute);
+ }
+ container.appendChild(copy);
+ this.#realElements.set(copy, node);
+ if (node.glanceTab) {
+ this.#appendCopy(copy.querySelector(".tab-content"), node.glanceTab);
+ }
+ return;
+ }
+ if (GROUP_TAGS.includes(node.localName)) {
+ const copy = node.cloneNode(false);
+ this.#renameIds(copy);
+ container.appendChild(copy);
+ this.#realElements.set(copy, node);
+ const icon = copy.querySelector(".tab-group-folder-icon");
+ const realIcon = node.querySelector(".tab-group-folder-icon");
+ if (icon && realIcon) {
+ icon.replaceChildren(
+ ...[...realIcon.children].map(child => child.cloneNode(true))
+ );
+ }
+ const inner = node.querySelector(":scope > .tab-group-container") ?? node;
+ for (const child of inner.children) {
+ if (!child.classList.contains("zen-tab-group-start")) {
+ this.#appendCopy(copy, child);
+ }
+ }
+ return;
+ }
+ if (!node.querySelector("tab, tab-group, zen-folder")) {
+ const copy = node.cloneNode(true);
+ this.#renameIds(copy);
+ container.appendChild(copy);
+ return;
+ }
+ const copy = node.cloneNode(false);
+ this.#renameIds(copy);
+ container.appendChild(copy);
+ for (const child of node.children) {
+ this.#appendCopy(copy, child);
+ }
+ }
+
+ #containEvent = event => {
+ event.stopPropagation();
+ };
+
+ #containHover = {
+ handleEvent: event => event.stopPropagation(),
+ capture: true,
+ };
+
+ #onStripClick = event => {
+ const label = event.target.closest(".tab-group-label-container");
+ if (label) {
+ const copy = label.closest(GROUP_TAGS.join());
+ const group = this.#realElements.get(copy);
+ if (group) {
+ event.stopPropagation();
+ const collapsed = !group.collapsed;
+ group.collapsed = collapsed;
+ copy.collapsed = collapsed;
+ if (collapsed) {
+ gZenFolders.animateCollapse(copy);
+ } else {
+ gZenFolders.animateExpand(copy);
+ }
+ }
+ return;
+ }
+ const tab = this.#realElements.get(event.target.closest("tab"));
+ if (!tab) {
+ return;
+ }
+ event.stopPropagation();
+ if (event.target.closest(".tab-close-button")) {
+ gBrowser.removeTab(tab, { animate: true });
+ return;
+ }
+ gBrowser.selectedTab = tab;
+ this.library?.constructor.toggle();
+ };
+
+ /**
+ * @param {Element} copy - A copied tab, group or folder
+ * @returns {Element|undefined} The real element it stands for
+ */
+ realElementFor(copy) {
+ return this.#realElements.get(copy);
+ }
+
+ /**
+ * Rebuilds every card's copies immediately, so a just-moved tab's new copy
+ * exists to read or animate.
+ */
+ refreshStripsNow() {
+ this.#fillStrips(true);
+ }
+
+ beginTabLanding(tab) {
+ this.#landing = true;
+ this.refreshStripsNow();
+ const copy = this.copyForTab(tab);
+ if (!copy?.isConnected) {
+ return null;
+ }
+ const rect = copy.getBoundingClientRect();
+ return rect.width && rect.height ? copy : null;
+ }
+
+ copyForTab(tab) {
+ for (const card of this.#cards) {
+ for (const copy of card.querySelectorAll(".zen-library-space-tabs tab")) {
+ if (this.#realElements.get(copy) === tab) {
+ return copy;
+ }
+ }
+ }
+ return null;
+ }
+
+ #onStripDragStart = event => {
+ const target =
+ event.target.nodeType === Node.TEXT_NODE
+ ? event.target.parentElement
+ : event.target;
+ const copy = target.closest("tab");
+ if (copy) {
+ this.#dnd.startTabDrag(event, copy);
+ copy.addEventListener("dragend", this.#onCopyDragEnd, { once: true });
+ }
+ };
+
+ #onStripDrop = event => {
+ this.#dnd.handle_drop(event);
+ this.#refreshStrips();
+ };
+
+ #renderStrip() {
+ return html`
+
+
this.#dnd.handle_dragover(event)}
+ @dragleave=${event => this.#dnd.handle_dragleave(event)}
+ @drop=${this.#onStripDrop}
+ >
+
+ `;
+ }
+
+ // Theme
+
+ #themeStyles(workspace) {
+ const { gradient, isDarkMode, isExplicitMode, toolbarColor, primaryColor } =
+ gZenThemePicker.getGradientForWorkspace(workspace);
+ let colorScheme = "";
+ if (isExplicitMode) {
+ colorScheme = isDarkMode ? "dark" : "light";
+ }
+ return {
+ "--zen-library-space-gradient": gradient,
+ "--zen-primary-color": primaryColor,
+ "--toolbox-textcolor": `rgba(${toolbarColor.join(",")})`,
+ colorScheme,
+ };
+ }
+
+ #openThemePicker(workspace, event) {
+ gZenThemePicker.openThemePickerForWorkspace(
+ workspace,
+ event.currentTarget,
+ event
+ );
+ }
+
+ #openActions(event) {
+ document
+ .getElementById("zenWorkspaceMoreActions")
+ .openPopup(event.currentTarget, "after_end");
+ }
+
+ // Icon and name
+
+ #changeIcon(workspace, event) {
+ gZenEmojiPicker.open(event.currentTarget, {
+ closeOnSelect: false,
+ allowNone: gZenWorkspaces.workspaceHasIcon(workspace),
+ onSelect: async icon => {
+ workspace.icon = icon;
+ await gZenWorkspaces.saveWorkspace(workspace);
+ },
+ });
+ }
+
+ #commitRename(workspace, event) {
+ if (this.renaming !== workspace.uuid) {
+ return;
+ }
+ const newName = event.target.value.trim();
+ this.renaming = null;
+ if (newName && newName !== workspace.name) {
+ workspace.name = newName;
+ gZenWorkspaces.saveWorkspace(workspace);
+ }
+ }
+
+ #onRenameKeyDown(workspace, event) {
+ if (event.key === "Enter") {
+ this.#commitRename(workspace, event);
+ } else if (event.key === "Escape") {
+ this.renaming = null;
+ }
+ }
+
+ #onPointerDown(event) {
+ if (event.button !== 0) {
+ return;
+ }
+ const cards = this.#cards;
+ const card = event.currentTarget.closest(".zen-library-space");
+ this.#dragIndex = cards.indexOf(card);
+ this.#dropIndex = this.#dragIndex;
+ this.#dragStartX = event.clientX;
+ this.#lastPointerX = event.clientX;
+ this.#dragStartScroll = this.#list.scrollLeft;
+ const cardRect = window.windowUtils.getBoundsWithoutFlushing(card);
+ this.#dragCenterX = cardRect.left + cardRect.width / 2;
+ this.#slotCenters = cards
+ .filter((other, i) => i !== this.#dragIndex)
+ .map(other => {
+ const rect = window.windowUtils.getBoundsWithoutFlushing(other);
+ return rect.left + rect.width / 2;
+ });
+ card.setAttribute("dragging", "true");
+ event.currentTarget.setPointerCapture(event.pointerId);
+ event.preventDefault();
+ this.#autoScroll();
+ }
+
+ get #list() {
+ return this.querySelector(".zen-library-spaces");
+ }
+
+ #onPointerMove(event) {
+ if (this.#dragIndex === -1) {
+ return;
+ }
+ this.#lastPointerX = event.clientX;
+ this.#updateDrag();
+ }
+
+ #updateDrag() {
+ const cards = this.#cards;
+ const scrolled = this.#list.scrollLeft - this.#dragStartScroll;
+ const travel = this.#lastPointerX - this.#dragStartX + scrolled;
+ cards[this.#dragIndex].style.translate = `${travel}px 0`;
+ const centerX = this.#dragCenterX + travel;
+ const index = this.#slotCenters.filter(center => centerX > center).length;
+ if (index !== this.#dropIndex) {
+ this.#dropIndex = index;
+ this.#shiftCards(cards);
+ }
+ }
+
+ #autoScroll() {
+ if (this.#dragIndex === -1) {
+ return;
+ }
+ const list = this.#list;
+ const rect = window.windowUtils.getBoundsWithoutFlushing(list);
+ let step = 0;
+ if (this.#lastPointerX < rect.left + SCROLL_EDGE_PX) {
+ step = -SCROLL_STEP_PX;
+ } else if (this.#lastPointerX > rect.right - SCROLL_EDGE_PX) {
+ step = SCROLL_STEP_PX;
+ }
+ if (step) {
+ const before = list.scrollLeft;
+ list.scrollLeft += step;
+ if (list.scrollLeft !== before) {
+ this.#updateDrag();
+ }
+ }
+ this.#scrollFrame = requestAnimationFrame(() => this.#autoScroll());
+ }
+
+ #shiftCards(cards) {
+ cards.forEach((card, i) => {
+ let shift = "";
+ if (this.#dragIndex < i && i <= this.#dropIndex) {
+ shift = "left";
+ } else if (this.#dropIndex <= i && i < this.#dragIndex) {
+ shift = "right";
+ }
+ if (shift) {
+ card.setAttribute("shift", shift);
+ } else {
+ card.removeAttribute("shift");
+ }
+ });
+ }
+
+ async #onPointerUp(event, workspace) {
+ if (this.#dragIndex === -1) {
+ return;
+ }
+ event.currentTarget.releasePointerCapture(event.pointerId);
+ cancelAnimationFrame(this.#scrollFrame);
+ this.#scrollFrame = null;
+ const moved =
+ this.#dropIndex !== this.#dragIndex && event.type === "pointerup";
+ const dropIndex = this.#dropIndex;
+ this.#dragIndex = -1;
+ this.#dropIndex = -1;
+
+ const list = this.#list;
+ list.setAttribute("no-transition", "true");
+ if (moved) {
+ gZenWorkspaces.reorderWorkspace(workspace.uuid, dropIndex);
+ await this.updateComplete;
+ }
+ for (const card of this.#cards) {
+ card.removeAttribute("dragging");
+ card.removeAttribute("shift");
+ card.style.translate = "";
+ }
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ list.removeAttribute("no-transition");
+ }
+
+ #renderIcon(workspace) {
+ const hasIcon = gZenWorkspaces.workspaceHasIcon(workspace);
+ let content = "";
+ if (hasIcon) {
+ const icon = gZenWorkspaces.getWorkspaceIcon(workspace);
+ content = icon.endsWith(".svg") ? html`
` : icon;
+ }
+ return html`
+
+ `;
+ }
+
+ #renderName(workspace) {
+ if (this.renaming === workspace.uuid) {
+ return html`
+ this.#onRenameKeyDown(workspace, event)}
+ @blur=${event => this.#commitRename(workspace, event)}
+ />
+ `;
+ }
+ return html`
+
+ `;
+ }
+
+ #renderSpace(workspace) {
+ return html`
+
+
+ ${this.#renderStrip()}
+
+
+ `;
+ }
+
+ render() {
+ return html`
+
+ ${repeat(
+ gZenWorkspaces.getWorkspaces(),
+ workspace => workspace.uuid,
+ workspace => this.#renderSpace(workspace)
+ )}
+
+ `;
+ }
+}
+
+customElements.define("zen-library-spaces-section", ZenLibrarySpacesSection);
diff --git a/src/zen/library/zen-library.css b/src/zen/library/zen-library.css
new file mode 100644
index 000000000..d641f9466
--- /dev/null
+++ b/src/zen/library/zen-library.css
@@ -0,0 +1,984 @@
+/*
+ * 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/.
+ */
+
+#zen-appcontent-wrapper {
+ --library-progress: 0;
+ --library-wrapper-target-px: 0px;
+ transform: translateX(calc(var(--library-progress) * var(--library-wrapper-target-px)));
+}
+
+#navigator-toolbox {
+ --toolbox-progress: min(1, calc(var(--library-progress) * 1.5));
+
+ transform: scale(calc(1 - var(--toolbox-progress) * 0.04));
+ opacity: calc(1 - var(--toolbox-progress));
+}
+
+#zen-main-app-wrapper[zen-compact-mode="true"] #navigator-toolbox {
+ @media -moz-pref('zen.view.compact.hide-tabbar') or -moz-pref('zen.view.use-single-toolbar') {
+ transform: translateX(calc(var(--toolbox-progress) * -100%));
+ opacity: 1;
+ }
+}
+
+:root[zen-right-side="true"] #zen-main-app-wrapper[zen-compact-mode="true"] #navigator-toolbox {
+ @media -moz-pref('zen.view.compact.hide-tabbar') or -moz-pref('zen.view.use-single-toolbar') {
+ transform: translateX(calc(var(--toolbox-progress) * 100%));
+ }
+}
+
+:root:not([zen-sidebar-expanded="true"]) .zen-library-window-buttons-clone {
+ display: none;
+}
+
+zen-library {
+ --zen-library-pill-bg: color-mix(in srgb, currentColor 7%, transparent);
+ --zen-library-pill-hover-bg: color-mix(in srgb, currentColor 12%, transparent);
+ --zen-library-radius: 12px;
+
+ contain: content;
+
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ width: clamp(418px, var(--zen-library-content-width, 0px), 95vw);
+ display: flex;
+ z-index: 9;
+
+ color: var(--toolbar-color);
+ color-scheme: var(--toolbar-color-scheme);
+
+ visibility: hidden;
+ pointer-events: none;
+ transform: translateX(calc(-100% * (1 - var(--library-progress, 0))));
+
+ & #zen-library-panel {
+ flex-direction: row;
+ }
+
+ & #zen-library-side {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ min-width: 110px;
+ height: 100%;
+ background: linear-gradient(to right, transparent, rgba(255, 255, 255, 0.08));
+ box-shadow: 9px 0 17px rgba(0, 0, 0, 0.04);
+ }
+}
+
+:root[zen-right-side="true"] zen-library {
+ transform: translateX(calc(100% * (1 - var(--library-progress, 0))));
+ left: auto;
+ right: 0;
+
+ & #zen-library-panel {
+ flex-direction: row-reverse;
+ }
+
+ & #zen-library-side {
+ background: linear-gradient(to left, transparent, rgba(255, 255, 255, 0.08));
+ box-shadow: -9px 0 17px rgba(0, 0, 0, 0.04);
+ }
+
+ & #zen-library-footer {
+ flex-direction: row-reverse;
+
+ & toolbarbutton:first-child {
+ rotate:180deg;
+ }
+ }
+}
+
+zen-library[open] {
+ --zen-main-app-child-z-index: 2;
+ visibility: visible;
+ pointer-events: auto;
+ -moz-window-dragging: drag;
+ transition: width 0.3s cubic-bezier(.36,.06,0,.94);
+}
+
+zen-library[transitioning] #zen-library-content {
+ pointer-events: none;
+}
+
+#zen-library-panel {
+ position: absolute;
+ inset: 0;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+#zen-library-content {
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ overflow: hidden;
+ contain: content;
+}
+
+#zen-library-footer {
+ display: flex;
+ padding: 8px;
+}
+
+@media (-moz-platform: macos) {
+ #zen-library-header {
+ padding: 12px 0 0 9px;
+ & .titlebar-buttonbox-container {
+ margin-left: 0 !important;
+ }
+ }
+}
+
+#zen-library-footer {
+ justify-content: space-between;
+
+ & .toolbarbutton-1 {
+ appearance: none;
+
+ & .toolbarbutton-text {
+ display: none;
+ }
+ }
+}
+
+#zen-library-body {
+ padding: 16px;
+}
+
+#zen-library-sidebar-tabs {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ justify-content: space-between;
+ height: min-content;
+}
+
+.zen-library-tab {
+ --zen-library-sprite-size: 28px;
+
+ -moz-window-dragging: no-drag;
+ margin: 8px;
+ display: flex;
+ position: relative;
+ justify-content: center;
+ align-items: center;
+ padding: 16px 0;
+ font-weight: 600;
+ flex-direction: column;
+ gap: 4px;
+
+ & label {
+ text-align: center;
+ }
+
+ isolation: isolate;
+
+ &::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ z-index: -2;
+ border-radius: 8px;
+ transition: transform 0.1s ease-in-out;
+ }
+
+ &:hover::before {
+ background: color-mix(in srgb, currentColor 5%, transparent);
+ }
+
+ &[active]::before {
+ background: color-mix(in srgb, currentColor 10%, transparent);
+ }
+
+ &:hover:active::before {
+ transform: scale(0.96);
+ }
+
+ & .zen-library-tab-icon {
+ width: var(--zen-library-sprite-size);
+ height: var(--zen-library-sprite-size);
+ overflow: clip;
+ }
+
+ @media (prefers-reduced-motion: no-preference) {
+ &[animate] .zen-library-tab-icon-image {
+ animation: zen-library-sprite-play 0.583s steps(36, jump-none);
+ }
+ }
+}
+
+@media (max-height: 275px) {
+ .zen-library-tab {
+ & label {
+ display: none;
+ }
+ }
+}
+
+.zen-library-tab-icon-image,
+.empty-state-icon-image {
+ width: calc(36 * var(--zen-library-sprite-size));
+ height: var(--zen-library-sprite-size);
+ background-size: 100% 100%;
+ --stroke: light-dark(var(--zen-colors-primary), var(--zen-accent-button-color));
+ fill: color-mix(in srgb, var(--stroke), light-dark(white, black) 70%);
+ stroke: var(--stroke);
+ -moz-context-properties: fill, stroke;
+ transform: translateX(0);
+}
+
+.zen-library-tab:not([active]) .zen-library-tab-icon-image {
+ fill: transparent;
+}
+
+[data-section="history"] :is(.zen-library-tab-icon-image, .empty-state-icon-image) {
+ background-image: url("chrome://browser/skin/zen-icons/library/library-history-sprite.svg");
+}
+
+[data-section="downloads"] :is(.zen-library-tab-icon-image, .empty-state-icon-image) {
+ background-image: url("chrome://browser/skin/zen-icons/library/library-downloads-sprite.svg");
+}
+
+zen-library[zen-library-downloading] .zen-library-tab[data-section="downloads"]::after {
+ --border-color: color-mix(in srgb, var(--zen-primary-color), light-dark(rgba(0,0,0,.8), white) 70%);
+
+ content: "";
+ position: absolute;
+ z-index: -1;
+ top: 16px;
+ left: 50%;
+ translate: -50% 0;
+ width: var(--zen-library-sprite-size);
+ height: var(--zen-library-sprite-size);
+ box-sizing: border-box;
+ border: 3px solid transparent;
+ border-radius: 100%;
+ background:
+ conic-gradient(
+ var(--zen-accent-button-background) var(--zen-library-download-progress, 0%),
+ var(--border-color) 0
+ )
+ border-box,
+ color-mix(in srgb, var(--zen-primary-color), light-dark(white, rgba(0,0,0,.5)) 80%)
+ padding-box;
+ box-shadow: 0 0 6px 2px rgba(0,0,0,.1);
+ pointer-events: none;
+}
+
+[data-section="boosts"] :is(.zen-library-tab-icon-image, .empty-state-icon-image) {
+ background-image: url("chrome://browser/skin/zen-icons/library/library-boosts-sprite.svg");
+}
+
+[data-section="spaces"] :is(.zen-library-tab-icon-image, .empty-state-icon-image) {
+ background-image: url("chrome://browser/skin/zen-icons/library/library-spaces-sprite.svg");
+}
+
+[data-section="media"] :is(.zen-library-tab-icon-image, .empty-state-icon-image) {
+ background-image: url("chrome://browser/skin/zen-icons/library/library-media-sprite.svg");
+}
+
+@keyframes zen-library-sprite-play {
+ from {
+ transform: translateX(0);
+ }
+
+ to {
+ transform: translateX(calc(35 * -1 * var(--zen-library-sprite-size)));
+ }
+}
+
+.zen-library-section {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+ font-size: 14px;
+
+ &:not([data-section="spaces"]) {
+ max-width: calc(418px - 110px);
+ }
+}
+
+zen-library:not([open]) :is(.zen-library-search-header, .zen-library-filter-header, .zen-library-filter-panel-inner, .zen-library-search-results) {
+ transition: none;
+}
+
+.zen-library-search-top {
+ display: grid;
+ grid-template-columns: 1fr;
+ padding: 12px 14px 8px;
+ -moz-window-dragging: no-drag;
+}
+
+.zen-library-search-header,
+.zen-library-filter-header {
+ grid-area: 1 / 1;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 1px;
+ transition: opacity 0.28s ease;
+}
+
+.zen-library-search-top[open] .zen-library-search-header,
+.zen-library-search-top:not([open]) .zen-library-filter-header {
+ opacity: 0;
+}
+
+.zen-library-search-box,
+.zen-library-filter-button {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ box-sizing: border-box;
+ height: 36px;
+ padding-inline: 12px;
+ border-radius: var(--zen-library-radius);
+ background: var(--zen-library-pill-bg);
+}
+
+.zen-library-filter-button,
+.zen-library-filter-done,
+.zen-library-filter-chip {
+ border: none;
+ color: inherit;
+ font: inherit;
+
+ &:hover {
+ background: var(--zen-library-pill-hover-bg);
+ }
+}
+
+.zen-library-search-box img,
+.zen-library-filter-button img,
+.zen-library-filter-chip img {
+ width: 16px;
+ height: 16px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+ opacity: 0.8;
+}
+
+.zen-library-search-box {
+ flex: 1;
+ min-width: 0;
+
+ & img {
+ opacity: 0.5;
+ }
+
+ & input {
+ flex: 1;
+ min-width: 0;
+ height: 100%;
+ padding: 0;
+ background: none;
+ border: none;
+ outline: none;
+ color: inherit;
+ font: inherit;
+
+ &::placeholder {
+ color: inherit;
+ opacity: 0.5;
+ }
+ }
+}
+
+.zen-library-filter-button {
+ font-weight: 600;
+}
+
+.zen-library-filter-header {
+ justify-content: space-between;
+
+ & h2 {
+ margin: 0;
+ font-size: 1em;
+ font-weight: 700;
+ }
+}
+
+.zen-library-filter-done {
+ padding: 8px 14px;
+ border-radius: var(--zen-library-radius);
+ background: var(--zen-library-pill-bg);
+ font-weight: 600;
+}
+
+.zen-library-filter-panel {
+ grid-area: 2 / 1;
+ height: 0;
+ overflow: visible;
+}
+
+.zen-library-filter-panel-inner {
+ opacity: 0;
+ transition: opacity 0.3s ease;
+
+ .zen-library-search-top[open] & {
+ opacity: 1;
+ }
+}
+
+.zen-library-filter-group h3 {
+ margin: 16px 0 10px;
+ font-size: 0.93em;
+ font-weight: 600;
+ opacity: 0.85;
+}
+
+.zen-library-filter-options {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ padding-bottom: 6px;
+}
+
+.zen-library-filter-chip {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 9px 14px;
+ border-radius: var(--zen-library-radius);
+ background: var(--zen-library-pill-bg);
+
+ &[active] {
+ background: color-mix(in srgb, currentColor 20%, transparent);
+ }
+}
+
+.zen-library-search-results {
+ flex: 1;
+ min-height: 0;
+ position: relative;
+ z-index: 1;
+ overflow-y: auto;
+ padding: 0 10px 10px;
+ border-top: 1px solid transparent;
+ transition:
+ transform 0.3s ease,
+ border-color 0.2s ease;
+ -moz-window-dragging: no-drag;
+
+ &[scrolled] {
+ border-top-color: color-mix(in srgb, currentColor 15%, transparent);
+ }
+
+ .zen-library-search-top[open] + & {
+ transform: translateY(var(--zen-library-filter-height, 0));
+ }
+}
+
+.zen-library-search-sentinel {
+ height: 1px;
+}
+
+.zen-library-empty {
+ padding: 24px 0;
+ text-align: center;
+ opacity: 0.6;
+}
+
+.zen-library-group h3 {
+ margin: 10px 8px 5px;
+ font-size: 12px;
+ font-weight: 700;
+ opacity: 0.6;
+}
+
+.zen-library-row {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 10px 12px;
+ border-radius: 16px;
+ /* The open library window is draggable; let rows be dragged as items. */
+ -moz-window-dragging: no-drag;
+
+ &:hover {
+ background: color-mix(in srgb, currentColor 10%, transparent);
+ }
+}
+
+.zen-library-row-icon {
+ width: 16px;
+ height: 16px;
+ border-radius: 4px;
+ margin-inline: 2px 1px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+
+ zen-library-downloads-section & {
+ width: 22px;
+ height: 22px;
+ border-radius: 5px;
+ }
+}
+
+.zen-library-row-text {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+
+ & span {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+}
+
+.zen-library-row-title {
+ font-size: 14px;
+ font-weight: 500;
+}
+
+.zen-library-row-subtitle {
+ font-size: 12px;
+ opacity: 0.6;
+}
+
+.zen-library-row-actions {
+ display: flex;
+ gap: 4px;
+
+ /* Shown while the row is hovered; outranks the library's button rule. */
+ #zen-library-panel & toolbarbutton {
+ display: none;
+
+ .zen-library-row:is(:hover, [menu-open]) & {
+ display: inline-flex;
+ }
+ }
+}
+
+#zen-library-panel .toolbarbutton-1 {
+ appearance: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ margin: 0;
+ padding: 0 var(--toolbarbutton-padding-outer);
+ border: none;
+ background: none;
+
+ & > .toolbarbutton-icon {
+ display: block;
+ box-sizing: content-box;
+ width: 16px;
+ height: 16px;
+ padding: var(--toolbarbutton-padding-inner);
+ border-radius: var(--toolbarbutton-border-radius);
+ -moz-context-properties: fill;
+ fill: var(--toolbarbutton-icon-fill);
+ }
+
+ &:not([disabled]):hover > .toolbarbutton-icon {
+ background-color: var(--toolbarbutton-background-color-hover);
+ }
+
+ &:not([disabled]):is(:hover:active, [open]) > .toolbarbutton-icon {
+ background-color: var(--toolbarbutton-background-color-active);
+ }
+
+ .zen-library-row-actions & > .toolbarbutton-icon {
+ width: 18px;
+ height: 18px;
+ padding: 8px;
+ border-radius: var(--zen-library-radius);
+ }
+}
+
+/* Boosts */
+
+.zen-library-row-actions moz-toggle {
+ --toggle-width: 44px;
+ --toggle-height: 24px;
+ --toggle-dot-margin: 2px;
+ --toggle-border-width: 0px;
+ --toggle-background-color: color-mix(in srgb, var(--zen-primary-color) 15%, transparent);
+ --toggle-background-color-hover: color-mix(in srgb, var(--zen-primary-color) 25%, transparent);
+ --toggle-background-color-active: var(--toggle-background-color-hover);
+ --toggle-dot-background-color: light-dark(white, rgb(235, 235, 235));
+
+ display: none;
+
+ .zen-library-row:is(:hover, [menu-open]) & {
+ display: block;
+ }
+}
+
+.zen-library-boost-row {
+ padding: 6px 12px 6px 4px;
+ border-radius: 18px;
+}
+
+.zen-library-boost-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+ width: 46px;
+ height: 46px;
+ margin-inline: 2px 1px;
+
+ &::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ box-sizing: border-box;
+ border-radius: 10px;
+ border: 3px solid white;
+ background: #edededaa;
+ box-shadow: 0 0 6px 1px rgba(0, 0, 0, 0.1);
+ }
+
+ & img {
+ position: relative;
+ width: 24px;
+ height: 24px;
+ object-fit: contain;
+ -moz-context-properties: fill;
+ fill: currentColor;
+ }
+}
+
+.zen-library-boost-row[disabled] {
+ & .zen-library-row-text,
+ & .zen-library-boost-icon img {
+ opacity: 0.5;
+ }
+
+ & .zen-library-boost-icon {
+ &::before,
+ & img {
+ mask-image: linear-gradient(45deg, black calc(50% - 5px), transparent calc(50% - 5px), transparent calc(50% + 5px), black calc(50% + 5px));
+ mask-size: 200% 200%;
+ mask-position: center;
+ mask-repeat: no-repeat;
+ mask-clip: no-clip;
+ }
+
+ &::after {
+ content: "";
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 135%;
+ height: 4px;
+ border-radius: 10px;
+ background: light-dark(rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0.5));
+ transform: translate(-50%, -50%) rotate(45deg);
+ pointer-events: none;
+ }
+ }
+}
+
+.zen-library-download-url {
+ display: none;
+}
+
+.zen-library-row:hover:not([opening], [pending]) {
+ & .zen-library-download-status {
+ display: none;
+ }
+
+ & .zen-library-download-url {
+ display: inline;
+ }
+}
+
+.zen-library-row[pending]:not(:hover) .zen-library-download-status {
+ display: none;
+}
+
+.zen-library-row[pending]:not(:hover) .zen-library-row-subtitle::before {
+ content: "";
+ display: block;
+ height: 4px;
+ margin-block: calc((1lh - 4px) / 2);
+ border-radius: 2px;
+ background:
+ linear-gradient(var(--color-accent-primary), var(--color-accent-primary)) 0 0 / var(--zen-library-download-progress, 0%) 100% no-repeat,
+ color-mix(in srgb, currentColor 12%, transparent);
+ transition: background-size 0.2s ease;
+}
+
+.zen-library-row[paused] .zen-library-row-subtitle::before {
+ background-image: linear-gradient(
+ color-mix(in srgb, var(--color-accent-primary) 50%, transparent),
+ color-mix(in srgb, var(--color-accent-primary) 50%, transparent)
+ );
+}
+
+.zen-library-row[indeterminate] .zen-library-row-subtitle::before {
+ background-size: 40% 100%;
+ transition: none;
+ animation: zen-library-progress-indeterminate 1.2s ease-in-out infinite;
+}
+
+@keyframes zen-library-progress-indeterminate {
+ from {
+ background-position-x: -70%;
+ }
+
+ to {
+ background-position-x: 170%;
+ }
+}
+
+.zen-library-spaces {
+ --zen-library-space-width: 242px;
+ --zen-library-space-gap: 24px;
+
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ gap: var(--zen-library-space-gap);
+ padding: 45px 25px;
+ overflow-x: auto;
+ -moz-window-dragging: no-drag;
+ user-select: none;
+}
+
+.zen-library-space {
+ position: relative;
+ isolation: isolate;
+ display: flex;
+ flex-direction: column;
+ flex: 0 0 var(--zen-library-space-width);
+ /* The shift transforms assume the card is exactly this wide. */
+ box-sizing: border-box;
+ min-height: 0;
+ color: var(--toolbox-textcolor);
+ transition:
+ translate 0.2s ease,
+ opacity 0.2s ease;
+ background: var(--zen-branding-bg);
+ border-radius: 14px;
+ --toolbar-color: var(--toolbox-textcolor);
+
+ &::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ z-index: -1;
+ border-radius: 14px;
+ background: var(--zen-library-space-gradient);
+ background-blend-mode: screen;
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
+ }
+
+ &[dragging] {
+ z-index: 1;
+
+ &::before {
+ box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
+ }
+ }
+
+ &[dragging],
+ .zen-library-spaces[no-transition] & {
+ transition: none;
+ }
+
+ &[shift="left"] {
+ translate: calc(-1 * (var(--zen-library-space-width) + var(--zen-library-space-gap))) 0;
+ }
+
+ &[shift="right"] {
+ translate: calc(var(--zen-library-space-width) + var(--zen-library-space-gap)) 0;
+ }
+}
+
+.zen-library-space-header,
+.zen-library-space-footer {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.zen-library-space-header {
+ position: relative;
+ margin: 18px 14px 6px;
+ font-size: 13px;
+ font-weight: 500;
+ opacity: 0.85;
+ height: 28px;
+
+ &::before {
+ content: "";
+ position: absolute;
+ inset: -4px -6px;
+ z-index: -1;
+ border-radius: var(--border-radius-medium);
+ pointer-events: none;
+ }
+
+ &:hover::before {
+ background: var(--tab-background-color-hover);
+ }
+}
+
+.zen-library-space-body {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ scrollbar-width: none;
+ --zen-library-scroll-border: color-mix(
+ in srgb,
+ var(--toolbox-textcolor) 15%,
+ transparent
+ );
+ border-block: 1px solid transparent;
+ transition:
+ border-top-color 0.15s ease,
+ border-bottom-color 0.15s ease;
+
+ &[scrolled-top] {
+ border-top-color: var(--zen-library-scroll-border);
+ }
+
+ &[scrolled-bottom] {
+ border-bottom-color: var(--zen-library-scroll-border);
+ }
+
+ #zen-library-panel &[zen-sidebar-expanded] {
+ min-width: 0;
+ padding: 0;
+ }
+}
+
+.zen-library-space-tabs {
+ display: flex;
+ flex-direction: column;
+ flex: 0 0 auto !important;
+
+ &::after,
+ & #tabbrowser-arrowscrollbox-periphery-copy {
+ display: none !important;
+ }
+
+ & * {
+ animation: none !important;
+ }
+
+ /* The strip's drop indicator is placed in here so it scrolls along. */
+ position: relative;
+
+ & > #zen-drag-indicator {
+ position: absolute;
+ }
+
+ & .tab-group-label-container[dragover] {
+ background: color-mix(in srgb, var(--zen-primary-color) 25%, transparent);
+ }
+
+ & .zen-current-workspace-indicator {
+ min-height: var(--tab-min-height);
+ }
+}
+
+.zen-library-space-footer {
+ justify-content: space-between;
+ padding: 8px 14px 14px;
+ opacity: 0.7;
+}
+
+.zen-library-space-icon,
+.zen-library-space-name,
+.zen-library-space-name-input {
+ margin: 0;
+ padding: 0;
+ border: none;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ appearance: none;
+}
+
+.zen-library-space-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 20px;
+ height: 20px;
+ border-radius: 6px;
+ font-size: 15px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+
+ &:hover {
+ background: color-mix(in srgb, currentColor 14%, transparent);
+ }
+
+ & img {
+ width: 16px;
+ height: 16px;
+ }
+
+ &[no-icon] {
+ border: 1px dashed light-dark(rgba(0, 0, 0, 0.5), rgba(255, 255, 255, 0.5));
+ box-sizing: border-box;
+ position: absolute;
+ inset-inline-start: 0;
+ opacity: 0;
+ transition: opacity 0.15s ease;
+
+ .zen-library-space-header:hover & {
+ opacity: 1;
+ }
+ }
+}
+
+.zen-library-space-name,
+.zen-library-space-name-input {
+ flex: 1;
+ min-width: 0;
+ text-align: start;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.zen-library-space-icon[no-icon] ~ :is(.zen-library-space-name, .zen-library-space-name-input) {
+ transition:
+ translate 0.15s ease,
+ margin 0.15s ease;
+
+ .zen-library-space-header:hover & {
+ translate: 28px 0;
+ margin-inline-end: 28px;
+ }
+}
+
+.zen-library-space-name-input {
+ width: 100%;
+ height: auto;
+ line-height: inherit;
+ outline: none;
+ box-shadow: none;
+ border-radius: 0;
+ -moz-appearance: none;
+}
+
+.zen-library-space-handle {
+ cursor: grab;
+ touch-action: none;
+
+ .zen-library-space[dragging] & {
+ cursor: grabbing;
+ }
+}
diff --git a/src/zen/moz.build b/src/zen/moz.build
index 1a67f5456..ec08cd1bb 100644
--- a/src/zen/moz.build
+++ b/src/zen/moz.build
@@ -12,6 +12,7 @@ DIRS += [
"compact-mode",
"drag-and-drop",
"glance",
+ "library",
"live-folders",
"mods",
"tests",
diff --git a/src/zen/sessionstore/ZenWindowSync.sys.mjs b/src/zen/sessionstore/ZenWindowSync.sys.mjs
index 84e3b0eb4..9f4aefd9d 100644
--- a/src/zen/sessionstore/ZenWindowSync.sys.mjs
+++ b/src/zen/sessionstore/ZenWindowSync.sys.mjs
@@ -383,6 +383,12 @@ class nsZenWindowSync {
handleEvent(aEvent) {
const window = aEvent.currentTarget.documentGlobal ?? aEvent.currentTarget;
+ // The library builds copies of tabs and folders that fire these same
+ // events while being built. Their ids are suffixed with "-copy" (see
+ // ZenLibrarySpacesSection#renameIds), so never sync anything from one.
+ if (aEvent.target?.id?.endsWith("-copy")) {
+ return;
+ }
if (
!window.gZenStartup.isReady ||
!window.gZenWorkspaces?.shouldHaveWorkspaces ||
diff --git a/src/zen/spaces/ZenGradientGenerator.mjs b/src/zen/spaces/ZenGradientGenerator.mjs
index 7ca02341a..cd66575f3 100644
--- a/src/zen/spaces/ZenGradientGenerator.mjs
+++ b/src/zen/spaces/ZenGradientGenerator.mjs
@@ -1542,14 +1542,21 @@ export class nsZenThemePicker extends nsZenMultiWindowFeature {
browser.gZenThemePicker.invalidateGradientCache(uuid);
}
- // Do not rebuild if the workspace is not the same as the current one
+ // Only rebuild for the workspace the window shows, or the one its
+ // picker is editing without showing it.
const windowWorkspace = browser.gZenWorkspaces.getActiveWorkspace();
- if (windowWorkspace.uuid !== uuid) {
+ const appliesToWindow = windowWorkspace.uuid === uuid;
+ if (
+ !appliesToWindow &&
+ browser.gZenThemePicker.editingWorkspaceId !== uuid
+ ) {
return;
}
// get the theme from the window
- workspaceTheme = this.fixTheme(theme || windowWorkspace.theme);
+ workspaceTheme = this.fixTheme(
+ theme || (appliesToWindow ? windowWorkspace : workspace).theme
+ );
const docElement = browser.document.documentElement;
if (!skipUpdate) {
@@ -1560,7 +1567,7 @@ export class nsZenThemePicker extends nsZenMultiWindowFeature {
}
}
- if (theme) {
+ if (theme && appliesToWindow) {
const workspaceElement = browser.gZenWorkspaces.workspaceElement(
windowWorkspace.uuid
);
@@ -1569,7 +1576,7 @@ export class nsZenThemePicker extends nsZenMultiWindowFeature {
}
}
- if (!skipUpdate) {
+ if (!skipUpdate && appliesToWindow) {
let backgroundElement =
browser.gZenThemePicker.browserBackgroundElement;
let toolbarElement = browser.gZenThemePicker.toolbarBackgroundElement;
@@ -1724,7 +1731,9 @@ export class nsZenThemePicker extends nsZenMultiWindowFeature {
workspaceTheme.gradientColors,
true
);
- browser.gZenThemePicker.updateNoise(workspaceTheme.texture);
+ if (appliesToWindow) {
+ browser.gZenThemePicker.updateNoise(workspaceTheme.texture);
+ }
browser.gZenThemePicker.customColorList.innerHTML = "";
for (const dot of workspaceTheme.gradientColors) {
@@ -1733,59 +1742,66 @@ export class nsZenThemePicker extends nsZenMultiWindowFeature {
}
}
- browser.gZenThemePicker.toolbarBackgroundElement.style.setProperty(
- "--zen-main-browser-background-toolbar",
- gradientToolbar
- );
- browser.gZenThemePicker.browserBackgroundElement.style.setProperty(
- "--zen-main-browser-background",
- gradient
- );
- const isDarkModeWindow = browser.gZenThemePicker.isDarkMode;
- if (isDefaultTheme) {
- docElement.setAttribute("zen-default-theme", "true");
- } else {
- docElement.removeAttribute("zen-default-theme");
- }
- if (dominantColor) {
- // Should be set to `this.isLegacyVersion` but for some reason it is set to undefined if we open a private window,
- // so instead get the pref value directly.
- browser.gZenThemePicker.isLegacyVersion =
- Services.prefs.getIntPref("zen.theme.gradient-legacy-version", 1) ===
- 0;
-
- let isDarkMode = isDarkModeWindow;
- if (!isDefaultTheme && !this.isLegacyVersion) {
- // Check for the primary color
- isDarkMode = browser.gZenThemePicker.shouldBeDarkMode(dominantColor);
- docElement.setAttribute("zen-should-be-dark-mode", isDarkMode);
- browser.gZenThemePicker.panel.removeAttribute("invalidate-controls");
+ if (appliesToWindow) {
+ browser.gZenThemePicker.toolbarBackgroundElement.style.setProperty(
+ "--zen-main-browser-background-toolbar",
+ gradientToolbar
+ );
+ browser.gZenThemePicker.browserBackgroundElement.style.setProperty(
+ "--zen-main-browser-background",
+ gradient
+ );
+ const isDarkModeWindow = browser.gZenThemePicker.isDarkMode;
+ if (isDefaultTheme) {
+ docElement.setAttribute("zen-default-theme", "true");
} else {
- docElement.removeAttribute("zen-should-be-dark-mode");
- if (!this.isLegacyVersion) {
- browser.gZenThemePicker.panel.setAttribute(
- "invalidate-controls",
- "true"
- );
- }
+ docElement.removeAttribute("zen-default-theme");
}
+ if (dominantColor) {
+ // Should be set to `this.isLegacyVersion` but for some reason it is set to undefined if we open a private window,
+ // so instead get the pref value directly.
+ browser.gZenThemePicker.isLegacyVersion =
+ Services.prefs.getIntPref(
+ "zen.theme.gradient-legacy-version",
+ 1
+ ) === 0;
- const primaryColor = this.getAccentColorForUI(
- dominantColor,
- isDarkMode
- );
- docElement.style.setProperty("--zen-primary-color", primaryColor);
+ let isDarkMode = isDarkModeWindow;
+ if (!isDefaultTheme && !this.isLegacyVersion) {
+ // Check for the primary color
+ isDarkMode =
+ browser.gZenThemePicker.shouldBeDarkMode(dominantColor);
+ docElement.setAttribute("zen-should-be-dark-mode", isDarkMode);
+ browser.gZenThemePicker.panel.removeAttribute(
+ "invalidate-controls"
+ );
+ } else {
+ docElement.removeAttribute("zen-should-be-dark-mode");
+ if (!this.isLegacyVersion) {
+ browser.gZenThemePicker.panel.setAttribute(
+ "invalidate-controls",
+ "true"
+ );
+ }
+ }
- // Set `--toolbox-textcolor` to have a contrast with the primary color
- let textColor = this.getToolbarColor(isDarkMode, dominantColor);
- docElement.style.setProperty(
- "--toolbox-textcolor",
- `rgba(${textColor[0]}, ${textColor[1]}, ${textColor[2]}, ${textColor[3]})`
- );
- docElement.style.setProperty(
- "--toolbar-color-scheme",
- isDarkMode ? "dark" : "light"
- );
+ const primaryColor = this.getAccentColorForUI(
+ dominantColor,
+ isDarkMode
+ );
+ docElement.style.setProperty("--zen-primary-color", primaryColor);
+
+ // Set `--toolbox-textcolor` to have a contrast with the primary color
+ let textColor = this.getToolbarColor(isDarkMode, dominantColor);
+ docElement.style.setProperty(
+ "--toolbox-textcolor",
+ `rgba(${textColor[0]}, ${textColor[1]}, ${textColor[2]}, ${textColor[3]})`
+ );
+ docElement.style.setProperty(
+ "--toolbar-color-scheme",
+ isDarkMode ? "dark" : "light"
+ );
+ }
}
if (!skipUpdate) {
@@ -1878,6 +1894,36 @@ export class nsZenThemePicker extends nsZenMultiWindowFeature {
}
}
+ #editingWorkspaceId = null;
+
+ get editingWorkspaceId() {
+ return this.#editingWorkspaceId;
+ }
+
+ get workspaceBeingEdited() {
+ return (
+ gZenWorkspaces.getWorkspaceFromId(this.#editingWorkspaceId) ??
+ gZenWorkspaces.getActiveWorkspace()
+ );
+ }
+
+ /**
+ * Opens the picker for a space without switching to it.
+ *
+ * @param {object} workspace - The space to edit
+ * @param {Element} anchor - Element to anchor the panel to
+ * @param {Event} event - The triggering event
+ */
+ openThemePickerForWorkspace(workspace, anchor, event) {
+ this.#editingWorkspaceId = workspace.uuid;
+ this.onWorkspaceChange(workspace);
+ this.panel.removeAttribute("hidepopovertail");
+ PanelMultiView.openPopup(this.panel, anchor, {
+ position: "bottomleft topleft",
+ triggerEvent: event,
+ });
+ }
+
updateCurrentWorkspace(skipSave = true) {
this.updated = skipSave;
const dots = this.panel.querySelectorAll(".zen-theme-picker-dot");
@@ -1916,7 +1962,7 @@ export class nsZenThemePicker extends nsZenMultiWindowFeature {
this.currentOpacity,
this.currentTexture
);
- let currentWorkspace = gZenWorkspaces.getActiveWorkspace();
+ let currentWorkspace = this.workspaceBeingEdited;
currentWorkspace.theme = gradient;
if (!skipSave) {
@@ -1928,6 +1974,10 @@ export class nsZenThemePicker extends nsZenMultiWindowFeature {
skipSave,
skipSave ? gradient : null
);
+ if (currentWorkspace.uuid !== gZenWorkspaces.activeWorkspace) {
+ this.invalidateGradientCache(currentWorkspace.uuid);
+ Services.obs.notifyObservers(null, "zen-space-gradient-update");
+ }
}
handlePanelClose() {
@@ -1935,6 +1985,11 @@ export class nsZenThemePicker extends nsZenMultiWindowFeature {
this.updateCurrentWorkspace(false);
}
this.uninitThemePicker();
+ if (this.#editingWorkspaceId) {
+ this.#editingWorkspaceId = null;
+ this.panel.setAttribute("hidepopovertail", "true");
+ this.onWorkspaceChange(gZenWorkspaces.getActiveWorkspace());
+ }
}
handlePanelOpen() {
diff --git a/src/zen/spaces/ZenSpacesSwipe.mjs b/src/zen/spaces/ZenSpacesSwipe.mjs
index eea00519d..b9b9015ad 100644
--- a/src/zen/spaces/ZenSpacesSwipe.mjs
+++ b/src/zen/spaces/ZenSpacesSwipe.mjs
@@ -12,15 +12,23 @@ ChromeUtils.defineLazyGetter(lazy, "toolbarBackgroundElement", () => {
return document.getElementById("zen-toolbar-background");
});
+ChromeUtils.defineESModuleGetters(
+ lazy,
+ { ZenLibrary: "moz-src:///zen/library/ZenLibrary.mjs" },
+ { global: "current" }
+);
+
export class ZenSpacesSwipe {
_swipeState = {
isGestureActive: false,
lastDelta: 0,
direction: null,
+ isSwipingLibrary: false,
+ beforeLibraryState: 0,
};
constructor() {
- this.#attachWorkspaceSwipeGestures(gNavToolbox);
+ this.attachWorkspaceSwipeGestures(gNavToolbox);
this._popupOpenHandler = this._popupOpenHandler.bind(this);
}
@@ -35,20 +43,39 @@ export class ZenSpacesSwipe {
);
}
- #attachWorkspaceSwipeGestures(element) {
+ #readySwipeOpenLibrary() {
+ const spaces = gZenWorkspaces.getWorkspaces();
+ const current = gZenWorkspaces.getActiveWorkspaceFromCache();
+ const libraryEnabled = Services.prefs.getBoolPref("zen.library.enabled");
+ const libraryOnRight = lazy.ZenLibrary.libraryOnRight;
+ return (
+ spaces.indexOf(current) === (libraryOnRight ? spaces.length - 1 : 0) &&
+ libraryEnabled
+ );
+ }
+
+ attachWorkspaceSwipeGestures(element) {
+ const gestureControl = {
+ _handleSwipeMayStart: this._handleSwipeMayStart.bind(this),
+ _handleSwipeStart: this._handleSwipeStart.bind(this),
+ _handleSwipeUpdate: this._handleSwipeUpdate.bind(this),
+ _handleSwipeEnd: this._handleSwipeEnd.bind(this),
+ _handleSwipeAnimationEnd: this.onSwipeGestureAnimationEnd.bind(this),
+ };
+
element.addEventListener(
"MozSwipeGestureMayStart",
- this._handleSwipeMayStart.bind(this),
+ gestureControl._handleSwipeMayStart,
true
);
element.addEventListener(
"MozSwipeGestureStart",
- this._handleSwipeStart.bind(this),
+ gestureControl._handleSwipeStart,
true
);
element.addEventListener(
"MozSwipeGestureUpdate",
- this._handleSwipeUpdate.bind(this),
+ gestureControl._handleSwipeUpdate,
true
);
@@ -56,15 +83,47 @@ export class ZenSpacesSwipe {
// while MozSwipeGesture is fired immediately after swipe ends.
element.addEventListener(
"MozSwipeGesture",
- this._handleSwipeEnd.bind(this),
+ gestureControl._handleSwipeEnd,
true
);
element.addEventListener(
"MozSwipeGestureEnd",
- () => {
- this.onSwipeGestureAnimationEnd();
- },
+ gestureControl._handleSwipeAnimationEnd,
+ true
+ );
+
+ return gestureControl;
+ }
+
+ detachWorkspaceSwipeGestures(element, gestureControl) {
+ element.removeEventListener(
+ "MozSwipeGestureMayStart",
+ gestureControl._handleSwipeMayStart,
+ true
+ );
+ element.removeEventListener(
+ "MozSwipeGestureStart",
+ gestureControl._handleSwipeStart,
+ true
+ );
+ element.removeEventListener(
+ "MozSwipeGestureUpdate",
+ gestureControl._handleSwipeUpdate,
+ true
+ );
+
+ // Use MozSwipeGesture instead of MozSwipeGestureEnd because MozSwipeGestureEnd is fired after animation ends,
+ // while MozSwipeGesture is fired immediately after swipe ends.
+ element.removeEventListener(
+ "MozSwipeGesture",
+ gestureControl._handleSwipeEnd,
+ true
+ );
+
+ element.removeEventListener(
+ "MozSwipeGestureEnd",
+ gestureControl._handleSwipeAnimationEnd,
true
);
}
@@ -115,6 +174,8 @@ export class ZenSpacesSwipe {
isGestureActive: true,
lastDelta: 0,
direction: null,
+ isSwipingLibrary: false,
+ beforeLibraryState: 0,
};
Services.prefs.setBoolPref("zen.swipe.is-fast-swipe", true);
}
@@ -154,6 +215,55 @@ export class ZenSpacesSwipe {
this._swipeState.direction = delta > 0 ? "left" : "right";
}
+ const libraryOnRight = lazy.ZenLibrary.libraryOnRight;
+ const libraryOpen = lazy.ZenLibrary.isLibraryOpen;
+ const couldClose = libraryOpen;
+ const wantsOpen =
+ !libraryOpen &&
+ (libraryOnRight ? translateX < 0 : translateX > 0) &&
+ this.#readySwipeOpenLibrary();
+
+ if (wantsOpen || couldClose || this._swipeState.isSwipingLibrary) {
+ if (!this._swipeState.isSwipingLibrary) {
+ this._swipeState.isSwipingLibrary = true;
+ this._swipeState.beforeLibraryState = libraryOpen ? 1 : 0;
+ lazy.ZenLibrary.startSwipe();
+ }
+
+ const rubberBand = function (offset, dimension, constant = 0.55) {
+ if (offset === 0 || dimension === 0) {
+ return 0;
+ }
+ return (
+ dimension *
+ (1 - Math.exp(-(Math.abs(offset) * constant) / dimension)) *
+ Math.sign(offset)
+ );
+ };
+
+ const DAMPING_DIMENSION = 0.2;
+ const RUBBER_BAND_CONSTANT = 0.08;
+
+ const LIBRARY_SWIPE_FULL = 0.8;
+ const translation = libraryOnRight ? -translateX : translateX;
+ const deltaProgress = (translation / stripWidth) * LIBRARY_SWIPE_FULL;
+ const progress = this._swipeState.beforeLibraryState + deltaProgress;
+
+ let progressDamped;
+ if (progress < 0) {
+ progressDamped =
+ 0 + rubberBand(progress, DAMPING_DIMENSION, RUBBER_BAND_CONSTANT);
+ } else if (progress > 1) {
+ progressDamped =
+ 1 + rubberBand(progress - 1, DAMPING_DIMENSION, RUBBER_BAND_CONSTANT);
+ } else {
+ progressDamped = progress;
+ }
+
+ lazy.ZenLibrary.swipeProgress(progressDamped);
+ return;
+ }
+
// Apply a translateX to the tab strip to give the user feedback on the swipe
const currentWorkspace = ws.getActiveWorkspaceFromCache();
ws._organizeWorkspaceStripLocations(currentWorkspace, true, translateX);
@@ -173,17 +283,29 @@ export class ZenSpacesSwipe {
const rawDirection = moveForward ? 1 : -1;
const direction = ws.naturalScroll ? -1 : 1;
+
+ if (this._swipeState.isSwipingLibrary) {
+ lazy.ZenLibrary.stopSwipe(rawDirection * direction);
+ return;
+ }
+
await ws.changeWorkspaceShortcut(rawDirection * direction, true);
}
onSwipeGestureAnimationEnd() {
const ws = gZenWorkspaces;
+ if (this._swipeState.isSwipingLibrary) {
+ lazy.ZenLibrary.stopSwipe(null);
+ }
+
// Reset swipe state
this._swipeState = {
isGestureActive: false,
lastDelta: 0,
direction: null,
+ isSwipingLibrary: false,
+ beforeLibraryState: 0,
};
Services.prefs.setBoolPref("zen.swipe.is-fast-swipe", false);
diff --git a/src/zen/split-view/ZenViewSplitter.mjs b/src/zen/split-view/ZenViewSplitter.mjs
index 67ded7b24..fcc9ac8b3 100644
--- a/src/zen/split-view/ZenViewSplitter.mjs
+++ b/src/zen/split-view/ZenViewSplitter.mjs
@@ -1425,9 +1425,15 @@ class nsZenViewSplitter extends nsZenDOMOperatedFeature {
* use -1 to avoid selecting any tab.
* @param {object} options - Additional options.
* @param {string|null} options.groupFetchId - An optional group fetch ID.
+ * @param {boolean} options.activate - Whether to select the split after creating it.
* @returns {object|undefined} The split view data or undefined if the split was not performed.
*/
- splitTabs(tabs, gridType, initialIndex = 0, { groupFetchId = null } = {}) {
+ splitTabs(
+ tabs,
+ gridType,
+ initialIndex = 0,
+ { groupFetchId = null, activate = true } = {}
+ ) {
const tabIndexToUse = Math.max(0, initialIndex);
return this.#withoutSplitViewTransition(() => {
// TODO: Add support for splitting essential tabs
@@ -1443,6 +1449,7 @@ class nsZenViewSplitter extends nsZenDOMOperatedFeature {
const existingSplitTab = tabs.find(tab => tab.splitView);
let shouldActivateSplit =
+ activate &&
(initialIndex >= 0 || tabs.includes(window.gBrowser.selectedTab)) &&
!this._sessionRestoring;
diff --git a/src/zen/tabs/zen-tabs/vertical-tabs.css b/src/zen/tabs/zen-tabs/vertical-tabs.css
index 71a056922..713fe8717 100644
--- a/src/zen/tabs/zen-tabs/vertical-tabs.css
+++ b/src/zen/tabs/zen-tabs/vertical-tabs.css
@@ -83,7 +83,8 @@
height: 100%;
}
-#browser {
+#browser,
+zen-library {
--zen-min-toolbox-padding: 5px;
@media (-moz-platform: macos) {
--zen-min-toolbox-padding: 6px;
@@ -229,7 +230,8 @@
/* ==========================================================================
Navigator Toolbox (Main Sidebar Container) Base Styles
========================================================================== */
-#navigator-toolbox {
+#navigator-toolbox,
+.zen-library-space-body {
/* Define theme variables, including platform specifics for native look */
--zen-toolbox-min-width: 1px;
--border-radius-medium: 14px;
@@ -453,6 +455,7 @@
}
#zen-sidebar-foot-buttons {
+ position: relative;
background: transparent;
gap: 5px;
align-items: center;
@@ -460,6 +463,10 @@
& > toolbarbutton:not(#zen-workspaces-button) {
padding: 0 !important;
}
+
+ :root[zen-right-side="true"][zen-sidebar-expanded="true"] & {
+ flex-direction: row-reverse;
+ }
}
#tabbrowser-arrowscrollbox {
@@ -508,7 +515,7 @@
}
}
-#navigator-toolbox[zen-sidebar-expanded="true"] {
+:is(#navigator-toolbox, .zen-library-space-body)[zen-sidebar-expanded="true"] {
--zen-toolbox-min-width: fit-content;
padding: var(--zen-toolbox-padding);
@@ -564,7 +571,6 @@
& #zen-sidebar-foot-buttons {
display: flex;
- flex-direction: row;
justify-content: space-between;
width: 100%;
position: relative;
@@ -877,6 +883,10 @@
display: none !important;
}
+:root[zen-library-open="true"] #zen-sidebar-splitter {
+ pointer-events: none !important;
+}
+
#zen-sidebar-splitter {
opacity: 0;
max-width: var(--zen-toolbox-padding) !important;
diff --git a/src/zen/urlbar/ZenUBGlobalActions.sys.mjs b/src/zen/urlbar/ZenUBGlobalActions.sys.mjs
index a109a329c..3dae7ac16 100644
--- a/src/zen/urlbar/ZenUBGlobalActions.sys.mjs
+++ b/src/zen/urlbar/ZenUBGlobalActions.sys.mjs
@@ -35,7 +35,7 @@ const globalActionsTemplate = [
{
l10nId: "zen-action-open-theme-picker",
command: "cmd_zenOpenZenThemePicker",
- icon: "chrome://browser/skin/zen-icons/edit-theme.svg",
+ icon: "chrome://browser/skin/zen-icons/paintbrush-fill.svg",
},
{
l10nId: "zen-action-new-split-view",