~bigbes/sr-ht-spec

ref: 658bae75f9714af212b463dbba6bb2de565112f9 sr-ht-spec/web/diff.go -rw-r--r-- 19.5 KiB
658bae75 — Eugene Blikh feat(prosediff): recover the source line each word edit sits on (spec-by6.3.5) 24 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
package web

import (
	"bytes"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"html/template"
	"log"
	"strings"

	"sourcecraft.dev/bigbes/sr-ht-spec/core"
	"sourcecraft.dev/bigbes/sr-ht-spec/prosediff"
	"sourcecraft.dev/bigbes/sr-ht-spec/service"
)

// inlineSimilarityThreshold is the Phase 0 verdict's presentation switch: a
// modified prose block whose token similarity is at or above it renders as an
// inline word diff, and one below it renders as a two-column old/new view.
//
// 13% of real prose modifications shred into interleaved fragments — those
// paragraphs really were rewritten sentence by sentence — and every one of them
// scores at or below 0.73. Rendering them inline makes one review in eight
// unreadable, which is the one where the agent changed the most. prosediff
// exports BlockChange.Similarity for exactly this decision and computes no HTML
// itself; this is where the decision is made.
const inlineSimilarityThreshold = 0.75

// contextPreviewRunes bounds the one-line preview a collapsed unchanged block
// shows: long enough to recognise the paragraph, short enough that a screen of
// them still reads as a list of blocks rather than as the document itself.
const contextPreviewRunes = 90

// diffView is the whole rendered diff of one document, ready for the proposal
// template. Unchanged reports the degenerate case — a proposal that touches a
// document without changing it — so the page can say so rather than show an
// empty diff.
type diffView struct {
	HTML      template.HTML
	Stats     prosediff.Stats
	Unchanged bool

	// Unplaced are this document's threads that no rendered block claimed. The
	// page shows them in its own area: a comment whose anchor is lost, or whose
	// block this diff does not render, must still be visible somewhere.
	Unplaced []service.Thread
}

// docDiff is one document's review: the two revisions to compare, the identity
// its comments anchor to, the threads already resolved against this revision,
// and who may act on them.
type docDiff struct {
	// DocID is the document's anchoring key — see docIDFor.
	DocID string
	// Path is the document's path on the proposal branch, which the compose form
	// posts back so the anchor is rebuilt against the branch, not the form.
	Path string
	// Base is the approved content the proposal was made against; Proposed is
	// what the branch says now.
	Base, Proposed []byte
	// Threads are this document's threads, already run through
	// service.AnchorThreads: State and Block are meaningless before that.
	Threads  []service.Thread
	Controls reviewControls
}

// blockKey identifies a block of one revision of one document: which side it is
// on and its position in that side's segmentation. It is the only key a thread
// is placed by — see docRenderer.
type blockKey struct {
	side    core.CommentSide
	ordinal int
}

// renderDocDiff diffs the approved (old) and proposed (new) source of one
// document and renders it to HTML, with every block's review threads and, for
// the owner, the form that opens a new one.
//
// It walks prosediff's block-change model rather than its text renderer: the
// text renderer is for a terminal, and the review page needs headings grouped,
// word edits marked with <ins>/<del>, and — the load-bearing part — the
// two-column fallback for shredded blocks. Every piece of document content is
// HTML-escaped before it reaches the output; the only markup this produces is
// its own structure and the thread markup html/template escapes for it.
func renderDocDiff(in docDiff) diffView {
	d := prosediff.Compare(in.Base, in.Proposed)
	view := diffView{Stats: d.Stats, Unchanged: !d.Stats.Changed()}

	r := newDocRenderer(in, d)
	if view.Unchanged {
		// Nothing is rendered, so nothing can hold a thread. Handing them all
		// back keeps the invariant this renderer is built on: every thread comes
		// out either attached to a block or in Unplaced, and never neither.
		view.Unplaced = r.unplaced()
		return view
	}

	var b strings.Builder
	b.WriteString(`<div class="prosediff">`)
	lastPath := "\x00" // impossible path, so the first real one always prints
	inHunk := false
	closeHunk := func() {
		if inHunk {
			b.WriteString(`</div></section>`)
			inHunk = false
		}
	}

	for _, c := range d.Changes {
		blk := c.New
		if blk == nil {
			blk = c.Old
		}
		if path := strings.Join(blk.HeadingPath, " › "); path != lastPath {
			closeHunk()
			lastPath = path
			shown := path
			if shown == "" {
				shown = "(document preamble)"
			}
			b.WriteString(`<section class="ph-hunk"><div class="ph-path">`)
			b.WriteString(template.HTMLEscapeString(shown))
			b.WriteString(`</div><div class="ph-blocks">`)
			inHunk = true
		}
		r.writeBlock(&b, c)
	}
	closeHunk()
	b.WriteString(`</div>`)
	view.HTML = template.HTML(b.String())
	view.Unplaced = r.unplaced()
	return view
}

