// Lightweight Terminal Panel singleton (() => { class TerminalPanelImpl { constructor() { this.container = null; // external container passed by DrawerComponent this.panelEl = null; this.logEl = null; this.inputEl = null; this.socket = null; this.connectedIp = null; this.isOpen = false; this.resizeHandler = null; this.isMinimized = false; this.dockEl = null; this.dockBtnEl = null; this.lastNodeIp = null; } open(container, nodeIp) { try { this.container = container; if (!this.container) return; if (!this.panelEl) { this._buildPanel(); } this.lastNodeIp = nodeIp || this.lastNodeIp; // Reset any leftover inline positioning so CSS centering applies if (this.container) { this.container.style.right = ''; this.container.style.left = ''; this.container.style.top = ''; this.container.style.bottom = ''; this.container.style.width = ''; } if (!this.isMinimized) { if (this.panelEl) { this.panelEl.classList.remove('minimized'); this.panelEl.classList.remove('visible'); requestAnimationFrame(() => { this.panelEl.classList.add('visible'); this.inputEl && this.inputEl.focus(); }); } this._hideDock(); } else { if (this.panelEl) { this.panelEl.classList.remove('visible'); this.panelEl.classList.add('minimized'); } this._showDock(); } this.isOpen = true; // Connect websocket if (nodeIp) { this._connect(nodeIp); } else if (this.lastNodeIp && !this.socket) { this._connect(this.lastNodeIp); } } catch (err) { console.error('TerminalPanel.open error:', err); } } close() { try { if (!this.isOpen) return; this.panelEl && this.panelEl.classList.remove('visible'); this.isOpen = false; this.isMinimized = false; if (this.panelEl) this.panelEl.classList.remove('minimized'); this._hideDock(); if (this.socket) { try { this.socket.close(); } catch (_) {} this.socket = null; } } catch (err) { console.error('TerminalPanel.close error:', err); } } _buildPanel() { // Ensure container baseline positioning this.container.classList.add('terminal-panel-container'); // Create panel DOM this.panelEl = document.createElement('div'); this.panelEl.className = 'terminal-panel'; this.panelEl.innerHTML = `
Terminal

                
`; this.container.appendChild(this.panelEl); this.logEl = this.panelEl.querySelector('.terminal-log'); this.inputEl = this.panelEl.querySelector('.terminal-input'); const sendBtn = this.panelEl.querySelector('.terminal-send-btn'); const closeBtn = this.panelEl.querySelector('.terminal-close-btn'); const clearBtn = this.panelEl.querySelector('.terminal-clear-btn'); const minimizeBtn = this.panelEl.querySelector('.terminal-minimize-btn'); const sendHandler = () => { const value = (this.inputEl && this.inputEl.value) || ''; if (!value) return; this._send(value); this.inputEl.value = ''; }; if (sendBtn) sendBtn.addEventListener('click', (e) => { e.stopPropagation(); sendHandler(); }); if (this.inputEl) this.inputEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); sendHandler(); } }); if (closeBtn) closeBtn.addEventListener('click', (e) => { e.stopPropagation(); this.close(); }); if (clearBtn) clearBtn.addEventListener('click', (e) => { e.stopPropagation(); this._clear(); }); if (minimizeBtn) minimizeBtn.addEventListener('click', (e) => { e.stopPropagation(); this.minimize(); }); } _connect(nodeIp) { try { const titleEl = this.panelEl.querySelector('.terminal-title'); if (titleEl) titleEl.textContent = `Terminal — ${nodeIp}`; // Close previous socket if switching node if (this.socket) { try { this.socket.close(); } catch (_) {} this.socket = null; } const protocol = (window.location && window.location.protocol === 'https:') ? 'wss' : 'ws'; const url = `${protocol}://${nodeIp}/ws`; this.connectedIp = nodeIp; this._appendLine(`[connecting] ${url}`); const ws = new WebSocket(url); this.socket = ws; ws.addEventListener('open', () => { this._appendLine('[open] WebSocket connection established'); }); ws.addEventListener('message', (evt) => { let dataStr = typeof evt.data === 'string' ? evt.data : '[binary]'; // Decode any HTML entities so JSON isn't shown as " etc. dataStr = this._decodeHtmlEntities(dataStr); // Try to pretty-print JSON if applicable const trimmed = dataStr.trim(); if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) { try { const obj = JSON.parse(trimmed); const pretty = JSON.stringify(obj, null, 2); this._appendLine(pretty); return; } catch (_) { // fall through if not valid JSON } } this._appendLine(dataStr); }); ws.addEventListener('error', (evt) => { this._appendLine('[error] WebSocket error'); }); ws.addEventListener('close', () => { this._appendLine('[close] WebSocket connection closed'); }); } catch (err) { console.error('TerminalPanel._connect error:', err); this._appendLine(`[error] ${err.message || err}`); } } minimize() { try { if (!this.panelEl || this.isMinimized) return; this.panelEl.classList.remove('visible'); this.panelEl.classList.add('minimized'); this.isMinimized = true; this.isOpen = true; this._showDock(); } catch (err) { console.error('TerminalPanel.minimize error:', err); } } restore() { try { if (!this.panelEl || !this.isMinimized) return; this.panelEl.classList.remove('minimized'); requestAnimationFrame(() => { this.panelEl.classList.add('visible'); this.inputEl && this.inputEl.focus(); }); this.isMinimized = false; this.isOpen = true; this._hideDock(); } catch (err) { console.error('TerminalPanel.restore error:', err); } } _ensureDock() { if (this.dockEl) return this.dockEl; const dock = document.createElement('div'); dock.className = 'terminal-dock'; const button = document.createElement('button'); button.type = 'button'; button.className = 'terminal-dock-btn'; button.title = 'Show Terminal'; button.setAttribute('aria-label', 'Show Terminal'); button.innerHTML = ` Terminal `; button.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); this.restore(); }); dock.appendChild(button); document.body.appendChild(dock); this.dockEl = dock; this.dockBtnEl = button; return dock; } _showDock() { const dock = this._ensureDock(); if (dock) dock.classList.add('visible'); } _hideDock() { if (this.dockEl) this.dockEl.classList.remove('visible'); } _send(text) { try { if (!this.socket || this.socket.readyState !== 1) { this._appendLine('[warn] Socket not open'); return; } this.socket.send(text); this._appendLine(`> ${text}`); } catch (err) { this._appendLine(`[error] ${err.message || err}`); } } _clear() { if (this.logEl) this.logEl.textContent = ''; } _appendLine(line) { if (!this.logEl) return; const timestamp = new Date().toLocaleTimeString(); this.logEl.textContent += `[${timestamp}] ${line}\n`; // Auto-scroll to bottom const bodyEl = this.panelEl.querySelector('.terminal-body'); if (bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight; } _decodeHtmlEntities(text) { try { const div = document.createElement('div'); div.innerHTML = text; return div.textContent || div.innerText || ''; } catch (_) { return text; } } } // Expose singleton API window.TerminalPanel = window.TerminalPanel || new TerminalPanelImpl(); })();