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:
lhh010
2026-09-01 18:23:28 +08:00
parent c1992125d0
commit b7138cf4fd
27 changed files with 195 additions and 6 deletions
+50 -3
View File
@@ -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">
+5
View File
@@ -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); }
+19
View File
@@ -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 }
+2
View File
@@ -20,6 +20,8 @@ export const ar: Record<string, string> = {
files: 'الملفات',
changesSessionEmpty: 'لا توجد عمليات ملفات في هذه الجلسة بعد',
changesRead: 'قراءة',
changesMdReading: 'قراءة',
changesMdRaw: 'الخام',
changesWrite: 'كتابة',
changesEdit: 'تحرير',
changesRunning: 'قيد التنفيذ',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -19,6 +19,8 @@ export const hi: Record<string, string> = {
files: 'फ़ाइलें',
changesSessionEmpty: 'इस सत्र में अभी कोई फ़ाइल ऑपरेशन नहीं',
changesRead: 'पढ़ना',
changesMdReading: 'पठन',
changesMdRaw: 'मूल पाठ',
changesWrite: 'लिखना',
changesEdit: 'संपादन',
changesRunning: 'चल रहा है',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -19,6 +19,8 @@ export const ja: Record<string, string> = {
files: 'ファイル',
changesSessionEmpty: 'このセッションにはファイル操作がまだありません',
changesRead: '読み取り',
changesMdReading: '閲覧',
changesMdRaw: '原文',
changesWrite: '書き込み',
changesEdit: '編集',
changesRunning: '実行中',
+2
View File
@@ -11,6 +11,8 @@ export const ko: Record<string, string> = {
files: '파일',
changesSessionEmpty: '이 세션에 아직 파일 작업이 없습니다',
changesRead: '읽기',
changesMdReading: '읽기',
changesMdRaw: '원문',
changesWrite: '쓰기',
changesEdit: '편집',
changesRunning: '실행 중',
+2
View File
@@ -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',
+2
View File
@@ -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
View File
@@ -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',
+2
View File
@@ -15,6 +15,8 @@ export const ru: Record<string, string> = {
files: 'Файлы',
changesSessionEmpty: 'В этой сессии пока нет файловых операций',
changesRead: 'Чтение',
changesMdReading: 'Чтение',
changesMdRaw: 'Исходный текст',
changesWrite: 'Запись',
changesEdit: 'Изменение',
changesRunning: 'выполняется',
+2
View File
@@ -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',
+2
View File
@@ -19,6 +19,8 @@ export const th: Record<string, string> = {
files: 'ไฟล์',
changesSessionEmpty: 'ยังไม่มีการดำเนินการไฟล์ในเซสชันนี้',
changesRead: 'อ่าน',
changesMdReading: 'อ่าน',
changesMdRaw: 'ดิบ',
changesWrite: 'เขียน',
changesEdit: 'แก้ไข',
changesRunning: 'กำลังทำงาน',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -34,6 +34,8 @@ export const zhHK: Record<string, string> = {
files: '檔案',
changesSessionEmpty: '本工作階段還沒有檔案操作',
changesRead: '讀取',
changesMdReading: '閱讀',
changesMdRaw: '原文',
changesWrite: '寫入',
changesEdit: '編輯',
changesRunning: '執行中',
+2
View File
@@ -34,6 +34,8 @@ export const zhMO: Record<string, string> = {
files: '檔案',
changesSessionEmpty: '本工作階段還沒有檔案操作',
changesRead: '讀取',
changesMdReading: '閱讀',
changesMdRaw: '原文',
changesWrite: '寫入',
changesEdit: '編輯',
changesRunning: '執行中',
+2
View File
@@ -34,6 +34,8 @@ export const zhTW: Record<string, string> = {
files: '檔案',
changesSessionEmpty: '本作業階段還沒有檔案操作',
changesRead: '讀取',
changesMdReading: '閱讀',
changesMdRaw: '原文',
changesWrite: '寫入',
changesEdit: '編輯',
changesRunning: '執行中',
+4
View File
@@ -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',