diff --git a/prefs/zen/zen.yaml b/prefs/zen/zen.yaml index 685bb3866e..aea854e9e3 100644 --- a/prefs/zen/zen.yaml +++ b/prefs/zen/zen.yaml @@ -52,3 +52,9 @@ - name: zen.tabs.ctrl-tab.ignore-pending-tabs value: false + +- name: zen.tabs.ctrl-tab-panel.enabled + value: true + +- name: zen.tabs.ctrl-tab-panel.sort-by-recent + value: false diff --git a/src/browser/base/content/zen-assets.inc.xhtml b/src/browser/base/content/zen-assets.inc.xhtml index b88b74cfee..e27020f949 100644 --- a/src/browser/base/content/zen-assets.inc.xhtml +++ b/src/browser/base/content/zen-assets.inc.xhtml @@ -20,6 +20,7 @@ + @@ -45,6 +46,7 @@ + s diff --git a/src/browser/base/content/zen-panels/ctrl-tab-panel.inc b/src/browser/base/content/zen-panels/ctrl-tab-panel.inc new file mode 100644 index 0000000000..d194bfe4a7 --- /dev/null +++ b/src/browser/base/content/zen-panels/ctrl-tab-panel.inc @@ -0,0 +1,22 @@ +# 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/. + + + + + + + + + diff --git a/src/browser/base/content/zen-popupset.inc.xhtml b/src/browser/base/content/zen-popupset.inc.xhtml index 9d6e1c06c1..672d88901d 100644 --- a/src/browser/base/content/zen-popupset.inc.xhtml +++ b/src/browser/base/content/zen-popupset.inc.xhtml @@ -6,5 +6,6 @@ #include zen-panels/emojis-picker.inc #include zen-panels/folders-search.inc #include zen-panels/site-data.inc +#include zen-panels/ctrl-tab-panel.inc #include zen-panels/popups.inc diff --git a/src/zen/tabs/ZenCtrlTabPanel.mjs b/src/zen/tabs/ZenCtrlTabPanel.mjs new file mode 100644 index 0000000000..b3bc2a5039 --- /dev/null +++ b/src/zen/tabs/ZenCtrlTabPanel.mjs @@ -0,0 +1,478 @@ +/* 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 { nsZenDOMOperatedFeature } from "chrome://browser/content/zen-components/ZenCommonUtils.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + PageThumbs: "resource://gre/modules/PageThumbs.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "enabled", + "zen.tabs.ctrl-tab-panel.enabled", + true +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "sortByRecentlyUsed", + "zen.tabs.ctrl-tab-panel.sort-by-recent", + false +); + +class nsZenCtrlTabPanel extends nsZenDOMOperatedFeature { + static CARD_WIDTH = 250; + static CARD_HEIGHT = 220; + static PANEL_PADDING = 16; + static PANEL_HEIGHT = + nsZenCtrlTabPanel.CARD_HEIGHT + nsZenCtrlTabPanel.PANEL_PADDING * 2; + + #isOpen = false; + #currentIndex = 0; + #tabList = []; + #thumbnailCache = new Map(); + #actualVisibleCards = undefined; + + init() { + this.#managePreference(); + this.#setupEventListeners(); + this.#setupLazyGetters(); + } + + #setupLazyGetters() { + ChromeUtils.defineLazyGetter(this, "panel", () => + document.getElementById("zen-ctrl-tab-panel") + ); + ChromeUtils.defineLazyGetter(this, "tabsContainer", () => + document.getElementById("zen-ctrl-tab-panel-tabs") + ); + } + + #managePreference() { + const toggleCtrlTabBehaviour = () => { + const method = lazy.enabled ? "uninit" : "readPref"; + window.ctrlTab?.[method]?.(); + }; + + toggleCtrlTabBehaviour(); + + Services.prefs.addObserver( + "zen.tabs.ctrl-tab-panel.enabled", + toggleCtrlTabBehaviour + ); + + window.addEventListener( + "unload", + () => { + Services.prefs.removeObserver( + "zen.tabs.ctrl-tab-panel.enabled", + toggleCtrlTabBehaviour + ); + }, + { once: true } + ); + } + + #setupEventListeners() { + const keydownListener = e => this.#handleKeyDown(e); + const keyupListener = e => this.#handleKeyUp(e); + const onTabClose = e => this.#thumbnailCache.delete(e.target.linkedPanel); + + window.addEventListener("keydown", keydownListener, true); + window.addEventListener("keyup", keyupListener, true); + window.addEventListener("TabClose", onTabClose); + + window.addEventListener( + "unload", + () => { + window.removeEventListener("keydown", keydownListener, true); + window.removeEventListener("keyup", keyupListener, true); + window.removeEventListener("TabClose", onTabClose); + }, + { once: true } + ); + } + + #handleKeyDown(event) { + if (event.key === "Escape" && this.#isOpen) { + event.preventDefault(); + event.stopImmediatePropagation(); + this.close(false); + return; + } + + if (lazy.enabled && event.ctrlKey && event.key === "Tab") { + event.preventDefault(); + event.stopImmediatePropagation(); + + if (!this.#isOpen) { + this.open(event.shiftKey); + } else { + event.shiftKey ? this.navigateBackward() : this.navigateForward(); + } + } + } + + #handleKeyUp(event) { + if (this.#isOpen && event.key === "Control") { + this.close(document.hasFocus()); + } + } + + // Ensure panel fits on narrow or vertical screens. + #getMaxCards() { + const screenWidth = screen.width; + + const getPanelWidth = cards => + nsZenCtrlTabPanel.CARD_WIDTH * cards + + nsZenCtrlTabPanel.PANEL_PADDING * 2; + + if (screenWidth < getPanelWidth(4)) { + return 3; + } else if (screenWidth < getPanelWidth(5)) { + return 4; + } + return 5; + } + + /** + * Constructs tab list, determines initial selection, captures thumbnails, and opens panel. + * + * @param {boolean} shiftKey - Navigate backward (true) or forward (false). + * @returns {Promise} Resolves when panel is displayed. + */ + async open(shiftKey = false) { + if (this.#isOpen) { + return; + } + + this.#tabList = Array.from(gBrowser.tabs).filter(tab => { + if (tab.closing || !tab.visible || tab.hasAttribute("busy")) { + return false; + } + if (lazy.sortByRecentlyUsed && tab.hasAttribute("pending")) { + return false; + } + return true; + }); + + if (lazy.sortByRecentlyUsed) { + this.#tabList.sort((tab1, tab2) => tab2.lastAccessed - tab1.lastAccessed); + } + + if (this.#tabList.length <= 1) { + return; + } + + /* Delete current tab's cached thumbnail so it gets recaptured below, + * this ensures thumbnails show the most up-to-date page content. */ + this.#thumbnailCache.delete(gBrowser.selectedTab.linkedPanel); + const currentTabIndex = lazy.sortByRecentlyUsed + ? 0 + : this.#tabList.indexOf(gBrowser.selectedTab); + + if (shiftKey) { + this.#currentIndex = + currentTabIndex >= 0 + ? (currentTabIndex - 1 + this.#tabList.length) % this.#tabList.length + : this.#tabList.length - 1; + } else { + this.#currentIndex = + currentTabIndex >= 0 ? (currentTabIndex + 1) % this.#tabList.length : 0; + } + + const maxCards = this.#getMaxCards(); + this.#actualVisibleCards = Math.min(this.#tabList.length, maxCards); + this.#isOpen = true; + + const browserRect = gBrowser.tabbox.getBoundingClientRect(); + const tabBoxAspectRatio = browserRect.width / browserRect.height; + // Clamp width to 300 on narrow viewports and 700 on wide viewports + const thumbnailWidth = Math.round( + Math.min(Math.max(tabBoxAspectRatio * 500, 300), 700) + ); + const thumbnailHeight = Math.round(thumbnailWidth / tabBoxAspectRatio); + + await Promise.all( + this.#tabList.map(tab => + this.#captureThumbnail(tab, thumbnailWidth, thumbnailHeight) + ) + ); + + if (!this.#isOpen) { + return; + } + + this.#createTabCards(); + + const pageStartIndex = this.#getPageStartIndex(this.#currentIndex); + const scrollPosition = pageStartIndex * nsZenCtrlTabPanel.CARD_WIDTH; + + const windowWidth = window.innerWidth; + const windowHeight = window.innerHeight; + + const panelWidth = + nsZenCtrlTabPanel.CARD_WIDTH * this.#actualVisibleCards + + nsZenCtrlTabPanel.PANEL_PADDING * 2; + + // Math.max(0, ...) prevents panel from being cut off by screen edge on narrow browser windows. + const centerX = Math.max(0, (windowWidth - panelWidth) / 2); + const centerY = (windowHeight - nsZenCtrlTabPanel.PANEL_HEIGHT) / 2; + + this.panel.addEventListener( + "popupshowing", + () => { + this.tabsContainer.scrollLeft = scrollPosition; + }, + { once: true } + ); + + this.panel.addEventListener( + "popuphiding", + () => { + if (this.#isOpen) { + this.close(false); + } + }, + { once: true } + ); + + PanelMultiView.openPopup(this.panel, document.documentElement, { + position: "overlap", + triggerEvent: null, + x: centerX, + y: centerY, + }); + } + + close(switchTab = true) { + if (!this.#isOpen) { + return; + } + + const selectedTab = this.#tabList[this.#currentIndex]; + if ( + switchTab && + selectedTab && + !selectedTab.closing && + selectedTab !== gBrowser.selectedTab + ) { + gBrowser.selectedTab = selectedTab; + } + + // Reset state + this.#isOpen = false; + this.#currentIndex = 0; + this.#tabList = []; + this.#actualVisibleCards = undefined; + this.panel.hidePopup(); + } + + /** + * Captures tab thumbnail and caches it. + * + * @param {object} tab + * @param {number} thumbnailWidth + * @param {number} thumbnailHeight + * @returns {Promise} Resolves when captured or skipped. + */ + async #captureThumbnail(tab, thumbnailWidth, thumbnailHeight) { + if (tab.hasAttribute("pending")) { + return; + } + const tabId = tab.linkedPanel; + if (this.#thumbnailCache.has(tabId)) { + return; + } + const browser = tab.linkedBrowser; + if (!browser) { + return; + } + + try { + const canvas = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "canvas" + ); + canvas.width = thumbnailWidth; + canvas.height = thumbnailHeight; + + await lazy.PageThumbs.captureToCanvas(browser, canvas, { + fullViewport: true, + }); + const tabThumbnail = canvas.toDataURL("image/jpeg", 0.9); + + if (tab.closing) { + return; + } + + this.#thumbnailCache.set(tabId, tabThumbnail); + } catch (e) { + console.warn("ZenCtrlTabPanel: Failed to cache thumbnail:", e); + } + } + + /** + * Creates card UI for each tab in the current tab list and appends to card container. + * + * @returns {void} + */ + #createTabCards() { + if (!this.tabsContainer) { + return; + } + + this.tabsContainer.replaceChildren(); + this.tabsContainer.style.width = `${nsZenCtrlTabPanel.CARD_WIDTH * this.#actualVisibleCards}px`; + + const defaultFavicon = PlacesUtils.favicons.defaultFavicon.spec; + const newTabFavicon = "chrome://browser/skin/zen-icons/new-tab-image.svg"; + + this.#tabList.forEach((tab, index) => { + const tabId = tab.linkedPanel; + const isPending = tab.hasAttribute("pending"); + + const card = document.createXULElement("vbox"); + card.className = "zen-ctrl-tab-panel-card"; + + const thumbnailContainer = document.createXULElement("box"); + thumbnailContainer.className = "zen-ctrl-tab-panel-thumbnail"; + + const thumbnail = isPending ? null : this.#thumbnailCache.get(tabId); + + if (thumbnail) { + const img = document.createXULElement("image"); + img.setAttribute("src", thumbnail); + thumbnailContainer.appendChild(img); + } else { + card.classList.add("zen-ctrl-tab-panel-no-thumbnail"); + } + + card.appendChild(thumbnailContainer); + + const infoContainer = document.createXULElement("hbox"); + infoContainer.className = "zen-ctrl-tab-panel-info"; + + const favicon = document.createXULElement("image"); + favicon.className = "zen-ctrl-tab-panel-favicon"; + + let iconSrc = gBrowser.getIcon(tab) || defaultFavicon; + + if (iconSrc.startsWith("chrome://branding/content/")) { + iconSrc = newTabFavicon; + } + + favicon.setAttribute("src", iconSrc); + infoContainer.appendChild(favicon); + + const title = document.createXULElement("label"); + title.className = "zen-ctrl-tab-panel-title"; + title.setAttribute("value", tab.label || ""); + title.setAttribute("crop", "end"); + infoContainer.appendChild(title); + card.appendChild(infoContainer); + + if (isPending) { + card.classList.add("zen-ctrl-tab-panel-pending"); + } + + if (index === this.#currentIndex) { + card.classList.add("zen-ctrl-tab-panel-selected"); + } + + card.addEventListener("click", event => { + if (event.ctrlKey || event.metaKey) { + event.preventDefault(); + event.stopPropagation(); + this.#currentIndex = index; + this.close(); + } + }); + + this.tabsContainer.appendChild(card); + }); + } + + /** + * Updates visual selection state and scrolls smoothly to show the selected card. + * + * @param previousIndex - Index of the previously selected card to deselect. + * @returns {void} + */ + #updateSelection(previousIndex) { + if (!this.tabsContainer?.children.length) { + return; + } + + const prevSelected = this.tabsContainer.children[previousIndex]; + if (prevSelected) { + prevSelected.classList.remove("zen-ctrl-tab-panel-selected"); + } + + const newSelected = this.tabsContainer.children[this.#currentIndex]; + if (newSelected) { + newSelected.classList.add("zen-ctrl-tab-panel-selected"); + } + + const pageStartIndex = this.#getPageStartIndex(this.#currentIndex); + const scrollPosition = pageStartIndex * nsZenCtrlTabPanel.CARD_WIDTH; + + this.tabsContainer.scrollTo({ + left: scrollPosition, + behavior: "smooth", + }); + } + + /** + * Calculates which card should be at the left edge for pagination. + * + * @param {number} currentCardIndex - Index of the selected card. + * @returns {number} Index of the first card on the current page. + */ + #getPageStartIndex(currentCardIndex) { + const totalTabs = this.#tabList.length; + const maxVisible = this.#actualVisibleCards; + + if (totalTabs <= maxVisible) { + return 0; + } + + const pageStartIndex = + Math.floor(currentCardIndex / maxVisible) * maxVisible; + + // Adjust for last page to always show full page of cards + if (pageStartIndex + maxVisible > totalTabs) { + return totalTabs - maxVisible; + } + + return pageStartIndex; + } + + /** + * Moves selection to next card (wraps to first when at end). + * + * @returns {void} + */ + navigateForward() { + const previousIndex = this.#currentIndex; + this.#currentIndex = (this.#currentIndex + 1) % this.#tabList.length; + this.#updateSelection(previousIndex); + } + + /** + * Moves selection to previous card (wraps to last when at start). + * + * @returns {void} + */ + navigateBackward() { + const previousIndex = this.#currentIndex; + this.#currentIndex = + (this.#currentIndex - 1 + this.#tabList.length) % this.#tabList.length; + this.#updateSelection(previousIndex); + } +} + +window.gZenCtrlTabPanel = new nsZenCtrlTabPanel(); diff --git a/src/zen/tabs/jar.inc.mn b/src/zen/tabs/jar.inc.mn index ed348b83d7..0cdde4c6bb 100644 --- a/src/zen/tabs/jar.inc.mn +++ b/src/zen/tabs/jar.inc.mn @@ -4,5 +4,7 @@ content/browser/zen-components/ZenPinnedTabManager.mjs (../../zen/tabs/ZenPinnedTabManager.mjs) content/browser/zen-components/ZenEssentialsPromo.mjs (../../zen/tabs/ZenEssentialsPromo.mjs) + content/browser/zen-components/ZenCtrlTabPanel.mjs (../../zen/tabs/ZenCtrlTabPanel.mjs) * content/browser/zen-styles/zen-tabs.css (../../zen/tabs/zen-tabs.css) + content/browser/zen-styles/zen-ctrl-tab-panel.css (../../zen/tabs/zen-ctrl-tab-panel.css) content/browser/zen-styles/zen-tabs/vertical-tabs.css (../../zen/tabs/zen-tabs/vertical-tabs.css) diff --git a/src/zen/tabs/zen-ctrl-tab-panel.css b/src/zen/tabs/zen-ctrl-tab-panel.css new file mode 100644 index 0000000000..5189ee6ae4 --- /dev/null +++ b/src/zen/tabs/zen-ctrl-tab-panel.css @@ -0,0 +1,109 @@ +/* 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 { + --zen-ctrl-tab-panel-padding: 16px; + --zen-ctrl-tab-panel-card-inner-padding: 12px; + --zen-ctrl-tab-panel-card-gap: 12px; + --zen-ctrl-tab-panel-card-width: 250px; + --zen-ctrl-tab-panel-card-height: 220px; + --zen-ctrl-tab-panel-card-border-radius: 14px; + --zen-ctrl-tab-panel-thumbnail-border-radius: 8px; + --zen-ctrl-tab-panel-thumbnail-height: 156px; + --zen-ctrl-tab-panel-info-height: 28px; +} + +#zen-ctrl-tab-panel { + --panel-padding: var(--zen-ctrl-tab-panel-padding); + width: fit-content; + height: fit-content; +} + +#zen-ctrl-tab-panel .panel-viewcontainer, +#zen-ctrl-tab-panel .panel-viewstack { + display: flex; + flex-direction: row; + width: 100%; +} + +#zen-ctrl-tab-panel-tabs { + display: flex; + flex-direction: row; + overflow: hidden; + position: relative; + box-sizing: border-box; + flex-shrink: 0; + height: var(--zen-ctrl-tab-panel-card-height); +} + +.zen-ctrl-tab-panel-card { + flex-shrink: 0; + width: var(--zen-ctrl-tab-panel-card-width); + height: var(--zen-ctrl-tab-panel-card-height); + border-radius: var(--zen-ctrl-tab-panel-card-border-radius); + padding: var(--zen-ctrl-tab-panel-card-inner-padding); + cursor: pointer; + display: flex; + flex-direction: column; + gap: var(--zen-ctrl-tab-panel-card-gap); + box-sizing: border-box; +} + +.zen-ctrl-tab-panel-card:hover { + background: color-mix(in srgb, currentColor 5%, transparent); +} + +.zen-ctrl-tab-panel-card.zen-ctrl-tab-panel-selected { + background: var(--arrowpanel-dimmed); +} + +.zen-ctrl-tab-panel-thumbnail { + width: 100%; + height: var(--zen-ctrl-tab-panel-thumbnail-height); + overflow: hidden; + position: relative; + border-radius: var(--zen-ctrl-tab-panel-thumbnail-border-radius); + box-sizing: border-box; +} + +.zen-ctrl-tab-panel-no-thumbnail .zen-ctrl-tab-panel-thumbnail { + outline: 1px solid var(--panel-separator-color); + outline-offset: -1px; +} + +.zen-ctrl-tab-panel-thumbnail image { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + object-position: center top; +} + +.zen-ctrl-tab-panel-info { + padding: 0 5px; + display: flex; + align-items: center; + gap: 6px; + height: var(--zen-ctrl-tab-panel-info-height); + color: var(--zen-branding-bg-reverse); + -moz-text-fill-color: var(--zen-branding-bg-reverse); +} + +.zen-ctrl-tab-panel-favicon { + width: 20px; + height: 20px; + flex-shrink: 0; + -moz-context-properties: fill; + fill: currentColor; +} + +.zen-ctrl-tab-panel-title { + flex: 1; + font-size: 16px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/src/zen/tests/ctrl_tab/browser.toml b/src/zen/tests/ctrl_tab/browser.toml new file mode 100644 index 0000000000..7c67cb8d27 --- /dev/null +++ b/src/zen/tests/ctrl_tab/browser.toml @@ -0,0 +1,10 @@ +# 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_ctrl_tab.js"] diff --git a/src/zen/tests/ctrl_tab/browser_ctrl_tab.js b/src/zen/tests/ctrl_tab/browser_ctrl_tab.js new file mode 100644 index 0000000000..11aa283301 --- /dev/null +++ b/src/zen/tests/ctrl_tab/browser_ctrl_tab.js @@ -0,0 +1,188 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_setup(function () { + registerCleanupFunction(() => { + gZenCtrlTabPanel.close(false); + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.tabs[gBrowser.tabs.length - 1]); + } + }); +}); + +add_task(async function test_Tab_Navigation() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["zen.tabs.ctrl-tab-panel.enabled", true], + ["zen.tabs.ctrl-tab-panel.sort-by-recent", false], + ], + }); + + let tabs = await addTabs(3); + + let visible = Array.from(gBrowser.tabs).filter(t => !t.closing && t.visible); + is(visible.length, 4, "Should have 4 visible tabs"); + + gBrowser.selectedTab = visible[2]; + + // Forward: 2 → 3 + await openCtrlTabPanel(); + is(getPanel().state, "open", "Panel should be open"); + is(getCardCount(), 4, "Card count should match tab count"); + await closeCtrlTabPanel(); + is(getPanel().state, "closed", "Panel should be closed"); + is(gBrowser.selectedTab, visible[3], "Forward should move to next tab"); + + // Forward wrap: 3 → 0 + await openCtrlTabPanel(); + await closeCtrlTabPanel(); + is( + gBrowser.selectedTab, + visible[0], + "Forward from last should wrap to first" + ); + + // Backward wrap: 0 → 3 + await openCtrlTabPanel(true); + await closeCtrlTabPanel(); + is( + gBrowser.selectedTab, + visible[3], + "Backward from first should wrap to last" + ); + + // Backward: 3 → 2 + await openCtrlTabPanel(true); + await closeCtrlTabPanel(); + is(gBrowser.selectedTab, visible[2], "Shift should go backward"); + + // Forward without switch: stays at 2 + await openCtrlTabPanel(); + await closeCtrlTabPanel(false); + is(gBrowser.selectedTab, visible[2], "close(false) should not switch"); + + for (let tab of tabs) { + BrowserTestUtils.removeTab(tab); + } + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_Multi_Navigate_While_Open() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["zen.tabs.ctrl-tab-panel.enabled", true], + ["zen.tabs.ctrl-tab-panel.sort-by-recent", false], + ], + }); + + let tabs = await addTabs(3); + + let visible = Array.from(gBrowser.tabs).filter(t => !t.closing && t.visible); + gBrowser.selectedTab = visible[0]; + + // Open, navigate forward twice, then close + await openCtrlTabPanel(); + gZenCtrlTabPanel.navigateForward(); + gZenCtrlTabPanel.navigateForward(); + await closeCtrlTabPanel(); + is(gBrowser.selectedTab, visible[3], "Two forwards should land 3 ahead"); + + // Open with shift, navigate backward, then close + await openCtrlTabPanel(true); + gZenCtrlTabPanel.navigateBackward(); + await closeCtrlTabPanel(); + is(gBrowser.selectedTab, visible[1], "One backward should land 2 behind"); + + for (let tab of tabs) { + BrowserTestUtils.removeTab(tab); + } + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_Disabled_Pref() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["zen.tabs.ctrl-tab-panel.enabled", false], + ["zen.tabs.ctrl-tab-panel.sort-by-recent", true], + ], + }); + + let tabs = await addTabs(2); + EventUtils.synthesizeKey("VK_TAB", { ctrlKey: true }); + + await new Promise(r => setTimeout(r, 200)); + isnot( + getPanel().state, + "open", + "Panel should not open when pref is disabled" + ); + + if (getPanel().state === "open") { + await closeCtrlTabPanel(); + } + + for (let tab of tabs) { + BrowserTestUtils.removeTab(tab); + } + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_Less_Than_Two_Tabs() { + await SpecialPowers.pushPrefEnv({ + set: [["zen.tabs.ctrl-tab-panel.enabled", true]], + }); + + await gZenCtrlTabPanel.open(); + isnot( + getPanel().state, + "open", + "Panel should not open with less than two tabs" + ); + + if (getPanel().state === "open") { + await closeCtrlTabPanel(); + } + + let tabs = await addTabs(1); + await openCtrlTabPanel(); + is(getPanel().state, "open", "Panel should open with two tabs"); + await closeCtrlTabPanel(); + + for (let tab of tabs) { + BrowserTestUtils.removeTab(tab); + } + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_Recent_Sort_Order() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["zen.tabs.ctrl-tab-panel.enabled", true], + ["zen.tabs.ctrl-tab-panel.sort-by-recent", true], + ], + }); + + let tabs = await addTabs(3); + + gBrowser.selectedTab = tabs[0]; + await new Promise(r => setTimeout(r, 50)); + gBrowser.selectedTab = tabs[1]; + await new Promise(r => setTimeout(r, 50)); + gBrowser.selectedTab = tabs[2]; + + await openCtrlTabPanel(); + await closeCtrlTabPanel(); + + is( + gBrowser.selectedTab, + tabs[1], + "Should switch to the second most recently used tab" + ); + + for (let tab of tabs) { + BrowserTestUtils.removeTab(tab); + } + await SpecialPowers.popPrefEnv(); +}); diff --git a/src/zen/tests/ctrl_tab/head.js b/src/zen/tests/ctrl_tab/head.js new file mode 100644 index 0000000000..2c71671157 --- /dev/null +++ b/src/zen/tests/ctrl_tab/head.js @@ -0,0 +1,35 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +function getPanel() { + return document.getElementById("zen-ctrl-tab-panel"); +} + +async function openCtrlTabPanel(shiftKey = false) { + let popupShown = BrowserTestUtils.waitForEvent(getPanel(), "popupshown"); + await gZenCtrlTabPanel.open(shiftKey); + await popupShown; +} + +async function closeCtrlTabPanel(switchTab = true) { + let popupHidden = BrowserTestUtils.waitForEvent(getPanel(), "popuphidden"); + gZenCtrlTabPanel.close(switchTab); + await popupHidden; +} + +async function addTabs(n) { + let tabs = []; + for (let i = 0; i < n; i++) { + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank", { + skipAnimation: true, + }); + tabs.push(tab); + } + return tabs; +} + +function getCardCount() { + return document.getElementById("zen-ctrl-tab-panel-tabs")?.children.length ?? 0; +}