Commit the control-panel terminal frontend (xterm) so clean builds keep it
Deploy Ladill Hosting / deploy (push) Successful in 1m21s
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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ff3959b734
commit
e86f95ddc7
Generated
+21
-2
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "ladill-email",
|
"name": "ladill-hosting",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
@@ -7,7 +7,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alpinejs/collapse": "^3.15.12",
|
"@alpinejs/collapse": "^3.15.12",
|
||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"alpinejs": "^3.15.12"
|
"alpinejs": "^3.15.12",
|
||||||
|
"xterm": "^5.3.0",
|
||||||
|
"xterm-addon-fit": "^0.8.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
@@ -2489,6 +2491,23 @@
|
|||||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xterm": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==",
|
||||||
|
"deprecated": "This package is now deprecated. Move to @xterm/xterm instead.",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/xterm-addon-fit": {
|
||||||
|
"version": "0.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.8.0.tgz",
|
||||||
|
"integrity": "sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==",
|
||||||
|
"deprecated": "This package is now deprecated. Move to @xterm/addon-fit instead.",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"xterm": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/y18n": {
|
"node_modules/y18n": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
|
|||||||
+3
-1
@@ -17,6 +17,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alpinejs/collapse": "^3.15.12",
|
"@alpinejs/collapse": "^3.15.12",
|
||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"alpinejs": "^3.15.12"
|
"alpinejs": "^3.15.12",
|
||||||
|
"xterm": "^5.3.0",
|
||||||
|
"xterm-addon-fit": "^0.8.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import './bootstrap';
|
import './bootstrap';
|
||||||
|
|
||||||
|
// Registers window.hostingInteractiveTerminal (xterm.js) used by the control-panel
|
||||||
|
// terminal blade. Must stay imported so clean builds include the terminal.
|
||||||
|
import './hosting-terminal';
|
||||||
|
|
||||||
import { registerLadillConfirmStore, registerLadillModalHelpers } from './ladill-modals';
|
import { registerLadillConfirmStore, registerLadillModalHelpers } from './ladill-modals';
|
||||||
import { registerLadillSearchShortcut } from './ladill-search-shortcut';
|
import { registerLadillSearchShortcut } from './ladill-search-shortcut';
|
||||||
import { registerLadillDomainPurchase } from './ladill-domain-purchase';
|
import { registerLadillDomainPurchase } from './ladill-domain-purchase';
|
||||||
|
|||||||
@@ -0,0 +1,556 @@
|
|||||||
|
// 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(() => {});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
Reference in New Issue
Block a user