diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 1fd43290..cc6c5e42 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -9,7 +9,6 @@ import type { NotificationItem } from './dashboard/types'; import BashExecModal from './BashExecModal'; import LazyBoundary from './LazyBoundary'; import { Button } from './ui/button'; -import { ConfirmModal } from './ui/modal'; import { Tabs, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from './ui/tabs'; import { springs } from '@/lib/motion'; import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; @@ -21,7 +20,6 @@ import { NotificationPanel } from './NotificationPanel'; import { apiFetch, fetchForNode } from '@/lib/api'; import { copyToClipboard } from '@/lib/clipboard'; import { toast } from '@/components/ui/toast-store'; -import { Checkbox } from './ui/checkbox'; 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'; @@ -30,6 +28,8 @@ import { cn } from '@/lib/utils'; import type { SectionId } from './settings/types'; import { ViewRouter } from './EditorLayout/ViewRouter'; import { CreateStackDialog } from './EditorLayout/CreateStackDialog'; +import { DeleteStackDialog } from './EditorLayout/DeleteStackDialog'; +import { UnsavedChangesDialog } from './EditorLayout/UnsavedChangesDialog'; import { StackAlertSheet } from './StackAlertSheet'; import { StackAutoHealSheet } from '@/components/StackAutoHealSheet'; import { GitSourcePanel } from './stack/GitSourcePanel'; @@ -98,13 +98,6 @@ interface StackStatusInfo { type StackAction = 'deploy' | 'stop' | 'restart' | 'update' | 'delete' | 'rollback'; -interface BulkActionResult { - stackName: string; - success: boolean; - error?: string; -} - - const formatBytes = (bytes: number) => { if (bytes === 0) return '0 B'; const k = 1024; @@ -254,7 +247,6 @@ export default function EditorLayout() { const [createDialogOpen, setCreateDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [stackToDelete, setStackToDelete] = useState(null); - const [pruneVolumesOnDelete, setPruneVolumesOnDelete] = useState(false); const [pendingUnsavedLoad, setPendingUnsavedLoad] = useState(null); const [pendingUnsavedNode, setPendingUnsavedNode] = useState(null); const [isLoading, setIsLoading] = useState(false); @@ -329,12 +321,6 @@ export default function EditorLayout() { const [stackPorts, setStackPorts] = useState>({}); const [labels, setLabels] = useState([]); const [stackLabelMap, setStackLabelMap] = useState>({}); - // Bulk-action dialog is retained as a safety fallback; the label pill entry - // point that drove the setters was removed alongside the sidebar rewrite. - const [bulkActionLabel] = useState(null); - const [bulkAction] = useState(''); - const [bulkActionOpen, setBulkActionOpen] = useState(false); - const [bulkActionRunning, setBulkActionRunning] = useState(false); // Bash exec modal state const [bashModalOpen, setBashModalOpen] = useState(false); @@ -1572,14 +1558,14 @@ export default function EditorLayout() { } }; - const deleteStack = async () => { + const deleteStack = async (pruneVolumes: boolean) => { if (!stackToDelete) return; // Find matching file entry for per-stack tracking const deleteKey = files.find(f => f === stackToDelete || f.replace(/\.(yml|yaml)$/, '') === stackToDelete) ?? stackToDelete; if (isStackBusy(deleteKey)) return; setStackAction(deleteKey, 'delete'); try { - const url = pruneVolumesOnDelete + const url = pruneVolumes ? `/stacks/${stackToDelete}?pruneVolumes=true` : `/stacks/${stackToDelete}`; const response = await apiFetch(url, { @@ -1592,7 +1578,6 @@ export default function EditorLayout() { toast.success('Stack deleted successfully!'); setDeleteDialogOpen(false); setStackToDelete(null); - setPruneVolumesOnDelete(false); if (selectedFile === stackToDelete) { setSelectedFile(null); setContent(''); @@ -1612,40 +1597,21 @@ export default function EditorLayout() { } }; - const bulkAffected = useMemo(() => { - if (!bulkActionLabel) return []; - return Object.entries(stackLabelMap) - .filter(([, ls]) => ls.some(l => l.id === bulkActionLabel.id)) - .map(([name]) => name); - }, [stackLabelMap, bulkActionLabel]); + const cancelPendingUnsavedLoad = () => { + setPendingUnsavedLoad(null); + setPendingUnsavedNode(null); + }; - const runLabelBulkAction = async () => { - if (!bulkActionLabel) return; - setBulkActionRunning(true); - try { - const res = await apiFetch(`/labels/${bulkActionLabel.id}/action`, { - method: 'POST', - body: JSON.stringify({ action: bulkAction }), - }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data?.error || `Bulk ${bulkAction} failed.`); - } - const data = await res.json(); - const failed = (data.results ?? []).filter((r: BulkActionResult) => !r.success); - if (failed.length > 0) { - const failedNames = failed.map((r: BulkActionResult) => r.stackName).join(', '); - toast.error(`Failed to ${bulkAction}: ${failedNames}`); - } else { - const successVerb = bulkAction === 'deploy' ? 'deployed' : bulkAction === 'stop' ? 'stopped' : 'restarted'; - toast.success(`All stacks ${successVerb} successfully.`); - } - setBulkActionOpen(false); - refreshStacks(true); - } catch (err: unknown) { - toast.error((err as Error)?.message || 'Something went wrong.'); - } finally { - setBulkActionRunning(false); + const discardAndLoadPending = () => { + const target = pendingUnsavedLoad; + const targetNode = pendingUnsavedNode; + setContent(originalContent); + setEnvContent(originalEnvContent); + setPendingUnsavedLoad(null); + setPendingUnsavedNode(null); + if (target) { + if (targetNode) loadFileOnNode(targetNode, target); + else loadFile(target); } }; @@ -1893,7 +1859,7 @@ export default function EditorLayout() { stop: () => executeStackActionByFile(file, 'stop', 'stop'), restart: () => executeStackActionByFile(file, 'restart', 'restart'), update: () => executeStackActionByFile(file, 'update', 'update'), - remove: () => { setStackToDelete(stackName); setPruneVolumesOnDelete(false); setDeleteDialogOpen(true); }, + remove: () => { setStackToDelete(stackName); setDeleteDialogOpen(true); }, pin: () => pin(file), unpin: () => unpin(file), setAutoUpdateEnabled: async (enabled: boolean) => { @@ -2248,7 +2214,6 @@ export default function EditorLayout() { disabled={loadingAction !== null} onClick={() => { setStackToDelete(selectedFile); - setPruneVolumesOnDelete(false); setDeleteDialogOpen(true); }} > @@ -2654,88 +2619,18 @@ export default function EditorLayout() { - - Delete {stackToDelete}? - - ) : ( - 'Delete stack?' - ) - } - description={`Confirm deletion of ${stackToDelete ?? 'stack'}.`} - hint={pruneVolumesOnDelete ? 'VOLUMES PRUNED' : 'VOLUMES KEPT'} - confirmLabel="Delete" + stackName={stackToDelete} onConfirm={deleteStack} - > -

This action cannot be undone.

-
- setPruneVolumesOnDelete(v === true)} - /> - -
-
+ /> - { if (!open) { setPendingUnsavedLoad(null); setPendingUnsavedNode(null); } }} - kicker="EDITOR · UNSAVED CHANGES" - title="Discard unsaved changes?" - description="You have unsaved changes. Switching stacks will discard them." - confirmLabel="Discard changes" - onConfirm={() => { - const target = pendingUnsavedLoad; - const targetNode = pendingUnsavedNode; - setContent(originalContent); - setEnvContent(originalEnvContent); - setPendingUnsavedLoad(null); - setPendingUnsavedNode(null); - if (target) { - if (targetNode) loadFileOnNode(targetNode, target); - else loadFile(target); - } - }} - > -

