diff --git a/package-lock.json b/package-lock.json index cef82ef..ee38152 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,6 @@ "@tanstack/react-table": "^8.21.3", "@trivago/prettier-plugin-sort-imports": "^6.0.2", "@types/luxon": "^3.7.3", - "@xterm/addon-attach": "^0.12.0", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", @@ -3257,10 +3256,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@xterm/addon-attach": { - "version": "0.12.0", - "license": "MIT" - }, "node_modules/@xterm/addon-fit": { "version": "0.11.0", "license": "MIT" diff --git a/package.json b/package.json index ebb8e9e..c1cbc8f 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,6 @@ "@tanstack/react-table": "^8.21.3", "@trivago/prettier-plugin-sort-imports": "^6.0.2", "@types/luxon": "^3.7.3", - "@xterm/addon-attach": "^0.12.0", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", diff --git a/src/components/terminal.tsx b/src/components/terminal.tsx index 3e34a95..466b80a 100644 --- a/src/components/terminal.tsx +++ b/src/components/terminal.tsx @@ -8,8 +8,7 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog" import useTerminal from "@/hooks/useTerminal" -import { sleep } from "@/lib/utils" -import { AttachAddon } from "@xterm/addon-attach" +import { type TerminalKey, controlSequenceForInput, terminalKeySequence } from "@/lib/terminal-keys" import { FitAddon } from "@xterm/addon-fit" import { Terminal } from "@xterm/xterm" import "@xterm/xterm/css/xterm.css" @@ -36,111 +35,299 @@ interface XtermProps { setClose: React.Dispatch> } +type ConnectionState = "connecting" | "connected" | "disconnected" + +const terminalKeys: Array<{ key: TerminalKey; label: string; ariaLabel: string }> = [ + { key: "escape", label: "Esc", ariaLabel: "Escape" }, + { key: "tab", label: "Tab", ariaLabel: "Tab" }, + { key: "arrowLeft", label: "←", ariaLabel: "Left arrow" }, + { key: "arrowUp", label: "↑", ariaLabel: "Up arrow" }, + { key: "arrowDown", label: "↓", ariaLabel: "Down arrow" }, + { key: "arrowRight", label: "→", ariaLabel: "Right arrow" }, + { key: "home", label: "Home", ariaLabel: "Home" }, + { key: "end", label: "End", ariaLabel: "End" }, + { key: "pageUp", label: "PgUp", ariaLabel: "Page up" }, + { key: "pageDown", label: "PgDn", ariaLabel: "Page down" }, +] + +const maxClipboardBytes = 512 * 1024 + export const XtermComponent = forwardRef( - ({ wsUrl, setClose, ...props }, ref) => { - const terminalIdRef = useRef(null) + ({ wsUrl, setClose, className = "", ...props }, ref) => { + const shellRef = useRef(null) + const screenRef = useRef(null) const terminalRef = useRef(null) const wsRef = useRef(null) + const fitAddonRef = useRef(null) + const fitFrameRef = useRef(0) + const lastSizeRef = useRef("") + const controlActiveRef = useRef(false) + const pasteInProgressRef = useRef(false) + const pasteRequestRef = useRef(0) + const [controlActive, setControlActive] = useState(false) + const [pasteBusy, setPasteBusy] = useState(false) + const [connectionState, setConnectionState] = useState("connecting") - useImperativeHandle(ref, () => { - return { - ...terminalIdRef.current!, - async requestFullscreen() { - await terminalIdRef.current?.requestFullscreen() - }, - } + useImperativeHandle(ref, () => shellRef.current!, []) + + const updateControl = useCallback((active: boolean) => { + controlActiveRef.current = active + setControlActive(active) }, []) - const fitAddon = useRef(new FitAddon()).current - const sendResize = useRef(false) + const sendResize = useCallback(() => { + const terminal = terminalRef.current + const ws = wsRef.current + if (!terminal || !ws || ws.readyState !== WebSocket.OPEN) return + if (terminal.cols < 2 || terminal.rows < 2) return - const doResize = useCallback(() => { - if (!terminalIdRef.current) return + const sizeKey = `${terminal.cols}x${terminal.rows}` + if (lastSizeRef.current === sizeKey) return + lastSizeRef.current = sizeKey + const resizeMessage = new TextEncoder().encode( + JSON.stringify({ Rows: terminal.rows, Cols: terminal.cols }), + ) + const message = new Uint8Array(resizeMessage.length + 1) + message[0] = 1 + message.set(resizeMessage, 1) + ws.send(message) + }, []) - fitAddon.fit() - - const dimensions = fitAddon.proposeDimensions() - - if (dimensions) { - const prefix = new Int8Array([1]) - const resizeMessage = new TextEncoder().encode( - JSON.stringify({ - Rows: dimensions.rows, - Cols: dimensions.cols, - }), - ) - - const msg = new Int8Array(prefix.length + resizeMessage.length) - msg.set(prefix) - msg.set(resizeMessage, prefix.length) - - wsRef.current?.send(msg) - } - }, [fitAddon]) - - const onResize = useCallback(async () => { - if (sendResize.current) return - - sendResize.current = true - try { - await sleep(1500) - doResize() - } catch (error) { - console.error("resize error", error) - } finally { - sendResize.current = false - } - }, [doResize]) + const fit = useCallback(() => { + window.cancelAnimationFrame(fitFrameRef.current) + fitFrameRef.current = window.requestAnimationFrame(() => { + const screen = screenRef.current + if (!screen || screen.clientWidth === 0 || screen.clientHeight === 0) return + try { + fitAddonRef.current?.fit() + sendResize() + } catch (error) { + console.error("resize error", error) + } + }) + }, [sendResize]) useEffect(() => { - const container = terminalIdRef.current - if (!container) return + const shell = shellRef.current + const screen = screenRef.current + if (!shell || !screen) return + let active = true const terminal = new Terminal({ cursorBlink: true, - fontSize: 16, + fontSize: window.innerWidth <= 640 ? 13 : 16, + scrollback: 10000, + scrollOnUserInput: true, }) + const fitAddon = new FitAddon() + fitAddonRef.current = fitAddon + terminal.loadAddon(fitAddon) + terminal.open(screen) + terminal.element?.setAttribute("aria-label", "Interactive terminal") + terminalRef.current = terminal + const url = new URL(wsUrl, window.location.origin) url.protocol = url.protocol.replace("http", "ws") const ws = new WebSocket(url) ws.binaryType = "arraybuffer" - - terminalRef.current = terminal wsRef.current = ws + pasteInProgressRef.current = false + setPasteBusy(false) + updateControl(false) + setConnectionState("connecting") - const attachAddon = new AttachAddon(ws) - terminal.loadAddon(attachAddon) - terminal.loadAddon(fitAddon) - terminal.open(container) - window.addEventListener("resize", onResize) + const sendText = (data: string, applyControl: boolean) => { + if (ws.readyState !== WebSocket.OPEN) return + let outgoing = data + if (applyControl && controlActiveRef.current) { + outgoing = controlSequenceForInput(data) ?? data + updateControl(false) + } + ws.send(outgoing) + terminal.focus() + } + + const dataSubscription = terminal.onData((data) => { + sendText(data, !pasteInProgressRef.current) + }) + const binarySubscription = terminal.onBinary((data) => { + if (ws.readyState !== WebSocket.OPEN) return + const message = new Uint8Array(data.length + 1) + for (let index = 0; index < data.length; index += 1) { + message[index + 1] = data.charCodeAt(index) & 0xff + } + ws.send(message) + }) ws.onopen = () => { - onResize() + if (!active) return + lastSizeRef.current = "" + setConnectionState("connected") + fit() + terminal.focus() + } + ws.onmessage = (event) => { + if (!active) return + if (event.data instanceof ArrayBuffer) { + terminal.write(new Uint8Array(event.data)) + } else if (typeof event.data === "string") { + terminal.write(event.data) + } } ws.onclose = () => { - terminal.dispose() + if (!active) return + pasteRequestRef.current += 1 + setPasteBusy(false) + setConnectionState("disconnected") setClose(true) } - ws.onerror = (e) => { - console.error(e) - toast("Websocket error", { - description: "View console for details.", - }) + ws.onerror = (event) => { + console.error(event) + toast("Websocket error", { description: "View console for details." }) } + const updateViewport = () => { + const viewport = window.visualViewport + const viewportTop = viewport?.offsetTop ?? 0 + const viewportBottom = viewportTop + (viewport?.height ?? window.innerHeight) + const shellTop = Math.max(shell.getBoundingClientRect().top, viewportTop) + shell.style.setProperty( + "--terminal-available-height", + `${Math.max(220, Math.floor(viewportBottom - shellTop - 8))}px`, + ) + fit() + } + const observer = new ResizeObserver(fit) + observer.observe(screen) + window.addEventListener("resize", updateViewport) + window.addEventListener("orientationchange", updateViewport) + window.visualViewport?.addEventListener("resize", updateViewport) + window.visualViewport?.addEventListener("scroll", updateViewport) + updateViewport() + return () => { - window.removeEventListener("resize", onResize) + active = false + pasteRequestRef.current += 1 + window.cancelAnimationFrame(fitFrameRef.current) + observer.disconnect() + window.removeEventListener("resize", updateViewport) + window.removeEventListener("orientationchange", updateViewport) + window.visualViewport?.removeEventListener("resize", updateViewport) + window.visualViewport?.removeEventListener("scroll", updateViewport) + dataSubscription.dispose() + binarySubscription.dispose() ws.onopen = null + ws.onmessage = null ws.onclose = null ws.onerror = null ws.close() terminal.dispose() if (wsRef.current === ws) wsRef.current = null if (terminalRef.current === terminal) terminalRef.current = null + if (fitAddonRef.current === fitAddon) fitAddonRef.current = null } - }, [fitAddon, onResize, setClose, wsUrl]) + }, [fit, setClose, updateControl, wsUrl]) - return
+ const pasteClipboard = async () => { + if (pasteBusy || connectionState !== "connected") return + if (!navigator.clipboard?.readText) { + toast("Clipboard unavailable", { + description: "Use HTTPS and allow clipboard permission in your browser.", + }) + terminalRef.current?.focus() + return + } + + setPasteBusy(true) + const requestID = ++pasteRequestRef.current + try { + const text = await navigator.clipboard.readText() + if (pasteRequestRef.current !== requestID) return + if (!text) { + toast("Clipboard is empty") + return + } + if (new TextEncoder().encode(text).length > maxClipboardBytes) { + toast("Clipboard is too large", { + description: "Terminal paste is limited to 512 KiB per action.", + }) + return + } + const terminal = terminalRef.current + if (!terminal) return + pasteInProgressRef.current = true + try { + terminal.paste(text) + } finally { + pasteInProgressRef.current = false + } + terminal.focus() + } catch { + if (pasteRequestRef.current === requestID) { + toast("Could not read clipboard", { + description: "Allow clipboard access in your browser and try again.", + }) + } + } finally { + if (pasteRequestRef.current === requestID) setPasteBusy(false) + } + } + + const sendKey = (key: TerminalKey) => { + const ws = wsRef.current + if (!ws || ws.readyState !== WebSocket.OPEN) return + const withControl = controlActiveRef.current + updateControl(false) + ws.send(terminalKeySequence(key, withControl)) + terminalRef.current?.focus() + } + + return ( +
+
+
+ + + {terminalKeys.map(({ key, label, ariaLabel }) => ( + + ))} +
+
+ ) }, ) @@ -150,10 +337,10 @@ export const TerminalPage = () => { const terminal = useTerminal(id ? parseInt(id) : undefined) const terminalIdRef = useRef(null) return ( -
-
-

{`Terminal (${id})`}

-
+
+
+

{`Terminal (${id})`}

+
{ @@ -166,8 +353,8 @@ export const TerminalPage = () => { {terminal?.session_id ? ( ) : ( diff --git a/src/index.css b/src/index.css index 23c3a94..638e823 100644 --- a/src/index.css +++ b/src/index.css @@ -119,3 +119,119 @@ body, ::-webkit-scrollbar-thumb { @apply bg-border rounded-full border-[1px] border-solid border-transparent bg-clip-padding; } + +.terminal-shell { + --terminal-available-height: 70vh; + display: flex; + height: min(70vh, var(--terminal-available-height)); + min-height: 360px; + flex-direction: column; + overflow: hidden; + border: 1px solid hsl(var(--border)); + border-radius: var(--radius); + background: #09090b; +} + +.terminal-screen { + min-height: 0; + flex: 1 1 auto; + padding: 6px 3px 2px 6px; + touch-action: pan-y; +} + +.terminal-screen .xterm { + height: 100%; +} + +.terminal-screen .xterm-viewport { + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; +} + +.terminal-keyboard { + display: flex; + flex: 0 0 auto; + gap: 4px; + overflow-x: auto; + padding: 6px 6px calc(6px + env(safe-area-inset-bottom)); + border-top: 1px solid hsl(var(--border)); + background: hsl(var(--background)); + touch-action: pan-x; + -webkit-overflow-scrolling: touch; +} + +.terminal-key { + display: inline-flex; + min-width: 46px; + height: 42px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + border: 1px solid hsl(var(--border)); + border-radius: calc(var(--radius) - 2px); + background: hsl(var(--secondary)); + padding: 0 9px; + color: hsl(var(--secondary-foreground)); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.75rem; + font-weight: 500; + user-select: none; + -webkit-user-select: none; +} + +.terminal-key:hover:not(:disabled), +.terminal-key:focus-visible, +.terminal-key.is-active { + border-color: hsl(var(--ring)); + background: hsl(var(--accent)); + outline: none; +} + +.terminal-key:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.terminal-key-arrow { + min-width: 40px; + padding-inline: 5px; +} + +.terminal-key-paste { + min-width: 58px; +} + +.terminal-shell:fullscreen { + width: 100%; + height: 100%; + min-height: 0; + border: 0; + border-radius: 0; +} + +@media (max-width: 640px) { + .terminal-shell { + height: var(--terminal-available-height); + min-height: 220px; + } + + .terminal-keyboard { + gap: 2px; + padding-inline: 4px; + } + + .terminal-key { + min-width: 43px; + height: 44px; + padding-inline: 5px; + } + + .terminal-key-arrow { + min-width: 36px; + padding-inline: 2px; + } + + .terminal-key-paste { + min-width: 49px; + } +} diff --git a/src/lib/terminal-keys.ts b/src/lib/terminal-keys.ts new file mode 100644 index 0000000..a10e249 --- /dev/null +++ b/src/lib/terminal-keys.ts @@ -0,0 +1,66 @@ +export type TerminalKey = + | "escape" + | "tab" + | "arrowUp" + | "arrowDown" + | "arrowLeft" + | "arrowRight" + | "home" + | "end" + | "pageUp" + | "pageDown" + +const keySequences: Record = { + escape: "\x1b", + tab: "\t", + arrowUp: "\x1b[A", + arrowDown: "\x1b[B", + arrowRight: "\x1b[C", + arrowLeft: "\x1b[D", + home: "\x1b[H", + end: "\x1b[F", + pageUp: "\x1b[5~", + pageDown: "\x1b[6~", +} + +const controlKeySequences: Partial> = { + arrowUp: "\x1b[1;5A", + arrowDown: "\x1b[1;5B", + arrowRight: "\x1b[1;5C", + arrowLeft: "\x1b[1;5D", + home: "\x1b[1;5H", + end: "\x1b[1;5F", + pageUp: "\x1b[5;5~", + pageDown: "\x1b[6;5~", +} + +export function terminalKeySequence(key: TerminalKey, control = false): string { + return (control && controlKeySequences[key]) || keySequences[key] +} + +export function controlSequenceForInput(data: string): string | null { + if (data.length !== 1) return null + + const code = data.toUpperCase().charCodeAt(0) + if (code >= 65 && code <= 90) return String.fromCharCode(code - 64) + + const punctuation: Record = { + " ": 0, + "@": 0, + "[": 27, + "\\": 28, + "]": 29, + "^": 30, + _: 31, + "?": 127, + "2": 0, + "3": 27, + "4": 28, + "5": 29, + "6": 30, + "7": 31, + "8": 127, + } + const mapped = punctuation[data] + return mapped === undefined ? null : String.fromCharCode(mapped) +} diff --git a/src/test/terminal-keys.test.ts b/src/test/terminal-keys.test.ts new file mode 100644 index 0000000..db2ae64 --- /dev/null +++ b/src/test/terminal-keys.test.ts @@ -0,0 +1,18 @@ +import { controlSequenceForInput, terminalKeySequence } from "@/lib/terminal-keys" +import { expect, test } from "vitest" + +test("terminal mobile keys use xterm-compatible control sequences", () => { + expect(terminalKeySequence("escape")).toBe("\x1b") + expect(terminalKeySequence("tab")).toBe("\t") + expect(terminalKeySequence("arrowUp")).toBe("\x1b[A") + expect(terminalKeySequence("arrowLeft", true)).toBe("\x1b[1;5D") + expect(terminalKeySequence("pageDown", true)).toBe("\x1b[6;5~") +}) + +test("one-shot Ctrl converts printable input without corrupting paste chunks", () => { + expect(controlSequenceForInput("c")).toBe("\x03") + expect(controlSequenceForInput("D")).toBe("\x04") + expect(controlSequenceForInput("[")).toBe("\x1b") + expect(controlSequenceForInput("codex")).toBeNull() + expect(controlSequenceForInput("中文")).toBeNull() +}) diff --git a/src/test/terminal.test.tsx b/src/test/terminal.test.tsx index 0c11aeb..214cffc 100644 --- a/src/test/terminal.test.tsx +++ b/src/test/terminal.test.tsx @@ -1,46 +1,61 @@ -import { render } from "@testing-library/react" +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" import { afterEach, beforeEach, expect, test, vi } from "vitest" -vi.mock("sonner", () => ({ toast: () => undefined })) - -vi.mock("@/lib/utils", () => ({ - sleep: () => Promise.resolve(), - cn: (...args: unknown[]) => args.filter(Boolean).join(" "), +const terminalMocks = vi.hoisted(() => ({ + instances: [] as Array<{ + write: ReturnType + paste: ReturnType + focus: ReturnType + dataHandler?: (data: string) => void + binaryHandler?: (data: string) => void + }>, })) -const attachAddonInstances: { ws: WebSocket }[] = [] -vi.mock("@xterm/addon-attach", () => ({ - AttachAddon: class { - ws: WebSocket - constructor(ws: WebSocket) { - this.ws = ws - attachAddonInstances.push(this) - } - activate() {} - dispose() {} - }, -})) +const toastMock = vi.hoisted(() => vi.fn()) +vi.mock("sonner", () => ({ toast: toastMock })) vi.mock("@xterm/addon-fit", () => ({ FitAddon: class { activate() {} dispose() {} fit() {} - proposeDimensions() { - return { rows: 24, cols: 80 } - } }, })) vi.mock("@xterm/xterm", () => ({ Terminal: class { + cols = 80 + rows = 24 + element: HTMLElement | null = null + write = vi.fn() + focus = vi.fn() + dataHandler?: (data: string) => void + binaryHandler?: (data: string) => void + paste = vi.fn((data: string) => this.dataHandler?.(data)) + + constructor() { + terminalMocks.instances.push(this) + } + loadAddon() {} - open() {} + open(container: HTMLElement) { + this.element = document.createElement("div") + container.appendChild(this.element) + } dispose() {} + onData(handler: (data: string) => void) { + this.dataHandler = handler + return { dispose() {} } + } + onBinary(handler: (data: string) => void) { + this.binaryHandler = handler + return { dispose() {} } + } }, })) class FakeWebSocket { + static readonly OPEN = 1 static instances: FakeWebSocket[] = [] url: string binaryType = "arraybuffer" @@ -50,37 +65,42 @@ class FakeWebSocket { onmessage: ((ev: MessageEvent) => unknown) | null = null readyState = 0 closeCalls = 0 + send = vi.fn() constructor(url: string | URL) { this.url = url.toString() FakeWebSocket.instances.push(this) } + open() { + this.readyState = FakeWebSocket.OPEN + this.onopen?.(new Event("open")) + } + close() { this.closeCalls += 1 this.readyState = 3 } - - send() {} - addEventListener() {} - removeEventListener() {} - dispatchEvent() { - return true - } } beforeEach(() => { FakeWebSocket.instances = [] - attachAddonInstances.length = 0 + terminalMocks.instances = [] + toastMock.mockReset() ;(globalThis as { WebSocket: typeof WebSocket }).WebSocket = FakeWebSocket as unknown as typeof WebSocket + vi.stubGlobal( + "requestAnimationFrame", + vi.fn(() => 1), + ) + vi.stubGlobal("cancelAnimationFrame", vi.fn()) }) afterEach(() => { vi.clearAllMocks() }) -test("XtermComponent closes the previous WebSocket and re-attaches xterm when wsUrl changes", async () => { +test("XtermComponent closes the previous WebSocket and recreates xterm when wsUrl changes", async () => { const { XtermComponent } = await import("../components/terminal") const noop = () => undefined @@ -90,14 +110,55 @@ test("XtermComponent closes the previous WebSocket and re-attaches xterm when ws expect(FakeWebSocket.instances).toHaveLength(1) const firstSocket = FakeWebSocket.instances[0] - expect(attachAddonInstances).toHaveLength(1) - expect(attachAddonInstances[0].ws).toBe(firstSocket as unknown as WebSocket) + expect(terminalMocks.instances).toHaveLength(1) rerender() expect(FakeWebSocket.instances).toHaveLength(2) - const secondSocket = FakeWebSocket.instances[1] expect(firstSocket.closeCalls).toBeGreaterThanOrEqual(1) - expect(attachAddonInstances).toHaveLength(2) - expect(attachAddonInstances[1].ws).toBe(secondSocket as unknown as WebSocket) + expect(terminalMocks.instances).toHaveLength(2) +}) + +test("mobile controls send text keys and preserve one-shot Ctrl across xterm paste", async () => { + const { XtermComponent } = await import("../components/terminal") + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { readText: vi.fn().mockResolvedValue("first line\n第二行") }, + }) + render( undefined} />) + const socket = FakeWebSocket.instances[0] + act(() => socket.open()) + + const control = screen.getByRole("button", { name: "Control modifier for next key" }) + fireEvent.click(control) + fireEvent.click(screen.getByRole("button", { name: "Paste clipboard" })) + + const terminal = terminalMocks.instances[0] + await waitFor(() => expect(terminal.paste).toHaveBeenCalledWith("first line\n第二行")) + expect(control.getAttribute("aria-pressed")).toBe("true") + expect(socket.send).toHaveBeenCalledWith("first line\n第二行") + + fireEvent.click(screen.getByRole("button", { name: "Left arrow" })) + expect(socket.send).toHaveBeenLastCalledWith("\x1b[1;5D") + expect(control.getAttribute("aria-pressed")).toBe("false") +}) + +test("xterm output and binary input remain byte-safe without AttachAddon", async () => { + const { XtermComponent } = await import("../components/terminal") + render( undefined} />) + const socket = FakeWebSocket.instances[0] + act(() => socket.open()) + const terminal = terminalMocks.instances[0] + + const output = new window.Uint8Array(new window.ArrayBuffer(16)) + output.set(new TextEncoder().encode("stream 中文")) + act(() => socket.onmessage?.({ data: output.buffer } as MessageEvent)) + expect(terminal.write).toHaveBeenCalledWith(expect.any(Uint8Array)) + + terminal.binaryHandler?.("\x00\xff") + const binaryCall = socket.send.mock.calls.find(([data]) => ArrayBuffer.isView(data))?.[0] + expect(Array.from(binaryCall as Uint8Array)).toEqual([0, 0, 255]) + + fireEvent.click(screen.getByRole("button", { name: "Escape" })) + expect(socket.send).toHaveBeenLastCalledWith("\x1b") })