From 187dece11c14f09eca43e2441ea26152f7c72a84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristijan=20Ribari=C4=87?= Date: Fri, 4 Oct 2024 11:11:35 +0200 Subject: [PATCH 01/10] feat: Sync Workspaces to Services-Sync This commit adds a new feature to synchronize workspaces to the Services-Sync framework. - **ZenWorkspacesSync.mjs:** A new file that defines a custom engine for syncing workspaces. - **ZenWorkspaces.mjs:** Modified to register the ZenWorkspacesEngine with the Service engine manager and to update its state using services-sync's notification system. This feature enables users to sync their workspaces across devices and seamlessly switch between them using the services-sync mechanism. --- src/ZenWorkspaces.mjs | 5 + src/ZenWorkspacesSync.mjs | 221 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 src/ZenWorkspacesSync.mjs diff --git a/src/ZenWorkspaces.mjs b/src/ZenWorkspaces.mjs index a92fcc6..382643e 100644 --- a/src/ZenWorkspaces.mjs +++ b/src/ZenWorkspaces.mjs @@ -19,6 +19,7 @@ var ZenWorkspaces = new (class extends ZenMultiWindowFeature { ); ChromeUtils.defineLazyGetter(this, 'tabContainer', () => document.getElementById('tabbrowser-tabs')); await ZenWorkspacesStorage.init(); + Weave.Service.engineManager.register(ZenWorkspacesEngine); await this.initializeWorkspaces(); console.info('ZenWorkspaces: ZenWorkspaces initialized'); } @@ -190,6 +191,7 @@ var ZenWorkspaces = new (class extends ZenMultiWindowFeature { this._workspaceCache = null; await this._propagateWorkspaceData(); await this._updateWorkspacesChangeContextMenu(); + Services.obs.notifyObservers(null, "zen-workspace-removed", windowID); } isWorkspaceActive(workspace) { @@ -539,6 +541,7 @@ var ZenWorkspaces = new (class extends ZenMultiWindowFeature { workspaceData.name = workspaceName; workspaceData.icon = icon?.label; await this.saveWorkspace(workspaceData); + Services.obs.notifyObservers(null, "zen-workspace-updated", workspaceData.uuid); await this._propagateWorkspaceData(); this.closeWorkspacesSubView(); } @@ -664,6 +667,7 @@ var ZenWorkspaces = new (class extends ZenMultiWindowFeature { } let workspaceData = this._createWorkspaceData(name, isDefault, icon); await this.saveWorkspace(workspaceData); + Services.obs.notifyObservers(null, "zen-workspace-added", workspaceData.uuid); await this.changeWorkspace(workspaceData); } @@ -740,6 +744,7 @@ var ZenWorkspaces = new (class extends ZenMultiWindowFeature { let userContextId = parseInt(event.target.getAttribute('data-usercontextid')); workspace.containerTabId = userContextId; await this.saveWorkspace(workspace); + Services.obs.notifyObservers(null, "zen-workspace-updated", workspaceData.uuid); await this._propagateWorkspaceData(); } diff --git a/src/ZenWorkspacesSync.mjs b/src/ZenWorkspacesSync.mjs new file mode 100644 index 0000000..2b02a1b --- /dev/null +++ b/src/ZenWorkspacesSync.mjs @@ -0,0 +1,221 @@ +var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); +var { LegacyTracker } = ChromeUtils.importESModule("resource://services-sync/engines.sys.mjs"); +var { Store } = ChromeUtils.importESModule("resource://services-sync/engines.sys.mjs"); +var { SyncEngine } = ChromeUtils.importESModule("resource://services-sync/engines.sys.mjs"); +var { CryptoWrapper } = ChromeUtils.importESModule("resource://services-sync/record.sys.mjs"); +var { Svc,Utils } = ChromeUtils.importESModule("resource://services-sync/util.sys.mjs"); +var { SCORE_INCREMENT_XLARGE } = ChromeUtils.importESModule("resource://services-sync/constants.sys.mjs"); + +function ZenWorkspacesTracker(name, engine) { + LegacyTracker.call(this, name, engine); +} + +ZenWorkspacesTracker.prototype = { + __proto__: LegacyTracker.prototype, + + start() { + if (this._started) { + return; + } + this._log.trace("Starting tracker"); + Services.obs.addObserver(this, "zen-workspace-added"); + Services.obs.addObserver(this, "zen-workspace-removed"); + Services.obs.addObserver(this, "zen-workspace-updated"); + this._started = true; + }, + + stop() { + if (!this._started) { + return; + } + this._log.trace("Stopping tracker"); + Services.obs.removeObserver(this, "zen-workspace-added"); + Services.obs.removeObserver(this, "zen-workspace-removed"); + Services.obs.removeObserver(this, "zen-workspace-updated"); + this._started = false; + }, + + observe(subject, topic, data) { + switch (topic) { + case "zen-workspace-removed": + case "zen-workspace-updated": + case "zen-workspace-added": + let workspaceID = data; + this._log.trace(`Observed ${topic} for ${workspaceID}`); + this.addChangedID(workspaceID); + this.score += SCORE_INCREMENT_XLARGE; + break; + } + }, +}; + +function ZenWorkspacesStore(name, engine) { + Store.call(this, name, engine); +} + +ZenWorkspacesStore.prototype = { + __proto__: Store.prototype, + + async getAllIDs() { + try { + let workspaces = await ZenWorkspacesStorage.getWorkspaces(); + let ids = {}; + for (let workspace of workspaces) { + ids[workspace.uuid] = true; + } + return ids; + } catch (error) { + this._log.error("Error fetching all workspace IDs", error); + throw error; + } + }, + + async changeItemID(oldID, newID) { + try { + let workspaces = await ZenWorkspacesStorage.getWorkspaces(); + let workspace = workspaces.find(ws => ws.uuid === oldID); + if (workspace) { + workspace.uuid = newID; + await ZenWorkspacesStorage.saveWorkspace(workspace); + } + } catch (error) { + this._log.error(`Error changing workspace ID from ${oldID} to ${newID}`, error); + throw error; + } + }, + + async itemExists(id) { + try { + let workspaces = await ZenWorkspacesStorage.getWorkspaces(); + return workspaces.some(ws => ws.uuid === id); + } catch (error) { + this._log.error(`Error checking if workspace exists with ID ${id}`, error); + throw error; + } + }, + + async createRecord(id, collection) { + try { + let workspaces = await ZenWorkspacesStorage.getWorkspaces(); + let workspace = workspaces.find(ws => ws.uuid === id); + let record = new ZenWorkspaceRecord(collection, id); + + if (workspace) { + record.name = workspace.name; + record.icon = workspace.icon; + record.default = workspace.default; + record.containerTabId = workspace.containerTabId; + record.deleted = false; + } else { + record.deleted = true; + } + + return record; + } catch (error) { + this._log.error(`Error creating record for workspace ID ${id}`, error); + throw error; + } + }, + + async create(record) { + try { + // Data validation + this._validateRecord(record); + + let workspace = { + uuid: record.id, + name: record.name, + icon: record.icon, + default: record.default, + containerTabId: record.containerTabId, + }; + await ZenWorkspacesStorage.saveWorkspace(workspace); + } catch (error) { + this._log.error(`Error creating workspace with ID ${record.id}`, error); + throw error; + } + }, + + async update(record) { + try { + // Data validation + this._validateRecord(record); + + await this.create(record); + } catch (error) { + this._log.error(`Error updating workspace with ID ${record.id}`, error); + throw error; + } + }, + + async remove(record) { + try { + await ZenWorkspacesStorage.removeWorkspace(record.id); + } catch (error) { + this._log.error(`Error removing workspace with ID ${record.id}`, error); + throw error; + } + }, + + async wipe() { + try { + let workspaces = await ZenWorkspacesStorage.getWorkspaces(); + for (let workspace of workspaces) { + await ZenWorkspacesStorage.removeWorkspace(workspace.uuid); + } + } catch (error) { + this._log.error("Error wiping all workspaces", error); + throw error; + } + }, + + _validateRecord(record) { + if (!record.id || typeof record.id !== "string") { + throw new Error("Invalid workspace ID"); + } + if (!record.name || typeof record.name !== "string") { + throw new Error(`Invalid workspace name for ID ${record.id}`); + } + // 'default' is a boolean; if undefined, default to false + if (typeof record.default !== "boolean") { + record.default = false; + } + // 'icon' and 'containerTabId' can be null, but should be validated if present + if (record.icon != null && typeof record.icon !== "string") { + throw new Error(`Invalid icon for workspace ID ${record.id}`); + } + if (record.containerTabId != null && typeof record.containerTabId !== "number") { + throw new Error(`Invalid containerTabId for workspace ID ${record.id}`); + } + }, +}; + +function ZenWorkspacesEngine(service) { + SyncEngine.call(this, "Workspaces", service); +} + +ZenWorkspacesEngine.prototype = { + __proto__: SyncEngine.prototype, + + _storeObj: ZenWorkspacesStore, + _trackerObj: ZenWorkspacesTracker, + _recordObj: ZenWorkspaceRecord, + +}; + +function ZenWorkspaceRecord(collection, id) { + CryptoWrapper.call(this, collection, id); +} + +ZenWorkspaceRecord.prototype = { + __proto__: CryptoWrapper.prototype, + _logName: "Sync.Record.ZenWorkspace", + +}; + +Utils.deferGetSet(ZenWorkspaceRecord, "cleartext", [ + "name", + "icon", + "default", + "containerTabId", +]); From 4a54adea9a2c23d1c1cdb91ec7b1895ecbc57766 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristijan=20Ribari=C4=87?= Date: Fri, 4 Oct 2024 14:04:24 +0200 Subject: [PATCH 02/10] feat(workspaces): Refresh workspace strip after sync completion This commit adds an observer for the `weave:engine:sync:finish` event and clears the workspace cache when the workspace sync is complete. This ensures that the workspace strip is updated with the latest data after a sync. --- src/ZenWorkspaces.mjs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/ZenWorkspaces.mjs b/src/ZenWorkspaces.mjs index 382643e..fbb0c1b 100644 --- a/src/ZenWorkspaces.mjs +++ b/src/ZenWorkspaces.mjs @@ -22,6 +22,20 @@ var ZenWorkspaces = new (class extends ZenMultiWindowFeature { Weave.Service.engineManager.register(ZenWorkspacesEngine); await this.initializeWorkspaces(); console.info('ZenWorkspaces: ZenWorkspaces initialized'); + + // Add observer for sync completion + Services.obs.addObserver(this, "weave:engine:sync:finish"); + } + + observe(subject, topic, data) { + if (topic === "weave:engine:sync:finish" && data === "workspaces") { + this._workspaceCache = null; // Clear cache to fetch fresh data + this.updateWorkspaceStrip(); + } + } + + updateWorkspaceStrip() { + this._propagateWorkspaceData().catch(console.error); } get shouldHaveWorkspaces() { From f1f547fe69f0e7429c88a9c961021f96929b5559 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristijan=20Ribari=C4=87?= Date: Fri, 4 Oct 2024 17:02:09 +0200 Subject: [PATCH 03/10] fix(zen-workspaces): Correctly emit workspace update notification The previous implementation of the workspace update notification was using `workspaceData.uuid`, which is incorrect. This commit fixes the issue by using `workspace.uuid` instead, ensuring the correct workspace identifier is sent to observers. --- src/ZenWorkspaces.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ZenWorkspaces.mjs b/src/ZenWorkspaces.mjs index fbb0c1b..de7a874 100644 --- a/src/ZenWorkspaces.mjs +++ b/src/ZenWorkspaces.mjs @@ -758,7 +758,7 @@ var ZenWorkspaces = new (class extends ZenMultiWindowFeature { let userContextId = parseInt(event.target.getAttribute('data-usercontextid')); workspace.containerTabId = userContextId; await this.saveWorkspace(workspace); - Services.obs.notifyObservers(null, "zen-workspace-updated", workspaceData.uuid); + Services.obs.notifyObservers(null, "zen-workspace-updated", workspace.uuid); await this._propagateWorkspaceData(); } From e366e624d1483e0765ca08e71199566ff7b55ec6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristijan=20Ribari=C4=87?= Date: Fri, 4 Oct 2024 17:41:48 +0200 Subject: [PATCH 04/10] Fix: Ensure only one workspace is marked as default This commit ensures that only one workspace can be marked as default. Previously, multiple workspaces could be marked as default, leading to unexpected behavior. The commit introduces a transaction to update all workspaces, setting `is_default` to `0` for all workspaces except the one being saved as default. This prevents multiple workspaces from being marked as default. --- src/ZenWorkspacesStorage.mjs | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/ZenWorkspacesStorage.mjs b/src/ZenWorkspacesStorage.mjs index 5ae9363..82b5f7c 100644 --- a/src/ZenWorkspacesStorage.mjs +++ b/src/ZenWorkspacesStorage.mjs @@ -39,8 +39,15 @@ var ZenWorkspacesStorage = { async saveWorkspace(workspace) { await PlacesUtils.withConnectionWrapper('ZenWorkspacesStorage.saveWorkspace', async (db) => { const now = Date.now(); - await db.executeCached( - ` + + await db.executeTransaction(async function() { + // If the workspace is set as default, unset is_default for all other workspaces + if (workspace.default) { + await db.execute(`UPDATE zen_workspaces SET is_default = 0 WHERE uuid != :uuid`, { uuid: workspace.uuid }); + } + + // Then insert or replace the workspace + await db.executeCached(` INSERT OR REPLACE INTO zen_workspaces ( uuid, name, icon, is_default, container_id, created_at, updated_at ) VALUES ( @@ -48,16 +55,15 @@ var ZenWorkspacesStorage = { COALESCE((SELECT created_at FROM zen_workspaces WHERE uuid = :uuid), :now), :now ) - `, - { + `, { uuid: workspace.uuid, name: workspace.name, icon: workspace.icon || null, is_default: workspace.default ? 1 : 0, container_id: workspace.containerTabId || null, - now, - } - ); + now + }); + }); }); }, From 1c7bc5c5016c2683a1ceee904a8f27395881b575 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristijan=20Ribari=C4=87?= Date: Fri, 4 Oct 2024 21:43:36 +0200 Subject: [PATCH 05/10] Fix: Remove unnecessary workspace close after saving This commit removes a call to `this.closeWorkspacesSubView()` after saving a workspace. The previous behavior caused an error to be thrown because main view was already closed. --- src/ZenWorkspaces.mjs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ZenWorkspaces.mjs b/src/ZenWorkspaces.mjs index de7a874..c0d0ad7 100644 --- a/src/ZenWorkspaces.mjs +++ b/src/ZenWorkspaces.mjs @@ -557,7 +557,6 @@ var ZenWorkspaces = new (class extends ZenMultiWindowFeature { await this.saveWorkspace(workspaceData); Services.obs.notifyObservers(null, "zen-workspace-updated", workspaceData.uuid); await this._propagateWorkspaceData(); - this.closeWorkspacesSubView(); } onWorkspaceCreationNameChange(event) { From 0d161326ef101934ca5835a323f3bfa0e0685608 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristijan=20Ribari=C4=87?= Date: Sat, 5 Oct 2024 19:20:19 +0200 Subject: [PATCH 06/10] feat: Add workspace ordering and changes tracking This commit introduces workspace ordering and a new mechanism for tracking changes to workspaces. **Changes:** - **Workspace Ordering:** Workspaces can now be ordered using a `position` field. This allows for user-defined ordering of workspaces, improving usability. - **Changes Tracking:** A new `zen_workspaces_changes` table is added to track changes to workspaces. This allows for more efficient sync operations and improved error handling. **Benefits:** - **Improved Workspace Management:** Users can now customize the order of their workspaces. - **More Efficient Sync:** Changes tracking enables faster and more accurate synchronization of workspaces across devices. - **Enhanced Error Handling:** Changes tracking helps to identify and resolve conflicts during sync. **Notes:** - This change requires deleting the zen_workspaces table in places db --- src/ZenWorkspaces.mjs | 2 + src/ZenWorkspacesStorage.mjs | 173 ++++++++++- src/ZenWorkspacesSync.mjs | 571 ++++++++++++++++++++++------------- 3 files changed, 535 insertions(+), 211 deletions(-) diff --git a/src/ZenWorkspaces.mjs b/src/ZenWorkspaces.mjs index c0d0ad7..891e82a 100644 --- a/src/ZenWorkspaces.mjs +++ b/src/ZenWorkspaces.mjs @@ -84,6 +84,8 @@ var ZenWorkspaces = new (class extends ZenMultiWindowFeature { // Set the active workspace ID to the first one if active workspace doesn't exist Services.prefs.setStringPref('zen.workspaces.active', this._workspaceCache.workspaces[0]?.uuid); } + // sort by position + this._workspaceCache.workspaces.sort((a, b) => (a.position ?? Infinity) - (b.position ?? Infinity)); } return this._workspaceCache; } diff --git a/src/ZenWorkspacesStorage.mjs b/src/ZenWorkspacesStorage.mjs index 82b5f7c..c712e51 100644 --- a/src/ZenWorkspacesStorage.mjs +++ b/src/ZenWorkspacesStorage.mjs @@ -6,6 +6,7 @@ var ZenWorkspacesStorage = { async _ensureTable() { await PlacesUtils.withConnectionWrapper('ZenWorkspacesStorage._ensureTable', async (db) => { + // Create the main workspaces table if it doesn't exist await db.execute(` CREATE TABLE IF NOT EXISTS zen_workspaces ( id INTEGER PRIMARY KEY, @@ -14,11 +15,22 @@ var ZenWorkspacesStorage = { icon TEXT, is_default INTEGER NOT NULL DEFAULT 0, container_id INTEGER, + position INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL + updated_at INTEGER NOT NULL, + used INTEGER NOT NULL DEFAULT 0 + ) + `); + + // Create the changes tracking table if it doesn't exist + await db.execute(` + CREATE TABLE IF NOT EXISTS zen_workspaces_changes ( + uuid TEXT PRIMARY KEY, + timestamp INTEGER NOT NULL ) `); }); + await this._migrateWorkspacesFromJSON(); }, @@ -46,22 +58,60 @@ var ZenWorkspacesStorage = { await db.execute(`UPDATE zen_workspaces SET is_default = 0 WHERE uuid != :uuid`, { uuid: workspace.uuid }); } - // Then insert or replace the workspace + // Get the current maximum position + const maxOrderResult = await db.execute(`SELECT MAX("position") as max_position FROM zen_workspaces`); + const maxOrder = maxOrderResult[0].getResultByName('max_position') || 0; + + let newOrder; + + if ('position' in workspace && workspace.position !== null && Number.isInteger(workspace.position)) { + // If position is provided, check if it's already occupied + const occupiedOrderResult = await db.execute(` + SELECT uuid FROM zen_workspaces WHERE "position" = :position AND uuid != :uuid + `, { position: workspace.position, uuid: workspace.uuid }); + + if (occupiedOrderResult.length > 0) { + // If the position is occupied, shift the positions of subsequent workspaces + await db.execute(` + UPDATE zen_workspaces + SET "position" = "position" + 1 + WHERE "position" >= :position AND uuid != :uuid + `, { position: workspace.position, uuid: workspace.uuid }); + } + + newOrder = workspace.position; + } else { + // If no position is provided, set it to the last position + newOrder = maxOrder + 1; + } + + // Insert or replace the workspace await db.executeCached(` - INSERT OR REPLACE INTO zen_workspaces ( - uuid, name, icon, is_default, container_id, created_at, updated_at + INSERT OR REPLACE INTO zen_workspaces ( + uuid, name, icon, is_default, container_id, created_at, updated_at, "position" ) VALUES ( :uuid, :name, :icon, :is_default, :container_id, COALESCE((SELECT created_at FROM zen_workspaces WHERE uuid = :uuid), :now), - :now + :now, + :position ) - `, { + `, { uuid: workspace.uuid, name: workspace.name, icon: workspace.icon || null, is_default: workspace.default ? 1 : 0, container_id: workspace.containerTabId || null, - now + now, + position: newOrder + }); + + // Record the change in the changes tracking table + await db.execute(` + INSERT OR REPLACE INTO zen_workspaces_changes (uuid, timestamp) + VALUES (:uuid, :timestamp) + `, { + uuid: workspace.uuid, + timestamp: Math.floor(now / 1000) // Unix timestamp in seconds }); }); }); @@ -78,6 +128,7 @@ var ZenWorkspacesStorage = { icon: row.getResultByName('icon'), default: !!row.getResultByName('is_default'), containerTabId: row.getResultByName('container_id'), + position: row.getResultByName('position'), })); }, @@ -89,14 +140,122 @@ var ZenWorkspacesStorage = { `, { uuid } ); + + // Record the removal as a change + const now = Date.now(); + await db.execute(` + INSERT OR REPLACE INTO zen_workspaces_changes (uuid, timestamp) + VALUES (:uuid, :timestamp) + `, { + uuid, + timestamp: Math.floor(now / 1000) + }); + }); + }, + + async wipeAllWorkspaces() { + await PlacesUtils.withConnectionWrapper('ZenWorkspacesStorage.wipeAllWorkspaces', async (db) => { + await db.execute(`DELETE FROM zen_workspaces`); + await db.execute(`DELETE FROM zen_workspaces_changes`); }); }, async setDefaultWorkspace(uuid) { await PlacesUtils.withConnectionWrapper('ZenWorkspacesStorage.setDefaultWorkspace', async (db) => { await db.executeTransaction(async function () { + const now = Date.now(); + // Unset the default flag for all other workspaces await db.execute(`UPDATE zen_workspaces SET is_default = 0`); + // Set the default flag for the specified workspace await db.execute(`UPDATE zen_workspaces SET is_default = 1 WHERE uuid = :uuid`, { uuid }); + // Record the change for the specified workspace + await db.execute(` + INSERT OR REPLACE INTO zen_workspaces_changes (uuid, timestamp) + VALUES (:uuid, :timestamp) + `, { + uuid, + timestamp: Math.floor(now / 1000) + }); + }); + }); + }, + + async markChanged(uuid) { + await PlacesUtils.withConnectionWrapper('ZenWorkspacesStorage.markChanged', async (db) => { + const now = Date.now(); + await db.execute(` + INSERT OR REPLACE INTO zen_workspaces_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_workspaces_changes + `); + const changes = {}; + for (const row of rows) { + changes[row.getResultByName('uuid')] = row.getResultByName('timestamp'); + } + return changes; + }, + + async clearChangedIDs() { + await PlacesUtils.withConnectionWrapper('ZenWorkspacesStorage.clearChangedIDs', async (db) => { + await db.execute(`DELETE FROM zen_workspaces_changes`); + }); + }, + + async updateWorkspaceOrder(uuid, newOrder) { + await PlacesUtils.withConnectionWrapper('ZenWorkspacesStorage.updateWorkspaceOrder', async (db) => { + await db.executeTransaction(async function () { + // Get the current position of the workspace + const currentOrderResult = await db.execute(` + SELECT "position" FROM zen_workspaces WHERE uuid = :uuid + `, { uuid }); + const currentOrder = currentOrderResult[0].getResultByName('position'); + + if (currentOrder === newOrder) { + return; // No change needed + } + + if (newOrder > currentOrder) { + // Moving down: decrement position of workspaces between old and new positions + await db.execute(` + UPDATE zen_workspaces + SET "position" = "position" - 1 + WHERE "position" > :currentOrder AND "position" <= :newOrder + `, { currentOrder, newOrder }); + } else { + // Moving up: increment position of workspaces between new and old positions + await db.execute(` + UPDATE zen_workspaces + SET "position" = "position" + 1 + WHERE "position" >= :newOrder AND "position" < :currentOrder + `, { currentOrder, newOrder }); + } + + // Set the new position for the workspace + await db.execute(` + UPDATE zen_workspaces + SET "position" = :newOrder + WHERE uuid = :uuid + `, { uuid, newOrder }); + + // Mark the workspace as changed + const now = Date.now(); + await db.execute(` + INSERT OR REPLACE INTO zen_workspaces_changes (uuid, timestamp) + VALUES (:uuid, :timestamp) + `, { + uuid, + timestamp: Math.floor(now / 1000) + }); }); }); }, diff --git a/src/ZenWorkspacesSync.mjs b/src/ZenWorkspacesSync.mjs index 2b02a1b..a60413d 100644 --- a/src/ZenWorkspacesSync.mjs +++ b/src/ZenWorkspacesSync.mjs @@ -1,221 +1,384 @@ -var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); -var { LegacyTracker } = ChromeUtils.importESModule("resource://services-sync/engines.sys.mjs"); -var { Store } = ChromeUtils.importESModule("resource://services-sync/engines.sys.mjs"); -var { SyncEngine } = ChromeUtils.importESModule("resource://services-sync/engines.sys.mjs"); +var { Tracker, Store, SyncEngine } = ChromeUtils.importESModule("resource://services-sync/engines.sys.mjs"); var { CryptoWrapper } = ChromeUtils.importESModule("resource://services-sync/record.sys.mjs"); -var { Svc,Utils } = ChromeUtils.importESModule("resource://services-sync/util.sys.mjs"); +var { Utils } = ChromeUtils.importESModule("resource://services-sync/util.sys.mjs"); var { SCORE_INCREMENT_XLARGE } = ChromeUtils.importESModule("resource://services-sync/constants.sys.mjs"); -function ZenWorkspacesTracker(name, engine) { - LegacyTracker.call(this, name, engine); -} -ZenWorkspacesTracker.prototype = { - __proto__: LegacyTracker.prototype, - - start() { - if (this._started) { - return; - } - this._log.trace("Starting tracker"); - Services.obs.addObserver(this, "zen-workspace-added"); - Services.obs.addObserver(this, "zen-workspace-removed"); - Services.obs.addObserver(this, "zen-workspace-updated"); - this._started = true; - }, - - stop() { - if (!this._started) { - return; - } - this._log.trace("Stopping tracker"); - Services.obs.removeObserver(this, "zen-workspace-added"); - Services.obs.removeObserver(this, "zen-workspace-removed"); - Services.obs.removeObserver(this, "zen-workspace-updated"); - this._started = false; - }, - - observe(subject, topic, data) { - switch (topic) { - case "zen-workspace-removed": - case "zen-workspace-updated": - case "zen-workspace-added": - let workspaceID = data; - this._log.trace(`Observed ${topic} for ${workspaceID}`); - this.addChangedID(workspaceID); - this.score += SCORE_INCREMENT_XLARGE; - break; - } - }, -}; - -function ZenWorkspacesStore(name, engine) { - Store.call(this, name, engine); -} - -ZenWorkspacesStore.prototype = { - __proto__: Store.prototype, - - async getAllIDs() { - try { - let workspaces = await ZenWorkspacesStorage.getWorkspaces(); - let ids = {}; - for (let workspace of workspaces) { - ids[workspace.uuid] = true; - } - return ids; - } catch (error) { - this._log.error("Error fetching all workspace IDs", error); - throw error; - } - }, - - async changeItemID(oldID, newID) { - try { - let workspaces = await ZenWorkspacesStorage.getWorkspaces(); - let workspace = workspaces.find(ws => ws.uuid === oldID); - if (workspace) { - workspace.uuid = newID; - await ZenWorkspacesStorage.saveWorkspace(workspace); - } - } catch (error) { - this._log.error(`Error changing workspace ID from ${oldID} to ${newID}`, error); - throw error; - } - }, - - async itemExists(id) { - try { - let workspaces = await ZenWorkspacesStorage.getWorkspaces(); - return workspaces.some(ws => ws.uuid === id); - } catch (error) { - this._log.error(`Error checking if workspace exists with ID ${id}`, error); - throw error; - } - }, - - async createRecord(id, collection) { - try { - let workspaces = await ZenWorkspacesStorage.getWorkspaces(); - let workspace = workspaces.find(ws => ws.uuid === id); - let record = new ZenWorkspaceRecord(collection, id); - - if (workspace) { - record.name = workspace.name; - record.icon = workspace.icon; - record.default = workspace.default; - record.containerTabId = workspace.containerTabId; - record.deleted = false; - } else { - record.deleted = true; - } - - return record; - } catch (error) { - this._log.error(`Error creating record for workspace ID ${id}`, error); - throw error; - } - }, - - async create(record) { - try { - // Data validation - this._validateRecord(record); - - let workspace = { - uuid: record.id, - name: record.name, - icon: record.icon, - default: record.default, - containerTabId: record.containerTabId, - }; - await ZenWorkspacesStorage.saveWorkspace(workspace); - } catch (error) { - this._log.error(`Error creating workspace with ID ${record.id}`, error); - throw error; - } - }, - - async update(record) { - try { - // Data validation - this._validateRecord(record); - - await this.create(record); - } catch (error) { - this._log.error(`Error updating workspace with ID ${record.id}`, error); - throw error; - } - }, - - async remove(record) { - try { - await ZenWorkspacesStorage.removeWorkspace(record.id); - } catch (error) { - this._log.error(`Error removing workspace with ID ${record.id}`, error); - throw error; - } - }, - - async wipe() { - try { - let workspaces = await ZenWorkspacesStorage.getWorkspaces(); - for (let workspace of workspaces) { - await ZenWorkspacesStorage.removeWorkspace(workspace.uuid); - } - } catch (error) { - this._log.error("Error wiping all workspaces", error); - throw error; - } - }, - - _validateRecord(record) { - if (!record.id || typeof record.id !== "string") { - throw new Error("Invalid workspace ID"); - } - if (!record.name || typeof record.name !== "string") { - throw new Error(`Invalid workspace name for ID ${record.id}`); - } - // 'default' is a boolean; if undefined, default to false - if (typeof record.default !== "boolean") { - record.default = false; - } - // 'icon' and 'containerTabId' can be null, but should be validated if present - if (record.icon != null && typeof record.icon !== "string") { - throw new Error(`Invalid icon for workspace ID ${record.id}`); - } - if (record.containerTabId != null && typeof record.containerTabId !== "number") { - throw new Error(`Invalid containerTabId for workspace ID ${record.id}`); - } - }, -}; - -function ZenWorkspacesEngine(service) { - SyncEngine.call(this, "Workspaces", service); -} - -ZenWorkspacesEngine.prototype = { - __proto__: SyncEngine.prototype, - - _storeObj: ZenWorkspacesStore, - _trackerObj: ZenWorkspacesTracker, - _recordObj: ZenWorkspaceRecord, - -}; +// Define ZenWorkspaceRecord function ZenWorkspaceRecord(collection, id) { CryptoWrapper.call(this, collection, id); } -ZenWorkspaceRecord.prototype = { - __proto__: CryptoWrapper.prototype, - _logName: "Sync.Record.ZenWorkspace", +ZenWorkspaceRecord.prototype = Object.create(CryptoWrapper.prototype); +ZenWorkspaceRecord.prototype.constructor = ZenWorkspaceRecord; -}; +ZenWorkspaceRecord.prototype._logName = "Sync.Record.ZenWorkspace"; Utils.deferGetSet(ZenWorkspaceRecord, "cleartext", [ "name", "icon", "default", "containerTabId", + "position" ]); + +// Define ZenWorkspacesStore +function ZenWorkspacesStore(name, engine) { + Store.call(this, name, engine); +} + +ZenWorkspacesStore.prototype = Object.create(Store.prototype); +ZenWorkspacesStore.prototype.constructor = ZenWorkspacesStore; + +/** + * Initializes the store by loading the current changeset. + */ +ZenWorkspacesStore.prototype.initialize = async function () { + await Store.prototype.initialize.call(this); + // Additional initialization if needed +}; + +/** + * Retrieves all workspace IDs from the storage. + * @returns {Object} An object mapping workspace UUIDs to true. + */ +ZenWorkspacesStore.prototype.getAllIDs = async function () { + try { + const workspaces = await ZenWorkspacesStorage.getWorkspaces(); + const ids = {}; + for (const workspace of workspaces) { + ids[workspace.uuid] = true; + } + return ids; + } catch (error) { + this._log.error("Error fetching all workspace IDs", error); + throw error; + } +}; + +/** + * Handles changing the ID of a workspace. + * @param {String} oldID - The old UUID. + * @param {String} newID - The new UUID. + */ +ZenWorkspacesStore.prototype.changeItemID = async function (oldID, newID) { + try { + const workspaces = await ZenWorkspacesStorage.getWorkspaces(); + const workspace = workspaces.find(ws => ws.uuid === oldID); + if (workspace) { + workspace.uuid = newID; + await ZenWorkspacesStorage.saveWorkspace(workspace); + // Mark the new ID as changed for sync + await ZenWorkspacesStorage.markChanged(newID); + } + } catch (error) { + this._log.error(`Error changing workspace ID from ${oldID} to ${newID}`, error); + throw error; + } +}; + +/** + * Checks if a workspace exists. + * @param {String} id - The UUID of the workspace. + * @returns {Boolean} True if the workspace exists, false otherwise. + */ +ZenWorkspacesStore.prototype.itemExists = async function (id) { + try { + const workspaces = await ZenWorkspacesStorage.getWorkspaces(); + return workspaces.some(ws => ws.uuid === id); + } catch (error) { + this._log.error(`Error checking if workspace exists with ID ${id}`, error); + throw error; + } +}; + +/** + * Creates a record for a workspace. + * @param {String} id - The UUID of the workspace. + * @param {String} collection - The collection name. + * @returns {ZenWorkspaceRecord} The workspace record. + */ +ZenWorkspacesStore.prototype.createRecord = async function (id, collection) { + try { + const workspaces = await ZenWorkspacesStorage.getWorkspaces(); + const workspace = workspaces.find(ws => ws.uuid === id); + const record = new ZenWorkspaceRecord(collection, id); + + if (workspace) { + record.name = workspace.name; + record.icon = workspace.icon; + record.default = workspace.default; + record.containerTabId = workspace.containerTabId; + record.position = workspace.position; + record.deleted = false; + } else { + record.deleted = true; + } + + return record; + } catch (error) { + this._log.error(`Error creating record for workspace ID ${id}`, error); + throw error; + } +}; + +/** + * Creates a new workspace. + * @param {ZenWorkspaceRecord} record - The workspace record to create. + */ +ZenWorkspacesStore.prototype.create = async function (record) { + try { + this._validateRecord(record); + const workspace = { + uuid: record.id, + name: record.name, + icon: record.icon, + default: record.default, + containerTabId: record.containerTabId, + position: record.position + }; + await ZenWorkspacesStorage.saveWorkspace(workspace); + } catch (error) { + this._log.error(`Error creating workspace with ID ${record.id}`, error); + throw error; + } +}; + +/** + * Updates an existing workspace. + * @param {ZenWorkspaceRecord} record - The workspace record to update. + */ +ZenWorkspacesStore.prototype.update = async function (record) { + try { + this._validateRecord(record); + await this.create(record); // Reuse create for update + } catch (error) { + this._log.error(`Error updating workspace with ID ${record.id}`, error); + throw error; + } +}; + +/** + * Removes a workspace. + * @param {ZenWorkspaceRecord} record - The workspace record to remove. + */ +ZenWorkspacesStore.prototype.remove = async function (record) { + try { + await ZenWorkspacesStorage.removeWorkspace(record.id); + // Changes are already marked within ZenWorkspacesStorage.removeWorkspace + } catch (error) { + this._log.error(`Error removing workspace with ID ${record.id}`, error); + throw error; + } +}; + +/** + * Wipes all workspaces from the storage. + */ +ZenWorkspacesStore.prototype.wipe = async function () { + try { + await ZenWorkspacesStorage.wipeAllWorkspaces(); + } catch (error) { + this._log.error("Error wiping all workspaces", error); + throw error; + } +}; + +/** + * Validates a workspace record. + * @param {ZenWorkspaceRecord} record - The workspace record to validate. + */ +ZenWorkspacesStore.prototype._validateRecord = function (record) { + if (!record.id || typeof record.id !== "string") { + throw new Error("Invalid workspace ID"); + } + if (!record.name || typeof record.name !== "string") { + throw new Error(`Invalid workspace name for ID ${record.id}`); + } + if (typeof record.default !== "boolean") { + record.default = false; + } + if (record.icon != null && typeof record.icon !== "string") { + throw new Error(`Invalid icon for workspace ID ${record.id}`); + } + if (record.containerTabId != null && typeof record.containerTabId !== "number") { + throw new Error(`Invalid containerTabId for workspace ID ${record.id}`); + } + if(record.position != null && typeof record.position !== "number") { + throw new Error(`Invalid position for workspace ID ${record.id}`); + } +}; + +/** + * Retrieves changed workspace IDs since the last sync. + * @returns {Object} An object mapping workspace UUIDs to their change timestamps. + */ +ZenWorkspacesStore.prototype.getChangedIDs = async function () { + try { + return await ZenWorkspacesStorage.getChangedIDs(); + } catch (error) { + this._log.error("Error retrieving changed IDs from storage", error); + throw error; + } +}; + +/** + * Clears all recorded changes after a successful sync. + */ +ZenWorkspacesStore.prototype.clearChangedIDs = async function () { + try { + await ZenWorkspacesStorage.clearChangedIDs(); + } catch (error) { + this._log.error("Error clearing changed IDs in storage", error); + throw error; + } +}; + +/** + * Marks a workspace as changed. + * @param {String} uuid - The UUID of the workspace that changed. + */ +ZenWorkspacesStore.prototype.markChanged = async function (uuid) { + try { + await ZenWorkspacesStorage.markChanged(uuid); + } catch (error) { + this._log.error(`Error marking workspace ${uuid} as changed`, error); + throw error; + } +}; + +/** + * Finalizes the store by ensuring all pending operations are completed. + */ +ZenWorkspacesStore.prototype.finalize = async function () { + await Store.prototype.finalize.call(this); +}; + + +// Define ZenWorkspacesTracker +function ZenWorkspacesTracker(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"); +} + +ZenWorkspacesTracker.prototype = Object.create(Tracker.prototype); +ZenWorkspacesTracker.prototype.constructor = ZenWorkspacesTracker; + +/** + * Retrieves changed workspace IDs by delegating to the store. + * @returns {Object} An object mapping workspace UUIDs to their change timestamps. + */ +ZenWorkspacesTracker.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. + */ +ZenWorkspacesTracker.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 workspace changes. + */ +ZenWorkspacesTracker.prototype.onStart = function () { + if (this._started) { + return; + } + this._log.trace("Starting tracker"); + // Register observers for workspace changes + Services.obs.addObserver(this.asyncObserver, "zen-workspace-added"); + Services.obs.addObserver(this.asyncObserver, "zen-workspace-removed"); + Services.obs.addObserver(this.asyncObserver, "zen-workspace-updated"); + this._started = true; +}; + +/** + * Called when the tracker stops. Unregisters observers. + */ +ZenWorkspacesTracker.prototype.onStop = function () { + if (!this._started) { + return; + } + this._log.trace("Stopping tracker"); + // Unregister observers for workspace changes + Services.obs.removeObserver(this.asyncObserver, "zen-workspace-added"); + Services.obs.removeObserver(this.asyncObserver, "zen-workspace-removed"); + Services.obs.removeObserver(this.asyncObserver, "zen-workspace-updated"); + this._started = false; +}; + +/** + * Handles observed events and marks workspaces as changed accordingly. + * @param {nsISupports} subject - The subject of the notification. + * @param {String} topic - The topic of the notification. + * @param {String} data - Additional data (workspace UUID). + */ +ZenWorkspacesTracker.prototype.observe = async function (subject, topic, data) { + if (this.ignoreAll) { + return; + } + + try { + switch (topic) { + case "profile-before-change": + await this.stop(); + break; + case "zen-workspace-removed": + case "zen-workspace-updated": + case "zen-workspace-added": + const workspaceID = data; + this._log.trace(`Observed ${topic} for ${workspaceID}`); + // Inform the store about the change + await this.engine._store.markChanged(workspaceID); + // Bump the score to indicate a high-priority sync + 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. + */ +ZenWorkspacesTracker.prototype.finalize = async function () { + await Tracker.prototype.finalize.call(this); +}; + + +// Define ZenWorkspacesEngine +function ZenWorkspacesEngine(service) { + SyncEngine.call(this, "Workspaces", service); +} + +ZenWorkspacesEngine.prototype = Object.create(SyncEngine.prototype); +ZenWorkspacesEngine.prototype.constructor = ZenWorkspacesEngine; + +ZenWorkspacesEngine.prototype._storeObj = ZenWorkspacesStore; +ZenWorkspacesEngine.prototype._trackerObj = ZenWorkspacesTracker; +ZenWorkspacesEngine.prototype._recordObj = ZenWorkspaceRecord; +ZenWorkspacesEngine.prototype.version = 1; + +ZenWorkspacesEngine.prototype.syncPriority = 10; +ZenWorkspacesEngine.prototype.allowSkippedRecord = false; + +Object.setPrototypeOf(ZenWorkspacesEngine.prototype, SyncEngine.prototype); + + From b28ca11e57d933d2a5e1474cbafba9ed1ae81a35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristijan=20Ribari=C4=87?= Date: Sat, 5 Oct 2024 19:54:08 +0200 Subject: [PATCH 07/10] fix(workspaces): Ensure ZenWorkspacesEngine is registered only once This commit prevents multiple registrations of the `ZenWorkspacesEngine`. Previously, the `ZenWorkspacesEngine` was registered unconditionally every time `ZenWorkspaces` was initialized. This could lead to issues if the engine was already registered. This commit adds a check to ensure that the engine is only registered if it is not already present. This prevents multiple registrations and ensures that the engine is only initialized once. --- src/ZenWorkspaces.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ZenWorkspaces.mjs b/src/ZenWorkspaces.mjs index 891e82a..f240aca 100644 --- a/src/ZenWorkspaces.mjs +++ b/src/ZenWorkspaces.mjs @@ -19,7 +19,9 @@ var ZenWorkspaces = new (class extends ZenMultiWindowFeature { ); ChromeUtils.defineLazyGetter(this, 'tabContainer', () => document.getElementById('tabbrowser-tabs')); await ZenWorkspacesStorage.init(); - Weave.Service.engineManager.register(ZenWorkspacesEngine); + if(!Weave.Service.engineManager.get("workspaces")) { + Weave.Service.engineManager.register(ZenWorkspacesEngine); + } await this.initializeWorkspaces(); console.info('ZenWorkspaces: ZenWorkspaces initialized'); From 863dcfa70513b8fffc7b3362ac24e0455f215b42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristijan=20Ribari=C4=87?= Date: Sat, 5 Oct 2024 20:16:20 +0200 Subject: [PATCH 08/10] feat(workspaces): remove unused `used` field from workspaces This commit removes the `used` field from the Workspaces database table and Zen Workspace objects. The `used` field was originally intended to track whether a workspace was currently being used, but it turned out to be unnecessary. This change simplifies the data model and removes redundant information. --- src/ZenWorkspaces.mjs | 1 - src/ZenWorkspacesStorage.mjs | 1 - 2 files changed, 2 deletions(-) diff --git a/src/ZenWorkspaces.mjs b/src/ZenWorkspaces.mjs index f240aca..f60126a 100644 --- a/src/ZenWorkspaces.mjs +++ b/src/ZenWorkspaces.mjs @@ -670,7 +670,6 @@ var ZenWorkspaces = new (class extends ZenMultiWindowFeature { let window = { uuid: gZenUIManager.generateUuidv4(), default: isDefault, - used: true, icon: icon, name: name, }; diff --git a/src/ZenWorkspacesStorage.mjs b/src/ZenWorkspacesStorage.mjs index c712e51..861af51 100644 --- a/src/ZenWorkspacesStorage.mjs +++ b/src/ZenWorkspacesStorage.mjs @@ -18,7 +18,6 @@ var ZenWorkspacesStorage = { position INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, - used INTEGER NOT NULL DEFAULT 0 ) `); From 86578cb4d30befe3d3b0670c737aa5d6d378f612 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristijan=20Ribari=C4=87?= Date: Sat, 5 Oct 2024 21:16:07 +0200 Subject: [PATCH 09/10] feat(workspace): Move observer notifications to workspaces storage --- src/ZenWorkspaces.mjs | 4 ---- src/ZenWorkspacesStorage.mjs | 14 +++++++++++--- src/ZenWorkspacesSync.mjs | 7 +++---- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/ZenWorkspaces.mjs b/src/ZenWorkspaces.mjs index f60126a..026c552 100644 --- a/src/ZenWorkspaces.mjs +++ b/src/ZenWorkspaces.mjs @@ -209,7 +209,6 @@ var ZenWorkspaces = new (class extends ZenMultiWindowFeature { this._workspaceCache = null; await this._propagateWorkspaceData(); await this._updateWorkspacesChangeContextMenu(); - Services.obs.notifyObservers(null, "zen-workspace-removed", windowID); } isWorkspaceActive(workspace) { @@ -559,7 +558,6 @@ var ZenWorkspaces = new (class extends ZenMultiWindowFeature { workspaceData.name = workspaceName; workspaceData.icon = icon?.label; await this.saveWorkspace(workspaceData); - Services.obs.notifyObservers(null, "zen-workspace-updated", workspaceData.uuid); await this._propagateWorkspaceData(); } @@ -683,7 +681,6 @@ var ZenWorkspaces = new (class extends ZenMultiWindowFeature { } let workspaceData = this._createWorkspaceData(name, isDefault, icon); await this.saveWorkspace(workspaceData); - Services.obs.notifyObservers(null, "zen-workspace-added", workspaceData.uuid); await this.changeWorkspace(workspaceData); } @@ -760,7 +757,6 @@ var ZenWorkspaces = new (class extends ZenMultiWindowFeature { let userContextId = parseInt(event.target.getAttribute('data-usercontextid')); workspace.containerTabId = userContextId; await this.saveWorkspace(workspace); - Services.obs.notifyObservers(null, "zen-workspace-updated", workspace.uuid); await this._propagateWorkspaceData(); } diff --git a/src/ZenWorkspacesStorage.mjs b/src/ZenWorkspacesStorage.mjs index 861af51..3606055 100644 --- a/src/ZenWorkspacesStorage.mjs +++ b/src/ZenWorkspacesStorage.mjs @@ -17,7 +17,7 @@ var ZenWorkspacesStorage = { container_id INTEGER, position INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL ) `); @@ -47,7 +47,7 @@ var ZenWorkspacesStorage = { } }, - async saveWorkspace(workspace) { + async saveWorkspace(workspace, notifyObservers = true) { await PlacesUtils.withConnectionWrapper('ZenWorkspacesStorage.saveWorkspace', async (db) => { const now = Date.now(); @@ -114,6 +114,10 @@ var ZenWorkspacesStorage = { }); }); }); + + if(notifyObservers) { + Services.obs.notifyObservers(null, "zen-workspace-updated", workspace.uuid); + } }, async getWorkspaces() { @@ -131,7 +135,7 @@ var ZenWorkspacesStorage = { })); }, - async removeWorkspace(uuid) { + async removeWorkspace(uuid, notifyObservers = true) { await PlacesUtils.withConnectionWrapper('ZenWorkspacesStorage.removeWorkspace', async (db) => { await db.execute( ` @@ -150,6 +154,10 @@ var ZenWorkspacesStorage = { timestamp: Math.floor(now / 1000) }); }); + + if(notifyObservers) { + Services.obs.notifyObservers(null, "zen-workspace-removed", uuid); + } }, async wipeAllWorkspaces() { diff --git a/src/ZenWorkspacesSync.mjs b/src/ZenWorkspacesSync.mjs index a60413d..192d446 100644 --- a/src/ZenWorkspacesSync.mjs +++ b/src/ZenWorkspacesSync.mjs @@ -68,7 +68,7 @@ ZenWorkspacesStore.prototype.changeItemID = async function (oldID, newID) { const workspace = workspaces.find(ws => ws.uuid === oldID); if (workspace) { workspace.uuid = newID; - await ZenWorkspacesStorage.saveWorkspace(workspace); + await ZenWorkspacesStorage.saveWorkspace(workspace,false); // Mark the new ID as changed for sync await ZenWorkspacesStorage.markChanged(newID); } @@ -138,7 +138,7 @@ ZenWorkspacesStore.prototype.create = async function (record) { containerTabId: record.containerTabId, position: record.position }; - await ZenWorkspacesStorage.saveWorkspace(workspace); + await ZenWorkspacesStorage.saveWorkspace(workspace,false); } catch (error) { this._log.error(`Error creating workspace with ID ${record.id}`, error); throw error; @@ -165,8 +165,7 @@ ZenWorkspacesStore.prototype.update = async function (record) { */ ZenWorkspacesStore.prototype.remove = async function (record) { try { - await ZenWorkspacesStorage.removeWorkspace(record.id); - // Changes are already marked within ZenWorkspacesStorage.removeWorkspace + await ZenWorkspacesStorage.removeWorkspace(record.id, false); } catch (error) { this._log.error(`Error removing workspace with ID ${record.id}`, error); throw error; From d3ac1895e5cd28ea560f2df76f421953ab30285c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristijan=20Ribari=C4=87?= Date: Sat, 5 Oct 2024 22:09:54 +0200 Subject: [PATCH 10/10] feat(workspaces): Improve workspace change notification and handling This commit improves the notification and handling of workspace changes by: - **Introducing a dedicated observer notification system:** The `ZenWorkspacesStorage` now uses a private helper method (`_notifyWorkspacesChanged`) to notify observers with a list of changed workspace UUIDs. This provides a more efficient and flexible notification system. - **Updating `ZenWorkspacesTracker` to parse UUIDs:** The `ZenWorkspacesTracker` now correctly parses the JSON stringified array of UUIDs received from the observer, allowing it to handle multiple workspace changes in a single notification. - **Refactoring `saveWorkspace`, `removeWorkspace`, `setDefaultWorkspace`, and `updateWorkspaceOrder`:** These methods now consistently collect and notify observers about changed UUIDs. - **Removing unnecessary code:** The previous approach of notifying observers individually for each change has been removed, leading to code simplification and reduced complexity. This commit ensures that workspace changes are more reliably and efficiently tracked, leading to a smoother and more responsive workspace management experience. --- src/ZenWorkspacesStorage.mjs | 130 +++++++++++++++++++++++++++-------- src/ZenWorkspacesSync.mjs | 40 +++++++++-- 2 files changed, 136 insertions(+), 34 deletions(-) diff --git a/src/ZenWorkspacesStorage.mjs b/src/ZenWorkspacesStorage.mjs index 3606055..c26e57b 100644 --- a/src/ZenWorkspacesStorage.mjs +++ b/src/ZenWorkspacesStorage.mjs @@ -40,14 +40,30 @@ var ZenWorkspacesStorage = { const oldWorkspaces = await IOUtils.readJSON(oldWorkspacesPath); if (oldWorkspaces.workspaces) { for (const workspace of oldWorkspaces.workspaces) { - await this.saveWorkspace(workspace); + await this.saveWorkspace(workspace, false); // Disable immediate notification } } await IOUtils.remove(oldWorkspacesPath); } }, + /** + * 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. + */ + _notifyWorkspacesChanged(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 saveWorkspace(workspace, notifyObservers = true) { + const changedUUIDs = new Set(); + await PlacesUtils.withConnectionWrapper('ZenWorkspacesStorage.saveWorkspace', async (db) => { const now = Date.now(); @@ -55,6 +71,12 @@ var ZenWorkspacesStorage = { // If the workspace is set as default, unset is_default for all other workspaces if (workspace.default) { await db.execute(`UPDATE zen_workspaces SET is_default = 0 WHERE uuid != :uuid`, { uuid: workspace.uuid }); + + // Collect UUIDs of workspaces that were unset as default + const unsetDefaultRows = await db.execute(`SELECT uuid FROM zen_workspaces WHERE is_default = 0 AND uuid != :uuid`, { uuid: workspace.uuid }); + for (const row of unsetDefaultRows) { + changedUUIDs.add(row.getResultByName('uuid')); + } } // Get the current maximum position @@ -66,16 +88,21 @@ var ZenWorkspacesStorage = { if ('position' in workspace && workspace.position !== null && Number.isInteger(workspace.position)) { // If position is provided, check if it's already occupied const occupiedOrderResult = await db.execute(` - SELECT uuid FROM zen_workspaces WHERE "position" = :position AND uuid != :uuid - `, { position: workspace.position, uuid: workspace.uuid }); + SELECT uuid FROM zen_workspaces WHERE "position" = :position AND uuid != :uuid + `, { position: workspace.position, uuid: workspace.uuid }); if (occupiedOrderResult.length > 0) { // If the position is occupied, shift the positions of subsequent workspaces await db.execute(` - UPDATE zen_workspaces - SET "position" = "position" + 1 - WHERE "position" >= :position AND uuid != :uuid - `, { position: workspace.position, uuid: workspace.uuid }); + UPDATE zen_workspaces + SET "position" = "position" + 1 + WHERE "position" >= :position AND uuid != :uuid + `, { position: workspace.position, uuid: workspace.uuid }); + + // Collect UUIDs of workspaces whose positions were shifted + for (const row of occupiedOrderResult) { + changedUUIDs.add(row.getResultByName('uuid')); + } } newOrder = workspace.position; @@ -87,13 +114,13 @@ var ZenWorkspacesStorage = { // Insert or replace the workspace await db.executeCached(` INSERT OR REPLACE INTO zen_workspaces ( - uuid, name, icon, is_default, container_id, created_at, updated_at, "position" - ) VALUES ( - :uuid, :name, :icon, :is_default, :container_id, - COALESCE((SELECT created_at FROM zen_workspaces WHERE uuid = :uuid), :now), - :now, - :position - ) + uuid, name, icon, is_default, container_id, created_at, updated_at, "position" + ) VALUES ( + :uuid, :name, :icon, :is_default, :container_id, + COALESCE((SELECT created_at FROM zen_workspaces WHERE uuid = :uuid), :now), + :now, + :position + ) `, { uuid: workspace.uuid, name: workspace.name, @@ -107,16 +134,19 @@ var ZenWorkspacesStorage = { // Record the change in the changes tracking table await db.execute(` INSERT OR REPLACE INTO zen_workspaces_changes (uuid, timestamp) - VALUES (:uuid, :timestamp) + VALUES (:uuid, :timestamp) `, { uuid: workspace.uuid, timestamp: Math.floor(now / 1000) // Unix timestamp in seconds }); + + // Add the main workspace UUID to the changed set + changedUUIDs.add(workspace.uuid); }); }); - if(notifyObservers) { - Services.obs.notifyObservers(null, "zen-workspace-updated", workspace.uuid); + if (notifyObservers) { + this._notifyWorkspacesChanged("zen-workspace-updated", Array.from(changedUUIDs)); } }, @@ -136,12 +166,14 @@ var ZenWorkspacesStorage = { }, async removeWorkspace(uuid, notifyObservers = true) { + const changedUUIDs = [uuid]; + await PlacesUtils.withConnectionWrapper('ZenWorkspacesStorage.removeWorkspace', async (db) => { await db.execute( - ` - DELETE FROM zen_workspaces WHERE uuid = :uuid - `, - { uuid } + ` + DELETE FROM zen_workspaces WHERE uuid = :uuid + `, + { uuid } ); // Record the removal as a change @@ -155,8 +187,8 @@ var ZenWorkspacesStorage = { }); }); - if(notifyObservers) { - Services.obs.notifyObservers(null, "zen-workspace-removed", uuid); + if (notifyObservers) { + this._notifyWorkspacesChanged("zen-workspace-removed", changedUUIDs); } }, @@ -167,14 +199,24 @@ var ZenWorkspacesStorage = { }); }, - async setDefaultWorkspace(uuid) { + async setDefaultWorkspace(uuid, notifyObservers = true) { + const changedUUIDs = []; + await PlacesUtils.withConnectionWrapper('ZenWorkspacesStorage.setDefaultWorkspace', async (db) => { await db.executeTransaction(async function () { const now = Date.now(); // Unset the default flag for all other workspaces - await db.execute(`UPDATE zen_workspaces SET is_default = 0`); + await db.execute(`UPDATE zen_workspaces SET is_default = 0 WHERE uuid != :uuid`, { uuid }); + + // Collect UUIDs of workspaces that were unset as default + const unsetDefaultRows = await db.execute(`SELECT uuid FROM zen_workspaces WHERE is_default = 0 AND uuid != :uuid`, { uuid }); + for (const row of unsetDefaultRows) { + changedUUIDs.push(row.getResultByName('uuid')); + } + // Set the default flag for the specified workspace await db.execute(`UPDATE zen_workspaces SET is_default = 1 WHERE uuid = :uuid`, { uuid }); + // Record the change for the specified workspace await db.execute(` INSERT OR REPLACE INTO zen_workspaces_changes (uuid, timestamp) @@ -183,8 +225,15 @@ var ZenWorkspacesStorage = { uuid, timestamp: Math.floor(now / 1000) }); + + // Add the main workspace UUID to the changed set + changedUUIDs.push(uuid); }); }); + + if (notifyObservers) { + this._notifyWorkspacesChanged("zen-workspace-updated", changedUUIDs); + } }, async markChanged(uuid) { @@ -218,7 +267,9 @@ var ZenWorkspacesStorage = { }); }, - async updateWorkspaceOrder(uuid, newOrder) { + async updateWorkspaceOrder(uuid, newOrder, notifyObservers = true) { + const changedUUIDs = [uuid]; + await PlacesUtils.withConnectionWrapper('ZenWorkspacesStorage.updateWorkspaceOrder', async (db) => { await db.executeTransaction(async function () { // Get the current position of the workspace @@ -233,18 +284,36 @@ var ZenWorkspacesStorage = { if (newOrder > currentOrder) { // Moving down: decrement position of workspaces between old and new positions + const rows = await db.execute(` + SELECT uuid FROM zen_workspaces + WHERE "position" > :currentOrder AND "position" <= :newOrder + `, { currentOrder, newOrder }); + await db.execute(` UPDATE zen_workspaces SET "position" = "position" - 1 WHERE "position" > :currentOrder AND "position" <= :newOrder `, { currentOrder, newOrder }); + + for (const row of rows) { + changedUUIDs.push(row.getResultByName('uuid')); + } } else { // Moving up: increment position of workspaces between new and old positions + const rows = await db.execute(` + SELECT uuid FROM zen_workspaces + WHERE "position" >= :newOrder AND "position" < :currentOrder + `, { currentOrder, newOrder }); + await db.execute(` UPDATE zen_workspaces SET "position" = "position" + 1 WHERE "position" >= :newOrder AND "position" < :currentOrder `, { currentOrder, newOrder }); + + for (const row of rows) { + changedUUIDs.push(row.getResultByName('uuid')); + } } // Set the new position for the workspace @@ -254,7 +323,7 @@ var ZenWorkspacesStorage = { WHERE uuid = :uuid `, { uuid, newOrder }); - // Mark the workspace as changed + // Record the change for the specified workspace const now = Date.now(); await db.execute(` INSERT OR REPLACE INTO zen_workspaces_changes (uuid, timestamp) @@ -263,7 +332,14 @@ var ZenWorkspacesStorage = { uuid, timestamp: Math.floor(now / 1000) }); + + // Add the main workspace UUID to the changed set + changedUUIDs.push(uuid); }); }); + + if (notifyObservers) { + this._notifyWorkspacesChanged("zen-workspace-updated", changedUUIDs); + } }, }; diff --git a/src/ZenWorkspacesSync.mjs b/src/ZenWorkspacesSync.mjs index 192d446..5a7737e 100644 --- a/src/ZenWorkspacesSync.mjs +++ b/src/ZenWorkspacesSync.mjs @@ -326,7 +326,7 @@ ZenWorkspacesTracker.prototype.onStop = function () { * Handles observed events and marks workspaces as changed accordingly. * @param {nsISupports} subject - The subject of the notification. * @param {String} topic - The topic of the notification. - * @param {String} data - Additional data (workspace UUID). + * @param {String} data - Additional data (JSON stringified array of UUIDs). */ ZenWorkspacesTracker.prototype.observe = async function (subject, topic, data) { if (this.ignoreAll) { @@ -341,12 +341,38 @@ ZenWorkspacesTracker.prototype.observe = async function (subject, topic, data) { case "zen-workspace-removed": case "zen-workspace-updated": case "zen-workspace-added": - const workspaceID = data; - this._log.trace(`Observed ${topic} for ${workspaceID}`); - // Inform the store about the change - await this.engine._store.markChanged(workspaceID); - // Bump the score to indicate a high-priority sync - this.score += SCORE_INCREMENT_XLARGE; + let workspaceIDs; + if (data) { + try { + workspaceIDs = JSON.parse(data); + if (!Array.isArray(workspaceIDs)) { + throw new Error("Parsed data is not an array"); + } + } catch (parseError) { + this._log.error(`Failed to parse workspace UUIDs from data: ${data}`, parseError); + return; + } + } else { + this._log.error(`No data received for event ${topic}`); + return; + } + + this._log.trace(`Observed ${topic} for UUIDs: ${workspaceIDs.join(", ")}`); + + // Process each UUID + for (const workspaceID of workspaceIDs) { + if (typeof workspaceID === "string") { + // Inform the store about the change + await this.engine._store.markChanged(workspaceID); + } else { + this._log.warn(`Invalid workspace ID encountered: ${workspaceID}`); + } + } + + // Bump the score once after processing all changes + if (workspaceIDs.length > 0) { + this.score += SCORE_INCREMENT_XLARGE; + } break; } } catch (error) {