From 3577b800d1d144ef214a043ba7f0e8986f756371 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Tue, 16 Jun 2026 16:08:05 +0100 Subject: [PATCH] Final tweaks for v3 --- frontend/scripts/test-regressions.js | 55 ++++++ frontend/src/css/layout.css | 43 ++++- frontend/src/css/tabs.css | 32 +++- frontend/src/js/tabs.js | 242 +++++++++++++++++++++++++-- 4 files changed, 355 insertions(+), 17 deletions(-) diff --git a/frontend/scripts/test-regressions.js b/frontend/scripts/test-regressions.js index 2fe2ba2..0656ba6 100644 --- a/frontend/scripts/test-regressions.js +++ b/frontend/scripts/test-regressions.js @@ -144,6 +144,61 @@ assertContains( "height: var(--tab-bar-height);", "tab bar must use the shared sidebar header height", ); +assertContains( + "frontend/src/js/tabs.js", + "this._setupScrolling();", + "tab bar must enable horizontal scroll handling for overflowed tabs", +); +assertContains( + "frontend/src/js/tabs.js", + "this._setupTooltip();", + "tab bar must show rich hover hints for truncated tabs", +); +assertContains( + "frontend/src/js/tabs.js", + "removeTab(sessionId) {\n this._hideTabTooltip();", + "removing a tab must clear any visible tab tooltip", +); +assertContains( + "frontend/src/js/tabs.js", + "async closeTab(sessionId) {\n this._hideTabTooltip();", + "closing a tab must clear any visible or pending tab tooltip", +); +assertContains( + "frontend/src/js/tabs.js", + "!tab?.isConnected", + "tab tooltip timers must not show hints for removed tab elements", +); +assertContains( + "frontend/src/js/tabs.js", + "_clearTabTooltipTimer", + "tab tooltip cleanup must cancel pending hover timers", +); +assertContains( + "frontend/src/js/tabs.js", + "session.protocol !== \"leviathan\"", + "Leviathan tabs must close without connected-session confirmation", +); +assertContains( + "frontend/src/js/tabs.js", + "Connected since:", + "connection tab hover hints must include the connected-since timestamp", +); +assertContains( + "frontend/src/js/tabs.js", + "tab.appendChild(closeBtn);\n\n // Protocol badge", + "tab close button must render on the left side of each tab", +); +assertContains( + "frontend/src/css/layout.css", + "scrollbar-gutter: stable;", + "overflowing tab lists must expose a stable horizontal scrollbar", +); +assertContains( + "frontend/src/css/tabs.css", + ".tab-tooltip", + "tab hover hints must have dedicated tooltip styling", +); assertContains( "frontend/src/js/leviathan.js", "Hex Editor", diff --git a/frontend/src/css/layout.css b/frontend/src/css/layout.css index eb088ab..3a9f243 100644 --- a/frontend/src/css/layout.css +++ b/frontend/src/css/layout.css @@ -40,6 +40,7 @@ .tab-bar { grid-column: 2; grid-row: 1; + position: relative; background: var(--bg-surface); border-bottom: 1px solid var(--border-subtle); display: flex; @@ -53,13 +54,51 @@ display: flex; align-items: center; flex: 1; + height: 100%; min-width: 0; overflow-x: auto; overflow-y: hidden; - scrollbar-width: none; /* Firefox */ + scrollbar-width: thin; + scrollbar-gutter: stable; + scroll-behavior: smooth; } .tab-list::-webkit-scrollbar { - display: none; + height: 6px; +} +.tab-list::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: var(--radius-full); +} +.tab-list::-webkit-scrollbar-thumb:hover { + background: var(--text-tertiary); +} + +.tab-bar::before, +.tab-bar::after { + content: ""; + position: absolute; + top: 0; + bottom: 1px; + z-index: 2; + width: 18px; + pointer-events: none; + opacity: 0; + transition: opacity var(--transition-fast); +} + +.tab-bar::before { + left: 0; + background: linear-gradient(90deg, var(--bg-surface), transparent); +} + +.tab-bar::after { + right: calc(var(--tab-bar-height) * 2); + background: linear-gradient(-90deg, var(--bg-surface), transparent); +} + +.tab-bar.has-overflow-left::before, +.tab-bar.has-overflow-right::after { + opacity: 1; } /* ----------------------------------------------------------------------- diff --git a/frontend/src/css/tabs.css b/frontend/src/css/tabs.css index a19c87f..593ab55 100644 --- a/frontend/src/css/tabs.css +++ b/frontend/src/css/tabs.css @@ -240,7 +240,7 @@ } .tab:hover .tab-sftp, -.tab.active .tab-sftp { +.tab.tab-active .tab-sftp { opacity: 1; } @@ -268,11 +268,15 @@ background var(--transition-fast), color var(--transition-fast); cursor: pointer; - margin-left: 2px; + margin: 0 2px 0 0; + padding: 0; + border: 0; + background: none; + line-height: 1; } .tab:hover .tab-close, -.tab.active .tab-close { +.tab.tab-active .tab-close { opacity: 1; } @@ -456,6 +460,28 @@ /* Transparent hit area for right-click context menu */ } +/* ----------------------------------------------------------------------- + Tab hover tooltip + ----------------------------------------------------------------------- */ +.tab-tooltip { + max-width: 360px; + white-space: normal; +} + +.tab-tooltip-title { + max-width: 320px; + overflow: hidden; + color: var(--text-primary); + text-overflow: ellipsis; + white-space: nowrap; +} + +.tab-tooltip-meta { + margin-top: 2px; + color: var(--text-tertiary); + font-weight: var(--weight-normal); +} + /* ----------------------------------------------------------------------- Modified indicator (unsaved changes) ----------------------------------------------------------------------- */ diff --git a/frontend/src/js/tabs.js b/frontend/src/js/tabs.js index 5f83324..f1f1719 100644 --- a/frontend/src/js/tabs.js +++ b/frontend/src/js/tabs.js @@ -32,7 +32,13 @@ /** @type {string[]} Ordered list of session IDs. */ this.order = []; + this.tooltip = null; + this.tooltipTimer = null; + this.tooltipSessionId = null; + this._setupDragAndDrop(); + this._setupScrolling(); + this._setupTooltip(); } // ── Public API ─────────────────────────────────────────────────── @@ -56,6 +62,7 @@ // Hide the empty-state BEFORE activating the panel so the // panel-container gets the full workspace height immediately. this._updateEmptyState(); + this._updateScrollState(); this.setActive(sessionInfo.id); } @@ -64,6 +71,7 @@ * @param {string} sessionId */ removeTab(sessionId) { + this._hideTabTooltip(); const idx = this.order.indexOf(sessionId); const wasActive = this.app.state.activeSessionId === sessionId; if (idx !== -1) this.order.splice(idx, 1); @@ -92,6 +100,7 @@ } this._updateEmptyState(); + this._updateScrollState(); } /** @@ -120,6 +129,7 @@ this.app.sidebar.highlightActive(session.profileId); } this.app.leviathan?.handleTabFocus?.(sessionId); + this._scrollTabIntoView(sessionId); // Fit and refresh the terminal for the newly active session. // Double-RAF ensures the panel has layout dimensions before fitting. @@ -157,12 +167,14 @@ * @param {string} sessionId */ async closeTab(sessionId) { + this._hideTabTooltip(); const session = this.app.state.getSession(sessionId); // Ask for confirmation if connected and the preference is set if ( session && session.state === "connected" && + session.protocol !== "leviathan" && this.app.state.preferences.confirmOnDisconnect ) { const ok = await this._confirmClose(sessionId); @@ -191,7 +203,7 @@ */ _confirmClose(sessionId) { const createElement = window.NexusTerminal.createElement; - const label = this._tabLabel(sessionId); + const label = escapeHtml(this._tabLabel(sessionId)); return new Promise((resolve) => { const backdrop = createElement("div", "modal-backdrop"); const modal = createElement("div", "modal modal-sm"); @@ -479,6 +491,18 @@ draggable: "true", }); + // Close button + const closeBtn = createElement("button", "tab-close", { + textContent: "×", + "aria-label": "Close tab", + }); + closeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + this._hideTabTooltip(); + this.closeTab(sessionInfo.id); + }); + tab.appendChild(closeBtn); + // Protocol badge const protoLabel = sessionInfo.protocol === "github" @@ -534,17 +558,6 @@ tab.appendChild(sftpBtn); } - // Close button - const closeBtn = createElement("button", "tab-close", { - textContent: "×", - "aria-label": "Close tab", - }); - closeBtn.addEventListener("click", (e) => { - e.stopPropagation(); - this.closeTab(sessionInfo.id); - }); - tab.appendChild(closeBtn); - // Click → activate tab.addEventListener("click", () => this.setActive(sessionInfo.id)); @@ -579,6 +592,7 @@ const el = this.tabList.querySelector(`[data-session-id="${id}"]`); if (el) this.tabList.appendChild(el); } + this._updateScrollState(); } /** @@ -596,6 +610,210 @@ } } + /** + * Makes the tab strip usable when the tab list overflows horizontally. + * @private + */ + _setupScrolling() { + if (!this.tabList) return; + this.tabList.addEventListener( + "wheel", + (event) => { + if (this.tabList.scrollWidth <= this.tabList.clientWidth) return; + const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) + ? event.deltaX + : event.deltaY; + if (!delta) return; + event.preventDefault(); + this.tabList.scrollLeft += delta; + this._updateScrollState(); + }, + { passive: false }, + ); + this.tabList.addEventListener("scroll", () => this._updateScrollState(), { + passive: true, + }); + window.addEventListener("resize", () => this._updateScrollState()); + } + + /** + * Keeps the active tab visible inside the scrollable tab list. + * @param {string} sessionId + * @private + */ + _scrollTabIntoView(sessionId) { + const tab = this.tabList?.querySelector(`[data-session-id="${sessionId}"]`); + if (!tab) return; + requestAnimationFrame(() => { + tab.scrollIntoView({ + behavior: "smooth", + block: "nearest", + inline: "nearest", + }); + this._updateScrollState(); + }); + } + + /** + * Reflects whether tab overflow exists at either edge. + * @private + */ + _updateScrollState() { + if (!this.tabList || !this.tabBar) return; + const max = this.tabList.scrollWidth - this.tabList.clientWidth; + this.tabBar.classList.toggle("has-tab-overflow", max > 1); + this.tabBar.classList.toggle("has-overflow-left", this.tabList.scrollLeft > 1); + this.tabBar.classList.toggle( + "has-overflow-right", + max > 1 && this.tabList.scrollLeft < max - 1, + ); + } + + /** + * Shows a custom hover hint for tabs with full names and connection start. + * @private + */ + _setupTooltip() { + if (!this.tabList) return; + this.tooltip = createElement("div", "tooltip tab-tooltip", { + role: "tooltip", + }); + document.body.appendChild(this.tooltip); + + this.tabList.addEventListener("pointerover", (event) => { + const tab = event.target.closest(".tab"); + if (!tab || !this.tabList.contains(tab)) return; + if (tab.contains(event.relatedTarget)) return; + this._clearTabTooltipTimer(); + this.tooltipTimer = setTimeout(() => { + this.tooltipTimer = null; + this._showTabTooltip(tab.dataset.sessionId, tab); + }, 250); + }); + + this.tabList.addEventListener("pointerdown", () => this._hideTabTooltip()); + + this.tabList.addEventListener("pointermove", (event) => { + const tab = event.target.closest(".tab"); + if (!tab || !this.tooltip?.classList.contains("is-visible")) return; + this._positionTabTooltip(tab); + }); + + this.tabList.addEventListener("pointerout", (event) => { + const tab = event.target.closest(".tab"); + if (!tab || tab.contains(event.relatedTarget)) return; + this._hideTabTooltip(); + }); + + this.tabList.addEventListener("scroll", () => this._hideTabTooltip(), { + passive: true, + }); + window.addEventListener("blur", () => this._hideTabTooltip()); + document.addEventListener("visibilitychange", () => { + if (document.hidden) this._hideTabTooltip(); + }); + } + + /** + * Renders and positions the tab hover tooltip. + * @param {string} sessionId + * @param {HTMLElement} tab + * @private + */ + _showTabTooltip(sessionId, tab) { + const session = this.app.state.getSession(sessionId); + if (!session || !this.tooltip || !tab?.isConnected || !this.tabList.contains(tab)) { + this._hideTabTooltip(); + return; + } + this.tooltip.replaceChildren(); + this.tooltip.appendChild( + createElement("div", "tab-tooltip-title", { + textContent: this._tabLabel(sessionId), + }), + ); + if ( + session.protocol !== "leviathan" && + session.state === "connected" && + session.connectedAt + ) { + const connected = this._formatConnectedSince(session.connectedAt); + if (connected) { + this.tooltip.appendChild( + createElement("div", "tab-tooltip-meta", { + textContent: `Connected since: ${connected}`, + }), + ); + } + } + this._positionTabTooltip(tab); + this.tooltipSessionId = sessionId; + this.tooltip.classList.add("is-visible"); + } + + /** + * Positions the tab tooltip under the hovered tab. + * @param {HTMLElement} tab + * @private + */ + _positionTabTooltip(tab) { + if (!this.tooltip) return; + if (!tab?.isConnected || !this.tabList.contains(tab)) { + this._hideTabTooltip(); + return; + } + const rect = tab.getBoundingClientRect(); + const tooltipRect = this.tooltip.getBoundingClientRect(); + const left = Math.min( + Math.max(8, rect.left + rect.width / 2 - tooltipRect.width / 2), + window.innerWidth - tooltipRect.width - 8, + ); + const top = Math.min(rect.bottom + 8, window.innerHeight - tooltipRect.height - 8); + this.tooltip.style.left = `${left}px`; + this.tooltip.style.top = `${top}px`; + } + + /** + * Hides the tab hover tooltip. + * @private + */ + _hideTabTooltip() { + this._clearTabTooltipTimer(); + this.tooltipSessionId = null; + if (!this.tooltip) return; + this.tooltip.classList.remove("is-visible"); + this.tooltip.replaceChildren(); + } + + /** + * Cancels any pending tab tooltip show timer. + * @private + */ + _clearTabTooltipTimer() { + if (!this.tooltipTimer) return; + clearTimeout(this.tooltipTimer); + this.tooltipTimer = null; + } + + /** + * Formats a session connection timestamp for the tab tooltip. + * @param {string} value + * @returns {string} + * @private + */ + _formatConnectedSince(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime()) || date.getFullYear() <= 1) return ""; + try { + return date.toLocaleString([], { + dateStyle: "medium", + timeStyle: "short", + }); + } catch (_) { + return date.toLocaleString(); + } + } + /** * Shows/hides the empty-state panel based on whether tabs exist. * @private