import { useEffect, 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 } 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 { useComposeDiffPreviewEnabled } from '@/hooks/use-compose-diff-preview-enabled'; export default function EditorLayout() { const { isAdmin, can } = useAuth(); const { isPaid, license } = useLicense(); const { status: trivy } = useTrivyStatus(); const { runWithLog } = 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 { selectedFile, isLoading, stackActions: stackActionMap, isScanning, searchQuery, setSearchQuery, stackStatuses, stackLabelMap, filterChip, setFilterChip, bulkMode, selectedFiles, filterCounts, chipFilteredFiles, remoteResults, isStackBusy, refreshStacks, fetchAutoUpdateSettings, handleScanStacks, scheduleStateInvalidateRefresh, toggleBulkMode, toggleSelect, clearSelection, handleBulkAction, stackUpdates, pinned, isCollapsed, toggleCollapse, remoteSearchLoading, } = stackListState; const { nodes, activeNode, setActiveNode } = useNodes(); 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, }); const containerStats = useContainerStats(containers); 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 loadingAction = selectedFile ? (stackActionMap[selectedFile] ?? null) : null; const stackName = selectedFile || ''; const { theme, setTheme, isDarkMode } = useTheme(); // 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 (!activeNode) return; 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(); 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); }, }} notifications={notifications} tickerConnected={tickerConnected} onOpenActivity={() => setActiveView('global-observability')} 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={() => ( )} />
); }