fix(web): address diff card review — split terminator, error arm, wire narrowing

- DiffBlock: an empty side contributes zero lines and a trailing newline is a
  terminator, so a create ending in a newline draws one added line (not a
  phantom empty one) and a full deletion draws no phantom + line.
- diffCardModel: narrow the wire diffs payload (card is the only validated
  field) so a malformed diff card falls back to the generic path instead of
  throwing inside DiffBlock.
- FileMutationRow: surface the result text when an errored mutation has no diff
  card, so a failed edit/write is more than a red dot.
- copyText ends its closed union on assertNever.
- Docs: drop the "bridge relativizes" claim, record the file-count divergence
  from the TUI footer, correct the built-boot overclaim, note why the row title
  outranks the view title, and make fixture turn 67 args self-consistent.
- Tests: terminator/empty-side/interior-blank rows, wire-narrowing null arms,
  the error-text arm and its name/code fallback, stopped state, no-path summary,
  and the registration/disposal shape.
This commit is contained in:
Chinesezjc
2026-07-30 18:32:55 +08:00
parent d7e46bea35
commit 8c5c4b46c8
10 changed files with 221 additions and 19 deletions

View File

@@ -26,7 +26,7 @@ export const DEFAULT_DIFF_MAX_LINES = 16
* free of the tool contract (the terminal card's decoupling, applied to diffs).
*/
export interface DiffHunk {
/** The changed file's path (as the tool operated on it; the bridge relativizes it). */
/** The changed file's path, drawn verbatim as the hunk's header (the tool's model-facing path). */
path: string
/** Prior content, or `null` for a new file / an overwrite (nothing on the removed side). */
oldText: string | null
@@ -49,6 +49,12 @@ interface DiffRow {
text: string
}
/** Local exhaustiveness helper — this package does not depend on `dsh-llm`. */
/* v8 ignore next 3 -- closed-union backstop; only reached if a row kind is forged */
function assertNever(value: never): never {
throw new Error(`unreachable diff row kind: ${String(value)}`)
}
/** The dim class per row kind (path/gap chrome vs the diff's own +/- colors). */
const ROW_CLASS: Record<DiffRow['kind'], string | undefined> = {
path: css.path,
@@ -61,8 +67,10 @@ const ROW_CLASS: Record<DiffRow['kind'], string | undefined> = {
* Flatten the hunks into the body's rows plus the footer counts. A path header
* opens each new file; a same-file second hunk (a scattered edit) opens with a
* `⋯` gap instead of repeating the path. Every old-side line counts toward
* `removed` and every new-side line toward `added`, the same per-side line count
* the TUI footer draws, so the two front ends agree on a change's size.
* `removed` and every new-side line toward `added`. The file count is of
* DISTINCT paths, which is the one deliberate divergence from the TUI diff card:
* the TUI footer uses `diffs.length`, so two hunks in one file read there as
* `2 files`, whereas this counts the one file they belong to.
* @param diffs - the hunks to render.
* @returns the body rows, the +/- totals, and the distinct-file count.
*/
@@ -78,12 +86,12 @@ function buildRows(diffs: DiffHunk[]): { rows: DiffRow[]; added: number; removed
else rows.push({ kind: 'gap', text: '⋯' })
prevPath = diff.path
if (diff.oldText !== null) {
for (const line of diff.oldText.split('\n')) {
for (const line of contentLines(diff.oldText)) {
rows.push({ kind: 'del', text: line })
removed++
}
}
for (const line of diff.newText.split('\n')) {
for (const line of contentLines(diff.newText)) {
rows.push({ kind: 'add', text: line })
added++
}
@@ -91,6 +99,21 @@ function buildRows(diffs: DiffHunk[]): { rows: DiffRow[]; added: number; removed
return { rows, added, removed, files: paths.size }
}
/**
* Split a side's text into its content lines. Empty text is zero lines (a full
* deletion's `newText` or a create's absent `oldText` side draws nothing), and a
* single trailing newline is a line terminator rather than an extra empty line —
* the same terminator rule TerminalBlock applies to command output. An interior
* blank line (a genuine `\n\n`) survives.
* @param text - the removed or added side's text.
* @returns the content lines, without the terminating newline.
*/
function contentLines(text: string): string[] {
if (text === '') return []
const body = text.endsWith('\n') ? text.slice(0, -1) : text
return body.split('\n')
}
/**
* The diff text a reader copies: each row's `-`/`+`/path/gap prefix and its
* content, exactly what the card shows. The removed and added blocks are the
@@ -103,8 +126,9 @@ function copyText(rows: DiffRow[]): string {
switch (row.kind) {
case 'del': return `- ${row.text}`
case 'add': return `+ ${row.text}`
case 'path': return row.text
case 'gap': return row.text
default: return row.text
default: return assertNever(row.kind)
}
}).join('\n')
}

View File

@@ -76,6 +76,26 @@ describe('DiffBlock structure', () => {
const { container } = render(<DiffBlock diffs={[]} />)
expect(container.firstChild).toBeNull()
})
it('treats a trailing newline as a terminator, not an extra blank line', () => {
// A create whose newText ends in a newline is one added line, not two, and
// the footer counts one — the phantom `+ ` empty line the naive split drew.
const { container } = render(<DiffBlock diffs={[{ path: 'n.txt', oldText: null, newText: 'hello\n' }]} />)
expect(changeRows(container)).toEqual(['hello'])
expect(screen.getByText('└ +1 -0 · 1 file')).toBeTruthy()
})
it('renders a full deletion as removed-only with no phantom added line', () => {
// newText '' is zero added lines: an empty string must contribute nothing.
const { container } = render(<DiffBlock diffs={[{ path: 'gone.ts', oldText: 'a\nb', newText: '' }]} />)
expect(container.querySelectorAll('[class*="_add_"]').length).toBe(0)
expect(screen.getByText('└ +0 -2 · 1 file')).toBeTruthy()
})
it('keeps a genuine interior blank line', () => {
const { container } = render(<DiffBlock diffs={[{ path: 'a.ts', oldText: null, newText: 'x\n\ny' }]} />)
expect(container.querySelectorAll('[class*="_add_"]').length).toBe(3)
})
})
describe('DiffBlock footer', () => {