diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts new file mode 100644 index 00000000000..d6551f6b857 --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts @@ -0,0 +1,101 @@ +import { + attachToContainer, + createRuntime, + detachFromContainer, + disposeRuntime, + type TerminalRuntime, +} from "./terminal-runtime"; +import { + type ConnectionState, + connect, + createTransport, + disposeTransport, + sendDispose, + sendResize, + type TerminalTransport, +} from "./terminal-ws-transport"; + +interface RegistryEntry { + runtime: TerminalRuntime; + transport: TerminalTransport; +} + +class TerminalRuntimeRegistryImpl { + private entries = new Map(); + + private getOrCreate(paneId: string): RegistryEntry { + let entry = this.entries.get(paneId); + if (entry) return entry; + + entry = { + runtime: createRuntime(paneId), + transport: createTransport(), + }; + + this.entries.set(paneId, entry); + return entry; + } + + attach(paneId: string, container: HTMLDivElement, wsUrl: string) { + const { runtime, transport } = this.getOrCreate(paneId); + + attachToContainer(runtime, container, () => { + sendResize(transport, runtime.terminal.cols, runtime.terminal.rows); + }); + + connect(transport, runtime.terminal, wsUrl); + } + + /** + * Detach the terminal from its DOM container. + * + * This only removes the DOM attachment (wrapper, resize observer, focus). + * The WebSocket and xterm data flow are intentionally kept alive so output + * written while the pane is hidden is not lost. Disposal of the transport + * happens exclusively through {@link dispose} when the paneId is removed + * from persisted pane state. + */ + detach(paneId: string) { + const entry = this.entries.get(paneId); + if (!entry) return; + + detachFromContainer(entry.runtime); + } + + dispose(paneId: string) { + const entry = this.entries.get(paneId); + if (!entry) return; + + sendDispose(entry.transport); + disposeTransport(entry.transport); + disposeRuntime(entry.runtime); + + this.entries.delete(paneId); + } + + getAllPaneIds(): Set { + return new Set(this.entries.keys()); + } + + has(paneId: string): boolean { + return this.entries.has(paneId); + } + + getConnectionState(paneId: string): ConnectionState { + return ( + this.entries.get(paneId)?.transport.connectionState ?? "disconnected" + ); + } + + onStateChange(paneId: string, listener: () => void): () => void { + const { transport } = this.getOrCreate(paneId); + transport.stateListeners.add(listener); + return () => { + transport.stateListeners.delete(listener); + }; + } +} + +export const terminalRuntimeRegistry = new TerminalRuntimeRegistryImpl(); + +export type { ConnectionState }; diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts new file mode 100644 index 00000000000..484a44d635f --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts @@ -0,0 +1,181 @@ +import { FitAddon } from "@xterm/addon-fit"; +import { SerializeAddon } from "@xterm/addon-serialize"; +import { Terminal as XTerm } from "@xterm/xterm"; + +const SERIALIZE_SCROLLBACK = 1000; +const STORAGE_KEY_PREFIX = "terminal-buffer:"; +const DIMS_KEY_PREFIX = "terminal-dims:"; +const DEFAULT_COLS = 120; +const DEFAULT_ROWS = 32; + +export interface TerminalRuntime { + paneId: string; + terminal: XTerm; + fitAddon: FitAddon; + serializeAddon: SerializeAddon; + /** Reparented between containers across attach/detach cycles — not recreated. */ + wrapper: HTMLDivElement; + container: HTMLDivElement | null; + resizeObserver: ResizeObserver | null; + /** Fallback grid size used when the host is not visible. */ + lastCols: number; + lastRows: number; +} + +function createTerminal( + cols: number, + rows: number, +): { + terminal: XTerm; + fitAddon: FitAddon; + serializeAddon: SerializeAddon; +} { + const fitAddon = new FitAddon(); + const serializeAddon = new SerializeAddon(); + const terminal = new XTerm({ + cols, + rows, + cursorBlink: true, + fontFamily: + 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', + fontSize: 12, + theme: { + background: "#14100f", + foreground: "#f5efe9", + }, + }); + terminal.loadAddon(fitAddon); + terminal.loadAddon(serializeAddon); + return { terminal, fitAddon, serializeAddon }; +} + +function persistBuffer(paneId: string, serializeAddon: SerializeAddon) { + try { + const data = serializeAddon.serialize({ scrollback: SERIALIZE_SCROLLBACK }); + localStorage.setItem(`${STORAGE_KEY_PREFIX}${paneId}`, data); + } catch {} +} + +function restoreBuffer(paneId: string, terminal: XTerm) { + try { + const data = localStorage.getItem(`${STORAGE_KEY_PREFIX}${paneId}`); + if (data) terminal.write(data); + } catch {} +} + +function clearPersistedBuffer(paneId: string) { + try { + localStorage.removeItem(`${STORAGE_KEY_PREFIX}${paneId}`); + } catch {} +} + +function persistDimensions(paneId: string, cols: number, rows: number) { + try { + localStorage.setItem( + `${DIMS_KEY_PREFIX}${paneId}`, + JSON.stringify({ cols, rows }), + ); + } catch {} +} + +function loadSavedDimensions( + paneId: string, +): { cols: number; rows: number } | null { + try { + const raw = localStorage.getItem(`${DIMS_KEY_PREFIX}${paneId}`); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (typeof parsed.cols === "number" && typeof parsed.rows === "number") { + return parsed; + } + return null; + } catch { + return null; + } +} + +function clearPersistedDimensions(paneId: string) { + try { + localStorage.removeItem(`${DIMS_KEY_PREFIX}${paneId}`); + } catch {} +} + +function hostIsVisible(container: HTMLDivElement | null): boolean { + if (!container) return false; + return container.clientWidth > 0 && container.clientHeight > 0; +} + +function measureAndResize(runtime: TerminalRuntime) { + if (!hostIsVisible(runtime.container)) return; + runtime.fitAddon.fit(); + runtime.lastCols = runtime.terminal.cols; + runtime.lastRows = runtime.terminal.rows; +} + +export function createRuntime(paneId: string): TerminalRuntime { + const savedDims = loadSavedDimensions(paneId); + const cols = savedDims?.cols ?? DEFAULT_COLS; + const rows = savedDims?.rows ?? DEFAULT_ROWS; + + const { terminal, fitAddon, serializeAddon } = createTerminal(cols, rows); + + const wrapper = document.createElement("div"); + wrapper.style.width = "100%"; + wrapper.style.height = "100%"; + terminal.open(wrapper); + restoreBuffer(paneId, terminal); + + return { + paneId, + terminal, + fitAddon, + serializeAddon, + wrapper, + container: null, + resizeObserver: null, + lastCols: cols, + lastRows: rows, + }; +} + +export function attachToContainer( + runtime: TerminalRuntime, + container: HTMLDivElement, + onResize?: () => void, +) { + runtime.container = container; + container.appendChild(runtime.wrapper); + measureAndResize(runtime); + + // Force a full repaint — the renderer may have skipped paint frames while + // the wrapper was detached from the DOM and receiving background data. + runtime.terminal.refresh(0, runtime.terminal.rows - 1); + + runtime.resizeObserver?.disconnect(); + const observer = new ResizeObserver(() => { + measureAndResize(runtime); + onResize?.(); + }); + observer.observe(container); + runtime.resizeObserver = observer; + + runtime.terminal.focus(); +} + +export function detachFromContainer(runtime: TerminalRuntime) { + persistBuffer(runtime.paneId, runtime.serializeAddon); + persistDimensions(runtime.paneId, runtime.lastCols, runtime.lastRows); + runtime.resizeObserver?.disconnect(); + runtime.resizeObserver = null; + runtime.wrapper.remove(); + runtime.container = null; +} + +export function disposeRuntime(runtime: TerminalRuntime) { + runtime.resizeObserver?.disconnect(); + runtime.resizeObserver = null; + runtime.wrapper.remove(); + runtime.terminal.dispose(); + clearPersistedBuffer(runtime.paneId); + clearPersistedDimensions(runtime.paneId); +} diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts new file mode 100644 index 00000000000..e522a0b8304 --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts @@ -0,0 +1,148 @@ +import type { Terminal as XTerm } from "@xterm/xterm"; + +export type ConnectionState = "disconnected" | "connecting" | "open" | "closed"; + +type TerminalServerMessage = + | { type: "data"; data: string } + | { type: "error"; message: string } + | { type: "exit"; exitCode: number; signal: number } + | { type: "replay"; data: string }; + +export interface TerminalTransport { + socket: WebSocket | null; + connectionState: ConnectionState; + /** The URL the socket is currently connected (or connecting) to. */ + currentUrl: string | null; + onDataDisposable: { dispose(): void } | null; + stateListeners: Set<() => void>; +} + +function setConnectionState( + transport: TerminalTransport, + state: ConnectionState, +) { + transport.connectionState = state; + for (const listener of transport.stateListeners) { + listener(); + } +} + +export function createTransport(): TerminalTransport { + return { + socket: null, + connectionState: "disconnected", + currentUrl: null, + onDataDisposable: null, + stateListeners: new Set(), + }; +} + +export function connect( + transport: TerminalTransport, + terminal: XTerm, + wsUrl: string, +) { + // Idempotent: skip if already connected/connecting to the same endpoint. + const isActive = + transport.connectionState === "open" || + transport.connectionState === "connecting"; + if (isActive && transport.currentUrl === wsUrl) return; + + if (transport.socket) { + transport.socket.close(); + transport.socket = null; + } + + transport.currentUrl = wsUrl; + setConnectionState(transport, "connecting"); + const socket = new WebSocket(wsUrl); + transport.socket = socket; + + socket.addEventListener("open", () => { + if (transport.socket !== socket) return; + setConnectionState(transport, "open"); + sendResize(transport, terminal.cols, terminal.rows); + }); + + socket.addEventListener("message", (event) => { + if (transport.socket !== socket) return; + let message: TerminalServerMessage; + try { + message = JSON.parse(String(event.data)) as TerminalServerMessage; + } catch { + terminal.writeln("\r\n[terminal] invalid server payload"); + return; + } + + if (message.type === "data" || message.type === "replay") { + terminal.write(message.data); + return; + } + + if (message.type === "error") { + terminal.writeln(`\r\n[terminal] ${message.message}`); + return; + } + + if (message.type === "exit") { + terminal.writeln( + `\r\n[terminal] exited with code ${message.exitCode} (signal ${message.signal})`, + ); + } + }); + + socket.addEventListener("close", () => { + if (transport.socket !== socket) return; + setConnectionState(transport, "closed"); + transport.socket = null; + }); + + socket.addEventListener("error", () => { + if (transport.socket !== socket) return; + terminal.writeln("\r\n[terminal] websocket error"); + }); + + transport.onDataDisposable?.dispose(); + transport.onDataDisposable = terminal.onData((data) => { + if (socket.readyState !== WebSocket.OPEN) return; + socket.send(JSON.stringify({ type: "input", data })); + }); +} + +export function disconnect(transport: TerminalTransport) { + if (transport.socket) { + transport.socket.close(); + transport.socket = null; + } + transport.currentUrl = null; + setConnectionState(transport, "disconnected"); + transport.onDataDisposable?.dispose(); + transport.onDataDisposable = null; +} + +export function sendResize( + transport: TerminalTransport, + cols: number, + rows: number, +) { + if (!transport.socket || transport.socket.readyState !== WebSocket.OPEN) + return; + transport.socket.send(JSON.stringify({ type: "resize", cols, rows })); +} + +export function sendDispose(transport: TerminalTransport) { + if (transport.socket?.readyState === WebSocket.OPEN) { + transport.socket.send(JSON.stringify({ type: "dispose" })); + } +} + +export function disposeTransport(transport: TerminalTransport) { + if (transport.socket) { + transport.socket.close(); + transport.socket = null; + } + transport.currentUrl = null; + transport.onDataDisposable?.dispose(); + transport.onDataDisposable = null; + transport.stateListeners.clear(); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx index 5df9f5293ec..dffa3e4810d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx @@ -1,168 +1,58 @@ -import { Button } from "@superset/ui/button"; -import { FitAddon } from "@xterm/addon-fit"; -import { Terminal as XTerm } from "@xterm/xterm"; import "@xterm/xterm/css/xterm.css"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useSyncExternalStore } from "react"; +import { + type ConnectionState, + terminalRuntimeRegistry, +} from "renderer/lib/terminal/terminal-runtime-registry"; import { useWorkspaceWsUrl } from "../../../../../providers/WorkspaceTrpcProvider/WorkspaceTrpcProvider"; -interface WorkspaceTerminalProps { +interface TerminalPaneProps { + paneId: string; workspaceId: string; } -type TerminalServerMessage = - | { - type: "data"; - data: string; - } - | { - type: "error"; - message: string; - } - | { - type: "exit"; - exitCode: number; - signal: number; - }; +function subscribeToState(paneId: string) { + return (callback: () => void) => + terminalRuntimeRegistry.onStateChange(paneId, callback); +} + +function getConnectionState(paneId: string): ConnectionState { + return terminalRuntimeRegistry.getConnectionState(paneId); +} -export function TerminalPane({ workspaceId }: WorkspaceTerminalProps) { +export function TerminalPane({ paneId, workspaceId }: TerminalPaneProps) { const containerRef = useRef(null); - const [connectionState, setConnectionState] = useState< - "connecting" | "open" | "closed" - >("connecting"); - const [reconnectKey, setReconnectKey] = useState(0); - const websocketUrl = useWorkspaceWsUrl(`/terminal/${workspaceId}`, { - reconnect: String(reconnectKey), + const websocketUrl = useWorkspaceWsUrl(`/terminal/${paneId}`, { + workspaceId, }); + const connectionState = useSyncExternalStore(subscribeToState(paneId), () => + getConnectionState(paneId), + ); + useEffect(() => { const container = containerRef.current; - if (!container) { - return; - } - - const fitAddon = new FitAddon(); - const terminal = new XTerm({ - cursorBlink: true, - fontFamily: - 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', - fontSize: 12, - theme: { - background: "#14100f", - foreground: "#f5efe9", - }, - }); - terminal.loadAddon(fitAddon); - terminal.open(container); - fitAddon.fit(); - terminal.focus(); - - setConnectionState("connecting"); - const socket = new WebSocket(websocketUrl); - - const sendResize = () => { - if (socket.readyState !== WebSocket.OPEN) { - return; - } - - socket.send( - JSON.stringify({ - type: "resize", - cols: terminal.cols, - rows: terminal.rows, - }), - ); - }; - - const resizeObserver = new ResizeObserver(() => { - fitAddon.fit(); - sendResize(); - }); - resizeObserver.observe(container); + if (!container) return; - const onTerminalDataDispose = terminal.onData((data) => { - if (socket.readyState !== WebSocket.OPEN) { - return; - } - - socket.send( - JSON.stringify({ - type: "input", - data, - }), - ); - }); - - socket.addEventListener("open", () => { - setConnectionState("open"); - sendResize(); - }); - - socket.addEventListener("message", (event) => { - let message: TerminalServerMessage; - try { - message = JSON.parse(String(event.data)) as TerminalServerMessage; - } catch { - terminal.writeln("\r\n[terminal] invalid server payload"); - return; - } - - if (message.type === "data") { - terminal.write(message.data); - return; - } - - if (message.type === "error") { - terminal.writeln(`\r\n[terminal] ${message.message}`); - return; - } - - terminal.writeln( - `\r\n[terminal] exited with code ${message.exitCode} (signal ${message.signal})`, - ); - }); - - socket.addEventListener("close", () => { - setConnectionState("closed"); - }); - - socket.addEventListener("error", () => { - terminal.writeln("\r\n[terminal] websocket error"); - }); + terminalRuntimeRegistry.attach(paneId, container, websocketUrl); return () => { - resizeObserver.disconnect(); - onTerminalDataDispose.dispose(); - socket.close(); - terminal.dispose(); + terminalRuntimeRegistry.detach(paneId); }; - }, [websocketUrl]); + }, [paneId, websocketUrl]); return ( -
-
-
-

terminal

-

- {connectionState === "open" - ? "Connected" - : connectionState === "connecting" - ? "Connecting..." - : "Disconnected"} -

-
- -
+
+ {connectionState === "closed" && ( +
+ Disconnected +
+ )}
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index 2d9e5373828..d8331c359b8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -40,7 +40,9 @@ export function usePaneRegistry( terminal: { getIcon: () => , getTitle: () => "Terminal", - renderPane: () => , + renderPane: (ctx: RendererContext) => ( + + ), }, browser: { getIcon: () => , diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/GlobalTerminalLifecycle.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/GlobalTerminalLifecycle.tsx new file mode 100644 index 00000000000..a5fe4668d22 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/GlobalTerminalLifecycle.tsx @@ -0,0 +1,6 @@ +import { useGlobalTerminalLifecycle } from "./hooks/useGlobalTerminalLifecycle"; + +export function GlobalTerminalLifecycle() { + useGlobalTerminalLifecycle(); + return null; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/hooks/useGlobalTerminalLifecycle/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/hooks/useGlobalTerminalLifecycle/index.ts new file mode 100644 index 00000000000..42a2df8af11 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/hooks/useGlobalTerminalLifecycle/index.ts @@ -0,0 +1 @@ +export { useGlobalTerminalLifecycle } from "./useGlobalTerminalLifecycle"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts new file mode 100644 index 00000000000..7a3e528c301 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts @@ -0,0 +1,84 @@ +import type { WorkspaceState } from "@superset/panes"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useEffect, useRef } from "react"; +import { terminalRuntimeRegistry } from "renderer/lib/terminal/terminal-runtime-registry"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; + +/** Cross-workspace moves temporarily remove a paneId then re-add it. Wait before disposing. */ +const DISPOSE_DELAY_MS = 500; + +function extractTerminalPaneIds(rows: { paneLayout: unknown }[]): Set { + const ids = new Set(); + for (const row of rows) { + const layout = row.paneLayout as WorkspaceState | undefined; + if (!layout?.tabs) continue; + for (const tab of layout.tabs) { + for (const [paneId, pane] of Object.entries(tab.panes)) { + if (pane.kind === "terminal") { + ids.add(paneId); + } + } + } + } + return ids; +} + +export function useGlobalTerminalLifecycle() { + const collections = useCollections(); + const prevPaneIdsRef = useRef>(new Set()); + const pendingDisposals = useRef>>( + new Map(), + ); + + const { data: allWorkspaceRows = [] } = useLiveQuery( + (query) => + query.from({ + v2WorkspaceLocalState: collections.v2WorkspaceLocalState, + }), + [collections], + ); + + useEffect(() => { + const currentPaneIds = extractTerminalPaneIds(allWorkspaceRows); + const prevPaneIds = prevPaneIdsRef.current; + + for (const paneId of currentPaneIds) { + const timer = pendingDisposals.current.get(paneId); + if (timer) { + clearTimeout(timer); + pendingDisposals.current.delete(paneId); + } + } + + for (const paneId of prevPaneIds) { + if (currentPaneIds.has(paneId)) continue; + if (pendingDisposals.current.has(paneId)) continue; + + const timer = setTimeout(() => { + pendingDisposals.current.delete(paneId); + + const freshRows = Array.from( + collections.v2WorkspaceLocalState.state.values(), + ); + const freshIds = extractTerminalPaneIds(freshRows); + + if (!freshIds.has(paneId)) { + terminalRuntimeRegistry.dispose(paneId); + } + }, DISPOSE_DELAY_MS); + + pendingDisposals.current.set(paneId, timer); + } + + prevPaneIdsRef.current = currentPaneIds; + }, [allWorkspaceRows, collections]); + + useEffect(() => { + return () => { + for (const timer of pendingDisposals.current.values()) { + clearTimeout(timer); + } + pendingDisposals.current.clear(); + }; + }, []); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/index.ts new file mode 100644 index 00000000000..d2ed752bdc2 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/index.ts @@ -0,0 +1 @@ +export { GlobalTerminalLifecycle } from "./GlobalTerminalLifecycle"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx index 8083d6b0dec..fdf03790792 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx @@ -32,6 +32,7 @@ import { setPaneWorkspaceRunState } from "renderer/stores/tabs/workspace-run"; import { useWorkspaceInitStore } from "renderer/stores/workspace-init"; import { MOCK_ORG_ID, NOTIFICATION_EVENTS } from "shared/constants"; import { AgentHooks } from "./components/AgentHooks"; +import { GlobalTerminalLifecycle } from "./components/GlobalTerminalLifecycle"; import { TeardownLogsDialog } from "./components/TeardownLogsDialog"; import { CollectionsProvider } from "./providers/CollectionsProvider"; import { HostServiceProvider } from "./providers/HostServiceProvider"; @@ -174,6 +175,7 @@ function AuthenticatedLayout() { return ( + diff --git a/bun.lock b/bun.lock index 4d4a05c0bff..87ed368ec99 100644 --- a/bun.lock +++ b/bun.lock @@ -932,6 +932,17 @@ "version": "0.1.0", }, }, + "trustedDependencies": [ + "koffi", + "sharp", + "node-pty", + "utf-8-validate", + "esbuild", + "electron", + "better-sqlite3", + "bufferutil", + "workerd", + ], "overrides": { "axios": "1.14.0", }, diff --git a/package.json b/package.json index 02b99f62842..2cef5488f9f 100644 --- a/package.json +++ b/package.json @@ -50,5 +50,16 @@ }, "patchedDependencies": { "@durable-streams/state@0.2.1": "patches/@durable-streams%2Fstate@0.2.1.patch" - } + }, + "trustedDependencies": [ + "better-sqlite3", + "bufferutil", + "electron", + "esbuild", + "koffi", + "node-pty", + "sharp", + "utf-8-validate", + "workerd" + ] } diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts index 3e50c23c704..1e917419791 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -14,41 +14,41 @@ interface RegisterWorkspaceTerminalRouteOptions { } type TerminalClientMessage = - | { - type: "input"; - data: string; - } - | { - type: "resize"; - cols: number; - rows: number; - }; + | { type: "input"; data: string } + | { type: "resize"; cols: number; rows: number } + | { type: "dispose" }; type TerminalServerMessage = - | { - type: "data"; - data: string; - } - | { - type: "error"; - message: string; - } - | { - type: "exit"; - exitCode: number; - signal: number; - }; + | { type: "data"; data: string } + | { type: "error"; message: string } + | { type: "exit"; exitCode: number; signal: number } + | { type: "replay"; data: string }; -function sendMessage( +const MAX_BUFFER_BYTES = 64 * 1024; + +interface TerminalSession { + paneId: string; + pty: IPty; socket: { send: (data: string) => void; + close: (code?: number, reason?: string) => void; readyState: number; - }, + } | null; + buffer: string[]; + bufferBytes: number; + exited: boolean; + exitCode: number; + exitSignal: number; +} + +/** PTY lifetime is independent of socket lifetime — sockets detach/reattach freely. */ +const sessions = new Map(); + +function sendMessage( + socket: { send: (data: string) => void; readyState: number }, message: TerminalServerMessage, ) { - if (socket.readyState !== 1) { - return; - } + if (socket.readyState !== 1) return; socket.send(JSON.stringify(message)); } @@ -56,44 +56,97 @@ function resolveShell(): string { if (process.platform === "win32") { return process.env.COMSPEC || "cmd.exe"; } - return process.env.SHELL || "/bin/zsh"; } +function bufferOutput(session: TerminalSession, data: string) { + session.buffer.push(data); + session.bufferBytes += data.length; + + while (session.bufferBytes > MAX_BUFFER_BYTES && session.buffer.length > 1) { + const removed = session.buffer.shift(); + if (removed) session.bufferBytes -= removed.length; + } +} + +function replayBuffer( + session: TerminalSession, + socket: { send: (data: string) => void; readyState: number }, +) { + if (session.buffer.length === 0) return; + const combined = session.buffer.join(""); + session.buffer.length = 0; + session.bufferBytes = 0; + sendMessage(socket, { type: "replay", data: combined }); +} + +function disposeSession(paneId: string) { + const session = sessions.get(paneId); + if (!session) return; + + if (!session.exited) { + try { + session.pty.kill(); + } catch { + // PTY may already be dead + } + } + sessions.delete(paneId); +} + export function registerWorkspaceTerminalRoute({ app, db, upgradeWebSocket, }: RegisterWorkspaceTerminalRouteOptions) { app.get( - "/terminal/:workspaceId", + "/terminal/:paneId", upgradeWebSocket((c) => { - const workspaceId = c.req.param("workspaceId"); - const workspace = workspaceId - ? db.query.workspaces - .findFirst({ where: eq(workspaces.id, workspaceId) }) - .sync() - : null; - - let terminal: IPty | null = null; - let disposed = false; - - const disposeTerminal = () => { - if (disposed) { - return; - } - disposed = true; - terminal?.kill(); - terminal = null; - }; + const paneId = c.req.param("paneId"); + const workspaceId = c.req.query("workspaceId") ?? null; return { onOpen: (_event, ws) => { - if ( - !workspaceId || - !workspace || - !existsSync(workspace.worktreePath) - ) { + if (!paneId) { + sendMessage(ws, { + type: "error", + message: "Missing paneId", + }); + ws.close(1011, "Missing paneId"); + return; + } + + const existing = sessions.get(paneId); + if (existing) { + if (existing.socket && existing.socket !== ws) { + existing.socket.close(4000, "Displaced by new connection"); + } + existing.socket = ws; + replayBuffer(existing, ws); + if (existing.exited) { + sendMessage(ws, { + type: "exit", + exitCode: existing.exitCode, + signal: existing.exitSignal, + }); + } + return; + } + + if (!workspaceId) { + sendMessage(ws, { + type: "error", + message: "Missing workspaceId for new terminal session", + }); + ws.close(1011, "Missing workspaceId"); + return; + } + + const workspace = db.query.workspaces + .findFirst({ where: eq(workspaces.id, workspaceId) }) + .sync(); + + if (!workspace || !existsSync(workspace.worktreePath)) { sendMessage(ws, { type: "error", message: "Workspace worktree not found", @@ -102,8 +155,9 @@ export function registerWorkspaceTerminalRoute({ return; } + let pty: IPty; try { - terminal = spawn(resolveShell(), [], { + pty = spawn(resolveShell(), [], { name: "xterm-256color", cwd: workspace.worktreePath, cols: 120, @@ -128,55 +182,89 @@ export function registerWorkspaceTerminalRoute({ return; } - terminal.onData((data) => { - sendMessage(ws, { - type: "data", - data, - }); + const session: TerminalSession = { + paneId, + pty, + socket: ws, + buffer: [], + bufferBytes: 0, + exited: false, + exitCode: 0, + exitSignal: 0, + }; + sessions.set(paneId, session); + + pty.onData((data) => { + if (session.socket?.readyState === 1) { + sendMessage(session.socket, { type: "data", data }); + } else { + bufferOutput(session, data); + } }); - terminal.onExit(({ exitCode, signal }) => { - sendMessage(ws, { - type: "exit", - exitCode: exitCode ?? 0, - signal: signal ?? 0, - }); - ws.close(1000, "Terminal exited"); - disposeTerminal(); + pty.onExit(({ exitCode, signal }) => { + session.exited = true; + session.exitCode = exitCode ?? 0; + session.exitSignal = signal ?? 0; + + if (session.socket?.readyState === 1) { + sendMessage(session.socket, { + type: "exit", + exitCode: session.exitCode, + signal: session.exitSignal, + }); + } }); }, + onMessage: (event, ws) => { - if (!terminal) { - return; - } + const session = sessions.get(paneId ?? ""); + if (!session || session.socket !== ws) return; let message: TerminalClientMessage; try { message = JSON.parse(String(event.data)) as TerminalClientMessage; } catch { - sendMessage(ws, { - type: "error", - message: "Invalid terminal message payload", - }); + if (session.socket) { + sendMessage(session.socket, { + type: "error", + message: "Invalid terminal message payload", + }); + } + return; + } + + if (message.type === "dispose") { + disposeSession(paneId ?? ""); return; } + if (session.exited) return; + if (message.type === "input") { - terminal.write(message.data); + session.pty.write(message.data); return; } if (message.type === "resize") { const cols = Math.max(20, Math.floor(message.cols)); const rows = Math.max(5, Math.floor(message.rows)); - terminal.resize(cols, rows); + session.pty.resize(cols, rows); } }, - onClose: () => { - disposeTerminal(); + + onClose: (_event, ws) => { + const session = sessions.get(paneId ?? ""); + if (session?.socket === ws) { + session.socket = null; + } }, - onError: () => { - disposeTerminal(); + + onError: (_event, ws) => { + const session = sessions.get(paneId ?? ""); + if (session?.socket === ws) { + session.socket = null; + } }, }; }),