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
+22 -1
View File
@@ -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([
+55
View File
@@ -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 = '<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()
}
})
})