From 5db8780f5d730b94fa83048840e1c5538621aa6c Mon Sep 17 00:00:00 2001 From: "mr. m" <91018726+mr-cheffy@users.noreply.github.com> Date: Mon, 8 Jun 2026 17:41:00 +0200 Subject: [PATCH] gh-14044: Finish implementation for space routing (gh-14069) --- prefs/firefox/browser.yaml | 5 + .../tabbrowser/content/tabbrowser-js.patch | 186 +++++++------- src/zen/common/modules/ZenStartup.mjs | 2 +- src/zen/common/moz.build | 1 + .../sys/ui/ZenSpaceRoutingNavigation.sys.mjs | 122 +++++++++ .../ZenSpaceRoutingManager.sys.mjs | 34 +++ src/zen/spaces/ZenGradientGenerator.mjs | 9 +- src/zen/spaces/ZenSpaceManager.mjs | 13 +- src/zen/spaces/zen-workspaces.css | 6 +- src/zen/tabs/zen-tabs/vertical-tabs.css | 2 - src/zen/tests/space_routing/browser.toml | 4 + .../browser_space_routing_dialog.js | 12 +- .../browser_space_routing_fuzz.js | 239 ++++++++++++++++++ ...owser_space_routing_redirect_navigation.js | 127 ++++++++++ src/zen/tests/space_routing/head.js | 38 ++- src/zen/tests/spaces/browser.toml | 2 + .../browser_select_tab_switches_space.js | 69 +++++ 17 files changed, 759 insertions(+), 112 deletions(-) create mode 100644 src/zen/common/sys/ui/ZenSpaceRoutingNavigation.sys.mjs create mode 100644 src/zen/tests/space_routing/browser_space_routing_fuzz.js create mode 100644 src/zen/tests/space_routing/browser_space_routing_redirect_navigation.js create mode 100644 src/zen/tests/spaces/browser_select_tab_switches_space.js diff --git a/prefs/firefox/browser.yaml b/prefs/firefox/browser.yaml index 341577cac..4545795c6 100644 --- a/prefs/firefox/browser.yaml +++ b/prefs/firefox/browser.yaml @@ -94,3 +94,8 @@ # See gh-12985 for details on the following preferences - name: browser.search.widget.new value: true + +# Disabled from https://searchfox.org/firefox-main/rev/d6bfff43852356ca98af848b4705d37f8d41856f/modules/libpref/init/StaticPrefList.yaml#2008 +# Only enabled for windows, doesn't really fit inside Zen. +- name: browser.startup.preXulSkeletonUI + value: false diff --git a/src/browser/components/tabbrowser/content/tabbrowser-js.patch b/src/browser/components/tabbrowser/content/tabbrowser-js.patch index ef96e759e..90ad2574e 100644 --- a/src/browser/components/tabbrowser/content/tabbrowser-js.patch +++ b/src/browser/components/tabbrowser/content/tabbrowser-js.patch @@ -1,5 +1,5 @@ diff --git a/browser/components/tabbrowser/content/tabbrowser.js b/browser/components/tabbrowser/content/tabbrowser.js -index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a1559780fe22 100644 +index 43fb79a3060e20f671ae6ffc26350c7abf497702..028dfcba9e23a17e4152071dd58eb97a70e59c10 100644 --- a/browser/components/tabbrowser/content/tabbrowser.js +++ b/browser/components/tabbrowser/content/tabbrowser.js @@ -502,6 +502,7 @@ @@ -79,7 +79,15 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 set selectedTab(val) { if ( gSharedTabWarning.willShowSharedTabWarning(val) || -@@ -659,6 +711,10 @@ +@@ -592,6 +644,7 @@ + ) { + return; + } ++ gZenWorkspaces.onBeforeTabSelect(val); + // Update the tab + this.tabbox.selectedTab = val; + } +@@ -659,6 +712,10 @@ userContextId = parseInt(tabArgument.getAttribute("usercontextid"), 10); } @@ -90,7 +98,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 if (tabArgument && tabArgument.linkedBrowser) { remoteType = tabArgument.linkedBrowser.remoteType; initialBrowsingContextGroupId = -@@ -751,6 +807,8 @@ +@@ -751,6 +808,8 @@ this.tabpanels.appendChild(panel); let tab = this.tabs[0]; @@ -99,7 +107,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 tab.linkedPanel = uniqueId; this._selectedTab = tab; this._selectedBrowser = browser; -@@ -1121,13 +1179,18 @@ +@@ -1121,13 +1180,18 @@ } this.showTab(aTab); @@ -119,7 +127,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 aTab.setAttribute("pinned", "true"); this._updateTabBarForPinnedTabs(); -@@ -1140,11 +1203,19 @@ +@@ -1140,11 +1204,19 @@ } this.#handleTabMove(aTab, () => { @@ -140,7 +148,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 }); aTab.style.marginInlineStart = ""; -@@ -1321,6 +1392,9 @@ +@@ -1321,6 +1393,9 @@ let LOCAL_PROTOCOLS = ["chrome:", "about:", "resource:", "data:"]; @@ -150,7 +158,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 if ( aIconURL && !LOCAL_PROTOCOLS.some(protocol => aIconURL.startsWith(protocol)) -@@ -1330,6 +1404,9 @@ +@@ -1330,6 +1405,9 @@ ); return; } @@ -160,7 +168,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 let browser = this.getBrowserForTab(aTab); browser.mIconURL = aIconURL; -@@ -1652,7 +1729,6 @@ +@@ -1652,7 +1730,6 @@ // Preview mode should not reset the owner if (!this._previewMode && !oldTab.selected) { @@ -168,7 +176,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 } let lastRelatedTab = this._lastRelatedTabMap.get(oldTab); -@@ -1743,6 +1819,7 @@ +@@ -1743,6 +1820,7 @@ if (!this._previewMode) { newTab.recordTimeFromUnloadToReload(); newTab.updateLastAccessed(); @@ -176,7 +184,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 oldTab.updateLastAccessed(); // if this is the foreground window, update the last-seen timestamps. if (this.ownerGlobal == BrowserWindowTracker.getTopWindow()) { -@@ -1957,6 +2034,9 @@ +@@ -1957,6 +2035,9 @@ } let activeEl = document.activeElement; @@ -186,7 +194,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 // If focus is on the old tab, move it to the new tab. if (activeEl == oldTab) { newTab.focus(); -@@ -1995,7 +2075,7 @@ +@@ -1995,7 +2076,7 @@ // Focus the location bar if it was previously focused for that tab. // In full screen mode, only bother making the location bar visible // if the tab is a blank one. @@ -195,7 +203,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 let selectURL = () => { if (this._asyncTabSwitching) { // Set _awaitingSetURI flag to suppress popup notification -@@ -2283,7 +2363,12 @@ +@@ -2283,7 +2364,12 @@ return this._setTabLabel(aTab, aLabel); } @@ -209,7 +217,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 if (!aLabel || (isURL && /^about:reader\?url=/.test(aLabel))) { return false; } -@@ -2408,7 +2493,7 @@ +@@ -2408,7 +2494,7 @@ newIndex = this.selectedTab._tPos + 1; } @@ -218,7 +226,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 if (this.isTabGroupLabel(targetTab)) { throw new Error( "Replacing a tab group label with a tab is not supported" -@@ -2685,6 +2770,7 @@ +@@ -2685,6 +2771,7 @@ uriIsAboutBlank, userContextId, skipLoad, @@ -226,7 +234,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 } = {}) { let b = document.createXULElement("browser"); // Use the JSM global to create the permanentKey, so that if the -@@ -2758,8 +2844,7 @@ +@@ -2758,8 +2845,7 @@ // we use a different attribute name for this? b.setAttribute("name", name); } @@ -236,7 +244,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 b.setAttribute("transparent", "true"); } -@@ -2929,7 +3014,7 @@ +@@ -2929,7 +3015,7 @@ let panel = this.getPanel(browser); let uniqueId = this._generateUniquePanelID(); @@ -245,7 +253,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 aTab.linkedPanel = uniqueId; // Inject the into the DOM if necessary. -@@ -2989,8 +3074,8 @@ +@@ -2989,8 +3075,8 @@ // If we transitioned from one browser to two browsers, we need to set // hasSiblings=false on both the existing browser and the new browser. if (this.tabs.length == 2) { @@ -256,7 +264,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 } else { aTab.linkedBrowser.browsingContext.hasSiblings = this.tabs.length > 1; } -@@ -3175,7 +3260,6 @@ +@@ -3175,7 +3261,6 @@ this.selectedTab = this.addTrustedTab(BROWSER_NEW_TAB_URL, { tabIndex: tab._tPos + 1, userContextId: tab.userContextId, @@ -264,7 +272,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 focusUrlBar: true, }); resolve(this.selectedBrowser); -@@ -3285,6 +3369,10 @@ +@@ -3285,6 +3370,10 @@ schemelessInput, hasValidUserGestureActivation = false, textDirectiveUserActivation = false, @@ -275,7 +283,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 } = {} ) { // all callers of addTab that pass a params object need to pass -@@ -3295,10 +3383,25 @@ +@@ -3295,10 +3384,25 @@ ); } @@ -301,7 +309,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 // If we're opening a foreground tab, set the owner by default. ownerTab ??= inBackground ? null : this.selectedTab; -@@ -3306,6 +3409,7 @@ +@@ -3306,6 +3410,7 @@ if (this.selectedTab.owner) { this.selectedTab.owner = null; } @@ -309,7 +317,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 // Find the tab that opened this one, if any. This is used for // determining positioning, and inherited attributes such as the -@@ -3358,6 +3462,22 @@ +@@ -3358,6 +3463,22 @@ noInitialLabel, skipBackgroundNotify, }); @@ -332,7 +340,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 if (insertTab) { // Insert the tab into the tab container in the correct position. this.#insertTabAtIndex(t, { -@@ -3366,6 +3486,7 @@ +@@ -3366,6 +3487,7 @@ ownerTab, openerTab, pinned, @@ -340,7 +348,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 bulkOrderedOpen, tabGroup: tabGroup ?? openerTab?.group, }); -@@ -3384,6 +3505,7 @@ +@@ -3384,6 +3506,7 @@ openWindowInfo, skipLoad, triggeringRemoteType, @@ -348,7 +356,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 })); if (focusUrlBar) { -@@ -3508,6 +3630,12 @@ +@@ -3508,6 +3631,12 @@ } } @@ -361,7 +369,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 // Additionally send pinned tab events if (pinned) { this.#notifyPinnedStatus(t); -@@ -3518,6 +3646,9 @@ +@@ -3518,6 +3647,9 @@ if (!inBackground) { this.selectedTab = t; } @@ -371,7 +379,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 return t; } -@@ -3750,6 +3881,7 @@ +@@ -3750,6 +3882,7 @@ isAdoptingGroup = false, isUserTriggered = false, telemetryUserCreateSource = "unknown", @@ -379,7 +387,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 } = {} ) { if ( -@@ -3760,9 +3892,6 @@ +@@ -3760,9 +3893,6 @@ !this.isSplitViewWrapper(tabOrSplitView) ) ) { @@ -389,7 +397,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 } if (!color) { -@@ -3783,9 +3912,14 @@ +@@ -3783,9 +3913,14 @@ label, isAdoptingGroup ); @@ -406,7 +414,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 ); group.addTabs(tabsAndSplitViews); -@@ -3906,7 +4040,7 @@ +@@ -3906,7 +4041,7 @@ } this.#handleTabMove(tab, () => @@ -415,7 +423,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 ); } -@@ -3990,6 +4124,7 @@ +@@ -3990,6 +4125,7 @@ color: group.color, insertBefore: newTabs[0], isAdoptingGroup: true, @@ -423,7 +431,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 }); } -@@ -4200,6 +4335,7 @@ +@@ -4200,6 +4336,7 @@ openWindowInfo, skipLoad, triggeringRemoteType, @@ -431,7 +439,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 } ) { // If we don't have a preferred remote type (or it is `NOT_REMOTE`), and -@@ -4269,6 +4405,7 @@ +@@ -4269,6 +4406,7 @@ openWindowInfo, name, skipLoad, @@ -439,7 +447,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 }); } -@@ -4482,9 +4619,9 @@ +@@ -4482,9 +4620,9 @@ } // Add a new tab if needed. @@ -451,7 +459,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 let url = "about:blank"; if (tabData.entries?.length) { -@@ -4521,8 +4658,10 @@ +@@ -4521,8 +4659,10 @@ insertTab: false, skipLoad: true, preferredRemoteType, @@ -463,7 +471,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 if (select) { tabToSelect = tab; } -@@ -4544,7 +4683,8 @@ +@@ -4544,7 +4684,8 @@ this.pinTab(tab); // Then ensure all the tab open/pinning information is sent. this._fireTabOpen(tab, {}); @@ -473,7 +481,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 let { groupId } = tabData; const tabGroup = tabGroupWorkingData.get(groupId); // if a tab refers to a tab group we don't know, skip any group -@@ -4564,7 +4704,10 @@ +@@ -4564,7 +4705,10 @@ tabGroup.stateData.id, tabGroup.stateData.color, tabGroup.stateData.collapsed, @@ -485,7 +493,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 ); tabsFragment.appendChild(tabGroup.node); } -@@ -4619,9 +4762,21 @@ +@@ -4619,9 +4763,21 @@ // to remove the old selected tab. if (tabToSelect) { let leftoverTab = this.selectedTab; @@ -507,7 +515,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 if (tabs.length > 1 || !tabs[0].selected) { this._updateTabsAfterInsert(); -@@ -4812,11 +4967,14 @@ +@@ -4812,11 +4968,14 @@ if (ownerTab) { tab.owner = ownerTab; } @@ -523,7 +531,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 if ( !bulkOrderedOpen && ((openerTab && -@@ -4828,7 +4986,7 @@ +@@ -4828,7 +4987,7 @@ let lastRelatedTab = openerTab && this._lastRelatedTabMap.get(openerTab); let previousTab = lastRelatedTab || openerTab || this.selectedTab; @@ -532,7 +540,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 tabGroup = previousTab.group; } if ( -@@ -4844,7 +5002,7 @@ +@@ -4844,7 +5003,7 @@ previousTab.splitview ) + 1; } else if (previousTab.visible) { @@ -541,7 +549,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 } else if (previousTab == FirefoxViewHandler.tab) { elementIndex = 0; } -@@ -4872,14 +5030,14 @@ +@@ -4872,14 +5031,14 @@ } // Ensure index is within bounds. if (tab.pinned) { @@ -560,7 +568,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 if (pinned && !itemAfter?.pinned) { itemAfter = null; -@@ -4896,7 +5054,7 @@ +@@ -4896,7 +5055,7 @@ this.tabContainer._invalidateCachedTabs(); @@ -569,7 +577,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 if ( (this.isTab(itemAfter) && itemAfter.group == tabGroup) || this.isSplitViewWrapper(itemAfter) -@@ -4927,7 +5085,11 @@ +@@ -4927,7 +5086,11 @@ const tabContainer = pinned ? this.tabContainer.pinnedTabsContainer : this.tabContainer; @@ -581,7 +589,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 } if (tab.group?.collapsed) { -@@ -4942,6 +5104,7 @@ +@@ -4942,6 +5105,7 @@ if (pinned) { this._updateTabBarForPinnedTabs(); } @@ -589,7 +597,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 TabBarVisibility.update(); } -@@ -5490,6 +5653,7 @@ +@@ -5490,6 +5654,7 @@ telemetrySource, } = {} ) { @@ -597,7 +605,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 // When 'closeWindowWithLastTab' pref is enabled, closing all tabs // can be considered equivalent to closing the window. if ( -@@ -5579,6 +5743,7 @@ +@@ -5579,6 +5744,7 @@ if (lastToClose) { this.removeTab(lastToClose, aParams); } @@ -605,7 +613,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 } catch (e) { console.error(e); } -@@ -5624,6 +5789,14 @@ +@@ -5624,6 +5790,14 @@ return; } @@ -620,7 +628,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 let isVisibleTab = aTab.visible; // We have to sample the tab width now, since _beginRemoveTab might // end up modifying the DOM in such a way that aTab gets a new -@@ -5631,6 +5804,9 @@ +@@ -5631,6 +5805,9 @@ // state). let tabWidth = window.windowUtils.getBoundsWithoutFlushing(aTab).width; let isLastTab = this.#isLastTabInWindow(aTab); @@ -630,7 +638,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 if ( !this._beginRemoveTab(aTab, { closeWindowFastpath: true, -@@ -5642,13 +5818,14 @@ +@@ -5642,13 +5819,14 @@ telemetrySource, }) ) { @@ -646,7 +654,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 let lockTabSizing = !this.tabContainer.verticalMode && !aTab.pinned && -@@ -5679,7 +5856,13 @@ +@@ -5679,7 +5857,13 @@ // We're not animating, so we can cancel the animation stopwatch. Glean.browserTabclose.timeAnim.cancel(aTab._closeTimeAnimTimerId); aTab._closeTimeAnimTimerId = null; @@ -661,7 +669,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 return; } -@@ -5813,7 +5996,7 @@ +@@ -5813,7 +5997,7 @@ closeWindowWithLastTab != null ? closeWindowWithLastTab : !window.toolbar.visible || @@ -670,7 +678,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 if (closeWindow) { // We've already called beforeunload on all the relevant tabs if we get here, -@@ -5837,6 +6020,7 @@ +@@ -5837,6 +6021,7 @@ newTab = true; } @@ -678,7 +686,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 aTab._endRemoveArgs = [closeWindow, newTab]; // swapBrowsersAndCloseOther will take care of closing the window without animation. -@@ -5877,13 +6061,7 @@ +@@ -5877,13 +6062,7 @@ aTab._mouseleave(); if (newTab) { @@ -693,7 +701,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 } else { TabBarVisibility.update(); } -@@ -6016,6 +6194,7 @@ +@@ -6016,6 +6195,7 @@ this.tabs[i]._tPos = i; } @@ -701,7 +709,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 if (!this._windowIsClosing) { // update tab close buttons state this.tabContainer._updateCloseButtons(); -@@ -6201,6 +6380,7 @@ +@@ -6201,6 +6381,7 @@ memory_after: await getTotalMemoryUsage(), time_to_unload_in_ms: timeElapsed, }); @@ -709,7 +717,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 } /** -@@ -6246,6 +6426,7 @@ +@@ -6246,6 +6427,7 @@ } let excludeTabs = new Set(aExcludeTabs); @@ -717,7 +725,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 // If this tab has a successor, it should be selectable, since // hiding or closing a tab removes that tab as a successor. -@@ -6258,15 +6439,22 @@ +@@ -6258,15 +6440,22 @@ !excludeTabs.has(aTab.owner) && Services.prefs.getBoolPref("browser.tabs.selectOwnerOnClose") ) { @@ -742,7 +750,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 let tab = this.tabContainer.findNextTab(aTab, { direction: 1, filter: _tab => remainingTabs.includes(_tab), -@@ -6280,7 +6468,7 @@ +@@ -6280,7 +6469,7 @@ } if (tab) { @@ -751,7 +759,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 } // If no qualifying visible tab was found, see if there is a tab in -@@ -6301,7 +6489,7 @@ +@@ -6301,7 +6490,7 @@ }); } @@ -760,7 +768,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 } _blurTab(aTab) { -@@ -6312,7 +6500,7 @@ +@@ -6312,7 +6501,7 @@ * @returns {boolean} * False if swapping isn't permitted, true otherwise. */ @@ -769,7 +777,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 // Do not allow transfering a private tab to a non-private window // and vice versa. if ( -@@ -6366,6 +6554,7 @@ +@@ -6366,6 +6555,7 @@ // fire the beforeunload event in the process. Close the other // window if this was its last tab. if ( @@ -777,7 +785,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 !remoteBrowser._beginRemoveTab(aOtherTab, { adoptedByTab: aOurTab, closeWindowWithLastTab: true, -@@ -6377,7 +6566,7 @@ +@@ -6377,7 +6567,7 @@ // If this is the last tab of the window, hide the window // immediately without animation before the docshell swap, to avoid // about:blank being painted. @@ -786,7 +794,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 if (closeWindow) { let win = aOtherTab.ownerGlobal; win.windowUtils.suppressAnimation(true); -@@ -6511,11 +6700,13 @@ +@@ -6511,11 +6701,13 @@ } // Finish tearing down the tab that's going away. @@ -800,7 +808,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 this.setTabTitle(aOurTab); -@@ -6717,10 +6908,10 @@ +@@ -6717,10 +6909,10 @@ SessionStore.deleteCustomTabValue(aTab, "hiddenBy"); } @@ -813,7 +821,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 aTab.selected || aTab.closing || // Tabs that are sharing the screen, microphone or camera cannot be hidden. -@@ -6780,7 +6971,8 @@ +@@ -6780,7 +6972,8 @@ * @param {object} [aOptions={}] * Key-value pairs that will be serialized into the features string. */ @@ -823,7 +831,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 if (this.tabs.length == 1) { return null; } -@@ -6797,7 +6989,7 @@ +@@ -6797,7 +6990,7 @@ // tell a new window to take the "dropped" tab let args = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray); args.appendElement(aTab.splitview ?? aTab); @@ -832,7 +840,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 private: PrivateBrowsingUtils.isWindowPrivate(window), features: Object.entries(aOptions) .map(([key, value]) => `${key}=${value}`) -@@ -6805,6 +6997,8 @@ +@@ -6805,6 +6998,8 @@ openerWindow: window, args, }); @@ -841,7 +849,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 } /** -@@ -6917,7 +7111,7 @@ +@@ -6917,7 +7112,7 @@ * `true` if element is a `` */ isTabGroup(element) { @@ -850,7 +858,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 } /** -@@ -7002,8 +7196,8 @@ +@@ -7002,8 +7197,8 @@ } // Don't allow mixing pinned and unpinned tabs. @@ -861,7 +869,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 } else { tabIndex = Math.max(tabIndex, this.pinnedTabCount); } -@@ -7049,8 +7243,8 @@ +@@ -7049,8 +7244,8 @@ this.#handleTabMove( element, () => { @@ -872,7 +880,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 neighbor = neighbor.group; } if (neighbor?.splitview) { -@@ -7061,6 +7255,12 @@ +@@ -7061,6 +7256,12 @@ return; } } @@ -885,7 +893,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 if (movingForwards && neighbor) { neighbor.after(element); -@@ -7119,23 +7319,31 @@ +@@ -7119,23 +7320,31 @@ #moveTabNextTo(element, targetElement, moveBefore = false, metricsContext) { if (this.isTabGroupLabel(targetElement)) { targetElement = targetElement.group; @@ -923,7 +931,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 } else if (!element.pinned && targetElement && targetElement.pinned) { // If the caller asks to move an unpinned element next to a pinned // tab, move the unpinned element to be the first unpinned element -@@ -7148,12 +7356,35 @@ +@@ -7148,12 +7357,35 @@ // move the tab group right before the first unpinned tab. // 4. Moving a tab group and the first unpinned tab is grouped: // move the tab group right before the first unpinned tab's tab group. @@ -960,7 +968,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 // We want to include the splitview wrapper if it's the targetElement, but // not in the case where we want to reverse tabs within the same splitview. -@@ -7162,6 +7393,7 @@ +@@ -7162,6 +7394,7 @@ } let getContainer = () => @@ -968,7 +976,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 element.pinned ? this.tabContainer.pinnedTabsContainer : this.tabContainer; -@@ -7170,11 +7402,15 @@ +@@ -7170,11 +7403,15 @@ element, () => { if (moveBefore) { @@ -985,7 +993,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 } }, metricsContext -@@ -7248,11 +7484,15 @@ +@@ -7248,11 +7485,15 @@ * @param {TabMetricsContext} [metricsContext] */ moveTabToExistingGroup(aTab, aGroup, metricsContext) { @@ -1004,7 +1012,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 } if (aTab.group && aTab.group.id === aGroup.id) { return; -@@ -7324,6 +7564,7 @@ +@@ -7324,6 +7565,7 @@ let state = { tabIndex: tab._tPos, @@ -1012,7 +1020,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 }; if (tab.visible) { state.elementIndex = tab.elementIndex; -@@ -7355,7 +7596,7 @@ +@@ -7355,7 +7597,7 @@ let changedSplitView = previousTabState.splitViewId != currentTabState.splitViewId; @@ -1021,7 +1029,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 tab.dispatchEvent( new CustomEvent("TabMove", { bubbles: true, -@@ -7402,6 +7643,10 @@ +@@ -7402,6 +7644,10 @@ moveActionCallback(); @@ -1032,7 +1040,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 // Clear tabs cache after moving nodes because the order of tabs may have // changed. this.tabContainer._invalidateCachedTabs(); -@@ -7452,7 +7697,22 @@ +@@ -7452,7 +7698,22 @@ * @returns {object} * The new tab in the current window, null if the tab couldn't be adopted. */ @@ -1056,7 +1064,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 // Swap the dropped tab with a new one we create and then close // it in the other window (making it seem to have moved between // windows). We also ensure that the tab we create to swap into has -@@ -7495,6 +7755,8 @@ +@@ -7495,6 +7756,8 @@ } params.skipLoad = true; let newTab = this.addWebTab("about:blank", params); @@ -1065,7 +1073,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 aTab.container.tabDragAndDrop.finishAnimateTabMove(); -@@ -8205,7 +8467,7 @@ +@@ -8205,7 +8468,7 @@ // preventDefault(). It will still raise the window if appropriate. return; } @@ -1074,7 +1082,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 window.focus(); aEvent.preventDefault(); } -@@ -8222,7 +8484,6 @@ +@@ -8222,7 +8485,6 @@ on_TabGroupCollapse(aEvent) { aEvent.target.tabs.forEach(tab => { @@ -1082,7 +1090,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 }); } -@@ -8556,7 +8817,9 @@ +@@ -8556,7 +8818,9 @@ let filter = this._tabFilters.get(tab); if (filter) { @@ -1092,7 +1100,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 let listener = this._tabListeners.get(tab); if (listener) { -@@ -9359,6 +9622,7 @@ +@@ -9359,6 +9623,7 @@ aWebProgress.isTopLevel ) { this.mTab.setAttribute("busy", "true"); @@ -1100,7 +1108,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 gBrowser._tabAttrModified(this.mTab, ["busy"]); this.mTab._notselectedsinceload = !this.mTab.selected; } -@@ -9439,6 +9703,7 @@ +@@ -9439,6 +9704,7 @@ // known defaults. Note we use the original URL since about:newtab // redirects to a prerendered page. const shouldRemoveFavicon = @@ -1108,7 +1116,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 !this.mBrowser.mIconURL && !ignoreBlank && !(originalLocation.spec in FAVICON_DEFAULTS); -@@ -9613,13 +9878,6 @@ +@@ -9613,13 +9879,6 @@ this.mBrowser.originalURI = aRequest.originalURI; } @@ -1122,7 +1130,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a155 } let userContextId = this.mBrowser.getAttribute("usercontextid") || 0; -@@ -10507,7 +10765,8 @@ var TabContextMenu = { +@@ -10507,7 +10766,8 @@ var TabContextMenu = { ); contextUnpinSelectedTabs.hidden = !this.contextTab.pinned || !this.multiselected; diff --git a/src/zen/common/modules/ZenStartup.mjs b/src/zen/common/modules/ZenStartup.mjs index dd37719f8..93e549f56 100644 --- a/src/zen/common/modules/ZenStartup.mjs +++ b/src/zen/common/modules/ZenStartup.mjs @@ -147,7 +147,7 @@ class ZenStartup { } #initUIComponents() { - const kUIComponents = ["ZenProgressBar"]; + const kUIComponents = ["ZenProgressBar", "ZenSpaceRoutingNavigation"]; for (let component of kUIComponents) { const module = ChromeUtils.importESModule( "resource:///modules/zen/ui/" + component + ".sys.mjs" diff --git a/src/zen/common/moz.build b/src/zen/common/moz.build index 115234dad..d904916ea 100644 --- a/src/zen/common/moz.build +++ b/src/zen/common/moz.build @@ -10,5 +10,6 @@ EXTRA_JS_MODULES += [ EXTRA_JS_MODULES.zen.ui += [ "sys/ui/ZenProgressBar.sys.mjs", + "sys/ui/ZenSpaceRoutingNavigation.sys.mjs", "sys/ui/ZenUIComponent.sys.mjs", ] diff --git a/src/zen/common/sys/ui/ZenSpaceRoutingNavigation.sys.mjs b/src/zen/common/sys/ui/ZenSpaceRoutingNavigation.sys.mjs new file mode 100644 index 000000000..fcf85bb19 --- /dev/null +++ b/src/zen/common/sys/ui/ZenSpaceRoutingNavigation.sys.mjs @@ -0,0 +1,122 @@ +// 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 { ZenUIComponent } from "resource:///modules/zen/ui/ZenUIComponent.sys.mjs"; + +/** + * Per-window listener that re-routes in-place navigations for Space Routing. + * + * When any top-level navigation (link click, address bar, JS redirect, form + * submit, ...) targets a URL whose rule points at a *different* space than the + * one the tab currently lives in, the load is cancelled and re-opened in a new + * tab. The new tab flows through tabbrowser's addTab() routing, which moves it + * into the matching space. + */ +export class ZenSpaceRoutingNavigation extends ZenUIComponent { + init() { + this.listenBrowserTabsProgress(); + } + + /** + * @param {MozBrowser} aBrowser - The browser the state change happened in + * @param {nsIWebProgress} aWebProgress - The web progress + * @param {nsIRequest} aRequest - The request driving the state change + * @param {number} aStateFlags - The nsIWebProgressListener state flags + */ + onStateChange(aBrowser, aWebProgress, aRequest, aStateFlags) { + const wpl = Ci.nsIWebProgressListener; + if ( + !aWebProgress?.isTopLevel || + !(aStateFlags & wpl.STATE_START) || + !(aStateFlags & wpl.STATE_IS_DOCUMENT) + ) { + return; + } + + // The tab we spawn for a route must be allowed to load once without being + // redirected again, regardless of when its workspace attribute lands. + if (aBrowser._zenSkipNavRouteOnce) { + aBrowser._zenSkipNavRouteOnce = false; + return; + } + + let uri; + try { + uri = aRequest.QueryInterface(Ci.nsIChannel).URI; + } catch (e) { + return; + } + if (!uri || !(uri.schemeIs("http") || uri.schemeIs("https"))) { + return; + } + + // Don't disturb a tab that is merely (re)loading the page it already shows: + // a reload, a session restore, or a tab that was already sitting on this URL + // before the rule was set. At STATE_START the browser's currentURI still + // points at the existing document, so an equal target means this isn't a + // new navigation worth routing. + let currentURI = null; + try { + currentURI = aBrowser.currentURI; + } catch (e) { + currentURI = null; + } + if (currentURI?.equals(uri)) { + return; + } + + const win = this.window; + const gBrowser = win.gBrowser; + const tab = gBrowser.getTabForBrowser(aBrowser); + if ( + !tab || + tab.pinned || + tab.hasAttribute("zen-empty-tab") || + tab.hasAttribute("zen-glance-tab") + ) { + return; + } + + const currentWorkspaceId = tab.getAttribute("zen-workspace-id"); + if ( + !win.gZenSpaceRoutingManager.shouldRedirectNavigation( + uri.spec, + currentWorkspaceId, + win + ) + ) { + return; + } + + // Under Fission the parent-side aRequest is a RemoteWebProgress stand-in + // whose cancel()/loadInfo throw NS_ERROR_NOT_IMPLEMENTED (the real channel + // lives in the content process). Stop the in-place load through the browser, + // which proxies the request to the content process. + try { + aBrowser.stop(); + } catch (e) { + return; + } + + const urlToOpen = uri.spec; + + // loadInfo isn't reachable on the remote request, so use the navigating + // page as the triggering principal (correct for link clicks), with a null + // principal as the safe last resort. + const principal = + aBrowser.contentPrincipal || + Services.scriptSecurityManager.createNullPrincipal({}); + + // Defer so we don't mutate the tab strip from inside a progress notification. + win.setTimeout(() => { + const newTab = gBrowser.addTab(urlToOpen, { + triggeringPrincipal: principal, + ownerTab: tab.isConnected ? tab : null, + }); + if (newTab?.linkedBrowser) { + newTab.linkedBrowser._zenSkipNavRouteOnce = true; + } + }, 0); + } +} diff --git a/src/zen/space-routing/ZenSpaceRoutingManager.sys.mjs b/src/zen/space-routing/ZenSpaceRoutingManager.sys.mjs index b5d07c7a3..e9cf24438 100644 --- a/src/zen/space-routing/ZenSpaceRoutingManager.sys.mjs +++ b/src/zen/space-routing/ZenSpaceRoutingManager.sys.mjs @@ -82,6 +82,40 @@ class nsZenSpaceRoutingManager { this.#routeToWorkspace(targetRoute, newTab, win); } + /** + * Decides whether an in-place top-level navigation should be pulled out of + * the current tab and re-opened in a new tab, so that addTab()'s routing can + * move it into the space its rule points at. + * + * Only navigations whose rule targets a *different* space than the one the + * navigating tab already lives in are redirected. Staying put when the tab is + * already in the destination space keeps normal browsing in place and also + * prevents the freshly routed tab from being redirected again (infinite loop). + * + * @param {string} uriString - The destination URI + * @param {string|null} currentWorkspaceId - The zen-workspace-id of the navigating tab + * @param {Window} win - The owning browser window + * @returns {boolean} True when the navigation should open in a new routed tab + */ + shouldRedirectNavigation(uriString, currentWorkspaceId, win) { + if (!win?.gZenWorkspaces?.workspaceEnabled) { + return false; + } + + const targetRoute = this.routeUri(uriString, { fromExternal: false }); + + // No specific destination, or the tab is already where the rule points. + if ( + targetRoute === "most-recent-space" || + targetRoute === currentWorkspaceId + ) { + return false; + } + + // Only redirect when the destination space actually exists. + return !!win.gZenWorkspaces.getWorkspaceFromId(targetRoute); + } + /** * Checks if the tab should be processed or not * diff --git a/src/zen/spaces/ZenGradientGenerator.mjs b/src/zen/spaces/ZenGradientGenerator.mjs index b5b7bd289..f93df4bdf 100644 --- a/src/zen/spaces/ZenGradientGenerator.mjs +++ b/src/zen/spaces/ZenGradientGenerator.mjs @@ -1494,12 +1494,9 @@ export class nsZenThemePicker extends nsZenMultiWindowFeature { return color; } - getToolbarColor(isDarkMode = false, accentColor = null) { + getToolbarColor(isDarkMode = false) { const opacity = 0.8; let baseColor = isDarkMode ? [255, 255, 255, opacity] : [0, 0, 0, opacity]; // Default toolbar - if (accentColor) { - return this.blendColors(baseColor.slice(0, 3), accentColor, 75).concat(1); - } return baseColor; } @@ -1765,7 +1762,7 @@ export class nsZenThemePicker extends nsZenMultiWindowFeature { docElement.style.setProperty("--zen-primary-color", primaryColor); // Set `--toolbox-textcolor` to have a contrast with the primary color - let textColor = this.getToolbarColor(isDarkMode, dominantColor); + let textColor = this.getToolbarColor(isDarkMode); docElement.style.setProperty( "--toolbox-textcolor", `rgba(${textColor[0]}, ${textColor[1]}, ${textColor[2]}, ${textColor[3]})` @@ -2011,7 +2008,7 @@ export class nsZenThemePicker extends nsZenMultiWindowFeature { grain: theme.texture ?? 0, isDarkMode, isExplicitMode, - toolbarColor: this.getToolbarColor(isDarkMode, dominantColor), + toolbarColor: this.getToolbarColor(isDarkMode), primaryColor: this.getAccentColorForUI(dominantColor, isDarkMode), }; this.currentOpacity = previousOpacity; diff --git a/src/zen/spaces/ZenSpaceManager.mjs b/src/zen/spaces/ZenSpaceManager.mjs index b75733b81..43dcb6dda 100644 --- a/src/zen/spaces/ZenSpaceManager.mjs +++ b/src/zen/spaces/ZenSpaceManager.mjs @@ -1498,7 +1498,6 @@ class nsZenWorkspaces { continue; } - tab.owner = null; if (container) { if (tab.group?.hasAttribute("split-view-group")) { gBrowser.zenHandleTabMove(tab.group, () => { @@ -2282,6 +2281,18 @@ class nsZenWorkspaces { ); } + onBeforeTabSelect(aTab) { + const tabSpace = aTab?.getAttribute("zen-workspace-id"); + if ( + tabSpace && + tabSpace !== this.activeWorkspace && + !aTab.hasAttribute("zen-empty-tab") && + !aTab.hasAttribute("zen-essential") + ) { + this.changeWorkspaceWithID(tabSpace); + } + } + _shouldShowTab(tab, workspaceUuid, containerId, workspaces) { const isEssential = tab.getAttribute("zen-essential") === "true"; const tabWorkspaceId = tab.getAttribute("zen-workspace-id"); diff --git a/src/zen/spaces/zen-workspaces.css b/src/zen/spaces/zen-workspaces.css index 0007b69da..d9fa560af 100644 --- a/src/zen/spaces/zen-workspaces.css +++ b/src/zen/spaces/zen-workspaces.css @@ -321,7 +321,11 @@ zen-workspace { position: absolute; height: 100%; overflow: hidden; - color: var(--toolbox-textcolor); + color: color-mix(in srgb, var(--toolbox-textcolor) 95%, var(--zen-primary-color)); + + --tab-selected-bgcolor: color-mix(in srgb, light-dark(rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0.18)) 95%, var(--zen-primary-color)) !important; + --tab-selected-shadow: 0 0.8px 1.5px 0px light-dark(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.05)) !important; + --tab-selected-textcolor: color-mix(in srgb, var(--toolbox-textcolor) 95%, var(--zen-primary-color)) !important; @media not (prefers-reduced-motion: reduce) { transition: padding-top 0.1s; diff --git a/src/zen/tabs/zen-tabs/vertical-tabs.css b/src/zen/tabs/zen-tabs/vertical-tabs.css index c24e2cc71..95b14688f 100644 --- a/src/zen/tabs/zen-tabs/vertical-tabs.css +++ b/src/zen/tabs/zen-tabs/vertical-tabs.css @@ -303,8 +303,6 @@ border-bottom: 0 solid transparent !important; --tab-block-margin: 2px; - --tab-selected-bgcolor: light-dark(rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0.18)); - --tab-selected-shadow: 0 0.8px 1.5px 0px light-dark(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.05)) !important; grid-gap: 0 !important; &[overflow]::after, diff --git a/src/zen/tests/space_routing/browser.toml b/src/zen/tests/space_routing/browser.toml index 278473300..f81904fa2 100644 --- a/src/zen/tests/space_routing/browser.toml +++ b/src/zen/tests/space_routing/browser.toml @@ -11,8 +11,12 @@ support-files = [ ["browser_space_routing_dialog.js"] +["browser_space_routing_fuzz.js"] + ["browser_space_routing_on_add_tab.js"] +["browser_space_routing_redirect_navigation.js"] + ["browser_space_routing_route_matching.js"] ["browser_space_routing_route_uri.js"] diff --git a/src/zen/tests/space_routing/browser_space_routing_dialog.js b/src/zen/tests/space_routing/browser_space_routing_dialog.js index 3733f7abd..ee186f1d9 100644 --- a/src/zen/tests/space_routing/browser_space_routing_dialog.js +++ b/src/zen/tests/space_routing/browser_space_routing_dialog.js @@ -199,7 +199,7 @@ add_task(async function test_routes_are_saved_on_close() { }; try { - const closed = BrowserTestUtils.domWindowClosed(dlg); + const closed = promiseRoutingDialogClosed(); dlg.close(); await TestUtils.waitForCondition( () => saveCalls > 0, @@ -241,11 +241,15 @@ add_task(async function test_open_broadcasts_kill_to_other_instances() { add_task(async function test_kill_notification_closes_dialog() { clearAllRoutes(); - const dlg = await openRoutingDialog(); + await openRoutingDialog(); - const closed = BrowserTestUtils.domWindowClosed(dlg); + const closed = promiseRoutingDialogClosed(); Services.obs.notifyObservers(null, "zen-space-routing-kill"); await closed; - ok(dlg.closed, "A 'zen-space-routing-kill' notification closes the dialog"); + const container = document.getElementById("window-modal-dialog"); + ok( + !container.open && !container.hasChildNodes(), + "A 'zen-space-routing-kill' notification closes the dialog" + ); }); diff --git a/src/zen/tests/space_routing/browser_space_routing_fuzz.js b/src/zen/tests/space_routing/browser_space_routing_fuzz.js new file mode 100644 index 000000000..f74d74c58 --- /dev/null +++ b/src/zen/tests/space_routing/browser_space_routing_fuzz.js @@ -0,0 +1,239 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Seeded fuzzing for the pure routing decision functions. The point is not to +// assert a particular routing outcome but to prove robustness invariants under +// adversarial input: the functions must never throw, must always return the +// declared type, and routeUri must only ever return a value it is allowed to. +// +// The RNG is seeded so any failure is reproducible: re-run with the logged seed. + +const FUZZ_SEED = 0x5eed1234; + +// mulberry32 — small, fast, deterministic PRNG. +function makeRng(seed) { + let s = seed >>> 0; + return function rng() { + s = (s + 0x6d2b79f5) | 0; + let t = Math.imul(s ^ (s >>> 15), 1 | s); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const DOMAIN_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-."; +const REGEX_CHARS = ".*+?^${}()|[]\\" + DOMAIN_CHARS; +const TRICKY_CHARS = + DOMAIN_CHARS + "%/?#:@!$&'()*+,;= []{}<>\"\\^`|~\tünïçødé日本語🚀"; +const SCHEMES = [ + "http://", + "https://", + "ftp://", + "file://", + "about:", + "data:text/plain,", + "javascript:", + "//", + "", +]; +const MATCH_TYPES = ["contains", "equal-to", "regex", "bogus-type", ""]; + +function randInt(rng, n) { + return Math.floor(rng() * n); +} +function pick(rng, arr) { + return arr[randInt(rng, arr.length)]; +} +function randString(rng, maxLen, charset) { + const len = randInt(rng, maxLen + 1); + let out = ""; + for (let i = 0; i < len; i++) { + out += charset[randInt(rng, charset.length)]; + } + return out; +} + +function randomUrl(rng) { + const scheme = pick(rng, SCHEMES); + const host = randString(rng, 30, DOMAIN_CHARS + "ünïçødé"); + const port = rng() < 0.2 ? ":" + randInt(rng, 99999) : ""; + const path = rng() < 0.7 ? "/" + randString(rng, 40, TRICKY_CHARS) : ""; + return scheme + host + port + path; +} + +function randomReference(rng) { + switch (randInt(rng, 5)) { + case 0: + return ""; + case 1: + return " "; + case 2: + return randString(rng, 30, DOMAIN_CHARS); + case 3: + // Deliberately regex-flavoured to exercise the "regex" match path. + return randString(rng, 20, REGEX_CHARS); + default: + return randString(rng, 50, TRICKY_CHARS); + } +} + +function randomRoute(rng, openIn = "most-recent-space") { + return { + id: "fuzz-" + randInt(rng, 1e9), + reference: randomReference(rng), + openIn, + matchType: pick(rng, MATCH_TYPES), + }; +} + +add_setup(async function () { + clearAllRoutes(); + registerCleanupFunction(() => clearAllRoutes()); + info(`Space Routing fuzz seed: 0x${FUZZ_SEED.toString(16)}`); +}); + +add_task(async function fuzz_isRouteMatching_never_throws() { + const rng = makeRng(FUZZ_SEED); + const ITERATIONS = 5000; + + for (let i = 0; i < ITERATIONS; i++) { + const url = randomUrl(rng); + const route = randomRoute(rng); + + let result; + try { + result = gZenSpaceRoutingManager.isRouteMatching(url, route); + } catch (e) { + ok( + false, + `isRouteMatching threw on url=${JSON.stringify( + url + )} route=${JSON.stringify(route)}: ${e}` + ); + continue; + } + + is( + typeof result, + "boolean", + `isRouteMatching must return a boolean (iter ${i})` + ); + + // An empty / whitespace reference can never match. + if (typeof route.reference !== "string" || route.reference.trim() === "") { + ok(!result, "Empty reference never matches"); + } + } +}); + +add_task(async function fuzz_routeUri_returns_only_valid_destinations() { + const rng = makeRng(FUZZ_SEED ^ 0x1111); + clearAllRoutes(); + + // Populate the manager with a mix of routes pointing at a few destinations. + const destinations = ["most-recent-space", "ws-a", "ws-b", "ws-c"]; + for (let i = 0; i < 200; i++) { + const r = randomRoute(rng, pick(rng, destinations)); + addRoute({ + reference: r.reference, + openIn: r.openIn, + matchType: r.matchType, + }); + } + + const allowed = new Set( + gZenSpaceRoutingManager.getAllRoutes().map(r => r.openIn) + ); + allowed.add("most-recent-space"); + const defaultExternal = gZenSpaceRoutingManager.getDefaultExternalRoute(); + allowed.add(defaultExternal); + + const ITERATIONS = 4000; + for (let i = 0; i < ITERATIONS; i++) { + const url = randomUrl(rng); + const fromExternal = rng() < 0.5; + + let result; + try { + result = gZenSpaceRoutingManager.routeUri(url, { fromExternal }); + } catch (e) { + ok(false, `routeUri threw on url=${JSON.stringify(url)}: ${e}`); + continue; + } + + is(typeof result, "string", `routeUri must return a string (iter ${i})`); + ok( + allowed.has(result), + `routeUri returned an out-of-set destination: ${JSON.stringify(result)}` + ); + } + + clearAllRoutes(); +}); + +add_task(async function fuzz_shouldRedirectNavigation_invariants() { + const rng = makeRng(FUZZ_SEED ^ 0x2222); + clearAllRoutes(); + + const workspaces = [ + { uuid: "ws-a", containerTabId: 1 }, + { uuid: "ws-b", containerTabId: 2 }, + ]; + const win = makeFakeWindow({ workspaces }); + + for (let i = 0; i < 120; i++) { + const r = randomRoute( + rng, + pick(rng, ["ws-a", "ws-b", "most-recent-space"]) + ); + addRoute({ + reference: r.reference, + openIn: r.openIn, + matchType: r.matchType, + }); + } + + const ITERATIONS = 4000; + const currentChoices = ["ws-a", "ws-b", "ws-other", "", null]; + + for (let i = 0; i < ITERATIONS; i++) { + const url = randomUrl(rng); + const currentWorkspaceId = pick(rng, currentChoices); + + let result; + try { + result = gZenSpaceRoutingManager.shouldRedirectNavigation( + url, + currentWorkspaceId, + win + ); + } catch (e) { + ok( + false, + `shouldRedirectNavigation threw on url=${JSON.stringify(url)}: ${e}` + ); + continue; + } + + is(typeof result, "boolean", "shouldRedirectNavigation returns a boolean"); + + if (result) { + // If we decided to redirect, the target must be a real, *different* space. + const target = gZenSpaceRoutingManager.routeUri(url, { + fromExternal: false, + }); + ok( + target !== "most-recent-space" && target !== currentWorkspaceId, + `Redirect target must differ from current space (url=${url})` + ); + ok( + !!win.gZenWorkspaces.getWorkspaceFromId(target), + "Redirect target must be an existing workspace" + ); + } + } + + clearAllRoutes(); +}); diff --git a/src/zen/tests/space_routing/browser_space_routing_redirect_navigation.js b/src/zen/tests/space_routing/browser_space_routing_redirect_navigation.js new file mode 100644 index 000000000..ba22536d1 --- /dev/null +++ b/src/zen/tests/space_routing/browser_space_routing_redirect_navigation.js @@ -0,0 +1,127 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Exercises nsZenSpaceRoutingManager.shouldRedirectNavigation: an in-place +// navigation is only redirected into a new tab when its rule points at a space +// that differs from the one the navigating tab already lives in. + +const TARGET_WS = { uuid: "ws-target", containerTabId: 7 }; + +add_setup(async function () { + clearAllRoutes(); + registerCleanupFunction(() => clearAllRoutes()); +}); + +add_task(async function test_redirect_when_route_targets_other_space() { + clearAllRoutes(); + addRoute({ + reference: "github.com", + matchType: "contains", + openIn: TARGET_WS.uuid, + }); + const win = makeFakeWindow({ workspaces: [TARGET_WS] }); + + ok( + gZenSpaceRoutingManager.shouldRedirectNavigation( + "https://github.com/zen", + "ws-current", + win + ), + "Navigating to a routed site from a different space redirects" + ); +}); + +add_task(async function test_no_redirect_when_already_in_target_space() { + clearAllRoutes(); + addRoute({ + reference: "github.com", + matchType: "contains", + openIn: TARGET_WS.uuid, + }); + const win = makeFakeWindow({ workspaces: [TARGET_WS] }); + + ok( + !gZenSpaceRoutingManager.shouldRedirectNavigation( + "https://github.com/zen", + TARGET_WS.uuid, + win + ), + "Already in the destination space navigates in place (and avoids a loop)" + ); +}); + +add_task(async function test_no_redirect_when_no_rule_matches() { + clearAllRoutes(); + const win = makeFakeWindow({ workspaces: [TARGET_WS] }); + + ok( + !gZenSpaceRoutingManager.shouldRedirectNavigation( + "https://example.com", + "ws-current", + win + ), + "An unmatched URL is never redirected" + ); +}); + +add_task(async function test_no_redirect_when_rule_targets_most_recent() { + clearAllRoutes(); + addRoute({ + reference: "github.com", + matchType: "contains", + openIn: "most-recent-space", + }); + const win = makeFakeWindow({ workspaces: [TARGET_WS] }); + + ok( + !gZenSpaceRoutingManager.shouldRedirectNavigation( + "https://github.com", + "ws-current", + win + ), + "A rule that opens in the most recent space is not redirected" + ); +}); + +add_task(async function test_no_redirect_when_target_workspace_missing() { + clearAllRoutes(); + addRoute({ + reference: "github.com", + matchType: "contains", + openIn: "ws-does-not-exist", + }); + const win = makeFakeWindow({ workspaces: [TARGET_WS] }); + + ok( + !gZenSpaceRoutingManager.shouldRedirectNavigation( + "https://github.com", + "ws-current", + win + ), + "A rule pointing at a missing workspace is not redirected" + ); +}); + +add_task(async function test_no_redirect_when_workspaces_disabled() { + clearAllRoutes(); + addRoute({ + reference: "github.com", + matchType: "contains", + openIn: TARGET_WS.uuid, + }); + const win = makeFakeWindow({ + workspaces: [TARGET_WS], + workspaceEnabled: false, + }); + + ok( + !gZenSpaceRoutingManager.shouldRedirectNavigation( + "https://github.com", + "ws-current", + win + ), + "Nothing is redirected when workspaces are disabled" + ); +}); diff --git a/src/zen/tests/space_routing/head.js b/src/zen/tests/space_routing/head.js index ab6717d65..943fb364f 100644 --- a/src/zen/tests/space_routing/head.js +++ b/src/zen/tests/space_routing/head.js @@ -29,10 +29,15 @@ function addRoute({ return route; } -function makeFakeWindow({ ready = true, workspaces = [] } = {}) { +function makeFakeWindow({ + ready = true, + workspaces = [], + workspaceEnabled = true, +} = {}) { return { gZenStartup: { isReady: ready }, gZenWorkspaces: { + workspaceEnabled, moveCalls: [], changeCalls: [], lastSelectedWorkspaceTabs: {}, @@ -57,12 +62,17 @@ async function flushEventLoop() { } async function openRoutingDialog() { - const dialogPromise = BrowserTestUtils.domWindowOpenedAndLoaded(null, win => - win.document?.documentURI?.includes("zen-space-routing.xhtml") + // openSpaceRoutingDialog() presents an in-window modal through gDialogBox, so + // the dialog is a subdialog rather than a separate top-level window. + const dialogPromise = BrowserTestUtils.promiseAlertDialogOpen( + null, + SR_DIALOG_URI, + { isSubDialog: true } ); + // gDialogBox.open() only resolves once the dialog is dismissed, so kick it off + // without awaiting and wait on the open notification instead. executeSoon(() => gZenSpaceRoutingManager.openSpaceRoutingDialog(window)); const dialogWin = await dialogPromise; - await SimpleTest.promiseFocus(dialogWin); await TestUtils.waitForCondition( () => dialogWin.spaceroutingDialog?.initialized, "Space Routing dialog finished initializing" @@ -70,11 +80,23 @@ async function openRoutingDialog() { return dialogWin; } -async function closeRoutingDialog(dialogWin) { - if (dialogWin.closed) { - return; +// Resolves once the gDialogBox subdialog has fully torn down. Use this instead +// of BrowserTestUtils.domWindowClosed(), which only fires for separate +// top-level windows and so never resolves for an in-window subdialog. +function promiseRoutingDialogClosed() { + const container = document.getElementById("window-modal-dialog"); + if (!container?.open) { + return Promise.resolve(); } - const closed = BrowserTestUtils.domWindowClosed(dialogWin); + return BrowserTestUtils.waitForMutationCondition( + container, + { childList: true, attributes: true }, + () => !container.hasChildNodes() && !container.open + ); +} + +async function closeRoutingDialog(dialogWin) { + const closed = promiseRoutingDialogClosed(); dialogWin.close(); await closed; } diff --git a/src/zen/tests/spaces/browser.toml b/src/zen/tests/spaces/browser.toml index 99c7db003..a6bdae3ce 100644 --- a/src/zen/tests/spaces/browser.toml +++ b/src/zen/tests/spaces/browser.toml @@ -26,6 +26,8 @@ support-files = [ ["browser_private_mode_startup.js"] +["browser_select_tab_switches_space.js"] + ["browser_unload_all_other_spaces.js"] ["browser_workspace_bookmarks.js"] diff --git a/src/zen/tests/spaces/browser_select_tab_switches_space.js b/src/zen/tests/spaces/browser_select_tab_switches_space.js new file mode 100644 index 000000000..4fd738f17 --- /dev/null +++ b/src/zen/tests/spaces/browser_select_tab_switches_space.js @@ -0,0 +1,69 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +function fakeTab(workspaceId) { + return { + getAttribute(name) { + return name === "zen-workspace-id" ? workspaceId : null; + }, + }; +} + +function withRecordedSwitch(fn) { + const calls = []; + gZenWorkspaces.changeWorkspaceWithID = id => { + calls.push(id); + }; + try { + fn(calls); + } finally { + // Remove the own property so the prototype method shows through again. + delete gZenWorkspaces.changeWorkspaceWithID; + } +} + +add_task(function test_switches_when_tab_in_other_space() { + withRecordedSwitch(calls => { + const otherSpace = gZenWorkspaces.activeWorkspace + "-different"; + gZenWorkspaces.onBeforeTabSelect(fakeTab(otherSpace)); + Assert.deepEqual( + calls, + [otherSpace], + "Selecting a tab from another space switches to that space" + ); + }); +}); + +add_task(function test_no_switch_when_tab_in_active_space() { + withRecordedSwitch(calls => { + const active = gZenWorkspaces.activeWorkspace; + Assert.ok(active, "Test relies on a non-empty active workspace"); + gZenWorkspaces.onBeforeTabSelect(fakeTab(active)); + Assert.deepEqual( + calls, + [], + "Selecting a tab already in the active space does not switch" + ); + }); +}); + +add_task(function test_no_switch_when_tab_has_no_space() { + withRecordedSwitch(calls => { + gZenWorkspaces.onBeforeTabSelect(fakeTab(null)); + Assert.deepEqual( + calls, + [], + "A tab with no zen-workspace-id does not switch spaces" + ); + }); +}); + +add_task(function test_handles_missing_tab() { + withRecordedSwitch(calls => { + gZenWorkspaces.onBeforeTabSelect(null); + gZenWorkspaces.onBeforeTabSelect(undefined); + Assert.deepEqual(calls, [], "A missing tab is ignored without throwing"); + }); +});