From 880d61df16db2bc039636025482e07a9c42767d0 Mon Sep 17 00:00:00 2001 From: fen4flo <75260616+FlorianButz@users.noreply.github.com> Date: Sat, 6 Jun 2026 14:11:42 +0200 Subject: [PATCH] gh-14044: Implement Space Routing (gh-13981) Signed-off-by: mr. m <91018726+mr-cheffy@users.noreply.github.com> --- crowdin.yml | 2 + .../browser/browser/zen-space-routing.ftl | 25 + .../base/content/zen-assets.jar.inc.mn | 1 + .../base/content/zen-commands.inc.xhtml | 1 + .../base/content/zen-locales.inc.xhtml | 1 + .../base/content/zen-panels/popups.inc | 1 + .../tabbrowser/content/tabbrowser-js.patch | 163 ++++--- src/browser/themes/shared/zen-icons/icons.css | 14 +- .../themes/shared/zen-icons/jar.inc.mn | 3 + .../nucleo/arrow-corner-down-right.svg | 5 + src/zen/common/ZenPreloadedScripts.js | 5 + src/zen/common/styles/zen-panels/dialog.css | 4 +- src/zen/common/zen-sets.js | 4 + src/zen/glance/ZenGlanceManager.mjs | 1 + src/zen/moz.build | 1 + .../space-routing/ZenSpaceRoutingDialog.mjs | 454 ++++++++++++++++++ .../ZenSpaceRoutingManager.sys.mjs | 413 ++++++++++++++++ src/zen/space-routing/jar.inc.mn | 9 + src/zen/space-routing/moz.build | 8 + src/zen/space-routing/zen-space-routing.css | 315 ++++++++++++ .../space-routing/zen-space-routing.inc.xhtml | 108 +++++ src/zen/split-view/ZenViewSplitter.mjs | 5 +- src/zen/tests/moz.build | 1 + src/zen/tests/space_routing/browser.toml | 18 + .../browser_space_routing_crud.js | 115 +++++ .../browser_space_routing_dialog.js | 251 ++++++++++ .../browser_space_routing_on_add_tab.js | 363 ++++++++++++++ .../browser_space_routing_route_matching.js | 108 +++++ .../browser_space_routing_route_uri.js | 66 +++ src/zen/tests/space_routing/head.js | 80 +++ src/zen/urlbar/ZenUBGlobalActions.sys.mjs | 5 + src/zen/zen.globals.mjs | 2 + 32 files changed, 2475 insertions(+), 77 deletions(-) create mode 100644 locales/en-US/browser/browser/zen-space-routing.ftl create mode 100644 src/browser/themes/shared/zen-icons/nucleo/arrow-corner-down-right.svg create mode 100644 src/zen/space-routing/ZenSpaceRoutingDialog.mjs create mode 100644 src/zen/space-routing/ZenSpaceRoutingManager.sys.mjs create mode 100644 src/zen/space-routing/jar.inc.mn create mode 100644 src/zen/space-routing/moz.build create mode 100644 src/zen/space-routing/zen-space-routing.css create mode 100644 src/zen/space-routing/zen-space-routing.inc.xhtml create mode 100644 src/zen/tests/space_routing/browser.toml create mode 100644 src/zen/tests/space_routing/browser_space_routing_crud.js create mode 100644 src/zen/tests/space_routing/browser_space_routing_dialog.js create mode 100644 src/zen/tests/space_routing/browser_space_routing_on_add_tab.js create mode 100644 src/zen/tests/space_routing/browser_space_routing_route_matching.js create mode 100644 src/zen/tests/space_routing/browser_space_routing_route_uri.js create mode 100644 src/zen/tests/space_routing/head.js diff --git a/crowdin.yml b/crowdin.yml index 469c8a1cd..964f52d44 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -22,3 +22,5 @@ files: translation: browser/browser/zen-folders.ftl - source: en-US/browser/browser/zen-boosts.ftl translation: browser/browser/zen-boosts.ftl + - source: en-US/browser/browser/zen-space-routing.ftl + translation: browser/browser/zen-space-routing.ftl diff --git a/locales/en-US/browser/browser/zen-space-routing.ftl b/locales/en-US/browser/browser/zen-space-routing.ftl new file mode 100644 index 000000000..b8f063fb7 --- /dev/null +++ b/locales/en-US/browser/browser/zen-space-routing.ftl @@ -0,0 +1,25 @@ +# 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/. + +zen-space-routing-settings = + .label = Space Routing Settings +zen-space-routing-rulepanel-placeholder = Routes let you choose where specific sites open inside Zen. For example, you can route YouTube links to always open inside your Personal space. +zen-space-routing-dialog-title = Space Routing Settings +zen-space-routing-external-default = Default route for external links +zen-space-routing-new-route = New Route +zen-space-routing-open-in-space = Open in Space +zen-space-routing-most-recent-space = Most recent Space +zen-space-routing-close-button = + .aria-label = Close + .tooltiptext = Close + +zen-space-routing-contains = + .label = Contains +zen-space-routing-equal-to = + .label = Is Equal To +zen-space-routing-regex = + .label = RegEx + +zen-space-routing-open-in = Open In +zen-space-routing-url = URL \ No newline at end of file diff --git a/src/browser/base/content/zen-assets.jar.inc.mn b/src/browser/base/content/zen-assets.jar.inc.mn index dac39decc..07d61a897 100644 --- a/src/browser/base/content/zen-assets.jar.inc.mn +++ b/src/browser/base/content/zen-assets.jar.inc.mn @@ -20,3 +20,4 @@ #include ../../../zen/fonts/jar.inc.mn #include ../../../zen/boosts/jar.inc.mn #include ../../../zen/live-folders/jar.inc.mn +#include ../../../zen/space-routing/jar.inc.mn diff --git a/src/browser/base/content/zen-commands.inc.xhtml b/src/browser/base/content/zen-commands.inc.xhtml index 4f9e0fca9..2122c56f0 100644 --- a/src/browser/base/content/zen-commands.inc.xhtml +++ b/src/browser/base/content/zen-commands.inc.xhtml @@ -41,6 +41,7 @@ + diff --git a/src/browser/base/content/zen-locales.inc.xhtml b/src/browser/base/content/zen-locales.inc.xhtml index ed52e6fca..2b0ec6692 100644 --- a/src/browser/base/content/zen-locales.inc.xhtml +++ b/src/browser/base/content/zen-locales.inc.xhtml @@ -11,4 +11,5 @@ + diff --git a/src/browser/base/content/zen-panels/popups.inc b/src/browser/base/content/zen-panels/popups.inc index c888a26c2..bc9abc04e 100644 --- a/src/browser/base/content/zen-panels/popups.inc +++ b/src/browser/base/content/zen-panels/popups.inc @@ -30,6 +30,7 @@ + diff --git a/src/browser/components/tabbrowser/content/tabbrowser-js.patch b/src/browser/components/tabbrowser/content/tabbrowser-js.patch index 95bf58cee..ef96e759e 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..146b1559b8430773bd4ec173a8f4fe88f4eec282 100644 +index 43fb79a3060e20f671ae6ffc26350c7abf497702..2da16e06541438ece4a3ae3a1663a1559780fe22 100644 --- a/browser/components/tabbrowser/content/tabbrowser.js +++ b/browser/components/tabbrowser/content/tabbrowser.js @@ -502,6 +502,7 @@ @@ -264,23 +264,32 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 focusUrlBar: true, }); resolve(this.selectedBrowser); -@@ -3285,6 +3369,9 @@ +@@ -3285,6 +3369,10 @@ schemelessInput, hasValidUserGestureActivation = false, textDirectiveUserActivation = false, + _forZenEmptyTab, + essential, + zenWorkspaceId, ++ skipRoute = false, } = {} ) { // all callers of addTab that pass a params object need to pass -@@ -3295,10 +3382,17 @@ +@@ -3295,10 +3383,25 @@ ); } ++ const beforeRouteResult = window.gZenSpaceRoutingManager.onBeforeAddTab(uriString, { skipRoute, pinned, tabGroup, fromExternal }, window); ++ if (beforeRouteResult.shouldEarlyExit) { ++ return null; ++ } ++ + let hasZenDefaultUserContextId = false; + let zenForcedWorkspaceId = undefined; -+ if (typeof gZenWorkspaces !== "undefined" && !_forZenEmptyTab) { ++ if (beforeRouteResult.userContextId) { ++ userContextId = beforeRouteResult.userContextId; ++ hasZenDefaultUserContextId = true; ++ } else if (typeof gZenWorkspaces !== "undefined" && !_forZenEmptyTab) { + [userContextId, hasZenDefaultUserContextId, zenForcedWorkspaceId] = gZenWorkspaces.getContextIdIfNeeded(userContextId, fromExternal, triggeringPrincipal); + } + @@ -292,7 +301,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 // If we're opening a foreground tab, set the owner by default. ownerTab ??= inBackground ? null : this.selectedTab; -@@ -3306,6 +3400,7 @@ +@@ -3306,6 +3409,7 @@ if (this.selectedTab.owner) { this.selectedTab.owner = null; } @@ -300,7 +309,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 // Find the tab that opened this one, if any. This is used for // determining positioning, and inherited attributes such as the -@@ -3358,6 +3453,22 @@ +@@ -3358,6 +3462,22 @@ noInitialLabel, skipBackgroundNotify, }); @@ -323,7 +332,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 if (insertTab) { // Insert the tab into the tab container in the correct position. this.#insertTabAtIndex(t, { -@@ -3366,6 +3477,7 @@ +@@ -3366,6 +3486,7 @@ ownerTab, openerTab, pinned, @@ -331,7 +340,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 bulkOrderedOpen, tabGroup: tabGroup ?? openerTab?.group, }); -@@ -3384,6 +3496,7 @@ +@@ -3384,6 +3505,7 @@ openWindowInfo, skipLoad, triggeringRemoteType, @@ -339,7 +348,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 })); if (focusUrlBar) { -@@ -3508,6 +3621,12 @@ +@@ -3508,6 +3630,12 @@ } } @@ -352,7 +361,17 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 // Additionally send pinned tab events if (pinned) { this.#notifyPinnedStatus(t); -@@ -3750,6 +3869,7 @@ +@@ -3518,6 +3646,9 @@ + if (!inBackground) { + this.selectedTab = t; + } ++ ++ window.gZenSpaceRoutingManager.onAfterAddTab(uriString, t, { skipRoute: skipRoute || _forZenEmptyTab, fromExternal, pinned, tabGroup }, window, beforeRouteResult); ++ + return t; + } + +@@ -3750,6 +3881,7 @@ isAdoptingGroup = false, isUserTriggered = false, telemetryUserCreateSource = "unknown", @@ -360,7 +379,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 } = {} ) { if ( -@@ -3760,9 +3880,6 @@ +@@ -3760,9 +3892,6 @@ !this.isSplitViewWrapper(tabOrSplitView) ) ) { @@ -370,7 +389,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 } if (!color) { -@@ -3783,9 +3900,14 @@ +@@ -3783,9 +3912,14 @@ label, isAdoptingGroup ); @@ -387,7 +406,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 ); group.addTabs(tabsAndSplitViews); -@@ -3906,7 +4028,7 @@ +@@ -3906,7 +4040,7 @@ } this.#handleTabMove(tab, () => @@ -396,7 +415,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 ); } -@@ -3990,6 +4112,7 @@ +@@ -3990,6 +4124,7 @@ color: group.color, insertBefore: newTabs[0], isAdoptingGroup: true, @@ -404,7 +423,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 }); } -@@ -4200,6 +4323,7 @@ +@@ -4200,6 +4335,7 @@ openWindowInfo, skipLoad, triggeringRemoteType, @@ -412,7 +431,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 } ) { // If we don't have a preferred remote type (or it is `NOT_REMOTE`), and -@@ -4269,6 +4393,7 @@ +@@ -4269,6 +4405,7 @@ openWindowInfo, name, skipLoad, @@ -420,7 +439,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 }); } -@@ -4482,9 +4607,9 @@ +@@ -4482,9 +4619,9 @@ } // Add a new tab if needed. @@ -432,7 +451,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 let url = "about:blank"; if (tabData.entries?.length) { -@@ -4521,8 +4646,10 @@ +@@ -4521,8 +4658,10 @@ insertTab: false, skipLoad: true, preferredRemoteType, @@ -444,7 +463,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 if (select) { tabToSelect = tab; } -@@ -4544,7 +4671,8 @@ +@@ -4544,7 +4683,8 @@ this.pinTab(tab); // Then ensure all the tab open/pinning information is sent. this._fireTabOpen(tab, {}); @@ -454,7 +473,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 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 +4692,10 @@ +@@ -4564,7 +4704,10 @@ tabGroup.stateData.id, tabGroup.stateData.color, tabGroup.stateData.collapsed, @@ -466,7 +485,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 ); tabsFragment.appendChild(tabGroup.node); } -@@ -4619,9 +4750,21 @@ +@@ -4619,9 +4762,21 @@ // to remove the old selected tab. if (tabToSelect) { let leftoverTab = this.selectedTab; @@ -488,7 +507,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 if (tabs.length > 1 || !tabs[0].selected) { this._updateTabsAfterInsert(); -@@ -4812,11 +4955,14 @@ +@@ -4812,11 +4967,14 @@ if (ownerTab) { tab.owner = ownerTab; } @@ -504,7 +523,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 if ( !bulkOrderedOpen && ((openerTab && -@@ -4828,7 +4974,7 @@ +@@ -4828,7 +4986,7 @@ let lastRelatedTab = openerTab && this._lastRelatedTabMap.get(openerTab); let previousTab = lastRelatedTab || openerTab || this.selectedTab; @@ -513,7 +532,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 tabGroup = previousTab.group; } if ( -@@ -4844,7 +4990,7 @@ +@@ -4844,7 +5002,7 @@ previousTab.splitview ) + 1; } else if (previousTab.visible) { @@ -522,7 +541,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 } else if (previousTab == FirefoxViewHandler.tab) { elementIndex = 0; } -@@ -4872,14 +5018,14 @@ +@@ -4872,14 +5030,14 @@ } // Ensure index is within bounds. if (tab.pinned) { @@ -541,7 +560,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 if (pinned && !itemAfter?.pinned) { itemAfter = null; -@@ -4896,7 +5042,7 @@ +@@ -4896,7 +5054,7 @@ this.tabContainer._invalidateCachedTabs(); @@ -550,7 +569,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 if ( (this.isTab(itemAfter) && itemAfter.group == tabGroup) || this.isSplitViewWrapper(itemAfter) -@@ -4927,7 +5073,11 @@ +@@ -4927,7 +5085,11 @@ const tabContainer = pinned ? this.tabContainer.pinnedTabsContainer : this.tabContainer; @@ -562,7 +581,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 } if (tab.group?.collapsed) { -@@ -4942,6 +5092,7 @@ +@@ -4942,6 +5104,7 @@ if (pinned) { this._updateTabBarForPinnedTabs(); } @@ -570,7 +589,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 TabBarVisibility.update(); } -@@ -5490,6 +5641,7 @@ +@@ -5490,6 +5653,7 @@ telemetrySource, } = {} ) { @@ -578,7 +597,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 // When 'closeWindowWithLastTab' pref is enabled, closing all tabs // can be considered equivalent to closing the window. if ( -@@ -5579,6 +5731,7 @@ +@@ -5579,6 +5743,7 @@ if (lastToClose) { this.removeTab(lastToClose, aParams); } @@ -586,7 +605,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 } catch (e) { console.error(e); } -@@ -5624,6 +5777,14 @@ +@@ -5624,6 +5789,14 @@ return; } @@ -601,7 +620,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 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 +5792,9 @@ +@@ -5631,6 +5804,9 @@ // state). let tabWidth = window.windowUtils.getBoundsWithoutFlushing(aTab).width; let isLastTab = this.#isLastTabInWindow(aTab); @@ -611,7 +630,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 if ( !this._beginRemoveTab(aTab, { closeWindowFastpath: true, -@@ -5642,13 +5806,14 @@ +@@ -5642,13 +5818,14 @@ telemetrySource, }) ) { @@ -627,7 +646,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 let lockTabSizing = !this.tabContainer.verticalMode && !aTab.pinned && -@@ -5679,7 +5844,13 @@ +@@ -5679,7 +5856,13 @@ // We're not animating, so we can cancel the animation stopwatch. Glean.browserTabclose.timeAnim.cancel(aTab._closeTimeAnimTimerId); aTab._closeTimeAnimTimerId = null; @@ -642,7 +661,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 return; } -@@ -5813,7 +5984,7 @@ +@@ -5813,7 +5996,7 @@ closeWindowWithLastTab != null ? closeWindowWithLastTab : !window.toolbar.visible || @@ -651,7 +670,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 if (closeWindow) { // We've already called beforeunload on all the relevant tabs if we get here, -@@ -5837,6 +6008,7 @@ +@@ -5837,6 +6020,7 @@ newTab = true; } @@ -659,7 +678,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 aTab._endRemoveArgs = [closeWindow, newTab]; // swapBrowsersAndCloseOther will take care of closing the window without animation. -@@ -5877,13 +6049,7 @@ +@@ -5877,13 +6061,7 @@ aTab._mouseleave(); if (newTab) { @@ -674,7 +693,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 } else { TabBarVisibility.update(); } -@@ -6016,6 +6182,7 @@ +@@ -6016,6 +6194,7 @@ this.tabs[i]._tPos = i; } @@ -682,7 +701,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 if (!this._windowIsClosing) { // update tab close buttons state this.tabContainer._updateCloseButtons(); -@@ -6201,6 +6368,7 @@ +@@ -6201,6 +6380,7 @@ memory_after: await getTotalMemoryUsage(), time_to_unload_in_ms: timeElapsed, }); @@ -690,7 +709,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 } /** -@@ -6246,6 +6414,7 @@ +@@ -6246,6 +6426,7 @@ } let excludeTabs = new Set(aExcludeTabs); @@ -698,7 +717,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 // If this tab has a successor, it should be selectable, since // hiding or closing a tab removes that tab as a successor. -@@ -6258,15 +6427,22 @@ +@@ -6258,15 +6439,22 @@ !excludeTabs.has(aTab.owner) && Services.prefs.getBoolPref("browser.tabs.selectOwnerOnClose") ) { @@ -723,7 +742,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 let tab = this.tabContainer.findNextTab(aTab, { direction: 1, filter: _tab => remainingTabs.includes(_tab), -@@ -6280,7 +6456,7 @@ +@@ -6280,7 +6468,7 @@ } if (tab) { @@ -732,7 +751,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 } // If no qualifying visible tab was found, see if there is a tab in -@@ -6301,7 +6477,7 @@ +@@ -6301,7 +6489,7 @@ }); } @@ -741,7 +760,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 } _blurTab(aTab) { -@@ -6312,7 +6488,7 @@ +@@ -6312,7 +6500,7 @@ * @returns {boolean} * False if swapping isn't permitted, true otherwise. */ @@ -750,7 +769,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 // Do not allow transfering a private tab to a non-private window // and vice versa. if ( -@@ -6366,6 +6542,7 @@ +@@ -6366,6 +6554,7 @@ // fire the beforeunload event in the process. Close the other // window if this was its last tab. if ( @@ -758,7 +777,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 !remoteBrowser._beginRemoveTab(aOtherTab, { adoptedByTab: aOurTab, closeWindowWithLastTab: true, -@@ -6377,7 +6554,7 @@ +@@ -6377,7 +6566,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. @@ -767,7 +786,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 if (closeWindow) { let win = aOtherTab.ownerGlobal; win.windowUtils.suppressAnimation(true); -@@ -6511,11 +6688,13 @@ +@@ -6511,11 +6700,13 @@ } // Finish tearing down the tab that's going away. @@ -781,7 +800,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 this.setTabTitle(aOurTab); -@@ -6717,10 +6896,10 @@ +@@ -6717,10 +6908,10 @@ SessionStore.deleteCustomTabValue(aTab, "hiddenBy"); } @@ -794,7 +813,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 aTab.selected || aTab.closing || // Tabs that are sharing the screen, microphone or camera cannot be hidden. -@@ -6780,7 +6959,8 @@ +@@ -6780,7 +6971,8 @@ * @param {object} [aOptions={}] * Key-value pairs that will be serialized into the features string. */ @@ -804,7 +823,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 if (this.tabs.length == 1) { return null; } -@@ -6797,7 +6977,7 @@ +@@ -6797,7 +6989,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); @@ -813,7 +832,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 private: PrivateBrowsingUtils.isWindowPrivate(window), features: Object.entries(aOptions) .map(([key, value]) => `${key}=${value}`) -@@ -6805,6 +6985,8 @@ +@@ -6805,6 +6997,8 @@ openerWindow: window, args, }); @@ -822,7 +841,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 } /** -@@ -6917,7 +7099,7 @@ +@@ -6917,7 +7111,7 @@ * `true` if element is a `` */ isTabGroup(element) { @@ -831,7 +850,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 } /** -@@ -7002,8 +7184,8 @@ +@@ -7002,8 +7196,8 @@ } // Don't allow mixing pinned and unpinned tabs. @@ -842,7 +861,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 } else { tabIndex = Math.max(tabIndex, this.pinnedTabCount); } -@@ -7049,8 +7231,8 @@ +@@ -7049,8 +7243,8 @@ this.#handleTabMove( element, () => { @@ -853,7 +872,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 neighbor = neighbor.group; } if (neighbor?.splitview) { -@@ -7061,6 +7243,12 @@ +@@ -7061,6 +7255,12 @@ return; } } @@ -866,7 +885,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 if (movingForwards && neighbor) { neighbor.after(element); -@@ -7119,23 +7307,31 @@ +@@ -7119,23 +7319,31 @@ #moveTabNextTo(element, targetElement, moveBefore = false, metricsContext) { if (this.isTabGroupLabel(targetElement)) { targetElement = targetElement.group; @@ -904,7 +923,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 } 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 +7344,35 @@ +@@ -7148,12 +7356,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. @@ -941,7 +960,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 // 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 +7381,7 @@ +@@ -7162,6 +7393,7 @@ } let getContainer = () => @@ -949,7 +968,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 element.pinned ? this.tabContainer.pinnedTabsContainer : this.tabContainer; -@@ -7170,11 +7390,15 @@ +@@ -7170,11 +7402,15 @@ element, () => { if (moveBefore) { @@ -966,7 +985,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 } }, metricsContext -@@ -7248,11 +7472,15 @@ +@@ -7248,11 +7484,15 @@ * @param {TabMetricsContext} [metricsContext] */ moveTabToExistingGroup(aTab, aGroup, metricsContext) { @@ -985,7 +1004,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 } if (aTab.group && aTab.group.id === aGroup.id) { return; -@@ -7324,6 +7552,7 @@ +@@ -7324,6 +7564,7 @@ let state = { tabIndex: tab._tPos, @@ -993,7 +1012,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 }; if (tab.visible) { state.elementIndex = tab.elementIndex; -@@ -7355,7 +7584,7 @@ +@@ -7355,7 +7596,7 @@ let changedSplitView = previousTabState.splitViewId != currentTabState.splitViewId; @@ -1002,7 +1021,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 tab.dispatchEvent( new CustomEvent("TabMove", { bubbles: true, -@@ -7402,6 +7631,10 @@ +@@ -7402,6 +7643,10 @@ moveActionCallback(); @@ -1013,7 +1032,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 // Clear tabs cache after moving nodes because the order of tabs may have // changed. this.tabContainer._invalidateCachedTabs(); -@@ -7452,7 +7685,22 @@ +@@ -7452,7 +7697,22 @@ * @returns {object} * The new tab in the current window, null if the tab couldn't be adopted. */ @@ -1037,7 +1056,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 // 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 +7743,8 @@ +@@ -7495,6 +7755,8 @@ } params.skipLoad = true; let newTab = this.addWebTab("about:blank", params); @@ -1046,7 +1065,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 aTab.container.tabDragAndDrop.finishAnimateTabMove(); -@@ -8205,7 +8455,7 @@ +@@ -8205,7 +8467,7 @@ // preventDefault(). It will still raise the window if appropriate. return; } @@ -1055,7 +1074,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 window.focus(); aEvent.preventDefault(); } -@@ -8222,7 +8472,6 @@ +@@ -8222,7 +8484,6 @@ on_TabGroupCollapse(aEvent) { aEvent.target.tabs.forEach(tab => { @@ -1063,7 +1082,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 }); } -@@ -8556,7 +8805,9 @@ +@@ -8556,7 +8817,9 @@ let filter = this._tabFilters.get(tab); if (filter) { @@ -1073,7 +1092,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 let listener = this._tabListeners.get(tab); if (listener) { -@@ -9359,6 +9610,7 @@ +@@ -9359,6 +9622,7 @@ aWebProgress.isTopLevel ) { this.mTab.setAttribute("busy", "true"); @@ -1081,7 +1100,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 gBrowser._tabAttrModified(this.mTab, ["busy"]); this.mTab._notselectedsinceload = !this.mTab.selected; } -@@ -9439,6 +9691,7 @@ +@@ -9439,6 +9703,7 @@ // known defaults. Note we use the original URL since about:newtab // redirects to a prerendered page. const shouldRemoveFavicon = @@ -1089,7 +1108,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 !this.mBrowser.mIconURL && !ignoreBlank && !(originalLocation.spec in FAVICON_DEFAULTS); -@@ -9613,13 +9866,6 @@ +@@ -9613,13 +9878,6 @@ this.mBrowser.originalURI = aRequest.originalURI; } @@ -1103,7 +1122,7 @@ index 43fb79a3060e20f671ae6ffc26350c7abf497702..146b1559b8430773bd4ec173a8f4fe88 } let userContextId = this.mBrowser.getAttribute("usercontextid") || 0; -@@ -10507,7 +10753,8 @@ var TabContextMenu = { +@@ -10507,7 +10765,8 @@ var TabContextMenu = { ); contextUnpinSelectedTabs.hidden = !this.contextTab.pinned || !this.multiselected; diff --git a/src/browser/themes/shared/zen-icons/icons.css b/src/browser/themes/shared/zen-icons/icons.css index 918a36ec1..513683d2c 100644 --- a/src/browser/themes/shared/zen-icons/icons.css +++ b/src/browser/themes/shared/zen-icons/icons.css @@ -51,7 +51,8 @@ } #PanelUI-zen-gradient-generator-color-remove, -#zen-gradient-generator-color-remove { +#zen-gradient-generator-color-remove, +.sr-remove { list-style-image: url("unpin.svg") !important; } @@ -134,6 +135,10 @@ list-style-image: url("arrow-right.svg"); } +.sr-open-in-icon { + list-style-image: url("arrow-corner-down-right.svg"); +} + #PanelUI-menu-button, #appMenu-more-button2, .zen-workspaces-actions, @@ -1006,7 +1011,8 @@ } } -#zen-copy-url-button image { +#zen-copy-url-button image, +.sr-url-icon { list-style-image: url("link.svg"); fill-opacity: 0.65; } @@ -1090,3 +1096,7 @@ #zen-boost-load { list-style-image: url("open.svg"); } + +.sr-airplane { + list-style-image: url("selectable/airplane.svg"); +} \ No newline at end of file diff --git a/src/browser/themes/shared/zen-icons/jar.inc.mn b/src/browser/themes/shared/zen-icons/jar.inc.mn index b252b3f52..91c0cc49d 100644 --- a/src/browser/themes/shared/zen-icons/jar.inc.mn +++ b/src/browser/themes/shared/zen-icons/jar.inc.mn @@ -4,6 +4,7 @@ #ifdef XP_WIN * skin/classic/browser/zen-icons/algorithm.svg (../shared/zen-icons/nucleo/algorithm.svg) +* skin/classic/browser/zen-icons/arrow-corner-down-right.svg (../shared/zen-icons/nucleo/arrow-corner-down-right.svg) * skin/classic/browser/zen-icons/arrow-down.svg (../shared/zen-icons/nucleo/arrow-down.svg) * skin/classic/browser/zen-icons/arrow-left.svg (../shared/zen-icons/nucleo/arrow-left.svg) * skin/classic/browser/zen-icons/arrow-right.svg (../shared/zen-icons/nucleo/arrow-right.svg) @@ -153,6 +154,7 @@ #endif #ifdef XP_MACOSX * skin/classic/browser/zen-icons/algorithm.svg (../shared/zen-icons/nucleo/algorithm.svg) +* skin/classic/browser/zen-icons/arrow-corner-down-right.svg (../shared/zen-icons/nucleo/arrow-corner-down-right.svg) * skin/classic/browser/zen-icons/arrow-down.svg (../shared/zen-icons/nucleo/arrow-down.svg) * skin/classic/browser/zen-icons/arrow-left.svg (../shared/zen-icons/nucleo/arrow-left.svg) * skin/classic/browser/zen-icons/arrow-right.svg (../shared/zen-icons/nucleo/arrow-right.svg) @@ -302,6 +304,7 @@ #endif #ifdef XP_LINUX * skin/classic/browser/zen-icons/algorithm.svg (../shared/zen-icons/nucleo/algorithm.svg) +* skin/classic/browser/zen-icons/arrow-corner-down-right.svg (../shared/zen-icons/nucleo/arrow-corner-down-right.svg) * skin/classic/browser/zen-icons/arrow-down.svg (../shared/zen-icons/nucleo/arrow-down.svg) * skin/classic/browser/zen-icons/arrow-left.svg (../shared/zen-icons/nucleo/arrow-left.svg) * skin/classic/browser/zen-icons/arrow-right.svg (../shared/zen-icons/nucleo/arrow-right.svg) diff --git a/src/browser/themes/shared/zen-icons/nucleo/arrow-corner-down-right.svg b/src/browser/themes/shared/zen-icons/nucleo/arrow-corner-down-right.svg new file mode 100644 index 000000000..18b9d18df --- /dev/null +++ b/src/browser/themes/shared/zen-icons/nucleo/arrow-corner-down-right.svg @@ -0,0 +1,5 @@ +#filter dumbComments emptyLines substitution +# 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/. + \ No newline at end of file diff --git a/src/zen/common/ZenPreloadedScripts.js b/src/zen/common/ZenPreloadedScripts.js index 559d4c8e4..e8128603a 100644 --- a/src/zen/common/ZenPreloadedScripts.js +++ b/src/zen/common/ZenPreloadedScripts.js @@ -5,6 +5,11 @@ // prettier-ignore // eslint-disable-next-line no-lone-blocks { + ChromeUtils.defineESModuleGetters(this, { + gZenSpaceRoutingManager: + "resource:///modules/zen/spacerouting/ZenSpaceRoutingManager.sys.mjs", + }); + Services.scriptloader.loadSubScript("chrome://browser/content/zen-components/ZenSpaceBookmarksStorage.js", this); let scripts = [ diff --git a/src/zen/common/styles/zen-panels/dialog.css b/src/zen/common/styles/zen-panels/dialog.css index b5979bb4c..e25f3e8d7 100644 --- a/src/zen/common/styles/zen-panels/dialog.css +++ b/src/zen/common/styles/zen-panels/dialog.css @@ -5,10 +5,10 @@ */ .dialogBox { border-radius: 12px !important; - border: 1px solid light-dark(rgba(168, 168, 169, 0.50), var(--zen-dialog-background)) !important; + border: 0.5px solid light-dark(rgba(0, 0, 0, 0.4), var(--zen-dialog-background)) !important; outline: 1px solid light-dark(transparent, rgba(168, 168, 169, 0.50)) !important; box-shadow: 0 10px 8px rgba(0, 0 , 0, 0.15) !important; - outline-offset: -1.5px; + outline-offset: -2px; @media not (prefers-reduced-motion: reduce) { animation: zen-dialog-fade-in 0.3s ease-out; diff --git a/src/zen/common/zen-sets.js b/src/zen/common/zen-sets.js index 1a1acb728..289dbe9eb 100644 --- a/src/zen/common/zen-sets.js +++ b/src/zen/common/zen-sets.js @@ -133,6 +133,10 @@ document.addEventListener( gZenWorkspaces.unloadAllOtherWorkspaces(); break; } + case "cmd_zenOpenSpaceRoutingSettings": { + gZenSpaceRoutingManager.openSpaceRoutingDialog(window); + break; + } case "cmd_zenNewNavigatorUnsynced": OpenBrowserWindow({ zenSyncedWindow: false }); break; diff --git a/src/zen/glance/ZenGlanceManager.mjs b/src/zen/glance/ZenGlanceManager.mjs index 2eba42111..affc555c2 100644 --- a/src/zen/glance/ZenGlanceManager.mjs +++ b/src/zen/glance/ZenGlanceManager.mjs @@ -214,6 +214,7 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature { skipAnimation: true, ownerTab: currentTab, triggeringPrincipal: data.triggeringPrincipal, + skipRoute: true, }; } diff --git a/src/zen/moz.build b/src/zen/moz.build index e1efea205..8b1426065 100644 --- a/src/zen/moz.build +++ b/src/zen/moz.build @@ -19,4 +19,5 @@ DIRS += [ "sessionstore", "share", "spaces", + "space-routing", ] diff --git a/src/zen/space-routing/ZenSpaceRoutingDialog.mjs b/src/zen/space-routing/ZenSpaceRoutingDialog.mjs new file mode 100644 index 000000000..3bdb6324f --- /dev/null +++ b/src/zen/space-routing/ZenSpaceRoutingDialog.mjs @@ -0,0 +1,454 @@ +/* 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/. */ + +// eslint-disable-next-line no-shadow +const { gZenSpaceRoutingManager } = ChromeUtils.importESModule( + "resource:///modules/zen/spacerouting/ZenSpaceRoutingManager.sys.mjs" +); + +export class nsZenSpaceRoutingDialog { + doc = null; + editorWindow = null; + openerWindow = null; + + static OBSERVERS = ["zen-space-routing-kill"]; + + /** + * Creates a new Space Routing dialog controller. + * + * @param {Document} doc - The document object for the dialog window. + * @param {Window} editorWindow - The Space Routing dialog window. + * @param {Window} openerWindow - The browser window that opened the dialog. + */ + constructor(doc, editorWindow, openerWindow) { + this.doc = doc; + this.editorWindow = editorWindow; + this.openerWindow = openerWindow; + + this.killOtherShareInstances(); + + nsZenSpaceRoutingDialog.OBSERVERS.forEach(observe => { + Services.obs.addObserver(this, observe); + }); + + this.init(); + this.editorWindow.addEventListener("unload", () => this.uninit(), { + once: true, + }); + } + + /** + * Initializes the boost share instance by setting up event listeners for all UI controls. + */ + init() { + this.editorWindow.addEventListener("unload", () => this.handleClose(), { + once: true, + }); + + this.doc + .getElementById("sr-close") + .addEventListener("click", this.onClosePressed.bind(this)); + this.doc + .getElementById("sr-new-route") + .addEventListener("click", this.onNewRoutePressed.bind(this)); + + const defaultRouteSelect = this.doc.getElementById( + "sr-default-external-open-in" + ); + this.createOpenInList( + defaultRouteSelect, + gZenSpaceRoutingManager.getDefaultExternalRoute() + ); + + defaultRouteSelect.addEventListener("command", e => + this.onRouteDefaultExternalChange(e.target.value) + ); + + this.doc.addEventListener("keydown", event => { + if ( + event.key === "Escape" || + (event.key === "w" && (event.ctrlKey || event.metaKey)) + ) { + this.onClosePressed(); + } + }); + + this.initRouteList(); + this.initialized = true; + } + + /** + * Initializes the routes list and loads all current routes from the disk + */ + initRouteList() { + const allRoutes = gZenSpaceRoutingManager.getAllRoutes(); + allRoutes.forEach(r => this.createRouteElement(r)); + } + + /** + * Will create a new route and update the route list + */ + onNewRoutePressed() { + const newRoute = gZenSpaceRoutingManager.createNewRoute(); + this.createRouteElement(newRoute); + } + + /** + * Will remove a route and update the list + * + * @param {string} routeId - The unique ID of the affected route + * @param {string} containerElement - The container element of the route in the list + */ + onRemoveRoutePressed(routeId, containerElement) { + gZenSpaceRoutingManager.removeRoute(routeId); + containerElement.remove(); + + this.updateShowNoRouteText(); + } + + /** + * Will create the rule element content and inject it into the ui + * + * @param {object} route - The target route + * @returns {Element} The created element for the route + */ + createRouteElement(route) { + const container = this.doc.getElementById("sr-content"); + + const root = this.doc.createXULElement("vbox"); + root.setAttribute("routeId", route.id); + root.className = "sr-rule-container"; + + // ---- Top row + + const topRow = this.doc.createXULElement("hbox"); + topRow.className = "sr-rule-row sr-rule-top"; + + const topLabelContainer = this.doc.createXULElement("hbox"); + topLabelContainer.className = "sr-label-container"; + + const urlIcon = this.doc.createXULElement("image"); + urlIcon.className = "sr-url-icon"; + + const urlLabel = this.doc.createXULElement("label"); + urlLabel.className = "sr-label"; + urlLabel.setAttribute("data-l10n-id", "zen-space-routing-url"); + + topLabelContainer.append(urlIcon, urlLabel); + + // Match type + + const matchTypeMenulist = this.doc.createXULElement("menulist"); + matchTypeMenulist.className = "select match-type-select"; + + const matchTypePopup = this.doc.createXULElement("menupopup"); + matchTypeMenulist.appendChild(matchTypePopup); + + ["contains", "equal-to", "regex"].forEach(id => { + const menuItem = this.doc.createXULElement("menuitem"); + menuItem.setAttribute("data-l10n-id", `zen-space-routing-${id}`); + menuItem.setAttribute("value", id); + matchTypePopup.appendChild(menuItem); + }); + + matchTypeMenulist.value = route.matchType; + + // Input domain + + const input = this.doc.createElement("input"); + input.className = "input"; + input.value = route.reference; + this.updateInputPlaceholder(route.matchType, input); + + const removeButton = this.doc.createXULElement("button"); + removeButton.className = "sr-remove"; + + topRow.append(topLabelContainer, matchTypeMenulist, input, removeButton); + + // ---- Bottom row + + const bottomRow = this.doc.createXULElement("hbox"); + bottomRow.className = "sr-rule-row sr-rule-bottom"; + + const bottomLabelContainer = this.doc.createXULElement("hbox"); + bottomLabelContainer.className = "sr-label-container"; + + const openInIcon = this.doc.createXULElement("image"); + openInIcon.className = "sr-open-in-icon"; + + const openInLabel = this.doc.createXULElement("label"); + openInLabel.className = "sr-label"; + openInLabel.setAttribute("data-l10n-id", "zen-space-routing-open-in"); + + bottomLabelContainer.append(openInIcon, openInLabel); + + // Open in + + const openInMenulist = this.doc.createXULElement("menulist"); + openInMenulist.className = "select open-in-select"; + + const openInMenupopup = this.doc.createXULElement("menupopup"); + openInMenulist.appendChild(openInMenupopup); + + this.createOpenInList(openInMenulist, route.openIn); + + bottomRow.append(bottomLabelContainer, openInMenulist); + + root.append(topRow, bottomRow); + container.appendChild(root); + + removeButton.addEventListener("click", () => { + this.onRemoveRoutePressed(route.id, root); + }); + + input.addEventListener("input", e => + this.onRouteReferenceChange(e.target.value, route.id, input) + ); + matchTypeMenulist.addEventListener("command", e => + this.onRouteMatchTypeChange(e.target.value, route.id, input) + ); + openInMenulist.addEventListener("command", e => + this.onRouteOpenInChange(e.target.value, route.id) + ); + + input.focus(); + + this.updateShowNoRouteText(); + + return root; + } + + /** + * Checks if the text for when no routes are + * created should be displayed + */ + updateShowNoRouteText() { + const container = this.doc.getElementById("sr-content"); + const noRoutesText = this.doc.getElementById("sr-empty-content"); + + // One because of the element itself + noRoutesText.style.display = + container.children.length == 1 ? "flex" : "none"; + } + + /** + * Callback for when the reference text changes + * + * @param {string} value - The new value + * @param {string} routeId - The ID of the affected route + * @param {Element} input - The input element + */ + onRouteReferenceChange(value, routeId, input) { + const route = gZenSpaceRoutingManager.getRoute(routeId); + route.reference = value; + + this.updateInputPlaceholder(route.matchType, input); + + // Don't update the route if the regex is invalid + if (route.matchType == "regex") { + if (!this.onCheckRegexValid(input)) { + return; + } + } + + gZenSpaceRoutingManager.updateRoute(route); + } + + /** + * Callback for when the open in attribute changes + * + * @param {string} value - The new value + * @param {string} routeId - The ID of the affected route + */ + onRouteOpenInChange(value, routeId) { + const route = gZenSpaceRoutingManager.getRoute(routeId); + route.openIn = value; + gZenSpaceRoutingManager.updateRoute(route); + } + + /** + * Callback for when the route match type changes + * + * @param {string} value - The new value + * @param {string} routeId - The ID of the affected route + * @param {Element} input - The text input + */ + onRouteMatchTypeChange(value, routeId, input) { + const route = gZenSpaceRoutingManager.getRoute(routeId); + route.matchType = value; + + this.updateInputPlaceholder(route.matchType, input); + + // Don't update the route if the regex is invalid + if (route.matchType == "regex") { + if (!this.onCheckRegexValid(input)) { + return; + } + } + + gZenSpaceRoutingManager.updateRoute(route); + } + + /** + * Updates the input placeholder based on the + * current route match type + * + * @param {string} matchType - The match type (e.g. "contains", "equal-to", "regex") + * @param {Element} input - The input element + */ + updateInputPlaceholder(matchType, input) { + switch (matchType) { + case "regex": + input.placeholder = "zen-browser\\.app"; + break; + default: + input.placeholder = "zen-browser.app"; + break; + } + } + + /** + * Will validate and return the validity of the + * regex. Applies a tint to the input if an error occurs. + * + * @param {Element} input - The input element for the regex + * @returns {bool} True if regex is valid + */ + onCheckRegexValid(input) { + const reference = input.value; + + // Ignore empty + if (reference.trim() == "") { + input.classList.remove("invalid"); + return true; + } + + try { + new RegExp(reference); + } catch (e) { + input.classList.add("invalid"); + return false; + } + input.classList.remove("invalid"); + return true; + } + + /** + * Callback for when the default external route changes + * + * @param {string} value - The new value + */ + onRouteDefaultExternalChange(value) { + gZenSpaceRoutingManager.setDefaultExternalRoute(value); + } + + /** + * Creates the options list selects + * + * @param {Element} selectElement - The menulist element + * @param {string} value - The initial value + */ + async createOpenInList(selectElement, value) { + const popupElement = + selectElement.querySelector("menupopup") || selectElement; + popupElement.replaceChildren(); // Clear existing + + const [openInSpace, mostRecentSpace] = await this.doc.l10n.formatMessages([ + "zen-space-routing-open-in-space", + "zen-space-routing-most-recent-space", + ]); + + const sectionHeader = this.doc.createXULElement("menuitem"); + sectionHeader.setAttribute("label", openInSpace.value); + sectionHeader.setAttribute("disabled", "true"); + sectionHeader.classList.add("menu-section-header"); + popupElement.appendChild(sectionHeader); + + let availOptions = []; + + let createXulItem = (text, id, iconPath = null) => { + if (text === "sep") { + popupElement.appendChild(this.doc.createXULElement("menuseparator")); + return; + } + + availOptions.push(id || text); + const menuItem = this.doc.createXULElement("menuitem"); + menuItem.setAttribute("label", text); + menuItem.setAttribute("value", id || text); + + if (iconPath) { + if (iconPath.startsWith("chrome://")) { + menuItem.setAttribute("class", "menuitem-iconic"); + menuItem.setAttribute("image", iconPath); + } else { + menuItem.setAttribute("label", `${iconPath} ${text}`); + } + } + + popupElement.appendChild(menuItem); + }; + + const workspaces = this.openerWindow.gZenWorkspaces.getWorkspaces(); + + createXulItem(mostRecentSpace.value, "most-recent-space"); + createXulItem("sep"); + + workspaces.forEach(workspace => { + createXulItem(workspace.name, workspace.uuid, workspace.icon); + }); + + // Check if the workspace still exists, if not use default + if (availOptions.includes(value)) { + selectElement.value = value; + } else { + selectElement.value = "most-recent-space"; + } + } + + /** + * Uninitializes the boost editor by cleaning up event listeners and observers. + */ + uninit() { + nsZenSpaceRoutingDialog.OBSERVERS.forEach(observe => { + Services.obs.removeObserver(this, observe); + }); + } + + /** + * Kills all other Space Routing dialog instances + */ + killOtherShareInstances() { + Services.obs.notifyObservers(null, "zen-space-routing-kill"); + } + + /** + * Observer callback that handles notifications from the observer service. + * Closes the control window when a 'zen-space-routing-kill' notification is received. + * + * @param {object} subject - The subject of the notification. + * @param {string} topic - The topic of the notification. + */ + observe(subject, topic) { + switch (topic) { + case "zen-space-routing-kill": + this.editorWindow.close(); + break; + } + } + + /** + * Callback for when the user presses the close button + */ + onClosePressed() { + this.editorWindow.close(); + } + + /** + * Handles the window close event + */ + handleClose() { + gZenSpaceRoutingManager.saveRoutes(); + } +} diff --git a/src/zen/space-routing/ZenSpaceRoutingManager.sys.mjs b/src/zen/space-routing/ZenSpaceRoutingManager.sys.mjs new file mode 100644 index 000000000..9109e8924 --- /dev/null +++ b/src/zen/space-routing/ZenSpaceRoutingManager.sys.mjs @@ -0,0 +1,413 @@ +/* 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 { JSONFile } from "resource://gre/modules/JSONFile.sys.mjs"; + +class nsZenSpaceRoutingManager { + #file = null; + #saveFilename = "zen-space-routing.jsonlz4"; + + static SKIP_TYPE = { + NONE: "none", + SKIPPED_TAB: "skipped_tab", + RESTORED_TAB: "restored_tab", + }; + + constructor() { + this.#readFromDisk(); + } + + /** + * Callback that will be executed from tabbrowser.js + * This method can be used to stop the tab from being created. + * + * @param {string} uriString - The URI as a string + * @param {object} options - The tab creation options + * @param {Window} win - The window which the tab will be added to + * @returns {object} Returns an object with { shouldEarlyExit, userContextId, isRouteFound, targetRoute } + */ + onBeforeAddTab(uriString, options, win) { + let userContextId = null; + let isRouteFound = false; + let targetRoute = null; + + if ( + this.#shouldSkipProcessing(options, win) != + nsZenSpaceRoutingManager.SKIP_TYPE.NONE + ) { + return { + shouldEarlyExit: false, + userContextId, + isRouteFound, + targetRoute, + }; + } + + targetRoute = this.routeUri(uriString, options); + switch (targetRoute) { + case "most-recent-space": + break; + default: { + const targetWorkspace = + win?.gZenWorkspaces?.getWorkspaceFromId(targetRoute); + + if (targetWorkspace) { + userContextId = targetWorkspace.containerTabId; + isRouteFound = true; + } + } + } + + return { shouldEarlyExit: false, userContextId, isRouteFound, targetRoute }; + } + + /** + * Callback that will be executed from tabbrowser.js + * + * @param {string} uriString - The URI as a string + * @param {Element} newTab - The tab element + * @param {object} options - The tab creation options + * @param {Window} win - The window which the tab was added to + * @param {object} [beforeResult] - The result returned by onBeforeAddTab for + * this tab. When present its precomputed targetRoute is reused instead of + * running routeUri() a second time. + */ + onAfterAddTab(uriString, newTab, options, win, beforeResult) { + const targetRoute = beforeResult?.targetRoute; + if (!targetRoute) { + return; + } + + this.#routeToWorkspace(targetRoute, newTab, win); + } + + /** + * Checks if the tab should be processed or not + * + * @param {object} options - The tab creation options + * @param {Window} win - The owning window + * @returns {SKIP_TYPE} The type of skip or null if not skipped + */ + #shouldSkipProcessing(options, win) { + if (options.skipRoute || options.pinned || options.tabGroup) { + return nsZenSpaceRoutingManager.SKIP_TYPE.SKIPPED_TAB; + } + + // addTab() is being called when the session restores. + // To avoid automatically routing these tabs, + // a check if the restore is already complete is needed + if (!win.gZenStartup.isReady) { + return nsZenSpaceRoutingManager.SKIP_TYPE.RESTORED_TAB; + } + + return nsZenSpaceRoutingManager.SKIP_TYPE.NONE; + } + + /** + * Will route the given tab to a space if a rule applies + * + * @param {string} targetRoute - The precomputed route for the tab + * @param {Element} newTab - The tab element + * @param {Window} win - The window which the tab was added to + * @private + */ + async #routeToWorkspace(targetRoute, newTab, win) { + try { + if (!newTab || !newTab.parentNode) { + return; + } + + switch (targetRoute) { + case "most-recent-space": + break; + + default: { + const workspaces = win?.gZenWorkspaces; + const targetWorkspace = workspaces?.getWorkspaceFromId?.(targetRoute); + + if (targetWorkspace) { + workspaces.moveTabToWorkspace(newTab, targetWorkspace.uuid); + const mostRecentWindow = + Services.wm.getMostRecentWindow("navigator:browser"); + const isOriginatingWindow = win === mostRecentWindow; + if (isOriginatingWindow) { + win.gZenWorkspaces.lastSelectedWorkspaceTabs[ + targetWorkspace.uuid + ] = newTab; + await win.gZenWorkspaces.changeWorkspace(targetWorkspace); + } + } + } + } + } catch (err) { + console.error("[ZenSpaceRouting]: Error moving tab to workspace:", err); + } + } + + /** + * This will give the id of the workspace this uri will + * route to, or "most-recent-space" + * + * @param {string} uriString - The uri which will be routed + * @param {object} options - The tab creation options + * @returns {string} Route instructions + */ + routeUri(uriString, options) { + const isExternal = options.fromExternal; + + // Go over all routes and return the open type for the first match + const allRoutes = this.getAllRoutes(); + for (const route of allRoutes) { + if (this.isRouteMatching(uriString, route)) { + return route.openIn; + } + } + + // If nothing matches and it's an external link, + // use the default external route + if (isExternal) { + return this.getDefaultExternalRoute(); + } + + // If nothing matches, open in most recent space + return "most-recent-space"; + } + + /** + * Checks if a given rule matches a uriString + * + * @param {string} uriString - The uri + * @param {object} route - The route + * @returns {boolean} True if the rule matches + */ + isRouteMatching(uriString, route) { + if (typeof uriString !== "string" || typeof route?.reference !== "string") { + return false; + } + + let reference = route.reference.toLowerCase(); + if (reference.trim() == "") { + return false; + } + + const uri = uriString.toLowerCase(); + switch (route.matchType) { + case "contains": + if (uri.includes(reference)) { + return true; + } + break; + case "equal-to": + if (this.#normalizeURL(uri) == this.#normalizeURL(reference)) { + return true; + } + break; + case "regex": { + let unmodifiedReference = route.reference; + try { + // Use unmodified parameters for the regex test + const regex = new RegExp(unmodifiedReference); + if (regex.test(uriString)) { + return true; + } + } catch (e) { + console.error( + "[ZenSpaceRouting] Failed to resolve regular expression:", + unmodifiedReference, + e + ); + } + break; + } + } + return false; + } + + /** + * Will remove any protocol sequences to normalize the url + * + * @param {string} uriString - The url + * @returns {string} The normalized url + */ + #normalizeURL(uriString) { + if (!uriString) { + return ""; + } + let clean = uriString.trim(); + + // Remove protocol sequences with regex + clean = clean.replace(/^https?:\/\//i, ""); + clean = clean.replace(/^www\./i, ""); + + // If there is a trailing slash, remove + if (clean.endsWith("/")) { + clean = clean.slice(0, -1); + } + + return clean; + } + + /** + * Opens the Space Routing editor in a new popup window. + * + * @param {Window} parentWindow - The parent browser window + * @returns {Window|null} The instanced editor window + */ + openSpaceRoutingDialog(parentWindow) { + const control = parentWindow.openDialog( + "chrome://browser/content/zen-components/windows/zen-space-routing.xhtml", + "", + "centerscreen,modal,dependent,resizable=no,titlebar=no", + { parentWindow } + ); + + control.focus(); + return control; + } + + /** + * @returns {object} Returns a new empty Space Routing route + */ + getEmptyRoute() { + return { + id: crypto.randomUUID(), + reference: "", + openIn: "most-recent-space", + matchType: "contains", + }; + } + + /** + * @returns {Array} A copy of the routes list + */ + getAllRoutes() { + if (!this.#file?.data?.routes) { + return []; + } + return structuredClone(this.#file.data.routes); + } + + /** + * Returns a specific route + * + * @param {string} id - The ID of the given route + * @returns {object|null} The route, or null if no route has the given id + */ + getRoute(id) { + const idx = this.#file.data.routes.findIndex(r => r.id === id); + if (idx === -1) { + return null; + } + return structuredClone(this.#file.data.routes[idx]); + } + + /** + * Will update an existing route + * + * @param {object} route - The updated route + */ + updateRoute(route) { + const idx = this.#file.data.routes.findIndex(r => r.id === route.id); + if (idx === -1) { + return; + } + this.#file.data.routes[idx] = structuredClone(route); + } + + /** + * Creates a new route and returns it + * + * @returns {object} Returns the empty route + */ + createNewRoute() { + const newRoute = this.getEmptyRoute(); + this.#file.data.routes.push(newRoute); + + return structuredClone(newRoute); + } + + /** + * Removes an existing route with the given id + * + * @param {string} id - The given id + */ + removeRoute(id) { + const objWithIdIndex = this.#file.data.routes.findIndex(r => r.id === id); + if (objWithIdIndex === -1) { + return; + } + this.#file.data.routes.splice(objWithIdIndex, 1); + } + + /** + * @returns {string} Returns the default route type for external links + */ + getDefaultExternalRoute() { + return this.#file?.data?.defaultRouteExternal ?? "most-recent-space"; + } + + /** + * @param {string} routeType - Sets the default route type for external links + */ + setDefaultExternalRoute(routeType) { + this.#file.data.defaultRouteExternal = routeType; + } + + /** + * Saves all routes + */ + saveRoutes() { + this.#writeToDisk(); + } + + /** + * Writes the Space Routing data back onto the disk. + * + * @private + */ + #writeToDisk() { + this.#file.saveSoon(); + } + + /** + * Reads Space Routing data from disk and decompresses it. + * + * @returns {Promise} A promise that resolves to an array of Space Routing rules. + * @private + */ + async #readFromDisk() { + this.#file = new JSONFile({ + path: this.#storePath, + compression: "lz4", + + dataPostProcessor(data) { + if (!data || typeof data !== "object") { + data = {}; + } + if (!Array.isArray(data.routes)) { + data.routes = []; + } + if (typeof data.defaultRouteExternal !== "string") { + data.defaultRouteExternal = "most-recent-space"; + } + return data; + }, + }); + + await this.#file.load(); + } + + /** + * Gets the file path where Space Routing data is stored in the user's profile directory. + * + * @returns {string} The full path to the Space Routing storage file. + * @private + */ + get #storePath() { + const profilePath = PathUtils.profileDir; + return PathUtils.join(profilePath, this.#saveFilename); + } +} + +export const gZenSpaceRoutingManager = new nsZenSpaceRoutingManager(); diff --git a/src/zen/space-routing/jar.inc.mn b/src/zen/space-routing/jar.inc.mn new file mode 100644 index 000000000..99e725681 --- /dev/null +++ b/src/zen/space-routing/jar.inc.mn @@ -0,0 +1,9 @@ +# 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/. + + # Styles + content/browser/zen-styles/zen-space-routing.css (../../zen/space-routing/zen-space-routing.css) + + # Windows +* content/browser/zen-components/windows/zen-space-routing.xhtml (../../zen/space-routing/zen-space-routing.inc.xhtml) diff --git a/src/zen/space-routing/moz.build b/src/zen/space-routing/moz.build new file mode 100644 index 000000000..9bb392f21 --- /dev/null +++ b/src/zen/space-routing/moz.build @@ -0,0 +1,8 @@ +# 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/. + +EXTRA_JS_MODULES.zen.spacerouting += [ + "ZenSpaceRoutingDialog.mjs", + "ZenSpaceRoutingManager.sys.mjs", +] diff --git a/src/zen/space-routing/zen-space-routing.css b/src/zen/space-routing/zen-space-routing.css new file mode 100644 index 000000000..31aea297e --- /dev/null +++ b/src/zen/space-routing/zen-space-routing.css @@ -0,0 +1,315 @@ +/* + * 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/. + */ + +:root { + background: none; + appearance: none; + border: none; + outline: none; + color-scheme: light dark; + + --sr-background: light-dark(white, #212223); + --select-background-color: light-dark(#eceef0, #3e3f43); + --input-background-color: light-dark(#f1f2f4, #2c2d31); + --hr-color: light-dark(#cdced4, #313235); + + --sr-width: 510px; + --sr-height: 500px; + + --content-padding: 26px; + --content-padding-vertical: 16px; + + --text-color: light-dark(#4c4c4c, #dbdcdf); + --text-color-secondary: light-dark(#5c5e65, #8b8e98); + --text-color-error: light-dark(#9d2222, #d03535); + + --rules-gap: 14px; + + --sr-border-radius: 12px; +} + +.dialog-button-box { + /* Remove default dialog buttons */ + display: none !important; +} + +#zen-space-routing-dialog-container { + background-color: var(--sr-background); + padding: 0; + margin: 24px; + + height: var(--sr-height); + max-height: var(--sr-height); + min-height: var(--sr-height); + + width: var(--sr-width); + max-width: var(--sr-width); + min-width: var(--sr-width); +} + +#sr-container { + height: var(--sr-height); + width: var(--sr-width); + + border-radius: var(--sr-border-radius); + overflow: hidden; + font-family: system-ui !important; + + position: relative; + display: flex; +} + +#sr-empty-content { + display: flex; + flex-direction: column; + text-align: center; + gap: 24px; + + position: absolute; + left: 84px; + right: 84px; + + bottom: 100px; + top: 100px; + justify-content: center; + + & image { + aspect-ratio: 1; + height: 75px; + + min-height: 0; + max-height: none; + max-width: none; + min-width: 0; + + color: var(--text-color-secondary); + -moz-context-properties: fill, fill-opacity; + fill: currentColor; + fill-opacity: 0.65; + } + + & p { + font-weight: bold; + font-size: small; + + color: var(--text-color); + } +} + +.hr { + color: var(--hr-color); + padding: 0; + margin: 0; + margin-left: 32px; + margin-right: 32px; + border-style: solid; + border-width: 0.5px; +} + +h3 { + font-size: medium; + color: var(--text-color); + font-weight: normal; +} + +h4 { + font-size: small; + color: var(--text-color-secondary); +} + +p { + color: var(--text-color); +} + +.select { + width: 150px; + height: 26px; + padding: 4px; +} + +.select, +.select * { + background-color: var(--select-background-color); + color: var(--text-color); + margin: 0; + + &.match-type-select { + width: 100px; + } + &.open-in-select { + width: 165px; + } +} + +menulist[image] .menulist-icon, +menulist[image]::part(icon) { + width: 16px; + height: 16px; + min-width: 16px; + min-height: 16px; + -moz-context-properties: fill, stroke; + fill: currentColor; + margin-inline-end: 0; + padding-left: 2px; +} + +menulist { + -moz-box-align: center; +} + +.sr-rule-row { + display: flex; + align-items: center; + gap: 8px; +} + +.sr-rule-container { + display: flex; + flex-direction: column; + gap: 4px; + margin: 0 24px; + + & p { + color: var(--text-color-secondary); + } +} + +.sr-rule-bottom { + margin-left: 85px; +} + +.input, +.input * { + background-color: var(--input-background-color); + color: var(--text-color); + margin: 0; + + flex-grow: 1; +} + +.invalid, +.invalid * { + color: var(--text-color-error) !important; + + text-decoration-line: underline; + text-decoration-style: wavy; + text-decoration-thickness: 1px; + text-decoration-color: var(--text-color-error); +} + +button { + user-select: none !important; + + padding: 2px; + display: flex; + justify-content: center; + + margin: 0; + + & hbox { + display: none; + } +} + +.sr-remove, +.close-icon { + & hbox { + display: initial; + padding: 0; + margin: 0; + } + + opacity: 0.5; + + &:hover { + opacity: 0.8; + } + + transition: 0.2s opacity cubic-bezier(0.075, 0.82, 0.165, 1); + + appearance: none !important; + background: none; + border: none !important; + + aspect-ratio: 1; + height: 24px; + padding: 3px; + width: 24px; + + min-width: 0 !important; + color: var(--text-color-secondary); + + -moz-context-properties: fill, fill-opacity; + fill: currentColor; +} + +#sr-header { + width: 100%; + padding: var(--content-padding); + align-items: center; + + display: flex; + flex-direction: row; +} + +.sr-left { + margin-left: auto; + + display: flex; + flex-direction: row; + align-items: center; + gap: 6px; + + & button { + color: var(--text-color); + opacity: 1; + + &:hover .sr-remove { + opacity: 0.8; + } + } +} + +#sr-footer { + width: 100%; + padding: var(--content-padding); + align-items: center; + + display: flex; + flex-direction: row; +} + +#sr-content { + width: 100%; + overflow: scroll; + padding: var(--content-padding); + flex-grow: 1; + + display: flex; + flex-direction: column; + gap: var(--rules-gap); +} + +.sr-label-container { + display: flex; + align-items: center; + gap: 6px; + + color: var(--text-color-secondary); +} + +.sr-label { + margin: 0; +} + +.sr-url-icon, +.sr-open-in-icon { + width: 16px; + height: 16px; + -moz-context-properties: fill, fill-opacity; + fill: currentColor; + fill-opacity: 0.65; +} diff --git a/src/zen/space-routing/zen-space-routing.inc.xhtml b/src/zen/space-routing/zen-space-routing.inc.xhtml new file mode 100644 index 000000000..dfda34229 --- /dev/null +++ b/src/zen/space-routing/zen-space-routing.inc.xhtml @@ -0,0 +1,108 @@ +#filter substitution + + +# -*- Mode: HTML -*- +# +# 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/. + + + + + + + + + + + + + + + + + + + +

