/** * Client-side tab cache for instant tab switching. * * Instead of full page navigations, previously loaded tabs are kept in the DOM * and shown/hidden via CSS. New tabs are fetched via fetch() + DOMParser on * first visit, then stay in the DOM forever. * * Hiding preserves ALL component state: Shadow DOM, event listeners, * WebSocket connections, timers. */ export class TabCache { private currentModuleId: string; private spaceSlug: string; private panes = new Map(); constructor(spaceSlug: string, moduleId: string) { this.spaceSlug = spaceSlug; this.currentModuleId = moduleId; } /** Wrap the initial #app content in a pane div and set up popstate */ init(): boolean { const app = document.getElementById("app"); if (!app) return false; // Wrap existing content in a pane const pane = document.createElement("div"); pane.className = "rspace-tab-pane rspace-tab-pane--active"; pane.dataset.moduleId = this.currentModuleId; pane.dataset.pageTitle = document.title; while (app.firstChild) { pane.appendChild(app.firstChild); } app.appendChild(pane); this.panes.set(this.currentModuleId, pane); // Push initial state into history history.replaceState( { moduleId: this.currentModuleId, spaceSlug: this.spaceSlug }, "", window.location.href, ); // Handle browser back/forward window.addEventListener("popstate", (e) => { const state = e.state; if (state?.moduleId && this.panes.has(state.moduleId)) { this.showPane(state.moduleId); } else { window.location.reload(); } }); return true; } /** Switch to a module tab. Returns true if handled client-side. */ async switchTo(moduleId: string): Promise { if (moduleId === this.currentModuleId) return true; if (this.panes.has(moduleId)) { this.showPane(moduleId); this.updateUrl(moduleId); return true; } return this.fetchAndInject(moduleId); } /** Check if a pane exists in the DOM cache */ has(moduleId: string): boolean { return this.panes.has(moduleId); } /** Remove a cached pane from the DOM */ removePane(moduleId: string): void { const pane = this.panes.get(moduleId); if (pane) { pane.remove(); this.panes.delete(moduleId); } } /** Fetch a module page, extract content, and inject into a new pane */ private async fetchAndInject(moduleId: string): Promise { const navUrl = (window as any).__rspaceNavUrl; if (!navUrl) return false; const url: string = navUrl(this.spaceSlug, moduleId); // Cross-origin URLs → fall back to full navigation try { const resolved = new URL(url, window.location.href); if (resolved.origin !== window.location.origin) return false; } catch { return false; } const app = document.getElementById("app"); if (!app) return false; // Show loading spinner const loadingPane = document.createElement("div"); loadingPane.className = "rspace-tab-pane rspace-tab-pane--active rspace-tab-loading"; loadingPane.innerHTML = '
'; this.hideAllPanes(); app.appendChild(loadingPane); try { const resp = await fetch(url); if (!resp.ok) { loadingPane.remove(); return false; } const html = await resp.text(); const content = this.extractContent(html); if (!content) { loadingPane.remove(); return false; } // Remove loading spinner loadingPane.remove(); // Create the real pane const pane = document.createElement("div"); pane.className = "rspace-tab-pane rspace-tab-pane--active"; pane.dataset.moduleId = moduleId; pane.dataset.pageTitle = content.title; pane.innerHTML = content.body; app.appendChild(pane); this.panes.set(moduleId, pane); // Load module-specific assets this.loadAssets(content.scripts, content.styles); // Update shell state this.currentModuleId = moduleId; document.title = content.title; this.updateShellState(moduleId); this.updateUrl(moduleId); this.updateCanvasLayout(moduleId); return true; } catch { loadingPane.remove(); return false; } } /** Parse full HTML response and extract module content */ private extractContent( html: string, ): { body: string; title: string; scripts: string[]; styles: string[]; } | null { const parser = new DOMParser(); const doc = parser.parseFromString(html, "text/html"); // Extract #app content or .rspace-iframe-wrap (for external app modules) const appEl = doc.getElementById("app"); const iframeWrap = doc.querySelector(".rspace-iframe-wrap"); let body: string; if (appEl) { body = appEl.innerHTML; } else if (iframeWrap) { body = iframeWrap.outerHTML; } else { return null; } const title = doc.title || ""; // Extract module-specific scripts (exclude shell.js and inline shell bootstrap) const scripts: string[] = []; doc.querySelectorAll('script[type="module"]').forEach((s) => { const src = s.getAttribute("src") || ""; if (src.includes("shell.js")) return; const text = s.textContent || ""; if (text.includes("import '/shell.js")) return; if (text.includes('import "/shell.js')) return; if (src) scripts.push(src); }); // Extract module-specific styles (exclude shell.css) const styles: string[] = []; doc.querySelectorAll('link[rel="stylesheet"]').forEach((l) => { const href = l.getAttribute("href") || ""; if (href.includes("shell.css")) return; styles.push(href); }); return { body, title, scripts, styles }; } /** Load script and style assets (idempotent — browsers deduplicate module scripts) */ private loadAssets(scripts: string[], styles: string[]): void { for (const src of scripts) { const el = document.createElement("script"); el.type = "module"; el.src = src; document.body.appendChild(el); } for (const href of styles) { if (document.querySelector(`link[href="${CSS.escape(href)}"]`)) continue; const el = document.createElement("link"); el.rel = "stylesheet"; el.href = href; document.head.appendChild(el); } } /** Show a specific pane and update shell state */ private showPane(moduleId: string): void { this.hideAllPanes(); const pane = this.panes.get(moduleId); if (pane) { pane.classList.add("rspace-tab-pane--active"); const storedTitle = pane.dataset.pageTitle; if (storedTitle) document.title = storedTitle; } this.currentModuleId = moduleId; this.updateShellState(moduleId); this.updateCanvasLayout(moduleId); } /** Hide all panes */ private hideAllPanes(): void { const app = document.getElementById("app"); if (!app) return; app.querySelectorAll(".rspace-tab-pane").forEach((p) => { p.classList.remove("rspace-tab-pane--active"); }); } /** Update app-switcher and tab-bar to reflect current module */ private updateShellState(moduleId: string): void { const appSwitcher = document.querySelector("rstack-app-switcher"); if (appSwitcher) appSwitcher.setAttribute("current", moduleId); const tabBar = (window as any).__rspaceTabBar; if (tabBar) tabBar.setAttribute("active", "layer-" + moduleId); } /** Toggle canvas-layout class on #app */ private updateCanvasLayout(moduleId: string): void { const app = document.getElementById("app"); if (!app) return; if (moduleId === "canvas") { app.classList.add("canvas-layout"); } else { app.classList.remove("canvas-layout"); } } /** Push new URL to browser history */ private updateUrl(moduleId: string): void { const navUrl = (window as any).__rspaceNavUrl; if (!navUrl) return; const url: string = navUrl(this.spaceSlug, moduleId); history.pushState( { moduleId, spaceSlug: this.spaceSlug }, "", url, ); } }