From 71b7a52def1e6bfb7c8fe47c727e1edaeaa3cc55 Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 3 May 2026 12:54:37 -0400 Subject: [PATCH] refactor(frontend): extract CreateStackDialog from EditorLayout (#895) Continues the EditorLayout decomposition (B4-2). Pulls the inline three-tab Create Stack dialog (Empty / From Git / From Docker Run) out of EditorLayout into EditorLayout/CreateStackDialog.tsx. Parent now owns only the open boolean and a domain callback pair; all 14 form-state vars and 6 handlers move into the child. The slot renders a thin trigger button plus the new dialog. Metrics: - EditorLayout.tsx: 3,266 -> 2,843 LOC - new CreateStackDialog.tsx: 463 LOC (under the 500 ceiling) - useState count in EditorLayout: 81 -> 66 --- frontend/src/components/EditorLayout.tsx | 465 +----------------- .../EditorLayout/CreateStackDialog.tsx | 462 +++++++++++++++++ 2 files changed, 483 insertions(+), 444 deletions(-) create mode 100644 frontend/src/components/EditorLayout/CreateStackDialog.tsx diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 138bda13..50b43ebf 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -9,13 +9,11 @@ import type { NotificationItem } from './dashboard/types'; import BashExecModal from './BashExecModal'; import LazyBoundary from './LazyBoundary'; import { Button } from './ui/button'; -import { Input } from './ui/input'; -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter, DialogTrigger } from './ui/dialog'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from './ui/alert-dialog'; import { Tabs, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from './ui/tabs'; import { springs } from '@/lib/motion'; import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; -import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, MoreVertical, Rocket, HardDrive, ScrollText, Activity, Radar, Undo2, RefreshCw, Clock, Loader2, Check, ChevronDown, GitBranch, FileCode2, ShieldCheck, ArrowUpRight, Copy, FolderOpen } from 'lucide-react'; +import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, MoreVertical, Rocket, HardDrive, ScrollText, Activity, Radar, Undo2, RefreshCw, Clock, Loader2, Check, ChevronDown, GitBranch, ShieldCheck, ArrowUpRight, Copy, FolderOpen } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { type Label as StackLabel, type LabelColor } from './label-types'; import { UserProfileDropdown } from './UserProfileDropdown'; @@ -23,10 +21,7 @@ import { NotificationPanel } from './NotificationPanel'; import { apiFetch, fetchForNode } from '@/lib/api'; import { copyToClipboard } from '@/lib/clipboard'; import { toast } from '@/components/ui/toast-store'; -import { Label } from './ui/label'; -import { ScrollArea } from './ui/scroll-area'; import { Checkbox } from './ui/checkbox'; -import { GitSourceFields, type ApplyMode } from './stack/GitSourceFields'; import { PolicyBlockDialog, type PolicyBlockPayload } from './stack/PolicyBlockDialog'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from './ui/dropdown-menu'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; @@ -34,6 +29,7 @@ import { TopBar } from './TopBar'; import { cn } from '@/lib/utils'; import type { SectionId } from './settings/types'; import { ViewRouter } from './EditorLayout/ViewRouter'; +import { CreateStackDialog } from './EditorLayout/CreateStackDialog'; import { StackAlertSheet } from './StackAlertSheet'; import { StackAutoHealSheet } from '@/components/StackAutoHealSheet'; import { GitSourcePanel } from './stack/GitSourcePanel'; @@ -256,24 +252,7 @@ export default function EditorLayout() { const pendingStackLoadRef = useRef(null); const pendingLogsRef = useRef<{ stackName: string; containerName: string } | null>(null); const [createDialogOpen, setCreateDialogOpen] = useState(false); - const [createMode, setCreateMode] = useState<'empty' | 'git' | 'docker-run'>('empty'); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [newStackName, setNewStackName] = useState(''); - // "From Docker Run" tab state - const [dockerRunInput, setDockerRunInput] = useState(''); - const [convertedYaml, setConvertedYaml] = useState(null); - const [isConverting, setIsConverting] = useState(false); - const [creatingFromDockerRun, setCreatingFromDockerRun] = useState(false); - // "From Git" tab state - const [gitRepoUrl, setGitRepoUrl] = useState(''); - const [gitBranch, setGitBranch] = useState('main'); - const [gitComposePath, setGitComposePath] = useState('compose.yaml'); - 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 [stackToDelete, setStackToDelete] = useState(null); const [pruneVolumesOnDelete, setPruneVolumesOnDelete] = useState(false); const [pendingUnsavedLoad, setPendingUnsavedLoad] = useState(null); @@ -1708,230 +1687,6 @@ export default function EditorLayout() { } }; - const handleCreateStack = async () => { - if (!newStackName.trim()) return; - // Send stackName directly (no .yml extension - backend creates directory) - const stackName = newStackName.trim(); - 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'); - } else if (response.status === 400) { - throw new Error('Invalid stack name (use alphanumeric characters and hyphens only)'); - } - throw new Error('Failed to create stack'); - } - setCreateDialogOpen(false); - setNewStackName(''); - await refreshStacks(); - // Auto-load the new stack in the editor pane - await loadFile(stackName); - } catch (error) { - console.error('Failed to create stack:', error); - toast.error((error as Error).message || 'Failed to create stack'); - } - }; - - const resetCreateFromGitForm = () => { - setNewStackName(''); - setGitRepoUrl(''); - setGitBranch('main'); - setGitComposePath('compose.yaml'); - setGitSyncEnv(false); - setGitAuthType('none'); - setGitToken(''); - setGitApplyMode('review'); - setGitDeployNow(false); - }; - - const handleCreateStackFromGit = async () => { - const stackName = newStackName.trim(); - if (!stackName) { - toast.error('Stack name is required.'); - return; - } - if (!gitRepoUrl.trim() || !gitBranch.trim() || !gitComposePath.trim()) { - toast.error('Repository URL, branch, and compose path are required.'); - return; - } - if (!/^https:\/\//i.test(gitRepoUrl.trim())) { - toast.error('Only HTTPS repository URLs are supported.'); - return; - } - 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_path: gitComposePath.trim(), - 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(' ')); - } - setCreateDialogOpen(false); - resetCreateFromGitForm(); - await refreshStacks(); - await loadFile(stackName); - } 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 resetCreateFromDockerRunForm = () => { - setDockerRunInput(''); - setConvertedYaml(null); - setIsConverting(false); - setCreatingFromDockerRun(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; - } - 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.`); - setCreateDialogOpen(false); - resetCreateFromDockerRunForm(); - setNewStackName(''); - await refreshStacks(); - await loadFile(stackName); - } 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 refreshStacks().catch(() => undefined); - } - } finally { - toast.dismiss(loadingId); - setCreatingFromDockerRun(false); - } - }; - const openBashModal = (containerId: string, containerName: string) => { setSelectedContainer({ id: containerId, name: containerName }); setBashModalOpen(true); @@ -2183,203 +1938,25 @@ export default function EditorLayout() { ]); const createStackSlot = can('stack:create') ? ( - { - setCreateDialogOpen(o); - if (!o) { - setCreateMode('empty'); - resetCreateFromGitForm(); - resetCreateFromDockerRunForm(); - } - }}> - - - - - - Create New Stack - - Create a new stack: empty, cloned from a Git repository, or converted from a docker run command. - - - -
- setCreateMode(v as 'empty' | 'git' | 'docker-run')}> - - - - - - Empty - - - - - - From Git - - - - - - From Docker Run - - - - - -
- - {createMode === 'empty' && ( - <> -
- - setNewStackName(e.target.value)} - /> -
- - - - - )} - {createMode === 'git' && ( - <> - -
-
- - setNewStackName(e.target.value)} - disabled={creatingFromGit} - /> -
- - - -
- setGitDeployNow(c === true)} - disabled={creatingFromGit} - /> - -
-
-
- - - - - )} - {createMode === 'docker-run' && ( - <> - -
-
- - setNewStackName(e.target.value)} - disabled={creatingFromDockerRun} - /> -
-
- -