import { useCallback, useEffect, useMemo, useRef } from 'react'; import { Button } from './ui/button'; import { Plus } from 'lucide-react'; import { UserProfileDropdown } from './UserProfileDropdown'; import { NotificationPanel } from './NotificationPanel'; import { TopBar } from './TopBar'; import { ViewRouter } from './EditorLayout/ViewRouter'; import { CreateStackDialog } from './EditorLayout/CreateStackDialog'; import { EditorView } from './EditorLayout/EditorView'; import { ShellOverlays } from './EditorLayout/ShellOverlays'; import { useEditorViewState } from './EditorLayout/hooks/useEditorViewState'; import { useStackListState } from './EditorLayout/hooks/useStackListState'; import { useViewNavigationState } from './EditorLayout/hooks/useViewNavigationState'; import { useOverlayState } from './EditorLayout/hooks/useOverlayState'; import { useStackActions, NODE_SWITCH_PENDING_TOKEN } from './EditorLayout/hooks/useStackActions'; import { useTheme } from './EditorLayout/hooks/useTheme'; import { useNotifications } from './EditorLayout/hooks/useNotifications'; import { useContainerStats } from './EditorLayout/hooks/useContainerStats'; import { useSidebarContextMenu } from './EditorLayout/hooks/useSidebarContextMenu'; import { NodeSwitcher } from './NodeSwitcher'; import { GlobalCommandPalette, GlobalCommandPaletteProvider, GlobalCommandPaletteTrigger, } from './GlobalCommandPalette'; import { SENCHO_OPEN_LOGS_EVENT } from '@/lib/events'; import type { SenchoOpenLogsDetail } from '@/lib/events'; import { useNodes } from '@/context/NodeContext'; import { useAuth } from '@/context/AuthContext'; import { useLicense } from '@/context/LicenseContext'; import { useDeployFeedback } from '@/context/DeployFeedbackContext'; import { useTrivyStatus } from '@/hooks/useTrivyStatus'; import { StackSidebar } from '@/components/sidebar/StackSidebar'; import type { StackRowStatus } from '@/components/sidebar/stack-status-utils'; import { useSidebarActivitySummary, countEnabledAutoUpdates } from '@/components/sidebar/useSidebarActivitySummary'; import { useNextAutoUpdateRun } from '@/components/sidebar/useNextAutoUpdateRun'; import { usePanelSessionStartedAt } from '@/components/sidebar/usePanelSessionStartedAt'; import type { SidebarActivityAction } from '@/components/sidebar/SidebarActivityTicker'; import { useComposeDiffPreviewEnabled } from '@/hooks/use-compose-diff-preview-enabled'; import { toast } from '@/components/ui/toast-store'; export default function EditorLayout() { const { isAdmin, can } = useAuth(); const { isPaid, license } = useLicense(); const { status: trivy } = useTrivyStatus(); const { runWithLog, panelState } = useDeployFeedback(); const editorState = useEditorViewState(); const { stackMisconfigScanning, copiedDigest, setCopiedDigest, copiedDigestTimerRef, content, setContent, envContent, setEnvContent, envExists, envFiles, selectedEnvFile, containers, activeTab, setActiveTab, logsMode, setLogsMode, gitSourceOpen, setGitSourceOpen, gitSourcePendingMap, isFileLoading, backupInfo, isEditing, editingCompose, setEditingCompose, } = editorState; const stackListState = useStackListState(); const { files, selectedFile, isLoading, stackActions: stackActionMap, isScanning, searchQuery, setSearchQuery, stackStatuses, stackLabelMap, autoUpdateSettings, filterChip, setFilterChip, bulkMode, selectedFiles, filterCounts, chipFilteredFiles, remoteResults, isStackBusy, refreshStacks, fetchAutoUpdateSettings, handleScanStacks, scheduleStateInvalidateRefresh, toggleBulkMode, toggleSelect, clearSelection, handleBulkAction, stackUpdates, fetchImageUpdates, pinned, isCollapsed, toggleCollapse, remoteSearchLoading, } = stackListState; const { nodes, activeNode, setActiveNode } = useNodes(); // Mirror activeNode.id in a ref so async handlers (e.g. CreateStackDialog's // post-create handoff) can detect a node switch that happened mid-flight. // Closure capture of activeNode would always match the value at handler-creation // time and miss the switch. const activeNodeIdRef = useRef(activeNode?.id ?? null); useEffect(() => { activeNodeIdRef.current = activeNode?.id ?? null; }, [activeNode?.id]); const overlayState = useOverlayState(); const { createDialogOpen, setCreateDialogOpen, } = overlayState; const [diffPreviewEnabled] = useComposeDiffPreviewEnabled(); // Use a ref to break the circular dependency: // useViewNavigationState needs onNavigateToDashboard -> resetEditorState // but stackActions isn't created until after navState const resetEditorStateRef = useRef<() => void>(() => {}); const navState = useViewNavigationState({ onNavigateToDashboard: () => resetEditorStateRef.current(), }); const { activeView, setActiveView, settingsSection, setSettingsSection, securityHistoryOpen, setSecurityHistoryOpen, filterNodeId, setFilterNodeId, schedulePrefill, mobileNavOpen, setMobileNavOpen, handleOpenSettings, handlePrefillConsumed, handleNavigate, navItems, } = navState; const isAdmiral = license?.variant === 'admiral'; const { notifications, tickerConnected, markAllRead, deleteNotification, clearAllNotifications, } = useNotifications({ nodes, onStateInvalidate: scheduleStateInvalidateRefresh, onAutoUpdateChange: fetchAutoUpdateSettings, onImageUpdatesChange: fetchImageUpdates, }); const { stats: containerStats, error: containerStatsError } = useContainerStats( containers, activeNode?.id ?? null, ); const stackActions = useStackActions({ editorState, stackListState, navState, overlayState, activeNode, setActiveNode, nodes, isPaid, runWithLog, diffPreviewEnabled, }); // Wire the ref now that stackActions is available resetEditorStateRef.current = stackActions.resetEditorState; const buildMenuCtx = useSidebarContextMenu({ stackListState, navState, overlayState, stackActions, activeNode, isPaid, isAdmiral, can, }); const { pendingStackLoadRef, pendingLogsRef, } = stackActions; const panelStartedAt = usePanelSessionStartedAt(panelState); const autoUpdateEnabledCount = useMemo( () => countEnabledAutoUpdates(files, autoUpdateSettings), [files, autoUpdateSettings], ); const nextAutoUpdateRunAt = useNextAutoUpdateRun(); const activitySummary = useSidebarActivitySummary({ notifications, tickerConnected, panelState, panelStartedAt, autoUpdateEnabledCount, totalStackCount: files.length, nextAutoUpdateRunAt, }); const handleActivityAction = useCallback((action: SidebarActivityAction) => { switch (action.kind) { case 'open-stack-notification': stackActions.navigateToNotification(action.summary.notif); return; case 'open-auto-updates': setActiveView('auto-updates'); return; case 'open-activity': setActiveView('global-observability'); return; case 'noop': return; } }, [stackActions, setActiveView]); const loadingAction = selectedFile ? (stackActionMap[selectedFile] ?? null) : null; const stackName = selectedFile || ''; const { theme, setTheme, isDarkMode } = useTheme(); // Track the last "committed" node id so the node-switch dirty guard can // detect an actual switch (vs the initial mount or an internal revert). const lastSeenNodeIdRef = useRef(activeNode?.id ?? null); // Set true when we revert activeNode after stashing a pending switch, so the // re-fire of this effect on the reverted id is a no-op. const revertingNodeSwitchRef = useRef(false); // Re-fetch stacks whenever the active node changes (or becomes available on mount). // Also clears any stale editor/container state that belonged to the previous node. useEffect(() => { if (revertingNodeSwitchRef.current) { revertingNodeSwitchRef.current = false; return; } if (!activeNode) return; const previousId = lastSeenNodeIdRef.current; const isRealSwitch = previousId !== null && previousId !== activeNode.id; // A node-switch prompt is already open. Ignore any further activeNode // changes until the user resolves the current dialog; revert silently so // the dialog's pendingUnsavedNode stays anchored to the first attempt. if (isRealSwitch && overlayState.pendingUnsavedLoad === NODE_SWITCH_PENDING_TOKEN) { const previousNode = nodes.find(n => n.id === previousId); if (previousNode) { revertingNodeSwitchRef.current = true; setActiveNode(previousNode); } return; } if (isRealSwitch && stackActions.hasUnsavedChanges()) { const previousNode = nodes.find(n => n.id === previousId); if (previousNode) { // Stash the attempted node + open the unsaved-changes dialog via the // existing pendingUnsavedLoad/Node fields. Revert activeNode back to // the previous node; the revertingNodeSwitchRef makes the resulting // effect fire a no-op so dirty content survives. overlayState.setPendingUnsavedNode(activeNode); overlayState.setPendingUnsavedLoad(NODE_SWITCH_PENDING_TOKEN); revertingNodeSwitchRef.current = true; setActiveNode(previousNode); return; } // Previous node is no longer reachable from the nodes list (deleted or // dropped from the registry). We cannot revert, so the unsaved edits // will be lost. Warn the operator before the wipe so the loss is at // least visible. toast.warning('Unsaved changes were discarded: the previous node is no longer available.'); } lastSeenNodeIdRef.current = activeNode.id; const pendingStack = pendingStackLoadRef.current; pendingStackLoadRef.current = null; stackActions.resetEditorState(); if (pendingStack) { void stackActions.loadFile(pendingStack); } else { setActiveView('dashboard'); } refreshStacks(); fetchAutoUpdateSettings(); void stackActions.refreshGitSourcePending(); }, [activeNode?.id]); // eslint-disable-line react-hooks/exhaustive-deps // Resolve a pending container name (from notification click) to a live // container id once the target stack's container list loads, then dispatch // the logs event. Only consume when the current stack matches the pending // target — prevents a canceled unsaved-load from leaking the pending name // into an unrelated container refresh. Container ids churn across // recreations, so we store the name and resolve here instead of storing an // id at dispatch time. useEffect(() => { const pending = pendingLogsRef.current; if (!pending || selectedFile !== pending.stackName || containers.length === 0) return; pendingLogsRef.current = null; const match = containers.find(c => (c.Names ?? []).some(n => n.replace(/^\//, '') === pending.containerName), ); if (match) { window.dispatchEvent(new CustomEvent(SENCHO_OPEN_LOGS_EVENT, { detail: { containerId: match.Id, containerName: pending.containerName }, })); } }, [containers, selectedFile]); // eslint-disable-line react-hooks/exhaustive-deps const createStackSlot = can('stack:create') ? ( <> { await refreshStacks(); // loadFile keeps its own unsaved-changes overlay (intentional safety, // shared with every other "switch to a different stack" code path). // Skip the load if the user switched nodes mid-create so we do not // 404 against a stack name that lives on the previous node. if (sourceNodeId != null && activeNodeIdRef.current !== sourceNodeId) { toast.info(`Stack "${sName}" created on the previous node.`); return; } await stackActions.loadFile(sName); }} onStacksChanged={async () => { await refreshStacks(); }} /> ) : null; return (
{/* Left Sidebar (Stacks) */} handleOpenSettings('nodes')} /> } createStackSlot={createStackSlot} onScan={handleScanStacks} isScanning={isScanning} canCreate={can('stack:create')} searchQuery={searchQuery} onSearchChange={setSearchQuery} filterChip={filterChip} filterCounts={filterCounts} onFilterChipChange={setFilterChip} list={{ files: chipFilteredFiles, isLoading, isPaid, selectedFile, searchQuery, stackLabelMap, stackStatuses: stackStatuses as Record, stackUpdates, gitSourcePendingMap, pinnedFiles: pinned, isCollapsed, toggleCollapse, isBusy: isStackBusy, getDisplayName: stackActions.getDisplayName, onSelectFile: stackActions.loadFile, buildMenuCtx, remoteResults, remoteLoading: remoteSearchLoading, onSelectRemoteFile: (nodeId, file) => { const node = nodes.find(n => n.id === nodeId); if (node) void stackActions.loadFileOnNode(node, file); }, }} activitySummary={activitySummary} onActivityAction={handleActivityAction} bulkMode={bulkMode} selectedFiles={selectedFiles} isPaid={isPaid} onToggleBulkMode={toggleBulkMode} onToggleSelect={toggleSelect} onClearSelection={clearSelection} onBulkAction={handleBulkAction} /> {/* Main Content Area */}
} notifications={ } userMenu={ handleOpenSettings('account')} /> } /> {/* Main Workspace */}
{ refreshStacks(); void stackActions.loadFile(sName); }} onHostConsoleClose={() => setActiveView(selectedFile ? 'editor' : 'dashboard')} onFleetNavigateToNode={(nodeId, sName) => { const node = nodes.find(n => n.id === nodeId); if (node) { if (activeNode?.id === nodeId) { void stackActions.loadFile(sName); } else { pendingStackLoadRef.current = sName; setActiveNode(node); } } }} filterNodeId={filterNodeId} onClearScheduledOpsFilter={() => setFilterNodeId(null)} schedulePrefill={schedulePrefill} onPrefillConsumed={handlePrefillConsumed} notifications={notifications} onNavigateToStack={(stackFile) => { void stackActions.loadFile(stackFile); }} onOpenSettingsSection={(section) => handleOpenSettings(section)} onClearNotifications={clearAllNotifications} renderEditor={() => ( )} />
); }