Files
kandev/apps/web/components/task/browser-panel.tsx
T

217 lines
7.3 KiB
TypeScript

"use client";
import { memo, useState, useEffect, useMemo, useRef } from "react";
import { IconRefresh, IconExternalLink } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
import { PanelRoot, PanelBody, PanelHeaderBar } from "./panel-primitives";
import { useAppStore } from "@/components/state-provider";
import { detectPreviewUrlFromOutput, rewritePreviewUrlForProxy } from "@/lib/preview-url-detector";
import { InspectButton } from "./inspector/inspect-button";
import { AnnotationsPanel } from "./inspector/annotations-panel";
import { useInspectMode } from "@/hooks/use-inspect-mode";
import { usePreviewConsoleForwarder } from "@/hooks/use-preview-console-forwarder";
import { openExternalLink } from "@/lib/desktop/external-links";
import { useTranslation } from "react-i18next";
function BrowserPanelContent({
showIframeDelayed,
iframeSrc,
refreshKey,
iframeRef,
onIframeLoad,
}: {
showIframeDelayed: string | false;
iframeSrc: string;
refreshKey: number;
iframeRef: React.RefObject<HTMLIFrameElement | null>;
onIframeLoad: () => void;
}) {
const { t } = useTranslation();
if (showIframeDelayed) {
return (
<iframe
ref={iframeRef}
key={refreshKey}
src={iframeSrc}
title={t("task:browserPreview")}
className="h-full w-full border-0"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"
referrerPolicy="no-referrer"
onLoad={onIframeLoad}
/>
);
}
if (iframeSrc) {
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">{t("task:loadingPreview")}</p>
</div>
);
}
return (
<div className="h-full w-full flex flex-col items-center justify-center text-muted-foreground gap-2">
<p className="text-sm">{t("task:enterAUrlAboveOrStart")}</p>
</div>
);
}
type BrowserPanelProps = {
panelId: string;
params: Record<string, unknown>;
};
function useBrowserPanelUrl(initialUrl: string, useProxy: boolean) {
const [userUrl, setUserUrl] = useState(initialUrl);
const [urlDraft, setUrlDraft] = useState(initialUrl);
const [refreshKey, setRefreshKey] = useState(0);
const [showIframe, setShowIframe] = useState(false);
useEffect(() => {
setUserUrl(initialUrl);
setUrlDraft(initialUrl);
}, [initialUrl]);
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const devProcessId = useAppStore((state) =>
activeSessionId ? state.processes.devProcessBySessionId[activeSessionId] : undefined,
);
const devOutput = useAppStore((state) =>
devProcessId ? (state.processes.outputsByProcessId[devProcessId] ?? "") : "",
);
const detectedUrl = detectPreviewUrlFromOutput(devOutput);
const directUrl = useMemo(() => userUrl || detectedUrl || "", [userUrl, detectedUrl]);
// Proxied variant — only reachable when there is an active session AND the
// URL is localhost-with-port. Returns null otherwise so the panel can hide
// the Inspect button.
const proxiedUrl = useMemo(() => {
if (!directUrl || !activeSessionId) return null;
return rewritePreviewUrlForProxy(directUrl, activeSessionId);
}, [directUrl, activeSessionId]);
// Default to the direct URL so the page renders normally; clicking Inspect
// switches to the proxied src so the inspector script can be injected. The
// gateway port-proxy rewrites root-absolute asset references and patches the
// network-facing browser APIs at runtime, so the proxied page works for SPA
// routers and dynamic asset URLs too.
const iframeSrc = useProxy && proxiedUrl ? proxiedUrl : directUrl;
// Key the loading-spinner gate to the underlying URL (and the refresh key),
// NOT to `iframeSrc`. Toggling Inspect mode flips `iframeSrc` between the
// direct URL and the proxied URL even though the user's destination didn't
// change; if the effect re-ran on every `iframeSrc` change, the cleanup
// would hide the iframe and show the 1.5s spinner on every Inspect toggle.
// Browser-level iframe navigation handles direct↔proxy swaps fine.
useEffect(() => {
if (!directUrl) return;
const showTimer = setTimeout(() => setShowIframe(true), 1500);
return () => {
setShowIframe(false);
clearTimeout(showTimer);
};
}, [directUrl, refreshKey]);
const displayDraft = urlDraft || detectedUrl || "";
const showIframeDelayed: string | false = showIframe ? iframeSrc : false;
function handleUrlSubmit() {
const trimmed = urlDraft.trim();
if (trimmed) setUserUrl(trimmed);
}
function handleOpenInTab() {
if (directUrl) void openExternalLink(directUrl).catch(() => undefined);
}
return {
directUrl,
iframeSrc,
canProxy: !!proxiedUrl,
refreshKey,
setRefreshKey,
urlDraft,
setUrlDraft,
displayDraft,
detectedUrl,
showIframeDelayed,
handleUrlSubmit,
handleOpenInTab,
};
}
export const BrowserPanel = memo(function BrowserPanel({ params }: BrowserPanelProps) {
const { t } = useTranslation();
const iframeRef = useRef<HTMLIFrameElement>(null);
const inspect = useInspectMode(iframeRef);
usePreviewConsoleForwarder(iframeRef);
// Inspect mode = "load this page through the proxy so the inspector script
// can be injected". Toggling Inspect remounts the iframe with a different src.
const url = useBrowserPanelUrl((params.url as string) || "", inspect.isInspectMode);
const showInspect = url.canProxy;
return (
<PanelRoot data-testid="browser-panel">
<PanelHeaderBar>
<Input
value={url.displayDraft}
onChange={(e) => url.setUrlDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
url.handleUrlSubmit();
}
}}
placeholder={url.detectedUrl || "http://localhost:3000"}
className="h-6 flex-1 min-w-[180px]"
/>
<Button
size="sm"
variant="outline"
onClick={url.handleOpenInTab}
disabled={!url.directUrl}
className="cursor-pointer"
title={t("task:openInBrowserTab")}
>
<IconExternalLink className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => url.setRefreshKey((v) => v + 1)}
disabled={!url.directUrl}
className="cursor-pointer"
title={t("task:refresh")}
>
<IconRefresh className="h-4 w-4" />
</Button>
{showInspect && (
<InspectButton
active={inspect.isInspectMode}
count={inspect.annotations.length}
onToggle={inspect.toggleInspect}
/>
)}
</PanelHeaderBar>
<AnnotationsPanel
annotations={inspect.annotations}
onRemove={inspect.handleRemoveAnnotation}
onClear={inspect.handleClearAnnotations}
/>
<PanelBody padding={false} scroll={false}>
<BrowserPanelContent
showIframeDelayed={url.showIframeDelayed}
iframeSrc={url.iframeSrc}
refreshKey={url.refreshKey}
iframeRef={iframeRef}
onIframeLoad={inspect.handleIframeLoad}
/>
</PanelBody>
</PanelRoot>
);
});