feat(FN-2260): merge fusion/fn-2260
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
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 type { DetectedDevServerCommand, DevServerState, DevServerSession } from "../api";
|
||||
import { useDevServer } from "../hooks/useDevServer";
|
||||
import { useDevServerConfig } from "../hooks/useDevServerConfig";
|
||||
import { useDevServerLogs } from "../hooks/useDevServerLogs";
|
||||
@@ -21,10 +21,11 @@ interface StatusBadgeConfig {
|
||||
label: string;
|
||||
}
|
||||
|
||||
const STATUS_BADGE_CONFIG: Record<"stopped" | "starting" | "running" | "failed", StatusBadgeConfig> = {
|
||||
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" },
|
||||
};
|
||||
|
||||
@@ -36,6 +37,32 @@ function normalizeCwdToSource(cwd: string): string {
|
||||
return cwd === "." ? "root" : cwd;
|
||||
}
|
||||
|
||||
function normalizeLegacyServerState(serverState: DevServerState): DevServerSession {
|
||||
return {
|
||||
config: {
|
||||
id: serverState.id ?? "default",
|
||||
name: serverState.name ?? "Dev Server",
|
||||
command: serverState.command ?? "",
|
||||
cwd: serverState.cwd ?? ".",
|
||||
},
|
||||
status: serverState.status as DevServerSession["status"],
|
||||
runtime: serverState.pid
|
||||
? {
|
||||
pid: serverState.pid,
|
||||
startedAt: serverState.startedAt ?? new Date().toISOString(),
|
||||
exitCode: serverState.exitCode ?? undefined,
|
||||
previewUrl: serverState.previewUrl,
|
||||
}
|
||||
: undefined,
|
||||
previewUrl: serverState.previewUrl ?? serverState.detectedUrl ?? serverState.manualUrl ?? undefined,
|
||||
logHistory: (serverState.logs ?? []).map((line) => ({
|
||||
timestamp: new Date().toISOString(),
|
||||
stream: line.startsWith("[stderr]") ? "stderr" as const : "stdout" as const,
|
||||
text: line.replace(/^\[stderr\]\s*/, ""),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSourceToCwd(source: string | null | undefined): string | null {
|
||||
if (!source) {
|
||||
return null;
|
||||
@@ -43,7 +70,7 @@ function normalizeSourceToCwd(source: string | null | undefined): string | null
|
||||
return source === "root" ? "." : source;
|
||||
}
|
||||
|
||||
function candidateMatchesSelection(candidate: DevServerCandidate, selectedScript: string | null, selectedSource: string | null): boolean {
|
||||
function candidateMatchesSelection(candidate: DetectedDevServerCommand, selectedScript: string | null, selectedSource: string | null): boolean {
|
||||
if (!selectedScript) {
|
||||
return false;
|
||||
}
|
||||
@@ -59,16 +86,13 @@ function candidateMatchesSelection(candidate: DevServerCandidate, selectedScript
|
||||
return normalizeCwdToSource(candidate.cwd) === selectedSource;
|
||||
}
|
||||
|
||||
function formatCandidateSource(candidate: DevServerCandidate): string {
|
||||
if (candidate.source === "root") {
|
||||
function formatCandidateSource(candidate: DetectedDevServerCommand): string {
|
||||
// DetectedDevServerCommand doesn't have source/workspaceName, so use cwd-based approach
|
||||
if (candidate.cwd === ".") {
|
||||
return "root";
|
||||
}
|
||||
|
||||
if (candidate.workspaceName) {
|
||||
return `${candidate.workspaceName} · ${candidate.source}`;
|
||||
}
|
||||
|
||||
return candidate.source;
|
||||
return candidate.cwd;
|
||||
}
|
||||
|
||||
function truncateCommand(command: string): string {
|
||||
@@ -81,18 +105,7 @@ function truncateCommand(command: string): string {
|
||||
}
|
||||
|
||||
export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
const {
|
||||
candidates,
|
||||
serverState,
|
||||
start,
|
||||
stop,
|
||||
restart,
|
||||
setPreviewUrl,
|
||||
loading,
|
||||
error,
|
||||
detect,
|
||||
} = useDevServer(projectId);
|
||||
|
||||
const devServer = useDevServer(projectId);
|
||||
const {
|
||||
config,
|
||||
loading: configLoading,
|
||||
@@ -103,7 +116,71 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
refresh: refreshConfig,
|
||||
} = useDevServerConfig(projectId);
|
||||
|
||||
const status = serverState?.status ?? "stopped";
|
||||
const legacyServerState = (devServer.serverState as DevServerState | null | undefined) ?? null;
|
||||
const session = devServer.session ?? (legacyServerState ? normalizeLegacyServerState(legacyServerState) : null);
|
||||
const detectedCommands = devServer.detectedCommands ?? devServer.candidates ?? [];
|
||||
const isLoading = devServer.isLoading ?? devServer.loading ?? configLoading;
|
||||
const error = devServer.error ?? configError ?? null;
|
||||
|
||||
const startServer = useCallback(async (command: string, cwd?: string) => {
|
||||
if (typeof devServer.startServer === "function") {
|
||||
await devServer.startServer(command, cwd);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof devServer.start === "function") {
|
||||
await devServer.start({ command, cwd });
|
||||
}
|
||||
}, [devServer]);
|
||||
|
||||
const stopServer = useCallback(async () => {
|
||||
if (typeof devServer.stopServer === "function") {
|
||||
await devServer.stopServer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof devServer.stop === "function") {
|
||||
await devServer.stop();
|
||||
}
|
||||
}, [devServer]);
|
||||
|
||||
const restartServer = useCallback(async () => {
|
||||
if (typeof devServer.restartServer === "function") {
|
||||
await devServer.restartServer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof devServer.restart === "function") {
|
||||
await devServer.restart();
|
||||
}
|
||||
}, [devServer]);
|
||||
|
||||
const detectCommands = useCallback(async () => {
|
||||
if (typeof devServer.detectCommands === "function") {
|
||||
await devServer.detectCommands();
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof devServer.detect === "function") {
|
||||
await devServer.detect();
|
||||
}
|
||||
}, [devServer]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (typeof devServer.refresh === "function") {
|
||||
await devServer.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof devServer.refreshStatus === "function") {
|
||||
await devServer.refreshStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
await refreshConfig();
|
||||
}, [devServer, refreshConfig]);
|
||||
|
||||
const status = session?.status ?? "stopped";
|
||||
const isRunning = status === "running" || status === "starting";
|
||||
const statusBadge = STATUS_BADGE_CONFIG[status] ?? STATUS_BADGE_CONFIG.stopped;
|
||||
|
||||
@@ -116,29 +193,28 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
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 manualPreviewUrl = config?.previewUrlOverride ?? legacyServerState?.manualPreviewUrl ?? null;
|
||||
const effectivePreviewUrl = manualPreviewUrl ?? devServer.previewUrl ?? session?.previewUrl ?? null;
|
||||
const selectedSource = config?.selectedSource ?? (session?.config?.cwd ? normalizeCwdToSource(session.config.cwd) : null);
|
||||
|
||||
const [showCandidates, setShowCandidates] = useState(true);
|
||||
const [commandInput, setCommandInput] = useState("");
|
||||
const [previewInput, setPreviewInput] = useState("");
|
||||
const [selectedScript, setSelectedScript] = useState<string | null>(null);
|
||||
const [actionInFlight, setActionInFlight] = useState<"start" | "stop" | "restart" | "preview" | null>(null);
|
||||
|
||||
const [previewMode, setPreviewMode] = useState<PreviewMode>("embedded");
|
||||
|
||||
const previewEmbedUrl = previewMode === "embedded" ? effectivePreviewUrl : null;
|
||||
const {
|
||||
embedStatus,
|
||||
setEmbedStatus,
|
||||
resetEmbedStatus,
|
||||
iframeRef,
|
||||
isEmbedded,
|
||||
isBlocked,
|
||||
embedContext,
|
||||
retry,
|
||||
} = usePreviewEmbed(previewEmbedUrl);
|
||||
const previewEmbed = usePreviewEmbed(previewEmbedUrl);
|
||||
const embedStatus = previewEmbed.embedStatus ?? "unknown";
|
||||
const setEmbedStatus = previewEmbed.setEmbedStatus ?? (() => undefined);
|
||||
const resetEmbedStatus = previewEmbed.resetEmbedStatus ?? (() => undefined);
|
||||
const iframeRef = previewEmbed.iframeRef ?? useRef<HTMLIFrameElement | null>(null);
|
||||
const isEmbedded = previewEmbed.isEmbedded ?? false;
|
||||
const isBlocked = previewEmbed.isBlocked ?? false;
|
||||
const blockReason = previewEmbed.blockReason ?? (previewEmbed as { embedContext?: string | null }).embedContext ?? null;
|
||||
const retry = previewEmbed.retry ?? (() => undefined);
|
||||
|
||||
const [showFallback, setShowFallback] = useState(false);
|
||||
const prevStatusRef = useRef(embedStatus);
|
||||
@@ -162,14 +238,14 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
}, [effectivePreviewUrl]);
|
||||
|
||||
const selectedCandidate = useMemo(() => {
|
||||
if (!config?.selectedScript) {
|
||||
if (!selectedScript) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedCwd = normalizeSourceToCwd(config.selectedSource);
|
||||
const selectedCwd = normalizeSourceToCwd(selectedSource);
|
||||
|
||||
return candidates.find((candidate) => {
|
||||
if (candidate.scriptName !== config.selectedScript) {
|
||||
return detectedCommands.find((candidate) => {
|
||||
if (candidate.scriptName !== selectedScript) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -177,39 +253,45 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (config.selectedCommand && candidate.command !== config.selectedCommand) {
|
||||
if (session?.config?.command && candidate.command !== session.config.command) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
?? candidates.find((candidate) => candidateMatchesSelection(candidate, config.selectedScript, config.selectedSource))
|
||||
?? detectedCommands.find((candidate) => candidateMatchesSelection(candidate, selectedScript, selectedSource))
|
||||
?? null;
|
||||
}, [candidates, config?.selectedCommand, config?.selectedScript, config?.selectedSource]);
|
||||
}, [detectedCommands, session?.config?.command, selectedScript, selectedSource]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof detect !== "function") {
|
||||
if (typeof detectCommands !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
void detect().catch((detectError) => {
|
||||
void detectCommands().catch((detectError: unknown) => {
|
||||
addToast(normalizeError(detectError), "error");
|
||||
});
|
||||
}, [addToast, detect]);
|
||||
}, [addToast, detectCommands]);
|
||||
|
||||
useEffect(() => {
|
||||
if (config?.selectedScript) {
|
||||
if (config?.selectedScript !== undefined) {
|
||||
setSelectedScript(config?.selectedScript ?? null);
|
||||
}
|
||||
}, [config?.selectedScript]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedScript) {
|
||||
setShowCandidates(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setShowCandidates(true);
|
||||
}, [config?.selectedScript]);
|
||||
}, [selectedScript]);
|
||||
|
||||
useEffect(() => {
|
||||
if (serverState?.status === "running" || serverState?.status === "starting") {
|
||||
if (serverState.command.trim().length > 0) {
|
||||
setCommandInput(serverState.command);
|
||||
if (session?.status === "running" || session?.status === "starting") {
|
||||
if (session.config?.command?.trim().length > 0) {
|
||||
setCommandInput(session.config.command);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -219,19 +301,14 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (config?.selectedCommand) {
|
||||
setCommandInput(config.selectedCommand);
|
||||
return;
|
||||
if (detectedCommands.length > 0) {
|
||||
setCommandInput((current) => (current.trim().length > 0 ? current : detectedCommands[0]?.command ?? ""));
|
||||
}
|
||||
|
||||
if (candidates.length > 0) {
|
||||
setCommandInput((current) => (current.trim().length > 0 ? current : candidates[0]?.command ?? ""));
|
||||
}
|
||||
}, [candidates, config?.selectedCommand, selectedCandidate, serverState?.command, serverState?.status]);
|
||||
}, [detectedCommands, selectedCandidate, session?.config?.command, session?.status]);
|
||||
|
||||
useEffect(() => {
|
||||
setPreviewInput(config?.previewUrlOverride ?? serverState?.manualPreviewUrl ?? "");
|
||||
}, [config?.previewUrlOverride, serverState?.manualPreviewUrl]);
|
||||
setPreviewInput(effectivePreviewUrl ?? "");
|
||||
}, [effectivePreviewUrl]);
|
||||
|
||||
const handleOpenInNewTab = useCallback(() => {
|
||||
if (!effectivePreviewUrl) {
|
||||
@@ -288,27 +365,40 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
}
|
||||
}, [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");
|
||||
});
|
||||
const handleSelectCandidate = useCallback((candidate: DetectedDevServerCommand) => {
|
||||
void (async () => {
|
||||
try {
|
||||
if (typeof selectScript === "function") {
|
||||
await selectScript({
|
||||
name: candidate.scriptName,
|
||||
command: candidate.command,
|
||||
source: normalizeCwdToSource(candidate.cwd),
|
||||
});
|
||||
}
|
||||
|
||||
setSelectedScript(candidate.scriptName);
|
||||
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");
|
||||
});
|
||||
void (async () => {
|
||||
try {
|
||||
if (typeof clearSelection === "function") {
|
||||
await clearSelection();
|
||||
}
|
||||
setSelectedScript(null);
|
||||
setShowCandidates(true);
|
||||
addToast("Cleared selected dev server script.", "success");
|
||||
} catch (clearError) {
|
||||
addToast(normalizeError(clearError), "error");
|
||||
}
|
||||
})();
|
||||
}, [addToast, clearSelection]);
|
||||
|
||||
const handleStart = () => {
|
||||
@@ -318,28 +408,23 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackCwd = normalizeSourceToCwd(config?.selectedSource) ?? ".";
|
||||
const scriptName = selectedCandidate?.scriptName ?? config?.selectedScript ?? "custom";
|
||||
const fallbackCwd = normalizeSourceToCwd(selectedSource) ?? ".";
|
||||
const scriptName = selectedCandidate?.scriptName ?? selectedScript ?? "custom";
|
||||
const cwd = selectedCandidate?.cwd ?? fallbackCwd;
|
||||
|
||||
void runAction(
|
||||
"start",
|
||||
() => {
|
||||
if (selectedCandidate && trimmedCommand === selectedCandidate.command) {
|
||||
return start(selectedCandidate);
|
||||
}
|
||||
return start({ command: trimmedCommand, scriptName, cwd });
|
||||
},
|
||||
() => startServer(trimmedCommand, cwd),
|
||||
"Dev server started.",
|
||||
);
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
void runAction("stop", stop, "Dev server stopped.");
|
||||
void runAction("stop", stopServer, "Dev server stopped.");
|
||||
};
|
||||
|
||||
const handleRestart = () => {
|
||||
void runAction("restart", restart, "Dev server restarted.");
|
||||
void runAction("restart", restartServer, "Dev server restarted.");
|
||||
};
|
||||
|
||||
const handleSetPreview = () => {
|
||||
@@ -349,24 +434,28 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
void runAction(
|
||||
"preview",
|
||||
async () => {
|
||||
await setPreviewUrlOverride(nextUrl);
|
||||
await setPreviewUrl(nextUrl);
|
||||
if (typeof setPreviewUrlOverride === "function") {
|
||||
await setPreviewUrlOverride(nextUrl);
|
||||
}
|
||||
if (typeof devServer.setPreviewUrl === "function") {
|
||||
await devServer.setPreviewUrl(nextUrl);
|
||||
return;
|
||||
}
|
||||
if (typeof devServer.setManualUrl === "function") {
|
||||
await devServer.setManualUrl(nextUrl);
|
||||
}
|
||||
},
|
||||
nextUrl ? "Preview URL updated." : "Preview URL override cleared.",
|
||||
);
|
||||
};
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
if (!configError && error) {
|
||||
window.location.reload();
|
||||
return;
|
||||
if (error) {
|
||||
void refresh();
|
||||
}
|
||||
}, [error, refresh]);
|
||||
|
||||
void refreshConfig();
|
||||
}, [configError, error, refreshConfig]);
|
||||
|
||||
const isLoading = loading || configLoading;
|
||||
const combinedError = configError ?? error;
|
||||
const isManualPreviewOverride = Boolean(manualPreviewUrl);
|
||||
|
||||
const startDisabled = status === "starting" || status === "running" || actionInFlight !== null;
|
||||
const stopDisabled = status === "stopped" || actionInFlight !== null;
|
||||
@@ -425,16 +514,16 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
{isLoading && <span className="dev-server-muted">Loading...</span>}
|
||||
</div>
|
||||
|
||||
{isLoading && !config && candidates.length === 0 && (
|
||||
{isLoading && !session && detectedCommands.length === 0 && (
|
||||
<div className="dev-server-loading-state" data-testid="dev-server-loading-state">
|
||||
<Loader2 size={16} className="dev-server-spin" />
|
||||
<span>Loading dev server configuration...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{combinedError && (
|
||||
{error && (
|
||||
<div className="dev-server-error-box" role="alert" data-testid="dev-server-error-box">
|
||||
<p>{combinedError}</p>
|
||||
<p>{error}</p>
|
||||
<button type="button" className="btn btn-sm" onClick={handleRetry}>Retry</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -442,9 +531,9 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
<div className="dev-server-section">
|
||||
<h3>Script Selection</h3>
|
||||
|
||||
{config?.selectedScript && (
|
||||
{selectedScript && (
|
||||
<div className="dev-server-selected" data-testid="dev-server-selected-summary">
|
||||
<span className="dev-server-candidate-name">{config.selectedScript}</span>
|
||||
<span className="dev-server-candidate-name">{selectedScript}</span>
|
||||
<span className="dev-server-candidate-source">{selectedSource ?? "root"}</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -465,16 +554,16 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCandidates && candidates.length === 0 && (
|
||||
{showCandidates && detectedCommands.length === 0 && (
|
||||
<p className="dev-server-empty-state" data-testid="dev-server-empty-candidates">
|
||||
No dev server scripts detected. Check that your project has a <code>package.json</code> with a <code>dev</code>, <code>start</code>, or similar script.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showCandidates && candidates.length > 0 && (
|
||||
{showCandidates && detectedCommands.length > 0 && (
|
||||
<div className="dev-server-candidates" data-testid="dev-server-candidates">
|
||||
{candidates.map((candidate) => {
|
||||
const isSelected = candidateMatchesSelection(candidate, config?.selectedScript ?? null, selectedSource);
|
||||
{detectedCommands.map((candidate) => {
|
||||
const isSelected = candidateMatchesSelection(candidate, selectedScript, selectedSource);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -506,10 +595,10 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(status === "running" || status === "starting") && serverState && (
|
||||
{(status === "running" || status === "starting") && session && (
|
||||
<div className="dev-server-current-command" data-testid="dev-server-current-command">
|
||||
<span className="dev-server-label">Running command</span>
|
||||
<code>{serverState.command}</code>
|
||||
<code>{session.config?.command ?? commandInput}</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -535,8 +624,8 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{detectedPreviewUrl && (
|
||||
<p className="dev-server-preview-hint">Auto-detected: {detectedPreviewUrl}</p>
|
||||
{effectivePreviewUrl && (
|
||||
<p className="dev-server-preview-hint">Auto-detected: {effectivePreviewUrl}</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -641,7 +730,7 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
<p className="devserver-preview-blocked-title">
|
||||
{embedStatus === "error" ? "Preview failed" : "Preview blocked"}
|
||||
</p>
|
||||
{embedContext && <p className="devserver-preview-blocked-context">{embedContext}</p>}
|
||||
{blockReason && <p className="devserver-preview-blocked-context">{blockReason}</p>}
|
||||
</div>
|
||||
<p className="devserver-preview-blocked-description">
|
||||
Open the preview in a new tab, or retry embedded mode after checking your server settings.
|
||||
@@ -673,7 +762,7 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||
embedStatus={embedStatus}
|
||||
onEmbedStatusChange={setEmbedStatus}
|
||||
iframeRef={iframeRef}
|
||||
embedContext={embedContext}
|
||||
blockReason={blockReason}
|
||||
onRetry={handleRetryEmbeddedPreview}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -7,9 +7,11 @@ export interface PreviewIframeProps {
|
||||
embedStatus: EmbedStatus;
|
||||
onEmbedStatusChange: (status: EmbedStatus) => void;
|
||||
iframeRef: RefObject<HTMLIFrameElement | null>;
|
||||
embedContext: string | null;
|
||||
blockReason: string | null;
|
||||
onRetry?: () => void;
|
||||
className?: string;
|
||||
/** @deprecated Use blockReason instead */
|
||||
embedContext?: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_IFRAME_CLASS = "devserver-preview-iframe";
|
||||
@@ -19,10 +21,14 @@ export function PreviewIframe({
|
||||
embedStatus,
|
||||
onEmbedStatusChange,
|
||||
iframeRef,
|
||||
embedContext,
|
||||
blockReason,
|
||||
onRetry,
|
||||
className = DEFAULT_IFRAME_CLASS,
|
||||
embedContext: deprecatedEmbedContext,
|
||||
}: PreviewIframeProps) {
|
||||
// Support both blockReason and legacy embedContext
|
||||
const context = blockReason ?? deprecatedEmbedContext ?? null;
|
||||
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -98,7 +104,7 @@ export function PreviewIframe({
|
||||
<ShieldAlert className="devserver-preview-blocked-icon" aria-hidden="true" />
|
||||
<div>
|
||||
<p className="devserver-preview-blocked-title">Preview cannot be embedded</p>
|
||||
{embedContext && <p className="devserver-preview-blocked-context">{embedContext}</p>}
|
||||
{context && <p className="devserver-preview-blocked-context">{context}</p>}
|
||||
</div>
|
||||
<p className="devserver-preview-blocked-description">You can view the preview in a separate browser tab.</p>
|
||||
<div className="devserver-preview-blocked-actions">
|
||||
@@ -127,7 +133,7 @@ export function PreviewIframe({
|
||||
<AlertTriangle className="devserver-preview-blocked-icon" aria-hidden="true" />
|
||||
<div>
|
||||
<p className="devserver-preview-blocked-title">Unable to load preview</p>
|
||||
{embedContext && <p className="devserver-preview-blocked-context">{embedContext}</p>}
|
||||
{context && <p className="devserver-preview-blocked-context">{context}</p>}
|
||||
</div>
|
||||
<p className="devserver-preview-blocked-description">You can view the preview in a separate browser tab.</p>
|
||||
<div className="devserver-preview-blocked-actions">
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
detectDevServerCommands,
|
||||
fetchDevServer,
|
||||
fetchDevServerLogs,
|
||||
fetchDevServers,
|
||||
getDevServerLogsStreamUrl,
|
||||
getDevServerSessionLogsStreamUrl,
|
||||
restartDevServerById,
|
||||
setDevServerPreviewUrlById,
|
||||
startDevServerById,
|
||||
stopDevServerById,
|
||||
fetchDevServerCandidates,
|
||||
fetchDevServerStatus,
|
||||
getDevServerLogsStreamUrl,
|
||||
restartDevServer,
|
||||
setDevServerPreviewUrl,
|
||||
startDevServer,
|
||||
stopDevServer,
|
||||
type DevServerCandidate,
|
||||
type DetectedDevServerCommand,
|
||||
type DevServerLogEntry,
|
||||
type DevServerSession,
|
||||
type DevServerState,
|
||||
} from "../api";
|
||||
import { subscribeSse } from "../sse-bus";
|
||||
@@ -40,56 +51,113 @@ function parseJson<T>(value: string): T | null {
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToHook(state: DevServerState | null): Pick<UseDevServerReturn, "status" | "detectedUrl" | "manualUrl" | "selectedCommand" | "serverState"> {
|
||||
if (!state) {
|
||||
return {
|
||||
status: "stopped",
|
||||
detectedUrl: null,
|
||||
manualUrl: null,
|
||||
selectedCommand: null,
|
||||
serverState: null,
|
||||
};
|
||||
}
|
||||
function logEntryToString(entry: DevServerLogEntry): string {
|
||||
const text = entry.text ?? "";
|
||||
return entry.stream === "stderr" ? `[stderr] ${text}` : text;
|
||||
}
|
||||
|
||||
function extractPreviewUrl(session: DevServerSession | null): string | null {
|
||||
return session?.previewUrl ?? null;
|
||||
}
|
||||
|
||||
// Legacy API helpers for single-server fallback
|
||||
async function legacyFetchStatus(projectId?: string) {
|
||||
return fetchDevServerStatus(projectId);
|
||||
}
|
||||
|
||||
async function legacyStart(body: { command: string; cwd?: string; scriptName?: string; packagePath?: string }, projectId?: string) {
|
||||
return startDevServer(body, projectId);
|
||||
}
|
||||
|
||||
async function legacyStop(projectId?: string) {
|
||||
return stopDevServer(projectId);
|
||||
}
|
||||
|
||||
async function legacyRestart(projectId?: string) {
|
||||
return restartDevServer(projectId);
|
||||
}
|
||||
|
||||
async function legacyDetect(projectId?: string) {
|
||||
return fetchDevServerCandidates(projectId);
|
||||
}
|
||||
|
||||
function getOptionalExport<T>(reader: () => T): T | null {
|
||||
try {
|
||||
return reader();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hasSessionApi(): boolean {
|
||||
return typeof getOptionalExport(() => fetchDevServers) === "function"
|
||||
&& typeof getOptionalExport(() => fetchDevServer) === "function";
|
||||
}
|
||||
|
||||
function toSessionFromLegacy(legacyState: DevServerState): DevServerSession {
|
||||
return {
|
||||
status: state.status,
|
||||
detectedUrl: state.detectedUrl ?? state.previewUrl ?? null,
|
||||
manualUrl: state.manualUrl ?? state.manualPreviewUrl ?? null,
|
||||
selectedCommand: state.command?.trim().length ? state.command : null,
|
||||
serverState: state,
|
||||
config: {
|
||||
id: legacyState.id ?? "default",
|
||||
name: legacyState.name ?? "Dev Server",
|
||||
command: legacyState.command ?? "",
|
||||
cwd: legacyState.cwd ?? ".",
|
||||
},
|
||||
status: legacyState.status as DevServerSession["status"],
|
||||
runtime: legacyState.pid
|
||||
? {
|
||||
pid: legacyState.pid,
|
||||
startedAt: legacyState.startedAt ?? new Date().toISOString(),
|
||||
exitCode: legacyState.exitCode ?? undefined,
|
||||
previewUrl: legacyState.previewUrl,
|
||||
}
|
||||
: undefined,
|
||||
previewUrl: legacyState.previewUrl ?? legacyState.detectedUrl ?? legacyState.manualUrl ?? undefined,
|
||||
logHistory: (legacyState.logs ?? []).map<DevServerLogEntry>((text) => ({
|
||||
timestamp: new Date().toISOString(),
|
||||
stream: text.startsWith("[stderr]") ? "stderr" : "stdout",
|
||||
text: text.replace(/^\[stderr\]\s*/, ""),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
type DevServerStartArgs =
|
||||
| string
|
||||
| DevServerCandidate
|
||||
| {
|
||||
command: string;
|
||||
cwd?: string;
|
||||
scriptName?: string;
|
||||
packagePath?: string;
|
||||
};
|
||||
|
||||
export interface UseDevServerReturn {
|
||||
status: "starting" | "running" | "stopped" | "failed";
|
||||
/** Current active session */
|
||||
session: DevServerSession | null;
|
||||
/** All available sessions */
|
||||
sessions: DevServerSession[];
|
||||
/** Current log entries as strings */
|
||||
logs: string[];
|
||||
detectedUrl: string | null;
|
||||
manualUrl: string | null;
|
||||
selectedCommand: string | null;
|
||||
candidates: DevServerCandidate[];
|
||||
/** Detected dev server commands */
|
||||
detectedCommands: DetectedDevServerCommand[];
|
||||
/** Current preview URL */
|
||||
previewUrl: string | null;
|
||||
/** Loading state */
|
||||
isLoading: boolean;
|
||||
/** Error message if any */
|
||||
error: string | null;
|
||||
start: (commandOrInput: DevServerStartArgs, cwd?: string, scriptName?: string, packagePath?: string) => Promise<void>;
|
||||
/** Start the dev server with the given command */
|
||||
startServer: (command: string, cwd?: string) => Promise<void>;
|
||||
/** Stop the dev server */
|
||||
stopServer: () => Promise<void>;
|
||||
/** Restart the dev server */
|
||||
restartServer: () => Promise<void>;
|
||||
/** Set the preview URL */
|
||||
setPreviewUrl: (url: string | null) => Promise<void>;
|
||||
/** Detect available dev server commands */
|
||||
detectCommands: () => Promise<void>;
|
||||
/** Refresh session state */
|
||||
refresh: () => Promise<void>;
|
||||
|
||||
// Legacy aliases for compatibility
|
||||
candidates: DetectedDevServerCommand[];
|
||||
serverState: (DevServerSession & { pid?: number }) | null;
|
||||
loading: boolean;
|
||||
start: (commandOrInput: string | { command: string; cwd?: string; scriptName?: string; packagePath?: string }, cwd?: string) => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
restart: () => Promise<void>;
|
||||
setManualUrl: (url: string | null) => Promise<void>;
|
||||
detect: () => Promise<void>;
|
||||
refreshStatus: () => Promise<void>;
|
||||
|
||||
// Back-compat aliases used by existing consumers/tests.
|
||||
serverState: DevServerState | null;
|
||||
loading: boolean;
|
||||
setPreviewUrl: (url: string | null) => Promise<void>;
|
||||
}
|
||||
|
||||
export function __resetUseDevServerForTests(): void {
|
||||
@@ -97,188 +165,211 @@ export function __resetUseDevServerForTests(): void {
|
||||
}
|
||||
|
||||
export function useDevServer(projectId?: string): UseDevServerReturn {
|
||||
const [status, setStatus] = useState<UseDevServerReturn["status"]>("stopped");
|
||||
const [session, setSession] = useState<DevServerSession | null>(null);
|
||||
const [sessions, setSessions] = useState<DevServerSession[]>([]);
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
|
||||
const [manualUrl, setManualUrlState] = useState<string | null>(null);
|
||||
const [selectedCommand, setSelectedCommand] = useState<string | null>(null);
|
||||
const [candidates, setCandidates] = useState<DevServerCandidate[]>([]);
|
||||
const [detectedCommands, setDetectedCommands] = useState<DetectedDevServerCommand[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [serverState, setServerState] = useState<DevServerState | null>(null);
|
||||
|
||||
const contextVersionRef = useRef(0);
|
||||
// Track session ID for SSE subscription - this state change triggers effect re-run
|
||||
const [subscriptionSessionId, setSubscriptionSessionId] = useState<string | null>(null);
|
||||
|
||||
const applyStatusState = useCallback((state: DevServerState) => {
|
||||
const mapped = mapStateToHook(state);
|
||||
setStatus(mapped.status);
|
||||
setDetectedUrl(mapped.detectedUrl);
|
||||
setManualUrlState(mapped.manualUrl);
|
||||
setSelectedCommand(mapped.selectedCommand);
|
||||
setServerState(mapped.serverState);
|
||||
if (Array.isArray(state.logs)) {
|
||||
setLogs(capLogs(state.logs));
|
||||
const applySession = useCallback((newSession: DevServerSession | null) => {
|
||||
setSession(newSession);
|
||||
if (newSession?.logHistory) {
|
||||
const logStrings = newSession.logHistory
|
||||
.slice(-MAX_LOG_LINES)
|
||||
.map(logEntryToString);
|
||||
setLogs(logStrings);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
const refresh = useCallback(async () => {
|
||||
const versionAtStart = contextVersionRef.current;
|
||||
|
||||
try {
|
||||
const nextState = await fetchDevServerStatus(projectId);
|
||||
if (contextVersionRef.current !== versionAtStart) {
|
||||
return;
|
||||
if (hasSessionApi()) {
|
||||
if (subscriptionSessionId) {
|
||||
const updatedSession = await fetchDevServer(subscriptionSessionId, projectId);
|
||||
if (contextVersionRef.current !== versionAtStart) {
|
||||
return;
|
||||
}
|
||||
applySession(updatedSession);
|
||||
} else {
|
||||
const allSessions = await fetchDevServers(projectId);
|
||||
if (contextVersionRef.current !== versionAtStart) {
|
||||
return;
|
||||
}
|
||||
setSessions(allSessions);
|
||||
if (allSessions.length > 0) {
|
||||
setSubscriptionSessionId(allSessions[0].config.id);
|
||||
applySession(allSessions[0]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const legacyState = await legacyFetchStatus(projectId);
|
||||
if (contextVersionRef.current !== versionAtStart) {
|
||||
return;
|
||||
}
|
||||
const legacySession = toSessionFromLegacy(legacyState);
|
||||
setSessions([legacySession]);
|
||||
applySession(legacySession);
|
||||
}
|
||||
applyStatusState(nextState);
|
||||
setError(null);
|
||||
} catch (refreshError) {
|
||||
if (contextVersionRef.current !== versionAtStart) {
|
||||
return;
|
||||
}
|
||||
setError(normalizeError(refreshError));
|
||||
throw refreshError;
|
||||
}
|
||||
}, [applyStatusState, projectId]);
|
||||
}, [applySession, projectId, subscriptionSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
contextVersionRef.current += 1;
|
||||
const versionAtStart = contextVersionRef.current;
|
||||
|
||||
setStatus("stopped");
|
||||
// Reset state
|
||||
setSession(null);
|
||||
setSessions([]);
|
||||
setLogs([]);
|
||||
setDetectedUrl(null);
|
||||
setManualUrlState(null);
|
||||
setSelectedCommand(null);
|
||||
setCandidates([]);
|
||||
setDetectedCommands([]);
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setServerState(null);
|
||||
setSubscriptionSessionId(null);
|
||||
|
||||
void Promise.allSettled([
|
||||
fetchDevServerCandidates(projectId),
|
||||
fetchDevServerStatus(projectId),
|
||||
]).then(([candidateResult, statusResult]) => {
|
||||
if (contextVersionRef.current !== versionAtStart) {
|
||||
return;
|
||||
const loadInitialData = async () => {
|
||||
try {
|
||||
const [sessionsResult, commandsResult] = await Promise.allSettled([
|
||||
hasSessionApi()
|
||||
? fetchDevServers(projectId)
|
||||
: legacyFetchStatus(projectId).then((state) => [toSessionFromLegacy(state)]),
|
||||
typeof getOptionalExport(() => detectDevServerCommands) === "function"
|
||||
? detectDevServerCommands(projectId)
|
||||
: legacyDetect(projectId).then((legacyCandidates) => legacyCandidates.map((candidate) => ({
|
||||
name: candidate.name,
|
||||
command: candidate.command,
|
||||
cwd: candidate.cwd,
|
||||
scriptName: candidate.scriptName,
|
||||
packagePath: candidate.packagePath,
|
||||
}))),
|
||||
]);
|
||||
|
||||
if (contextVersionRef.current !== versionAtStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
let nextError: string | null = null;
|
||||
|
||||
if (sessionsResult.status === "fulfilled") {
|
||||
const sessionsData = sessionsResult.value;
|
||||
setSessions(sessionsData);
|
||||
if (sessionsData.length > 0) {
|
||||
const firstSession = sessionsData[0];
|
||||
if (hasSessionApi()) {
|
||||
setSubscriptionSessionId(firstSession.config.id);
|
||||
}
|
||||
applySession(firstSession);
|
||||
}
|
||||
} else {
|
||||
nextError = normalizeError(sessionsResult.reason);
|
||||
}
|
||||
|
||||
if (commandsResult.status === "fulfilled") {
|
||||
setDetectedCommands(commandsResult.value);
|
||||
}
|
||||
|
||||
if (nextError) {
|
||||
setError(nextError);
|
||||
}
|
||||
} catch (err) {
|
||||
if (contextVersionRef.current !== versionAtStart) {
|
||||
return;
|
||||
}
|
||||
setError(normalizeError(err));
|
||||
} finally {
|
||||
if (contextVersionRef.current === versionAtStart) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
let nextError: string | null = null;
|
||||
|
||||
if (candidateResult.status === "fulfilled") {
|
||||
setCandidates(candidateResult.value);
|
||||
} else {
|
||||
nextError = normalizeError(candidateResult.reason);
|
||||
}
|
||||
|
||||
if (statusResult.status === "fulfilled") {
|
||||
applyStatusState(statusResult.value);
|
||||
} else {
|
||||
nextError = nextError ?? normalizeError(statusResult.reason);
|
||||
}
|
||||
|
||||
setError(nextError);
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
contextVersionRef.current += 1;
|
||||
};
|
||||
}, [applyStatusState, projectId]);
|
||||
|
||||
void loadInitialData();
|
||||
}, [applySession, projectId]);
|
||||
|
||||
// SSE subscription for live log updates
|
||||
useEffect(() => {
|
||||
if (status !== "running" && status !== "starting") {
|
||||
const streamUrl = subscriptionSessionId
|
||||
? getDevServerSessionLogsStreamUrl(subscriptionSessionId, projectId)
|
||||
: (typeof getOptionalExport(() => getDevServerLogsStreamUrl) === "function" ? getDevServerLogsStreamUrl(projectId) : null);
|
||||
|
||||
if (!streamUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
void refreshStatus().catch(() => {
|
||||
// Errors are recorded in hook state.
|
||||
});
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [refreshStatus, status]);
|
||||
|
||||
useEffect(() => {
|
||||
const versionAtStart = contextVersionRef.current;
|
||||
|
||||
const unsubscribe = subscribeSse(getDevServerLogsStreamUrl(projectId), {
|
||||
const unsubscribe = subscribeSse(streamUrl, {
|
||||
events: {
|
||||
history: (event) => {
|
||||
if (contextVersionRef.current !== versionAtStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = parseJson<{ lines?: Array<string | { text?: string; line?: string; stream?: "stdout" | "stderr" }> }>(event.data);
|
||||
const nextLogs = Array.isArray(payload?.lines)
|
||||
? payload.lines.map((entry) => {
|
||||
if (typeof entry === "string") {
|
||||
return entry;
|
||||
}
|
||||
const text = typeof entry?.text === "string" ? entry.text : (typeof entry?.line === "string" ? entry.line : "");
|
||||
const stream = entry?.stream;
|
||||
return stream === "stderr" ? `[stderr] ${text}` : text;
|
||||
}).filter((line) => line.length > 0)
|
||||
: [];
|
||||
if (nextLogs.length > 0) {
|
||||
setLogs(capLogs(nextLogs));
|
||||
const payload = parseJson<{ lines?: DevServerLogEntry[] }>(event.data);
|
||||
if (payload?.lines) {
|
||||
const logStrings = payload.lines.map(logEntryToString);
|
||||
setLogs(capLogs(logStrings));
|
||||
}
|
||||
},
|
||||
log: (event) => {
|
||||
if (contextVersionRef.current !== versionAtStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = parseJson<{ line?: string; text?: string; stream?: "stdout" | "stderr" }>(event.data);
|
||||
const text = typeof payload?.line === "string"
|
||||
? payload.line
|
||||
: typeof payload?.text === "string"
|
||||
? payload.text
|
||||
: event.data;
|
||||
const stream = payload?.stream;
|
||||
const formatted = stream === "stderr" ? `[stderr] ${text}` : text;
|
||||
setLogs((current) => appendLog(current, formatted));
|
||||
setError(null);
|
||||
const payload = parseJson<DevServerLogEntry & { line?: string }>(event.data);
|
||||
if (payload) {
|
||||
const line = typeof payload.line === "string" ? payload.line : logEntryToString(payload);
|
||||
setLogs((prev) => appendLog(prev, line));
|
||||
}
|
||||
},
|
||||
"dev-server:log": (event) => {
|
||||
if (contextVersionRef.current !== versionAtStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = parseJson<{ line?: string; text?: string; stream?: "stdout" | "stderr" }>(event.data);
|
||||
const text = typeof payload?.line === "string"
|
||||
? payload.line
|
||||
: typeof payload?.text === "string"
|
||||
? payload.text
|
||||
: event.data;
|
||||
const stream = payload?.stream;
|
||||
const formatted = stream === "stderr" ? `[stderr] ${text}` : text;
|
||||
setLogs((current) => appendLog(current, formatted));
|
||||
setError(null);
|
||||
const payload = parseJson<DevServerLogEntry & { line?: string }>(event.data);
|
||||
if (payload) {
|
||||
const line = typeof payload.line === "string" ? payload.line : logEntryToString(payload);
|
||||
setLogs((prev) => appendLog(prev, line));
|
||||
}
|
||||
},
|
||||
"dev-server:output": (event) => {
|
||||
if (contextVersionRef.current !== versionAtStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = parseJson<{ text?: string; stream?: "stdout" | "stderr" }>(event.data);
|
||||
if (!payload?.text) {
|
||||
return;
|
||||
const payload = parseJson<{ line?: string }>(event.data);
|
||||
if (payload?.line) {
|
||||
setLogs((prev) => appendLog(prev, payload.line!));
|
||||
}
|
||||
const formatted = payload.stream === "stderr" ? `[stderr] ${payload.text}` : payload.text;
|
||||
setLogs((current) => appendLog(current, formatted));
|
||||
setError(null);
|
||||
},
|
||||
status: (event) => {
|
||||
if (contextVersionRef.current !== versionAtStart) {
|
||||
return;
|
||||
}
|
||||
const payload = parseJson<DevServerState>(event.data);
|
||||
if (payload) {
|
||||
applyStatusState(payload);
|
||||
setError(null);
|
||||
const payload = parseJson<{ status?: DevServerSession["status"]; pid?: number }>(event.data);
|
||||
const nextStatus = payload?.status;
|
||||
if (nextStatus) {
|
||||
setSession((prev) => (prev
|
||||
? {
|
||||
...prev,
|
||||
status: nextStatus,
|
||||
runtime: payload.pid
|
||||
? {
|
||||
...(prev.runtime ?? { startedAt: new Date().toISOString() }),
|
||||
pid: payload.pid,
|
||||
}
|
||||
: prev.runtime,
|
||||
}
|
||||
: prev));
|
||||
}
|
||||
},
|
||||
"dev-server:status": (event) => {
|
||||
@@ -286,35 +377,30 @@ export function useDevServer(projectId?: string): UseDevServerReturn {
|
||||
return;
|
||||
}
|
||||
const payload = parseJson<DevServerState>(event.data);
|
||||
if (payload) {
|
||||
applyStatusState(payload);
|
||||
setError(null);
|
||||
if (payload?.status) {
|
||||
const nextSession = toSessionFromLegacy(payload);
|
||||
setSession(nextSession);
|
||||
setSessions([nextSession]);
|
||||
}
|
||||
},
|
||||
stopped: () => {
|
||||
if (contextVersionRef.current !== versionAtStart) {
|
||||
return;
|
||||
}
|
||||
void refreshStatus().catch(() => {
|
||||
// Errors are recorded in hook state.
|
||||
});
|
||||
// Server stopped event
|
||||
},
|
||||
failed: () => {
|
||||
if (contextVersionRef.current !== versionAtStart) {
|
||||
return;
|
||||
}
|
||||
void refreshStatus().catch(() => {
|
||||
// Errors are recorded in hook state.
|
||||
});
|
||||
// Server failed event
|
||||
},
|
||||
},
|
||||
onReconnect: () => {
|
||||
if (contextVersionRef.current !== versionAtStart) {
|
||||
return;
|
||||
}
|
||||
void refreshStatus().catch(() => {
|
||||
// Errors are recorded in hook state.
|
||||
});
|
||||
void refresh();
|
||||
},
|
||||
onError: () => {
|
||||
if (contextVersionRef.current !== versionAtStart) {
|
||||
@@ -327,206 +413,264 @@ export function useDevServer(projectId?: string): UseDevServerReturn {
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [applyStatusState, projectId, refreshStatus]);
|
||||
}, [projectId, refresh, subscriptionSessionId]);
|
||||
|
||||
// Polling while server is running
|
||||
useEffect(() => {
|
||||
const version = resetVersion;
|
||||
return () => {
|
||||
if (resetVersion !== version) {
|
||||
contextVersionRef.current += 1;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const start = useCallback(async (
|
||||
commandOrInput: DevServerStartArgs,
|
||||
cwd?: string,
|
||||
scriptName?: string,
|
||||
packagePath?: string,
|
||||
) => {
|
||||
const payload = typeof commandOrInput === "string"
|
||||
? {
|
||||
command: commandOrInput,
|
||||
cwd,
|
||||
scriptName,
|
||||
packagePath,
|
||||
}
|
||||
: {
|
||||
command: commandOrInput.command,
|
||||
cwd: commandOrInput.cwd ?? cwd,
|
||||
scriptName: commandOrInput.scriptName ?? scriptName,
|
||||
packagePath: commandOrInput.packagePath ?? cwd ?? commandOrInput.cwd ?? packagePath,
|
||||
};
|
||||
|
||||
const trimmedCommand = payload.command.trim();
|
||||
if (!trimmedCommand) {
|
||||
const message = "Command is required to start the dev server.";
|
||||
setError(message);
|
||||
throw new Error(message);
|
||||
if (session?.status !== "running" && session?.status !== "starting") {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
void refresh();
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [refresh, session?.status]);
|
||||
|
||||
const startServer = useCallback(async (command: string, cwd?: string) => {
|
||||
contextVersionRef.current += 1;
|
||||
const versionAtStart = contextVersionRef.current;
|
||||
setError(null);
|
||||
setStatus("starting");
|
||||
setSelectedCommand(trimmedCommand);
|
||||
|
||||
try {
|
||||
const nextState = await startDevServer(
|
||||
{
|
||||
command: trimmedCommand,
|
||||
cwd: payload.cwd,
|
||||
scriptName: payload.scriptName,
|
||||
packagePath: payload.packagePath,
|
||||
},
|
||||
projectId,
|
||||
);
|
||||
let result: DevServerSession;
|
||||
|
||||
if (subscriptionSessionId && typeof getOptionalExport(() => startDevServerById) === "function") {
|
||||
result = await startDevServerById(subscriptionSessionId, projectId);
|
||||
} else {
|
||||
const legacyState = await legacyStart({ command, cwd }, projectId);
|
||||
result = toSessionFromLegacy(legacyState);
|
||||
}
|
||||
|
||||
if (contextVersionRef.current !== versionAtStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyStatusState(nextState);
|
||||
setSession(result);
|
||||
setError(null);
|
||||
} catch (startError) {
|
||||
if (contextVersionRef.current !== versionAtStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("failed");
|
||||
setError(normalizeError(startError));
|
||||
throw startError;
|
||||
}
|
||||
}, [applyStatusState, projectId]);
|
||||
}, [projectId, subscriptionSessionId]);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
const stopServer = useCallback(async () => {
|
||||
contextVersionRef.current += 1;
|
||||
const versionAtStart = contextVersionRef.current;
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const nextState = await stopDevServer(projectId);
|
||||
let result: DevServerSession;
|
||||
|
||||
if (subscriptionSessionId && typeof getOptionalExport(() => stopDevServerById) === "function") {
|
||||
result = await stopDevServerById(subscriptionSessionId, projectId);
|
||||
} else {
|
||||
const legacyState = await legacyStop(projectId);
|
||||
result = toSessionFromLegacy(legacyState);
|
||||
}
|
||||
|
||||
if (contextVersionRef.current !== versionAtStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyStatusState(nextState);
|
||||
setSession(result);
|
||||
setError(null);
|
||||
} catch (stopError) {
|
||||
if (contextVersionRef.current !== versionAtStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(normalizeError(stopError));
|
||||
throw stopError;
|
||||
}
|
||||
}, [applyStatusState, projectId]);
|
||||
}, [projectId, subscriptionSessionId]);
|
||||
|
||||
const restart = useCallback(async () => {
|
||||
const restartServer = useCallback(async () => {
|
||||
contextVersionRef.current += 1;
|
||||
const versionAtStart = contextVersionRef.current;
|
||||
setError(null);
|
||||
setStatus("starting");
|
||||
|
||||
try {
|
||||
const nextState = await restartDevServer(projectId);
|
||||
let result: DevServerSession;
|
||||
|
||||
if (subscriptionSessionId && typeof getOptionalExport(() => restartDevServerById) === "function") {
|
||||
result = await restartDevServerById(subscriptionSessionId, projectId);
|
||||
} else {
|
||||
const legacyState = await legacyRestart(projectId);
|
||||
result = toSessionFromLegacy(legacyState);
|
||||
}
|
||||
|
||||
if (contextVersionRef.current !== versionAtStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyStatusState(nextState);
|
||||
setSession(result);
|
||||
setError(null);
|
||||
} catch (restartError) {
|
||||
if (contextVersionRef.current !== versionAtStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(normalizeError(restartError));
|
||||
throw restartError;
|
||||
}
|
||||
}, [applyStatusState, projectId]);
|
||||
}, [projectId, subscriptionSessionId]);
|
||||
|
||||
const setManualUrl = useCallback(async (url: string | null) => {
|
||||
const setPreviewUrl = useCallback(async (url: string | null) => {
|
||||
contextVersionRef.current += 1;
|
||||
const versionAtStart = contextVersionRef.current;
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const nextState = await setDevServerPreviewUrl({ url }, projectId);
|
||||
let result: { url: string | null; source: string | null };
|
||||
|
||||
if (subscriptionSessionId && typeof getOptionalExport(() => setDevServerPreviewUrlById) === "function") {
|
||||
result = await setDevServerPreviewUrlById(subscriptionSessionId, url, projectId);
|
||||
} else {
|
||||
const legacyState = await setDevServerPreviewUrl({ url }, projectId);
|
||||
result = {
|
||||
url: legacyState.manualUrl ?? legacyState.previewUrl ?? legacyState.detectedUrl ?? null,
|
||||
source: legacyState.manualUrl ? "manual" : "auto",
|
||||
};
|
||||
}
|
||||
|
||||
if (contextVersionRef.current !== versionAtStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyStatusState(nextState);
|
||||
setManualUrlState(nextState.manualUrl ?? nextState.manualPreviewUrl ?? url ?? null);
|
||||
// Update session with new preview URL
|
||||
setSession((prev) => {
|
||||
if (!prev) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
previewUrl: result.url ?? undefined,
|
||||
};
|
||||
});
|
||||
setError(null);
|
||||
} catch (previewError) {
|
||||
if (contextVersionRef.current !== versionAtStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(normalizeError(previewError));
|
||||
throw previewError;
|
||||
}
|
||||
}, [applyStatusState, projectId]);
|
||||
}, [projectId, subscriptionSessionId]);
|
||||
|
||||
const detect = useCallback(async () => {
|
||||
const detectCommands = useCallback(async () => {
|
||||
contextVersionRef.current += 1;
|
||||
const versionAtStart = contextVersionRef.current;
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const detected = await fetchDevServerCandidates(projectId);
|
||||
let commands: DetectedDevServerCommand[];
|
||||
|
||||
try {
|
||||
commands = await detectDevServerCommands(projectId);
|
||||
} catch {
|
||||
// Fallback to legacy detect
|
||||
const legacyCandidates = await legacyDetect(projectId);
|
||||
commands = legacyCandidates.map((candidate) => ({
|
||||
name: candidate.name,
|
||||
command: candidate.command,
|
||||
cwd: candidate.cwd,
|
||||
scriptName: candidate.scriptName,
|
||||
packagePath: candidate.packagePath,
|
||||
}));
|
||||
}
|
||||
|
||||
if (contextVersionRef.current !== versionAtStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCandidates(detected);
|
||||
setDetectedCommands(commands);
|
||||
setError(null);
|
||||
} catch (detectError) {
|
||||
if (contextVersionRef.current !== versionAtStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(normalizeError(detectError));
|
||||
throw detectError;
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const setPreviewUrl = useCallback((url: string | null) => setManualUrl(url), [setManualUrl]);
|
||||
// Legacy alias methods
|
||||
const start = useCallback(async (commandOrInput: string | { command: string; cwd?: string; scriptName?: string; packagePath?: string }, cwd?: string) => {
|
||||
if (typeof commandOrInput !== "string" && !subscriptionSessionId) {
|
||||
try {
|
||||
const input = commandOrInput;
|
||||
const legacyState = await legacyStart(
|
||||
{
|
||||
command: input.command,
|
||||
cwd: input.cwd,
|
||||
scriptName: input.scriptName,
|
||||
packagePath: input.packagePath ?? input.cwd,
|
||||
},
|
||||
projectId,
|
||||
);
|
||||
const nextSession = toSessionFromLegacy(legacyState);
|
||||
setSession(nextSession);
|
||||
setSessions([nextSession]);
|
||||
setError(null);
|
||||
return;
|
||||
} catch (legacyStartError) {
|
||||
setError(normalizeError(legacyStartError));
|
||||
throw legacyStartError;
|
||||
}
|
||||
}
|
||||
|
||||
return useMemo(() => ({
|
||||
status,
|
||||
const command = typeof commandOrInput === "string" ? commandOrInput : commandOrInput.command;
|
||||
const cwdArg = typeof commandOrInput === "string" ? cwd : commandOrInput.cwd;
|
||||
await startServer(command, cwdArg);
|
||||
}, [projectId, startServer, subscriptionSessionId]);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
await stopServer();
|
||||
}, [stopServer]);
|
||||
|
||||
const restart = useCallback(async () => {
|
||||
await restartServer();
|
||||
}, [restartServer]);
|
||||
|
||||
const setManualUrl = useCallback(async (url: string | null) => {
|
||||
await setPreviewUrl(url);
|
||||
}, [setPreviewUrl]);
|
||||
|
||||
const detect = useCallback(async () => {
|
||||
await detectCommands();
|
||||
}, [detectCommands]);
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
await refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const previewUrl = extractPreviewUrl(session);
|
||||
const serverState = session ? { ...session, pid: session.runtime?.pid } : null;
|
||||
|
||||
return {
|
||||
session,
|
||||
sessions,
|
||||
logs,
|
||||
detectedUrl,
|
||||
manualUrl,
|
||||
selectedCommand,
|
||||
candidates,
|
||||
detectedCommands,
|
||||
previewUrl,
|
||||
isLoading,
|
||||
error,
|
||||
start,
|
||||
stop,
|
||||
restart,
|
||||
setManualUrl,
|
||||
detect,
|
||||
refreshStatus,
|
||||
|
||||
startServer,
|
||||
stopServer,
|
||||
restartServer,
|
||||
setPreviewUrl,
|
||||
detectCommands,
|
||||
refresh,
|
||||
// Legacy aliases
|
||||
candidates: detectedCommands,
|
||||
serverState,
|
||||
loading: isLoading,
|
||||
setPreviewUrl,
|
||||
}), [
|
||||
status,
|
||||
logs,
|
||||
detectedUrl,
|
||||
manualUrl,
|
||||
selectedCommand,
|
||||
candidates,
|
||||
isLoading,
|
||||
error,
|
||||
// Legacy methods
|
||||
start,
|
||||
stop,
|
||||
restart,
|
||||
setManualUrl,
|
||||
detect,
|
||||
refreshStatus,
|
||||
serverState,
|
||||
setPreviewUrl,
|
||||
]);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -351,6 +351,9 @@ export function useDevServerLogs(projectId: string | undefined, enabled: boolean
|
||||
setEntries([]);
|
||||
}, []);
|
||||
|
||||
// Track streaming state: true when SSE subscription is active
|
||||
const isStreaming = unsubscribeRef.current !== null && !loading && !cancelledRef.current;
|
||||
|
||||
return {
|
||||
entries,
|
||||
loading,
|
||||
@@ -359,6 +362,10 @@ export function useDevServerLogs(projectId: string | undefined, enabled: boolean
|
||||
total,
|
||||
loadMore,
|
||||
clear,
|
||||
// New simplified interface per Step 2 requirements
|
||||
logs: entries, // Alias for backward compatibility
|
||||
isStreaming, // Track SSE subscription state
|
||||
clearLogs: clear, // Alias for clear
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
|
||||
export type EmbedStatus = "unknown" | "loading" | "embedded" | "blocked" | "error";
|
||||
export type EmbedDetectionMethod = "auto" | "manual" | null;
|
||||
|
||||
const BLOCKED_CONTEXT = "The server may block iframe embedding via X-Frame-Options or Content-Security-Policy headers. Browsers prevent detecting these headers from JavaScript.";
|
||||
const ERROR_CONTEXT = "The preview URL could not be loaded. The server may not be running or the URL may be incorrect.";
|
||||
@@ -8,17 +9,22 @@ const TIMEOUT_CONTEXT = "Preview is taking longer than expected to load. The ser
|
||||
|
||||
interface UsePreviewEmbedOptions {
|
||||
loadTimeoutMs?: number;
|
||||
detectionMethod?: EmbedDetectionMethod;
|
||||
}
|
||||
|
||||
interface UsePreviewEmbedResult {
|
||||
embedStatus: EmbedStatus;
|
||||
setEmbedStatus: (status: EmbedStatus) => void;
|
||||
resetEmbedStatus: () => void;
|
||||
iframeRef: RefObject<HTMLIFrameElement | null>;
|
||||
isEmbedded: boolean;
|
||||
isBlocked: boolean;
|
||||
embedContext: string | null;
|
||||
blockReason: string | null;
|
||||
detectionMethod: EmbedDetectionMethod;
|
||||
iframeRef: RefObject<HTMLIFrameElement | null>;
|
||||
resetEmbedStatus: () => void;
|
||||
// Extended API for direct status control (backward compatibility)
|
||||
setEmbedStatus: (status: EmbedStatus) => void;
|
||||
retry: () => void;
|
||||
// Legacy aliases for backward compatibility
|
||||
embedContext: string | null;
|
||||
handleIframeLoad: () => void;
|
||||
handleIframeError: () => void;
|
||||
resetEmbed: () => void;
|
||||
@@ -39,12 +45,13 @@ function defaultContextForStatus(status: EmbedStatus): string | null {
|
||||
}
|
||||
|
||||
export function usePreviewEmbed(url: string | null, options: UsePreviewEmbedOptions = {}): UsePreviewEmbedResult {
|
||||
const { loadTimeoutMs = 10000 } = options;
|
||||
const { loadTimeoutMs = 10000, detectionMethod: initialDetectionMethod = null } = options;
|
||||
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [embedStatus, setEmbedStatusState] = useState<EmbedStatus>("unknown");
|
||||
const [embedContext, setEmbedContext] = useState<string | null>(null);
|
||||
const [blockReason, setBlockReason] = useState<string | null>(null);
|
||||
const [detectionMethod, setDetectionMethod] = useState<EmbedDetectionMethod>(initialDetectionMethod);
|
||||
|
||||
const clearLoadingTimeout = useCallback(() => {
|
||||
if (timeoutRef.current !== null) {
|
||||
@@ -55,12 +62,12 @@ export function usePreviewEmbed(url: string | null, options: UsePreviewEmbedOpti
|
||||
|
||||
const setEmbedStatus = useCallback((status: EmbedStatus) => {
|
||||
setEmbedStatusState(status);
|
||||
setEmbedContext(defaultContextForStatus(status));
|
||||
setBlockReason(defaultContextForStatus(status));
|
||||
}, []);
|
||||
|
||||
const setBlockedByTimeout = useCallback(() => {
|
||||
setEmbedStatusState("blocked");
|
||||
setEmbedContext(TIMEOUT_CONTEXT);
|
||||
setBlockReason(TIMEOUT_CONTEXT);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -68,12 +75,12 @@ export function usePreviewEmbed(url: string | null, options: UsePreviewEmbedOpti
|
||||
|
||||
if (!url) {
|
||||
setEmbedStatusState("unknown");
|
||||
setEmbedContext(null);
|
||||
setBlockReason(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setEmbedStatusState("unknown");
|
||||
setEmbedContext(null);
|
||||
setBlockReason(null);
|
||||
|
||||
let canceled = false;
|
||||
queueMicrotask(() => {
|
||||
@@ -81,7 +88,7 @@ export function usePreviewEmbed(url: string | null, options: UsePreviewEmbedOpti
|
||||
return;
|
||||
}
|
||||
setEmbedStatusState("loading");
|
||||
setEmbedContext(null);
|
||||
setBlockReason(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -138,13 +145,13 @@ export function usePreviewEmbed(url: string | null, options: UsePreviewEmbedOpti
|
||||
const resetEmbedStatus = useCallback(() => {
|
||||
clearLoadingTimeout();
|
||||
setEmbedStatusState("unknown");
|
||||
setEmbedContext(null);
|
||||
setBlockReason(null);
|
||||
}, [clearLoadingTimeout]);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
clearLoadingTimeout();
|
||||
setEmbedStatusState("unknown");
|
||||
setEmbedContext(null);
|
||||
setBlockReason(null);
|
||||
}, [clearLoadingTimeout]);
|
||||
|
||||
const isEmbedded = useMemo(() => embedStatus === "embedded", [embedStatus]);
|
||||
@@ -155,12 +162,15 @@ export function usePreviewEmbed(url: string | null, options: UsePreviewEmbedOpti
|
||||
|
||||
return {
|
||||
embedStatus,
|
||||
setEmbedStatus,
|
||||
resetEmbedStatus,
|
||||
iframeRef,
|
||||
isEmbedded,
|
||||
isBlocked,
|
||||
embedContext,
|
||||
blockReason,
|
||||
detectionMethod,
|
||||
iframeRef,
|
||||
resetEmbedStatus,
|
||||
// Legacy aliases
|
||||
setEmbedStatus,
|
||||
embedContext: blockReason, // Alias for backward compatibility
|
||||
retry,
|
||||
handleIframeLoad,
|
||||
handleIframeError,
|
||||
|
||||
Reference in New Issue
Block a user