mirror of
https://github.com/zen-browser/desktop.git
synced 2026-06-13 15:03:41 +00:00
gh-14044: Implement Space Routing (gh-13981)
Signed-off-by: mr. m <91018726+mr-cheffy@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
|
||||
25
locales/en-US/browser/browser/zen-space-routing.ftl
Normal file
25
locales/en-US/browser/browser/zen-space-routing.ftl
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
<command id="cmd_zenCtxDeleteWorkspace" />
|
||||
<command id="cmd_zenUnloadWorkspace" />
|
||||
<command id="cmd_zenUnloadAllOtherWorkspace" />
|
||||
<command id="cmd_zenOpenSpaceRoutingSettings" />
|
||||
<command id="cmd_zenChangeWorkspaceName" />
|
||||
<command id="cmd_zenChangeWorkspaceIcon" />
|
||||
<command id="cmd_zenReorderWorkspaces" />
|
||||
|
||||
@@ -11,4 +11,5 @@
|
||||
<link rel="localization" href="browser/zen-folders.ftl"/>
|
||||
<link rel="localization" href="browser/zen-boosts.ftl"/>
|
||||
<link rel="localization" href="browser/zen-live-folders.ftl"/>
|
||||
<link rel="localization" href="browser/zen-space-routing.ftl"/>
|
||||
</linkset>
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<menuitem id="context_zenReorderWorkspaces" data-l10n-id="zen-workspaces-panel-context-reorder" command="cmd_zenReorderWorkspaces"/>
|
||||
<menuitem id="context_zenUnloadWorkspace" data-l10n-id="zen-workspaces-panel-unload" command="cmd_zenUnloadWorkspace"/>
|
||||
<menuitem id="context_zenUnloadAllOtherWorkspace" data-l10n-id="zen-workspaces-panel-unload-others" command="cmd_zenUnloadAllOtherWorkspace"/>
|
||||
<menuitem id="context_zenSpaceRoutingSettings" data-l10n-id="zen-space-routing-settings" command="cmd_zenOpenSpaceRoutingSettings"/>
|
||||
<menuseparator/>
|
||||
<menuitem data-l10n-id="zen-panel-ui-workspaces-create" command="cmd_zenOpenWorkspaceCreation"/>
|
||||
<menuitem id="context_zenDeleteWorkspace" data-l10n-id="zen-workspaces-panel-context-delete" command="cmd_zenCtxDeleteWorkspace"/>
|
||||
|
||||
@@ -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 `<tab-group>`
|
||||
*/
|
||||
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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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/.
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 18 18"><path d="M2.75 3a.75.75 0 0 1 .75.75v4C3.5 8.44 4.06 9 4.75 9h8.69l-2.97-2.97a.75.75 0 1 1 1.06-1.06l4.25 4.25a.8.8 0 0 1 .118.16q.025.04.044.083a.75.75 0 0 1-.078.715 1 1 0 0 1-.084.102l-4.25 4.25a.75.75 0 0 1-1.06-1.06l2.97-2.97H4.75A2.75 2.75 0 0 1 2 7.75v-4A.75.75 0 0 1 2.75 3"/></svg>
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -133,6 +133,10 @@ document.addEventListener(
|
||||
gZenWorkspaces.unloadAllOtherWorkspaces();
|
||||
break;
|
||||
}
|
||||
case "cmd_zenOpenSpaceRoutingSettings": {
|
||||
gZenSpaceRoutingManager.openSpaceRoutingDialog(window);
|
||||
break;
|
||||
}
|
||||
case "cmd_zenNewNavigatorUnsynced":
|
||||
OpenBrowserWindow({ zenSyncedWindow: false });
|
||||
break;
|
||||
|
||||
@@ -214,6 +214,7 @@ class nsZenGlanceManager extends nsZenDOMOperatedFeature {
|
||||
skipAnimation: true,
|
||||
ownerTab: currentTab,
|
||||
triggeringPrincipal: data.triggeringPrincipal,
|
||||
skipRoute: true,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -19,4 +19,5 @@ DIRS += [
|
||||
"sessionstore",
|
||||
"share",
|
||||
"spaces",
|
||||
"space-routing",
|
||||
]
|
||||
|
||||
454
src/zen/space-routing/ZenSpaceRoutingDialog.mjs
Normal file
454
src/zen/space-routing/ZenSpaceRoutingDialog.mjs
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
413
src/zen/space-routing/ZenSpaceRoutingManager.sys.mjs
Normal file
413
src/zen/space-routing/ZenSpaceRoutingManager.sys.mjs
Normal file
@@ -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<object>} 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<Map>} 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();
|
||||
9
src/zen/space-routing/jar.inc.mn
Normal file
9
src/zen/space-routing/jar.inc.mn
Normal file
@@ -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)
|
||||
8
src/zen/space-routing/moz.build
Normal file
8
src/zen/space-routing/moz.build
Normal file
@@ -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",
|
||||
]
|
||||
315
src/zen/space-routing/zen-space-routing.css
Normal file
315
src/zen/space-routing/zen-space-routing.css
Normal file
@@ -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;
|
||||
}
|
||||
108
src/zen/space-routing/zen-space-routing.inc.xhtml
Normal file
108
src/zen/space-routing/zen-space-routing.inc.xhtml
Normal file
@@ -0,0 +1,108 @@
|
||||
#filter substitution
|
||||
<?xml version="1.0"?>
|
||||
|
||||
# -*- 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/.
|
||||
|
||||
<!DOCTYPE window>
|
||||
|
||||
<window
|
||||
xmlns:html="http://www.w3.org/1999/xhtml"
|
||||
xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
|
||||
windowtype="Zen:SpaceRouting"
|
||||
customtitlebar="true"
|
||||
id="zenSpaceRoutingDialog"
|
||||
dialogroot="true"
|
||||
>
|
||||
<linkset>
|
||||
<html:link rel="stylesheet" href="chrome://global/skin/commonDialog.css" />
|
||||
<html:link
|
||||
rel="stylesheet"
|
||||
href="chrome://global/content/commonDialog.css"
|
||||
/>
|
||||
<html:link
|
||||
rel="stylesheet"
|
||||
href="chrome://global/skin/global.css"
|
||||
type="text/css"
|
||||
/>
|
||||
<html:link
|
||||
rel="stylesheet"
|
||||
href="chrome://browser/content/zen-styles/zen-theme.css"
|
||||
type="text/css"
|
||||
/>
|
||||
<html:link
|
||||
rel="stylesheet"
|
||||
href="chrome://browser/content/zen-styles/zen-space-routing.css"
|
||||
type="text/css"
|
||||
/>
|
||||
<html:link
|
||||
rel="stylesheet"
|
||||
href="chrome://browser/skin/zen-icons/icons.css"
|
||||
type="text/css"
|
||||
/>
|
||||
<html:link
|
||||
rel="stylesheet"
|
||||
href="chrome://browser/content/zen-styles/zen-animations.css"
|
||||
type="text/css"
|
||||
/>
|
||||
<html:link
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
href="chrome://browser/content/zen-styles/zen-popup.css"
|
||||
/>
|
||||
<html:link rel="localization" href="browser/zen-space-routing.ftl" />
|
||||
</linkset>
|
||||
|
||||
<vbox
|
||||
id="zen-space-routing-dialog-container"
|
||||
class="dialogBox"
|
||||
role="dialog"
|
||||
>
|
||||
<vbox id="sr-container">
|
||||
<hbox id="sr-header">
|
||||
<h3 data-l10n-id="zen-space-routing-dialog-title"></h3>
|
||||
<html:div class="sr-left">
|
||||
<button
|
||||
id="sr-new-route"
|
||||
data-l10n-id="zen-space-routing-new-route"
|
||||
></button>
|
||||
<button class="close-icon" data-l10n-id="zen-space-routing-close-button" id="sr-close"></button>
|
||||
</html:div>
|
||||
</hbox>
|
||||
<html:hr class="hr"></html:hr>
|
||||
<vbox id="sr-content">
|
||||
<vbox id="sr-empty-content">
|
||||
<image class="sr-airplane"></image>
|
||||
<p data-l10n-id="zen-space-routing-rulepanel-placeholder"></p>
|
||||
</vbox>
|
||||
# All rules will be injected here later
|
||||
</vbox>
|
||||
<html:hr class="hr"></html:hr>
|
||||
<hbox id="sr-footer">
|
||||
<h4 data-l10n-id="zen-space-routing-external-default"></h4>
|
||||
<html:div class="sr-left">
|
||||
<menulist class="select" id="sr-default-external-open-in">
|
||||
<menupopup id="sr-default-external-open-in-popup">
|
||||
# Select open in types will be injected here
|
||||
</menupopup>
|
||||
</menulist>
|
||||
</html:div>
|
||||
</hbox>
|
||||
</vbox>
|
||||
</vbox>
|
||||
|
||||
<script>
|
||||
const { nsZenSpaceRoutingDialog } = ChromeUtils.importESModule(
|
||||
"resource:///modules/zen/spacerouting/ZenSpaceRoutingDialog.mjs",
|
||||
);
|
||||
const args = window.arguments?.[0] || {};
|
||||
window.spaceroutingDialog = new nsZenSpaceRoutingDialog(
|
||||
document,
|
||||
window,
|
||||
args.parentWindow,
|
||||
);
|
||||
</script>
|
||||
</window>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
18
src/zen/tests/space_routing/browser.toml
Normal file
18
src/zen/tests/space_routing/browser.toml
Normal file
@@ -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"]
|
||||
115
src/zen/tests/space_routing/browser_space_routing_crud.js
Normal file
115
src/zen/tests/space_routing/browser_space_routing_crud.js
Normal file
@@ -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"
|
||||
);
|
||||
});
|
||||
251
src/zen/tests/space_routing/browser_space_routing_dialog.js
Normal file
251
src/zen/tests/space_routing/browser_space_routing_dialog.js
Normal file
@@ -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");
|
||||
});
|
||||
363
src/zen/tests/space_routing/browser_space_routing_on_add_tab.js
Normal file
363
src/zen/tests/space_routing/browser_space_routing_on_add_tab.js
Normal file
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
80
src/zen/tests/space_routing/head.js
Normal file
80
src/zen/tests/space_routing/head.js
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -40,6 +40,8 @@ export default [
|
||||
|
||||
"gZenViewSplitter",
|
||||
|
||||
"gZenSpaceRoutingManager",
|
||||
|
||||
"Ci",
|
||||
"Cu",
|
||||
"Cc",
|
||||
|
||||
Reference in New Issue
Block a user