feat(terminal): improve mobile controls and paste

This commit is contained in:
naiba committed 2026-08-12 02:26:58 +00:00
1 parent d4f3f998a3
commit ab8c803607
7 files changed
+559 -117

No files matched your search

+262 -75
View File
@@ -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<React.SetStateAction<boolean>>
}
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<HTMLDivElement, XtermProps & JSX.IntrinsicElements["div"]>(
({ wsUrl, setClose, ...props }, ref) => {
const terminalIdRef = useRef<HTMLDivElement>(null)
({ wsUrl, setClose, className = "", ...props }, ref) => {
const shellRef = useRef<HTMLDivElement>(null)
const screenRef = useRef<HTMLDivElement>(null)
const terminalRef = useRef<Terminal | null>(null)
const wsRef = useRef<WebSocket | null>(null)
const fitAddonRef = useRef<FitAddon | null>(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<ConnectionState>("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 <div ref={terminalIdRef} {...props} />
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 (
<div
ref={shellRef}
className={`terminal-shell ${className}`}
data-connection-state={connectionState}
{...props}
>
<div ref={screenRef} className="terminal-screen" />
<div className="terminal-keyboard" role="toolbar" aria-label="Terminal controls">
<button
type="button"
className="terminal-key terminal-key-paste"
disabled={connectionState !== "connected" || pasteBusy}
aria-label="Paste clipboard"
onClick={() => void pasteClipboard()}
>
{pasteBusy ? "Pasting…" : "Paste"}
</button>
<button
type="button"
className={`terminal-key ${controlActive ? "is-active" : ""}`}
aria-label="Control modifier for next key"
aria-pressed={controlActive}
disabled={connectionState !== "connected"}
onClick={() => {
updateControl(!controlActiveRef.current)
terminalRef.current?.focus()
}}
>
Ctrl
</button>
{terminalKeys.map(({ key, label, ariaLabel }) => (
<button
key={key}
type="button"
className={`terminal-key ${key.startsWith("arrow") ? "terminal-key-arrow" : ""}`}
aria-label={ariaLabel}
disabled={connectionState !== "connected"}
onClick={() => sendKey(key)}
>
{label}
</button>
))}
</div>
</div>
)
},
)
@@ -150,10 +337,10 @@ export const TerminalPage = () => {
const terminal = useTerminal(id ? parseInt(id) : undefined)
const terminalIdRef = useRef<HTMLDivElement>(null)
return (
<div className="px-8">
<div className="flex mt-6 mb-4">
<h1 className="flex-1 text-3xl font-bold tracking-tight">{`Terminal (${id})`}</h1>
<div className="flex ml-auto self-end sm:self-auto gap-2 flex-wrap shrink-0">
<div className="terminal-page px-3 sm:px-8">
<div className="flex mt-3 sm:mt-6 mb-3 sm:mb-4 items-center gap-2">
<h1 className="flex-1 text-xl sm:text-3xl font-bold tracking-tight">{`Terminal (${id})`}</h1>
<div className="flex ml-auto gap-2 shrink-0">
<IconButton
icon="expand"
onClick={async () => {
@@ -166,8 +353,8 @@ export const TerminalPage = () => {
{terminal?.session_id ? (
<XtermComponent
ref={terminalIdRef}
className="max-h-[60%] mb-5 overflow-auto"
wsUrl={`/api/v1/ws/terminal/${terminal?.session_id}`}
className="mb-3 sm:mb-5"
wsUrl={`/api/v1/ws/terminal/${terminal.session_id}`}
setClose={setOpen}
/>
) : (