Files
sencho/frontend/src/components/EditorLayout/EditorView.tsx
T
Anso 1cf996142b refactor(frontend): EditorLayout final shell (B4-7) (#906)
* refactor(frontend): extract useOverlayState hook from EditorLayout

* refactor(frontend): extract useStackActions hook and wire useOverlayState into EditorLayout

* refactor(frontend): fix quality issues in useStackActions post-review

* fix(frontend): fix interval leak, RunResult contract, yml hardcode, and loadFile length in useStackActions

* refactor(frontend): extract useSidebarContextMenu hook from EditorLayout

* refactor(frontend): extract ShellOverlays component from EditorLayout

* refactor(frontend): relocate Monaco layout effect and log-viewer event listener out of EditorLayout

The Monaco tab-switch layout effect is now self-contained in EditorView,
alongside its monacoEditorRef. The SENCHO_OPEN_LOGS_EVENT listener moves
into useOverlayState, where openLogViewer lives. EditorLayout is left with
the two coordination effects that depend on cross-hook state.
2026-05-04 07:43:02 -04:00

838 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Suspense, useRef, useEffect } from 'react';
import { Editor } from '@/lib/monacoLoader';
import {
RotateCw,
Play,
Square,
Save,
Terminal,
CloudDownload,
Pencil,
X,
MoreVertical,
Rocket,
Trash2,
ScrollText,
Undo2,
Loader2,
Check,
ChevronDown,
GitBranch,
ShieldCheck,
ArrowUpRight,
Copy,
FolderOpen,
} from 'lucide-react';
import { Button } from '../ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import {
Tabs,
TabsList,
TabsTrigger,
TabsHighlight,
TabsHighlightItem,
} from '../ui/tabs';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Sparkline } from '../ui/sparkline';
import { springs } from '@/lib/motion';
import { cn } from '@/lib/utils';
import { copyToClipboard } from '@/lib/clipboard';
import ErrorBoundary from '../ErrorBoundary';
import TerminalComponent from '../Terminal';
import StructuredLogViewer from '../StructuredLogViewer';
import StackAnatomyPanel from '../StackAnatomyPanel';
import { StackFileExplorer } from '@/components/files/StackFileExplorer';
import type { NotificationItem } from '../dashboard/types';
import type { Node } from '@/context/NodeContext';
import type { useAuth } from '@/context/AuthContext';
export interface ContainerInfo {
Id: string;
Names: string[];
Service?: string;
State: string;
Status?: string;
Ports?: { PrivatePort: number; PublicPort: number }[];
healthStatus?: 'healthy' | 'unhealthy' | 'starting' | 'none';
Image?: string;
ImageID?: string;
}
export type StackAction =
| 'deploy'
| 'stop'
| 'restart'
| 'update'
| 'delete'
| 'rollback';
export interface ContainerStatsEntry {
cpu: string;
ram: string;
net: string;
lastRx?: number;
lastTx?: number;
history: { cpu: number[]; mem: number[]; netIn: number[]; netOut: number[] };
}
const extractUptime = (status: string | undefined): string | null => {
if (!status) return null;
const match = status.match(/^\s*Up\s+(.+?)(?:\s*\(.*\))?\s*$/i);
if (!match) return null;
return `up ${match[1].trim()}`;
};
const healthcheckLabel = (
health?: 'healthy' | 'unhealthy' | 'starting' | 'none',
): string | null => {
if (!health || health === 'none') return null;
if (health === 'healthy') return 'healthcheck passing';
if (health === 'unhealthy') return 'healthcheck failing';
return 'healthcheck starting';
};
type StackPill = {
label: string;
dotClass: string;
className: string;
pulse: boolean;
};
const getStackStatePill = (containers: ContainerInfo[]): StackPill | null => {
if (!containers || containers.length === 0) return null;
const running = containers.some(c => c.State === 'running');
if (!running) {
return {
label: 'exited',
dotClass: 'bg-destructive',
className: 'border-destructive/40 bg-destructive/10 text-destructive',
pulse: false,
};
}
const anyUnhealthy = containers.some(c => c.healthStatus === 'unhealthy');
const anyStarting = containers.some(c => c.healthStatus === 'starting');
const anyHealthy = containers.some(c => c.healthStatus === 'healthy');
if (anyUnhealthy) {
return {
label: 'running · unhealthy',
dotClass: 'bg-destructive',
className: 'border-destructive/40 bg-destructive/10 text-destructive',
pulse: true,
};
}
if (anyStarting) {
return {
label: 'running · starting',
dotClass: 'bg-warning',
className: 'border-warning/40 bg-warning/10 text-warning',
pulse: true,
};
}
if (anyHealthy) {
return {
label: 'running · healthy',
dotClass: 'bg-success',
className: 'border-success/40 bg-success/10 text-success',
pulse: true,
};
}
return {
label: 'running',
dotClass: 'bg-success',
className: 'border-success/40 bg-success/10 text-success',
pulse: true,
};
};
export interface EditorViewProps {
// Identity
stackName: string;
isDarkMode: boolean;
// Stack data (raw; safe-wrapped locally for backwards-compat with prior idiom)
containers: ContainerInfo[];
containerStats: Record<string, ContainerStatsEntry>;
content: string;
envContent: string;
envExists: boolean;
envFiles: string[];
selectedEnvFile: string;
isFileLoading: boolean;
backupInfo: { exists: boolean; timestamp: number | null };
gitSourcePendingMap: Record<string, boolean>;
notifications: NotificationItem[];
// Editor mode
activeTab: 'compose' | 'env' | 'files';
isEditing: boolean;
editingCompose: boolean;
logsMode: 'structured' | 'raw';
copiedDigest: string | null;
loadingAction: StackAction | null;
stackMisconfigScanning: boolean;
// Permissions / tier / context
can: ReturnType<typeof useAuth>['can'];
isAdmin: boolean;
isPaid: boolean;
trivy: { available: boolean };
activeNode: Node | null;
// Refs
copiedDigestTimerRef: React.MutableRefObject<number | null>;
// Stack actions
deployStack: (e: React.MouseEvent) => Promise<void>;
restartStack: (e: React.MouseEvent) => Promise<void>;
stopStack: (e: React.MouseEvent) => Promise<void>;
updateStack: (e?: React.MouseEvent) => Promise<void>;
rollbackStack: () => Promise<void>;
scanStackConfig: () => Promise<void>;
// Edit lifecycle
enterEditMode: () => void;
requestSave: () => void;
requestSaveAndDeploy: (e: React.MouseEvent) => void;
discardChanges: () => void;
setContent: (next: string) => void;
setEnvContent: (next: string) => void;
changeEnvFile: (file: string) => Promise<void>;
// Container / service actions
openLogViewer: (containerId: string, containerName: string) => void;
openBashModal: (containerId: string, containerName: string) => void;
serviceAction: (
action: 'start' | 'stop' | 'restart',
serviceName: string,
) => Promise<void>;
// UI state setters
setActiveTab: (tab: 'compose' | 'env' | 'files') => void;
setLogsMode: (mode: 'structured' | 'raw') => void;
setEditingCompose: (open: boolean) => void;
setGitSourceOpen: (open: boolean) => void;
setCopiedDigest: React.Dispatch<React.SetStateAction<string | null>>;
// Composed action — wraps setStackToDelete + setDeleteDialogOpen
requestDeleteStack: () => void;
}
export function EditorView({
stackName,
isDarkMode,
containers,
containerStats,
content,
envContent,
envExists,
envFiles,
selectedEnvFile,
isFileLoading,
backupInfo,
gitSourcePendingMap,
notifications,
activeTab,
isEditing,
editingCompose,
logsMode,
copiedDigest,
loadingAction,
stackMisconfigScanning,
can,
isAdmin,
isPaid,
trivy,
activeNode,
copiedDigestTimerRef,
deployStack,
restartStack,
stopStack,
updateStack,
rollbackStack,
scanStackConfig,
enterEditMode,
requestSave,
requestSaveAndDeploy,
discardChanges,
setContent,
setEnvContent,
changeEnvFile,
openLogViewer,
openBashModal,
serviceAction,
setActiveTab,
setLogsMode,
setEditingCompose,
setGitSourceOpen,
setCopiedDigest,
requestDeleteStack,
}: EditorViewProps) {
const monacoEditorRef = useRef<import('monaco-editor').editor.IStandaloneCodeEditor | null>(null);
// Force Monaco to re-measure its container after the tab switch DOM settles.
// Monaco's internal child is position:static with an explicit pixel height that
// creates a circular CSS dependency (Monaco drives card height -> grid height -> Monaco).
// Fix: reset Monaco to 0x0 first (breaks the cycle), then trigger a forced synchronous
// reflow so the container has its CSS-correct size before Monaco re-measures.
useEffect(() => {
const id = requestAnimationFrame(() => {
const editor = monacoEditorRef.current;
if (!editor) return;
editor.layout({ width: 0, height: 0 }); // collapse -> breaks CSS circular dependency
editor.layout(); // forced reflow -> measures correct container size
});
return () => cancelAnimationFrame(id);
}, [activeTab]);
const safeContainers = containers || [];
const safeContent = content || '';
const safeEnvContent = envContent || '';
const isRunning = safeContainers.some(c => c.State === 'running');
return (
<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 */}
<div className="flex flex-col gap-6 min-h-0">
{/* Command Center Card (identity + health strip) */}
<Card className="rounded-xl border-muted bg-card shrink-0">
<CardHeader className="p-4 pb-2">
<div className="flex flex-col gap-3">
{/* Identity block */}
<div className="flex flex-col gap-1.5">
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
{(activeNode?.name || 'local')} <span className="text-muted-foreground/60"></span> stacks <span className="text-muted-foreground/60"></span> {stackName}
</div>
<div className="flex items-center gap-3 flex-wrap">
<CardTitle className="font-display italic text-3xl leading-none tracking-tight">{stackName}</CardTitle>
{(() => {
const pill = getStackStatePill(safeContainers);
if (!pill) return null;
return (
<span className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 ${pill.className}`}>
<span
aria-hidden="true"
className={`h-1.5 w-1.5 rounded-full ${pill.dotClass} ${pill.pulse ? 'animate-[pulse_2.4s_ease-in-out_infinite]' : ''}`}
/>
<span className="font-mono text-[10px] uppercase tracking-[0.18em]">{pill.label}</span>
</span>
);
})()}
</div>
{(() => {
const first = safeContainers[0];
if (!first?.Image) return null;
const digest = first.ImageID ? first.ImageID.replace(/^sha256:/, '').slice(0, 12) : '';
return (
<div className="flex items-center gap-1.5 font-mono text-[11px] text-stat-subtitle">
<span>image <span className="text-muted-foreground/60">·</span> <span className="text-foreground/90">{first.Image}</span></span>
{digest && first.ImageID && (
<>
<span className="text-muted-foreground/60">·</span>
<span>digest <span className="text-foreground/90">{digest}</span></span>
<button
type="button"
aria-label={copiedDigest === first.ImageID ? 'Copied' : 'Copy digest'}
onClick={() => {
const id = first.ImageID as string;
void copyToClipboard(id).then(() => {
setCopiedDigest(id);
if (copiedDigestTimerRef.current !== null) {
window.clearTimeout(copiedDigestTimerRef.current);
}
copiedDigestTimerRef.current = window.setTimeout(() => {
setCopiedDigest(prev => (prev === id ? null : prev));
copiedDigestTimerRef.current = null;
}, 1500);
}).catch(() => { /* clipboard unavailable */ });
}}
className="inline-flex h-4 w-4 items-center justify-center rounded text-stat-subtitle hover:text-foreground hover:bg-muted/60 transition-colors"
>
{copiedDigest === first.ImageID ? (
<Check className="h-3 w-3" strokeWidth={2} />
) : (
<Copy className="h-3 w-3" strokeWidth={1.5} />
)}
</button>
</>
)}
</div>
);
})()}
</div>
{/* Action Bar */}
{can('stack:deploy', 'stack', stackName) && (
<div className="flex items-center gap-2 flex-wrap">
{isRunning ? (
<Button type="button" size="sm" data-testid="stack-deploy-button" className="rounded-lg bg-brand text-brand-foreground hover:bg-brand/90" onClick={restartStack} disabled={loadingAction !== null}>
<RotateCw className="w-4 h-4 mr-2" strokeWidth={1.5} />
{loadingAction === 'restart' ? 'Restarting...' : 'Restart'}
</Button>
) : (
<Button type="button" size="sm" data-testid="stack-deploy-button" className="rounded-lg bg-brand text-brand-foreground hover:bg-brand/90" onClick={deployStack} disabled={loadingAction !== null}>
<Play className="w-4 h-4 mr-2" strokeWidth={1.5} />
{loadingAction === 'deploy' ? 'Starting...' : 'Start'}
</Button>
)}
{isRunning && (
<Button type="button" size="sm" variant="outline" className="rounded-lg" onClick={stopStack} disabled={loadingAction !== null}>
<Square className="w-4 h-4 mr-2" strokeWidth={1.5} />
{loadingAction === 'stop' ? 'Stopping...' : 'Stop'}
</Button>
)}
<Button type="button" size="sm" variant="outline" className="rounded-lg" onClick={updateStack} disabled={loadingAction !== null}>
<CloudDownload className="w-4 h-4 mr-2" strokeWidth={1.5} />
{loadingAction === 'update' ? 'Updating...' : 'Update'}
</Button>
{(() => {
const canRollback = isPaid && backupInfo.exists;
const canScan = trivy.available && isAdmin && isPaid;
const hasOverflowExtras = canRollback || canScan;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" size="sm" variant="ghost" className="rounded-lg h-8 w-8 p-0" disabled={loadingAction !== null} aria-label="More actions">
<MoreVertical className="w-4 h-4" strokeWidth={1.5} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{canRollback && (
<DropdownMenuItem onClick={rollbackStack} disabled={loadingAction !== null}>
<Undo2 className="w-4 h-4 mr-2" strokeWidth={1.5} />
<div className="flex flex-col gap-0.5">
<span>{loadingAction === 'rollback' ? 'Rolling back...' : 'Rollback'}</span>
{backupInfo.timestamp && (
<span className="text-[10px] text-stat-subtitle font-mono">{new Date(backupInfo.timestamp).toLocaleString()}</span>
)}
</div>
</DropdownMenuItem>
)}
{canScan && (
<DropdownMenuItem onClick={scanStackConfig} disabled={loadingAction !== null || stackMisconfigScanning}>
{stackMisconfigScanning ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" strokeWidth={1.5} />
) : (
<ShieldCheck className="w-4 h-4 mr-2" strokeWidth={1.5} />
)}
{stackMisconfigScanning ? 'Scanning...' : 'Scan config'}
</DropdownMenuItem>
)}
{hasOverflowExtras && <DropdownMenuSeparator />}
<DropdownMenuItem
className="text-destructive focus:text-destructive focus:bg-destructive/10"
disabled={loadingAction !== null}
onClick={requestDeleteStack}
>
<Trash2 className="w-4 h-4 mr-2" strokeWidth={1.5} />
{loadingAction === 'delete' ? 'Deleting...' : 'Delete'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
})()}
</div>
)}
</div>
</CardHeader>
<CardContent className="p-4 pt-2">
{/* Per-container health strip */}
<div className="mt-4">
<h4 className="text-sm font-medium text-muted-foreground mb-3">CONTAINERS</h4>
{safeContainers.length === 0 ? (
<div className="text-muted-foreground text-sm">No containers running for this stack.</div>
) : (
<div className="flex flex-col gap-2">
{safeContainers.map(container => {
let mainPort: number | undefined;
let mainPortPrivate: number | undefined;
let mainPortProto: string | undefined;
if (container.Ports && container.Ports.length > 0) {
const WEB_UI_PORTS = [32400, 8989, 7878, 9696, 5055, 8080, 80, 443, 3000, 9000];
const IGNORE_PORTS = [1900, 53, 22];
let match = container.Ports.find(p => WEB_UI_PORTS.includes(p.PrivatePort));
if (!match) match = container.Ports.find(p => WEB_UI_PORTS.includes(p.PublicPort));
if (!match) match = container.Ports.find(p => !IGNORE_PORTS.includes(p.PrivatePort) && !IGNORE_PORTS.includes(p.PublicPort));
const chosen = match || container.Ports[0];
mainPort = chosen.PublicPort;
mainPortPrivate = chosen.PrivatePort;
mainPortProto = 'tcp';
}
const containerName = container?.Names?.[0]?.replace(/^\//, '') || container?.Id?.slice(0, 12) || 'container';
const isActive = container.State === 'running' || container.State === 'paused';
const health = container.healthStatus;
const uptime = isActive ? extractUptime(container.Status) : null;
const hcLabel = healthcheckLabel(health);
const stats = containerStats[container?.Id];
const history = stats?.history;
const badgeClass = health === 'unhealthy' || !isActive
? 'bg-destructive text-destructive-foreground'
: health === 'starting'
? 'bg-warning text-warning-foreground'
: 'bg-success text-success-foreground';
const badgeGlyph = health === 'unhealthy' || !isActive ? '✗' : health === 'starting' ? '…' : '✓';
const sparkStroke = health === 'unhealthy' ? 'var(--destructive)' : health === 'starting' ? 'var(--warning)' : 'var(--chart-1)';
return (
<div key={container?.Id || Math.random()} className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel px-3 py-2.5">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 min-w-0 flex-1">
<div className={cn('mt-1 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10px] font-bold', badgeClass)}>
{badgeGlyph}
</div>
<div className="flex min-w-0 flex-col gap-0.5">
<div className="truncate font-mono text-sm text-foreground">{containerName}</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 font-mono text-[11px] text-stat-subtitle">
{uptime ? <span>{uptime}</span> : <span>{(container.State || 'unknown').toLowerCase()}</span>}
{hcLabel ? <><span>·</span><span>{hcLabel}</span></> : null}
{mainPort && mainPortPrivate ? (
<>
<span>·</span>
<span>{mainPort} {mainPortPrivate}/{mainPortProto}</span>
<button
type="button"
onClick={() => {
const host = activeNode?.type === 'remote' && activeNode?.api_url
? new URL(activeNode.api_url).hostname
: window.location.hostname;
window.open(`http://${host}:${mainPort}`, '_blank');
}}
className="inline-flex items-center gap-1 text-brand hover:underline"
>
open <ArrowUpRight className="h-3 w-3" strokeWidth={1.5} />
</button>
</>
) : null}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
size="icon"
variant="ghost"
className="h-7 w-7 rounded-md"
onClick={() => openLogViewer(container?.Id, containerName)}
disabled={!isActive}
aria-label="View logs"
>
<ScrollText className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button>
{isAdmin && (
<Button
size="icon"
variant="ghost"
className="h-7 w-7 rounded-md"
onClick={() => openBashModal(container?.Id, containerName)}
disabled={!isActive}
aria-label="Open bash shell"
>
<Terminal className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button>
)}
{container.Service && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 rounded-md"
aria-label="Service actions"
>
<MoreVertical className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{isActive ? (
<>
<DropdownMenuItem onSelect={() => serviceAction('restart', container.Service!)}>
Restart service
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => serviceAction('stop', container.Service!)}>
Stop service
</DropdownMenuItem>
</>
) : (
<DropdownMenuItem onSelect={() => serviceAction('start', container.Service!)}>
Start service
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
{isActive ? (
<div className="mt-2 grid grid-cols-3 gap-2">
<div className="flex items-center gap-2 rounded-md bg-background/60 px-2 py-1.5">
<div className="flex flex-col">
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">cpu</span>
<span className="font-mono text-xs tabular-nums text-foreground">{stats?.cpu ?? '-'}</span>
</div>
<div className="ml-auto h-5 w-16">
<Sparkline points={history?.cpu ?? []} stroke={sparkStroke} fill={sparkStroke} showPeak={false} />
</div>
</div>
<div className="flex items-center gap-2 rounded-md bg-background/60 px-2 py-1.5">
<div className="flex flex-col">
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">mem</span>
<span className="font-mono text-xs tabular-nums text-foreground">{stats?.ram ?? '-'}</span>
</div>
<div className="ml-auto h-5 w-16">
<Sparkline points={history?.mem ?? []} stroke={sparkStroke} fill={sparkStroke} showPeak={false} />
</div>
</div>
<div className="flex items-center gap-2 rounded-md bg-background/60 px-2 py-1.5">
<div className="flex flex-col">
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">net i/o</span>
<span className="font-mono text-xs tabular-nums text-foreground">{stats?.net ?? '-'}</span>
</div>
<div className="ml-auto h-5 w-16">
<Sparkline points={history?.netIn ?? []} stroke={sparkStroke} fill={sparkStroke} showPeak={false} />
</div>
</div>
</div>
) : null}
</div>
);
})}
</div>
)}
</div>
</CardContent>
</Card>
{/* Logs Section (fills remaining left-column height) */}
<div className="flex-1 min-h-0 flex flex-col gap-2 overflow-hidden">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-stat-subtitle">Logs</h3>
<div className="inline-flex rounded-md border border-muted bg-muted/30 p-0.5">
<button
type="button"
onClick={() => setLogsMode('structured')}
className={cn(
'rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors',
logsMode === 'structured' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground',
)}
>
Structured
</button>
<button
type="button"
onClick={() => setLogsMode('raw')}
className={cn(
'rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors',
logsMode === 'raw' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground',
)}
>
Raw terminal
</button>
</div>
</div>
{logsMode === 'structured' ? (
<ErrorBoundary>
<StructuredLogViewer stackName={stackName} />
</ErrorBoundary>
) : (
<div className="flex-1 rounded-xl overflow-hidden border border-muted bg-black p-3 shadow-[inset_0_2px_4px_0_oklch(0_0_0/0.4)]">
<div className="h-full">
<ErrorBoundary>
<TerminalComponent stackName={stackName} />
</ErrorBoundary>
</div>
</div>
)}
</div>
</div>
{/* Right column: anatomy panel by default, Monaco editor when editing */}
{editingCompose ? (
<Card className="rounded-xl border-muted overflow-hidden flex flex-col h-full min-h-0 bg-card">
<div className="p-4 border-b border-muted flex items-center justify-between shrink-0">
<div className="flex items-center gap-4">
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as 'compose' | 'env' | 'files')}>
<TabsList>
<TabsHighlight className="rounded-md bg-glass-highlight" transition={springs.snappy}>
<TabsHighlightItem value="compose">
<TabsTrigger value="compose">compose.yaml</TabsTrigger>
</TabsHighlightItem>
<TabsHighlightItem value="env">
<TabsTrigger value="env" disabled={!envExists}>.env</TabsTrigger>
</TabsHighlightItem>
<TabsHighlightItem value="files">
<TabsTrigger value="files">
<FolderOpen className="w-3.5 h-3.5 mr-1" strokeWidth={1.5} />
Files
</TabsTrigger>
</TabsHighlightItem>
</TabsHighlight>
</TabsList>
</Tabs>
{activeTab === 'env' && envFiles.length > 1 && (
<Select value={selectedEnvFile} onValueChange={changeEnvFile} disabled={isEditing || isFileLoading}>
<SelectTrigger className="h-9 text-xs bg-muted border-none min-w-[200px]">
<SelectValue placeholder="Select environment file" />
</SelectTrigger>
<SelectContent>
{envFiles.map((file) => (
<SelectItem key={file} value={file} className="text-xs">
{file.split('/').pop()}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div className="flex items-center gap-2">
{activeTab !== 'files' && can('stack:edit', 'stack', stackName) && (
<>
<Button
size="sm"
variant="outline"
className="rounded-lg relative"
onClick={() => setGitSourceOpen(true)}
>
<GitBranch className="w-4 h-4 mr-2" strokeWidth={1.5} />
Git Source
{gitSourcePendingMap[stackName] && (
<span className="absolute -top-1 -right-1 w-2.5 h-2.5 rounded-full bg-brand animate-pulse" />
)}
</Button>
{!isEditing ? (
<Button size="sm" variant="default" className="rounded-lg" onClick={enterEditMode}>
<Pencil className="w-4 h-4 mr-2" />
Edit
</Button>
) : (
<div className="flex items-center">
<Button size="sm" variant="default" className="rounded-l-lg rounded-r-none" onClick={requestSaveAndDeploy} disabled={loadingAction === 'deploy'}>
<Rocket className="w-4 h-4 mr-2" strokeWidth={1.5} />
Save & Deploy
</Button>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="default" className="rounded-r-lg rounded-l-none border-l border-primary-foreground/20 px-1.5" disabled={loadingAction === 'deploy'}>
<ChevronDown className="w-3.5 h-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={requestSave}>
<Save className="w-4 h-4 mr-2" strokeWidth={1.5} />
Save Only
</DropdownMenuItem>
<DropdownMenuItem onClick={discardChanges} className="text-destructive/80 focus:text-destructive">
<X className="w-4 h-4 mr-2" strokeWidth={1.5} />
Discard Changes
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</>
)}
<Button
size="sm"
variant="ghost"
className="rounded-lg h-8 w-8 p-0"
onClick={() => {
if (isEditing) {
discardChanges();
}
setEditingCompose(false);
}}
aria-label="Close editor"
>
<X className="w-4 h-4" strokeWidth={1.5} />
</Button>
</div>
</div>
<div className="flex-1 min-h-0 flex flex-col">
{activeTab === 'files' ? (
<StackFileExplorer
stackName={stackName}
canEdit={can('stack:edit', 'stack', stackName)}
isDarkMode={isDarkMode}
onNavigateToCompose={() => setActiveTab('compose')}
onNavigateToEnv={() => setActiveTab('env')}
/>
) : (
<>
{activeTab === 'env' && (
<div className="bg-brand/8 border-b border-brand/20 px-4 py-2 flex items-center gap-2 text-xs text-brand">
<span>
Variables defined here are automatically available for substitution in your compose.yaml (e.g., <code className="bg-background px-1 rounded text-[10px]">${'{}'}VAR</code>). To pass them directly into your container, you must add <code className="bg-background px-1 rounded text-[10px]">env_file: - .env</code> to your service definition.
</span>
</div>
)}
<div className="flex-1 min-h-0 overflow-hidden">
{!isFileLoading && (
<Suspense fallback={<div className="w-full h-full" aria-busy="true" />}>
<Editor
height="100%"
language={activeTab === 'compose' ? 'yaml' : 'plaintext'}
theme={isDarkMode ? 'vs-dark' : 'vs'}
value={activeTab === 'compose' ? safeContent : safeEnvContent}
onMount={(editor) => { monacoEditorRef.current = editor; }}
onChange={(value) => {
if (!isEditing) return; // Prevent changes in view mode
if (activeTab === 'compose') {
setContent(value || '');
} else {
setEnvContent(value || '');
}
}}
options={{
minimap: { enabled: false },
fontFamily: "'Geist Mono', monospace",
fontSize: 14,
padding: { top: 10 },
scrollBeyondLastLine: false,
readOnly: !isEditing || !can('stack:edit', 'stack', stackName),
}}
/>
</Suspense>
)}
{isFileLoading && (
<div className="flex items-center justify-center h-full text-muted-foreground">
Loading...
</div>
)}
</div>
</>
)}
</div>
</Card>
) : (
<StackAnatomyPanel
stackName={stackName}
content={content}
envContent={envContent}
selectedEnvFile={selectedEnvFile}
gitSourcePending={Boolean(gitSourcePendingMap[stackName])}
onEditCompose={() => setEditingCompose(true)}
onOpenFiles={() => { setEditingCompose(true); setActiveTab('files'); }}
onOpenGitSource={() => setGitSourceOpen(true)}
onApplyUpdate={() => { void updateStack(); }}
canEdit={can('stack:edit', 'stack', stackName)}
notifications={notifications}
/>
)}
</div>
</ErrorBoundary>
);
}