# Conflicts: # crates/maestro-daemon/tests/worktree.rs # crates/maestro-e2e/tests/e2e.rs # web/src/attach.ts # web/src/connections.test.ts # web/src/connections.ts
299 lines
12 KiB
TypeScript
299 lines
12 KiB
TypeScript
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<Uuid, PaneConnection>();
|
|
/** 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<Uuid, SessionView>();
|
|
/** 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;
|
|
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 `<body>` — 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);
|
|
}
|
|
}
|
|
}
|