Files
kandev/apps/web/lib/layout/panel-portal-manager.ts
T

215 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* PanelPortalManager — singleton registry for persistent dockview panel portals.
*
* When dockview calls `api.fromJSON()` during layout switches, all React panel
* components are unmounted and remounted. This destroys expensive state such as
* xterm WebSocket connections, iframes, and editor instances.
*
* The portal manager lifts panel content out of dockview's lifecycle:
* – Each panel gets a persistent `HTMLDivElement` (the "portal element") that
* lives in a hidden host outside the dockview tree.
* – Dockview panel wrappers adopt the portal element into their own DOM on mount
* and release it on unmount — without destroying it.
* – The actual React component tree is rendered into the portal element via
* `createPortal`, so component state, effects, and DOM survive layout switches.
*
* ## Session scoping
*
* Portals are either **global** or **session-scoped**:
*
* - **Global** (`sessionId` is `undefined`) — the portal persists across session
* switches. Used for panels that read `activeSessionId` reactively from the
* store and automatically show the correct data for whichever session is active.
* Examples: sidebar, chat, terminal, changes, files, plan.
* Note: terminal, changes, and files use `useEnvironmentSessionId()` to stay
* stable across same-environment session switches.
*
* - **Session-scoped** (`sessionId` is set) — the portal is bound to the session
* that created it and is destroyed via `releaseBySession()` when the user
* switches away. Used for panels whose content is intrinsically tied to a
* specific session's runtime state (container processes, worktree files, etc.)
* and cannot simply re-read a store selector to switch context.
* Examples: file-editor, browser, vscode, commit-detail, diff-viewer, pr-detail.
*
* See `SESSION_SCOPED_COMPONENTS` in `dockview-desktop-layout.tsx` for the
* authoritative list and per-component rationale.
*/
import type { DockviewPanelApi } from "dockview-react";
export type PortalEntry = {
/** The persistent DOM element that React portals into. */
element: HTMLDivElement;
/** The dockview component name (e.g. "terminal", "chat"). */
component: string;
/** Latest panel params from dockview. */
params: Record<string, unknown>;
/** Latest dockview panel API handle — updated on each remount. */
api: DockviewPanelApi | null;
/** Session ID this portal is scoped to (undefined = global, persists across sessions). */
sessionId?: string;
};
type Listener = () => void;
export class PanelPortalManager {
private entries = new Map<string, PortalEntry>();
private listeners = new Set<Listener>();
private version = 0;
/** Monotonic counter bumped on every change — used as a render key by consumers. */
getVersion(): number {
return this.version;
}
/**
* Update a panel's params in place. Triggered by `api.onDidParametersChange`
* after `updateParameters` is called on an existing panel (preview tabs).
*/
updateParams(panelId: string, params: Record<string, unknown>): void {
const entry = this.entries.get(panelId);
if (!entry) return;
entry.params = params;
this.version++;
this.notify();
}
/**
* Get or create the portal entry for a panel.
* Called by the dockview slot wrapper on mount.
*/
acquire(
panelId: string,
component: string,
params: Record<string, unknown>,
api: DockviewPanelApi,
sessionId?: string,
): PortalEntry {
let entry = this.entries.get(panelId);
if (!entry) {
const el = document.createElement("div");
el.style.display = "contents";
el.dataset.portalPanel = panelId;
entry = { element: el, component, params, api, sessionId };
this.entries.set(panelId, entry);
this.version++;
this.notify();
} else {
// Panel remounted after fromJSON — update api & params
entry.api = api;
entry.params = params;
entry.component = component;
}
return entry;
}
/** Remove a panel's portal (permanent deletion, e.g. user closes tab). */
release(panelId: string): void {
const entry = this.entries.get(panelId);
if (!entry) return;
entry.element.remove();
entry.api = null;
this.entries.delete(panelId);
this.version++;
this.notify();
}
/** Release all portals scoped to a specific session. */
releaseBySession(sessionId: string): void {
const toRemove: string[] = [];
for (const [panelId, entry] of this.entries) {
if (entry.sessionId === sessionId) {
toRemove.push(panelId);
}
}
if (toRemove.length === 0) return;
for (const panelId of toRemove) {
const entry = this.entries.get(panelId)!;
entry.element.remove();
entry.api = null;
this.entries.delete(panelId);
}
this.version++;
this.notify();
}
/**
* Release portals whose panel no longer exists in dockview.
* Call after fast-path session switches where `isRestoringLayout` blocked
* the normal `onDidRemovePanel` cleanup.
*/
reconcile(livePanelIds: Set<string>): void {
const toRemove: string[] = [];
for (const panelId of this.entries.keys()) {
if (!livePanelIds.has(panelId)) {
toRemove.push(panelId);
}
}
if (toRemove.length === 0) return;
for (const panelId of toRemove) {
const entry = this.entries.get(panelId)!;
entry.element.remove();
entry.api = null;
this.entries.delete(panelId);
}
this.version++;
this.notify();
}
/** Release all portals (e.g. when the layout component unmounts entirely). */
releaseAll(): void {
if (this.entries.size === 0) return;
for (const entry of this.entries.values()) {
entry.element.remove();
entry.api = null;
}
this.entries.clear();
this.version++;
this.notify();
}
/** Check if a portal exists for a panel. */
has(panelId: string): boolean {
return this.entries.has(panelId);
}
/** Get a specific entry. */
get(panelId: string): PortalEntry | undefined {
return this.entries.get(panelId);
}
/** All registered panel IDs. */
ids(): string[] {
return Array.from(this.entries.keys());
}
/** Iterate all registered portals (used by the host to render). */
getAll(): Map<string, PortalEntry> {
return this.entries;
}
/** Subscribe to entry additions/removals. Returns unsubscribe fn. */
subscribe(listener: Listener): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify(): void {
for (const fn of this.listeners) fn();
}
}
/** App-wide singleton. */
export const panelPortalManager = new PanelPortalManager();
/**
* Set the dockview tab title for a panel managed by the portal system.
* Safe to call even when the panel's api isn't available yet.
*/
export function setPanelTitle(panelId: string, title: string): void {
const entry = panelPortalManager.get(panelId);
if (entry?.api && entry.api.title !== title) {
entry.api.setTitle(title);
}
}