From 04ab9a4b4ed2ffdd4727d1cb226b65823816b471 Mon Sep 17 00:00:00 2001 From: "Mr. M" Date: Sat, 22 Nov 2025 18:15:18 +0100 Subject: [PATCH] feat: Listen for more tab events and properly sync them, b=no-bug, c=common, folders, tabs --- src/browser/base/content/zen-assets.inc.xhtml | 1 - .../sessionstore/SessionStore-sys-mjs.patch | 6 +- src/zen/common/modules/ZenSessionStore.mjs | 4 +- src/zen/common/modules/ZenUIManager.mjs | 1 - src/zen/folders/ZenFolders.mjs | 3 +- .../sessionstore/ZenSessionManager.sys.mjs | 2 +- src/zen/sessionstore/ZenWindowSync.sys.mjs | 232 ++++++- src/zen/tabs/ZenPinnedTabManager.mjs | 4 +- src/zen/tabs/ZenPinnedTabsStorage.mjs | 635 ------------------ src/zen/tabs/jar.inc.mn | 1 - 10 files changed, 223 insertions(+), 666 deletions(-) delete mode 100644 src/zen/tabs/ZenPinnedTabsStorage.mjs diff --git a/src/browser/base/content/zen-assets.inc.xhtml b/src/browser/base/content/zen-assets.inc.xhtml index 2ab4e638e..7f3bce1f4 100644 --- a/src/browser/base/content/zen-assets.inc.xhtml +++ b/src/browser/base/content/zen-assets.inc.xhtml @@ -47,7 +47,6 @@ - diff --git a/src/browser/components/sessionstore/SessionStore-sys-mjs.patch b/src/browser/components/sessionstore/SessionStore-sys-mjs.patch index 47833210a..6390c02f9 100644 --- a/src/browser/components/sessionstore/SessionStore-sys-mjs.patch +++ b/src/browser/components/sessionstore/SessionStore-sys-mjs.patch @@ -1,5 +1,5 @@ diff --git a/browser/components/sessionstore/SessionStore.sys.mjs b/browser/components/sessionstore/SessionStore.sys.mjs -index 1cdbc0f41bf5b55dfbbd850cb618c6d870f7a261..9ef1996a0e8a3ebe55dc25921b8fc8cc0ac8a303 100644 +index 1cdbc0f41bf5b55dfbbd850cb618c6d870f7a261..a7ae5953037bbdab8844c9e400caf233bd61eb0f 100644 --- a/browser/components/sessionstore/SessionStore.sys.mjs +++ b/browser/components/sessionstore/SessionStore.sys.mjs @@ -127,6 +127,8 @@ const TAB_EVENTS = [ @@ -72,7 +72,7 @@ index 1cdbc0f41bf5b55dfbbd850cb618c6d870f7a261..9ef1996a0e8a3ebe55dc25921b8fc8cc ); tabState.pinned = false; + tabState.zenEssential = false; -+ tabState.zenPinnedId = null; ++ tabState.zenSyncId = null; + tabState.zenIsGlance = false; + tabState.zenGlanceId = null; + tabState.zenHasStaticLabel = false; @@ -172,7 +172,7 @@ index 1cdbc0f41bf5b55dfbbd850cb618c6d870f7a261..9ef1996a0e8a3ebe55dc25921b8fc8cc + tab.setAttribute("zen-has-static-label", "true"); + } + if (tabData.zenSyncId) { -+ tab.setAttribute("id", tabData.zenPinnedId); ++ tab.setAttribute("id", tabData.zenSyncId); + } + if (tabData.zenDefaultUserContextId) { + tab.setAttribute("zenDefaultUserContextId", true); diff --git a/src/zen/common/modules/ZenSessionStore.mjs b/src/zen/common/modules/ZenSessionStore.mjs index c19233e7d..aad335c01 100644 --- a/src/zen/common/modules/ZenSessionStore.mjs +++ b/src/zen/common/modules/ZenSessionStore.mjs @@ -17,8 +17,8 @@ class ZenSessionStore extends nsZenPreloadedFeature { if (tabData.zenWorkspace) { tab.setAttribute('zen-workspace-id', tabData.zenWorkspace); } - if (tabData.zenPinnedId) { - tab.setAttribute('id', tabData.zenPinnedId); + if (tabData.zenSyncId) { + tab.setAttribute('id', tabData.zenSyncId); } if (tabData.zenHasStaticLabel) { tab.setAttribute('zen-has-static-label', 'true'); diff --git a/src/zen/common/modules/ZenUIManager.mjs b/src/zen/common/modules/ZenUIManager.mjs index 780063f0f..517696d30 100644 --- a/src/zen/common/modules/ZenUIManager.mjs +++ b/src/zen/common/modules/ZenUIManager.mjs @@ -811,7 +811,6 @@ window.gZenVerticalTabsManager = { !aItem.isConnected || gZenUIManager.testingEnabled || !gZenStartup.isReady || - !gZenPinnedTabManager.hasInitializedPins || aItem.group?.hasAttribute('split-view-group') ) { return; diff --git a/src/zen/folders/ZenFolders.mjs b/src/zen/folders/ZenFolders.mjs index d5adaa8b0..08b0ad465 100644 --- a/src/zen/folders/ZenFolders.mjs +++ b/src/zen/folders/ZenFolders.mjs @@ -945,8 +945,7 @@ class nsZenFolders extends nsZenDOMOperatedFeature { if (gBrowser.isTabGroup(prevSibling)) { prevSiblingInfo = { type: 'group', id: prevSibling.id }; } else if (gBrowser.isTab(prevSibling) && prevSibling.hasAttribute('id')) { - const zenPinId = prevSibling.getAttribute('id'); - prevSiblingInfo = { type: 'tab', id: zenPinId }; + prevSiblingInfo = { type: 'tab', id: prevSibling.getAttribute('id') }; } else { prevSiblingInfo = { type: 'start', id: null }; } diff --git a/src/zen/sessionstore/ZenSessionManager.sys.mjs b/src/zen/sessionstore/ZenSessionManager.sys.mjs index 914a3eb6d..2d2a630a9 100644 --- a/src/zen/sessionstore/ZenSessionManager.sys.mjs +++ b/src/zen/sessionstore/ZenSessionManager.sys.mjs @@ -164,7 +164,7 @@ class nsZenSessionManager { let newWindow = Cu.cloneInto(windows[0], {}); delete newWindow.selected; const newState = { windows: [newWindow] }; - SessionStoreInternal.restoreWindows(aWindow, newState, {}); + //SessionStoreInternal.restoreWindows(aWindow, newState, {}); }); } } diff --git a/src/zen/sessionstore/ZenWindowSync.sys.mjs b/src/zen/sessionstore/ZenWindowSync.sys.mjs index 42947a9ed..12b70f64e 100644 --- a/src/zen/sessionstore/ZenWindowSync.sys.mjs +++ b/src/zen/sessionstore/ZenWindowSync.sys.mjs @@ -10,17 +10,41 @@ ChromeUtils.defineESModuleGetters(lazy, { TabStateFlusher: 'resource:///modules/sessionstore/TabStateFlusher.sys.mjs', }); -const OBSERVING = ['browser-window-delayed-startup']; -const EVENTS = ['TabOpen']; +const OBSERVING = ['browser-window-before-show']; +const EVENTS = [ + 'TabOpen', + 'ZenTabIconChanged', + 'ZenTabLabelChanged', + 'TabMove', + 'TabPinned', + 'TabUnpinned', + 'TabClose', + 'TabAddedToEssentials', + 'TabRemovedFromEssentials', +]; + +// Flags acting as an enum for sync types. +const SYNC_FLAG_LABEL = 1 << 0; +const SYNC_FLAG_ICON = 1 << 1; +const SYNC_FLAG_MOVE = 1 << 2; class nsZenWindowSync { constructor() {} /** - * Whether to ignore the next set of events. - * This is used to prevent recursive event handling. + * Context about the currently handled event. + * Used to avoid re-entrancy issues. + * + * We do still wan't to keep a stack of these in order + * to handle consequtive events properly. For example, + * loading a webpage will call IconChanged and TitleChanged + * events one after another. */ - #ignoreNextEvents = false; + #eventHandlingContext = { + window: null, + eventCount: 0, + lastHandlerPromise: Promise.resolve(), + }; /** * Iterator that yields all currently opened browser windows. @@ -31,7 +55,7 @@ class nsZenWindowSync { #browserWindows = { *[Symbol.iterator]() { for (let window of lazy.BrowserWindowTracker.orderedWindows) { - if (window.__SSi && !window.closed) { + if (window.__SSi && !window.closed && window.gZenStartup.isReady) { yield window; } } @@ -42,7 +66,7 @@ class nsZenWindowSync { for (let topic of OBSERVING) { Services.obs.addObserver(this, topic); } - SessionStore.promiseInitialized.then(() => { + lazy.SessionStore.promiseAllWindowsRestored.then(() => { this.#onSessionStoreInitialized(); }); } @@ -106,18 +130,16 @@ class nsZenWindowSync { * @param {Function} aCallback - The callback function to run on each window. */ #runOnAllWindows(aWindow, aCallback) { - this.#ignoreNextEvents = true; for (let window of this.#browserWindows) { if (window !== aWindow) { aCallback(window); } } - this.#ignoreNextEvents = false; } observe(aSubject, aTopic) { switch (aTopic) { - case 'browser-window-delayed-startup': { + case 'browser-window-before-show': { this.#onWindowBeforeShow(aSubject); break; } @@ -125,9 +147,41 @@ class nsZenWindowSync { } handleEvent(aEvent) { - if (this.#ignoreNextEvents) { + const window = aEvent.currentTarget.ownerGlobal; + if (!window.gZenStartup.isReady) { return; } + if (this.#eventHandlingContext.window && this.#eventHandlingContext.window !== window) { + // We're already handling an event for another window. + // To avoid re-entrancy issues, we skip this event. + return; + } + const lastHandlerPromise = this.#eventHandlingContext.lastHandlerPromise; + this.#eventHandlingContext.eventCount++; + this.#eventHandlingContext.window = window; + let resolveNewPromise; + this.#eventHandlingContext.lastHandlerPromise = new Promise((resolve) => { + resolveNewPromise = resolve; + }); + // Wait for the last handler to finish before processing the next event. + lastHandlerPromise.then(() => { + try { + this.#handleNextEvent(aEvent); + } finally { + if (--this.#eventHandlingContext.eventCount === 0) { + this.#eventHandlingContext.window = null; + } + resolveNewPromise(); + } + }); + } + + /** + * Handles the next event by calling the appropriate handler method. + * + * @param {Event} aEvent - The event to handle. + */ + #handleNextEvent(aEvent) { const handler = `on_${aEvent.type}`; if (typeof this[handler] === 'function') { this[handler](aEvent); @@ -136,18 +190,46 @@ class nsZenWindowSync { } } + /** + * Retrieves a tab element from a window by its ID. + * + * @param {Window} aWindow - The window containing the tab. + * @param {string} aTabId - The ID of the tab to retrieve. + * @returns {Object|null} The tab element if found, otherwise null. + */ + #getTabFromWindow(aWindow, aTabId) { + return aWindow.document.getElementById(aTabId); + } + /** * Synchronizes the icon and label of the target tab with the original tab. * * @param {Object} aOriginalTab - The original tab to copy from. * @param {Object} aTargetTab - The target tab to copy to. * @param {Window} aWindow - The window containing the tabs. + * @param {number} flags - The sync flags indicating what to synchronize. */ - #syncTabWithOriginal(aOriginalTab, aTargetTab, aWindow) { + #syncTabWithOriginal(aOriginalTab, aTargetTab, aWindow, flags = 0) { + if (!aOriginalTab || !aTargetTab) { + return; + } const { gBrowser } = aWindow; - gBrowser.setIcon(aTargetTab, gBrowser.getIcon(aOriginalTab)); - gBrowser._setTabLabel(aTargetTab, aOriginalTab.label); - this.#syncTabPosition(aOriginalTab, aTargetTab, aWindow); + if (flags & SYNC_FLAG_ICON) { + gBrowser.setIcon(aTargetTab, gBrowser.getIcon(aOriginalTab)); + } + if (flags & SYNC_FLAG_LABEL) { + gBrowser._setTabLabel(aTargetTab, aOriginalTab.label); + } + if (flags & SYNC_FLAG_MOVE && !aTargetTab.hasAttribute('zen-empty-tab')) { + const workspaceId = aOriginalTab.getAttribute('zen-workspace-id'); + if (workspaceId) { + aTargetTab.setAttribute('zen-workspace-id', workspaceId); + } else { + aTargetTab.removeAttribute('zen-workspace-id'); + } + this.#syncTabPosition(aOriginalTab, aTargetTab, aWindow); + } + lazy.TabStateFlusher.flush(aTargetTab.linkedBrowser); } /** @@ -177,15 +259,131 @@ class nsZenWindowSync { gBrowser.unpinTab(aTargetTab); } } + + this.#moveTabToMatchOriginal(aOriginalTab, aTargetTab, aWindow, { + isEssential: originalIsEssential, + isPinned: originalIsPinned, + }); } + /** + * Moves the target tab to match the position of the original tab. + * + * @param {Object} aOriginalTab - The original tab to match. + * @param {Object} aTargetTab - The target tab to move. + * @param {Window} aWindow - The window containing the tabs. + */ + #moveTabToMatchOriginal(aOriginalTab, aTargetTab, aWindow, { isEssential, isPinned }) { + const { gBrowser, gZenWorkspaces } = aWindow; + const originalSibling = aOriginalTab.previousElementSibling; + let isFirstTab = true; + if (gBrowser.isTabGroup(originalSibling) || gBrowser.isTab(originalSibling)) { + isFirstTab = !originalSibling.hasAttribute('id'); + } + + gBrowser.zenHandleTabMove(aOriginalTab, () => { + if (isFirstTab) { + let container; + if (isEssential) { + container = gZenWorkspaces.getEssentialsSection(aTargetTab); + } else { + const workspaceId = aTargetTab.getAttribute('zen-workspace-id'); + const workspaceElement = gZenWorkspaces.workspaceElement(workspaceId); + container = isPinned + ? workspaceElement.pinnedTabsContainer + : workspaceElement.tabsContainer; + } + if (container) { + container.insertBefore(aTargetTab, container.firstChild); + } + return; + } + const relativeTab = this.#getTabFromWindow(aWindow, originalSibling.id); + if (relativeTab) { + relativeTab.after(aTargetTab); + } + }); + } + + /** + * Synchronizes a tab across all browser windows. + * + * @param {Object} aTab - The tab to synchronize. + * @param {number} flags - The sync flags indicating what to synchronize. + */ + #syncTabForAllWindows(aTab, flags = 0) { + const window = aTab.ownerGlobal; + this.#runOnAllWindows(window, (win) => { + this.#syncTabWithOriginal(aTab, this.#getTabFromWindow(win, aTab.id), win, flags); + }); + } + + /** + * Delegates generic sync events to synchronize tabs across windows. + * + * @param {Event} aEvent - The event to delegate. + * @param {number} flags - The sync flags indicating what to synchronize. + */ + #delegateGenericSyncEvent(aEvent, flags = 0) { + const tab = aEvent.target; + this.#syncTabForAllWindows(tab, flags); + } + + /* Mark: Event Handlers */ + on_TabOpen(aEvent) { const tab = aEvent.target; const window = tab.ownerGlobal; - + tab.id = this.#newTabSyncId; this.#runOnAllWindows(window, (win) => { const newTab = win.gBrowser.duplicateTab(tab); - this.#syncTabWithOriginal(tab, newTab, win); + newTab.id = tab.id; + this.#syncTabWithOriginal( + tab, + newTab, + win, + SYNC_FLAG_ICON | SYNC_FLAG_LABEL | SYNC_FLAG_MOVE + ); + win.gZenVerticalTabsManager.animateItemOpen(newTab); + }); + } + + on_ZenTabIconChanged(aEvent) { + return this.#delegateGenericSyncEvent(aEvent, SYNC_FLAG_ICON); + } + + on_ZenTabLabelChanged(aEvent) { + return this.#delegateGenericSyncEvent(aEvent, SYNC_FLAG_LABEL); + } + + on_TabMove(aEvent) { + return this.#delegateGenericSyncEvent(aEvent, SYNC_FLAG_MOVE); + } + + on_TabPinned(aEvent) { + return this.on_TabMove(aEvent); + } + + on_TabUnpinned(aEvent) { + return this.on_TabMove(aEvent); + } + + on_TabAddedToEssentials(aEvent) { + return this.on_TabMove(aEvent); + } + + on_TabRemovedFromEssentials(aEvent) { + return this.on_TabMove(aEvent); + } + + on_TabClose(aEvent) { + const tab = aEvent.target; + const window = tab.ownerGlobal; + this.#runOnAllWindows(window, (win) => { + const targetTab = this.#getTabFromWindow(win, tab.id); + if (targetTab) { + win.gBrowser.removeTab(targetTab, { animate: true }); + } }); } } diff --git a/src/zen/tabs/ZenPinnedTabManager.mjs b/src/zen/tabs/ZenPinnedTabManager.mjs index af06b405e..3ef23aab1 100644 --- a/src/zen/tabs/ZenPinnedTabManager.mjs +++ b/src/zen/tabs/ZenPinnedTabManager.mjs @@ -60,7 +60,6 @@ class ZenPinnedTabsObserver { } class nsZenPinnedTabManager extends nsZenDOMOperatedFeature { - hasInitializedPins = false; promiseInitializedPinned = new Promise((resolve) => { this._resolvePinnedInitializedInternal = resolve; }); @@ -87,7 +86,6 @@ class nsZenPinnedTabManager extends nsZenDOMOperatedFeature { } onTabIconChanged(tab, url = null) { - tab.dispatchEvent(new CustomEvent('ZenTabIconChanged', { bubbles: true, detail: { tab } })); tab.dispatchEvent(new CustomEvent('ZenTabIconChanged', { bubbles: true, detail: { tab } })); const iconUrl = url ?? tab.iconImage.src; if (tab.hasAttribute('zen-essential')) { @@ -949,7 +947,7 @@ class nsZenPinnedTabManager extends nsZenDOMOperatedFeature { } async onTabLabelChanged(tab) { - tab.dispatchEvent(new CustomEvent('ZenTabLabelChanged', { detail: { tab } })); + tab.dispatchEvent(new CustomEvent('ZenTabLabelChanged', { bubbles: true, detail: { tab } })); if (!this._pinsCache) { return; } diff --git a/src/zen/tabs/ZenPinnedTabsStorage.mjs b/src/zen/tabs/ZenPinnedTabsStorage.mjs deleted file mode 100644 index 425dbf2d1..000000000 --- a/src/zen/tabs/ZenPinnedTabsStorage.mjs +++ /dev/null @@ -1,635 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. -var ZenPinnedTabsStorage = { - async init() { - await this._ensureTable(); - }, - - async _ensureTable() { - await PlacesUtils.withConnectionWrapper('ZenPinnedTabsStorage._ensureTable', async (db) => { - // Create the pins table if it doesn't exist - await db.execute(` - CREATE TABLE IF NOT EXISTS zen_pins ( - id INTEGER PRIMARY KEY, - uuid TEXT UNIQUE NOT NULL, - title TEXT NOT NULL, - url TEXT, - container_id INTEGER, - workspace_uuid TEXT, - position INTEGER NOT NULL DEFAULT 0, - is_essential BOOLEAN NOT NULL DEFAULT 0, - is_group BOOLEAN NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ) - `); - - const columns = await db.execute(`PRAGMA table_info(zen_pins)`); - const columnNames = columns.map((row) => row.getResultByName('name')); - - // Helper function to add column if it doesn't exist - const addColumnIfNotExists = async (columnName, definition) => { - if (!columnNames.includes(columnName)) { - await db.execute(`ALTER TABLE zen_pins ADD COLUMN ${columnName} ${definition}`); - } - }; - - await addColumnIfNotExists('edited_title', 'BOOLEAN NOT NULL DEFAULT 0'); - await addColumnIfNotExists('is_folder_collapsed', 'BOOLEAN NOT NULL DEFAULT 0'); - await addColumnIfNotExists('folder_icon', 'TEXT DEFAULT NULL'); - await addColumnIfNotExists('folder_parent_uuid', 'TEXT DEFAULT NULL'); - - await db.execute(` - CREATE INDEX IF NOT EXISTS idx_zen_pins_uuid ON zen_pins(uuid) - `); - - await db.execute(` - CREATE TABLE IF NOT EXISTS zen_pins_changes ( - uuid TEXT PRIMARY KEY, - timestamp INTEGER NOT NULL - ) - `); - - await db.execute(` - CREATE INDEX IF NOT EXISTS idx_zen_pins_changes_uuid ON zen_pins_changes(uuid) - `); - - this._resolveInitialized(); - }); - }, - - /** - * Private helper method to notify observers with a list of changed UUIDs. - * @param {string} event - The observer event name. - * @param {Array} uuids - Array of changed workspace UUIDs. - */ - _notifyPinsChanged(event, uuids) { - if (uuids.length === 0) return; // No changes to notify - - // Convert the array of UUIDs to a JSON string - const data = JSON.stringify(uuids); - - Services.obs.notifyObservers(null, event, data); - }, - - async savePin(pin, notifyObservers = true) { - const changedUUIDs = new Set(); - - await PlacesUtils.withConnectionWrapper('ZenPinnedTabsStorage.savePin', async (db) => { - await db.executeTransaction(async () => { - const now = Date.now(); - - let newPosition; - if ('position' in pin && Number.isFinite(pin.position)) { - newPosition = pin.position; - } else { - // Get the maximum position within the same parent group (or null for root level) - const maxPositionResult = await db.execute( - ` - SELECT MAX("position") as max_position - FROM zen_pins - WHERE COALESCE(folder_parent_uuid, '') = COALESCE(:folder_parent_uuid, '') - `, - { folder_parent_uuid: pin.parentUuid || null } - ); - const maxPosition = maxPositionResult[0].getResultByName('max_position') || 0; - newPosition = maxPosition + 1000; - } - - // Insert or replace the pin - await db.executeCached( - ` - INSERT OR REPLACE INTO zen_pins ( - uuid, title, url, container_id, workspace_uuid, position, - is_essential, is_group, folder_parent_uuid, edited_title, created_at, - updated_at, is_folder_collapsed, folder_icon - ) VALUES ( - :uuid, :title, :url, :container_id, :workspace_uuid, :position, - :is_essential, :is_group, :folder_parent_uuid, :edited_title, - COALESCE((SELECT created_at FROM zen_pins WHERE uuid = :uuid), :now), - :now, :is_folder_collapsed, :folder_icon - ) - `, - { - uuid: pin.uuid, - title: pin.title, - url: pin.isGroup ? '' : pin.url, - container_id: pin.containerTabId || null, - workspace_uuid: pin.workspaceUuid || null, - position: newPosition, - is_essential: pin.isEssential || false, - is_group: pin.isGroup || false, - folder_parent_uuid: pin.parentUuid || null, - edited_title: pin.editedTitle || false, - now, - folder_icon: pin.folderIcon || null, - is_folder_collapsed: pin.isFolderCollapsed || false, - } - ); - - await db.execute( - ` - INSERT OR REPLACE INTO zen_pins_changes (uuid, timestamp) - VALUES (:uuid, :timestamp) - `, - { - uuid: pin.uuid, - timestamp: Math.floor(now / 1000), - } - ); - - changedUUIDs.add(pin.uuid); - await this.updateLastChangeTimestamp(db); - }); - }); - - if (notifyObservers) { - this._notifyPinsChanged('zen-pin-updated', Array.from(changedUUIDs)); - } - }, - - async getPins() { - const db = await PlacesUtils.promiseDBConnection(); - const rows = await db.executeCached(` - SELECT * FROM zen_pins - ORDER BY position ASC - `); - return rows.map((row) => ({ - uuid: row.getResultByName('uuid'), - title: row.getResultByName('title'), - url: row.getResultByName('url'), - containerTabId: row.getResultByName('container_id'), - workspaceUuid: row.getResultByName('workspace_uuid'), - position: row.getResultByName('position'), - isEssential: Boolean(row.getResultByName('is_essential')), - isGroup: Boolean(row.getResultByName('is_group')), - parentUuid: row.getResultByName('folder_parent_uuid'), - editedTitle: Boolean(row.getResultByName('edited_title')), - folderIcon: row.getResultByName('folder_icon'), - isFolderCollapsed: Boolean(row.getResultByName('is_folder_collapsed')), - })); - }, - - /** - * Create a new group - * @param {string} title - The title of the group - * @param {string} workspaceUuid - The workspace UUID (optional) - * @param {string} parentUuid - The parent group UUID (optional, null for root level) - * @param {number} position - The position of the group (optional, will auto-calculate if not provided) - * @param {boolean} notifyObservers - Whether to notify observers (default: true) - * @returns {Promise} The UUID of the created group - */ - async createGroup( - title, - icon = null, - isCollapsed = false, - workspaceUuid = null, - parentUuid = null, - position = null, - notifyObservers = true - ) { - if (!title || typeof title !== 'string') { - throw new Error('Group title is required and must be a string'); - } - - const groupUuid = gZenUIManager.generateUuidv4(); - - const groupPin = { - uuid: groupUuid, - title, - folderIcon: icon || null, - isFolderCollapsed: isCollapsed || false, - workspaceUuid, - parentUuid, - position, - isGroup: true, - isEssential: false, - editedTitle: true, // Group titles are always considered edited - }; - - await this.savePin(groupPin, notifyObservers); - return groupUuid; - }, - - /** - * Add an existing tab/pin to a group - * @param {string} tabUuid - The UUID of the tab to add to the group - * @param {string} groupUuid - The UUID of the target group - * @param {number} position - The position within the group (optional, will append if not provided) - * @param {boolean} notifyObservers - Whether to notify observers (default: true) - */ - async addTabToGroup(tabUuid, groupUuid, position = null, notifyObservers = true) { - if (!tabUuid || !groupUuid) { - throw new Error('Both tabUuid and groupUuid are required'); - } - - const changedUUIDs = new Set(); - - await PlacesUtils.withConnectionWrapper('ZenPinnedTabsStorage.addTabToGroup', async (db) => { - await db.executeTransaction(async () => { - // Verify the group exists and is actually a group - const groupCheck = await db.execute( - `SELECT is_group FROM zen_pins WHERE uuid = :groupUuid`, - { groupUuid } - ); - - if (groupCheck.length === 0) { - throw new Error(`Group with UUID ${groupUuid} does not exist`); - } - - if (!groupCheck[0].getResultByName('is_group')) { - throw new Error(`Pin with UUID ${groupUuid} is not a group`); - } - - const tabCheck = await db.execute(`SELECT uuid FROM zen_pins WHERE uuid = :tabUuid`, { - tabUuid, - }); - - if (tabCheck.length === 0) { - throw new Error(`Tab with UUID ${tabUuid} does not exist`); - } - - const now = Date.now(); - let newPosition; - - if (position !== null && Number.isFinite(position)) { - newPosition = position; - } else { - // Get the maximum position within the group - const maxPositionResult = await db.execute( - `SELECT MAX("position") as max_position FROM zen_pins WHERE folder_parent_uuid = :groupUuid`, - { groupUuid } - ); - const maxPosition = maxPositionResult[0].getResultByName('max_position') || 0; - newPosition = maxPosition + 1000; - } - - await db.execute( - ` - UPDATE zen_pins - SET folder_parent_uuid = :groupUuid, - position = :newPosition, - updated_at = :now - WHERE uuid = :tabUuid - `, - { - tabUuid, - groupUuid, - newPosition, - now, - } - ); - - changedUUIDs.add(tabUuid); - - await db.execute( - ` - INSERT OR REPLACE INTO zen_pins_changes (uuid, timestamp) - VALUES (:uuid, :timestamp) - `, - { - uuid: tabUuid, - timestamp: Math.floor(now / 1000), - } - ); - - await this.updateLastChangeTimestamp(db); - }); - }); - - if (notifyObservers) { - this._notifyPinsChanged('zen-pin-updated', Array.from(changedUUIDs)); - } - }, - - /** - * Remove a tab from its group (move to root level) - * @param {string} tabUuid - The UUID of the tab to remove from its group - * @param {number} newPosition - The new position at root level (optional, will append if not provided) - * @param {boolean} notifyObservers - Whether to notify observers (default: true) - */ - async removeTabFromGroup(tabUuid, newPosition = null, notifyObservers = true) { - if (!tabUuid) { - throw new Error('tabUuid is required'); - } - - const changedUUIDs = new Set(); - - await PlacesUtils.withConnectionWrapper( - 'ZenPinnedTabsStorage.removeTabFromGroup', - async (db) => { - await db.executeTransaction(async () => { - // Verify the tab exists and is in a group - const tabCheck = await db.execute( - `SELECT folder_parent_uuid FROM zen_pins WHERE uuid = :tabUuid`, - { tabUuid } - ); - - if (tabCheck.length === 0) { - throw new Error(`Tab with UUID ${tabUuid} does not exist`); - } - - if (!tabCheck[0].getResultByName('folder_parent_uuid')) { - return; - } - - const now = Date.now(); - let finalPosition; - - if (newPosition !== null && Number.isFinite(newPosition)) { - finalPosition = newPosition; - } else { - // Get the maximum position at root level (where folder_parent_uuid is null) - const maxPositionResult = await db.execute( - `SELECT MAX("position") as max_position FROM zen_pins WHERE folder_parent_uuid IS NULL` - ); - const maxPosition = maxPositionResult[0].getResultByName('max_position') || 0; - finalPosition = maxPosition + 1000; - } - - // Update the tab to be at root level - await db.execute( - ` - UPDATE zen_pins - SET folder_parent_uuid = NULL, - position = :newPosition, - updated_at = :now - WHERE uuid = :tabUuid - `, - { - tabUuid, - newPosition: finalPosition, - now, - } - ); - - changedUUIDs.add(tabUuid); - - // Record the change - await db.execute( - ` - INSERT OR REPLACE INTO zen_pins_changes (uuid, timestamp) - VALUES (:uuid, :timestamp) - `, - { - uuid: tabUuid, - timestamp: Math.floor(now / 1000), - } - ); - - await this.updateLastChangeTimestamp(db); - }); - } - ); - - if (notifyObservers) { - this._notifyPinsChanged('zen-pin-updated', Array.from(changedUUIDs)); - } - }, - - async removePin(uuid, notifyObservers = true) { - const changedUUIDs = [uuid]; - - await PlacesUtils.withConnectionWrapper('ZenPinnedTabsStorage.removePin', async (db) => { - await db.executeTransaction(async () => { - // Get all child UUIDs first for change tracking - const children = await db.execute( - `SELECT uuid FROM zen_pins WHERE folder_parent_uuid = :uuid`, - { - uuid, - } - ); - - // Add child UUIDs to changedUUIDs array - for (const child of children) { - changedUUIDs.push(child.getResultByName('uuid')); - } - - // Delete the pin/group itself - await db.execute(`DELETE FROM zen_pins WHERE uuid = :uuid`, { uuid }); - - // Record the changes - const now = Math.floor(Date.now() / 1000); - for (const changedUuid of changedUUIDs) { - await db.execute( - ` - INSERT OR REPLACE INTO zen_pins_changes (uuid, timestamp) - VALUES (:uuid, :timestamp) - `, - { - uuid: changedUuid, - timestamp: now, - } - ); - } - - await this.updateLastChangeTimestamp(db); - }); - }); - - if (notifyObservers) { - this._notifyPinsChanged('zen-pin-removed', changedUUIDs); - } - }, - - async wipeAllPins() { - await PlacesUtils.withConnectionWrapper('ZenPinnedTabsStorage.wipeAllPins', async (db) => { - await db.execute(`DELETE FROM zen_pins`); - await db.execute(`DELETE FROM zen_pins_changes`); - await this.updateLastChangeTimestamp(db); - }); - }, - - async markChanged(uuid) { - await PlacesUtils.withConnectionWrapper('ZenPinnedTabsStorage.markChanged', async (db) => { - const now = Date.now(); - await db.execute( - ` - INSERT OR REPLACE INTO zen_pins_changes (uuid, timestamp) - VALUES (:uuid, :timestamp) - `, - { - uuid, - timestamp: Math.floor(now / 1000), - } - ); - }); - }, - - async getChangedIDs() { - const db = await PlacesUtils.promiseDBConnection(); - const rows = await db.execute(` - SELECT uuid, timestamp FROM zen_pins_changes - `); - const changes = {}; - for (const row of rows) { - changes[row.getResultByName('uuid')] = row.getResultByName('timestamp'); - } - return changes; - }, - - async clearChangedIDs() { - await PlacesUtils.withConnectionWrapper('ZenPinnedTabsStorage.clearChangedIDs', async (db) => { - await db.execute(`DELETE FROM zen_pins_changes`); - }); - }, - - shouldReorderPins(before, current, after) { - const minGap = 1; // Minimum allowed gap between positions - return ( - (before !== null && current - before < minGap) || (after !== null && after - current < minGap) - ); - }, - - async reorderAllPins(db, changedUUIDs) { - const pins = await db.execute(` - SELECT uuid - FROM zen_pins - ORDER BY position ASC - `); - - for (let i = 0; i < pins.length; i++) { - const newPosition = (i + 1) * 1000; // Use large increments - await db.execute( - ` - UPDATE zen_pins - SET position = :newPosition - WHERE uuid = :uuid - `, - { newPosition, uuid: pins[i].getResultByName('uuid') } - ); - changedUUIDs.add(pins[i].getResultByName('uuid')); - } - }, - - async updateLastChangeTimestamp(db) { - const now = Date.now(); - await db.execute( - ` - INSERT OR REPLACE INTO moz_meta (key, value) - VALUES ('zen_pins_last_change', :now) - `, - { now } - ); - }, - - async getLastChangeTimestamp() { - const db = await PlacesUtils.promiseDBConnection(); - const result = await db.executeCached(` - SELECT value FROM moz_meta WHERE key = 'zen_pins_last_change' - `); - return result.length ? parseInt(result[0].getResultByName('value'), 10) : 0; - }, - - async updatePinPositions(pins) { - const changedUUIDs = new Set(); - - await PlacesUtils.withConnectionWrapper( - 'ZenPinnedTabsStorage.updatePinPositions', - async (db) => { - await db.executeTransaction(async () => { - const now = Date.now(); - - for (let i = 0; i < pins.length; i++) { - const pin = pins[i]; - const newPosition = (i + 1) * 1000; - - await db.execute( - ` - UPDATE zen_pins - SET position = :newPosition - WHERE uuid = :uuid - `, - { newPosition, uuid: pin.uuid } - ); - - changedUUIDs.add(pin.uuid); - - // Record the change - await db.execute( - ` - INSERT OR REPLACE INTO zen_pins_changes (uuid, timestamp) - VALUES (:uuid, :timestamp) - `, - { - uuid: pin.uuid, - timestamp: Math.floor(now / 1000), - } - ); - } - - await this.updateLastChangeTimestamp(db); - }); - } - ); - - this._notifyPinsChanged('zen-pin-updated', Array.from(changedUUIDs)); - }, - - async updatePinTitle(uuid, newTitle, isEdited = true, notifyObservers = true) { - if (!uuid || typeof newTitle !== 'string') { - throw new Error('Invalid parameters: uuid and newTitle are required'); - } - - const changedUUIDs = new Set(); - - await PlacesUtils.withConnectionWrapper('ZenPinnedTabsStorage.updatePinTitle', async (db) => { - await db.executeTransaction(async () => { - const now = Date.now(); - - // Update the pin's title and edited_title flag - const result = await db.execute( - ` - UPDATE zen_pins - SET title = :newTitle, - edited_title = :isEdited, - updated_at = :now - WHERE uuid = :uuid - `, - { - uuid, - newTitle, - isEdited, - now, - } - ); - - // Only proceed with change tracking if a row was actually updated - if (result.rowsAffected > 0) { - changedUUIDs.add(uuid); - - // Record the change - await db.execute( - ` - INSERT OR REPLACE INTO zen_pins_changes (uuid, timestamp) - VALUES (:uuid, :timestamp) - `, - { - uuid, - timestamp: Math.floor(now / 1000), - } - ); - - await this.updateLastChangeTimestamp(db); - } - }); - }); - - if (notifyObservers && changedUUIDs.size > 0) { - this._notifyPinsChanged('zen-pin-updated', Array.from(changedUUIDs)); - } - }, - - async __dropTables() { - await PlacesUtils.withConnectionWrapper('ZenPinnedTabsStorage.__dropTables', async (db) => { - await db.execute(`DROP TABLE IF EXISTS zen_pins`); - await db.execute(`DROP TABLE IF EXISTS zen_pins_changes`); - }); - }, -}; - -ZenPinnedTabsStorage.promiseInitialized = new Promise((resolve) => { - ZenPinnedTabsStorage._resolveInitialized = resolve; - ZenPinnedTabsStorage.init(); -}); diff --git a/src/zen/tabs/jar.inc.mn b/src/zen/tabs/jar.inc.mn index 2acab4816..192d0d33d 100644 --- a/src/zen/tabs/jar.inc.mn +++ b/src/zen/tabs/jar.inc.mn @@ -2,7 +2,6 @@ # 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-components/ZenPinnedTabsStorage.mjs (../../zen/tabs/ZenPinnedTabsStorage.mjs) content/browser/zen-components/ZenPinnedTabManager.mjs (../../zen/tabs/ZenPinnedTabManager.mjs) * content/browser/zen-styles/zen-tabs.css (../../zen/tabs/zen-tabs.css) content/browser/zen-styles/zen-tabs/vertical-tabs.css (../../zen/tabs/zen-tabs/vertical-tabs.css) \ No newline at end of file