wraith/frontend/composables/useTerminal.ts
Vantz Stockwell c8868258d5 feat: Phase 2 — SSH terminal + SFTP sidebar in browser
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 17:21:11 -04:00

117 lines
3.6 KiB
TypeScript

import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { SearchAddon } from '@xterm/addon-search'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { WebglAddon } from '@xterm/addon-webgl'
import { useAuthStore } from '~/stores/auth.store'
import { useSessionStore } from '~/stores/session.store'
export function useTerminal() {
const auth = useAuthStore()
const sessions = useSessionStore()
let ws: WebSocket | null = null
function createTerminal(container: HTMLElement, options?: Partial<{ fontSize: number; scrollback: number }>) {
const term = new Terminal({
cursorBlink: true,
fontSize: options?.fontSize || 14,
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace",
scrollback: options?.scrollback || 10000,
theme: {
background: '#0a0a0f',
foreground: '#e4e4ef',
cursor: '#5c7cfa',
selectionBackground: '#364fc744',
},
})
const fitAddon = new FitAddon()
const searchAddon = new SearchAddon()
term.loadAddon(fitAddon)
term.loadAddon(searchAddon)
term.loadAddon(new WebLinksAddon())
term.open(container)
try {
term.loadAddon(new WebglAddon())
} catch {
// WebGL not available, fall back to canvas
}
fitAddon.fit()
const resizeObserver = new ResizeObserver(() => fitAddon.fit())
resizeObserver.observe(container)
return { term, fitAddon, searchAddon, resizeObserver }
}
function connectToHost(hostId: number, hostName: string, protocol: 'ssh', color: string | null, term: Terminal, fitAddon: FitAddon) {
const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/terminal?token=${auth.token}`
ws = new WebSocket(wsUrl)
ws.onopen = () => {
ws!.send(JSON.stringify({ type: 'connect', hostId }))
}
ws.onmessage = (event) => {
const msg = JSON.parse(event.data)
switch (msg.type) {
case 'connected':
sessions.addSession({ id: msg.sessionId, hostId, hostName, protocol, color, active: true })
// Send initial terminal size
ws!.send(JSON.stringify({ type: 'resize', sessionId: msg.sessionId, cols: term.cols, rows: term.rows }))
break
case 'data':
term.write(msg.data)
break
case 'disconnected':
sessions.removeSession(msg.sessionId)
break
case 'host-key-verify':
// Auto-accept for now — full UX in polish phase
ws!.send(JSON.stringify({ type: 'host-key-accept' }))
break
case 'error':
term.write(`\r\n\x1b[31mError: ${msg.message}\x1b[0m\r\n`)
break
}
}
ws.onclose = () => {
term.write('\r\n\x1b[33mConnection closed.\x1b[0m\r\n')
}
// Terminal input → WebSocket
term.onData((data) => {
if (ws?.readyState === WebSocket.OPEN) {
const sessionId = sessions.activeSession?.id
if (sessionId) {
ws.send(JSON.stringify({ type: 'data', sessionId, data }))
}
}
})
// Terminal resize → WebSocket
term.onResize(({ cols, rows }) => {
if (ws?.readyState === WebSocket.OPEN) {
const sessionId = sessions.activeSession?.id
if (sessionId) {
ws.send(JSON.stringify({ type: 'resize', sessionId, cols, rows }))
}
}
})
return ws
}
function disconnect(sessionId: string) {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'disconnect', sessionId }))
}
sessions.removeSession(sessionId)
}
return { createTerminal, connectToHost, disconnect }
}