MVP: Go daemon, password auth, persistent PTY terminals, React/xterm UI, PWA
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
190604247f
commit
436732dac0
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
import { wsURL } from './api'
|
||||
|
||||
export interface TermHandle { input: (s: string) => void; armCtrl: (v: boolean) => void }
|
||||
|
||||
interface Props {
|
||||
id: string; active: boolean
|
||||
register: (h: TermHandle | null) => void
|
||||
onStatus: (ok: boolean) => void
|
||||
onExit: () => void
|
||||
}
|
||||
|
||||
const THEME = {
|
||||
background: '#0d1117', foreground: '#d0d7de', cursor: '#d0d7de', selectionBackground: '#264f78',
|
||||
black: '#0d1117', red: '#ff7b72', green: '#3fb950', yellow: '#d29922', blue: '#58a6ff',
|
||||
magenta: '#bc8cff', cyan: '#39c5cf', white: '#b1bac4', brightBlack: '#6e7681', brightRed: '#ffa198',
|
||||
brightGreen: '#56d364', brightYellow: '#e3b341', brightBlue: '#79c0ff', brightMagenta: '#d2a8ff',
|
||||
brightCyan: '#56d4dd', brightWhite: '#f0f6fc',
|
||||
}
|
||||
|
||||
export default function TerminalPane({ id, active, register, onStatus, onExit }: Props) {
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
const activeRef = useRef(active)
|
||||
activeRef.current = active
|
||||
const cbs = useRef({ onStatus, onExit })
|
||||
cbs.current = { onStatus, onExit }
|
||||
const fitRef = useRef<() => void>(() => {})
|
||||
|
||||
useEffect(() => {
|
||||
const el = box.current!
|
||||
const term = new Terminal({
|
||||
theme: THEME, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',
|
||||
fontSize: window.matchMedia('(pointer: coarse)').matches ? 13 : 14,
|
||||
cursorBlink: true, scrollback: 5000, allowProposedApi: true,
|
||||
})
|
||||
const fit = new FitAddon()
|
||||
term.loadAddon(fit)
|
||||
term.loadAddon(new WebLinksAddon())
|
||||
term.open(el)
|
||||
|
||||
let ws: WebSocket | null = null
|
||||
let closed = false
|
||||
let ctrlArmed = false
|
||||
let retry = 0
|
||||
let retryTimer: number | undefined
|
||||
let lastSize = ''
|
||||
const enc = new TextEncoder()
|
||||
|
||||
const send = (s: string) => { if (ws && ws.readyState === WebSocket.OPEN) ws.send(enc.encode(s)) }
|
||||
const sendResize = () => {
|
||||
const key = `${term.cols}x${term.rows}`
|
||||
if (key === lastSize) return
|
||||
lastSize = key
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
|
||||
}
|
||||
const doFit = () => {
|
||||
if (!activeRef.current || el.clientWidth === 0 || el.clientHeight === 0) return
|
||||
fit.fit()
|
||||
sendResize()
|
||||
}
|
||||
|
||||
const connect = () => {
|
||||
if (closed) return
|
||||
const sock = new WebSocket(wsURL(id))
|
||||
sock.binaryType = 'arraybuffer'
|
||||
ws = sock
|
||||
sock.onopen = () => { retry = 0; cbs.current.onStatus(true); lastSize = ''; doFit() }
|
||||
sock.onmessage = (ev) => {
|
||||
if (ev.data instanceof ArrayBuffer) { term.write(new Uint8Array(ev.data)); return }
|
||||
try {
|
||||
const m = JSON.parse(ev.data)
|
||||
if (m.type === 'exit') { term.write('\r\n\x1b[90m[process exited' + (m.code !== undefined ? ` (${m.code})` : '') + ']\x1b[0m\r\n'); closed = true; cbs.current.onExit() }
|
||||
if (m.type === 'closed') closed = true
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
sock.onclose = () => {
|
||||
if (ws !== sock) return
|
||||
cbs.current.onStatus(false)
|
||||
if (closed) return
|
||||
retryTimer = window.setTimeout(connect, Math.min(1000 * 2 ** retry++, 15000))
|
||||
}
|
||||
sock.onerror = () => sock.close()
|
||||
}
|
||||
connect()
|
||||
|
||||
term.onData((d) => {
|
||||
if (ctrlArmed && d.length === 1) {
|
||||
ctrlArmed = false
|
||||
const c = d.toUpperCase().charCodeAt(0)
|
||||
if (c >= 64 && c <= 95) { send(String.fromCharCode(c & 0x1f)); return }
|
||||
}
|
||||
send(d)
|
||||
})
|
||||
term.onBinary((d) => send(d))
|
||||
term.onResize(sendResize)
|
||||
|
||||
fitRef.current = () => { doFit(); term.focus() }
|
||||
const ro = new ResizeObserver(() => doFit())
|
||||
ro.observe(el)
|
||||
const onVis = () => { if (document.visibilityState === 'visible' && ws && ws.readyState !== WebSocket.OPEN && !closed) { clearTimeout(retryTimer); connect() } }
|
||||
document.addEventListener('visibilitychange', onVis)
|
||||
|
||||
register({ input: send, armCtrl: (v) => { ctrlArmed = v; term.focus() } })
|
||||
|
||||
return () => {
|
||||
register(null)
|
||||
closed = true
|
||||
clearTimeout(retryTimer)
|
||||
document.removeEventListener('visibilitychange', onVis)
|
||||
ro.disconnect()
|
||||
ws?.close()
|
||||
term.dispose()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id])
|
||||
|
||||
// Refit + focus whenever this pane becomes the visible one (after the display change applies).
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
const t = window.setTimeout(() => fitRef.current(), 0)
|
||||
return () => clearTimeout(t)
|
||||
}, [active])
|
||||
|
||||
return <div ref={box} className="term" style={{ display: active ? 'block' : 'none' }} />
|
||||
}
|
||||
Reference in New Issue
Block a user