import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AlertTriangle, ExternalLink, Eye, Loader2, Monitor, Play, RefreshCw, RotateCw, ShieldAlert, Square } from "lucide-react"; import "./DevServerView.css"; import type { DetectedDevServerCommand } from "../api"; import { useDevServer } from "../hooks/useDevServer"; import { useDevServerLogs } from "../hooks/useDevServerLogs"; import { usePreviewEmbed } from "../hooks/usePreviewEmbed"; import type { ToastType } from "../hooks/useToast"; import { DevServerLogViewer } from "./DevServerLogViewer"; import { PreviewIframe } from "./PreviewIframe"; interface DevServerViewProps { addToast: (msg: string, type?: ToastType) => void; projectId?: string; } type PreviewMode = "embedded" | "external"; interface StatusBadgeConfig { className: string; label: string; } const STATUS_BADGE_CONFIG: Record<"stopped" | "starting" | "running" | "failed" | "stopping", StatusBadgeConfig> = { stopped: { className: "dev-server-status-badge--stopped", label: "Stopped" }, starting: { className: "dev-server-status-badge--starting", label: "Starting..." }, running: { className: "dev-server-status-badge--running", label: "Running" }, stopping: { className: "dev-server-status-badge--starting", label: "Stopping..." }, failed: { className: "dev-server-status-badge--failed", label: "Failed" }, }; function normalizeError(error: unknown): string { return error instanceof Error ? error.message : String(error); } function normalizeCwdToSource(cwd: string): string { return cwd === "." ? "root" : cwd; } function normalizeSourceToCwd(source: string | null | undefined): string | null { if (!source) { return null; } return source === "root" ? "." : source; } function candidateMatchesSelection(candidate: DetectedDevServerCommand, selectedScript: string | null, selectedSource: string | null): boolean { if (!selectedScript) { return false; } if (candidate.scriptName !== selectedScript) { return false; } if (!selectedSource) { return true; } return normalizeCwdToSource(candidate.cwd) === selectedSource; } function formatCandidateSource(candidate: DetectedDevServerCommand): string { // DetectedDevServerCommand doesn't have source/workspaceName, so use cwd-based approach if (candidate.cwd === ".") { return "root"; } return candidate.cwd; } function truncateCommand(command: string): string { const maxLength = 60; if (command.length <= maxLength) { return command; } return `${command.slice(0, maxLength)}…`; } export function DevServerView({ addToast, projectId }: DevServerViewProps) { const { session, detectedCommands, previewUrl, isLoading, error, startServer, stopServer, restartServer, setPreviewUrl, detectCommands, refresh, } = useDevServer(projectId); const status = session?.status ?? "stopped"; const isRunning = status === "running" || status === "starting"; const statusBadge = STATUS_BADGE_CONFIG[status] ?? STATUS_BADGE_CONFIG.stopped; const { entries: logEntries, loading: logsLoading, loadingMore: logsLoadingMore, hasMore: logsHasMore, total: logsTotal, loadMore: loadMoreLogs, } = useDevServerLogs(projectId, Boolean(projectId)); const effectivePreviewUrl = previewUrl; const selectedSource = session?.config?.cwd ?? null; const [showCandidates, setShowCandidates] = useState(true); const [commandInput, setCommandInput] = useState(""); const [previewInput, setPreviewInput] = useState(""); const [selectedScript, setSelectedScript] = useState(null); const [actionInFlight, setActionInFlight] = useState<"start" | "stop" | "restart" | "preview" | null>(null); const [previewMode, setPreviewMode] = useState("embedded"); const previewEmbedUrl = previewMode === "embedded" ? effectivePreviewUrl : null; const { embedStatus, setEmbedStatus, resetEmbedStatus, iframeRef, isEmbedded, isBlocked, blockReason, retry, } = usePreviewEmbed(previewEmbedUrl); const [showFallback, setShowFallback] = useState(false); const prevStatusRef = useRef(embedStatus); useEffect(() => { const hasTransitioned = prevStatusRef.current !== embedStatus; if (isBlocked && hasTransitioned) { setShowFallback(true); } if (embedStatus === "embedded") { setShowFallback(false); } prevStatusRef.current = embedStatus; }, [embedStatus, isBlocked]); useEffect(() => { setShowFallback(false); }, [effectivePreviewUrl]); const selectedCandidate = useMemo(() => { if (!selectedScript) { return null; } const selectedCwd = normalizeSourceToCwd(selectedSource); return detectedCommands.find((candidate) => { if (candidate.scriptName !== selectedScript) { return false; } if (selectedCwd && candidate.cwd !== selectedCwd) { return false; } if (session?.config?.command && candidate.command !== session.config.command) { return false; } return true; }) ?? detectedCommands.find((candidate) => candidateMatchesSelection(candidate, selectedScript, selectedSource)) ?? null; }, [detectedCommands, session?.config?.command, selectedScript, selectedSource]); useEffect(() => { if (typeof detectCommands !== "function") { return; } void detectCommands().catch((detectError: unknown) => { addToast(normalizeError(detectError), "error"); }); }, [addToast, detectCommands]); useEffect(() => { if (selectedScript) { setShowCandidates(false); return; } setShowCandidates(true); }, [selectedScript]); useEffect(() => { if (session?.status === "running" || session?.status === "starting") { if (session.config?.command?.trim().length > 0) { setCommandInput(session.config.command); } return; } if (selectedCandidate) { setCommandInput(selectedCandidate.command); return; } if (detectedCommands.length > 0) { setCommandInput((current) => (current.trim().length > 0 ? current : detectedCommands[0]?.command ?? "")); } }, [detectedCommands, selectedCandidate, session?.config?.command, session?.status]); useEffect(() => { setPreviewInput(effectivePreviewUrl ?? ""); }, [effectivePreviewUrl]); const handleOpenInNewTab = useCallback(() => { if (!effectivePreviewUrl) { return; } window.open(effectivePreviewUrl, "_blank", "noopener,noreferrer"); }, [effectivePreviewUrl]); const handleRetryEmbeddedPreview = useCallback(() => { setShowFallback(false); retry(); }, [retry]); const handleRefreshPreview = useCallback(() => { try { const iframeElement = iframeRef.current; if (iframeElement?.contentWindow) { iframeElement.contentWindow.location.reload(); setShowFallback(false); resetEmbedStatus(); return; } } catch { // Cross-origin reload access can throw. Fall through to cache-buster reload. } if (!effectivePreviewUrl || !iframeRef.current) { return; } try { const refreshedUrl = new URL(effectivePreviewUrl); refreshedUrl.searchParams.set("_t", Date.now().toString()); iframeRef.current.src = refreshedUrl.toString(); setShowFallback(false); resetEmbedStatus(); } catch { iframeRef.current.src = effectivePreviewUrl; setShowFallback(false); resetEmbedStatus(); } }, [effectivePreviewUrl, iframeRef, resetEmbedStatus]); const runAction = useCallback(async (kind: "start" | "stop" | "restart" | "preview", action: () => Promise, successMessage: string) => { setActionInFlight(kind); try { await action(); addToast(successMessage, "success"); } catch (actionError) { addToast(normalizeError(actionError), "error"); } finally { setActionInFlight(null); } }, [addToast]); const handleSelectCandidate = useCallback((candidate: DetectedDevServerCommand) => { setSelectedScript(candidate.scriptName); setShowCandidates(false); setCommandInput(candidate.command); addToast(`Selected ${candidate.scriptName} script.`, "success"); }, [addToast]); const handleClearSelection = useCallback(() => { setSelectedScript(null); setShowCandidates(true); addToast("Cleared selected dev server script.", "success"); }, [addToast]); const handleStart = () => { const trimmedCommand = commandInput.trim(); if (trimmedCommand.length === 0) { addToast("Enter a command before starting the dev server.", "warning"); return; } const fallbackCwd = normalizeSourceToCwd(selectedSource) ?? "."; const cwd = selectedCandidate?.cwd ?? fallbackCwd; void runAction( "start", () => startServer(trimmedCommand, cwd), "Dev server started.", ); }; const handleStop = () => { void runAction("stop", stopServer, "Dev server stopped."); }; const handleRestart = () => { void runAction("restart", restartServer, "Dev server restarted."); }; const handleSetPreview = () => { const trimmed = previewInput.trim(); const nextUrl = trimmed.length > 0 ? trimmed : null; void runAction( "preview", () => setPreviewUrl(nextUrl), nextUrl ? "Preview URL updated." : "Preview URL override cleared.", ); }; const handleRetry = useCallback(() => { if (error) { void refresh(); } }, [error, refresh]); const isManualPreviewOverride = false; // With session model, previewUrl is always auto-detected const startDisabled = status === "starting" || status === "running" || actionInFlight !== null; const stopDisabled = status === "stopped" || actionInFlight !== null; const restartDisabled = status === "stopped" || status === "starting" || actionInFlight !== null; return (

Dev Server

{statusBadge.label}

Configuration

{isLoading && Loading...}
{isLoading && !session && detectedCommands.length === 0 && (
Loading dev server configuration...
)} {error && (

{error}

)}

Script Selection

{selectedScript && (
{selectedScript} {selectedSource ?? "root"}
)} {showCandidates && detectedCommands.length === 0 && (

No dev server scripts detected. Check that your project has a package.json with a dev, start, or similar script.

)} {showCandidates && detectedCommands.length > 0 && (
{detectedCommands.map((candidate) => { const isSelected = candidateMatchesSelection(candidate, selectedScript, selectedSource); return ( ); })}
)}
setCommandInput(event.target.value)} placeholder="pnpm dev" data-testid="dev-server-command-input" readOnly={status === "running" || status === "starting"} />
{(status === "running" || status === "starting") && session && (
Running command {session.config?.command ?? commandInput}
)}
setPreviewInput(event.target.value)} placeholder="http://localhost:3000" data-testid="dev-server-preview-input" />
{effectivePreviewUrl && (

Auto-detected: {effectivePreviewUrl}

)}

Logs

{logsTotal ?? logEntries.length} lines
Preview
{isManualPreviewOverride ? "Manual" : "Auto"} {effectivePreviewUrl ? ` · ${effectivePreviewUrl}` : " · Not available"}
{!effectivePreviewUrl && !isRunning && (

Start a dev server to see a live preview here.

)} {!effectivePreviewUrl && isRunning && (

No preview URL detected. Start the dev server or set a manual URL to preview your app.

)} {effectivePreviewUrl && previewMode === "external" && (

Embedded preview is disabled. Open your app in a separate browser tab.

)} {effectivePreviewUrl && previewMode === "embedded" && showFallback && isBlocked && (
{embedStatus === "error" ?
)} {effectivePreviewUrl && previewMode === "embedded" && !showFallback && ( )}
); }