From b7138cf4fdde17f0bffffaa0fe83f5fd8cb3b222 Mon Sep 17 00:00:00 2001 From: lhh010 Date: Tue, 1 Sep 2026 18:23:28 +0800 Subject: [PATCH 01/13] feat(changes): markdown reading mode for .md op previews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The changes tab preview pane gains a Reading/Raw toggle for .md/.markdown/.mdx session ops (read, write, and edit alike). Reading mode renders the resulting document through the shared MarkdownText pass (the same stack as the editor preview): GFM tables with alignment, task lists, strikethrough, footnotes, math, and code fences, with local image destinations rewritten through the session-scoped /sidebar/file media route (rewriteLocalImageUrls). Content sources: read -> parseReadContent (envelope stripped, blank lines preserved — markdown block structure depends on them, which parseReadLines drops); write -> the written content; edit -> the reconstructed resulting document from the in-window prior content when known, else the new snippet. Ported from dsh-file-trace v0.2.0 (PR #471 lineage), re-based onto the unified changes tab (#475) and the repo shared-renderer conventions (markdownTextProps labels contract, skin tokens, 21-dictionary locale parity). --- README.md | 2 +- README_EN.md | 2 +- src/client/changes/DiffPane.tsx | 53 ++++++++++++++++++++++++-- src/client/changes/changes.module.css | 5 +++ src/client/changes/ops.ts | 19 +++++++++ src/client/locales-ar.ts | 2 + src/client/locales-de.ts | 2 + src/client/locales-fr.ts | 2 + src/client/locales-hi.ts | 2 + src/client/locales-id.ts | 2 + src/client/locales-it.ts | 2 + src/client/locales-ja.ts | 2 + src/client/locales-ko.ts | 2 + src/client/locales-nl.ts | 2 + src/client/locales-pl.ts | 2 + src/client/locales-pt.ts | 2 + src/client/locales-ru.ts | 2 + src/client/locales-sv.ts | 2 + src/client/locales-th.ts | 2 + src/client/locales-tr.ts | 2 + src/client/locales-vi.ts | 2 + src/client/locales-zh-HK.ts | 2 + src/client/locales-zh-MO.ts | 2 + src/client/locales-zh-TW.ts | 2 + src/client/locales.ts | 4 ++ tests/changes-ops.spec.ts | 23 ++++++++++- tests/changes-tab.spec.tsx | 55 +++++++++++++++++++++++++++ 27 files changed, 195 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index fcd706a..812c733 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 渲染(改蓝配对 + 行内字符级高亮 + 语法着色 + 上下文折叠),底部可拖拽预览面板,可一键展开为独立 diff tab(默认自由浮窗,可在设置改为面板下半 split);`.md` 操作(读 / 写 / 编辑)预览头部可切换**阅读模式**——经共享 MarkdownText 渲染 GFM 表格 / 任务列表 / 删除线 / 脚注 / 数学公式,本地图片自动改写为 `/sidebar/file` 媒体路由(与编辑器预览同一渲染栈) - **🧩 后台任务页**:subagent 拓扑 + 后台任务(退出码 / 实时输出 / 强制终止) - **💬 侧边对话(beta)**:Codex 风格的侧边线程——继承主会话完整上下文(含进行中的回合与工具调用)独立运行,不进入主会话;线程内可持续追问,一键「保存为新会话」提升为顶层会话 - **🪟 双工作台**:右侧栏 + 底部面板;拖 Tab 拆分 / 合并分栏(可跨面板),移动端自动合并全宽抽屉 diff --git a/README_EN.md b/README_EN.md index 0855f3c..ba11ff6 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 + 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 (the same rendering stack as the editor preview) - **🧩 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/src/client/changes/DiffPane.tsx b/src/client/changes/DiffPane.tsx index c8ff841..939a221 100644 --- a/src/client/changes/DiffPane.tsx +++ b/src/client/changes/DiffPane.tsx @@ -9,7 +9,7 @@ * 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 { t } from '../locales.ts' @@ -20,7 +20,9 @@ import { DiffRows, ReadRows } from '../diff/DiffRows.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 { rewriteLocalImageUrls } from '../markdown-images.ts' +import { markdownTextProps } from '../markdown-labels.tsx' import css from './changes.module.css' import diffCss from '../diff/diff.module.css' @@ -157,6 +159,32 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp [op], ) + // ── Markdown reading mode: .md op targets (read/write/edit, non-error) + // toggle between the raw/diff view and the rendered document — the same + // shared MarkdownText pass the editor preview uses, with local image + // destinations rewritten through the /sidebar/file media route. ────── + const mdOp = target.kind === 'op' && !target.op.isError && /\.(md|markdown|mdx)$/i.test(target.path) + const [reading, setReading] = useState(false) + const readingSrc = useMemo(() => { + if (!mdOp || op === null) return '' + if (op.kind === 'read') return parseReadContent(op.read ?? '') + if (op.kind === 'write') return op.content ?? '' + if (op.kind === 'edit' && op.edit !== undefined) { + if (prior !== undefined && prior.includes(op.edit.oldString)) { + return prior.replace(op.edit.oldString, op.edit.newString) + } + return op.edit.newString + } + return '' + }, [mdOp, op, prior]) + const readingText = useMemo( + () => (mdOp && reading && readingSrc !== '' && target.kind === 'op' + ? rewriteLocalImageUrls(readingSrc, scope, target.path, window.location.origin) + : ''), + [mdOp, reading, readingSrc, scope, target], + ) + const codeLabels = { copyLabel: t('copy'), copiedLabel: t('copied') } + // Header stats for git targets come off the parsed patch text. const gitStats = useMemo(() => { if (target.kind !== 'git' || diffText === null || diffText === '') return null @@ -257,6 +285,17 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp )} + {mdOp && ( + + )} - {target.kind === 'op' && op !== null && op.isError + {target.kind === 'op' && mdOp && reading && readingText !== '' + ? ( +
+
+ +
+
+ ) + : target.kind === 'op' && op !== null && op.isError ? (
diff --git a/src/client/changes/changes.module.css b/src/client/changes/changes.module.css index 1328393..dd5e2bf 100644 --- a/src/client/changes/changes.module.css +++ b/src/client/changes/changes.module.css @@ -342,6 +342,11 @@ /* ── Shared preview pane ───────────────────────────────────────────────── */ +.mdToggle { flex: none; border: 1px solid var(--dsw-alias-border-l1); border-radius: 7px; background: transparent; color: var(--dsw-alias-label-secondary); font: var(--dsw-font-xxxs-strong-11); padding: 2px 8px; cursor: pointer; } +.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; } + .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); } .dragHandle::after { content: ''; position: absolute; left: calc(50% - 18px); top: 3px; width: 36px; height: 2px; border-radius: 1px; background: var(--dsw-alias-border-l2); } diff --git a/src/client/changes/ops.ts b/src/client/changes/ops.ts index 8eb9ca8..78b2061 100644 --- a/src/client/changes/ops.ts +++ b/src/client/changes/ops.ts @@ -201,6 +201,25 @@ export function knownContentBefore(ops: readonly FileOp[], path: string, before: return undefined } +/** + * Strip the DSH read-tool response envelope down to the file's raw content, + * PRESERVING blank lines (unlike {@link parseReadLines}, which drops them): + * drops the wrapper, the "(Showing lines ...)" note, and the + * per-line ": " prefixes. The markdown reading mode needs the blank lines + * because markdown block structure depends on them. + */ +export function parseReadContent(raw: string): string { + const contentMatch = raw.match(/([\s\S]*?)<\/content>/) + const body = contentMatch ? contentMatch[1]! : raw + return body + .split('\n') + .filter((line) => !/^\s*\(Showing lines .*\)\s*$/.test(line)) + .map((line) => line.replace(/^\s*\d+:\s/, '')) + .join('\n') + .replace(/^[\n]+/, '') + .replace(/\n+$/, '') +} + /** One parsed read line: the file's own line number and its content text. */ export interface ReadLine { readonly line: number; readonly text: string } diff --git a/src/client/locales-ar.ts b/src/client/locales-ar.ts index c58f27d..496e266 100644 --- a/src/client/locales-ar.ts +++ b/src/client/locales-ar.ts @@ -20,6 +20,8 @@ export const ar: Record = { files: 'الملفات', changesSessionEmpty: 'لا توجد عمليات ملفات في هذه الجلسة بعد', changesRead: 'قراءة', + changesMdReading: 'قراءة', + changesMdRaw: 'الخام', changesWrite: 'كتابة', changesEdit: 'تحرير', changesRunning: 'قيد التنفيذ', diff --git a/src/client/locales-de.ts b/src/client/locales-de.ts index f9613ac..ff5be06 100644 --- a/src/client/locales-de.ts +++ b/src/client/locales-de.ts @@ -5,6 +5,8 @@ export const de: Record = { files: 'Dateien', changesSessionEmpty: 'Noch keine Dateioperationen in dieser Sitzung', changesRead: 'Lesen', + changesMdReading: 'Lesen', + changesMdRaw: 'Rohfassung', changesWrite: 'Schreiben', changesEdit: 'Bearbeiten', changesRunning: 'läuft', diff --git a/src/client/locales-fr.ts b/src/client/locales-fr.ts index 7fbc147..f6ce418 100644 --- a/src/client/locales-fr.ts +++ b/src/client/locales-fr.ts @@ -12,6 +12,8 @@ export const fr: Record = { files: 'Fichiers', changesSessionEmpty: 'Aucune opération de fichier dans cette session', changesRead: 'Lecture', + changesMdReading: 'Lecture', + changesMdRaw: 'Brut', changesWrite: 'Écriture', changesEdit: 'Modification', changesRunning: 'en cours', diff --git a/src/client/locales-hi.ts b/src/client/locales-hi.ts index fc928ca..1bda5ae 100644 --- a/src/client/locales-hi.ts +++ b/src/client/locales-hi.ts @@ -19,6 +19,8 @@ export const hi: Record = { files: 'फ़ाइलें', changesSessionEmpty: 'इस सत्र में अभी कोई फ़ाइल ऑपरेशन नहीं', changesRead: 'पढ़ना', + changesMdReading: 'पठन', + changesMdRaw: 'मूल पाठ', changesWrite: 'लिखना', changesEdit: 'संपादन', changesRunning: 'चल रहा है', diff --git a/src/client/locales-id.ts b/src/client/locales-id.ts index 88c061c..e32be67 100644 --- a/src/client/locales-id.ts +++ b/src/client/locales-id.ts @@ -17,6 +17,8 @@ export const id: Record = { files: 'Berkas', changesSessionEmpty: 'Belum ada operasi file di sesi ini', changesRead: 'Baca', + changesMdReading: 'Baca', + changesMdRaw: 'Mentah', changesWrite: 'Tulis', changesEdit: 'Edit', changesRunning: 'berjalan', diff --git a/src/client/locales-it.ts b/src/client/locales-it.ts index 5e8dd45..0089257 100644 --- a/src/client/locales-it.ts +++ b/src/client/locales-it.ts @@ -10,6 +10,8 @@ export const it: Record = { files: 'File', changesSessionEmpty: 'Ancora nessuna operazione sui file in questa sessione', changesRead: 'Lettura', + changesMdReading: 'Lettura', + changesMdRaw: 'Grezzo', changesWrite: 'Scrittura', changesEdit: 'Modifica', changesRunning: 'in esecuzione', diff --git a/src/client/locales-ja.ts b/src/client/locales-ja.ts index 756a94e..b382ecf 100644 --- a/src/client/locales-ja.ts +++ b/src/client/locales-ja.ts @@ -19,6 +19,8 @@ export const ja: Record = { files: 'ファイル', changesSessionEmpty: 'このセッションにはファイル操作がまだありません', changesRead: '読み取り', + changesMdReading: '閲覧', + changesMdRaw: '原文', changesWrite: '書き込み', changesEdit: '編集', changesRunning: '実行中', diff --git a/src/client/locales-ko.ts b/src/client/locales-ko.ts index 4aaa3cb..ca92e33 100644 --- a/src/client/locales-ko.ts +++ b/src/client/locales-ko.ts @@ -11,6 +11,8 @@ export const ko: Record = { files: '파일', changesSessionEmpty: '이 세션에 아직 파일 작업이 없습니다', changesRead: '읽기', + changesMdReading: '읽기', + changesMdRaw: '원문', changesWrite: '쓰기', changesEdit: '편집', changesRunning: '실행 중', diff --git a/src/client/locales-nl.ts b/src/client/locales-nl.ts index f413688..0523d7c 100644 --- a/src/client/locales-nl.ts +++ b/src/client/locales-nl.ts @@ -17,6 +17,8 @@ export const nl: Record = { files: 'Bestanden', changesSessionEmpty: 'Nog geen bestandsbewerkingen in deze sessie', changesRead: 'Lezen', + changesMdReading: 'Lezen', + changesMdRaw: 'Brontekst', changesWrite: 'Schrijven', changesEdit: 'Bewerken', changesRunning: 'actief', diff --git a/src/client/locales-pl.ts b/src/client/locales-pl.ts index 610ad13..5f8ee53 100644 --- a/src/client/locales-pl.ts +++ b/src/client/locales-pl.ts @@ -21,6 +21,8 @@ export const pl: Record = { files: 'Pliki', changesSessionEmpty: 'Brak operacji na plikach w tej sesji', changesRead: 'Odczyt', + changesMdReading: 'Czytanie', + changesMdRaw: 'Źródło', changesWrite: 'Zapis', changesEdit: 'Edycja', changesRunning: 'w toku', diff --git a/src/client/locales-pt.ts b/src/client/locales-pt.ts index 8fc42ed..c2674ac 100644 --- a/src/client/locales-pt.ts +++ b/src/client/locales-pt.ts @@ -2,6 +2,8 @@ export const pt: Record = { files: 'Arquivos', changesSessionEmpty: 'Ainda sem operações de arquivo nesta sessão', changesRead: 'Leitura', + changesMdReading: 'Leitura', + changesMdRaw: 'Bruto', changesWrite: 'Escrita', changesEdit: 'Edição', changesRunning: 'em execução', diff --git a/src/client/locales-ru.ts b/src/client/locales-ru.ts index dedb0f4..f9b8841 100644 --- a/src/client/locales-ru.ts +++ b/src/client/locales-ru.ts @@ -15,6 +15,8 @@ export const ru: Record = { files: 'Файлы', changesSessionEmpty: 'В этой сессии пока нет файловых операций', changesRead: 'Чтение', + changesMdReading: 'Чтение', + changesMdRaw: 'Исходный текст', changesWrite: 'Запись', changesEdit: 'Изменение', changesRunning: 'выполняется', diff --git a/src/client/locales-sv.ts b/src/client/locales-sv.ts index 4687e4a..0c35da7 100644 --- a/src/client/locales-sv.ts +++ b/src/client/locales-sv.ts @@ -2,6 +2,8 @@ export const sv: Record = { files: 'Filer', changesSessionEmpty: 'Inga filoperationer i den här sessionen ännu', changesRead: 'Läs', + changesMdReading: 'Läsning', + changesMdRaw: 'Råtext', changesWrite: 'Skriv', changesEdit: 'Redigera', changesRunning: 'körs', diff --git a/src/client/locales-th.ts b/src/client/locales-th.ts index 7777ce5..6445f61 100644 --- a/src/client/locales-th.ts +++ b/src/client/locales-th.ts @@ -19,6 +19,8 @@ export const th: Record = { files: 'ไฟล์', changesSessionEmpty: 'ยังไม่มีการดำเนินการไฟล์ในเซสชันนี้', changesRead: 'อ่าน', + changesMdReading: 'อ่าน', + changesMdRaw: 'ดิบ', changesWrite: 'เขียน', changesEdit: 'แก้ไข', changesRunning: 'กำลังทำงาน', diff --git a/src/client/locales-tr.ts b/src/client/locales-tr.ts index a658e3b..2700d94 100644 --- a/src/client/locales-tr.ts +++ b/src/client/locales-tr.ts @@ -19,6 +19,8 @@ export const tr: Record = { files: 'Dosyalar', changesSessionEmpty: 'Bu oturumda henüz dosya işlemi yok', changesRead: 'Okuma', + changesMdReading: 'Okuma', + changesMdRaw: 'Ham', changesWrite: 'Yazma', changesEdit: 'Düzenleme', changesRunning: 'çalışıyor', diff --git a/src/client/locales-vi.ts b/src/client/locales-vi.ts index e67245e..2e00894 100644 --- a/src/client/locales-vi.ts +++ b/src/client/locales-vi.ts @@ -19,6 +19,8 @@ export const vi: Record = { files: 'Tệp', changesSessionEmpty: 'Chưa có thao tác tệp nào trong phiên này', changesRead: 'Đọc', + changesMdReading: 'Đọc', + changesMdRaw: 'Gốc', changesWrite: 'Ghi', changesEdit: 'Chỉnh sửa', changesRunning: 'đang chạy', diff --git a/src/client/locales-zh-HK.ts b/src/client/locales-zh-HK.ts index 3bc82e0..f79832f 100644 --- a/src/client/locales-zh-HK.ts +++ b/src/client/locales-zh-HK.ts @@ -34,6 +34,8 @@ export const zhHK: Record = { files: '檔案', changesSessionEmpty: '本工作階段還沒有檔案操作', changesRead: '讀取', + changesMdReading: '閱讀', + changesMdRaw: '原文', changesWrite: '寫入', changesEdit: '編輯', changesRunning: '執行中', diff --git a/src/client/locales-zh-MO.ts b/src/client/locales-zh-MO.ts index d58bc12..17cd94e 100644 --- a/src/client/locales-zh-MO.ts +++ b/src/client/locales-zh-MO.ts @@ -34,6 +34,8 @@ export const zhMO: Record = { files: '檔案', changesSessionEmpty: '本工作階段還沒有檔案操作', changesRead: '讀取', + changesMdReading: '閱讀', + changesMdRaw: '原文', changesWrite: '寫入', changesEdit: '編輯', changesRunning: '執行中', diff --git a/src/client/locales-zh-TW.ts b/src/client/locales-zh-TW.ts index 35625b4..23dd891 100644 --- a/src/client/locales-zh-TW.ts +++ b/src/client/locales-zh-TW.ts @@ -34,6 +34,8 @@ export const zhTW: Record = { files: '檔案', changesSessionEmpty: '本作業階段還沒有檔案操作', changesRead: '讀取', + changesMdReading: '閱讀', + changesMdRaw: '原文', changesWrite: '寫入', changesEdit: '編輯', changesRunning: '執行中', diff --git a/src/client/locales.ts b/src/client/locales.ts index 584a7b7..e45db85 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -26,6 +26,8 @@ export const zh = { files: '文件', changesSessionEmpty: '本会话还没有文件操作', changesRead: '读取', + changesMdReading: '阅读', + changesMdRaw: '原文', changesWrite: '写入', changesEdit: '编辑', changesRunning: '执行中', @@ -435,6 +437,8 @@ export const en: Record = { files: 'Files', changesSessionEmpty: 'No file operations in this session yet', changesRead: 'Read', + changesMdReading: 'Reading', + changesMdRaw: 'Raw', changesWrite: 'Write', changesEdit: 'Edit', changesRunning: 'running', diff --git a/tests/changes-ops.spec.ts b/tests/changes-ops.spec.ts index 71bf276..48b68b7 100644 --- a/tests/changes-ops.spec.ts +++ b/tests/changes-ops.spec.ts @@ -4,7 +4,7 @@ * (their full behavior suites live in the standalone dsh-file-trace plugin). */ import { describe, expect, it } from 'vitest' -import { extractFileOps, groupByFile, knownContentBefore, parseReadLines } from '../src/client/changes/ops.ts' +import { extractFileOps, groupByFile, knownContentBefore, parseReadContent, parseReadLines } from '../src/client/changes/ops.ts' import { diffLines, buildDiffSegments, coalesceInline, diffInline } from '../src/client/diff/rows.ts' import { langOfPath, scanLine } from '../src/client/diff/highlight.ts' import type { SidebarSessionEvent } from '../src/context-types.ts' @@ -29,6 +29,27 @@ function result(seq: number, callId: string, text: string, isError = false, time }) } +describe('parseReadContent', () => { + it('strips the envelope and line-number prefixes but keeps blank lines', () => { + const raw = [ + 'notes.md', + 'file', + '(Showing lines 1 to 4)', + '', + '1: # Title', + '2: ', + '3: para one', + '4: ', + '', + ].join('\n') + expect(parseReadContent(raw)).toBe('# Title\n\npara one') + }) + + it('returns the raw text unchanged when no section is present', () => { + expect(parseReadContent('plain text')).toBe('plain text') + }) +}) + describe('extractFileOps', () => { it('seeds running ops from tool/call and settles them from tool/result', () => { const ops = extractFileOps([ diff --git a/tests/changes-tab.spec.tsx b/tests/changes-tab.spec.tsx index 8187ce7..1c0d673 100644 --- a/tests/changes-tab.spec.tsx +++ b/tests/changes-tab.spec.tsx @@ -145,4 +145,59 @@ describe('ChangesTab', () => { container.remove() } }) + + it('renders a .md read op in reading mode through the shared MarkdownText', async () => { + mockGit([]) + const md = ['# Title', '', '| A | B |', '| --- | --- |', '| 1 | 2 |', '', '![pic](./img.png)', ''].join('\n') + const numbered = md.split('\n').map((line, i) => String(i + 1) + ': ' + line).join('\n') + const envelope = '\n' + numbered + '\n' + vi.spyOn(api, 'changesOps').mockResolvedValue({ + events: [ + { type: 'tool/call', seq: 1, time: 1, data: { name: 'read', callId: 'r1', arguments: JSON.stringify({ file_path: 'C:/repo/main/notes.md' }) } }, + { type: 'tool/result', seq: 2, time: 2, data: { message: { source: { kind: 'tool', callId: 'r1' }, content: [{ type: 'tool-result', content: [{ type: 'text', text: envelope }] }] } } }, + ], + lastSeq: 2, + }) + + const container = document.createElement('div') + document.body.append(container) + const root: Root = createRoot(container) + try { + mount(root) + await flushEffects() + + // Session lens: open the read op's preview. + const group = container.querySelector('[role="group"]') + const sessionButton = [...group!.querySelectorAll('button')] + .find(button => button.getAttribute('aria-pressed') === 'false') + await act(async () => { sessionButton!.click() }) + const row = container.querySelector('button[data-op-kind="read"]') + expect(row).toBeDefined() + expect(container.textContent).toContain('notes.md') + await act(async () => { row!.click() }) + await flushEffects() + + // Reading toggle: raw first (line-numbered rows), then rendered GFM. + const toggle = [...container.querySelectorAll('button')] + .find(button => (button.textContent ?? '').match(/阅读|Reading/)) + expect(toggle).toBeDefined() + expect(container.querySelector('h1')).toBeNull() + await act(async () => { toggle!.click() }) + await flushEffects() + expect(container.querySelector('h1')?.textContent).toContain('Title') + expect(container.querySelector('table')).not.toBeNull() + // The local image destination was rewritten to the media route. + const img = container.querySelector('img') + expect(img?.getAttribute('src')).toContain('/sidebar/file?') + // Toggling back restores the raw line view. + const rawToggle = [...container.querySelectorAll('button')] + .find(button => (button.textContent ?? '').match(/原文|Raw/)) + await act(async () => { rawToggle!.click() }) + await flushEffects() + expect(container.querySelector('h1')).toBeNull() + } finally { + act(() => { root.unmount() }) + container.remove() + } + }) }) From 0142ac550b311f030017cd6721dabeb8447914f8 Mon Sep 17 00:00:00 2001 From: lhh010 Date: Tue, 1 Sep 2026 23:05:26 +0800 Subject: [PATCH 02/13] =?UTF-8?q?feat(diff):=20expand=20syntax=20highlight?= =?UTF-8?q?ing=20=E2=80=94=20mjs/cjs/mts/cts,=20CSS/SCSS/Less,=20HTML/XML/?= =?UTF-8?q?SVG/Vue,=20GraphQL,=20JSONC/JSON5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports dsh-file-trace v0.2.1 highlight coverage into the shared diff engine: JS/TS module suffixes share the js/ts keyword lists; CSS gains its own config (slash-star block comments threaded across lines, hyphenated property words via a wider word charset, 120+ properties/selectors/at-rules); markup files gain markup comments plus common tags/attributes; GraphQL and JSONC/JSON5 map in. Previously css/scss/less had a wrong hash-comment config and html was plain CONFIG_LANG, so both effectively rendered uncolored. --- README.md | 2 +- README_EN.md | 2 +- src/client/diff/highlight.ts | 98 ++++++++++++++++++++++++++++++------ tests/diff-highlight.spec.ts | 70 ++++++++++++++++++++++++++ 4 files changed, 155 insertions(+), 17 deletions(-) create mode 100644 tests/diff-highlight.spec.ts diff --git a/README.md b/README.md index 812c733..2968782 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);`.md` 操作(读 / 写 / 编辑)预览头部可切换**阅读模式**——经共享 MarkdownText 渲染 GFM 表格 / 任务列表 / 删除线 / 脚注 / 数学公式,本地图片自动改写为 `/sidebar/file` 媒体路由(与编辑器预览同一渲染栈) +- **🌿 文件变动**: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` 媒体路由(与编辑器预览同一渲染栈) - **🧩 后台任务页**:subagent 拓扑 + 后台任务(退出码 / 实时输出 / 强制终止) - **💬 侧边对话(beta)**:Codex 风格的侧边线程——继承主会话完整上下文(含进行中的回合与工具调用)独立运行,不进入主会话;线程内可持续追问,一键「保存为新会话」提升为顶层会话 - **🪟 双工作台**:右侧栏 + 底部面板;拖 Tab 拆分 / 合并分栏(可跨面板),移动端自动合并全宽抽屉 diff --git a/README_EN.md b/README_EN.md index ba11ff6..52f39fd 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; `.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 (the same rendering stack as the editor preview) +- **🌿 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 (the same rendering stack as the editor preview) - **🧩 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/src/client/diff/highlight.ts b/src/client/diff/highlight.ts index 7981a99..510c541 100644 --- a/src/client/diff/highlight.ts +++ b/src/client/diff/highlight.ts @@ -132,24 +132,81 @@ const MD_LANG: LangConfig = { wordBody: WORD, } +/** JS keyword list, shared by js/jsx/mjs/cjs. */ +const JS_WORDS = `async await break case catch class const continue debugger default delete do else + export extends false finally for from function get if implements import in instanceof interface + let new null of return set static super switch this throw true try typeof undefined var void + while with yield` + +/** TS keyword list, shared by ts/tsx/mts/cts. */ +const TS_WORDS = `abstract any as asserts async await boolean break case catch class const constructor + continue debugger declare default delete do else enum export extends false finally for from + function get if implements import in infer instanceof interface is keyof let module namespace + never new null number object of override private protected public readonly return satisfies set + static string super switch symbol this throw true try type typeof undefined union unknown var + void while with yield` + +/** CSS family: block comments delimited by slash-star, hyphenated property words. */ +const CSS_LANG: LangConfig = { + lineComments: [], + blockComment: ['/*', '*/'], + strings: ['"', "'"], + keywords: kw(`media import charset keyframes font-face supports page namespace layer scope container + property value at-rule when and not only from to important + background background-color background-image background-position background-size + border border-radius border-color border-width border-style bottom box-shadow box-sizing + color content cursor clip clip-path clear display direction + flex flex-direction flex-wrap flex-flow flex-grow flex-shrink flex-basis + font font-family font-size font-style font-weight float fill + grid grid-area grid-template grid-template-columns grid-template-rows grid-gap gap + height left letter-spacing line-height list-style margin margin-top margin-right + margin-bottom margin-left max-height max-width min-height min-width opacity order + outline overflow padding padding-top padding-right padding-bottom padding-left + position pointer-events right rotate scale translate transform transform-origin + text-align text-decoration text-transform top transition transition-property + user-select vertical-align visibility white-space width word-break word-spacing z-index + align-items align-content align-self justify-content justify-items justify-self + aspect-ratio inset object-fit object-position resize scroll-behavior filter backdrop-filter + animation animation-name animation-duration animation-timing-function animation-delay + will-change + html body p a div span li ul ol table tr td th form input button label select textarea img + section header footer main nav article aside h1 h2 h3 h4 h5 h6 i b em strong small pre code + inherit initial unset auto none fixed absolute relative sticky static flex block inline + inline-block inline-flex grid hidden visible hidden bold normal inherit root var calc env`), + constants: kw('true false'), + macro: false, + wordStart: /[A-Za-z-]/u, + wordBody: /[A-Za-z0-9-]/u, +} + +/** Markup family (HTML/XML/SVG/Vue): markup comments plus common tags. */ +const MARKUP_LANG: LangConfig = { + lineComments: [], + blockComment: [''], + strings: ['"', "'"], + keywords: kw(`html head body title meta link script style template slot + div span p a img ul ol li table thead tbody tr th td form input button label select + option textarea header footer main nav section article aside figure figcaption + h1 h2 h3 h4 h5 h6 strong em b i u s small br hr pre code blockquote + svg path circle rect line polyline polygon g defs use text symbol + xml doctype class id href src type value name content charset async defer + vue component props setup script-style export import v-if v-for v-bind v-on`), + constants: new Set(), + macro: false, + wordStart: LETTER, + wordBody: WORD, +} + /** Extension → language id, mirroring the read tool's hint table. */ const LANGS: Readonly> = { - ts: C_FAMILY(`abstract any as asserts async await boolean break case catch class const constructor - continue debugger declare default delete do else enum export extends false finally for from - function get if implements import in infer instanceof interface is keyof let module namespace - never new null number object of override private protected public readonly return satisfies set - static string super switch symbol this throw true try type typeof undefined union unknown var - void while with yield`), + ts: C_FAMILY(TS_WORDS), tsx: C_FAMILY(`abstract any as asserts async await boolean break case catch class const constructor continue declare default delete do else enum export extends false finally for from function get if implements import in infer instanceof interface is keyof let module namespace never new null number object of override private protected public readonly return satisfies set static string super switch symbol this throw true try type typeof undefined union unknown var void while with yield`), - js: C_FAMILY(`async await break case catch class const continue debugger default delete do else - export extends false finally for from function get if implements import in instanceof interface - let new null of return set static super switch this throw true try typeof undefined var void - while with yield`), + js: C_FAMILY(JS_WORDS), jsx: C_FAMILY(`async await break case catch class const continue debugger default delete do else export extends false finally for from function get if implements import in instanceof interface let new null of return set static super switch this throw true try typeof undefined var void @@ -225,11 +282,22 @@ const LANGS: Readonly> = { md: MD_LANG, markdown: MD_LANG, mdx: MD_LANG, - html: CONFIG_LANG, - htm: CONFIG_LANG, - css: HASH_FAMILY(''), - scss: HASH_FAMILY(''), - less: HASH_FAMILY(''), + mjs: C_FAMILY(JS_WORDS), + cjs: C_FAMILY(JS_WORDS), + mts: C_FAMILY(TS_WORDS), + cts: C_FAMILY(TS_WORDS), + jsonc: CONFIG_LANG, + json5: CONFIG_LANG, + html: MARKUP_LANG, + htm: MARKUP_LANG, + xml: MARKUP_LANG, + svg: MARKUP_LANG, + vue: MARKUP_LANG, + css: CSS_LANG, + scss: CSS_LANG, + less: CSS_LANG, + graphql: HASH_FAMILY('query mutation fragment on directive enum input interface scalar schema type implements'), + gql: HASH_FAMILY('query mutation fragment on directive enum input interface scalar schema type implements'), lua: HASH_FAMILY(`and break do else elseif end false for function goto if in local nil not or repeat return then true until while`), } diff --git a/tests/diff-highlight.spec.ts b/tests/diff-highlight.spec.ts new file mode 100644 index 0000000..84ec248 --- /dev/null +++ b/tests/diff-highlight.spec.ts @@ -0,0 +1,70 @@ +/** + * The highlight engine's v0.2.1-ported expansion: JS/TS module suffixes, + * CSS/SCSS/Less (block comments + hyphenated properties), markup files + * (comments + tags), and GraphQL mapping — the same coverage the standalone + * dsh-file-trace plugin ships. + */ +import { describe, expect, it } from 'vitest' +import { langOfPath, tokenizeLine, scanLine, hasBlockComment } from '../src/client/diff/highlight.ts' + +describe('langOfPath: expanded suffixes', () => { + it('maps the JS/TS module suffixes to their families', () => { + expect(langOfPath('tool/config.mjs')).toBe('mjs') + expect(langOfPath('tool/config.cjs')).toBe('cjs') + expect(langOfPath('src/env.mts')).toBe('mts') + expect(langOfPath('src/env.cts')).toBe('cts') + }) + + it('maps markup, style and graphql suffixes', () => { + expect(langOfPath('site/index.html')).toBe('html') + expect(langOfPath('site/logo.svg')).toBe('svg') + expect(langOfPath('app/App.vue')).toBe('vue') + expect(langOfPath('data/feed.xml')).toBe('xml') + expect(langOfPath('style/main.css')).toBe('css') + expect(langOfPath('style/theme.scss')).toBe('scss') + expect(langOfPath('style/vars.less')).toBe('less') + expect(langOfPath('api/schema.graphql')).toBe('graphql') + expect(langOfPath('api/query.gql')).toBe('gql') + expect(langOfPath('cfg/settings.jsonc')).toBe('jsonc') + }) +}) + +describe('tokenizeLine: CSS / markup', () => { + it('colors CSS block comments, hyphenated properties, strings and hex numbers', () => { + const toks = tokenizeLine('body { color: #333; background-color: var(--fg); content: "x" }', 'css') + expect(toks).toContainEqual(expect.objectContaining({ text: 'body', type: 'keyword' })) + expect(toks).toContainEqual(expect.objectContaining({ text: 'color', type: 'keyword' })) + expect(toks).toContainEqual(expect.objectContaining({ text: 'background-color', type: 'keyword' })) + expect(toks).toContainEqual(expect.objectContaining({ text: '"x"', type: 'string' })) + expect(toks).toContainEqual(expect.objectContaining({ text: '333', type: 'number' })) + const cmt = tokenizeLine('/* one line comment */ body', 'css') + expect(cmt[0]).toMatchObject({ text: '/* one line comment */', type: 'comment' }) + }) + + it('threads CSS block comments across lines', () => { + expect(hasBlockComment('css')).toBe(true) + const open = scanLine('/* starts here', 'css') + expect(open.inBlock).toBe(true) + expect(open.tokens[0]).toMatchObject({ type: 'comment' }) + const close = scanLine('ends here */ p {}', 'css', true) + expect(close.tokens[0]).toMatchObject({ type: 'comment' }) + expect(close.tokens).toContainEqual(expect.objectContaining({ text: 'p', type: 'keyword' })) + }) + + it('colors markup comments, tags and attributes', () => { + const toks = tokenizeLine('
hi
', 'html') + expect(toks).toContainEqual(expect.objectContaining({ text: 'div', type: 'keyword' })) + expect(toks).toContainEqual(expect.objectContaining({ text: 'class', type: 'keyword' })) + expect(toks).toContainEqual(expect.objectContaining({ text: '"box"', type: 'string' })) + const cmt = tokenizeLine('', 'html') + expect(cmt[0]).toMatchObject({ text: '', type: 'comment' }) + expect(hasBlockComment('html')).toBe(true) + }) + + it('colors mjs like js (await keyword wins over the call-site rule)', () => { + const toks = tokenizeLine('const x = await import("./mod.js")', 'mjs') + expect(toks).toContainEqual(expect.objectContaining({ text: 'const', type: 'keyword' })) + expect(toks).toContainEqual(expect.objectContaining({ text: 'await', type: 'keyword' })) + expect(toks).toContainEqual(expect.objectContaining({ text: 'import', type: 'keyword' })) + }) +}) From 2eb9f66ce82072ae0e120e16ee72bd0b8d4c508f Mon Sep 17 00:00:00 2001 From: lhh010 Date: Wed, 2 Sep 2026 09:14:59 +0800 Subject: [PATCH 03/13] feat(changes): render mermaid fences in the reading mode via the shared lazy chunk Documents containing mermaid fences now route through the editor preview chunk-resident renderer (splitMermaidBlocks detection + LazyMermaidMarkdown), so diagrams draw with zoom/pan instead of code blocks, while plain documents keep the byte-for-byte single MarkdownText pass. The lazy stub moves to a light mermaid-lazy.tsx module (re-exported from MarkdownHtml.tsx) so the core client bundle imports it without statically pulling DOMPurify/HTML analysis along. Typecheck, 4 affected suites (22 tests), and the full build pass. --- README.md | 2 +- README_EN.md | 2 +- src/client/MarkdownHtml.tsx | 10 +++++----- src/client/changes/DiffPane.tsx | 13 ++++++++++++- src/client/mermaid-lazy.tsx | 16 ++++++++++++++++ 5 files changed, 35 insertions(+), 8 deletions(-) create mode 100644 src/client/mermaid-lazy.tsx diff --git a/README.md b/README.md index 2968782..dbe65ed 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 渲染(改蓝配对 + 行内字符级高亮 + 语法着色(含 mjs/cjs/mts/cts、CSS/SCSS/Less、HTML/XML/SVG/Vue、GraphQL、JSONC/JSON5)+ 上下文折叠),底部可拖拽预览面板,可一键展开为独立 diff tab(默认自由浮窗,可在设置改为面板下半 split);`.md` 操作(读 / 写 / 编辑)预览头部可切换**阅读模式**——经共享 MarkdownText 渲染 GFM 表格 / 任务列表 / 删除线 / 脚注 / 数学公式,本地图片自动改写为 `/sidebar/file` 媒体路由(与编辑器预览同一渲染栈) +- **🌿 文件变动**: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 渲染器(图可点击缩放 / 平移) - **🧩 后台任务页**:subagent 拓扑 + 后台任务(退出码 / 实时输出 / 强制终止) - **💬 侧边对话(beta)**:Codex 风格的侧边线程——继承主会话完整上下文(含进行中的回合与工具调用)独立运行,不进入主会话;线程内可持续追问,一键「保存为新会话」提升为顶层会话 - **🪟 双工作台**:右侧栏 + 底部面板;拖 Tab 拆分 / 合并分栏(可跨面板),移动端自动合并全宽抽屉 diff --git a/README_EN.md b/README_EN.md index 52f39fd..75b4c65 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 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 (the same rendering stack as the editor preview) +- **🌿 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) - **🧩 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/src/client/MarkdownHtml.tsx b/src/client/MarkdownHtml.tsx index 70011ab..ce291bb 100644 --- a/src/client/MarkdownHtml.tsx +++ b/src/client/MarkdownHtml.tsx @@ -31,11 +31,11 @@ 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/changes/DiffPane.tsx b/src/client/changes/DiffPane.tsx index 939a221..e00bdf3 100644 --- a/src/client/changes/DiffPane.tsx +++ b/src/client/changes/DiffPane.tsx @@ -23,6 +23,8 @@ import { buildDiffSegments, diffLines, diffStats, parseUnifiedDiff, unifiedSegme import { parseReadContent, parseReadLines, type FileOp } from './ops.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 css from './changes.module.css' import diffCss from '../diff/diff.module.css' @@ -183,6 +185,13 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp : ''), [mdOp, reading, readingSrc, scope, target], ) + // Mermaid fences render through the same chunk-resident renderer the editor + // preview uses (one MarkdownText pass with the mermaid fences lifted out); + // the plain single-pass path stays byte-for-byte for documents without any. + const readingHasMermaid = useMemo( + () => (readingText !== '' ? splitMermaidBlocks(readingText).some((block) => block.kind === 'mermaid') : false), + [readingText], + ) const codeLabels = { copyLabel: t('copy'), copiedLabel: t('copied') } // Header stats for git targets come off the parsed patch text. @@ -310,7 +319,9 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp ? (
- + {readingHasMermaid + ? + : }
) diff --git a/src/client/mermaid-lazy.tsx b/src/client/mermaid-lazy.tsx new file mode 100644 index 0000000..3a56793 --- /dev/null +++ b/src/client/mermaid-lazy.tsx @@ -0,0 +1,16 @@ +/** + * The chunk-resident markdown renderer (mermaid lazy chunk): a light stub + * module so core-bundle consumers (the changes tab's reading mode) can mount + * the mermaid-aware renderer without statically pulling MarkdownHtml's + * DOMPurify/HTML-analysis machinery into the core bundle. The chunk itself + * (src/client/chunks/mermaid) loads on first render via /sidebar/bundle. + */ +import type { ComponentType } from 'react' +import { lazyChunkComponent } from './lazy-chunk.tsx' +import type { MermaidMarkdownProps } from './mermaid-blocks.ts' + +/** Shared with the legacy no-HTML preview path in TextEditor (re-exported). */ +export const LazyMermaidMarkdown = lazyChunkComponent( + 'mermaid', + (mod) => mod.MermaidMarkdown as ComponentType | undefined, +) From a2d3faf7b5a92294bab50e7294e4f50497ae86c4 Mon Sep 17 00:00:00 2001 From: lhh010 Date: Fri, 4 Sep 2026 13:06:05 +0800 Subject: [PATCH 04/13] feat(changes): secret redaction layer for op previews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The changes-tab op previews replay read/write/edit payloads into the DOM (diff rows, read rows, markdown reading mode, error text) — when the model reads a credentials file, that plaintext must not land on screen or in inspectable DOM. New display-only redaction layer, on by default with a one-click pane toggle (persisted in localStorage): - path layer: credential-shaped filenames (.env / *secret* / *credential* / *token* / *api-key* / private keys...) mask whole files line-wise - content layer: ordinary files mask only secret-shaped spans — secret field assignments keep the field name (api_key: [REDACTED]), plus bare token prefixes (sk- / AKIA / ghp_ / xox / ya29.), Bearer headers, and PEM private-key block headers - redacted payloads flow through the same viewOp shape into diff rows, read rows, markdown reading mode, and error text Known edge (documented in README): an unquoted mermaid node label containing a redacted secret breaks the diagram and falls back to source; workaround is quoting the label. Session data is untouched — this is display-only and not a boundary against same-context malicious plugins. Locales: zh/en dictionaries + all 19 shipped third-language overrides (key-set-equal gate green). 9 new unit tests; typecheck clean; targeted suites 40/40 green. The fs-operations failure is pre-existing on the base branch (environment-dependent, verified via stash) and unrelated. --- README.md | 2 +- README_EN.md | 2 +- src/client/changes/DiffPane.tsx | 52 +++++++++- src/client/changes/changes.module.css | 1 + src/client/locales-ar.ts | 5 + src/client/locales-de.ts | 5 + src/client/locales-fr.ts | 5 + src/client/locales-hi.ts | 5 + src/client/locales-id.ts | 5 + src/client/locales-it.ts | 5 + src/client/locales-ja.ts | 5 + src/client/locales-ko.ts | 5 + src/client/locales-nl.ts | 5 + src/client/locales-pl.ts | 5 + src/client/locales-pt.ts | 5 + src/client/locales-ru.ts | 5 + src/client/locales-sv.ts | 5 + src/client/locales-th.ts | 5 + src/client/locales-tr.ts | 5 + src/client/locales-vi.ts | 5 + src/client/locales-zh-HK.ts | 5 + src/client/locales-zh-MO.ts | 5 + src/client/locales-zh-TW.ts | 5 + src/client/locales.ts | 10 ++ src/client/redact.ts | 132 ++++++++++++++++++++++++++ tests/redact.spec.ts | 59 ++++++++++++ 26 files changed, 349 insertions(+), 4 deletions(-) create mode 100644 src/client/redact.ts create mode 100644 tests/redact.spec.ts diff --git a/README.md b/README.md index dbe65ed..7c9d398 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 渲染(改蓝配对 + 行内字符级高亮 + 语法着色(含 mjs/cjs/mts/cts、CSS/SCSS/Less、HTML/XML/SVG/Vue、GraphQL、JSONC/JSON5)+ 上下文折叠),底部可拖拽预览面板,可一键展开为独立 diff tab(默认自由浮窗,可在设置改为面板下半 split);`.md` 操作(读 / 写 / 编辑)预览头部可切换**阅读模式**——经共享 MarkdownText 渲染 GFM 表格 / 任务列表 / 删除线 / 脚注 / 数学公式,本地图片自动改写为 `/sidebar/file` 媒体路由;含 ```mermaid 围栏时走编辑器同款懒加载 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 无引号节点标签含被遮密钥时,图回退源码(规避:标签加引号) - **🧩 后台任务页**:subagent 拓扑 + 后台任务(退出码 / 实时输出 / 强制终止) - **💬 侧边对话(beta)**:Codex 风格的侧边线程——继承主会话完整上下文(含进行中的回合与工具调用)独立运行,不进入主会话;线程内可持续追问,一键「保存为新会话」提升为顶层会话 - **🪟 双工作台**:右侧栏 + 底部面板;拖 Tab 拆分 / 合并分栏(可跨面板),移动端自动合并全宽抽屉 diff --git a/README_EN.md b/README_EN.md index 75b4c65..9726fe4 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 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) +- **🌿 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 - **🧩 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/src/client/changes/DiffPane.tsx b/src/client/changes/DiffPane.tsx index e00bdf3..a1d6694 100644 --- a/src/client/changes/DiffPane.tsx +++ b/src/client/changes/DiffPane.tsx @@ -21,6 +21,7 @@ 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 { 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' @@ -150,8 +151,40 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp // ── Op target material (pure snapshots; the prior content came with the // target so a running op shows what is already known). ──────────────── - const op = target.kind === 'op' ? target.op : null - const prior = target.kind === 'op' ? target.prior : undefined + const opRaw = target.kind === 'op' ? target.op : null + const priorRaw = target.kind === 'op' ? target.prior : undefined + // Secret redaction: on by default, toggle persists per browser (localStorage). + // Every op payload consumer below (diff rows, read rows, markdown source, + // error text) renders from the REDACTED shape, so masked payloads are the + // only thing that can reach the DOM while the toggle is on. Display-only: + // session events and the fs layer keep their original bytes. + const [redactionOn, setRedactionOn] = useState((): boolean => { + try { return localStorage.getItem('dsh-better-sidebar.redaction') !== '0' } catch { return true } + }) + const toggleRedaction = (): void => { + setRedactionOn((prev) => { + const next = !prev + try { localStorage.setItem('dsh-better-sidebar.redaction', next ? '1' : '0') } catch { /* storage unavailable */ } + return next + }) + } + const { op, prior, redactionHit } = useMemo(() => { + const path = target.kind === 'op' ? target.path : '' + if (opRaw === null || !redactionOn) { + return { op: opRaw, prior: priorRaw, redactionHit: false } + } + const mask = (text: string): string => redactText(path, text).text + const hit = [opRaw.read, opRaw.content, opRaw.edit?.oldString, opRaw.edit?.newString, opRaw.errorText, priorRaw] + .some((text) => text !== undefined && redactText(path, text).hit) + const redacted: FileOp = { + ...opRaw, + ...(opRaw.read !== undefined ? { read: mask(opRaw.read) } : {}), + ...(opRaw.content !== undefined ? { content: mask(opRaw.content) } : {}), + ...(opRaw.edit !== undefined ? { edit: { oldString: mask(opRaw.edit.oldString), newString: mask(opRaw.edit.newString) } } : {}), + ...(opRaw.errorText !== undefined ? { errorText: mask(opRaw.errorText) } : {}), + } + return { op: redacted, prior: priorRaw === undefined ? undefined : mask(priorRaw), redactionHit: true } + }, [opRaw, priorRaw, target, redactionOn]); const opLang = useMemo(() => (target.kind === 'op' ? langOfPath(target.path) : undefined), [target]) const opRows = useMemo(() => (op === null ? [] : diffOf(op, prior)), [op, prior]) const opSegments = useMemo(() => buildDiffSegments(opRows), [opRows]) @@ -294,6 +327,21 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp )} + {redactionHit && redactionOn && ( + {t('changesRedactBanner')} + )} + {target.kind === 'op' && ( + + )} {mdOp && (