1.0.5
This commit is contained in:
1 parent
5475112806
commit
242f8df49d
35 files changed
+1892
-1987
No files matched your search
@@ -3,11 +3,51 @@
|
||||
import { EditorState, StateField, StateEffect, RangeSet } from '@codemirror/state';
|
||||
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, gutter, GutterMarker, Decoration, WidgetType, type DecorationSet } from '@codemirror/view';
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
|
||||
import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching } from '@codemirror/language';
|
||||
import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching, StreamLanguage, type StreamParser } from '@codemirror/language';
|
||||
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
||||
import { autocompletion, completionKeymap, closeBrackets, closeBracketsKeymap, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete';
|
||||
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
||||
|
||||
// Simple dotenv/env file language parser
|
||||
const dotenvParser: StreamParser<{ inValue: boolean }> = {
|
||||
startState() {
|
||||
return { inValue: false };
|
||||
},
|
||||
token(stream, state) {
|
||||
// Start of line
|
||||
if (stream.sol()) {
|
||||
state.inValue = false;
|
||||
// Skip leading whitespace
|
||||
stream.eatSpace();
|
||||
// Comment line
|
||||
if (stream.peek() === '#') {
|
||||
stream.skipToEnd();
|
||||
return 'comment';
|
||||
}
|
||||
}
|
||||
// If in value part, consume the rest
|
||||
if (state.inValue) {
|
||||
stream.skipToEnd();
|
||||
return 'string';
|
||||
}
|
||||
// Variable name before =
|
||||
if (stream.match(/^[a-zA-Z_][a-zA-Z0-9_]*/)) {
|
||||
if (stream.peek() === '=') {
|
||||
return 'variableName.definition';
|
||||
}
|
||||
return 'variableName';
|
||||
}
|
||||
// Equals sign - switch to value mode
|
||||
if (stream.eat('=')) {
|
||||
state.inValue = true;
|
||||
return 'operator';
|
||||
}
|
||||
// Skip anything else
|
||||
stream.next();
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Docker Compose keywords for autocomplete
|
||||
const COMPOSE_TOP_LEVEL = ['services', 'networks', 'volumes', 'configs', 'secrets', 'name', 'version'];
|
||||
|
||||
@@ -453,6 +493,9 @@
|
||||
case 'sh':
|
||||
// No dedicated shell/dockerfile support, use basic highlighting
|
||||
return [];
|
||||
case 'dotenv':
|
||||
case 'env':
|
||||
return StreamLanguage.define(dotenvParser);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
@@ -542,6 +585,13 @@
|
||||
// Track if we're initialized (prevents multiple createEditor calls)
|
||||
let initialized = false;
|
||||
|
||||
// Debounce timer for marker updates (prevents flicker during fast typing)
|
||||
let markerUpdateTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const MARKER_UPDATE_DEBOUNCE_MS = 300;
|
||||
|
||||
// Track last applied markers to avoid redundant updates
|
||||
let lastAppliedMarkersJson = '';
|
||||
|
||||
function createEditor() {
|
||||
if (!container || view || initialized) return;
|
||||
initialized = true;
|
||||
@@ -551,12 +601,14 @@
|
||||
: [dockhandLight, syntaxHighlighting(defaultHighlightStyle)];
|
||||
|
||||
// Build autocompletion config - add Docker Compose completions for YAML
|
||||
// Note: activateOnTyping can interfere with key repeat, so we disable it
|
||||
// Users can still trigger autocomplete manually with Ctrl+Space
|
||||
const autocompletionConfig = language === 'yaml'
|
||||
? autocompletion({
|
||||
override: [composeCompletions, composeValueCompletions],
|
||||
activateOnTyping: true
|
||||
activateOnTyping: false
|
||||
})
|
||||
: autocompletion();
|
||||
: autocompletion({ activateOnTyping: false });
|
||||
|
||||
const extensions = [
|
||||
lineNumbers(),
|
||||
@@ -594,18 +646,25 @@
|
||||
extensions
|
||||
});
|
||||
|
||||
// Custom transaction handler - this is SYNCHRONOUS and more reliable than updateListener
|
||||
// Custom transaction handler - applies transactions synchronously but defers callback
|
||||
// Based on the Svelte Playground pattern: https://svelte.dev/playground/91649ba3e0ce4122b3b34f3a95a00104
|
||||
const dispatchTransactions = (trs: readonly import('@codemirror/state').Transaction[]) => {
|
||||
if (!view) return;
|
||||
|
||||
// Apply all transactions
|
||||
// Apply all transactions synchronously (required by CodeMirror)
|
||||
view.update(trs);
|
||||
|
||||
// Check if any transaction changed the document
|
||||
const lastChangingTr = trs.findLast(tr => tr.docChanged);
|
||||
if (lastChangingTr && onchangeRef) {
|
||||
onchangeRef(lastChangingTr.newDoc.toString());
|
||||
// Defer callback to next microtask to avoid blocking input handling
|
||||
// This allows key repeat to work properly
|
||||
const newContent = lastChangingTr.newDoc.toString();
|
||||
queueMicrotask(() => {
|
||||
if (onchangeRef) {
|
||||
onchangeRef(newContent);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -615,7 +674,6 @@
|
||||
dispatchTransactions
|
||||
});
|
||||
|
||||
|
||||
// Push initial markers if provided
|
||||
if (variableMarkers.length > 0) {
|
||||
view.dispatch({
|
||||
@@ -625,11 +683,16 @@
|
||||
}
|
||||
|
||||
function destroyEditor() {
|
||||
if (markerUpdateTimer) {
|
||||
clearTimeout(markerUpdateTimer);
|
||||
markerUpdateTimer = null;
|
||||
}
|
||||
if (view) {
|
||||
view.destroy();
|
||||
view = null;
|
||||
}
|
||||
initialized = false;
|
||||
lastAppliedMarkersJson = '';
|
||||
}
|
||||
|
||||
// Get current editor content
|
||||
@@ -656,11 +719,35 @@
|
||||
}
|
||||
|
||||
// Update variable markers - this is the key method for parent to call
|
||||
export function updateVariableMarkers(markers: VariableMarker[]) {
|
||||
if (view) {
|
||||
view.dispatch({
|
||||
effects: updateMarkersEffect.of(markers)
|
||||
});
|
||||
// Debounced to prevent flicker during fast typing
|
||||
export function updateVariableMarkers(markers: VariableMarker[], immediate = false) {
|
||||
if (!view) return;
|
||||
|
||||
// Check if markers actually changed (compare by content, not reference)
|
||||
const newJson = JSON.stringify(markers);
|
||||
if (newJson === lastAppliedMarkersJson) {
|
||||
return; // No change, skip update
|
||||
}
|
||||
|
||||
// Clear any pending update
|
||||
if (markerUpdateTimer) {
|
||||
clearTimeout(markerUpdateTimer);
|
||||
markerUpdateTimer = null;
|
||||
}
|
||||
|
||||
const applyUpdate = () => {
|
||||
if (view) {
|
||||
lastAppliedMarkersJson = newJson;
|
||||
view.dispatch({
|
||||
effects: updateMarkersEffect.of(markers)
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (immediate) {
|
||||
applyUpdate();
|
||||
} else {
|
||||
markerUpdateTimer = setTimeout(applyUpdate, MARKER_UPDATE_DEBOUNCE_MS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -693,12 +780,11 @@
|
||||
});
|
||||
|
||||
// Update markers when prop changes (backup mechanism, parent should also call updateVariableMarkers)
|
||||
// Uses the debounced update to prevent flicker during fast typing
|
||||
$effect(() => {
|
||||
const markers = variableMarkers;
|
||||
if (view && markers) {
|
||||
view.dispatch({
|
||||
effects: updateMarkersEffect.of(markers)
|
||||
});
|
||||
updateVariableMarkers(markers);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -706,7 +792,6 @@
|
||||
<div
|
||||
bind:this={container}
|
||||
class="h-full w-full overflow-hidden {className}"
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
></div>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -46,6 +46,8 @@
|
||||
let status = $state<PullStatus>('idle');
|
||||
let image = $state(initialImageName);
|
||||
let duration = $state(0);
|
||||
// Track whether image was set from initial prop vs typed by user
|
||||
let hasAutoStarted = $state(false);
|
||||
|
||||
// Notify parent of status changes
|
||||
$effect(() => {
|
||||
@@ -82,8 +84,10 @@
|
||||
onImageChange?.(image);
|
||||
});
|
||||
|
||||
// Auto-start only once for prefilled images, not when user is typing
|
||||
$effect(() => {
|
||||
if (autoStart && image && status === 'idle') {
|
||||
if (autoStart && initialImageName && image === initialImageName && status === 'idle' && !hasAutoStarted) {
|
||||
hasAutoStarted = true;
|
||||
startPull();
|
||||
}
|
||||
});
|
||||
@@ -133,6 +137,7 @@
|
||||
layerOrder = 0;
|
||||
outputLines = [];
|
||||
duration = 0;
|
||||
hasAutoStarted = false;
|
||||
}
|
||||
|
||||
export function getImage() {
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
<div class="space-y-3">
|
||||
<!-- Variables List -->
|
||||
<div class="space-y-3">
|
||||
{#each variables as variable, index}
|
||||
{#each variables as variable, index (index)}
|
||||
{@const source = getSource(variable.key)}
|
||||
{@const isVarRequired = isRequired(variable.key)}
|
||||
{@const isVarOptional = isOptional(variable.key)}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { tick, untrack } 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 } from 'lucide-svelte';
|
||||
import { Plus, Info, Upload, Trash2, List, FileText, AlertTriangle } from 'lucide-svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
|
||||
interface Props {
|
||||
variables: EnvVar[];
|
||||
variables: EnvVar[]; // Bindable - kept in sync with rawContent
|
||||
rawContent?: string; // The actual content saved to disk - source of truth
|
||||
validation?: ValidationResult | null;
|
||||
readonly?: boolean;
|
||||
showSource?: boolean;
|
||||
@@ -19,7 +22,8 @@
|
||||
}
|
||||
|
||||
let {
|
||||
variables = $bindable(),
|
||||
variables = $bindable([]),
|
||||
rawContent = $bindable(''),
|
||||
validation = null,
|
||||
readonly = false,
|
||||
showSource = false,
|
||||
@@ -31,44 +35,194 @@
|
||||
onchange
|
||||
}: Props = $props();
|
||||
|
||||
const STORAGE_KEY_VIEW_MODE = 'dockhand-env-vars-view-mode';
|
||||
|
||||
let fileInputRef: HTMLInputElement;
|
||||
let viewMode = $state<'form' | 'text'>(
|
||||
(typeof localStorage !== 'undefined' && localStorage.getItem(STORAGE_KEY_VIEW_MODE) as 'form' | 'text') || 'form'
|
||||
);
|
||||
let confirmClearOpen = $state(false);
|
||||
let contentAreaRef: HTMLDivElement;
|
||||
let parseWarnings = $state<string[]>([]);
|
||||
let editorTheme = $state<'light' | 'dark'>('dark');
|
||||
|
||||
function addEnvVariable() {
|
||||
variables = [...variables, { key: '', value: '', isSecret: false }];
|
||||
}
|
||||
// Track previous variables to detect form changes
|
||||
let prevVariablesJson = $state('');
|
||||
|
||||
function handleLoadFromFile() {
|
||||
fileInputRef?.click();
|
||||
}
|
||||
// Track if initial sync has been done (to distinguish initial load from user action)
|
||||
let initialized = $state(false);
|
||||
|
||||
function parseEnvFile(content: string): EnvVar[] {
|
||||
const lines = content.split('\n');
|
||||
const envVars: EnvVar[] = [];
|
||||
// Parse raw content to EnvVar array
|
||||
function parseRawContent(content: string): { vars: EnvVar[], warnings: string[] } {
|
||||
const result: EnvVar[] = [];
|
||||
const warnings: string[] = [];
|
||||
let lineNum = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
// Skip empty lines and comments
|
||||
for (const line of content.split('\n')) {
|
||||
lineNum++;
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
|
||||
// Parse KEY=VALUE format
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex === -1) continue;
|
||||
if (eqIndex === -1) {
|
||||
warnings.push(`Line ${lineNum}: "${trimmed.slice(0, 30)}${trimmed.length > 30 ? '...' : ''}" (no = found)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = trimmed.slice(0, eqIndex).trim();
|
||||
let value = trimmed.slice(eqIndex + 1).trim();
|
||||
let value = trimmed.slice(eqIndex + 1);
|
||||
|
||||
// Remove surrounding quotes if present
|
||||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
if (key) {
|
||||
envVars.push({ key, value, isSecret: false });
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
|
||||
warnings.push(`Line ${lineNum}: "${key}" (invalid variable name)`);
|
||||
continue;
|
||||
}
|
||||
result.push({
|
||||
key,
|
||||
value,
|
||||
isSecret: existingSecretKeys.has(key) || false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return envVars;
|
||||
return { vars: result, warnings };
|
||||
}
|
||||
|
||||
// Update rawContent when variables change - replace var lines by position, preserve comments
|
||||
function syncRawContentFromVariables(newVars: EnvVar[]) {
|
||||
const lines = rawContent.split('\n');
|
||||
const resultLines: string[] = [];
|
||||
const varsWithKeys = newVars.filter(v => v.key.trim());
|
||||
let varIdx = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
// Keep comments and blank lines
|
||||
if (!trimmed || trimmed.startsWith('#')) {
|
||||
resultLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex > 0) {
|
||||
const key = trimmed.slice(0, eqIndex).trim();
|
||||
if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
|
||||
// This is a valid variable line - replace with var at current index
|
||||
if (varIdx < varsWithKeys.length) {
|
||||
const v = varsWithKeys[varIdx];
|
||||
resultLines.push(`${v.key.trim()}=${v.value}`);
|
||||
varIdx++;
|
||||
}
|
||||
// If we have fewer vars, this line is deleted
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Keep invalid lines as-is
|
||||
resultLines.push(line);
|
||||
}
|
||||
|
||||
// Append any new variables
|
||||
while (varIdx < varsWithKeys.length) {
|
||||
const v = varsWithKeys[varIdx];
|
||||
resultLines.push(`${v.key.trim()}=${v.value}`);
|
||||
varIdx++;
|
||||
}
|
||||
|
||||
let result = resultLines.join('\n');
|
||||
if (result && !result.endsWith('\n')) {
|
||||
result += '\n';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// When rawContent changes externally (text view, file load), update variables
|
||||
$effect(() => {
|
||||
const { vars, warnings } = parseRawContent(rawContent);
|
||||
parseWarnings = warnings;
|
||||
|
||||
// Initial load with no .env file: don't overwrite DB-loaded variables
|
||||
// Let the second $effect generate rawContent from the existing variables instead
|
||||
if (!initialized && !rawContent.trim() && variables.length > 0) {
|
||||
initialized = true;
|
||||
return;
|
||||
}
|
||||
initialized = true;
|
||||
|
||||
// When rawContent has content, merge parsed vars with existing DB secrets
|
||||
// This handles the case where .env file exists but DB has additional secrets
|
||||
let finalVars = vars;
|
||||
if (rawContent.trim()) {
|
||||
const parsedKeys = new Set(vars.map(v => v.key));
|
||||
const existingSecrets = untrack(() =>
|
||||
variables.filter(v => v.isSecret && !parsedKeys.has(v.key))
|
||||
);
|
||||
if (existingSecrets.length > 0) {
|
||||
finalVars = [...vars, ...existingSecrets];
|
||||
}
|
||||
}
|
||||
|
||||
const newJson = JSON.stringify(finalVars.map(v => ({ key: v.key, value: v.value })));
|
||||
// Use untrack to read variables without creating a dependency on it
|
||||
// This prevents the effect from running when variables changes (only rawContent should trigger it)
|
||||
const currentNonEmptyJson = untrack(() =>
|
||||
JSON.stringify(variables.filter(v => v.key.trim()).map(v => ({ key: v.key, value: v.value })))
|
||||
);
|
||||
|
||||
if (newJson !== currentNonEmptyJson) {
|
||||
variables = finalVars;
|
||||
prevVariablesJson = newJson;
|
||||
}
|
||||
});
|
||||
|
||||
// When variables change from form edits, update rawContent
|
||||
$effect(() => {
|
||||
const currentJson = JSON.stringify(variables.map(v => ({ key: v.key, value: v.value })));
|
||||
|
||||
// Only sync if variables actually changed (not from parsing rawContent)
|
||||
if (currentJson !== prevVariablesJson) {
|
||||
prevVariablesJson = currentJson;
|
||||
const newRaw = syncRawContentFromVariables(variables);
|
||||
if (newRaw !== rawContent) {
|
||||
rawContent = newRaw;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function handleTextChange(value: string) {
|
||||
rawContent = value;
|
||||
onchange?.();
|
||||
}
|
||||
|
||||
function handleViewModeChange(newMode: 'form' | 'text') {
|
||||
viewMode = newMode;
|
||||
localStorage.setItem(STORAGE_KEY_VIEW_MODE, newMode);
|
||||
}
|
||||
|
||||
async function addEnvVariable() {
|
||||
variables = [...variables, { key: '', value: '', isSecret: false }];
|
||||
onchange?.();
|
||||
await tick();
|
||||
if (contentAreaRef) {
|
||||
contentAreaRef.scrollTop = contentAreaRef.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
async function addMissingVariable(key: string) {
|
||||
variables = [...variables, { key, value: '', isSecret: false }];
|
||||
onchange?.();
|
||||
await tick();
|
||||
if (contentAreaRef) {
|
||||
contentAreaRef.scrollTop = contentAreaRef.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function handleLoadFromFile() {
|
||||
fileInputRef?.click();
|
||||
}
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
@@ -78,90 +232,90 @@
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result as string;
|
||||
const parsedVars = parseEnvFile(content);
|
||||
|
||||
if (parsedVars.length > 0) {
|
||||
// Get existing keys to avoid duplicates
|
||||
const existingKeys = new Set(variables.filter(v => v.key.trim()).map(v => v.key.trim()));
|
||||
|
||||
// Filter empty entries from current variables
|
||||
const nonEmptyVars = variables.filter(v => v.key.trim());
|
||||
|
||||
// Add new variables, updating existing ones or appending new
|
||||
for (const newVar of parsedVars) {
|
||||
if (existingKeys.has(newVar.key)) {
|
||||
// Update existing variable
|
||||
const idx = nonEmptyVars.findIndex(v => v.key.trim() === newVar.key);
|
||||
if (idx !== -1) {
|
||||
nonEmptyVars[idx] = { ...nonEmptyVars[idx], value: newVar.value };
|
||||
}
|
||||
} else {
|
||||
// Add new variable
|
||||
nonEmptyVars.push(newVar);
|
||||
existingKeys.add(newVar.key);
|
||||
}
|
||||
}
|
||||
|
||||
variables = nonEmptyVars;
|
||||
// Notify parent of change (important for async file load)
|
||||
onchange?.();
|
||||
}
|
||||
rawContent = e.target?.result as string;
|
||||
onchange?.();
|
||||
};
|
||||
reader.readAsText(file);
|
||||
|
||||
// Reset input so the same file can be selected again
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function clearAllVariables() {
|
||||
function clearAll() {
|
||||
rawContent = '';
|
||||
variables = [];
|
||||
onchange?.();
|
||||
}
|
||||
|
||||
// Count of non-empty variables
|
||||
const hasVariables = $derived(variables.some(v => v.key.trim()));
|
||||
const hasContent = $derived(!!rawContent?.trim() || variables.some(v => v.key.trim()));
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<div class="flex items-center gap-2">
|
||||
<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.Content class="max-w-md">
|
||||
<p class="text-xs">{infoText}</p>
|
||||
</Tooltip.Content>
|
||||
<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>
|
||||
</div>
|
||||
{#if !readonly}
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex items-center gap-1 shrink-0 ml-4">
|
||||
<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
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="ghost" onclick={addEnvVariable} class="h-6 text-xs px-2">
|
||||
<Plus class="w-3.5 h-3.5 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
{#if hasVariables}
|
||||
<ConfirmPopover
|
||||
title="Clear all variables"
|
||||
description="This will remove all environment variables. This cannot be undone."
|
||||
confirmText="Clear all"
|
||||
onConfirm={clearAllVariables}
|
||||
>
|
||||
<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>
|
||||
</ConfirmPopover>
|
||||
{#if viewMode === 'form'}
|
||||
<Button type="button" size="sm" variant="ghost" onclick={addEnvVariable} class="h-6 text-xs px-2">
|
||||
<Plus class="w-3.5 h-3.5 mr-1" />
|
||||
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>
|
||||
</div>
|
||||
<input
|
||||
bind:this={fileInputRef}
|
||||
@@ -172,47 +326,44 @@
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Variable syntax help -->
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-0.5 text-2xs text-zinc-400 dark:text-zinc-500 font-mono">
|
||||
<span><span class="text-zinc-500 dark:text-zinc-400">${`{VAR}`}</span> required</span>
|
||||
<span><span class="text-zinc-500 dark:text-zinc-400">${`{VAR:-default}`}</span> optional</span>
|
||||
<span><span class="text-zinc-500 dark:text-zinc-400">${`{VAR:?error}`}</span> required w/ error</span>
|
||||
</div>
|
||||
<!-- Validation status pills -->
|
||||
{#if validation}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#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} required
|
||||
</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}
|
||||
{#if validation.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">
|
||||
{validation.unused.length} unused
|
||||
</span>
|
||||
{/if}
|
||||
<!-- Help text -->
|
||||
{#if viewMode === 'form'}
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-0.5 text-2xs text-zinc-400 dark:text-zinc-500 font-mono">
|
||||
<span><span class="text-zinc-500 dark:text-zinc-400">${`{VAR}`}</span> required</span>
|
||||
<span><span class="text-zinc-500 dark:text-zinc-400">${`{VAR:-default}`}</span> optional</span>
|
||||
<span><span class="text-zinc-500 dark:text-zinc-400">${`{VAR:?error}`}</span> required w/ error</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-2xs text-zinc-400 dark:text-zinc-500">
|
||||
Raw .env file (comments preserved, saved exactly as typed)
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Add missing variables -->
|
||||
{#if validation && validation.missing.length > 0 && !readonly}
|
||||
<!-- Parse warnings (form mode only) -->
|
||||
{#if viewMode === 'form' && parseWarnings.length > 0}
|
||||
<div class="flex items-start gap-2 px-2 py-1.5 rounded bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50">
|
||||
<AlertTriangle class="w-3.5 h-3.5 text-amber-500 shrink-0 mt-0.5" />
|
||||
<div class="text-2xs text-amber-700 dark:text-amber-300">
|
||||
<span class="font-medium">Some lines couldn't be parsed:</span>
|
||||
<ul class="mt-0.5 list-disc list-inside">
|
||||
{#each parseWarnings.slice(0, 3) as warning}
|
||||
<li>{warning}</li>
|
||||
{/each}
|
||||
{#if parseWarnings.length > 3}
|
||||
<li>...and {parseWarnings.length - 3} more</li>
|
||||
{/if}
|
||||
</ul>
|
||||
<p class="mt-1 text-amber-600 dark:text-amber-400">Switch to text view to edit these lines.</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Add missing variables (form mode only) -->
|
||||
{#if viewMode === 'form' && validation && validation.missing.length > 0 && !readonly}
|
||||
<div class="flex flex-wrap gap-1 items-center">
|
||||
<span class="text-xs text-muted-foreground mr-1">Add missing:</span>
|
||||
{#each validation.missing as missing}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
variables = [...variables, { key: missing, value: '', isSecret: false }];
|
||||
}}
|
||||
onclick={() => addMissingVariable(missing)}
|
||||
class="text-xs px-1.5 py-0.5 rounded bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-300 dark:hover:bg-red-900/50 transition-colors"
|
||||
>
|
||||
{missing}
|
||||
@@ -221,16 +372,27 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Variables list -->
|
||||
<div class="flex-1 overflow-auto px-4 py-3">
|
||||
<StackEnvVarsEditor
|
||||
bind:variables
|
||||
{validation}
|
||||
{readonly}
|
||||
{showSource}
|
||||
{sources}
|
||||
{placeholder}
|
||||
{existingSecretKeys}
|
||||
/>
|
||||
<!-- Content area -->
|
||||
<div bind:this={contentAreaRef} class="flex-1 overflow-auto px-4 py-3">
|
||||
{#if viewMode === 'form'}
|
||||
<StackEnvVarsEditor
|
||||
bind:variables
|
||||
{validation}
|
||||
{readonly}
|
||||
{showSource}
|
||||
{sources}
|
||||
{placeholder}
|
||||
{existingSecretKeys}
|
||||
/>
|
||||
{:else}
|
||||
<CodeEditor
|
||||
value={rawContent}
|
||||
language="dotenv"
|
||||
theme={editorTheme}
|
||||
readonly={readonly}
|
||||
onchange={handleTextChange}
|
||||
class="h-full min-h-[200px] rounded-md overflow-hidden border border-zinc-200 dark:border-zinc-700"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
Reference in new issue
Block a user