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:
@@ -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 }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user