This commit is contained in:
jarek committed 2026-01-03 14:56:20 +01:00
1 parent 66e723052d
commit bd7b832394
9 files changed
+265 -134

No files matched your search

+11 -3
View File
@@ -6,6 +6,9 @@
# - Full transparency (no dependency on pre-built Chainguard images)
# - Reproducible builds from open-source Wolfi packages
# - Minimal attack surface with only required packages
#
# Bun is copied from the official oven/bun image (app-builder stage) to ensure
# compatibility with all x86_64 CPUs (including those without AVX2 like Celeron).
# =============================================================================
# -----------------------------------------------------------------------------
@@ -15,13 +18,14 @@
# to build our custom Wolfi OS from scratch using open-source packages.
FROM alpine:3.21 AS os-builder
ARG TARGETARCH
WORKDIR /work
# Install apko tool (latest stable release)
# apko is the tool Chainguard uses to build their images - we use it directly
ARG APKO_VERSION=0.30.34
ARG TARGETARCH
RUN apk add --no-cache curl \
RUN apk add --no-cache curl unzip \
&& ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "arm64" || echo "amd64") \
&& curl -sL "https://github.com/chainguard-dev/apko/releases/download/v${APKO_VERSION}/apko_${APKO_VERSION}_linux_${ARCH}.tar.gz" \
| tar -xz --strip-components=1 -C /usr/local/bin \
@@ -29,6 +33,7 @@ RUN apk add --no-cache curl \
# Generate apko.yaml for current target architecture only
# We build single-arch to avoid multi-arch layer confusion in extraction
# Note: Bun is NOT included here - it's copied from app-builder stage for CPU compatibility
RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64") \
&& printf '%s\n' \
"contents:" \
@@ -41,7 +46,6 @@ RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64")
" - ca-certificates" \
" - busybox" \
" - tzdata" \
" - bun" \
" - docker-cli" \
" - docker-compose" \
" - sqlite" \
@@ -99,6 +103,10 @@ FROM scratch
# Install our custom-built Wolfi OS (now we have /bin/sh!)
COPY --from=os-builder /work/rootfs/ /
# Copy Bun from official image - ensures compatibility with all x86_64 CPUs (no AVX2 requirement)
# Wolfi's bun package requires AVX2 which breaks on Celeron/Atom CPUs
COPY --from=app-builder /usr/local/bin/bun /usr/bin/bun
WORKDIR /app
# Set up environment variables
+80 -54
View File
@@ -287,46 +287,68 @@
<div class="flex flex-col h-full {className}">
<!-- Header -->
<div class="px-4 py-2.5 border-b border-zinc-200 dark:border-zinc-700 flex flex-col gap-1.5">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2 flex-nowrap min-w-0">
<span class="text-xs text-zinc-500 dark:text-zinc-400">Environment variables</span>
{#if infoText}
<Tooltip.Root>
<Tooltip.Trigger>
<Info class="w-3.5 h-3.5 text-blue-400" />
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content side="bottom" sideOffset={8} class="max-w-xs w-64 bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 border-zinc-200 dark:border-zinc-700">
<p class="text-xs text-left">{infoText}</p>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
{/if}
<!-- View mode toggle -->
<div class="flex items-center gap-0.5 bg-zinc-100 dark:bg-zinc-800 rounded p-0.5 ml-1">
<button
type="button"
class="flex items-center gap-1 px-1.5 py-0.5 rounded text-2xs transition-colors {viewMode === 'form' ? 'bg-white dark:bg-zinc-700 text-zinc-800 dark:text-zinc-100 shadow-sm' : 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200'}"
onclick={() => handleViewModeChange('form')}
title="Form view"
>
<List class="w-3 h-3" />
</button>
<button
type="button"
class="flex items-center gap-1 px-1.5 py-0.5 rounded text-2xs transition-colors {viewMode === 'text' ? 'bg-white dark:bg-zinc-700 text-zinc-800 dark:text-zinc-100 shadow-sm' : 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200'}"
onclick={() => handleViewModeChange('text')}
title="Text view (raw .env file)"
>
<FileText class="w-3 h-3" />
</button>
</div>
<!-- Header row: title + info + view toggle + validation pills + actions -->
<div class="flex items-center gap-2 justify-between">
<div class="flex items-center gap-2 flex-wrap min-w-0">
<span class="text-xs text-zinc-500 dark:text-zinc-400 shrink-0">Environment variables</span>
{#if infoText}
<Tooltip.Root>
<Tooltip.Trigger>
<Info class="w-3.5 h-3.5 text-blue-400 shrink-0" />
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content side="bottom" sideOffset={8} class="max-w-xs w-64 bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 border-zinc-200 dark:border-zinc-700">
<p class="text-xs text-left">{infoText}</p>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
{/if}
<!-- View mode toggle -->
<div class="flex items-center gap-0.5 bg-zinc-100 dark:bg-zinc-800 rounded p-0.5 shrink-0">
<button
type="button"
class="flex items-center gap-1 px-1.5 py-0.5 rounded text-2xs transition-colors {viewMode === 'form' ? 'bg-white dark:bg-zinc-700 text-zinc-800 dark:text-zinc-100 shadow-sm' : 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200'}"
onclick={() => handleViewModeChange('form')}
title="Form view"
>
<List class="w-3 h-3" />
</button>
<button
type="button"
class="flex items-center gap-1 px-1.5 py-0.5 rounded text-2xs transition-colors {viewMode === 'text' ? 'bg-white dark:bg-zinc-700 text-zinc-800 dark:text-zinc-100 shadow-sm' : 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200'}"
onclick={() => handleViewModeChange('text')}
title="Text view (raw .env file)"
>
<FileText class="w-3 h-3" />
</button>
</div>
<!-- Validation status pills -->
{#if validation}
<div class="flex gap-1 flex-wrap">
{#if validation.missing.length > 0}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-2xs font-medium bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">
{validation.missing.length} missing
</span>
{/if}
{#if validation.required.length > 0}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-2xs font-medium bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
{validation.required.length - validation.missing.length} defined
</span>
{/if}
{#if validation.optional.length > 0}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-2xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
{validation.optional.length} optional
</span>
{/if}
</div>
{/if}
</div>
<!-- Actions - right-aligned -->
{#if !readonly}
<div class="flex items-center gap-1 shrink-0 ml-4">
<div class="flex items-center gap-1 shrink-0">
<Button type="button" size="sm" variant="ghost" onclick={handleLoadFromFile} class="h-6 text-xs px-2">
<Upload class="w-3.5 h-3.5 mr-1" />
Load .env
Load
</Button>
{#if viewMode === 'form'}
<Button type="button" size="sm" variant="ghost" onclick={addEnvVariable} class="h-6 text-xs px-2">
@@ -334,24 +356,28 @@
Add
</Button>
{/if}
<div class="{hasContent ? '' : 'invisible'}">
<ConfirmPopover
bind:open={confirmClearOpen}
title="Clear all variables?"
action="clear"
itemType="environment variables"
confirmText="Clear all"
onConfirm={clearAll}
onOpenChange={(o) => confirmClearOpen = o}
>
{#snippet children({ open })}
<Button type="button" size="sm" variant="ghost" class="h-6 text-xs px-2 text-destructive hover:text-destructive">
<Trash2 class="w-3.5 h-3.5 mr-1" />
Clear
</Button>
{/snippet}
</ConfirmPopover>
</div>
<ConfirmPopover
bind:open={confirmClearOpen}
title="Clear all variables?"
action="clear"
itemType="environment variables"
confirmText="Clear all"
onConfirm={clearAll}
onOpenChange={(o) => confirmClearOpen = o}
>
{#snippet children({ open })}
<Button
type="button"
size="sm"
variant="ghost"
class="h-6 text-xs px-2 {hasContent ? 'text-destructive hover:text-destructive' : 'text-muted-foreground/50 cursor-not-allowed'}"
disabled={!hasContent}
>
<Trash2 class="w-3.5 h-3.5 mr-1" />
Clear
</Button>
{/snippet}
</ConfirmPopover>
</div>
<input
bind:this={fileInputRef}
+8 -2
View File
@@ -498,12 +498,18 @@
// Row state cache to prevent creating new objects on every scroll
let rowStateCache = new WeakMap<object, DataGridRowState>();
let rowStateCacheDataRef: T[] | null = null;
let rowStateCacheExpandedRef: Set<unknown> | null = null;
let rowStateCacheSelectedRef: Set<unknown> | null = null;
// Clear row state cache when data reference changes
// Clear row state cache when data or selection/expansion state changes
$effect(() => {
if (data !== rowStateCacheDataRef) {
if (data !== rowStateCacheDataRef ||
expandedKeys !== rowStateCacheExpandedRef ||
selectedKeys !== rowStateCacheSelectedRef) {
rowStateCache = new WeakMap();
rowStateCacheDataRef = data;
rowStateCacheExpandedRef = expandedKeys;
rowStateCacheSelectedRef = selectedKeys;
}
});
+10
View File
@@ -1,4 +1,14 @@
[
{
"version": "1.0.6",
"date": "2026-01-03",
"changes": [
{ "type": "fix", "text": "Legacy CPU support (Celeron, Atom) - Bun binary now copied from official image instead of Wolfi package" },
{ "type": "fix", "text": "Stack modal layouts improved with resizable split panels" },
{ "type": "fix", "text": "Missing column headers in images overview" }
],
"imageTag": "fnsys/dockhand:v1.0.6"
},
{
"version": "1.0.5",
"date": "2026-01-01",
+9 -3
View File
@@ -307,7 +307,9 @@
total = data.total;
if (append) {
events = [...events, ...data.events];
// Use push() for O(k) append instead of spread for O(n) copy
events.push(...data.events);
events = events; // Trigger Svelte reactivity
hasMore = events.length < total;
// Update eventIds Set with new events
for (const evt of data.events) {
@@ -517,12 +519,16 @@
// Add to beginning of events (prepend new events) - use Set for fast duplicate check
if (!eventIds.has(newEvent.id)) {
eventIds.add(newEvent.id);
events = [newEvent, ...events];
// Use unshift() for in-place mutation instead of spread for O(n) copy
events.unshift(newEvent);
events = events; // Trigger Svelte reactivity
total = total + 1;
// Add container to list if not already there
if (newEvent.containerName && !containers.includes(newEvent.containerName)) {
containers = [...containers, newEvent.containerName].sort();
containers.push(newEvent.containerName);
containers.sort();
containers = containers; // Trigger Svelte reactivity
}
}
} catch {
+20 -1
View File
@@ -7,7 +7,7 @@
import { Label } from '$lib/components/ui/label';
import * as Dialog from '$lib/components/ui/dialog';
import * as Select from '$lib/components/ui/select';
import { Trash2, Upload, RefreshCw, Play, Search, Layers, Server, ShieldCheck, CheckSquare, Square, Tag, Check, XCircle, Icon, AlertTriangle, X, Images, Copy, Download, ChevronRight, ChevronDown, Loader2 } from 'lucide-svelte';
import { Trash2, Upload, RefreshCw, Play, Search, Layers, Server, ShieldCheck, CheckSquare, Square, Tag, Check, XCircle, Icon, AlertTriangle, X, Images, Copy, Download, ChevronRight, ChevronDown, Loader2, ArrowUp, ArrowDown, ArrowUpDown } from 'lucide-svelte';
import { broom, whale } from '@lucide/lab';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import BatchOperationModal from '$lib/components/BatchOperationModal.svelte';
@@ -720,6 +720,25 @@
<Square class="w-3.5 h-3.5 text-muted-foreground" />
{/if}
</button>
{:else if column.sortable}
<button
type="button"
onclick={() => toggleSort(column.sortField ?? column.id)}
class="flex items-center gap-1 hover:text-foreground transition-colors w-full"
>
{column.label}
{#if sortState?.field === (column.sortField ?? column.id)}
{#if sortState.direction === 'asc'}
<ArrowUp class="w-3 h-3" />
{:else}
<ArrowDown class="w-3 h-3" />
{/if}
{:else}
<ArrowUpDown class="w-3 h-3 opacity-30" />
{/if}
</button>
{:else if column.id !== 'expand' && column.id !== 'actions'}
{column.label}
{/if}
{/snippet}
{#snippet cell(column, group, rowState)}
+8 -4
View File
@@ -1270,10 +1270,7 @@
sortDirection = state.direction;
}}
onRowClick={(stack, e) => {
const hasContainers = stack.containers && stack.containers.length > 0;
if (hasContainers) {
toggleExpand(stack.name);
}
toggleExpand(stack.name);
}}
rowClass={(stack) => {
const isExp = expandedStacks.has(stack.name);
@@ -1904,6 +1901,13 @@
{/each}
</div>
</div>
{:else}
<div class="p-4 pl-12 shadow-inner bg-muted/30">
<div class="flex items-center justify-center gap-2 py-4 text-muted-foreground text-sm">
<Box class="w-4 h-4" />
<span>No containers</span>
</div>
</div>
{/if}
{/snippet}
</DataGrid>
+103 -14
View File
@@ -1,17 +1,25 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import * as Select from '$lib/components/ui/select';
import { Label } from '$lib/components/ui/label';
import { Input } from '$lib/components/ui/input';
import { TogglePill } from '$lib/components/ui/toggle-pill';
import { Loader2, GitBranch, RefreshCw, Webhook, Rocket, RefreshCcw, Copy, Check, FolderGit2, Github, Key, KeyRound, Lock, FileText, HelpCircle } from 'lucide-svelte';
import { Loader2, GitBranch, RefreshCw, Webhook, Rocket, RefreshCcw, Copy, Check, FolderGit2, Github, Key, KeyRound, Lock, FileText, HelpCircle, GripVertical, X } from 'lucide-svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import CronEditor from '$lib/components/cron-editor.svelte';
import StackEnvVarsPanel from '$lib/components/StackEnvVarsPanel.svelte';
import { type EnvVar, type ValidationResult } from '$lib/components/StackEnvVarsEditor.svelte';
import { toast } from 'svelte-sonner';
import { focusFirstInput } from '$lib/utils';
import { useSidebar } from '$lib/components/ui/sidebar/context.svelte';
// Get sidebar state to adjust modal positioning
const sidebar = useSidebar();
// localStorage key for persisted split ratio
const STORAGE_KEY_SPLIT = 'dockhand-git-stack-modal-split';
interface GitCredential {
id: number;
@@ -92,6 +100,11 @@
let loadingFileVars = $state(false);
let existingSecretKeys = $state<Set<string>>(new Set());
// Resizable split panel state
let splitRatio = $state(60); // percentage for form panel
let isDraggingSplit = $state(false);
let containerRef: HTMLDivElement | null = $state(null);
// Derived state for merged variables and sources
const envVarSources = $derived<Record<string, 'file' | 'override'>>(() => {
const sources: Record<string, 'file' | 'override'> = {};
@@ -124,6 +137,48 @@
// Derived state for selected repository
let selectedRepo = $derived(formRepositoryId ? repositories.find(r => r.id === formRepositoryId) : null);
onMount(() => {
// Load saved split ratio
const savedSplit = localStorage.getItem(STORAGE_KEY_SPLIT);
if (savedSplit) {
const ratio = parseFloat(savedSplit);
if (!isNaN(ratio) && ratio >= 30 && ratio <= 80) {
splitRatio = ratio;
}
}
// Add global mouse event listeners for split dragging
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
});
onDestroy(() => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
});
// Split panel drag handlers
function startSplitDrag(e: MouseEvent) {
e.preventDefault();
isDraggingSplit = true;
}
function handleMouseMove(e: MouseEvent) {
if (isDraggingSplit && containerRef) {
const rect = containerRef.getBoundingClientRect();
const newRatio = ((e.clientX - rect.left) / rect.width) * 100;
splitRatio = Math.max(30, Math.min(80, newRatio));
}
}
function handleMouseUp() {
if (isDraggingSplit) {
isDraggingSplit = false;
// Save split ratio
localStorage.setItem(STORAGE_KEY_SPLIT, splitRatio.toString());
}
}
function generateWebhookSecret(): string {
const array = new Uint8Array(24);
crypto.getRandomValues(array);
@@ -388,20 +443,40 @@
</script>
<Dialog.Root bind:open onOpenChange={(isOpen) => { if (isOpen) focusFirstInput(); }}>
<Dialog.Content class="max-w-6xl max-h-[90vh] flex flex-col overflow-hidden p-0">
<Dialog.Header class="shrink-0 px-6 pt-6 pb-4 border-b">
<Dialog.Title class="flex items-center gap-2">
<GitBranch class="w-5 h-5" />
{gitStack ? 'Edit git stack' : 'Deploy from Git'}
</Dialog.Title>
<Dialog.Description>
{gitStack ? 'Update git stack settings' : 'Deploy a compose stack from a Git repository'}
</Dialog.Description>
<Dialog.Content
class="max-w-none h-[95vh] flex flex-col p-0 gap-0 shadow-xl border-zinc-200 dark:border-zinc-700 {sidebar.state === 'collapsed' ? 'w-[calc(100vw-6rem)] ml-[1.5rem]' : 'w-[calc(100vw-12rem)] ml-[4.5rem]'}"
showCloseButton={false}
>
<Dialog.Header class="px-5 py-3 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="p-1.5 rounded-md bg-zinc-200 dark:bg-zinc-700">
<GitBranch class="w-4 h-4 text-zinc-600 dark:text-zinc-300" />
</div>
<div>
<Dialog.Title class="text-sm font-semibold text-zinc-800 dark:text-zinc-100">
{gitStack ? 'Edit git stack' : 'Deploy from Git'}
</Dialog.Title>
<Dialog.Description class="text-xs text-zinc-500 dark:text-zinc-400">
{gitStack ? 'Update git stack settings' : 'Deploy a compose stack from a Git repository'}
</Dialog.Description>
</div>
</div>
<!-- Close button -->
<button
onclick={onClose}
class="p-1.5 rounded-md text-zinc-400 dark:text-zinc-500 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
>
<X class="w-4 h-4" />
</button>
</div>
</Dialog.Header>
<div class="flex-1 flex overflow-hidden">
<div bind:this={containerRef} class="flex-1 min-h-0 flex {isDraggingSplit ? 'select-none' : ''}">
<!-- Left column: Form fields -->
<div class="flex-1 overflow-y-auto space-y-4 py-4 px-6 border-r border-zinc-200 dark:border-zinc-700">
<div class="flex-shrink-0 flex flex-col min-w-0 overflow-y-auto" style="width: {splitRatio}%">
<div class="space-y-4 py-4 px-6">
<!-- Repository selection -->
{#if !gitStack}
<div class="space-y-3">
@@ -774,10 +849,24 @@
{#if formError}
<p class="text-sm text-destructive">{formError}</p>
{/if}
</div>
</div>
<!-- Resizable divider -->
<div
class="w-1 flex-shrink-0 bg-zinc-200 dark:bg-zinc-700 hover:bg-blue-400 dark:hover:bg-blue-500 cursor-col-resize transition-colors flex items-center justify-center group {isDraggingSplit ? 'bg-blue-500 dark:bg-blue-400' : ''}"
onmousedown={startSplitDrag}
role="separator"
aria-orientation="vertical"
tabindex="0"
>
<div class="w-4 h-8 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity {isDraggingSplit ? 'opacity-100' : ''}">
<GripVertical class="w-3 h-3 text-white" />
</div>
</div>
<!-- Right column: Environment Variables -->
<div class="w-[380px] flex-shrink-0 flex flex-col bg-zinc-50 dark:bg-zinc-800/50">
<div class="flex-1 min-w-0 flex flex-col overflow-hidden bg-zinc-50 dark:bg-zinc-800/50">
<StackEnvVarsPanel
bind:variables={envVars}
showSource={!!formEnvFilePath && gitStack !== null}
@@ -789,7 +878,7 @@
</div>
</div>
<Dialog.Footer class="shrink-0 border-t px-6 py-4">
<Dialog.Footer class="px-5 py-2.5 border-t border-zinc-200 dark:border-zinc-700 flex-shrink-0">
<Button variant="outline" onclick={onClose}>Cancel</Button>
{#if gitStack}
<Button variant="outline" onclick={() => saveGitStack(true)} disabled={formSaving}>
+16 -53
View File
@@ -7,13 +7,16 @@
import CodeEditor, { type VariableMarker } from '$lib/components/CodeEditor.svelte';
import StackEnvVarsPanel from '$lib/components/StackEnvVarsPanel.svelte';
import { type EnvVar, type ValidationResult } from '$lib/components/StackEnvVarsEditor.svelte';
import { Layers, Save, Play, Code, GitGraph, Loader2, AlertCircle, X, Sun, Moon, TriangleAlert, ChevronsLeft, ChevronsRight, Variable, HelpCircle, GripVertical, FolderOpen, Copy, Check } from 'lucide-svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import { Layers, Save, Play, Code, GitGraph, Loader2, AlertCircle, X, Sun, Moon, TriangleAlert, GripVertical, FolderOpen, Copy, Check } from 'lucide-svelte';
import { currentEnvironment, appendEnvParam } from '$lib/stores/environment';
import { focusFirstInput } from '$lib/utils';
import * as Alert from '$lib/components/ui/alert';
import { ErrorDialog } from '$lib/components/ui/error-dialog';
import ComposeGraphViewer from './ComposeGraphViewer.svelte';
import { useSidebar } from '$lib/components/ui/sidebar/context.svelte';
// Get sidebar state to adjust modal positioning
const sidebar = useSidebar();
// localStorage key for persisted split ratio
const STORAGE_KEY_SPLIT = 'dockhand-stack-modal-split';
@@ -596,7 +599,7 @@ services:
}}
>
<Dialog.Content
class="max-w-none w-[calc(100vw-12rem)] h-[95vh] ml-[4.5rem] flex flex-col p-0 gap-0 shadow-xl border-zinc-200 dark:border-zinc-700"
class="max-w-none h-[95vh] flex flex-col p-0 gap-0 shadow-xl border-zinc-200 dark:border-zinc-700 {sidebar.state === 'collapsed' ? 'w-[calc(100vw-6rem)] ml-[1.5rem]' : 'w-[calc(100vw-12rem)] ml-[4.5rem]'}"
showCloseButton={false}
>
<Dialog.Header class="px-5 py-3 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
@@ -767,56 +770,16 @@ services:
</div>
<!-- Environment variables panel -->
<div class="flex-1 min-w-0 flex flex-col overflow-hidden bg-zinc-50 dark:bg-zinc-800/50">
<div class="flex items-center gap-1.5 px-3 py-1.5 border-b border-zinc-200 dark:border-zinc-700 text-xs font-medium text-zinc-600 dark:text-zinc-300">
<Variable class="w-3.5 h-3.5" />
Environment variables
<Tooltip.Root>
<Tooltip.Trigger>
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground cursor-help" />
</Tooltip.Trigger>
<Tooltip.Content>
<div class="w-64">
<p class="text-xs">These variables will be written to a <code class="bg-muted px-1 rounded">.env</code> file in the stack directory.</p>
</div>
</Tooltip.Content>
</Tooltip.Root>
<!-- Validation status pills -->
{#if envValidation}
<div class="flex gap-1 ml-auto">
{#if envValidation.missing.length > 0}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-2xs font-medium bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">
{envValidation.missing.length} missing
</span>
{/if}
{#if envValidation.required.length > 0}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-2xs font-medium bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
{envValidation.required.length - envValidation.missing.length} required
</span>
{/if}
{#if envValidation.optional.length > 0}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-2xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
{envValidation.optional.length} optional
</span>
{/if}
{#if envValidation.unused.length > 0}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-2xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
{envValidation.unused.length} unused
</span>
{/if}
</div>
{/if}
</div>
<div class="flex-1 min-h-0 overflow-hidden">
<StackEnvVarsPanel
bind:this={envVarsPanelRef}
bind:variables={envVars}
bind:rawContent={rawEnvContent}
validation={envValidation}
existingSecretKeys={mode === 'edit' ? existingSecretKeys : new Set()}
onchange={() => { markDirty(); debouncedValidate(); }}
theme={editorTheme}
/>
</div>
<StackEnvVarsPanel
bind:this={envVarsPanelRef}
bind:variables={envVars}
bind:rawContent={rawEnvContent}
validation={envValidation}
existingSecretKeys={mode === 'edit' ? existingSecretKeys : new Set()}
onchange={() => { markDirty(); debouncedValidate(); }}
theme={editorTheme}
infoText="These variables will be written to a .env file in the stack directory."
/>
</div>
{:else if activeTab === 'graph'}
<!-- Graph tab: Full width -->