import { useEffect, useRef, useState } from "react"; import { Check, ChevronDown, Copy, ExternalLink, Loader2, Play, RotateCcw, Square, TriangleAlert, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; import { copyTextToClipboard } from "@/lib/clipboard"; export type WorkspaceServiceControlState = | "stopped" | "provisioning" | "starting" | "running" | "stopping" | "restarting" | "failed"; export type WorkspaceServiceControlAction = "start" | "stop" | "restart"; export type WorkspaceServiceControlEntry = { key: string; name: string; state: WorkspaceServiceControlState; healthStatus?: "unknown" | "healthy" | "unhealthy" | null; url?: string | null; port?: number | null; /** Short human-readable failure summary, e.g. "dev exited with code 1, 12s ago". */ failureDetail?: string | null; /** HTTPS exposure lifecycle, intentionally separate from process health. */ exposureState?: "pending" | "ready" | "failed" | "cleanup_pending" | "removed" | null; exposureDetail?: string | null; canStart?: boolean; }; export type WorkspaceServiceControlBarProps = { services: WorkspaceServiceControlEntry[]; /** serviceKey is null when the action targets all services (aggregate bar / popover footer). */ onAction: (action: WorkspaceServiceControlAction, serviceKey: string | null) => void; onViewLogs?: () => void; /** Optional link target for "Manage in Services tab" in the multi-service popover. */ onManageServices?: () => void; /** Initial open state for the multi-service popover (used by Storybook/static captures). */ defaultServicesOpen?: boolean; className?: string; }; const TRANSITIONAL_STATES: WorkspaceServiceControlState[] = ["provisioning", "starting", "stopping", "restarting"]; function isTransitional(state: WorkspaceServiceControlState) { return TRANSITIONAL_STATES.includes(state); } function formatServiceUrl(url: string | null | undefined) { if (!url) return null; return url.replace(/^https?:\/\//, "").replace(/\/$/, ""); } function statusMeta(entry: WorkspaceServiceControlEntry): { label: string; unhealthy: boolean } { switch (entry.state) { case "provisioning": return { label: "Provisioning…", unhealthy: false }; case "starting": return { label: "Starting…", unhealthy: false }; case "stopping": return { label: "Stopping…", unhealthy: false }; case "restarting": return { label: "Restarting…", unhealthy: false }; case "failed": return { label: "Failed", unhealthy: false }; case "running": return entry.healthStatus === "unhealthy" ? { label: "Unhealthy", unhealthy: true } : { label: "Running", unhealthy: false }; default: return { label: "Stopped", unhealthy: false }; } } function StatusIndicator({ entry, className }: { entry: WorkspaceServiceControlEntry; className?: string }) { if (isTransitional(entry.state)) { return ; } if (entry.state === "failed") { return ; } const unhealthy = entry.state === "running" && entry.healthStatus === "unhealthy"; return ( ); } function CopyUrlButton({ url, disabled }: { url: string; disabled?: boolean }) { const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle"); const timeoutRef = useRef | null>(null); useEffect(() => () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); }, []); const copyLabel = copyState === "copied" ? "URL copied" : copyState === "failed" ? "Copy failed" : "Copy URL"; return ( ); } function UrlSegment({ entry, compact }: { entry: WorkspaceServiceControlEntry; compact?: boolean }) { const displayUrl = formatServiceUrl(entry.url) ?? (entry.port ? `:${entry.port}` : null); const live = entry.state === "running" && Boolean(entry.url); if (!displayUrl) { return no url; } return ( <> {live ? ( {displayUrl} ) : ( {displayUrl} )} ); } function ActionSlots({ entry, onAction, }: { entry: Pick; onAction: (action: WorkspaceServiceControlAction) => void; }) { const transitional = isTransitional(entry.state); const canStart = entry.canStart ?? true; if (entry.state === "stopped") { return ( ); } if (entry.state === "failed") { return ( <> ); } return ( <> ); } function ServiceDetail({ entry, onViewLogs, }: { entry: WorkspaceServiceControlEntry; onViewLogs?: () => void; }) { const detail = entry.exposureDetail ?? (entry.state === "failed" ? entry.failureDetail : null); if (!detail) return null; const exposureFailed = entry.exposureState === "failed" || entry.exposureState === "cleanup_pending"; return (
{detail} {onViewLogs && entry.state === "failed" ? ( <> · ) : null}
); } function SingleServiceBar({ entry, onAction, onViewLogs, className, }: { entry: WorkspaceServiceControlEntry; onAction: (action: WorkspaceServiceControlAction, serviceKey: string | null) => void; onViewLogs?: () => void; className?: string; }) { const meta = statusMeta(entry); return (
{meta.label}
onAction(action, entry.key)} />
); } function ServicePopoverRow({ entry, onAction, }: { entry: WorkspaceServiceControlEntry; onAction: (action: WorkspaceServiceControlAction, serviceKey: string | null) => void; }) { const meta = statusMeta(entry); const displayUrl = formatServiceUrl(entry.url); const live = entry.state === "running" && Boolean(entry.url); const exposureFailed = entry.exposureState === "failed" || entry.exposureState === "cleanup_pending"; const secondary = entry.exposureDetail ? entry.exposureDetail : live ? displayUrl : entry.state === "starting" && entry.port ? `starting on :${entry.port}…` : entry.state === "failed" && entry.failureDetail ? entry.failureDetail : `${meta.label.toLowerCase().replace(/…$/, "")}${entry.port ? ` · :${entry.port}` : ""}`; return (
{entry.name}
{live && entry.url ? ( <> {displayUrl} ) : ( {secondary} )}
onAction(action, entry.key)} />
); } function MultiServiceBar({ services, onAction, onManageServices, defaultServicesOpen, className, }: { services: WorkspaceServiceControlEntry[]; onAction: (action: WorkspaceServiceControlAction, serviceKey: string | null) => void; onManageServices?: () => void; defaultServicesOpen?: boolean; className?: string; }) { const [open, setOpen] = useState(defaultServicesOpen ?? false); const runningCount = services.filter((entry) => entry.state === "running").length; const anyTransitional = services.some((entry) => isTransitional(entry.state)); const anyFailed = services.some((entry) => entry.state === "failed"); const anyRunning = runningCount > 0; const primary = services.find((entry) => entry.state === "running" && entry.url) ?? null; const aggregateEntry: WorkspaceServiceControlEntry = { key: "__all__", name: "All services", state: anyTransitional ? "starting" : anyFailed ? "failed" : anyRunning ? "running" : "stopped", healthStatus: services.some((entry) => entry.state === "running" && entry.healthStatus === "unhealthy") ? "unhealthy" : "healthy", }; return (
event.preventDefault()}>
Services · {services.length}
{services.map((entry) => ( ))}
{onManageServices ? ( ) : null}
{primary ? ( <> {primary.name} ) : ( no url )}
onAction(action, null)} />
{primary ? (
) : null}
); } /** * Segmented control bar for execution-workspace services: status · URL · actions. * Geometry is identical in every state — transitions are announced by the status * segment (spinner + label) instead of buttons appearing and disappearing. */ export function WorkspaceServiceControlBar({ services, onAction, onViewLogs, onManageServices, defaultServicesOpen, className, }: WorkspaceServiceControlBarProps) { if (services.length === 0) return null; if (services.length === 1) { return ( ); } return ( ); }