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 `