import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ExternalLink, Loader2, Monitor, Play, RotateCw, Square } from "lucide-react"; import { useDevServer } from "../hooks/useDevServer"; import type { DevServerCandidate } from "../api"; import type { ToastType } from "../hooks/useToast"; 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 sanitizeLogLine(line: string): string { return line.replace(/\x1b\[[0-9;]*m/g, ""); } function candidateKey(candidate: DevServerCandidate): string { return `${candidate.cwd}::${candidate.scriptName}::${candidate.command}`; } function normalizeError(error: unknown): string { return error instanceof Error ? error.message : String(error); } export function DevServerView({ addToast, projectId }: DevServerViewProps) { const { candidates, serverState, logs, start, stop, restart, setPreviewUrl, loading, error, } = useDevServer(projectId); const status = serverState?.status ?? "stopped"; const statusBadge = STATUS_BADGE_CONFIG[status] ?? STATUS_BADGE_CONFIG.stopped; const previewUrl = serverState?.manualPreviewUrl ?? serverState?.previewUrl ?? null; const [selectedCandidateId, setSelectedCandidateId] = useState(""); const [commandInput, setCommandInput] = useState(""); const [previewInput, setPreviewInput] = useState(""); const [actionInFlight, setActionInFlight] = useState<"start" | "stop" | "restart" | "preview" | null>(null); const [previewMode, setPreviewMode] = useState("embedded"); const [iframeLoading, setIframeLoading] = useState(false); const [iframeError, setIframeError] = useState(false); const iframeTimeoutRef = useRef(null); const logContainerRef = useRef(null); const stickToBottomRef = useRef(true); const selectedCandidate = useMemo( () => candidates.find((candidate) => candidateKey(candidate) === selectedCandidateId) ?? null, [candidates, selectedCandidateId], ); const renderedLogs = useMemo(() => logs.map(sanitizeLogLine), [logs]); const clearIframeTimeout = useCallback(() => { if (iframeTimeoutRef.current !== null) { window.clearTimeout(iframeTimeoutRef.current); iframeTimeoutRef.current = null; } }, []); useEffect(() => { if (candidates.length === 0) { setSelectedCandidateId(""); return; } const hasSelectedCandidate = candidates.some((candidate) => candidateKey(candidate) === selectedCandidateId); if (hasSelectedCandidate) { return; } const first = candidates[0]; setSelectedCandidateId(candidateKey(first)); setCommandInput(first.command); }, [candidates, selectedCandidateId]); useEffect(() => { if (selectedCandidate) { setCommandInput(selectedCandidate.command); } }, [selectedCandidate]); useEffect(() => { if (serverState?.status === "running" || serverState?.status === "starting") { if (serverState.command.trim().length > 0) { setCommandInput(serverState.command); } } }, [serverState?.command, serverState?.status]); useEffect(() => { setPreviewInput(serverState?.manualPreviewUrl ?? ""); }, [serverState?.manualPreviewUrl]); useEffect(() => { clearIframeTimeout(); if (previewMode !== "embedded" || !previewUrl) { setIframeError(false); setIframeLoading(false); return; } setIframeError(false); setIframeLoading(true); iframeTimeoutRef.current = window.setTimeout(() => { setIframeLoading(false); setIframeError(true); }, 5000); return () => { clearIframeTimeout(); }; }, [clearIframeTimeout, previewMode, previewUrl]); useEffect(() => { const container = logContainerRef.current; if (!container || !stickToBottomRef.current) { return; } container.scrollTop = container.scrollHeight; }, [renderedLogs]); useEffect(() => { return () => { clearIframeTimeout(); }; }, [clearIframeTimeout]); const handleLogScroll = useCallback(() => { const container = logContainerRef.current; if (!container) { return; } const threshold = 24; const nearBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - threshold; stickToBottomRef.current = nearBottom; }, []); const handleSelectCandidate = (value: string) => { setSelectedCandidateId(value); const nextCandidate = candidates.find((candidate) => candidateKey(candidate) === value); if (nextCandidate) { setCommandInput(nextCandidate.command); } }; const openPreview = useCallback(() => { if (!previewUrl) { return; } window.open(previewUrl, "_blank", "noopener,noreferrer"); }, [previewUrl]); 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 handleStart = () => { const trimmedCommand = commandInput.trim(); if (trimmedCommand.length === 0) { addToast("Enter a command before starting the dev server.", "warning"); return; } const scriptName = selectedCandidate?.scriptName ?? "custom"; const cwd = selectedCandidate?.cwd ?? "."; 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(); void runAction( "preview", () => setPreviewUrl(trimmed.length > 0 ? trimmed : null), trimmed.length > 0 ? "Preview URL updated." : "Preview URL override cleared.", ); }; 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

{loading && Loading...}
{status === "stopped" && candidates.length > 0 && (
)} {status === "stopped" && candidates.length === 0 && (

No dev server scripts detected. Add a dev, start, or serve script to your package.json.

)}
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}
)} {error &&

{error}

}

Logs

{renderedLogs.length} lines
{renderedLogs.length === 0 ? (

No logs yet.

) : ( renderedLogs.map((line, index) => (
                  {line}
                
)) )}

Preview

{previewUrl && ( )}
setPreviewInput(event.target.value)} placeholder="https://localhost:5173" data-testid="dev-server-preview-input" />
{!previewUrl && (

Preview URL will appear once the dev server starts.

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

Embedded preview is disabled. Open the preview in a new tab.

)} {previewUrl && previewMode === "embedded" && (
{!iframeError && (