+ + + + +
+ + + + +

+
+# All rules will be injected here later +
+ + +

+ + + +# Select open in types will be injected here + + + +
+
+
+ + +
diff --git a/src/zen/split-view/ZenViewSplitter.mjs b/src/zen/split-view/ZenViewSplitter.mjs index 344938714..9deb7014d 100644 --- a/src/zen/split-view/ZenViewSplitter.mjs +++ b/src/zen/split-view/ZenViewSplitter.mjs @@ -1226,7 +1226,10 @@ class nsZenViewSplitter extends nsZenDOMOperatedFeature { const currentTab = gZenGlanceManager.getTabOrGlanceParent( window.gBrowser.selectedTab ); - const newTab = this.openAndSwitchToTab(url, { inBackground: false }); + const newTab = this.openAndSwitchToTab(url, { + skipRoute: true, + inBackground: false, + }); this.splitTabs([currentTab, newTab], undefined, 1); } diff --git a/src/zen/tests/moz.build b/src/zen/tests/moz.build index a79f5a7f4..fbbf132a8 100644 --- a/src/zen/tests/moz.build +++ b/src/zen/tests/moz.build @@ -13,6 +13,7 @@ BROWSER_CHROME_MANIFESTS += [ "pinned/browser.toml", "popover/browser.toml", "site_control/browser.toml", + "space_routing/browser.toml", "spaces/browser.toml", "split_view/browser.toml", "tabs/browser.toml", diff --git a/src/zen/tests/space_routing/browser.toml b/src/zen/tests/space_routing/browser.toml new file mode 100644 index 000000000..278473300 --- /dev/null +++ b/src/zen/tests/space_routing/browser.toml @@ -0,0 +1,18 @@ +# 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] +support-files = [ + "head.js", +] + +["browser_space_routing_crud.js"] + +["browser_space_routing_dialog.js"] + +["browser_space_routing_on_add_tab.js"] + +["browser_space_routing_route_matching.js"] + +["browser_space_routing_route_uri.js"] diff --git a/src/zen/tests/space_routing/browser_space_routing_crud.js b/src/zen/tests/space_routing/browser_space_routing_crud.js new file mode 100644 index 000000000..afff1e152 --- /dev/null +++ b/src/zen/tests/space_routing/browser_space_routing_crud.js @@ -0,0 +1,115 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_setup(async function () { + clearAllRoutes(); + const savedDefault = gZenSpaceRoutingManager.getDefaultExternalRoute(); + registerCleanupFunction(() => { + clearAllRoutes(); + gZenSpaceRoutingManager.setDefaultExternalRoute(savedDefault); + }); +}); + +add_task(async function test_empty_route_shape_and_unique_ids() { + const a = gZenSpaceRoutingManager.getEmptyRoute(); + const b = gZenSpaceRoutingManager.getEmptyRoute(); + + Assert.equal(a.reference, "", "Empty route starts with no reference"); + Assert.equal( + a.openIn, + "most-recent-space", + "Empty route defaults to most-recent-space" + ); + Assert.equal(a.matchType, "contains", "Empty route defaults to 'contains'"); + Assert.equal(typeof a.id, "string", "Empty route has a string id"); + ok(a.id.length, "Empty route id is non-empty"); + Assert.notEqual(a.id, b.id, "Each empty route gets a unique id"); +}); + +add_task(async function test_create_get_update_remove_lifecycle() { + clearAllRoutes(); + Assert.equal( + gZenSpaceRoutingManager.getAllRoutes().length, + 0, + "Precondition: no routes" + ); + + const created = gZenSpaceRoutingManager.createNewRoute(); + Assert.equal( + gZenSpaceRoutingManager.getAllRoutes().length, + 1, + "createNewRoute() appends one route" + ); + + created.reference = "zen-browser.app"; + created.openIn = "ws-42"; + created.matchType = "equal-to"; + gZenSpaceRoutingManager.updateRoute(created); + + const fetched = gZenSpaceRoutingManager.getRoute(created.id); + Assert.equal(fetched.reference, "zen-browser.app", "reference persisted"); + Assert.equal(fetched.openIn, "ws-42", "openIn persisted"); + Assert.equal(fetched.matchType, "equal-to", "matchType persisted"); + + gZenSpaceRoutingManager.removeRoute(created.id); + Assert.equal( + gZenSpaceRoutingManager.getAllRoutes().length, + 0, + "removeRoute() deletes the route" + ); +}); + +add_task(async function test_remove_only_targets_the_given_id() { + clearAllRoutes(); + const keep1 = addRoute({ reference: "a" }); + const drop = addRoute({ reference: "b" }); + const keep2 = addRoute({ reference: "c" }); + + gZenSpaceRoutingManager.removeRoute(drop.id); + + const ids = gZenSpaceRoutingManager.getAllRoutes().map(r => r.id); + Assert.deepEqual( + ids, + [keep1.id, keep2.id], + "Only the targeted route is removed; order of the rest is preserved" + ); +}); + +add_task(async function test_reads_return_copies_not_internal_refs() { + clearAllRoutes(); + const created = gZenSpaceRoutingManager.createNewRoute(); + + const fromGet = gZenSpaceRoutingManager.getRoute(created.id); + fromGet.reference = "mutated-via-getRoute"; + Assert.equal( + gZenSpaceRoutingManager.getRoute(created.id).reference, + "", + "getRoute() returns a copy; external mutation does not leak" + ); + + const all = gZenSpaceRoutingManager.getAllRoutes(); + all[0].reference = "mutated-via-getAllRoutes"; + Assert.equal( + gZenSpaceRoutingManager.getRoute(created.id).reference, + "", + "getAllRoutes() returns copies; external mutation does not leak" + ); +}); + +add_task(async function test_default_external_route_getter_setter() { + gZenSpaceRoutingManager.setDefaultExternalRoute("ws-default"); + Assert.equal( + gZenSpaceRoutingManager.getDefaultExternalRoute(), + "ws-default", + "setDefaultExternalRoute() round-trips through the getter" + ); + + gZenSpaceRoutingManager.setDefaultExternalRoute("most-recent-space"); + Assert.equal( + gZenSpaceRoutingManager.getDefaultExternalRoute(), + "most-recent-space", + "The default external route can be changed again" + ); +}); diff --git a/src/zen/tests/space_routing/browser_space_routing_dialog.js b/src/zen/tests/space_routing/browser_space_routing_dialog.js new file mode 100644 index 000000000..3733f7abd --- /dev/null +++ b/src/zen/tests/space_routing/browser_space_routing_dialog.js @@ -0,0 +1,251 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_setup(async function () { + clearAllRoutes(); + const savedDefault = gZenSpaceRoutingManager.getDefaultExternalRoute(); + registerCleanupFunction(() => { + clearAllRoutes(); + gZenSpaceRoutingManager.setDefaultExternalRoute(savedDefault); + }); +}); + +add_task(async function test_empty_placeholder_and_add_route() { + clearAllRoutes(); + const dlg = await openRoutingDialog(); + try { + const doc = dlg.document; + const emptyText = doc.getElementById("sr-empty-content"); + const content = doc.getElementById("sr-content"); + + Assert.notEqual( + emptyText.style.display, + "none", + "The empty-state placeholder is visible when there are no routes" + ); + + doc.getElementById("sr-new-route").click(); + + Assert.equal( + content.querySelectorAll(".sr-rule-container").length, + 1, + "Clicking 'New Route' injects one route element" + ); + Assert.equal( + emptyText.style.display, + "none", + "The empty-state placeholder is hidden once a route exists" + ); + Assert.equal( + gZenSpaceRoutingManager.getAllRoutes().length, + 1, + "The new route is persisted into the manager" + ); + } finally { + await closeRoutingDialog(dlg); + clearAllRoutes(); + } +}); + +add_task(async function test_remove_route_via_ui() { + clearAllRoutes(); + addRoute({ reference: "github.com" }); + const dlg = await openRoutingDialog(); + try { + const doc = dlg.document; + Assert.equal( + doc.querySelectorAll(".sr-rule-container").length, + 1, + "Existing route is rendered on open" + ); + + doc.querySelector(".sr-remove").click(); + + Assert.equal( + doc.querySelectorAll(".sr-rule-container").length, + 0, + "The route element is removed from the DOM" + ); + Assert.equal( + gZenSpaceRoutingManager.getAllRoutes().length, + 0, + "The route is removed from the manager" + ); + Assert.equal( + doc.getElementById("sr-empty-content").style.display, + "flex", + "The empty-state placeholder returns after the last route is removed" + ); + } finally { + await closeRoutingDialog(dlg); + clearAllRoutes(); + } +}); + +add_task(async function test_match_type_updates_placeholder_and_store() { + clearAllRoutes(); + const route = addRoute({ reference: "", matchType: "contains" }); + const dlg = await openRoutingDialog(); + try { + const doc = dlg.document; + const menulist = doc.querySelector(".sr-rule-container .match-type-select"); + const input = doc.querySelector(".sr-rule-container .input"); + + Assert.equal( + input.placeholder, + "zen-browser.app", + "The 'contains' placeholder is the plain domain" + ); + + menulist.value = "regex"; + menulist.dispatchEvent(new Event("command", { bubbles: true })); + + Assert.equal( + input.placeholder, + "zen-browser\\.app", + "Switching to 'regex' updates the placeholder to an escaped pattern" + ); + Assert.equal( + gZenSpaceRoutingManager.getRoute(route.id).matchType, + "regex", + "The match type change is persisted to the manager" + ); + } finally { + await closeRoutingDialog(dlg); + clearAllRoutes(); + } +}); + +add_task(async function test_invalid_regex_is_flagged_and_not_saved() { + clearAllRoutes(); + const route = addRoute({ reference: "", matchType: "regex" }); + const dlg = await openRoutingDialog(); + try { + const doc = dlg.document; + const input = doc.querySelector(".sr-rule-container .input"); + + input.value = "(["; + input.dispatchEvent(new Event("input", { bubbles: true })); + + ok( + input.classList.contains("invalid"), + "An invalid regex marks the input as invalid" + ); + Assert.equal( + gZenSpaceRoutingManager.getRoute(route.id).reference, + "", + "An invalid regex is NOT written to the route" + ); + + input.value = "zen.*app"; + input.dispatchEvent(new Event("input", { bubbles: true })); + + ok( + !input.classList.contains("invalid"), + "A subsequently valid regex clears the invalid state" + ); + Assert.equal( + gZenSpaceRoutingManager.getRoute(route.id).reference, + "zen.*app", + "A valid regex is written to the route" + ); + } finally { + await closeRoutingDialog(dlg); + clearAllRoutes(); + } +}); + +add_task(async function test_default_external_select_updates_store() { + clearAllRoutes(); + await gZenWorkspaces.promiseInitialized; + gZenSpaceRoutingManager.setDefaultExternalRoute("most-recent-space"); + + const dlg = await openRoutingDialog(); + try { + const doc = dlg.document; + const select = doc.getElementById("sr-default-external-open-in"); + + await TestUtils.waitForCondition( + () => select.querySelectorAll("menuitem").length > 1, + "External-default options were populated" + ); + + const workspaceUuid = gZenWorkspaces.getWorkspaces()[0].uuid; + select.value = workspaceUuid; + select.dispatchEvent(new Event("command", { bubbles: true })); + + Assert.equal( + gZenSpaceRoutingManager.getDefaultExternalRoute(), + workspaceUuid, + "Changing the external-default select updates the manager" + ); + } finally { + await closeRoutingDialog(dlg); + gZenSpaceRoutingManager.setDefaultExternalRoute("most-recent-space"); + } +}); + +add_task(async function test_routes_are_saved_on_close() { + clearAllRoutes(); + const dlg = await openRoutingDialog(); + + let saveCalls = 0; + const realSave = gZenSpaceRoutingManager.saveRoutes; + gZenSpaceRoutingManager.saveRoutes = function () { + saveCalls++; + return realSave.call(this); + }; + + try { + const closed = BrowserTestUtils.domWindowClosed(dlg); + dlg.close(); + await TestUtils.waitForCondition( + () => saveCalls > 0, + "Closing the dialog flushes routes to disk via saveRoutes()" + ); + await closed; + } finally { + delete gZenSpaceRoutingManager.saveRoutes; + } +}); + +add_task(async function test_open_broadcasts_kill_to_other_instances() { + clearAllRoutes(); + + let killNotified = false; + const observer = { + observe(_subject, topic) { + if (topic === "zen-space-routing-kill") { + killNotified = true; + } + }, + }; + Services.obs.addObserver(observer, "zen-space-routing-kill"); + + let dlg; + try { + dlg = await openRoutingDialog(); + ok( + killNotified, + "Opening a dialog broadcasts 'zen-space-routing-kill' so others can close" + ); + } finally { + Services.obs.removeObserver(observer, "zen-space-routing-kill"); + if (dlg) { + await closeRoutingDialog(dlg); + } + } +}); + +add_task(async function test_kill_notification_closes_dialog() { + clearAllRoutes(); + const dlg = await openRoutingDialog(); + + const closed = BrowserTestUtils.domWindowClosed(dlg); + Services.obs.notifyObservers(null, "zen-space-routing-kill"); + await closed; + + ok(dlg.closed, "A 'zen-space-routing-kill' notification closes the dialog"); +}); diff --git a/src/zen/tests/space_routing/browser_space_routing_on_add_tab.js b/src/zen/tests/space_routing/browser_space_routing_on_add_tab.js new file mode 100644 index 000000000..8fa66ceaa --- /dev/null +++ b/src/zen/tests/space_routing/browser_space_routing_on_add_tab.js @@ -0,0 +1,363 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TARGET_WS = { uuid: "ws-target", containerTabId: 7 }; + +add_setup(async function () { + clearAllRoutes(); + registerCleanupFunction(() => clearAllRoutes()); +}); + +add_task(async function test_onBeforeAddTab_resolves_container_for_match() { + clearAllRoutes(); + addRoute({ + reference: "github.com", + matchType: "contains", + openIn: TARGET_WS.uuid, + }); + const win = makeFakeWindow({ ready: true, workspaces: [TARGET_WS] }); + + const result = gZenSpaceRoutingManager.onBeforeAddTab( + "https://github.com/zen", + {}, + win + ); + + Assert.deepEqual( + result, + { + shouldEarlyExit: false, + userContextId: TARGET_WS.containerTabId, + isRouteFound: true, + targetRoute: TARGET_WS.uuid, + }, + "A matching route resolves to the workspace's containerTabId" + ); +}); + +add_task(async function test_onBeforeAddTab_no_match_returns_no_route() { + clearAllRoutes(); + const win = makeFakeWindow({ ready: true, workspaces: [TARGET_WS] }); + + const result = gZenSpaceRoutingManager.onBeforeAddTab( + "https://example.com", + {}, + win + ); + + Assert.deepEqual( + result, + { + shouldEarlyExit: false, + userContextId: null, + isRouteFound: false, + targetRoute: "most-recent-space", + }, + "An unmatched URL (most-recent-space) reports no container and no route" + ); +}); + +add_task(async function test_onBeforeAddTab_route_to_missing_workspace() { + clearAllRoutes(); + addRoute({ + reference: "github.com", + matchType: "contains", + openIn: "ws-does-not-exist", + }); + const win = makeFakeWindow({ ready: true, workspaces: [TARGET_WS] }); + + const result = gZenSpaceRoutingManager.onBeforeAddTab( + "https://github.com", + {}, + win + ); + + Assert.deepEqual( + result, + { + shouldEarlyExit: false, + userContextId: null, + isRouteFound: false, + targetRoute: "ws-does-not-exist", + }, + "A route to a non-existent workspace yields no container and no route" + ); +}); + +add_task(async function test_onBeforeAddTab_skips_special_tab_options() { + clearAllRoutes(); + addRoute({ + reference: "github.com", + matchType: "contains", + openIn: TARGET_WS.uuid, + }); + const win = makeFakeWindow({ ready: true, workspaces: [TARGET_WS] }); + + for (const skipOption of ["skipRoute", "pinned", "tabGroup"]) { + const result = gZenSpaceRoutingManager.onBeforeAddTab( + "https://github.com/zen", + { [skipOption]: true }, + win + ); + Assert.deepEqual( + result, + { + shouldEarlyExit: false, + userContextId: null, + isRouteFound: false, + targetRoute: null, + }, + `Option '${skipOption}' skips routing even though a rule matches` + ); + } +}); + +add_task(async function test_onBeforeAddTab_skips_until_startup_ready() { + clearAllRoutes(); + addRoute({ + reference: "github.com", + matchType: "contains", + openIn: TARGET_WS.uuid, + }); + const win = makeFakeWindow({ ready: false, workspaces: [TARGET_WS] }); + + const result = gZenSpaceRoutingManager.onBeforeAddTab( + "https://github.com/zen", + {}, + win + ); + + Assert.deepEqual( + result, + { + shouldEarlyExit: false, + userContextId: null, + isRouteFound: false, + targetRoute: null, + }, + "While gZenStartup.isReady is false (session restore), routing is skipped" + ); +}); + +add_task(async function test_onAfterAddTab_moves_tab_on_non_origin_window() { + clearAllRoutes(); + addRoute({ + reference: "github.com", + matchType: "contains", + openIn: TARGET_WS.uuid, + }); + const win = makeFakeWindow({ ready: true, workspaces: [TARGET_WS] }); + const fakeTab = { parentNode: {} }; + + gZenSpaceRoutingManager.onAfterAddTab( + "https://github.com/zen", + fakeTab, + {}, + win, + { targetRoute: TARGET_WS.uuid } + ); + + await TestUtils.waitForCondition( + () => win.gZenWorkspaces.moveCalls.length === 1, + "moveTabToWorkspace was called once" + ); + Assert.equal( + win.gZenWorkspaces.moveCalls[0].uuid, + TARGET_WS.uuid, + "The tab is moved to the matched workspace" + ); + Assert.equal( + win.gZenWorkspaces.moveCalls[0].tab, + fakeTab, + "The correct tab element is moved" + ); + Assert.equal( + win.gZenWorkspaces.changeCalls.length, + 0, + "A non-originating window does not switch the active workspace" + ); +}); + +add_task(async function test_onAfterAddTab_reuses_before_result() { + clearAllRoutes(); + const win = makeFakeWindow({ ready: true, workspaces: [TARGET_WS] }); + const fakeTab = { parentNode: {} }; + + // No routes exist, so a fresh routeUri() would yield "most-recent-space" and + // move nothing. The tab is still moved to TARGET_WS, proving onAfterAddTab + // routes purely from the precomputed result rather than recomputing. + gZenSpaceRoutingManager.onAfterAddTab( + "https://example.com", + fakeTab, + {}, + win, + { targetRoute: TARGET_WS.uuid } + ); + + await TestUtils.waitForCondition( + () => win.gZenWorkspaces.moveCalls.length === 1, + "moveTabToWorkspace used the precomputed route" + ); + Assert.equal( + win.gZenWorkspaces.moveCalls[0].uuid, + TARGET_WS.uuid, + "onAfterAddTab routes using the precomputed targetRoute, not a fresh routeUri()" + ); +}); + +add_task(async function test_onAfterAddTab_ignores_detached_tab() { + clearAllRoutes(); + const win = makeFakeWindow({ ready: true, workspaces: [TARGET_WS] }); + + gZenSpaceRoutingManager.onAfterAddTab( + "https://github.com/zen", + { parentNode: null }, + {}, + win, + { targetRoute: TARGET_WS.uuid } + ); + await flushEventLoop(); + + Assert.equal( + win.gZenWorkspaces.moveCalls.length, + 0, + "A detached tab (no parentNode) is never moved" + ); +}); + +add_task( + async function test_onAfterAddTab_does_nothing_for_most_recent_space() { + clearAllRoutes(); + const win = makeFakeWindow({ ready: true, workspaces: [TARGET_WS] }); + + gZenSpaceRoutingManager.onAfterAddTab( + "https://example.com", + { parentNode: {} }, + {}, + win, + { targetRoute: "most-recent-space" } + ); + await flushEventLoop(); + + Assert.equal( + win.gZenWorkspaces.moveCalls.length, + 0, + "A 'most-recent-space' route does not move the tab" + ); + } +); + +add_task(async function test_onAfterAddTab_does_nothing_when_skipped() { + clearAllRoutes(); + const win = makeFakeWindow({ ready: true, workspaces: [TARGET_WS] }); + + // onBeforeAddTab reports targetRoute null for skipped/unready tabs; without a + // route there is nothing for onAfterAddTab to do. + gZenSpaceRoutingManager.onAfterAddTab( + "https://github.com/zen", + { parentNode: {} }, + {}, + win, + { targetRoute: null } + ); + await flushEventLoop(); + + Assert.equal( + win.gZenWorkspaces.moveCalls.length, + 0, + "A null targetRoute (skipped tab) is not routed" + ); +}); + +add_task(async function test_onAfterAddTab_ignores_missing_before_result() { + clearAllRoutes(); + const win = makeFakeWindow({ ready: true, workspaces: [TARGET_WS] }); + + gZenSpaceRoutingManager.onAfterAddTab( + "https://github.com/zen", + { parentNode: {} }, + {}, + win + ); + await flushEventLoop(); + + Assert.equal( + win.gZenWorkspaces.moveCalls.length, + 0, + "Without a beforeResult there is no precomputed route, so nothing is moved" + ); +}); + +add_task(async function test_onAfterAddTab_activates_workspace_on_origin() { + clearAllRoutes(); + await gZenWorkspaces.promiseInitialized; + + await gZenWorkspaces.createAndSaveWorkspace("SR Origin Test"); + const workspaces = gZenWorkspaces.getWorkspaces(); + const target = workspaces[workspaces.length - 1]; + + const isOriginating = + window === Services.wm.getMostRecentWindow("navigator:browser"); + ok(isOriginating, "Precondition: the test window is the most-recent window"); + + const ws = window.gZenWorkspaces; + const origMove = ws.moveTabToWorkspace; + const origChange = ws.changeWorkspace; + const origLastSelected = ws.lastSelectedWorkspaceTabs; + + let moved = null; + let changedTo = null; + ws.lastSelectedWorkspaceTabs = {}; + ws.moveTabToWorkspace = (tab, uuid) => { + moved = { tab, uuid }; + }; + ws.changeWorkspace = workspace => { + changedTo = workspace; + return Promise.resolve(); + }; + + const tab = BrowserTestUtils.addTab(gBrowser, "about:blank", { + skipAnimation: true, + skipRoute: true, + }); + + try { + gZenSpaceRoutingManager.onAfterAddTab( + "https://github.com/zen", + tab, + {}, + window, + { targetRoute: target.uuid } + ); + + await TestUtils.waitForCondition( + () => moved, + "moveTabToWorkspace was called" + ); + Assert.equal(moved.uuid, target.uuid, "Moved to the matched workspace"); + Assert.equal(moved.tab, tab, "Moved the tab we passed in"); + + await TestUtils.waitForCondition( + () => changedTo, + "changeWorkspace was called on the originating window" + ); + Assert.equal( + changedTo.uuid, + target.uuid, + "Activated the matched workspace" + ); + Assert.equal( + ws.lastSelectedWorkspaceTabs[target.uuid], + tab, + "The moved tab is remembered as the workspace's last-selected tab" + ); + } finally { + ws.moveTabToWorkspace = origMove; + ws.changeWorkspace = origChange; + ws.lastSelectedWorkspaceTabs = origLastSelected; + BrowserTestUtils.removeTab(tab); + await gZenWorkspaces.removeWorkspace(target.uuid); + } +}); diff --git a/src/zen/tests/space_routing/browser_space_routing_route_matching.js b/src/zen/tests/space_routing/browser_space_routing_route_matching.js new file mode 100644 index 000000000..5db7a422c --- /dev/null +++ b/src/zen/tests/space_routing/browser_space_routing_route_matching.js @@ -0,0 +1,108 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_contains_is_case_insensitive_substring() { + const route = { reference: "GitHub", matchType: "contains" }; + + ok( + gZenSpaceRoutingManager.isRouteMatching("https://github.com/zen", route), + "'contains' matches a substring regardless of case" + ); + ok( + gZenSpaceRoutingManager.isRouteMatching("https://api.GITHUB.com/v3", route), + "'contains' matches when the URL casing differs from the reference" + ); + ok( + !gZenSpaceRoutingManager.isRouteMatching("https://gitlab.com/zen", route), + "'contains' rejects a URL that does not include the reference" + ); +}); + +add_task(async function test_equal_to_normalizes_protocol_and_www() { + const route = { reference: "github.com", matchType: "equal-to" }; + + ok( + gZenSpaceRoutingManager.isRouteMatching("https://www.github.com/", route), + "'equal-to' ignores https://, www. and a trailing slash" + ); + ok( + gZenSpaceRoutingManager.isRouteMatching("HTTP://GitHub.com", route), + "'equal-to' is case-insensitive and strips http://" + ); + ok( + !gZenSpaceRoutingManager.isRouteMatching("https://github.com/zen", route), + "'equal-to' does not match when a path is present (not an exact host)" + ); + ok( + !gZenSpaceRoutingManager.isRouteMatching("https://notgithub.com", route), + "'equal-to' requires the whole normalized URL to be equal" + ); +}); + +add_task(async function test_regex_match_is_case_sensitive_on_raw_uri() { + ok( + gZenSpaceRoutingManager.isRouteMatching("https://zen-browser.app", { + reference: "^https://.*\\.app$", + matchType: "regex", + }), + "'regex' matches against the raw URI" + ); + + ok( + !gZenSpaceRoutingManager.isRouteMatching("https://github.com", { + reference: "GitHub", + matchType: "regex", + }), + "'regex' is case-sensitive (no implicit lower-casing like 'contains')" + ); +}); + +add_task(async function test_invalid_regex_is_swallowed() { + let threw = false; + let result; + try { + result = gZenSpaceRoutingManager.isRouteMatching( + "https://zen-browser.app", + { + reference: "([", + matchType: "regex", + } + ); + } catch (e) { + threw = true; + } + + ok(!threw, "An invalid regex does not throw out of isRouteMatching"); + Assert.strictEqual(result, false, "An invalid regex never matches"); +}); + +add_task(async function test_empty_reference_never_matches() { + for (const matchType of ["contains", "equal-to", "regex"]) { + ok( + !gZenSpaceRoutingManager.isRouteMatching("https://github.com", { + reference: "", + matchType, + }), + `An empty reference never matches (${matchType})` + ); + ok( + !gZenSpaceRoutingManager.isRouteMatching("https://github.com", { + reference: " ", + matchType, + }), + `A whitespace-only reference never matches (${matchType})` + ); + } +}); + +add_task(async function test_unknown_match_type_does_not_match() { + ok( + !gZenSpaceRoutingManager.isRouteMatching("https://github.com", { + reference: "github.com", + matchType: "starts-with", + }), + "An unsupported match type falls through to no match" + ); +}); diff --git a/src/zen/tests/space_routing/browser_space_routing_route_uri.js b/src/zen/tests/space_routing/browser_space_routing_route_uri.js new file mode 100644 index 000000000..2763116ef --- /dev/null +++ b/src/zen/tests/space_routing/browser_space_routing_route_uri.js @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_setup(async function () { + clearAllRoutes(); + const savedDefault = gZenSpaceRoutingManager.getDefaultExternalRoute(); + registerCleanupFunction(() => { + clearAllRoutes(); + gZenSpaceRoutingManager.setDefaultExternalRoute(savedDefault); + }); +}); + +add_task(async function test_no_match_returns_most_recent_space() { + clearAllRoutes(); + addRoute({ reference: "github.com", matchType: "contains", openIn: "ws-1" }); + + Assert.equal( + gZenSpaceRoutingManager.routeUri("https://example.com", {}), + "most-recent-space", + "A non-matching, non-external URL routes to most-recent-space" + ); +}); + +add_task(async function test_first_matching_route_wins() { + clearAllRoutes(); + addRoute({ reference: "github", matchType: "contains", openIn: "ws-first" }); + addRoute({ reference: "github", matchType: "contains", openIn: "ws-second" }); + + Assert.equal( + gZenSpaceRoutingManager.routeUri("https://github.com/zen", {}), + "ws-first", + "The openIn of the first matching route is returned, later matches ignored" + ); +}); + +add_task(async function test_external_default_only_applies_without_match() { + clearAllRoutes(); + gZenSpaceRoutingManager.setDefaultExternalRoute("ws-external"); + addRoute({ reference: "github", matchType: "contains", openIn: "ws-rule" }); + + Assert.equal( + gZenSpaceRoutingManager.routeUri("https://github.com", { + fromExternal: true, + }), + "ws-rule", + "A matching rule wins even for external links" + ); + + Assert.equal( + gZenSpaceRoutingManager.routeUri("https://example.com", { + fromExternal: true, + }), + "ws-external", + "An unmatched external link uses the default external route" + ); + + Assert.equal( + gZenSpaceRoutingManager.routeUri("https://example.com", { + fromExternal: false, + }), + "most-recent-space", + "An unmatched internal link ignores the external default" + ); +}); diff --git a/src/zen/tests/space_routing/head.js b/src/zen/tests/space_routing/head.js new file mode 100644 index 000000000..ab6717d65 --- /dev/null +++ b/src/zen/tests/space_routing/head.js @@ -0,0 +1,80 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { gZenSpaceRoutingManager } = ChromeUtils.importESModule( + "resource:///modules/zen/spacerouting/ZenSpaceRoutingManager.sys.mjs" +); + +const SR_DIALOG_URI = + "chrome://browser/content/zen-components/windows/zen-space-routing.xhtml"; + +function clearAllRoutes() { + for (const route of gZenSpaceRoutingManager.getAllRoutes()) { + gZenSpaceRoutingManager.removeRoute(route.id); + } +} + +function addRoute({ + reference = "", + openIn = "most-recent-space", + matchType = "contains", +} = {}) { + const route = gZenSpaceRoutingManager.createNewRoute(); + route.reference = reference; + route.openIn = openIn; + route.matchType = matchType; + gZenSpaceRoutingManager.updateRoute(route); + return route; +} + +function makeFakeWindow({ ready = true, workspaces = [] } = {}) { + return { + gZenStartup: { isReady: ready }, + gZenWorkspaces: { + moveCalls: [], + changeCalls: [], + lastSelectedWorkspaceTabs: {}, + getWorkspaceFromId(id) { + return workspaces.find(w => w.uuid === id) || null; + }, + moveTabToWorkspace(tab, uuid) { + this.moveCalls.push({ tab, uuid }); + }, + changeWorkspace(workspace) { + this.changeCalls.push(workspace); + return Promise.resolve(); + }, + }, + }; +} + +async function flushEventLoop() { + for (let i = 0; i < 5; i++) { + await new Promise(resolve => Services.tm.dispatchToMainThread(resolve)); + } +} + +async function openRoutingDialog() { + const dialogPromise = BrowserTestUtils.domWindowOpenedAndLoaded(null, win => + win.document?.documentURI?.includes("zen-space-routing.xhtml") + ); + executeSoon(() => gZenSpaceRoutingManager.openSpaceRoutingDialog(window)); + const dialogWin = await dialogPromise; + await SimpleTest.promiseFocus(dialogWin); + await TestUtils.waitForCondition( + () => dialogWin.spaceroutingDialog?.initialized, + "Space Routing dialog finished initializing" + ); + return dialogWin; +} + +async function closeRoutingDialog(dialogWin) { + if (dialogWin.closed) { + return; + } + const closed = BrowserTestUtils.domWindowClosed(dialogWin); + dialogWin.close(); + await closed; +} diff --git a/src/zen/urlbar/ZenUBGlobalActions.sys.mjs b/src/zen/urlbar/ZenUBGlobalActions.sys.mjs index bfc789bb5..613c0073f 100644 --- a/src/zen/urlbar/ZenUBGlobalActions.sys.mjs +++ b/src/zen/urlbar/ZenUBGlobalActions.sys.mjs @@ -81,6 +81,11 @@ const globalActionsTemplate = [ return !tab.hasAttribute("zen-empty-tab") && tab.pinned; }, }, + { + label: "Open Space Routing", + command: "cmd_zenOpenSpaceRoutingSettings", + icon: "chrome://browser/skin/zen-icons/selectable/airplane.svg", + }, { label: "New Boost", icon: "chrome://browser/skin/zen-icons/boost.svg", diff --git a/src/zen/zen.globals.mjs b/src/zen/zen.globals.mjs index 058a4d413..204cf37b7 100644 --- a/src/zen/zen.globals.mjs +++ b/src/zen/zen.globals.mjs @@ -40,6 +40,8 @@ export default [ "gZenViewSplitter", + "gZenSpaceRoutingManager", + "Ci", "Cu", "Cc",