mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-28 04:38:59 +00:00
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.
This commit is contained in:
@@ -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 (
|
||||
<div className="flex flex-col gap-6" aria-busy="true">
|
||||
<Skeleton className="h-10 w-1/3 rounded-md" />
|
||||
<Skeleton className="h-96 w-full rounded-lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 */}
|
||||
<div key={activeView} className="flex-1 overflow-y-auto p-6 animate-fade-up">
|
||||
{activeView === 'settings' ? (
|
||||
<SettingsPage
|
||||
currentSection={settingsSection}
|
||||
onSectionChange={setSettingsSection}
|
||||
/>
|
||||
) : activeView === 'templates' ? (
|
||||
<AppStoreView onDeploySuccess={(stackName) => { refreshStacks(); loadFile(stackName); }} />
|
||||
) : activeView === 'resources' ? (
|
||||
<ResourcesView />
|
||||
) : activeView === 'host-console' ? (
|
||||
<AdmiralGate>
|
||||
<CapabilityGate capability="host-console" featureName="Host Console">
|
||||
<LazyBoundary>
|
||||
<Suspense fallback={<ViewSkeleton />}>
|
||||
<HostConsole stackName={selectedFile} onClose={() => setActiveView(selectedFile ? 'editor' : 'dashboard')} />
|
||||
</Suspense>
|
||||
</LazyBoundary>
|
||||
</CapabilityGate>
|
||||
</AdmiralGate>
|
||||
) : !isLoading && selectedFile && activeView === 'editor' ? (
|
||||
<ViewRouter
|
||||
activeView={activeView}
|
||||
selectedFile={selectedFile}
|
||||
isLoading={isLoading}
|
||||
settingsSection={settingsSection}
|
||||
onSettingsSectionChange={setSettingsSection}
|
||||
onTemplateDeploySuccess={(stackName) => { 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={() => (
|
||||
<ErrorBoundary>
|
||||
<div className="grid gap-6 grid-cols-1 lg:grid-cols-2 min-h-[600px] h-[calc(100vh-160px)] max-h-[1040px]">
|
||||
{/* Left column: identity + health strip + logs, stacked */}
|
||||
@@ -3066,67 +3035,8 @@ export default function EditorLayout() {
|
||||
)}
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
) : activeView === 'global-observability' ? (
|
||||
<LazyBoundary>
|
||||
<Suspense fallback={<ViewSkeleton />}>
|
||||
<GlobalObservabilityView />
|
||||
</Suspense>
|
||||
</LazyBoundary>
|
||||
) : activeView === 'fleet' ? (
|
||||
<CapabilityGate capability="fleet" featureName="Fleet Management">
|
||||
<LazyBoundary>
|
||||
<Suspense fallback={<ViewSkeleton />}>
|
||||
<FleetView onNavigateToNode={(nodeId, stackName) => {
|
||||
const node = nodes.find(n => n.id === nodeId);
|
||||
if (node) {
|
||||
if (activeNode?.id === nodeId) {
|
||||
loadFile(stackName);
|
||||
} else {
|
||||
pendingStackLoadRef.current = stackName;
|
||||
setActiveNode(node);
|
||||
}
|
||||
}
|
||||
}} />
|
||||
</Suspense>
|
||||
</LazyBoundary>
|
||||
</CapabilityGate>
|
||||
) : activeView === 'audit-log' ? (
|
||||
<CapabilityGate capability="audit-log" featureName="Audit Log">
|
||||
<LazyBoundary>
|
||||
<Suspense fallback={<ViewSkeleton />}>
|
||||
<AuditLogView />
|
||||
</Suspense>
|
||||
</LazyBoundary>
|
||||
</CapabilityGate>
|
||||
) : activeView === 'auto-updates' ? (
|
||||
<CapabilityGate capability="auto-updates" featureName="Auto-Update Readiness">
|
||||
<LazyBoundary>
|
||||
<Suspense fallback={<ViewSkeleton />}>
|
||||
<AutoUpdateReadinessView />
|
||||
</Suspense>
|
||||
</LazyBoundary>
|
||||
</CapabilityGate>
|
||||
) : activeView === 'scheduled-ops' ? (
|
||||
<CapabilityGate capability="scheduled-ops" featureName="Scheduled Operations">
|
||||
<LazyBoundary>
|
||||
<Suspense fallback={<ViewSkeleton />}>
|
||||
<ScheduledOperationsView
|
||||
filterNodeId={filterNodeId}
|
||||
onClearFilter={() => setFilterNodeId(null)}
|
||||
prefill={schedulePrefill}
|
||||
onPrefillConsumed={handlePrefillConsumed}
|
||||
/>
|
||||
</Suspense>
|
||||
</LazyBoundary>
|
||||
</CapabilityGate>
|
||||
) : (
|
||||
<HomeDashboard
|
||||
onNavigateToStack={(stackFile) => { loadFile(stackFile); }}
|
||||
onOpenSettingsSection={(section) => handleOpenSettings(section)}
|
||||
notifications={notifications}
|
||||
onClearNotifications={clearAllNotifications}
|
||||
/>
|
||||
)}
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-6" aria-busy="true">
|
||||
<Skeleton className="h-10 w-1/3 rounded-md" />
|
||||
<Skeleton className="h-96 w-full rounded-lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LazyView({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<LazyBoundary>
|
||||
<Suspense fallback={<ViewSkeleton />}>
|
||||
{children}
|
||||
</Suspense>
|
||||
</LazyBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<SettingsPage
|
||||
currentSection={settingsSection}
|
||||
onSectionChange={onSettingsSectionChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (activeView === 'templates') {
|
||||
return <AppStoreView onDeploySuccess={onTemplateDeploySuccess} />;
|
||||
}
|
||||
if (activeView === 'resources') {
|
||||
return <ResourcesView />;
|
||||
}
|
||||
if (activeView === 'host-console') {
|
||||
return (
|
||||
<AdmiralGate>
|
||||
<CapabilityGate capability="host-console" featureName="Host Console">
|
||||
<LazyView>
|
||||
<HostConsole stackName={selectedFile} onClose={onHostConsoleClose} />
|
||||
</LazyView>
|
||||
</CapabilityGate>
|
||||
</AdmiralGate>
|
||||
);
|
||||
}
|
||||
// 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 (
|
||||
<LazyView>
|
||||
<GlobalObservabilityView />
|
||||
</LazyView>
|
||||
);
|
||||
}
|
||||
if (activeView === 'fleet') {
|
||||
return (
|
||||
<CapabilityGate capability="fleet" featureName="Fleet Management">
|
||||
<LazyView>
|
||||
<FleetView onNavigateToNode={onFleetNavigateToNode} />
|
||||
</LazyView>
|
||||
</CapabilityGate>
|
||||
);
|
||||
}
|
||||
if (activeView === 'audit-log') {
|
||||
return (
|
||||
<CapabilityGate capability="audit-log" featureName="Audit Log">
|
||||
<LazyView>
|
||||
<AuditLogView />
|
||||
</LazyView>
|
||||
</CapabilityGate>
|
||||
);
|
||||
}
|
||||
if (activeView === 'auto-updates') {
|
||||
return (
|
||||
<CapabilityGate capability="auto-updates" featureName="Auto-Update Readiness">
|
||||
<LazyView>
|
||||
<AutoUpdateReadinessView />
|
||||
</LazyView>
|
||||
</CapabilityGate>
|
||||
);
|
||||
}
|
||||
if (activeView === 'scheduled-ops') {
|
||||
return (
|
||||
<CapabilityGate capability="scheduled-ops" featureName="Scheduled Operations">
|
||||
<LazyView>
|
||||
<ScheduledOperationsView
|
||||
filterNodeId={filterNodeId}
|
||||
onClearFilter={onClearScheduledOpsFilter}
|
||||
prefill={schedulePrefill}
|
||||
onPrefillConsumed={onPrefillConsumed}
|
||||
/>
|
||||
</LazyView>
|
||||
</CapabilityGate>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<HomeDashboard
|
||||
onNavigateToStack={onNavigateToStack}
|
||||
onOpenSettingsSection={onOpenSettingsSection}
|
||||
notifications={notifications}
|
||||
onClearNotifications={onClearNotifications}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user