492 lines
14 KiB
TypeScript
492 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback } from "react";
|
|
import { IconRefresh, IconExternalLink, IconLoader2 } from "@tabler/icons-react";
|
|
import { Button } from "@kandev/ui/button";
|
|
import { Input } from "@kandev/ui/input";
|
|
import { SessionPanel, SessionPanelContent } from "@kandev/ui/pannel-session";
|
|
import { usePreviewPanel } from "@/hooks/use-preview-panel";
|
|
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
|
|
import { ShellTerminal } from "@/components/task/shell-terminal";
|
|
import { getLocalStorage } from "@/lib/local-storage";
|
|
import { rewritePreviewUrlForProxy } from "@/lib/preview-url-detector";
|
|
import type { PreviewViewMode, PreviewStage } from "@/lib/state/slices";
|
|
|
|
type PreviewContentProps = {
|
|
previewView: string;
|
|
previewUrl: string;
|
|
showIframe: boolean;
|
|
refreshKey: number;
|
|
isRunning: boolean;
|
|
showLoadingSpinner: boolean;
|
|
allowManualUrl: boolean;
|
|
detectedUrl: string | null;
|
|
devOutput: string;
|
|
devProcessId: string | undefined;
|
|
};
|
|
|
|
function PreviewContent({
|
|
previewView,
|
|
previewUrl,
|
|
showIframe,
|
|
refreshKey,
|
|
isRunning,
|
|
showLoadingSpinner,
|
|
allowManualUrl,
|
|
detectedUrl,
|
|
devOutput,
|
|
devProcessId,
|
|
}: PreviewContentProps) {
|
|
if (previewView === "output") {
|
|
return <ShellTerminal processOutput={devOutput} processId={devProcessId ?? null} />;
|
|
}
|
|
if (showIframe && previewUrl) {
|
|
return (
|
|
<iframe
|
|
key={refreshKey}
|
|
src={previewUrl}
|
|
title="Preview"
|
|
className="h-full w-full border-0"
|
|
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"
|
|
referrerPolicy="no-referrer"
|
|
/>
|
|
);
|
|
}
|
|
if (previewUrl && !showIframe) {
|
|
return (
|
|
<div className="h-full w-full flex flex-col items-center justify-center text-muted-foreground gap-2">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
|
<p className="text-sm">Loading preview...</p>
|
|
</div>
|
|
);
|
|
}
|
|
if (isRunning && showLoadingSpinner) {
|
|
return (
|
|
<div className="h-full w-full flex flex-col items-center justify-center text-muted-foreground gap-2">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
|
<p className="text-sm">Waiting for preview URL...</p>
|
|
</div>
|
|
);
|
|
}
|
|
if (isRunning) {
|
|
return (
|
|
<div className="h-full w-full flex flex-col items-center justify-center text-muted-foreground gap-2">
|
|
<p className="text-sm">No preview URL detected</p>
|
|
{allowManualUrl && !detectedUrl && (
|
|
<p className="text-sm text-muted-foreground/70 mt-2 max-w-md text-center">
|
|
You can manually enter a URL in the input above and press Enter.
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div className="h-full w-full flex items-center justify-center text-muted-foreground">
|
|
Start the dev server to enable preview.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type PreviewPanelProps = {
|
|
sessionId: string | null;
|
|
hasDevScript: boolean;
|
|
};
|
|
|
|
function usePreviewViewSync(
|
|
sessionId: string | null,
|
|
setPreviewView: (sid: string, view: PreviewViewMode) => void,
|
|
appStoreApi: ReturnType<typeof useAppStoreApi>,
|
|
) {
|
|
useEffect(() => {
|
|
if (!sessionId) return;
|
|
const storeView = appStoreApi.getState().previewPanel.viewBySessionId[sessionId];
|
|
if (storeView) return;
|
|
const key = `preview-view:${sessionId}`;
|
|
const stored = getLocalStorage(key, null as string | null);
|
|
if (stored === "output" || stored === "preview") setPreviewView(sessionId, stored);
|
|
}, [sessionId, appStoreApi, setPreviewView]);
|
|
}
|
|
|
|
type PreviewTimerOptions = {
|
|
isRunning: boolean;
|
|
detectedUrl: string | null;
|
|
sessionId: string | null;
|
|
previewUrl: string;
|
|
refreshKey: number;
|
|
setPreviewView: (sid: string, view: PreviewViewMode) => void;
|
|
};
|
|
|
|
function usePreviewTimers({
|
|
isRunning,
|
|
detectedUrl,
|
|
sessionId,
|
|
previewUrl,
|
|
refreshKey,
|
|
setPreviewView,
|
|
}: PreviewTimerOptions) {
|
|
const [allowManualUrl, setAllowManualUrl] = useState(false);
|
|
const [showLoadingSpinner, setShowLoadingSpinner] = useState(true);
|
|
const [showIframe, setShowIframe] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!isRunning || detectedUrl) {
|
|
const t = setTimeout(() => {
|
|
setAllowManualUrl(false);
|
|
setShowLoadingSpinner(true);
|
|
}, 0);
|
|
return () => clearTimeout(t);
|
|
}
|
|
const resetTimer = setTimeout(() => setShowLoadingSpinner(true), 0);
|
|
const timer = setTimeout(() => {
|
|
setAllowManualUrl(true);
|
|
setShowLoadingSpinner(false);
|
|
if (sessionId) setPreviewView(sessionId, "preview");
|
|
}, 10000);
|
|
return () => {
|
|
clearTimeout(resetTimer);
|
|
clearTimeout(timer);
|
|
};
|
|
}, [isRunning, detectedUrl, sessionId, setPreviewView]);
|
|
|
|
useEffect(() => {
|
|
if (!previewUrl) {
|
|
const t = setTimeout(() => setShowIframe(false), 0);
|
|
return () => clearTimeout(t);
|
|
}
|
|
const hideTimer = setTimeout(() => setShowIframe(false), 0);
|
|
const showTimer = setTimeout(() => setShowIframe(true), 2000);
|
|
return () => {
|
|
clearTimeout(hideTimer);
|
|
clearTimeout(showTimer);
|
|
};
|
|
}, [previewUrl, refreshKey]);
|
|
|
|
return { allowManualUrl, showLoadingSpinner, showIframe };
|
|
}
|
|
|
|
type PreviewActions = {
|
|
setPreviewOpen: (sid: string, open: boolean) => void;
|
|
setPreviewStage: (sid: string, stage: PreviewStage) => void;
|
|
setPreviewView: (sid: string, view: PreviewViewMode) => void;
|
|
setPreviewUrl: (sid: string, url: string) => void;
|
|
setPreviewUrlDraft: (sid: string, draft: string) => void;
|
|
clearProcessOutput: (pid: string) => void;
|
|
handleStop: () => Promise<void>;
|
|
previewUrl: string;
|
|
previewUrlDraft: string;
|
|
devProcessId: string | undefined;
|
|
isRemote: boolean;
|
|
};
|
|
|
|
function usePreviewActions(sessionId: string | null, actions: PreviewActions) {
|
|
const {
|
|
setPreviewOpen,
|
|
setPreviewStage,
|
|
setPreviewView,
|
|
setPreviewUrl,
|
|
setPreviewUrlDraft,
|
|
clearProcessOutput,
|
|
handleStop,
|
|
previewUrl,
|
|
previewUrlDraft,
|
|
devProcessId,
|
|
isRemote,
|
|
} = actions;
|
|
|
|
const handleStopClick = useCallback(async () => {
|
|
if (!sessionId) return;
|
|
setPreviewOpen(sessionId, false);
|
|
setPreviewStage(sessionId, "closed");
|
|
setPreviewView(sessionId, "preview");
|
|
setPreviewUrl(sessionId, "");
|
|
setPreviewUrlDraft(sessionId, "");
|
|
if (devProcessId) clearProcessOutput(devProcessId);
|
|
await handleStop();
|
|
}, [
|
|
sessionId,
|
|
setPreviewOpen,
|
|
setPreviewStage,
|
|
setPreviewView,
|
|
setPreviewUrl,
|
|
setPreviewUrlDraft,
|
|
devProcessId,
|
|
clearProcessOutput,
|
|
handleStop,
|
|
]);
|
|
|
|
const handleUrlSubmit = useCallback(() => {
|
|
if (!sessionId) return;
|
|
const trimmed = previewUrlDraft.trim();
|
|
if (!trimmed) return;
|
|
const rewritten = rewritePreviewUrlForProxy(trimmed, sessionId, isRemote);
|
|
setPreviewUrl(sessionId, rewritten ?? trimmed);
|
|
}, [sessionId, previewUrlDraft, setPreviewUrl, isRemote]);
|
|
|
|
const handleOpenInTab = useCallback(() => {
|
|
if (!sessionId || !previewUrl) return;
|
|
window.open(previewUrl, "_blank", "noopener,noreferrer");
|
|
setPreviewOpen(sessionId, false);
|
|
setPreviewStage(sessionId, "closed");
|
|
setPreviewView(sessionId, "preview");
|
|
setPreviewUrl(sessionId, "");
|
|
setPreviewUrlDraft(sessionId, "");
|
|
}, [
|
|
sessionId,
|
|
previewUrl,
|
|
setPreviewOpen,
|
|
setPreviewStage,
|
|
setPreviewView,
|
|
setPreviewUrl,
|
|
setPreviewUrlDraft,
|
|
]);
|
|
|
|
return { handleStopClick, handleUrlSubmit, handleOpenInTab };
|
|
}
|
|
|
|
function resolveStopLabel(isStopping: boolean, isFailed: boolean, isExited: boolean): string {
|
|
if (isStopping) return "Stopping\u2026";
|
|
if (isFailed || isExited) return "Close";
|
|
return "Stop";
|
|
}
|
|
|
|
type PreviewToolbarProps = {
|
|
sessionId: string;
|
|
previewUrlDraft: string;
|
|
previewUrl: string;
|
|
previewView: string;
|
|
detectedUrl: string | null;
|
|
isStopping: boolean;
|
|
isWaitingForUrl: boolean;
|
|
showLoadingSpinner: boolean;
|
|
stopLabel: string;
|
|
setPreviewUrlDraft: (sid: string, draft: string) => void;
|
|
setPreviewView: (sid: string, view: PreviewViewMode) => void;
|
|
setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
|
|
handleUrlSubmit: () => void;
|
|
handleOpenInTab: () => void;
|
|
handleStopClick: () => void;
|
|
};
|
|
|
|
function PreviewToolbar({
|
|
sessionId,
|
|
previewUrlDraft,
|
|
previewUrl,
|
|
previewView,
|
|
detectedUrl,
|
|
isStopping,
|
|
isWaitingForUrl,
|
|
showLoadingSpinner,
|
|
stopLabel,
|
|
setPreviewUrlDraft,
|
|
setPreviewView,
|
|
setRefreshKey,
|
|
handleUrlSubmit,
|
|
handleOpenInTab,
|
|
handleStopClick,
|
|
}: PreviewToolbarProps) {
|
|
return (
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Input
|
|
value={previewUrlDraft}
|
|
onChange={(event) => setPreviewUrlDraft(sessionId, event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
handleUrlSubmit();
|
|
}
|
|
}}
|
|
placeholder={detectedUrl || "http://localhost:3000"}
|
|
className="h-6 flex-1 min-w-[240px]"
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={handleOpenInTab}
|
|
disabled={!previewUrl}
|
|
className="cursor-pointer"
|
|
title="Open in browser tab"
|
|
>
|
|
<IconExternalLink className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => setRefreshKey((v) => v + 1)}
|
|
disabled={!previewUrl}
|
|
className="cursor-pointer"
|
|
title="Refresh preview"
|
|
>
|
|
<IconRefresh className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={handleStopClick}
|
|
disabled={isStopping}
|
|
className="cursor-pointer"
|
|
>
|
|
{stopLabel}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant={previewView === "output" ? "default" : "outline"}
|
|
className="cursor-pointer"
|
|
onClick={() => setPreviewView(sessionId, previewView === "output" ? "preview" : "output")}
|
|
>
|
|
{isWaitingForUrl && showLoadingSpinner && (
|
|
<IconLoader2 className="h-4 w-4 mr-1 animate-spin" />
|
|
)}
|
|
{previewView === "output" ? "Preview" : "Logs"}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function usePreviewPanelState(sessionId: string | null, hasDevScript: boolean) {
|
|
const panelState = usePreviewPanel({ sessionId, hasDevScript });
|
|
const setPreviewOpen = useAppStore((state) => state.setPreviewOpen);
|
|
const setPreviewStage = useAppStore((state) => state.setPreviewStage);
|
|
const setPreviewView = useAppStore((state) => state.setPreviewView);
|
|
const clearProcessOutput = useAppStore((state) => state.clearProcessOutput);
|
|
const appStoreApi = useAppStoreApi();
|
|
|
|
const previewView = useAppStore((state) => {
|
|
if (!sessionId) return "preview";
|
|
const stored = state.previewPanel.viewBySessionId[sessionId];
|
|
if (stored) return stored;
|
|
const key = `preview-view:${sessionId}`;
|
|
const localStored = getLocalStorage(key, null as string | null);
|
|
return localStored === "output" || localStored === "preview" ? localStored : "preview";
|
|
});
|
|
|
|
const devProcessId = useAppStore((state) =>
|
|
sessionId ? state.processes.devProcessBySessionId[sessionId] : undefined,
|
|
);
|
|
const devProcess = useAppStore((state) =>
|
|
devProcessId ? state.processes.processesById[devProcessId] : undefined,
|
|
);
|
|
const devOutput = useAppStore((state) =>
|
|
devProcessId ? (state.processes.outputsByProcessId[devProcessId] ?? "") : "",
|
|
);
|
|
|
|
return {
|
|
...panelState,
|
|
setPreviewOpen,
|
|
setPreviewStage,
|
|
setPreviewView,
|
|
clearProcessOutput,
|
|
appStoreApi,
|
|
previewView,
|
|
devProcessId,
|
|
devProcess,
|
|
devOutput,
|
|
};
|
|
}
|
|
|
|
function PreviewPlaceholder({ message }: { message: string }) {
|
|
return (
|
|
<div className="h-full w-full flex items-center justify-center text-muted-foreground mr-[5px]">
|
|
{message}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function PreviewPanel({ sessionId, hasDevScript }: PreviewPanelProps) {
|
|
const panelState = usePreviewPanelState(sessionId, hasDevScript);
|
|
const {
|
|
previewUrl,
|
|
previewUrlDraft,
|
|
setPreviewUrl,
|
|
setPreviewUrlDraft,
|
|
setPreviewOpen,
|
|
setPreviewStage,
|
|
setPreviewView,
|
|
clearProcessOutput,
|
|
appStoreApi,
|
|
previewView,
|
|
devProcessId,
|
|
devProcess,
|
|
devOutput,
|
|
isStopping,
|
|
handleStop,
|
|
detectedUrl,
|
|
isRunning,
|
|
isRemote,
|
|
} = panelState;
|
|
|
|
const [refreshKey, setRefreshKey] = useState(0);
|
|
const isWaitingForUrl = isRunning && previewView === "output" && !previewUrl;
|
|
|
|
usePreviewViewSync(sessionId, setPreviewView, appStoreApi);
|
|
const { allowManualUrl, showLoadingSpinner, showIframe } = usePreviewTimers({
|
|
isRunning,
|
|
detectedUrl,
|
|
sessionId,
|
|
previewUrl,
|
|
refreshKey,
|
|
setPreviewView,
|
|
});
|
|
const { handleStopClick, handleUrlSubmit, handleOpenInTab } = usePreviewActions(sessionId, {
|
|
setPreviewOpen,
|
|
setPreviewStage,
|
|
setPreviewView,
|
|
setPreviewUrl,
|
|
setPreviewUrlDraft,
|
|
clearProcessOutput,
|
|
handleStop,
|
|
previewUrl,
|
|
previewUrlDraft,
|
|
devProcessId,
|
|
isRemote: isRemote ?? false,
|
|
});
|
|
|
|
if (!sessionId) return <PreviewPlaceholder message="Select a session to enable preview." />;
|
|
if (!hasDevScript) return <PreviewPlaceholder message="Configure a dev script to use preview." />;
|
|
|
|
const stopLabel = resolveStopLabel(
|
|
isStopping,
|
|
devProcess?.status === "failed",
|
|
devProcess?.status === "exited",
|
|
);
|
|
|
|
return (
|
|
<SessionPanel margin="right">
|
|
<div className="h-full flex flex-col gap-2 flex-1">
|
|
<PreviewToolbar
|
|
sessionId={sessionId}
|
|
previewUrlDraft={previewUrlDraft}
|
|
previewUrl={previewUrl}
|
|
previewView={previewView}
|
|
detectedUrl={detectedUrl}
|
|
isStopping={isStopping}
|
|
isWaitingForUrl={isWaitingForUrl}
|
|
showLoadingSpinner={showLoadingSpinner}
|
|
stopLabel={stopLabel}
|
|
setPreviewUrlDraft={setPreviewUrlDraft}
|
|
setPreviewView={setPreviewView}
|
|
setRefreshKey={setRefreshKey}
|
|
handleUrlSubmit={handleUrlSubmit}
|
|
handleOpenInTab={handleOpenInTab}
|
|
handleStopClick={handleStopClick}
|
|
/>
|
|
<SessionPanelContent
|
|
className={previewView === "output" || (showIframe && previewUrl) ? "p-0" : ""}
|
|
>
|
|
<PreviewContent
|
|
previewView={previewView}
|
|
previewUrl={previewUrl}
|
|
showIframe={showIframe}
|
|
refreshKey={refreshKey}
|
|
isRunning={isRunning}
|
|
showLoadingSpinner={showLoadingSpinner}
|
|
allowManualUrl={allowManualUrl}
|
|
detectedUrl={detectedUrl}
|
|
devOutput={devOutput}
|
|
devProcessId={devProcessId}
|
|
/>
|
|
</SessionPanelContent>
|
|
</div>
|
|
</SessionPanel>
|
|
);
|
|
}
|