Files
peardock/libs/terminal.js
T

347 lines
10 KiB
JavaScript

/**
* 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<string, any>} */
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,
}