From dd38dcb6313a3ab168e1f20bf546cfcda100fd0d Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 9 Apr 2026 15:56:30 -0400 Subject: [PATCH] feat(tabs): close-all button with confirmation dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a ✕ button next to the + tab button that closes all open rApp tabs and returns to the user dashboard. Shows a confirm() prompt with tab count before proceeding. Co-Authored-By: Claude Opus 4.6 --- server/shell.ts | 40 +++++++++++++++++++++++++++ shared/components/rstack-tab-bar.ts | 43 +++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/server/shell.ts b/server/shell.ts index 3c66c027..0b3c6c75 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -1267,6 +1267,42 @@ export function renderShell(opts: ShellOptions): string { } }); + tabBar.addEventListener('layers-close-all', () => { + // Track all closed modules so server merge doesn't resurrect them + layers.forEach(l => _closedModuleIds.add(l.moduleId)); + // Clean up space layer refs + const token = document.cookie.match(/encryptid_token=([^;]+)/)?.[1]; + layers.forEach(l => { + if (l.spaceSlug && l._spaceRefId && token) { + fetch('/api/spaces/' + encodeURIComponent(spaceSlug) + '/nest/' + encodeURIComponent(l._spaceRefId), { + method: 'DELETE', + headers: { 'Authorization': 'Bearer ' + token }, + }).catch(() => {}); + } + }); + // Remove all cached panes + if (tabCache) { + layers.forEach(l => tabCache.removePane(l.moduleId)); + tabCache.hideAllPanes(); + } + layers = []; + saveTabs(); + // Show the dashboard + const dashboard = document.querySelector('rstack-user-dashboard'); + if (dashboard) { + dashboard.style.display = ''; + if (dashboard.refresh) dashboard.refresh(); + if (dashboard.setOpenTabs) dashboard.setOpenTabs([]); + } + const app = document.getElementById('app'); + if (app) app.classList.remove('canvas-layout'); + tabBar.setAttribute('active', ''); + tabBar.setLayers([]); + currentModuleId = ''; + var dashUrl = window.location.hostname.endsWith('.rspace.online') ? '/' : '/' + spaceSlug; + history.pushState({ dashboard: true, spaceSlug: spaceSlug }, '', dashUrl); + }); + tabBar.addEventListener('layer-reorder', (e) => { const { layerId, newIndex } = e.detail; const oldIdx = layers.findIndex(l => l.id === layerId); @@ -1482,6 +1518,9 @@ export function renderShell(opts: ShellOptions): string { tabBar.addEventListener('layer-close', (e) => { sync.removeLayer(e.detail.layerId); }); + tabBar.addEventListener('layers-close-all', () => { + sync.getLayers().forEach(l => sync.removeLayer(l.id)); + }); tabBar.addEventListener('layer-reorder', (e) => { const { layerId, newIndex } = e.detail; const all = sync.getLayers(); // already sorted by order @@ -1822,6 +1861,7 @@ export function renderExternalAppShell(opts: ExternalAppShellOptions): string { tabBar.addEventListener('layer-switch', (e) => { saveTabs(); window.location.href = window.__rspaceNavUrl(spaceSlug, e.detail.moduleId); }); tabBar.addEventListener('layer-add', (e) => { const { moduleId } = e.detail; if (!layers.find(l => l.moduleId === moduleId)) layers.push(makeLayer(moduleId, layers.length)); saveTabs(); window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId); }); tabBar.addEventListener('layer-close', (e) => { const { layerId } = e.detail; tabBar.removeLayer(layerId); layers = layers.filter(l => l.id !== layerId); saveTabs(); if (layerId === 'layer-' + currentModuleId && layers.length > 0) window.location.href = window.__rspaceNavUrl(spaceSlug, layers[0].moduleId); }); + tabBar.addEventListener('layers-close-all', () => { layers = []; tabBar.setLayers([]); tabBar.setAttribute('active', ''); saveTabs(); var dashUrl = window.location.hostname.endsWith('.rspace.online') ? '/' : '/' + spaceSlug; window.location.href = dashUrl; }); tabBar.addEventListener('layer-reorder', (e) => { const { layerId, newIndex } = e.detail; const oldIdx = layers.findIndex(l => l.id === layerId); if (oldIdx === -1 || oldIdx === newIndex) return; const [moved] = layers.splice(oldIdx, 1); layers.splice(newIndex, 0, moved); layers.forEach((l, i) => l.order = i); saveTabs(); tabBar.setLayers(layers); }); } diff --git a/shared/components/rstack-tab-bar.ts b/shared/components/rstack-tab-bar.ts index bcea794d..7d83216e 100644 --- a/shared/components/rstack-tab-bar.ts +++ b/shared/components/rstack-tab-bar.ts @@ -15,6 +15,7 @@ * layer-switch — fired when user clicks a tab { detail: { layerId, moduleId } } * layer-add — fired when user clicks + to add a layer * layer-close — fired when user closes a tab { detail: { layerId } } + * layers-close-all — fired when user clicks close-all button (no detail) * layer-reorder — fired on drag reorder { detail: { layerId, newIndex } } * view-toggle — fired when switching flat/stack view { detail: { mode } } * flow-select — fired when a flow is clicked in stack view { detail: { flowId } } @@ -426,6 +427,7 @@ export class RStackTabBar extends HTMLElement {
+ ${this.#layers.length > 1 ? `` : ""} ${this.#addMenuOpen ? this.#renderAddMenu() : ""}
@@ -1098,6 +1100,20 @@ export class RStackTabBar extends HTMLElement { addBtn?.addEventListener("click", openAppSwitcher); addBtn?.addEventListener("touchend", openAppSwitcher); + // Close-all button + const closeAllBtn = this.#shadow.getElementById("close-all-btn"); + if (closeAllBtn) { + const handleCloseAll = (e: Event) => { + e.stopPropagation(); + e.preventDefault(); + const count = this.#layers.length; + if (!confirm(`Close all ${count} rApp tabs?`)) return; + this.dispatchEvent(new CustomEvent("layers-close-all", { bubbles: true })); + }; + closeAllBtn.addEventListener("click", handleCloseAll); + closeAllBtn.addEventListener("touchend", handleCloseAll); + } + // Add menu items — click + touch support this.#shadow.querySelectorAll(".add-menu-item").forEach(item => { const handleSelect = (e: Event) => { @@ -1613,6 +1629,8 @@ const STYLES = ` /* ── Add button ── */ .tab-add-wrap { + display: flex; + align-items: center; position: relative; flex-shrink: 0; margin-left: 2px; @@ -1642,6 +1660,31 @@ const STYLES = ` background: var(--rs-bg-hover); color: var(--rs-text-primary); } +.tab-close-all { + display: flex; + align-items: center; + justify-content: center; + padding: 4px 8px; + min-height: 44px; + border: none; + border-radius: 6px 6px 0 0; + background: transparent; + color: var(--rs-text-muted); + font-size: 0.85rem; + cursor: pointer; + transition: background 0.15s, color 0.15s; + flex-shrink: 0; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; + white-space: nowrap; + user-select: none; + opacity: 0.6; +} +.tab-close-all:hover, .tab-close-all:active { + background: rgba(239,68,68,0.15); + color: #ef4444; + opacity: 1; +} /* ── Add menu ── */