Deploy Ladill Hosting / deploy (push) Successful in 1m21s
The terminal Alpine component + xterm/xterm-addon-fit only ever existed in an out-of-band build; committed source never had them, so clean CI rebuilds shipped a bundle WITHOUT the terminal (blank panel). Add resources/js/hosting-terminal.js (window.hostingInteractiveTerminal, recovered from the last working build), import it from app.js, and add xterm deps to package.json/lock. Verified: a clean 'npm ci && vite build' now includes the component + xterm. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
557 lines
18 KiB
JavaScript
557 lines
18 KiB
JavaScript
// 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'}`}`,
|
|
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);
|
|
this.term.onData((data) => {
|
|
if (this.sessionId) {
|
|
this.queueInput(data);
|
|
}
|
|
});
|
|
|
|
// Delay initial fit to ensure container has dimensions
|
|
requestAnimationFrame(() => {
|
|
this.fitAddon.fit();
|
|
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.shouldCaptureTerminalInput(event)) {
|
|
return;
|
|
}
|
|
|
|
const text = event.clipboardData?.getData('text/plain') ?? '';
|
|
|
|
if (text === '') {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
event.stopImmediatePropagation?.();
|
|
this.queueInput(text.replace(/\r?\n/g, '\r'));
|
|
},
|
|
|
|
shouldCaptureTerminalInput(event) {
|
|
if (!this.sessionId || !this.terminalActive) {
|
|
return false;
|
|
}
|
|
|
|
const target = event.target;
|
|
const terminalElement = this.$refs.terminal;
|
|
|
|
if (terminalElement && target instanceof Node && terminalElement.contains(target)) {
|
|
return true;
|
|
}
|
|
|
|
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.$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(() => {});
|
|
},
|
|
};
|
|
};
|
|
|