diff --git a/locales/en-US/browser/browser/preferences/zen-preferences.ftl b/locales/en-US/browser/browser/preferences/zen-preferences.ftl
index 2941a51573..136c23aa18 100644
--- a/locales/en-US/browser/browser/preferences/zen-preferences.ftl
+++ b/locales/en-US/browser/browser/preferences/zen-preferences.ftl
@@ -21,6 +21,12 @@ sync-engine-workspaces =
.tooltiptext = Sync your workspaces across devices
.accesskey = W
+sync-currently-syncing-pinnedtabs = Pinned Tabs
+sync-engine-pinnedtabs =
+ .label = Pinned Tabs
+ .tooltiptext = Sync your pinned tabs and tab folders across devices
+ .accesskey = i
+
zen-glance-title = Glance
zen-glance-header = General settings for glance
zen-glance-description = Get a quick overview of your links without opening them in a new tab
diff --git a/prefs/zen/workspaces.yaml b/prefs/zen/workspaces.yaml
index 9b61e17bbc..66652f49bc 100644
--- a/prefs/zen/workspaces.yaml
+++ b/prefs/zen/workspaces.yaml
@@ -29,6 +29,9 @@
- name: services.sync.engine.workspaces
value: false
+- name: services.sync.engine.pinnedtabs
+ value: false
+
- name: zen.workspaces.separate-essentials
value: true
diff --git a/src/browser/base/content/zen-assets.jar.inc.mn b/src/browser/base/content/zen-assets.jar.inc.mn
index d8ee81f6ed..d093c072da 100644
--- a/src/browser/base/content/zen-assets.jar.inc.mn
+++ b/src/browser/base/content/zen-assets.jar.inc.mn
@@ -51,6 +51,7 @@
content/browser/zen-components/ZenKeyboardShortcuts.mjs (../../zen/kbs/ZenKeyboardShortcuts.mjs)
content/browser/zen-components/ZenPinnedTabsStorage.mjs (../../zen/tabs/ZenPinnedTabsStorage.mjs)
+ content/browser/zen-components/ZenPinnedTabsSync.mjs (../../zen/tabs/ZenPinnedTabsSync.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)
diff --git a/src/browser/base/content/zen-preloaded.inc.xhtml b/src/browser/base/content/zen-preloaded.inc.xhtml
index 83e73ef9d5..6eefb40c5f 100644
--- a/src/browser/base/content/zen-preloaded.inc.xhtml
+++ b/src/browser/base/content/zen-preloaded.inc.xhtml
@@ -13,4 +13,5 @@
+
diff --git a/src/browser/components/preferences/dialogs/syncChooseWhatToSync-js.patch b/src/browser/components/preferences/dialogs/syncChooseWhatToSync-js.patch
index e066aca216..8216467742 100644
--- a/src/browser/components/preferences/dialogs/syncChooseWhatToSync-js.patch
+++ b/src/browser/components/preferences/dialogs/syncChooseWhatToSync-js.patch
@@ -2,11 +2,12 @@ diff --git a/browser/components/preferences/dialogs/syncChooseWhatToSync.js b/br
index 64aa0d98a0622c01f3dcfff1a04bfcda368354d2..2013e04b0881ad2295d6897b91e1573cc6efc571 100644
--- a/browser/components/preferences/dialogs/syncChooseWhatToSync.js
+++ b/browser/components/preferences/dialogs/syncChooseWhatToSync.js
-@@ -21,6 +21,7 @@ Preferences.addAll([
+@@ -21,6 +21,8 @@ Preferences.addAll([
{ id: "services.sync.engine.passwords", type: "bool" },
{ id: "services.sync.engine.addresses", type: "bool" },
{ id: "services.sync.engine.creditcards", type: "bool" },
-+ { id: "services.sync.engine.workspaces", type: "bool" },
+ { id: "services.sync.engine.workspaces", type: "bool" },
++ { id: "services.sync.engine.pinnedtabs", type: "bool" },
]);
let gSyncChooseWhatToSync = {
diff --git a/src/browser/components/preferences/dialogs/syncChooseWhatToSync-xhtml.patch b/src/browser/components/preferences/dialogs/syncChooseWhatToSync-xhtml.patch
index ba16a42fc8..6a0624835e 100644
--- a/src/browser/components/preferences/dialogs/syncChooseWhatToSync-xhtml.patch
+++ b/src/browser/components/preferences/dialogs/syncChooseWhatToSync-xhtml.patch
@@ -13,7 +13,7 @@ index ef127a1bc2e3ea4221b641156c38a74edb3b44ae..acd39fe7f6dc7ec03ea50928e2d00279
-@@ -87,6 +91,12 @@
+@@ -87,6 +91,18 @@
preference="services.sync.engine.prefs"
/>
@@ -22,6 +22,12 @@ index ef127a1bc2e3ea4221b641156c38a74edb3b44ae..acd39fe7f6dc7ec03ea50928e2d00279
+ data-l10n-id="sync-engine-workspaces"
+ preference="services.sync.engine.workspaces"
+ />
++
++
++
+
diff --git a/src/browser/components/preferences/sync-inc-xhtml.patch b/src/browser/components/preferences/sync-inc-xhtml.patch
index 406ca8fe29..2d6232fed1 100644
--- a/src/browser/components/preferences/sync-inc-xhtml.patch
+++ b/src/browser/components/preferences/sync-inc-xhtml.patch
@@ -2,13 +2,17 @@ diff --git a/browser/components/preferences/sync.inc.xhtml b/browser/components/
index 31b95644f820eda3267d3b52913ed0845abc1c80..1c8869418f7c88ed33601860dafd2c4eb0d5731f 100644
--- a/browser/components/preferences/sync.inc.xhtml
+++ b/browser/components/preferences/sync.inc.xhtml
-@@ -223,6 +223,10 @@
+@@ -223,6 +223,14 @@
+
+
+
++
++
++
++
+
diff --git a/src/browser/themes/shared/preferences/zen-preferences.css b/src/browser/themes/shared/preferences/zen-preferences.css
index 14f88a823e..03673fd2c0 100644
--- a/src/browser/themes/shared/preferences/zen-preferences.css
+++ b/src/browser/themes/shared/preferences/zen-preferences.css
@@ -568,6 +568,11 @@ groupbox h2 {
list-style-image: url('chrome://devtools/skin/images/tool-storage.svg');
}
+.sync-engine-pinnedtabs .checkbox-icon,
+.sync-engine-pinnedtabs.sync-engine-image {
+ list-style-image: url('chrome://browser/skin/tabs.svg');
+}
+
@media -moz-pref('zen.theme.disable-lightweight') {
html|div[data-l10n-id='preferences-web-appearance-footer'] {
display: none;
diff --git a/src/zen/tabs/ZenPinnedTabManager.mjs b/src/zen/tabs/ZenPinnedTabManager.mjs
index 3436ba1785..624e26d521 100644
--- a/src/zen/tabs/ZenPinnedTabManager.mjs
+++ b/src/zen/tabs/ZenPinnedTabManager.mjs
@@ -91,9 +91,29 @@
this._zenClickEventListener = this._onTabClick.bind(this);
+ // Listen for sync finish events
+ Services.obs.addObserver(this, 'weave:engine:sync:finish');
+ window.addEventListener(
+ 'unload',
+ () => {
+ Services.obs.removeObserver(this, 'weave:engine:sync:finish');
+ },
+ { once: true }
+ );
+
gZenWorkspaces._resolvePinnedInitialized();
}
+ async observe(_subject, topic, data) {
+ if (topic === 'weave:engine:sync:finish' && data === 'pinnedtabs') {
+ try {
+ await this.refreshPinnedTabs({ init: true });
+ } catch (error) {
+ console.error('[ZenPinnedTabManager] Error refreshing pinned tabs after sync:', error);
+ }
+ }
+ }
+
log(message) {
if (this._canLog) {
console.log(`[ZenPinnedTabManager] ${message}`);
@@ -391,7 +411,7 @@
gBrowser.tabContainer._invalidateCachedTabs();
newTab.initialize();
} catch (ex) {
- console.error('Failed to initialize pinned tabs:', ex);
+ console.error('[ZenPinnedTabManager] Failed to create pinned tab for:', pin.title, ex);
}
}
diff --git a/src/zen/tabs/ZenPinnedTabsStorage.mjs b/src/zen/tabs/ZenPinnedTabsStorage.mjs
index bc11213f77..8b59dc8a8e 100644
--- a/src/zen/tabs/ZenPinnedTabsStorage.mjs
+++ b/src/zen/tabs/ZenPinnedTabsStorage.mjs
@@ -2,16 +2,24 @@
// 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 = {
+ lazy: {},
_saveCache: [],
+ _resolveInitialized: null,
async init() {
+ ChromeUtils.defineESModuleGetters(this.lazy, {
+ PlacesUtils: 'resource://gre/modules/PlacesUtils.sys.mjs',
+ Weave: 'resource://services-sync/main.sys.mjs',
+ });
await this._ensureTable();
},
async _ensureTable() {
- await PlacesUtils.withConnectionWrapper('ZenPinnedTabsStorage._ensureTable', async (db) => {
- // Create the pins table if it doesn't exist
- await db.execute(`
+ await this.lazy.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,
@@ -27,38 +35,47 @@ var ZenPinnedTabsStorage = {
)
`);
- const columns = await db.execute(`PRAGMA table_info(zen_pins)`);
- const columnNames = columns.map((row) => row.getResultByName('name'));
+ 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}`);
- }
- };
+ // 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 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(`
+ await db.execute(`
CREATE INDEX IF NOT EXISTS idx_zen_pins_uuid ON zen_pins(uuid)
`);
- await db.execute(`
+ await db.execute(`
CREATE TABLE IF NOT EXISTS zen_pins_changes (
uuid TEXT PRIMARY KEY,
timestamp INTEGER NOT NULL
)
`);
- await db.execute(`
+ await db.execute(`
CREATE INDEX IF NOT EXISTS idx_zen_pins_changes_uuid ON zen_pins_changes(uuid)
`);
- this._resolveInitialized();
- });
+ if (!this.lazy.Weave.Service.engineManager.get('pinnedtabs')) {
+ try {
+ this.lazy.Weave.Service.engineManager.register(ZenPinnedTabsEngine);
+ } catch (e) {
+ console.error('[ZenPinnedTabsStorage] Registration failed:', e);
+ }
+ }
+
+ this._resolveInitialized();
+ }
+ );
},
/**
@@ -94,30 +111,32 @@ var ZenPinnedTabsStorage = {
const changedUUIDs = new Set();
- await PlacesUtils.withConnectionWrapper('ZenPinnedTabsStorage.savePin', async (db) => {
- await db.executeTransaction(async () => {
- const now = Date.now();
+ await this.lazy.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(
- `
+ 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;
- }
+ { 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 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,
@@ -129,38 +148,39 @@ var ZenPinnedTabsStorage = {
: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,
- }
- );
+ {
+ 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(
- `
+ await db.execute(
+ `
INSERT OR REPLACE INTO zen_pins_changes (uuid, timestamp)
VALUES (:uuid, :timestamp)
`,
- {
- uuid: pin.uuid,
- timestamp: Math.floor(now / 1000),
- }
- );
+ {
+ uuid: pin.uuid,
+ timestamp: Math.floor(now / 1000),
+ }
+ );
- changedUUIDs.add(pin.uuid);
- await this.updateLastChangeTimestamp(db);
- });
- });
+ changedUUIDs.add(pin.uuid);
+ await this.updateLastChangeTimestamp(db);
+ });
+ }
+ );
if (notifyObservers) {
this._notifyPinsChanged('zen-pin-updated', Array.from(changedUUIDs));
@@ -168,7 +188,7 @@ var ZenPinnedTabsStorage = {
},
async getPins() {
- const db = await PlacesUtils.promiseDBConnection();
+ const db = await this.lazy.PlacesUtils.promiseDBConnection();
const rows = await db.executeCached(`
SELECT * FROM zen_pins
ORDER BY position ASC
@@ -244,77 +264,80 @@ var ZenPinnedTabsStorage = {
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 }
- );
+ await this.lazy.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.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`);
- }
+ 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,
- });
+ 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`);
- }
+ 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;
- }
+ const now = Date.now();
+ let newPosition;
- await db.execute(
- `
+ 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,
- }
- );
+ {
+ tabUuid,
+ groupUuid,
+ newPosition,
+ now,
+ }
+ );
- changedUUIDs.add(tabUuid);
+ changedUUIDs.add(tabUuid);
- await db.execute(
- `
+ await db.execute(
+ `
INSERT OR REPLACE INTO zen_pins_changes (uuid, timestamp)
VALUES (:uuid, :timestamp)
`,
- {
- uuid: tabUuid,
- timestamp: Math.floor(now / 1000),
- }
- );
+ {
+ uuid: tabUuid,
+ timestamp: Math.floor(now / 1000),
+ }
+ );
- await this.updateLastChangeTimestamp(db);
- });
- });
+ await this.updateLastChangeTimestamp(db);
+ });
+ }
+ );
if (notifyObservers) {
this._notifyPinsChanged('zen-pin-updated', Array.from(changedUUIDs));
@@ -334,7 +357,7 @@ var ZenPinnedTabsStorage = {
const changedUUIDs = new Set();
- await PlacesUtils.withConnectionWrapper(
+ await this.lazy.PlacesUtils.withConnectionWrapper(
'ZenPinnedTabsStorage.removeTabFromGroup',
async (db) => {
await db.executeTransaction(async () => {
@@ -412,44 +435,68 @@ var ZenPinnedTabsStorage = {
this._saveCache.splice(cachedIndex, 1);
}
- const changedUUIDs = [uuid];
+ const changedUUIDs = [];
- 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,
- }
- );
+ await this.lazy.PlacesUtils.withConnectionWrapper(
+ 'ZenPinnedTabsStorage.removePin',
+ async (db) => {
+ await db.executeTransaction(async () => {
+ // Recursively collect all descendants (children, grandchildren, etc.)
+ const collectDescendants = async (parentUuid) => {
+ const children = await db.execute(
+ `SELECT uuid FROM zen_pins WHERE folder_parent_uuid = :parentUuid`,
+ { parentUuid }
+ );
- // Add child UUIDs to changedUUIDs array
- for (const child of children) {
- changedUUIDs.push(child.getResultByName('uuid'));
- }
+ const uuids = [];
+ for (const child of children) {
+ const childUuid = child.getResultByName('uuid');
+ uuids.push(childUuid);
+ // Recursively get descendants of this child
+ const descendants = await collectDescendants(childUuid);
+ uuids.push(...descendants);
+ }
+ return uuids;
+ };
+
+ // Get all descendants recursively
+ const descendants = await collectDescendants(uuid);
+
+ // Add the parent UUID and all descendants to changedUUIDs
+ changedUUIDs.push(uuid, ...descendants);
+
+ // Delete all descendants first (to avoid constraint issues)
+ for (const descendantUuid of descendants) {
+ await db.execute(`DELETE FROM zen_pins WHERE uuid = :uuid`, { uuid: descendantUuid });
+ // Remove from cache
+ const idx = this._saveCache.findIndex((pin) => pin.uuid === descendantUuid);
+ if (idx !== -1) {
+ this._saveCache.splice(idx, 1);
+ }
+ }
- // Delete the pin/group itself
- await db.execute(`DELETE FROM zen_pins WHERE uuid = :uuid`, { uuid });
+ // Delete the pin/group itself
+ await db.execute(`DELETE FROM zen_pins WHERE uuid = :uuid`, { uuid });
- // Record the changes
- const now = Math.floor(Date.now() / 1000);
- for (const changedUuid of changedUUIDs) {
- await db.execute(
- `
+ // Record all deletions for sync
+ 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,
- }
- );
- }
+ {
+ uuid: changedUuid,
+ timestamp: now,
+ }
+ );
+ }
- await this.updateLastChangeTimestamp(db);
- });
- });
+ await this.updateLastChangeTimestamp(db);
+ });
+ }
+ );
if (notifyObservers) {
this._notifyPinsChanged('zen-pin-removed', changedUUIDs);
@@ -457,31 +504,37 @@ var ZenPinnedTabsStorage = {
},
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);
- });
+ await this.lazy.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(
- `
+ await this.lazy.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),
- }
- );
- });
+ {
+ uuid,
+ timestamp: Math.floor(now / 1000),
+ }
+ );
+ }
+ );
},
async getChangedIDs() {
- const db = await PlacesUtils.promiseDBConnection();
+ const db = await this.lazy.PlacesUtils.promiseDBConnection();
const rows = await db.execute(`
SELECT uuid, timestamp FROM zen_pins_changes
`);
@@ -493,9 +546,12 @@ var ZenPinnedTabsStorage = {
},
async clearChangedIDs() {
- await PlacesUtils.withConnectionWrapper('ZenPinnedTabsStorage.clearChangedIDs', async (db) => {
- await db.execute(`DELETE FROM zen_pins_changes`);
- });
+ await this.lazy.PlacesUtils.withConnectionWrapper(
+ 'ZenPinnedTabsStorage.clearChangedIDs',
+ async (db) => {
+ await db.execute(`DELETE FROM zen_pins_changes`);
+ }
+ );
},
shouldReorderPins(before, current, after) {
@@ -538,7 +594,7 @@ var ZenPinnedTabsStorage = {
},
async getLastChangeTimestamp() {
- const db = await PlacesUtils.promiseDBConnection();
+ const db = await this.lazy.PlacesUtils.promiseDBConnection();
const result = await db.executeCached(`
SELECT value FROM moz_meta WHERE key = 'zen_pins_last_change'
`);
@@ -548,7 +604,7 @@ var ZenPinnedTabsStorage = {
async updatePinPositions(pins) {
const changedUUIDs = new Set();
- await PlacesUtils.withConnectionWrapper(
+ await this.lazy.PlacesUtils.withConnectionWrapper(
'ZenPinnedTabsStorage.updatePinPositions',
async (db) => {
await db.executeTransaction(async () => {
@@ -597,47 +653,54 @@ var ZenPinnedTabsStorage = {
const changedUUIDs = new Set();
- await PlacesUtils.withConnectionWrapper('ZenPinnedTabsStorage.updatePinTitle', async (db) => {
- await db.executeTransaction(async () => {
- const now = Date.now();
+ await this.lazy.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
- `,
- {
+ // First check if the pin exists
+ const existsResult = await db.execute(`SELECT uuid FROM zen_pins 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);
+ if (existsResult.length > 0) {
+ // Update the pin's title and edited_title flag
+ await db.execute(
+ `
+ UPDATE zen_pins
+ SET title = :newTitle,
+ edited_title = :isEdited,
+ updated_at = :now
+ WHERE uuid = :uuid
+ `,
+ {
+ uuid,
+ newTitle,
+ isEdited,
+ now,
+ }
+ );
- // Record the change
- await db.execute(
- `
+ 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),
- }
- );
+ {
+ uuid,
+ timestamp: Math.floor(now / 1000),
+ }
+ );
- await this.updateLastChangeTimestamp(db);
- }
- });
- });
+ await this.updateLastChangeTimestamp(db);
+ }
+ });
+ }
+ );
if (notifyObservers && changedUUIDs.size > 0) {
this._notifyPinsChanged('zen-pin-updated', Array.from(changedUUIDs));
@@ -645,14 +708,18 @@ var ZenPinnedTabsStorage = {
},
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`);
- });
+ await this.lazy.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();
});
+
+ZenPinnedTabsStorage.init();
diff --git a/src/zen/tabs/ZenPinnedTabsSync.mjs b/src/zen/tabs/ZenPinnedTabsSync.mjs
new file mode 100644
index 0000000000..27f752c86c
--- /dev/null
+++ b/src/zen/tabs/ZenPinnedTabsSync.mjs
@@ -0,0 +1,448 @@
+// 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 { Tracker, Store, SyncEngine } = ChromeUtils.importESModule(
+ 'resource://services-sync/engines.sys.mjs'
+);
+var { CryptoWrapper } = ChromeUtils.importESModule('resource://services-sync/record.sys.mjs');
+var { Utils } = ChromeUtils.importESModule('resource://services-sync/util.sys.mjs');
+var { SCORE_INCREMENT_XLARGE } = ChromeUtils.importESModule(
+ 'resource://services-sync/constants.sys.mjs'
+);
+
+// Define ZenPinnedTabRecord
+function ZenPinnedTabRecord(collection, id) {
+ CryptoWrapper.call(this, collection, id);
+}
+
+ZenPinnedTabRecord.prototype = Object.create(CryptoWrapper.prototype);
+ZenPinnedTabRecord.prototype.constructor = ZenPinnedTabRecord;
+
+ZenPinnedTabRecord.prototype._logName = 'Sync.Record.ZenPinnedTab';
+
+Utils.deferGetSet(ZenPinnedTabRecord, 'cleartext', [
+ 'title',
+ 'url',
+ 'containerTabId',
+ 'workspaceUuid',
+ 'position',
+ 'isEssential',
+ 'isGroup',
+ 'parentUuid',
+ 'editedTitle',
+ 'folderIcon',
+ 'isFolderCollapsed',
+]);
+
+// Define ZenPinnedTabsStore
+function ZenPinnedTabsStore(name, engine) {
+ Store.call(this, name, engine);
+}
+
+ZenPinnedTabsStore.prototype = Object.create(Store.prototype);
+ZenPinnedTabsStore.prototype.constructor = ZenPinnedTabsStore;
+
+/**
+ * Initializes the store by loading the current changeset.
+ */
+ZenPinnedTabsStore.prototype.initialize = async function () {
+ await Store.prototype.initialize.call(this);
+ // Additional initialization if needed
+};
+
+/**
+ * Retrieves all pinned tab IDs from the storage.
+ * @returns {Object} An object mapping pinned tab UUIDs to true.
+ */
+ZenPinnedTabsStore.prototype.getAllIDs = async function () {
+ try {
+ const pins = await ZenPinnedTabsStorage.getPins();
+ const ids = {};
+ for (const pin of pins) {
+ ids[pin.uuid] = true;
+ }
+ return ids;
+ } catch (error) {
+ this._log.error('Error fetching all pinned tab IDs', error);
+ throw error;
+ }
+};
+
+/**
+ * Handles changing the ID of a pinned tab.
+ * @param {String} oldID - The old UUID.
+ * @param {String} newID - The new UUID.
+ */
+ZenPinnedTabsStore.prototype.changeItemID = async function (oldID, newID) {
+ try {
+ const pins = await ZenPinnedTabsStorage.getPins();
+ const pin = pins.find((p) => p.uuid === oldID);
+ if (pin) {
+ pin.uuid = newID;
+ await ZenPinnedTabsStorage.savePin(pin, false);
+ // Mark the new ID as changed for sync
+ await ZenPinnedTabsStorage.markChanged(newID);
+ }
+ } catch (error) {
+ this._log.error(`Error changing pinned tab ID from ${oldID} to ${newID}`, error);
+ throw error;
+ }
+};
+
+/**
+ * Checks if a pinned tab exists.
+ * @param {String} id - The UUID of the pinned tab.
+ * @returns {Boolean} True if the pinned tab exists, false otherwise.
+ */
+ZenPinnedTabsStore.prototype.itemExists = async function (id) {
+ try {
+ const pins = await ZenPinnedTabsStorage.getPins();
+ return pins.some((p) => p.uuid === id);
+ } catch (error) {
+ this._log.error(`Error checking if pinned tab exists with ID ${id}`, error);
+ throw error;
+ }
+};
+
+/**
+ * Creates a record for a pinned tab.
+ * @param {String} id - The UUID of the pinned tab.
+ * @param {String} collection - The collection name.
+ * @returns {ZenPinnedTabRecord} The pinned tab record.
+ */
+ZenPinnedTabsStore.prototype.createRecord = async function (id, collection) {
+ try {
+ const pins = await ZenPinnedTabsStorage.getPins();
+ const pin = pins.find((p) => p.uuid === id);
+ const record = new ZenPinnedTabRecord(collection, id);
+
+ if (pin) {
+ record.title = pin.title;
+ record.url = pin.url || '';
+ record.containerTabId = pin.containerTabId || 0;
+ record.workspaceUuid = pin.workspaceUuid || null;
+ record.position = pin.position;
+ record.isEssential = pin.isEssential || false;
+ record.isGroup = pin.isGroup || false;
+ record.parentUuid = pin.parentUuid || null;
+ record.editedTitle = pin.editedTitle || false;
+ record.folderIcon = pin.folderIcon || null;
+ record.isFolderCollapsed = pin.isFolderCollapsed || false;
+ record.deleted = false;
+ } else {
+ record.deleted = true;
+ }
+
+ return record;
+ } catch (error) {
+ this._log.error(`Error creating record for pinned tab ID ${id}`, error);
+ throw error;
+ }
+};
+
+/**
+ * Creates a new pinned tab.
+ * @param {ZenPinnedTabRecord} record - The pinned tab record to create.
+ */
+ZenPinnedTabsStore.prototype.create = async function (record) {
+ try {
+ this._validateRecord(record);
+ const pin = {
+ uuid: record.id,
+ title: record.title,
+ url: record.url,
+ containerTabId: record.containerTabId,
+ workspaceUuid: record.workspaceUuid,
+ position: record.position,
+ isEssential: record.isEssential,
+ isGroup: record.isGroup,
+ parentUuid: record.parentUuid,
+ editedTitle: record.editedTitle,
+ folderIcon: record.folderIcon,
+ isFolderCollapsed: record.isFolderCollapsed,
+ };
+ await ZenPinnedTabsStorage.savePin(pin, false);
+ } catch (error) {
+ this._log.error(`Error creating pinned tab with ID ${record.id}`, error);
+ throw error;
+ }
+};
+
+/**
+ * Updates an existing pinned tab.
+ * @param {ZenPinnedTabRecord} record - The pinned tab record to update.
+ */
+ZenPinnedTabsStore.prototype.update = async function (record) {
+ try {
+ this._validateRecord(record);
+ await this.create(record); // Reuse create for update
+ } catch (error) {
+ this._log.error(`Error updating pinned tab with ID ${record.id}`, error);
+ throw error;
+ }
+};
+
+/**
+ * Removes a pinned tab.
+ * @param {ZenPinnedTabRecord} record - The pinned tab record to remove.
+ */
+ZenPinnedTabsStore.prototype.remove = async function (record) {
+ try {
+ await ZenPinnedTabsStorage.removePin(record.id, false);
+ } catch (error) {
+ this._log.error(`Error removing pinned tab with ID ${record.id}`, error);
+ throw error;
+ }
+};
+
+/**
+ * Wipes all pinned tabs from the storage.
+ */
+ZenPinnedTabsStore.prototype.wipe = async function () {
+ try {
+ await ZenPinnedTabsStorage.wipeAllPins();
+ } catch (error) {
+ this._log.error('Error wiping all pinned tabs', error);
+ throw error;
+ }
+};
+
+/**
+ * Validates a pinned tab record.
+ * @param {ZenPinnedTabRecord} record - The pinned tab record to validate.
+ */
+ZenPinnedTabsStore.prototype._validateRecord = function (record) {
+ if (!record.id || typeof record.id !== 'string') {
+ throw new Error('Invalid pinned tab ID');
+ }
+ if (!record.title || typeof record.title !== 'string') {
+ throw new Error(`Invalid pinned tab title for ID ${record.id}`);
+ }
+ if (record.url != null && typeof record.url !== 'string') {
+ throw new Error(`Invalid URL for pinned tab ID ${record.id}`);
+ }
+ if (record.containerTabId != null && typeof record.containerTabId !== 'number') {
+ throw new Error(`Invalid containerTabId for pinned tab ID ${record.id}`);
+ }
+ if (record.workspaceUuid != null && typeof record.workspaceUuid !== 'string') {
+ throw new Error(`Invalid workspaceUuid for pinned tab ID ${record.id}`);
+ }
+ if (record.position != null && typeof record.position !== 'number') {
+ throw new Error(`Invalid position for pinned tab ID ${record.id}`);
+ }
+ if (typeof record.isEssential !== 'boolean') {
+ record.isEssential = false;
+ }
+ if (typeof record.isGroup !== 'boolean') {
+ record.isGroup = false;
+ }
+ if (record.parentUuid != null && typeof record.parentUuid !== 'string') {
+ throw new Error(`Invalid parentUuid for pinned tab ID ${record.id}`);
+ }
+ if (typeof record.editedTitle !== 'boolean') {
+ record.editedTitle = false;
+ }
+ if (record.folderIcon != null && typeof record.folderIcon !== 'string') {
+ throw new Error(`Invalid folderIcon for pinned tab ID ${record.id}`);
+ }
+ if (typeof record.isFolderCollapsed !== 'boolean') {
+ record.isFolderCollapsed = false;
+ }
+};
+
+/**
+ * Retrieves changed pinned tab IDs since the last sync.
+ * @returns {Object} An object mapping pinned tab UUIDs to their change timestamps.
+ */
+ZenPinnedTabsStore.prototype.getChangedIDs = async function () {
+ try {
+ return await ZenPinnedTabsStorage.getChangedIDs();
+ } catch (error) {
+ this._log.error('Error retrieving changed IDs from storage', error);
+ throw error;
+ }
+};
+
+/**
+ * Clears all recorded changes after a successful sync.
+ */
+ZenPinnedTabsStore.prototype.clearChangedIDs = async function () {
+ try {
+ await ZenPinnedTabsStorage.clearChangedIDs();
+ } catch (error) {
+ this._log.error('Error clearing changed IDs in storage', error);
+ throw error;
+ }
+};
+
+/**
+ * Marks a pinned tab as changed.
+ * @param {String} uuid - The UUID of the pinned tab that changed.
+ */
+ZenPinnedTabsStore.prototype.markChanged = async function (uuid) {
+ try {
+ await ZenPinnedTabsStorage.markChanged(uuid);
+ } catch (error) {
+ this._log.error(`Error marking pinned tab ${uuid} as changed`, error);
+ throw error;
+ }
+};
+
+/**
+ * Finalizes the store by ensuring all pending operations are completed.
+ */
+ZenPinnedTabsStore.prototype.finalize = async function () {
+ await Store.prototype.finalize.call(this);
+};
+
+// Define ZenPinnedTabsTracker
+function ZenPinnedTabsTracker(name, engine) {
+ Tracker.call(this, name, engine);
+ this._ignoreAll = false;
+
+ // Observe profile-before-change to stop the tracker gracefully
+ Services.obs.addObserver(this.asyncObserver, 'profile-before-change');
+}
+
+ZenPinnedTabsTracker.prototype = Object.create(Tracker.prototype);
+ZenPinnedTabsTracker.prototype.constructor = ZenPinnedTabsTracker;
+
+/**
+ * Retrieves changed pinned tab IDs by delegating to the store.
+ * @returns {Object} An object mapping pinned tab UUIDs to their change timestamps.
+ */
+ZenPinnedTabsTracker.prototype.getChangedIDs = async function () {
+ try {
+ return await this.engine._store.getChangedIDs();
+ } catch (error) {
+ this._log.error('Error retrieving changed IDs from store', error);
+ throw error;
+ }
+};
+
+/**
+ * Clears all recorded changes after a successful sync.
+ */
+ZenPinnedTabsTracker.prototype.clearChangedIDs = async function () {
+ try {
+ await this.engine._store.clearChangedIDs();
+ } catch (error) {
+ this._log.error('Error clearing changed IDs in store', error);
+ throw error;
+ }
+};
+
+/**
+ * Called when the tracker starts. Registers observers to listen for pinned tab changes.
+ */
+ZenPinnedTabsTracker.prototype.onStart = function () {
+ if (this._started) {
+ return;
+ }
+ this._log.trace('Starting tracker');
+ // Register observers for pinned tab changes
+ Services.obs.addObserver(this.asyncObserver, 'zen-pin-added');
+ Services.obs.addObserver(this.asyncObserver, 'zen-pin-removed');
+ Services.obs.addObserver(this.asyncObserver, 'zen-pin-updated');
+ this._started = true;
+};
+
+/**
+ * Called when the tracker stops. Unregisters observers.
+ */
+ZenPinnedTabsTracker.prototype.onStop = function () {
+ if (!this._started) {
+ return;
+ }
+ this._log.trace('Stopping tracker');
+ // Unregister observers for pinned tab changes
+ Services.obs.removeObserver(this.asyncObserver, 'zen-pin-added');
+ Services.obs.removeObserver(this.asyncObserver, 'zen-pin-removed');
+ Services.obs.removeObserver(this.asyncObserver, 'zen-pin-updated');
+ this._started = false;
+};
+
+/**
+ * Handles observed events and marks pinned tabs as changed accordingly.
+ * @param {nsISupports} subject - The subject of the notification.
+ * @param {String} topic - The topic of the notification.
+ * @param {String} data - Additional data (JSON stringified array of UUIDs).
+ */
+ZenPinnedTabsTracker.prototype.observe = async function (subject, topic, data) {
+ if (this.ignoreAll) {
+ return;
+ }
+
+ try {
+ switch (topic) {
+ case 'profile-before-change':
+ await this.stop();
+ break;
+ case 'zen-pin-removed':
+ case 'zen-pin-updated':
+ case 'zen-pin-added': {
+ let pinIDs;
+ if (data) {
+ try {
+ pinIDs = JSON.parse(data);
+ if (!Array.isArray(pinIDs)) {
+ throw new Error('Parsed data is not an array');
+ }
+ } catch (parseError) {
+ this._log.error(`Failed to parse pinned tab UUIDs from data: ${data}`, parseError);
+ return;
+ }
+ } else {
+ this._log.error(`No data received for event ${topic}`);
+ return;
+ }
+
+ this._log.trace(`Observed ${topic} for UUIDs: ${pinIDs.join(', ')}`);
+
+ // Process each UUID
+ for (const pinID of pinIDs) {
+ if (typeof pinID === 'string') {
+ // Inform the store about the change
+ await this.engine._store.markChanged(pinID);
+ } else {
+ this._log.warn(`Invalid pinned tab ID encountered: ${pinID}`);
+ }
+ }
+
+ // Bump the score once after processing all changes
+ if (pinIDs.length > 0) {
+ this.score += SCORE_INCREMENT_XLARGE;
+ }
+ break;
+ }
+ }
+ } catch (error) {
+ this._log.error(`Error handling ${topic} in observe method`, error);
+ }
+};
+
+/**
+ * Finalizes the tracker by ensuring all pending operations are completed.
+ */
+ZenPinnedTabsTracker.prototype.finalize = async function () {
+ await Tracker.prototype.finalize.call(this);
+};
+
+// Define ZenPinnedTabsEngine
+function ZenPinnedTabsEngine(service) {
+ SyncEngine.call(this, 'PinnedTabs', service);
+}
+
+ZenPinnedTabsEngine.prototype = Object.create(SyncEngine.prototype);
+ZenPinnedTabsEngine.prototype.constructor = ZenPinnedTabsEngine;
+
+ZenPinnedTabsEngine.prototype._storeObj = ZenPinnedTabsStore;
+ZenPinnedTabsEngine.prototype._trackerObj = ZenPinnedTabsTracker;
+ZenPinnedTabsEngine.prototype._recordObj = ZenPinnedTabRecord;
+ZenPinnedTabsEngine.prototype.version = 1;
+
+ZenPinnedTabsEngine.prototype.syncPriority = 11; // Sync after workspaces (priority 10)
+ZenPinnedTabsEngine.prototype.allowSkippedRecord = false;
+
+Object.setPrototypeOf(ZenPinnedTabsEngine.prototype, SyncEngine.prototype);
diff --git a/src/zen/zen.globals.js b/src/zen/zen.globals.js
index 93f2484d1c..c6f5c32ac8 100644
--- a/src/zen/zen.globals.js
+++ b/src/zen/zen.globals.js
@@ -29,6 +29,7 @@ export default [
'gZenPinnedTabManager',
'ZenPinnedTabsStorage',
+ 'ZenPinnedTabsEngine',
'gZenEmojiPicker',
'gZenSessionStore',