Skip to content

Commit ddc10d6

Browse files
committed
web: Source ⟷ Rendered toggle for markdown & HTML files
Adds a per-file view toggle in the diff header, shown for renderable files (.md/.markdown → react-markdown+remark-gfm; .html/.htm → sandboxed script-free iframe). Default stays the plain Source view (highlighted, fully commentable as before); flip to Rendered for a formatted read-only preview. Rendered is read-only by design: review comments are line-anchored, and only the source has line numbers — so commenting on either file type still works, via the Source view. No backend change (renders the working-tree content from the existing /api/file/raw). Frontend rebuilt; committed static assets refreshed.
1 parent 6b39e6b commit ddc10d6

6 files changed

Lines changed: 219 additions & 137 deletions

File tree

src/pyclawd/web/static/assets/index-BE7CGXqf.js

Lines changed: 131 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/pyclawd/web/static/assets/index-C9ezW9Ye.css

Lines changed: 0 additions & 1 deletion
This file was deleted.

src/pyclawd/web/static/assets/index-CQrkiI_I.js

Lines changed: 0 additions & 131 deletions
This file was deleted.

src/pyclawd/web/static/assets/index-Do9wbQPX.css

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/pyclawd/web/static/index.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
<meta charset="UTF-8" />
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
66
<title>pyclawd web</title>
7-
<script type="module" crossorigin src="/assets/index-CQrkiI_I.js"></script>
8-
<link rel="stylesheet" crossorigin href="/assets/index-C9ezW9Ye.css">
7+
<script type="module" crossorigin src="/assets/index-BE7CGXqf.js"></script>
8+
<link rel="stylesheet" crossorigin href="/assets/index-Do9wbQPX.css">
99
</head>
1010
<body>
1111
<div id="root"></div>

src/pyclawd/web_frontend/src/components/DiffView.tsx

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// carries its own comment gutter — a 💬 fades in on hover and stays (highlighted) on
33
// lines that already have a staged comment. In split view that means a comment
44
// affordance and line numbers on BOTH the old (left) and new (right) sides.
5-
import { useQueryClient } from "@tanstack/react-query";
5+
import { useQuery, useQueryClient } from "@tanstack/react-query";
66
import { useEffect, useRef, useState } from "react";
77

88
import { api } from "@/api";
@@ -27,6 +27,18 @@ interface Anchor {
2727

2828
type Composing = Anchor | null;
2929

30+
/** The kind of rendered preview a file supports, or null if it has none. */
31+
type Renderable = "md" | "html" | null;
32+
33+
/** Classify a path by whether it has a rendered ("as md" / "as html") preview. */
34+
function renderableKind(path: string | null): Renderable {
35+
if (!path) return null;
36+
const p = path.toLowerCase();
37+
if (p.endsWith(".md") || p.endsWith(".markdown")) return "md";
38+
if (p.endsWith(".html") || p.endsWith(".htm")) return "html";
39+
return null;
40+
}
41+
3042
/** Where to anchor a comment for a whole inline line (prefers the new side). */
3143
function anchor(line: DiffLine): Anchor | null {
3244
if (line.kind === "del" && line.old != null) return { line: line.old, side: "old", code: line.content };
@@ -39,13 +51,15 @@ export function DiffView({ view }: { view: FileView }) {
3951
const { layout, project, selected, staged, scrollTo, setScrollTo, selectFile } = useStore();
4052
const [composing, setComposing] = useState<Composing>(null);
4153
const [editing, setEditing] = useState<string | null>(null); // draft content, or null
54+
const [render, setRender] = useState<"source" | "rendered">("source");
4255
const [busy, setBusy] = useState(false);
4356
const qc = useQueryClient();
4457

45-
// Drop any open composer / editor when switching files.
58+
// Drop any open composer / editor and reset to the source view when switching files.
4659
useEffect(() => {
4760
setComposing(null);
4861
setEditing(null);
62+
setRender("source");
4963
}, [selected]);
5064

5165
const refresh = () => {
@@ -105,8 +119,16 @@ export function DiffView({ view }: { view: FileView }) {
105119
const fileComments = staged.filter((c) => c.file === selected).length;
106120
const shared = { composing, setComposing };
107121

122+
// Files that render (markdown / html) get a Source ⟷ Rendered toggle. The rendered
123+
// preview is read-only — comments are line-anchored, which only the source has — so
124+
// flip back to Source to leave a comment.
125+
const renderable = renderableKind(selected);
126+
const showRendered = renderable !== null && render === "rendered";
127+
108128
let body: React.ReactNode;
109-
if (view.binary) {
129+
if (showRendered && project && selected) {
130+
body = <RenderedFile project={project} path={selected} kind={renderable} />;
131+
} else if (view.binary) {
110132
body = <div className="p-12 text-center text-dim">Binary file — no text diff.</div>;
111133
} else if (view.mode === "full") {
112134
body = <InlineTable rows={view.lines} {...shared} />;
@@ -131,6 +153,7 @@ export function DiffView({ view }: { view: FileView }) {
131153
</span>
132154
)}
133155
<span className="flex-1" />
156+
{renderable && editing === null && <ViewToggle value={render} onChange={setRender} />}
134157
{editing !== null ? (
135158
<>
136159
<HeaderBtn onClick={saveEdit} disabled={busy} tone="accent">
@@ -203,6 +226,65 @@ function HeaderBtn({
203226
);
204227
}
205228

229+
/** A two-state Source ⟷ Rendered segmented toggle (shown for renderable files). */
230+
function ViewToggle({
231+
value,
232+
onChange,
233+
}: {
234+
value: "source" | "rendered";
235+
onChange: (v: "source" | "rendered") => void;
236+
}) {
237+
const opt = (v: "source" | "rendered", label: string) => (
238+
<button
239+
onClick={() => onChange(v)}
240+
className={`px-2 py-0.5 text-[11px] font-medium transition ${
241+
value === v ? "bg-accent text-white" : "text-dim hover:bg-panel2"
242+
}`}
243+
>
244+
{label}
245+
</button>
246+
);
247+
return (
248+
<div
249+
className="flex overflow-hidden rounded-md border border-line"
250+
title="Toggle the source and rendered views (rendered is read-only)"
251+
>
252+
{opt("source", "Source")}
253+
{opt("rendered", "Rendered")}
254+
</div>
255+
);
256+
}
257+
258+
/** The read-only rendered preview of a file: markdown via react-markdown, HTML in a
259+
* sandboxed (script-free) iframe. Fetches the working-tree content on demand. */
260+
function RenderedFile({ project, path, kind }: { project: string; path: string; kind: "md" | "html" }) {
261+
const { data, isLoading, error } = useQuery({
262+
queryKey: ["fileRaw", project, path],
263+
queryFn: () => api.fileRaw(project, path),
264+
});
265+
266+
if (isLoading) return <div className="p-12 text-center text-dim">Loading…</div>;
267+
if (error || !data) return <div className="p-12 text-center text-del">Could not load file.</div>;
268+
269+
if (kind === "md") {
270+
return (
271+
<div className="mx-auto max-w-3xl px-6 py-6 text-[13.5px]">
272+
<Markdown>{data.content}</Markdown>
273+
</div>
274+
);
275+
}
276+
// HTML: render in a sandboxed iframe with no allowances → scripts and same-origin
277+
// access are blocked, so viewing a repo's .html file is safe.
278+
return (
279+
<iframe
280+
title={`rendered ${path}`}
281+
sandbox=""
282+
srcDoc={data.content}
283+
className="h-[calc(100vh-150px)] w-full border-0 bg-white"
284+
/>
285+
);
286+
}
287+
206288
interface Shared {
207289
composing: Composing;
208290
setComposing: (c: Composing) => void;

0 commit comments

Comments
 (0)