~bigbes/sr-ht-spec

ref: 3d811988f9960cf9057e09f30b59ceb71a7623c2 sr-ht-spec/web/diff.go -rw-r--r-- 17.9 KiB
3d811988 — Eugene Blikh service: wrap the merge and reject failures with culpa 9 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
package web

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

	"go.bigb.es/auxilia/scribe"

	"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 paired old/new region.
//
// 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

// 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 as a line-numbered unified diff, with every block's
// review threads and, for the owner, the form that opens a new one.
//
// The page is a table because the gutter has to be a gutter: two number tracks
// that stay aligned with the first visual line of a prose line that wraps three
// times. Selection is by line and anchoring is by block, so every row carries
// the anchor of the block it belongs to and the block's first row carries the id
// a comment link scrolls to.
//
// The arithmetic — which number belongs in which track, and which lines a fold
// may hide — is diffrows.go's, deliberately kept out of here. What is left is
// escaping and concatenation: every piece of document content passes through
// template.HTMLEscapeString, and the only markup this produces is its own
// structure plus 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"><table class="ph-diff">`)
	for _, g := range groupRows(buildRows(d.Changes, r.blockInfo)) {
		r.writeGroup(&b, g)
	}
	b.WriteString(`</table></div>`)

	view.HTML = template.HTML(b.String())
	view.Unplaced = r.unplaced()
	return view
}

// docRenderer renders the rows 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 its notes row 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
	// foldPrefix scopes this document's fold checkbox ids. A proposal page
	// renders several documents into one HTML document, and two folds sharing an
	// id would toggle each other.
	foldPrefix string
}

func newDocRenderer(in docDiff, d *prosediff.Diff) *docRenderer {
	sum := sha256.Sum256([]byte(in.Path))
	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)),
		foldPrefix: hex.EncodeToString(sum[:])[:8],
	}
	// 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
}

// blockInfo answers, for the row builder, the two questions about a change that
// are not in the change: what it anchors to, and whether it needs a notes row.
//
// It counts this block's pending threads without claiming them — claiming
// happens when the notes row is written, which is the one place that can
// guarantee they were actually rendered. A block the anchor numbering does not
// cover is still given rows, because losing document content would be worse
// than losing its comment affordance; it just carries no id and offers no form.
func (r *docRenderer) blockInfo(c prosediff.BlockChange) blockInfo {
	anchor, key, ok := r.target(c)
	if !ok {
		return blockInfo{}
	}
	threads := len(r.pending[key]) > 0
	return blockInfo{
		Anchor:      anchor,
		Key:         key,
		Commentable: true,
		HasThreads:  threads,
		Notes:       threads || r.in.Controls.Owner,
	}
}

// writeGroup renders one <tbody>. A fold group opens with the checkbox and
// label that reveal it: a real form control rather than a script-driven button,
// so an unchanged run can be opened with JavaScript off.
func (r *docRenderer) writeGroup(b *strings.Builder, g rowGroup) {
	if !g.Fold {
		b.WriteString(`<tbody>`)
	} else {
		id := fmt.Sprintf("fold-%s-%d", r.foldPrefix, g.Index)
		b.WriteString(`<tbody class="ph-fold"><tr class="ph-fold-head"><td colspan="4">`)
		fmt.Fprintf(b, `<input type="checkbox" class="ph-fold-cb" id="%s"><label for="%s">%d unchanged lines</label>`,
			id, id, g.Hidden)
		b.WriteString(`</td></tr>`)
	}
	for _, row := range g.Rows {
		r.writeRow(b, row)
	}
	b.WriteString(`</tbody>`)
}

// writeRow renders one row of the table: two number cells, a sign, and the
// line.
func (r *docRenderer) writeRow(b *strings.Builder, row diffRow) {
	if row.Kind == rowNotes {
		r.writeNotesRow(b, row)
		return
	}

	b.WriteString(`<tr class="ph-row ph-r-`)
	b.WriteString(string(row.Kind))
	if row.Start {
		b.WriteString(" ph-blk-start")
	}
	if row.Block.Heading {
		b.WriteString(" ph-head")
	}
	if row.Region {
		b.WriteString(" ph-region")
	}
	if row.Folded {
		b.WriteString(" ph-folded")
	}
	// A marker row is the renderer talking, not the document: "paragraph moved
	// here (was line 3)" sits in the same column as the prose around it, and
	// without a class of its own it reads as a sentence someone wrote.
	if row.Note != "" {
		b.WriteString(" ph-marker")
	}
	b.WriteString(`"`)
	// The id goes on the block's first row and only there: an id repeated down a
	// block would give one anchor several places to scroll to, and an empty id=""
	// on an uncommentable row is a fragment that matches every such row at once.
	if row.Start && row.Block.Commentable {
		b.WriteString(` id="` + blockDOMID(row.Block.Anchor) + `"`)
	}
	writeBlockAttrs(b, row.Block)
	b.WriteString(`>`)

	writeNumCell(b, "ph-n-old", row.OldNum, row.OldEnd)
	writeNumCell(b, "ph-n-new", row.NewNum, row.NewEnd)
	b.WriteString(`<td class="ph-sign">` + signOf(row.Kind) + `</td>`)

	b.WriteString(`<td class="ph-text`)
	if row.Block.Mono {
		b.WriteString(" ph-mono")
	}
	b.WriteString(`">`)
	if row.Note != "" {
		// Renderer-authored text, but escaped all the same: a block's label
		// carries a code fence's info string, which the document wrote.
		b.WriteString(template.HTMLEscapeString(row.Note))
	} else {
		writeInlineSpans(b, row.Spans)
	}
	b.WriteString(`</td></tr>`)
}

