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>
138 lines
4.9 KiB
TypeScript
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 }
|
|
}
|