"use client"; import { useParams } from "next/navigation"; import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { Loader2 } from "lucide-react"; import { useAnatomy } from "@/components/project/use-anatomy"; import { usePreviewBridge } from "@/components/project/preview-bridge-context"; import { usePreviewToolbarStore } from "@/components/project/preview-toolbar/preview-toolbar-state"; const SAME_ORIGIN_SANDBOX = "allow-scripts allow-forms allow-same-origin allow-popups allow-modals allow-downloads" as const; function sandboxIframe(src: string, origin: string): boolean { if (!src.startsWith("http://") && !src.startsWith("https://")) return true; try { return origin.length > 0 && new URL(src).origin === origin; } catch { return true; } } /** Elapsed time since an ISO string, formatted as "1m 23s". Capped at 59m 59s. */ function useElapsed(sinceIso: string | undefined) { const [elapsed, setElapsed] = useState(""); useEffect(() => { if (!sinceIso) return; const update = () => { const ms = Date.now() - new Date(sinceIso).getTime(); if (ms < 0) return; const s = Math.floor(ms / 1000); const m = Math.floor(s / 60); if (m > 59) { setElapsed("> 1h"); } else { setElapsed(m > 0 ? `${m}m ${s % 60}s` : `${s}s`); } }; update(); const id = setInterval(update, 1000); return () => { clearInterval(id); setElapsed(""); }; }, [sinceIso]); return elapsed; } export default function PreviewTab() { const params = useParams(); const projectId = params.projectId as string; // Poll every 5s so state transitions (starting→running, build complete, etc.) // surface without a manual refresh. const { anatomy, loading } = useAnatomy(projectId, { pollMs: 5000 }); const previews = anatomy?.hosting.previews ?? []; const [now, setNow] = useState(() => Date.now()); useEffect(() => { const id = setInterval(() => setNow(Date.now()), 10000); return () => clearInterval(id); }, []); // Identify all valid running/starting previews (ignoring stale starting ones) const validPreviews = previews .filter( (p) => p.state === "running" || (p.state === "starting" && now - new Date(p.startedAt).getTime() < 15 * 60 * 1000), ) .sort((a, b) => a.port - b.port); // sort ports ascending const [selectedPort, setSelectedPort] = useState(3000); // Auto-select logic if selectedPort is not in validPreviews useEffect(() => { if (validPreviews.length === 0) return; const hasSelected = validPreviews.some((p) => p.port === selectedPort); if (!hasSelected) { // Prefer 3000 if available, else pick the first one const fallback = validPreviews.find((p) => p.port === 3000) ?? validPreviews[0]; if (fallback) setSelectedPort(fallback.port); } }, [validPreviews, selectedPort]); // Derive the currently selected preview const activePreview = validPreviews.find((p) => p.port === selectedPort); // Split it into running and starting like before const primaryRunning = activePreview?.state === "running" ? activePreview : undefined; const primaryStarting = activePreview?.state === "starting" ? activePreview : undefined; // Derive in-flight / recently-failed build from prod apps. const liveApps = anatomy?.hosting.live ?? []; const inFlightApp = liveApps.find((a) => a.inFlightBuild); const failedApp = !inFlightApp ? liveApps.find((a) => a.lastBuild?.status === "failed") : undefined; // Fallback URL — the last deployed production app. Shown as a link while the // dev server is warming up so the user has something to interact with. const fallbackFqdn = liveApps.find((a) => a.fqdn && a.status === "running")?.fqdn ?? null; const fallbackUrl = fallbackFqdn ? fallbackFqdn.startsWith("http") ? fallbackFqdn : `https://${fallbackFqdn}` : null; // ── Auto-ensure: fire a background restart when the pane loads and finds // no running dev server, but there's a previous config to restart from. const ensureCalledRef = useRef(false); const [ensureStatus, setEnsureStatus] = useState< "idle" | "calling" | "starting" | "no_history" | "error" >("idle"); useEffect(() => { // Only trigger once per mount, and only when anatomy has loaded with no running server. if (ensureCalledRef.current) return; if (loading || !anatomy) return; if (primaryRunning || primaryStarting) return; // already up or already starting ensureCalledRef.current = true; fetch(`/api/projects/${projectId}/dev-server/ensure`, { method: "POST", credentials: "include", }) .then((r) => r.json()) .then((data: { status?: string }) => { if (data.status === "no_history" || data.status === "no_container") { setEnsureStatus("no_history"); } else if (data.status === "starting" || data.status === "running") { setEnsureStatus("starting"); // The 5s anatomy poll will pick up the new 'starting' row and // transition the pane automatically — no extra work needed here. } else { setEnsureStatus("idle"); } }) .catch(() => setEnsureStatus("error")); }, [loading, anatomy, primaryRunning, primaryStarting, projectId]); const [iframeSrc, setIframeSrc] = useState(null); const iframeDomRef = useRef(null); const bridge = usePreviewBridge(); const origin = typeof window !== "undefined" ? window.location.origin : ""; const deviceMode = usePreviewToolbarStore((s) => s.deviceMode); const refreshKey = usePreviewToolbarStore((s) => s.refreshKey); // When the user clicks the manual refresh button in the toolbar, we don't // just want to reload the iframe — we also want to trigger the same ghost/zombie // check as the initial mount, in case the server died while they were looking at it. const prevRefreshKeyRef = useRef(refreshKey); useEffect(() => { if (refreshKey === prevRefreshKeyRef.current) return; prevRefreshKeyRef.current = refreshKey; // Reset the ensure called flag so the ensure effect (below) fires again. ensureCalledRef.current = false; }, [refreshKey]); useLayoutEffect(() => { setIframeSrc(primaryRunning?.url ?? null); }, [primaryRunning?.url]); useEffect(() => { if (!bridge || !iframeSrc || !iframeDomRef.current) return; bridge.registerPreviewIframe(iframeDomRef.current, iframeSrc); }, [bridge, iframeSrc]); const [isForceStarting, setIsForceStarting] = useState(false); // Determine which empty state to show. const emptyContent = (() => { if (loading && !anatomy) return ; if (inFlightApp) return ; if (failedApp) return ; // Dev server is in the process of booting (either picked up from anatomy // or we just fired the ensure endpoint and are waiting for the DB row). if (primaryStarting) { return ( ); } if ( ensureStatus === "calling" || ensureStatus === "starting" || isForceStarting ) { return ( ); } // Never had a dev server — needs the AI to start one. return ( { setIsForceStarting(true); fetch( `/api/projects/${projectId}/dev-server/ensure?forceStart=true`, { method: "POST", }, ).catch(() => setIsForceStarting(false)); }} /> ); })(); return (
{validPreviews.length > 1 && (
{validPreviews.map((p) => ( ))}
)}
{deviceMode === "mobile" && } {iframeSrc ? (