// writeNotesRow renders a block's threads and, for the owner, the form that
// opens a new one, in a full-width row under the block's lines.
//
// 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.
//
// This is also where a block claims its threads. The row model only emits a
// notes row for a block that has something to put in it, so there is no case
// here for an empty one.
func (r *docRenderer) writeNotesRow(b *strings.Builder, row diffRow) {
	blk := row.Block
	threads := r.pending[blk.Key]
	delete(r.pending, blk.Key)

	// The anchor note states what a comment written here will attach to. The
	// selection a reviewer makes is by line and the anchor stored is by block, so
	// without this the indirection would be invisible — and it is server-rendered
	// rather than filled in by script, because it has to be readable before the
	// reviewer decides to type.
	path, index := anchorPathLabel(blk.Anchor.HeadingPath), blk.Anchor.Index

	data := blockComments{}
	for _, t := range threads {
		p := threadPanelOf(t, r.in.Controls)
		p.AnchorPath, p.AnchorIndex = path, index
		data.Threads = append(data.Threads, p)
	}
	if r.in.Controls.Owner {
		data.Compose = &composeForm{
			ActionBase:  r.in.Controls.ActionBase,
			DocPath:     r.in.Path,
			Ordinal:     blk.Key.ordinal,
			Side:        blk.Key.side,
			Hash:        blk.Anchor.BlockHash,
			AnchorPath:  path,
			AnchorIndex: index,
		}
	}

	var buf bytes.Buffer
	if err := blockThreadsTmpl.Execute(&buf, data); err != nil {
		slog.Error("rendering the comments on a diff block failed",
			"doc", r.in.Path, scribe.Err(err))
		return
	}
	b.WriteString(`<tr class="ph-notes`)
	// A notes row hides with the block it belongs to. Dropping the flag the row
	// model already set left a collapsed run displaying the compose forms of the
	// very blocks it had just hidden.
	if row.Folded {
		b.WriteString(" ph-folded")
	}
	b.WriteString(`"`)
	writeBlockAttrs(b, blk)
	b.WriteString(`><td colspan="4">`)
	b.Write(buf.Bytes())
	b.WriteString(`</td></tr>`)
}

// writeBlockAttrs writes the one data attribute a row of a commentable block
// carries: which block it belongs to.
//
// It used to write the block's heading path and index alongside, so that a
// script could read a row's section without walking back up the table. No
// script reads them, and an attribute pair repeated on every row of every diff
// for a reader that does not exist is the speculative chrome this port set out
// to remove. The heading path a person sees is in the composer and the thread
// header, where it is read.
//
// A row with no anchor gets no attribute at all rather than an empty one. The
// selection script clamps a drag to rows sharing a data-anchor, and rows that
// all carried data-anchor="" would look to it like one enormous block.
func writeBlockAttrs(b *strings.Builder, blk *rowBlock) {
	if !blk.Commentable {
		return
	}
	b.WriteString(` data-anchor="` + blockDOMID(blk.Anchor) + `"`)
}

// writeNumCell writes one of the two gutter tracks.
//
// A zero number is an empty cell: the row has no line number on this side, and
// the design's rule is that a number the renderer had to guess is never shown.
// A region row states the range it stands for instead, in the cell and in a
// title so it is readable when the track is too narrow for it.
func writeNumCell(b *strings.Builder, side string, n, end int) {
	b.WriteString(`<td class="ph-n ` + side + `"`)
	switch {
	case n == 0:
		b.WriteString(`></td>`)
	case end > n:
		fmt.Fprintf(b, ` title="lines %d–%d">%d–%d</td>`, n, end, n, end)
	default:
		fmt.Fprintf(b, `>%d</td>`, n)
	}
}

// signOf is the one character the sign column shows. It is where the add/delete
// tint starts, so the gutter never reads as part of the change.
func signOf(kind rowKind) string {
	switch kind {
	case rowInsert:
		return "+"
	case rowDelete:
		return "-"
	case rowMove:
		return "≡"
	}
	return ""
}

// 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]
}

// 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
}

// writeInlineSpans renders a row's content, 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)
	}
}

// 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)
	}
}