From 96c9460ed6a91311d987f301a729d3d01277670b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emilio=20Cobos=20=C3=81lvarez?= Date: Fri, 19 Apr 2024 13:31:38 +0000 Subject: [PATCH] Bug 1891797 - Close duplicate tabs from the context menu. r=tabbrowser-reviewers,fluent-reviewers,dao,flod Differential Revision: https://phabricator.services.mozilla.com/D207633 --- browser/app/profile/firefox.js | 7 ++ browser/base/content/browser.js | 5 +- browser/base/content/main-popupset.inc.xhtml | 7 +- browser/base/content/tabbrowser.js | 79 +++++++++++- browser/base/content/test/tabs/browser.toml | 2 + ...r_multiselect_tabs_close_duplicate_tabs.js | 118 ++++++++++++++++++ .../tabs/browser_visibleTabs_contextMenu.js | 19 ++- .../en-US/browser/confirmationHints.ftl | 7 ++ .../locales/en-US/browser/tabContextMenu.ftl | 3 + 9 files changed, 228 insertions(+), 19 deletions(-) create mode 100644 browser/base/content/test/tabs/browser_multiselect_tabs_close_duplicate_tabs.js diff --git a/browser/app/profile/firefox.js b/browser/app/profile/firefox.js index 48c5dedbbcc7..4bde501d9a2b 100644 --- a/browser/app/profile/firefox.js +++ b/browser/app/profile/firefox.js @@ -2228,6 +2228,13 @@ pref("privacy.exposeContentTitleInWindow.pbm", true); // Run media transport in a separate process? pref("media.peerconnection.mtransport_process", true); +// Whether the "Close duplicate tabs" tab context menu is enabled. +#ifdef NIGHTLY_BUILD +pref("browser.tabs.context.close-duplicate.enabled", true); +#else +pref("browser.tabs.context.close-duplicate.enabled", false); +#endif + // For speculatively warming up tabs to improve perceived // performance while using the async tab switcher. pref("browser.tabs.remote.warmup.enabled", true); diff --git a/browser/base/content/browser.js b/browser/base/content/browser.js index 24880f524a8b..7f27f70c2fea 100644 --- a/browser/base/content/browser.js +++ b/browser/base/content/browser.js @@ -9030,15 +9030,14 @@ var ConfirmationHint = { * - event (DOM event): The event that triggered the feedback * - descriptionId (string): message ID of the description text * - position (string): position of the panel relative to the anchor. - * + * - l10nArgs (object): l10n arguments for the messageId. */ show(anchor, messageId, options = {}) { this._reset(); MozXULElement.insertFTLIfNeeded("toolkit/branding/brandings.ftl"); MozXULElement.insertFTLIfNeeded("browser/confirmationHints.ftl"); - document.l10n.setAttributes(this._message, messageId); - + document.l10n.setAttributes(this._message, messageId, options.l10nArgs); if (options.descriptionId) { document.l10n.setAttributes(this._description, options.descriptionId); this._description.hidden = false; diff --git a/browser/base/content/main-popupset.inc.xhtml b/browser/base/content/main-popupset.inc.xhtml index 91b2483c53fe..d4063ba398de 100644 --- a/browser/base/content/main-popupset.inc.xhtml +++ b/browser/base/content/main-popupset.inc.xhtml @@ -77,13 +77,16 @@ data-lazy-l10n-id="tab-context-close-n-tabs" data-l10n-args='{"tabCount": 1}' oncommand="TabContextMenu.closeContextTabs();"/> + + oncommand="gBrowser.removeTabsToTheStartFrom(TabContextMenu.contextTab);"/> + oncommand="gBrowser.removeTabsToTheEndFrom(TabContextMenu.contextTab);"/> diff --git a/browser/base/content/tabbrowser.js b/browser/base/content/tabbrowser.js index df02a59117dc..6056d0502083 100644 --- a/browser/base/content/tabbrowser.js +++ b/browser/base/content/tabbrowser.js @@ -161,6 +161,7 @@ TO_START: 2, TO_END: 3, MULTI_SELECTED: 4, + DUPLICATES: 6, }, _lastRelatedTabMap: new WeakMap(), @@ -348,6 +349,46 @@ return this.tabContainer._getVisibleTabs(); }, + getDuplicateTabsToClose(aTab) { + // One would think that a set is better, but it would need to copy all + // the strings instead of just keeping references to the nsIURI objects, + // and the array is presumed to be small anyways. + let urisToLookFor = []; + if (aTab.multiselected) { + for (let tab of this.selectedTabs) { + let uri = tab.linkedBrowser?.currentURI; + if (uri) { + urisToLookFor.push(uri); + } + } + } else { + let uri = aTab.linkedBrowser?.currentURI; + if (uri) { + urisToLookFor.push(uri); + } + } + + if (!urisToLookFor.length) { + return []; + } + + let duplicateTabs = []; + for (let tab of this.tabs) { + if (tab == aTab || tab.pinned) { + continue; + } + if (aTab.multiselected && tab.multiselected) { + continue; + } + let uri = tab.linkedBrowser?.currentURI; + if (uri && urisToLookFor.some(u => u.equals(uri))) { + duplicateTabs.push(tab); + } + } + + return duplicateTabs; + }, + get _numPinnedTabs() { for (var i = 0; i < this.tabs.length; i++) { if (!this.tabs[i].pinned) { @@ -3509,6 +3550,28 @@ return tabsToEnd; }, + removeDuplicateTabs(aTab) { + let tabs = this.getDuplicateTabsToClose(aTab); + if (!tabs.length) { + return; + } + + if ( + !this.warnAboutClosingTabs(tabs.length, this.closingTabsEnum.DUPLICATES) + ) { + return; + } + + this.removeTabs(tabs); + if (tabs.length) { + ConfirmationHint.show(aTab, "confirmation-hint-duplicate-tabs-closed", { + l10nArgs: { + tabCount: tabs.length, + }, + }); + } + }, + /** * In a multi-select context, the tabs (except pinned tabs) that are located to the * left of the leftmost selected tab will be removed. @@ -7554,9 +7617,8 @@ var TabContextMenu = { tabsToMove[0] == visibleTabs[gBrowser._numPinnedTabs]; contextMoveTabToStart.disabled = isFirstTab && allSelectedTabsAdjacent; - if (this.contextTab.hasAttribute("customizemode")) { - document.getElementById("context_openTabInWindow").disabled = true; - } + document.getElementById("context_openTabInWindow").disabled = + this.contextTab.hasAttribute("customizemode"); // Only one of "Duplicate Tab"/"Duplicate Tabs" should be visible. document.getElementById("context_duplicateTab").hidden = @@ -7590,6 +7652,17 @@ var TabContextMenu = { .getElementById("context_closeTab") .setAttribute("data-l10n-args", tabCountInfo); + let closeDuplicateEnabled = Services.prefs.getBoolPref( + "browser.tabs.context.close-duplicate.enabled" + ); + let closeDuplicateTabsItem = document.getElementById( + "context_closeDuplicateTabs" + ); + closeDuplicateTabsItem.hidden = !closeDuplicateEnabled; + closeDuplicateTabsItem.disabled = + !closeDuplicateEnabled || + !gBrowser.getDuplicateTabsToClose(this.contextTab).length; + // Disable "Close Multiple Tabs" if all sub menuitems are disabled document.getElementById("context_closeTabOptions").disabled = closeTabsToTheStartItem.disabled && diff --git a/browser/base/content/test/tabs/browser.toml b/browser/base/content/test/tabs/browser.toml index 420f003544ae..8a95c87a6ede 100644 --- a/browser/base/content/test/tabs/browser.toml +++ b/browser/base/content/test/tabs/browser.toml @@ -142,6 +142,8 @@ support-files = [ ["browser_multiselect_tabs_close.js"] +["browser_multiselect_tabs_close_duplicate_tabs.js"] + ["browser_multiselect_tabs_close_other_tabs.js"] ["browser_multiselect_tabs_close_tabs_to_the_left.js"] diff --git a/browser/base/content/test/tabs/browser_multiselect_tabs_close_duplicate_tabs.js b/browser/base/content/test/tabs/browser_multiselect_tabs_close_duplicate_tabs.js new file mode 100644 index 000000000000..ea2d240066bd --- /dev/null +++ b/browser/base/content/test/tabs/browser_multiselect_tabs_close_duplicate_tabs.js @@ -0,0 +1,118 @@ +const PREF_WARN_ON_CLOSE = "browser.tabs.warnOnCloseOtherTabs"; + +add_task(async function setPref() { + await SpecialPowers.pushPrefEnv({ + set: [[PREF_WARN_ON_CLOSE, false]], + }); +}); + +add_task(async function withAMultiSelectedTab() { + let initialTab = gBrowser.selectedTab; + let tab1 = await addTab(); + let tab2 = await addTab(); + let tab3 = await addTab(); + let tab4 = await addTab(); + + is(gBrowser.multiSelectedTabsCount, 0, "Zero multiselected tabs"); + + await triggerClickOn(tab1, { ctrlKey: true }); + + let tab4Pinned = BrowserTestUtils.waitForEvent(tab4, "TabPinned"); + gBrowser.pinTab(tab4); + await tab4Pinned; + + ok(initialTab.multiselected, "InitialTab is multiselected"); + ok(tab1.multiselected, "Tab1 is multiselected"); + ok(!tab2.multiselected, "Tab2 is not multiselected"); + ok(!tab3.multiselected, "Tab3 is not multiselected"); + ok(!tab4.multiselected, "Tab4 is not multiselected"); + ok(tab4.pinned, "Tab4 is pinned"); + is(gBrowser.multiSelectedTabsCount, 2, "Two multiselected tabs"); + is(gBrowser.selectedTab, initialTab, "InitialTab is the active tab"); + + let closingTabs = [tab2, tab3]; + let tabClosingPromises = []; + for (let tab of closingTabs) { + tabClosingPromises.push(BrowserTestUtils.waitForTabClosing(tab)); + } + + gBrowser.removeDuplicateTabs(tab1); + + await Promise.all(tabClosingPromises); + + ok(!initialTab.closing, "InitialTab is not closing"); + ok(!tab1.closing, "Tab1 is not closing"); + ok(tab2.closing, "Tab2 is closing"); + ok(tab3.closing, "Tab3 is closing"); + ok(!tab4.closing, "Tab4 is not closing"); + is(gBrowser.multiSelectedTabsCount, 2, "Two multiselected tabs"); + is(gBrowser.selectedTab, initialTab, "InitialTab is still the active tab"); + + gBrowser.clearMultiSelectedTabs(); + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab4); +}); + +add_task(async function withNotAMultiSelectedTab() { + let initialTab = gBrowser.selectedTab; + let tab1 = await addTab(); + let tab2 = await addTab(); + let tab3 = await addTab(); + let tab4 = await addTab(); + let tab5 = await addTab(); + + is(gBrowser.multiSelectedTabsCount, 0, "Zero multiselected tabs"); + + await BrowserTestUtils.switchTab(gBrowser, tab1); + await triggerClickOn(tab2, { ctrlKey: true }); + await triggerClickOn(tab5, { ctrlKey: true }); + + let tab4Pinned = BrowserTestUtils.waitForEvent(tab4, "TabPinned"); + gBrowser.pinTab(tab4); + await tab4Pinned; + + let tab5Pinned = BrowserTestUtils.waitForEvent(tab5, "TabPinned"); + gBrowser.pinTab(tab5); + await tab5Pinned; + + ok(!initialTab.multiselected, "InitialTab is not multiselected"); + ok(tab1.multiselected, "Tab1 is multiselected"); + ok(tab2.multiselected, "Tab2 is multiselected"); + ok(!tab3.multiselected, "Tab3 is not multiselected"); + ok(!tab4.multiselected, "Tab4 is not multiselected"); + ok(tab4.pinned, "Tab4 is pinned"); + ok(tab5.multiselected, "Tab5 is multiselected"); + ok(tab5.pinned, "Tab5 is pinned"); + is(gBrowser.multiSelectedTabsCount, 3, "Three multiselected tabs"); + is(gBrowser.selectedTab, tab1, "Tab1 is the active tab"); + + let closingTabs = [tab1, tab2]; + let tabClosingPromises = []; + for (let tab of closingTabs) { + tabClosingPromises.push(BrowserTestUtils.waitForTabClosing(tab)); + } + + await BrowserTestUtils.switchTab( + gBrowser, + gBrowser.removeDuplicateTabs(tab3) + ); + + await Promise.all(tabClosingPromises); + + ok(!initialTab.closing, "InitialTab is not closing"); + ok(tab1.closing, "Tab1 is closing"); + ok(tab2.closing, "Tab2 is closing"); + ok(!tab3.closing, "Tab3 is not closing"); + ok(!tab4.closing, "Tab4 is not closing"); + ok(!tab5.closing, "Tab5 is not closing"); + is( + gBrowser.multiSelectedTabsCount, + 0, + "Zero multiselected tabs, selection is cleared" + ); + is(gBrowser.selectedTab, tab3, "tab3 is the active tab now"); + + for (let tab of [tab3, tab4, tab5]) { + BrowserTestUtils.removeTab(tab); + } +}); diff --git a/browser/base/content/test/tabs/browser_visibleTabs_contextMenu.js b/browser/base/content/test/tabs/browser_visibleTabs_contextMenu.js index 202c43ce47d1..06fdd27d9c00 100644 --- a/browser/base/content/test/tabs/browser_visibleTabs_contextMenu.js +++ b/browser/base/content/test/tabs/browser_visibleTabs_contextMenu.js @@ -2,11 +2,6 @@ * 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/. */ -const remoteClientsFixture = [ - { id: 1, name: "Foo" }, - { id: 2, name: "Bar" }, -]; - add_task(async function test() { // There should be one tab when we start the test let [origTab] = gBrowser.visibleTabs; @@ -16,9 +11,8 @@ add_task(async function test() { // Check the context menu with two tabs updateTabContextMenu(origTab); - is( - document.getElementById("context_closeTab").disabled, - false, + ok( + !document.getElementById("context_closeTab").disabled, "Close Tab is enabled" ); @@ -29,11 +23,14 @@ add_task(async function test() { // Check the context menu with one tab. updateTabContextMenu(testTab); - is( - document.getElementById("context_closeTab").disabled, - false, + ok( + !document.getElementById("context_closeTab").disabled, "Close Tab is enabled when more than one tab exists" ); + ok( + !document.getElementById("context_closeDuplicateTabs").disabled, + "Close duplicate tabs is enabled when more than one tab with the same URL exists" + ); // Add a tab that will get pinned // So now there's one pinned tab, one visible unpinned tab, and one hidden tab diff --git a/browser/locales/en-US/browser/confirmationHints.ftl b/browser/locales/en-US/browser/confirmationHints.ftl index 2da1e317cdb0..3e03977a3b3c 100644 --- a/browser/locales/en-US/browser/confirmationHints.ftl +++ b/browser/locales/en-US/browser/confirmationHints.ftl @@ -18,3 +18,10 @@ confirmation-hint-send-to-device = Sent! confirmation-hint-firefox-relay-mask-created = New mask created! confirmation-hint-firefox-relay-mask-reused = Existing mask reused! confirmation-hint-screenshot-copied = Screenshot copied! +# Variables: +# $tabCount (Number): The number of duplicate tabs closed, at least 1. +confirmation-hint-duplicate-tabs-closed = + { $tabCount -> + [one] Closed { $tabCount } tab + *[other] Closed { $tabCount } tabs + } diff --git a/browser/locales/en-US/browser/tabContextMenu.ftl b/browser/locales/en-US/browser/tabContextMenu.ftl index df58df794c5e..5ace34fd8a39 100644 --- a/browser/locales/en-US/browser/tabContextMenu.ftl +++ b/browser/locales/en-US/browser/tabContextMenu.ftl @@ -72,6 +72,9 @@ move-to-new-window = tab-context-close-multiple-tabs = .label = Close Multiple Tabs .accesskey = M +tab-context-close-duplicate-tabs = + .label = Close Duplicate Tabs + .accesskey = u tab-context-share-url = .label = Share .accesskey = h