import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AlertTriangle, ExternalLink, Eye, Loader2, Monitor, Play, RefreshCw, RotateCw, ShieldAlert, Square } from "lucide-react"; import type { DevServerCandidate } from "../api"; import { useDevServer } from "../hooks/useDevServer"; import { useDevServerConfig } from "../hooks/useDevServerConfig"; 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", 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" }, 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: DevServerCandidate, 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: DevServerCandidate): string { if (candidate.source === "root") { return "root"; } if (candidate.workspaceName) { return `${candidate.workspaceName} · ${candidate.source}`; } return candidate.source; } 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 { candidates, serverState, start, stop, restart, setPreviewUrl, loading, error, detect, } = useDevServer(projectId); const { config, loading: configLoading, error: configError, selectScript, clearSelection, setPreviewUrlOverride, refresh: refreshConfig, } = useDevServerConfig(projectId); const status = serverState?.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 = config?.previewUrlOverride ?? serverState?.manualPreviewUrl ?? serverState?.previewUrl ?? null; const detectedPreviewUrl = config?.detectedPreviewUrl ?? serverState?.previewUrl ?? null; const selectedSource = config?.selectedSource ?? null; const isManualPreviewOverride = Boolean(config?.previewUrlOverride ?? serverState?.manualPreviewUrl); const [showCandidates, setShowCandidates] = useState(true); const [commandInput, setCommandInput] = useState(""); const [previewInput, setPreviewInput] = useState(""); 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, embedContext, 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 (!config?.selectedScript) { return null; } const selectedCwd = normalizeSourceToCwd(config.selectedSource); return candidates.find((candidate) => { if (candidate.scriptName !== config.selectedScript) { return false; } if (selectedCwd && candidate.cwd !== selectedCwd) { return false; } if (config.selectedCommand && candidate.command !== config.selectedCommand) { return false; } return true; }) ?? candidates.find((candidate) => candidateMatchesSelection(candidate, config.selectedScript, config.selectedSource)) ?? null; }, [candidates, config?.selectedCommand, config?.selectedScript, config?.selectedSource]); useEffect(() => { if (typeof detect !== "function") { return; } void detect().catch((detectError) => { addToast(normalizeError(detectError), "error"); }); }, [addToast, detect]); useEffect(() => { if (config?.selectedScript) { setShowCandidates(false); return; } setShowCandidates(true); }, [config?.selectedScript]); useEffect(() => { if (serverState?.status === "running" || serverState?.status === "starting") { if (serverState.command.trim().length > 0) { setCommandInput(serverState.command); } return; } if (selectedCandidate) { setCommandInput(selectedCandidate.command); return; } if (config?.selectedCommand) { setCommandInput(config.selectedCommand); return; } if (candidates.length > 0) { setCommandInput((current) => (current.trim().length > 0 ? current : candidates[0]?.command ?? "")); } }, [candidates, config?.selectedCommand, selectedCandidate, serverState?.command, serverState?.status]); useEffect(() => { setPreviewInput(config?.previewUrlOverride ?? serverState?.manualPreviewUrl ?? ""); }, [config?.previewUrlOverride, serverState?.manualPreviewUrl]); 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: DevServerCandidate) => { void selectScript({ name: candidate.scriptName, command: candidate.command, source: normalizeCwdToSource(candidate.cwd), }).then(() => { setShowCandidates(false); setCommandInput(candidate.command); addToast(`Selected ${candidate.scriptName} script.`, "success"); }).catch((selectionError) => { addToast(normalizeError(selectionError), "error"); }); }, [addToast, selectScript]); const handleClearSelection = useCallback(() => { void clearSelection().then(() => { setShowCandidates(true); addToast("Cleared selected dev server script.", "success"); }).catch((clearError) => { addToast(normalizeError(clearError), "error"); }); }, [addToast, clearSelection]); const handleStart = () => { const trimmedCommand = commandInput.trim(); if (trimmedCommand.length === 0) { addToast("Enter a command before starting the dev server.", "warning"); return; } const fallbackCwd = normalizeSourceToCwd(config?.selectedSource) ?? "."; const scriptName = selectedCandidate?.scriptName ?? config?.selectedScript ?? "custom"; const cwd = selectedCandidate?.cwd ?? fallbackCwd; void runAction( "start", () => { if (selectedCandidate && trimmedCommand === selectedCandidate.command) { return start(selectedCandidate); } return start({ command: trimmedCommand, scriptName, cwd }); }, "Dev server started.", ); }; const handleStop = () => { void runAction("stop", stop, "Dev server stopped."); }; const handleRestart = () => { void runAction("restart", restart, "Dev server restarted."); }; const handleSetPreview = () => { const trimmed = previewInput.trim(); const nextUrl = trimmed.length > 0 ? trimmed : null; void runAction( "preview", async () => { await setPreviewUrlOverride(nextUrl); await setPreviewUrl(nextUrl); }, nextUrl ? "Preview URL updated." : "Preview URL override cleared.", ); }; const handleRetry = useCallback(() => { if (!configError && error) { window.location.reload(); return; } void refreshConfig(); }, [configError, error, refreshConfig]); const isLoading = loading || configLoading; const combinedError = configError ?? error; 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 && !config && candidates.length === 0 && (
Loading dev server configuration...
)} {combinedError && (

{combinedError}

)}

Script Selection

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

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

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

Auto-detected: {detectedPreviewUrl}

)}

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 && ( )}
); }