// Browser terminal (xterm.js) Alpine factory for the hosting control panel. // Exposed as a global so the panel blade can use x-data="hostingInteractiveTerminal({...})". // NOTE: this frontend previously only existed in an out-of-band build and was // lost on clean CI rebuilds — keep it committed. import { Terminal } from 'xterm'; import { FitAddon } from 'xterm-addon-fit'; import 'xterm/css/xterm.css'; window.hostingInteractiveTerminal = (config = {}) => { console.log('[Terminal] hostingInteractiveTerminal called with config:', config); return { sessionId: null, offset: 0, term: null, fitAddon: null, pollTimer: null, resizeTimer: null, inputTimer: null, pendingInput: '', commandLine: '', overlayMessage: 'Connecting to shell...', canReconnect: false, statusLabel: 'Connecting', terminalActive: false, termHasOutput: false, currentPath: config.initialPath ?? '/public_html', currentPathLabel: `Starting in ${config.initialPath === '/' ? `/home/${config.username ?? ''}` : `/home/${config.username ?? ''}${config.initialPath ?? '/public_html'}`}`, // Full working-directory path shown in the toolbar; updated live from the // shell's OSC 7 cwd report as the user navigates. fullPath: config.initialPath === '/' ? `/home/${config.username ?? ''}` : `/home/${config.username ?? ''}${config.initialPath ?? '/public_html'}`, promptPath() { if (this.currentPath === '/' || !this.currentPath) { return '~'; } return `~${this.currentPath}`; }, writePrompt(command = '') { const prompt = `\x1b[36m${config.username ?? 'user'}@ladill:${this.promptPath()}$ \x1b[0m`; const suffix = command ? `${command}\r\n` : ''; this.term?.write(`${prompt}${suffix}`); }, init() { this.term = new Terminal({ cursorBlink: true, cursorStyle: 'bar', convertEol: true, allowTransparency: true, disableStdin: false, fontFamily: '"JetBrains Mono", "SF Mono", "Fira Code", "Monaco", "Consolas", monospace', fontSize: 13, lineHeight: 1.5, letterSpacing: 0, theme: { background: '#0a0a0a', foreground: '#e4e4e7', cursor: '#a1a1aa', cursorAccent: '#0a0a0a', selectionBackground: 'rgba(161, 161, 170, 0.2)', selectionForeground: '#fafafa', black: '#18181b', red: '#f87171', green: '#4ade80', yellow: '#fbbf24', blue: '#60a5fa', magenta: '#c084fc', cyan: '#22d3ee', white: '#e4e4e7', brightBlack: '#52525b', brightRed: '#fca5a5', brightGreen: '#86efac', brightYellow: '#fde047', brightBlue: '#93c5fd', brightMagenta: '#d8b4fe', brightCyan: '#67e8f9', brightWhite: '#fafafa', }, }); console.log('[Terminal] Initializing terminal...'); this.fitAddon = new FitAddon(); this.term.loadAddon(this.fitAddon); this.term.open(this.$refs.terminal); // The shell reports its working directory via OSC 7 (file://host/path); // capture it so the toolbar shows the live full path as the user cd's. this.term.parser?.registerOscHandler(7, (data) => { try { let p = String(data || '').replace(/^file:\/\/[^/]*/, ''); p = decodeURIComponent(p); if (p) { this.fullPath = p.replace(/\/$/, '') || '/'; } } catch (e) { /* ignore malformed cwd reports */ } return true; }); this.term.onData((data) => { if (this.sessionId) { this.queueInput(data); } }); // Delay initial fit to ensure container has dimensions requestAnimationFrame(() => { this.fitAddon.fit(); this.bindTerminalInputCapture(); this.term.focus(); }); // Use ResizeObserver for more reliable resize detection if (typeof ResizeObserver !== 'undefined') { this.resizeObserver = new ResizeObserver(() => this.queueResize()); this.resizeObserver.observe(this.$refs.terminal); } window.addEventListener('resize', () => this.queueResize()); window.addEventListener('beforeunload', () => this.shutdown(true)); this.start(); }, async start() { this.statusLabel = 'Connecting'; this.overlayMessage = 'Connecting to shell...'; this.canReconnect = false; try { const response = await fetch(config.startUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': config.csrfToken, }, body: JSON.stringify({ path: config.initialPath ?? '/public_html', cols: this.term.cols, rows: this.term.rows, }), }); const payload = await response.json(); if (!response.ok || !payload.session_id) { throw new Error(payload.message || 'Unable to start the browser terminal session.'); } if (payload.status === 'failed') { throw new Error(payload.error || 'Unable to start the browser terminal session.'); } this.sessionId = payload.session_id; this.offset = 0; this.statusLabel = payload.status === 'starting' ? 'Connecting' : 'Live'; this.overlayMessage = payload.status === 'starting' ? 'Connecting to shell...' : ''; this.canReconnect = false; this.queueResize(); this.poll(); // Delay focus to ensure terminal is fully rendered setTimeout(() => this.focusTerminal(), 100); } catch (error) { this.statusLabel = 'Unavailable'; this.overlayMessage = error.message || 'Unable to connect to the browser terminal.'; this.canReconnect = true; } }, buildUrl(template) { return String(template || '').replace('__SESSION__', this.sessionId ?? ''); }, bindTerminalInputCapture() { console.log('[Terminal] bindTerminalInputCapture called'); const terminalElement = this.$refs.terminal; if (!terminalElement || terminalElement.dataset.keyboardCaptureBound === 'true') { console.log('[Terminal] Already bound or no element'); return; } this.boundTerminalKeydownHandler = (event) => this.handleTerminalKeydown(event); this.boundTerminalPasteHandler = (event) => this.handleTerminalPaste(event); this.boundTerminalPointerHandler = (event) => { this.terminalActive = this.$refs.terminal?.contains(event.target) ?? false; }; window.addEventListener('keydown', this.boundTerminalKeydownHandler, true); window.addEventListener('paste', this.boundTerminalPasteHandler, true); window.addEventListener('pointerdown', this.boundTerminalPointerHandler, true); terminalElement.dataset.keyboardCaptureBound = 'true'; console.log('[Terminal] Input capture bound successfully'); }, queueInput(data) { if (!this.sessionId || !data) { return; } this.pendingInput += data; if (this.inputTimer) { return; } this.inputTimer = window.setTimeout(() => this.flushInput(), 16); }, async flushInput() { const payload = this.pendingInput; this.pendingInput = ''; this.inputTimer = null; if (!this.sessionId || payload === '') { return; } console.log('[Terminal] Sending input:', JSON.stringify(payload)); try { const response = await fetch(this.buildUrl(config.inputUrlTemplate), { method: 'POST', headers: { 'Content-Type': 'text/plain;charset=UTF-8', Accept: 'application/json', 'X-CSRF-TOKEN': config.csrfToken, }, body: payload, }); console.log('[Terminal] Input response:', response.status); } catch (e) { console.error('[Terminal] Input error:', e); this.term?.write('\r\n[Input delivery failed]\r\n'); } }, handleTerminalKeydown(event) { console.log('[Terminal] keydown:', event.key, 'terminalActive:', this.terminalActive, 'sessionId:', !!this.sessionId); if (!this.shouldCaptureTerminalInput(event)) { console.log('[Terminal] Not capturing input'); return; } const sequence = this.keyEventSequence(event); console.log('[Terminal] sequence:', sequence ? JSON.stringify(sequence) : 'null'); if (sequence === null) { return; } event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation?.(); this.queueInput(sequence); }, handleTerminalPaste(event) { if (!this.sessionId) { return; } const terminalElement = this.$refs.terminal; const target = event.target; const inTerminal = Boolean( terminalElement && target instanceof Node && (terminalElement === target || terminalElement.contains(target)) ); const termFocused = Boolean( this.term?.textarea && document.activeElement === this.term.textarea ); // Panel paste often targets the wrapper/div, not the xterm textarea. if (!this.terminalActive && !inTerminal && !termFocused) { return; } const text = event.clipboardData?.getData('text/plain') ?? ''; if (text === '') { return; } this.terminalActive = true; event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation?.(); // Bracketed paste keeps bash/readline from mangling long pasted commands. const normalized = text .replace(/\r\n/g, '\n') .replace(/\r/g, '\n') .replace(/\n/g, '\r'); this.queueInput(`\x1b[200~${normalized}\x1b[201~`); }, shouldCaptureTerminalInput(event) { if (!this.sessionId) { return false; } const target = event.target; const terminalElement = this.$refs.terminal; if (terminalElement && target instanceof Node && terminalElement.contains(target)) { this.terminalActive = true; return true; } if (this.term?.textarea && document.activeElement === this.term.textarea) { this.terminalActive = true; return true; } if (!this.terminalActive) { return false; } if (!(target instanceof HTMLElement)) { return false; } return !target.closest('input, textarea, select, button, [contenteditable="true"], a[href]'); }, keyEventSequence(event) { if (!this.sessionId || event.metaKey) { return null; } if (event.ctrlKey && !event.altKey) { const key = String(event.key || '').toLowerCase(); if (key >= 'a' && key <= 'z') { return String.fromCharCode(key.charCodeAt(0) - 96); } switch (key) { case ' ': case '@': return '\x00'; case '[': return '\x1b'; case '\\': return '\x1c'; case ']': return '\x1d'; case '^': return '\x1e'; case '_': return '\x1f'; default: return null; } } if (event.altKey && !event.ctrlKey && String(event.key || '').length === 1) { return `\x1b${event.key}`; } switch (event.key) { case 'Enter': return '\r'; case 'Backspace': return '\x7f'; case 'Tab': return event.shiftKey ? '\x1b[Z' : '\t'; case 'Escape': return '\x1b'; case 'ArrowUp': return '\x1b[A'; case 'ArrowDown': return '\x1b[B'; case 'ArrowRight': return '\x1b[C'; case 'ArrowLeft': return '\x1b[D'; case 'Home': return '\x1b[H'; case 'End': return '\x1b[F'; case 'Insert': return '\x1b[2~'; case 'Delete': return '\x1b[3~'; case 'PageUp': return '\x1b[5~'; case 'PageDown': return '\x1b[6~'; default: return event.ctrlKey || event.altKey || String(event.key || '').length !== 1 ? null : event.key; } }, poll() { if (!this.sessionId) { return; } const url = new URL(this.buildUrl(config.readUrlTemplate), window.location.origin); url.searchParams.set('offset', String(this.offset)); fetch(url.toString(), { headers: { Accept: 'application/json', 'X-CSRF-TOKEN': config.csrfToken, }, }) .then(async (response) => { const payload = await response.json().catch(() => ({})); console.log('[Terminal] Poll response:', response.status, 'output length:', payload.output?.length ?? 0, 'status:', payload.status); if (!response.ok) { throw new Error(payload.message || 'Unable to read terminal output.'); } if (typeof payload.output === 'string' && payload.output.length > 0) { console.log('[Terminal] Writing output:', JSON.stringify(payload.output.substring(0, 100))); this.term?.write(payload.output); this.termHasOutput = true; this.overlayMessage = ''; this.statusLabel = 'Live'; } if (typeof payload.offset === 'number') { this.offset = payload.offset; } if (payload.status === 'starting') { if (!this.termHasOutput) { this.statusLabel = 'Connecting'; this.overlayMessage = 'Connecting to shell...'; } this.pollTimer = window.setTimeout(() => this.poll(), 120); return; } if (payload.status === 'closed' || payload.status === 'failed') { this.statusLabel = payload.status === 'failed' ? 'Failed' : 'Closed'; this.overlayMessage = payload.error || 'Shell session ended.'; this.canReconnect = true; this.sessionId = null; return; } this.statusLabel = 'Live'; this.pollTimer = window.setTimeout(() => this.poll(), 120); }) .catch((error) => { this.statusLabel = 'Disconnected'; this.overlayMessage = error.message || 'Terminal connection lost.'; this.canReconnect = true; this.sessionId = null; }); }, queueResize() { if (!this.term || !this.fitAddon) { return; } window.clearTimeout(this.resizeTimer); this.resizeTimer = window.setTimeout(() => { this.fitAddon.fit(); if (!this.sessionId) { return; } fetch(this.buildUrl(config.resizeUrlTemplate), { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': config.csrfToken, }, body: JSON.stringify({ cols: this.term.cols, rows: this.term.rows, }), }).catch(() => {}); }, 120); }, async sendCommand(command) { const value = String(command || '').trim(); if (!value) { return; } this.commandLine = ''; this.focusCommandInput(); this.term?.write('\r\n'); this.writePrompt(value); try { const response = await fetch(config.runUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': config.csrfToken, }, body: JSON.stringify({ command: value, path: this.currentPath, }), }); const payload = await response.json().catch(() => ({})); const output = typeof payload.output === 'string' ? payload.output : (typeof payload.error === 'string' ? payload.error : ''); if (typeof payload.cwd === 'string' && payload.cwd.length > 0) { this.currentPath = payload.cwd; this.currentPathLabel = this.currentPath === '/' ? `/home/${config.username ?? ''}` : `/home/${config.username ?? ''}${this.currentPath}`; } if (payload.clear) { this.term?.reset(); } if (output) { this.term?.write(`${output.replace(/\n/g, '\r\n')}\r\n`); } if (!response.ok && !output) { this.term?.write('\x1b[31mCommand failed.\x1b[0m\r\n'); } this.writePrompt(); } catch (error) { this.term?.write(`\x1b[31m${error.message || 'Command failed.'}\x1b[0m\r\n`); this.writePrompt(); } }, submitCommand() { const command = String(this.commandLine || '').trim(); if (!command) { this.term?.focus(); return; } this.sendCommand(command); }, async restart() { this.shutdown(); this.term?.reset(); this.pendingInput = ''; this.commandLine = ''; this.offset = 0; this.terminalActive = false; this.termHasOutput = false; await this.start(); }, focusTerminal() { console.log('[Terminal] focusTerminal called'); if (this.term) { this.terminalActive = true; this.bindTerminalInputCapture(); this.$refs.terminal?.focus(); this.term.focus(); console.log('[Terminal] terminalActive set to true'); } }, focusCommandInput() { this.$nextTick(() => this.$refs.commandInput?.focus()); }, shutdown(keepalive = false) { if (this.pollTimer) { window.clearTimeout(this.pollTimer); this.pollTimer = null; } if (!this.sessionId) { return; } const sessionId = this.sessionId; this.sessionId = null; this.terminalActive = false; fetch(String(config.closeUrlTemplate || '').replace('__SESSION__', sessionId), { method: 'DELETE', headers: { Accept: 'application/json', 'X-CSRF-TOKEN': config.csrfToken, }, keepalive, }).catch(() => {}); }, }; };