// docRenderer renders the blocks of one document's diff. It holds the anchor
// numbering of both revisions and the threads still waiting for a block: a
// block claims its threads as it is written, and whatever is left over at the
// end never had a block on the page.
type docRenderer struct {
	in docDiff
	// anchors is each side's blocks, numbered within their heading path.
	anchors map[core.CommentSide][]core.AnchorBlock
	pending map[blockKey][]service.Thread
}

func newDocRenderer(in docDiff, d *prosediff.Diff) *docRenderer {
	r := &docRenderer{
		in: in,
		anchors: map[core.CommentSide][]core.AnchorBlock{
			core.SideNew: blockAnchors(d.NewBlocks),
			core.SideOld: blockAnchors(d.OldBlocks),
		},
		pending: make(map[blockKey][]service.Thread, len(in.Threads)),
	}
	// A thread is placed by (side, block ordinal) and by nothing else. The
	// anchor resolution already decided which block it belongs to, and any
	// second-guessing here would be the one thing the anchor model forbids: a
	// comment quietly moved onto a neighbouring paragraph. A thread whose anchor
	// did not resolve (Block < 0) is never placed at all.
	for _, t := range in.Threads {
		if t.Block < 0 {
			continue
		}
		k := blockKey{sideOf(t.Anchor.Side), t.Block}
		r.pending[k] = append(r.pending[k], t)
	}
	return r
}

// unplaced reports the threads no block claimed. It walks the input rather than
// the leftover map so the order is the one the service listed them in, not a
// map's.
func (r *docRenderer) unplaced() []service.Thread {
	out := make([]service.Thread, 0, len(r.pending))
	for _, t := range r.in.Threads {
		if t.Block < 0 {
			out = append(out, t)
			continue
		}
		k := blockKey{sideOf(t.Anchor.Side), t.Block}
		if _, still := r.pending[k]; still {
			out = append(out, t)
		}
	}
	return out
}

// target returns the anchor of the block a change offers to comment on, and
// whether it offers one at all.
//
// A comment goes on the new side, which is the text under review; the old side
// is for a block the proposal deletes, where there is no new text to point at.
// A move-out offers nothing: it is a pointer to text that is rendered at its
// new position, and anchoring it here would give one paragraph two places to be
// commented on.
func (r *docRenderer) target(c prosediff.BlockChange) (core.CommentAnchor, blockKey, bool) {
	var side core.CommentSide
	var blk *prosediff.Block
	switch c.Kind {
	case prosediff.ChangeMoveOut:
		return core.CommentAnchor{}, blockKey{}, false
	case prosediff.ChangeDelete:
		side, blk = core.SideOld, c.Old
	default:
		side, blk = core.SideNew, c.New
	}
	blocks := r.anchors[side]
	if blk == nil || blk.Ordinal < 0 || blk.Ordinal >= len(blocks) {
		return core.CommentAnchor{}, blockKey{}, false
	}
	ab := blocks[blk.Ordinal]
	anchor := core.CommentAnchor{
		DocID:       r.in.DocID,
		HeadingPath: ab.HeadingPath,
		Index:       ab.Index,
		BlockHash:   ab.Hash,
		Side:        side,
	}
	return anchor, blockKey{side, blk.Ordinal}, true
}

