Files
dockhand/src/routes/stacks/StackModal.svelte
T
2026-01-17 15:06:14 +01:00

1737 lines
57 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy, tick } from 'svelte';
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 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, GripVertical, FolderOpen, Copy, Check, MapPin, ArrowRight, ArrowDown, Info, Box, FolderSync } from 'lucide-svelte';
import type { Component } from 'svelte';
import FilesystemBrowser from './FilesystemBrowser.svelte';
import PathBarItem from './PathBarItem.svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import * as Select from '$lib/components/ui/select';
import { Badge } from '$lib/components/ui/badge';
import { currentEnvironment, appendEnvParam } from '$lib/stores/environment';
import { appSettings } from '$lib/stores/settings';
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';
interface Props {
open: boolean;
mode: 'create' | 'edit';
stackName?: string; // Required for edit mode, optional for create
onClose: () => void;
onSuccess: () => void; // Called after create or save
}
let { open = $bindable(), mode, stackName = '', onClose, onSuccess }: Props = $props();
// Form state
let newStackName = $state('');
let loading = $state(false);
let saving = $state(false);
let savingWithRestart = $state(false); // Track which save action is in progress
let error = $state<string | null>(null);
let loadError = $state<string | null>(null);
let errors = $state<{ stackName?: string; compose?: string }>({});
let composeContent = $state('');
let activeTab = $state<'editor' | 'graph'>('editor');
let showConfirmClose = $state(false);
let editorTheme = $state<'light' | 'dark'>('dark');
// Environment variables state
let envVars = $state<EnvVar[]>([]);
let rawEnvContent = $state(''); // Raw .env file content (comments preserved)
let envValidation = $state<ValidationResult | null>(null);
let validating = $state(false);
let existingSecretKeys = $state<Set<string>>(new Set());
let hadExistingDbVars = $state(false); // Track if DB had any vars on load (for proper cleanup)
// Simple dirty flag - only set when user touches something
let isDirty = $state(false);
// Error dialog state
let operationError = $state<{ title: string; message: string; details?: string } | null>(null);
// Stack exists warning dialog state
let showExistsWarning = $state(false);
// ─── Path State (Simplified) ─────────────────────────────────────────────────
// Working paths: what we're currently editing (always strings, never null)
let workingComposePath = $state('');
let workingEnvPath = $state('');
// Original paths: loaded from server (for dirty/change detection in edit mode)
let originalComposePath = $state<string | null>(null);
let originalEnvPath = $state<string | null>(null);
// Auto-computed path from API (for create mode - tracks what the default would be)
let autoComputedComposePath = $state('');
// Path source info (for hint display)
let pathSource = $state<'default' | 'custom' | 'browsed' | null>(null);
// UI state
let composePathCopied = $state(false);
let envPathCopied = $state(false);
let needsFileLocation = $state(false);
// Container info for untracked stacks
let stackContainers = $state<{ name: string; state: string; image: string }[]>([]);
// Derived: has user customized the compose path from auto-computed default?
const isComposePathCustom = $derived(
workingComposePath !== '' && workingComposePath !== autoComputedComposePath
);
// Derived: suggested env path when workingEnvPath is empty
const suggestedEnvPath = $derived(
!workingEnvPath && workingComposePath
? workingComposePath.replace(/\/[^/]+$/, '/.env')
: null
);
// Derived: display path for env (actual or suggested)
const displayEnvPath = $derived(workingEnvPath || suggestedEnvPath || '');
// Derived: is env path just a suggestion (not explicitly set)?
const isEnvPathSuggested = $derived(!workingEnvPath && !!suggestedEnvPath);
// Derived: source hint text for the path bar (only in create mode)
const pathSourceHint = $derived.by(() => {
if (mode !== 'create' || !workingComposePath) return undefined;
switch (pathSource) {
case 'browsed':
return 'Custom location';
case 'custom':
return 'Using saved location';
case 'default':
return 'Using default location';
default:
return undefined;
}
});
// Path change confirmation dialog state
let showPathChangeConfirm = $state(false);
let pathChangeOldDir = $state<string | null>(null); // Old directory to move files from
let pathChangeFileCount = $state(0); // Number of files in old directory
let pendingSaveRestart = $state(false); // Whether user clicked "Save & restart" vs "Save"
// Browse confirmation dialog state (when selecting different file would replace content)
let showBrowseConfirm = $state(false);
let pendingBrowsePath = $state<string | null>(null);
let pendingBrowseName = $state<string | null>(null);
// Single file browser with dynamic config
let showFileBrowser = $state(false);
let fileBrowserConfig = $state<{
title: string;
icon?: Component<{ class?: string }>;
selectFilter?: RegExp;
selectMode: 'file' | 'directory' | 'file_or_directory';
onSelect: (path: string, name: string) => void;
}>({
title: '',
icon: undefined,
selectFilter: /.*/,
selectMode: 'file',
onSelect: () => {}
});
function openComposeBrowser() {
// For untracked stacks (needsFileLocation), only allow selecting files
// For tracked stacks, allow both files and directories
const isUntracked = needsFileLocation;
fileBrowserConfig = {
title: isUntracked ? 'Select compose file' : 'Select compose file or directory',
selectFilter: /\.ya?ml$/,
selectMode: isUntracked ? 'file' : 'file_or_directory',
onSelect: handleComposeSelect
};
showFileBrowser = true;
}
function openEnvBrowser() {
fileBrowserConfig = {
title: 'Select environment file or directory',
selectFilter: /\.env($|\.)/, // matches .env, .env.local, app.env, etc.
selectMode: 'file_or_directory',
onSelect: handleEnvSelect
};
showFileBrowser = true;
}
function openChangeLocationBrowser() {
const displayName = mode === 'edit' ? stackName : newStackName;
fileBrowserConfig = {
title: `Relocate ${displayName}`,
icon: FolderSync,
selectMode: 'directory',
onSelect: handleChangeLocation
};
showFileBrowser = true;
}
// State for change location confirmation
let pendingNewLocation = $state<string | null>(null);
let pendingNewComposePath = $state<string | null>(null);
let pendingNewEnvPath = $state<string | null>(null);
let showChangeLocationConfirm = $state(false);
let changeLocationFileCount = $state(0);
let changeLocationOldDir = $state<string | null>(null);
let movingLocation = $state(false);
async function handleChangeLocation(selectedDir: string, _name: string) {
showFileBrowser = false;
// Get the current compose filename
const currentComposePath = workingComposePath;
const composeFilename = currentComposePath ? currentComposePath.split('/').pop() : 'compose.yaml';
// Build new paths: create a subfolder with the stack name inside selected directory
const displayName = mode === 'edit' ? stackName : newStackName;
const newDir = `${selectedDir}/${displayName}`;
const newComposePath = `${newDir}/${composeFilename}`;
const newEnvPath = workingEnvPath ? `${newDir}/.env` : '';
// Check if old directory has files to move
const envId = $currentEnvironment?.id ?? null;
try {
const response = await fetch(
appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/check-path-change`, envId),
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ newComposePath })
}
);
if (response.ok) {
const data = await response.json();
if (data.hasChanges && data.oldDir && data.fileCount > 0) {
// Show confirmation dialog
pendingNewLocation = newDir;
pendingNewComposePath = newComposePath;
pendingNewEnvPath = newEnvPath;
changeLocationOldDir = data.oldDir;
changeLocationFileCount = data.fileCount;
showChangeLocationConfirm = true;
return;
}
}
} catch (e) {
console.warn('Failed to check path changes:', e);
}
// No files to move, just update paths
workingComposePath = newComposePath;
workingEnvPath = newEnvPath;
isDirty = true;
}
function cancelChangeLocation() {
showChangeLocationConfirm = false;
pendingNewLocation = null;
pendingNewComposePath = null;
pendingNewEnvPath = null;
changeLocationOldDir = null;
changeLocationFileCount = 0;
}
async function confirmChangeLocation() {
if (!pendingNewComposePath || !changeLocationOldDir) return;
movingLocation = true;
const envId = $currentEnvironment?.id ?? null;
try {
// Call API to move files
const response = await fetch(
appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/relocate`, envId),
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
oldDir: changeLocationOldDir,
newComposePath: pendingNewComposePath,
newEnvPath: pendingNewEnvPath || undefined
})
}
);
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to move files');
}
const result = await response.json();
// Update paths
workingComposePath = pendingNewComposePath;
workingEnvPath = pendingNewEnvPath || '';
originalComposePath = pendingNewComposePath;
originalEnvPath = pendingNewEnvPath || null;
// Reload content from new location
if (result.composeContent) {
composeContent = result.composeContent;
}
if (result.envVars) {
envVars = result.envVars;
}
if (result.rawEnvContent !== undefined) {
rawEnvContent = result.rawEnvContent;
}
// Reset dirty flag since we just reloaded
isDirty = false;
} catch (e: any) {
operationError = {
title: 'Failed to move files',
message: e.message || 'An error occurred while moving files'
};
} finally {
movingLocation = false;
showChangeLocationConfirm = false;
pendingNewLocation = null;
pendingNewComposePath = null;
pendingNewEnvPath = null;
changeLocationOldDir = null;
changeLocationFileCount = 0;
}
}
// Generic copy function that returns a reset callback
function copyToClipboard(text: string | null, setCopied: (v: boolean) => void) {
if (text) {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
}
// Parse env vars from raw content
function parseEnvVarsFromRaw(content: string) {
const vars: EnvVar[] = [];
const lines = content.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIndex = trimmed.indexOf('=');
if (eqIndex > 0) {
const key = trimmed.substring(0, eqIndex);
const value = trimmed.substring(eqIndex + 1);
vars.push({ key, value, isSecret: false });
}
}
envVars = vars;
}
// Handle compose file selection from browser
async function handleComposeSelect(path: string, name: string) {
const isDirectory = !path.match(/\.ya?ml$/i);
// If selecting a file in edit mode with existing content, show confirmation
if (mode === 'edit' && !isDirectory && composeContent.trim()) {
// Check if it's the same file (no confirmation needed)
const normalizedPath = path.endsWith('/') ? path.slice(0, -1) : path;
if (normalizedPath !== workingComposePath) {
pendingBrowsePath = path;
pendingBrowseName = name;
showBrowseConfirm = true;
showFileBrowser = false;
return;
}
}
// Continue with file selection
await proceedWithComposeSelect(path, name);
}
// Proceed with compose file selection (after optional confirmation)
async function proceedWithComposeSelect(path: string, name: string) {
// Check if it's a directory (no extension or doesn't end with .yml/.yaml)
const isDirectory = !path.match(/\.ya?ml$/i);
const baseDir = path.endsWith('/') ? path.slice(0, -1) : path;
let finalPath = path;
if (isDirectory) {
const stackName = newStackName.trim();
if (stackName) {
// If we have a stack name, include the subfolder
finalPath = `${baseDir}/${stackName}/compose.yaml`;
} else {
// No stack name yet - just show the selected directory
finalPath = `${baseDir}/`;
}
}
workingComposePath = finalPath;
pathSource = 'browsed';
showFileBrowser = false;
// Auto-suggest .env in the same directory (only if we have a full path)
if (!isDirectory || newStackName.trim()) {
const dir = finalPath.replace(/\/[^/]+$/, '');
if (!workingEnvPath) {
workingEnvPath = `${dir}/.env`;
}
}
// Load compose file content when selecting a file (not directory)
if (!isDirectory) {
await loadFilesFromLocalFilesystem(finalPath, workingEnvPath || suggestedEnvPath || '');
}
isDirty = true;
}
// Cancel browse confirmation
function cancelBrowseConfirm() {
showBrowseConfirm = false;
pendingBrowsePath = null;
pendingBrowseName = null;
}
// Confirm browse and load the new file
async function confirmBrowseAndLoad() {
showBrowseConfirm = false;
if (pendingBrowsePath && pendingBrowseName) {
await proceedWithComposeSelect(pendingBrowsePath, pendingBrowseName);
}
pendingBrowsePath = null;
pendingBrowseName = null;
}
// Handle env file selection from browser
async function handleEnvSelect(path: string, name: string) {
// Check if it's a directory (no extension or doesn't contain .env)
const isDirectory = !path.match(/\.env($|\.)/i);
let finalPath = path;
if (isDirectory) {
// Append default env filename
finalPath = path.endsWith('/') ? `${path}.env` : `${path}/.env`;
}
workingEnvPath = finalPath;
showFileBrowser = false;
// Load env content when selecting a file (not directory)
if (!isDirectory) {
try {
const envResponse = await fetch(`/api/system/files/content?path=${encodeURIComponent(finalPath)}`);
if (envResponse.ok) {
const envData = await envResponse.json();
rawEnvContent = envData.content || '';
parseEnvVarsFromRaw(rawEnvContent);
} else {
rawEnvContent = '';
}
} catch (e) {
console.error('Failed to load env file:', e);
}
}
isDirty = true;
}
// Load files from local filesystem (when user selects paths)
async function loadFilesFromLocalFilesystem(composeFilePath: string, envFilePath: string) {
try {
// Load compose file
const composeResponse = await fetch(`/api/system/files/content?path=${encodeURIComponent(composeFilePath)}`);
if (composeResponse.ok) {
const composeData = await composeResponse.json();
composeContent = composeData.content || '';
workingComposePath = composeFilePath;
// Clear the needsFileLocation flag since we now have content
needsFileLocation = false;
stackContainers = [];
} else {
const err = await composeResponse.json();
console.error('Failed to load compose file:', err.error);
}
// Try to load .env file (only set workingEnvPath if it exists)
if (envFilePath) {
const envResponse = await fetch(`/api/system/files/content?path=${encodeURIComponent(envFilePath)}`);
if (envResponse.ok) {
const envData = await envResponse.json();
rawEnvContent = envData.content || '';
workingEnvPath = envFilePath;
parseEnvVarsFromRaw(rawEnvContent);
} else {
// .env file not found - clear env path
rawEnvContent = '';
workingEnvPath = '';
}
}
} catch (e) {
console.error('Failed to load files:', e);
}
}
// CodeEditor reference for explicit marker updates
let codeEditorRef: CodeEditor | null = $state(null);
// ComposeGraphViewer reference for resize on panel toggle
let graphViewerRef: ComposeGraphViewer | null = $state(null);
// EnvVarsPanel reference for sync before save
let envVarsPanelRef: StackEnvVarsPanel | null = $state(null);
// Resizable split panel state
let splitRatio = $state(60); // percentage for compose panel
let isDraggingSplit = $state(false);
let containerRef: HTMLDivElement | null = $state(null);
// Debounce timer for validation
let validateTimer: ReturnType<typeof setTimeout> | null = null;
const defaultCompose = `version: "3.8"
services:
app:
image: nginx:alpine
ports:
- "8080:80"
environment:
- APP_ENV=\${APP_ENV:-production}
volumes:
- ./html:/usr/share/nginx/html:ro
restart: unless-stopped
# Add more services as needed
# networks:
# default:
# driver: bridge
`;
// Count of defined environment variables (with non-empty keys)
const envVarCount = $derived(envVars.filter(v => v.key.trim()).length);
// Build a lookup map from envVars for quick access
const envVarMap = $derived.by(() => {
const map = new Map<string, { value: string; isSecret: boolean }>();
for (const v of envVars) {
if (v.key.trim()) {
map.set(v.key.trim(), { value: v.value, isSecret: v.isSecret });
}
}
return map;
});
// Compute variable markers for the code editor (with values for overlay)
const variableMarkers = $derived.by<VariableMarker[]>(() => {
if (!envValidation) return [];
const markers: VariableMarker[] = [];
// Add missing required variables
for (const name of envValidation.missing) {
const env = envVarMap.get(name);
markers.push({
name,
type: 'missing',
value: env?.value,
isSecret: env?.isSecret
});
}
// Add defined required variables
for (const name of envValidation.required) {
if (!envValidation.missing.includes(name)) {
const env = envVarMap.get(name);
markers.push({
name,
type: 'required',
value: env?.value,
isSecret: env?.isSecret
});
}
}
// Add optional variables
for (const name of envValidation.optional) {
const env = envVarMap.get(name);
markers.push({
name,
type: 'optional',
value: env?.value,
isSecret: env?.isSecret
});
}
return markers;
});
// Stable callback for compose content changes - avoids stale closure issues
function handleComposeChange(value: string) {
composeContent = value;
isDirty = true;
debouncedValidate();
}
// Debounced validation to avoid too many API calls while typing
function debouncedValidate() {
if (validateTimer) clearTimeout(validateTimer);
validateTimer = setTimeout(() => {
validateEnvVars();
}, 1000);
}
// Explicitly push markers to the editor (immediate=true since this is called after validation)
function updateEditorMarkers() {
if (!codeEditorRef) return;
codeEditorRef.updateVariableMarkers(variableMarkers, true);
}
// Mark dirty when env vars change
function markDirty() {
isDirty = true;
}
// Display title
const displayName = $derived(mode === 'edit' ? stackName : (newStackName || 'New stack'));
onMount(() => {
// Load saved editor theme, or fall back to app theme / system preference
const savedEditorTheme = localStorage.getItem('dockhand-editor-theme');
if (savedEditorTheme === 'dark' || savedEditorTheme === 'light') {
editorTheme = savedEditorTheme;
} else {
const appTheme = localStorage.getItem('theme');
if (appTheme === 'dark' || appTheme === 'light') {
editorTheme = appTheme;
} else {
// Fallback to system preference
editorTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
}
// 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());
}
}
async function loadComposeFile() {
if (mode !== 'edit' || !stackName) return;
loading = true;
loadError = null;
error = null;
needsFileLocation = false;
try {
const envId = $currentEnvironment?.id ?? null;
// Load compose file
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/compose`, envId));
const data = await response.json();
if (!response.ok) {
// Check if this stack needs file location selection
if (data.needsFileLocation) {
needsFileLocation = true;
// Initialize paths from response (may have suggested paths)
workingComposePath = data.composePath || '';
workingEnvPath = data.envPath || '';
// Show empty editors - user can browse for files
composeContent = '';
rawEnvContent = '';
loadError = null;
loading = false; // Important: stop loading spinner
// Fetch containers for this stack to show what's running
try {
const stacksRes = await fetch(appendEnvParam('/api/stacks', envId));
if (stacksRes.ok) {
const stacks = await stacksRes.json();
const thisStack = stacks.find((s: any) => s.name === stackName);
if (thisStack?.containerDetails) {
stackContainers = thisStack.containerDetails.map((c: any) => ({
name: c.name || 'unknown',
state: c.state || 'unknown',
image: c.image || 'unknown'
}));
}
}
} catch (e) {
console.error('Failed to fetch stack containers:', e);
}
return;
}
throw new Error(data.error || 'Failed to load compose file');
}
composeContent = data.content;
// Set working paths
workingComposePath = data.composePath || '';
workingEnvPath = data.envPath || '';
// Track original paths for detecting changes
originalComposePath = data.composePath || null;
originalEnvPath = data.envPath || null;
// Load both env endpoints in parallel, then process results together
const [envResponse, rawEnvResponse] = await Promise.all([
fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env`, envId)),
fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env/raw`, envId))
]);
// Process env vars from DB
let loadedVars: EnvVar[] = [];
if (envResponse.ok) {
const envData = await envResponse.json();
loadedVars = envData.variables || [];
hadExistingDbVars = loadedVars.length > 0;
existingSecretKeys = new Set(
loadedVars.filter(v => v.isSecret && v.key.trim()).map(v => v.key.trim())
);
}
// Process raw .env file content
let loadedRawContent = '';
if (rawEnvResponse.ok) {
const rawEnvData = await rawEnvResponse.json();
loadedRawContent = rawEnvData.content || '';
}
// Pass data directly to syncAfterLoad - no tick() needed
// This sets both envVars and rawEnvContent synchronously via the panel
loading = false;
await tick(); // Wait for panel ref to be available
envVarsPanelRef?.syncAfterLoad(loadedVars, loadedRawContent);
isDirty = false;
} catch (e: any) {
loadError = e.message;
loading = false;
}
}
async function validateEnvVars() {
const content = composeContent || defaultCompose;
if (!content.trim()) return;
validating = true;
try {
const envId = $currentEnvironment?.id ?? null;
// Use 'new' as placeholder stack name for new stacks
const stackNameForValidation = mode === 'edit' ? stackName : (newStackName.trim() || 'new');
// Pass current UI env vars for validation
const currentVars = envVars.filter(v => v.key.trim()).map(v => v.key.trim());
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackNameForValidation)}/env/validate`, envId), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ compose: content, variables: currentVars })
});
if (response.ok) {
envValidation = await response.json();
// Explicitly update markers in the editor after validation
// Use setTimeout to ensure derived variableMarkers has updated
setTimeout(() => updateEditorMarkers(), 0);
}
} catch (e) {
console.error('Failed to validate env vars:', e);
} finally {
validating = false;
}
}
function toggleEditorTheme() {
editorTheme = editorTheme === 'light' ? 'dark' : 'light';
localStorage.setItem('dockhand-editor-theme', editorTheme);
}
function handleGraphContentChange(newContent: string) {
composeContent = newContent;
}
async function handleCreate(start: boolean = false) {
errors = {};
let hasErrors = false;
if (!newStackName.trim()) {
errors.stackName = 'Stack name is required';
hasErrors = true;
}
const content = composeContent || defaultCompose;
if (!content.trim()) {
errors.compose = 'Compose file content is required';
hasErrors = true;
}
if (hasErrors) return;
const envId = $currentEnvironment?.id ?? null;
// Check if stack already exists
try {
const stacksResponse = await fetch(appendEnvParam('/api/stacks', envId));
if (stacksResponse.ok) {
const stacks = await stacksResponse.json();
const existingStack = stacks.find((s: { name: string }) =>
s.name.toLowerCase() === newStackName.trim().toLowerCase()
);
if (existingStack) {
showExistsWarning = true;
return;
}
}
} catch (e) {
console.warn('Failed to check for existing stacks:', e);
// Continue with creation if check fails
}
saving = true;
error = null;
// Prepare env vars for creating - syncs variables and rawContent
const prepared = envVarsPanelRef?.prepareForSave() || { rawContent: '', variables: [] };
try {
// Build request body
const requestBody: Record<string, unknown> = {
name: newStackName.trim(),
compose: content,
start,
// Send raw env content (non-secrets only, preserves comments/formatting)
rawEnvContent: prepared.rawContent.trim() ? prepared.rawContent : undefined,
// Also send parsed vars for DB secret tracking (includes secrets)
envVars: prepared.variables.length > 0 ? prepared.variables.map(v => ({
key: v.key.trim(),
value: v.value,
isSecret: v.isSecret
})) : undefined
};
// Include custom paths if specified
if (workingComposePath.trim()) {
requestBody.composePath = workingComposePath.trim();
}
// Use working env path or suggested path
const envPathToSave = workingEnvPath.trim() || suggestedEnvPath || '';
if (envPathToSave) {
requestBody.envPath = envPathToSave;
}
// Create the stack
const response = await fetch(appendEnvParam('/api/stacks', envId), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to create stack');
}
onSuccess();
handleClose();
} catch (e: any) {
operationError = {
title: 'Failed to create stack',
message: e.message || 'An error occurred while creating the stack',
details: e.details
};
} finally {
saving = false;
}
}
async function handleSave(restart = false, moveFromDir: string | null = null) {
errors = {};
// Validate compose content (unless file location is needed and we have a path)
if (!composeContent.trim() && !workingComposePath.trim()) {
errors.compose = 'Compose file content or path is required';
return;
}
// If file location is needed, require a compose path
if (needsFileLocation && !workingComposePath.trim()) {
errors.compose = 'Please select a compose file location';
return;
}
const envId = $currentEnvironment?.id ?? null;
// Check if directory has changed (edit mode only, and not already confirmed)
if (mode === 'edit' && !moveFromDir) {
const newComposePath = workingComposePath.trim() || null;
// Only check if compose path changed (which means directory changed)
if (newComposePath && originalComposePath && newComposePath !== originalComposePath) {
try {
const checkResponse = await fetch(
appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/check-path-change`, envId),
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ newComposePath })
}
);
if (checkResponse.ok) {
const checkData = await checkResponse.json();
if (checkData.hasChanges && checkData.oldDir && checkData.fileCount > 0) {
// Show confirmation dialog
pathChangeOldDir = checkData.oldDir;
pathChangeFileCount = checkData.fileCount;
pendingSaveRestart = restart;
showPathChangeConfirm = true;
return;
}
}
} catch (e) {
console.warn('Failed to check path changes:', e);
// Continue with save even if check fails
}
}
}
saving = true;
savingWithRestart = restart;
error = null;
// Prepare env vars for saving - syncs variables and rawContent
const prepared = envVarsPanelRef?.prepareForSave() || { rawContent: '', variables: [] };
// Resolve env path (use working or suggested)
const envPathToSave = workingEnvPath.trim() || suggestedEnvPath || '';
try {
// Build request body - include paths if they've been set/changed
const requestBody: Record<string, unknown> = {
content: composeContent,
restart
};
// Include compose path if set (either custom path or user selected)
if (workingComposePath.trim()) {
requestBody.composePath = workingComposePath.trim();
}
// Include env path - empty string means "no env file", null/undefined means "use default"
if (envPathToSave) {
requestBody.envPath = envPathToSave;
}
// Include old paths for file move/rename operations
if (originalComposePath && workingComposePath.trim() && originalComposePath !== workingComposePath.trim()) {
requestBody.oldComposePath = originalComposePath;
}
if (originalEnvPath && envPathToSave && originalEnvPath !== envPathToSave) {
requestBody.oldEnvPath = originalEnvPath;
}
// Include old directory to move files from if user confirmed
if (moveFromDir) {
requestBody.moveFromDir = moveFromDir;
}
// Save compose file (with optional paths)
const response = await fetch(
appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/compose`, envId),
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
}
);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to save compose file');
}
// Save raw content to .env file (non-secrets only, comments preserved)
const rawEnvResponse = await fetch(
appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env/raw`, envId),
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: prepared.rawContent })
}
);
if (!rawEnvResponse.ok) {
const rawEnvError = await rawEnvResponse.json().catch(() => ({ error: 'Failed to save environment file' }));
throw new Error(rawEnvError.error || 'Failed to save environment file');
}
// Save ALL vars to DB (includes secrets with real values)
const definedVars = prepared.variables;
if (definedVars.length > 0 || hadExistingDbVars) {
const envResponse = await fetch(
appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env`, envId),
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
variables: definedVars.map(v => ({
key: v.key.trim(),
value: v.value,
isSecret: v.isSecret
}))
})
}
);
if (!envResponse.ok) {
// Log but don't fail - DB stores secret metadata
console.warn('Failed to save environment variable metadata to database');
}
hadExistingDbVars = definedVars.length > 0;
existingSecretKeys = new Set(
definedVars.filter(v => v.isSecret && v.key.trim()).map(v => v.key.trim())
);
}
isDirty = false; // Reset dirty flag after successful save
onSuccess();
if (!restart) {
// Show success briefly then close
setTimeout(() => handleClose(), 500);
} else {
handleClose();
}
} catch (e: any) {
operationError = {
title: restart ? 'Failed to apply stack' : 'Failed to save stack',
message: e.message || (restart ? 'An error occurred while applying the stack' : 'An error occurred while saving the stack'),
details: e.details
};
} finally {
saving = false;
}
}
// Handle path change confirmation - move files to new location and proceed
function confirmPathChangeAndMove() {
showPathChangeConfirm = false;
handleSave(pendingSaveRestart, pathChangeOldDir);
}
// Handle path change - keep old files and proceed (just save without moving)
function confirmPathChangeKeepFiles() {
showPathChangeConfirm = false;
// Pass empty string to skip move check this time (not null, which means "not checked yet")
handleSave(pendingSaveRestart, '');
}
function tryClose() {
if (isDirty) {
showConfirmClose = true;
} else {
handleClose();
}
}
function handleClose() {
// Clear any pending validation timer
if (validateTimer) {
clearTimeout(validateTimer);
validateTimer = null;
}
// Reset all state
newStackName = '';
error = null;
loadError = null;
rawEnvContent = '';
errors = {};
composeContent = '';
envVars = [];
envValidation = null;
isDirty = false;
existingSecretKeys = new Set();
hadExistingDbVars = false;
activeTab = 'editor';
showConfirmClose = false;
codeEditorRef = null;
operationError = null;
// Reset path state
workingComposePath = '';
workingEnvPath = '';
originalComposePath = null;
originalEnvPath = null;
autoComputedComposePath = '';
pathSource = null;
needsFileLocation = false;
stackContainers = [];
showFileBrowser = false;
// Reset path change confirmation state
showPathChangeConfirm = false;
pathChangeOldDir = null;
pathChangeFileCount = 0;
pendingSaveRestart = false;
// Reset browse confirmation state
showBrowseConfirm = false;
pendingBrowsePath = null;
pendingBrowseName = null;
onClose();
}
function discardAndClose() {
showConfirmClose = false;
handleClose();
}
// Initialize when dialog opens - ONLY ONCE per open
let hasInitialized = $state(false);
$effect(() => {
if (open && !hasInitialized) {
hasInitialized = true;
if (mode === 'edit' && stackName) {
loadComposeFile().then(() => {
// Auto-validate after loading
validateEnvVars();
});
} else if (mode === 'create') {
// Set default compose content for create mode
composeContent = defaultCompose;
isDirty = false; // Reset dirty flag for new modal
loading = false;
// Auto-validate default compose
validateEnvVars();
}
} else if (!open) {
hasInitialized = false; // Reset when modal closes
}
});
// Re-validate when envVars change (adding/removing variables affects missing/defined status)
$effect(() => {
// Track envVars changes (this triggers on any modification to envVars array)
const vars = envVars;
if (!open || !envValidation) return;
// Debounce to avoid too many API calls while typing
const timeout = setTimeout(() => {
validateEnvVars();
}, 800);
return () => clearTimeout(timeout);
});
// Auto-update default paths when stack name changes in create mode
$effect(() => {
if (mode !== 'create' || !open) return;
// Don't overwrite if user has browsed and selected a path
if (pathSource === 'browsed') return;
const name = newStackName.trim();
const location = $appSettings.primaryStackLocation;
if (!name) {
// Clear paths when no name
workingComposePath = '';
workingEnvPath = '';
autoComputedComposePath = '';
pathSource = null;
return;
}
// Fetch the actual absolute path from the backend
const envId = $currentEnvironment?.id ?? null;
const fetchDefaultPath = async () => {
try {
const params = new URLSearchParams({ name });
if (envId) params.set('env', String(envId));
if (location) {
params.set('location', location);
}
const response = await fetch(`/api/stacks/default-path?${params}`);
if (response.ok) {
const data = await response.json();
// Check if user has customized before updating auto-computed
// Compare current working path against OLD auto path (before we update it)
const userHasCustomized = workingComposePath !== '' &&
workingComposePath !== autoComputedComposePath;
// Track the auto-computed path
autoComputedComposePath = data.composePath;
// Only update working paths if user hasn't customized
if (!userHasCustomized) {
workingComposePath = data.composePath;
workingEnvPath = data.envPath;
pathSource = data.source || 'default';
}
}
} catch (e) {
console.error('Failed to fetch default path:', e);
}
};
fetchDefaultPath();
});
</script>
<Dialog.Root
bind:open
onOpenChange={(isOpen) => {
if (isOpen) {
focusFirstInput();
} else {
// Prevent closing if there are unsaved changes - show confirmation instead
if (isDirty) {
// Re-open the dialog and show confirmation
open = true;
showConfirmClose = true;
} else {
// No unsaved changes - reset state
handleClose();
}
}
}}
>
<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="flex items-center gap-2">
<div class="p-1.5 rounded-md bg-zinc-200 dark:bg-zinc-700">
<Layers 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">
{#if mode === 'create'}
Create compose stack
{:else}
{stackName}
{/if}
</Dialog.Title>
<Dialog.Description class="text-xs text-zinc-500 dark:text-zinc-400">
{#if mode === 'create'}
Create a new Docker Compose stack
{:else}
Edit compose file and environment variables
{/if}
</Dialog.Description>
</div>
</div>
</div>
<div class="flex items-center gap-2">
<!-- View toggle -->
<div class="flex items-center gap-0.5 bg-zinc-200 dark:bg-zinc-700 rounded-md p-0.5">
<button
class="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs transition-colors {activeTab === 'editor' ? 'bg-white dark:bg-zinc-900 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={() => activeTab = 'editor'}
>
<Code class="w-3.5 h-3.5" />
Editor
</button>
<button
class="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs transition-colors {activeTab === 'graph' ? 'bg-white dark:bg-zinc-900 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={() => activeTab = 'graph'}
>
<GitGraph class="w-3.5 h-3.5" />
Graph
</button>
</div>
<!-- Theme toggle (only in editor mode) -->
{#if activeTab === 'editor'}
<button
onclick={toggleEditorTheme}
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"
title={editorTheme === 'light' ? 'Switch to dark theme' : 'Switch to light theme'}
>
{#if editorTheme === 'light'}
<Moon class="w-4 h-4" />
{:else}
<Sun class="w-4 h-4" />
{/if}
</button>
{/if}
<!-- Close button -->
<button
onclick={tryClose}
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>
</div>
</Dialog.Header>
<div class="flex-1 overflow-hidden flex flex-col min-h-0">
{#if errors.compose}
<Alert.Root variant="destructive" class="mx-6 mt-4">
<TriangleAlert class="h-4 w-4" />
<Alert.Description>{errors.compose}</Alert.Description>
</Alert.Root>
{/if}
{#if mode === 'edit' && loading}
<div class="flex-1 flex items-center justify-center">
<div class="flex items-center gap-3 text-zinc-400 dark:text-zinc-500">
<Loader2 class="w-5 h-5 animate-spin" />
<span>Loading compose file...</span>
</div>
</div>
{:else}
<!-- Stack name and location inputs (create mode only) -->
{#if mode === 'create'}
<div class="px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
<div class="flex gap-4 items-start">
<div class="flex-1 max-w-xs space-y-1">
<Label for="stack-name">Stack name</Label>
<Input
id="stack-name"
bind:value={newStackName}
placeholder="my-stack"
class={errors.stackName ? 'border-destructive focus-visible:ring-destructive' : ''}
oninput={() => errors.stackName = undefined}
/>
{#if errors.stackName}
<p class="text-xs text-destructive">{errors.stackName}</p>
{/if}
</div>
</div>
</div>
{/if}
<!-- File location needed banner -->
{#if mode === 'edit' && needsFileLocation}
<div class="px-4 py-3 border-b border-zinc-200 dark:border-zinc-700 bg-amber-50/50 dark:bg-amber-950/20">
<div class="flex items-start gap-3">
<AlertCircle class="w-4 h-4 shrink-0 text-amber-500 mt-0.5" />
<div class="flex-1 min-w-0">
<p class="text-sm text-zinc-600 dark:text-zinc-400 mb-2">
<span class="font-medium text-amber-800 dark:text-amber-300">Untracked stack.</span> Select the compose file location to start managing this stack with Dockhand.
</p>
{#if stackContainers.length > 0}
<div class="text-xs text-zinc-500 dark:text-zinc-400">
<span class="font-medium text-zinc-700 dark:text-zinc-300">Running containers:</span>
<div class="mt-1.5 flex flex-wrap gap-1.5">
{#each stackContainers as container}
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs {container.state === 'running' ? 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300' : 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400'}">
<Box class="w-3 h-3" />
{container.name}
</span>
{/each}
</div>
</div>
{/if}
</div>
</div>
</div>
{/if}
<!-- Content area -->
<div bind:this={containerRef} class="flex-1 min-h-0 flex flex-col {isDraggingSplit ? 'select-none' : ''}">
{#if activeTab === 'editor'}
<!-- Path bars row -->
<div class="flex border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/30">
<!-- Compose path -->
<div class="flex-shrink-0 px-4 py-2" style="width: {splitRatio}%">
<PathBarItem
label="Compose file"
path={workingComposePath || null}
placeholder="/path/to/compose.yaml"
copied={composePathCopied}
onCopy={() => copyToClipboard(workingComposePath, (v) => composePathCopied = v)}
onBrowse={openComposeBrowser}
onChangeLocation={mode === 'edit' && !needsFileLocation ? openChangeLocationBrowser : undefined}
defaultText={mode === 'create' ? 'Enter stack name above' : 'Not specified'}
sourceHint={pathSourceHint}
/>
</div>
<!-- Divider spacer -->
<div class="w-1 flex-shrink-0"></div>
<!-- Env path -->
<div class="flex-1 min-w-0 px-4 py-2 bg-zinc-100/50 dark:bg-zinc-800/50">
<PathBarItem
label="Env file"
path={displayEnvPath || null}
selectedPath={workingEnvPath || suggestedEnvPath || ''}
placeholder="/path/to/.env (optional)"
copied={envPathCopied}
onCopy={() => copyToClipboard(displayEnvPath, (v) => envPathCopied = v)}
onBrowse={openEnvBrowser}
isEditable={true}
isCustom={!!workingEnvPath}
defaultText={mode === 'create' ? 'Enter stack name above' : 'Not specified'}
isSuggested={isEnvPathSuggested}
onPathChange={(value) => {
workingEnvPath = value;
isDirty = true;
}}
/>
</div>
</div>
<!-- Editor panels row -->
<div class="flex-1 min-h-0 flex">
<!-- Compose editor panel -->
<div class="flex-shrink-0 flex flex-col min-w-0" style="width: {splitRatio}%">
{#if open}
<div class="flex-1 p-3 min-h-0">
{#if needsFileLocation && !composeContent}
<!-- Empty state for untracked stacks -->
<div class="h-full rounded-md border border-dashed border-zinc-300 dark:border-zinc-600 bg-zinc-50 dark:bg-zinc-800/30 flex flex-col items-center justify-center text-center px-8">
<FolderOpen class="w-12 h-12 text-zinc-300 dark:text-zinc-600 mb-4" />
<h3 class="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">No compose file selected</h3>
<p class="text-xs text-zinc-500 dark:text-zinc-400 mb-4 max-w-sm">
Browse to locate the compose file for this stack. The editor will load the file contents once selected.
</p>
<Button variant="outline" size="sm" onclick={openComposeBrowser}>
<FolderOpen class="w-4 h-4 mr-2" />
Browse for compose file
</Button>
<!-- Info box explaining what happens -->
<div class="mt-6 max-w-md flex items-start gap-2.5 text-xs bg-zinc-100 dark:bg-zinc-800/50 border border-zinc-200 dark:border-zinc-700 rounded-md px-3 py-2.5 text-left">
<Info class="w-4 h-4 text-amber-500 shrink-0 mt-0.5" />
<span><span class="font-medium text-amber-600 dark:text-amber-400">What happens when you select a file:</span> <span class="text-zinc-600 dark:text-zinc-400">Dockhand will track this compose file, letting you edit, start, and stop the stack from the UI. Your files stay in their current location.</span></span>
</div>
</div>
{:else}
<CodeEditor
bind:this={codeEditorRef}
value={composeContent}
language="yaml"
theme={editorTheme}
onchange={handleComposeChange}
variableMarkers={variableMarkers}
class="h-full rounded-md overflow-hidden border border-zinc-200 dark:border-zinc-700"
/>
{/if}
</div>
{/if}
</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>
<!-- Environment variables panel -->
<div class="flex-1 min-w-0 flex flex-col overflow-hidden bg-zinc-50 dark:bg-zinc-800/50">
<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 and passed to the compose command."
/>
</div>
</div>
{:else if activeTab === 'graph'}
<!-- Graph tab: Full width -->
<ComposeGraphViewer
bind:this={graphViewerRef}
composeContent={composeContent || defaultCompose}
class="h-full flex-1"
onContentChange={handleGraphContentChange}
/>
{/if}
</div>
{/if}
</div>
<!-- Footer -->
<div class="px-5 py-2.5 border-t border-zinc-200 dark:border-zinc-700 flex items-center justify-between flex-shrink-0">
<div class="text-xs text-zinc-500 dark:text-zinc-400">
{#if isDirty}
<span class="text-amber-600 dark:text-amber-500">Unsaved changes</span>
{:else}
No changes
{/if}
</div>
<div class="flex items-center gap-2">
<Button variant="outline" onclick={tryClose} disabled={saving}>
Cancel
</Button>
{#if mode === 'create'}
<!-- Create mode buttons -->
<Button variant="outline" onclick={() => handleCreate(false)} disabled={saving}>
{#if saving}
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
Creating...
{:else}
<Save class="w-4 h-4 mr-2" />
Create
{/if}
</Button>
<Button onclick={() => handleCreate(true)} disabled={saving}>
{#if saving}
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
Starting...
{:else}
<Play class="w-4 h-4 mr-2" />
Create & Start
{/if}
</Button>
{:else}
<!-- Edit mode buttons -->
<Button variant="outline" class="w-24" onclick={() => handleSave(false)} disabled={saving || loading || (needsFileLocation && !workingComposePath.trim())}>
{#if saving && !savingWithRestart}
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
Saving...
{:else}
<Save class="w-4 h-4 mr-2" />
Save
{/if}
</Button>
<Button class="w-36" onclick={() => handleSave(true)} disabled={saving || loading || (needsFileLocation && !workingComposePath.trim())}>
{#if saving && savingWithRestart}
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
Restarting...
{:else}
<Play class="w-4 h-4 mr-2" />
Save & restart
{/if}
</Button>
{/if}
</div>
</div>
</Dialog.Content>
</Dialog.Root>
<!-- Unsaved changes confirmation dialog -->
<Dialog.Root bind:open={showConfirmClose}>
<Dialog.Content class="max-w-sm">
<Dialog.Header>
<Dialog.Title>Unsaved changes</Dialog.Title>
<Dialog.Description>
You have unsaved changes. Are you sure you want to close without saving?
</Dialog.Description>
</Dialog.Header>
<div class="flex justify-end gap-1.5 mt-4">
<Button variant="outline" size="sm" onclick={() => showConfirmClose = false}>
Continue editing
</Button>
<Button variant="destructive" size="sm" onclick={discardAndClose}>
Discard changes
</Button>
</div>
</Dialog.Content>
</Dialog.Root>
<!-- Path change confirmation dialog -->
<Dialog.Root bind:open={showPathChangeConfirm}>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title>Move stack files?</Dialog.Title>
<Dialog.Description>
You've changed the stack location. There {pathChangeFileCount === 1 ? 'is' : 'are'} {pathChangeFileCount} file{pathChangeFileCount === 1 ? '' : 's'} in the old location that can be moved to the new location.
</Dialog.Description>
</Dialog.Header>
{#if pathChangeOldDir}
<div class="my-3 text-sm">
<div class="flex items-center gap-2 text-muted-foreground font-mono text-xs bg-muted/50 px-2 py-1 rounded">
<FolderOpen class="w-3.5 h-3.5 shrink-0 text-amber-500" />
{pathChangeOldDir}
</div>
</div>
{/if}
<p class="text-sm text-muted-foreground">
Would you like to move all files to the new location, or leave them in place?
</p>
<div class="flex justify-end gap-1.5 mt-4">
<Button variant="outline" size="sm" onclick={() => showPathChangeConfirm = false}>
Cancel
</Button>
<Button variant="secondary" size="sm" onclick={confirmPathChangeKeepFiles}>
Leave files
</Button>
<Button variant="default" size="sm" onclick={confirmPathChangeAndMove}>
<ArrowRight class="w-3.5 h-3.5 mr-1" />
Move files
</Button>
</div>
</Dialog.Content>
</Dialog.Root>
<!-- Browse confirmation dialog (when selecting different file would replace content) -->
<Dialog.Root bind:open={showBrowseConfirm}>
<Dialog.Content class="max-w-lg">
<Dialog.Header>
<Dialog.Title>Replace editor content?</Dialog.Title>
<Dialog.Description>
Loading a different compose file will replace the current editor content.
</Dialog.Description>
</Dialog.Header>
<div class="my-3 space-y-2 text-sm">
<div class="flex items-start gap-2 text-muted-foreground">
<span class="text-xs font-medium text-zinc-500 shrink-0 pt-0.5">Current:</span>
<code class="text-xs font-mono bg-muted px-1.5 py-0.5 rounded break-all">
{workingComposePath || '(unsaved)'}
</code>
</div>
<div class="flex items-start gap-2">
<ArrowRight class="w-3.5 h-3.5 text-amber-500 shrink-0 mt-0.5" />
<span class="text-xs font-medium text-zinc-500 shrink-0 pt-0.5">New:</span>
<code class="text-xs font-mono bg-muted px-1.5 py-0.5 rounded break-all">
{pendingBrowsePath}
</code>
</div>
</div>
<div class="flex justify-end gap-1.5 mt-4">
<Button variant="outline" size="sm" onclick={cancelBrowseConfirm}>
Cancel
</Button>
<Button variant="default" size="sm" onclick={confirmBrowseAndLoad}>
Replace content
</Button>
</div>
</Dialog.Content>
</Dialog.Root>
<!-- Change location confirmation dialog -->
<Dialog.Root bind:open={showChangeLocationConfirm}>
<Dialog.Content class="max-w-lg">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<FolderSync class="w-5 h-5" />
Relocate stack?
</Dialog.Title>
<Dialog.Description>
All {changeLocationFileCount} file{changeLocationFileCount === 1 ? '' : 's'} in the stack folder will be moved.
</Dialog.Description>
</Dialog.Header>
<div class="my-3 space-y-1 text-sm">
<div class="flex items-start gap-2 text-muted-foreground">
<span class="text-xs font-medium text-zinc-500 shrink-0 w-10">From</span>
<code class="text-xs font-mono bg-muted px-1.5 py-0.5 rounded break-all">
{changeLocationOldDir}
</code>
</div>
<div class="flex justify-center py-3">
<ArrowDown class="w-4 h-4 text-amber-500" />
</div>
<div class="flex items-start gap-2">
<span class="text-xs font-medium text-zinc-500 shrink-0 w-10">To</span>
<code class="text-xs font-mono bg-muted px-1.5 py-0.5 rounded break-all">
{pendingNewLocation}
</code>
</div>
</div>
<div class="flex justify-end gap-1.5 mt-4">
<Button variant="outline" size="sm" onclick={cancelChangeLocation} disabled={movingLocation}>
Cancel
</Button>
<Button variant="default" size="sm" onclick={confirmChangeLocation} disabled={movingLocation}>
{#if movingLocation}
<Loader2 class="w-3.5 h-3.5 mr-1.5 animate-spin" />
Moving...
{:else}
<FolderSync class="w-3.5 h-3.5 mr-1" />
Move files
{/if}
</Button>
</div>
</Dialog.Content>
</Dialog.Root>
<!-- Stack already exists warning dialog -->
<Dialog.Root bind:open={showExistsWarning}>
<Dialog.Content class="max-w-sm">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<TriangleAlert class="w-5 h-5 text-amber-500" />
Stack already exists
</Dialog.Title>
<Dialog.Description>
A stack named "{newStackName}" already exists. Please choose a different name.
</Dialog.Description>
</Dialog.Header>
<div class="flex justify-end mt-4">
<Button size="sm" onclick={() => showExistsWarning = false}>
OK
</Button>
</div>
</Dialog.Content>
</Dialog.Root>
<!-- Error dialog for failed operations -->
{#if operationError}
{@const errorDialogOpen = true}
<ErrorDialog
open={errorDialogOpen}
title={operationError.title}
message={operationError.message}
details={operationError.details}
onClose={() => operationError = null}
/>
{/if}
<!-- File browser for compose/env/location selection -->
<FilesystemBrowser
bind:open={showFileBrowser}
title={fileBrowserConfig.title}
icon={fileBrowserConfig.icon}
selectFilter={fileBrowserConfig.selectFilter}
selectMode={fileBrowserConfig.selectMode}
onSelect={fileBrowserConfig.onSelect}
onClose={() => showFileBrowser = false}
/>