Spaces:
Sleeping
Sleeping
| // static/js/terminal.js v10 - PTY-backed terminal with xterm.js | |
| var term = null, ws = null, fitAddon = null, ctrlOn = false, termInitialized = false; | |
| var darkTermTheme = { | |
| background: '#0a0a0a', foreground: '#f8fafc', | |
| cursor: '#ffc107', cursorAccent: '#0a0a0a', | |
| selectionBackground: 'rgba(255, 193, 7, 0.3)', selectionForeground: '#ffffff', | |
| black: '#1e293b', red: '#ef4444', green: '#22c55e', yellow: '#ffc107', | |
| blue: '#3b82f6', magenta: '#a855f7', cyan: '#06b6d4', white: '#f8fafc', | |
| brightBlack: '#64748b', brightRed: '#f87171', brightGreen: '#4ade80', | |
| brightYellow: '#facc15', brightBlue: '#60a5fa', brightMagenta: '#c084fc', | |
| brightCyan: '#22d3ee', brightWhite: '#ffffff', | |
| }; | |
| var lightTermTheme = { | |
| background: '#ffffff', foreground: '#0f172a', | |
| cursor: '#d97706', cursorAccent: '#ffffff', | |
| selectionBackground: 'rgba(217, 119, 6, 0.3)', selectionForeground: '#ffffff', | |
| black: '#0f172a', red: '#dc2626', green: '#16a34a', yellow: '#d97706', | |
| blue: '#2563eb', magenta: '#9333ea', cyan: '#0891b2', white: '#f8fafc', | |
| brightBlack: '#64748b', brightRed: '#ef4444', brightGreen: '#22c55e', | |
| brightYellow: '#f59e0b', brightBlue: '#3b82f6', brightMagenta: '#a855f7', | |
| brightCyan: '#06b6d4', brightWhite: '#ffffff', | |
| }; | |
| function fitTerminal() { | |
| if (!term || !fitAddon) return; | |
| var c = document.getElementById('xterm-container'); | |
| if (!c || c.offsetWidth === 0 || c.offsetHeight === 0) return; | |
| try { fitAddon.fit(); } catch(e) {} | |
| } | |
| function updateConnStatus(s) { | |
| var dot = document.getElementById('connection-dot'), txt = document.getElementById('connection-text'); | |
| if (!dot || !txt) return; | |
| var c = { connected: 'var(--success)', disconnected: 'var(--error)', connecting: 'var(--accent)' }; | |
| var l = { connected: 'Online', disconnected: 'Offline', connecting: 'Connecting...' }; | |
| dot.parentElement.style.background = s === 'connected' ? 'var(--success-dim)' : s === 'connecting' ? 'var(--accent-dim)' : 'var(--error-dim)'; | |
| dot.parentElement.style.color = c[s] || c.disconnected; | |
| txt.textContent = l[s] || l.disconnected; | |
| } | |
| function cleanupTerm() { | |
| if (ws) { try { ws.close(); } catch(e) {} ws = null; } | |
| if (term) { try { term.dispose(); } catch(e) {} term = null; } | |
| fitAddon = null; | |
| termInitialized = false; | |
| } | |
| window.initTerminal = function() { | |
| if (!currentToken) return; | |
| var container = document.getElementById('xterm-container'); | |
| if (!container) return; | |
| // Already connected and working — just refocus | |
| if (term && ws && ws.readyState === WebSocket.OPEN && termInitialized) { | |
| setTimeout(function() { fitTerminal(); term.focus(); }, 50); | |
| return; | |
| } | |
| // WS dead but terminal exists — full cleanup and reconnect | |
| cleanupTerm(); | |
| if (typeof Terminal === 'undefined') { | |
| container.innerHTML = '<div style="padding:20px;color:var(--error);font-size:14px"><i class="fa-solid fa-exclamation-triangle"></i> xterm.js failed to load. Check your connection.</div>'; | |
| return; | |
| } | |
| var isLight = document.documentElement.classList.contains('light'); | |
| term = new Terminal({ | |
| cursorBlink: true, | |
| fontSize: 14, | |
| fontFamily: "'JetBrains Mono', 'Fira Code', 'Consolas', monospace", | |
| theme: isLight ? lightTermTheme : darkTermTheme, | |
| scrollback: 10000, | |
| allowProposedApi: true, | |
| drawBoldTextInBrightColors: true, | |
| minimumContrastRatio: 1, | |
| }); | |
| fitAddon = new FitAddon.FitAddon(); | |
| term.loadAddon(fitAddon); | |
| try { | |
| var wla = new WebLinksAddon.WebLinksAddon(); | |
| term.loadAddon(wla); | |
| } catch(e) {} | |
| term.open(container); | |
| termInitialized = true; | |
| // Force fit + focus after render | |
| setTimeout(function() { | |
| fitTerminal(); | |
| term.focus(); | |
| }, 100); | |
| // Wire up input → WebSocket BEFORE connecting | |
| // This ensures keystrokes are captured even during connection | |
| term.onData(function(data) { | |
| if (ws && ws.readyState === WebSocket.OPEN) { | |
| ws.send(JSON.stringify({type: 'input', data: data})); | |
| } | |
| }); | |
| term.onResize(function(size) { | |
| if (ws && ws.readyState === WebSocket.OPEN) { | |
| ws.send(JSON.stringify({type: 'resize', cols: size.cols, rows: size.rows})); | |
| } | |
| }); | |
| // Connect WebSocket | |
| var proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; | |
| updateConnStatus('connecting'); | |
| ws = new WebSocket(proto + '//' + location.host + '/ws/' + currentToken); | |
| ws.onopen = function() { | |
| updateConnStatus('connected'); | |
| try { | |
| ws.send(JSON.stringify({type: 'resize', cols: term.cols, rows: term.rows})); | |
| } catch(e) {} | |
| // CRITICAL: Focus terminal after WS connects | |
| setTimeout(function() { | |
| if (term) { | |
| term.focus(); | |
| } | |
| }, 50); | |
| }; | |
| ws.onmessage = function(e) { | |
| var msg; | |
| try { msg = JSON.parse(e.data); } catch(err) { return; } | |
| if (msg.type === 'output') { | |
| term.write(msg.data); | |
| } else if (msg.type === 'exit') { | |
| term.write('\r\n\x1b[33m[Process exited]\x1b[0m\r\n'); | |
| } else if (msg.type === 'error') { | |
| term.write('\r\n\x1b[31m' + (msg.msg || 'Error') + '\x1b[0m\r\n'); | |
| } | |
| }; | |
| ws.onclose = function() { | |
| updateConnStatus('disconnected'); | |
| if (term) term.write('\r\n\x1b[31m[Connection lost]\x1b[0m\r\n'); | |
| }; | |
| ws.onerror = function() { | |
| updateConnStatus('disconnected'); | |
| if (term) term.write('\r\n\x1b[31m[WebSocket error]\x1b[0m\r\n'); | |
| }; | |
| // Resize on window change | |
| var resizeTimeout; | |
| window.addEventListener('resize', function() { | |
| clearTimeout(resizeTimeout); | |
| resizeTimeout = setTimeout(fitTerminal, 150); | |
| }); | |
| // Click on terminal to focus | |
| container.addEventListener('click', function() { | |
| if (term) term.focus(); | |
| }); | |
| // Refocus when terminal view becomes active | |
| var obs = new MutationObserver(function() { | |
| var tv = document.getElementById('terminal-view'); | |
| if (tv && tv.classList.contains('active')) { | |
| setTimeout(function() { | |
| fitTerminal(); | |
| if (term) term.focus(); | |
| }, 150); | |
| } | |
| }); | |
| var tv = document.getElementById('terminal-view'); | |
| if (tv) obs.observe(tv, { attributes: true, attributeFilter: ['class'] }); | |
| }; | |
| window.sendSpecial = function(key) { | |
| if (!ws || ws.readyState !== WebSocket.OPEN || !term) return; | |
| var seq = ''; | |
| switch(key) { | |
| case 'ctrl': | |
| ctrlOn = !ctrlOn; | |
| document.getElementById('ctrl-btn').classList.toggle('ctrl-active', ctrlOn); | |
| return; | |
| case 'esc': seq = '\x1b'; break; | |
| case 'tab': seq = '\t'; break; | |
| case 'up': seq = '\x1b[A'; break; | |
| case 'down': seq = '\x1b[B'; break; | |
| case 'left': seq = '\x1b[D'; break; | |
| case 'right': seq = '\x1b[C'; break; | |
| case 'enter': seq = '\r'; break; | |
| } | |
| if (seq) ws.send(JSON.stringify({type: 'input', data: seq})); | |
| if (term) term.focus(); | |
| }; | |
| window.termClear = function() { if (term) term.clear(); }; | |
| window.restartTerminal = function() { | |
| cleanupTerm(); | |
| initTerminal(); | |
| }; | |
| window.updateTermTheme = function() { | |
| if (!term) return; | |
| term.options.theme = document.documentElement.classList.contains('light') ? lightTermTheme : darkTermTheme; | |
| }; | |
| // CTRL key combos on mobile | |
| document.addEventListener('keydown', function(e) { | |
| if (ctrlOn && e.key.length === 1 && !e.ctrlKey && !e.metaKey) { | |
| var code = e.key.toUpperCase().charCodeAt(0) - 64; | |
| if (ws && ws.readyState === WebSocket.OPEN) { | |
| ws.send(JSON.stringify({type: 'input', data: String.fromCharCode(code)})); | |
| } | |
| e.preventDefault(); | |
| ctrlOn = false; | |
| var btn = document.getElementById('ctrl-btn'); | |
| if (btn) btn.classList.remove('ctrl-active'); | |
| } | |
| }); | |