feat(changes): HTML render preview for .html op targets

.html/.htm/.xhtml op previews (read / write / edit, non-error) gain a
Render/Raw toggle in the preview pane head: the render view loads the
SAVED file through the same /sidebar/html route the editor's html viewer
uses, so relative assets (./style.css, img/x.png) resolve inside the
route and a segmented read still renders the whole document (the route
serves the file, not the op snapshot — the file-trace-style srcDoc
approach would render a cut-off document whose scripts never run).

The frame is ALWAYS sandboxed (HTML_IFRAME_SANDBOX, moved to the shared
html-preview.ts module — opaque origin, no allow-same-origin, no
top-navigation) plus the route's CSP sandbox header as
defense-in-depth; unlike the editor viewer this surface has no
no-sandbox escape hatch. The contract is pinned by new sandbox-spec
tests on the extracted HtmlRenderPreview component and the pane toggle
(off by default; absent for non-html and error targets).

Locales: zh/en + all 19 shipped third-language overrides (key-set-equal
gate green). Typecheck + eslint clean; sandbox/redact/diff-rows/frontmatter
suites 36/36 green. The full-suite host-side failures (fs-operations /
pty smoke / git / install-powershell) are pre-existing on the base
branch (verified via stash) and unrelated.
This commit is contained in:
lhh010
2026-09-06 10:37:53 +08:00
parent 7f9a040065
commit e356791661
27 changed files with 160 additions and 14 deletions
+1 -9
View File
@@ -36,21 +36,13 @@ import { LazyMermaidMarkdown, MarkdownDocument, type MarkdownHtmlMedia } from '.
import { MdToc } from './md-toc.tsx'
import { splitMermaidBlocks } from './mermaid-blocks.ts'
import { t } from './locales.ts'
import { HTML_IFRAME_SANDBOX } from './html-preview.ts'
import type { EditorToolbarState, FileViewerProps } from './service.ts'
import css from './sidebar.module.css'
/** Previewable files (rendered output vs source editing). */
type ViewMode = 'preview' | 'edit'
/**
* The sandbox tokens of the HTML preview iframe. NO allow-same-origin (the
* preview must stay in an opaque origin — with the route's own origin it
* could read session data) and NO allow-top-navigation (a previewed page
* must not hijack the GUI). The user can disable the sandbox per-feature
* in the side card settings (warned); the toggle below reflects it.
*/
export const HTML_IFRAME_SANDBOX = 'allow-scripts allow-popups allow-downloads allow-modals'
/** Per-file preview scroll memory. Module-level so it survives viewer
* remounts: the save-then-switch-to-preview reload (EditorHost #215 case B)
* rebuilds the whole TextEditor instance, and without this the preview
+48 -2
View File
@@ -11,10 +11,11 @@
import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
import { IconCloseOutline16, IconRefreshOutline16, IconRightUpOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SessionScope } from '../api.ts'
import { api } from '../api.ts'
import { api, htmlUrl } from '../api.ts'
import { t } from '../locales.ts'
import { baseName } from '../paths.ts'
import { resolveSidebarPath } from '../produced-files.ts'
import { HTML_IFRAME_SANDBOX } from '../html-preview.ts'
import type { SidebarDiffRef, SidebarTab } from '../state.ts'
import { DiffRows, ReadRows } from '../diff/DiffRows.tsx'
import { DiffFiles } from '../diff/DiffFiles.tsx'
@@ -60,6 +61,24 @@ function diffOf(op: FileOp, prior: string | undefined): readonly DiffRow[] {
return []
}
/** The render view of one html op target: the route-src iframe. Extracted
* (and exported) so the always-sandboxed contract is pinned directly by the
* sandbox spec — this surface has NO no-sandbox escape hatch. */
export function HtmlRenderPreview(props: { src: string; title: string }) {
return (
<div className={css.htmlPane}>
<iframe
className={css.htmlFrame}
title={props.title}
src={props.src}
sandbox={HTML_IFRAME_SANDBOX}
referrerPolicy="no-referrer"
allow=""
/>
</div>
)
}
/** The diff tab a git preview expands into (the shell owns placement). */
export function diffTabOf(ref: SidebarDiffRef): SidebarTab {
if (ref.kind === 'worktree') {
@@ -229,6 +248,20 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
)
const codeLabels = { copyLabel: t('copy'), copiedLabel: t('copied') }
// ── HTML render mode: .html/.htm/.xhtml op targets load the SAVED file
// through the same /sidebar/html route the editor's html viewer uses —
// relative assets (./style.css, img/x.png) resolve inside the route, and
// a segmented read still renders the whole document (the route serves
// the file, not the op snapshot). The frame is always sandboxed (the
// attribute plus the route's CSP sandbox header); the editor tab owns
// the warned no-sandbox escape hatch. ─────────────────────────────────
const htmlOp = target.kind === 'op' && !target.op.isError && /\.(html?|xhtml)$/i.test(target.path)
const [rendering, setRendering] = useState(false)
const htmlRenderSrc = useMemo(() => {
if (!htmlOp || target.kind !== 'op') return ''
return htmlUrl(scope, resolveSidebarPath(scope.cwd, target.path))
}, [htmlOp, scope, target])
// Header stats for git targets come off the parsed patch text.
const gitStats = useMemo(() => {
if (target.kind !== 'git' || diffText === null || diffText === '') return null
@@ -361,6 +394,17 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
{t(reading ? 'changesMdRaw' : 'changesMdReading')}
</button>
)}
{htmlOp && (
<button
type="button"
className={css.mdToggle}
data-on={rendering ? 'true' : undefined}
aria-pressed={rendering}
onClick={() => { setRendering(value => !value) }}
>
{t(rendering ? 'changesHtmlRaw' : 'changesHtmlRender')}
</button>
)}
<button
type="button"
className={css.iconButton}
@@ -371,7 +415,9 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
<IconCloseOutline16 size={14} />
</button>
</div>
{target.kind === 'op' && mdOp && reading && readingText !== ''
{target.kind === 'op' && htmlOp && rendering && htmlRenderSrc !== ''
? <HtmlRenderPreview src={htmlRenderSrc} title={target.path} />
: target.kind === 'op' && mdOp && reading && readingText !== ''
? (
<div className={css.paneBody}>
<div className={css.mdBody}>
+2
View File
@@ -347,6 +347,8 @@
.mdToggle:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
.mdToggle[data-on='true'] { border-color: var(--dsw-alias-state-business-primary); color: var(--dsw-alias-state-business-primary); }
.mdBody { padding: 12px 16px 20px; font: var(--dsw-font-xs-regular-13); line-height: 1.6; min-height: 0; overflow-y: auto; }
.htmlPane { flex: 1; min-height: 0; display: flex; }
.htmlFrame { flex: 1; width: 100%; border: 0; background: #fff; }
.diffPane { border-top: 1px solid var(--dsw-alias-border-l1); display: flex; flex-direction: column; flex: none; min-height: 0; }
.dragHandle { flex: none; height: 8px; cursor: ns-resize; position: relative; background: var(--dsw-alias-bg-base); }
+15
View File
@@ -0,0 +1,15 @@
/**
* Shared vocabulary of the sidebar's HTML preview surfaces (the editor's
* html viewer and the changes tab's op render preview): the sandbox tokens
* both preview iframes load with.
*/
/**
* The sandbox tokens of an HTML preview iframe. NO allow-same-origin (the
* preview must stay in an opaque origin — with the route's own origin it
* could read session data) and NO allow-top-navigation (a previewed page
* must not hijack the GUI). The editor viewer can disable the sandbox
* per-feature in the side card settings (warned); the changes tab's op
* render preview always stays sandboxed.
*/
export const HTML_IFRAME_SANDBOX = 'allow-scripts allow-popups allow-downloads allow-modals'
+2
View File
@@ -21,6 +21,8 @@ export const ar: Record<string, string> = {
changesSessionEmpty: 'لا توجد عمليات ملفات في هذه الجلسة بعد',
changesRead: 'قراءة',
changesMdReading: 'قراءة',
changesHtmlRender: "عرض",
changesHtmlRaw: "المصدر",
changesMdRaw: 'الخام',
changesWrite: 'كتابة',
changesEdit: 'تحرير',
+2
View File
@@ -6,6 +6,8 @@ export const de: Record<string, string> = {
changesSessionEmpty: 'Noch keine Dateioperationen in dieser Sitzung',
changesRead: 'Lesen',
changesMdReading: 'Lesen',
changesHtmlRender: "Rendern",
changesHtmlRaw: "Quelltext",
changesMdRaw: 'Rohfassung',
changesWrite: 'Schreiben',
changesEdit: 'Bearbeiten',
+2
View File
@@ -13,6 +13,8 @@ export const fr: Record<string, string> = {
changesSessionEmpty: 'Aucune opération de fichier dans cette session',
changesRead: 'Lecture',
changesMdReading: 'Lecture',
changesHtmlRender: "Rendu",
changesHtmlRaw: "Source",
changesMdRaw: 'Brut',
changesWrite: 'Écriture',
changesEdit: 'Modification',
+2
View File
@@ -20,6 +20,8 @@ export const hi: Record<string, string> = {
changesSessionEmpty: 'इस सत्र में अभी कोई फ़ाइल ऑपरेशन नहीं',
changesRead: 'पढ़ना',
changesMdReading: 'पठन',
changesHtmlRender: "रेंडर",
changesHtmlRaw: "मूल",
changesMdRaw: 'मूल पाठ',
changesWrite: 'लिखना',
changesEdit: 'संपादन',
+2
View File
@@ -18,6 +18,8 @@ export const id: Record<string, string> = {
changesSessionEmpty: 'Belum ada operasi file di sesi ini',
changesRead: 'Baca',
changesMdReading: 'Baca',
changesHtmlRender: "Render",
changesHtmlRaw: "Sumber",
changesMdRaw: 'Mentah',
changesWrite: 'Tulis',
changesEdit: 'Edit',
+2
View File
@@ -11,6 +11,8 @@ export const it: Record<string, string> = {
changesSessionEmpty: 'Ancora nessuna operazione sui file in questa sessione',
changesRead: 'Lettura',
changesMdReading: 'Lettura',
changesHtmlRender: "Render",
changesHtmlRaw: "Sorgente",
changesMdRaw: 'Grezzo',
changesWrite: 'Scrittura',
changesEdit: 'Modifica',
+2
View File
@@ -20,6 +20,8 @@ export const ja: Record<string, string> = {
changesSessionEmpty: 'このセッションにはファイル操作がまだありません',
changesRead: '読み取り',
changesMdReading: '閲覧',
changesHtmlRender: "レンダリング",
changesHtmlRaw: "原文",
changesMdRaw: '原文',
changesWrite: '書き込み',
changesEdit: '編集',
+2
View File
@@ -12,6 +12,8 @@ export const ko: Record<string, string> = {
changesSessionEmpty: '이 세션에 아직 파일 작업이 없습니다',
changesRead: '읽기',
changesMdReading: '읽기',
changesHtmlRender: "렌더링",
changesHtmlRaw: "원문",
changesMdRaw: '원문',
changesWrite: '쓰기',
changesEdit: '편집',
+2
View File
@@ -18,6 +18,8 @@ export const nl: Record<string, string> = {
changesSessionEmpty: 'Nog geen bestandsbewerkingen in deze sessie',
changesRead: 'Lezen',
changesMdReading: 'Lezen',
changesHtmlRender: "Renderen",
changesHtmlRaw: "Bron",
changesMdRaw: 'Brontekst',
changesWrite: 'Schrijven',
changesEdit: 'Bewerken',
+2
View File
@@ -22,6 +22,8 @@ export const pl: Record<string, string> = {
changesSessionEmpty: 'Brak operacji na plikach w tej sesji',
changesRead: 'Odczyt',
changesMdReading: 'Czytanie',
changesHtmlRender: "Renderuj",
changesHtmlRaw: "Źródło",
changesMdRaw: 'Źródło',
changesWrite: 'Zapis',
changesEdit: 'Edycja',
+2
View File
@@ -3,6 +3,8 @@ export const pt: Record<string, string> = {
changesSessionEmpty: 'Ainda sem operações de arquivo nesta sessão',
changesRead: 'Leitura',
changesMdReading: 'Leitura',
changesHtmlRender: "Renderizar",
changesHtmlRaw: "Fonte",
changesMdRaw: 'Bruto',
changesWrite: 'Escrita',
changesEdit: 'Edição',
+2
View File
@@ -16,6 +16,8 @@ export const ru: Record<string, string> = {
changesSessionEmpty: 'В этой сессии пока нет файловых операций',
changesRead: 'Чтение',
changesMdReading: 'Чтение',
changesHtmlRender: "Рендер",
changesHtmlRaw: "Исходник",
changesMdRaw: 'Исходный текст',
changesWrite: 'Запись',
changesEdit: 'Изменение',
+2
View File
@@ -3,6 +3,8 @@ export const sv: Record<string, string> = {
changesSessionEmpty: 'Inga filoperationer i den här sessionen ännu',
changesRead: 'Läs',
changesMdReading: 'Läsning',
changesHtmlRender: "Rendera",
changesHtmlRaw: "Källa",
changesMdRaw: 'Råtext',
changesWrite: 'Skriv',
changesEdit: 'Redigera',
+2
View File
@@ -20,6 +20,8 @@ export const th: Record<string, string> = {
changesSessionEmpty: 'ยังไม่มีการดำเนินการไฟล์ในเซสชันนี้',
changesRead: 'อ่าน',
changesMdReading: 'อ่าน',
changesHtmlRender: "เรนเดอร์",
changesHtmlRaw: "ต้นฉบับ",
changesMdRaw: 'ดิบ',
changesWrite: 'เขียน',
changesEdit: 'แก้ไข',
+2
View File
@@ -20,6 +20,8 @@ export const tr: Record<string, string> = {
changesSessionEmpty: 'Bu oturumda henüz dosya işlemi yok',
changesRead: 'Okuma',
changesMdReading: 'Okuma',
changesHtmlRender: "Render",
changesHtmlRaw: "Kaynak",
changesMdRaw: 'Ham',
changesWrite: 'Yazma',
changesEdit: 'Düzenleme',
+2
View File
@@ -20,6 +20,8 @@ export const vi: Record<string, string> = {
changesSessionEmpty: 'Chưa có thao tác tệp nào trong phiên này',
changesRead: 'Đọc',
changesMdReading: 'Đọc',
changesHtmlRender: "Kết xuất",
changesHtmlRaw: "Nguyên văn",
changesMdRaw: 'Gốc',
changesWrite: 'Ghi',
changesEdit: 'Chỉnh sửa',
+2
View File
@@ -35,6 +35,8 @@ export const zhHK: Record<string, string> = {
changesSessionEmpty: '本工作階段還沒有檔案操作',
changesRead: '讀取',
changesMdReading: '閱讀',
changesHtmlRender: "渲染",
changesHtmlRaw: "原文",
changesMdRaw: '原文',
changesWrite: '寫入',
changesEdit: '編輯',
+2
View File
@@ -35,6 +35,8 @@ export const zhMO: Record<string, string> = {
changesSessionEmpty: '本工作階段還沒有檔案操作',
changesRead: '讀取',
changesMdReading: '閱讀',
changesHtmlRender: "渲染",
changesHtmlRaw: "原文",
changesMdRaw: '原文',
changesWrite: '寫入',
changesEdit: '編輯',
+2
View File
@@ -35,6 +35,8 @@ export const zhTW: Record<string, string> = {
changesSessionEmpty: '本作業階段還沒有檔案操作',
changesRead: '讀取',
changesMdReading: '閱讀',
changesHtmlRender: "渲染",
changesHtmlRaw: "原文",
changesMdRaw: '原文',
changesWrite: '寫入',
changesEdit: '編輯',
+4
View File
@@ -28,6 +28,8 @@ export const zh = {
changesRead: '读取',
changesMdReading: '阅读',
changesMdRaw: '原文',
changesHtmlRender: '渲染',
changesHtmlRaw: '原文',
changesWrite: '写入',
changesEdit: '编辑',
changesRunning: '执行中',
@@ -464,6 +466,8 @@ export const en: Record<keyof typeof zh, string> = {
changesRead: 'Read',
changesMdReading: 'Reading',
changesMdRaw: 'Raw',
changesHtmlRender: 'Render',
changesHtmlRaw: 'Raw',
changesWrite: 'Write',
changesEdit: 'Edit',
changesRunning: 'running',