ci / rust (push) Successful in 5m46s
Render the content pane as tabs of nested split trees, each session leaf a live attach mounted through the multi-mount pool: - #30 arbitrarily-nested H/V splits with mouse-resizable dividers; per-pane ⏻/▥/▤/✕ tools; closing a pane collapses its parent split. - #31 tabs, each holding its own tree; add / switch / close. - #32 drag-to-dock: drag a pane title or a sidebar session onto a pane for a 5-zone dock (edges split, centre swaps/replaces); a sidebar drag moves an already-open session. - #33 focused pane at full strength, others dim; sidebar shows focused (loud) vs open-elsewhere (quiet) with a stable per-session colour mapping rows to panes. Opening a session fills the focused pane (single-pane parity by default); multi-pane is opt-in via split / drag / tabs. Panes auto-close when their session ends (guarded against eviction and intentional close). The steady "connected" label is hidden as noise. Closes #30, #31, #32, #33. Part of epic #37. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
172 lines
5.6 KiB
TypeScript
172 lines
5.6 KiB
TypeScript
import {
|
|
createAttachConnection,
|
|
type AttachConnection,
|
|
type AttachInfo,
|
|
} from "./attach.js";
|
|
import 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;
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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<Uuid, AttachConnection>();
|
|
/** Sessions currently mounted into a visible pane (never evicted). */
|
|
private readonly mounted = new Set<Uuid>();
|
|
private readonly hidden: HTMLElement;
|
|
private focusedId: Uuid | null = null;
|
|
private readonly opts: PoolOptions;
|
|
|
|
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);
|
|
}
|
|
|
|
/** 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): void {
|
|
let conn = this.cache.get(id);
|
|
if (conn) {
|
|
this.cache.delete(id);
|
|
this.cache.set(id, conn);
|
|
} else {
|
|
conn = createAttachConnection(
|
|
id,
|
|
{
|
|
onRequestClose: (sid) => this.opts.onRequestClose?.(sid),
|
|
onClosed: (sid) => this.opts.onClosed?.(sid),
|
|
},
|
|
info,
|
|
);
|
|
this.cache.set(id, conn);
|
|
}
|
|
if (conn.el.parentElement !== pane) {
|
|
pane.replaceChildren(conn.el);
|
|
conn.onMount();
|
|
} else if (!this.mounted.has(id)) {
|
|
conn.onMount();
|
|
}
|
|
this.mounted.add(id);
|
|
this.evict();
|
|
}
|
|
|
|
/** 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.
|
|
*/
|
|
setVisible(entries: { id: Uuid; pane: HTMLElement; info?: AttachInfo }[]): 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);
|
|
}
|
|
|
|
setFocus(id: Uuid | null): void {
|
|
if (id === this.focusedId) return;
|
|
this.focusedId = id;
|
|
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);
|
|
}
|
|
|
|
/** 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.mounted.delete(id);
|
|
if (this.focusedId === id) this.setFocus(null);
|
|
}
|
|
|
|
dispose(): void {
|
|
for (const conn of this.cache.values()) conn.dispose();
|
|
this.cache.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);
|
|
}
|
|
}
|
|
}
|