Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ba83c3d8f |
@@ -0,0 +1,134 @@
|
|||||||
|
// React port of ../../attach.ts's `buildKeyBar` — the Termux-style special-key
|
||||||
|
// bar for the soft keyboard (Esc/Tab/Ctrl/Alt/cursor keys/symbols it hides
|
||||||
|
// behind a page switch). Ctrl/Alt are one-shot sticky modifiers: arming one
|
||||||
|
// folds into the very next key, tapped here or typed on the soft keyboard,
|
||||||
|
// then disarms.
|
||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { applyModsToChar, cursorSeq, tildeSeq } from "../../keys.js";
|
||||||
|
|
||||||
|
type Modifier = "ctrl" | "alt";
|
||||||
|
/** CSI final bytes: arrows A-D, Home `H`, End `F`. */
|
||||||
|
type CursorFinal = "A" | "B" | "C" | "D" | "H" | "F";
|
||||||
|
|
||||||
|
type KeyDef =
|
||||||
|
| { label: string; mod: Modifier }
|
||||||
|
| { label: string; cursor: CursorFinal }
|
||||||
|
| { label: string; tilde: number }
|
||||||
|
| { label: string; char: string }
|
||||||
|
| { label: string; seq: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Row-major into an 8-column / 2-row grid (#7):
|
||||||
|
*
|
||||||
|
* Esc Tab Home ↑ End PgUp - /
|
||||||
|
* Ctrl Alt ← ↓ → PgDn | ~
|
||||||
|
*/
|
||||||
|
const KEY_DEFS: KeyDef[] = [
|
||||||
|
{ label: "Esc", seq: "\x1b" },
|
||||||
|
{ label: "Tab", seq: "\t" },
|
||||||
|
{ label: "Home", cursor: "H" },
|
||||||
|
{ label: "↑", cursor: "A" },
|
||||||
|
{ label: "End", cursor: "F" },
|
||||||
|
{ label: "PgUp", tilde: 5 },
|
||||||
|
{ label: "-", char: "-" },
|
||||||
|
{ label: "/", char: "/" },
|
||||||
|
{ label: "Ctrl", mod: "ctrl" },
|
||||||
|
{ label: "Alt", mod: "alt" },
|
||||||
|
{ label: "←", cursor: "D" },
|
||||||
|
{ label: "↓", cursor: "B" },
|
||||||
|
{ label: "→", cursor: "C" },
|
||||||
|
{ label: "PgDn", tilde: 6 },
|
||||||
|
{ label: "|", char: "|" },
|
||||||
|
{ label: "~", char: "~" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pending Ctrl/Alt state plus `consume`, which folds them into a soft-keyboard
|
||||||
|
* character before it reaches the PTY — used by the terminal's own `onData`
|
||||||
|
* handler, not by this component's own button clicks.
|
||||||
|
*/
|
||||||
|
export function useKeyBarModifiers() {
|
||||||
|
const armedRef = useRef<{ ctrl: boolean; alt: boolean }>({ ctrl: false, alt: false });
|
||||||
|
const [, setTick] = useState(0);
|
||||||
|
const rerender = useCallback(() => setTick((n) => n + 1), []);
|
||||||
|
|
||||||
|
const setMod = useCallback(
|
||||||
|
(mod: Modifier, on: boolean) => {
|
||||||
|
armedRef.current[mod] = on;
|
||||||
|
rerender();
|
||||||
|
},
|
||||||
|
[rerender],
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearMods = useCallback(() => {
|
||||||
|
if (!armedRef.current.ctrl && !armedRef.current.alt) return;
|
||||||
|
armedRef.current = { ctrl: false, alt: false };
|
||||||
|
rerender();
|
||||||
|
}, [rerender]);
|
||||||
|
|
||||||
|
const consume = useCallback((data: string): string => {
|
||||||
|
const { ctrl, alt } = armedRef.current;
|
||||||
|
if ((ctrl || alt) && data.length === 1) {
|
||||||
|
const out = applyModsToChar(data, ctrl, alt);
|
||||||
|
armedRef.current = { ctrl: false, alt: false };
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { armed: armedRef.current, setMod, clearMods, consume };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyBarProps {
|
||||||
|
armed: { ctrl: boolean; alt: boolean };
|
||||||
|
onToggleMod(mod: Modifier): void;
|
||||||
|
onClearMods(): void;
|
||||||
|
onSend(data: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeyBar({ armed, onToggleMod, onClearMods, onSend }: KeyBarProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="toolbar"
|
||||||
|
aria-label="Special keys"
|
||||||
|
className="flex flex-wrap gap-1 border-t border-white/10 bg-black/20 p-1"
|
||||||
|
>
|
||||||
|
{KEY_DEFS.map((def) => {
|
||||||
|
if ("mod" in def) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={def.label}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={armed[def.mod]}
|
||||||
|
aria-label={`${def.label} modifier`}
|
||||||
|
className="min-w-8 rounded px-2 py-1 text-xs aria-pressed:bg-emerald-500/40"
|
||||||
|
onClick={() => onToggleMod(def.mod)}
|
||||||
|
>
|
||||||
|
{def.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const bytes = () => {
|
||||||
|
if ("cursor" in def) return cursorSeq(def.cursor, armed.ctrl, armed.alt);
|
||||||
|
if ("tilde" in def) return tildeSeq(def.tilde, armed.ctrl, armed.alt);
|
||||||
|
if ("char" in def) return applyModsToChar(def.char, armed.ctrl, armed.alt);
|
||||||
|
return def.seq;
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={def.label}
|
||||||
|
type="button"
|
||||||
|
className="min-w-8 rounded px-2 py-1 text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
onSend(bytes());
|
||||||
|
onClearMods();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{def.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { cleanup, render } from "@testing-library/react";
|
||||||
|
import { createRef } from "react";
|
||||||
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "../../reconnect.js";
|
||||||
|
import { attachWsUrl } from "../../routes.js";
|
||||||
|
import type { ClientControl } from "../../types.js";
|
||||||
|
|
||||||
|
class FakeTerminal {
|
||||||
|
static instances: FakeTerminal[] = [];
|
||||||
|
cols = 80;
|
||||||
|
rows = 24;
|
||||||
|
element: HTMLElement | undefined = undefined;
|
||||||
|
textarea: HTMLTextAreaElement | null = null;
|
||||||
|
disposed = false;
|
||||||
|
focus = vi.fn();
|
||||||
|
private dataCb: ((d: string) => void) | null = null;
|
||||||
|
|
||||||
|
constructor(public opts: unknown) {
|
||||||
|
FakeTerminal.instances.push(this);
|
||||||
|
}
|
||||||
|
loadAddon(): void {}
|
||||||
|
open(container: HTMLElement): void {
|
||||||
|
container.appendChild(document.createElement("div"));
|
||||||
|
}
|
||||||
|
onData(cb: (d: string) => void) {
|
||||||
|
this.dataCb = cb;
|
||||||
|
return { dispose: vi.fn() };
|
||||||
|
}
|
||||||
|
emitData(d: string): void {
|
||||||
|
this.dataCb?.(d);
|
||||||
|
}
|
||||||
|
onResize() {
|
||||||
|
return { dispose: vi.fn() };
|
||||||
|
}
|
||||||
|
onSelectionChange() {
|
||||||
|
return { dispose: vi.fn() };
|
||||||
|
}
|
||||||
|
write(_data: Uint8Array, cb?: () => void): void {
|
||||||
|
cb?.();
|
||||||
|
}
|
||||||
|
scrollToBottom(): void {}
|
||||||
|
resize(cols: number, rows: number): void {
|
||||||
|
this.cols = cols;
|
||||||
|
this.rows = rows;
|
||||||
|
}
|
||||||
|
clearTextureAtlas(): void {}
|
||||||
|
getSelection(): string {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
dispose(): void {
|
||||||
|
this.disposed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeFitAddon {
|
||||||
|
fit = vi.fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeWebglAddon {
|
||||||
|
static instances: FakeWebglAddon[] = [];
|
||||||
|
disposed = false;
|
||||||
|
private lossCb: (() => void) | null = null;
|
||||||
|
constructor() {
|
||||||
|
FakeWebglAddon.instances.push(this);
|
||||||
|
}
|
||||||
|
onContextLoss(cb: () => void): void {
|
||||||
|
this.lossCb = cb;
|
||||||
|
}
|
||||||
|
triggerLoss(): void {
|
||||||
|
this.lossCb?.();
|
||||||
|
}
|
||||||
|
dispose(): void {
|
||||||
|
this.disposed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const policyEnabled = vi.fn(() => true);
|
||||||
|
const policyNoteLoss = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@xterm/xterm", () => ({ Terminal: FakeTerminal }));
|
||||||
|
vi.mock("@xterm/addon-fit", () => ({ FitAddon: FakeFitAddon }));
|
||||||
|
vi.mock("@xterm/addon-webgl", () => ({ WebglAddon: FakeWebglAddon }));
|
||||||
|
vi.mock("../../webglRenderer.js", () => ({
|
||||||
|
WebglPolicy: vi.fn().mockImplementation(() => ({
|
||||||
|
enabled: policyEnabled,
|
||||||
|
noteContextLoss: policyNoteLoss,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
class FakeWebSocket {
|
||||||
|
static OPEN = 1;
|
||||||
|
static CONNECTING = 0;
|
||||||
|
static CLOSING = 2;
|
||||||
|
static CLOSED = 3;
|
||||||
|
static instances: FakeWebSocket[] = [];
|
||||||
|
readyState = FakeWebSocket.CONNECTING;
|
||||||
|
binaryType = "";
|
||||||
|
sent: (string | ArrayBufferView)[] = [];
|
||||||
|
onopen: (() => void) | null = null;
|
||||||
|
onmessage: ((ev: { data: unknown }) => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
onclose: ((ev: { code: number; reason: string }) => void) | null = null;
|
||||||
|
|
||||||
|
constructor(public url: string) {
|
||||||
|
FakeWebSocket.instances.push(this);
|
||||||
|
}
|
||||||
|
send(data: string | ArrayBufferView): void {
|
||||||
|
this.sent.push(data);
|
||||||
|
}
|
||||||
|
close(): void {
|
||||||
|
this.readyState = FakeWebSocket.CLOSED;
|
||||||
|
}
|
||||||
|
open(): void {
|
||||||
|
this.readyState = FakeWebSocket.OPEN;
|
||||||
|
this.onopen?.();
|
||||||
|
}
|
||||||
|
drop(code = 1006, reason = ""): void {
|
||||||
|
this.readyState = FakeWebSocket.CLOSED;
|
||||||
|
this.onclose?.({ code, reason });
|
||||||
|
}
|
||||||
|
lastControl(): ClientControl {
|
||||||
|
return JSON.parse(this.sent.at(-1) as string) as ClientControl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"ResizeObserver",
|
||||||
|
class {
|
||||||
|
observe(): void {}
|
||||||
|
disconnect(): void {}
|
||||||
|
unobserve(): void {}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
FakeTerminal.instances = [];
|
||||||
|
FakeWebglAddon.instances = [];
|
||||||
|
FakeWebSocket.instances = [];
|
||||||
|
policyEnabled.mockReturnValue(true);
|
||||||
|
policyEnabled.mockClear();
|
||||||
|
policyNoteLoss.mockClear();
|
||||||
|
vi.stubGlobal("WebSocket", FakeWebSocket);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Imported after the mocks above so it picks up the mocked xterm/webgl modules.
|
||||||
|
const { TerminalPane } = await import("./TerminalPane.js");
|
||||||
|
|
||||||
|
describe("TerminalPane lifecycle", () => {
|
||||||
|
it("opens an xterm terminal and connects to the session's attach socket on mount", () => {
|
||||||
|
render(<TerminalPane sessionId="s1" />);
|
||||||
|
|
||||||
|
expect(FakeTerminal.instances).toHaveLength(1);
|
||||||
|
expect(FakeWebSocket.instances).toHaveLength(1);
|
||||||
|
expect(FakeWebSocket.instances[0].url).toBe(attachWsUrl("s1"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends the terminal size once the socket opens", () => {
|
||||||
|
render(<TerminalPane sessionId="s1" />);
|
||||||
|
const sock = FakeWebSocket.instances[0];
|
||||||
|
|
||||||
|
sock.open();
|
||||||
|
|
||||||
|
expect(sock.lastControl()).toEqual({ type: "Resize", cols: 80, rows: 24 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disposes the terminal and closes the socket on unmount", () => {
|
||||||
|
const { unmount } = render(<TerminalPane sessionId="s1" />);
|
||||||
|
const term = FakeTerminal.instances[0];
|
||||||
|
const sock = FakeWebSocket.instances[0];
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(term.disposed).toBe(true);
|
||||||
|
expect(sock.readyState).toBe(FakeWebSocket.CLOSED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reconnects to a fresh socket for a new sessionId", () => {
|
||||||
|
const { rerender } = render(<TerminalPane sessionId="s1" />);
|
||||||
|
rerender(<TerminalPane sessionId="s2" />);
|
||||||
|
|
||||||
|
expect(FakeWebSocket.instances).toHaveLength(2);
|
||||||
|
expect(FakeWebSocket.instances[1].url).toBe(attachWsUrl("s2"));
|
||||||
|
expect(FakeTerminal.instances[0].disposed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TerminalPane reconnect backoff", () => {
|
||||||
|
it("retries a dropped socket, doubling the delay from 500ms up to a 10s cap", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
render(<TerminalPane sessionId="s1" />);
|
||||||
|
|
||||||
|
const drop = () => FakeWebSocket.instances.at(-1)!.drop(1006, "");
|
||||||
|
const expectStillWaiting = (ms: number, countBefore: number) => {
|
||||||
|
vi.advanceTimersByTime(ms - 1);
|
||||||
|
expect(FakeWebSocket.instances).toHaveLength(countBefore);
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(FakeWebSocket.instances).toHaveLength(countBefore + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(FakeWebSocket.instances).toHaveLength(1);
|
||||||
|
drop();
|
||||||
|
expectStillWaiting(INITIAL_RECONNECT_MS, 1); // 500ms -> #2
|
||||||
|
drop();
|
||||||
|
expectStillWaiting(INITIAL_RECONNECT_MS * 2, 2); // 1000ms -> #3
|
||||||
|
drop();
|
||||||
|
expectStillWaiting(INITIAL_RECONNECT_MS * 4, 3); // 2000ms -> #4
|
||||||
|
drop();
|
||||||
|
expectStillWaiting(INITIAL_RECONNECT_MS * 8, 4); // 4000ms -> #5
|
||||||
|
drop();
|
||||||
|
expectStillWaiting(INITIAL_RECONNECT_MS * 16, 5); // 8000ms -> #6
|
||||||
|
drop();
|
||||||
|
expectStillWaiting(MAX_RECONNECT_MS, 6); // capped at 10s -> #7
|
||||||
|
drop();
|
||||||
|
expectStillWaiting(MAX_RECONNECT_MS, 7); // stays capped -> #8
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets the backoff once a reconnected socket opens", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
render(<TerminalPane sessionId="s1" />);
|
||||||
|
|
||||||
|
FakeWebSocket.instances[0].drop(1006, "");
|
||||||
|
vi.advanceTimersByTime(INITIAL_RECONNECT_MS);
|
||||||
|
FakeWebSocket.instances[1].open();
|
||||||
|
FakeWebSocket.instances[1].drop(1006, "");
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(INITIAL_RECONNECT_MS - 1);
|
||||||
|
expect(FakeWebSocket.instances).toHaveLength(2);
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(FakeWebSocket.instances).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not retry a clean close, and reports it closed", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const onClosed = vi.fn();
|
||||||
|
render(<TerminalPane sessionId="s1" onClosed={onClosed} />);
|
||||||
|
|
||||||
|
FakeWebSocket.instances[0].drop(1000, "");
|
||||||
|
vi.advanceTimersByTime(MAX_RECONNECT_MS * 2);
|
||||||
|
|
||||||
|
expect(FakeWebSocket.instances).toHaveLength(1);
|
||||||
|
expect(onClosed).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not retry an application-level rejection, and writes it into the terminal", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const onClosed = vi.fn();
|
||||||
|
render(<TerminalPane sessionId="s1" onClosed={onClosed} />);
|
||||||
|
const term = FakeTerminal.instances[0];
|
||||||
|
const writeSpy = vi.spyOn(term, "write");
|
||||||
|
|
||||||
|
FakeWebSocket.instances[0].drop(4004, "unknown session");
|
||||||
|
vi.advanceTimersByTime(MAX_RECONNECT_MS * 2);
|
||||||
|
|
||||||
|
expect(FakeWebSocket.instances).toHaveLength(1);
|
||||||
|
expect(onClosed).not.toHaveBeenCalled();
|
||||||
|
expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining("unknown session"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors a caller-supplied shouldReconnect policy", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const onClosed = vi.fn();
|
||||||
|
render(<TerminalPane sessionId="s1" shouldReconnect={() => false} onClosed={onClosed} />);
|
||||||
|
|
||||||
|
FakeWebSocket.instances[0].drop(1006, "");
|
||||||
|
vi.advanceTimersByTime(MAX_RECONNECT_MS * 2);
|
||||||
|
|
||||||
|
expect(FakeWebSocket.instances).toHaveLength(1);
|
||||||
|
expect(onClosed).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TerminalPane WebGL context budget", () => {
|
||||||
|
it("acquires a WebGL context on mount, shared policy allowing", () => {
|
||||||
|
render(<TerminalPane sessionId="s1" />);
|
||||||
|
|
||||||
|
expect(policyEnabled).toHaveBeenCalled();
|
||||||
|
expect(FakeWebglAddon.instances).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never creates a context once the shared budget is exhausted", () => {
|
||||||
|
policyEnabled.mockReturnValue(false);
|
||||||
|
render(<TerminalPane sessionId="s1" />);
|
||||||
|
|
||||||
|
expect(FakeWebglAddon.instances).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("releases its context on unmount, handing the budget back", () => {
|
||||||
|
const { unmount } = render(<TerminalPane sessionId="s1" />);
|
||||||
|
const addon = FakeWebglAddon.instances[0];
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(addon.disposed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts a context loss and re-acquires while still mounted", () => {
|
||||||
|
render(<TerminalPane sessionId="s1" />);
|
||||||
|
const first = FakeWebglAddon.instances[0];
|
||||||
|
|
||||||
|
first.triggerLoss();
|
||||||
|
|
||||||
|
expect(policyNoteLoss).toHaveBeenCalledOnce();
|
||||||
|
expect(first.disposed).toBe(true);
|
||||||
|
expect(FakeWebglAddon.instances).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not re-acquire a lost context once the budget is spent", () => {
|
||||||
|
render(<TerminalPane sessionId="s1" />);
|
||||||
|
const first = FakeWebglAddon.instances[0];
|
||||||
|
policyEnabled.mockReturnValue(false);
|
||||||
|
|
||||||
|
first.triggerLoss();
|
||||||
|
|
||||||
|
expect(FakeWebglAddon.instances).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TerminalPane imperative handle", () => {
|
||||||
|
it("sendInput writes bytes to the PTY only once the socket is open", () => {
|
||||||
|
const ref = createRef<import("./TerminalPane.js").TerminalPaneHandle>();
|
||||||
|
render(<TerminalPane sessionId="s1" ref={ref} />);
|
||||||
|
const sock = FakeWebSocket.instances[0];
|
||||||
|
|
||||||
|
ref.current!.sendInput("nope");
|
||||||
|
expect(sock.sent).toHaveLength(0);
|
||||||
|
|
||||||
|
sock.open();
|
||||||
|
sock.sent.length = 0; // drop the resize control frame from open()
|
||||||
|
ref.current!.sendInput("hi");
|
||||||
|
|
||||||
|
expect(sock.sent).toHaveLength(1);
|
||||||
|
expect(new TextDecoder().decode(sock.sent[0] as Uint8Array)).toBe("hi");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("focusTerminal focuses the underlying xterm instance", () => {
|
||||||
|
const ref = createRef<import("./TerminalPane.js").TerminalPaneHandle>();
|
||||||
|
render(<TerminalPane sessionId="s1" ref={ref} />);
|
||||||
|
|
||||||
|
ref.current!.focusTerminal();
|
||||||
|
|
||||||
|
expect(FakeTerminal.instances[0].focus).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes typed keystrokes through to the socket", () => {
|
||||||
|
render(<TerminalPane sessionId="s1" />);
|
||||||
|
const sock = FakeWebSocket.instances[0];
|
||||||
|
sock.open();
|
||||||
|
sock.sent.length = 0;
|
||||||
|
|
||||||
|
FakeTerminal.instances[0].emitData("q");
|
||||||
|
|
||||||
|
expect(new TextDecoder().decode(sock.sent[0] as Uint8Array)).toBe("q");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
// 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"}>
|
||||||
|
<div ref={bodyRef} className="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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user