commit 3554df13c00591f201cedf6db9d958ae97e45364 parent 4c661cea7ad5f1a3e1f02b906ed82a895ee266cf Author: Kelly Cochrane <kcochrane@mozilla.com> Date: Mon, 8 Dec 2025 13:54:18 +0000 Bug 2000938 - Add follow-up updates to about:opentabs r=desktop-theme-reviewers,fxview-reviewers,tabbrowser-reviewers,fluent-reviewers,sfoster,bolsson,sclements,tschuster Differential Revision: https://phabricator.services.mozilla.com/D274055 Diffstat:
26 files changed, 739 insertions(+), 182 deletions(-)
diff --git a/browser/base/content/browser.js b/browser/base/content/browser.js @@ -715,6 +715,7 @@ async function gLazyFindCommand(cmd, ...args) { var gPageIcons = { "about:home": "chrome://branding/content/icon32.png", "about:newtab": "chrome://branding/content/icon32.png", + "about:opentabs": "chrome://branding/content/icon32.png", "about:welcome": "chrome://branding/content/icon32.png", "about:privatebrowsing": "chrome://browser/skin/privatebrowsing/favicon.svg", }; @@ -724,6 +725,7 @@ var gInitialPages = [ "about:home", "about:firefoxview", "about:newtab", + "about:opentabs", "about:privatebrowsing", "about:sessionrestore", "about:welcome", diff --git a/browser/base/content/browser.xhtml b/browser/base/content/browser.xhtml @@ -66,6 +66,7 @@ <link rel="localization" href="browser/genai.ftl"/> <link rel="localization" href="browser/identityCredentialNotification.ftl" /> <link rel="localization" href="browser/menubar.ftl"/> + <link rel="localization" href="browser/openTabs.ftl"/> <link rel="localization" href="browser/originControls.ftl"/> <link rel="localization" href="browser/panelUI.ftl"/> <link rel="localization" href="browser/places.ftl"/> diff --git a/browser/components/DesktopActorRegistry.sys.mjs b/browser/components/DesktopActorRegistry.sys.mjs @@ -506,6 +506,7 @@ let JSWINDOWACTORS = { "about:editprofile", "about:deleteprofile", "about:newprofile", + "about:opentabs", ], }, diff --git a/browser/components/firefoxview/OpenTabs.sys.mjs b/browser/components/firefoxview/OpenTabs.sys.mjs @@ -30,6 +30,8 @@ const TAB_CHANGE_EVENTS = Object.freeze([ "TabOpen", "TabPinned", "TabUnpinned", + "SplitViewCreated", + "SplitViewRemoved", ]); const TAB_RECENCY_CHANGE_EVENTS = Object.freeze([ "activate", @@ -252,6 +254,8 @@ class OpenTabsTarget extends EventTarget { tabContainer.addEventListener("TabPinned", this); tabContainer.addEventListener("TabUnpinned", this); tabContainer.addEventListener("TabSelect", this); + tabContainer.addEventListener("SplitViewCreated", this); + tabContainer.addEventListener("SplitViewRemoved", this); win.addEventListener("activate", this); win.addEventListener("sizemodechange", this); @@ -284,6 +288,8 @@ class OpenTabsTarget extends EventTarget { tabContainer.removeEventListener("TabPinned", this); tabContainer.removeEventListener("TabSelect", this); tabContainer.removeEventListener("TabUnpinned", this); + tabContainer.removeEventListener("SplitViewCreated", this); + tabContainer.removeEventListener("SplitViewRemoved", this); win.removeEventListener("activate", this); win.removeEventListener("sizemodechange", this); diff --git a/browser/components/firefoxview/OpenTabsController.sys.mjs b/browser/components/firefoxview/OpenTabsController.sys.mjs @@ -0,0 +1,173 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ContextualIdentityService: + "resource://gre/modules/ContextualIdentityService.sys.mjs", + NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs", +}); + +export class OpenTabsController { + /** + * Checks if a given tab is within a container (contextual identity) + * + * @param {MozTabbrowserTab[]} tab + * Tab to fetch container info on. + * @returns {object[]} + * Container object. + */ + #getContainerObj(tab) { + let userContextId = tab.getAttribute("usercontextid"); + let containerObj = null; + if (userContextId) { + containerObj = + lazy.ContextualIdentityService.getPublicIdentityFromId(userContextId); + } + return containerObj; + } + + /** + * Gets an array of tab indicators (if any) when normalizing for fxview-tab-list + * + * @param {MozTabbrowserTab[]} tab + * Tab to fetch container info on. + * @returns {Array[]} + * Array of named tab indicators + */ + #getIndicatorsForTab(tab) { + const url = tab.linkedBrowser?.currentURI?.spec || ""; + let tabIndicators = []; + let hasAttention = + (tab.pinned && + (tab.hasAttribute("attention") || tab.hasAttribute("titlechanged"))) || + (!tab.pinned && tab.hasAttribute("attention")); + + if (tab.pinned) { + tabIndicators.push("pinned"); + } + if (this.#getContainerObj(tab)) { + tabIndicators.push("container"); + } + if (hasAttention) { + tabIndicators.push("attention"); + } + if (tab.hasAttribute("soundplaying") && !tab.hasAttribute("muted")) { + tabIndicators.push("soundplaying"); + } + if (tab.hasAttribute("muted")) { + tabIndicators.push("muted"); + } + if (this.#checkIfPinnedNewTab(url)) { + tabIndicators.push("pinnedOnNewTab"); + } + + return tabIndicators; + } + + /** + * Check if a given url is pinned on the new tab page + * + * @param {string} url + * url to check + * @returns {boolean} + * is tabbed pinned on new tab page + */ + #checkIfPinnedNewTab(url) { + return url && lazy.NewTabUtils.pinnedLinks.isPinned({ url }); + } + + /** + * Gets the primary l10n id for a tab when normalizing for fxview-tab-list + * + * @param {boolean} isRecentBrowsing + * Whether the tabs are going to be displayed on the Recent Browsing page or not + * @param {Array[]} tabIndicators + * Array of tab indicators for the given tab + * @returns {string} + * L10n ID string + */ + getPrimaryL10nId(isRecentBrowsing, tabIndicators) { + let indicatorL10nId = null; + if (isRecentBrowsing) { + return indicatorL10nId; + } + if ( + tabIndicators?.includes("pinned") && + tabIndicators?.includes("bookmark") + ) { + indicatorL10nId = "firefoxview-opentabs-bookmarked-pinned-tab"; + } else if (tabIndicators?.includes("pinned")) { + indicatorL10nId = "firefoxview-opentabs-pinned-tab"; + } else if (tabIndicators?.includes("bookmark")) { + indicatorL10nId = "firefoxview-opentabs-bookmarked-tab"; + } + return indicatorL10nId; + } + + /** + * Gets the primary l10n args for a tab when normalizing for fxview-tab-list + * + * @param {MozTabbrowserTab[]} tab + * Tab to fetch container info on. + * @param {boolean} isRecentBrowsing + * Whether the tabs are going to be displayed on the Recent Browsing page or not + * @param {string} url + * URL for the given tab + * @returns {string} + * L10n ID args + */ + #getPrimaryL10nArgs(tab, isRecentBrowsing, url) { + return JSON.stringify({ tabTitle: tab.label, url }); + } + + /** + * Convert a list of tabs into the format expected by the fxview-tab-list + * component. + * + * @param {MozTabbrowserTab[]} tabs + * Tabs to format. + * @param {boolean} isRecentBrowsing + * Whether the tabs are going to be displayed on the Recent Browsing page or not + * @returns {object[]} + * Formatted objects. + */ + getTabListItems(tabs, isRecentBrowsing) { + let filtered = tabs?.filter(tab => !tab.closing && !tab.hidden); + + return filtered.map(tab => { + let tabIndicators = this.#getIndicatorsForTab(tab); + let containerObj = this.#getContainerObj(tab); + const url = tab?.linkedBrowser?.currentURI?.spec || ""; + return { + containerObj, + indicators: tabIndicators, + icon: tab.getAttribute("image"), + primaryL10nId: this.getPrimaryL10nId(isRecentBrowsing, tabIndicators), + primaryL10nArgs: this.#getPrimaryL10nArgs(tab, isRecentBrowsing, url), + secondaryL10nId: + isRecentBrowsing || (!isRecentBrowsing && !tab.pinned) + ? "fxviewtabrow-options-menu-button" + : null, + secondaryL10nArgs: + isRecentBrowsing || (!isRecentBrowsing && !tab.pinned) + ? JSON.stringify({ tabTitle: tab.label }) + : null, + tertiaryL10nId: + isRecentBrowsing || (!isRecentBrowsing && !tab.pinned) + ? "fxviewtabrow-close-tab-button" + : null, + tertiaryL10nArgs: + isRecentBrowsing || (!isRecentBrowsing && !tab.pinned) + ? JSON.stringify({ tabTitle: tab.label }) + : null, + tabElement: tab, + time: tab.lastSeenActive, + title: tab.label, + url, + }; + }); + } +} diff --git a/browser/components/firefoxview/opentabs.mjs b/browser/components/firefoxview/opentabs.mjs @@ -20,10 +20,8 @@ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { BookmarkList: "resource://gre/modules/BookmarkList.sys.mjs", BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", - ContextualIdentityService: - "resource://gre/modules/ContextualIdentityService.sys.mjs", - NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs", NonPrivateTabs: "resource:///modules/OpenTabs.sys.mjs", + OpenTabsController: "resource:///modules/OpenTabsController.sys.mjs", getTabsTargetForWindow: "resource:///modules/OpenTabs.sys.mjs", PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", TabMetrics: "moz-src:///browser/components/tabbrowser/TabMetrics.sys.mjs", @@ -418,6 +416,7 @@ class OpenTabsInViewCard extends ViewPageContent { this.searchResults = null; this.showAll = false; this.cumulativeSearches = 0; + this.controller = new lazy.OpenTabsController(this, {}); } static queries = { @@ -566,7 +565,7 @@ class OpenTabsInViewCard extends ViewPageContent { tertiaryActionClass="dismiss-button" .maxTabsLength=${this.getMaxTabsLength()} .tabItems=${this.searchResults || - getTabListItems(this.tabs, this.recentBrowsing)} + this.controller.getTabListItems(this.tabs, this.recentBrowsing)} .searchQuery=${this.searchQuery} .pinnedTabsGridView=${!this.recentBrowsing} ><view-opentabs-contextmenu slot="menu"></view-opentabs-contextmenu> @@ -615,7 +614,10 @@ class OpenTabsInViewCard extends ViewPageContent { updateSearchResults() { this.searchResults = this.searchQuery - ? searchTabList(this.searchQuery, getTabListItems(this.tabs)) + ? searchTabList( + this.searchQuery, + this.controller.getTabListItems(this.tabs) + ) : null; } @@ -633,7 +635,7 @@ class OpenTabsInViewCard extends ViewPageContent { if (!isBookmark && row.indicators.includes("bookmark")) { row.indicators = row.indicators.filter(i => i !== "bookmark"); } - row.primaryL10nId = getPrimaryL10nId( + row.primaryL10nId = this.controller.getPrimaryL10nId( this.isRecentBrowsing, row.indicators ); @@ -883,161 +885,3 @@ class OpenTabsContextMenu extends MozLitElement { } } customElements.define("view-opentabs-contextmenu", OpenTabsContextMenu); - -/** - * Checks if a given tab is within a container (contextual identity) - * - * @param {MozTabbrowserTab[]} tab - * Tab to fetch container info on. - * @returns {object[]} - * Container object. - */ -function getContainerObj(tab) { - let userContextId = tab.getAttribute("usercontextid"); - let containerObj = null; - if (userContextId) { - containerObj = - lazy.ContextualIdentityService.getPublicIdentityFromId(userContextId); - } - return containerObj; -} - -/** - * Gets an array of tab indicators (if any) when normalizing for fxview-tab-list - * - * @param {MozTabbrowserTab[]} tab - * Tab to fetch container info on. - * @returns {Array[]} - * Array of named tab indicators - */ -function getIndicatorsForTab(tab) { - const url = tab.linkedBrowser?.currentURI?.spec || ""; - let tabIndicators = []; - let hasAttention = - (tab.pinned && - (tab.hasAttribute("attention") || tab.hasAttribute("titlechanged"))) || - (!tab.pinned && tab.hasAttribute("attention")); - - if (tab.pinned) { - tabIndicators.push("pinned"); - } - if (getContainerObj(tab)) { - tabIndicators.push("container"); - } - if (hasAttention) { - tabIndicators.push("attention"); - } - if (tab.hasAttribute("soundplaying") && !tab.hasAttribute("muted")) { - tabIndicators.push("soundplaying"); - } - if (tab.hasAttribute("muted")) { - tabIndicators.push("muted"); - } - if (checkIfPinnedNewTab(url)) { - tabIndicators.push("pinnedOnNewTab"); - } - - return tabIndicators; -} -/** - * Gets the primary l10n id for a tab when normalizing for fxview-tab-list - * - * @param {boolean} isRecentBrowsing - * Whether the tabs are going to be displayed on the Recent Browsing page or not - * @param {Array[]} tabIndicators - * Array of tab indicators for the given tab - * @returns {string} - * L10n ID string - */ -function getPrimaryL10nId(isRecentBrowsing, tabIndicators) { - let indicatorL10nId = null; - if (!isRecentBrowsing) { - if ( - tabIndicators?.includes("pinned") && - tabIndicators?.includes("bookmark") - ) { - indicatorL10nId = "firefoxview-opentabs-bookmarked-pinned-tab"; - } else if (tabIndicators?.includes("pinned")) { - indicatorL10nId = "firefoxview-opentabs-pinned-tab"; - } else if (tabIndicators?.includes("bookmark")) { - indicatorL10nId = "firefoxview-opentabs-bookmarked-tab"; - } - } - return indicatorL10nId; -} - -/** - * Gets the primary l10n args for a tab when normalizing for fxview-tab-list - * - * @param {MozTabbrowserTab[]} tab - * Tab to fetch container info on. - * @param {boolean} isRecentBrowsing - * Whether the tabs are going to be displayed on the Recent Browsing page or not - * @param {string} url - * URL for the given tab - * @returns {string} - * L10n ID args - */ -function getPrimaryL10nArgs(tab, isRecentBrowsing, url) { - return JSON.stringify({ tabTitle: tab.label, url }); -} - -/** - * Check if a given url is pinned on the new tab page - * - * @param {string} url - * url to check - * @returns {boolean} - * is tabbed pinned on new tab page - */ -function checkIfPinnedNewTab(url) { - return url && lazy.NewTabUtils.pinnedLinks.isPinned({ url }); -} - -/** - * Convert a list of tabs into the format expected by the fxview-tab-list - * component. - * - * @param {MozTabbrowserTab[]} tabs - * Tabs to format. - * @param {boolean} isRecentBrowsing - * Whether the tabs are going to be displayed on the Recent Browsing page or not - * @returns {object[]} - * Formatted objects. - */ -function getTabListItems(tabs, isRecentBrowsing) { - let filtered = tabs?.filter(tab => !tab.closing && !tab.hidden); - - return filtered.map(tab => { - let tabIndicators = getIndicatorsForTab(tab); - let containerObj = getContainerObj(tab); - const url = tab?.linkedBrowser?.currentURI?.spec || ""; - return { - containerObj, - indicators: tabIndicators, - icon: tab.getAttribute("image"), - primaryL10nId: getPrimaryL10nId(isRecentBrowsing, tabIndicators), - primaryL10nArgs: getPrimaryL10nArgs(tab, isRecentBrowsing, url), - secondaryL10nId: - isRecentBrowsing || (!isRecentBrowsing && !tab.pinned) - ? "fxviewtabrow-options-menu-button" - : null, - secondaryL10nArgs: - isRecentBrowsing || (!isRecentBrowsing && !tab.pinned) - ? JSON.stringify({ tabTitle: tab.label }) - : null, - tertiaryL10nId: - isRecentBrowsing || (!isRecentBrowsing && !tab.pinned) - ? "fxviewtabrow-close-tab-button" - : null, - tertiaryL10nArgs: - isRecentBrowsing || (!isRecentBrowsing && !tab.pinned) - ? JSON.stringify({ tabTitle: tab.label }) - : null, - tabElement: tab, - time: tab.lastSeenActive, - title: tab.label, - url, - }; - }); -} diff --git a/browser/components/sessionstore/test/browser_newtab_userTypedValue.js b/browser/components/sessionstore/test/browser_newtab_userTypedValue.js @@ -54,7 +54,7 @@ add_task(async function () { BrowserTestUtils.removeTab(tab); for (let url of gInitialPages) { - if (url == BROWSER_NEW_TAB_URL) { + if (url == BROWSER_NEW_TAB_URL || url === "about:opentabs") { continue; // We tested about:newtab using BrowserCommands.openTab() above. } info("Testing " + url + " - " + new Date()); diff --git a/browser/components/sidebar/sidebar-tab-list.mjs b/browser/components/sidebar/sidebar-tab-list.mjs @@ -169,6 +169,8 @@ export class SidebarTabList extends FxviewTabListBase { if ((this.searchQuery || this.sortOption == "lastvisited") && i == 0) { // Make the first row focusable if there is no header. tabIndex = 0; + } else if (!this.searchQuery) { + tabIndex = 0; } return html` <sidebar-tab-row @@ -182,6 +184,7 @@ export class SidebarTabList extends FxviewTabListBase { .favicon=${tabItem.icon} .guid=${tabItem.guid} .hasPopup=${this.hasPopup} + .indicators=${tabItem.indicators} .primaryL10nArgs=${ifDefined(tabItem.primaryL10nArgs)} .primaryL10nId=${tabItem.primaryL10nId} role="listitem" @@ -219,6 +222,7 @@ export class SidebarTabRow extends FxviewTabRowBase { static properties = { guid: { type: String }, selected: { type: Boolean, reflect: true }, + indicators: { type: Array }, }; /** @@ -260,6 +264,12 @@ export class SidebarTabRow extends FxviewTabRowBase { class=${classMap({ "fxview-tab-row-main": true, "no-action-button-row": this.canClose === false, + muted: this.indicators?.includes("muted"), + attention: this.indicators?.includes("attention"), + soundplaying: this.indicators?.includes("soundplaying"), + "activemedia-blocked": this.indicators?.includes( + "activemedia-blocked" + ), })} disabled=${this.closeRequested} data-l10n-args=${ifDefined(this.primaryL10nArgs)} diff --git a/browser/components/sidebar/sidebar-tab-row.css b/browser/components/sidebar/sidebar-tab-row.css @@ -42,3 +42,60 @@ --button-size-icon: 24px; --button-min-height: 24px; } + +.attention > #fxview-tab-row-favicon::after { + background-image: radial-gradient(circle, var(--attention-dot-color), var(--attention-dot-color) 2px, transparent 2px); + /* stylelint-disable-next-line stylelint-plugin-mozilla/use-size-tokens */ + height: 4px; + width: 100%; + inset-block-start: var(--icon-size-medium); +} + +.fxview-tab-row-favicon::after { + display: block; + content: ""; + background-size: var(--size-item-xsmall); + background-position: center; + background-repeat: no-repeat; + position: relative; + height: var(--size-item-xsmall); + width: var(--size-item-xsmall); + -moz-context-properties: fill, stroke; + fill: currentColor; + /* stylelint-disable-next-line stylelint-plugin-mozilla/no-non-semantic-token-usage */ + stroke: var(--background-color-box); + + .fxview-tab-row-main.attention > & { + background-image: radial-gradient(circle, var(--attention-dot-color), var(--attention-dot-color) 2px, transparent 2px); + /* stylelint-disable-next-line stylelint-plugin-mozilla/use-size-tokens */ + height: 4px; + width: 100%; + inset-block-start: var(--icon-size-medium); + } +} + +.fxview-tab-row-main:is(.muted, .soundplaying, .activemedia-blocked):not(.attention) .fxview-tab-row-favicon::after { + /* inset-inline-start set to half of the favicon + width to place it horizontally centered */ + /* stylelint-disable-next-line stylelint-plugin-mozilla/use-size-tokens */ + inset-inline-start: 8px; + /* inset-block-start set to display 6px above + the top of the favicon */ + /* stylelint-disable-next-line stylelint-plugin-mozilla/use-size-tokens */ + inset-block-start: -6px; + background-color: var(--background-color-box); + padding: 1px; + border-radius: var(--border-radius-circle); +} + +.fxview-tab-row-main.muted .fxview-tab-row-favicon::after { + background-image: url("chrome://global/skin/media/audio-muted.svg"); +} + +.fxview-tab-row-main.soundplaying .fxview-tab-row-favicon::after { + background-image: url("chrome://browser/skin/tabbrowser/tab-audio-playing-small.svg"); +} + +.fxview-tab-row-main.activemedia-blocked .fxview-tab-row-favicon::after { + background-image: url("chrome://browser/skin/tabbrowser/tab-audio-blocked-small.svg"); +} diff --git a/browser/components/tabbrowser/SmartTabGrouping.sys.mjs b/browser/components/tabbrowser/SmartTabGrouping.sys.mjs @@ -163,6 +163,7 @@ const TAB_URLS_TO_EXCLUDE = [ "about:privatebrowsing", "chrome://browser/content/blanktab.html", "about:firefoxview", + "about:opentabs", ]; const TITLE_DELIMETER_SET = new Set(["-", "|", "—"]); diff --git a/browser/components/tabbrowser/content/opentabs-splitview.css b/browser/components/tabbrowser/content/opentabs-splitview.css @@ -0,0 +1,11 @@ +/* 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 url("chrome://global/skin/in-content/common.css"); + +moz-card { + --card-padding: var(--space-medium); + --card-border-radius: var(--border-radius-large); + margin-block-end: var(--space-xxlarge); +} diff --git a/browser/components/tabbrowser/content/opentabs-splitview.mjs b/browser/components/tabbrowser/content/opentabs-splitview.mjs @@ -0,0 +1,161 @@ +/* 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 { html } from "chrome://global/content/vendor/lit.all.mjs"; +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; + +const lazy = {}; +const BROWSER_NEW_TAB_URL = "about:newtab"; +const BROWSER_OPEN_TABS_URL = "about:opentabs"; + +ChromeUtils.defineESModuleGetters(lazy, { + OpenTabsController: "resource:///modules/OpenTabsController.sys.mjs", + NonPrivateTabs: "resource:///modules/OpenTabs.sys.mjs", + getTabsTargetForWindow: "resource:///modules/OpenTabs.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +/** + * A collection of open, unpinned, unsplit tabs for the current by window. + */ +class OpenTabsInSplitView extends MozLitElement { + currentWindow = null; + openTabsTarget = null; + + constructor() { + super(); + this.currentWindow = + this.ownerGlobal.top.browsingContext.embedderWindowGlobal.browsingContext.window; + if (lazy.PrivateBrowsingUtils.isWindowPrivate(this.currentWindow)) { + this.openTabsTarget = lazy.getTabsTargetForWindow(this.currentWindow); + } else { + this.openTabsTarget = lazy.NonPrivateTabs; + } + this.controller = new lazy.OpenTabsController(this, { + component: "splitview", + }); + this.listenersAdded = false; + } + + static queries = { + sidebarTabList: "sidebar-tab-list", + }; + + connectedCallback() { + super.connectedCallback(); + this.addListeners(true); + this.currentWindow.addEventListener("TabSelect", this); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.removeListeners(); + this.currentWindow.removeEventListener("TabSelect", this); + } + + addListeners(skipUpdate) { + if (!this.listenersAdded) { + this.openTabsTarget.addEventListener("TabChange", this); + if (!skipUpdate) { + this.requestUpdate(); + } + this.listenersAdded = true; + } + } + + removeListeners() { + if (this.listenersAdded) { + this.openTabsTarget.removeEventListener("TabChange", this); + this.listenersAdded = false; + } + } + + handleEvent(e) { + switch (e.type) { + case "TabChange": + this.requestUpdate(); + break; + case "TabSelect": + if (this.currentSplitView) { + this.addListeners(); + } else { + this.removeListeners(); + } + break; + } + } + + getWindow() { + return window.browsingContext.embedderWindowGlobal.browsingContext.window; + } + + get currentSplitView() { + const { gBrowser } = this.getWindow(); + return gBrowser.selectedTab.splitview; + } + + onTabListRowClick(event) { + const { gBrowser } = this.getWindow(); + const tab = event.originalTarget.tabElement; + if (this.currentSplitView) { + this.currentSplitView.replaceTab(gBrowser.selectedTab, tab); + } + } + + get nonSplitViewUnpinnedTabs() { + const { gBrowser } = this.getWindow(); + return gBrowser.tabs.filter(tab => { + return ( + !tab.hidden && + !tab.pinned && + !tab.splitview && + tab?.linkedBrowser?.currentURI?.spec !== BROWSER_OPEN_TABS_URL + ); + }); + } + + render() { + const { gBrowser } = this.getWindow(); + let tabs = this.nonSplitViewUnpinnedTabs; + if ( + !tabs.length || + (gBrowser.selectedTab.linkedBrowser.currentURI.spec === + BROWSER_OPEN_TABS_URL && + !this.currentSplitView) + ) { + // If there are no unpinned, unsplit tabs to display or about:opentabs + // is opened outside of a split view, open about:newtab instead + this.getWindow().openTrustedLinkIn(BROWSER_NEW_TAB_URL, "current"); + } + return html` + <link + rel="stylesheet" + href="chrome://browser/content/tabbrowser/opentabs-splitview.css" + /> + <link + rel="stylesheet" + href="chrome://browser/content/firefoxview/firefoxview.css" + /> + <moz-card> + <sidebar-tab-list + maxTabsLength="-1" + .tabItems=${this.controller.getTabListItems(tabs)} + @fxview-tab-list-primary-action=${this.onTabListRowClick} + > + </sidebar-tab-list> + </moz-card> + `; + } +} +customElements.define("splitview-opentabs", OpenTabsInSplitView); + +window.addEventListener( + "unload", + () => { + // Clear out the document so the disconnectedCallback will trigger + // properly + document.body.textContent = ""; + }, + { once: true } +); diff --git a/browser/components/tabbrowser/content/opentabs.css b/browser/components/tabbrowser/content/opentabs.css @@ -6,17 +6,39 @@ :root { height: 100%; + --splitview-opentabs-card-width: 358px; } body { background: no-repeat light-dark(linear-gradient(#f0ebfd, #f7ebeb), linear-gradient(#332a50, #3f2a2f)); + background-attachment: fixed; display: flex; - justify-content: center; + flex-direction: column; + align-items: center; height: 100%; } +.sticky-header { + background: linear-gradient(to bottom, light-dark(#f0ebfd, #332a50) 0%, light-dark(#f0ebfd, #332a50) 95%, transparent 100%); + position: sticky; + top: 0; + z-index: 1; + display: flex; + flex-direction: column; + width: 100%; + align-items: center; +} + h3 { color: var(--text-color); /* stylelint-disable-next-line stylelint-plugin-mozilla/use-space-tokens */ - margin-block-start: calc(var(--size-item-large) * 5); + margin-block-start: calc(var(--size-item-large) * 3.375); + height: min-content; +} + +splitview-opentabs { + /* stylelint-disable-next-line stylelint-plugin-mozilla/use-size-tokens */ + width: var(--splitview-opentabs-card-width); + /* stylelint-disable-next-line stylelint-plugin-mozilla/use-size-tokens */ + max-width: var(--splitview-opentabs-card-width); } diff --git a/browser/components/tabbrowser/content/opentabs.html b/browser/components/tabbrowser/content/opentabs.html @@ -11,14 +11,36 @@ content="default-src resource: chrome:; object-src 'none'; img-src data: chrome:;" /> <meta name="color-scheme" content="light dark" /> - <title>Open Tabs</title> + <link + rel="icon" + type="image/png" + id="favicon" + href="chrome://branding/content/icon32.png" + /> + <link rel="localization" href="browser/firefoxView.ftl" /> + <link rel="localization" href="browser/openTabs.ftl" /> + <title data-l10n-id="opentabs-page-title"></title> + <link + rel="stylesheet" + href="chrome://browser/content/sidebar/sidebar.css" + /> <link rel="stylesheet" href="chrome://browser/content/tabbrowser/opentabs.css" /> + <script + type="module" + src="chrome://browser/content/tabbrowser/opentabs-splitview.mjs" + ></script> + <script + type="module" + src="chrome://browser/content/sidebar/sidebar-tab-list.mjs" + ></script> </head> <body> - <h3>Choose a tab to add to split view</h3> - <!-- TODO: Bug 2000938 - Add oepn tabs list --> + <div class="sticky-header"> + <h3 data-l10n-id="opentabs-page-title"></h3> + </div> + <splitview-opentabs></splitview-opentabs> </body> </html> diff --git a/browser/components/tabbrowser/content/split-view-footer.js b/browser/components/tabbrowser/content/split-view-footer.js @@ -161,6 +161,7 @@ * @param {nsIURI} uri */ #updateUri(uri) { + this.hidden = uri.specIgnoringRef === "about:opentabs"; this.#uri = uri; if (this.uriElement) { this.#updateUriElement(); diff --git a/browser/components/tabbrowser/content/tabbrowser.js b/browser/components/tabbrowser/content/tabbrowser.js @@ -3238,7 +3238,7 @@ return null; } - this.dispatchEvent( + this.tabContainer.dispatchEvent( new CustomEvent("SplitViewCreated", { bubbles: true, }) @@ -3247,7 +3247,7 @@ } /** - * Removes a tab from a split view wrapper. This also removes the split view wrapper component + * Removes all tabs from a split view wrapper. This also removes the split view wrapper component * * @param {MozTabSplitViewWrapper} [splitView] * The split view to remove. @@ -3265,6 +3265,7 @@ ) ); } + splitview.remove(); } @@ -3302,12 +3303,14 @@ */ #insertSplitViewFooter(tab) { const panelEl = document.getElementById(tab.linkedPanel); - if (panelEl.querySelector("split-view-footer")) { + if (panelEl?.querySelector("split-view-footer")) { return; } - const footer = document.createXULElement("split-view-footer"); - footer.setTab(tab); - panelEl.appendChild(footer); + if (panelEl) { + const footer = document.createXULElement("split-view-footer"); + footer.setTab(tab); + panelEl.appendChild(footer); + } } openSplitViewMenu(anchorElement) { @@ -10261,7 +10264,7 @@ var TabContextMenu = { let newTab = null; if (this.contextTabs.length < 2) { // Open new tab to split with context tab - newTab = gBrowser.addTrustedTab(BROWSER_NEW_TAB_URL); + newTab = gBrowser.addTrustedTab("about:opentabs"); tabsToAdd = [this.contextTabs[0], newTab]; } diff --git a/browser/components/tabbrowser/content/tabsplitview.js b/browser/components/tabbrowser/content/tabsplitview.js @@ -73,6 +73,10 @@ this.#observeTabChanges(); + if (this.hasActiveTab) { + this.#activate(); + } + if (this._initialized) { return; } @@ -89,9 +93,10 @@ this.#tabChangeObserver?.disconnect(); this.ownerGlobal.removeEventListener("TabSelect", this); this.#deactivate(); - this.dispatchEvent( + this.container.dispatchEvent( new CustomEvent("SplitViewRemoved", { bubbles: true, + composed: true, }) ); } @@ -200,6 +205,15 @@ } /** + * Replace a tab in the split view with another tab + */ + replaceTab(tabToReplace, newTab) { + this.#tabs = this.#tabs.filter(tab => tab != tabToReplace); + this.addTabs([newTab]); + gBrowser.removeTab(tabToReplace); + } + + /** * Reverse order of the tabs in the split view wrapper. */ reverseTabs() { diff --git a/browser/components/tabbrowser/jar.mn b/browser/components/tabbrowser/jar.mn @@ -9,6 +9,8 @@ browser.jar: content/browser/tabbrowser/drag-and-drop.js (content/drag-and-drop.js) content/browser/tabbrowser/opentabs.css (content/opentabs.css) content/browser/tabbrowser/opentabs.html (content/opentabs.html) + content/browser/tabbrowser/opentabs-splitview.css (content/opentabs-splitview.css) + content/browser/tabbrowser/opentabs-splitview.mjs (content/opentabs-splitview.mjs) content/browser/tabbrowser/tab-stacking.js (content/tab-stacking.js) content/browser/tabbrowser/split-view-footer.js (content/split-view-footer.js) content/browser/tabbrowser/tab.js (content/tab.js) diff --git a/browser/components/tabbrowser/test/browser/tabs/browser.toml b/browser/components/tabbrowser/test/browser/tabs/browser.toml @@ -623,6 +623,8 @@ tags = "vertical-tabs" ["browser_tab_splitview.js"] +["browser_tab_splitview_about_opentabs.js"] + ["browser_tab_splitview_contextmenu.js"] skip-if = [ "os == 'linux' && os_version == '24.04' && arch == 'x86_64' && display == 'x11' && opt", # Bug 1996471 diff --git a/browser/components/tabbrowser/test/browser/tabs/browser_tab_splitview_about_opentabs.js b/browser/components/tabbrowser/test/browser/tabs/browser_tab_splitview_about_opentabs.js @@ -0,0 +1,211 @@ +/* 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/. */ + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.tabs.splitView.enabled", true]], + }); +}); + +registerCleanupFunction(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.tabs.splitView.enabled", false]], + }); +}); + +/** + * Synthesize a key press and wait for an element to be focused. + * + * @param {Element} element + * @param {string} keyCode + * @param {ChromeWindow} contentWindow + */ +async function focusWithKeyboard(element, keyCode, contentWindow) { + await SimpleTest.promiseFocus(contentWindow); + const focused = BrowserTestUtils.waitForEvent( + element, + "focus", + contentWindow + ); + EventUtils.synthesizeKey(keyCode, {}, contentWindow); + await focused; +} + +/** + * @param {MozTabbrowserTab} tab + * @param {function(splitViewMenuItem: Element, unsplitMenuItem: Element) => Promise<void>} callback + */ +const withTabMenu = async function (tab, callback) { + const tabContextMenu = document.getElementById("tabContextMenu"); + Assert.equal( + tabContextMenu.state, + "closed", + "context menu is initially closed" + ); + const contextMenuShown = BrowserTestUtils.waitForPopupEvent( + tabContextMenu, + "shown" + ); + + EventUtils.synthesizeMouseAtCenter( + tab, + { type: "contextmenu", button: 2 }, + window + ); + await contextMenuShown; + + const moveTabToNewSplitViewItem = document.getElementById( + "context_moveTabToSplitView" + ); + + let contextMenuHidden = BrowserTestUtils.waitForPopupEvent( + tabContextMenu, + "hidden" + ); + await callback(moveTabToNewSplitViewItem); + tabContextMenu.hidePopup(); + info("Hide popup"); + return await contextMenuHidden; +}; + +add_task(async function test_contextMenuMoveTabsToNewSplitView() { + const tab1 = await addTab(); + const tab2 = await addTab(); + const tab3 = await addTab(); + + // Click the first tab in our test split view to make sure the default tab at the + // start of the tab strip is deselected + EventUtils.synthesizeMouseAtCenter(tab1, {}); + + // Test adding split view with one tab and new tab + + let tabToClick = tab1; + EventUtils.synthesizeMouseAtCenter(tab1, {}); + let openTabsPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + "about:opentabs" + ); + let tabContainer = document.getElementById("tabbrowser-tabs"); + let splitViewCreated = BrowserTestUtils.waitForEvent( + tabContainer, + "SplitViewCreated" + ); + await withTabMenu(tabToClick, async moveTabToNewSplitViewItem => { + await BrowserTestUtils.waitForMutationCondition( + moveTabToNewSplitViewItem, + { attributes: true }, + () => + !moveTabToNewSplitViewItem.hidden && + !moveTabToNewSplitViewItem.disabled, + "moveTabToNewSplitViewItem is visible and not disabled" + ); + Assert.ok( + !moveTabToNewSplitViewItem.hidden && !moveTabToNewSplitViewItem.disabled, + "moveTabToNewSplitViewItem is visible and not disabled" + ); + + info("Click menu option to add new split view"); + moveTabToNewSplitViewItem.click(); + await splitViewCreated; + await openTabsPromise; + info("about:opentabs has been opened"); + Assert.equal( + gBrowser.selectedTab.linkedBrowser.currentURI.spec, + "about:opentabs", + "about:opentabs is active in split view" + ); + }); + + let splitview = tab1.splitview; + + Assert.equal(tab1.splitview, splitview, `tab1 is in split view`); + let aboutOpenTabsDocument = + gBrowser.selectedTab.linkedBrowser.contentDocument; + let openTabsComponent = await TestUtils.waitForCondition( + () => aboutOpenTabsDocument.querySelector("splitview-opentabs"), + "Open tabs component rendered" + ); + await TestUtils.waitForCondition( + () => openTabsComponent.nonSplitViewUnpinnedTabs?.length, + "Open tabs component has rendered items" + ); + + Assert.equal( + openTabsComponent.nonSplitViewUnpinnedTabs.length, + 3, + "3 tabs are shown in the open tabs list" + ); + + await TestUtils.waitForCondition( + () => openTabsComponent.sidebarTabList.shadowRoot, + "Open tabs component has shadowRoot" + ); + await openTabsComponent.sidebarTabList.updateComplete; + await BrowserTestUtils.waitForMutationCondition( + openTabsComponent.sidebarTabList.shadowRoot, + { childList: true, subtree: true }, + () => openTabsComponent.sidebarTabList.rowEls.length === 3, + "Tabs are shown in the open tabs list" + ); + + Assert.ok( + openTabsComponent.sidebarTabList.rowEls[1].__url === + tab2.linkedBrowser.currentURI.spec && + openTabsComponent.sidebarTabList.rowEls[2].__url === + tab3.linkedBrowser.currentURI.spec, + "tab2 and tab3 are listed on the about:opentabs page" + ); + + let aboutOpenTabsWindow = document.querySelector( + "hbox.deck-selected.split-view-panel browser" + ).contentWindow; + openTabsComponent.sidebarTabList.rowEls[0].focus(); + + info("Focus the next row."); + await focusWithKeyboard( + openTabsComponent.sidebarTabList.rowEls[1], + "KEY_ArrowDown", + aboutOpenTabsWindow + ); + + info("Focus the previous row."); + await focusWithKeyboard( + openTabsComponent.sidebarTabList.rowEls[0], + "KEY_ArrowUp", + aboutOpenTabsWindow + ); + + info("Focus the next row."); + await focusWithKeyboard( + openTabsComponent.sidebarTabList.rowEls[1], + "KEY_ArrowDown", + aboutOpenTabsWindow + ); + + info("Focus the next row."); + await focusWithKeyboard( + openTabsComponent.sidebarTabList.rowEls[2], + "KEY_ArrowDown", + aboutOpenTabsWindow + ); + + info("Focus the previous row."); + await focusWithKeyboard( + openTabsComponent.sidebarTabList.rowEls[1], + "KEY_ArrowUp", + aboutOpenTabsWindow + ); + + info("Open the focused link."); + EventUtils.synthesizeKey("KEY_Enter", {}, aboutOpenTabsWindow); + await TestUtils.waitForCondition( + () => splitview.tabs.includes(tab2), + "We've opened tab2 in the split view" + ); + + splitview.unsplitTabs(); + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.tabs.at(-1)); + } +}); diff --git a/browser/locales/en-US/browser/openTabs.ftl b/browser/locales/en-US/browser/openTabs.ftl @@ -0,0 +1,5 @@ +# 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/. + +opentabs-page-title = Choose a tab to add to split view diff --git a/browser/themes/shared/tabbrowser/content-area.css b/browser/themes/shared/tabbrowser/content-area.css @@ -206,6 +206,10 @@ /* Display split view footer within inactive panels. */ .split-view-panel:not(.deck-selected) > split-view-footer { display: inherit; + + &[hidden] { + display: none; + } } } } diff --git a/dom/security/nsContentSecurityUtils.cpp b/dom/security/nsContentSecurityUtils.cpp @@ -1345,6 +1345,7 @@ static nsLiteralCString sImgSrcDataBlobAllowList[] = { "about:logins"_ns, "about:newprofile"_ns, "about:newtab"_ns, + "about:opentabs"_ns, "about:preferences"_ns, "about:privatebrowsing"_ns, "about:processes"_ns, diff --git a/toolkit/content/widgets/tabbox.js b/toolkit/content/widgets/tabbox.js @@ -284,20 +284,19 @@ handleEvent(e) { const browser = e.currentTarget; - const tabbrowser = browser.getTabBrowser(); switch (e.type) { case "click": case "focus": { - const tab = tabbrowser.getTabForBrowser(browser); + const tab = gBrowser.getTabForBrowser(browser); const tabstrip = this.tabbox.tabs; tabstrip.selectedItem = tab; break; } case "mouseover": - tabbrowser.appendStatusPanel(browser); + gBrowser.appendStatusPanel(browser); break; case "mouseout": - tabbrowser.appendStatusPanel(); + gBrowser.appendStatusPanel(); break; } } diff --git a/tools/@types/generated/lib.gecko.modules.d.ts b/tools/@types/generated/lib.gecko.modules.d.ts @@ -400,6 +400,7 @@ export interface Modules { "resource:///modules/MigrationUtils.sys.mjs": typeof import("resource:///modules/MigrationUtils.sys.mjs"), "resource:///modules/MigratorBase.sys.mjs": typeof import("resource:///modules/MigratorBase.sys.mjs"), "resource:///modules/OpenTabs.sys.mjs": typeof import("resource:///modules/OpenTabs.sys.mjs"), + "resource:///modules/OpenTabsController.sys.mjs": typeof import("resource:///modules/OpenTabs.sys.mjs"), "resource:///modules/PageActions.sys.mjs": typeof import("resource:///modules/PageActions.sys.mjs"), "resource:///modules/PartnerLinkAttribution.sys.mjs": typeof import("resource:///modules/PartnerLinkAttribution.sys.mjs"), "resource:///modules/PermissionUI.sys.mjs": typeof import("resource:///modules/PermissionUI.sys.mjs"), diff --git a/tools/@types/generated/tspaths.json b/tools/@types/generated/tspaths.json @@ -1049,6 +1049,9 @@ "resource:///modules/OpenTabs.sys.mjs": [ "browser/components/firefoxview/OpenTabs.sys.mjs" ], + "resource:///modules/OpenTabsController.sys.mjs": [ + "browser/components/firefoxview/OpenTabsController.sys.mjs" + ], "resource:///modules/PageActions.sys.mjs": [ "browser/modules/PageActions.sys.mjs" ],