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