/** * Container shell terminal (modal) — production-grade xterm + FitAddon. */ import { manager, Methods } from '../client/manager.js' import { getTerminalCtor, getFitAddonCtor, defaultXtermOptions, decodePayload, createFitController, safeFit, } from './xtermUtils.js' import { createInputCoalescer } from './termInput.js' const Terminal = getTerminalCtor() const FitAddon = getFitAddonCtor() const terminalModal = document.getElementById('terminal-modal') const terminalTitle = document.getElementById('terminal-title') const terminalContainer = document.getElementById('terminal-container') const terminalHeader = document.querySelector('#terminal-modal .header') /** @type {Record} */ let terminalSessions = {} let activeContainerId = null let modalTerminalFontSize = 14 let modalTerminalTheme = 'dark' const terminalThemes = { dark: defaultXtermOptions().theme, light: { background: '#ffffff', foreground: '#0f172a', cursor: '#0f172a', selectionBackground: '#b3d4fc', selectionForeground: '#0f172a', }, 'solarized-dark': { background: '#002b36', foreground: '#839496', cursor: '#93a1a1', selectionBackground: '#073642', }, 'solarized-light': { background: '#fdf6e3', foreground: '#657b83', cursor: '#586e75', selectionBackground: '#eee8d5', }, monokai: { background: '#272822', foreground: '#f8f8f2', cursor: '#f8f8f0', selectionBackground: '#49483e', }, } // Modal height drag-resize let isResizing = false let startY = 0 let startHeight = 0 const killBtn = document.getElementById('kill-terminal-btn') if (killBtn) killBtn.addEventListener('click', killActiveTerminal) if (terminalHeader) { terminalHeader.addEventListener('mousedown', (e) => { if (e.target.closest('#kill-terminal-btn')) return if (!terminalModal) return isResizing = true startY = e.clientY startHeight = terminalModal.offsetHeight document.body.style.cursor = 'ns-resize' document.body.style.userSelect = 'none' e.preventDefault() }) } document.addEventListener('mousemove', (e) => { if (!isResizing || !terminalModal || !terminalContainer) return const deltaY = startY - e.clientY const newHeight = Math.max(180, Math.min(startHeight + deltaY, window.innerHeight * 0.9)) terminalModal.style.height = `${newHeight}px` terminalContainer.style.height = `${newHeight - 100}px` const session = terminalSessions[activeContainerId] session?.fitController?.schedule() }) document.addEventListener('mouseup', () => { if (!isResizing) return isResizing = false document.body.style.cursor = 'default' document.body.style.userSelect = '' const session = terminalSessions[activeContainerId] session?.fitController?.fitNow() }) function sendResize(containerId, cols, rows) { if (!manager.active?.connected || !cols || !rows) return manager.event(Methods.terminalResize, { containerId, cols, rows }) } function startTerminal(containerId, containerName) { if (!manager.active?.connected) { console.error('[ERROR] No active peer connection.') return } if (terminalSessions[containerId]) { switchTerminal(containerId) return } if (!terminalContainer) { console.error('[ERROR] #terminal-container missing') return } const xterm = new Terminal( defaultXtermOptions({ fontSize: modalTerminalFontSize, theme: terminalThemes[modalTerminalTheme] || terminalThemes.dark, }) ) const fitAddon = new FitAddon() xterm.loadAddon(fitAddon) const terminalDiv = document.createElement('div') terminalDiv.className = 'xterm-host' terminalDiv.style.cssText = 'width:100%;height:100%;display:none;' terminalContainer.appendChild(terminalDiv) xterm.open(terminalDiv) const fitController = createFitController(fitAddon, xterm, (cols, rows) => { sendResize(containerId, cols, rows) }) fitController.observe(terminalDiv) fitController.observe(terminalContainer) // Coalesce keystrokes → fewer RPC events; UTF-8 JSON (no base64) for text const inputCoalescer = createInputCoalescer(({ data, encoding }) => { if (!manager.active?.connected) return manager.event(Methods.terminalInput, { containerId, data, encoding: encoding || 'utf8', }) }) const onDataDisposable = xterm.onData((data) => { if (!manager.active?.connected) return inputCoalescer.push(data) }) // Binary paste path (rare) const onBinaryDisposable = xterm.onBinary?.((data) => { if (!manager.active?.connected) return inputCoalescer.pushBinary(data) }) terminalSessions[containerId] = { xterm, fitAddon, fitController, inputCoalescer, onDataDisposable, onBinaryDisposable, container: terminalDiv, name: containerName, } // Fit after layout, then start remote PTY with known size. // Server probes bash → sh → ash → … until a working shell is found. requestAnimationFrame(() => { const dims = safeFit(fitAddon, xterm) || { cols: xterm.cols, rows: xterm.rows } manager .request(Methods.startTerminal, { containerId, cols: dims.cols, rows: dims.rows, tty: true, }) .catch((err) => { xterm.writeln(`\r\n\x1b[31m[ERROR] ${err.message}\x1b[0m\r\n`) }) sendResize(containerId, dims.cols, dims.rows) }) switchTerminal(containerId) } function switchTerminal(containerId) { const session = terminalSessions[containerId] if (!session) return if (activeContainerId && activeContainerId !== containerId) { const prev = terminalSessions[activeContainerId] if (prev) prev.container.style.display = 'none' } session.container.style.display = 'block' if (terminalModal) terminalModal.style.display = 'flex' if (terminalTitle) { terminalTitle.textContent = `Terminal — ${session.name}` terminalTitle.dataset.containerId = containerId } activeContainerId = containerId // Fit when visible (critical for FitAddon) requestAnimationFrame(() => { session.fitController?.fitNow() session.xterm.focus() }) removeFromTray(containerId) } function appendTerminalOutput(data, containerId, encoding = 'base64') { const session = terminalSessions[containerId] if (!session) return const text = decodePayload(data, encoding) if (!text) return session.xterm.write(text) } function removeFromTray(containerId) { document.querySelector(`.tray-item[data-id="${containerId}"]`)?.remove() } function killActiveTerminal() { if (!activeContainerId) return const containerId = activeContainerId if (window.sendCommand) { window.sendCommand('killTerminal', { containerId }) } cleanUpTerminal(containerId) if (terminalModal) terminalModal.style.display = 'none' activeContainerId = null } function cleanUpTerminal(containerId) { const session = terminalSessions[containerId] if (!session) return try { session.inputCoalescer?.flush() session.inputCoalescer?.destroy() session.onDataDisposable?.dispose() session.onBinaryDisposable?.dispose?.() session.fitController?.disconnect() session.xterm?.dispose() } catch { // ignore } session.container?.parentNode?.removeChild(session.container) delete terminalSessions[containerId] } function cleanUpAllTerminals() { Object.keys(terminalSessions).forEach(cleanUpTerminal) terminalSessions = {} activeContainerId = null if (terminalModal) terminalModal.style.display = 'none' } function updateModalTerminalFontSizeDisplay() { const display = document.getElementById('modal-terminal-font-size-display') if (display) display.textContent = String(modalTerminalFontSize) } function applyModalTerminalTheme(theme) { modalTerminalTheme = theme const t = terminalThemes[theme] || terminalThemes.dark Object.values(terminalSessions).forEach((session) => { if (session.xterm) session.xterm.options.theme = t }) } function applyFontSizeAll(size) { modalTerminalFontSize = size Object.values(terminalSessions).forEach((session) => { if (session.xterm) { session.xterm.options.fontSize = size session.fitController?.fitNow() } }) updateModalTerminalFontSizeDisplay() } document.addEventListener('DOMContentLoaded', () => { document.getElementById('modal-terminal-font-decrease')?.addEventListener('click', () => { if (modalTerminalFontSize > 8) applyFontSizeAll(modalTerminalFontSize - 1) }) document.getElementById('modal-terminal-font-increase')?.addEventListener('click', () => { if (modalTerminalFontSize < 28) applyFontSizeAll(modalTerminalFontSize + 1) }) document.getElementById('modal-terminal-font-reset')?.addEventListener('click', () => { applyFontSizeAll(14) }) document.getElementById('modal-terminal-copy-btn')?.addEventListener('click', () => { const session = terminalSessions[activeContainerId] const selection = session?.xterm?.getSelection?.() if (selection) { navigator.clipboard.writeText(selection).catch(() => {}) } }) document.getElementById('modal-terminal-clear-btn')?.addEventListener('click', () => { const session = terminalSessions[activeContainerId] if (session?.xterm) session.xterm.clear() }) const themeSelect = document.getElementById('modal-terminal-theme-select') // Prefer saved settings default try { const pref = window.__peardockSettings?.terminalTheme if (pref && terminalThemes[pref]) { modalTerminalTheme = pref } } catch { // ignore } if (themeSelect) { themeSelect.value = modalTerminalTheme themeSelect.addEventListener('change', (e) => applyModalTerminalTheme(e.target.value)) } updateModalTerminalFontSizeDisplay() }) if (typeof window !== 'undefined') { window.__peardockApplyTerminalThemes = (termTheme, _dockerTheme) => { if (termTheme && terminalThemes[termTheme]) { applyModalTerminalTheme(termTheme) const themeSelect = document.getElementById('modal-terminal-theme-select') if (themeSelect) themeSelect.value = termTheme } } } export { startTerminal, appendTerminalOutput, switchTerminal, killActiveTerminal, cleanUpTerminal, cleanUpAllTerminals, }