coverage~bigbes/sr-ht-spec3cb1c03dweb/diff.go

Coverage
93.0% 147/158 statements
Δ
Blob
50dd78f
1 package web
2
3 import (
4 "bytes"
5 "crypto/sha256"
6 "encoding/hex"
7 "fmt"
8 "html/template"
9 "log/slog"
10 "strings"
11
12 "go.bigb.es/auxilia/scribe"
13
14 "sourcecraft.dev/bigbes/sr-ht-spec/core"
15 "sourcecraft.dev/bigbes/sr-ht-spec/prosediff"
16 "sourcecraft.dev/bigbes/sr-ht-spec/service"
17 )
18
19 // inlineSimilarityThreshold is the Phase 0 verdict's presentation switch: a
20 // modified prose block whose token similarity is at or above it renders as an
21 // inline word diff, and one below it renders as a paired old/new region.
22 //
23 // 13% of real prose modifications shred into interleaved fragments — those
24 // paragraphs really were rewritten sentence by sentence — and every one of them
25 // scores at or below 0.73. Rendering them inline makes one review in eight
26 // unreadable, which is the one where the agent changed the most. prosediff
27 // exports BlockChange.Similarity for exactly this decision and computes no HTML
28 // itself; this is where the decision is made.
29 const inlineSimilarityThreshold = 0.75
30
31 // diffView is the whole rendered diff of one document, ready for the proposal
32 // template. Unchanged reports the degenerate case — a proposal that touches a
33 // document without changing it — so the page can say so rather than show an
34 // empty diff.
35 type diffView struct {
36 HTML template.HTML
37 Stats prosediff.Stats
38 Unchanged bool
39
40 // Unplaced are this document's threads that no rendered block claimed. The
41 // page shows them in its own area: a comment whose anchor is lost, or whose
42 // block this diff does not render, must still be visible somewhere.
43 Unplaced []service.Thread
44 }
45
46 // docDiff is one document's review: the two revisions to compare, the identity
47 // its comments anchor to, the threads already resolved against this revision,
48 // and who may act on them.
49 type docDiff struct {
50 // DocID is the document's anchoring key — see docIDFor.
51 DocID string
52 // Path is the document's path on the proposal branch, which the compose form
53 // posts back so the anchor is rebuilt against the branch, not the form.
54 Path string
55 // Base is the approved content the proposal was made against; Proposed is
56 // what the branch says now.
57 Base, Proposed []byte
58 // Threads are this document's threads, already run through
59 // service.AnchorThreads: State and Block are meaningless before that.
60 Threads []service.Thread
61 Controls reviewControls
62 }
63
64 // blockKey identifies a block of one revision of one document: which side it is
65 // on and its position in that side's segmentation. It is the only key a thread
66 // is placed by — see docRenderer.
67 type blockKey struct {
68 side core.CommentSide
69 ordinal int
70 }
71
72 // renderDocDiff diffs the approved (old) and proposed (new) source of one
73 // document and renders it as a line-numbered unified diff, with every block's
74 // review threads and, for the owner, the form that opens a new one.
75 //
76 // The page is a table because the gutter has to be a gutter: two number tracks
77 // that stay aligned with the first visual line of a prose line that wraps three
78 // times. Selection is by line and anchoring is by block, so every row carries
79 // the anchor of the block it belongs to and the block's first row carries the id
80 // a comment link scrolls to.
81 //
82 // The arithmetic — which number belongs in which track, and which lines a fold
83 // may hide — is diffrows.go's, deliberately kept out of here. What is left is
84 // escaping and concatenation: every piece of document content passes through
85 // template.HTMLEscapeString, and the only markup this produces is its own
86 // structure plus the thread markup html/template escapes for it.
87 28 func renderDocDiff(in docDiff) diffView {
88 28 d := prosediff.Compare(in.Base, in.Proposed)
89 28 view := diffView{Stats: d.Stats, Unchanged: !d.Stats.Changed()}
90 28
91 28 r := newDocRenderer(in, d)
92 28 if view.Unchanged {
93 2 // Nothing is rendered, so nothing can hold a thread. Handing them all
94 2 // back keeps the invariant this renderer is built on: every thread comes
95 2 // out either attached to a block or in Unplaced, and never neither.
96 2 view.Unplaced = r.unplaced()
97 2 return view
98 2 }
99
100 26 var b strings.Builder
101 26 b.WriteString(`<div class="prosediff"><table class="ph-diff">`)
102 34 for _, g := range groupRows(buildRows(d.Changes, r.blockInfo)) {
103 34 r.writeGroup(&b, g)
104 34 }
105 26 b.WriteString(`</table></div>`)
106 26
107 26 view.HTML = template.HTML(b.String())
108 26 view.Unplaced = r.unplaced()
109 26 return view
110 }
111
112 // docRenderer renders the rows of one document's diff. It holds the anchor
113 // numbering of both revisions and the threads still waiting for a block: a
114 // block claims its threads as its notes row is written, and whatever is left
115 // over at the end never had a block on the page.
116 type docRenderer struct {
117 in docDiff
118 // anchors is each side's blocks, numbered within their heading path.
119 anchors map[core.CommentSide][]core.AnchorBlock
120 pending map[blockKey][]service.Thread
121 // foldPrefix scopes this document's fold checkbox ids. A proposal page
122 // renders several documents into one HTML document, and two folds sharing an
123 // id would toggle each other.
124 foldPrefix string
125 }
126
127 28 func newDocRenderer(in docDiff, d *prosediff.Diff) *docRenderer {
128 28 sum := sha256.Sum256([]byte(in.Path))
129 28 r := &docRenderer{
130 28 in: in,
131 28 anchors: map[core.CommentSide][]core.AnchorBlock{
132 28 core.SideNew: blockAnchors(d.NewBlocks),
133 28 core.SideOld: blockAnchors(d.OldBlocks),
134 28 },
135 28 pending: make(map[blockKey][]service.Thread, len(in.Threads)),
136 28 foldPrefix: hex.EncodeToString(sum[:])[:8],
137 28 }
138 28 // A thread is placed by (side, block ordinal) and by nothing else. The
139 28 // anchor resolution already decided which block it belongs to, and any
140 28 // second-guessing here would be the one thing the anchor model forbids: a
141 28 // comment quietly moved onto a neighbouring paragraph. A thread whose anchor
142 28 // did not resolve (Block < 0) is never placed at all.
143 28 for _, t := range in.Threads {
144 9 if t.Block < 0 {
145 2 continue
146 }
147 7 k := blockKey{sideOf(t.Anchor.Side), t.Block}
148 7 r.pending[k] = append(r.pending[k], t)
149 }
150 28 return r
151 }
152
153 // unplaced reports the threads no block claimed. It walks the input rather than
154 // the leftover map so the order is the one the service listed them in, not a
155 // map's.
156 28 func (r *docRenderer) unplaced() []service.Thread {
157 28 out := make([]service.Thread, 0, len(r.pending))
158 28 for _, t := range r.in.Threads {
159 9 if t.Block < 0 {
160 2 out = append(out, t)
161 2 continue
162 }
163 7 k := blockKey{sideOf(t.Anchor.Side), t.Block}
164 7 if _, still := r.pending[k]; still {
165 2 out = append(out, t)
166 2 }
167 }
168 28 return out
169 }
170
171 // target returns the anchor of the block a change offers to comment on, and
172 // whether it offers one at all.
173 //
174 // A comment goes on the new side, which is the text under review; the old side
175 // is for a block the proposal deletes, where there is no new text to point at.
176 // A move-out offers nothing: it is a pointer to text that is rendered at its
177 // new position, and anchoring it here would give one paragraph two places to be
178 // commented on.
179 124 func (r *docRenderer) target(c prosediff.BlockChange) (core.CommentAnchor, blockKey, bool) {
180 124 var side core.CommentSide
181 124 var blk *prosediff.Block
182 124 switch c.Kind {
183 0 case prosediff.ChangeMoveOut:
184 0 return core.CommentAnchor{}, blockKey{}, false
185 3 case prosediff.ChangeDelete:
186 3 side, blk = core.SideOld, c.Old
187 121 default:
188 121 side, blk = core.SideNew, c.New
189 }
190 124 blocks := r.anchors[side]
191 124 if blk == nil || blk.Ordinal < 0 || blk.Ordinal >= len(blocks) {
192 0 return core.CommentAnchor{}, blockKey{}, false
193 0 }
194 124 ab := blocks[blk.Ordinal]
195 124 anchor := core.CommentAnchor{
196 124 DocID: r.in.DocID,
197 124 HeadingPath: ab.HeadingPath,
198 124 Index: ab.Index,
199 124 BlockHash: ab.Hash,
200 124 Side: side,
201 124 }
202 124 return anchor, blockKey{side, blk.Ordinal}, true
203 }
204
205 // blockInfo answers, for the row builder, the two questions about a change that
206 // are not in the change: what it anchors to, and whether it needs a notes row.
207 //
208 // It counts this block's pending threads without claiming them — claiming
209 // happens when the notes row is written, which is the one place that can
210 // guarantee they were actually rendered. A block the anchor numbering does not
211 // cover is still given rows, because losing document content would be worse
212 // than losing its comment affordance; it just carries no id and offers no form.
213 124 func (r *docRenderer) blockInfo(c prosediff.BlockChange) blockInfo {
214 124 anchor, key, ok := r.target(c)
215 124 if !ok {
216 0 return blockInfo{}
217 0 }
218 124 threads := len(r.pending[key]) > 0
219 124 return blockInfo{
220 124 Anchor: anchor,
221 124 Key: key,
222 124 Commentable: true,
223 124 HasThreads: threads,
224 124 Notes: threads || r.in.Controls.Owner,
225 124 }
226 }
227
228 // writeGroup renders one <tbody>. A fold group opens with the checkbox and
229 // label that reveal it: a real form control rather than a script-driven button,
230 // so an unchanged run can be opened with JavaScript off.
231 34 func (r *docRenderer) writeGroup(b *strings.Builder, g rowGroup) {
232 34 if !g.Fold {
233 30 b.WriteString(`<tbody>`)
234 30 } else {
235 4 id := fmt.Sprintf("fold-%s-%d", r.foldPrefix, g.Index)
236 4 b.WriteString(`<tbody class="ph-fold"><tr class="ph-fold-head"><td colspan="4">`)
237 4 fmt.Fprintf(b, `<input type="checkbox" class="ph-fold-cb" id="%s"><label for="%s">%d unchanged lines</label>`,
238 4 id, id, g.Hidden)
239 4 b.WriteString(`</td></tr>`)
240 4 }
241 180 for _, row := range g.Rows {
242 180 r.writeRow(b, row)
243 180 }
244 34 b.WriteString(`</tbody>`)
245 }
246
247 // writeRow renders one row of the table: two number cells, a sign, and the
248 // line.
249 180 func (r *docRenderer) writeRow(b *strings.Builder, row diffRow) {
250 180 if row.Kind == rowNotes {
251 36 r.writeNotesRow(b, row)
252 36 return
253 36 }
254
255 144 b.WriteString(`<tr class="ph-row ph-r-`)
256 144 b.WriteString(string(row.Kind))
257 144 if row.Start {
258 124 b.WriteString(" ph-blk-start")
259 124 }
260 144 if row.Block.Heading {
261 28 b.WriteString(" ph-head")
262 28 }
263 144 if row.Region {
264 12 b.WriteString(" ph-region")
265 12 }
266 144 if row.Folded {
267 36 b.WriteString(" ph-folded")
268 36 }
269 // A marker row is the renderer talking, not the document: "paragraph moved
270 // here (was line 3)" sits in the same column as the prose around it, and
271 // without a class of its own it reads as a sentence someone wrote.
272 144 if row.Note != "" {
273 0 b.WriteString(" ph-marker")
274 0 }
275 144 b.WriteString(`"`)
276 144 // The id goes on the block's first row and only there: an id repeated down a
277 144 // block would give one anchor several places to scroll to, and an empty id=""
278 144 // on an uncommentable row is a fragment that matches every such row at once.
279 144 if row.Start && row.Block.Commentable {
280 124 b.WriteString(` id="` + blockDOMID(row.Block.Anchor) + `"`)
281 124 }
282 144 writeBlockAttrs(b, row.Block)
283 144 b.WriteString(`>`)
284 144
285 144 writeNumCell(b, "ph-n-old", row.OldNum, row.OldEnd)
286 144 writeNumCell(b, "ph-n-new", row.NewNum, row.NewEnd)
287 144 b.WriteString(`<td class="ph-sign">` + signOf(row.Kind) + `</td>`)
288 144
289 144 b.WriteString(`<td class="ph-text`)
290 144 if row.Block.Mono {
291 0 b.WriteString(" ph-mono")
292 0 }
293 144 b.WriteString(`">`)
294 144 if row.Note != "" {
295 0 // Renderer-authored text, but escaped all the same: a block's label
296 0 // carries a code fence's info string, which the document wrote.
297 0 b.WriteString(template.HTMLEscapeString(row.Note))
298 144 } else {
299 144 writeInlineSpans(b, row.Spans)
300 144 }
301 144 b.WriteString(`</td></tr>`)
302 }
303
304 // writeNotesRow renders a block's threads and, for the owner, the form that
305 // opens a new one, in a full-width row under the block's lines.
306 //
307 // It renders through html/template rather than by hand because everything here
308 // is prose someone else wrote; contextual auto-escaping is what keeps a comment
309 // body text. The template is executed into a buffer first, for the reason
310 // Server.render uses one: a template that fails halfway must not leave its
311 // half-written markup inside the diff.
312 //
313 // This is also where a block claims its threads. The row model only emits a
314 // notes row for a block that has something to put in it, so there is no case
315 // here for an empty one.
316 36 func (r *docRenderer) writeNotesRow(b *strings.Builder, row diffRow) {
317 36 blk := row.Block
318 36 threads := r.pending[blk.Key]
319 36 delete(r.pending, blk.Key)
320 36
321 36 // The anchor note states what a comment written here will attach to. The
322 36 // selection a reviewer makes is by line and the anchor stored is by block, so
323 36 // without this the indirection would be invisible — and it is server-rendered
324 36 // rather than filled in by script, because it has to be readable before the
325 36 // reviewer decides to type.
326 36 path, index := anchorPathLabel(blk.Anchor.HeadingPath), blk.Anchor.Index
327 36
328 36 data := blockComments{}
329 36 for _, t := range threads {
330 5 p := threadPanelOf(t, r.in.Controls)
331 5 p.AnchorPath, p.AnchorIndex = path, index
332 5 data.Threads = append(data.Threads, p)
333 5 }
334 36 if r.in.Controls.Owner {
335 34 data.Compose = &composeForm{
336 34 ActionBase: r.in.Controls.ActionBase,
337 34 DocPath: r.in.Path,
338 34 Ordinal: blk.Key.ordinal,
339 34 Side: blk.Key.side,
340 34 Hash: blk.Anchor.BlockHash,
341 34 AnchorPath: path,
342 34 AnchorIndex: index,
343 34 }
344 34 }
345
346 36 var buf bytes.Buffer
347 36 if err := blockThreadsTmpl.Execute(&buf, data); err != nil {
348 0 slog.Error("rendering the comments on a diff block failed",
349 0 "doc", r.in.Path, scribe.Err(err))
350 0 return
351 0 }
352 36 b.WriteString(`<tr class="ph-notes`)
353 36 // A notes row hides with the block it belongs to. Dropping the flag the row
354 36 // model already set left a collapsed run displaying the compose forms of the
355 36 // very blocks it had just hidden.
356 36 if row.Folded {
357 9 b.WriteString(" ph-folded")
358 9 }
359 36 b.WriteString(`"`)
360 36 writeBlockAttrs(b, blk)
361 36 b.WriteString(`><td colspan="4">`)
362 36 b.Write(buf.Bytes())
363 36 b.WriteString(`</td></tr>`)
364 }
365
366 // writeBlockAttrs writes the one data attribute a row of a commentable block
367 // carries: which block it belongs to.
368 //
369 // It used to write the block's heading path and index alongside, so that a
370 // script could read a row's section without walking back up the table. No
371 // script reads them, and an attribute pair repeated on every row of every diff
372 // for a reader that does not exist is the speculative chrome this port set out
373 // to remove. The heading path a person sees is in the composer and the thread
374 // header, where it is read.
375 //
376 // A row with no anchor gets no attribute at all rather than an empty one. The
377 // selection script clamps a drag to rows sharing a data-anchor, and rows that
378 // all carried data-anchor="" would look to it like one enormous block.
379 180 func writeBlockAttrs(b *strings.Builder, blk *rowBlock) {
380 180 if !blk.Commentable {
381 0 return
382 0 }
383 180 b.WriteString(` data-anchor="` + blockDOMID(blk.Anchor) + `"`)
384 }
385
386 // writeNumCell writes one of the two gutter tracks.
387 //
388 // A zero number is an empty cell: the row has no line number on this side, and
389 // the design's rule is that a number the renderer had to guess is never shown.
390 // A region row states the range it stands for instead, in the cell and in a
391 // title so it is readable when the track is too narrow for it.
392 288 func writeNumCell(b *strings.Builder, side string, n, end int) {
393 288 b.WriteString(`<td class="ph-n ` + side + `"`)
394 288 switch {
395 50 case n == 0:
396 50 b.WriteString(`></td>`)
397 0 case end > n:
398 0 fmt.Fprintf(b, ` title="lines %d–%d">%d–%d</td>`, n, end, n, end)
399 238 default:
400 238 fmt.Fprintf(b, `>%d</td>`, n)
401 }
402 }
403
404 // signOf is the one character the sign column shows. It is where the add/delete
405 // tint starts, so the gutter never reads as part of the change.
406 144 func signOf(kind rowKind) string {
407 144 switch kind {
408 27 case rowInsert:
409 27 return "+"
410 23 case rowDelete:
411 23 return "-"
412 0 case rowMove:
413 0 return "≡"
414 }
415 94 return ""
416 }
417
418 // blockAnchors numbers a revision's blocks the way the anchor model does:
419 // within their own heading path, not document-globally. core.AnchorBlocks is
420 // the single spelling of that numbering — service.AnchorOf reproduces it for
421 // the anchor a comment stores — so the id a block carries here and the anchor
422 // the form posts back cannot drift apart.
423 56 func blockAnchors(blocks []prosediff.Block) []core.AnchorBlock {
424 56 hashes := make([]string, len(blocks))
425 56 paths := make([][]string, len(blocks))
426 246 for i, b := range blocks {
427 246 hashes[i], paths[i] = b.Hash, b.HeadingPath
428 246 }
429 56 return core.AnchorBlocks(hashes, paths)
430 }
431
432 // blockDOMID is the id attribute a rendered block carries: a digest of its
433 // anchor tuple.
434 //
435 // Not its position on the page. An ordinal id renumbers whenever anything above
436 // it is inserted, so a link saved from one revision would silently scroll to a
437 // different paragraph in the next — exactly the relocation the anchor model
438 // refuses to do for comments, and the reader could not tell it had happened.
439 // The digest covers the whole tuple, block hash included, so a link into a block
440 // that has since been rewritten resolves to nothing at all rather than to its
441 // neighbour. The index keeps two blocks that repeat the same text under the same
442 // headings — "TBD" is written a dozen times in a real corpus — from sharing an
443 // id.
444 304 func blockDOMID(a core.CommentAnchor) string {
445 304 h := sha256.New()
446 304 // NUL separates the parts because it cannot occur in a heading, a path or a
447 304 // hash, so no two distinct tuples can hash the same byte string.
448 1469 write := func(s string) {
449 1469 h.Write([]byte(s))
450 1469 h.Write([]byte{0})
451 1469 }
452 304 write(a.DocID)
453 304 write(string(a.Side))
454 304 for _, seg := range a.HeadingPath {
455 253 write(seg)
456 253 }
457 304 write(fmt.Sprint(a.Index))
458 304 write(a.BlockHash)
459 304 return "b-" + hex.EncodeToString(h.Sum(nil))[:16]
460 }
461
462 // sideOf defaults an empty side to the new one, the way service.CommentOn does
463 // when it stores a comment: an anchor read back without a side is a comment on
464 // the text under review.
465 14 func sideOf(s core.CommentSide) core.CommentSide {
466 14 if s == core.SideOld {
467 2 return core.SideOld
468 2 }
469 12 return core.SideNew
470 }
471
472 // writeInlineSpans renders a row's content, marking deletions and insertions
473 // where they sit. The Space flag reproduces prosediff's own spacing: a span
474 // that replaces the one before it carries Space=false, so a one-word
475 // substitution does not render with a gap in the middle.
476 144 func writeInlineSpans(b *strings.Builder, spans []prosediff.Span) {
477 144 emitted := false
478 200 for _, s := range spans {
479 200 if s.Space && emitted {
480 36 b.WriteByte(' ')
481 36 }
482 200 emitted = true
483 200 writeSpan(b, s)
484 }
485 }
486
487 // writeSpan writes one span's escaped text, wrapped in <del> or <ins> for a
488 // change and bare for an equal run.
489 200 func writeSpan(b *strings.Builder, s prosediff.Span) {
490 200 esc := template.HTMLEscapeString(s.Text)
491 200 switch s.Op {
492 13 case prosediff.OpDelete:
493 13 b.WriteString("<del>")
494 13 b.WriteString(esc)
495 13 b.WriteString("</del>")
496 23 case prosediff.OpInsert:
497 23 b.WriteString("<ins>")
498 23 b.WriteString(esc)
499 23 b.WriteString("</ins>")
500 164 default:
501 164 b.WriteString(esc)
502 }
503 }