/**
* The one HTML-preview frame both preview surfaces render.
*
* Why this is not a plain `src` to the `/sidebar/html` route: on DSH Desktop
* the host answers 403 `forbidden` to every request that does not carry the
* Electron renderer capability (`x-dsh-desktop-renderer`), and the shell
* attaches that header only to MAIN-frame and SAME-ORIGIN-subframe traffic
* (`requestComesFromRendererOrigin`). A frame sandboxed WITHOUT
* allow-same-origin is an opaque origin at request time, so its document
* request never carries the capability and the preview shows nothing but the
* bare refusal.
*
* So the document is fetched by the MAIN frame — which does carry it — and
* handed to the frame through `srcdoc`. The `sandbox` attribute stays exactly
* as before: the preview still runs in an opaque origin, and the route's own
* workspace fence still bounds what can be fetched.
*
* Two consequences worth knowing:
* - sandboxed: an opaque origin cannot make network requests either, so a
* self-contained document previews completely while a multi-file one shows
* its markup with the relative assets missing (inlining them is a separate
* job, not this component's);
* - unsandboxed (the warned per-surface unlock, or the setting): the srcdoc
* frame inherits the GUI's origin — which is precisely what that escape
* hatch already meant — and there its relative references resolve and load
* like any other same-origin request.
*
* A fetch that fails hands the frame the route URL instead, so the route's
* own answer (its JSON error, or the refusal) stays visible rather than being
* replaced by a blank pane.
*/
import { useEffect, useState, type ReactNode } from 'react'
import { HTML_IFRAME_SANDBOX, withPreviewBase } from './html-preview.ts'
/** One preview frame. */
export function HtmlPreviewFrame(props: {
/** The `/sidebar/html//` route URL of the SAVED file. */
url: string
/** Whether the opaque-origin sandbox applies (the default for both surfaces). */
sandboxed: boolean
className?: string
title: string
}): ReactNode {
const { url, sandboxed, className, title } = props
const [doc, setDoc] = useState(null)
const [failed, setFailed] = useState(false)
useEffect(() => {
const controller = new AbortController()
setDoc(null)
setFailed(false)
void fetch(url, { signal: controller.signal })
.then(async (response) => {
if (!response.ok) throw new Error(String(response.status))
return response.text()
})
.then((text) => { setDoc(text) })
.catch((error: unknown) => {
// An abort is this effect's own cleanup, not a failure.
if ((error as { name?: string }).name !== 'AbortError') setFailed(true)
})
return () => { controller.abort() }
}, [url])
// The delivery path depends only on what we have: the fetched markup, the
// route's own answer after a failed fetch, or an empty frame while loading.
const delivery = doc !== null
? { srcDoc: withPreviewBase(doc, url) }
: failed ? { src: url } : { srcDoc: '' }
return (
)
}