forked from snxraven/peardock
347 lines
10 KiB
JavaScript
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,
|
|
}
|