From b7138cf4fdde17f0bffffaa0fe83f5fd8cb3b222 Mon Sep 17 00:00:00 2001 From: lhh010 Date: Tue, 1 Sep 2026 18:23:28 +0800 Subject: [PATCH] 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() + } + }) })