diff --git a/src/browser/base/content/zen-commands.inc.xhtml b/src/browser/base/content/zen-commands.inc.xhtml index aad8b0c741..d6f4b11399 100644 --- a/src/browser/base/content/zen-commands.inc.xhtml +++ b/src/browser/base/content/zen-commands.inc.xhtml @@ -55,6 +55,10 @@ + + + + diff --git a/src/browser/components/preferences/zen-settings.js b/src/browser/components/preferences/zen-settings.js index a93ba5b460..6107d11252 100644 --- a/src/browser/components/preferences/zen-settings.js +++ b/src/browser/components/preferences/zen-settings.js @@ -775,6 +775,10 @@ var zenMissingKeyboardShortcutL10n = { key_wrToggleCaptureSequenceCmd: 'zen-key-wr-toggle-capture-sequence-cmd', key_undoCloseWindow: 'zen-key-undo-close-window', + key_zenTabNext: 'zen-tab-next-shortcut', + key_zenTabPrevious: 'zen-tab-prev-shortcut', + key_toggleUnloadedCycling: 'zen-toggle-unloaded-cycling-shortcut', + 'zen-glance-expand': 'zen-glance-expand', key_selectTab1: 'zen-key-select-tab-1', @@ -1160,4 +1164,9 @@ Preferences.addAll([ type: 'bool', default: true, }, + { + id: 'zen.tabs.unloaded-navigation-mode', + type: 'string', + default: 'always', + }, ]); diff --git a/src/browser/components/preferences/zenTabsManagement.inc.xhtml b/src/browser/components/preferences/zenTabsManagement.inc.xhtml index b8693566fd..32dc4525a1 100644 --- a/src/browser/components/preferences/zenTabsManagement.inc.xhtml +++ b/src/browser/components/preferences/zenTabsManagement.inc.xhtml @@ -68,6 +68,33 @@ - + + + + + + \ No newline at end of file diff --git a/src/toolkit/content/widgets/tabbox-js.patch b/src/toolkit/content/widgets/tabbox-js.patch index 72d25a5245..9bdf480f50 100644 --- a/src/toolkit/content/widgets/tabbox-js.patch +++ b/src/toolkit/content/widgets/tabbox-js.patch @@ -2,7 +2,15 @@ diff --git a/toolkit/content/widgets/tabbox.js b/toolkit/content/widgets/tabbox. index 70afbfc87d543971e6f8a0661a44b682920a7bc4..2f767296db8043318fab2aeb39bfc5eee090b258 100644 --- a/toolkit/content/widgets/tabbox.js +++ b/toolkit/content/widgets/tabbox.js -@@ -213,7 +213,7 @@ +@@ -125,6 +125,7 @@ + + const { ShortcutUtils } = imports; + ++ return; + switch (ShortcutUtils.getSystemActionForEvent(event)) { + case ShortcutUtils.CYCLE_TABS: + Glean.browserUiInteraction.keyboard["ctrl-tab"].add(1); +@@ -213,7 +214,7 @@ ) { this._inAsyncOperation = false; if (oldPanel != this._selectedPanel) { @@ -11,7 +19,7 @@ index 70afbfc87d543971e6f8a0661a44b682920a7bc4..2f767296db8043318fab2aeb39bfc5ee this._selectedPanel?.classList.add("deck-selected"); } this.setAttribute("selectedIndex", val); -@@ -610,7 +610,7 @@ +@@ -610,7 +611,7 @@ if (!tab) { return; } @@ -20,7 +28,7 @@ index 70afbfc87d543971e6f8a0661a44b682920a7bc4..2f767296db8043318fab2aeb39bfc5ee if (otherTab != tab && otherTab.selected) { otherTab._selected = false; } -@@ -823,7 +823,7 @@ +@@ -823,7 +824,7 @@ if (tab == startTab) { return null; } @@ -29,7 +37,18 @@ index 70afbfc87d543971e6f8a0661a44b682920a7bc4..2f767296db8043318fab2aeb39bfc5ee return tab; } } -@@ -888,7 +888,7 @@ +@@ -885,10 +886,18 @@ + * @param {boolean} [aWrap] + */ + advanceSelectedTab(aDir, aWrap) { ++ ++ const basePref = Services.prefs.getStringPref('zen.tabs.unloaded-navigation-mode', 'never'); ++ const invertedState = Services.prefs.getBoolPref('zen.tabs.unloaded-navigation-mode.inverted', false); ++ const includeUnloaded = (basePref === 'always' && !invertedState) || (basePref === 'never' && invertedState); ++ const tabFilter = includeUnloaded ++ ? (tab => tab.visible) ++ : (tab => tab.visible && !tab.hasAttribute('pending')); ++ let { ariaFocusedItem } = this; let startTab = ariaFocusedItem; if (!ariaFocusedItem || !this.allTabs.includes(ariaFocusedItem)) { @@ -37,4 +56,23 @@ index 70afbfc87d543971e6f8a0661a44b682920a7bc4..2f767296db8043318fab2aeb39bfc5ee + startTab = gZenGlanceManager.getFocusedTab(aDir) || this.selectedItem; } let newTab = null; - + +@@ -896,15 +905,15 @@ + // which has a random placement in this.allTabs. + if (startTab.hidden) { + if (aDir == 1) { +- newTab = this.allTabs.find(tab => tab.visible); ++ newTab = this.allTabs.find(tabFilter); + } else { +- newTab = this.allTabs.findLast(tab => tab.visible); ++ newTab = this.allTabs.findLast(tabFilter); + } + } else { + newTab = this.findNextTab(startTab, { + direction: aDir, + wrap: aWrap, +- filter: tab => tab.visible, ++ filter: tabFilter, + }); + } + diff --git a/src/zen/common/ZenCommonUtils.mjs b/src/zen/common/ZenCommonUtils.mjs index 5b0a09c67a..67663de9c8 100644 --- a/src/zen/common/ZenCommonUtils.mjs +++ b/src/zen/common/ZenCommonUtils.mjs @@ -17,7 +17,7 @@ window.gZenOperatingSystemCommonUtils = { /* eslint-disable no-unused-vars */ class nsZenMultiWindowFeature { - constructor() {} + constructor() { } static get browsers() { return Services.wm.getEnumerator('navigator:browser'); @@ -121,6 +121,31 @@ var gZenCommonActions = { } }, + nextTab() { + gBrowser + .tabContainer + .advanceSelectedTab(1, true); + }, + + previousTab() { + gBrowser + .tabContainer + .advanceSelectedTab(-1, true); + }, + + toggleUnloadedCycling() { + try { + const currentMode = Services.prefs.getStringPref( + 'zen.tabs.unloaded-navigation-mode', + 'always' + ); + const nextMode = currentMode === 'always' ? 'never' : 'always'; + Services.prefs.setStringPref('zen.tabs.unloaded-navigation-mode', nextMode); + } catch (e) { + console.error('[gZenCommonActions] Error on unloaded cycling:', e); + } + }, + throttle(f, delay) { let timer = 0; return function (...args) { diff --git a/src/zen/common/zen-sets.js b/src/zen/common/zen-sets.js index 5d8316589c..e1b8e3c599 100644 --- a/src/zen/common/zen-sets.js +++ b/src/zen/common/zen-sets.js @@ -47,6 +47,15 @@ document.addEventListener( case 'cmd_zenSplitViewUnsplit': gZenViewSplitter.toggleShortcut('unsplit'); break; + case 'cmd_zenTabNext': + gZenCommonActions.nextTab(); + break; + case 'cmd_zenTabPrev': + gZenCommonActions.previousTab(); + break; + case 'cmd_zenToggleUnloadedCycling': + gZenCommonActions.toggleUnloadedCycling(); + break; case 'cmd_zenSplitViewContextMenu': gZenViewSplitter.contextSplitTabs(); break; diff --git a/src/zen/kbs/ZenKeyboardShortcuts.mjs b/src/zen/kbs/ZenKeyboardShortcuts.mjs index f9636a4ebb..77abeb53b4 100644 --- a/src/zen/kbs/ZenKeyboardShortcuts.mjs +++ b/src/zen/kbs/ZenKeyboardShortcuts.mjs @@ -229,10 +229,10 @@ class nsKeyShortcutModifiers { this.#control == other.#control && (AppConstants.platform == 'macosx' ? (this.#meta || this.#accel) == (other.#meta || other.#accel) && - this.#control == other.#control + this.#control == other.#control : // In other platforms, we can have control and accel counting as the same thing - this.#meta == other.#meta && - (this.#control || this.#accel) == (other.#control || other.#accel)) + this.#meta == other.#meta && + (this.#control || this.#accel) == (other.#control || other.#accel)) ); } @@ -303,6 +303,7 @@ class KeyShortcut { #reserved = false; #internal = false; #shouldBeEmpty = false; + #isPrecedent = false; constructor( id, @@ -314,7 +315,8 @@ class KeyShortcut { l10nId, disabled = false, reserved = false, - internal = false + internal = false, + isPrecedent = false ) { this.#id = id; this.#key = key?.toLowerCase(); @@ -331,6 +333,7 @@ class KeyShortcut { this.#disabled = disabled; this.#reserved = reserved; this.#internal = internal; + this.#isPrecedent = isPrecedent; } isEmpty() { @@ -369,7 +372,8 @@ class KeyShortcut { json['l10nId'], json['disabled'], json['reserved'], - json['internal'] + json['internal'], + json['isPrecedent'] ); } @@ -379,16 +383,17 @@ class KeyShortcut { key.getAttribute('key'), key.getAttribute('keycode'), group ?? - KeyShortcut.getGroupFromL10nId( - KeyShortcut.sanitizeL10nId(key.getAttribute('data-l10n-id')), - key.getAttribute('id') - ), + KeyShortcut.getGroupFromL10nId( + KeyShortcut.sanitizeL10nId(key.getAttribute('data-l10n-id')), + key.getAttribute('id') + ), nsKeyShortcutModifiers.parseFromXHTMLAttribute(key.getAttribute('modifiers')), key.getAttribute('command'), key.getAttribute('data-l10n-id'), key.getAttribute('disabled') == 'true', key.getAttribute('reserved') == 'true', - key.getAttribute('internal') == 'true' + key.getAttribute('internal') == 'true', + key.getAttribute('isPrecedent') == 'true' ); } @@ -452,6 +457,9 @@ class KeyShortcut { if (this.#internal) { key.setAttribute('internal', this.#internal); } + if (this.#isPrecedent) { + key.setAttribute('isPrecedent', this.#isPrecedent); + } key.setAttribute('zen-keybind', 'true'); return key; @@ -517,6 +525,10 @@ class KeyShortcut { return this.#internal; } + isPrecedent() { + return this.#isPrecedent; + } + isInvalid() { return this.#key == '' && this.#keycode == '' && this.#l10nId == null; } @@ -540,6 +552,7 @@ class KeyShortcut { disabled: this.#disabled, reserved: this.#reserved, internal: this.#internal, + isPrecedent: this.#isPrecedent, }; } @@ -1051,8 +1064,55 @@ class nsZenKeyboardShortcutsVersioner { } } if (version < 10) { - // Migrate from version 9 to 10 - // 1) Add the new pin/unpin tab toggle shortcut with Ctrl+Shift+D + // 1) Migrate from version 9 to 10 + // In this new version, we add customizable shortcuts for switching to the next/previous tab and toggling unloaded tab cycling. + data.push( + new KeyShortcut( + 'zen-tab-next-shortcut', + 'TAB', + 'VK_TAB', + 'windowAndTabManagement', + nsKeyShortcutModifiers.fromObject({ accel: true }), + 'cmd_zenTabNext', + 'zen-tab-next-shortcut', + false, // disabled + false, // reserved + false, // internal + true // isPrecedent + ) + ); + data.push( + new KeyShortcut( + 'zen-tab-prev-shortcut', + 'TAB', + 'VK_TAB', + 'windowAndTabManagement', + nsKeyShortcutModifiers.fromObject({ accel: true, shift: true }), + 'cmd_zenTabPrev', + 'zen-tab-prev-shortcut', + false, // disabled + false, // reserved + false, // internal + true // isPrecedent + ) + ); + data.push( + new KeyShortcut( + 'zen-toggle-unloaded-cycling-shortcut', + '', + '', + 'windowAndTabManagement', + nsKeyShortcutModifiers.fromObject({}), + 'cmd_zenToggleUnloadedCycling', + 'zen-toggle-unloaded-cycling-shortcut', + false, // disabled + false, // reserved + false, // internal + true // isPrecedent + ) + ); + + // 2) Add the new pin/unpin tab toggle shortcut with Ctrl+Shift+D data.push( new KeyShortcut( 'zen-toggle-pin-tab', @@ -1065,7 +1125,7 @@ class nsZenKeyboardShortcutsVersioner { ) ); - // 2) Add shortcut to expand Glance into a full tab: Default Accel+O + // 3) Add shortcut to expand Glance into a full tab: Default Accel+O data.push( new KeyShortcut( 'zen-glance-expand', @@ -1123,10 +1183,8 @@ var gZenKeyboardShortcutsManager = { async init() { if (this.inBrowserView) { const loadedShortcuts = await this._loadSaved(); - - this._currentShortcutList = this.versioner.fixedKeyboardShortcuts(loadedShortcuts); + this._currentShortcutList = this.versioner.fixedKeyboardShortcuts(loadedShortcuts) || []; this._applyShortcuts(); - await this._saveShortcuts(); } }, @@ -1231,6 +1289,46 @@ var gZenKeyboardShortcutsManager = { this.triggerShortcutRebuild(); }, + _registerPrecedentShortcut(shortcut, browser) { + const listener = (event) => { + let keyMatch = false; + if (shortcut.getKeyName()) { + keyMatch = event.key.toLowerCase() === shortcut.getKeyName().toLowerCase(); + } else if (shortcut.getKeyCode()) { + for (const [mapKey, mapValue] of Object.entries(KEYCODE_MAP)) { + if (mapValue === shortcut.getKeyCode()) { + if (mapKey.toLowerCase() === event.code.toLowerCase()) { + keyMatch = true; + } + break; + } + } + } + + if (!keyMatch) { + return; + } + + const modifiers = shortcut.getModifiers(); + const accelPressed = AppConstants.platform === 'macosx' ? event.metaKey : event.ctrlKey; + const modifiersMatch = + modifiers.accel === accelPressed && + modifiers.alt === event.altKey && + modifiers.shift === event.shiftKey; + + if (modifiersMatch) { + event.preventDefault(); + event.stopImmediatePropagation(); + const command = browser.document.getElementById(shortcut.getAction()); + if (command) { + command.doCommand(); + } + } + }; + + browser.addEventListener('keydown', listener, true); + }, + _applyShortcuts() { for (const browser of nsZenMultiWindowFeature.browsers) { let mainKeyset = browser.document.getElementById(ZEN_MAIN_KEYSET_ID); @@ -1251,8 +1349,13 @@ var gZenKeyboardShortcutsManager = { if (key.isInternal()) { continue; } - let child = key.toXHTMLElement(browser); - keyset.appendChild(child); + + if (key.isPrecedent()) { + this._registerPrecedentShortcut(key, browser); + } else { + let child = key.toXHTMLElement(browser); + keyset.appendChild(child); + } } this._applyDevtoolsShortcuts(browser); diff --git a/src/zen/tests/tabs/browser.toml b/src/zen/tests/tabs/browser.toml index 3d86041c67..e3a6fcc7f8 100644 --- a/src/zen/tests/tabs/browser.toml +++ b/src/zen/tests/tabs/browser.toml @@ -7,9 +7,10 @@ support-files = [ "head.js", ] +["browser_tab_unloaded_navigation.js"] ["browser_tabs_empty_checks.js"] ["browser_drag_drop_vertical.js"] tags = [ "drag-drop", "vertical-tabs" -] \ No newline at end of file +] diff --git a/src/zen/tests/tabs/browser_tab_unloaded_navigation.js b/src/zen/tests/tabs/browser_tab_unloaded_navigation.js new file mode 100644 index 0000000000..93e4a109a1 --- /dev/null +++ b/src/zen/tests/tabs/browser_tab_unloaded_navigation.js @@ -0,0 +1,268 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +'use strict'; + +/** + * Tests tab navigation between loaded/unloaded tabs based on user preference + */ + +const URL1 = 'http://example.com/page/1'; +const URL2 = 'http://example.com/page/2_unloaded'; +const URL3 = 'http://example.com/page/3'; + +// Helper function to create a normal loaded tab +async function createLoadedTab(url) { + info(`Creating loaded tab predictably with URL ${url}`); + const tab = BrowserTestUtils.addTab(gBrowser, url, { + inBackground: true, + }); + + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + return tab; +} + +function createUnloadedTab(url) { + const tab = BrowserTestUtils.addTab(gBrowser, url, { + inBackground: true, + skipAnimation: true, + }); + + tab.linkedBrowser.setAttribute('pending', 'true'); + tab.setAttribute('pending', 'true'); + + info(`New unloaded tab created at index ${gBrowser.tabs.indexOf(tab)} with URL ${url}`); + return tab; +} + +function resetPreferences() { + Services.prefs.clearUserPref('zen.tabs.unloaded-navigation-mode'); +} + +async function waitForTabSelection(expectedTab) { + await BrowserTestUtils.waitForCondition( + () => gBrowser.selectedTab === expectedTab, + 'Waiting for tab to be selected' + ); +} + +add_setup(async () => { + resetPreferences(); +}); + +add_task(async function test_basic_unloaded_navigation() { + info('Basic test to verify the test infrastructure works'); + + const tab1 = await createLoadedTab(URL1); + const tab2 = createUnloadedTab(URL2); + + ok(tab1, 'Loaded tab should be created'); + ok(tab2, 'Unloaded tab should be created'); + is(tab2.hasAttribute('pending'), true, 'Tab should have pending attribute'); + is(tab1.hasAttribute('pending'), false, 'Loaded tab should not have pending attribute'); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); +}); + +add_task(async function test_unloaded_navigation_always_mode() { + info("Testing navigation with 'always' mode (includes unloaded tabs)"); + + await SpecialPowers.pushPrefEnv({ + set: [['zen.tabs.unloaded-navigation-mode', 'always']], + }); + + const navigateAndAssert = async (direction, expectedTab, message) => { + gBrowser.tabContainer.advanceSelectedTab(direction, false); + await TestUtils.waitForTick(); + info( + `After navigating by ${direction}, selected tab is at index ${gBrowser.tabs.indexOf(gBrowser.selectedTab)}` + ); + is(gBrowser.selectedTab, expectedTab, message); + }; + + const loadedTab = await createLoadedTab(URL1); + const unloadedTab = createUnloadedTab(URL2); + const initialIndex = gBrowser.tabs.indexOf(loadedTab); + + gBrowser.selectedTab = loadedTab; + await waitForTabSelection(loadedTab); + + await navigateAndAssert( + 1, + gBrowser.tabs[initialIndex + 1], + 'Should navigate forward to the next tab (unloaded)' + ); + + await navigateAndAssert(-1, loadedTab, 'Should navigate backward to the previous tab (loaded)'); + + BrowserTestUtils.removeTab(loadedTab); + BrowserTestUtils.removeTab(unloadedTab); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_unloaded_navigation_never_mode() { + info("Testing navigation with 'never' mode (skips unloaded tabs) using URL comparison"); + + await SpecialPowers.pushPrefEnv({ + set: [['zen.tabs.unloaded-navigation-mode', 'never']], + }); + + await TestUtils.waitForTick(); + + /** + * Helper to test navigation scenarios. Each run is isolated by creating and + * then cleaning up its own set of tabs. + */ + async function runNavTest({ setup, startIndex, direction, shouldWrap, expectedURL, description }) { + info(`Running test: ${description}`); + + let allTestTabs = []; + try { + // Create all the tabs required for this specific test scenario + for (const tabConfig of setup) { + let newTab; + if (tabConfig.type === 'loaded') { + newTab = await createLoadedTab(tabConfig.url); + } else { + newTab = createUnloadedTab(tabConfig.url); + } + allTestTabs.push(newTab); + } + + // put tabs in ascending order for the tests to make sense + for (let i = 0; i < allTestTabs.length; i++) { + const targetIndex = gBrowser.tabs.length - allTestTabs.length + i; + gBrowser.moveTabTo(allTestTabs[i], targetIndex); + } + + //trimming unnecessary about:blanks + for (let i = gBrowser.tabs.length - 1; i >= 0; i--) { + const tab = gBrowser.tabs[i]; + if ( + tab.linkedBrowser.currentURI.spec === 'about:blank' && + tab.visible && + !tab.hasAttribute('pending') && + !allTestTabs.includes(tab) + ) { + gBrowser.removeTab(tab); + } + } + + const startTab = allTestTabs[startIndex]; + gBrowser.selectedTab = startTab; + await waitForTabSelection(startTab); + + const startURL = gBrowser.selectedTab.linkedBrowser.currentURI.spec; + const startTabIndex = gBrowser.tabs.indexOf(gBrowser.selectedTab); + + info(`Starting navigation from tab index ${startTabIndex}, direction ${direction}`); + + gBrowser.tabContainer.advanceSelectedTab(direction, shouldWrap); + await TestUtils.waitForTick(); + + const finalURL = gBrowser.selectedTab.linkedBrowser.currentURI.spec; + const finalTabIndex = gBrowser.tabs.indexOf(gBrowser.selectedTab); + + info( + `--> Navigation result: Started on tab ${startTabIndex} [${startURL}], ended on tab ${finalTabIndex} [${finalURL}]` + ); + + is(finalURL, expectedURL, description); + } finally { + // just remove all tabs created for a specific test run before moving on to the next. Trying to 'KISS' + for (const tab of allTestTabs) { + if (tab && tab.isConnected) { + BrowserTestUtils.removeTab(tab); + } + } + } + } + + // test scenarios + await runNavTest({ + setup: [ + { type: 'loaded', url: URL1 }, + { type: 'unloaded', url: URL2 }, + { type: 'loaded', url: URL3 }, + ], + startIndex: 0, + direction: 1, + shouldWrap: false, + expectedURL: URL3, + description: 'Should skip one unloaded tab and land on the correct URL.', + }); + + await runNavTest({ + setup: [ + { type: 'loaded', url: URL1 }, + { type: 'unloaded', url: URL2 }, + { type: 'loaded', url: URL3 }, + ], + startIndex: 2, + direction: -1, + shouldWrap: false, + expectedURL: URL1, + description: 'Should skip one unloaded tab backward and land on the correct URL.', + }); + + await runNavTest({ + setup: [ + { type: 'loaded', url: URL1 }, + { type: 'unloaded', url: URL2 }, + { type: 'unloaded', url: URL2 }, + { type: 'loaded', url: URL3 }, + ], + startIndex: 0, + direction: 1, + shouldWrap: false, + expectedURL: URL3, + description: 'Should skip multiple unloaded tabs and land on the correct URL.', + }); + + await runNavTest({ + setup: [ + { type: 'loaded', url: URL1 }, + { type: 'unloaded', url: URL2 }, + { type: 'unloaded', url: URL2 }, + ], + startIndex: 0, + direction: 1, + shouldWrap: false, + expectedURL: URL1, + description: 'Should not move if there is no next loaded tab.', + }); + + await runNavTest({ + setup: [ + { type: 'loaded', url: URL1 }, + { type: 'unloaded', url: URL2 }, + { type: 'loaded', url: URL3 }, + ], + startIndex: 2, + direction: -1, + shouldWrap: false, + expectedURL: URL1, + description: "Should skip the unloaded tab and reach the correct URLto the first loaded tab's URL, by moving backwards.", + }); + + await runNavTest({ + setup: [ + { type: 'loaded', url: URL1 }, + { type: 'loaded', url: URL2 }, + { type: 'loaded', url: URL3 }, + ], + startIndex: 2, + direction: 1, + shouldWrap: true, + expectedURL: URL1, + description: "Should wrap from the last tab to the first one, by moving forward.", + }); + + await SpecialPowers.popPrefEnv(); +}); + +registerCleanupFunction(() => { + resetPreferences(); +});