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
* shows them; any other file opens on its read-only diff; a
* whole markdown file can also be read rendered (Preview), an HTML
* file opens as the page itself (`HtmlFrame`, its relative CSS,
* scripts and images read like downloads), and an
* file shown as the page on Preview, from Changes too
* (`HtmlFrame`, its relative CSS, scripts and images read like
* downloads), and an
* image file is shown as the picture (`GET …/git/raw`)
* history — recent commits; opening one shows that commit's diff
* 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);
// A whole markdown or HTML file shown rendered instead of as source; per
// 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);
useEffect(() => setMdPreview(isHtml(openFile) || readMarkdownPreview()), [openFile]);
useEffect(() => setMdPreview(!isHtml(openFile) && readMarkdownPreview()), [openFile]);
const [data, setData] = useState<LaneChanges | null>(null);
// 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
@@ -2562,6 +2563,9 @@ export function LaneChangesPanel({
aria-pressed={whole === w}
onClick={() => {
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
// draft goes along, unsaved or not.
if (editable && draft !== null) return setWhole(w);
@@ -2993,16 +2997,21 @@ export function LaneChangesPanel({
{t("changes.annotate")}
</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
type="button"
data-testid="lane-file-preview"
aria-pressed={mdPreview}
onClick={() => setMdPreview((v) => !v)}
aria-pressed={whole && mdPreview}
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"
>
{mdPreview ? <Code2 className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
{mdPreview ? t("changes.raw") : t("changes.preview")}
{whole && mdPreview ? <Code2 className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
{whole && mdPreview ? t("changes.raw") : t("changes.preview")}
</button>
)}
{!local && range !== "commit" && openFile && (
@@ -544,7 +544,7 @@ describe("LaneChangesPanel", () => {
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({
available: true,
path: "",
@@ -564,14 +564,48 @@ describe("LaneChangesPanel", () => {
render(<LaneChangesPanel laneId={7} full />);
const sidebar = await screen.findByTestId("lane-changes-sidebar");
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");
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"));
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("<p>deck</p>");
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 () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,