wraith/frontend/composables/useTerminal.ts
Vantz Stockwell aa457b54d4 fix: infinite remount loop — use stable key for session components
When replaceSession changed the session ID from pending-XXX to a
real UUID, Vue's :key="session.id" treated it as a new element,
destroyed and recreated TerminalInstance, which called connectToHost
again, got another UUID, replaced again — infinite loop.

Added a stable `key` field to sessions that never changes after
creation, used as the Vue :key instead of the mutable `id`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 01:51:31 -04:00

138 lines
4.9 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'
import { getTerminalTheme } from '~/composables/useTerminalThemes'
export function useTerminal() {
const auth = useAuthStore()
const sessions = useSessionStore()
let ws: WebSocket | null = null
function createTerminal(container: HTMLElement, options?: Partial<{ fontSize: number; scrollback: number; themeId: string }>) {
const themeId = options?.themeId || localStorage.getItem('wraith_terminal_theme') || 'wraith'
const theme = getTerminalTheme(themeId)
const term = new Terminal({
cursorBlink: true,
fontSize: options?.fontSize || parseInt(localStorage.getItem('wraith_font_size') || '14'),
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace",
scrollback: options?.scrollback || parseInt(localStorage.getItem('wraith_scrollback') || '10000'),
theme,
})
const fitAddon = new FitAddon()
const searchAddon = new SearchAddon()
term.loadAddon(fitAddon)
term.loadAddon(searchAddon)
term.loadAddon(new WebLinksAddon())
term.open(container)
try {
const webgl = new WebglAddon()
webgl.onContextLoss(() => { webgl.dispose() })
term.loadAddon(webgl)
} catch {
// WebGL not available, fall back to canvas
}
// Delay fit until container has layout dimensions
const safeFit = () => {
try {
if (container.offsetWidth > 0 && container.offsetHeight > 0) {
fitAddon.fit()
}
} catch { /* ignore fit errors on unmounted containers */ }
}
requestAnimationFrame(safeFit)
const resizeObserver = new ResizeObserver(() => safeFit())
resizeObserver.observe(container)
return { term, fitAddon, searchAddon, resizeObserver }
}
function connectToHost(hostId: number, hostName: string, protocol: 'ssh', color: string | null, pendingSessionId: string, term: Terminal, fitAddon: FitAddon) {
const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/api/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':
// Replace the pending placeholder with the real backend session
sessions.replaceSession(pendingSessionId, { key: pendingSessionId, 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`)
term.write('\x1b[33mPress any key to close this session.\x1b[0m\r\n')
// Mark this connection as failed so onData closes it
ws?.close()
break
}
}
ws.onclose = () => {
term.write('\r\n\x1b[33mConnection closed.\x1b[0m\r\n')
// Clean up pending session if connection never succeeded
if (pendingSessionId.startsWith('pending-')) {
const session = sessions.sessions.find(s => s.id === pendingSessionId)
if (session) {
// Session was never replaced with a real one — remove after short delay so user sees the error
setTimeout(() => sessions.removeSession(pendingSessionId), 3000)
}
}
}
// 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 }
}