feat: Ctrl+Tab cycling to keep within essentials/regular tabs only, p=#11242

* feat: ctrl+tab cycles within essential/workspace tabs only

* add test for ctrl+tab cycling by attribute

* move settings UI related code to /src/zen, revert patches for main.inc.xhtml and main.js, use simple tab filter when current tab is hidden

* chore: Cleanup and add extra pref, b=no-bug, c=tests, tabs

---------

Co-authored-by: mr. m <mr.m@tuta.com>
This commit is contained in:
Jun Jie
2025-11-11 17:53:39 +08:00
committed by GitHub
parent 8b64925407
commit 249d793b5d
7 changed files with 208 additions and 8 deletions

View File

@@ -59,6 +59,12 @@ zen-tabs-unloader-enabled =
zen-tabs-close-on-back-with-no-history =
.label = Close tab and switch to its owner tab (or most recently used tab) when going back with no history
zen-tabs-cycle-by-attribute =
.label = Ctrl+Tab cycles within Essential or Workspace tabs only
zen-tabs-cycle-ignore-pending-tabs =
.label = Ignore Pending tabs when cycling with Ctrl+Tab
zen-tabs-cycle-by-attribute-warning = Ctrl+Tab will cycle by recently used order, as it is enabled
zen-look-and-feel-compact-toolbar-themed =
.label = Use themed background for compact toolbar
@@ -150,7 +156,7 @@ zen-theme-marketplace-input-default-placeholder =
.placeholder = Type something...
pane-zen-marketplace-title = Zen Mods
zen-themes-auto-update =
.label = Automatically update installed mods on startup
.label = Automatically update installed mods on startup
zen-settings-workspaces-force-container-tabs-to-workspace =
.label = Switch to workspace where container is set as default when opening container tabs

View File

@@ -37,3 +37,9 @@
- name: zen.tabs.close-on-back-with-no-history
value: true
- name: zen.tabs.ctrl-tab.ignore-essential-tabs
value: false
- name: zen.tabs.ctrl-tab.ignore-pending-tabs
value: false

View File

