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;
+}