1ad2fec073
YouTube, Google and login pages refuse framing and the browser draws a broken-page icon; the hint lived only in the empty state. A page not on this machine now gets a strip under the address bar with Open tab. The refused- address toast says what is allowed in plain words.
181 lines
6.7 KiB
TypeScript
181 lines
6.7 KiB
TypeScript
/**
|
|
* @file UrlPreview.tsx
|
|
* @description The session panel's Preview tab: a web page (a lane's dev
|
|
* server, mostly) shown beside the console. One bar — address, Reload, Open
|
|
* in a browser tab — and, before anything is open, the workspace's own ports
|
|
* as one-click chips. The page runs in an `<iframe src>` on ITS OWN origin:
|
|
* `normalizePreviewUrl` refuses the dashboard's origin and anything but
|
|
* http(s), and the sandbox leaves out top navigation so a page cannot move
|
|
* the dashboard away. A site that forbids framing (most login pages) shows
|
|
* blank or as the browser's broken-page icon; that is the browser's rule and
|
|
* cannot be read from here, so any page not on this machine gets a strip
|
|
* saying so with the Open button beside it. The
|
|
* last address is remembered per workspace in localStorage (a viewer
|
|
* convenience, never the server's).
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { ExternalLink, Globe, RotateCw } from "lucide-react";
|
|
import { isLoopbackUrl, normalizePreviewUrl } from "../../lib/openInPreview";
|
|
import { isSubmitEnter } from "../../lib/keys";
|
|
import { toast } from "../../lib/toast";
|
|
|
|
const KEY = (laneId: number) => `agenthub.preview.url.${laneId}`;
|
|
|
|
function readSaved(laneId: number): string {
|
|
try {
|
|
return window.localStorage.getItem(KEY(laneId)) ?? "";
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
/** The frame's sandbox: everything a web app needs, minus moving the
|
|
* dashboard (`allow-top-navigation*`). `allow-same-origin` is the PAGE's
|
|
* own origin — never the dashboard's, which `normalizePreviewUrl` refuses. */
|
|
const FRAME_SANDBOX =
|
|
"allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-modals allow-downloads";
|
|
|
|
export function UrlPreview({
|
|
laneId,
|
|
ports,
|
|
request,
|
|
}: {
|
|
laneId: number;
|
|
/** The lane's bound ports, by name — the chips of the empty state. */
|
|
ports: Record<string, number>;
|
|
/** A link asked for this page (`openInPreview`); a new nonce re-opens it. */
|
|
request: { url: string; nonce: number } | null;
|
|
}) {
|
|
const { t } = useTranslation("lanes");
|
|
const [url, setUrl] = useState(() => readSaved(laneId));
|
|
const [draft, setDraft] = useState(url);
|
|
const [reload, setReload] = useState(0);
|
|
|
|
// Another workspace: its own last page.
|
|
useEffect(() => {
|
|
const saved = readSaved(laneId);
|
|
setUrl(saved);
|
|
setDraft(saved);
|
|
}, [laneId]);
|
|
|
|
useEffect(() => {
|
|
if (request) go(request.url);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the nonce
|
|
}, [request?.nonce]);
|
|
|
|
function go(raw: string) {
|
|
const next = normalizePreviewUrl(raw);
|
|
if (!next) {
|
|
toast.error(t("preview.badUrl"));
|
|
return;
|
|
}
|
|
setUrl(next);
|
|
setDraft(next);
|
|
setReload((n) => n + 1);
|
|
try {
|
|
window.localStorage.setItem(KEY(laneId), next);
|
|
} catch {
|
|
/* disabled storage: the tab just forgets between visits */
|
|
}
|
|
}
|
|
|
|
const openTab = () => url && window.open(url, "_blank", "noopener,noreferrer");
|
|
const portList = Object.entries(ports ?? {}).filter(([, p]) => Number.isInteger(p) && p > 0);
|
|
const btn =
|
|
"flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40 disabled:hover:bg-transparent";
|
|
|
|
return (
|
|
<div className="flex min-h-0 flex-1 flex-col" data-testid="url-preview">
|
|
<div className="flex shrink-0 items-center gap-1 border-b border-border px-2 py-1.5">
|
|
<input
|
|
value={draft}
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (isSubmitEnter(e)) {
|
|
e.preventDefault();
|
|
go(draft);
|
|
}
|
|
}}
|
|
placeholder="localhost:3000"
|
|
aria-label={t("preview.address")}
|
|
spellCheck={false}
|
|
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
|
/>
|
|
<button
|
|
type="button"
|
|
className={btn}
|
|
disabled={!url}
|
|
onClick={() => setReload((n) => n + 1)}
|
|
title={t("preview.reload")}
|
|
aria-label={t("preview.reload")}
|
|
>
|
|
<RotateCw className="h-3.5 w-3.5" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={btn}
|
|
disabled={!url}
|
|
onClick={openTab}
|
|
title={t("preview.openTab")}
|
|
aria-label={t("preview.openTab")}
|
|
>
|
|
<ExternalLink className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
{url && !isLoopbackUrl(url) && (
|
|
// An outside site: most refuse to be framed, and a refusal cannot be
|
|
// detected from here — so say it, with the way out beside it.
|
|
<div
|
|
data-testid="url-preview-blocked-hint"
|
|
className="flex shrink-0 items-center gap-2 border-b border-border bg-surface-2 px-2 py-1.5 text-[11px] text-fg-secondary"
|
|
>
|
|
<span className="min-w-0 flex-1">{t("preview.blockedHint")}</span>
|
|
<button
|
|
type="button"
|
|
onClick={openTab}
|
|
className="flex shrink-0 items-center gap-1 rounded-md px-1.5 py-0.5 font-medium text-accent hover:bg-surface-4"
|
|
>
|
|
<ExternalLink className="h-3 w-3" />
|
|
{t("preview.openTabShort")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
{url ? (
|
|
<iframe
|
|
key={`${url}#${reload}`}
|
|
src={url}
|
|
title={t("preview.title")}
|
|
sandbox={FRAME_SANDBOX}
|
|
referrerPolicy="no-referrer"
|
|
className="min-h-0 w-full flex-1 border-0 bg-white"
|
|
data-testid="url-preview-frame"
|
|
/>
|
|
) : (
|
|
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
|
|
<Globe className="h-6 w-6 text-fg-muted" />
|
|
<p className="text-xs text-fg-secondary">{t("preview.empty")}</p>
|
|
{portList.length > 0 && (
|
|
<div className="flex flex-wrap justify-center gap-1.5">
|
|
{portList.map(([name, port]) => (
|
|
<button
|
|
key={name}
|
|
type="button"
|
|
onClick={() => go(`localhost:${port}`)}
|
|
title={name}
|
|
className="rounded-full border border-border px-2.5 py-0.5 font-mono text-[11px] text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary"
|
|
>
|
|
localhost:{port}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
<p className="text-[11px] text-fg-muted">{t("preview.loginHint")}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|