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:
@@ -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