import { useRef, useState, type FormEvent, type KeyboardEvent } from 'react'; import { Plus, GitBranch, FileCode2, FolderSearch, Loader2, type LucideIcon } from 'lucide-react'; import { Modal, ModalHeader, ModalBody, ModalFooter } from '../ui/modal'; import { Button } from '../ui/button'; import { Input } from '../ui/input'; import { Label } from '../ui/label'; import { ScrollArea } from '../ui/scroll-area'; import { Checkbox } from '../ui/checkbox'; import { GitSourceFields, type ApplyMode } from '../stack/GitSourceFields'; import type { GitBrowseResult } from '../stack/GitComposeFilePicker'; import { ImportStackPanel } from './ImportStackPanel'; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; import { useNodes } from '@/context/NodeContext'; import { cn } from '@/lib/utils'; export interface CreateStackDialogProps { open: boolean; onOpenChange: (open: boolean) => void; // sourceNodeId is the active node ID captured at the moment the user clicked // Create. Parent compares against the current active node before navigating // so a mid-flight node switch does not land the user on a 404. onStackCreated: (stackName: string, sourceNodeId: number | null | undefined) => void | Promise; onStacksChanged: () => void | Promise; // Mode the dialog opens on. The empty-state entry opens directly on 'import'; // the toolbar Create button opens on 'empty'. initialMode?: CreateMode; } export type CreateMode = 'import' | 'empty' | 'git' | 'docker-run'; const MODES: ReadonlyArray<{ id: CreateMode; label: string; icon: LucideIcon }> = [ { id: 'import', label: 'Import', icon: FolderSearch }, { id: 'empty', label: 'Empty', icon: Plus }, { id: 'git', label: 'From Git', icon: GitBranch }, { id: 'docker-run', label: 'From Docker Run', icon: FileCode2 }, ]; const tabId = (m: CreateMode) => `create-stack-tab-${m}`; const panelId = (m: CreateMode) => `create-stack-panel-${m}`; export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacksChanged, initialMode = 'empty' }: CreateStackDialogProps) { const { activeNode } = useNodes(); const [createMode, setCreateMode] = useState(initialMode); // Reset to the requested starting mode each time the dialog opens (empty for // the toolbar button, import for the empty-state entry). Tracked during render // via a previous-open sentinel rather than an effect, the pattern React // recommends for resetting state in response to a prop change. const [prevOpen, setPrevOpen] = useState(open); if (open !== prevOpen) { setPrevOpen(open); if (open) setCreateMode(initialMode); } const [newStackName, setNewStackName] = useState(''); // Synchronous guard. The disabled-button + setState pair can race a rapid // second click that lands before React has committed the disabled state, // re-entering the handler with a stale closure value of creatingEmpty and // firing a second POST. The ref check is read/written synchronously inside // the handler so the second invocation bails before issuing another POST. const creatingEmptyRef = useRef(false); const [creatingEmpty, setCreatingEmpty] = useState(false); const [dockerRunInput, setDockerRunInput] = useState(''); const [convertedYaml, setConvertedYaml] = useState(null); const [isConverting, setIsConverting] = useState(false); const [creatingFromDockerRun, setCreatingFromDockerRun] = useState(false); const [gitRepoUrl, setGitRepoUrl] = useState(''); const [gitBranch, setGitBranch] = useState('main'); const [gitComposePaths, setGitComposePaths] = useState(['compose.yaml']); const [gitContextDir, setGitContextDir] = useState(''); const [gitSyncEnv, setGitSyncEnv] = useState(false); const [gitAuthType, setGitAuthType] = useState<'none' | 'token'>('none'); const [gitToken, setGitToken] = useState(''); const [gitApplyMode, setGitApplyMode] = useState('review'); const [gitDeployNow, setGitDeployNow] = useState(false); const [creatingFromGit, setCreatingFromGit] = useState(false); const resetCreateFromGitForm = () => { setNewStackName(''); setGitRepoUrl(''); setGitBranch('main'); setGitComposePaths(['compose.yaml']); setGitContextDir(''); setGitSyncEnv(false); setGitAuthType('none'); setGitToken(''); setGitApplyMode('review'); setGitDeployNow(false); }; const browseGitRepo = async (): Promise => { if (!gitRepoUrl.trim() || !gitBranch.trim()) { toast.error('Enter a repository URL and branch first.'); return null; } try { const body: Record = { repo_url: gitRepoUrl.trim(), branch: gitBranch.trim(), auth_type: gitAuthType, }; if (gitAuthType === 'token' && gitToken !== '') body.token = gitToken; const res = await apiFetch('/git-sources/browse', { method: 'POST', body: JSON.stringify(body), }); if (res.ok) { const data = await res.json(); return { files: data.files ?? [], truncated: data.truncated ?? false }; } const err = await res.json().catch(() => ({})); toast.error(err?.error || 'Failed to browse repository.'); return null; } catch (e) { toast.error((e as Error)?.message || 'Network error.'); return null; } }; const resetCreateFromDockerRunForm = () => { setDockerRunInput(''); setConvertedYaml(null); setIsConverting(false); setCreatingFromDockerRun(false); }; const handleCreateStack = async () => { if (creatingEmptyRef.current) return; if (!newStackName.trim()) return; const stackName = newStackName.trim(); const sourceNodeId = activeNode?.id; creatingEmptyRef.current = true; setCreatingEmpty(true); try { const response = await apiFetch('/stacks', { method: 'POST', body: JSON.stringify({ stackName }), }); if (!response.ok) { if (response.status === 409) { throw new Error('Stack already exists.'); } if (response.status === 400) { throw new Error('Invalid stack name (use alphanumeric characters and hyphens only).'); } const body = await response.json().catch(() => ({})); const backendError = (body as { error?: string })?.error; if (response.status === 403) { throw new Error(backendError || 'You do not have permission to create stacks.'); } throw new Error(backendError || 'Failed to create stack.'); } onOpenChange(false); setNewStackName(''); setCreateMode('empty'); toast.success(`Stack "${stackName}" created.`); await onStackCreated(stackName, sourceNodeId); } catch (error) { console.error('Failed to create stack:', error); toast.error((error as Error).message || 'Failed to create stack.'); } finally { creatingEmptyRef.current = false; setCreatingEmpty(false); } }; const handleEmptyFormSubmit = (e: FormEvent) => { e.preventDefault(); void handleCreateStack(); }; const handleCreateStackFromGit = async () => { const stackName = newStackName.trim(); if (!stackName) { toast.error('Stack name is required.'); return; } if (!gitRepoUrl.trim() || !gitBranch.trim() || gitComposePaths.length === 0) { toast.error('Repository URL, branch, and at least one compose file are required.'); return; } if (!/^https:\/\//i.test(gitRepoUrl.trim())) { toast.error('Only HTTPS repository URLs are supported.'); return; } const sourceNodeId = activeNode?.id; setCreatingFromGit(true); const loadingId = toast.loading(gitDeployNow ? 'Fetching, creating, and deploying...' : 'Fetching and creating stack...'); try { const autoApply = gitApplyMode !== 'review'; const autoDeploy = gitApplyMode === 'auto-deploy'; const body: Record = { stack_name: stackName, repo_url: gitRepoUrl.trim(), branch: gitBranch.trim(), compose_paths: gitComposePaths, context_dir: gitContextDir.trim() || null, sync_env: gitSyncEnv, auth_type: gitAuthType, auto_apply_on_webhook: autoApply, auto_deploy_on_apply: autoDeploy, deploy_now: gitDeployNow, }; if (gitAuthType === 'token' && gitToken !== '') { body.token = gitToken; } const response = await apiFetch('/stacks/from-git', { method: 'POST', body: JSON.stringify(body), }); if (!response.ok) { const err = await response.json().catch(() => ({})); if (response.status === 409) { throw new Error(err?.error || 'Stack already exists.'); } throw new Error(err?.error || 'Failed to create stack from Git.'); } const data: { deployed?: boolean; deployError?: string; commitSha?: string; warnings?: string[]; } = await response.json(); const shortSha = typeof data.commitSha === 'string' ? data.commitSha.slice(0, 7) : ''; const shaSuffix = shortSha ? ` @ ${shortSha}` : ''; if (gitDeployNow && data.deployError) { toast.warning(`Stack created${shaSuffix}, but deploy failed: ${data.deployError}`); } else if (gitDeployNow && data.deployed) { toast.success(`Stack created and deployed from Git${shaSuffix}.`); } else { toast.success(`Stack created from Git${shaSuffix}.`); } if (Array.isArray(data.warnings) && data.warnings.length > 0) { toast.warning(data.warnings.join(' ')); } onOpenChange(false); resetCreateFromGitForm(); await onStackCreated(stackName, sourceNodeId); } catch (error) { console.error('Failed to create stack from Git:', error); toast.error((error as Error)?.message || 'Failed to create stack from Git.'); } finally { toast.dismiss(loadingId); setCreatingFromGit(false); } }; const handleConvertDockerRun = async () => { const command = dockerRunInput.trim(); if (!command) { toast.error('Paste a docker run command first.'); return; } setIsConverting(true); try { const response = await apiFetch('/convert', { method: 'POST', body: JSON.stringify({ dockerRun: command }), }); const data = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(data?.error || 'Could not parse command.'); } if (typeof data?.yaml !== 'string' || data.yaml.length === 0) { throw new Error('Converter returned an empty result.'); } setConvertedYaml(data.yaml); toast.success('Converted to compose YAML.'); } catch (error) { setConvertedYaml(null); const err = error as { message?: string; error?: string; data?: { error?: string } }; toast.error( err?.message || err?.error || err?.data?.error || 'Failed to convert docker run command.', ); } finally { setIsConverting(false); } }; const handleCreateStackFromDockerRun = async () => { const stackName = newStackName.trim(); if (!stackName) { toast.error('Stack name is required.'); return; } if (!convertedYaml) { toast.error('Convert the command before creating the stack.'); return; } const sourceNodeId = activeNode?.id; setCreatingFromDockerRun(true); const loadingId = toast.loading('Creating stack from converted YAML...'); let createdStack = false; try { const createResponse = await apiFetch('/stacks', { method: 'POST', body: JSON.stringify({ stackName }), }); if (!createResponse.ok) { if (createResponse.status === 409) { throw new Error('Stack already exists.'); } if (createResponse.status === 400) { throw new Error('Invalid stack name (use alphanumeric characters and hyphens only).'); } throw new Error('Failed to create stack.'); } createdStack = true; const saveResponse = await apiFetch(`/stacks/${encodeURIComponent(stackName)}`, { method: 'PUT', body: JSON.stringify({ content: convertedYaml }), }); if (!saveResponse.ok) { // Roll back the empty stack we just created so we don't leave an orphan. await apiFetch(`/stacks/${encodeURIComponent(stackName)}`, { method: 'DELETE' }).catch((cleanupError) => { console.error('Failed to roll back orphan stack after save failure:', cleanupError); }); createdStack = false; throw new Error('Could not save the converted YAML. Please try again.'); } toast.success(`Stack "${stackName}" created from docker run.`); onOpenChange(false); resetCreateFromDockerRunForm(); setNewStackName(''); await onStackCreated(stackName, sourceNodeId); } catch (error) { console.error('Failed to create stack from docker run:', error); const err = error as { message?: string; error?: string; data?: { error?: string } }; toast.error( err?.message || err?.error || err?.data?.error || 'Failed to create stack from docker run.', ); // If we bailed before the createdStack flag got reset, surface that the stack still exists. if (createdStack) { await Promise.resolve(onStacksChanged()).catch(() => undefined); } } finally { toast.dismiss(loadingId); setCreatingFromDockerRun(false); } }; const busy = creatingEmpty || creatingFromGit || creatingFromDockerRun; return ( { onOpenChange(o); if (!o) { setCreateMode(initialMode); resetCreateFromGitForm(); resetCreateFromDockerRunForm(); // Intentionally NOT resetting creatingEmptyRef / creatingEmpty // here: the in-flight POST owns its own lifecycle and clears // both flags in finally. Resetting on close would let an // Escape-then-reopen sequence slip past the guard and fire a // second POST before the first request settled. } }} > {createMode === 'import' && (
onOpenChange(false)} onImported={() => { void onStacksChanged(); }} />
)} {createMode === 'empty' && (
setNewStackName(e.target.value)} disabled={creatingEmpty} autoFocus />
onOpenChange(false)} disabled={creatingEmpty} > Cancel } primary={ } />
)} {createMode === 'git' && (
setNewStackName(e.target.value)} disabled={creatingFromGit} />
setGitDeployNow(c === true)} disabled={creatingFromGit} />
onOpenChange(false)} disabled={creatingFromGit}> Cancel } primary={ } />
)} {createMode === 'docker-run' && (
setNewStackName(e.target.value)} disabled={creatingFromDockerRun} />