From 8ae9c0320968c544f2d2d1f6e5eae698e4d3fe1e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:14:26 +0200 Subject: [PATCH] feat(terminal): lazy-load targets and polish theme UI Defer container loading with wire:init and a containersLoaded gate, add autoStart/starting session state, extract a shared theme selector, and restyle terminal headers, pickers, and fullscreen chrome. Cover the new packaging and header behavior in feature tests. --- .../Shared/ExecuteContainerCommand.php | 17 +- app/Livewire/Project/Shared/Terminal.php | 2 + resources/css/app.css | 130 ++++++++++--- resources/js/terminal.js | 5 + .../terminal/theme-selector.blade.php | 33 ++++ .../execute-container-command.blade.php | 119 ++++++------ .../project/shared/terminal.blade.php | 18 +- .../views/livewire/terminal/index.blade.php | 169 +++++++++++----- .../Feature/RealtimeTerminalPackagingTest.php | 183 +++++++++++++++++- tests/Feature/TerminalPageHeaderTest.php | 140 +++++++++++++- 10 files changed, 651 insertions(+), 165 deletions(-) create mode 100644 resources/views/components/terminal/theme-selector.blade.php diff --git a/app/Livewire/Project/Shared/ExecuteContainerCommand.php b/app/Livewire/Project/Shared/ExecuteContainerCommand.php index 3fa063298..bb3991f22 100644 --- a/app/Livewire/Project/Shared/ExecuteContainerCommand.php +++ b/app/Livewire/Project/Shared/ExecuteContainerCommand.php @@ -29,13 +29,15 @@ class ExecuteContainerCommand extends Component public bool $isConnecting = false; + public bool $containersLoaded = false; + protected $rules = [ 'server' => 'required', 'container' => 'required', 'command' => 'required', ]; - public function mount() + public function mount(): void { $this->parameters = get_route_parameters(); $this->containers = collect(); @@ -52,7 +54,6 @@ class ExecuteContainerCommand extends Component $this->servers = $this->servers->push($server); } } - $this->loadContainers(); } elseif (data_get($this->parameters, 'database_uuid')) { $this->type = 'database'; $resource = getResourceByUuid($this->parameters['database_uuid'], data_get(auth()->user()->currentTeam(), 'id')); @@ -64,7 +65,6 @@ class ExecuteContainerCommand extends Component if ($this->resource->destination->server->isFunctional()) { $this->servers = $this->servers->push($this->resource->destination->server); } - $this->loadContainers(); } elseif (data_get($this->parameters, 'service_uuid')) { $this->type = 'service'; $this->resource = Service::ownedByCurrentTeam()->where('uuid', $this->parameters['service_uuid'])->firstOrFail(); @@ -72,18 +72,22 @@ class ExecuteContainerCommand extends Component if ($this->resource->server->isFunctional()) { $this->servers = $this->servers->push($this->resource->server); } - $this->loadContainers(); } elseif (data_get($this->parameters, 'server_uuid')) { $this->type = 'server'; $this->resource = Server::ownedByCurrentTeam()->where('uuid', $this->parameters['server_uuid'])->firstOrFail(); $this->authorize('view', $this->resource); $this->servers = $this->servers->push($this->resource); + $this->containersLoaded = true; } $this->servers = $this->servers->sortByDesc(fn ($server) => $server->isTerminalEnabled()); } - public function loadContainers() + public function loadContainers(): void { + if ($this->containersLoaded) { + return; + } + foreach ($this->servers as $server) { if (data_get($this->parameters, 'application_uuid')) { if ($server->isSwarm()) { @@ -145,7 +149,10 @@ class ExecuteContainerCommand extends Component if ($this->containers->count() === 1) { $this->selected_container = data_get($this->containers->first(), 'container.Names'); + $this->connectToContainer(); } + + $this->containersLoaded = true; } public function updatedSelectedContainer() diff --git a/app/Livewire/Project/Shared/Terminal.php b/app/Livewire/Project/Shared/Terminal.php index 3b704d007..f6145807e 100644 --- a/app/Livewire/Project/Shared/Terminal.php +++ b/app/Livewire/Project/Shared/Terminal.php @@ -17,6 +17,8 @@ class Terminal extends Component public bool $isTerminalConnected = false; + public bool $autoStart = false; + public string $variant = 'default'; private function checkShellAvailability(Server $server, string $container): bool diff --git a/resources/css/app.css b/resources/css/app.css index c61458fe8..400b4a9a0 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -500,11 +500,6 @@ html:not(.dark) .application-console-shell[data-console-theme="system"] .applica backdrop-filter: none; } -html:not(.dark) .application-console-shell[data-console-theme="system"] .application-console-block::before { - background: transparent; - backdrop-filter: none; -} - html:not(.dark) .application-console-shell[data-console-theme="system"] .application-console-header [class*="text-white"] { color: #52525b !important; } @@ -678,7 +673,7 @@ body.terminal-is-fullscreen .terminal-fullscreen-shell { inset: 0 !important; z-index: 100000 !important; width: 100vw !important; - height: 100dvh !important; + height: auto !important; max-width: none !important; margin: 0 !important; overflow: hidden !important; @@ -707,25 +702,111 @@ body.terminal-is-fullscreen .terminal-fullscreen-shell [data-terminal-mobile-too backdrop-filter: blur(20px); } +.terminal-loading-label { + font-size: 0.875rem; + font-weight: 500; + color: rgb(255 255 255 / 0.75); +} + +html:not(.dark) .application-console-shell[data-console-theme="system"] .terminal-loading-label { + color: #52525b; +} + +.terminal-session-expiry { + font-size: 0.75rem; + font-weight: 500; + color: rgb(255 255 255 / 0.6); +} + +html:not(.dark) .application-console-shell[data-console-theme="system"] .terminal-session-expiry, +html:not(.dark) .terminal-fullscreen-shell[data-console-theme="system"] .terminal-session-expiry { + color: #52525b; +} + +.terminal-target-picker { + color: rgb(255 255 255 / 0.75); + background: rgb(0 0 0 / 0.18); + border-color: rgb(255 255 255 / 0.1); + backdrop-filter: blur(16px); +} + +.terminal-session-panel { + border: 0; + border-radius: 0.75rem; + background: transparent; + box-shadow: none; +} + +html:not(.dark) .application-console-shell[data-console-theme="system"] .terminal-session-panel { + background: transparent; +} + +.terminal-target-list { + scrollbar-width: thin; + scrollbar-color: var(--terminal-scrollbar, rgb(255 255 255 / 0.3)) transparent; +} + +.terminal-target-list::-webkit-scrollbar { + width: 8px; +} + +.terminal-target-list::-webkit-scrollbar-thumb { + border: 2px solid transparent; + border-radius: 9999px; + background: var(--terminal-scrollbar, rgb(255 255 255 / 0.3)); + background-clip: padding-box; +} + +.terminal-target-list::-webkit-scrollbar-track { + background: transparent; +} + +html:not(.dark) .application-console-shell[data-console-theme="system"] .terminal-target-picker { + color: #52525b; + background: #fff; + border-color: #d4d4d8; + box-shadow: 0 18px 50px rgb(0 0 0 / 0.18); +} + +html:not(.dark) .application-console-shell[data-console-theme="system"] .terminal-target-picker [class*="text-white"] { + color: #52525b !important; +} + +html:not(.dark) .application-console-shell[data-console-theme="system"] .terminal-target-picker input { + color: #27272a !important; + background: #f4f4f5 !important; + border-color: #d4d4d8 !important; +} + +html:not(.dark) .application-console-shell[data-console-theme="system"] .terminal-target-picker input::placeholder { + color: #71717a !important; +} + +html:not(.dark) .application-console-shell[data-console-theme="system"] .terminal-target-picker button:hover { + color: #18181b !important; + background: #f4f4f5; +} + +html:not(.dark) .application-console-shell[data-console-theme="system"] .terminal-theme-trigger { + color: #52525b; +} + +html:not(.dark) .application-console-shell[data-console-theme="system"] .terminal-session-target-trigger { + color: #52525b; +} + +html:not(.dark) .application-console-shell[data-console-theme="system"] .terminal-theme-trigger:hover, +html:not(.dark) .application-console-shell[data-console-theme="system"] .terminal-session-target-trigger:hover { + color: #18181b; + background: rgb(0 0 0 / 0.06); +} + .application-console-block { - /* Keep backdrop blur on a pseudo-element so position:fixed descendants - (terminal fullscreen) are not trapped by a backdrop-filter containing block. */ position: relative; z-index: 1; isolation: isolate; } -.application-console-block::before { - position: absolute; - z-index: -1; - inset: 0; - border-radius: inherit; - background: rgb(0 0 0 / 0.56); - backdrop-filter: blur(10px); - content: ""; - pointer-events: none; -} - .terminal-fullscreen-btn { display: flex; width: 1.75rem; @@ -733,10 +814,9 @@ body.terminal-is-fullscreen .terminal-fullscreen-shell [data-terminal-mobile-too align-items: center; justify-content: center; border-radius: 0.375rem; - border: 1px solid rgb(255 255 255 / 0.08); - background: rgb(0 0 0 / 0.55); - color: rgb(255 255 255 / 0.65); - backdrop-filter: blur(8px); + border: 0; + background: transparent; + color: var(--terminal-scrollbar, rgb(255 255 255 / 0.65)); transition: background-color 150ms ease, color 150ms ease, @@ -744,8 +824,8 @@ body.terminal-is-fullscreen .terminal-fullscreen-shell [data-terminal-mobile-too } .terminal-fullscreen-btn:hover { - background: rgb(255 255 255 / 0.08); - color: rgb(255 255 255 / 0.95); + background: color-mix(in srgb, var(--terminal-scrollbar, #fff) 18%, transparent); + color: var(--terminal-scrollbar, #fff); } /* diff --git a/resources/js/terminal.js b/resources/js/terminal.js index 26e32bb6f..25867e500 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -152,6 +152,7 @@ export function initializeTerminalComponent() { return { fullscreen: false, terminalActive: false, + starting: false, message: '(connection closed)', term: null, fitAddon: null, @@ -203,6 +204,7 @@ export function initializeTerminalComponent() { : 'system', init() { + this.starting = this.$el.dataset.autoStart === 'true'; this.themeObserver = new MutationObserver(() => { if (this.selectedTheme === 'system') { applicationTerminalThemes.system = createSystemTerminalTheme(); @@ -676,6 +678,7 @@ export function initializeTerminalComponent() { } if (event.data === 'pty-ready') { + this.starting = false; if (!this.term._initialized) { this.term.open(document.getElementById('terminal')); this.term._initialized = true; @@ -715,6 +718,7 @@ export function initializeTerminalComponent() { // 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; @@ -724,6 +728,7 @@ export function initializeTerminalComponent() { // 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; diff --git a/resources/views/components/terminal/theme-selector.blade.php b/resources/views/components/terminal/theme-selector.blade.php new file mode 100644 index 000000000..18f0585fd --- /dev/null +++ b/resources/views/components/terminal/theme-selector.blade.php @@ -0,0 +1,33 @@ +@props(['themes', 'themeNames', 'themeAccents']) + +
+ + +
+ @foreach ($themes as $theme) + + @endforeach +
+
diff --git a/resources/views/livewire/project/shared/execute-container-command.blade.php b/resources/views/livewire/project/shared/execute-container-command.blade.php index 887876813..25e9b452f 100644 --- a/resources/views/livewire/project/shared/execute-container-command.blade.php +++ b/resources/views/livewire/project/shared/execute-container-command.blade.php @@ -1,4 +1,4 @@ -
+
{{ data_get_str($resource, 'name')->limit(10) }} > Terminal | Coolify @@ -18,7 +18,7 @@ @php $consoleUnavailable = ($type === 'server' && (! $servers->first()->isTerminalEnabled() || ! $servers->first()->isFunctional())) - || ($type !== 'server' && $containers->isEmpty()); + || ($type !== 'server' && $containersLoaded && $containers->isEmpty()); $consoleThemes = [ ['key' => 'system', 'name' => 'System', 'background' => 'linear-gradient(135deg, #ffffff 0 50%, #121214 50% 100%)', 'accent' => '#8C8E9C'], ['key' => 'shadows-midnight', 'name' => 'Midnight', 'background' => 'linear-gradient(135deg, #2a3b4c, rgba(42, 59, 76, 0.4))', 'accent' => '#6d7a7c'], @@ -70,11 +70,15 @@ @else
-
+
+ class="terminal-session-toolbar absolute top-3 right-3 left-3 z-20 flex items-center gap-3 text-white select-none"> @if ($type === 'server') -
first()->isTerminalEnabled() && $servers->first()->isFunctional()) x-on:terminal-websocket-ready.window="if (!autoConnected) { autoConnected = true; @@ -110,31 +123,25 @@ }" @endif> - + {{ $servers->first()->name }} -
@else -
- +
@if ($containers->count() === 1) - +
+ + {{ data_get($containers->first(), 'container.Names') }} · {{ data_get($containers->first(), 'server.name') }} +
@else -
+
+ class="terminal-target-picker terminal-target-list absolute top-11 left-0 z-50 max-h-72 w-80 overflow-y-auto rounded-lg border p-1 shadow-[0_18px_50px_rgba(0,0,0,0.35)]">