From d203caf7dc7c71fe2aed206304b03f2f9242d0e3 Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 3 May 2026 12:19:54 -0400 Subject: [PATCH] refactor(frontend): extract ViewRouter from EditorLayout (#894) Move the activeView switch from EditorLayout.tsx into a new EditorLayout/ViewRouter.tsx covering the nine non-editor views (settings, templates, resources, host-console, global-observability, fleet, audit-log, auto-updates, scheduled-ops) plus the HomeDashboard fall-through. The inline editor branch stays in EditorLayout via a renderEditor render slot; it gets its own extraction in a follow-up. EditorLayout shrinks from 3,356 to 3,266 LOC and sheds imports for SettingsPage, AppStoreView, ResourcesView, HomeDashboard, AdmiralGate, CapabilityGate, Skeleton, plus six lazy view declarations and the inline ViewSkeleton helper. The lazy declarations move into ViewRouter; SecurityHistoryView stays behind because it renders as a settings overlay, not as a top-level tab. ViewRouter introduces a small inline LazyView helper to deduplicate the LazyBoundary + Suspense + ViewSkeleton triple-wrap that repeats across six lazy views. First step of the EditorLayout decomposition tracker. --- frontend/src/components/EditorLayout.tsx | 160 +++----------- .../components/EditorLayout/ViewRouter.tsx | 205 ++++++++++++++++++ 2 files changed, 240 insertions(+), 125 deletions(-) create mode 100644 frontend/src/components/EditorLayout/ViewRouter.tsx diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 55dcdf94..138bda13 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -5,14 +5,9 @@ import { Editor } from '@/lib/monacoLoader'; import { useImageUpdates } from '@/hooks/useImageUpdates'; import TerminalComponent from './Terminal'; import ErrorBoundary from './ErrorBoundary'; -import HomeDashboard from './HomeDashboard'; import type { NotificationItem } from './dashboard/types'; import BashExecModal from './BashExecModal'; import LazyBoundary from './LazyBoundary'; -import { Skeleton } from '@/components/ui/skeleton'; -import { AdmiralGate } from './AdmiralGate'; -import { CapabilityGate } from './CapabilityGate'; -import ResourcesView from './ResourcesView'; import { Button } from './ui/button'; import { Input } from './ui/input'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter, DialogTrigger } from './ui/dialog'; @@ -37,58 +32,24 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSepara import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { TopBar } from './TopBar'; import { cn } from '@/lib/utils'; -import { SettingsPage } from './settings/SettingsPage'; import type { SectionId } from './settings/types'; +import { ViewRouter } from './EditorLayout/ViewRouter'; import { StackAlertSheet } from './StackAlertSheet'; import { StackAutoHealSheet } from '@/components/StackAutoHealSheet'; import { GitSourcePanel } from './stack/GitSourcePanel'; -import { AppStoreView } from './AppStoreView'; import { LogViewer } from './LogViewer'; import StructuredLogViewer from './StructuredLogViewer'; import StackAnatomyPanel from './StackAnatomyPanel'; import { Sparkline } from './ui/sparkline'; import type { ScheduleTaskPrefill } from './ScheduledOperationsView'; -// Paid-tier views and the security-history overlay are loaded on demand. -// Their internal PaidGate / AdmiralGate / CapabilityGate wrappers render -// the upsell or capability-missing card with blurred children rather than -// short-circuiting, so a tier-locked or capability-missing operator -// opening one of these tabs still triggers the chunk fetch to render the -// blurred preview. The gate-short-circuit fix lives in a follow-up -// branch. What this lazy split closes is the much larger initial-bundle -// leak: every Community user used to download the full FleetView, -// AuditLogView, etc. on first page load even if they never clicked those -// tabs. After this change, the chunks fetch only on tab open. -// -// GlobalObservabilityView is a free-tier feature with no internal gate; -// it is split here purely for the bundle-size win, not for IP protection. -const HostConsole = lazy(() => import('./HostConsole')); -const GlobalObservabilityView = lazy(() => - import('./GlobalObservabilityView').then(m => ({ default: m.GlobalObservabilityView })), -); -const FleetView = lazy(() => - import('./FleetView').then(m => ({ default: m.FleetView })), -); -const AuditLogView = lazy(() => - import('./AuditLogView').then(m => ({ default: m.AuditLogView })), -); +// SecurityHistoryView is the only lazy-loaded view that lives outside +// the ViewRouter switch — it renders as an overlay sheet wired into the +// settings flow, not as a top-level tab. The other tab-level lazy views +// (HostConsole, FleetView, AuditLogView, etc.) live inside ViewRouter. const SecurityHistoryView = lazy(() => import('./SecurityHistoryView').then(m => ({ default: m.SecurityHistoryView })), ); -const ScheduledOperationsView = lazy(() => import('./ScheduledOperationsView')); -const AutoUpdateReadinessView = lazy(() => import('./AutoUpdateReadinessView')); - -// Sized for the main workspace area (flex-1 with p-6 padding). Visible -// only during the brief window between an unlocked view's chunk request -// and its first render. -function ViewSkeleton() { - return ( -
- - -
- ); -} import { SENCHO_NAVIGATE_EVENT } from './NodeManager'; import type { SenchoNavigateDetail } from './NodeManager'; import { NodeSwitcher } from './NodeSwitcher'; @@ -2512,26 +2473,34 @@ export default function EditorLayout() { {/* Main Workspace */}
- {activeView === 'settings' ? ( - - ) : activeView === 'templates' ? ( - { refreshStacks(); loadFile(stackName); }} /> - ) : activeView === 'resources' ? ( - - ) : activeView === 'host-console' ? ( - - - - }> - setActiveView(selectedFile ? 'editor' : 'dashboard')} /> - - - - - ) : !isLoading && selectedFile && activeView === 'editor' ? ( + { refreshStacks(); loadFile(stackName); }} + onHostConsoleClose={() => setActiveView(selectedFile ? 'editor' : 'dashboard')} + onFleetNavigateToNode={(nodeId, stackName) => { + const node = nodes.find(n => n.id === nodeId); + if (node) { + if (activeNode?.id === nodeId) { + loadFile(stackName); + } else { + pendingStackLoadRef.current = stackName; + setActiveNode(node); + } + } + }} + filterNodeId={filterNodeId} + onClearScheduledOpsFilter={() => setFilterNodeId(null)} + schedulePrefill={schedulePrefill} + onPrefillConsumed={handlePrefillConsumed} + notifications={notifications} + onNavigateToStack={(stackFile) => { loadFile(stackFile); }} + onOpenSettingsSection={(section) => handleOpenSettings(section)} + onClearNotifications={clearAllNotifications} + renderEditor={() => (
{/* Left column: identity + health strip + logs, stacked */} @@ -3066,67 +3035,8 @@ export default function EditorLayout() { )}
- ) : activeView === 'global-observability' ? ( - - }> - - - - ) : activeView === 'fleet' ? ( - - - }> - { - const node = nodes.find(n => n.id === nodeId); - if (node) { - if (activeNode?.id === nodeId) { - loadFile(stackName); - } else { - pendingStackLoadRef.current = stackName; - setActiveNode(node); - } - } - }} /> - - - - ) : activeView === 'audit-log' ? ( - - - }> - - - - - ) : activeView === 'auto-updates' ? ( - - - }> - - - - - ) : activeView === 'scheduled-ops' ? ( - - - }> - setFilterNodeId(null)} - prefill={schedulePrefill} - onPrefillConsumed={handlePrefillConsumed} - /> - - - - ) : ( - { loadFile(stackFile); }} - onOpenSettingsSection={(section) => handleOpenSettings(section)} - notifications={notifications} - onClearNotifications={clearAllNotifications} - /> - )} + )} + />
diff --git a/frontend/src/components/EditorLayout/ViewRouter.tsx b/frontend/src/components/EditorLayout/ViewRouter.tsx new file mode 100644 index 00000000..eaa81fc8 --- /dev/null +++ b/frontend/src/components/EditorLayout/ViewRouter.tsx @@ -0,0 +1,205 @@ +import { Suspense, lazy, type ReactNode } from 'react'; +import { Skeleton } from '@/components/ui/skeleton'; +import { AdmiralGate } from '../AdmiralGate'; +import { CapabilityGate } from '../CapabilityGate'; +import LazyBoundary from '../LazyBoundary'; +import { SettingsPage } from '../settings/SettingsPage'; +import type { SectionId } from '../settings/types'; +import { AppStoreView } from '../AppStoreView'; +import ResourcesView from '../ResourcesView'; +import HomeDashboard from '../HomeDashboard'; +import type { NotificationItem } from '../dashboard/types'; +import type { ScheduleTaskPrefill } from '../ScheduledOperationsView'; + +// Paid-tier views and the security-history overlay are loaded on demand. +// Their internal PaidGate / AdmiralGate / CapabilityGate wrappers render +// the upsell or capability-missing card with blurred children rather than +// short-circuiting, so a tier-locked or capability-missing operator +// opening one of these tabs still triggers the chunk fetch to render the +// blurred preview. What this lazy split closes is the much larger +// initial-bundle leak: every Community user used to download the full +// FleetView, AuditLogView, etc. on first page load even if they never +// clicked those tabs. After this change, the chunks fetch only on tab +// open. +// +// GlobalObservabilityView is a free-tier feature with no internal gate; +// it is split here purely for the bundle-size win, not for IP protection. +const HostConsole = lazy(() => import('../HostConsole')); +const GlobalObservabilityView = lazy(() => + import('../GlobalObservabilityView').then(m => ({ default: m.GlobalObservabilityView })), +); +const FleetView = lazy(() => + import('../FleetView').then(m => ({ default: m.FleetView })), +); +const AuditLogView = lazy(() => + import('../AuditLogView').then(m => ({ default: m.AuditLogView })), +); +const ScheduledOperationsView = lazy(() => import('../ScheduledOperationsView')); +const AutoUpdateReadinessView = lazy(() => import('../AutoUpdateReadinessView')); + +// Sized for the main workspace area (flex-1 with p-6 padding). Visible +// only during the brief window between an unlocked view's chunk request +// and its first render. +function ViewSkeleton() { + return ( +
+ + +
+ ); +} + +function LazyView({ children }: { children: ReactNode }) { + return ( + + }> + {children} + + + ); +} + +export type ActiveView = + | 'dashboard' + | 'editor' + | 'host-console' + | 'resources' + | 'templates' + | 'global-observability' + | 'fleet' + | 'audit-log' + | 'scheduled-ops' + | 'auto-updates' + | 'settings'; + +export interface ViewRouterProps { + activeView: ActiveView; + selectedFile: string | null; + isLoading: boolean; + settingsSection: SectionId; + onSettingsSectionChange: (section: SectionId) => void; + onTemplateDeploySuccess: (stackName: string) => void; + onHostConsoleClose: () => void; + onFleetNavigateToNode: (nodeId: number, stackName: string) => void; + filterNodeId: number | null; + onClearScheduledOpsFilter: () => void; + schedulePrefill: ScheduleTaskPrefill | null; + onPrefillConsumed: () => void; + notifications: NotificationItem[]; + onNavigateToStack: (stackFile: string) => void; + onOpenSettingsSection: (section: SectionId) => void; + onClearNotifications: () => void; + // Render slot for the inline editor view. Kept as a callback so the + // (large) editor JSX is only allocated when activeView === 'editor', + // not on every parent render that lands on a different view. + renderEditor: () => ReactNode; +} + +export function ViewRouter({ + activeView, + selectedFile, + isLoading, + settingsSection, + onSettingsSectionChange, + onTemplateDeploySuccess, + onHostConsoleClose, + onFleetNavigateToNode, + filterNodeId, + onClearScheduledOpsFilter, + schedulePrefill, + onPrefillConsumed, + notifications, + onNavigateToStack, + onOpenSettingsSection, + onClearNotifications, + renderEditor, +}: ViewRouterProps): ReactNode { + if (activeView === 'settings') { + return ( + + ); + } + if (activeView === 'templates') { + return ; + } + if (activeView === 'resources') { + return ; + } + if (activeView === 'host-console') { + return ( + + + + + + + + ); + } + // Fall-through: when activeView === 'editor' but selectedFile is + // null or the stack is still loading, drop through to the default + // HomeDashboard render below. This matches the pre-extraction + // behavior of the conditional ternary chain in EditorLayout.tsx. + if (!isLoading && selectedFile && activeView === 'editor') { + return renderEditor(); + } + if (activeView === 'global-observability') { + return ( + + + + ); + } + if (activeView === 'fleet') { + return ( + + + + + + ); + } + if (activeView === 'audit-log') { + return ( + + + + + + ); + } + if (activeView === 'auto-updates') { + return ( + + + + + + ); + } + if (activeView === 'scheduled-ops') { + return ( + + + + + + ); + } + return ( + + ); +}