diff --git a/src/browser/components/preferences/sync-js.patch b/src/browser/components/preferences/sync-js.patch new file mode 100644 index 0000000000..b0174d721f --- /dev/null +++ b/src/browser/components/preferences/sync-js.patch @@ -0,0 +1,20 @@ +diff --git a/browser/components/preferences/sync.js b/browser/components/preferences/sync.js +index dc89a9c41a0dbd44054ede0025d333773f0ae908..7fd91bd704b3b187277e4c8b076f990cb56ea8dc 100644 +--- a/browser/components/preferences/sync.js ++++ b/browser/components/preferences/sync.js +@@ -40,6 +40,7 @@ Preferences.addAll([ + { id: "services.sync.engine.creditcards", type: "bool" }, + { id: "services.sync.engine.addons", type: "bool" }, + { id: "services.sync.engine.prefs", type: "bool" }, ++ { id: "services.sync.engine.workspaces", type: "bool" }, + ]); + + /** +@@ -512,6 +513,7 @@ const SYNC_ENGINE_SETTINGS = [ + }, + { id: "syncAddons", pref: "services.sync.engine.addons", type: "addons" }, + { id: "syncSettings", pref: "services.sync.engine.prefs", type: "settings" }, ++ { id: "syncWorkspaces", pref: "services.sync.engine.workspaces", type: "workspaces" }, + ]; + + SYNC_ENGINE_SETTINGS.forEach(({ id, pref }) => { diff --git a/src/services/sync/modules/service-sys-mjs.patch b/src/services/sync/modules/service-sys-mjs.patch new file mode 100644 index 0000000000..831514b9bc --- /dev/null +++ b/src/services/sync/modules/service-sys-mjs.patch @@ -0,0 +1,15 @@ +diff --git a/services/sync/modules/service.sys.mjs b/services/sync/modules/service.sys.mjs +index c873293871ffaba305bc1bf41730d79c13546b85..0e0171cec13dfcbb296ec7bf03628370ce2fa93f 100644 +--- a/services/sync/modules/service.sys.mjs ++++ b/services/sync/modules/service.sys.mjs +@@ -99,6 +99,10 @@ function getEngineModules() { + whenTrue: "ExtensionStorageEngineKinto", + whenFalse: "ExtensionStorageEngineBridge", + }; ++ result.Workspaces = { ++ module: "resource:///modules/zen/ZenWorkspacesSync.sys.mjs", ++ symbol: "ZenWorkspacesEngine", ++ }; + return result; + } + diff --git a/src/toolkit/components/contextualidentity/ContextualIdentityService-sys-mjs.patch b/src/toolkit/components/contextualidentity/ContextualIdentityService-sys-mjs.patch new file mode 100644 index 0000000000..14f8f997c3 --- /dev/null +++ b/src/toolkit/components/contextualidentity/ContextualIdentityService-sys-mjs.patch @@ -0,0 +1,19 @@ +diff --git a/toolkit/components/contextualidentity/ContextualIdentityService.sys.mjs b/toolkit/components/contextualidentity/ContextualIdentityService.sys.mjs +index 5702ff28cc22206f5ce16584dac8a78d816562ce..2132ee9ad8f553b3effeb7c4386e5fae46b80507 100644 +--- a/toolkit/components/contextualidentity/ContextualIdentityService.sys.mjs ++++ b/toolkit/components/contextualidentity/ContextualIdentityService.sys.mjs +@@ -270,11 +270,11 @@ _ContextualIdentityService.prototype = { + }); + }, + +- create(name, icon, color) { ++ create(name, icon, color, id = null) { + this.ensureDataReady(); + +- // Retrieve the next userContextId available. +- let userContextId = ++this._lastUserContextId; ++ // If explicit ID is provided, use it if it's not already in use, otherwise use the next available ID. ++ let userContextId = id !== null && !this._identities.some(i => i.userContextId === id) ? id : ++this._lastUserContextId; + + // Throw an error if the next userContextId available is invalid (the one associated to + // MAX_USER_CONTEXT_ID is already reserved to "userContextIdInternal.webextStorageLocal", which diff --git a/src/zen/folders/ZenFolder.mjs b/src/zen/folders/ZenFolder.mjs index abd7e162eb..881e8c44ff 100644 --- a/src/zen/folders/ZenFolder.mjs +++ b/src/zen/folders/ZenFolder.mjs @@ -172,6 +172,13 @@ export class nsZenFolder extends MozTabbrowserTabGroup { } async delete() { + if (this.id) { + Services.obs.notifyObservers( + null, + "zen-workspace-item-changed", + `f~${this.id}` + ); + } for (const tab of this.allItemsRecursive) { if (tab.hasAttribute("zen-empty-tab")) { // Manually remove the empty tabs as removeTabs() inside removeTabGroup diff --git a/src/zen/folders/ZenFolders.mjs b/src/zen/folders/ZenFolders.mjs index 31d3cbf41a..12fbe6a8e0 100644 --- a/src/zen/folders/ZenFolders.mjs +++ b/src/zen/folders/ZenFolders.mjs @@ -234,6 +234,14 @@ class nsZenFolders extends nsZenDOMOperatedFeature { if (groupIsCollapsiblePins(group)) { return; } + // Mark tab as modified for sync when its folder membership changes. + if (tab.id && group?.isZenFolder) { + Services.obs.notifyObservers( + null, + "zen-workspace-item-changed", + `t~${tab.id}` + ); + } group.pinned = tab.pinned; const isActiveFolder = group?.activeGroups?.length > 0; @@ -329,6 +337,14 @@ class nsZenFolders extends nsZenDOMOperatedFeature { async on_TabUngrouped(event) { const tab = event.detail; const group = event.target; + // Mark tab as modified for sync when its folder membership changes. + if (tab.id && group?.isZenFolder) { + Services.obs.notifyObservers( + null, + "zen-workspace-item-changed", + `t~${tab.id}` + ); + } if ( group.hasAttribute("split-view-group") && tab.hasAttribute("had-zen-pinned-changed") @@ -558,16 +574,22 @@ class nsZenFolders extends nsZenDOMOperatedFeature { const insertBefore = options.insertBefore || pinnedContainer.querySelector(".pinned-tabs-container-separator"); - const emptyTab = gBrowser.addTab("about:blank", { - skipAnimation: true, - pinned: true, - triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), - _forZenEmptyTab: true, - createLazyBrowser: true, - }); - gBrowser.pinTab(emptyTab); - tabs = [emptyTab, ...filteredTabs]; + if (!options.skipEmptyTab) { + const emptyTab = gBrowser.addTab("about:blank", { + skipAnimation: true, + pinned: true, + triggeringPrincipal: + Services.scriptSecurityManager.getSystemPrincipal(), + _forZenEmptyTab: true, + createLazyBrowser: true, + }); + + gBrowser.pinTab(emptyTab); + tabs = [emptyTab, ...filteredTabs]; + } else { + tabs = filteredTabs; + } const folder = this._createFolderNode(options); @@ -597,6 +619,13 @@ class nsZenFolders extends nsZenDOMOperatedFeature { } this.#groupInit(folder); + if (folder.id) { + Services.obs.notifyObservers( + null, + "zen-workspace-item-changed", + `f~${folder.id}` + ); + } return folder; } diff --git a/src/zen/sessionstore/ZenSessionManager.sys.mjs b/src/zen/sessionstore/ZenSessionManager.sys.mjs index 7b854e4376..a53958fff7 100644 --- a/src/zen/sessionstore/ZenSessionManager.sys.mjs +++ b/src/zen/sessionstore/ZenSessionManager.sys.mjs @@ -16,6 +16,8 @@ ChromeUtils.defineESModuleGetters(lazy, { gWindowSyncEnabled: "resource:///modules/zen/ZenWindowSync.sys.mjs", gSyncOnlyPinnedTabs: "resource:///modules/zen/ZenWindowSync.sys.mjs", DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs", + ContextualIdentityService: + "resource://gre/modules/ContextualIdentityService.sys.mjs", }); XPCOMUtils.defineLazyPreferenceGetter( @@ -89,6 +91,13 @@ export class nsZenSessionManager { * A deferred task to create backups of the session file. */ #deferredBackupTask = null; + /** + * Snapshot of item hashes from the last saveState() cycle, used + * to detect per-item changes for the multi-record sync engine. + * Shape: { spaces: Map, tabs: Map, + * folders: Map, metaHash: string } + */ + _lastSnapshot = null; init() { this.log("Initializing session manager"); @@ -280,7 +289,9 @@ export class nsZenSessionManager { } catch (e) { console.error("ZenSessionManager: Failed to read session file", e); } - this.#sidebar = this._dataFromFile || {}; + const rawData = this._dataFromFile || {}; + const { _syncMeta: _ignored, ...sidebarData } = rawData; + this.#sidebar = sidebarData; if (!this.#sidebar.spaces?.length && !this._shouldRunMigration) { this.log( "No spaces data found in session file, running migration", @@ -588,7 +599,7 @@ export class nsZenSessionManager { } ); this.#collectWindowData(windows); - // This would save the data to disk asynchronously or when quitting the app. + this.#notifyChangedItems(this.#sidebar); let sidebar = this.#sidebar; this.#file.data = sidebar; if (soon) { @@ -706,6 +717,7 @@ export class nsZenSessionManager { sidebarData.lastCollected = Date.now(); this.#collectTabsData(sidebarData, aStateWindows); + this.#sidebar = sidebarData; } @@ -904,6 +916,327 @@ export class nsZenSessionManager { } return Cu.cloneInto(sidebar.spaces, {}); } + + /** + * Returns a deep clone of the full sidebar object (spaces, tabs, folders, etc.). + * Used by the ZenWorkspacesSync engine to build the sync payload. + * + * @returns {object} A deep clone of the sidebar data. + */ + getSidebarData() { + const sidebar = this.#sidebar; + if (!sidebar) { + return {}; + } + return JSON.parse(JSON.stringify(sidebar)); + } + + /** + * Applies incoming sync data. Called by the ZenWorkspacesStore + * after classifying all incoming records by type. + * + * @param {{ spaces: Array, tabs: Array, folders: Array, containers: Array }} pulled + * @param {{ spaces: Array, tabs: Array, folders: Array, containers: Array }} removals + * @param {{ groups: Array, splitViewData: Array }|null} meta + */ + async applyMultiRecordSync(pulled, removals, meta) { + try { + let sidebar = { ...this.#sidebar }; + + // 1. Apply container changes via ContextualIdentityService + const localContainers = + lazy.ContextualIdentityService.getPublicIdentities(); + for (const container of pulled.containers || []) { + if (!container.name) { + continue; + } + const existsLocally = localContainers.some( + c => String(c.userContextId) === String(container.userContextId) + ); + if (existsLocally) { + lazy.ContextualIdentityService.update( + container.userContextId, + container.name, + container.icon, + container.color + ); + } else { + lazy.ContextualIdentityService.create( + container.name, + container.icon, + container.color, + container.userContextId + ); + } + } + for (const container of removals.containers || []) { + try { + lazy.ContextualIdentityService.remove(container.userContextId); + } catch (e) { + /* ignore if container doesn't exist */ + } + } + + // 2. Remove deleted items from sidebar arrays + const removedSpaceIds = new Set((removals.spaces || []).map(s => s.uuid)); + const removedTabIds = new Set( + (removals.tabs || []).map(t => t.zenSyncId) + ); + const removedFolderIds = new Set( + (removals.folders || []).map(f => String(f.id)) + ); + if (removedSpaceIds.size) { + sidebar.spaces = (sidebar.spaces || []).filter( + s => !removedSpaceIds.has(s.uuid) + ); + } + if (removedTabIds.size) { + sidebar.tabs = (sidebar.tabs || []).filter( + t => !removedTabIds.has(t.zenSyncId) + ); + } + if (removedFolderIds.size) { + sidebar.folders = (sidebar.folders || []).filter( + f => !removedFolderIds.has(String(f.id)) + ); + } + + // 3. Merge pulled items into sidebar arrays + if (pulled.spaces?.length) { + const spaceMap = new Map((sidebar.spaces || []).map(s => [s.uuid, s])); + for (const space of pulled.spaces) { + if (!space.uuid) { + continue; + } + const existing = spaceMap.get(space.uuid); + spaceMap.set( + space.uuid, + existing ? { ...existing, ...space } : space + ); + } + sidebar.spaces = Array.from(spaceMap.values()); + // Sort spaces by the position field from the sync payload + sidebar.spaces.sort( + (a, b) => (a.position ?? Infinity) - (b.position ?? Infinity) + ); + } + + if (pulled.tabs?.length) { + const tabMap = new Map(); + const noIdTabs = []; + for (const tab of sidebar.tabs || []) { + if (tab.zenSyncId) { + tabMap.set(tab.zenSyncId, tab); + } else { + noIdTabs.push(tab); + } + } + for (const tab of pulled.tabs) { + if (!tab.zenSyncId) { + continue; + } + const existing = tabMap.get(tab.zenSyncId); + tabMap.set(tab.zenSyncId, existing ? { ...existing, ...tab } : tab); + } + sidebar.tabs = [...noIdTabs, ...tabMap.values()]; + } + + if (pulled.folders?.length) { + const folderMap = new Map( + (sidebar.folders || []).map(f => [String(f.id), f]) + ); + for (const folder of pulled.folders) { + if (!folder.id) { + continue; + } + const existing = folderMap.get(String(folder.id)); + folderMap.set( + String(folder.id), + existing ? { ...existing, ...folder } : folder + ); + } + sidebar.folders = Array.from(folderMap.values()); + } + + // 4. Apply meta if present (replace groups and splitViewData) + if (meta) { + sidebar.groups = meta.groups; + sidebar.splitViewData = meta.splitViewData; + } + + // 5. Persist sidebar to disk + this.#sidebar = sidebar; + this.#file.data = sidebar; + this.#file.saveSoon(); + + // Rebuild the snapshot so the next saveState() diff doesn't + // re-fire notifications for items we just applied from sync. + this._lastSnapshot = this.#buildSnapshot(sidebar); + + // 6. Dispatch to ONE window for live DOM updates + const win = Services.wm.getMostRecentWindow("navigator:browser"); + if (win?.gZenWorkspaces && !win.gZenWorkspaces.privateWindowOrDisabled) { + await win.gZenWorkspaces._applySyncChanges(pulled, removals); + } + + this.log("Applied multi-record sync data"); + } catch (e) { + console.error( + "ZenSessionManager: Failed to apply multi-record sync data:", + e + ); + } + } + + // Runtime-only tab fields excluded from sync hashing (mirrors STRIP_TAB_FIELDS + // in ZenWorkspacesSync plus lastAccessed which changes on every tab switch). + static #HASH_STRIP_TAB_FIELDS = [ + "syncStatus", + "scroll", + "formdata", + "selected", + "_zenIsActiveTab", + "_zenContentsVisible", + "_zenChangeLabelFlag", + "lastAccessed", + ]; + + /** + * Builds a snapshot of per-item hashes from the current sidebar state. + * Only sync-relevant fields are included so that runtime-only changes + * (scroll position, formdata, etc.) don't trigger spurious notifications. + * + * @param {object} sidebar - The sidebar data to snapshot. + * @returns {{ spaces: Map, tabs: Map, folders: Map, metaHash: string }} + */ + #buildSnapshot(sidebar) { + const spaces = new Map(); + const spaceList = sidebar.spaces || []; + for (let i = 0; i < spaceList.length; i++) { + const s = spaceList[i]; + if (s.uuid) { + // Include array index so reordering is detected. + spaces.set(s.uuid, JSON.stringify({ ...s, _pos: i })); + } + } + + const tabs = new Map(); + const tabList = sidebar.tabs || []; + for (let i = 0; i < tabList.length; i++) { + const t = tabList[i]; + if (t.zenSyncId && !(t.zenIsEmpty && !t.groupId)) { + const cleaned = { ...t }; + for (const field of nsZenSessionManager.#HASH_STRIP_TAB_FIELDS) { + delete cleaned[field]; + } + // Include array index so reordering is detected. + cleaned._pos = i; + tabs.set(t.zenSyncId, JSON.stringify(cleaned)); + } + } + + const folders = new Map(); + for (const f of sidebar.folders || []) { + if (f.id) { + const { syncStatus: _s, ...rest } = f; + folders.set(String(f.id), JSON.stringify(rest)); + } + } + + const metaHash = JSON.stringify({ + g: sidebar.groups || [], + sv: sidebar.splitViewData || [], + }); + + return { spaces, tabs, folders, metaHash }; + } + + /** + * Computes per-item hashes and fires "zen-workspace-item-changed" notifications + * for items that have changed, been added, or been removed since the last snapshot. + * + * @param sidebar + */ + #notifyChangedItems(sidebar) { + const snapshot = this.#buildSnapshot(sidebar); + const prev = this._lastSnapshot; + + if (prev) { + // Detect changed/new spaces + for (const [uuid, hash] of snapshot.spaces) { + if (prev.spaces.get(uuid) !== hash) { + Services.obs.notifyObservers( + null, + "zen-workspace-item-changed", + `s~${uuid}` + ); + } + } + // Detect removed spaces + for (const uuid of prev.spaces.keys()) { + if (!snapshot.spaces.has(uuid)) { + Services.obs.notifyObservers( + null, + "zen-workspace-item-changed", + `s~${uuid}` + ); + } + } + + // Detect changed/new tabs + for (const [id, hash] of snapshot.tabs) { + if (prev.tabs.get(id) !== hash) { + Services.obs.notifyObservers( + null, + "zen-workspace-item-changed", + `t~${id}` + ); + } + } + // Detect removed tabs + for (const id of prev.tabs.keys()) { + if (!snapshot.tabs.has(id)) { + Services.obs.notifyObservers( + null, + "zen-workspace-item-changed", + `t~${id}` + ); + } + } + + // Detect changed/new folders + for (const [id, hash] of snapshot.folders) { + if (prev.folders.get(id) !== hash) { + Services.obs.notifyObservers( + null, + "zen-workspace-item-changed", + `f~${id}` + ); + } + } + // Detect removed folders + for (const id of prev.folders.keys()) { + if (!snapshot.folders.has(id)) { + Services.obs.notifyObservers( + null, + "zen-workspace-item-changed", + `f~${id}` + ); + } + } + + // Detect meta changes + if (prev.metaHash !== snapshot.metaHash) { + Services.obs.notifyObservers( + null, + "zen-workspace-item-changed", + "meta~global" + ); + } + } + + this._lastSnapshot = snapshot; + } } export const ZenSessionStore = new nsZenSessionManager(); diff --git a/src/zen/sessionstore/ZenWindowSync.sys.mjs b/src/zen/sessionstore/ZenWindowSync.sys.mjs index 2b95cf2181..a91fa333f9 100644 --- a/src/zen/sessionstore/ZenWindowSync.sys.mjs +++ b/src/zen/sessionstore/ZenWindowSync.sys.mjs @@ -11,8 +11,6 @@ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs", TabStateFlusher: "resource:///modules/sessionstore/TabStateFlusher.sys.mjs", - // eslint-disable-next-line mozilla/valid-lazy - ZenSessionStore: "resource:///modules/zen/ZenSessionManager.sys.mjs", TabStateCache: "resource:///modules/sessionstore/TabStateCache.sys.mjs", setTimeout: "resource://gre/modules/Timer.sys.mjs", PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", @@ -1272,14 +1270,48 @@ class nsZenWindowSync { return; } tab._zenContentsVisible = true; - tab.id = this.#newTabSyncId; + // Only assign a new sync ID if one isn't already set. A pre-existing id + // means the tab came from an external source (e.g. Firefox Sync) and its + // zenSyncId must be preserved so other devices can recognise it. + if (!tab.id) { + tab.id = this.#newTabSyncId; + if (tab.pinned && !isUnsyncedWindow) { + Services.obs.notifyObservers( + null, + "zen-workspace-item-changed", + `t~${tab.id}` + ); + } + } if (lazy.gSyncOnlyPinnedTabs && !tab.pinned) { return; } + // Folder placeholder tabs are created by on_TabGroupCreate in other windows; + // mirroring them here would create a duplicate placeholder inside the folder. + if ( + duringPinning && + tab.hasAttribute("zen-empty-tab") && + tab.group?.isZenFolder + ) { + return; + } if (isUnsyncedWindow || !lazy.gWindowSyncEnabled) { return; } this.#runOnAllWindows(window, win => { + // If a tab with this id already exists in the other window (e.g. it was + // placed there by Firefox Sync before ZenWindowSync ran), sync its + // attributes/position instead of creating a duplicate. + const existingTab = tab.id ? this.getItemFromWindow(win, tab.id) : null; + if (existingTab) { + this.#syncItemWithOriginal( + tab, + existingTab, + win, + SYNC_FLAG_ICON | SYNC_FLAG_LABEL | SYNC_FLAG_MOVE + ); + return; + } const newTab = win.gBrowser.addTrustedTab("about:blank", { animate: true, createLazyBrowser: true, @@ -1303,6 +1335,13 @@ class nsZenWindowSync { // No need to sync icon changes for tabs that aren't active in this window. return; } + if (aEvent.target?.id) { + Services.obs.notifyObservers( + null, + "zen-workspace-item-changed", + `t~${aEvent.target.id}` + ); + } this.#maybeEditAllTabsEntryImage(aEvent.target); return this.#delegateGenericSyncEvent(aEvent, SYNC_FLAG_ICON); } @@ -1312,12 +1351,26 @@ class nsZenWindowSync { // No need to sync label changes for tabs that aren't active in this window. return; } + if (aEvent.target?.id) { + Services.obs.notifyObservers( + null, + "zen-workspace-item-changed", + `t~${aEvent.target.id}` + ); + } return this.#delegateGenericSyncEvent(aEvent, SYNC_FLAG_LABEL); } on_TabHide(aEvent) { const tab = aEvent.target; const window = tab.ownerGlobal; + if (tab.id) { + Services.obs.notifyObservers( + null, + "zen-workspace-item-changed", + `t~${tab.id}` + ); + } if (lazy.gSyncOnlyPinnedTabs && !tab.pinned) { return; } @@ -1332,6 +1385,13 @@ class nsZenWindowSync { on_TabShow(aEvent) { const tab = aEvent.target; const window = tab.ownerGlobal; + if (tab.id) { + Services.obs.notifyObservers( + null, + "zen-workspace-item-changed", + `t~${tab.id}` + ); + } if (lazy.gSyncOnlyPinnedTabs && !tab.pinned) { return; } @@ -1344,12 +1404,27 @@ class nsZenWindowSync { } on_TabMove(aEvent) { + const item = aEvent.target; + if (item.id) { + Services.obs.notifyObservers( + null, + "zen-workspace-item-changed", + `t~${item.id}` + ); + } this.#delegateGenericSyncEvent(aEvent, SYNC_FLAG_MOVE); return Promise.resolve(); } on_TabPinned(aEvent) { const tab = aEvent.target; + if (tab.id) { + Services.obs.notifyObservers( + null, + "zen-workspace-item-changed", + `t~${tab.id}` + ); + } // There are cases where the pinned state is changed but we don't // wan't to override the initial state we stored when the tab was created. // For example, when session restore pins a tab again. @@ -1369,6 +1444,13 @@ class nsZenWindowSync { on_TabUnpinned(aEvent) { const tab = aEvent.target; + if (tab.id) { + Services.obs.notifyObservers( + null, + "zen-workspace-item-changed", + `t~${tab.id}` + ); + } this.#runOnAllWindows(null, win => { const targetTab = this.getItemFromWindow(win, tab.id); if (targetTab) { @@ -1383,16 +1465,39 @@ class nsZenWindowSync { } on_TabAddedToEssentials(aEvent) { + const tab = aEvent.target; + if (tab.id) { + Services.obs.notifyObservers( + null, + "zen-workspace-item-changed", + `t~${tab.id}` + ); + } return this.on_TabMove(aEvent); } on_TabRemovedFromEssentials(aEvent) { + const tab = aEvent.target; + if (tab.id) { + Services.obs.notifyObservers( + null, + "zen-workspace-item-changed", + `t~${tab.id}` + ); + } return this.on_TabMove(aEvent); } on_TabClose(aEvent) { const tab = aEvent.target; const window = tab.ownerGlobal; + if (tab.id) { + Services.obs.notifyObservers( + null, + "zen-workspace-item-changed", + `t~${tab.id}` + ); + } this.#runOnAllWindows(window, win => { const targetTab = this.getItemFromWindow(win, tab.id); if (targetTab) { @@ -1495,6 +1600,18 @@ class nsZenWindowSync { // This tab group was opened as part of a sync operation. return; } + if (tabGroup.isZenFolder && tabGroup.id) { + Services.obs.notifyObservers( + null, + "zen-workspace-item-changed", + `f~${tabGroup.id}` + ); + } + Services.obs.notifyObservers( + null, + "zen-workspace-item-changed", + "meta~global" + ); const window = tabGroup.ownerGlobal; const isFolder = tabGroup.isZenFolder; const isSplitView = tabGroup.hasAttribute("split-view-group"); @@ -1528,6 +1645,18 @@ class nsZenWindowSync { on_TabGroupRemoved(aEvent) { const tabGroup = aEvent.target; + if (tabGroup.isZenFolder && tabGroup.id) { + Services.obs.notifyObservers( + null, + "zen-workspace-item-changed", + `f~${tabGroup.id}` + ); + } + Services.obs.notifyObservers( + null, + "zen-workspace-item-changed", + "meta~global" + ); const window = tabGroup.ownerGlobal; this.#runOnAllWindows(window, win => { const targetGroup = this.getItemFromWindow(win, tabGroup.id); @@ -1542,10 +1671,31 @@ class nsZenWindowSync { } on_TabGroupMoved(aEvent) { + const tabGroup = aEvent.target; + if (tabGroup.isZenFolder && tabGroup.id) { + Services.obs.notifyObservers( + null, + "zen-workspace-item-changed", + `f~${tabGroup.id}` + ); + } + Services.obs.notifyObservers( + null, + "zen-workspace-item-changed", + "meta~global" + ); return this.on_TabMove(aEvent); } on_TabGroupUpdate(aEvent) { + const tabGroup = aEvent.target; + if (tabGroup.isZenFolder && tabGroup.id) { + Services.obs.notifyObservers( + null, + "zen-workspace-item-changed", + `f~${tabGroup.id}` + ); + } return this.#delegateGenericSyncEvent( aEvent, SYNC_FLAG_ICON | SYNC_FLAG_LABEL diff --git a/src/zen/sessionstore/ZenWorkspacesSync.sys.mjs b/src/zen/sessionstore/ZenWorkspacesSync.sys.mjs new file mode 100644 index 0000000000..1102dd8cc6 --- /dev/null +++ b/src/zen/sessionstore/ZenWorkspacesSync.sys.mjs @@ -0,0 +1,486 @@ +/* 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/. */ + +import { + Store, + SyncEngine, + Tracker, +} from "resource://services-sync/engines.sys.mjs"; +import { CryptoWrapper } from "resource://services-sync/record.sys.mjs"; +import { SCORE_INCREMENT_XLARGE } from "resource://services-sync/constants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ZenSessionStore: "resource:///modules/zen/ZenSessionManager.sys.mjs", + ContextualIdentityService: + "resource://gre/modules/ContextualIdentityService.sys.mjs", +}); + +// Runtime-only fields that must never be synced. +const STRIP_TAB_FIELDS = [ + "syncStatus", + "scroll", + "formdata", + "selected", + "_zenIsActiveTab", + "_zenContentsVisible", + "_zenChangeLabelFlag", +]; + +// --------------------------------------------------------------------------- +// Record +// --------------------------------------------------------------------------- + +export class ZenWorkspacesRecord extends CryptoWrapper { + _logName = "Sync.Record.ZenWorkspaces"; +} + +ZenWorkspacesRecord.prototype.type = "workspaces"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function parseRecordId(id) { + const sep = id.indexOf("~"); + if (sep === -1) { + return null; + } + const prefix = id.slice(0, sep); + const key = id.slice(sep + 1); + const typeMap = { + s: "space", + t: "tab", + f: "folder", + c: "container", + meta: "meta", + }; + return { type: typeMap[prefix] || prefix, key }; +} + +/** + * Strips the sync-envelope fields (`id` and `type`) from incoming record data + * and restores the item's real identity key where needed (e.g. folder `id`). + * + * @param data + */ +function stripSyncFields(data) { + const parsed = parseRecordId(data.id); + const { id: _recordId, type: _recordType, ...rest } = data; + // For folders the real `id` is the key portion of the record ID. + if (parsed?.type === "folder") { + rest.id = parsed.key; + } + return rest; +} + +// --------------------------------------------------------------------------- +// Store +// --------------------------------------------------------------------------- + +class ZenWorkspacesStore extends Store { + constructor(name, engine) { + super(name, engine); + } + + async getAllIDs() { + const ids = {}; + const sidebar = lazy.ZenSessionStore.getSidebarData(); + + for (const space of sidebar.spaces || []) { + if (space.uuid) { + ids[`s~${space.uuid}`] = true; + } + } + + for (const tab of sidebar.tabs || []) { + if (tab.zenSyncId && !(tab.zenIsEmpty && !tab.groupId)) { + ids[`t~${tab.zenSyncId}`] = true; + } + } + + for (const folder of sidebar.folders || []) { + if (folder.id) { + ids[`f~${folder.id}`] = true; + } + } + + for (const c of lazy.ContextualIdentityService.getPublicIdentities()) { + ids[`c~${c.userContextId}`] = true; + } + + ids["meta~global"] = true; + return ids; + } + + async itemExists(id) { + const parsed = parseRecordId(id); + if (!parsed) { + return false; + } + const sidebar = lazy.ZenSessionStore.getSidebarData(); + + switch (parsed.type) { + case "space": + return (sidebar.spaces || []).some(s => s.uuid === parsed.key); + case "tab": + return (sidebar.tabs || []).some(t => t.zenSyncId === parsed.key); + case "folder": + return (sidebar.folders || []).some(f => String(f.id) === parsed.key); + case "container": + return lazy.ContextualIdentityService.getPublicIdentities().some( + c => String(c.userContextId) === parsed.key + ); + case "meta": + return true; + default: + return false; + } + } + + async createRecord(id, collection) { + const record = new ZenWorkspacesRecord(collection, id); + const parsed = parseRecordId(id); + if (!parsed) { + record.deleted = true; + return record; + } + + const sidebar = lazy.ZenSessionStore.getSidebarData(); + + switch (parsed.type) { + case "space": { + const spaces = sidebar.spaces || []; + const idx = spaces.findIndex(s => s.uuid === parsed.key); + if (idx === -1) { + record.deleted = true; + return record; + } + const { syncStatus: _s, ...rest } = spaces[idx]; + record.cleartext = { id, type: "space", ...rest, position: idx }; + break; + } + + case "tab": { + const tab = (sidebar.tabs || []).find(t => t.zenSyncId === parsed.key); + if (!tab) { + record.deleted = true; + return record; + } + const cleaned = { ...tab }; + for (const field of STRIP_TAB_FIELDS) { + delete cleaned[field]; + } + // Trim unpinned tab entries to just the active entry + if (!tab.pinned && cleaned.entries?.length) { + const idx = + typeof cleaned.index === "number" + ? Math.max(0, cleaned.index - 1) + : 0; + const entry = cleaned.entries[idx] || cleaned.entries[0]; + cleaned.entries = entry ? [entry] : []; + cleaned.index = 1; + } + record.cleartext = { id, type: "tab", ...cleaned }; + break; + } + + case "folder": { + const folder = (sidebar.folders || []).find( + f => String(f.id) === parsed.key + ); + if (!folder) { + record.deleted = true; + return record; + } + const { syncStatus: _s, ...rest } = folder; + record.cleartext = { ...rest, id, type: "folder" }; + break; + } + + case "container": { + const container = + lazy.ContextualIdentityService.getPublicIdentities().find( + c => String(c.userContextId) === parsed.key + ); + if (!container) { + record.deleted = true; + return record; + } + record.cleartext = { + id, + type: "container", + userContextId: container.userContextId, + name: container.name, + icon: container.icon, + color: container.color, + }; + break; + } + + case "meta": { + record.cleartext = { + id, + type: "meta", + groups: sidebar.groups || [], + splitViewData: sidebar.splitViewData || [], + }; + break; + } + + default: + record.deleted = true; + } + + return record; + } + + async applyIncomingBatch(records, countTelemetry) { + const pulled = { spaces: [], tabs: [], folders: [], containers: [] }; + const removals = { spaces: [], tabs: [], folders: [], containers: [] }; + let meta = null; + + for (const record of records) { + if (record.deleted) { + this._collectRemoval(record.id, removals); + continue; + } + const data = record.cleartext; + if (!data?.type) { + continue; + } + const clean = stripSyncFields(data); + switch (data.type) { + case "space": + pulled.spaces.push(clean); + break; + case "tab": + pulled.tabs.push(clean); + break; + case "folder": + pulled.folders.push(clean); + break; + case "container": + pulled.containers.push(clean); + break; + case "meta": + meta = { + groups: data.groups || [], + splitViewData: data.splitViewData || [], + }; + break; + } + } + + // Suppress change tracking while applying incoming data to prevent + // feedback loops where applied items get re-uploaded immediately. + this.engine._tracker.ignoreAll = true; + try { + await lazy.ZenSessionStore.applyMultiRecordSync(pulled, removals, meta); + } finally { + this.engine._tracker.ignoreAll = false; + } + return []; + } + + _collectRemoval(id, removals) { + const parsed = parseRecordId(id); + if (!parsed) { + return; + } + switch (parsed.type) { + case "space": + removals.spaces.push({ uuid: parsed.key }); + break; + case "tab": + removals.tabs.push({ zenSyncId: parsed.key }); + break; + case "folder": + removals.folders.push({ id: parsed.key }); + break; + case "container": + removals.containers.push({ userContextId: parsed.key }); + break; + } + } + + async create(record) { + await this._applySingle(record); + } + + async update(record) { + await this._applySingle(record); + } + + async _applySingle(record) { + this.engine._tracker.ignoreAll = true; + try { + if (record.deleted) { + const removals = { spaces: [], tabs: [], folders: [], containers: [] }; + this._collectRemoval(record.id, removals); + await lazy.ZenSessionStore.applyMultiRecordSync( + { spaces: [], tabs: [], folders: [], containers: [] }, + removals, + null + ); + return; + } + const data = record.cleartext; + if (!data?.type) { + return; + } + const clean = stripSyncFields(data); + const pulled = { spaces: [], tabs: [], folders: [], containers: [] }; + let meta = null; + switch (data.type) { + case "space": + pulled.spaces.push(clean); + break; + case "tab": + pulled.tabs.push(clean); + break; + case "folder": + pulled.folders.push(clean); + break; + case "container": + pulled.containers.push(clean); + break; + case "meta": + meta = { + groups: data.groups || [], + splitViewData: data.splitViewData || [], + }; + break; + } + await lazy.ZenSessionStore.applyMultiRecordSync( + pulled, + { spaces: [], tabs: [], folders: [], containers: [] }, + meta + ); + } finally { + this.engine._tracker.ignoreAll = false; + } + } + + async remove() { + // No-op: never delete user data on wipe + } + + async wipe() { + // No-op: never delete user data on wipe + } + + changeItemID() { + // No-op + } +} + +// --------------------------------------------------------------------------- +// Tracker +// --------------------------------------------------------------------------- + +class ZenWorkspacesTracker extends Tracker { + _changedIDs = {}; + _ignoreAll = false; + + get ignoreAll() { + return this._ignoreAll; + } + + set ignoreAll(value) { + this._ignoreAll = value; + } + + onStart() { + Services.obs.addObserver(this, "zen-workspace-item-changed"); + Services.obs.addObserver(this, "contextual-identity-created"); + Services.obs.addObserver(this, "contextual-identity-updated"); + Services.obs.addObserver(this, "contextual-identity-deleted"); + } + + onStop() { + Services.obs.removeObserver(this, "zen-workspace-item-changed"); + Services.obs.removeObserver(this, "contextual-identity-created"); + Services.obs.removeObserver(this, "contextual-identity-updated"); + Services.obs.removeObserver(this, "contextual-identity-deleted"); + } + + observe(subject, topic, data) { + if (this._ignoreAll) { + return; + } + if (topic === "zen-workspace-item-changed") { + this._trackChange(data); + } else if (topic.startsWith("contextual-identity-")) { + const id = subject?.wrappedJSObject?.userContextId; + if (id) { + this._trackChange(`c~${id}`); + } + } + } + + _trackChange(id) { + this._changedIDs[id] = Date.now() / 1000; + this.score += SCORE_INCREMENT_XLARGE; + } + + async getChangedIDs() { + return { ...this._changedIDs }; + } + + async addChangedID(id, when) { + this._changedIDs[id] = when; + return true; + } + + async removeChangedID(...ids) { + for (const id of ids) { + delete this._changedIDs[id]; + } + return true; + } + + clearChangedIDs() { + this._changedIDs = {}; + } +} + +// --------------------------------------------------------------------------- +// Engine +// --------------------------------------------------------------------------- + +export class ZenWorkspacesEngine extends SyncEngine { + static get name() { + return "Workspaces"; + } + + constructor(service) { + super("Workspaces", service); + } + + get _storeObj() { + return ZenWorkspacesStore; + } + + get _trackerObj() { + return ZenWorkspacesTracker; + } + + get _recordObj() { + return ZenWorkspacesRecord; + } + + get version() { + return 1; + } + + get syncPriority() { + return 6; + } + + get allowSkippedRecord() { + return false; + } +} diff --git a/src/zen/sessionstore/moz.build b/src/zen/sessionstore/moz.build index 188f4c27ce..3659cb393c 100644 --- a/src/zen/sessionstore/moz.build +++ b/src/zen/sessionstore/moz.build @@ -5,4 +5,5 @@ EXTRA_JS_MODULES.zen += [ "ZenSessionManager.sys.mjs", "ZenWindowSync.sys.mjs", + "ZenWorkspacesSync.sys.mjs", ] diff --git a/src/zen/workspaces/ZenWorkspaces.mjs b/src/zen/workspaces/ZenWorkspaces.mjs index d66024bec1..36cdfb0ec2 100644 --- a/src/zen/workspaces/ZenWorkspaces.mjs +++ b/src/zen/workspaces/ZenWorkspaces.mjs @@ -171,6 +171,341 @@ class nsZenWorkspaces { } } + /** + * Applies live sync changes: updates workspace cache, removes deleted items, + * then creates/updates pulled items. + * Called on ONE window by ZenSessionManager; ZenWindowSync propagates + * new/removed items to every other open window automatically. + * + * @param {{ spaces: Array, tabs: Array, folders: Array }} pulled Reconcile-pulled items. + * @param {{ spaces: Array, tabs: Array, folders: Array, containers: Array }} removals Items to remove. + */ + async _applySyncChanges(pulled, removals = {}) { + if (!this.shouldHaveWorkspaces || this.privateWindowOrDisabled) { + return; + } + await this.promiseInitialized; + + // 1. Update workspace cache (remove deleted, merge pulled) + const removedSpaceIds = new Set((removals.spaces || []).map(s => s.uuid)); + if (removedSpaceIds.size || pulled.spaces?.length) { + const localMap = new Map( + this._workspaceCache + .filter(w => !removedSpaceIds.has(w.uuid)) + .map(w => [w.uuid, w]) + ); + for (const space of pulled.spaces || []) { + const existing = localMap.get(space.uuid); + localMap.set(space.uuid, existing ? { ...existing, ...space } : space); + } + await this.propagateWorkspaces(Array.from(localMap.values())); + this.#propagateWorkspaceData(); + } + + // 2. Remove deleted folders/tabs + await this._removeSyncedItems(removals); + + // 3. Create/update pulled folders and tabs + await this._applyPulledItems(pulled); + } + + /** + * Removes folders and tabs that were previously synced but are absent + * from the latest incoming sync payload. + * + * Workspace removal is handled via propagateWorkspaces() in + * _applySyncChanges. + * + * @param {{ folders: Array, tabs: Array }} removals + */ + async _removeSyncedItems(removals) { + if (!this.shouldHaveWorkspaces || this.privateWindowOrDisabled) { + return; + } + await this.promiseInitialized; + + // Remove folders first; tabs inside them are removed together with the folder. + for (const folderData of removals.folders || []) { + if (!folderData.id) { + continue; + } + const folder = document.getElementById(folderData.id); + if (folder?.isZenFolder) { + await folder.delete(); + } + } + + // Remove tabs not already cleaned up by folder deletion. + for (const tabData of removals.tabs || []) { + if (!tabData.zenSyncId) { + continue; + } + const tab = document.getElementById(tabData.zenSyncId); + if (tab && gBrowser.isTab(tab)) { + gBrowser.removeTab(tab, { animate: false }); + } + } + } + + /** + * Creates or updates folders and tabs that arrived from Firefox Sync. + * + * Called on ONE window by ZenSessionManager; ZenWindowSync propagates every + * new/updated item to all other open windows automatically. + * + * Ordering rules: + * 1. Folders first — tabs need the folder elements to exist so they can + * be placed inside them immediately after being pinned. + * 2. Essential tabs — use addToEssentials() which handles pinning and + * placement in the essentials container. + * 3. Regular pinned tabs — restoreInitialTabData → pinTab → addTabs into + * their folder (if any). The subsequent TabMove event causes + * ZenWindowSync to mirror the folder placement to all other windows. + * 4. Unpinned tabs — created with addTrustedTab and placed in the + * correct workspace/folder. + * + * @param {{ tabs: Array, folders: Array }} pulled Reconcile-pulled items. + */ + async _applyPulledItems(pulled) { + if (!this.shouldHaveWorkspaces || this.privateWindowOrDisabled) { + return; + } + await this.promiseInitialized; + + const incomingFolders = pulled.folders || []; + // Filter out folder placeholder tabs — they should never be synced. + const incomingTabs = (pulled.tabs || []).filter(t => !t.zenIsEmpty); + + if (!incomingFolders.length && !incomingTabs.length) { + return; + } + + // Step 1 — create or update folders. + for (const folderData of incomingFolders) { + if (!folderData.id) { + continue; + } + const existing = document.getElementById(folderData.id); + if (existing?.isZenFolder) { + // Update existing folder + if (folderData.name && existing.label !== folderData.name) { + existing.label = folderData.name; + } + if (folderData.collapsed !== undefined) { + existing.collapsed = folderData.collapsed; + } + if (folderData.workspaceId) { + existing.setAttribute("zen-workspace-id", folderData.workspaceId); + } + if (folderData.userIcon !== undefined) { + gZenFolders.setFolderUserIcon(existing, folderData.userIcon); + } + existing.dispatchEvent( + new CustomEvent("TabGroupUpdate", { bubbles: true }) + ); + } else { + // Create new folder — skip the placeholder empty tab since real tabs + // will be added in step 2 from the pulled tabs. + gZenFolders.createFolder([], { + id: folderData.id, + label: folderData.name || "Folder", + workspaceId: folderData.workspaceId, + collapsed: folderData.collapsed, + skipEmptyTab: true, + }); + } + } + + // Step 2 — create or update tabs (pinned AND unpinned). + for (const tabData of incomingTabs) { + if (!tabData.zenSyncId) { + continue; + } + const existingTab = document.getElementById(tabData.zenSyncId); + if (existingTab && gBrowser.isTab(existingTab)) { + if ( + tabData.zenWorkspace && + existingTab.getAttribute("zen-workspace-id") !== tabData.zenWorkspace + ) { + this.moveTabToWorkspace(existingTab, tabData.zenWorkspace); + } + + // Essentials state changes. + const isCurrentlyEssential = existingTab.hasAttribute("zen-essential"); + const shouldBeEssential = !!tabData.zenEssential; + if (shouldBeEssential && !isCurrentlyEssential) { + gZenPinnedTabManager.addToEssentials(existingTab); + } else if (!shouldBeEssential && isCurrentlyEssential) { + gZenPinnedTabManager.removeEssentials(existingTab, /* unpin */ false); + } + + // Pinned state changes (after essentials, since essentials implies pinned). + if ( + tabData.pinned !== undefined && + existingTab.pinned !== tabData.pinned + ) { + if (tabData.pinned) { + gBrowser.pinTab(existingTab); + } else { + gBrowser.unpinTab(existingTab); + } + } + + // Group/folder membership. + const currentGroupId = existingTab.group?.id || null; + const targetGroupId = tabData.groupId || null; + if (currentGroupId !== targetGroupId) { + if (targetGroupId) { + const folder = document.getElementById(targetGroupId); + if (folder?.isZenFolder) { + folder.addTabs([existingTab]); + } + } else if (currentGroupId) { + gBrowser.ungroupTab(existingTab); + } + } + + // Visual updates. + if ( + tabData.image && + existingTab.getAttribute("image") !== tabData.image + ) { + gBrowser.setIcon(existingTab, tabData.image); + } + if (typeof tabData.zenStaticLabel === "string") { + existingTab.zenStaticLabel = tabData.zenStaticLabel; + } + + continue; + } + + if (tabData.pinned) { + // --- PINNED TAB CREATION --- + + // Build _zenPinnedInitialState from the session entries if the sync + // payload doesn't already include it. This is needed so that: + // 1. ZenWindowSync skips setPinnedTabState (which would clobber with + // an empty about:blank entry) because tab._zenPinnedInitialState is set. + // 2. We can derive the correct visual label and favicon below. + // 3. resetPinnedTab can restore the tab's URL / history. + // Session index is 1-based; convert to 0-based. + let pinnedInitialState = tabData._zenPinnedInitialState; + if (!pinnedInitialState && tabData.entries?.length) { + const entryIndex = + typeof tabData.index === "number" + ? Math.max(0, tabData.index - 1) + : 0; + const entry = tabData.entries[entryIndex] ?? tabData.entries[0]; + pinnedInitialState = { entry, image: tabData.image || "" }; + } + + // Create the tab unpinned so ZenWindowSync does NOT mirror it yet + // (with gSyncOnlyPinnedTabs=true it skips unpinned tabs in on_TabOpen). + const newTab = gBrowser.addTrustedTab("about:blank", { + createLazyBrowser: true, + }); + + // Set the zenSyncId as the DOM id BEFORE pinning. The guard we added + // to ZenWindowSync.on_TabOpen (!tab.id) will preserve this id through + // the duringPinning code-path so ZenWindowSync propagates the tab to + // other windows with the correct id. + newTab.id = tabData.zenSyncId; + + if (tabData.zenEssential) { + // Set attributes manually but skip zen-essential — addToEssentials() + // must set it; if it is already present the method skips the tab. + if (tabData.zenWorkspace) { + newTab.setAttribute("zen-workspace-id", tabData.zenWorkspace); + } + if (typeof tabData.zenStaticLabel === "string") { + newTab.zenStaticLabel = tabData.zenStaticLabel; + } + if (tabData.zenHasStaticIcon && tabData.image) { + newTab.zenStaticIcon = tabData.image; + } + if (pinnedInitialState) { + newTab._zenPinnedInitialState = pinnedInitialState; + } + // Set visual label and favicon BEFORE pinning so that when + // ZenWindowSync's on_TabOpen(duringPinning:true) mirrors this tab + // it copies the correct label/icon to other windows. + const label = + newTab.zenStaticLabel || pinnedInitialState?.entry?.title || ""; + if (label) { + gBrowser._setTabLabel(newTab, label); + } + const image = tabData.image || pinnedInitialState?.image || ""; + if (image) { + gBrowser.setIcon(newTab, image); + } + // addToEssentials pins the tab and moves it to the essentials section. + // ZenWindowSync mirrors the pinned essential to other windows. + gZenPinnedTabManager.addToEssentials(newTab); + // Restore the tab's session state (URL / history) from pinnedInitialState. + gZenPinnedTabManager.resetPinnedTab(newTab); + } else { + // restoreInitialTabData sets workspace-id, static label/icon, and + // _zenPinnedInitialState (preventing ZenWindowSync from overwriting + // the pinned initial state with an empty about:blank state). + gZenSessionStore.restoreInitialTabData(newTab, tabData); + // Use the built pinnedInitialState if restoreInitialTabData didn't set one. + if (!newTab._zenPinnedInitialState && pinnedInitialState) { + newTab._zenPinnedInitialState = pinnedInitialState; + } + // Set visual label and favicon BEFORE pinning so that when + // ZenWindowSync's on_TabOpen(duringPinning:true) mirrors this tab + // it copies the correct label/icon to other windows. + const label = + newTab.zenStaticLabel || pinnedInitialState?.entry?.title || ""; + if (label) { + gBrowser._setTabLabel(newTab, label); + } + const image = tabData.image || pinnedInitialState?.image || ""; + if (image) { + gBrowser.setIcon(newTab, image); + } + // pinTab triggers ZenWindowSync to mirror the tab (with correct id) + // to every other open window. + gBrowser.pinTab(newTab); + // Restore the tab's session state (URL / history) from pinnedInitialState. + gZenPinnedTabManager.resetPinnedTab(newTab); + // If this tab belongs to a folder, move it in now. The subsequent + // TabMove event causes ZenWindowSync to mirror the folder placement + // into the corresponding folder in every other window. + if (tabData.groupId) { + const folder = document.getElementById(tabData.groupId); + if (folder?.isZenFolder) { + folder.addTabs([newTab]); + } + } + } + } else { + // --- UNPINNED TAB CREATION --- + const activeEntry = tabData.entries?.[0] || {}; + const url = activeEntry.url || "about:blank"; + const newTab = gBrowser.addTrustedTab(url, { createLazyBrowser: true }); + newTab.id = tabData.zenSyncId; + if (tabData.zenWorkspace) { + newTab.setAttribute("zen-workspace-id", tabData.zenWorkspace); + } + const label = activeEntry.title || url; + if (label) { + gBrowser._setTabLabel(newTab, label); + } + if (tabData.image) { + gBrowser.setIcon(newTab, tabData.image); + } + // Place in folder if applicable + if (tabData.groupId) { + const folder = document.getElementById(tabData.groupId); + if (folder?.isZenFolder) { + folder.addTabs([newTab]); + } + } + } + } + } + log(...args) { if (this.#canDebug) { /* eslint-disable no-console */ @@ -1426,13 +1761,28 @@ class nsZenWorkspaces { ); if (index !== -1) { workspacesData[index] = workspaceData; + Services.obs.notifyObservers( + null, + "zen-workspace-item-changed", + `s~${workspaceData.uuid}` + ); } else { workspacesData.push(workspaceData); + Services.obs.notifyObservers( + null, + "zen-workspace-item-changed", + `s~${workspaceData.uuid}` + ); } this.#propagateWorkspaceData(); } removeWorkspace(windowID) { + Services.obs.notifyObservers( + null, + "zen-workspace-item-changed", + `s~${windowID}` + ); let workspacesData = this.getWorkspaces(); // Remove the workspace from the cache workspacesData = workspacesData.filter( @@ -1578,6 +1928,14 @@ class nsZenWorkspaces { workspaces.splice(newPosition, 0, workspace); // Propagate the changes if the order has changed if (currentIndex !== newPosition) { + // Mark all workspaces as modified so the new order is pushed to sync. + for (const ws of workspaces) { + Services.obs.notifyObservers( + null, + "zen-workspace-item-changed", + `s~${ws.uuid}` + ); + } this.#propagateWorkspaceData(); } }