/** * 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, content.inlineStyles, moduleId); // 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[]; inlineStyles: 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); }); // Extract inline