// writeBlock renders one block of the diff and the comment UI hanging off it.
// Insert/delete/move show the whole block; a modify chooses among a code line
// diff, an inline word diff and the two-column view, on the rules the design
// pins; an unchanged block renders as collapsed context.
func (r *docRenderer) writeBlock(b *strings.Builder, c prosediff.BlockChange) {
	if c.Kind == prosediff.ChangeMoveOut {
		fmt.Fprintf(b, `<div class="ph-block ph-move"><span class="ph-label">%s moved away (now line %d)</span></div>`,
			template.HTMLEscapeString(c.Old.Label()), c.New.StartLine)
		return
	}

	anchor, key, commentable := r.target(c)
	var threads []service.Thread
	if commentable {
		threads = r.pending[key]
		delete(r.pending, key)
	}

	if c.Kind == prosediff.ChangeEqual {
		r.writeContext(b, c, anchor, key, threads, commentable)
		return
	}

	class, label := changedPresentation(c)
	// A block the anchor numbering does not cover is still shown — losing
	// document content would be worse than losing its comment affordance — it
	// just carries no id and offers no form. Nothing in prosediff produces one
	// today; this is the branch that keeps that assumption from being fatal.
	fmt.Fprintf(b, `<div class="ph-block %s"%s><span class="ph-label">%s</span>`,
		class, idAttr(anchor, commentable), template.HTMLEscapeString(label))
	writeChangedBody(b, c)
	if commentable {
		r.writeComments(b, anchor, key, threads)
	}
	b.WriteString(`</div>`)
}

// idAttr renders the id attribute of a commentable block, and nothing at all
// for one that carries no anchor: an empty id="" is a fragment that matches
// every such block at once.
func idAttr(a core.CommentAnchor, commentable bool) string {
	if !commentable {
		return ""
	}
	return ` id="` + blockDOMID(a) + `"`
}

// writeContext renders an unchanged block.
//
// The rule used to be that the review shows only what changed, and it is now
// that any block of a proposed document can be commented on — a reviewer's
// objection is as often to the paragraph the agent left alone as to the one it
// touched, and a block that is not on the page cannot be pointed at. So
// unchanged text is rendered, but subordinate: collapsed behind a one-line
// preview, so a screenful of context never competes with the marked-up blocks
// and the page still reads at a glance as a diff. A block that already carries a
// comment opens by default — it is no longer merely context once someone has
// said something about it.
func (r *docRenderer) writeContext(b *strings.Builder, c prosediff.BlockChange,
	anchor core.CommentAnchor, key blockKey, threads []service.Thread, commentable bool) {

	open := ""
	if len(threads) > 0 {
		open = " open"
	}
	fmt.Fprintf(b, `<details class="ph-block ph-context"%s%s><summary class="ph-context-label">%s</summary>`,
		idAttr(anchor, commentable), open, template.HTMLEscapeString(blockPreview(c.New)))
	writeBody(b, c.New.Text, c.New.Kind.Prose())
	if commentable {
		r.writeComments(b, anchor, key, threads)
	}
	b.WriteString(`</details>`)
}

// writeComments appends one block's threads and, for the owner, the form that
// opens a new one.
//
// It renders through html/template rather than by hand because everything here
// is prose someone else wrote; contextual auto-escaping is what keeps a comment
// body text. The template is executed into a buffer first, for the reason
// Server.render uses one: a template that fails halfway must not leave its
// half-written markup inside the diff.
func (r *docRenderer) writeComments(b *strings.Builder, anchor core.CommentAnchor,
	key blockKey, threads []service.Thread) {

	data := blockComments{}
	for _, t := range threads {
		data.Threads = append(data.Threads, threadPanelOf(t, r.in.Controls))
	}
	if r.in.Controls.Owner {
		data.Compose = &composeForm{
			ActionBase: r.in.Controls.ActionBase,
			DocPath:    r.in.Path,
			Ordinal:    key.ordinal,
			Side:       key.side,
			Hash:       anchor.BlockHash,
		}
	}
	if len(data.Threads) == 0 && data.Compose == nil {
		return
	}

	var buf bytes.Buffer
	if err := blockThreadsTmpl.Execute(&buf, data); err != nil {
		log.Printf("web: rendering comments on %s: %v", r.in.Path, err)
		return
	}
	b.Write(buf.Bytes())
}

