feat: Listen for more tab events and properly sync them, b=no-bug, c=common, folders, tabs

This commit is contained in:
Mr. M
2025-11-22 18:15:18 +01:00
parent 1fcef12d0a
commit 04ab9a4b4e
10 changed files with 223 additions and 666 deletions

View File

@@ -47,7 +47,6 @@
<script type="module" src="chrome://browser/content/zen-components/ZenFolder.mjs"></script>
<script type="module" src="chrome://browser/content/zen-components/ZenCompactMode.mjs"></script>
<script type="module" src="chrome://browser/content/zen-components/ZenPinnedTabsStorage.mjs"></script>
<script type="module" src="chrome://browser/content/zen-components/ZenWorkspacesStorage.mjs"></script>
<script type="module" src="chrome://browser/content/zen-components/ZenMediaController.mjs"></script>

View File

@@ -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);

View File

@@ -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');

View File

@@ -811,7 +811,6 @@ window.gZenVerticalTabsManager = {
!aItem.isConnected ||
gZenUIManager.testingEnabled ||
!gZenStartup.isReady ||
!gZenPinnedTabManager.hasInitializedPins ||
aItem.group?.hasAttribute('split-view-group')
) {
return;

View File

@@ -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 };
}

View File

@@ -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, {});
});
}
}

View File

@@ -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 });
}
});
}
}

View File

@@ -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;
}

View File

@@ -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<string>} 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<string>} 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();
});

View File

@@ -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)