feat(changes): markdown reading mode for .md op previews
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).
This commit is contained in:
@@ -44,7 +44,7 @@
|
||||
- **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整)
|
||||
- **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具
|
||||
- **📂 模型侧边栏打开(可选)**:全局设置开启后注入 `sidebar_open` 工具——模型可主动在侧边栏打开文件 / 文件夹(树以该目录为根)/ HTTP(S) 网页
|
||||
- **🌿 文件变动**:Git 视角(真 diff / 历史 / 暂存·提交·还原 / worktree·子仓库选择)与本轮文件视角(模型读 / 写 / 编辑实时追踪,按文件分组、按类型筛选)**双视角合一**;统一 diff 渲染(改蓝配对 + 行内字符级高亮 + 语法着色 + 上下文折叠),底部可拖拽预览面板,可一键展开为独立 diff tab(默认自由浮窗,可在设置改为面板下半 split)
|
||||
- **🌿 文件变动**:Git 视角(真 diff / 历史 / 暂存·提交·还原 / worktree·子仓库选择)与本轮文件视角(模型读 / 写 / 编辑实时追踪,按文件分组、按类型筛选)**双视角合一**;统一 diff 渲染(改蓝配对 + 行内字符级高亮 + 语法着色 + 上下文折叠),底部可拖拽预览面板,可一键展开为独立 diff tab(默认自由浮窗,可在设置改为面板下半 split);`.md` 操作(读 / 写 / 编辑)预览头部可切换**阅读模式**——经共享 MarkdownText 渲染 GFM 表格 / 任务列表 / 删除线 / 脚注 / 数学公式,本地图片自动改写为 `/sidebar/file` 媒体路由(与编辑器预览同一渲染栈)
|
||||
- **🧩 后台任务页**:subagent 拓扑 + 后台任务(退出码 / 实时输出 / 强制终止)
|
||||
- **💬 侧边对话(beta)**:Codex 风格的侧边线程——继承主会话完整上下文(含进行中的回合与工具调用)独立运行,不进入主会话;线程内可持续追问,一键「保存为新会话」提升为顶层会话
|
||||
- **🪟 双工作台**:右侧栏 + 底部面板;拖 Tab 拆分 / 合并分栏(可跨面板),移动端自动合并全宽抽屉
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@
|
||||
- **🌐 Embedded Browser**: multiple web tabs with back / forward / refresh; content runs in a sandboxed iframe; external links are routed by protocol by default — HTTP opens in the sidebar, HTTPS goes to the system browser (both adjustable in settings)
|
||||
- **💻 Real Terminal**: xterm.js + node-pty real shell, reconnect with transcript replay; optionally injects `terminal_*` tools for the model
|
||||
- **📂 Model-driven sidebar opens (opt-in)**: with the global setting on, the `sidebar_open` tool lets the model actively open files / folders (tree rooted there) / HTTP(S) pages in the sidebar
|
||||
- **🌿 Changes**: one tab, two lenses — **Git** (real diff / history / stage·commit·revert / worktree & child-repo selection) and **This Session** (live tracking of every file the model reads / writes / edits, grouped by file with kind filters); a unified diff renderer (mod pairing + intra-line character highlights + syntax coloring + 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
|
||||
|
||||
@@ -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
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{mdOp && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.mdToggle}
|
||||
data-on={reading ? 'true' : undefined}
|
||||
aria-pressed={reading}
|
||||
onClick={() => { setReading(value => !value) }}
|
||||
>
|
||||
{t(reading ? 'changesMdRaw' : 'changesMdReading')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
@@ -267,7 +306,15 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
|
||||
<IconCloseOutline16 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{target.kind === 'op' && op !== null && op.isError
|
||||
{target.kind === 'op' && mdOp && reading && readingText !== ''
|
||||
? (
|
||||
<div className={css.paneBody}>
|
||||
<div className={css.mdBody}>
|
||||
<MarkdownText {...markdownTextProps(readingText, codeLabels)} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: target.kind === 'op' && op !== null && op.isError
|
||||
? (
|
||||
<div className={css.paneBody}>
|
||||
<div className={css.readError} role="alert">
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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 <content> wrapper, the "(Showing lines ...)" note, and the
|
||||
* per-line "<n>: " 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(/<content>([\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 }
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ export const ar: Record<string, string> = {
|
||||
files: 'الملفات',
|
||||
changesSessionEmpty: 'لا توجد عمليات ملفات في هذه الجلسة بعد',
|
||||
changesRead: 'قراءة',
|
||||
changesMdReading: 'قراءة',
|
||||
changesMdRaw: 'الخام',
|
||||
changesWrite: 'كتابة',
|
||||
changesEdit: 'تحرير',
|
||||
changesRunning: 'قيد التنفيذ',
|
||||
|
||||
@@ -5,6 +5,8 @@ export const de: Record<string, string> = {
|
||||
files: 'Dateien',
|
||||
changesSessionEmpty: 'Noch keine Dateioperationen in dieser Sitzung',
|
||||
changesRead: 'Lesen',
|
||||
changesMdReading: 'Lesen',
|
||||
changesMdRaw: 'Rohfassung',
|
||||
changesWrite: 'Schreiben',
|
||||
changesEdit: 'Bearbeiten',
|
||||
changesRunning: 'läuft',
|
||||
|
||||
@@ -12,6 +12,8 @@ export const fr: Record<string, string> = {
|
||||
files: 'Fichiers',
|
||||
changesSessionEmpty: 'Aucune opération de fichier dans cette session',
|
||||
changesRead: 'Lecture',
|
||||
changesMdReading: 'Lecture',
|
||||
changesMdRaw: 'Brut',
|
||||
changesWrite: 'Écriture',
|
||||
changesEdit: 'Modification',
|
||||
changesRunning: 'en cours',
|
||||
|
||||
@@ -19,6 +19,8 @@ export const hi: Record<string, string> = {
|
||||
files: 'फ़ाइलें',
|
||||
changesSessionEmpty: 'इस सत्र में अभी कोई फ़ाइल ऑपरेशन नहीं',
|
||||
changesRead: 'पढ़ना',
|
||||
changesMdReading: 'पठन',
|
||||
changesMdRaw: 'मूल पाठ',
|
||||
changesWrite: 'लिखना',
|
||||
changesEdit: 'संपादन',
|
||||
changesRunning: 'चल रहा है',
|
||||
|
||||
@@ -17,6 +17,8 @@ export const id: Record<string, string> = {
|
||||
files: 'Berkas',
|
||||
changesSessionEmpty: 'Belum ada operasi file di sesi ini',
|
||||
changesRead: 'Baca',
|
||||
changesMdReading: 'Baca',
|
||||
changesMdRaw: 'Mentah',
|
||||
changesWrite: 'Tulis',
|
||||
changesEdit: 'Edit',
|
||||
changesRunning: 'berjalan',
|
||||
|
||||
@@ -10,6 +10,8 @@ export const it: Record<string, string> = {
|
||||
files: 'File',
|
||||
changesSessionEmpty: 'Ancora nessuna operazione sui file in questa sessione',
|
||||
changesRead: 'Lettura',
|
||||
changesMdReading: 'Lettura',
|
||||
changesMdRaw: 'Grezzo',
|
||||
changesWrite: 'Scrittura',
|
||||
changesEdit: 'Modifica',
|
||||
changesRunning: 'in esecuzione',
|
||||
|
||||
@@ -19,6 +19,8 @@ export const ja: Record<string, string> = {
|
||||
files: 'ファイル',
|
||||
changesSessionEmpty: 'このセッションにはファイル操作がまだありません',
|
||||
changesRead: '読み取り',
|
||||
changesMdReading: '閲覧',
|
||||
changesMdRaw: '原文',
|
||||
changesWrite: '書き込み',
|
||||
changesEdit: '編集',
|
||||
changesRunning: '実行中',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const ko: Record<string, string> = {
|
||||
files: '파일',
|
||||
changesSessionEmpty: '이 세션에 아직 파일 작업이 없습니다',
|
||||
changesRead: '읽기',
|
||||
changesMdReading: '읽기',
|
||||
changesMdRaw: '원문',
|
||||
changesWrite: '쓰기',
|
||||
changesEdit: '편집',
|
||||
changesRunning: '실행 중',
|
||||
|
||||
@@ -17,6 +17,8 @@ export const nl: Record<string, string> = {
|
||||
files: 'Bestanden',
|
||||
changesSessionEmpty: 'Nog geen bestandsbewerkingen in deze sessie',
|
||||
changesRead: 'Lezen',
|
||||
changesMdReading: 'Lezen',
|
||||
changesMdRaw: 'Brontekst',
|
||||
changesWrite: 'Schrijven',
|
||||
changesEdit: 'Bewerken',
|
||||
changesRunning: 'actief',
|
||||
|
||||
@@ -21,6 +21,8 @@ export const pl: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -2,6 +2,8 @@ export const pt: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -15,6 +15,8 @@ export const ru: Record<string, string> = {
|
||||
files: 'Файлы',
|
||||
changesSessionEmpty: 'В этой сессии пока нет файловых операций',
|
||||
changesRead: 'Чтение',
|
||||
changesMdReading: 'Чтение',
|
||||
changesMdRaw: 'Исходный текст',
|
||||
changesWrite: 'Запись',
|
||||
changesEdit: 'Изменение',
|
||||
changesRunning: 'выполняется',
|
||||
|
||||
@@ -2,6 +2,8 @@ export const sv: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -19,6 +19,8 @@ export const th: Record<string, string> = {
|
||||
files: 'ไฟล์',
|
||||
changesSessionEmpty: 'ยังไม่มีการดำเนินการไฟล์ในเซสชันนี้',
|
||||
changesRead: 'อ่าน',
|
||||
changesMdReading: 'อ่าน',
|
||||
changesMdRaw: 'ดิบ',
|
||||
changesWrite: 'เขียน',
|
||||
changesEdit: 'แก้ไข',
|
||||
changesRunning: 'กำลังทำงาน',
|
||||
|
||||
@@ -19,6 +19,8 @@ export const tr: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -19,6 +19,8 @@ export const vi: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -34,6 +34,8 @@ export const zhHK: Record<string, string> = {
|
||||
files: '檔案',
|
||||
changesSessionEmpty: '本工作階段還沒有檔案操作',
|
||||
changesRead: '讀取',
|
||||
changesMdReading: '閱讀',
|
||||
changesMdRaw: '原文',
|
||||
changesWrite: '寫入',
|
||||
changesEdit: '編輯',
|
||||
changesRunning: '執行中',
|
||||
|
||||
@@ -34,6 +34,8 @@ export const zhMO: Record<string, string> = {
|
||||
files: '檔案',
|
||||
changesSessionEmpty: '本工作階段還沒有檔案操作',
|
||||
changesRead: '讀取',
|
||||
changesMdReading: '閱讀',
|
||||
changesMdRaw: '原文',
|
||||
changesWrite: '寫入',
|
||||
changesEdit: '編輯',
|
||||
changesRunning: '執行中',
|
||||
|
||||
@@ -34,6 +34,8 @@ export const zhTW: Record<string, string> = {
|
||||
files: '檔案',
|
||||
changesSessionEmpty: '本作業階段還沒有檔案操作',
|
||||
changesRead: '讀取',
|
||||
changesMdReading: '閱讀',
|
||||
changesMdRaw: '原文',
|
||||
changesWrite: '寫入',
|
||||
changesEdit: '編輯',
|
||||
changesRunning: '執行中',
|
||||
|
||||
@@ -26,6 +26,8 @@ export const zh = {
|
||||
files: '文件',
|
||||
changesSessionEmpty: '本会话还没有文件操作',
|
||||
changesRead: '读取',
|
||||
changesMdReading: '阅读',
|
||||
changesMdRaw: '原文',
|
||||
changesWrite: '写入',
|
||||
changesEdit: '编辑',
|
||||
changesRunning: '执行中',
|
||||
@@ -435,6 +437,8 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
files: 'Files',
|
||||
changesSessionEmpty: 'No file operations in this session yet',
|
||||
changesRead: 'Read',
|
||||
changesMdReading: 'Reading',
|
||||
changesMdRaw: 'Raw',
|
||||
changesWrite: 'Write',
|
||||
changesEdit: 'Edit',
|
||||
changesRunning: 'running',
|
||||
|
||||
@@ -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 = [
|
||||
'<path>notes.md</path>',
|
||||
'<type>file</type>',
|
||||
'(Showing lines 1 to 4)',
|
||||
'<content>',
|
||||
'1: # Title',
|
||||
'2: ',
|
||||
'3: para one',
|
||||
'4: ',
|
||||
'</content>',
|
||||
].join('\n')
|
||||
expect(parseReadContent(raw)).toBe('# Title\n\npara one')
|
||||
})
|
||||
|
||||
it('returns the raw text unchanged when no <content> 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([
|
||||
|
||||
@@ -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 |', '', '', ''].join('\n')
|
||||
const numbered = md.split('\n').map((line, i) => String(i + 1) + ': ' + line).join('\n')
|
||||
const envelope = '<content>\n' + numbered + '\n</content>'
|
||||
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<HTMLButtonElement>('button')]
|
||||
.find(button => button.getAttribute('aria-pressed') === 'false')
|
||||
await act(async () => { sessionButton!.click() })
|
||||
const row = container.querySelector<HTMLButtonElement>('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<HTMLButtonElement>('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<HTMLButtonElement>('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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user