// changedPresentation is the class and the label of a changed block, decided
// together: the two-column fallback changes both, and deciding it twice is how
// a block ends up labelled "rewritten" while rendering inline.
func changedPresentation(c prosediff.BlockChange) (class, label string) {
	switch c.Kind {
	case prosediff.ChangeInsert:
		return "ph-insert", "added " + c.New.Label()
	case prosediff.ChangeDelete:
		return "ph-delete", "removed " + c.Old.Label()
	case prosediff.ChangeMoveIn:
		return "ph-move", fmt.Sprintf("%s moved here (was line %d)", c.New.Label(), c.Old.StartLine)
	}

	label = "changed " + c.New.Label()
	if c.StructureOnly {
		label = fmt.Sprintf("changed %s → %s (structure)", c.Old.Label(), c.New.Label())
	}
	if c.Moved {
		label += fmt.Sprintf(" (moved from line %d)", c.Old.StartLine)
	}
	if twoColumn(c) {
		return "ph-modify ph-columns", label + " (rewritten)"
	}
	return "ph-modify", label
}

// twoColumn reports whether a modified block falls back to the old/new columns:
// a prose block (no line script) rewritten past the point where inline marks
// stay readable.
func twoColumn(c prosediff.BlockChange) bool {
	return len(c.Lines) == 0 && c.Similarity < inlineSimilarityThreshold
}

// writeChangedBody renders the body of a changed block. A code, frontmatter or
// HTML block has a line-oriented edit script and renders line by line; a prose
// block has a word edit script and renders inline when it stayed similar enough
// to follow, and two-column when it did not. An inserted, deleted or moved block
// has no edit script and shows its whole text.
//
// A move-in used to render as a bare "moved here" marker. It shows its text now
// because it is commentable, and a comment control on text the reviewer cannot
// see is a control on nothing.
func writeChangedBody(b *strings.Builder, c prosediff.BlockChange) {
	switch c.Kind {
	case prosediff.ChangeInsert, prosediff.ChangeMoveIn:
		writeBody(b, c.New.Text, c.New.Kind.Prose())
		return
	case prosediff.ChangeDelete:
		writeBody(b, c.Old.Text, c.Old.Kind.Prose())
		return
	}

	if len(c.Lines) > 0 {
		b.WriteString(`<pre class="ph-code">`)
		writeLineSpans(b, c.Lines)
		b.WriteString(`</pre>`)
		return
	}
	if !twoColumn(c) {
		b.WriteString(`<div class="ph-body ph-inline">`)
		writeInlineSpans(b, c.Words)
		b.WriteString(`</div>`)
		return
	}

	// The two-column fallback: the block was rewritten enough that inline marks
	// would shred it. The old column keeps deletions, the new keeps insertions,
	// each still marked, so a reviewer reads two coherent paragraphs side by side.
	b.WriteString(`<div class="ph-cols"><div class="ph-col ph-old">`)
	writeColumnSpans(b, c.Words, true)
	b.WriteString(`</div><div class="ph-col ph-new">`)
	writeColumnSpans(b, c.Words, false)
	b.WriteString(`</div></div>`)
}

// blockAnchors numbers a revision's blocks the way the anchor model does:
// within their own heading path, not document-globally. core.AnchorBlocks is
// the single spelling of that numbering — service.AnchorOf reproduces it for
// the anchor a comment stores — so the id a block carries here and the anchor
// the form posts back cannot drift apart.
func blockAnchors(blocks []prosediff.Block) []core.AnchorBlock {
	hashes := make([]string, len(blocks))
	paths := make([][]string, len(blocks))
	for i, b := range blocks {
		hashes[i], paths[i] = b.Hash, b.HeadingPath
	}
	return core.AnchorBlocks(hashes, paths)
}

