import { createAttachConnection } from "./attach.js"; import type { AttachInfo } from "./paneTitle.js"; import type { PaneConnection } from "./pane.js"; import { createStructuredConnection } from "./structured.js"; import { setSessionFocus } from "./api.js"; import type { CatalogState, CatalogStore } from "./events.js"; import { sessionName, type SessionView, type Uuid } from "./types.js"; /** * How many *parked* (off-screen but still-connected) sessions to keep warm * beyond whatever is currently visible. Visible panes are never counted or * evicted — only this background buffer is bounded, so the browser doesn't hold * an unbounded fan of open sockets. Switching back to a recently-viewed session * is instant while it's still parked; older ones are evicted (WS closed) once * the buffer overflows. */ const MAX_PARKED = 6; /** A session's view defaults to `"Terminal"` wherever it isn't carried — * matching the wire default (`SessionView`'s `#[default]`, types.ts). Only * meaningful once the session itself is known; an altogether-unknown * session (no `AttachInfo`/`Session` at all) is a distinct, un-resolved * state callers must check for separately, not "Terminal". */ function resolveView(view: SessionView | undefined): SessionView { return view ?? "Terminal"; } export interface PoolOptions { /** Fired when the focused session changes (drives sidebar highlight + HUD). */ onFocusChange?: (id: Uuid | null) => void; /** Fired when a view's explicit disconnect control is used; the embedder * decides how to update the layout/route (then typically calls `close`). */ onRequestClose?: (id: Uuid) => void; /** Fired when a connection's socket closes on its own (session ended/dropped), * not via an intentional close/eviction. The embedder closes the pane. */ onClosed?: (id: Uuid) => void; /** Forwarded to each connection: should a dropped socket be retried? */ shouldReconnect?: (id: Uuid, code: number) => boolean; /** Threaded through to a structured connection so its rail can read live * worktree/git-status data (#140). Terminal connections don't need it. */ store?: CatalogStore; } /** * Owns the set of live attach connections, decoupled from the router/UI. * * Unlike the old single-active pool, several connections can be **mounted** * (visible) at once — one per leaf of the active tab's layout tree. The shell * reconciles the visible set with {@link setVisible}; sessions no longer shown * are **parked** in a hidden cache (still streaming) and bounded by an LRU. A * connection only dies on eviction, an explicit {@link close}, or {@link dispose}. */ export class ConnectionPool { /** All live connections. Insertion order doubles as LRU: oldest first. */ private readonly cache = new Map(); /** The view each cached connection was actually created for — a deep link * or a restored layout can `mount()` a session before the catalog * snapshot names its view, defaulting it to `"Terminal"`; this is what * lets a later reconciliation notice the resolved view disagrees (#140 * review). */ private readonly connViews = new Map(); /** Sessions currently mounted into a visible pane (never evicted). */ private readonly mounted = new Set(); private readonly hidden: HTMLElement; private focusedId: Uuid | null = null; private readonly opts: PoolOptions; private readonly unsubscribeStore?: () => void; constructor(opts: PoolOptions = {}) { this.opts = opts; // Off-screen home for parked, still-connected terminals. this.hidden = document.createElement("div"); this.hidden.className = "conn-cache"; this.hidden.setAttribute("aria-hidden", "true"); document.body.appendChild(this.hidden); // A session `mount()`ed before its catalog entry exists gets a // `"Terminal"` connection by default; once the snapshot actually names // the view, fix any connection that guessed wrong — nothing else // re-`mount()`s an already-open pane just because the catalog updated. this.unsubscribeStore = opts.store?.subscribe((state) => this.reconcileViews(state)); } /** The focused session, or null when nothing is focused (welcome pane). */ focused(): Uuid | null { return this.focusedId; } /** Back-compat alias for {@link focused} (used by the debug HUD). */ active(): Uuid | null { return this.focusedId; } isMounted(id: Uuid): boolean { return this.mounted.has(id); } /** * Ensure `id` is live and shown inside `pane`, reusing its connection if one * exists (refreshing LRU recency). Does not touch other mounted panes. */ mount(id: Uuid, pane: HTMLElement, info?: AttachInfo, opts?: { focus?: boolean }): void { let conn = this.cache.get(id); if (conn) { // `info === undefined` means "session not known yet", not "Terminal" — // never downgrade a resolved connection just because a later caller // couldn't resolve the session. Only a *resolved* view that disagrees // with what this connection was built for forces a replace. if (info !== undefined && resolveView(info.view) !== this.connViews.get(id)) { conn = this.replace(id, info); } else { this.cache.delete(id); this.cache.set(id, conn); // LRU refresh if (info) conn.updateInfo(info); } } else { conn = this.create(id, info); this.cache.set(id, conn); } if (conn.el.parentElement !== pane) { pane.replaceChildren(conn.el); conn.onMount(opts); } else if (!this.mounted.has(id)) { conn.onMount(opts); } this.mounted.add(id); this.evict(); } private create(id: Uuid, info?: AttachInfo): PaneConnection { const view = resolveView(info?.view); this.connViews.set(id, view); const connOpts = { onRequestClose: (sid: Uuid) => this.opts.onRequestClose?.(sid), onClosed: (sid: Uuid) => this.opts.onClosed?.(sid), shouldReconnect: (sid: Uuid, code: number) => this.opts.shouldReconnect?.(sid, code) ?? false, }; return view === "Structured" ? createStructuredConnection(id, connOpts, info, this.opts.store) : createAttachConnection(id, connOpts, info); } /** Dispose whatever is cached for `id` and cache a freshly created * connection in its place. Does not touch the DOM or `mounted` — callers * place the new element themselves. */ private replace(id: Uuid, info?: AttachInfo): PaneConnection { this.cache.get(id)?.dispose(); const fresh = this.create(id, info); this.cache.set(id, fresh); return fresh; } /** Proactively fix any cached connection whose view no longer matches the * catalog — the only path that repairs a pane nothing will `mount()` * again on its own (parked, or mounted but not currently being * re-rendered by anything else). A session absent from `state` (not yet * known, or removed) is left alone — that's `main.ts`'s * `closePane`/eviction concern, not a view mismatch. */ private reconcileViews(state: CatalogState): void { for (const id of [...this.cache.keys()]) { const session = state.sessions.get(id); if (session === undefined || resolveView(session.view) === this.connViews.get(id)) continue; const existing = this.cache.get(id)!; const parent = existing.el.parentElement; const wasMounted = this.mounted.has(id); // Capture before anything below touches the DOM: dispose()/el.remove() // detach `existing.el`, and once a focused descendant is detached the // browser moves `document.activeElement` to `` — too late to // tell whether the keyboard was actually in this pane. const hadDomFocus = existing.el.contains(document.activeElement); const info: AttachInfo = { name: sessionName(session), command: session.command, view: session.view }; const fresh = this.replace(id, info); existing.el.remove(); // don't rely on dispose() to clear the DOM if (parent && parent !== this.hidden) { parent.replaceChildren(fresh.el); } else { // Parked: `this.hidden` holds every parked connection's element as // siblings — append, never replaceChildren, or they'd all vanish. this.hidden.appendChild(fresh.el); } if (wasMounted) { // `focusedId` names the session the pool was last told to focus — // but a same-tab sidebar preview moves the keyboard into the // sidebar without clearing it (multiview.ts skips `setFocus` while // previewing), so `focusedId` alone can point at a pane the // keyboard already left. Only re-focus when the DOM agrees it // never did. fresh.onMount({ focus: this.focusedId === id && hadDomFocus }); } else { fresh.onUnmount(); } } } /** Park `id` back into the hidden cache (still connected) and stop showing it. */ unmount(id: Uuid): void { if (!this.mounted.delete(id)) return; const conn = this.cache.get(id); if (conn) { this.hidden.appendChild(conn.el); // stays alive, just off-screen conn.onUnmount(); } } /** * Reconcile the visible set to exactly `entries` (in render order): park any * mounted session not listed, mount/refresh each listed one in its pane. * `focus: false` shows the panes without taking the keyboard (preview). */ setVisible( entries: { id: Uuid; pane: HTMLElement; info?: AttachInfo }[], opts?: { focus?: boolean }, ): void { const next = new Set(entries.map((e) => e.id)); for (const id of [...this.mounted]) { if (!next.has(id)) this.unmount(id); } for (const e of entries) this.mount(e.id, e.pane, e.info, opts); } /** Also tells the daemon which pane is focused (docs/SIDEBAR.md's * `AwaitingInput` auto-clear) — the sidebar highlight never calls this, * only a pane actually becoming the visible view. Best-effort: a failed * report only delays the glow clearing. */ setFocus(id: Uuid | null): void { if (id === this.focusedId) return; const previous = this.focusedId; this.focusedId = id; if (previous !== null) void setSessionFocus(previous, false).catch(() => {}); if (id !== null) void setSessionFocus(id, true).catch(() => {}); this.opts.onFocusChange?.(id); } /** Compat shim: show `id` alone in `pane` and focus it; park everything else. */ activate(id: Uuid, pane: HTMLElement, info?: AttachInfo): void { this.setVisible([{ id, pane, info }]); this.setFocus(id); } /** Compat shim: park all visible panes and clear focus (e.g. welcome pane). */ parkActive(): void { for (const id of [...this.mounted]) this.unmount(id); this.setFocus(null); } /** Put the keyboard back into the focused session's terminal. No-op when * nothing is focused (welcome pane) or its connection is gone. */ focusTerminal(): void { if (this.focusedId === null) return; this.cache.get(this.focusedId)?.focusTerminal(); } /** Type raw bytes into the focused session's PTY. Returns false when there is * nothing focused, or its connection is gone — the leader's literal Ctrl-K * is a no-op over the welcome pane rather than an error * (docs/COMMAND-BAR.md). */ sendToFocused(data: string): boolean { if (this.focusedId === null) return false; const conn = this.cache.get(this.focusedId); if (!conn) return false; conn.sendInput(data); return true; } /** Fully disconnect + forget a session. */ close(id: Uuid): void { const conn = this.cache.get(id); if (!conn) return; conn.dispose(); this.cache.delete(id); this.connViews.delete(id); this.mounted.delete(id); if (this.focusedId === id) this.setFocus(null); } dispose(): void { this.unsubscribeStore?.(); for (const conn of this.cache.values()) conn.dispose(); this.cache.clear(); this.connViews.clear(); this.mounted.clear(); this.hidden.remove(); } /** Drop least-recently-viewed *parked* connections beyond the buffer cap. */ private evict(): void { while (this.cache.size - this.mounted.size > MAX_PARKED) { let victim: Uuid | null = null; for (const id of this.cache.keys()) { if (!this.mounted.has(id)) { victim = id; break; } } if (victim === null) break; this.close(victim); } } }