@@ -707,13 +707,39 @@ var gZenWorkspacesSettings = {
}
},
};
let toggleZenCycleByAttrWarning = {
observe() {
const warning = document.getElementById('zenTabsCycleByAttributeWarning');
warning.hidden = !(
Services.prefs.getBoolPref('zen.tabs.ctrl-tab.ignore-essential-tabs', false) &&
Services.prefs.getBoolPref('browser.ctrlTab.sortByRecentlyUsed', false)
);
},
};
toggleZenCycleByAttrWarning.observe(); // call it once on initial load
Services.prefs.addObserver('zen.glance.enabled', tabsUnloaderPrefListener); // We can use the same listener for both prefs
Services.prefs.addObserver('zen.workspaces.separate-essentials', tabsUnloaderPrefListener);
Services.prefs.addObserver('zen.glance.activation-method', tabsUnloaderPrefListener);
Services.prefs.addObserver(
'zen.tabs.ctrl-tab.ignore-essential-tabs',
toggleZenCycleByAttrWarning
);
Services.prefs.addObserver('browser.ctrlTab.sortByRecentlyUsed', toggleZenCycleByAttrWarning);
window.addEventListener('unload', () => {
Services.prefs.removeObserver('zen.glance.enabled', tabsUnloaderPrefListener);
Services.prefs.removeObserver('zen.glance.activation-method', tabsUnloaderPrefListener);
Services.prefs.removeObserver('zen.workspaces.separate-essentials', tabsUnloaderPrefListener);
Services.prefs.removeObserver(
'zen.tabs.ctrl-tab.ignore-essential-tabs',
toggleZenCycleByAttrWarning
);
Services.prefs.removeObserver(
'browser.ctrlTab.sortByRecentlyUsed',
toggleZenCycleByAttrWarning
);
});
},
};
@@ -1135,6 +1161,21 @@ Preferences.addAll([
type: 'bool',
default: true,
},
{
id: 'zen.tabs.ctrl-tab.ignore-essential-tabs',
type: 'bool',
default: false,
},
{
id: 'zen.tabs.ctrl-tab.ignore-pending-tabs',
type: 'bool',
default: false,
},
{
id: 'zen.tabs.close-on-back-with-no-history',
type: 'bool',
default: false,
},
]);
Preferences.addSetting({

View File

@@ -29,6 +29,14 @@
<checkbox id="zenTabsCloseOnBackWithNoHistory"
data-l10n-id="zen-tabs-close-on-back-with-no-history"
preference="zen.tabs.close-on-back-with-no-history"/>
<checkbox data-l10n-id="zen-tabs-cycle-ignore-pending-tabs"
preference="zen.tabs.ctrl-tab.ignore-pending-tabs"/>
<checkbox data-l10n-id="zen-tabs-cycle-by-attribute"
preference="zen.tabs.ctrl-tab.ignore-essential-tabs"/>
<description id="zenTabsCycleByAttributeWarning"
class="description-deemphasized"
data-l10n-id="zen-tabs-cycle-by-attribute-warning"
hidden="true"/>
</groupbox>
<hbox id="zenTabsUnloadCategory"

View File

@@ -1,8 +1,32 @@
diff --git a/toolkit/content/widgets/tabbox.js b/toolkit/content/widgets/tabbox.js
index cfe2da6e199667bd668f117cc8972212c7f57da2..bdd7dcc26139202e6e31afde47dc4d877f3db3c5 100644
index cfe2da6e199667bd668f117cc8972212c7f57da2..470033466eae0e853855e21b86a0722627f9ed4b 100644
--- a/toolkit/content/widgets/tabbox.js
+++ b/toolkit/content/widgets/tabbox.js
@@ -213,7 +213,7 @@
@@ -11,6 +11,23 @@
"resource://gre/modules/AppConstants.sys.mjs"
);
+ const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+ );
+ const lazyZenPrefs = {};
+ XPCOMUtils.defineLazyPreferenceGetter(
+ lazyZenPrefs,
+ "cycleByAttribute",
+ "zen.tabs.ctrl-tab.ignore-essential-tabs",
+ false
+ );
+ XPCOMUtils.defineLazyPreferenceGetter(
+ lazyZenPrefs,
+ "ignorePendingTabs",
+ "zen.tabs.ctrl-tab.ignore-pending-tabs",
+ false
+ );
+
let imports = {};
ChromeUtils.defineESModuleGetters(imports, {
ShortcutUtils: "resource://gre/modules/ShortcutUtils.sys.mjs",
@@ -213,7 +230,7 @@
) {
this._inAsyncOperation = false;
if (oldPanel != this._selectedPanel) {
@@ -11,7 +35,7 @@ index cfe2da6e199667bd668f117cc8972212c7f57da2..bdd7dcc26139202e6e31afde47dc4d87
this._selectedPanel?.classList.add("deck-selected");
}
this.setAttribute("selectedIndex", val);
@@ -697,7 +697,7 @@
@@ -697,7 +714,7 @@
if (!tab) {
return;
}
@@ -20,7 +44,7 @@ index cfe2da6e199667bd668f117cc8972212c7f57da2..bdd7dcc26139202e6e31afde47dc4d87
if (otherTab != tab && otherTab.selected) {
otherTab._selected = false;
}
@@ -733,6 +733,7 @@
@@ -733,6 +750,7 @@
* @param {MozTab|null} [val]
*/
set selectedItem(val) {
@@ -28,7 +52,7 @@ index cfe2da6e199667bd668f117cc8972212c7f57da2..bdd7dcc26139202e6e31afde47dc4d87
if (val && !val.selected) {
// The selectedIndex setter ignores invalid values
// such as -1 if |val| isn't one of our child nodes.
@@ -910,7 +911,7 @@
@@ -910,7 +928,7 @@
if (tab == startTab) {
return null;
}
@@ -37,7 +61,7 @@ index cfe2da6e199667bd668f117cc8972212c7f57da2..bdd7dcc26139202e6e31afde47dc4d87
return tab;
}
}
@@ -972,10 +973,11 @@
@@ -972,13 +990,30 @@
* @param {boolean} [aWrap]
*/
advanceSelectedTab(aDir, aWrap) {
@@ -50,3 +74,31 @@ index cfe2da6e199667bd668f117cc8972212c7f57da2..bdd7dcc26139202e6e31afde47dc4d87
}
let newTab = null;
+ const tabFilter = tab => {
+ if (!tab.visible) {
+ return false
+ }
+ if (lazyZenPrefs.ignorePendingTabs && tab.hasAttribute("pending")) {
+ return false
+ }
+ if (!lazyZenPrefs.cycleByAttribute) {
+ return true
+ }
+ if (startTab.hasAttribute("zen-essential")) {
+ return tab.hasAttribute("zen-essential")
+ }
+ return !tab.hasAttribute("zen-essential")
+ }
+
// Handle keyboard navigation for a hidden tab that can be selected, like the Firefox View tab,
// which has a random placement in this.allTabs.
if (startTab.hidden) {
@@ -991,7 +1026,7 @@
newTab = this.findNextTab(startTab, {
direction: aDir,
wrap: aWrap,
- filter: tab => tab.visible,
+ filter: tabFilter,
});
}

View File

@@ -9,8 +9,9 @@ support-files = [
["browser_tabs_empty_checks.js"]
["browser_tabs_fetch_checks.js"]
["browser_tabs_cycle_by_attribute.js"]
["browser_drag_drop_vertical.js"]
tags = [
"drag-drop",
"vertical-tabs"
]
]

View File

@@ -0,0 +1,86 @@
'use strict';
const URL1 = 'data:text/plain,tab1';
const URL2 = 'data:text/plain,tab2';
const URL3 = 'data:text/plain,tab3';
const URL4 = 'data:text/plain,tab4';
const URL5 = 'data:text/plain,tab5';
const URL6 = 'data:text/plain,tab6';
/**
* ensures that tab select action is completed
*/
async function selectTab(tab) {
const onSelect = BrowserTestUtils.waitForEvent(gBrowser.tabContainer, 'TabSelect');
gBrowser.selectedTab = tab;
await onSelect;
}
add_setup(async () => {
// remove default new tab
const tabToRemove = gBrowser.selectedTab;
BrowserTestUtils.removeTab(tabToRemove);
const tabs = await Promise.all([
addTabTo(gBrowser, URL1),
addTabTo(gBrowser, URL2),
addTabTo(gBrowser, URL3),
addTabTo(gBrowser, URL4),
addTabTo(gBrowser, URL5),
addTabTo(gBrowser, URL6),
]);
gZenPinnedTabManager.addToEssentials(tabs.slice(0, 3));
await BrowserTestUtils.waitForCondition(
() => tabs.slice(0, 3).every((tab) => tab.hasAttribute('zen-essential')),
'all essentials ready'
);
const essentialTabs = gBrowser.tabs.filter((tab) => tab.hasAttribute('zen-essential'));
Assert.equal(essentialTabs.length, 3, '3 essential tabs created');
const workspaceTabs = gBrowser.tabs.filter(
(tab) => !tab.hasAttribute('zen-essential') && !tab.hasAttribute('zen-empty-tab')
);
Assert.equal(workspaceTabs.length, 3, '3 workspace tabs created, excluding empty tab');
registerCleanupFunction(async () => {
// replace the default new tab in the test window
addTabTo(gBrowser, 'about:blank');
tabs.forEach((element) => {
BrowserTestUtils.removeTab(element);
});
await SpecialPowers.popPrefEnv();
});
});
add_task(async function cycleTabsByAttribute() {
await SpecialPowers.pushPrefEnv({
set: [['zen.tabs.ctrl-tab.ignore-essential-tabs', true]],
});
const essentialTabs = gBrowser.tabs.filter((tab) => tab.hasAttribute('zen-essential'));
await selectTab(essentialTabs[0]);
gBrowser.tabContainer.advanceSelectedTab(1, true);
gBrowser.tabContainer.advanceSelectedTab(1, true);
gBrowser.tabContainer.advanceSelectedTab(1, true);
ok(
gBrowser.selectedTab === essentialTabs[0],
'tab cycling applies within essential tabs only, as the starting tab is a essential tab'
);
const workspaceTabs = gBrowser.tabs.filter(
(tab) => !tab.hasAttribute('zen-essential') && !tab.hasAttribute('zen-empty-tab')
);
await selectTab(workspaceTabs[0]);
gBrowser.tabContainer.advanceSelectedTab(1, true);
gBrowser.tabContainer.advanceSelectedTab(1, true);
gBrowser.tabContainer.advanceSelectedTab(1, true);
ok(
gBrowser.selectedTab === workspaceTabs[0],
'tab cycling applies within workspace tabs only, as the starting tab is a workspace tab'
);
});