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
@@ -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()
+2
View File
@@ -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
+105 -25
View File
@@ -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);
}
/*
+5
View File
@@ -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;
@@ -0,0 +1,33 @@
@props(['themes', 'themeNames', 'themeAccents'])
<div class="relative ml-auto shrink-0" @click.outside="themeOpen = false">
<button type="button"
class="terminal-theme-trigger flex h-8 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="themeOpen = !themeOpen" aria-label="Choose terminal theme" :aria-expanded="themeOpen">
<span class="size-2 rounded-full ring-1 ring-white/20"
:style="{ backgroundColor: @js($themeAccents)[consoleTheme] }"></span>
<span x-text="@js($themeNames)[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-11 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 ($themes 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>
@@ -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>
@@ -2,7 +2,8 @@
$isApplicationConsole = $variant === 'application';
@endphp
<div id="terminal-container" x-data="terminalData()"
<div id="terminal-container" x-data="terminalData()" data-auto-start="{{ $autoStart ? 'true' : 'false' }}"
x-on:terminal-starting.window="starting = true; setTerminalTheme(localStorage.getItem('coolify-console-theme') ?? 'system')"
x-on:terminal-theme-change.window="setTerminalTheme($event.detail.theme)"
@class([
'group/terminal relative h-full min-h-0 bg-transparent' => $isApplicationConsole,
@@ -40,15 +41,15 @@
<div x-ref="terminalWrapper" wire:ignore
:data-console-theme="fullscreen ? selectedTheme : null"
:class="fullscreen
? 'terminal-fullscreen-shell fixed inset-0 z-[100000] m-0 flex h-[100dvh] w-screen max-w-none flex-col overflow-hidden rounded-none p-0'
? 'terminal-fullscreen-shell fixed inset-0 z-[100000] m-0 flex w-screen max-w-none flex-col overflow-hidden rounded-none p-0'
: @js($isApplicationConsole
? 'relative flex h-full min-h-0 w-full flex-col overflow-hidden bg-transparent'
: 'relative flex w-full h-full max-h-[510px] flex-col py-4 mx-auto')">
@if ($isApplicationConsole)
<div x-show="!terminalActive" x-cloak
class="pointer-events-none absolute inset-0 z-10 flex items-center justify-center bg-black/35">
<div class="flex items-center gap-2 text-[11px] text-[#6e6e74]">
<svg x-show="connectionState === 'connecting' || connectionState === 'reconnecting'"
class="pointer-events-none absolute inset-0 z-10 flex items-center justify-center bg-transparent">
<div class="terminal-loading-label flex items-center gap-2">
<svg x-show="starting || connectionState === 'connecting' || connectionState === 'reconnecting'"
class="size-3 animate-spin" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="9" stroke="currentColor"
stroke-width="3" />
@@ -56,11 +57,11 @@
stroke-width="3" stroke-linecap="round" />
</svg>
<span
x-text="connectionState === 'connecting' ? 'connecting…' : (connectionState === 'reconnecting' ? `reconnecting… (attempt ${reconnectAttempts})` : 'choose a container to start a session')"></span>
x-text="connectionState === 'reconnecting' ? `reconnecting… (attempt ${reconnectAttempts})` : (starting ? 'connecting…' : (connectionState === 'connecting' ? 'connecting…' : 'choose a container to start a session'))"></span>
</div>
</div>
<div x-show="terminalActive" x-cloak
class="pointer-events-none absolute right-3 bottom-2 z-20 font-mono text-[10px] text-white/25"
class="terminal-session-expiry pointer-events-none absolute right-3 bottom-2 z-20 font-mono"
x-text="terminalSessionRemainingLabel()">
</div>
@else
@@ -78,8 +79,7 @@
: 'terminal-host relative z-[1] min-h-0 flex-1 overflow-hidden px-1 py-[5px] bg-transparent max-sm:pb-24')
: @js($isApplicationConsole
? 'terminal-host relative min-h-0 flex-1 overflow-hidden pt-[5px] pr-px pb-[5px] pl-1 bg-transparent'
: 'terminal-host h-[510px] max-h-[calc(100dvh-10rem)] overflow-hidden px-2 py-1 rounded-sm bg-black')"
x-show="terminalActive">
: 'terminal-host h-[510px] max-h-[calc(100dvh-10rem)] overflow-hidden px-2 py-1 rounded-sm bg-black')">
</div>
<div x-show="terminalActive" x-cloak
+120 -49
View File
@@ -58,12 +58,16 @@
</header>
<section class="terminal-page-console min-h-0 w-full flex-1 overflow-hidden"
x-on:terminal-theme-selected="setTheme($event.detail.theme)"
x-on:terminal-starting.window="syncTheme()"
x-data="{
targets: @js($terminalOptions),
currentTargetLabel: @js($terminalLabel),
targetChosen: @js($selected_uuid !== 'default'),
targetOpen: false,
targetSearch: '',
themeKeys: @js($consoleThemeKeys),
themeAccents: @js($consoleThemeAccents),
consoleTheme: 'system',
themeOpen: false,
get filteredTargets() {
@@ -83,18 +87,86 @@
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';
},
async selectTarget(target) {
window.dispatchEvent(new CustomEvent('terminal-starting'));
this.currentTargetLabel = target.label;
this.targetChosen = true;
this.targetOpen = false;
this.targetSearch = '';
await $wire.set('selected_uuid', target.value);
await $wire.connectToContainer();
}
}">
<div class="application-console-shell flex h-full min-h-0 flex-col overflow-hidden rounded-lg"
:data-console-theme="consoleTheme">
@if ($selected_uuid === 'default')
<div data-terminal-target-canvas
class="application-console-shell relative flex h-full min-h-0 w-full items-center justify-center overflow-hidden rounded-lg p-6"
:data-console-theme="consoleTheme"
:style="{ '--terminal-scrollbar': themeAccents[consoleTheme] }">
<div class="absolute top-3 right-3 z-20">
<x-terminal.theme-selector :themes="$consoleThemes" :theme-names="$consoleThemeNames"
:theme-accents="$consoleThemeAccents" />
</div>
<div data-terminal-target-picker="page"
class="terminal-target-picker z-10 w-full max-w-lg overflow-hidden rounded-lg border shadow-[0_18px_50px_rgba(0,0,0,0.28)]">
<div class="border-b border-white/[0.08] p-4">
<h2 class="text-base font-semibold text-white/85">Start a terminal session</h2>
<p class="mt-1 text-sm text-white/55">
{{ $isLoadingContainers ? 'Finding available servers and containers…' : 'Choose a server or container. The terminal will open after you select a target.' }}
</p>
@if (! $isLoadingContainers && $servers->isNotEmpty())
<div class="relative mt-3">
<x-reicon name="search"
class="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-white/40" />
<input x-model.debounce.100ms="targetSearch" type="search" placeholder="Filter targets…"
class="w-full rounded-md! border-white/[0.1]! bg-black/15! py-2! pr-3! pl-9! text-sm! text-white! shadow-none! placeholder:text-white/35 focus:border-white/25! focus:ring-0!">
</div>
@endif
</div>
<div class="terminal-target-list max-h-96 overflow-y-auto p-2">
@if ($isLoadingContainers)
<div class="flex min-h-28 items-center justify-center">
<div class="terminal-loading-label flex items-center gap-2">
<x-loading class="size-4" />
<span>Loading servers and containers…</span>
</div>
</div>
@elseif ($servers->isEmpty())
<div class="px-3 py-8 text-center">
<div class="text-sm font-medium text-white/70">No terminal targets available</div>
<div class="mt-1 text-xs text-white/45">Connect a reachable server and enable terminal access.</div>
</div>
@else
<template x-for="target in filteredTargets" :key="target.value">
<button type="button"
class="flex min-h-11 w-full items-center gap-3 rounded-md px-3 text-left text-sm text-white/70 transition-colors hover:bg-white/[0.07] hover:text-white"
x-on:click="selectTarget(target)">
<x-reicon name="servers" x-show="target.type === 'server'"
class="size-4 shrink-0 text-white/40" />
<x-reicon name="layers" x-show="target.type === 'container'"
class="size-4 shrink-0 text-white/40" />
<span class="min-w-0 flex-1 truncate" x-text="target.label"></span>
<x-reicon name="arrow-right" class="size-3.5 shrink-0 text-white/35" />
</button>
</template>
<div x-show="filteredTargets.length === 0"
class="px-3 py-8 text-center text-sm text-white/50">
No matching targets
</div>
@endif
</div>
</div>
</div>
@else
<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">
<div class="relative flex min-w-0 flex-1 items-center gap-2"
x-on:click.outside="targetOpen = false">
@if ($isLoadingContainers)
@@ -115,7 +187,8 @@
</span>
@else
<button type="button"
class="flex h-6 min-w-0 max-w-sm cursor-pointer items-center gap-1.5 rounded-md px-1.5 text-left transition-colors hover:bg-white/[0.08]"
x-cloak x-show="targetChosen"
class="terminal-session-target-trigger flex h-8 min-w-0 max-w-sm cursor-pointer items-center gap-2 rounded-md px-2.5 text-left text-xs font-medium text-white/70 transition-colors hover:bg-white/[0.08] hover:text-white"
x-on:click="targetOpen = !targetOpen" :aria-expanded="targetOpen"
aria-label="Choose terminal target">
<span class="min-w-0 truncate text-[11px] font-semibold text-white/80"
@@ -128,7 +201,7 @@
</button>
<div x-cloak x-show="targetOpen" x-transition.origin.top.left
class="absolute top-7 left-0 z-50 w-72 overflow-hidden rounded-lg border border-white/[0.1] bg-[#111113] shadow-[0_18px_50px_rgba(0,0,0,0.55)]">
class="terminal-target-picker absolute top-11 left-0 z-50 w-80 overflow-hidden rounded-lg border shadow-[0_18px_50px_rgba(0,0,0,0.35)]">
<div class="border-b border-white/[0.08] p-1.5">
<div class="relative">
<x-reicon name="search"
@@ -138,7 +211,7 @@
class="h-7! w-full rounded-md! border-white/[0.08]! bg-white/[0.05]! py-0! pr-2! pl-7! text-[11px]! text-white! shadow-none! placeholder:text-white/30 focus:border-accent! focus:ring-0!">
</div>
</div>
<div class="max-h-72 overflow-y-auto p-1">
<div class="terminal-target-list max-h-72 overflow-y-auto p-1">
<template x-for="target in filteredTargets" :key="target.value">
<button type="button"
class="flex min-h-8 w-full cursor-pointer 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"
@@ -164,57 +237,18 @@
</div>
</div>
<div class="hidden items-center gap-1.5 text-[10px] font-medium text-white/40"
wire:loading.flex wire:target="selected_uuid,connectToContainer">
<svg class="size-3 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle class="opacity-25" cx="12" cy="12" r="9" stroke="currentColor"
stroke-width="3" />
<path class="opacity-75" d="M21 12a9 9 0 0 0-9-9" stroke="currentColor"
stroke-width="3" stroke-linecap="round" />
</svg>
Connecting
</div>
@endif
</div>
<div class="relative ml-auto shrink-0" x-on:click.outside="themeOpen = false">
<button type="button"
class="flex h-6 cursor-pointer 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"
x-on:click="themeOpen = !themeOpen" aria-label="Choose terminal theme"
:aria-expanded="themeOpen">
<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 cursor-pointer 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"
x-on: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 overflow-hidden">
@if ($isLoadingContainers)
<div class="flex h-full min-h-0 items-center justify-center">
<div class="flex items-center gap-2 text-[11px] text-white/45">
<div class="terminal-loading-label flex items-center gap-2">
<svg class="size-3 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle class="opacity-25" cx="12" cy="12" r="9" stroke="currentColor"
stroke-width="3" />
@@ -231,9 +265,46 @@
icon-name="browser-terminal" />
</div>
@else
<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 overflow-hidden rounded-lg border shadow-[0_18px_50px_rgba(0,0,0,0.28)]">
<div class="border-b border-white/[0.08] p-2">
<div class="px-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 server or container</div>
</div>
<div class="relative">
<x-reicon name="search"
class="pointer-events-none absolute top-1/2 left-2 size-3 -translate-y-1/2 text-white/35" />
<input x-model.debounce.100ms="targetSearch" type="search"
placeholder="Filter targets…"
class="h-8! w-full rounded-md! border-white/[0.08]! bg-white/[0.05]! py-0! pr-2! pl-7! text-[11px]! text-white! shadow-none! placeholder:text-white/30 focus:border-accent! focus:ring-0!">
</div>
</div>
<div class="max-h-72 overflow-y-auto p-1">
<template x-for="target in filteredTargets" :key="target.value">
<button type="button"
class="flex min-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"
x-on:click="selectTarget(target)">
<x-reicon name="servers" x-show="target.type === 'server'"
class="size-3.5 shrink-0 text-white/35" />
<x-reicon name="layers" x-show="target.type === 'container'"
class="size-3.5 shrink-0 text-white/35" />
<span class="min-w-0 flex-1 truncate" x-text="target.label"></span>
</button>
</template>
<div x-show="filteredTargets.length === 0"
class="px-2 py-5 text-center text-[11px] text-white/35">
No matching targets
</div>
</div>
</div>
</div>
<livewire:project.shared.terminal variant="application" />
@endif
</div>
</div>
</div>
@endif
</section>
</div>
+175 -8
View File
@@ -1,5 +1,161 @@
<?php
it('renders the resource terminal shell while containers are discovered', function () {
$terminalComponent = file_get_contents(app_path('Livewire/Project/Shared/ExecuteContainerCommand.php'));
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
expect($terminalComponent)
->toContain('public bool $containersLoaded = false;')
->and($terminalView)
->toContain('wire:init="loadContainers"')
->not->toContain('@if (! $containersLoaded)')
->not->toContain('Loading terminal containers…')
->toContain(':auto-start="$type === \'server\' || ! $containersLoaded || $containers->count() === 1"')
->not->toContain('Loading targets…')
->not->toContain('<x-loading text="Loading containers" />');
});
it('starts a single discovered resource container without waiting for a missed browser event', function () {
$terminalComponent = file_get_contents(app_path('Livewire/Project/Shared/ExecuteContainerCommand.php'));
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
expect($terminalComponent)
->toMatch('/if \(\$this->containers->count\(\) === 1\) \{\s*\$this->selected_container = [^;]+;\s*\$this->connectToContainer\(\);/s')
->and($terminalView)
->not->toContain('x-on:containers-loaded.window');
});
it('integrates automatic terminal startup into the terminal console', function () {
$terminalComponent = file_get_contents(app_path('Livewire/Project/Shared/Terminal.php'));
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
$commandView = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
expect($terminalComponent)
->toContain('public bool $autoStart = false;')
->and($terminalView)
->toContain('data-auto-start="{{ $autoStart ? \'true\' : \'false\' }}"')
->toContain("starting ? 'connecting…'")
->and($commandView)
->toContain(':auto-start="$type === \'server\' || ! $containersLoaded || $containers->count() === 1"')
->not->toContain('terminalLoading')
->not->toContain('Starting terminal');
});
it('resynchronizes the themed canvas whenever a terminal enters a loading state', function () {
$resourceView = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
$globalView = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
foreach ([$resourceView, $globalView] as $view) {
expect($view)
->toContain('x-on:terminal-starting.window="syncTheme()"')
->toContain('syncTheme() {')
->toContain("this.consoleTheme = this.themeKeys.includes(savedTheme) ? savedTheme : 'system';");
}
});
it('keeps the xterm mount visible while the terminal connection starts', function () {
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
expect($terminalView)
->toMatch('/<div id="terminal"[^>]*wire:ignore/s')
->not->toMatch('/<div id="terminal"[^>]*x-show="terminalActive"/s');
});
it('shows the initial resource container launcher and keeps the header picker for switching', function () {
$commandView = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
expect($commandView)
->toContain('targetChosen: @js($selected_container !== \'default\')')
->toContain('data-terminal-target-picker="launcher"')
->toContain('x-show="!targetChosen"')
->toContain('x-show="targetChosen"')
->toContain('this.targetChosen = true;');
});
it('uses a larger high-contrast label for every terminal loading phase', function () {
$resourceView = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
$globalView = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
$styles = file_get_contents(resource_path('css/app.css'));
expect(substr_count($resourceView.$terminalView.$globalView, 'terminal-loading-label'))
->toBeGreaterThanOrEqual(3)
->and($styles)
->toContain('.terminal-loading-label')
->toContain('font-size: 0.875rem;')
->toContain('color: rgb(255 255 255 / 0.75);')
->toContain('html:not(.dark) .application-console-shell[data-console-theme="system"] .terminal-loading-label');
});
it('styles centered terminal target pickers from the selected console theme', function () {
$resourceView = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
$globalView = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
$styles = file_get_contents(resource_path('css/app.css'));
expect(substr_count($resourceView.$globalView, 'terminal-target-picker'))
->toBeGreaterThanOrEqual(2)
->and($styles)
->toContain('.terminal-target-picker')
->toContain('.application-console-shell[data-console-theme="system"] .terminal-target-picker')
->toContain('background: rgb(0 0 0 / 0.18);')
->toContain('background: #fff;')
->toContain('color: #52525b;');
});
it('presents initial terminal targets as a top-aligned launcher instead of a centered dialog', function () {
$resourceView = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
$globalView = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
expect($resourceView.$globalView)
->toContain('data-terminal-target-picker="launcher"')
->toContain('items-start justify-start')
->toContain('Start a terminal session')
->not->toContain('data-terminal-target-picker="center"');
});
it('shows connection progress in the terminal body instead of the header', function () {
$resourceView = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
$globalView = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
expect($resourceView.$globalView)
->toContain("new CustomEvent('terminal-starting')")
->not->toContain('wire:loading.flex wire:target="selected_container,connectToContainer"')
->not->toContain('wire:loading.flex wire:target="selected_uuid,connectToContainer"')
->and($terminalView)
->toContain("x-on:terminal-starting.window=\"starting = true; setTerminalTheme(localStorage.getItem('coolify-console-theme') ?? 'system')\"")
->toContain('data-auto-start="{{ $autoStart ? \'true\' : \'false\' }}"')
->toContain("starting ? 'connecting…'")
->and($terminalClient)
->toContain('starting: false')
->toContain("this.starting = this.\$el.dataset.autoStart === 'true';");
});
it('uses the redesigned terminal canvas and controls on resource terminal pages', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
expect($view)
->toContain('data-terminal-session-canvas')
->toContain('terminal-session-toolbar absolute top-3 right-3 left-3')
->toContain('terminal-session-panel mt-8')
->toContain('<x-terminal.theme-selector')
->not->toContain('application-console-header flex h-[30px]');
});
it('uses a readable theme-aware terminal session expiry label', function () {
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
$styles = file_get_contents(resource_path('css/app.css'));
expect($terminalView)
->toContain('terminal-session-expiry')
->and($styles)
->toContain('.terminal-session-expiry')
->toContain('font-size: 0.75rem;')
->toContain('color: rgb(255 255 255 / 0.6);')
->toContain('.terminal-fullscreen-shell[data-console-theme="system"] .terminal-session-expiry');
});
it('copies the realtime terminal utilities into the container image', function () {
$dockerfile = file_get_contents(base_path('docker/coolify-realtime/Dockerfile'));
@@ -201,15 +357,18 @@ it('resizes after toggling the mobile terminal toolbar', function () {
it('uses fixed viewport positioning for fullscreen terminal instead of inherited container size', function () {
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
$styles = file_get_contents(resource_path('css/app.css'));
expect($terminalView)
->toContain('terminal-fullscreen-shell fixed inset-0')
->toContain('z-[100000]')
->toContain('h-[100dvh]')
->toContain('w-screen')
->toContain('max-w-none')
->toContain('overflow-hidden')
->toContain(':data-console-theme="fullscreen ? selectedTheme : null"');
->toContain(':data-console-theme="fullscreen ? selectedTheme : null"')
->not->toContain('h-[100dvh]')
->and($styles)
->toContain('height: auto !important;');
});
it('constrains normal terminal height after leaving fullscreen', function () {
@@ -229,17 +388,25 @@ it('keeps enter and exit fullscreen controls the same size and chrome', function
->and($appCss)
->toContain('.terminal-fullscreen-btn')
->toContain('width: 1.75rem')
->toContain('height: 1.75rem');
->toContain('height: 1.75rem')
->toContain('color: var(--terminal-scrollbar')
->toContain('background: transparent;')
->toContain('color-mix(in srgb, var(--terminal-scrollbar');
});
it('does not apply backdrop-filter on the console block itself so fixed fullscreen can escape', function () {
it('lets the selected theme show through the active terminal panel', function () {
$appCss = file_get_contents(resource_path('css/app.css'));
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
expect($appCss)
->toContain('.application-console-block::before')
->toContain('position:fixed descendants')
->toMatch('/\.application-console-block::before\s*\{[^}]*backdrop-filter/s')
->not->toMatch('/\.application-console-block\s*\{[^}]*\bbackdrop-filter\s*:/s');
->toMatch('/\.terminal-session-panel\s*\{[^}]*background:\s*transparent;/s')
->toMatch('/\.terminal-session-panel\s*\{[^}]*border:\s*0;/s')
->toMatch('/\.terminal-session-panel\s*\{[^}]*box-shadow:\s*none;/s')
->not->toContain('.application-console-block::before')
->not->toMatch('/\.application-console-block\s*\{[^}]*\bbackdrop-filter\s*:/s')
->and($terminalView)
->toContain('items-center justify-center bg-transparent')
->not->toContain('items-center justify-center bg-black/35');
});
it('keeps the terminal in the Livewire tree and unlocks ancestor stacking for fullscreen', function () {
+132 -8
View File
@@ -1,5 +1,126 @@
<?php
it('shows the initial terminal target launcher and keeps the header picker for switching', function () {
$view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
expect($view)
->toContain('targetChosen: @js($selected_uuid !== \'default\')')
->toContain('data-terminal-target-picker="launcher"')
->toContain('x-show="!targetChosen"')
->toContain('x-show="targetChosen"')
->toContain('this.targetChosen = true;');
});
it('shows a centered themed target canvas before loading xterm', function () {
$view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
expect($view)
->toContain("@if (\$selected_uuid === 'default')")
->toContain('data-terminal-target-picker="page"')
->toContain('data-terminal-target-canvas')
->toContain('items-center justify-center')
->toContain(':data-console-theme="consoleTheme"')
->toContain("@else\n <div data-terminal-session-canvas");
});
it('loads targets inside the themed session picker with an accent scrollbar', function () {
$view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
$styles = file_get_contents(resource_path('css/app.css'));
expect($view)
->toContain('Finding available servers and containers…')
->toContain('terminal-target-list')
->toContain('Loading servers and containers…')
->toContain("'--terminal-scrollbar': themeAccents[consoleTheme]")
->and($styles)
->toContain('.terminal-target-list')
->toContain('var(--terminal-scrollbar')
->toContain('.terminal-target-list::-webkit-scrollbar-thumb');
});
it('uses the same padded themed canvas for the active terminal session', function () {
$view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
$styles = file_get_contents(resource_path('css/app.css'));
expect($view)
->toContain('data-terminal-session-canvas')
->toContain('p-3 sm:p-6')
->toContain('terminal-session-panel mt-8')
->and($styles)
->toContain('.terminal-session-panel')
->toContain('border-radius: 0.75rem;')
->toMatch('/\.terminal-session-panel\s*\{[^}]*border:\s*0;/s')
->toMatch('/\.terminal-session-panel\s*\{[^}]*background:\s*transparent;/s')
->toMatch('/\.terminal-session-panel\s*\{[^}]*box-shadow:\s*none;/s');
});
it('uses floating rounded controls instead of the legacy terminal header bar', function () {
$view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
expect($view)
->toContain('terminal-session-toolbar')
->toContain('terminal-session-target-trigger')
->toContain('<x-terminal.theme-selector')
->not->toContain('application-console-header flex h-[30px]');
});
it('keeps the theme selector identical before and during a terminal session', function () {
$view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
$selector = file_get_contents(resource_path('views/components/terminal/theme-selector.blade.php'));
expect(substr_count($view, '<x-terminal.theme-selector'))
->toBe(2)
->and($selector)
->toContain('terminal-theme-trigger flex h-8 items-center gap-2 rounded-md px-2.5 text-xs font-medium')
->and($view)
->toContain('terminal-session-toolbar absolute top-3 right-3 left-3')
->not->toContain('terminal-theme-trigger');
});
it('uses the same borderless control style for target and theme selectors', function () {
$globalView = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
$resourceView = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
expect($globalView.$resourceView)
->not->toContain('terminal-session-target-trigger flex h-9')
->not->toContain('terminal-session-target-trigger flex h-8 min-w-0 max-w-sm cursor-pointer items-center gap-2 rounded-md border')
->toContain('terminal-session-target-trigger flex h-8')
->toContain('rounded-md px-2.5');
});
it('reuses one chevron theme selector before and during terminal sessions', function () {
$globalView = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
$resourceView = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
$selector = file_get_contents(resource_path('views/components/terminal/theme-selector.blade.php'));
expect(substr_count($globalView.$resourceView, '<x-terminal.theme-selector'))
->toBe(3)
->and($selector)
->toContain('terminal-theme-trigger')
->toContain('viewBox="0 0 12 12"')
->toContain('m3.5 4.75 2.5 2.5 2.5-2.5');
});
it('keeps the pre-session theme selector out of the centered picker layout', function () {
$view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
expect($view)
->toContain('class="absolute top-3 right-3 z-20"')
->toContain('<x-terminal.theme-selector');
});
it('updates the owning terminal theme directly so its label and canvas stay synchronized', function () {
$globalView = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
$resourceView = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
$selector = file_get_contents(resource_path('views/components/terminal/theme-selector.blade.php'));
expect($globalView.$resourceView)
->toContain('setTheme(theme)')
->and($selector)
->toContain('@click="setTheme(')
->not->toContain("\$dispatch('terminal-theme-selected'");
});
it('places the terminal helper beside the page title instead of under the subtitle', function () {
$view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
@@ -57,7 +178,7 @@ it('defaults to a system console theme that follows the page color mode', functi
expect($terminalClient)
->toContain("'system': createSystemTerminalTheme()")
->toContain('new MutationObserver')
->toContain("attributeFilter: ['class', 'data-theme']")
->toContain("attributeFilter: ['class', 'data-theme', 'style']")
->and($appCss)
->toContain('[data-console-theme="system"]')
->toContain('html:not(.dark) .application-console-shell[data-console-theme="system"]')
@@ -69,16 +190,19 @@ it('defaults to a system console theme that follows the page color mode', functi
it('uses the page color mode for the console theme selector', function () {
$terminalView = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
$consoleView = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
$selector = file_get_contents(resource_path('views/components/terminal/theme-selector.blade.php'));
foreach ([$terminalView, $consoleView] as $view) {
expect($view)
->toContain('console-theme-selector')
->toContain('border-neutral-200 bg-white')
->toContain('dark:bg-[#111113]')
->toContain('text-neutral-600 transition-colors')
->toContain('dark:text-white/65');
expect($view)->toContain('<x-terminal.theme-selector');
}
expect($selector)
->toContain('console-theme-selector')
->toContain('border-neutral-200 bg-white')
->toContain('dark:bg-[#111113]')
->toContain('text-neutral-600 transition-colors')
->toContain('dark:text-white/65');
$appCss = file_get_contents(resource_path('css/app.css'));
expect($appCss)
@@ -93,7 +217,7 @@ it('shows terminal unavailable without the terminal shell wrapper', function ()
$unavailableBranch = str($consoleView)
->after('@if ($consoleUnavailable)')
->before('@else')
->before("@else\n <section class=\"mt-8 mb-0!")
->toString();
expect($consoleView)