- You have unsaved changes. Switching stacks will discard them. -

-
- - - {bulkAction.charAt(0).toUpperCase() + bulkAction.slice(1)} all{' '} - “{bulkActionLabel?.name ?? ''}” stacks? - - } - description={`${bulkAction.charAt(0).toUpperCase() + bulkAction.slice(1)} all stacks labeled "${bulkActionLabel?.name ?? ''}".`} - hint={`${bulkAffected.length} AFFECTED`} - confirmLabel={bulkActionRunning ? 'Running...' : `${bulkAction.charAt(0).toUpperCase() + bulkAction.slice(1)} all`} - confirming={bulkActionRunning} - onConfirm={runLabelBulkAction} - > -

- This will {bulkAction || 'apply'} all stacks labeled “{bulkActionLabel?.name ?? ''}”. -

- {bulkAffected.length > 0 && ( -

- Affected: {bulkAffected.join(', ')} -

- )} -
+ onCancel={cancelPendingUnsavedLoad} + onConfirm={discardAndLoadPending} + /> {/* Bash Exec Modal */} {selectedContainer && ( diff --git a/frontend/src/components/EditorLayout/DeleteStackDialog.tsx b/frontend/src/components/EditorLayout/DeleteStackDialog.tsx new file mode 100644 index 00000000..a4a6a18f --- /dev/null +++ b/frontend/src/components/EditorLayout/DeleteStackDialog.tsx @@ -0,0 +1,53 @@ +import { useState } from 'react'; +import { ConfirmModal } from '../ui/modal'; +import { Checkbox } from '../ui/checkbox'; + +export interface DeleteStackDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + stackName: string | null; + onConfirm: (pruneVolumes: boolean) => void | Promise; +} + +export function DeleteStackDialog({ open, onOpenChange, stackName, onConfirm }: DeleteStackDialogProps) { + const [pruneVolumes, setPruneVolumes] = useState(false); + + const handleOpenChange = (next: boolean) => { + if (!next) setPruneVolumes(false); + onOpenChange(next); + }; + + return ( + + Delete {stackName}? + + ) : ( + 'Delete stack?' + ) + } + description={`Confirm deletion of ${stackName ?? 'stack'}.`} + hint={pruneVolumes ? 'VOLUMES PRUNED' : 'VOLUMES KEPT'} + confirmLabel="Delete" + onConfirm={() => onConfirm(pruneVolumes)} + > +

This action cannot be undone.

+
+ setPruneVolumes(v === true)} + /> + +
+
+ ); +} diff --git a/frontend/src/components/EditorLayout/UnsavedChangesDialog.tsx b/frontend/src/components/EditorLayout/UnsavedChangesDialog.tsx new file mode 100644 index 00000000..62b9c93f --- /dev/null +++ b/frontend/src/components/EditorLayout/UnsavedChangesDialog.tsx @@ -0,0 +1,25 @@ +import { ConfirmModal } from '../ui/modal'; + +export interface UnsavedChangesDialogProps { + open: boolean; + onCancel: () => void; + onConfirm: () => void; +} + +export function UnsavedChangesDialog({ open, onCancel, onConfirm }: UnsavedChangesDialogProps) { + return ( + { if (!next) onCancel(); }} + kicker="EDITOR · UNSAVED CHANGES" + title="Discard unsaved changes?" + description="You have unsaved changes. Switching stacks will discard them." + confirmLabel="Discard changes" + onConfirm={onConfirm} + > +

+ You have unsaved changes. Switching stacks will discard them. +

+
+ ); +}