Deletes the vanilla shell and mounts App.tsx on #root. main.ts and the 23 views it drove are gone; App.tsx composes the ported components — sidebar, multi-view, command bar, spawn modal, help and restart overlays, daemon link, drawer — around the catalog and layout stores. The pure parts of four deleted modules moved next to the components that still use them: the spawn-form model and spawnSimilar into components/spawn, the transcript's markdown/format helpers and the diff into components/structured, the help overlay's grouping into components/dialogs. Document-level event names now live in uiEvents.ts. Deduplicated two pairs the migration left side by side: the sidebar uses dialogs/KillDialog and common/ContextMenu. It also listens for the command bar's kill/rename events, which nothing had picked up since catalog.ts went away. styles.css stays as the token and base layer, with `#app` renamed to `#root` and the pane-body rules re-keyed off the classes the React panes actually carry. Closes #253
367 lines
12 KiB
TypeScript
367 lines
12 KiB
TypeScript
// React port of ../../attach.ts: an xterm terminal bound to the daemon's
|
|
// attach WebSocket, plus ../../webglRenderer.ts's shared GPU-context budget
|
|
// and ../../reconnect.ts's backoff schedule. Pane chrome (title, disconnect
|
|
// button, sessions drawer) stays out of scope here — this is the thin
|
|
// ref-based xterm wrapper the issue asked for; a header composes around it.
|
|
import { FitAddon } from "@xterm/addon-fit";
|
|
import { WebglAddon } from "@xterm/addon-webgl";
|
|
import { Terminal } from "@xterm/xterm";
|
|
import "@xterm/xterm/css/xterm.css";
|
|
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
|
|
|
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "../../reconnect.js";
|
|
import { attachWsUrl } from "../../routes.js";
|
|
import type { ClientControl, Uuid } from "../../types.js";
|
|
import { WebglPolicy } from "../../webglRenderer.js";
|
|
import { KeyBar, useKeyBarModifiers } from "./KeyBar.js";
|
|
|
|
/** One budget for the whole page — every mounted `TerminalPane` shares it, the
|
|
* same reasoning as attach.ts's own module-level policy. */
|
|
const webglPolicy = new WebglPolicy();
|
|
|
|
export type TerminalStatus = "connecting" | "open" | "reconnecting" | "closed" | "error";
|
|
|
|
/** Application-defined close codes (daemon rejection) and the clean-close
|
|
* code — retrying either would only loop. Mirrors reconnect.ts's
|
|
* `shouldRetryClose`, minus the catalog lookup a standalone pane doesn't have. */
|
|
function defaultShouldReconnect(code: number): boolean {
|
|
return code < 4000 && code !== 1000;
|
|
}
|
|
|
|
export interface TerminalPaneProps {
|
|
sessionId: Uuid;
|
|
/** Grabs the keyboard once the socket opens. A sidebar preview passes `false`. */
|
|
focus?: boolean;
|
|
/** Asked on every close: should the socket be retried? */
|
|
shouldReconnect?: (code: number) => boolean;
|
|
/** The socket closed for good (retry declined, or the daemon rejected the
|
|
* session) — not called on an ordinary unmount. */
|
|
onClosed?: () => void;
|
|
/** Connection status changes, for a header/status badge to render. */
|
|
onStatusChange?: (status: TerminalStatus, detail?: string) => void;
|
|
className?: string;
|
|
}
|
|
|
|
export interface TerminalPaneHandle {
|
|
focusTerminal(): void;
|
|
sendInput(data: string): void;
|
|
}
|
|
|
|
export const TerminalPane = forwardRef<TerminalPaneHandle, TerminalPaneProps>(
|
|
function TerminalPane(
|
|
{ sessionId, focus = true, shouldReconnect = defaultShouldReconnect, onClosed, onStatusChange, className },
|
|
ref,
|
|
) {
|
|
const bodyRef = useRef<HTMLDivElement | null>(null);
|
|
const sendInputRef = useRef<(data: string) => void>(() => {});
|
|
const focusTerminalRef = useRef<() => void>(() => {});
|
|
const keyBar = useKeyBarModifiers();
|
|
const [keybarActive, setKeybarActive] = useState(false);
|
|
|
|
useImperativeHandle(
|
|
ref,
|
|
() => ({
|
|
focusTerminal: () => focusTerminalRef.current(),
|
|
sendInput: (data: string) => sendInputRef.current(data),
|
|
}),
|
|
[],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const body = bodyRef.current;
|
|
if (!body) return;
|
|
|
|
const term = new Terminal({
|
|
cursorBlink: true,
|
|
fontFamily:
|
|
'"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
|
fontSize: 13,
|
|
convertEol: false,
|
|
theme: {
|
|
background: "#121419",
|
|
foreground: "#e6e8ec",
|
|
cursor: "#5fe3a1",
|
|
cursorAccent: "#121419",
|
|
selectionBackground: "#2b6b4f",
|
|
},
|
|
scrollback: 5000,
|
|
allowProposedApi: true,
|
|
});
|
|
const fit = new FitAddon();
|
|
term.loadAddon(fit);
|
|
term.open(body);
|
|
|
|
let webgl: WebglAddon | null = null;
|
|
let dprMedia: MediaQueryList | null = null;
|
|
|
|
const onDprChange = () => {
|
|
try {
|
|
term.clearTextureAtlas();
|
|
} catch {
|
|
// ignore — nothing to rebuild if the renderer already went away
|
|
}
|
|
watchDpr();
|
|
};
|
|
|
|
function watchDpr(): void {
|
|
dprMedia?.removeEventListener("change", onDprChange);
|
|
dprMedia = null;
|
|
try {
|
|
dprMedia = window.matchMedia?.(`(resolution: ${window.devicePixelRatio}dppx)`) ?? null;
|
|
dprMedia?.addEventListener("change", onDprChange);
|
|
} catch {
|
|
// ignore — a browser without `resolution` media queries just keeps
|
|
// the atlas it has
|
|
}
|
|
}
|
|
|
|
function releaseWebgl(): void {
|
|
dprMedia?.removeEventListener("change", onDprChange);
|
|
dprMedia = null;
|
|
if (!webgl) return;
|
|
const addon = webgl;
|
|
webgl = null;
|
|
try {
|
|
addon.dispose();
|
|
} catch {
|
|
// ignore — a lost context can throw on the way out
|
|
}
|
|
}
|
|
|
|
function acquireWebgl(): void {
|
|
if (webgl || disposed || !webglPolicy.enabled()) return;
|
|
try {
|
|
const addon = new WebglAddon();
|
|
addon.onContextLoss(() => {
|
|
webglPolicy.noteContextLoss();
|
|
releaseWebgl();
|
|
if (mounted) acquireWebgl();
|
|
});
|
|
term.loadAddon(addon);
|
|
webgl = addon;
|
|
watchDpr();
|
|
} catch {
|
|
webglPolicy.noteContextLoss();
|
|
webgl = null;
|
|
}
|
|
}
|
|
|
|
let mounted = false;
|
|
const tryFit = () => {
|
|
if (!mounted) return;
|
|
try {
|
|
const el2 = term.element;
|
|
const parent = el2?.parentElement;
|
|
const core = (
|
|
term as unknown as {
|
|
_core?: { _renderService?: { dimensions?: { css?: { cell?: { width: number; height: number } } } } };
|
|
}
|
|
)._core;
|
|
const cell = core?._renderService?.dimensions?.css?.cell;
|
|
if (!el2 || !parent || !cell || !cell.width || !cell.height) {
|
|
fit.fit();
|
|
return;
|
|
}
|
|
const vp = el2.querySelector(".xterm-viewport") as HTMLElement | null;
|
|
let availW: number;
|
|
let availH: number;
|
|
if (vp && vp.clientWidth > 0) {
|
|
availW = vp.clientWidth;
|
|
availH = vp.clientHeight;
|
|
} else {
|
|
const ps = getComputedStyle(parent);
|
|
const ts = getComputedStyle(el2);
|
|
availW = parseInt(ps.width) - (parseInt(ts.paddingLeft) + parseInt(ts.paddingRight) || 0);
|
|
availH = parseInt(ps.height) - (parseInt(ts.paddingTop) + parseInt(ts.paddingBottom) || 0);
|
|
}
|
|
const cols = Math.max(2, Math.floor(availW / cell.width));
|
|
const rows = Math.max(1, Math.floor(availH / cell.height));
|
|
if (cols !== term.cols || rows !== term.rows) term.resize(cols, rows);
|
|
} catch {
|
|
try {
|
|
fit.fit();
|
|
} catch {
|
|
// ignore — fit fails if the container is detached or zero-sized
|
|
}
|
|
}
|
|
};
|
|
|
|
let ws: WebSocket;
|
|
let reconnectMs = INITIAL_RECONNECT_MS;
|
|
let reconnectTimer: number | null = null;
|
|
let awaitingReplay = true;
|
|
let disposed = false;
|
|
|
|
const sendControl = (msg: ClientControl) => {
|
|
if (ws.readyState !== WebSocket.OPEN) return;
|
|
ws.send(JSON.stringify(msg));
|
|
};
|
|
|
|
const sendInput = (data: string) => {
|
|
if (ws.readyState !== WebSocket.OPEN) return;
|
|
ws.send(new TextEncoder().encode(data));
|
|
};
|
|
sendInputRef.current = sendInput;
|
|
focusTerminalRef.current = () => term.focus();
|
|
|
|
const onResize = () => {
|
|
tryFit();
|
|
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
|
};
|
|
|
|
function connect(): void {
|
|
awaitingReplay = true;
|
|
ws = new WebSocket(attachWsUrl(sessionId));
|
|
ws.binaryType = "arraybuffer";
|
|
|
|
ws.onopen = () => {
|
|
reconnectMs = INITIAL_RECONNECT_MS;
|
|
onStatusChange?.("open");
|
|
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
|
if (mounted && focus) term.focus();
|
|
};
|
|
|
|
ws.onmessage = (ev) => {
|
|
if (typeof ev.data === "string") {
|
|
try {
|
|
JSON.parse(ev.data) as ClientControl;
|
|
} catch {
|
|
// ignore unknown control frames
|
|
}
|
|
return;
|
|
}
|
|
if (ev.data instanceof ArrayBuffer) {
|
|
const replay = awaitingReplay;
|
|
awaitingReplay = false;
|
|
term.write(new Uint8Array(ev.data), () => {
|
|
if (replay) term.scrollToBottom();
|
|
});
|
|
} else if (ev.data instanceof Blob) {
|
|
void ev.data.arrayBuffer().then((b) => {
|
|
const replay = awaitingReplay;
|
|
awaitingReplay = false;
|
|
term.write(new Uint8Array(b), () => {
|
|
if (replay) term.scrollToBottom();
|
|
});
|
|
});
|
|
}
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
onStatusChange?.("error");
|
|
};
|
|
|
|
ws.onclose = (ev) => {
|
|
const reason = ev.reason?.trim();
|
|
if (ev.code >= 4000 && reason && !disposed) {
|
|
onStatusChange?.("error", reason);
|
|
term.write(`\r\n\x1b[31m⚠ cannot attach: ${reason}\x1b[0m\r\n`);
|
|
return;
|
|
}
|
|
if (disposed) return;
|
|
|
|
if (shouldReconnect(ev.code)) {
|
|
const delay = Math.min(reconnectMs, MAX_RECONNECT_MS);
|
|
onStatusChange?.("reconnecting");
|
|
reconnectTimer = window.setTimeout(() => {
|
|
reconnectTimer = null;
|
|
reconnectMs = Math.min(reconnectMs * 2, MAX_RECONNECT_MS);
|
|
connect();
|
|
}, delay);
|
|
return;
|
|
}
|
|
|
|
onStatusChange?.("closed", String(ev.code));
|
|
onClosed?.();
|
|
};
|
|
}
|
|
|
|
onStatusChange?.("connecting");
|
|
connect();
|
|
|
|
const dataDisp = term.onData((data) => {
|
|
sendInput(keyBar.consume(data));
|
|
});
|
|
const resizeDisp = term.onResize(({ cols, rows }) => {
|
|
sendControl({ type: "Resize", cols, rows });
|
|
});
|
|
const selectionDisp = term.onSelectionChange(() => {
|
|
const text = term.getSelection();
|
|
if (text) void navigator.clipboard?.writeText(text).catch(() => {});
|
|
});
|
|
|
|
const ta = term.textarea;
|
|
const onTaFocus = () => {
|
|
setKeybarActive(true);
|
|
requestAnimationFrame(tryFit);
|
|
};
|
|
const onTaBlur = () => {
|
|
setKeybarActive(false);
|
|
requestAnimationFrame(tryFit);
|
|
};
|
|
ta?.addEventListener("focus", onTaFocus);
|
|
ta?.addEventListener("blur", onTaBlur);
|
|
|
|
window.addEventListener("resize", onResize);
|
|
const visual = window.visualViewport ?? null;
|
|
visual?.addEventListener("resize", onResize);
|
|
|
|
const ro = new ResizeObserver(() => tryFit());
|
|
ro.observe(body);
|
|
document.fonts?.ready?.then(() => tryFit()).catch(() => {});
|
|
|
|
mounted = true;
|
|
acquireWebgl();
|
|
requestAnimationFrame(() => {
|
|
tryFit();
|
|
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
|
if (focus && ws.readyState === WebSocket.OPEN) term.focus();
|
|
});
|
|
|
|
return () => {
|
|
disposed = true;
|
|
mounted = false;
|
|
if (reconnectTimer !== null) {
|
|
window.clearTimeout(reconnectTimer);
|
|
reconnectTimer = null;
|
|
}
|
|
ro.disconnect();
|
|
window.removeEventListener("resize", onResize);
|
|
visual?.removeEventListener("resize", onResize);
|
|
ta?.removeEventListener("focus", onTaFocus);
|
|
ta?.removeEventListener("blur", onTaBlur);
|
|
dataDisp.dispose();
|
|
resizeDisp.dispose();
|
|
selectionDisp.dispose();
|
|
releaseWebgl();
|
|
try {
|
|
ws.close();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
term.dispose();
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- reconnecting
|
|
// a session change is a fresh connection, not a prop-driven re-render
|
|
}, [sessionId]);
|
|
|
|
return (
|
|
<div className={className ? `flex h-full flex-col ${className}` : "flex h-full flex-col"}>
|
|
{/* `attach-body` is what styles.css keys the terminal's own sizing on
|
|
(`.attach-body .xterm { width/height: 100% }`) plus the hidden
|
|
scrollbar and touch-scroll rules. Without it the terminal measures
|
|
its own stale viewport and never grows back to the pane. */}
|
|
<div ref={bodyRef} className="attach-body min-h-0 flex-1" />
|
|
{keybarActive && (
|
|
<KeyBar
|
|
armed={keyBar.armed}
|
|
onToggleMod={(mod) => keyBar.setMod(mod, !keyBar.armed[mod])}
|
|
onClearMods={keyBar.clearMods}
|
|
onSend={(data) => sendInputRef.current(data)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
);
|