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:
@@ -44,7 +44,7 @@
|
||||
- **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整)
|
||||
- **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具
|
||||
- **📂 模型侧边栏打开(可选)**:全局设置开启后注入 `sidebar_open` 工具——模型可主动在侧边栏打开文件 / 文件夹(树以该目录为根)/ HTTP(S) 网页
|
||||
- **🌿 文件变动**:Git 视角(真 diff / 历史 / 暂存·提交·还原 / worktree·子仓库选择)与本轮文件视角(模型读 / 写 / 编辑实时追踪,按文件分组、按类型筛选)**双视角合一**;统一 diff 渲染(改蓝配对 + 行内字符级高亮 + 语法着色(含 mjs/cjs/mts/cts、CSS/SCSS/Less、HTML/XML/SVG/Vue、GraphQL、JSONC/JSON5)+ 上下文折叠),底部可拖拽预览面板,可一键展开为独立 diff tab(默认自由浮窗,可在设置改为面板下半 split);`.md` 操作(读 / 写 / 编辑)预览头部可切换**阅读模式**——经共享 MarkdownText 渲染 GFM 表格 / 任务列表 / 删除线 / 脚注 / 数学公式,本地图片自动改写为 `/sidebar/file` 媒体路由;含 ```mermaid 围栏时走编辑器同款懒加载 mermaid 渲染器(图可点击缩放 / 平移);**敏感内容脱敏**——凭据形态路径整文件遮罩、普通文件按内容形态遮值(api_key: / Bearer / sk- / AKIA / ghp_ / PEM 等,字段名保留),默认开启、预览面板一键开关(localStorage 记忆),仅影响显示、不改会话数据。已知边界:mermaid 无引号节点标签含被遮密钥时,图回退源码(规避:标签加引号)
|
||||
- **🌿 文件变动**:Git 视角(真 diff / 历史 / 暂存·提交·还原 / worktree·子仓库选择)与本轮文件视角(模型读 / 写 / 编辑实时追踪,按文件分组、按类型筛选)**双视角合一**;统一 diff 渲染(改蓝配对 + 行内字符级高亮 + 语法着色(含 mjs/cjs/mts/cts、CSS/SCSS/Less、HTML/XML/SVG/Vue、GraphQL、JSONC/JSON5)+ 上下文折叠),底部可拖拽预览面板,可一键展开为独立 diff tab(默认自由浮窗,可在设置改为面板下半 split);`.md` 操作(读 / 写 / 编辑)预览头部可切换**阅读模式**——经共享 MarkdownText 渲染 GFM 表格 / 任务列表 / 删除线 / 脚注 / 数学公式,本地图片自动改写为 `/sidebar/file` 媒体路由;含 ```mermaid 围栏时走编辑器同款懒加载 mermaid 渲染器(图可点击缩放 / 平移);**敏感内容脱敏**——凭据形态路径整文件遮罩、普通文件按内容形态遮值(api_key: / Bearer / sk- / AKIA / ghp_ / PEM 等,字段名保留),默认开启、预览面板一键开关(localStorage 记忆),仅影响显示、不改会话数据。已知边界:mermaid 无引号节点标签含被遮密钥时,图回退源码(规避:标签加引号);`.html` 操作(读 / 写 / 编辑)预览头部可切换**渲染模式**——复用编辑器同款 `/sidebar/html` 路由 iframe,相对资源(./style.css、img/x.png)同路由解析,分段读取也渲染完整文档,恒定沙箱(opaque origin + CSP 头,无逃生门)
|
||||
- **🧩 后台任务页**:subagent 拓扑 + 后台任务(退出码 / 实时输出 / 强制终止)
|
||||
- **💬 侧边对话(beta)**:Codex 风格的侧边线程——继承主会话完整上下文(含进行中的回合与工具调用)独立运行,不进入主会话;线程内可持续追问,一键「保存为新会话」提升为顶层会话
|
||||
- **🪟 双工作台**:右侧栏 + 底部面板;拖 Tab 拆分 / 合并分栏(可跨面板),移动端自动合并全宽抽屉
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@
|
||||
- **🌐 Embedded Browser**: multiple web tabs with back / forward / refresh; content runs in a sandboxed iframe; external links are routed by protocol by default — HTTP opens in the sidebar, HTTPS goes to the system browser (both adjustable in settings)
|
||||
- **💻 Real Terminal**: xterm.js + node-pty real shell, reconnect with transcript replay; optionally injects `terminal_*` tools for the model
|
||||
- **📂 Model-driven sidebar opens (opt-in)**: with the global setting on, the `sidebar_open` tool lets the model actively open files / folders (tree rooted there) / HTTP(S) pages in the sidebar
|
||||
- **🌿 Changes**: one tab, two lenses — **Git** (real diff / history / stage·commit·revert / worktree & child-repo selection) and **This Session** (live tracking of every file the model reads / writes / edits, grouped by file with kind filters); a unified diff renderer (mod pairing + intra-line character highlights + syntax coloring incl. mjs/cjs/mts/cts, CSS/SCSS/Less, HTML/XML/SVG/Vue, GraphQL, JSONC/JSON5 + context folding), a draggable bottom preview pane, and one-click expansion into a dedicated diff tab; `.md` ops (read / write / edit) offer a **reading-mode** toggle in the preview header — the shared MarkdownText renders GFM tables / task lists / strikethrough / footnotes / math, with local images rewritten through the /sidebar/file media route; documents with ```mermaid fences render through the editor's lazy mermaid renderer (click-to-zoom / pan diagrams); **secret redaction** — credential-shaped paths mask whole files and ordinary files mask secret-shaped values (api_key: / Bearer / sk- / AKIA / ghp_ / PEM …, field names kept), on by default with a one-click preview-pane toggle (persisted in localStorage), display-only — session data untouched. Known edge: an unquoted mermaid label containing a redacted secret breaks the diagram (falls back to source); workaround: quote the label
|
||||
- **🌿 Changes**: one tab, two lenses — **Git** (real diff / history / stage·commit·revert / worktree & child-repo selection) and **This Session** (live tracking of every file the model reads / writes / edits, grouped by file with kind filters); a unified diff renderer (mod pairing + intra-line character highlights + syntax coloring incl. mjs/cjs/mts/cts, CSS/SCSS/Less, HTML/XML/SVG/Vue, GraphQL, JSONC/JSON5 + context folding), a draggable bottom preview pane, and one-click expansion into a dedicated diff tab; `.md` ops (read / write / edit) offer a **reading-mode** toggle in the preview header — the shared MarkdownText renders GFM tables / task lists / strikethrough / footnotes / math, with local images rewritten through the /sidebar/file media route; documents with ```mermaid fences render through the editor's lazy mermaid renderer (click-to-zoom / pan diagrams); **secret redaction** — credential-shaped paths mask whole files and ordinary files mask secret-shaped values (api_key: / Bearer / sk- / AKIA / ghp_ / PEM …, field names kept), on by default with a one-click preview-pane toggle (persisted in localStorage), display-only — session data untouched. Known edge: an unquoted mermaid label containing a redacted secret breaks the diagram (falls back to source); workaround: quote the label. `.html` ops (read / write / edit) gain a **Render** toggle in the preview head — the editor's `/sidebar/html` route iframe, with relative assets resolving inside the route and segmented reads rendering the full document; always sandboxed (opaque origin + CSP header, no escape hatch)
|
||||
- **🧩 Background Tasks**: agent topology + background tasks (exit codes / live output / force-kill)
|
||||
- **💬 Side Chat (beta)**: Codex-style side threads — the child inherits the parent's FULL context (completed turns + the pending question + the in-progress turn's assistant output and tool activity, honestly frozen as "interrupted") and runs independently without entering the main conversation; threads support continuous follow-ups (auto-resumed after a DSH restart) and one-click "Save as new session" promotion to a top-level session
|
||||
- **🪟 Dual Workbench**: right sidebar + bottom panel; drag tabs to split / merge panes (cross-panel), mobile auto-merges into a full-width drawer
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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'
|
||||
@@ -21,6 +21,8 @@ export const ar: Record<string, string> = {
|
||||
changesSessionEmpty: 'لا توجد عمليات ملفات في هذه الجلسة بعد',
|
||||
changesRead: 'قراءة',
|
||||
changesMdReading: 'قراءة',
|
||||
changesHtmlRender: "عرض",
|
||||
changesHtmlRaw: "المصدر",
|
||||
changesMdRaw: 'الخام',
|
||||
changesWrite: 'كتابة',
|
||||
changesEdit: 'تحرير',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -20,6 +20,8 @@ export const hi: Record<string, string> = {
|
||||
changesSessionEmpty: 'इस सत्र में अभी कोई फ़ाइल ऑपरेशन नहीं',
|
||||
changesRead: 'पढ़ना',
|
||||
changesMdReading: 'पठन',
|
||||
changesHtmlRender: "रेंडर",
|
||||
changesHtmlRaw: "मूल",
|
||||
changesMdRaw: 'मूल पाठ',
|
||||
changesWrite: 'लिखना',
|
||||
changesEdit: 'संपादन',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -20,6 +20,8 @@ export const ja: Record<string, string> = {
|
||||
changesSessionEmpty: 'このセッションにはファイル操作がまだありません',
|
||||
changesRead: '読み取り',
|
||||
changesMdReading: '閲覧',
|
||||
changesHtmlRender: "レンダリング",
|
||||
changesHtmlRaw: "原文",
|
||||
changesMdRaw: '原文',
|
||||
changesWrite: '書き込み',
|
||||
changesEdit: '編集',
|
||||
|
||||
@@ -12,6 +12,8 @@ export const ko: Record<string, string> = {
|
||||
changesSessionEmpty: '이 세션에 아직 파일 작업이 없습니다',
|
||||
changesRead: '읽기',
|
||||
changesMdReading: '읽기',
|
||||
changesHtmlRender: "렌더링",
|
||||
changesHtmlRaw: "원문",
|
||||
changesMdRaw: '원문',
|
||||
changesWrite: '쓰기',
|
||||
changesEdit: '편집',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -16,6 +16,8 @@ export const ru: Record<string, string> = {
|
||||
changesSessionEmpty: 'В этой сессии пока нет файловых операций',
|
||||
changesRead: 'Чтение',
|
||||
changesMdReading: 'Чтение',
|
||||
changesHtmlRender: "Рендер",
|
||||
changesHtmlRaw: "Исходник",
|
||||
changesMdRaw: 'Исходный текст',
|
||||
changesWrite: 'Запись',
|
||||
changesEdit: 'Изменение',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -20,6 +20,8 @@ export const th: Record<string, string> = {
|
||||
changesSessionEmpty: 'ยังไม่มีการดำเนินการไฟล์ในเซสชันนี้',
|
||||
changesRead: 'อ่าน',
|
||||
changesMdReading: 'อ่าน',
|
||||
changesHtmlRender: "เรนเดอร์",
|
||||
changesHtmlRaw: "ต้นฉบับ",
|
||||
changesMdRaw: 'ดิบ',
|
||||
changesWrite: 'เขียน',
|
||||
changesEdit: 'แก้ไข',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -35,6 +35,8 @@ export const zhHK: Record<string, string> = {
|
||||
changesSessionEmpty: '本工作階段還沒有檔案操作',
|
||||
changesRead: '讀取',
|
||||
changesMdReading: '閱讀',
|
||||
changesHtmlRender: "渲染",
|
||||
changesHtmlRaw: "原文",
|
||||
changesMdRaw: '原文',
|
||||
changesWrite: '寫入',
|
||||
changesEdit: '編輯',
|
||||
|
||||
@@ -35,6 +35,8 @@ export const zhMO: Record<string, string> = {
|
||||
changesSessionEmpty: '本工作階段還沒有檔案操作',
|
||||
changesRead: '讀取',
|
||||
changesMdReading: '閱讀',
|
||||
changesHtmlRender: "渲染",
|
||||
changesHtmlRaw: "原文",
|
||||
changesMdRaw: '原文',
|
||||
changesWrite: '寫入',
|
||||
changesEdit: '編輯',
|
||||
|
||||
@@ -35,6 +35,8 @@ export const zhTW: Record<string, string> = {
|
||||
changesSessionEmpty: '本作業階段還沒有檔案操作',
|
||||
changesRead: '讀取',
|
||||
changesMdReading: '閱讀',
|
||||
changesHtmlRender: "渲染",
|
||||
changesHtmlRaw: "原文",
|
||||
changesMdRaw: '原文',
|
||||
changesWrite: '寫入',
|
||||
changesEdit: '編輯',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -12,7 +12,9 @@ import { renderToString } from 'react-dom/server'
|
||||
import { createElement } from 'react'
|
||||
import './browser-globals.ts'
|
||||
import type { Context } from '../src/context-types.ts'
|
||||
import { TextEditor, HTML_IFRAME_SANDBOX } from '../src/client/TextEditor.tsx'
|
||||
import { TextEditor } from '../src/client/TextEditor.tsx'
|
||||
import { HTML_IFRAME_SANDBOX } from '../src/client/html-preview.ts'
|
||||
import { DiffPane, HtmlRenderPreview } from '../src/client/changes/DiffPane.tsx'
|
||||
import { BrowserView, BrowserEmbedBlocked, BROWSER_IFRAME_SANDBOX, iframeSandboxFor } from '../src/client/BrowserView.tsx'
|
||||
import { createSidebarStore } from '../src/client/state.ts'
|
||||
import type { FileViewerProps } from '../src/client/service.ts'
|
||||
@@ -109,6 +111,53 @@ describe('HTML preview iframe sandbox', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('changes tab HTML render preview sandbox', () => {
|
||||
function opProps(path: string) {
|
||||
return {
|
||||
target: {
|
||||
kind: 'op' as const,
|
||||
path,
|
||||
op: { callId: 'c1', kind: 'read' as const, path, time: 0, running: false, isError: false, read: '<content>1: <html><body>hi</body></html></content>' },
|
||||
},
|
||||
scope: { sessionId: 's1', cwd: '/p' },
|
||||
height: 300,
|
||||
onHeightCommit: () => {},
|
||||
onClose: () => {},
|
||||
onExpand: () => {},
|
||||
}
|
||||
}
|
||||
|
||||
it('render iframe is ALWAYS sandboxed (no escape hatch) with the route src, never srcdoc', () => {
|
||||
const html = renderToString(createElement(HtmlRenderPreview, { src: '/sidebar/html/s1/p/a/index.html', title: '/p/a/index.html' }))
|
||||
const iframe = /<iframe[^>]*>/.exec(html)?.[0]
|
||||
expect(iframe).toBeDefined()
|
||||
// The sandbox tokens are exactly the shared constant — and unlike the
|
||||
// editor viewer this surface has NO no-sandbox escape hatch.
|
||||
expect(iframe).toContain(`sandbox="${HTML_IFRAME_SANDBOX}"`)
|
||||
expect(HTML_IFRAME_SANDBOX).not.toContain('allow-same-origin')
|
||||
expect(HTML_IFRAME_SANDBOX).not.toContain('allow-top-navigation')
|
||||
// Cross-origin framing by construction: route-src (never srcdoc).
|
||||
expect(iframe).toContain('src="/sidebar/html/s1/p/a/index.html"')
|
||||
expect(iframe).not.toContain('srcdoc=')
|
||||
expect(iframe).toContain('referrerPolicy="no-referrer"')
|
||||
expect(iframe).toContain('allow=""')
|
||||
})
|
||||
|
||||
it('html op targets show the render toggle (off by default, no iframe); non-html and error targets show none', () => {
|
||||
const htmlOpHtml = renderToString(createElement(DiffPane, opProps('/p/a/index.html')))
|
||||
expect(htmlOpHtml).toContain('渲染')
|
||||
expect(htmlOpHtml).not.toContain('<iframe')
|
||||
const md = renderToString(createElement(DiffPane, opProps('/p/a/readme.md')))
|
||||
expect(md).not.toContain('渲染')
|
||||
const errorProps = opProps('/p/a/index.html')
|
||||
const html = renderToString(createElement(DiffPane, {
|
||||
...errorProps,
|
||||
target: { ...errorProps.target, op: { ...errorProps.target.op, isError: true, errorText: 'boom' } },
|
||||
}))
|
||||
expect(html).not.toContain('渲染')
|
||||
})
|
||||
})
|
||||
|
||||
describe('browser tab iframe sandbox', () => {
|
||||
function tabProps(store: ReturnType<typeof createSidebarStore>, path?: string) {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user