diff --git a/README.md b/README.md index 1908aa7..21056a2 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ - **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整) - **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具 - **📂 模型侧边栏打开(可选)**:全局设置开启后注入 `sidebar_open` 工具——模型可主动在侧边栏打开文件 / 文件夹(树以该目录为根)/ HTTP(S) 网页 -- **🌿 文件变动**:Git 视角(真 diff / 历史 / 暂存·提交·还原 / worktree·子仓库选择)与本轮文件视角(模型读 / 写 / 编辑实时追踪,按文件分组、按类型筛选)**双视角合一**;统一 diff 渲染(改蓝配对 + 行内字符级高亮 + 语法着色 + 上下文折叠),底部可拖拽预览面板,可一键展开为独立 diff tab(默认自由浮窗,可在设置改为面板下半 split) +- **🌿 文件变动**: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 头,无逃生门);`.pdf` 操作(读 / 写 / 编辑)同样可切换**渲染模式**——复用编辑器同款 PDF 预览(媒体路由字节流 + 显式 Blob,浏览器原生查看器内嵌,附下载入口) - **🧩 后台任务页**:subagent 拓扑 + 后台任务(退出码 / 实时输出 / 强制终止) - **💬 侧边对话(beta)**:Codex 风格的侧边线程——继承主会话完整上下文(含进行中的回合与工具调用)独立运行,不进入主会话;线程内可持续追问,一键「保存为新会话」提升为顶层会话 - **🪟 双工作台**:右侧栏 + 底部面板;拖 Tab 拆分 / 合并分栏(可跨面板),移动端自动合并全宽抽屉 diff --git a/README_EN.md b/README_EN.md index 99cbc4d..171d812 100644 --- a/README_EN.md +++ b/README_EN.md @@ -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 + context folding), a draggable bottom preview pane, and one-click expansion into a dedicated diff tab +- **🌿 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). `.pdf` ops (read / write / edit) gain the same **Render** toggle — the editor's PDF preview reused verbatim (media-route bytes + explicit Blob, the browser's native viewer inline, with a download fallback) - **🧩 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 diff --git a/docs/plans/2026-09-06-changes-op-previews-design.md b/docs/plans/2026-09-06-changes-op-previews-design.md new file mode 100644 index 0000000..b8c0628 --- /dev/null +++ b/docs/plans/2026-09-06-changes-op-previews-design.md @@ -0,0 +1,61 @@ +# 变更页操作预览增强设计(2026-09-06) + +> 状态:已实现(PR #499)。本文档记录六个特性的设计、关键取舍与已知限制,含 2026-09-06 规范化审查后的整改记录。特性移植自 dsh-file-trace v0.2.0/v0.2.1 会话(PDF 渲染为作者后续追加)。 + +## 背景 + +变更页(Changes tab)底部预览面板此前只渲染统一 diff / 逐行读取 / 错误文本。本批扩展为:markdown 文档可切「阅读模式」、mermaid 围栏随阅读模式渲染、预览载荷做「敏感内容脱敏」、`.html` / `.pdf` 操作可切「渲染模式」,并把 diff 引擎的语法着色表扩到此前无色/错配的一批语言。全部特性共用同一预览面板与头部 toggle 交互。 + +## 1. Markdown 阅读模式 + +- 适用:`.md / .markdown / .mdx` 的 read / write / edit 操作(非错误)→ 头部「阅读 / 原文」toggle。 +- 渲染走共享 `MarkdownText`(与编辑器预览、Side Chat 同一栈,labels 契约经 `markdownTextProps()`,见 AGENTS §3.3);本地图片目的地经 `rewriteLocalImageUrls` 改写到会话域 `/sidebar/file` 媒体路由。 +- 内容来源:read → `parseReadContent()`(见下);write → 写入内容;edit → 已知 prior 时 `prior.replace(oldString, newString)` 重建结果文档,否则仅展示 newString(设计上「prior 已知才重建」)。 +- `parseReadContent`(`src/client/changes/ops.ts`):与 `parseReadLines` 共享 `contentLines()` envelope 核心(`` 提取 + "(Showing lines …)" 过滤);区别在前者剥 `: ` 前缀后**保留空行**(markdown 块结构依赖空行)并修剪首尾空行(渲染不可见),后者解析行号、丢弃空行。 + +## 2. Mermaid 围栏渲染 + +- 阅读模式检测到 ```mermaid 围栏时,走编辑器同款懒加载渲染器(`LazyMermaidMarkdown`,mermaid chunk 经 `/sidebar/bundle` 按需下发);无围栏文档保持单次 MarkdownText 直渲染(字节不变)。 +- `LazyMermaidMarkdown` 的定义从 `MarkdownHtml.tsx` 移到轻量模块 `src/client/mermaid-lazy.tsx`:变更页(核心 bundle)要引用这个 stub 而不把 `MarkdownHtml` 的 DOMPurify/HTML 分析机械拖进核心 bundle;`MarkdownHtml.tsx` 改为 re-export。 +- 已知边界:无引号节点标签内含被遮密钥(`[REDACTED]` 字面)时 mermaid 解析失败,回落到「错误条 + 已遮源码」——该回落是 `mermaid.tsx` 既有机制(catch → 错误 + 源码),本批未新增代码,亦未为该路径加测试(README 已注明规避方式:标签加引号)。 + +## 3. 语法着色扩展(`src/client/diff/highlight.ts`) + +- `mjs/cjs → JS_WORDS`、`mts/cts → TS_WORDS`(此前无映射 → 纯文本);`tsx/jsx` 同用共享词表(规范化整改:原先保留内容相同的内联副本)。 +- `css/scss/less → CSS_LANG`:slash-star 块注释跨行、连字符属性词(`background-color` 整词着色)、字符串、十六进制数;此前误用 `#` 行注释配置导致不着色。 +- `html/htm/xml/svg/vue → MARKUP_LANG`:`` 注释 + 常见标签/属性;`graphql/gql`、`jsonc/json5` 映射。 +- 着色是启发式(词表驱动单遍扫描);CSS/MARKUP 词表混排选择器/属性/标签是刻意为之——`LangConfig` 只有一个 keywords 集,再分类属过度设计。 + +## 4. 敏感内容脱敏(`src/client/redact.ts`) + +- 动机:追踪器把 read/write 工具结果重放进 DOM;模型读取凭据文件时明文不应上屏。**仅显示层**:会话日志与 fs 层字节不动;不是对同上下文恶意插件的安全边界。 +- 两层启发式: + 1. **路径层**:文件名含凭据形态(`.env`、`secret(s)`、`credential(s)`、`token`、`api-key`、`password(s)`、私钥/证书扩展名…)→ 整文件逐行 `[REDACTED]`。匹配带**右边界**(模式后不得继 `[a-z0-9]`),且复数只收「密钥仓库」义(credentials/secrets/passwords),不收 `tokens`(api-tokens.md 通常是文档)——避免 `tokenizer.ts` / `dev.environment.ts` / `style.keys.ts` / `api-tokens.md` 被整文件误伤。 + 2. **内容层**:普通文件按行遮值——赋值形(`api_key:` / `TOKEN=` / `"password":`,**字段名保留**)、`Bearer` 头、`sk-`/`AKIA`/`ghp_`/`gho_`/`xox*`/`ya29.` 前缀 token、PEM 私钥块头。字段表**不含裸词 `key`/`auth`/`token`/`pass`**(规范化整改:这些词标注的普通内容远多于密钥——键盘键名、CSS 自定义属性、词法 token;Bearer 场景由 `BEARER_RE` 单独覆盖)。 +- 开关:默认开,头部一键切换,localStorage 记忆(key `dsh-sidebar:v1:redaction`,仓库既有前缀)。所有载荷消费方(diff 行、读取行、阅读模式源、错误文本、prior)统一从**遮蔽后**形状渲染;命中时头部显示「已脱敏」条。 + +## 5. HTML 渲染模式 + +- 适用:`.html / .htm` 操作(与编辑器 html viewer 的扩展集对齐;规范化整改:去掉越权的 `.xhtml`)→ 头部「渲染 / 原文」toggle。 +- 实现:`HtmlRenderPreview`(`DiffPane.tsx` 导出,供沙箱 spec 钉契约)以 route-src iframe 加载 `/sidebar/html`——相对资源(`./style.css`、`img/x.png`)在路由内解析;分段读取也能渲染完整文档。 +- **语义边界(刻意)**:路由服务的是**盘上已保存文件**而非操作快照——失败/未落盘的写入会渲染旧内容(README 已声明)。共享常量 `HTML_IFRAME_SANDBOX` 提到 `src/client/html-preview.ts`(编辑器与变更页共用);此面**恒定沙箱**(opaque origin + CSP 头),编辑器 tab 才持有带警告的去沙箱逃生门。 + +## 6. PDF 渲染模式(作者 2026-09-06 追加) + +- 适用:`.pdf` 操作(非错误)→ 头部「渲染 / 原文」toggle,复用编辑器 `PdfView`(媒体路由字节流 + 显式类型 Blob → 浏览器原生 PDF 查看器内嵌,附下载入口)。规范化整改:扩展名正则补 `\.` 转义(原 `/.pdf$/i` 会误命中任何以 pdf 结尾的路径)、toggle 并入 `PaneToggle`。无独立单测(PdfView 行为由编辑器侧既有测试覆盖)。 + +## 规范化整改(2026-09-06 审查后) + +- **皮肤契约**:`.htmlFrame` 背景硬编码 `#fff` → `var(--dsw-alias-bg-base)`(对齐 `.editorHtml`);`.redactBanner` 原用的 `--dsw-alias-state-warning-primary` 在 DSW 词表中**不存在**(实际为 `state-warn-*`),var() 恒回落到硬编码 `#b8860b` → 改用真实令牌 `var(--dsw-alias-state-warn-primary)`。 +- **约定对齐**:localStorage key 并入 `dsh-sidebar:v1:` 前缀;`tests/redact.spec.ts` 源码 import 由 `.js` 改 `.ts`(仓库惯例);DiffPane 三份同形 toggle 按钮提取 `PaneToggle`,阅读视图提取 `MdReadingView`(吸收 mermaid 分支与 codeLabels 计算);遮蔽 useMemo 单遍化(每字段一次 `redactText`,此前 hit 扫描 + mask 双遍)。 +- **去重**:`tsx/jsx` 共享 `TS_WORDS`/`JS_WORDS`;`parseReadContent`/`parseReadLines` 共享 `contentLines()`;CSS 词表去重(`hidden`/`inherit` 重复项)并去掉 CSS 没有的 `true false` 常量。 + +## 已知限制 + +- 脱敏是启发式:右边界规则放行 `mytoken.yaml`(命中遮蔽)而拒绝 `api-tokens.md`(文档);字段级遮蔽不覆盖未知命名的密钥字段——按 token 形态(`sk-` 等前缀)兜底。一键关闭可在受信任环境看原文。 +- HTML 渲染读盘上文件(见 §5);mermaid 无引号标签含遮蔽密钥时回落源码(见 §2)。 +- 阅读模式 edit 重建依赖窗口内已知 prior;窗口外旧内容不可知时仅展示新串(面板本就以「已知内容」为限)。 + +## 测试 + +`tests/redact.spec.ts`(两层遮蔽 + 边界/裸词误伤回归)、`tests/diff-highlight.spec.ts`(后缀映射、CSS 注释穿行/属性、markup 注释/标签、mjs 关键字)、`tests/changes-ops.spec.ts`(`parseReadContent` 空行保留)、`tests/changes-tab.spec.tsx`(阅读模式端到端:h1/GFM 表格/本地图片改写/切回原文)、`tests/sandbox-views.spec.tsx`(HTML 渲染 iframe 恒定沙箱 + toggle 出现条件)。 diff --git a/src/client/MarkdownHtml.tsx b/src/client/MarkdownHtml.tsx index 9f25286..de06633 100644 --- a/src/client/MarkdownHtml.tsx +++ b/src/client/MarkdownHtml.tsx @@ -19,24 +19,22 @@ import { useLayoutEffect, useMemo, useRef } from 'react' import { createElement, type ReactNode } from 'react' import DOMPurify from 'dompurify' import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ComponentType } from 'react' import { markdownTextProps } from './markdown-labels.tsx' -import { lazyChunkComponent } from './lazy-chunk.tsx' import { resolveLocalMediaDest, rewriteLocalImageUrls } from './markdown-images.ts' import { analyzeHtmlSegment, type AnalyzedMarkdownHtml, } from './markdown-html.ts' -import { splitMermaidBlocks, type MermaidMarkdownProps } from './mermaid-blocks.ts' +import { splitMermaidBlocks } from './mermaid-blocks.ts' import type { SessionScope } from './api.ts' import css from './sidebar.module.css' /** The chunk-resident markdown renderer (mermaid lazy chunk), shared with the - * legacy no-HTML preview path in TextEditor. */ -export const LazyMermaidMarkdown = lazyChunkComponent( - 'mermaid', - (mod) => mod.MermaidMarkdown as ComponentType | undefined, -) + * legacy no-HTML preview path in TextEditor. Defined in mermaid-lazy.tsx (a + * light module) so core-bundle consumers can import the stub without + * dragging DOMPurify along. */ +export { LazyMermaidMarkdown } from './mermaid-lazy.tsx' +import { LazyMermaidMarkdown } from './mermaid-lazy.tsx' /** Everything the sanitizers need to resolve local media + scope the route. */ export interface MarkdownHtmlMedia { diff --git a/src/client/TextEditor.tsx b/src/client/TextEditor.tsx index 865a034..8617274 100644 --- a/src/client/TextEditor.tsx +++ b/src/client/TextEditor.tsx @@ -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 diff --git a/src/client/changes/DiffPane.tsx b/src/client/changes/DiffPane.tsx index 436cb53..c609433 100644 --- a/src/client/changes/DiffPane.tsx +++ b/src/client/changes/DiffPane.tsx @@ -9,26 +9,37 @@ * diff tab via the shell. */ import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react' -import { IconCloseOutline16, IconRefreshOutline16, IconRightUpOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +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 { PdfView } from '../PdfView.tsx' import { DiffFiles } from '../diff/DiffFiles.tsx' import { langOfPath } from '../diff/highlight.ts' import { buildDiffSegments, diffLines, diffStats, parseUnifiedDiff, unifiedSegments, type DiffRow } from '../diff/rows.ts' -import { parseReadLines, type FileOp } from './ops.ts' +import { parseReadContent, parseReadLines, type FileOp } from './ops.ts' +import { redactText } from '../redact.ts' +import { rewriteLocalImageUrls } from '../markdown-images.ts' +import { markdownTextProps } from '../markdown-labels.tsx' +import { splitMermaidBlocks } from '../mermaid-blocks.ts' +import { LazyMermaidMarkdown } from '../mermaid-lazy.tsx' import { createFrameBatcher } from '../frame-batcher.ts' import css from './changes.module.css' import diffCss from '../diff/diff.module.css' -/** The drag handle height clamp (px) and keyboard-resize step. */ +/** Drag handle height clamp (px) and keyboard-resize step. */ const HEIGHT_MIN = 140 const HEIGHT_STEP = 24 +/** The redaction preference, persisted under the repo's sidebar storage + * prefix (see state.ts's `dsh-sidebar:v1`). */ +const REDACTION_KEY = 'dsh-sidebar:v1:redaction' + /** What the pane is showing right now. */ export type ChangesPreview = | { kind: 'git'; ref: SidebarDiffRef } @@ -55,6 +66,61 @@ 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 ( +
+