fix(files): an HTML file in the Changes panel shows as the page only on Preview

Changes stays the default view and Whole file shows the source, as for any
file. A Preview button — offered on the Changes diff too, where it was
missing — opens the page; Raw, Changes or Whole file go back to the source.
This commit is contained in:
2026-10-29 10:38:15 +07:00
parent 38ea78b489
commit 364fe4a286
3 changed files with 57 additions and 13 deletions
@@ -14,8 +14,9 @@
* is unsaved): Changes folds the unchanged lines away, Whole file * is unsaved): Changes folds the unchanged lines away, Whole file
* shows them; any other file opens on its read-only diff; a * shows them; any other file opens on its read-only diff; a
* whole markdown file can also be read rendered (Preview), an HTML * whole markdown file can also be read rendered (Preview), an HTML
* file opens as the page itself (`HtmlFrame`, its relative CSS, * file shown as the page on Preview, from Changes too
* scripts and images read like downloads), and an * (`HtmlFrame`, its relative CSS, scripts and images read like
* downloads), and an
* image file is shown as the picture (`GET …/git/raw`) * image file is shown as the picture (`GET …/git/raw`)
* history — recent commits; opening one shows that commit's diff * history — recent commits; opening one shows that commit's diff
* browse — quick-open by path, or the repo's tracked file tree (the * browse — quick-open by path, or the repo's tracked file tree (the
@@ -891,9 +892,9 @@ export function LaneChangesPanel({
const [whole, setWhole] = useState(false); const [whole, setWhole] = useState(false);
// A whole markdown or HTML file shown rendered instead of as source; per // A whole markdown or HTML file shown rendered instead of as source; per
// file, markdown opening as Settings → Appearance says // file, markdown opening as Settings → Appearance says
// (`lib/markdownView.ts`), an HTML page always as the page. // (`lib/markdownView.ts`), an HTML page only once Preview is clicked.
const [mdPreview, setMdPreview] = useState(readMarkdownPreview); const [mdPreview, setMdPreview] = useState(readMarkdownPreview);
useEffect(() => setMdPreview(isHtml(openFile) || readMarkdownPreview()), [openFile]); useEffect(() => setMdPreview(!isHtml(openFile) && readMarkdownPreview()), [openFile]);
const [data, setData] = useState<LaneChanges | null>(null); const [data, setData] = useState<LaneChanges | null>(null);
// A folder that is not a repo (an empty new workspace, a parent of clones) // A folder that is not a repo (an empty new workspace, a parent of clones)
// has no changes, review or history — only its files, which Files reads // has no changes, review or history — only its files, which Files reads
@@ -2562,6 +2563,9 @@ export function LaneChangesPanel({
aria-pressed={whole === w} aria-pressed={whole === w}
onClick={() => { onClick={() => {
if (whole === w) return; if (whole === w) return;
// Changes and Whole file show an HTML page's source;
// only Preview runs it.
if (isHtml(openFile)) setMdPreview(false);
// Both views of a working file are the same editor: the // Both views of a working file are the same editor: the
// draft goes along, unsaved or not. // draft goes along, unsaved or not.
if (editable && draft !== null) return setWhole(w); if (editable && draft !== null) return setWhole(w);
@@ -2993,16 +2997,21 @@ export function LaneChangesPanel({
{t("changes.annotate")} {t("changes.annotate")}
</button> </button>
)} )}
{whole && text && !dirty && (isMarkdown(openFile) || isHtml(openFile)) && ( {/* Offered on Changes too: from there it opens the rendered whole file. */}
{(!whole || text) && !dirty && (isMarkdown(openFile) || isHtml(openFile)) && (
<button <button
type="button" type="button"
data-testid="lane-file-preview" data-testid="lane-file-preview"
aria-pressed={mdPreview} aria-pressed={whole && mdPreview}
onClick={() => setMdPreview((v) => !v)} onClick={() => {
if (whole) return setMdPreview((v) => !v);
setMdPreview(true);
setWhole(true);
}}
className="flex shrink-0 items-center gap-1 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary" className="flex shrink-0 items-center gap-1 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
> >
{mdPreview ? <Code2 className="h-3 w-3" /> : <Eye className="h-3 w-3" />} {whole && mdPreview ? <Code2 className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
{mdPreview ? t("changes.raw") : t("changes.preview")} {whole && mdPreview ? t("changes.raw") : t("changes.preview")}
</button> </button>
)} )}
{!local && range !== "commit" && openFile && ( {!local && range !== "commit" && openFile && (
@@ -544,7 +544,7 @@ describe("LaneChangesPanel", () => {
expect(screen.queryByTestId("lane-file-editor")).toBeNull(); expect(screen.queryByTestId("lane-file-editor")).toBeNull();
}); });
it("opens a whole HTML file as the page, its source after Raw", async () => { it("opens a whole HTML file as its source, the page after Preview, the source after Raw", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({ vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true, available: true,
path: "", path: "",
@@ -564,14 +564,48 @@ describe("LaneChangesPanel", () => {
render(<LaneChangesPanel laneId={7} full />); render(<LaneChangesPanel laneId={7} full />);
const sidebar = await screen.findByTestId("lane-changes-sidebar"); const sidebar = await screen.findByTestId("lane-changes-sidebar");
await user.click(await within(sidebar).findByText("deck.html")); await user.click(await within(sidebar).findByText("deck.html"));
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("<p>deck</p>");
expect(screen.queryByTestId("html-frame")).toBeNull();
await user.click(screen.getByTestId("lane-file-preview"));
const frame = await screen.findByTestId("html-frame"); const frame = await screen.findByTestId("html-frame");
expect(frame.getAttribute("srcdoc")).toContain("<p>deck</p>"); expect(frame.getAttribute("srcdoc")).toContain("<p>deck</p>");
expect(frame.getAttribute("sandbox")).not.toContain("allow-same-origin");
await user.click(screen.getByTestId("lane-file-preview")); await user.click(screen.getByTestId("lane-file-preview"));
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("<p>deck</p>"); expect(await screen.findByTestId("lane-file-editor")).toHaveValue("<p>deck</p>");
expect(screen.queryByTestId("html-frame")).toBeNull(); expect(screen.queryByTestId("html-frame")).toBeNull();
}); });
it("opens a changed HTML file on its Changes, the page only after Preview", async () => {
vi.mocked(api.lanes.changes).mockResolvedValue(
changes({
files: [
{ path: "deck.html", status: "??", untracked: true, added: 1, deleted: 0, binary: false },
],
})
);
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "deck.html",
rev: "work",
binary: false,
truncated: false,
lines: ["<p>deck</p>"],
sha: "c".repeat(40),
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await user.click(await screen.findByTitle("deck.html"));
await screen.findByRole("button", { name: "Changes", pressed: true });
expect(screen.queryByTestId("html-frame")).toBeNull();
await user.click(await screen.findByTestId("lane-file-preview"));
const frame = await screen.findByTestId("html-frame");
expect(frame.getAttribute("srcdoc")).toContain("<p>deck</p>");
expect(frame.getAttribute("sandbox")).not.toContain("allow-same-origin");
// Back to Changes, then Whole file: the source again, not the page.
await user.click(screen.getByRole("button", { name: "Changes", pressed: false }));
await user.click(screen.getByRole("button", { name: "Whole file", pressed: false }));
await waitFor(() => expect(screen.queryByTestId("html-frame")).toBeNull());
});
it("shows an image file as the picture, with no edit control", async () => { it("shows an image file as the picture, with no edit control", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({ vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true, available: true,
+3 -2
View File
@@ -678,8 +678,9 @@ reads the workspace's git surface in five modes:
least one line tall (`MIN_ROW`), so an empty filler row keeps the two least one line tall (`MIN_ROW`), so an empty filler row keeps the two
columns aligned. An image file (png, jpg, gif, webp, avif, bmp, ico, svg) is columns aligned. An image file (png, jpg, gif, webp, avif, bmp, ico, svg) is
shown as the picture (`/git/raw`), an HTML file (`.html`/`.htm`) opens shown as the picture (`/git/raw`), an HTML file (`.html`/`.htm`) opens
as the page itself — **Raw** shows its source in the editor, **Preview** like any file (Changes, or Whole file as source) with a **Preview**
brings the page back — and a conflicted file opens in the button — on Changes too — that shows it as the page; **Raw** goes back to
its source — and a conflicted file opens in the
conflict view. The page (here, in a Chat file preview, and on a conflict view. The page (here, in a Chat file preview, and on a
double-click in the File Explorer, whose right-click menu keeps double-click in the File Explorer, whose right-click menu keeps
**Download**) runs in the chat artifact's sandbox (`HtmlFrame.tsx`: **Download**) runs in the chat artifact's sandbox (`HtmlFrame.tsx`: