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" ;
66import { useEffect , useRef , useState } from "react" ;
77
88import { api } from "@/api" ;
@@ -27,6 +27,18 @@ interface Anchor {
2727
2828type 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). */
3143function 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+
206288interface Shared {
207289 composing : Composing ;
208290 setComposing : ( c : Composing ) => void ;
0 commit comments