diff --git a/src/zen/common/styles/zen-omnibox.css b/src/zen/common/styles/zen-omnibox.css index 1565b77bb..89ea8d0fe 100644 --- a/src/zen/common/styles/zen-omnibox.css +++ b/src/zen/common/styles/zen-omnibox.css @@ -559,22 +559,24 @@ } .urlbarView-prettyName, +.urlbarView-tabGroup, .urlbarView-shortcutContent { - border-radius: 4px; + border-radius: 4px !important; font-weight: 600; - margin-top: auto; - margin-bottom: auto; + margin-top: auto !important; + margin-bottom: auto !important; } -.urlbarView-prettyName { - padding: 4px 6px; - background-color: color-mix(in srgb, var(--zen-branding-bg-reverse), transparent 90%); - margin-left: 6px; +.urlbarView-prettyName, +.urlbarView-tabGroup { + padding: 4px 6px !important; + background-color: color-mix(in srgb, var(--zen-branding-bg-reverse), transparent 90%) !important; + margin-left: 6px !important; font-size: 12px; align-items: center; gap: 6px; display: flex; - color: color-mix(in srgb, var(--zen-primary-color), currentColor 95%); + color: color-mix(in srgb, var(--zen-primary-color), currentColor 95%) !important; & img { -moz-context-properties: fill; @@ -684,8 +686,9 @@ background-color: rgba(255, 255, 255, 0.9) !important; } - & .urlbarView-prettyName { - background-color: color-mix(in srgb, var(--zen-branding-bg-reverse), transparent 80%); + & .urlbarView-prettyName, + & .urlbarView-tabGroup { + background-color: color-mix(in srgb, var(--zen-branding-bg-reverse), transparent 80%) !important; } } } diff --git a/src/zen/media/zen-media-controls.css b/src/zen/media/zen-media-controls.css index a3c0c9d7e..cdb83044d 100644 --- a/src/zen/media/zen-media-controls.css +++ b/src/zen/media/zen-media-controls.css @@ -381,7 +381,7 @@ /* Hide .zen-media-focus-button if it doesn't fit in the toolbar */ @container media-controls (max-width: 165px) { - .zen-media-focus-button { + .zen-media-card:not([media-sharing]) .zen-media-focus-button { display: none; } } diff --git a/src/zen/sync/ZenSpacesSyncModel.sys.mjs b/src/zen/sync/ZenSpacesSyncModel.sys.mjs index 9ff20efc0..78c7c0766 100644 --- a/src/zen/sync/ZenSpacesSyncModel.sys.mjs +++ b/src/zen/sync/ZenSpacesSyncModel.sys.mjs @@ -156,12 +156,26 @@ class nsZenSpacesSyncModel { * other device (gh-15426). */ #appliedStamp = new Map(); + #appliedStampGen = 0; /** The projection generation the current sidebar data belongs to. */ #currentStamp() { return lazy.ZenSessionStore.getSidebarData()?.lastCollected || 0; } + /** + * The current projection generation, dropping held ids once a newer + * collection has caught up (they can no longer be in the skew window). + */ + #appliedStampNow() { + const stamp = this.#currentStamp(); + if (stamp !== this.#appliedStampGen) { + this.#appliedStamp.clear(); + this.#appliedStampGen = stamp; + } + return stamp; + } + #data() { if (!this.#file) { this.#file = new JSONFile({ @@ -759,11 +773,11 @@ class nsZenSpacesSyncModel { const uploaded = this.#data().uploaded; const current = this.#digestAll(); const pending = this.#pendingIds(); - const stamp = this.#currentStamp(); + const stamp = this.#appliedStampNow(); const now = Date.now() / 1000; const changes = {}; for (const [id, digest] of current) { - if (uploaded[id] !== digest) { + if (uploaded[id] !== digest && this.#appliedStamp.get(id) !== stamp) { changes[id] = now; } } @@ -795,9 +809,9 @@ class nsZenSpacesSyncModel { const uploaded = this.#data().uploaded; const current = this.#digestAll(); const pending = this.#pendingIds(); - const stamp = this.#currentStamp(); + const stamp = this.#appliedStampNow(); for (const [id, digest] of current) { - if (uploaded[id] !== digest) { + if (uploaded[id] !== digest && this.#appliedStamp.get(id) !== stamp) { return true; } } @@ -849,15 +863,13 @@ class nsZenSpacesSyncModel { */ noteApplied(id, cleartext) { const data = this.#data(); + const stamp = this.#appliedStampNow(); if (!cleartext) { delete data.uploaded[id]; - this.#appliedStamp.delete(id); } else { data.uploaded[id] = recordDigest(cleartext.kind, cleartext.data); - // Hold back a tombstone for this id until the projection is recollected - // past the generation it was applied in (gh-15426). - this.#appliedStamp.set(id, this.#currentStamp()); } + this.#appliedStamp.set(id, stamp); syncLog( `acknowledged incoming ${cleartext ? cleartext.kind : "tombstone"} ${id}` ); diff --git a/src/zen/tests/device_sync/browser.toml b/src/zen/tests/device_sync/browser.toml index ba0d3b24b..7571cb721 100644 --- a/src/zen/tests/device_sync/browser.toml +++ b/src/zen/tests/device_sync/browser.toml @@ -12,6 +12,8 @@ support-files = [ "head.js", ] +["browser_apply_skew.js"] + ["browser_folder_delete.js"] ["browser_normal_tabs_apply.js"] diff --git a/src/zen/tests/device_sync/browser_apply_skew.js b/src/zen/tests/device_sync/browser_apply_skew.js new file mode 100644 index 000000000..e61013e3f --- /dev/null +++ b/src/zen/tests/device_sync/browser_apply_skew.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_AppliedCreateNotTombstonedBeforeCollection() { + await gZenWorkspaces.promiseInitialized; + await collectProjections(); + + const id = "test-skew-create"; + ZenSpacesSyncModel.noteApplied(id, tabRecord(id, { pinned: true }).cleartext); + Assert.ok( + !ZenSpacesSyncModel.itemExists(id), + "the id is acknowledged but not yet projected (the skew window)" + ); + + const changes = ZenSpacesSyncModel.computeChangedIDs(); + Assert.ok( + !(id in changes), + "a just-applied create is not tombstoned before the projection catches up" + ); + + ZenSpacesSyncModel.noteApplied(id, null); +}); + +add_task(async function test_AppliedDeletionNotResurrectedBeforeCollection() { + await gZenWorkspaces.promiseInitialized; + + const tab = await openSyncableTab("https://example.com/?skew-delete", { + pinned: true, + }); + const id = tab.id; + await collectProjections(); + ZenSpacesSyncModel.markUploaded([id]); + Assert.ok( + !(id in ZenSpacesSyncModel.computeChangedIDs()), + "the uploaded tab is clean before the deletion" + ); + + const failed = await ZenSpacesSyncApplier.applyBatch([tombstone(id)]); + Assert.deepEqual(failed, [], "the tombstone applies cleanly"); + Assert.ok(!document.getElementById(id), "the tab is removed"); + + const changes = ZenSpacesSyncModel.computeChangedIDs(); + Assert.ok( + !(id in changes), + "a just-applied deletion is not re-uploaded before the projection catches up" + ); + + ZenSpacesSyncModel.noteApplied(id, null); +}); diff --git a/src/zen/tests/urlbar/browser.toml b/src/zen/tests/urlbar/browser.toml index 34557d019..29e518dcb 100644 --- a/src/zen/tests/urlbar/browser.toml +++ b/src/zen/tests/urlbar/browser.toml @@ -13,4 +13,6 @@ support-files = [ ["browser_issue_7385.js"] +["browser_sidebar_provider.js"] + ["browser_urlbar_close_token.js"] diff --git a/src/zen/tests/urlbar/browser_sidebar_provider.js b/src/zen/tests/urlbar/browser_sidebar_provider.js new file mode 100644 index 000000000..9f687753b --- /dev/null +++ b/src/zen/tests/urlbar/browser_sidebar_provider.js @@ -0,0 +1,221 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarShared: "chrome://browser/content/urlbar/UrlbarShared.mjs", + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.sys.mjs", + SessionSaver: + "moz-src:///browser/components/sessionstore/SessionSaver.sys.mjs", + TabStateFlusher: + "moz-src:///browser/components/sessionstore/TabStateFlusher.sys.mjs", +}); + +const PROVIDER_NAME = "ZenUrlbarProviderSidebar"; +const TAB_URL = "https://example.com/"; + +async function collectSidebarData() { + await TabStateFlusher.flushWindow(window); + await SessionSaver.run(); +} + +async function searchSidebarRows(value) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + waitForFocus, + value, + }); + const rows = []; + for (let index = 0; index < UrlbarTestUtils.getResultCount(window); index++) { + const { result } = await UrlbarTestUtils.getRowAt(window, index); + if (result.providerName == PROVIDER_NAME) { + rows.push({ index, result }); + } + } + return rows; +} + +async function addLabelledTab(label) { + const tab = BrowserTestUtils.addTab(gBrowser, TAB_URL, { + skipAnimation: true, + }); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + tab.zenStaticLabel = label; + gBrowser._setTabLabel(tab, label); + return tab; +} + +async function removeFolder(folder) { + const removeEvent = BrowserTestUtils.waitForEvent(folder, "TabGroupRemoved"); + folder.delete(); + await removeEvent; +} + +add_task(async function test_custom_label_is_searchable() { + const tab = await addLabelledTab("Quarterly zeninvoices"); + await collectSidebarData(); + + const rows = await searchSidebarRows("zeninvoices quarterly"); + Assert.equal(rows.length, 1, "The renamed tab is the only match"); + const { result } = rows[0]; + Assert.equal(result.type, UrlbarShared.RESULT_TYPE.TAB_SWITCH); + Assert.equal(result.payload.url, TAB_URL); + Assert.equal( + result.payload.title, + "Quarterly zeninvoices", + "The custom label is shown instead of the page title" + ); + + Assert.deepEqual( + await searchSidebarRows("zeninvoices yearly"), + [], + "Every token needs to be part of the label" + ); + + await UrlbarTestUtils.promisePopupClose(window); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_current_tab_is_not_suggested() { + const tab = await addLabelledTab("zencurrenttab"); + gBrowser.selectedTab = tab; + await collectSidebarData(); + + Assert.deepEqual( + await searchSidebarRows("zencurrenttab"), + [], + "There is no point in switching to the tab we are already in" + ); + + await UrlbarTestUtils.promisePopupClose(window); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_stale_sidebar_url_is_not_suggested() { + const tab = await addLabelledTab("zenstaletab"); + await collectSidebarData(); + + BrowserTestUtils.startLoadingURIString( + tab.linkedBrowser, + "https://example.org/" + ); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + Assert.deepEqual( + await searchSidebarRows("zenstaletab"), + [], + "A url that is not open anymore must not be offered as switch to tab" + ); + + await UrlbarTestUtils.promisePopupClose(window); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_only_active_space_tabs() { + const originalSpace = gZenWorkspaces.activeWorkspace; + const tab = await addLabelledTab("zenotherspace"); + await gZenWorkspaces.createAndSaveWorkspace("Sidebar Provider Space"); + Assert.notEqual( + gZenWorkspaces.activeWorkspace, + originalSpace, + "The new space is the active one" + ); + await collectSidebarData(); + + Assert.deepEqual( + await searchSidebarRows("zenotherspace"), + [], + "Tabs from other spaces are not matched" + ); + await UrlbarTestUtils.promisePopupClose(window); + + await gZenWorkspaces.removeWorkspace(gZenWorkspaces.activeWorkspace); + Assert.equal(gZenWorkspaces.activeWorkspace, originalSpace); + await collectSidebarData(); + + const rows = await searchSidebarRows("zenotherspace"); + Assert.equal(rows.length, 1, "The tab is matched again in its own space"); + + await UrlbarTestUtils.promisePopupClose(window); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_folders_path_and_ranking() { + const tab = BrowserTestUtils.addTab(gBrowser, "data:text/html,tab1"); + const tab2 = BrowserTestUtils.addTab(gBrowser, "data:text/html,tab2"); + const subfolder = await gZenFolders.createFolder([tab], { + renameFolder: false, + label: "zenfold archive notes", + }); + const parent = await gZenFolders.createFolder([tab2], { + renameFolder: false, + label: "zenfold", + }); + parent.tabs[0].after(subfolder); + await collectSidebarData(); + + const rows = await searchSidebarRows("zenfold"); + Assert.equal(rows.length, 2, "Both folders are matched"); + for (const { result } of rows) { + Assert.equal(result.type, UrlbarShared.RESULT_TYPE.DYNAMIC); + } + const [best, worst] = rows; + const space = gZenWorkspaces.getWorkspaceFromId( + gZenWorkspaces.activeWorkspace + ); + Assert.equal(best.result.payload.zenFolderId, parent.id); + Assert.equal( + best.result.payload.path, + [space.name, "zenfold"].join(" / "), + "A root folder only shows its space and name" + ); + Assert.equal(best.index, 1, "A full match sits right below the heuristic"); + Assert.equal(worst.result.payload.zenFolderId, subfolder.id); + Assert.equal( + worst.result.payload.path, + [space.name, "zenfold", "zenfold archive notes"].join(" / "), + "A subfolder shows every parent folder" + ); + Assert.greater( + worst.index, + best.index, + "The weaker the match, the further down the folder goes" + ); + + await UrlbarTestUtils.promisePopupClose(window); + await removeFolder(subfolder); + await removeFolder(parent); +}); + +add_task(async function test_picking_a_folder_reveals_it() { + const tab = BrowserTestUtils.addTab(gBrowser, "data:text/html,tab1"); + const tab2 = BrowserTestUtils.addTab(gBrowser, "data:text/html,tab2"); + const subfolder = await gZenFolders.createFolder([tab], { + renameFolder: false, + label: "zenreveal", + }); + const parent = await gZenFolders.createFolder([tab2], { + renameFolder: false, + label: "parent", + }); + parent.tabs[0].after(subfolder); + subfolder.collapsed = true; + parent.collapsed = true; + await collectSidebarData(); + + const rows = await searchSidebarRows("zenreveal"); + Assert.equal(rows.length, 1, "The subfolder is matched"); + UrlbarTestUtils.setSelectedRowIndex(window, rows[0].index); + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Enter") + ); + + await TestUtils.waitForCondition( + () => !parent.collapsed && !subfolder.collapsed, + "The folder and its parent get expanded" + ); + + await removeFolder(subfolder); + await removeFolder(parent); +}); diff --git a/src/zen/urlbar/ZenUBSidebarProvider.sys.mjs b/src/zen/urlbar/ZenUBSidebarProvider.sys.mjs index aa9837bd2..5acb3fc64 100644 --- a/src/zen/urlbar/ZenUBSidebarProvider.sys.mjs +++ b/src/zen/urlbar/ZenUBSidebarProvider.sys.mjs @@ -49,21 +49,18 @@ export class ZenUrlbarProviderSidebar extends UrlbarProvider { return; } const tokens = queryContext.tokens.map(t => t.lowerCaseValue); - const matches = (percentage, text) => { + const score = text => { text = text.toLowerCase(); - const matchCount = tokens.filter(token => text.includes(token)).length; - return matchCount / tokens.length >= percentage; + if (!tokens.every(token => text.includes(token))) { + return 0; + } + return tokens.reduce((sum, token) => sum + token.length, 0) / text.length; }; - this.#addFolders(sidebar, matches.bind(undefined, 0.7), addCallback); - this.#addTabs( - sidebar, - matches.bind(undefined, 0.4), - queryContext, - addCallback - ); + this.#addFolders(sidebar, score, queryContext, addCallback); + this.#addTabs(sidebar, score, queryContext, addCallback); } - #addTabs(sidebar, matches, queryContext, addCallback) { + #addTabs(sidebar, score, queryContext, addCallback) { const openTabUrls = lazy.UrlbarProviderOpenTabs.getOpenTabUrls(); const activeSpace = lazy.BrowserWindowTracker.getTopWindow().gZenWorkspaces.activeWorkspace; @@ -76,7 +73,7 @@ export class ZenUrlbarProviderSidebar extends UrlbarProvider { const entries = tabData.entries || []; const entry = entries[(tabData.index || entries.length) - 1]; const label = tabData.zenStaticLabel || entry?.title; - if (typeof label !== "string" || !label || !matches(label)) { + if (typeof label !== "string" || !label || !score(label)) { continue; } const url = entry?.url; @@ -120,14 +117,17 @@ export class ZenUrlbarProviderSidebar extends UrlbarProvider { } } - #addFolders(sidebar, matches, addCallback) { + #addFolders(sidebar, score, queryContext, addCallback) { const folders = new Map( (sidebar.folders || []).map(folder => [folder.id, folder]) ); - for (const folder of folders.values()) { - if (folder.splitViewGroup || !folder.name || !matches(folder.name)) { - continue; - } + const matched = [...folders.values()] + .filter(folder => !folder.splitViewGroup && folder.name) + .map(folder => ({ folder, score: score(folder.name) })) + .filter(match => match.score) + .sort((a, b) => b.score - a.score); + for (const match of matched) { + const { folder } = match; const space = sidebar.spaces?.find(s => s.uuid == folder.workspaceId); const path = [folder.name]; for ( @@ -153,6 +153,11 @@ export class ZenUrlbarProviderSidebar extends UrlbarProvider { icon: "chrome://browser/skin/zen-icons/folder.svg", path: path.join(" / "), }, + suggestedIndex: + 1 + + Math.round( + (1 - Math.min(match.score, 1)) * (queryContext.maxResults - 2) + ), }) ); } diff --git a/surfer.json b/surfer.json index 5e946b570..4fb25c05d 100644 --- a/surfer.json +++ b/surfer.json @@ -20,7 +20,7 @@ "brandShortName": "Zen", "brandFullName": "Zen Browser", "release": { - "displayVersion": "1.22.2b", + "displayVersion": "1.22.3b", "github": { "repo": "zen-browser/desktop" },