// blockDOMID is the id attribute a rendered block carries: a digest of its
// anchor tuple.
//
// Not its position on the page. An ordinal id renumbers whenever anything above
// it is inserted, so a link saved from one revision would silently scroll to a
// different paragraph in the next — exactly the relocation the anchor model
// refuses to do for comments, and the reader could not tell it had happened.
// The digest covers the whole tuple, block hash included, so a link into a block
// that has since been rewritten resolves to nothing at all rather than to its
// neighbour. The index keeps two blocks that repeat the same text under the same
// headings — "TBD" is written a dozen times in a real corpus — from sharing an
// id.
func blockDOMID(a core.CommentAnchor) string {
	h := sha256.New()
	// NUL separates the parts because it cannot occur in a heading, a path or a
	// hash, so no two distinct tuples can hash the same byte string.
	write := func(s string) {
		h.Write([]byte(s))
		h.Write([]byte{0})
	}
	write(a.DocID)
	write(string(a.Side))
	for _, seg := range a.HeadingPath {
		write(seg)
	}
	write(fmt.Sprint(a.Index))
	write(a.BlockHash)
	return "b-" + hex.EncodeToString(h.Sum(nil))[:16]
}

// blockPreview is the one line a collapsed context block shows: its text with
// whitespace collapsed and truncated, or its structural label when it has no
// text to show (a thematic break, an empty block).
func blockPreview(blk *prosediff.Block) string {
	text := strings.Join(strings.Fields(blk.Text), " ")
	if text == "" {
		return blk.Label()
	}
	if runes := []rune(text); len(runes) > contextPreviewRunes {
		return string(runes[:contextPreviewRunes]) + "…"
	}
	return text
}

// sideOf defaults an empty side to the new one, the way service.CommentOn does
// when it stores a comment: an anchor read back without a side is a comment on
// the text under review.
func sideOf(s core.CommentSide) core.CommentSide {
	if s == core.SideOld {
		return core.SideOld
	}
	return core.SideNew
}

// writeBody renders whole-block text: escaped, in a <pre> when it is not prose.
func writeBody(b *strings.Builder, text string, prose bool) {
	if prose {
		b.WriteString(`<div class="ph-body">`)
		b.WriteString(template.HTMLEscapeString(text))
		b.WriteString(`</div>`)
		return
	}
	b.WriteString(`<pre class="ph-code">`)
	b.WriteString(template.HTMLEscapeString(text))
	b.WriteString(`</pre>`)
}

// writeInlineSpans renders a word edit script inline, marking deletions and
// insertions where they sit. The Space flag reproduces prosediff's own spacing:
// a span that replaces the one before it carries Space=false, so a one-word
// substitution does not render with a gap in the middle.
func writeInlineSpans(b *strings.Builder, spans []prosediff.Span) {
	emitted := false
	for _, s := range spans {
		if s.Space && emitted {
			b.WriteByte(' ')
		}
		emitted = true
		writeSpan(b, s)
	}
}

// writeColumnSpans renders one side of the two-column view: the old side keeps
// equal and deleted words, the new side keeps equal and inserted ones. Both
// still mark their changes, so each column is a readable paragraph that also
// shows what moved.
func writeColumnSpans(b *strings.Builder, spans []prosediff.Span, old bool) {
	emitted := false
	for _, s := range spans {
		keep := s.Op == prosediff.OpEqual ||
			(old && s.Op == prosediff.OpDelete) ||
			(!old && s.Op == prosediff.OpInsert)
		if !keep {
			continue
		}
		if s.Space && emitted {
			b.WriteByte(' ')
		}
		emitted = true
		writeSpan(b, s)
	}
}

// writeSpan writes one span's escaped text, wrapped in <del> or <ins> for a
// change and bare for an equal run.
func writeSpan(b *strings.Builder, s prosediff.Span) {
	esc := template.HTMLEscapeString(s.Text)
	switch s.Op {
	case prosediff.OpDelete:
		b.WriteString("<del>")
		b.WriteString(esc)
		b.WriteString("</del>")
	case prosediff.OpInsert:
		b.WriteString("<ins>")
		b.WriteString(esc)
		b.WriteString("</ins>")
	default:
		b.WriteString(esc)
	}
}

// writeLineSpans renders a line-oriented edit script (a code fence, frontmatter
// or HTML block) one line per row inside a <pre>, each line class-marked.
func writeLineSpans(b *strings.Builder, spans []prosediff.Span) {
	for _, s := range spans {
		class := "ph-line-eq"
		switch s.Op {
		case prosediff.OpDelete:
			class = "ph-line-del"
		case prosediff.OpInsert:
			class = "ph-line-ins"
		}
		fmt.Fprintf(b, `<span class="%s">%s</span>`+"\n", class, template.HTMLEscapeString(s.Text))
	}
}