From 8df45e5c9ad49eabfe010d7cf365ac21b1ea48ea Mon Sep 17 00:00:00 2001 From: "mr. m" <91018726+mr-cheffy@users.noreply.github.com> Date: Sun, 6 Sep 2026 02:42:59 +0200 Subject: [PATCH] gh-15247: Add support for cross-device normal tabs (gh-15250) --- .../browser/preferences/zen-preferences.ftl | 4 + prefs/zen/sync.yaml | 6 + .../preferences/config/account-sync-mjs.patch | 24 +++- .../common/styles/squircle-exclusions.inc.css | 31 +++++ src/zen/common/styles/zen-theme.css | 2 + src/zen/compact-mode/sidebar.inc.css | 2 +- src/zen/compact-mode/zen-compact-mode.css | 2 +- src/zen/sync/ZenSpacesSync.sys.mjs | 7 +- src/zen/sync/ZenSpacesSyncApplier.sys.mjs | 34 ++++- src/zen/sync/ZenSpacesSyncModel.sys.mjs | 51 ++++++- src/zen/tests/moz.build | 1 + src/zen/tests/spaces_sync/browser.toml | 17 +++ .../spaces_sync/browser_normal_tabs_apply.js | 129 ++++++++++++++++++ .../browser_normal_tabs_projection.js | 81 +++++++++++ src/zen/tests/spaces_sync/head.js | 74 ++++++++++ 15 files changed, 449 insertions(+), 16 deletions(-) create mode 100644 src/zen/common/styles/squircle-exclusions.inc.css create mode 100644 src/zen/tests/spaces_sync/browser.toml create mode 100644 src/zen/tests/spaces_sync/browser_normal_tabs_apply.js create mode 100644 src/zen/tests/spaces_sync/browser_normal_tabs_projection.js create mode 100644 src/zen/tests/spaces_sync/head.js diff --git a/locales/en-US/browser/browser/preferences/zen-preferences.ftl b/locales/en-US/browser/browser/preferences/zen-preferences.ftl index bb31d6c47..f660fd6bc 100644 --- a/locales/en-US/browser/browser/preferences/zen-preferences.ftl +++ b/locales/en-US/browser/browser/preferences/zen-preferences.ftl @@ -68,6 +68,10 @@ zen-settings-workspaces-sync = .label = Sync your sidebar across devices .description = Keep your workspaces, pinned tabs and folders in sync on all your devices through your Mozilla account. +zen-settings-normal-tabs-sync = + .label = Include unpinned tabs + .description = Also sync the regular tabs of each space, not just your pinned tabs and folders. + zen-settings-workspaces-sync-unpinned-tabs = .label = Sync only pinned tabs in workspaces diff --git a/prefs/zen/sync.yaml b/prefs/zen/sync.yaml index 8424b23f8..c2919829b 100644 --- a/prefs/zen/sync.yaml +++ b/prefs/zen/sync.yaml @@ -8,3 +8,9 @@ - name: zen.spaces-sync.debug value: false + +- name: zen.spaces-sync.normal-tabs + value: false + +- name: services.sync.prefs.sync.zen.spaces-sync.normal-tabs + value: true diff --git a/src/browser/components/preferences/config/account-sync-mjs.patch b/src/browser/components/preferences/config/account-sync-mjs.patch index 893b2a58b..6d8a9344f 100644 --- a/src/browser/components/preferences/config/account-sync-mjs.patch +++ b/src/browser/components/preferences/config/account-sync-mjs.patch @@ -1,16 +1,17 @@ diff --git a/browser/components/preferences/config/account-sync.mjs b/browser/components/preferences/config/account-sync.mjs -index b01fd4bb163ad53139fcbd8e79e12ef994d1fa13..dd60be4fe268618d6596cd5cfac3342975bd241a 100644 +index b01fd4bb163ad53139fcbd8e79e12ef994d1fa13..7e3c2f6852b77192b4505bb3d5a7b58586da6548 100644 --- a/browser/components/preferences/config/account-sync.mjs +++ b/browser/components/preferences/config/account-sync.mjs -@@ -46,6 +46,7 @@ Preferences.addAll([ +@@ -46,6 +46,8 @@ 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.spaces", type: "bool" }, ++ { id: "zen.spaces-sync.normal-tabs", type: "bool" }, ]); /** -@@ -557,6 +558,7 @@ const SYNC_ENGINE_SETTINGS = [ +@@ -557,6 +559,7 @@ const SYNC_ENGINE_SETTINGS = [ type: "payments", }, { id: "syncAddons", pref: "services.sync.engine.addons", type: "addons" }, @@ -18,7 +19,7 @@ index b01fd4bb163ad53139fcbd8e79e12ef994d1fa13..dd60be4fe268618d6596cd5cfac33429 { id: "syncSettings", pref: "services.sync.engine.prefs", type: "settings" }, ]; -@@ -564,6 +566,12 @@ SYNC_ENGINE_SETTINGS.forEach(({ id, pref }) => { +@@ -564,6 +567,18 @@ SYNC_ENGINE_SETTINGS.forEach(({ id, pref }) => { Preferences.addSetting({ id, pref }); }); @@ -27,11 +28,17 @@ index b01fd4bb163ad53139fcbd8e79e12ef994d1fa13..dd60be4fe268618d6596cd5cfac33429 + id: "zenSyncWorkspaces", + pref: "services.sync.engine.spaces", +}); ++Preferences.addSetting({ ++ id: "zenSyncNormalTabs", ++ pref: "zen.spaces-sync.normal-tabs", ++ deps: ["zenSyncWorkspaces"], ++ visible: deps => !!deps.zenSyncWorkspaces.value, ++}); + Preferences.addSetting({ id: "syncEngines", deps: SYNC_ENGINE_SETTINGS.map(({ id }) => id), -@@ -995,6 +1003,20 @@ SettingGroupManager.registerGroups({ +@@ -995,6 +1010,27 @@ SettingGroupManager.registerGroups({ id: "syncEnginesList", control: "sync-engines-list", }, @@ -46,6 +53,13 @@ index b01fd4bb163ad53139fcbd8e79e12ef994d1fa13..dd60be4fe268618d6596cd5cfac33429 + controlAttrs: { + inputlayout: "inline-end", + }, ++ items: [ ++ { ++ id: "zenSyncNormalTabs", ++ l10nId: "zen-settings-normal-tabs-sync", ++ control: "moz-checkbox", ++ }, ++ ], + }, + ], + }, diff --git a/src/zen/common/styles/squircle-exclusions.inc.css b/src/zen/common/styles/squircle-exclusions.inc.css new file mode 100644 index 000000000..f26710dc2 --- /dev/null +++ b/src/zen/common/styles/squircle-exclusions.inc.css @@ -0,0 +1,31 @@ +/* + * 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/. + */ + +/* Upstream elements that take their radius from --border-radius-circle (9999px). + They are meant to read as circles or pills, so a superellipse only makes them + look lumpy. */ +.tab-icon-overlay:not([crashed]):is([soundplaying], [muted], [activemedia-blocked]), +.tab-note-icon-overlay, +.tab-group-overflow-count, +.tab-group-editor-swatch, +.tab-preview-container-indicator, +#tabbrowser-tabs[orient='vertical']:not([expanded]) .tab-close-button, +.all-tabs-item > toolbarbutton.all-tabs-container-indicator::before, +.urlbarView-button, +.downloadButton, +#downloads-indicator-progress-inner, +#smartwindow-ask-button-inner, +#PanelUI-fxa-menu-manage-account-button, +.fxa-menu-manage-account-avatar, +[fxastatus='signedin'] #fxa-avatar-image, +#profile-icon-image, +.appMenu-update-banner-pulse-ring, +:is(#appMenu-zoomReduce-button2, #appMenu-zoomEnlarge-button2) > .toolbarbutton-icon, +.color-swatches .userContext-icon, +.reusable-relay-masks > button, +.radio-check { + corner-shape: round; +} diff --git a/src/zen/common/styles/zen-theme.css b/src/zen/common/styles/zen-theme.css index 77b932caa..489790e81 100644 --- a/src/zen/common/styles/zen-theme.css +++ b/src/zen/common/styles/zen-theme.css @@ -361,4 +361,6 @@ .zen-squircle-after::after { corner-shape: superellipse(var(--zen-squircle-value)); } + +%include squircle-exclusions.inc.css } diff --git a/src/zen/compact-mode/sidebar.inc.css b/src/zen/compact-mode/sidebar.inc.css index e25557d22..21fdcbee6 100644 --- a/src/zen/compact-mode/sidebar.inc.css +++ b/src/zen/compact-mode/sidebar.inc.css @@ -52,7 +52,7 @@ --zen-compact-float: var(--zen-element-separation); @media (-moz-mac-tahoe-theme) { - --zen-compact-mode-no-padding-radius-fix: -2px; + --zen-compact-mode-no-padding-radius-fix: -4px; } :root[zen-no-padding='true'] & { diff --git a/src/zen/compact-mode/zen-compact-mode.css b/src/zen/compact-mode/zen-compact-mode.css index 4724558e7..b5dc6c59c 100644 --- a/src/zen/compact-mode/zen-compact-mode.css +++ b/src/zen/compact-mode/zen-compact-mode.css @@ -29,7 +29,7 @@ &, &::before, &::after { - border-radius: calc((var(--zen-native-inner-radius) - var(--zen-compact-mode-no-padding-radius-fix, 0px)) * var(--zen-squircle-value)); + border-radius: calc((var(--zen-native-inner-radius) - var(--zen-compact-mode-no-padding-radius-fix, 0px))); } } diff --git a/src/zen/sync/ZenSpacesSync.sys.mjs b/src/zen/sync/ZenSpacesSync.sys.mjs index 68752270b..548365094 100644 --- a/src/zen/sync/ZenSpacesSync.sys.mjs +++ b/src/zen/sync/ZenSpacesSync.sys.mjs @@ -75,10 +75,9 @@ class ZenSpacesSyncStore extends Store { } /** - * Tracker for the Spaces engine. There is no per-event bookkeeping: every - * time fresh sidebar data is collected (or a container changes), the model - * diffs the current projections against the last-uploaded snapshot; the - * score is only bumped when something actually differs. + * Tracker for the Spaces engine. The model diffs the current projections + * against the last-uploaded snapshot. The score is only bumped when + * something actually differs. */ class ZenSpacesSyncTracker extends Tracker { _ignoreAll = false; diff --git a/src/zen/sync/ZenSpacesSyncApplier.sys.mjs b/src/zen/sync/ZenSpacesSyncApplier.sys.mjs index be661c02b..51974d383 100644 --- a/src/zen/sync/ZenSpacesSyncApplier.sys.mjs +++ b/src/zen/sync/ZenSpacesSyncApplier.sys.mjs @@ -11,8 +11,17 @@ import { ZenSpacesSyncModel, } from "resource:///modules/zen/ZenSpacesSyncModel.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + const lazy = {}; +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "syncNormalTabs", + "zen.spaces-sync.normal-tabs", + false +); + ChromeUtils.defineESModuleGetters(lazy, { SessionSaver: "resource:///modules/sessionstore/SessionSaver.sys.mjs", E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs", @@ -84,6 +93,11 @@ class nsZenSpacesSyncApplier { if (!data) { continue; } + if (data.pinned === false && !lazy.syncNormalTabs) { + // Normal-tab syncing is off here: leave the record untouched and + // unacknowledged, like an unknown kind. + continue; + } const entry = { key: record.id, data, record }; switch (record.cleartext.kind) { case RECORD_KINDS.CONTAINER: @@ -567,7 +581,9 @@ class nsZenSpacesSyncApplier { if (data.workspaceUuid) { tab.setAttribute("zen-workspace-id", data.workspaceUuid); } - win.gBrowser.pinTab(tab); + if (data.pinned !== false) { + win.gBrowser.pinTab(tab); + } if (data.workspaceUuid) { win.gZenWorkspaces.moveTabToWorkspace(tab, data.workspaceUuid); } @@ -593,7 +609,10 @@ class nsZenSpacesSyncApplier { const identityChanged = initial?.entry?.url !== data.url || (initial?.entry?.title || "") !== (data.title || ""); - if (identityChanged || syncableIconUrl(initial?.image || "") !== icon) { + if ( + data.pinned !== false && + (identityChanged || syncableIconUrl(initial?.image || "") !== icon) + ) { lazy.ZenWindowSync.setPinnedInitialState( tab, { url: data.url, title: data.title || "" }, @@ -745,6 +764,17 @@ class nsZenSpacesSyncApplier { // The split record governs placement of its members. return; } + const wantPinned = data.pinned !== false; + if (wantPinned !== tab.pinned) { + if (wantPinned) { + win.gBrowser.pinTab(tab); + } else { + win.gBrowser.unpinTab(tab); + // Pin identity would otherwise freeze the projection of what is now + // a normal tab. + delete tab._zenPinnedInitialState; + } + } this.#applyFolderMembership(win, tab, data.folderId); if ( !data.folderId && diff --git a/src/zen/sync/ZenSpacesSyncModel.sys.mjs b/src/zen/sync/ZenSpacesSyncModel.sys.mjs index 2f100a3f8..af96587c5 100644 --- a/src/zen/sync/ZenSpacesSyncModel.sys.mjs +++ b/src/zen/sync/ZenSpacesSyncModel.sys.mjs @@ -22,6 +22,13 @@ XPCOMUtils.defineLazyPreferenceGetter( false ); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "syncNormalTabs", + "zen.spaces-sync.normal-tabs", + false +); + /** * Debug logging for the whole Spaces sync pipeline. * @@ -258,19 +265,31 @@ class nsZenSpacesSyncModel { /* Mark: projections */ - #isSyncableTab(tabData) { + #isTabRecordMaterial(tabData) { return !!( tabData && tabData.zenSyncId && - (tabData.pinned || tabData.zenEssential) && !tabData.zenIsEmpty && !tabData.zenIsGlance && !tabData.zenLiveFolderItemId ); } + #isSyncableTab(tabData) { + return ( + this.#isTabRecordMaterial(tabData) && + (!!tabData.pinned || !!tabData.zenEssential || lazy.syncNormalTabs) + ); + } + #tabIdentity(tabData) { - const initial = tabData._zenPinnedInitialState; + // Pin identity freezes url/title at pin time. A normal tab's identity + // follows its live entry (a leftover initial state from a past pin must + // not shadow it). + const initial = + tabData.pinned || tabData.zenEssential + ? tabData._zenPinnedInitialState + : null; let url = initial?.entry?.url; let title = initial?.entry?.title; if (!url || url === "about:blank") { @@ -473,6 +492,7 @@ class nsZenSpacesSyncModel { create: true, }), essential, + pinned: !!(tab.pinned || tab.zenEssential), workspaceUuid: essential ? null : tab.zenWorkspace || null, folderId: ctx.folderOf(tab.groupId || null), staticLabel: @@ -496,6 +516,28 @@ class nsZenSpacesSyncModel { const ctx = this.#projectionContext(sidebar); const { tabs, folders, splits, splitParents, splitWs } = ctx; + if (!lazy.syncNormalTabs) { + // Items excluded only by the normal-tabs option are held back, not + // deleted. Flipping the option off must not tombstone them remotely. + const held = new Set(); + for (const tab of ctx.allTabs) { + if (this.#isTabRecordMaterial(tab) && !this.#isSyncableTab(tab)) { + held.add(tab.zenSyncId); + pending.add(tab.zenSyncId); + } + } + for (const split of sidebar.splitViewData || []) { + if ( + split?.groupId && + !ctx.splitIds.has(split.groupId) && + Array.isArray(split.tabs) && + split.tabs.some(id => held.has(id)) + ) { + pending.add(split.groupId); + } + } + } + for (const identity of lazy.ContextualIdentityService.getPublicIdentities()) { if (!identity.name) { continue; @@ -568,12 +610,15 @@ class nsZenSpacesSyncModel { this.#projectTabs(map, ctx); + const tabById = new Map(tabs.map(t => [t.zenSyncId, t])); for (const split of splits) { + const member = tabById.get(split.tabs[0]); map.set(split.groupId, { kind: RECORD_KINDS.SPLIT, data: { splitId: split.groupId, gridType: split.gridType || "grid", + pinned: !!(member?.pinned || member?.zenEssential), tabs: [...split.tabs], workspaceUuid: splitWs.get(split.groupId) ?? null, folderId: splitParents.get(split.groupId) || null, diff --git a/src/zen/tests/moz.build b/src/zen/tests/moz.build index 3e3969fdf..2746f9d1b 100644 --- a/src/zen/tests/moz.build +++ b/src/zen/tests/moz.build @@ -15,6 +15,7 @@ BROWSER_CHROME_MANIFESTS += [ "site_control/browser.toml", "space_routing/browser.toml", "spaces/browser.toml", + "spaces_sync/browser.toml", "split_view/browser.toml", "tabs/browser.toml", "ub-actions/browser.toml", diff --git a/src/zen/tests/spaces_sync/browser.toml b/src/zen/tests/spaces_sync/browser.toml new file mode 100644 index 000000000..5209e17ad --- /dev/null +++ b/src/zen/tests/spaces_sync/browser.toml @@ -0,0 +1,17 @@ +# 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/. + +[DEFAULT] +prefs = [ + "zen.window-sync.enabled=true", + "zen.urlbar.replace-newtab=false", + "zen.spaces-sync.first-sync-animation-shown=true", +] +support-files = [ + "head.js", +] + +["browser_normal_tabs_apply.js"] + +["browser_normal_tabs_projection.js"] diff --git a/src/zen/tests/spaces_sync/browser_normal_tabs_apply.js b/src/zen/tests/spaces_sync/browser_normal_tabs_apply.js new file mode 100644 index 000000000..ee11be08a --- /dev/null +++ b/src/zen/tests/spaces_sync/browser_normal_tabs_apply.js @@ -0,0 +1,129 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_ApplySkippedWhileOptionOff() { + await gZenWorkspaces.promiseInitialized; + const id = "test-spaces-sync-normal-off"; + const failed = await ZenSpacesSyncApplier.applyBatch([ + tabRecord(id, { pinned: false }), + ]); + Assert.deepEqual( + failed, + [], + "A skipped normal-tab record should not be reported as failed" + ); + Assert.ok( + !document.getElementById(id), + "No tab should materialize while the option is off" + ); +}); + +add_task(async function test_ApplyCreatesUnpinnedTab() { + await SpecialPowers.pushPrefEnv({ set: [[NORMAL_TABS_PREF, true]] }); + const id = "test-spaces-sync-normal-on"; + const failed = await ZenSpacesSyncApplier.applyBatch([ + tabRecord(id, { pinned: false }), + ]); + Assert.deepEqual(failed, [], "The record should apply cleanly"); + + const tab = document.getElementById(id); + Assert.ok(gBrowser.isTab(tab), "The tab should materialize"); + Assert.ok(!tab.pinned, "The tab should not be pinned"); + Assert.equal( + tab.getAttribute("zen-workspace-id"), + gZenWorkspaces.activeWorkspace, + "The tab should land in the record's workspace" + ); + Assert.ok( + !tab._zenPinnedInitialState, + "A normal tab should carry no pin identity" + ); + + ZenSpacesSyncModel.noteApplied(id, null); + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_UpdateRetargetsUnloadedNormalTab() { + await SpecialPowers.pushPrefEnv({ set: [[NORMAL_TABS_PREF, true]] }); + const id = "test-spaces-sync-retarget"; + let failed = await ZenSpacesSyncApplier.applyBatch([ + tabRecord(id, { pinned: false, url: "https://example.com/first" }), + ]); + Assert.deepEqual(failed, [], "The record should apply cleanly"); + const tab = document.getElementById(id); + Assert.ok(!tab.linkedPanel, "The synced tab should stay unloaded"); + + failed = await ZenSpacesSyncApplier.applyBatch([ + tabRecord(id, { pinned: false, url: "https://example.com/second" }), + ]); + Assert.deepEqual(failed, [], "The navigation record should apply cleanly"); + Assert.ok(!tab.linkedPanel, "The tab should stay unloaded after retarget"); + const state = JSON.parse(SessionStore.getTabState(tab)); + Assert.equal( + state.entries.at(-1)?.url, + "https://example.com/second", + "The unloaded tab's state should point at the new url" + ); + + ZenSpacesSyncModel.noteApplied(id, null); + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_UpdateRetargetsDiscardedNormalTab() { + await SpecialPowers.pushPrefEnv({ set: [[NORMAL_TABS_PREF, true]] }); + const tab = await openSyncableTab("https://example.com/?loadedfirst"); + const id = tab.id; + await BrowserTestUtils.switchTab(gBrowser, gBrowser.tabs[0]); + gBrowser.discardBrowser(tab); + Assert.ok(!tab.linkedPanel, "The tab should be discarded"); + + const failed = await ZenSpacesSyncApplier.applyBatch([ + tabRecord(id, { pinned: false, url: "https://example.com/?remotenav" }), + ]); + Assert.deepEqual(failed, [], "The navigation record should apply cleanly"); + Assert.ok(!tab.linkedPanel, "The tab should stay unloaded after retarget"); + const state = JSON.parse(SessionStore.getTabState(tab)); + Assert.equal( + state.entries.at(-1)?.url, + "https://example.com/?remotenav", + "The discarded tab's state should point at the new url" + ); + + ZenSpacesSyncModel.noteApplied(id, null); + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_ApplyPinStateTransition() { + await SpecialPowers.pushPrefEnv({ set: [[NORMAL_TABS_PREF, true]] }); + const id = "test-spaces-sync-transition"; + let failed = await ZenSpacesSyncApplier.applyBatch([ + tabRecord(id, { pinned: true }), + ]); + Assert.deepEqual(failed, [], "The pinned record should apply cleanly"); + const tab = document.getElementById(id); + Assert.ok(gBrowser.isTab(tab), "The tab should materialize"); + Assert.ok(tab.pinned, "A pinned record should materialize pinned"); + await TestUtils.waitForCondition( + () => tab._zenPinnedInitialState, + "waiting for the pin identity to settle" + ); + + failed = await ZenSpacesSyncApplier.applyBatch([ + tabRecord(id, { pinned: false }), + ]); + Assert.deepEqual(failed, [], "The demoting record should apply cleanly"); + Assert.ok(!tab.pinned, "A record demotion should unpin the tab"); + Assert.ok( + !tab._zenPinnedInitialState, + "Demotion should clear the pin identity" + ); + + ZenSpacesSyncModel.noteApplied(id, null); + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); +}); diff --git a/src/zen/tests/spaces_sync/browser_normal_tabs_projection.js b/src/zen/tests/spaces_sync/browser_normal_tabs_projection.js new file mode 100644 index 000000000..78e0f7ca6 --- /dev/null +++ b/src/zen/tests/spaces_sync/browser_normal_tabs_projection.js @@ -0,0 +1,81 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_ProjectionGatedByPref() { + await gZenWorkspaces.promiseInitialized; + const pinnedTab = await openSyncableTab("https://example.com/?pinned", { + pinned: true, + }); + const normalTab = await openSyncableTab("https://example.com/?normal"); + + let projections = await collectProjections(); + const pinnedRecord = projections.get(pinnedTab.id); + Assert.ok(pinnedRecord, "Pinned tab should project with the option off"); + Assert.equal( + pinnedRecord.data.pinned, + true, + "Pinned tab record should carry pinned: true" + ); + Assert.ok( + !projections.get(normalTab.id), + "Normal tab should not project with the option off" + ); + + await SpecialPowers.pushPrefEnv({ set: [[NORMAL_TABS_PREF, true]] }); + ZenSpacesSyncModel.invalidate(); + projections = ZenSpacesSyncModel.projections(); + + const record = projections.get(normalTab.id); + Assert.ok(record, "Normal tab should project with the option on"); + Assert.equal( + record.data.pinned, + false, + "Normal tab record should carry pinned: false" + ); + Assert.equal( + record.data.workspaceUuid, + gZenWorkspaces.activeWorkspace, + "Normal tab record should name its workspace" + ); + const space = projections.get(gZenWorkspaces.activeWorkspace); + Assert.ok( + space?.data?.children?.includes(normalTab.id), + "The space's child order should include the normal tab" + ); + + await SpecialPowers.popPrefEnv(); + BrowserTestUtils.removeTab(normalTab); + BrowserTestUtils.removeTab(pinnedTab); +}); + +add_task(async function test_DisablingOptionHoldsBackInsteadOfTombstoning() { + await gZenWorkspaces.promiseInitialized; + await SpecialPowers.pushPrefEnv({ set: [[NORMAL_TABS_PREF, true]] }); + const normalTab = await openSyncableTab("https://example.com/?heldback"); + + await collectProjections(); + ZenSpacesSyncModel.markUploaded([normalTab.id]); + let changes = ZenSpacesSyncModel.computeChangedIDs(); + Assert.ok( + !(normalTab.id in changes), + "An uploaded normal tab should report no pending change" + ); + + await SpecialPowers.popPrefEnv(); + ZenSpacesSyncModel.invalidate(); + Assert.ok( + !ZenSpacesSyncModel.projections().get(normalTab.id), + "Normal tab should stop projecting once the option is off" + ); + changes = ZenSpacesSyncModel.computeChangedIDs(); + Assert.ok( + !(normalTab.id in changes), + "Disabling the option should not tombstone the tab" + ); + + // Drop the snapshot entry so this task leaves no sync state behind. + ZenSpacesSyncModel.noteApplied(normalTab.id, null); + BrowserTestUtils.removeTab(normalTab); +}); diff --git a/src/zen/tests/spaces_sync/head.js b/src/zen/tests/spaces_sync/head.js new file mode 100644 index 000000000..e04d44b63 --- /dev/null +++ b/src/zen/tests/spaces_sync/head.js @@ -0,0 +1,74 @@ +/* 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/. */ + +"use strict"; + +const { ZenSpacesSyncModel } = ChromeUtils.importESModule( + "resource:///modules/zen/ZenSpacesSyncModel.sys.mjs" +); +const { ZenSpacesSyncApplier } = ChromeUtils.importESModule( + "resource:///modules/zen/ZenSpacesSyncApplier.sys.mjs" +); +const { SessionSaver } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionSaver.sys.mjs" +); +const { TabStateFlusher } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/TabStateFlusher.sys.mjs" +); +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); +const { E10SUtils } = ChromeUtils.importESModule( + "resource://gre/modules/E10SUtils.sys.mjs" +); + +const NORMAL_TABS_PREF = "zen.spaces-sync.normal-tabs"; + +/** + * Runs a full session save (which collects the sidebar data) and returns + * fresh sync projections built from it. + */ +async function collectProjections() { + await SessionSaver.run(); + ZenSpacesSyncModel.invalidate(); + return ZenSpacesSyncModel.projections(); +} + +async function openSyncableTab(url, { pinned = false } = {}) { + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + if (pinned) { + gBrowser.pinTab(tab); + } + await TabStateFlusher.flush(tab.linkedBrowser); + ok(tab.id, "the tab should have a sync id"); + return tab; +} + +function tabRecord( + id, + { pinned, url = "https://example.com/", current = null, workspaceUuid } = {} +) { + return { + id, + deleted: false, + cleartext: { + kind: "tab", + data: { + tabId: id, + url, + title: "Synced tab", + current, + icon: null, + containerGuid: null, + essential: false, + pinned, + workspaceUuid: workspaceUuid ?? gZenWorkspaces.activeWorkspace, + folderId: null, + staticLabel: null, + hasStaticIcon: false, + defaultContainer: false, + }, + }, + }; +}