132 lines
4.5 KiB
TypeScript
132 lines
4.5 KiB
TypeScript
|
|
import { useEffect, useRef, useState } from 'react'
|
||
|
|
import { Terminal as XTerm } from '@xterm/xterm'
|
||
|
|
import { FitAddon } from '@xterm/addon-fit'
|
||
|
|
import '@xterm/xterm/css/xterm.css'
|
||
|
|
import { api, getToken, type Integration } from '../lib/api'
|
||
|
|
|
||
|
|
const GOLD = '#C8A434'
|
||
|
|
const TEXT_SECONDARY = '#7A7D85'
|
||
|
|
|
||
|
|
export default function Terminal() {
|
||
|
|
const [hosts, setHosts] = useState<Integration[]>([])
|
||
|
|
const [activeHostId, setActiveHostId] = useState<number | null>(null)
|
||
|
|
const [connected, setConnected] = useState(false)
|
||
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
||
|
|
const termRef = useRef<XTerm | null>(null)
|
||
|
|
const fitRef = useRef<FitAddon | null>(null)
|
||
|
|
const wsRef = useRef<WebSocket | null>(null)
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
api.listIntegrations().then(({ integrations }) => {
|
||
|
|
setHosts(integrations.filter((i) => i.type === 'ssh'))
|
||
|
|
})
|
||
|
|
}, [])
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!containerRef.current) return
|
||
|
|
const term = new XTerm({
|
||
|
|
cursorBlink: true,
|
||
|
|
fontSize: 13,
|
||
|
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||
|
|
theme: { background: '#15161A', foreground: '#E8E6E0', cursor: GOLD },
|
||
|
|
})
|
||
|
|
const fit = new FitAddon()
|
||
|
|
term.loadAddon(fit)
|
||
|
|
term.open(containerRef.current)
|
||
|
|
fit.fit()
|
||
|
|
termRef.current = term
|
||
|
|
fitRef.current = fit
|
||
|
|
|
||
|
|
const onResize = () => fit.fit()
|
||
|
|
window.addEventListener('resize', onResize)
|
||
|
|
return () => {
|
||
|
|
window.removeEventListener('resize', onResize)
|
||
|
|
term.dispose()
|
||
|
|
wsRef.current?.close()
|
||
|
|
}
|
||
|
|
}, [])
|
||
|
|
|
||
|
|
function connect(hostId: number) {
|
||
|
|
wsRef.current?.close()
|
||
|
|
setActiveHostId(hostId)
|
||
|
|
setConnected(false)
|
||
|
|
const term = termRef.current
|
||
|
|
if (!term) return
|
||
|
|
term.reset()
|
||
|
|
term.writeln('Connecting…')
|
||
|
|
|
||
|
|
const token = getToken()
|
||
|
|
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||
|
|
const ws = new WebSocket(`${proto}://${window.location.host}/api/terminal?token=${encodeURIComponent(token ?? '')}`)
|
||
|
|
wsRef.current = ws
|
||
|
|
|
||
|
|
ws.onopen = () => {
|
||
|
|
ws.send(JSON.stringify({ type: 'connect', integrationId: hostId, cols: term.cols, rows: term.rows }))
|
||
|
|
}
|
||
|
|
ws.onmessage = (event) => {
|
||
|
|
const msg = JSON.parse(event.data)
|
||
|
|
if (msg.type === 'connected') {
|
||
|
|
setConnected(true)
|
||
|
|
term.reset()
|
||
|
|
} else if (msg.type === 'data') {
|
||
|
|
term.write(msg.data)
|
||
|
|
} else if (msg.type === 'error') {
|
||
|
|
term.writeln(`\r\n\x1b[31mError: ${msg.message}\x1b[0m`)
|
||
|
|
setConnected(false)
|
||
|
|
} else if (msg.type === 'closed') {
|
||
|
|
term.writeln('\r\n\x1b[33mConnection closed.\x1b[0m')
|
||
|
|
setConnected(false)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
ws.onclose = () => setConnected(false)
|
||
|
|
|
||
|
|
term.onData((data) => {
|
||
|
|
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data }))
|
||
|
|
})
|
||
|
|
term.onResize(({ cols, rows }) => {
|
||
|
|
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'resize', cols, rows }))
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="flex h-full gap-4">
|
||
|
|
<div className="w-56 shrink-0 overflow-y-auto rounded-lg border border-white/10 bg-white/5 p-3">
|
||
|
|
<p className="mb-2 text-xs font-medium uppercase tracking-wide" style={{ color: TEXT_SECONDARY }}>
|
||
|
|
SSH Hosts
|
||
|
|
</p>
|
||
|
|
{hosts.length === 0 && (
|
||
|
|
<p className="text-xs" style={{ color: TEXT_SECONDARY }}>
|
||
|
|
No SSH integrations configured. Add one in Settings → Integrations.
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
<div className="flex flex-col gap-1">
|
||
|
|
{hosts.map((h) => (
|
||
|
|
<button
|
||
|
|
key={h.id}
|
||
|
|
onClick={() => connect(h.id)}
|
||
|
|
className="rounded-md px-2 py-1.5 text-left text-sm transition-colors"
|
||
|
|
style={{
|
||
|
|
background: activeHostId === h.id ? 'rgba(200,164,52,0.15)' : 'transparent',
|
||
|
|
color: activeHostId === h.id ? GOLD : '#E8E6E0',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{h.name}
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex min-w-0 flex-1 flex-col rounded-lg border border-white/10 bg-[#15161A] p-2">
|
||
|
|
<div className="mb-2 flex items-center gap-2 px-1 text-xs" style={{ color: TEXT_SECONDARY }}>
|
||
|
|
<span
|
||
|
|
className="inline-block h-2 w-2 rounded-full"
|
||
|
|
style={{ background: connected ? '#2ECC71' : '#7A7D85' }}
|
||
|
|
/>
|
||
|
|
{activeHostId ? (connected ? 'Connected' : 'Disconnected') : 'Select a host to connect'}
|
||
|
|
</div>
|
||
|
|
<div ref={containerRef} className="min-h-0 flex-1" />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|