fix(preview): say why an outside site shows blank, with Open tab beside it

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.
This commit is contained in:
2026-11-02 15:10:22 +07:00
parent 1294ce1faa
commit 1ad2fec073
5 changed files with 44 additions and 7 deletions
+24 -3
View File
@@ -7,7 +7,9 @@
* `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; that is the browser's rule, so the way out is the Open button. The
* 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>
@@ -16,7 +18,7 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ExternalLink, Globe, RotateCw } from "lucide-react";
import { normalizePreviewUrl } from "../../lib/openInPreview";
import { isLoopbackUrl, normalizePreviewUrl } from "../../lib/openInPreview";
import { isSubmitEnter } from "../../lib/keys";
import { toast } from "../../lib/toast";
@@ -80,6 +82,7 @@ export function UrlPreview({
}
}
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";
@@ -115,13 +118,31 @@ export function UrlPreview({
type="button"
className={btn}
disabled={!url}
onClick={() => url && window.open(url, "_blank", "noopener,noreferrer")}
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}`}
@@ -2,7 +2,8 @@
* @file UrlPreview.test.tsx
* @description The Preview tab: port chips before a page is open, the
* address bar opening a page in a sandboxed frame without top navigation,
* a link's request opening its page, and the dashboard's own origin refused.
* a link's request opening its page, the dashboard's own origin refused, and
* the framing-refusal strip shown for an outside site only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -36,6 +37,17 @@ describe("UrlPreview", () => {
);
});
it("warns that an outside site may refuse to be framed, never for a local page", () => {
render(<UrlPreview laneId={4} ports={{}} request={null} />);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "localhost:3000" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.queryByTestId("url-preview-blocked-hint")).toBeNull();
fireEvent.change(input, { target: { value: "https://youtube.com" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByTestId("url-preview-blocked-hint")).toBeTruthy();
});
it("opens a link's request", () => {
render(
<UrlPreview laneId={3} ports={{}} request={{ url: "http://localhost:5173/", nonce: 1 }} />