162 lines
6.6 KiB
TypeScript
162 lines
6.6 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
import { Terminal } from '@xterm/xterm'
|
|
import '@xterm/xterm/css/xterm.css'
|
|
import { FitAddon } from '@xterm/addon-fit'
|
|
import { WebLinksAddon } from '@xterm/addon-web-links'
|
|
import { wsURL } from './api'
|
|
import { Spinner } from './ui'
|
|
|
|
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
|
|
}
|
|
|
|
import { currentThemeId, themeById } from './themes'
|
|
|
|
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>(() => {})
|
|
const [conn, setConn] = useState<'connecting' | 'open' | 'reconnecting' | 'exited'>('connecting')
|
|
|
|
useEffect(() => {
|
|
const el = box.current!
|
|
const term = new Terminal({
|
|
theme: themeById(currentThemeId()).xterm, 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 queued: string[] = []
|
|
const send = (s: string) => { if (ws && ws.readyState === WebSocket.OPEN) ws.send(enc.encode(s)); else queued.push(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()
|
|
}
|
|
|
|
// The address is fixed when the pane opens, not rebuilt on each attempt: it carries which
|
|
// machine this terminal belongs to, and the console may have moved to another machine
|
|
// before a reconnect fires — which had the hub's terminals being asked for on a node.
|
|
const url = wsURL(id)
|
|
const connect = () => {
|
|
if (closed) return
|
|
const sock = new WebSocket(url)
|
|
sock.binaryType = 'arraybuffer'
|
|
ws = sock
|
|
sock.onopen = () => { retry = 0; setConn('open'); cbs.current.onStatus(true); lastSize = ''; doFit(); while (queued.length) sock.send(enc.encode(queued.shift()!)) }
|
|
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; setConn('exited'); cbs.current.onExit() }
|
|
if (m.type === 'closed') closed = true
|
|
} catch { /* ignore */ }
|
|
}
|
|
sock.onclose = () => {
|
|
if (ws !== sock || closed) return
|
|
cbs.current.onStatus(false)
|
|
setConn('reconnecting')
|
|
retryTimer = window.setTimeout(connect, Math.min(1000 * 2 ** retry++, 15000))
|
|
}
|
|
sock.onerror = () => sock.close()
|
|
}
|
|
connect()
|
|
|
|
// Ctrl+C / Ctrl+V as on any desktop. Returning false tells xterm to leave the key alone,
|
|
// and *not* preventing the default lets the browser fire its own copy / paste event —
|
|
// which xterm answers with its selection, or pastes (bracketed, if the shell asked). That
|
|
// path needs no navigator.clipboard, so it works over plain http on the LAN as well.
|
|
term.attachCustomKeyEventHandler((e) => {
|
|
if (e.type !== 'keydown') return true
|
|
const k = e.key.toLowerCase(), ctrl = e.ctrlKey && !e.altKey && !e.metaKey
|
|
const copy = (ctrl && k === 'c' && (e.shiftKey || term.hasSelection())) || (ctrl && k === 'insert')
|
|
const paste = (ctrl && k === 'v') || (e.shiftKey && !ctrl && k === 'insert')
|
|
if (copy) {
|
|
if (!term.hasSelection()) { e.preventDefault(); return false } // Ctrl+Shift+C with nothing selected: not devtools, not ^C
|
|
if (e.shiftKey) { e.preventDefault(); document.execCommand('copy') } // Chrome keeps Ctrl+Shift+C for itself; copy by hand
|
|
if (navigator.clipboard) navigator.clipboard.writeText(term.getSelection()).catch(() => {})
|
|
window.setTimeout(() => term.clearSelection(), 50) // so the next Ctrl+C is an interrupt again
|
|
return false
|
|
}
|
|
if (paste) return false
|
|
return true
|
|
})
|
|
|
|
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)
|
|
|
|
const onTheme = (e: Event) => { term.options.theme = (e as CustomEvent).detail.xterm }
|
|
window.addEventListener('hamlab:theme', onTheme)
|
|
|
|
register({ input: send, armCtrl: (v) => { ctrlArmed = v; term.focus() } })
|
|
|
|
return () => {
|
|
register(null)
|
|
closed = true
|
|
clearTimeout(retryTimer)
|
|
document.removeEventListener('visibilitychange', onVis)
|
|
window.removeEventListener('hamlab:theme', onTheme)
|
|
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 className="term" style={{ display: active ? 'block' : 'none' }}>
|
|
<div ref={box} className="term-inner" />
|
|
{(conn === 'connecting' || conn === 'reconnecting') && (
|
|
<div className="term-overlay"><Spinner /> {conn === 'connecting' ? 'connecting…' : 'reconnecting…'}</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|