mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-20 00:23:27 +00:00
Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -627,6 +627,16 @@ Application and server browser terminals use the same browser-oriented console
|
||||
shell, theme picker, compact header controls, and outline `browser-terminal`
|
||||
Reicon. Hide a container switcher when only one container exists.
|
||||
|
||||
The themed console shell belongs to an open session. Before a target is
|
||||
selected, the global Terminal page stays a normal top-level destination: a
|
||||
full-width layer card titled `Start a terminal session`, its filter input in
|
||||
the card header actions, and grouped `Servers` / `Containers` rows reusing the
|
||||
command-palette row classes. Do not render an empty full-height console canvas
|
||||
just to host the target picker, and do not offer the console theme selector
|
||||
before a session owns that canvas. Rows show the target name, a muted server
|
||||
column that only appears when the team has more than one server, and the shared
|
||||
chevron. Group headers stick to the top of the scrolling list and carry a count.
|
||||
|
||||
### Logs
|
||||
|
||||
Runtime and deployment logs should feel like a clean terminal surface:
|
||||
|
||||
@@ -2884,6 +2884,10 @@ class ApplicationsController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$requestHasHttpBasicAuth = $request->has('is_http_basic_auth_enabled')
|
||||
|| $request->has('http_basic_auth_username')
|
||||
|| $request->has('http_basic_auth_password');
|
||||
|
||||
if ($request->has('is_http_basic_auth_enabled') && $request->is_http_basic_auth_enabled === true) {
|
||||
if (blank($application->http_basic_auth_username) || blank($application->http_basic_auth_password)) {
|
||||
$validationErrors = [];
|
||||
@@ -2901,10 +2905,6 @@ class ApplicationsController extends Controller
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($request->has('is_http_basic_auth_enabled') && $application->is_container_label_readonly_enabled === false) {
|
||||
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
|
||||
$application->save();
|
||||
}
|
||||
|
||||
// For dockercompose applications, domains (fqdn) field should not be used
|
||||
// Only docker_compose_domains should be used to set domains for individual services
|
||||
@@ -3119,7 +3119,7 @@ class ApplicationsController extends Controller
|
||||
// Must run after fqdn is filled: flags are kept only for domains the app still has.
|
||||
$application->setNoindexDomains($request->input('noindex_domains') ?? []);
|
||||
}
|
||||
if ($application->settings->is_container_label_readonly_enabled && ($requestHasDomains || $requestHasNoindexDomains) && $server->isProxyShouldRun()) {
|
||||
if ($application->settings->is_container_label_readonly_enabled && ($requestHasDomains || $requestHasNoindexDomains || $requestHasHttpBasicAuth) && $server->isProxyShouldRun()) {
|
||||
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
|
||||
}
|
||||
$application->save();
|
||||
|
||||
@@ -727,6 +727,86 @@ html:not(.dark) .application-console-shell[data-console-theme="system"] .termina
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
/* Pre-session target list: a normal page card, not the themed console canvas. */
|
||||
.terminal-target-card .application-settings-section-body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The header inset is tuned for 32px buttons. A full-width filter needs the
|
||||
same gutter on both sides once the actions row wraps below the title.
|
||||
Matches the base header selector's specificity so the shorthand cannot win. */
|
||||
@media (max-width: 640px) {
|
||||
.application-settings-section.terminal-target-card > :is(header, .application-settings-section-header) {
|
||||
padding-right: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.terminal-target-card-list {
|
||||
max-height: min(70vh, 34rem);
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--coollabs-fill) transparent;
|
||||
}
|
||||
|
||||
.terminal-target-card-list::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.terminal-target-card-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.terminal-target-card-list::-webkit-scrollbar-thumb {
|
||||
border: 2px solid transparent;
|
||||
border-radius: 9999px;
|
||||
background-color: var(--coollabs-fill);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
/* Group headers stay readable while scrolling long container lists. */
|
||||
.terminal-target-group-label {
|
||||
display: flex;
|
||||
position: sticky;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.5rem 0.375rem;
|
||||
background: var(--coollabs-base);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--coollabs-subtle);
|
||||
}
|
||||
|
||||
.terminal-target-group-count {
|
||||
border-radius: 9999px;
|
||||
background: var(--coollabs-fill);
|
||||
padding: 0 0.375rem;
|
||||
font-size: 0.625rem;
|
||||
line-height: 1rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* The server column only earns its space once the row is wide enough. */
|
||||
.terminal-target-item-server {
|
||||
display: none;
|
||||
max-width: 14rem;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1rem;
|
||||
color: var(--coollabs-subtle);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.terminal-target-item-server {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.terminal-session-panel {
|
||||
border: 0;
|
||||
border-radius: 0.75rem;
|
||||
|
||||
@@ -934,6 +934,7 @@ export function initializeTerminalComponent() {
|
||||
tab: '\t',
|
||||
escape: '\x1b',
|
||||
ctrlC: '\x03',
|
||||
ctrlD: '\x04',
|
||||
ctrlBackslash: '\x1c',
|
||||
ctrlS: '\x13',
|
||||
ctrlZ: '\x1a'
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
: '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
|
||||
<div x-show="terminalActive" x-cloak class="sm:hidden"
|
||||
:class="fullscreen ? 'relative z-[2] shrink-0 px-2 pb-2' : (keyboardInset > 0 ? 'fixed inset-x-0 z-[100002] px-2 pb-2' : 'relative z-[2] mt-2 shrink-0')"
|
||||
:style="!fullscreen && keyboardInset > 0 ? `top: ${keyboardAnchorTop}px; transform: translateY(-100%)` : ''"
|
||||
data-terminal-mobile-toolbar>
|
||||
@@ -96,6 +96,7 @@
|
||||
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalKey('~')">~</button>
|
||||
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalKey('-')">-</button>
|
||||
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalControl('ctrlC')">^C</button>
|
||||
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalControl('ctrlD')">^D</button>
|
||||
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalControl('ctrlBackslash')">^\</button>
|
||||
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalControl('ctrlS')">^S</button>
|
||||
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalControl('ctrlZ')">^Z</button>
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
$terminalOptions[] = [
|
||||
'value' => $server->uuid,
|
||||
'label' => $server->name.' · Server',
|
||||
'name' => $server->name,
|
||||
'server' => $server->name,
|
||||
'type' => 'server',
|
||||
];
|
||||
|
||||
@@ -30,6 +32,8 @@
|
||||
$terminalOptions[] = [
|
||||
'value' => $container['uuid'],
|
||||
'label' => $server->name.' · '.$container['name'],
|
||||
'name' => $container['name'],
|
||||
'server' => $server->name,
|
||||
'type' => 'container',
|
||||
];
|
||||
}
|
||||
@@ -41,7 +45,8 @@
|
||||
?? 'Select a server or container';
|
||||
@endphp
|
||||
|
||||
<div class="terminal-page application-settings-form" x-init="$wire.loadContainers()">
|
||||
<div class="{{ $selected_uuid === 'default' ? '' : 'terminal-page' }} application-settings-form"
|
||||
x-init="$wire.loadContainers()">
|
||||
<x-slot:title>
|
||||
Terminal | Coolify
|
||||
</x-slot>
|
||||
@@ -57,11 +62,12 @@
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="terminal-page-console min-h-0 w-full flex-1 overflow-hidden"
|
||||
<section class="{{ $selected_uuid === 'default' ? 'w-full' : '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),
|
||||
multipleServers: @js($servers->count() > 1),
|
||||
currentTargetLabel: @js($terminalLabel),
|
||||
targetChosen: @js($selected_uuid !== 'default'),
|
||||
targetOpen: false,
|
||||
@@ -106,72 +112,68 @@
|
||||
}
|
||||
}">
|
||||
@if ($selected_uuid === 'default')
|
||||
<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-3 sm: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 flex max-h-full w-full max-w-lg flex-col overflow-hidden rounded-lg border shadow-[0_18px_50px_rgba(0,0,0,0.28)]">
|
||||
<div class="shrink-0 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">
|
||||
<div wire:key="terminal-target-canvas" data-terminal-target-canvas
|
||||
class="application-settings-workspace flex w-full min-w-0 flex-col">
|
||||
<x-application.settings-section class="terminal-target-card" data-terminal-target-picker="page"
|
||||
title="Start a terminal session" flush>
|
||||
@if (! $isLoadingContainers && $servers->isNotEmpty())
|
||||
<x-slot:actions>
|
||||
<div class="relative w-full sm:w-64">
|
||||
<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!">
|
||||
class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
|
||||
<input x-model.debounce.100ms="targetSearch" type="search" placeholder="Filter targets"
|
||||
aria-label="Filter terminal targets"
|
||||
class="h-8! w-full rounded-lg! border-neutral-200! bg-white! py-0! pr-8! pl-8! text-[12px]! shadow-none! placeholder:text-neutral-400 focus:border-accent! focus:ring-0! dark:border-white/[0.08]! dark:bg-white/[0.035]! dark:text-fg! dark:placeholder:text-fg-faint">
|
||||
<button x-cloak x-show="targetSearch" x-on:click="targetSearch = ''" type="button"
|
||||
class="absolute top-1/2 right-2 flex size-5 -translate-y-1/2 items-center justify-center rounded text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.07] dark:hover:text-fg"
|
||||
aria-label="Clear target filter">
|
||||
<span class="text-sm leading-none">×</span>
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="terminal-target-list min-h-0 flex-1 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
|
||||
</x-slot:actions>
|
||||
@endif
|
||||
|
||||
@if ($isLoadingContainers)
|
||||
<div class="flex min-h-40 items-center justify-center gap-2 text-[13px] text-neutral-500 dark:text-fg-dim">
|
||||
<x-loading class="size-4" />
|
||||
<span>Finding available servers and containers…</span>
|
||||
</div>
|
||||
@elseif ($servers->isEmpty())
|
||||
<x-empty title="No terminal targets available"
|
||||
description="Connect a reachable server and enable terminal access to start a session."
|
||||
icon-name="browser-terminal" />
|
||||
@else
|
||||
<div class="terminal-target-card-list">
|
||||
<template x-for="group in filteredTargetGroups" :key="group.type">
|
||||
<section class="not-last:mb-2">
|
||||
<div class="px-3 py-2 text-[10px] font-semibold tracking-wider text-white/40 uppercase"
|
||||
x-text="group.label"></div>
|
||||
<section class="command-palette-section">
|
||||
<div class="terminal-target-group-label">
|
||||
<span x-text="group.label"></span>
|
||||
<span class="terminal-target-group-count" x-text="group.targets.length"></span>
|
||||
</div>
|
||||
<template x-for="target in group.targets" :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"
|
||||
<button type="button" class="command-palette-item cursor-pointer"
|
||||
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>
|
||||
<span class="rounded-full border border-white/10 px-2 py-0.5 text-[10px] text-white/40"
|
||||
x-text="target.type"></span>
|
||||
<x-reicon name="arrow-right" class="size-3.5 shrink-0 text-white/35" />
|
||||
<x-reicon name="servers" x-cloak x-show="target.type === 'server'"
|
||||
class="size-3.5 shrink-0 text-neutral-400 dark:text-fg-faint" />
|
||||
<x-reicon name="layers" x-cloak x-show="target.type === 'container'"
|
||||
class="size-3.5 shrink-0 text-neutral-400 dark:text-fg-faint" />
|
||||
<span class="command-palette-item-name min-w-0 flex-1"
|
||||
x-text="target.name"></span>
|
||||
<span x-cloak x-show="multipleServers"
|
||||
class="terminal-target-item-server" x-text="target.server"></span>
|
||||
<x-reicon name="arrow-right" class="command-palette-item-chevron" />
|
||||
</button>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
<div x-show="filteredTargetGroups.length === 0"
|
||||
class="px-3 py-8 text-center text-sm text-white/50">
|
||||
class="px-3 py-8 text-center text-[13px] text-neutral-500 dark:text-fg-dim">
|
||||
No matching targets
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</x-application.settings-section>
|
||||
</div>
|
||||
@else
|
||||
<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"
|
||||
@@ -237,8 +239,6 @@
|
||||
<x-reicon name="layers" x-cloak 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>
|
||||
<span class="rounded-full border border-white/10 px-1.5 py-0.5 text-[9px] text-white/35"
|
||||
x-text="target.type"></span>
|
||||
<svg x-show="$wire.selected_uuid === target.value"
|
||||
class="size-3 text-[#fcd452]" viewBox="0 0 12 12" fill="none"
|
||||
aria-hidden="true">
|
||||
@@ -301,22 +301,22 @@
|
||||
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">
|
||||
<div class="terminal-target-list max-h-72 overflow-y-auto p-1">
|
||||
<template x-for="group in filteredTargetGroups" :key="group.type">
|
||||
<section class="not-last:mb-1">
|
||||
<div class="px-2 py-1.5 text-[9px] font-semibold tracking-wider text-white/35 uppercase"
|
||||
x-text="group.label"></div>
|
||||
<template x-for="target in group.targets" :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"
|
||||
class="group flex min-h-9 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"
|
||||
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>
|
||||
<span class="rounded-full border border-white/10 px-1.5 py-0.5 text-[9px] text-white/35"
|
||||
x-text="target.type"></span>
|
||||
<x-reicon name="arrow-right"
|
||||
class="size-3 shrink-0 text-white/40 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
@@ -14,6 +14,8 @@ use Illuminate\Support\Facades\Queue;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config(['app.maintenance.driver' => 'file']);
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
@@ -136,6 +138,53 @@ test('proxy settings regenerate managed labels', function () {
|
||||
expect(base64_decode($this->application->fresh()->custom_labels))->not->toContain('sentinel-label=true');
|
||||
});
|
||||
|
||||
test('http basic auth updates regenerate managed labels', function () {
|
||||
$this->application->settings->update(['is_container_label_readonly_enabled' => true]);
|
||||
$this->application->update([
|
||||
'fqdn' => 'https://app.example.com',
|
||||
'is_http_basic_auth_enabled' => false,
|
||||
'http_basic_auth_username' => null,
|
||||
'http_basic_auth_password' => null,
|
||||
'custom_labels' => base64_encode('sentinel-label=true'),
|
||||
]);
|
||||
|
||||
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'is_http_basic_auth_enabled' => true,
|
||||
'http_basic_auth_username' => 'api-user',
|
||||
'http_basic_auth_password' => 'api-password',
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$application = $this->application->fresh();
|
||||
$labels = $application->parseContainerLabels();
|
||||
|
||||
expect((bool) $application->is_http_basic_auth_enabled)->toBeTrue()
|
||||
->and($application->http_basic_auth_username)->toBe('api-user')
|
||||
->and($application->http_basic_auth_password)->toBe('api-password')
|
||||
->and($labels)->toContain('basicauth')
|
||||
->and($labels)->toContain('api-user')
|
||||
->and($labels)->not->toContain('sentinel-label=true');
|
||||
});
|
||||
|
||||
test('http basic auth updates preserve user-managed labels', function () {
|
||||
$this->application->settings->update(['is_container_label_readonly_enabled' => false]);
|
||||
$this->application->update([
|
||||
'fqdn' => 'https://app.example.com',
|
||||
'custom_labels' => base64_encode('sentinel-label=true'),
|
||||
]);
|
||||
|
||||
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'is_http_basic_auth_enabled' => true,
|
||||
'http_basic_auth_username' => 'api-user',
|
||||
'http_basic_auth_password' => 'api-password',
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
expect(base64_decode($this->application->fresh()->custom_labels))->toBe('sentinel-label=true');
|
||||
});
|
||||
|
||||
test('rejects invalid boolean application settings', function () {
|
||||
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
|
||||
@@ -98,7 +98,7 @@ it('uses a larger high-contrast label for every terminal loading phase', functio
|
||||
$styles = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect(substr_count($resourceView.$terminalView.$globalView, 'terminal-loading-label'))
|
||||
->toBeGreaterThanOrEqual(3)
|
||||
->toBeGreaterThanOrEqual(2)
|
||||
->and($styles)
|
||||
->toContain('.terminal-loading-label')
|
||||
->toContain('font-size: 0.875rem;')
|
||||
@@ -323,12 +323,12 @@ it('preserves terminal scrollback across transient reconnects', function () {
|
||||
->not->toContain("this.term.reset();\n this.term.clear();");
|
||||
});
|
||||
|
||||
it('renders a horizontally scrollable mobile terminal key row', function () {
|
||||
it('renders a horizontally scrollable terminal key row only on mobile', function () {
|
||||
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
|
||||
$appCss = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($terminalView)
|
||||
->not->toContain('class="sm:hidden" data-terminal-mobile-toolbar')
|
||||
->toContain('class="sm:hidden"')
|
||||
->toContain('overflow-x-auto')
|
||||
->toContain('whitespace-nowrap')
|
||||
->toContain('pasteFromClipboard()')
|
||||
@@ -343,11 +343,12 @@ it('renders a horizontally scrollable mobile terminal key row', function () {
|
||||
->toContain("sendTerminalKey('~')")
|
||||
->toContain("sendTerminalKey('-')")
|
||||
->toContain("sendTerminalControl('ctrlC')")
|
||||
->toContain("sendTerminalControl('ctrlD')")
|
||||
->toContain("sendTerminalControl('ctrlBackslash')")
|
||||
->toContain("sendTerminalControl('ctrlS')")
|
||||
->toContain("sendTerminalControl('ctrlZ')")
|
||||
->not->toContain("sendTerminalControl('arrowUp')")
|
||||
->toContain("fullscreen ? 'relative z-[2] shrink-0 px-2 pb-2' : 'relative z-[2] mt-2 shrink-0'")
|
||||
->toContain("fullscreen ? 'relative z-[2] shrink-0 px-2 pb-2' : (keyboardInset > 0 ? 'fixed inset-x-0 z-[100002] px-2 pb-2' : 'relative z-[2] mt-2 shrink-0')")
|
||||
->toContain('data-terminal-mobile-toolbar')
|
||||
->and($appCss)
|
||||
->toContain('.terminal-mobile-key')
|
||||
@@ -358,13 +359,13 @@ it('renders a horizontally scrollable mobile terminal key row', function () {
|
||||
->toContain('var(--terminal-scrollbar');
|
||||
});
|
||||
|
||||
it('shows the terminal key row outside fullscreen mode', function () {
|
||||
it('shows the mobile terminal key row outside fullscreen mode', function () {
|
||||
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
|
||||
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
|
||||
|
||||
expect($terminalView)
|
||||
->toContain("fullscreen ? 'relative z-[2] shrink-0 px-2 pb-2' : 'relative z-[2] mt-2 shrink-0'")
|
||||
->not->toContain('class="sm:hidden" data-terminal-mobile-toolbar')
|
||||
->toContain("fullscreen ? 'relative z-[2] shrink-0 px-2 pb-2' : (keyboardInset > 0 ? 'fixed inset-x-0 z-[100002] px-2 pb-2' : 'relative z-[2] mt-2 shrink-0')")
|
||||
->toContain('class="sm:hidden"')
|
||||
->toContain(':style="!fullscreen && keyboardInset > 0 ? `top: ${keyboardAnchorTop}px; transform: translateY(-100%)` : \'\'"')
|
||||
->and($terminalClient)
|
||||
->toContain("this.\$refs.terminalWrapper.style.removeProperty('display')")
|
||||
@@ -384,6 +385,7 @@ it('sends terminal mobile toolbar controls through the websocket', function () {
|
||||
->toContain("tab: '\\t'")
|
||||
->toContain("escape: '\\x1b'")
|
||||
->toContain("ctrlC: '\\x03'")
|
||||
->toContain("ctrlD: '\\x04'")
|
||||
->toContain("ctrlBackslash: '\\x1c'")
|
||||
->toContain("ctrlS: '\\x13'")
|
||||
->toContain("ctrlZ: '\\x1a'")
|
||||
|
||||
@@ -11,25 +11,61 @@ it('shows the initial terminal target launcher and keeps the header picker for s
|
||||
->toContain('this.targetChosen = true;');
|
||||
});
|
||||
|
||||
it('shows a centered themed target canvas before loading xterm', function () {
|
||||
it('shows the pre-session target list as a page card instead of a full-height console canvas', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
|
||||
|
||||
$pickerBranch = str($view)
|
||||
->after("@if (\$selected_uuid === 'default')")
|
||||
->before("\n @else")
|
||||
->toString();
|
||||
|
||||
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 wire:key=\"terminal-session-canvas\" data-terminal-session-canvas");
|
||||
->toContain("@else\n <div wire:key=\"terminal-session-canvas\" data-terminal-session-canvas")
|
||||
->and($pickerBranch)
|
||||
->toContain('<x-application.settings-section class="terminal-target-card"')
|
||||
->toContain('title="Start a terminal session"')
|
||||
// The themed console canvas belongs to an open session, not to target selection.
|
||||
->not->toContain('application-console-shell')
|
||||
->not->toContain(':data-console-theme="consoleTheme"')
|
||||
->not->toContain('<x-terminal.theme-selector');
|
||||
});
|
||||
|
||||
it('keeps the terminal target picker within the mobile canvas', function () {
|
||||
it('keeps the pre-session target list scrollable inside the full-width card', 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-target-picker="page"')
|
||||
->toContain('flex max-h-full w-full max-w-lg flex-col')
|
||||
->toContain('class="terminal-target-list min-h-0 flex-1 overflow-y-auto p-2"');
|
||||
->toContain('aria-label="Filter terminal targets"')
|
||||
->toContain('class="application-settings-workspace flex w-full min-w-0 flex-col"')
|
||||
->toContain('class="terminal-target-card-list"')
|
||||
->and($styles)
|
||||
->toContain('.terminal-target-card .application-settings-section-body')
|
||||
->toMatch('/\.terminal-target-card-list\s*\{[^}]*max-height:\s*min\(70vh, 34rem\);/s')
|
||||
->toMatch('/\.terminal-target-group-label\s*\{[^}]*position:\s*sticky;/s')
|
||||
// The wrapped filter needs the same gutter on both sides of the header, and the
|
||||
// override must match the base header selector's specificity to win.
|
||||
->toMatch('/\.application-settings-section\.terminal-target-card > :is\(header, \.application-settings-section-header\)\s*\{\s*padding-right:\s*1rem;/s');
|
||||
});
|
||||
|
||||
it('splits target rows into a name and a server column that only shows with several servers', 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("'name' => \$container['name'],")
|
||||
->toContain("'server' => \$server->name,")
|
||||
->toContain('multipleServers: @js($servers->count() > 1)')
|
||||
->toContain('x-text="target.name"')
|
||||
->toContain('class="terminal-target-item-server" x-text="target.server"')
|
||||
->toContain('x-show="multipleServers"')
|
||||
// The label keeps both parts so filtering still matches the server name.
|
||||
->toContain("'label' => \$server->name.' · '.\$container['name'],")
|
||||
->and($styles)
|
||||
->toContain('.terminal-target-item-server');
|
||||
});
|
||||
|
||||
it('loads targets inside the themed session picker with an accent scrollbar', function () {
|
||||
@@ -100,12 +136,12 @@ it('uses floating rounded controls instead of the legacy terminal header bar', f
|
||||
->not->toContain('application-console-header flex h-[30px]');
|
||||
});
|
||||
|
||||
it('keeps the theme selector identical before and during a terminal session', function () {
|
||||
it('offers the console theme selector only while a session owns the canvas', 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)
|
||||
->toBe(1)
|
||||
->and($selector)
|
||||
->toContain('terminal-theme-trigger flex h-8 items-center gap-2 rounded-md px-2.5 text-xs font-medium')
|
||||
->and($view)
|
||||
@@ -130,19 +166,20 @@ it('reuses one chevron theme selector before and during terminal sessions', func
|
||||
$selector = file_get_contents(resource_path('views/components/terminal/theme-selector.blade.php'));
|
||||
|
||||
expect(substr_count($globalView.$resourceView, '<x-terminal.theme-selector'))
|
||||
->toBe(3)
|
||||
->toBe(2)
|
||||
->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 () {
|
||||
it('keeps the theme selector in the floating session toolbar', 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');
|
||||
->toContain('terminal-session-toolbar absolute top-3 right-3 left-3')
|
||||
->toContain('<x-terminal.theme-selector')
|
||||
->not->toContain('class="absolute top-3 right-3 z-20"');
|
||||
});
|
||||
|
||||
it('updates the owning terminal theme directly so its label and canvas stay synchronized', function () {
|
||||
@@ -174,8 +211,9 @@ it('locks the terminal page to the viewport so the document does not scroll', fu
|
||||
$appCss = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($view)
|
||||
->toContain('class="terminal-page application-settings-form"')
|
||||
->toContain('class="terminal-page-console min-h-0 w-full flex-1 overflow-hidden"')
|
||||
// The viewport lock only applies once a session owns the canvas.
|
||||
->toContain("class=\"{{ \$selected_uuid === 'default' ? '' : 'terminal-page' }} application-settings-form\"")
|
||||
->toContain("'terminal-page-console min-h-0 w-full flex-1 overflow-hidden'")
|
||||
->not->toContain('h-[calc(100dvh-11rem)]')
|
||||
->not->toContain('min-h-[32rem]')
|
||||
->and($appCss)
|
||||
|
||||
Reference in New Issue
Block a user