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(`
`)
for _, g := range groupRows(buildRows(d.Changes, r.blockInfo)) {
r.writeGroup(&b, g)
}
b.WriteString(`
`)
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 . 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(``)
} else {
id := fmt.Sprintf("fold-%s-%d", r.foldPrefix, g.Index)
b.WriteString(`
`)
}
for _, row := range g.Rows {
r.writeRow(b, row)
}
b.WriteString(``)
}
// 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(`
`)
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(`
`)
}
// 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(`
`)
b.Write(buf.Bytes())
b.WriteString(`
`)
}
// 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(`
`)
case end > n:
fmt.Fprintf(b, ` title="lines %d–%d">%d–%d`, n, end, n, end)
default:
fmt.Fprintf(b, `>%d`, 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 or 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("")
b.WriteString(esc)
b.WriteString("")
case prosediff.OpInsert:
b.WriteString("")
b.WriteString(esc)
b.WriteString("")
default:
b.WriteString(esc)
}
}