This commit is contained in:
jarek committed 2026-01-28 07:34:12 +01:00
1 parent a88d3d5788
commit 8f3a7eb435
96 files changed
+5235 -1870

No files matched your search

+4 -4
View File
@@ -548,14 +548,14 @@
fontSize: '13px'
},
'.cm-content': {
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
fontFamily: 'var(--font-editor, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace)',
padding: '8px 0'
},
'.cm-gutters': {
backgroundColor: '#1a1a1a',
color: '#858585',
border: 'none',
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
fontFamily: 'var(--font-editor, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace)',
fontSize: '13px'
},
'.cm-activeLineGutter': {
@@ -590,14 +590,14 @@
fontSize: '13px'
},
'.cm-content': {
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
fontFamily: 'var(--font-editor, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace)',
padding: '8px 0'
},
'.cm-gutters': {
backgroundColor: '#fafafa',
color: '#a1a1aa',
border: 'none',
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
fontFamily: 'var(--font-editor, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace)',
fontSize: '13px'
},
'.cm-activeLineGutter': {
+75
View File
@@ -0,0 +1,75 @@
<script lang="ts">
import { ArrowRight } from 'lucide-svelte';
import { formatFieldName, type AuditDiff, type FieldChange } from '$lib/utils/diff';
interface Props {
diff: AuditDiff | null;
}
let { diff }: Props = $props();
function formatDisplayValue(value: any): string {
if (value === null || value === undefined) {
return '—';
}
if (typeof value === 'boolean') {
return value ? 'Yes' : 'No';
}
if (Array.isArray(value)) {
if (value.length === 0) return '(empty)';
if (value.every(v => typeof v === 'string' || typeof v === 'number')) {
return value.join(', ');
}
return JSON.stringify(value, null, 2);
}
if (typeof value === 'object') {
return JSON.stringify(value, null, 2);
}
return String(value);
}
function isComplex(value: any): boolean {
if (value === null || value === undefined) return false;
if (Array.isArray(value) && value.length > 0) {
return !value.every(v => typeof v === 'string' || typeof v === 'number');
}
if (typeof value === 'object') return true;
return false;
}
</script>
{#if diff && diff.changes.length > 0}
<div class="max-h-64 overflow-y-auto border rounded-md divide-y">
{#each diff.changes as change}
{@const oldComplex = isComplex(change.oldValue)}
{@const newComplex = isComplex(change.newValue)}
<div class="flex items-start gap-3 px-3 py-2 text-sm hover:bg-muted/30">
<span class="font-medium text-muted-foreground shrink-0 w-32 truncate" title={formatFieldName(change.field)}>
{formatFieldName(change.field)}
</span>
{#if oldComplex || newComplex}
<!-- Complex values: stacked -->
<div class="flex-1 min-w-0 space-y-1">
<pre class="text-xs text-muted-foreground bg-muted/50 rounded px-2 py-1 overflow-x-auto whitespace-pre-wrap">{formatDisplayValue(change.oldValue)}</pre>
<pre class="text-xs text-amber-600 dark:text-amber-400 bg-amber-500/10 rounded px-2 py-1 overflow-x-auto whitespace-pre-wrap">{formatDisplayValue(change.newValue)}</pre>
</div>
{:else}
<!-- Simple values: inline -->
<div class="flex items-center gap-2 flex-1 min-w-0">
<span class="text-muted-foreground truncate" title={formatDisplayValue(change.oldValue)}>
{formatDisplayValue(change.oldValue)}
</span>
<ArrowRight class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<span class="text-amber-600 dark:text-amber-400 font-medium truncate" title={formatDisplayValue(change.newValue)}>
{formatDisplayValue(change.newValue)}
</span>
</div>
{/if}
</div>
{/each}
</div>
{:else}
<p class="text-sm text-muted-foreground italic">No changes recorded</p>
{/if}
+495
View File
@@ -0,0 +1,495 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Badge } from '$lib/components/ui/badge';
import * as Select from '$lib/components/ui/select';
import { CheckCircle2, XCircle, Download, ShieldCheck, ShieldAlert, ShieldX, ArrowBigRight, Settings2, Server, Trash2, Loader2, Icon } from 'lucide-svelte';
import { whale } from '@lucide/lab';
import { currentEnvironment } from '$lib/stores/environment';
import PullTab from '$lib/components/PullTab.svelte';
import ScanTab from '$lib/components/ScanTab.svelte';
import type { ScanResult } from '$lib/components/ScanTab.svelte';
interface Registry {
id: number;
name: string;
url: string;
hasCredentials: boolean;
is_default: boolean;
}
interface Props {
open: boolean;
imageName?: string; // Optional - if not provided, show configure step
registries?: Registry[]; // For registry selection in configure step
envHasScanning?: boolean;
envId?: number | null;
showDeleteButton?: boolean; // Show "Remove image" after scan (for Images page)
onClose?: () => void;
onComplete?: () => void;
}
let { open = $bindable(), imageName = '', registries = [], envHasScanning = false, envId, showDeleteButton = false, onClose, onComplete }: Props = $props();
// Component refs
let pullTabRef = $state<PullTab | undefined>();
let scanTabRef = $state<ScanTab | undefined>();
// Determine if we need configure step (when imageName is not provided)
const needsConfigureStep = $derived(!imageName);
// Tab state - use 'configure' | 'pull' | 'scan'
let activeTab = $state<'configure' | 'pull' | 'scan'>('pull');
// Configure step state
let selectedRegistryId = $state<number | 'dockerhub' | null>('dockerhub');
let configImageName = $state('');
// Track status from components
let pullStatus = $state<'idle' | 'pulling' | 'complete' | 'error'>('idle');
let scanStatus = $state<'idle' | 'scanning' | 'complete' | 'error'>('idle');
let scanResults = $state<ScanResult[]>([]);
let hasStarted = $state(false);
let pullStarted = $state(false);
let scanStarted = $state(false);
let autoSwitchedToScan = $state(false);
// Delete state
let isDeleting = $state(false);
// Check if a registry is Docker Hub
function isDockerHub(registry: Registry): boolean {
const url = registry.url.toLowerCase();
return url.includes('docker.io') ||
url.includes('hub.docker.com') ||
url.includes('registry.hub.docker.com');
}
// Get all registries plus a Docker Hub option
const allRegistries = $derived([
{ id: 'dockerhub' as const, name: 'Docker Hub (public)', url: 'https://hub.docker.com', hasCredentials: false, is_default: false },
...registries.filter(r => !isDockerHub(r))
]);
const selectedRegistry = $derived(
selectedRegistryId === 'dockerhub'
? allRegistries[0]
: registries.find(r => r.id === selectedRegistryId)
);
// Build full image reference for configure mode
const fullImageReference = $derived.by(() => {
if (!configImageName.trim()) return '';
const name = configImageName.trim();
// For Docker Hub, use as-is (docker handles it)
if (selectedRegistryId === 'dockerhub') {
return name.includes(':') ? name : `${name}:latest`;
}
// For other registries, prefix with registry URL
const registry = registries.find(r => r.id === selectedRegistryId);
if (!registry) return name;
const url = new URL(registry.url);
const hostWithPath = url.host + (url.pathname !== '/' ? url.pathname.replace(/\/$/, '') : '');
const imageWithTag = name.includes(':') ? name : `${name}:latest`;
return `${hostWithPath}/${imageWithTag}`;
});
// The actual image name to pull (either from prop or from configure step)
const effectiveImageName = $derived(imageName || fullImageReference);
$effect(() => {
if (open && imageName && !hasStarted) {
// When imageName is provided (registry page), go directly to pull
hasStarted = true;
pullStarted = true;
activeTab = 'pull';
}
if (open && !imageName && !hasStarted) {
// When no imageName (images page), show configure step
activeTab = 'configure';
}
if (!open) {
// Reset when modal closes
hasStarted = false;
pullStarted = false;
scanStarted = false;
pullStatus = 'idle';
scanStatus = 'idle';
scanResults = [];
activeTab = imageName ? 'pull' : 'configure';
autoSwitchedToScan = false;
isDeleting = false;
// Reset configure state
selectedRegistryId = 'dockerhub';
configImageName = '';
pullTabRef?.reset();
scanTabRef?.reset();
}
});
function handlePullComplete() {
pullStatus = 'complete';
if (envHasScanning && !autoSwitchedToScan) {
autoSwitchedToScan = true;
scanStarted = true;
activeTab = 'scan';
setTimeout(() => scanTabRef?.startScan(), 100);
} else {
onComplete?.();
}
}
function handlePullError(_error: string) {
pullStatus = 'error';
}
function handlePullStatusChange(status: 'idle' | 'pulling' | 'complete' | 'error') {
pullStatus = status;
}
function handleScanComplete(results: ScanResult[]) {
scanResults = results;
onComplete?.();
}
function handleScanError(_error: string) {
// Error is handled by ScanTab display
}
function handleScanStatusChange(status: 'idle' | 'scanning' | 'complete' | 'error') {
scanStatus = status;
}
function handleClose() {
if (pullStatus !== 'pulling' && scanStatus !== 'scanning' && !isDeleting) {
open = false;
onClose?.();
}
}
function startPullFromConfigure() {
// Switch to pull tab and start pulling
hasStarted = true;
pullStarted = true;
activeTab = 'pull';
}
async function deleteImage() {
if (!effectiveImageName) return;
isDeleting = true;
try {
const deleteUrl = effectiveEnvId
? `/api/images/${encodeURIComponent(effectiveImageName)}?env=${effectiveEnvId}`
: `/api/images/${encodeURIComponent(effectiveImageName)}`;
const response = await fetch(deleteUrl, { method: 'DELETE' });
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || 'Failed to delete image');
}
// Close modal after successful delete
onComplete?.();
open = false;
onClose?.();
} catch (error: any) {
console.error('Failed to delete image:', error);
// Could add error display here if needed
} finally {
isDeleting = false;
}
}
const totalVulnerabilities = $derived(
scanResults.reduce((total, r) => total + r.vulnerabilities.length, 0)
);
const hasCriticalOrHigh = $derived(
scanResults.some(r => r.summary.critical > 0 || r.summary.high > 0)
);
const isProcessing = $derived(pullStatus === 'pulling' || scanStatus === 'scanning' || isDeleting);
const effectiveEnvId = $derived(envId ?? $currentEnvironment?.id ?? null);
const title = $derived(envHasScanning ? 'Pull & scan image' : 'Pull image');
</script>
<Dialog.Root bind:open onOpenChange={handleClose}>
<Dialog.Content class="max-w-4xl h-[85vh] flex flex-col">
<Dialog.Header class="shrink-0 pb-2">
<Dialog.Title class="flex items-center gap-2">
{#if scanStatus === 'complete' && scanResults.length > 0}
{#if hasCriticalOrHigh}
<ShieldX class="w-5 h-5 text-red-500" />
{:else if totalVulnerabilities > 0}
<ShieldAlert class="w-5 h-5 text-yellow-500" />
{:else}
<ShieldCheck class="w-5 h-5 text-green-500" />
{/if}
{:else if pullStatus === 'complete' && !envHasScanning}
<CheckCircle2 class="w-5 h-5 text-green-500" />
{:else if pullStatus === 'error' || scanStatus === 'error'}
<XCircle class="w-5 h-5 text-red-500" />
{:else}
<Download class="w-5 h-5" />
{/if}
{title}
{#if effectiveImageName}
<code class="text-sm font-normal bg-muted px-1.5 py-0.5 rounded ml-1">{effectiveImageName}</code>
{/if}
</Dialog.Title>
</Dialog.Header>
<!-- Step tabs - show configure tab only when needed -->
<div class="flex items-center border-b shrink-0">
{#if needsConfigureStep}
<button
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors cursor-pointer {activeTab === 'configure' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => { if (!isProcessing && activeTab !== 'configure') activeTab = 'configure'; }}
disabled={isProcessing}
>
<Settings2 class="w-3.5 h-3.5 inline mr-1.5" />
Configure
</button>
<ArrowBigRight class="w-3.5 h-3.5 text-muted-foreground/50 shrink-0" />
{/if}
<button
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors cursor-pointer {activeTab === 'pull' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => { if (!isProcessing && pullStatus !== 'idle') activeTab = 'pull'; }}
disabled={isProcessing || (needsConfigureStep && pullStatus === 'idle')}
>
<Download class="w-3.5 h-3.5 inline mr-1.5" />
Pull
{#if pullStatus === 'complete'}
<CheckCircle2 class="w-3.5 h-3.5 inline ml-1 text-green-500" />
{:else if pullStatus === 'error'}
<XCircle class="w-3.5 h-3.5 inline ml-1 text-red-500" />
{:else}
<CheckCircle2 class="w-3.5 h-3.5 inline ml-1 invisible" />
{/if}
</button>
{#if envHasScanning}
<ArrowBigRight class="w-3.5 h-3.5 text-muted-foreground/50 shrink-0" />
<button
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors cursor-pointer {activeTab === 'scan' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => { if (!isProcessing && scanStarted) activeTab = 'scan'; }}
disabled={isProcessing || !scanStarted}
>
{#if scanStatus === 'complete' && scanResults.length > 0}
{#if hasCriticalOrHigh}
<ShieldX class="w-3.5 h-3.5 inline mr-1.5 text-red-500" />
{:else if totalVulnerabilities > 0}
<ShieldAlert class="w-3.5 h-3.5 inline mr-1.5 text-yellow-500" />
{:else}
<ShieldCheck class="w-3.5 h-3.5 inline mr-1.5 text-green-500" />
{/if}
{:else}
<ShieldCheck class="w-3.5 h-3.5 inline mr-1.5" />
{/if}
Scan
{#if scanStatus === 'complete'}
<CheckCircle2 class="w-3.5 h-3.5 inline ml-1 text-green-500" />
{:else if scanStatus === 'error'}
<XCircle class="w-3.5 h-3.5 inline ml-1 text-red-500" />
{:else}
<CheckCircle2 class="w-3.5 h-3.5 inline ml-1 invisible" />
{/if}
</button>
{/if}
</div>
<div class="flex-1 min-h-0 flex flex-col overflow-hidden py-2">
<!-- Configure Tab -->
{#if needsConfigureStep}
<div class="space-y-4 px-1 overflow-auto" class:hidden={activeTab !== 'configure'}>
<div class="space-y-2">
<Label>Registry</Label>
<Select.Root
type="single"
value={selectedRegistryId === 'dockerhub' ? 'dockerhub' : selectedRegistryId ? String(selectedRegistryId) : undefined}
onValueChange={(v) => selectedRegistryId = v === 'dockerhub' ? 'dockerhub' : Number(v)}
>
<Select.Trigger class="w-full h-9 justify-start">
{#if selectedRegistry}
{#if selectedRegistryId === 'dockerhub'}
<Icon iconNode={whale} class="w-4 h-4 mr-2 text-muted-foreground" />
{:else}
<Server class="w-4 h-4 mr-2 text-muted-foreground" />
{/if}
<span class="flex-1 text-left">{selectedRegistry.name}</span>
{:else}
<span class="text-muted-foreground">Select registry</span>
{/if}
</Select.Trigger>
<Select.Content>
{#each allRegistries as registry}
<Select.Item value={registry.id === 'dockerhub' ? 'dockerhub' : String(registry.id)} label={registry.name}>
{#if registry.id === 'dockerhub'}
<Icon iconNode={whale} class="w-4 h-4 mr-2 text-muted-foreground" />
{:else}
<Server class="w-4 h-4 mr-2 text-muted-foreground" />
{/if}
{registry.name}
{#if registry.hasCredentials}
<Badge variant="outline" class="ml-2 text-xs">auth</Badge>
{/if}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-2">
<Label>Image name</Label>
<Input
bind:value={configImageName}
placeholder={selectedRegistryId === 'dockerhub' ? 'nginx:latest or library/nginx:1.25' : 'myimage:latest'}
onkeydown={(e: KeyboardEvent) => {
if (e.key === 'Enter' && configImageName.trim()) {
startPullFromConfigure();
}
}}
/>
<p class="text-xs text-muted-foreground">
Format: <code class="bg-muted px-1 py-0.5 rounded">image:tag</code> or <code class="bg-muted px-1 py-0.5 rounded">namespace/image:tag</code>
</p>
</div>
{#if configImageName.trim()}
<div class="space-y-2">
<Label class="text-muted-foreground">Full image reference</Label>
<div class="p-2 bg-muted rounded text-sm">
<code class="break-all">{fullImageReference}</code>
</div>
</div>
{/if}
</div>
{/if}
<!-- Pull Tab -->
<div class="flex flex-col flex-1 min-h-0" class:hidden={activeTab !== 'pull'}>
<PullTab
bind:this={pullTabRef}
imageName={effectiveImageName}
envId={effectiveEnvId}
showImageInput={false}
autoStart={pullStarted && pullStatus === 'idle'}
onComplete={handlePullComplete}
onError={handlePullError}
onStatusChange={handlePullStatusChange}
/>
</div>
<!-- Scan Tab -->
{#if envHasScanning}
<div class="flex flex-col flex-1 min-h-0" class:hidden={activeTab !== 'scan'}>
<ScanTab
bind:this={scanTabRef}
imageName={effectiveImageName}
envId={effectiveEnvId}
autoStart={scanStarted && scanStatus === 'idle'}
onComplete={handleScanComplete}
onError={handleScanError}
onStatusChange={handleScanStatusChange}
/>
</div>
{/if}
</div>
<Dialog.Footer class="shrink-0 flex justify-between">
<div>
{#if activeTab === 'pull' && pullStatus === 'error'}
<Button variant="outline" onclick={() => pullTabRef?.startPull()}>
Retry
</Button>
{:else if activeTab === 'scan' && scanStatus === 'error'}
<Button variant="outline" onclick={() => scanTabRef?.startScan()}>
Retry scan
</Button>
{/if}
</div>
<div class="flex gap-2">
{#if showDeleteButton && scanStatus === 'complete'}
<!-- Show Keep/Remove buttons after scan completes (Images page usage) -->
<Button
variant="destructive"
onclick={deleteImage}
disabled={isDeleting}
>
{#if isDeleting}
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
Removing...
{:else}
<Trash2 class="w-4 h-4 mr-2" />
Remove image
{/if}
</Button>
<Button
variant="default"
onclick={handleClose}
disabled={isDeleting}
>
<CheckCircle2 class="w-4 h-4 mr-2" />
Keep image
</Button>
{:else if showDeleteButton && pullStatus === 'complete' && !envHasScanning}
<!-- Show Keep/Remove buttons after pull completes when no scanning (Images page) -->
<Button
variant="destructive"
onclick={deleteImage}
disabled={isDeleting}
>
{#if isDeleting}
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
Removing...
{:else}
<Trash2 class="w-4 h-4 mr-2" />
Remove image
{/if}
</Button>
<Button
variant="default"
onclick={handleClose}
disabled={isDeleting}
>
<CheckCircle2 class="w-4 h-4 mr-2" />
Keep image
</Button>
{:else}
<Button
variant="outline"
onclick={handleClose}
disabled={isProcessing}
>
{pullStatus === 'complete' && !envHasScanning ? 'Done' : 'Cancel'}
</Button>
{#if activeTab === 'configure'}
<Button
onclick={startPullFromConfigure}
disabled={!configImageName.trim()}
>
<Download class="w-4 h-4 mr-2" />
Pull
</Button>
{:else if pullStatus === 'complete' || scanStatus === 'complete'}
<Button
variant="default"
onclick={handleClose}
disabled={isProcessing}
>
OK
</Button>
{/if}
{/if}
</div>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
+21 -4
View File
@@ -2,7 +2,7 @@
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import * as Tooltip from '$lib/components/ui/tooltip';
import { Plus, Trash2, Key, AlertCircle, CheckCircle2, FileText, Pencil, CircleDot } from 'lucide-svelte';
import { Plus, Trash2, Key, AlertCircle, CheckCircle2, FileText, Pencil, CircleDot, Undo2 } from 'lucide-svelte';
export interface EnvVar {
key: string;
@@ -25,6 +25,7 @@
readonly?: boolean;
showSource?: boolean; // For git stacks - show where variable comes from
sources?: Record<string, 'file' | 'override'>; // Key -> source mapping
fileValues?: Record<string, string>; // Original file values for revert
placeholder?: { key: string; value: string };
existingSecretKeys?: Set<string>; // Keys of secrets loaded from DB (can't toggle visibility)
onchange?: () => void;
@@ -36,6 +37,7 @@
readonly = false,
showSource = false,
sources = {},
fileValues = {},
placeholder = { key: 'VARIABLE_NAME', value: 'value' },
existingSecretKeys = new Set<string>(),
onchange
@@ -119,14 +121,29 @@
<Tooltip.Trigger>
<FileText class="w-3.5 h-3.5 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content><p>From .env file</p></Tooltip.Content>
<Tooltip.Content side="bottom"><p class="whitespace-nowrap">From env file in repository</p></Tooltip.Content>
</Tooltip.Root>
{:else if source === 'override'}
<Tooltip.Root>
<Tooltip.Trigger>
<Pencil class="w-3.5 h-3.5 text-blue-500" />
{#if fileValues[variable.key] !== undefined}
<button
type="button"
class="cursor-pointer hover:text-orange-400 transition-colors"
onclick={() => {
variables = variables.map(v =>
v.key === variable.key ? { ...v, value: fileValues[variable.key] } : v
);
onchange?.();
}}
>
<Undo2 class="w-3.5 h-3.5 text-blue-500 hover:text-orange-400" />
</button>
{:else}
<Pencil class="w-3.5 h-3.5 text-blue-500" />
{/if}
</Tooltip.Trigger>
<Tooltip.Content><p>Manual override</p></Tooltip.Content>
<Tooltip.Content side="bottom"><p class="whitespace-nowrap">{fileValues[variable.key] !== undefined ? 'Revert to file value' : 'Manual override (not in file)'}</p></Tooltip.Content>
</Tooltip.Root>
{/if}
</div>
+18 -10
View File
@@ -1,25 +1,27 @@
<script lang="ts">
import { tick } from 'svelte';
import { tick, type Snippet } from 'svelte';
import { Button } from '$lib/components/ui/button';
import StackEnvVarsEditor, { type EnvVar, type ValidationResult } from '$lib/components/StackEnvVarsEditor.svelte';
import CodeEditor from '$lib/components/CodeEditor.svelte';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import { Plus, Info, Upload, Trash2, List, FileText, AlertTriangle, ShieldAlert } from 'lucide-svelte';
import { Plus, Upload, Trash2, List, FileText, AlertTriangle, ShieldAlert, HelpCircle } from 'lucide-svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
interface Props {
variables: EnvVar[]; // Bindable - ALL variables (secrets + non-secrets)
rawContent: string; // Bindable - raw .env file content (comments preserved, no secrets)
rawContent?: string; // Bindable - raw .env file content (comments preserved, no secrets)
validation?: ValidationResult | null;
readonly?: boolean;
showSource?: boolean;
sources?: Record<string, 'file' | 'override'>;
fileValues?: Record<string, string>;
placeholder?: { key: string; value: string };
infoText?: string;
existingSecretKeys?: Set<string>;
theme?: 'light' | 'dark';
class?: string;
onchange?: () => void;
headerActions?: Snippet;
}
let {
@@ -29,12 +31,14 @@
readonly = false,
showSource = false,
sources = {},
fileValues = {},
placeholder = { key: 'VARIABLE_NAME', value: 'value' },
infoText,
existingSecretKeys = new Set<string>(),
theme = 'dark',
class: className = '',
onchange
onchange,
headerActions
}: Props = $props();
const STORAGE_KEY_VIEW_MODE = 'dockhand-env-vars-view-mode';
@@ -291,13 +295,13 @@
{#if infoText}
<Tooltip.Root>
<Tooltip.Trigger>
<Info class="w-3.5 h-3.5 text-blue-400 shrink-0" />
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground cursor-help 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.Content>
<div class="w-64">
<p class="text-xs text-left">{@html infoText}</p>
</div>
</Tooltip.Content>
</Tooltip.Root>
{/if}
<!-- View mode toggle -->
@@ -343,6 +347,9 @@
<!-- Actions - right-aligned -->
{#if !readonly}
<div class="flex items-center gap-1 shrink-0">
{#if headerActions}
{@render headerActions()}
{/if}
<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
@@ -445,6 +452,7 @@
{readonly}
{showSource}
{sources}
{fileValues}
{placeholder}
{existingSecretKeys}
{onchange}
+51 -1
View File
@@ -1,10 +1,27 @@
<script lang="ts">
import { Sun, Moon, Type, AArrowUp, Table, Terminal } from 'lucide-svelte';
import { onMount } from 'svelte';
import { Sun, Moon, Type, AArrowUp, Table, Terminal, CodeXml } from 'lucide-svelte';
import * as Select from '$lib/components/ui/select';
import { Label } from '$lib/components/ui/label';
import { lightThemes, darkThemes, fonts, monospaceFonts } from '$lib/themes';
import { themeStore, applyTheme, type FontSize } from '$lib/stores/theme';
// Preload all monospace Google Fonts so dropdown previews render correctly
let monoFontsLoaded = $state(false);
onMount(() => {
const fontsToLoad = monospaceFonts.filter(f => f.googleFont);
if (fontsToLoad.length === 0) {
monoFontsLoaded = true;
return;
}
const families = fontsToLoad.map(f => `family=${f.googleFont}`).join('&');
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = `https://fonts.googleapis.com/css2?${families}&display=swap`;
link.onload = () => { monoFontsLoaded = true; };
document.head.appendChild(link);
});
// Font size options
const fontSizes: { id: FontSize; name: string }[] = [
{ id: 'xsmall', name: 'Extra Small' },
@@ -28,6 +45,7 @@
let selectedFontSize = $state($themeStore.fontSize);
let selectedGridFontSize = $state($themeStore.gridFontSize);
let selectedTerminalFont = $state($themeStore.terminalFont);
let selectedEditorFont = $state($themeStore.editorFont);
// Sync local state with store changes
$effect(() => {
@@ -37,6 +55,7 @@
selectedFontSize = $themeStore.fontSize;
selectedGridFontSize = $themeStore.gridFontSize;
selectedTerminalFont = $themeStore.terminalFont;
selectedEditorFont = $themeStore.editorFont;
});
async function handleLightThemeChange(value: string | undefined) {
@@ -74,6 +93,13 @@
selectedTerminalFont = value;
await themeStore.setPreference('terminalFont', value, userId);
}
async function handleEditorFontChange(value: string | undefined) {
if (!value) return;
selectedEditorFont = value;
await themeStore.setPreference('editorFont', value, userId);
}
</script>
<div class="space-y-4">
@@ -244,4 +270,28 @@
</Select.Content>
</Select.Root>
</div>
<!-- Editor Font -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<CodeXml class="w-4 h-4 text-muted-foreground" />
<Label>Editor font</Label>
</div>
<Select.Root type="single" value={selectedEditorFont} onValueChange={handleEditorFontChange}>
<Select.Trigger class="w-56">
{#each monospaceFonts as font}
{#if font.id === selectedEditorFont}
<span style="font-family: {font.family}">{font.name}</span>
{/if}
{/each}
</Select.Trigger>
<Select.Content>
{#each monospaceFonts as font}
<Select.Item value={font.id}>
<span style="font-family: {font.family}">{font.name}</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
</div>