Merge remote-tracking branch 'origin/next' into mobile-terminal-toolbar-controls

This commit is contained in:
Andras Bacsai committed 2026-06-01 10:15:23 +02:00
commit a8db2864f3
16 files changed
+236 -65

No files matched your search

+22
View File
@@ -0,0 +1,22 @@
export const MAX_TERMINAL_SESSION_SECONDS = 8 * 60 * 60;
export const TERMINAL_SESSION_WARNING_SECONDS = 30 * 60;
export const TERMINAL_SESSION_DANGER_SECONDS = 5 * 60;
export function formatTerminalSessionRemainingTime(seconds) {
const remainingSeconds = Math.max(0, Math.ceil(seconds));
if (remainingSeconds === 0) {
return 'expired';
}
const totalMinutes = Math.floor(remainingSeconds / 60);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
const secondsPart = remainingSeconds % 60;
if (hours === 0) {
return `${minutes}m ${String(secondsPart).padStart(2, '0')}s`;
}
return `${hours}h ${String(minutes).padStart(2, '0')}m ${String(secondsPart).padStart(2, '0')}s`;
}
+74 -10
View File
@@ -1,5 +1,11 @@
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 terminalDebugEnabled = import.meta.env.DEV;
@@ -44,7 +50,7 @@ export function initializeTerminalComponent() {
pendingCommand: null,
// Last successfully sent SSH command — replayed after a transient reconnect
// so the PTY respawns automatically. Cleared on intentional terminations
// (pty-exited, idle-timeout, unprocessable).
// (pty-exited, unprocessable).
lastSentCommand: null,
// Resize handling
resizeObserver: null,
@@ -53,6 +59,9 @@ export function initializeTerminalComponent() {
isDocumentVisible: true,
wasConnectedBeforeHidden: false,
mobileToolbarCollapsed: false,
terminalSessionStartedAt: null,
terminalSessionRemainingSeconds: null,
terminalSessionCountdownInterval: null,
init() {
this.setupTerminal();
@@ -136,6 +145,7 @@ export function initializeTerminalComponent() {
this.clearAllTimers();
this.connectionState = 'disconnected';
this.pendingCommand = null;
this.resetTerminalSessionCountdown();
if (this.socket) {
this.socket.close(1000, 'Client cleanup');
}
@@ -158,11 +168,68 @@ export function initializeTerminalComponent() {
}
[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';
},
resetTerminal() {
@@ -182,6 +249,7 @@ export function initializeTerminalComponent() {
this.paused = false;
this.commandBuffer = '';
this.pendingCommand = null;
this.resetTerminalSessionCountdown();
// Notify parent component that terminal disconnected
this.$wire.dispatch('terminalDisconnected');
@@ -329,6 +397,7 @@ export function initializeTerminalComponent() {
this.connectionState = 'disconnected';
this.clearAllTimers();
this.resetTerminalSessionCountdown();
// Only reset terminal and reconnect if it wasn't a clean close
if (event.code !== 1000) {
@@ -425,6 +494,7 @@ export function initializeTerminalComponent() {
}
}
this.terminalActive = true;
this.startTerminalSessionCountdown();
this.term.focus();
document.querySelector('.xterm-viewport').classList.add('scrollbar', 'rounded-sm');
@@ -451,27 +521,20 @@ export function initializeTerminalComponent() {
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.terminalActive = false;
this.resetTerminalSessionCountdown();
this.term.reset();
this.commandBuffer = '';
this.lastSentCommand = null;
// Notify parent component that terminal disconnected
this.$wire.dispatch('terminalDisconnected');
} else if (event.data === 'idle-timeout') {
this.$wire.dispatch('error', 'Terminal closed after 30 minutes of inactivity.');
this.terminalActive = false;
if (this.term) {
this.term.reset();
}
this.commandBuffer = '';
this.lastSentCommand = null;
this.$wire.dispatch('terminalDisconnected');
} else if (
typeof event.data === 'string' &&
(event.data.startsWith('Unauthorized:') || event.data.startsWith('Invalid SSH command:'))
@@ -479,6 +542,7 @@ export function initializeTerminalComponent() {
logTerminal('error', '[Terminal] Backend rejected terminal startup:', event.data);
this.$wire.dispatch('error', event.data);
this.terminalActive = false;
this.resetTerminalSessionCountdown();
} else {
try {
this.pendingWrites++;
+15
View File
@@ -0,0 +1,15 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
MAX_TERMINAL_SESSION_SECONDS,
formatTerminalSessionRemainingTime,
} from './terminal-session-timer.js';
test('formatTerminalSessionRemainingTime formats the eight hour terminal limit countdown', () => {
assert.equal(MAX_TERMINAL_SESSION_SECONDS, 8 * 60 * 60);
assert.equal(formatTerminalSessionRemainingTime(MAX_TERMINAL_SESSION_SECONDS), '8h 00m 00s');
assert.equal(formatTerminalSessionRemainingTime((7 * 60 * 60) + (59 * 60) + 59), '7h 59m 59s');
assert.equal(formatTerminalSessionRemainingTime(65 * 60), '1h 05m 00s');
assert.equal(formatTerminalSessionRemainingTime(59), '0m 59s');
assert.equal(formatTerminalSessionRemainingTime(0), 'expired');
});
@@ -1,4 +1,7 @@
<div id="terminal-container" x-data="terminalData()">
@if ($isTerminalConnected)
<div class="hidden" aria-hidden="true" wire:poll.keep-alive.30s="keepTerminalPageAlive"></div>
@endif
@if (!$hasShell)
<div class="flex pt-4 items-center justify-center w-full py-4 mx-auto">
<div class="p-4 w-full rounded-sm border dark:bg-coolgray-100 dark:border-coolgray-300">
@@ -20,6 +23,11 @@
<div x-ref="terminalWrapper"
:class="fullscreen ? 'fixed inset-0 z-[9999] m-0 h-[100dvh] w-screen max-w-none overflow-hidden rounded-none !bg-black p-0' : 'relative w-full h-full py-4 mx-auto max-h-[510px]'">
<!-- Terminal container -->
<div x-show="terminalActive" x-cloak class="mb-2 flex justify-start">
<div class="inline-flex rounded-sm border px-2 py-1 text-xs font-medium"
:class="terminalSessionTimerClass()" x-text="terminalSessionRemainingLabel()">
</div>
</div>
<div id="terminal" wire:ignore
:class="fullscreen ? (mobileToolbarCollapsed ? 'h-[calc(100dvh-3.5rem)] mb-14 px-2 py-1 bg-black' : 'h-[calc(100dvh-6rem)] mb-[6rem] px-2 py-1 bg-black') : 'h-[510px] max-h-[calc(100dvh-10rem)] overflow-hidden px-2 py-1 rounded-sm bg-black'"
x-show="terminalActive">