/** * Client-side tab cache for instant tab and space 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. * * Pane keys are `${spaceSlug}:${moduleId}` so panes from different spaces * coexist in the DOM. On space switch, current space's panes are hidden and * the target space's panes are shown (or fetched on first visit). * * 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; } /** Current space slug */ get currentSpace(): string { return this.spaceSlug; } /** Current module ID */ get currentModule(): string { return this.currentModuleId; } /** Build the composite pane key */ private paneKey(space: string, moduleId: string): string { return `${space}:${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; const key = this.paneKey(this.spaceSlug, this.currentModuleId); // 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.spaceSlug = this.spaceSlug; pane.dataset.pageTitle = document.title; while (app.firstChild) { pane.appendChild(app.firstChild); } app.appendChild(pane); this.panes.set(key, 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) { window.location.reload(); return; } const stateSpace = state.spaceSlug || this.spaceSlug; const key = this.paneKey(stateSpace, state.moduleId); if (this.panes.has(key)) { if (stateSpace !== this.spaceSlug) { this.hideAllPanes(); this.spaceSlug = stateSpace; } this.showPane(stateSpace, state.moduleId); } else { window.location.reload(); } }); return true; } /** Switch to a module tab within the current space. Returns true if handled client-side. */ async switchTo(moduleId: string): Promise { const key = this.paneKey(this.spaceSlug, moduleId); if (moduleId === this.currentModuleId && this.panes.has(key)) return true; if (this.panes.has(key)) { this.showPane(this.spaceSlug, moduleId); this.updateUrl(this.spaceSlug, moduleId); return true; } return this.fetchAndInject(this.spaceSlug, moduleId); } /** * Switch to a different space, loading a module within it. * Hides all panes from the current space, shows/fetches the target space's pane. * Returns true if handled client-side. */ async switchSpace(newSpace: string, moduleId: string): Promise { if (newSpace === this.spaceSlug && moduleId === this.currentModuleId) return true; const key = this.paneKey(newSpace, moduleId); // Hide all current panes this.hideAllPanes(); // Update active space const oldSpace = this.spaceSlug; this.spaceSlug = newSpace; if (this.panes.has(key)) { this.showPane(newSpace, moduleId); this.updateUrl(newSpace, moduleId); this.updateSpaceChrome(newSpace); return true; } const ok = await this.fetchAndInject(newSpace, moduleId); if (ok) { this.updateSpaceChrome(newSpace); return true; } // Rollback on failure this.spaceSlug = oldSpace; return false; } /** Check if a pane exists in the DOM cache */ has(moduleId: string): boolean { return this.panes.has(this.paneKey(this.spaceSlug, moduleId)); } /** Remove a cached pane from the DOM */ removePane(moduleId: string): void { const key = this.paneKey(this.spaceSlug, moduleId); const pane = this.panes.get(key); if (pane) { pane.remove(); this.panes.delete(key); } } /** Fetch a module page, extract content, and inject into a new pane */ private async fetchAndInject(space: string, moduleId: string): Promise { const navUrl = (window as any).__rspaceNavUrl; if (!navUrl) return false; const url: string = navUrl(space, moduleId); // For cross-space fetches, use the path-based API to stay same-origin let fetchUrl = url; try { const resolved = new URL(url, window.location.href); if (resolved.origin !== window.location.origin) { // Rewrite subdomain URL to path format: /{space}/{moduleId} fetchUrl = `/${space}/${moduleId}`; } } 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(fetchUrl); 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(); const key = this.paneKey(space, moduleId); // Create the real pane const pane = document.createElement("div"); pane.className = "rspace-tab-pane rspace-tab-pane--active"; pane.dataset.moduleId = moduleId; pane.dataset.spaceSlug = space; pane.dataset.pageTitle = content.title; pane.innerHTML = content.body; app.appendChild(pane); this.panes.set(key, pane); // Load module-specific assets this.loadAssets(content.scripts, content.styles, content.inlineStyles, `${space}-${moduleId}`); // Update shell state this.currentModuleId = moduleId; document.title = content.title; this.updateShellState(moduleId); this.updateUrl(space, 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