392 lines
15 KiB
JavaScript
392 lines
15 KiB
JavaScript
// 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;
|
|
this.fallbackContainer = null;
|
|
|
|
try {
|
|
this._onKeydown = this._onKeydown.bind(this);
|
|
document.addEventListener('keydown', this._onKeydown);
|
|
} catch (_) {
|
|
// ignore if document not available
|
|
}
|
|
}
|
|
|
|
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);
|
|
} else if (!this.socket && !this.lastNodeIp) {
|
|
this._updateTitle(null);
|
|
}
|
|
} 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 = `
|
|
<div class="terminal-header">
|
|
<div class="terminal-title">Terminal</div>
|
|
<div class="terminal-actions">
|
|
<button class="terminal-minimize-btn" title="Minimize" aria-label="Minimize">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
|
<path d="M5 19h14"/>
|
|
</svg>
|
|
</button>
|
|
<button class="terminal-close-btn" title="Close" aria-label="Close">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
|
<path d="M18 6L6 18M6 6l12 12"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="terminal-body">
|
|
<pre class="terminal-log"></pre>
|
|
</div>
|
|
<div class="terminal-input-row">
|
|
<input type="text" class="terminal-input" placeholder="Type and press Enter to send" />
|
|
<button class="terminal-clear-btn" title="Clear">Clear</button>
|
|
<button class="terminal-send-btn">Send</button>
|
|
</div>
|
|
`;
|
|
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 {
|
|
this._updateTitle(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 = `
|
|
<span class="terminal-dock-icon" aria-hidden="true">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M4 17l6-6-6-6"></path>
|
|
<path d="M12 19h8"></path>
|
|
</svg>
|
|
</span>
|
|
<span class="terminal-dock-label">Terminal</span>
|
|
`;
|
|
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');
|
|
}
|
|
|
|
_resolveContainer() {
|
|
if (this.container && document.body && document.body.contains(this.container)) {
|
|
return this.container;
|
|
}
|
|
|
|
const sharedDrawer = window.__sharedDrawerInstance;
|
|
if (sharedDrawer && sharedDrawer.terminalPanelContainer) {
|
|
return sharedDrawer.terminalPanelContainer;
|
|
}
|
|
|
|
if (this.fallbackContainer && document.body && document.body.contains(this.fallbackContainer)) {
|
|
return this.fallbackContainer;
|
|
}
|
|
|
|
if (typeof document !== 'undefined') {
|
|
const fallback = document.createElement('div');
|
|
fallback.className = 'terminal-panel-container';
|
|
document.body.appendChild(fallback);
|
|
this.fallbackContainer = fallback;
|
|
return fallback;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
_onKeydown(event) {
|
|
try {
|
|
if (!event || event.defaultPrevented) return;
|
|
if (event.key !== 't' && event.key !== 'T') return;
|
|
if (event.repeat) return;
|
|
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
|
|
|
const activeEl = document.activeElement;
|
|
if (activeEl) {
|
|
const tagName = activeEl.tagName;
|
|
const isEditable = activeEl.isContentEditable;
|
|
if (isEditable || tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT') {
|
|
return;
|
|
}
|
|
}
|
|
|
|
event.preventDefault();
|
|
this.toggleVisibility();
|
|
} catch (_) {
|
|
// swallow errors from key handler to avoid breaking global listeners
|
|
}
|
|
}
|
|
|
|
toggleVisibility() {
|
|
try {
|
|
if (this.isOpen && !this.isMinimized) {
|
|
this.minimize();
|
|
return;
|
|
}
|
|
|
|
if (this.isOpen && this.isMinimized) {
|
|
this.restore();
|
|
return;
|
|
}
|
|
|
|
const targetContainer = this._resolveContainer();
|
|
if (!targetContainer) return;
|
|
const targetIp = this.lastNodeIp || this.connectedIp || null;
|
|
this.open(targetContainer, targetIp);
|
|
} catch (err) {
|
|
console.error('TerminalPanel.toggleVisibility error:', err);
|
|
}
|
|
}
|
|
|
|
_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}`);
|
|
}
|
|
}
|
|
|
|
_updateTitle(nodeIp) {
|
|
if (!this.panelEl) return;
|
|
const titleEl = this.panelEl.querySelector('.terminal-title');
|
|
if (!titleEl) return;
|
|
titleEl.textContent = nodeIp ? `Terminal — ${nodeIp}` : 'Terminal';
|
|
}
|
|
|
|
_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();
|
|
})();
|
|
|
|
|