fix(ui): use listbox for domain redirects and key terminal canvases

Replace the service domain www-redirect native select with the shared
listbox (htmlId for stable trigger IDs) and allow dropdown overflow.
Key terminal target/session canvases for Livewire DOM branches, and
enable terminal browser debug via ?terminal-debug= plus theme logs.
This commit is contained in:
Andras Bacsai committed 2026-08-07 17:59:00 +02:00
1 parent fff83884da
commit 78fe7ee76f
8 files changed
+77 -28

No files matched your search

+40 -1
View File
@@ -8,7 +8,14 @@ import {
} from './terminal-session-timer.js';
import { FitAddon } from '@xterm/addon-fit';
const terminalDebugEnabled = import.meta.env.DEV;
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',
@@ -393,9 +400,15 @@ export function initializeTerminalComponent() {
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();
}
@@ -417,10 +430,36 @@ export function initializeTerminalComponent() {
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...');
@@ -1,5 +1,6 @@
@props([
'id' => null,
'htmlId' => null,
'label' => null,
'helper' => null,
'required' => false,
@@ -14,6 +15,10 @@
'tooltip' => true,
])
@php
$triggerId = ($htmlId ?? $id).'-trigger';
@endphp
<div class="w-full min-w-0">
@if ($label)
{{--
@@ -23,7 +28,7 @@
--}}
{{-- Fixed h-4 matches the helper icon so side-by-side fields align with or without a helper. --}}
<div class="mb-1.5 flex h-4 w-full items-center gap-1.5">
<label for="{{ $id }}-trigger" class="mb-0! flex items-center gap-1.5 leading-4">
<label for="{{ $triggerId }}" class="mb-0! flex items-center gap-1.5 leading-4">
{{ $label }}
@if ($required)
<x-highlighted text="*" />
@@ -52,7 +57,7 @@
}" x-modelable="value" {{ $attributes->whereStartsWith('x-model') }}
{{ $attributes->whereStartsWith('x-effect') }}
@click.outside="open = false" @keydown.escape="open = false">
<button id="{{ $id }}-trigger" type="button" class="listbox-trigger" @click="open = !open"
<button id="{{ $triggerId }}" type="button" class="listbox-trigger" @click="open = !open"
@disabled($disabled) {{ $attributes->whereStartsWith('x-bind:disabled') }} aria-haspopup="listbox"
:aria-expanded="open" @if ($tooltip) :title="current" @endif>
<span class="listbox-trigger-label" x-text="current"></span>
@@ -172,7 +172,7 @@
</div>
@else
<div wire:key="service-domains-list"
class="application-settings-section-body is-flush mt-1 w-full scroll-mt-28 overflow-hidden">
class="application-settings-section-body is-flush mt-1 w-full scroll-mt-28 overflow-visible">
@foreach ($domainGroups as $appId => $rows)
@php
$app = collect($serviceApps)->firstWhere('id', (int) $appId);
@@ -190,21 +190,14 @@
<div class="flex w-full items-center gap-3 px-4 py-3">
<span class="min-w-0 flex-1 truncate text-sm font-medium text-black dark:text-white">{{ $heading }}</span>
@can('update', $service)
<div class="relative flex shrink-0 items-center gap-2 px-1 py-1 text-sm text-neutral-600 dark:text-fg-dim"
<div class="w-52 shrink-0"
wire:loading.class="opacity-50" wire:target="serviceRedirects.{{ $appId }}">
<span>{{ $redirectLabel }}</span>
<x-reicon name="chevron-down" class="size-4 shrink-0"
wire:loading.remove wire:target="serviceRedirects.{{ $appId }}" />
<x-loading-on-button wire:loading.delay wire:target="serviceRedirects.{{ $appId }}" />
<select id="service-domain-redirect-{{ $appId }}"
wire:model.change="serviceRedirects.{{ $appId }}"
wire:loading.attr="disabled" wire:target="serviceRedirects.{{ $appId }}"
class="absolute inset-0 size-full cursor-pointer opacity-0 disabled:cursor-wait"
aria-label="Redirect direction for {{ $heading }}">
<option value="both">Allow www & non-www</option>
<option value="www">Redirect to www</option>
<option value="non-www">Redirect to non-www</option>
</select>
<x-forms.listbox id="serviceRedirects.{{ $appId }}"
htmlId="service-domain-redirect-{{ $appId }}" live :options="[
['value' => 'both', 'label' => 'Allow www & non-www'],
['value' => 'www', 'label' => 'Redirect to www'],
['value' => 'non-www', 'label' => 'Redirect to non-www'],
]" />
</div>
@else
<span class="shrink-0 text-sm text-neutral-600 dark:text-fg-dim">{{ $redirectLabel }}</span>
@@ -102,7 +102,7 @@
}
}">
@if ($selected_uuid === 'default')
<div data-terminal-target-canvas
<div wire:key="terminal-target-canvas" 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] }">
@@ -161,7 +161,7 @@
</div>
</div>
@else
<div data-terminal-session-canvas
<div wire:key="terminal-session-canvas" 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] }">
@@ -183,11 +183,12 @@ it('mounts the realtime terminal utilities in local development compose files',
'maxio dev compose' => 'docker-compose-maxio.dev.yml',
]);
it('keeps terminal browser logging restricted to Vite development mode', function () {
it('keeps terminal browser logging restricted to development or explicit diagnostics', function () {
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
expect($terminalClient)
->toContain('const terminalDebugEnabled = import.meta.env.DEV;')
->toContain('const terminalDebugEnabled = import.meta.env.DEV')
->toContain("localStorage.getItem('coolify-terminal-debug') === '1'")
->toContain("logTerminal('log', '[Terminal] WebSocket connection established.');")
->not->toContain("console.log('[Terminal] WebSocket connection established. Cool cool cool cool cool cool.');");
});
+5 -3
View File
@@ -104,9 +104,11 @@ it('groups configured domains with their service redirect and excludes services
expect($html)
->toContain("service-domain-group-{$this->apiApp->id}")
->toContain("service-domain-redirect-{$this->apiApp->id}")
->toContain("wire:model.change=\"serviceRedirects.{$this->apiApp->id}\"")
->toContain("wire:target=\"serviceRedirects.{$this->apiApp->id}\"")
->toContain("id=\"service-domain-redirect-{$this->apiApp->id}-trigger\"")
->toContain("serviceRedirects.{$this->apiApp->id}")
->toContain('class="listbox-trigger"')
->toContain('application-settings-section-body is-flush mt-1 w-full scroll-mt-28 overflow-visible')
->not->toContain("<select id=\"service-domain-redirect-{$this->apiApp->id}\"")
->not->toContain("service-domain-redirect-toggle-{$this->apiApp->id}")
->not->toContain("service-domain-group-{$this->webApp->id}")
->and(substr_count($html, '2 domains'))->toBe(1)
+9 -1
View File
@@ -20,7 +20,7 @@ it('shows a centered themed target canvas before loading xterm', function () {
->toContain('data-terminal-target-canvas')
->toContain('items-center justify-center')
->toContain(':data-console-theme="consoleTheme"')
->toContain("@else\n <div data-terminal-session-canvas");
->toContain("@else\n <div wire:key=\"terminal-session-canvas\" data-terminal-session-canvas");
});
it('loads targets inside the themed session picker with an accent scrollbar', function () {
@@ -54,6 +54,14 @@ it('uses the same padded themed canvas for the active terminal session', functio
->toMatch('/\.terminal-session-panel\s*\{[^}]*box-shadow:\s*none;/s');
});
it('keeps pre-connection and connected terminal canvases in distinct Livewire DOM branches', function () {
$view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
expect($view)
->toContain('wire:key="terminal-target-canvas"')
->toContain('wire:key="terminal-session-canvas"');
});
it('opens the global terminal outside Livewire navigation like resource terminals', function () {
$navbar = file_get_contents(resource_path('views/components/navbar.blade.php'));
@@ -111,8 +111,9 @@ it('renders an automatically added redirect counterpart without reloading', func
$page = visit("{$base}/domains")
->assertSee('https://web.example.com');
$selector = '#service-domain-redirect-'.$this->serviceApplication->id;
$page->select($selector, 'www')
$selector = '#service-domain-redirect-'.$this->serviceApplication->id.'-trigger';
$page->click($selector)
->click('Redirect to www')
->wait(3);
$this->serviceApplication->refresh();