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.
This commit is contained in:
Andras Bacsai
2026-08-07 14:14:26 +02:00
parent 7c6d00c8de
commit 8ae9c03209
10 changed files with 651 additions and 165 deletions
@@ -1,4 +1,4 @@
<div>
<div @if ($type !== 'server') wire:init="loadContainers" @endif>
<x-slot:title>
{{ data_get_str($resource, 'name')->limit(10) }} > Terminal | Coolify
</x-slot>
@@ -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 @@
</section>
@else
<section class="mt-8 mb-0! h-[calc(100dvh-8rem)] min-h-[32rem] w-full max-w-[1180px] xl:mt-0"
x-on:terminal-theme-selected="setTheme($event.detail.theme)"
x-on:terminal-starting.window="syncTheme()"
x-data="{
themeKeys: @js($consoleThemeKeys),
themeAccents: @js($consoleThemeAccents),
consoleTheme: 'system',
themeOpen: false,
containerOpen: false,
targetChosen: @js($selected_container !== 'default'),
selectedContainer: @entangle('selected_container').live,
containerOptions: @js($containerOptions),
init() {
@@ -88,8 +92,14 @@
localStorage.setItem('coolify-console-theme', theme);
window.dispatchEvent(new CustomEvent('terminal-theme-change', { detail: { theme } }));
},
syncTheme() {
const savedTheme = localStorage.getItem('coolify-console-theme');
this.consoleTheme = this.themeKeys.includes(savedTheme) ? savedTheme : 'system';
},
selectContainer(value) {
window.dispatchEvent(new CustomEvent('terminal-starting'));
this.selectedContainer = value;
this.targetChosen = true;
this.containerOpen = false;
},
get selectedContainerLabel() {
@@ -97,12 +107,15 @@
?? 'Choose a container';
},
}">
<div class="application-console-shell flex h-full min-h-0 flex-col overflow-hidden rounded-lg"
:data-console-theme="consoleTheme">
<div data-terminal-session-canvas
class="application-console-shell flex h-full min-h-0 flex-col overflow-hidden rounded-lg p-3 sm:p-6"
:data-console-theme="consoleTheme"
:style="{ '--terminal-scrollbar': themeAccents[consoleTheme] }">
<header
class="application-console-header flex h-[30px] shrink-0 items-center border-b border-white/[0.12] px-2.5 text-[11px] text-white select-none">
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')
<div class="flex min-w-0 flex-1 items-center gap-2" x-data="{ autoConnected: false }"
<div class="terminal-session-target-trigger flex h-8 min-w-0 max-w-sm flex-1 items-center gap-2 rounded-md px-2.5 text-xs font-medium text-white/70"
x-data="{ autoConnected: false }"
@if ($servers->first()->isTerminalEnabled() && $servers->first()->isFunctional())
x-on:terminal-websocket-ready.window="if (!autoConnected) {
autoConnected = true;
@@ -110,31 +123,25 @@
}"
@endif>
<x-reicon name="browser-terminal" class="size-3.5 shrink-0 text-white/55" />
<span class="min-w-0 truncate text-[11px] font-semibold text-white/80">
<span class="min-w-0 truncate font-semibold text-white/80">
{{ $servers->first()->name }}
</span>
<div class="hidden items-center gap-1.5 text-[10px] font-medium text-white/40"
wire:loading.flex wire:target="connectToServer">
<x-loading />
Connecting
</div>
</div>
@else
<div class="flex min-w-0 flex-1 items-center gap-2" x-data="{ autoConnected: false }"
x-on:terminal-websocket-ready.window="if ({{ $containers->count() }} === 1 && !autoConnected) {
autoConnected = true;
$nextTick(() => $wire.dispatchSelf('connectToContainer'));
}">
<x-reicon name="browser-terminal" class="size-3.5 shrink-0 text-white/55" />
<div class="flex min-w-0 flex-1 items-center gap-2">
@if ($containers->count() === 1)
<span class="min-w-0 truncate text-[11px] font-semibold text-white/80">
<div class="terminal-session-target-trigger flex h-8 min-w-0 max-w-sm items-center gap-2 rounded-md px-2.5 text-xs font-medium text-white/70">
<x-reicon name="browser-terminal" class="size-3.5 shrink-0 text-white/55" />
<span class="min-w-0 truncate font-semibold text-white/80">
{{ data_get($containers->first(), 'container.Names') }}
· {{ data_get($containers->first(), 'server.name') }}
</span>
</div>
@else
<div class="relative min-w-0" @click.outside="containerOpen = false">
<div x-cloak x-show="targetChosen" class="relative min-w-0"
@click.outside="containerOpen = false">
<button type="button"
class="flex h-6 max-w-[34rem] min-w-48 items-center gap-1.5 rounded-md px-2 text-[11px] font-semibold text-white/80 transition-colors hover:bg-white/[0.08]"
class="terminal-session-target-trigger flex h-8 max-w-[34rem] min-w-48 items-center gap-2 rounded-md px-2.5 text-xs font-medium text-white/70 transition-colors hover:bg-white/[0.08] hover:text-white"
@click="containerOpen = !containerOpen">
<span class="min-w-0 flex-1 truncate text-left"
x-text="selectedContainerLabel"></span>
@@ -145,7 +152,7 @@
</svg>
</button>
<div x-cloak x-show="containerOpen" x-transition.origin.top.left
class="absolute top-7 left-0 z-50 max-h-72 w-80 overflow-y-auto rounded-lg border border-white/[0.1] bg-[#111113] p-1 shadow-[0_18px_50px_rgba(0,0,0,0.55)]">
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)]">
<template x-for="option in containerOptions" :key="option.value">
<button type="button"
class="flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-[11px] text-white/65 transition-colors hover:bg-white/[0.07] hover:text-white"
@@ -163,51 +170,41 @@
</div>
</div>
@endif
<div class="hidden items-center gap-1.5 text-[10px] font-medium text-white/40"
wire:loading.flex wire:target="selected_container,connectToContainer">
<x-loading />
Connecting
</div>
</div>
@endif
<div class="relative ml-auto shrink-0" @click.outside="themeOpen = false">
<button type="button"
class="flex h-6 items-center gap-1.5 rounded-md px-2 text-[10px] font-medium text-white/55 transition-colors hover:bg-white/[0.08] hover:text-white/90"
@click="themeOpen = !themeOpen" aria-label="Choose terminal theme">
<span class="size-2 rounded-full ring-1 ring-white/20"
:style="{ backgroundColor: @js($consoleThemeAccents)[consoleTheme] }"></span>
<span x-text="@js($consoleThemeNames)[consoleTheme]"></span>
<svg class="size-2.5 text-white/35" viewBox="0 0 12 12" fill="none"
aria-hidden="true">
<path d="m3.5 4.75 2.5 2.5 2.5-2.5" stroke="currentColor"
stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<div x-cloak x-show="themeOpen" x-transition.origin.top.right
class="console-theme-selector absolute top-7 right-0 z-50 max-h-80 w-56 overflow-y-auto rounded-lg border border-neutral-200 bg-white p-1 shadow-[0_18px_50px_rgba(0,0,0,0.18)] dark:border-white/[0.1] dark:bg-[#111113] dark:shadow-[0_18px_50px_rgba(0,0,0,0.55)]">
@foreach ($consoleThemes as $theme)
<button type="button"
class="flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-[11px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-neutral-950 dark:text-white/65 dark:hover:bg-white/[0.07] dark:hover:text-white"
@click="setTheme('{{ $theme['key'] }}')">
<span class="h-3 w-5 rounded-full border border-white/10"
style="background: {{ $theme['background'] }}"></span>
<span class="flex-1">{{ $theme['name'] }}</span>
<svg x-show="consoleTheme === '{{ $theme['key'] }}'"
class="size-3 text-[#fcd452]" viewBox="0 0 12 12" fill="none"
aria-hidden="true">
<path d="m2.5 6.25 2.1 2.1 4.9-5" stroke="currentColor"
stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
@endforeach
</div>
</div>
<x-terminal.theme-selector :themes="$consoleThemes" :theme-names="$consoleThemeNames"
:theme-accents="$consoleThemeAccents" />
</header>
<div class="terminal-session-panel mt-8 flex min-h-0 flex-1 flex-col overflow-hidden">
<div class="application-console-block min-h-0 flex-1">
<livewire:project.shared.terminal variant="application" />
@if ($type !== 'server' && $containers->count() > 1)
<div x-cloak x-show="!targetChosen" data-terminal-target-picker="launcher"
class="absolute inset-0 z-20 flex items-start justify-start p-6 sm:p-10">
<div class="terminal-target-picker w-full max-w-md rounded-lg border p-2 shadow-[0_18px_50px_rgba(0,0,0,0.28)]">
<div class="px-2 pt-1 pb-2">
<div class="text-sm font-semibold text-white/80">Start a terminal session</div>
<div class="mt-0.5 text-[11px] text-white/45">
Choose a container to start a session
</div>
</div>
<div class="max-h-64 overflow-y-auto">
<template x-for="option in containerOptions" :key="option.value">
<button type="button"
class="flex h-9 w-full items-center gap-2 rounded-md px-2 text-left text-[11px] text-white/65 transition-colors hover:bg-white/[0.07] hover:text-white"
@click="selectContainer(option.value)">
<x-reicon name="layers" class="size-3.5 shrink-0 text-white/35" />
<span class="min-w-0 flex-1 truncate" x-text="option.label"></span>
</button>
</template>
</div>
</div>
</div>
@endif
<livewire:project.shared.terminal variant="application"
:auto-start="$type === 'server' || ! $containersLoaded || $containers->count() === 1" />
</div>
</div>
</div>
</section>