From 7b5abc6ad9f5561ee46b4c51b7c0ce555053fa5d Mon Sep 17 00:00:00 2001
From: nntrivi2001
Date: Wed, 28 Oct 2026 16:45:57 +0700
Subject: [PATCH] =?UTF-8?q?feat(files):=20an=20HTML=20file=20opens=20as=20?=
=?UTF-8?q?the=20page=20=E2=80=94=20Files=20panel,=20Chat=20preview,=20Fil?=
=?UTF-8?q?e=20Explorer?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Clicking an .html/.htm file now renders it instead of showing its source or
downloading it:
- Changes/Files panel: a whole HTML file opens on Preview (Raw = the editor).
- Chat file preview: a workspace page brings its relative files along.
- File Explorer: double-click / Enter opens a viewer; Download stays on its
header and right-click menu.
The page runs in the chat artifact's sandbox (iframe srcdoc, scripts, never
allow-same-origin). Its relative stylesheets (and their url()s), scripts,
images, media and icons are read by the dashboard through the existing
download routes and inlined as "
+ )
+ );
+ expect(click).not.toHaveBeenCalled();
+ fireEvent.click(within(viewer).getByRole("button", { name: "Close" }));
+ expect(screen.queryByTestId("fx-html-viewer")).toBeNull();
+ fetchSpy.mockRestore();
+ click.mockRestore();
+ });
+
it("makes a folder inline", async () => {
vi.mocked(api.files.mkdir).mockResolvedValue({ path: "docs" });
const user = userEvent.setup();
diff --git a/client/src/components/lanes/__tests__/LaneChangesPanel.test.tsx b/client/src/components/lanes/__tests__/LaneChangesPanel.test.tsx
index 30dcac15..57d67c98 100644
--- a/client/src/components/lanes/__tests__/LaneChangesPanel.test.tsx
+++ b/client/src/components/lanes/__tests__/LaneChangesPanel.test.tsx
@@ -544,6 +544,34 @@ describe("LaneChangesPanel", () => {
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
});
+ it("opens a whole HTML file as the page, its source after Raw", async () => {
+ vi.mocked(api.lanes.gitTree).mockResolvedValue({
+ available: true,
+ path: "",
+ entries: [{ name: "deck.html", path: "deck.html", dir: false, status: "??" }],
+ truncated: false,
+ });
+ vi.mocked(api.lanes.gitFile).mockResolvedValue({
+ available: true,
+ path: "deck.html",
+ rev: "work",
+ binary: false,
+ truncated: false,
+ lines: ["deck
"],
+ sha: "c".repeat(40),
+ });
+ const user = userEvent.setup();
+ render();
+ const sidebar = await screen.findByTestId("lane-changes-sidebar");
+ await user.click(await within(sidebar).findByText("deck.html"));
+ const frame = await screen.findByTestId("html-frame");
+ expect(frame.getAttribute("srcdoc")).toContain("deck
");
+ expect(frame.getAttribute("sandbox")).not.toContain("allow-same-origin");
+ await user.click(screen.getByTestId("lane-file-preview"));
+ expect(await screen.findByTestId("lane-file-editor")).toHaveValue("deck
");
+ expect(screen.queryByTestId("html-frame")).toBeNull();
+ });
+
it("shows an image file as the picture, with no edit control", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
diff --git a/client/src/components/run/ChatFilePreview.tsx b/client/src/components/run/ChatFilePreview.tsx
index b554b3d2..25c991b6 100644
--- a/client/src/components/run/ChatFilePreview.tsx
+++ b/client/src/components/run/ChatFilePreview.tsx
@@ -39,6 +39,7 @@ import { useDialog } from "../../hooks/useDialog";
import { dragSkill } from "../../lib/promptAttach";
import { readMarkdownPreview } from "../../lib/markdownView";
import { MarkdownPreview } from "../MarkdownPreview";
+import { HtmlFrame, fetchVia } from "../HtmlFrame";
import { CodeBlock } from "../conversation/CodeBlock";
import { useTheme } from "../../hooks/useTheme";
import { toast } from "../../lib/toast";
@@ -85,6 +86,18 @@ export const extOf = (p: string) => {
export const displayName = (p: string) =>
DROP_RE.test(p) ? baseName(p).replace(/^[0-9a-f]{16}-?/, "") || baseName(p) : baseName(p);
+/** A file inside the pane's folder: the git target that reads it and its path there. */
+function workspaceFile(path: string, scope: PreviewScope) {
+ const file = slashed(path);
+ const base = slashed(scope.cwd).replace(/\/+$/, "");
+ if (scope.laneId === null || !base || !file.startsWith(`${base}/`)) return null;
+ const target =
+ base === slashed(scope.laneCwd).replace(/\/+$/, "")
+ ? scope.laneId
+ : gitTarget(scope.laneId, scope.sessionId);
+ return { target, rel: file.slice(base.length + 1) };
+}
+
type Content =
| { kind: "loading" }
| { kind: "image"; src: string }
@@ -121,8 +134,8 @@ function useContent(path: string, scope: PreviewScope): Content {
};
}
const file = slashed(path);
- const base = slashed(scope.cwd).replace(/\/+$/, "");
- if (scope.laneId === null || !base || !file.startsWith(`${base}/`)) {
+ const inside = workspaceFile(path, scope);
+ if (!inside) {
// Claude Code's own files (memory, a skill): the config pages' guarded read.
if (/\/\.claude\//.test(file) && !IMAGE_RE.test(file)) {
show(
@@ -147,11 +160,7 @@ function useContent(path: string, scope: PreviewScope): Content {
live = false;
};
}
- const rel = file.slice(base.length + 1);
- const target =
- base === slashed(scope.laneCwd).replace(/\/+$/, "")
- ? scope.laneId
- : gitTarget(scope.laneId, scope.sessionId);
+ const { target, rel } = inside;
if (IMAGE_RE.test(path)) {
setContent({ kind: "image", src: api.lanes.gitRawUrl(target, rel) });
return;
@@ -235,9 +244,28 @@ function defaultView(path: string): View {
return kind === "markdown" && !readMarkdownPreview() ? "code" : "preview";
}
-function Body({ path, content, view }: { path: string; content: Content; view: View }) {
+function Body({
+ path,
+ content,
+ view,
+ scope,
+}: {
+ path: string;
+ content: Content;
+ view: View;
+ scope: PreviewScope;
+}) {
const { t } = useTranslation("run");
const kind = artifactKind(path);
+ // A page in the workspace brings its relative CSS, scripts and images along.
+ const inside = kind === "html" ? workspaceFile(path, scope) : null;
+ const insideTarget = inside ? JSON.stringify(inside.target) : null;
+ const page = useMemo(() => {
+ if (!inside) return null;
+ const { target, rel } = inside;
+ return { rel, fetchFile: fetchVia((p) => api.lanes.gitDownloadUrl(target, p)) };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [insideTarget, inside?.rel]);
// An image the route refused (gone, server mode) shows like no preview.
const [broken, setBroken] = useState(null);
const shown: Content =
@@ -290,6 +318,18 @@ function Body({ path, content, view }: { path: string; content: Content; view: V
);
}
if (kind === "csv") return ;
+ if (page && !shown.truncated) {
+ return (
+
+ );
+ }
return ;
}
@@ -503,7 +543,7 @@ export function FilePreview({
-
+
);
// Full screen: over the whole page, where a pane's edges cannot clip it.
diff --git a/client/src/components/run/__tests__/ChatFilePreview.test.tsx b/client/src/components/run/__tests__/ChatFilePreview.test.tsx
index 1af74d51..6931ec32 100644
--- a/client/src/components/run/__tests__/ChatFilePreview.test.tsx
+++ b/client/src/components/run/__tests__/ChatFilePreview.test.tsx
@@ -242,7 +242,9 @@ describe("the artifact view", () => {
((await screen.findByTestId("artifact-frame")) as HTMLIFrameElement).getAttribute("srcdoc")
).toContain("two");
fireEvent.click(versions.querySelector("button")!);
- expect(screen.getByTestId("artifact-frame").getAttribute("srcdoc")).toContain("
one
");
+ await vi.waitFor(() =>
+ expect(screen.getByTestId("artifact-frame").getAttribute("srcdoc")).toContain("one
")
+ );
cleanup();
});
diff --git a/client/src/lib/__tests__/htmlInline.test.ts b/client/src/lib/__tests__/htmlInline.test.ts
new file mode 100644
index 00000000..9579dbe2
--- /dev/null
+++ b/client/src/lib/__tests__/htmlInline.test.ts
@@ -0,0 +1,53 @@
+/**
+ * @file htmlInline.test.ts
+ * @description An HTML preview's relative CSS, scripts and images are inlined
+ * from the place's own files, never from above its root or off a URL.
+ * @author Nguyễn Ngọc Trí Vĩ
+ */
+
+import { describe, expect, it, vi } from "vitest";
+import { inlineHtml, resolveRef } from "../htmlInline";
+
+const files: Record = {
+ "site/css/app.css": "body{background:url(../img/bg.png)}",
+ "site/js/app.js": "window.ok=1",
+ "site/img/logo.png": "PNG",
+ "site/img/bg.png": "BG",
+};
+const fetchFile = vi.fn(async (p: string) => {
+ if (!(p in files)) throw new Error("404");
+ return new Blob([files[p]!]);
+});
+
+describe("resolveRef", () => {
+ it("resolves against the page's folder, from the root on a leading slash", () => {
+ expect(resolveRef("site", "css/a.css?v=2#x")).toBe("site/css/a.css");
+ expect(resolveRef("site/sub", "../img/a%20b.png")).toBe("site/img/a b.png");
+ expect(resolveRef("site", "/top.css")).toBe("top.css");
+ });
+ it("skips URLs, anchors and anything above the root", () => {
+ for (const r of ["https://x/a.js", "//cdn/a.js", "data:x", "#top", "", "../../etc/passwd"])
+ expect(resolveRef("site", r)).toBeNull();
+ });
+});
+
+describe("inlineHtml", () => {
+ it("inlines stylesheets (with their url()s), scripts and images", async () => {
+ const html = `
+
+
`;
+ const out = await inlineHtml(html, "site/index.html", fetchFile);
+ expect(out.startsWith("")).toBe(true);
+ expect(out).toContain(``);
+ expect(out).toContain(`src="data:text/javascript;base64,${btoa("window.ok=1")}"`);
+ expect(out).toContain(`src="data:image/png;base64,${btoa("PNG")}"`);
+ expect(out).not.toContain("srcset");
+ expect(out).toContain('src="https://cdn.example/x.js"');
+ expect(out).toContain('src="missing.png"');
+ expect(fetchFile).not.toHaveBeenCalledWith(expect.stringContaining("cdn"));
+ });
+ it("returns the page untouched when nothing is local", async () => {
+ const html = "hi x
";
+ expect(await inlineHtml(html, "a.html", fetchFile)).toBe(html);
+ });
+});
diff --git a/client/src/lib/htmlInline.ts b/client/src/lib/htmlInline.ts
new file mode 100644
index 00000000..abbdbdb4
--- /dev/null
+++ b/client/src/lib/htmlInline.ts
@@ -0,0 +1,230 @@
+/**
+ * @file htmlInline.ts
+ * @description An HTML file made whole for its sandboxed preview. The page
+ * runs in an `