feat(files): an HTML page opens in a modal over the screen
- Changes panel: Preview beside Whole file opens the page in a modal (HtmlViewerModal, shared with the File Explorer) instead of inside the panel; Changes and Whole file stay as they were. - Chat: an HTML file's card, Outputs row or a path in a reply (in the workspace or not) opens FilePreview as a modal — Preview | Code, versions, Save to Artifacts kept — rather than the side panel or the Changes panel. - Esc closes both modals: they stop every key from reaching the view below, which also kept useDialog's document listener from seeing it.
This commit is contained in:
@@ -91,7 +91,8 @@ import {
|
||||
} from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { HtmlFrame, fetchVia } from "../HtmlFrame";
|
||||
import { fetchVia } from "../HtmlFrame";
|
||||
import { HtmlViewerModal } from "../HtmlViewerModal";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { formatBytes } from "../../lib/format";
|
||||
@@ -1935,10 +1936,8 @@ export function FileExplorer({
|
||||
);
|
||||
}
|
||||
|
||||
/** An HTML file shown as the page, over the explorer: its relative CSS,
|
||||
* scripts and images read through the same download route (`HtmlFrame`).
|
||||
* Keys and clicks stop here — through the portal they would otherwise reach
|
||||
* the explorer's own shortcuts (Delete would trash the selection). */
|
||||
/** An HTML file as the page, in the shared modal viewer (`HtmlViewerModal`):
|
||||
* its relative CSS, scripts and images read through the same download route. */
|
||||
function HtmlViewer({
|
||||
entry,
|
||||
place,
|
||||
@@ -1950,49 +1949,20 @@ function HtmlViewer({
|
||||
onDownload: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useDialog(true, panelRef, onClose);
|
||||
const placeKey = JSON.stringify(place);
|
||||
const fetchFile = useMemo(
|
||||
() => fetchVia((p) => api.files.downloadUrl(place, p)),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[placeKey]
|
||||
);
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={entry.name}
|
||||
data-testid="fx-html-viewer"
|
||||
className="flex h-[92vh] w-[96vw] max-w-7xl flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/50"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[13px] text-fg-primary">
|
||||
{entry.path}
|
||||
</span>
|
||||
<HeadButton label={t("fx.download")} onClick={onDownload}>
|
||||
<Download className="h-4 w-4" />
|
||||
</HeadButton>
|
||||
<HeadButton label={t("fx.close")} onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</HeadButton>
|
||||
</div>
|
||||
<HtmlFrame path={entry.path} fetchFile={fetchFile} title={entry.name} />
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
return (
|
||||
<HtmlViewerModal
|
||||
path={entry.path}
|
||||
fetchFile={fetchFile}
|
||||
onDownload={onDownload}
|
||||
onClose={onClose}
|
||||
testId="fx-html-viewer"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,9 +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 shown as the page by Preview beside Whole file
|
||||
* (`HtmlFrame`, its relative CSS, scripts and images read like
|
||||
* downloads), and an
|
||||
* file's Preview beside Whole file opens the page in a modal
|
||||
* (`HtmlViewerModal`, 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
|
||||
@@ -139,7 +139,8 @@ import { LaneConflictView } from "./LaneConflictView";
|
||||
import { FileExplorer } from "./FileExplorer";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||
import { HtmlFrame, fetchVia } from "../HtmlFrame";
|
||||
import { fetchVia } from "../HtmlFrame";
|
||||
import { HtmlViewerModal } from "../HtmlViewerModal";
|
||||
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
|
||||
import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree";
|
||||
import { dragProjectPath } from "../../lib/promptAttach";
|
||||
@@ -198,7 +199,7 @@ function Delta({
|
||||
);
|
||||
}
|
||||
|
||||
/** An HTML page, which the viewer can show rendered (`HtmlFrame`). */
|
||||
/** An HTML page, which Preview opens as the page (`HtmlViewerModal`). */
|
||||
const isHtml = (p: string | null | undefined) => !!p && /\.html?$/i.test(p);
|
||||
|
||||
/** What the viewer shows as a picture (`readImageAt`'s list on the server). */
|
||||
@@ -890,11 +891,13 @@ export function LaneChangesPanel({
|
||||
const [layout, setLayout] = useState<DiffLayout>(wide ? "split" : "unified");
|
||||
useEffect(() => setLayout(wide ? "split" : "unified"), [wide]);
|
||||
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 only once Preview is clicked.
|
||||
// A whole markdown file shown rendered instead of as source; per file,
|
||||
// opening as Settings → Appearance says (`lib/markdownView.ts`).
|
||||
const [mdPreview, setMdPreview] = useState(readMarkdownPreview);
|
||||
useEffect(() => setMdPreview(!isHtml(openFile) && readMarkdownPreview()), [openFile]);
|
||||
useEffect(() => setMdPreview(readMarkdownPreview()), [openFile]);
|
||||
// An HTML file's page, open in the modal viewer (Preview beside Whole file).
|
||||
const [htmlOpen, setHtmlOpen] = useState(false);
|
||||
useEffect(() => setHtmlOpen(false), [openFile, mode]);
|
||||
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
|
||||
@@ -2059,10 +2062,8 @@ export function LaneChangesPanel({
|
||||
});
|
||||
|
||||
const dirty = draft !== null && draft !== text?.lines.join("\n");
|
||||
// Rendered only where the toggle is offered: a whole markdown or HTML file.
|
||||
const mdShown = mdPreview && whole && (isMarkdown(openFile) || isHtml(openFile));
|
||||
// The HTML page is showing (the Preview view of the Changes / Whole file / Preview group).
|
||||
const htmlShown = mdShown && isHtml(openFile);
|
||||
// Rendered only where the toggle is offered: a whole markdown file.
|
||||
const mdShown = mdPreview && whole && isMarkdown(openFile);
|
||||
// An HTML page's relative CSS/scripts/images, read like a download — only
|
||||
// from the working tree: a commit's page would get today's files.
|
||||
const htmlFetch = useMemo(
|
||||
@@ -2562,11 +2563,8 @@ export function LaneChangesPanel({
|
||||
<button
|
||||
key={String(w)}
|
||||
type="button"
|
||||
aria-pressed={whole === w && !htmlShown}
|
||||
aria-pressed={whole === w}
|
||||
onClick={() => {
|
||||
// Changes and Whole file show an HTML page's source;
|
||||
// only Preview runs it.
|
||||
if (htmlShown) setMdPreview(false);
|
||||
if (whole === w) return;
|
||||
// Both views of a working file are the same editor: the
|
||||
// draft goes along, unsaved or not.
|
||||
@@ -2580,7 +2578,7 @@ export function LaneChangesPanel({
|
||||
});
|
||||
}}
|
||||
className={`rounded px-2 py-0.5 text-[11px] transition-colors ${
|
||||
whole === w && !htmlShown
|
||||
whole === w
|
||||
? "bg-surface-4 text-fg-primary"
|
||||
: "text-fg-secondary hover:text-fg-primary"
|
||||
}`}
|
||||
@@ -2588,28 +2586,33 @@ export function LaneChangesPanel({
|
||||
{t(w ? "changes.wholeFile" : "changes.changesView")}
|
||||
</button>
|
||||
))}
|
||||
{/* An HTML file's third view: the page itself. */}
|
||||
{isHtml(openFile) && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-file-preview"
|
||||
aria-pressed={htmlShown}
|
||||
disabled={dirty}
|
||||
onClick={() => {
|
||||
setMdPreview(true);
|
||||
setWhole(true);
|
||||
}}
|
||||
className={`rounded px-2 py-0.5 text-[11px] transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||
htmlShown
|
||||
? "bg-surface-4 text-fg-primary"
|
||||
: "text-fg-secondary hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{t("changes.preview")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* An HTML file opens as the page in a modal over the screen;
|
||||
a commit's page once its text is read (Whole file). */}
|
||||
{mode === "file" && isHtml(openFile) && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-file-preview"
|
||||
disabled={dirty || (range === "commit" && !text)}
|
||||
onClick={() => setHtmlOpen(true)}
|
||||
className="flex items-center gap-1 rounded border border-border bg-surface-2 px-2 py-1 text-[11px] text-fg-secondary hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Eye className="h-3 w-3" />
|
||||
{t("changes.preview")}
|
||||
</button>
|
||||
)}
|
||||
{htmlOpen && openFile && (
|
||||
<HtmlViewerModal
|
||||
path={openFile}
|
||||
// A commit's page is its text at that commit (no sibling
|
||||
// files); a working one is read whole from the disk.
|
||||
html={range === "commit" ? text?.lines.join("\n") : undefined}
|
||||
fetchFile={htmlFetch}
|
||||
onDownload={!local && range !== "commit" ? () => download(openFile) : undefined}
|
||||
onClose={() => setHtmlOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -3164,17 +3167,7 @@ export function LaneChangesPanel({
|
||||
</Suspense>
|
||||
)
|
||||
) : whole ? (
|
||||
text && mdShown && isHtml(openFile) ? (
|
||||
<HtmlFrame
|
||||
key={openFile}
|
||||
path={text.path}
|
||||
// A cut-off page is read whole through the download.
|
||||
html={text.truncated && htmlFetch ? undefined : text.lines.join("\n")}
|
||||
fetchFile={htmlFetch}
|
||||
title={text.path}
|
||||
className="rounded border border-border-light"
|
||||
/>
|
||||
) : text && mdShown ? (
|
||||
text && mdShown ? (
|
||||
<div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0 p-4">
|
||||
<MarkdownPreview text={text.lines.join("\n")} />
|
||||
</div>
|
||||
|
||||
@@ -544,7 +544,7 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens a whole HTML file as its source, the page after Preview, the source after Whole file", async () => {
|
||||
it("opens a whole HTML file as its source, the page in a modal after Preview", async () => {
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
path: "",
|
||||
@@ -560,22 +560,31 @@ describe("LaneChangesPanel", () => {
|
||||
lines: ["<p>deck</p>"],
|
||||
sha: "c".repeat(40),
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
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(screen.getByRole("button", { name: "Preview", pressed: true })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Whole file", pressed: false }));
|
||||
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("<p>deck</p>");
|
||||
expect(screen.queryByTestId("html-frame")).toBeNull();
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response("<p>deck</p>", { status: 200 }));
|
||||
try {
|
||||
const user = userEvent.setup();
|
||||
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 modal = await screen.findByTestId("html-viewer");
|
||||
const frame = await within(modal).findByTestId("html-frame");
|
||||
expect(frame.getAttribute("srcdoc")).toContain("<p>deck</p>");
|
||||
expect(frame.getAttribute("sandbox")).not.toContain("allow-same-origin");
|
||||
await user.keyboard("{Escape}");
|
||||
await waitFor(() => expect(screen.queryByTestId("html-viewer")).toBeNull());
|
||||
// The source stayed where it was, under the modal.
|
||||
expect(screen.getByTestId("lane-file-editor")).toHaveValue("<p>deck</p>");
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("opens a changed HTML file on its Changes, the page only after Preview", async () => {
|
||||
it("offers Preview on a changed HTML file's Changes, opening the page in a modal", async () => {
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(
|
||||
changes({
|
||||
files: [
|
||||
@@ -583,28 +592,21 @@ describe("LaneChangesPanel", () => {
|
||||
],
|
||||
})
|
||||
);
|
||||
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: the diff again, not the page.
|
||||
await user.click(screen.getByRole("button", { name: "Changes", pressed: false }));
|
||||
await screen.findByRole("button", { name: "Changes", pressed: true });
|
||||
await waitFor(() => expect(screen.queryByTestId("html-frame")).toBeNull());
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response("<p>deck</p>", { status: 200 }));
|
||||
try {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await user.click(await screen.findByTitle("deck.html"));
|
||||
await screen.findByRole("button", { name: "Changes", pressed: true });
|
||||
await user.click(await screen.findByTestId("lane-file-preview"));
|
||||
const modal = await screen.findByTestId("html-viewer");
|
||||
expect(await within(modal).findByTestId("html-frame")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Changes", pressed: true })).toBeInTheDocument();
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("shows an image file as the picture, with no edit control", async () => {
|
||||
|
||||
Reference in New Issue
Block a user