Files
coolify/resources/js/terminal.js

1480 lines
65 KiB
JavaScript

import { Terminal } from '@xterm/xterm';
import '@xterm/xterm/css/xterm.css';
import {
MAX_TERMINAL_SESSION_SECONDS,
TERMINAL_SESSION_DANGER_SECONDS,
TERMINAL_SESSION_WARNING_SECONDS,
formatTerminalSessionRemainingTime,
} from './terminal-session-timer.js';
import { FitAddon } from '@xterm/addon-fit';
const terminalDebugParameter = new URLSearchParams(window.location.search).get('terminal-debug');
if (terminalDebugParameter === '1' || terminalDebugParameter === '0') {
localStorage.setItem('coolify-terminal-debug', terminalDebugParameter);
}
const terminalDebugEnabled = import.meta.env.DEV
|| localStorage.getItem('coolify-terminal-debug') === '1';
const baseApplicationTerminalTheme = {
black: '#675f70',
red: '#ef7272',
green: '#7bd88f',
yellow: '#e7bd68',
blue: '#85aacb',
magenta: '#c792ea',
cyan: '#72d5d0',
white: '#d8d2df',
brightBlack: '#8a8292',
brightRed: '#ff9b9b',
brightGreen: '#a5e7b2',
brightYellow: '#f2d596',
brightBlue: '#b0c8df',
brightMagenta: '#ddb3f4',
brightCyan: '#a7e8e4',
brightWhite: '#ffffff',
foreground: '#eee9f2',
background: '#00000000',
overviewRulerBorder: '#00000000',
};
function createApplicationTerminalTheme(accent, colors = {}) {
return {
...baseApplicationTerminalTheme,
cursor: accent,
cursorAccent: '#101012',
selectionBackground: `${accent}66`,
...colors,
};
}
function customThemeAccent() {
const color = localStorage.getItem('themeColor') || '#6b16ed';
if (!/^#[0-9a-f]{6}$/i.test(color)) {
return '#7c3aed';
}
const channels = color.match(/[a-f\d]{2}/gi).map((channel) => (
Math.round(parseInt(channel, 16) * 0.85 + 255 * 0.15)
));
return `#${channels.map((channel) => channel.toString(16).padStart(2, '0')).join('')}`;
}
function createSystemTerminalTheme() {
if (document.documentElement.dataset.theme === 'custom') {
return createApplicationTerminalTheme(customThemeAccent());
}
if (document.documentElement.classList.contains('dark')) {
return createApplicationTerminalTheme('#8C8E9C');
}
return createApplicationTerminalTheme('#52525b', {
black: '#18181b',
red: '#dc2626',
green: '#15803d',
yellow: '#a16207',
blue: '#2563eb',
magenta: '#9333ea',
cyan: '#0e7490',
white: '#52525b',
brightBlack: '#71717a',
brightWhite: '#18181b',
foreground: '#18181b',
});
}
const applicationTerminalThemes = {
'system': createSystemTerminalTheme(),
'shadows-midnight': createApplicationTerminalTheme('#6d7a7c', {
blue: '#7392ad',
cyan: '#7fa3a6',
brightBlue: '#9bb4c9',
brightCyan: '#a8c4c6',
}),
'shadows-golden-hour': createApplicationTerminalTheme('#bf8c3c', {
yellow: '#d9a759',
red: '#df7756',
brightYellow: '#edc987',
brightRed: '#efa086',
}),
'shadows-cosmic-purple': createApplicationTerminalTheme('#A76DBE', {
blue: '#8f86d9',
magenta: '#c58ad8',
brightBlue: '#b1a9ed',
brightMagenta: '#ddb0e9',
}),
'shadows-neon-glow': createApplicationTerminalTheme('#DB425A', {
red: '#ed5d72',
magenta: '#f35fc2',
brightRed: '#ff8c9d',
brightMagenta: '#ff93d7',
}),
'shadows-icy-mist': createApplicationTerminalTheme('#93b7c4', {
blue: '#8fb7d0',
cyan: '#9acbd0',
brightBlue: '#b9d5e5',
brightCyan: '#c0e3e5',
}),
'shadows-tropical-storm': createApplicationTerminalTheme('#1fa771', {
green: '#45c98b',
cyan: '#4ec7ad',
brightGreen: '#7de0ad',
brightCyan: '#80dfcc',
}),
'shadows-golden-nebula': createApplicationTerminalTheme('#d4a20e', {
yellow: '#e5bb35',
red: '#ee755f',
blue: '#718fc1',
brightYellow: '#f4d375',
}),
'shadows-cosmic-lagoon': createApplicationTerminalTheme('#00b5b8', {
blue: '#668de0',
magenta: '#ba6ad0',
cyan: '#38c6c8',
brightCyan: '#76e0e2',
}),
'shadows-neon-nebula': createApplicationTerminalTheme('#ff55aa', {
blue: '#6f91dd',
magenta: '#ff72c1',
cyan: '#51d5d5',
brightMagenta: '#ffa1d4',
}),
'shadows-transparent': createApplicationTerminalTheme('#8C8E9C'),
};
function logTerminal(level, message, ...context) {
if (!terminalDebugEnabled) {
return;
}
console[level](message, ...context);
}
export function initializeTerminalComponent() {
function terminalData() {
return {
fullscreen: false,
terminalActive: false,
starting: false,
message: '(connection closed)',
term: null,
fitAddon: null,
socket: null,
commandBuffer: '',
pendingWrites: 0,
paused: false,
MAX_PENDING_WRITES: 5,
keepAliveInterval: null,
reconnectInterval: null,
// Enhanced connection management
connectionState: 'disconnected', // 'connecting', 'connected', 'disconnected', 'reconnecting'
reconnectAttempts: 0,
maxReconnectAttempts: 10,
baseReconnectDelay: 1000,
maxReconnectDelay: 30000,
connectionTimeout: 10000,
connectionTimeoutId: null,
lastPingTime: null,
pingTimeout: 35000, // 5 seconds longer than ping interval
pingTimeoutId: null,
heartbeatMissed: 0,
maxHeartbeatMisses: 3,
// Command buffering for race condition prevention
pendingCommand: null,
// Last successfully sent SSH command — replayed after a transient reconnect
// so the PTY respawns automatically. Cleared on intentional terminations
// (pty-exited, unprocessable).
lastSentCommand: null,
// Resize handling
resizeObserver: null,
resizeTimeout: null,
// Visibility handling - prevent disconnects when tab loses focus
isDocumentVisible: true,
wasConnectedBeforeHidden: false,
mobileToolbarCollapsed: false,
terminalModifier: null,
keyboardInset: 0,
keyboardAnchorTop: 0,
keyboardViewportHeight: 0,
keyboardViewportWidth: 0,
keyboardInsetSettleTimeout: null,
updateKeyboardInset: null,
syncKeyboardInset: null,
// Inline style snapshots for ancestors unlocked while fullscreen (no DOM reparenting).
fullscreenAncestorPatches: null,
pageScrollLocked: false,
scrollLockY: 0,
scrollLockStyles: null,
preventPageScrollHandler: null,
themeObserver: null,
terminalSessionStartedAt: null,
terminalSessionRemainingSeconds: null,
terminalSessionCountdownInterval: null,
selectedTheme: applicationTerminalThemes[localStorage.getItem('coolify-console-theme')]
? localStorage.getItem('coolify-console-theme')
: 'system',
init() {
this.starting = this.$el.dataset.autoStart === 'true';
this.updateKeyboardInset = () => {
const viewport = window.visualViewport;
const viewportWidth = viewport?.width ?? window.innerWidth;
const layoutHeight = Math.max(
window.innerHeight,
document.documentElement.clientHeight,
viewport ? viewport.height + viewport.offsetTop : 0,
);
// Track the tallest viewport seen at this width — an open software
// keyboard shrinks the visual viewport well below it. A large width
// change (rotation) resets the baseline.
if (Math.abs(this.keyboardViewportWidth - viewportWidth) > 80) {
this.keyboardViewportHeight = layoutHeight;
} else {
this.keyboardViewportHeight = Math.max(this.keyboardViewportHeight, layoutHeight);
}
this.keyboardViewportWidth = viewportWidth;
const visualBottom = viewport ? viewport.height + viewport.offsetTop : layoutHeight;
this.keyboardInset = window.innerWidth < 640 && viewport
? Math.max(0, Math.round(this.keyboardViewportHeight - visualBottom))
: 0;
// position:fixed resolves `top` against the layout viewport and
// visualViewport.offsetTop is relative to it, so offsetTop + height
// is the exact bottom edge of the visible area — a toolbar pinned at
// this anchor rides on top of the keyboard no matter how the browser
// reports keyboard geometry (iOS overlay or Android layout resize).
this.keyboardAnchorTop = Math.round(visualBottom);
this.syncFullscreenShellWithKeyboard(viewport);
if (this.fullscreen) {
this.$nextTick(() => this.resizeTerminal());
}
};
this.syncKeyboardInset = () => {
// iOS fires viewport events mid keyboard animation — re-measure once
// the keyboard settles.
this.updateKeyboardInset();
clearTimeout(this.keyboardInsetSettleTimeout);
this.keyboardInsetSettleTimeout = setTimeout(this.updateKeyboardInset, 250);
};
this.updateKeyboardInset();
window.visualViewport?.addEventListener('resize', this.syncKeyboardInset);
window.visualViewport?.addEventListener('scroll', this.syncKeyboardInset);
window.addEventListener('resize', this.syncKeyboardInset);
this.themeObserver = new MutationObserver(() => {
if (this.selectedTheme === 'system') {
applicationTerminalThemes.system = createSystemTerminalTheme();
this.setTerminalTheme('system');
}
});
this.themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class', 'data-theme', 'style'],
});
// Recover if a previous portal build left the terminal on <body>.
this.$nextTick(() => this.salvageStrayFullscreenNodes());
this.setupTerminal();
// Add a small delay for initial connection to ensure everything is ready
setTimeout(() => {
this.initializeWebSocket();
}, 100);
this.setupTerminalEventListeners();
this.$wire.on('send-back-command', (command) => {
this.sendCommandWhenReady({ command: command });
});
this.$wire.on('terminal-should-focus', () => {
// Wait for terminal to be ready, then focus
const focusWhenReady = () => {
if (this.terminalActive && this.term) {
this.term.focus();
} else {
setTimeout(focusWhenReady, 100);
}
};
focusWhenReady();
});
this.$watch('terminalActive', (active) => {
if (!active && this.keepAliveInterval) {
clearInterval(this.keepAliveInterval);
}
this.$nextTick(() => {
if (active) {
this.$refs.terminalWrapper.style.removeProperty('display');
this.resizeTerminal();
// Start observing terminal wrapper for resize changes
if (this.resizeObserver && this.$refs.terminalWrapper) {
this.resizeObserver.observe(this.$refs.terminalWrapper);
}
} else {
const terminalElement = document.getElementById('terminal');
if (terminalElement?.dataset.terminalStyle === 'application') {
this.$refs.terminalWrapper.style.removeProperty('display');
} else {
this.$refs.terminalWrapper.style.display = 'none';
}
// Stop observing when terminal is inactive
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
}
});
});
['livewire:navigated', 'beforeunload'].forEach((event) => {
document.addEventListener(event, () => {
this.cleanup();
}, { once: true });
});
// Handle visibility changes to prevent disconnects when tab loses focus
document.addEventListener('visibilitychange', () => {
this.handleVisibilityChange();
});
window.onresize = () => {
this.resizeTerminal()
};
// Set up ResizeObserver for more reliable terminal resizing
if (window.ResizeObserver) {
this.resizeObserver = new ResizeObserver(() => {
// Debounce resize calls to avoid performance issues
clearTimeout(this.resizeTimeout);
this.resizeTimeout = setTimeout(() => {
this.resizeTerminal();
}, 50);
});
}
},
cleanup() {
window.visualViewport?.removeEventListener('resize', this.syncKeyboardInset);
window.visualViewport?.removeEventListener('scroll', this.syncKeyboardInset);
window.removeEventListener('resize', this.syncKeyboardInset);
clearTimeout(this.keyboardInsetSettleTimeout);
this.checkIfProcessIsRunningAndKillIt();
this.clearAllTimers();
this.connectionState = 'disconnected';
this.pendingCommand = null;
this.resetTerminalSessionCountdown();
this.exitFullscreen();
this.unlockPageScroll();
if (this.socket) {
this.socket.close(1000, 'Client cleanup');
}
// Clean up resize observer
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
// Clear resize timeout
if (this.resizeTimeout) {
clearTimeout(this.resizeTimeout);
}
},
clearAllTimers() {
if (this.keepAliveInterval) {
clearInterval(this.keepAliveInterval);
}
[this.reconnectInterval, this.connectionTimeoutId, this.pingTimeoutId, this.resizeTimeout]
.forEach(timer => timer && clearTimeout(timer));
if (this.terminalSessionCountdownInterval) {
clearInterval(this.terminalSessionCountdownInterval);
}
this.keepAliveInterval = null;
this.reconnectInterval = null;
this.connectionTimeoutId = null;
this.pingTimeoutId = null;
this.resizeTimeout = null;
this.terminalSessionCountdownInterval = null;
},
resetTerminalSessionCountdown() {
if (this.terminalSessionCountdownInterval) {
clearInterval(this.terminalSessionCountdownInterval);
}
this.terminalSessionStartedAt = null;
this.terminalSessionRemainingSeconds = null;
this.terminalSessionCountdownInterval = null;
},
startTerminalSessionCountdown() {
this.resetTerminalSessionCountdown();
this.terminalSessionStartedAt = Date.now();
this.updateTerminalSessionCountdown();
this.terminalSessionCountdownInterval = setInterval(() => {
this.updateTerminalSessionCountdown();
}, 1000);
},
updateTerminalSessionCountdown() {
if (!this.terminalSessionStartedAt) {
this.terminalSessionRemainingSeconds = null;
return;
}
const elapsedSeconds = (Date.now() - this.terminalSessionStartedAt) / 1000;
this.terminalSessionRemainingSeconds = Math.max(0, MAX_TERMINAL_SESSION_SECONDS - elapsedSeconds);
},
terminalSessionRemainingLabel() {
if (this.terminalSessionRemainingSeconds === null) {
return '';
}
return `Session expires in ${formatTerminalSessionRemainingTime(this.terminalSessionRemainingSeconds)}`;
},
terminalSessionTimerClass() {
if (this.terminalSessionRemainingSeconds === null) {
return 'text-neutral-300 bg-black/70 border-white/10';
}
if (this.terminalSessionRemainingSeconds <= TERMINAL_SESSION_DANGER_SECONDS) {
return 'text-red-200 bg-red-950/80 border-red-500/40';
}
if (this.terminalSessionRemainingSeconds <= TERMINAL_SESSION_WARNING_SECONDS) {
return 'text-yellow-200 bg-yellow-950/80 border-yellow-500/40';
}
return 'text-neutral-300 bg-black/70 border-white/10';
},
setTerminalTheme(themeName) {
if (!applicationTerminalThemes[themeName]) {
logTerminal('warn', '[Terminal Theme] Unknown theme', {
requestedTheme: themeName,
availableThemes: Object.keys(applicationTerminalThemes),
});
return;
}
logTerminal('log', '[Terminal Theme] Applying theme', this.terminalThemeDebugSnapshot(themeName));
if (themeName === 'system') {
applicationTerminalThemes.system = createSystemTerminalTheme();
}
this.selectedTheme = themeName;
localStorage.setItem('coolify-console-theme', themeName);
if (this.term) {
const cursorBlink = this.term.options.cursorBlink;
this.term.options.cursorBlink = false;
this.term.options.theme = { ...applicationTerminalThemes[themeName] };
this.term.refresh(0, Math.max(0, this.term.rows - 1));
requestAnimationFrame(() => {
if (!this.term) {
return;
}
this.term.options.cursorBlink = cursorBlink;
this.term.refresh(0, Math.max(0, this.term.rows - 1));
this.term.focus();
logTerminal('log', '[Terminal Theme] Theme applied', this.terminalThemeDebugSnapshot(themeName));
});
}
},
terminalThemeDebugSnapshot(themeName) {
const shell = this.$el.closest('.application-console-shell');
const viewport = this.term?.element?.querySelector('.xterm-viewport');
const screen = this.term?.element?.querySelector('.xterm-screen');
return {
requestedTheme: themeName,
selectedTheme: this.selectedTheme,
terminalExists: Boolean(this.term),
terminalOpened: Boolean(this.term?.element),
terminalActive: this.terminalActive,
connectionState: this.connectionState,
shellTheme: shell?.dataset.consoleTheme,
shellBackground: shell ? getComputedStyle(shell).background : null,
shellThemeBackground: shell ? getComputedStyle(shell).getPropertyValue('--console-theme-background') : null,
shellThemeOpacity: shell ? getComputedStyle(shell).getPropertyValue('--console-theme-opacity') : null,
shellPseudoBackground: shell ? getComputedStyle(shell, '::before').background : null,
viewportBackground: viewport ? getComputedStyle(viewport).background : null,
screenBackground: screen ? getComputedStyle(screen).background : null,
xtermBackground: this.term?.options.theme?.background,
xtermForeground: this.term?.options.theme?.foreground,
xtermCursor: this.term?.options.theme?.cursor,
};
},
resetTerminal() {
if (this.term) {
this.$wire.dispatch('error', 'Terminal websocket connection lost. Reconnecting...');
// Preserve scrollback so the user keeps the context of their previous
// session. Print a visible marker so they know where the disconnect
// happened. Old PTY shell state cannot be restored — this is purely
// a visual carry-over.
try {
const stamp = new Date().toLocaleTimeString();
this.term.write(`\r\n\x1b[33m── Connection lost at ${stamp}, reconnecting... ──\x1b[0m\r\n`);
} catch (_) {
// ignore — terminal not ready to receive writes
}
this.pendingWrites = 0;
this.paused = false;
this.commandBuffer = '';
this.pendingCommand = null;
this.resetTerminalSessionCountdown();
// Notify parent component that terminal disconnected
this.$wire.dispatch('terminalDisconnected');
// Force a refresh
this.$nextTick(() => {
this.resizeTerminal();
this.term.focus();
});
}
},
setupTerminal() {
const terminalElement = document.getElementById('terminal');
if (terminalElement) {
const isApplicationConsole = terminalElement.dataset.terminalStyle === 'application';
this.term = new Terminal({
cols: 80,
rows: 30,
fontFamily: '"Geist Mono", "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace, "Powerline Extra Symbols"',
fontSize: isApplicationConsole ? 13 : 14,
fontWeight: isApplicationConsole ? 550 : 'normal',
fontWeightBold: 700,
lineHeight: isApplicationConsole ? 1.15 : 1,
cursorBlink: true,
cursorStyle: 'block',
rendererType: 'canvas',
convertEol: true,
disableStdin: false,
scrollback: 5000,
theme: isApplicationConsole
? applicationTerminalThemes[this.selectedTheme] ?? applicationTerminalThemes.system
: undefined
});
this.fitAddon = new FitAddon();
this.term.loadAddon(this.fitAddon);
this.$nextTick(() => {
this.resizeTerminal();
});
}
},
initializeWebSocket() {
if (this.socket && this.socket.readyState !== WebSocket.CLOSED) {
logTerminal('log', '[Terminal] WebSocket already connecting/connected, skipping');
return; // Already connecting or connected
}
this.connectionState = 'connecting';
this.clearAllTimers();
// Ensure terminal config is available
if (!window.terminalConfig) {
logTerminal('warn', '[Terminal] Terminal config not available, using defaults');
window.terminalConfig = {};
}
const predefined = window.terminalConfig
const connectionString = {
protocol: window.location.protocol === 'https:' ? 'wss' : 'ws',
host: window.location.hostname,
port: ":6002",
path: '/terminal/ws'
}
if (!window.location.port) {
connectionString.port = ''
}
if (predefined.host) {
connectionString.host = predefined.host
}
if (predefined.port) {
connectionString.port = `:${predefined.port}`
}
if (predefined.protocol) {
connectionString.protocol = predefined.protocol
}
const url = `${connectionString.protocol}://${connectionString.host}${connectionString.port}${connectionString.path}`
logTerminal('log', `[Terminal] Attempting connection to: ${url}`);
try {
this.socket = new WebSocket(url);
// Set connection timeout - increased for initial connection
const timeoutMs = this.reconnectAttempts === 0 ? 15000 : this.connectionTimeout;
this.connectionTimeoutId = setTimeout(() => {
if (this.connectionState === 'connecting') {
logTerminal('error', `[Terminal] Connection timeout after ${timeoutMs}ms`);
this.socket.close();
this.handleConnectionError('Connection timeout');
}
}, timeoutMs);
this.socket.onopen = this.handleSocketOpen.bind(this);
this.socket.onmessage = this.handleSocketMessage.bind(this);
this.socket.onerror = this.handleSocketError.bind(this);
this.socket.onclose = this.handleSocketClose.bind(this);
} catch (error) {
logTerminal('error', '[Terminal] Failed to create WebSocket:', error);
this.handleConnectionError(`Failed to create WebSocket connection: ${error.message}`);
}
},
handleSocketOpen() {
logTerminal('log', '[Terminal] WebSocket connection established.');
this.connectionState = 'connected';
this.reconnectAttempts = 0;
this.heartbeatMissed = 0;
this.lastPingTime = Date.now();
// Clear connection timeout
if (this.connectionTimeoutId) {
clearTimeout(this.connectionTimeoutId);
this.connectionTimeoutId = null;
}
// Flush any buffered command from before WebSocket was ready, otherwise
// replay the last command so a transient reconnect respawns the PTY
// automatically without requiring the user to click Connect again.
if (this.pendingCommand) {
this.sendMessage(this.pendingCommand);
this.pendingCommand = null;
} else if (this.lastSentCommand) {
logTerminal('log', '[Terminal] Replaying last command after reconnect.');
this.sendMessage(this.lastSentCommand);
}
// (Re)start application-level keepalive on every successful connect.
// Server-side WebSocket protocol pings are the primary heartbeat; this
// adds a JSON-level ping in case the server-side is older or restarting.
if (!this.keepAliveInterval) {
this.keepAliveInterval = setInterval(this.keepAlive.bind(this), 30000);
}
// Start ping timeout monitoring
this.resetPingTimeout();
// Notify that WebSocket is ready for auto-connection
this.dispatchEvent('terminal-websocket-ready');
},
handleSocketError(error) {
logTerminal('error', '[Terminal] WebSocket error:', error);
logTerminal('error', '[Terminal] WebSocket state:', this.socket ? this.socket.readyState : 'No socket');
logTerminal('error', '[Terminal] Connection attempt:', this.reconnectAttempts + 1);
this.handleConnectionError('WebSocket error occurred');
},
handleSocketClose(event) {
logTerminal('warn', `[Terminal] WebSocket connection closed. Code: ${event.code}, Reason: ${event.reason || 'No reason provided'}`);
logTerminal('log', '[Terminal] Was clean close:', event.code === 1000);
logTerminal('log', '[Terminal] Connection attempt:', this.reconnectAttempts + 1);
this.connectionState = 'disconnected';
this.clearAllTimers();
this.resetTerminalSessionCountdown();
// Only reset terminal and reconnect if it wasn't a clean close
if (event.code !== 1000) {
// Don't show terminal reset message on first connection attempt
if (this.reconnectAttempts > 0) {
this.resetTerminal();
this.message = '(connection closed)';
this.terminalActive = false;
}
this.scheduleReconnect();
}
},
handleConnectionError(reason) {
logTerminal('error', `[Terminal] Connection error: ${reason} (attempt ${this.reconnectAttempts + 1})`);
this.connectionState = 'disconnected';
// Only dispatch error to UI after a few failed attempts to avoid immediate error on page load
if (this.reconnectAttempts >= 2) {
this.$wire.dispatch('error', `Terminal connection error: ${reason}`);
}
this.scheduleReconnect();
},
scheduleReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
logTerminal('error', '[Terminal] Max reconnection attempts reached');
this.message = '(connection failed - max retries exceeded)';
return;
}
this.connectionState = 'reconnecting';
// Exponential backoff with jitter
const delay = Math.min(
this.baseReconnectDelay * Math.pow(2, this.reconnectAttempts) + Math.random() * 1000,
this.maxReconnectDelay
);
logTerminal('warn', `[Terminal] Scheduling reconnect attempt ${this.reconnectAttempts + 1} in ${delay}ms`);
this.reconnectInterval = setTimeout(() => {
this.reconnectAttempts++;
this.initializeWebSocket();
}, delay);
},
sendMessage(message) {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(message));
if (message && message.command) {
this.lastSentCommand = message;
}
} else {
logTerminal('warn', '[Terminal] WebSocket not ready, message not sent:', message);
}
},
sendCommandWhenReady(message) {
if (this.isWebSocketReady()) {
this.sendMessage(message);
} else {
this.pendingCommand = message;
}
},
handleSocketMessage(event) {
// Handle pong responses
if (event.data === 'pong') {
this.heartbeatMissed = 0;
this.lastPingTime = Date.now();
this.resetPingTimeout();
return;
}
if (!this.term?._initialized && event.data !== 'pty-ready') {
logTerminal('warn', '[Terminal] Received message before PTY initialization:', event.data);
}
if (event.data === 'pty-ready') {
this.starting = false;
if (!this.term._initialized) {
this.term.open(document.getElementById('terminal'));
this.term._initialized = true;
} else {
// Already initialized — this is a reconnect or a follow-up command.
// Preserve scrollback so the user keeps context. Write a visible
// separator so the new shell prompt is easy to spot.
try {
const stamp = new Date().toLocaleTimeString();
this.term.write(`\r\n\x1b[32m── Reconnected at ${stamp} ──\x1b[0m\r\n`);
} catch (_) {
// ignore — fall through; xterm will render the new prompt anyway
}
}
this.terminalActive = true;
this.startTerminalSessionCountdown();
this.term.focus();
document.querySelector('.xterm-viewport').classList.add('scrollbar', 'rounded-sm');
// Initial resize after terminal is ready
this.resizeTerminal();
// Additional resize after a short delay to ensure proper sizing
setTimeout(() => {
this.resizeTerminal();
}, 200);
// Ensure terminal gets focus after connection with multiple attempts
setTimeout(() => {
this.term.focus();
}, 100);
setTimeout(() => {
this.term.focus();
}, 500);
// Notify parent component that terminal is connected
this.$wire.dispatch('terminalConnected');
} else if (event.data === 'unprocessable') {
this.starting = false;
if (this.term) this.term.reset();
this.terminalActive = false;
this.lastSentCommand = null;
this.resetTerminalSessionCountdown();
this.message = '(sorry, something went wrong, please try again)';
// Notify parent component that terminal connection failed
this.$wire.dispatch('terminalDisconnected');
} else if (event.data === 'pty-exited') {
this.starting = false;
this.exitFullscreen();
this.mobileToolbarCollapsed = false;
this.terminalActive = false;
this.resetTerminalSessionCountdown();
this.term.reset();
this.commandBuffer = '';
this.lastSentCommand = null;
// Notify parent component that terminal disconnected
this.$wire.dispatch('terminalDisconnected');
} else if (
typeof event.data === 'string' &&
(event.data.startsWith('Unauthorized:') || event.data.startsWith('Invalid SSH command:'))
) {
logTerminal('error', '[Terminal] Backend rejected terminal startup:', event.data);
this.$wire.dispatch('error', event.data);
this.terminalActive = false;
this.resetTerminalSessionCountdown();
} else {
try {
this.pendingWrites++;
this.term.write(event.data, (err) => {
if (err) {
logTerminal('error', '[Terminal] Write error:', err);
}
this.flowControlCallback();
});
} catch (error) {
logTerminal('error', '[Terminal] Write operation failed:', error);
this.pendingWrites = Math.max(0, this.pendingWrites - 1);
}
}
},
flowControlCallback() {
this.pendingWrites = Math.max(0, this.pendingWrites - 1);
if (this.pendingWrites > this.MAX_PENDING_WRITES && !this.paused) {
this.paused = true;
this.sendMessage({ pause: true });
return;
}
if (this.pendingWrites <= Math.floor(this.MAX_PENDING_WRITES / 2) && this.paused) {
this.paused = false;
this.sendMessage({ resume: true });
return;
}
},
setupTerminalEventListeners() {
if (!this.term) return;
this.term.onData((data) => {
this.sendMessage({ message: data });
if (data === '\r') {
this.commandBuffer = '';
} else {
this.commandBuffer += data;
}
});
// Copy and paste functionality
this.term.attachCustomKeyEventHandler((arg) => {
if (arg.ctrlKey && arg.code === "KeyV" && arg.type === "keydown") {
return false;
}
if (arg.ctrlKey && arg.code === "KeyC" && arg.type === "keydown") {
const selection = this.term.getSelection();
if (selection) {
navigator.clipboard.writeText(selection);
return false;
}
}
return true;
});
},
destroy() {
this.themeObserver?.disconnect();
window.visualViewport?.removeEventListener('resize', this.syncKeyboardInset);
window.visualViewport?.removeEventListener('scroll', this.syncKeyboardInset);
window.removeEventListener('resize', this.syncKeyboardInset);
clearTimeout(this.keyboardInsetSettleTimeout);
},
sendTerminalInput(data) {
if (!this.term || !this.terminalActive) {
return;
}
this.sendMessage({ message: data });
},
sendTerminalControl(sequence) {
const terminalSequences = {
arrowUp: '\x1b[A',
arrowDown: '\x1b[B',
arrowRight: '\x1b[C',
arrowLeft: '\x1b[D',
tab: '\t',
escape: '\x1b',
ctrlC: '\x03',
ctrlD: '\x04',
ctrlBackslash: '\x1c',
ctrlS: '\x13',
ctrlZ: '\x1a'
};
if (terminalSequences[sequence]) {
this.terminalModifier = null;
this.sendTerminalInput(terminalSequences[sequence]);
}
},
toggleTerminalModifier(modifier) {
this.terminalModifier = this.terminalModifier === modifier ? null : modifier;
},
sendTerminalKey(key) {
let input = key;
if (this.terminalModifier === 'ctrl') {
input = String.fromCharCode(key.toUpperCase().charCodeAt(0) & 31);
} else if (this.terminalModifier === 'alt') {
input = `\x1b${key}`;
}
this.terminalModifier = null;
this.sendTerminalInput(input);
},
async pasteFromClipboard() {
if (!navigator.clipboard?.readText) {
this.$wire.dispatch('error', 'Clipboard paste is not available in this browser.');
return;
}
try {
const text = await navigator.clipboard.readText();
if (text) {
this.sendTerminalInput(text);
}
} catch (error) {
logTerminal('warn', '[Terminal] Clipboard paste failed:', error);
this.$wire.dispatch('error', 'Clipboard paste permission was denied.');
}
},
async copyTerminalSelection() {
const selection = this.term?.getSelection();
if (!selection) {
this.$wire.dispatch('error', 'Select terminal text before copying.');
return;
}
try {
await navigator.clipboard.writeText(selection);
} catch (error) {
logTerminal('warn', '[Terminal] Clipboard copy failed:', error);
this.$wire.dispatch('error', 'Clipboard copy permission was denied.');
}
},
keepAlive() {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.sendMessage({ ping: true });
} else if (this.connectionState === 'disconnected') {
// Attempt to reconnect if we're disconnected
this.initializeWebSocket();
}
},
handleVisibilityChange() {
const wasVisible = this.isDocumentVisible;
this.isDocumentVisible = !document.hidden;
if (!this.isDocumentVisible) {
// Tab is now hidden - pause heartbeat monitoring to prevent false disconnects
this.wasConnectedBeforeHidden = this.connectionState === 'connected';
if (this.pingTimeoutId) {
clearTimeout(this.pingTimeoutId);
this.pingTimeoutId = null;
}
logTerminal('log', '[Terminal] Tab hidden, pausing heartbeat monitoring');
} else if (wasVisible === false) {
// Tab is now visible again
logTerminal('log', '[Terminal] Tab visible, resuming connection management');
if (this.wasConnectedBeforeHidden && this.socket && this.socket.readyState === WebSocket.OPEN) {
// Connection may be half-open after Cloudflare/proxy idle drop while hidden.
// Probe with a short timeout (5s) instead of the default 35s — force a
// reconnect quickly if no pong arrives so the user is not stuck typing
// into a dead socket.
this.heartbeatMissed = 0;
this.sendMessage({ ping: true });
if (this.pingTimeoutId) {
clearTimeout(this.pingTimeoutId);
}
this.pingTimeoutId = setTimeout(() => {
logTerminal('warn', '[Terminal] Visibility-resume ping timed out, forcing reconnect.');
try {
this.socket.close(4000, 'Visibility-resume timeout');
} catch (_) {
// ignore — close handler will run on its own
}
}, 5000);
} else if (this.wasConnectedBeforeHidden && this.connectionState !== 'connected') {
// Was connected before but now disconnected - attempt reconnection
this.reconnectAttempts = 0;
this.initializeWebSocket();
}
}
},
resetPingTimeout() {
if (this.pingTimeoutId) {
clearTimeout(this.pingTimeoutId);
}
this.pingTimeoutId = setTimeout(() => {
this.heartbeatMissed++;
logTerminal('warn', `[Terminal] Ping timeout - missed ${this.heartbeatMissed}/${this.maxHeartbeatMisses}`);
if (this.heartbeatMissed >= this.maxHeartbeatMisses) {
logTerminal('error', '[Terminal] Too many missed heartbeats, closing connection');
this.socket.close(1001, 'Heartbeat timeout');
}
}, this.pingTimeout);
},
checkIfProcessIsRunningAndKillIt() {
this.sendMessage({ checkActive: 'force' });
},
/**
* While the software keyboard is open, shrink the fullscreen shell to the
* visual viewport so xterm rows and the mobile key row stay visible above
* the keyboard. Inline !important is required to outrank the stylesheet's
* `inset: 0 !important` / `height: auto !important` fullscreen rules.
*/
syncFullscreenShellWithKeyboard(viewport) {
const wrapper = this.$refs.terminalWrapper;
if (!wrapper) {
return;
}
if (this.fullscreen && viewport && this.keyboardInset > 0) {
wrapper.style.setProperty('top', `${Math.round(viewport.offsetTop)}px`, 'important');
wrapper.style.setProperty('height', `${Math.round(viewport.height)}px`, 'important');
wrapper.style.setProperty('bottom', 'auto', 'important');
} else {
wrapper.style.removeProperty('top');
wrapper.style.removeProperty('height');
wrapper.style.removeProperty('bottom');
}
},
makeFullscreen() {
if (this.fullscreen) {
this.exitFullscreen();
} else {
this.enterFullscreen();
}
},
/**
* Keep the terminal in-place (no document.body reparent). Livewire morphs
* recreate missing children when nodes leave the component tree, which left
* an empty console shell and dumped the real xterm below the page.
*
* Instead, neutralize ancestor isolation/transform/filter/overflow so
* position:fixed + z-index can cover the viewport above sidebar/top bar.
*/
enterFullscreen() {
const wrapper = this.$refs.terminalWrapper;
if (!wrapper || this.fullscreen) {
return;
}
this.salvageStrayFullscreenNodes();
this.patchAncestorsForFullscreen(wrapper);
this.lockPageScroll();
wrapper.style.removeProperty('display');
wrapper.style.removeProperty('height');
wrapper.style.removeProperty('min-height');
this.fullscreen = true;
document.documentElement.classList.add('terminal-is-fullscreen');
document.body.classList.add('terminal-is-fullscreen');
this.updateKeyboardInset?.();
this.scheduleTerminalResize();
},
exitFullscreen() {
const wrapper = this.$refs.terminalWrapper;
this.restoreAncestorsAfterFullscreen();
this.unlockPageScroll();
this.fullscreen = false;
document.documentElement.classList.remove('terminal-is-fullscreen');
document.body.classList.remove('terminal-is-fullscreen');
if (wrapper) {
wrapper.style.removeProperty('display');
wrapper.style.removeProperty('height');
wrapper.style.removeProperty('min-height');
}
// Recover from older portal builds that left the terminal on <body>.
this.salvageStrayFullscreenNodes();
this.updateKeyboardInset?.();
this.scheduleTerminalResize();
},
/**
* Freeze document scroll while fullscreen. Only xterm's own viewport may scroll.
* Uses position:fixed scroll-lock so nested overflow:visible ancestors cannot
* re-enable page scrolling under the overlay.
*/
lockPageScroll() {
if (this.pageScrollLocked) {
return;
}
this.pageScrollLocked = true;
this.scrollLockY = window.scrollY || document.documentElement.scrollTop || 0;
this.scrollLockStyles = {
htmlOverflow: document.documentElement.style.getPropertyValue('overflow'),
htmlOverscroll: document.documentElement.style.getPropertyValue('overscroll-behavior'),
bodyOverflow: document.body.style.getPropertyValue('overflow'),
bodyPosition: document.body.style.getPropertyValue('position'),
bodyTop: document.body.style.getPropertyValue('top'),
bodyLeft: document.body.style.getPropertyValue('left'),
bodyRight: document.body.style.getPropertyValue('right'),
bodyWidth: document.body.style.getPropertyValue('width'),
bodyPaddingRight: document.body.style.getPropertyValue('padding-right'),
bodyOverscroll: document.body.style.getPropertyValue('overscroll-behavior'),
};
const scrollbarGap = Math.max(0, window.innerWidth - document.documentElement.clientWidth);
document.documentElement.style.setProperty('overflow', 'hidden', 'important');
document.documentElement.style.setProperty('overscroll-behavior', 'none', 'important');
document.body.style.setProperty('overflow', 'hidden', 'important');
document.body.style.setProperty('overscroll-behavior', 'none', 'important');
document.body.style.setProperty('position', 'fixed', 'important');
document.body.style.setProperty('top', `-${this.scrollLockY}px`, 'important');
document.body.style.setProperty('left', '0', 'important');
document.body.style.setProperty('right', '0', 'important');
document.body.style.setProperty('width', '100%', 'important');
if (scrollbarGap > 0) {
document.body.style.setProperty('padding-right', `${scrollbarGap}px`, 'important');
}
this.preventPageScrollHandler = (event) => {
const target = event.target;
if (!(target instanceof Element)) {
event.preventDefault();
return;
}
// Allow terminal scrollback and mobile toolbar touches only.
if (
target.closest('.xterm-viewport') ||
target.closest('[data-terminal-mobile-toolbar]') ||
target.closest('.terminal-fullscreen-btn')
) {
return;
}
event.preventDefault();
};
window.addEventListener('wheel', this.preventPageScrollHandler, { passive: false });
window.addEventListener('touchmove', this.preventPageScrollHandler, { passive: false });
},
unlockPageScroll() {
if (!this.pageScrollLocked) {
return;
}
this.pageScrollLocked = false;
if (this.preventPageScrollHandler) {
window.removeEventListener('wheel', this.preventPageScrollHandler);
window.removeEventListener('touchmove', this.preventPageScrollHandler);
this.preventPageScrollHandler = null;
}
const restore = (el, prop, value) => {
if (value) {
el.style.setProperty(prop, value);
} else {
el.style.removeProperty(prop);
}
};
const styles = this.scrollLockStyles ?? {};
restore(document.documentElement, 'overflow', styles.htmlOverflow);
restore(document.documentElement, 'overscroll-behavior', styles.htmlOverscroll);
restore(document.body, 'overflow', styles.bodyOverflow);
restore(document.body, 'position', styles.bodyPosition);
restore(document.body, 'top', styles.bodyTop);
restore(document.body, 'left', styles.bodyLeft);
restore(document.body, 'right', styles.bodyRight);
restore(document.body, 'width', styles.bodyWidth);
restore(document.body, 'padding-right', styles.bodyPaddingRight);
restore(document.body, 'overscroll-behavior', styles.bodyOverscroll);
this.scrollLockStyles = null;
window.scrollTo(0, this.scrollLockY || 0);
this.scrollLockY = 0;
},
patchAncestorsForFullscreen(fromEl) {
this.restoreAncestorsAfterFullscreen();
this.fullscreenAncestorPatches = [];
let node = fromEl.parentElement;
while (node && node !== document.documentElement) {
this.fullscreenAncestorPatches.push({
el: node,
isolation: node.style.getPropertyValue('isolation'),
transform: node.style.getPropertyValue('transform'),
filter: node.style.getPropertyValue('filter'),
backdropFilter: node.style.getPropertyValue('backdrop-filter'),
contain: node.style.getPropertyValue('contain'),
overflow: node.style.getPropertyValue('overflow'),
overflowX: node.style.getPropertyValue('overflow-x'),
overflowY: node.style.getPropertyValue('overflow-y'),
willChange: node.style.getPropertyValue('will-change'),
perspective: node.style.getPropertyValue('perspective'),
zIndex: node.style.getPropertyValue('z-index'),
position: node.style.getPropertyValue('position'),
});
// Drop fixed-position containing blocks + nested stacking contexts.
// Critical: CSS classes like .application-console-block { z-index: 1 }
// trap position:fixed descendants under the sidebar (z-40) / top bar (z-50)
// unless z-index is forced back to auto on the whole ancestor chain.
node.style.setProperty('isolation', 'auto', 'important');
node.style.setProperty('transform', 'none', 'important');
node.style.setProperty('filter', 'none', 'important');
node.style.setProperty('backdrop-filter', 'none', 'important');
node.style.setProperty('contain', 'none', 'important');
node.style.setProperty('perspective', 'none', 'important');
node.style.setProperty('will-change', 'auto', 'important');
node.style.setProperty('overflow', 'visible', 'important');
node.style.setProperty('overflow-x', 'visible', 'important');
node.style.setProperty('overflow-y', 'visible', 'important');
node.style.setProperty('z-index', 'auto', 'important');
node = node.parentElement;
}
// Sidebar/top bar are layout siblings of <main>, not ancestors. Elevate
// main so the fullscreen stacking context paints above z-50 chrome.
const main = fromEl.closest('main');
if (main) {
const existing = this.fullscreenAncestorPatches.find((patch) => patch.el === main);
if (!existing) {
this.fullscreenAncestorPatches.push({
el: main,
isolation: main.style.getPropertyValue('isolation'),
transform: main.style.getPropertyValue('transform'),
filter: main.style.getPropertyValue('filter'),
backdropFilter: main.style.getPropertyValue('backdrop-filter'),
contain: main.style.getPropertyValue('contain'),
overflow: main.style.getPropertyValue('overflow'),
overflowX: main.style.getPropertyValue('overflow-x'),
overflowY: main.style.getPropertyValue('overflow-y'),
willChange: main.style.getPropertyValue('will-change'),
perspective: main.style.getPropertyValue('perspective'),
zIndex: main.style.getPropertyValue('z-index'),
position: main.style.getPropertyValue('position'),
});
}
if (getComputedStyle(main).position === 'static') {
main.style.setProperty('position', 'relative', 'important');
}
main.style.setProperty('z-index', '100001', 'important');
}
},
restoreAncestorsAfterFullscreen() {
if (!this.fullscreenAncestorPatches?.length) {
this.fullscreenAncestorPatches = null;
return;
}
for (const patch of this.fullscreenAncestorPatches) {
const el = patch.el;
if (!el?.style) {
continue;
}
const entries = [
['isolation', patch.isolation],
['transform', patch.transform],
['filter', patch.filter],
['backdrop-filter', patch.backdropFilter],
['contain', patch.contain],
['overflow', patch.overflow],
['overflow-x', patch.overflowX],
['overflow-y', patch.overflowY],
['will-change', patch.willChange],
['perspective', patch.perspective],
['z-index', patch.zIndex],
['position', patch.position],
];
for (const [prop, value] of entries) {
if (value) {
el.style.setProperty(prop, value);
} else {
el.style.removeProperty(prop);
}
}
}
this.fullscreenAncestorPatches = null;
},
/**
* Older fullscreen code reparented the wrapper to document.body. Livewire then
* recreated an empty shell in-place. Pull any stray terminal hosts back home.
*/
salvageStrayFullscreenNodes() {
const host = document.getElementById('terminal-container');
if (!host) {
return;
}
const wrapper = this.$refs.terminalWrapper;
if (wrapper && wrapper.parentElement === document.body) {
host.appendChild(wrapper);
}
document.querySelectorAll('body > .terminal-fullscreen-shell').forEach((node) => {
if (node === wrapper) {
host.appendChild(node);
return;
}
if (node.querySelector('#terminal') || node.id === 'terminal') {
host.appendChild(node);
} else {
node.remove();
}
});
},
scheduleTerminalResize() {
this.$nextTick(() => {
// Multi-pass fit: Alpine class swaps need a couple frames before the
// host has a stable clientHeight for FitAddon.
this.resizeTerminal();
requestAnimationFrame(() => {
this.resizeTerminal();
setTimeout(() => this.resizeTerminal(), 50);
setTimeout(() => this.resizeTerminal(), 150);
});
});
},
resizeTerminal() {
if (!this.terminalActive || !this.term || !this.fitAddon) {
return;
}
try {
const terminalElement = document.getElementById('terminal');
if (!terminalElement || !this.term.element) {
return;
}
// Host must already be laid out; otherwise FitAddon under-reads and
// later the canvas keeps a wrong pixel height (page stretches on exit).
if (terminalElement.clientHeight < 24 || terminalElement.clientWidth < 24) {
setTimeout(() => this.resizeTerminal(), 50);
return;
}
const previousCols = this.term.cols;
const previousRows = this.term.rows;
this.fitAddon.fit();
// Keep the xterm chrome inside the host so overflow becomes scrollback,
// not document growth after leaving fullscreen.
if (this.term.element) {
this.term.element.style.width = '100%';
this.term.element.style.height = '100%';
this.term.element.style.maxHeight = '100%';
}
if (
this.term.cols > 0 &&
this.term.rows > 0 &&
(this.term.cols !== previousCols || this.term.rows !== previousRows)
) {
this.sendMessage({
resize: { cols: this.term.cols, rows: this.term.rows },
});
}
} catch (error) {
logTerminal('error', '[Terminal] Resize error:', error);
}
},
// Utility method to get connection status for debugging
getConnectionStatus() {
return {
state: this.connectionState,
readyState: this.socket ? this.socket.readyState : 'No socket',
reconnectAttempts: this.reconnectAttempts,
pendingWrites: this.pendingWrites,
paused: this.paused,
lastPingTime: this.lastPingTime,
heartbeatMissed: this.heartbeatMissed
};
},
// Helper method to dispatch custom events
dispatchEvent(eventName, detail = null) {
const event = new CustomEvent(eventName, {
detail: detail,
bubbles: true
});
this.$el.dispatchEvent(event);
},
// Check if WebSocket is ready for commands
isWebSocketReady() {
return this.connectionState === 'connected' &&
this.socket &&
this.socket.readyState === WebSocket.OPEN;
}
};
}
window.Alpine.data('terminalData', terminalData);
}