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 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(``) fmt.Fprintf(b, ``, id, id, g.Hidden) 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(``) writeNumCell(b, "ph-n-old", row.OldNum, row.OldEnd) writeNumCell(b, "ph-n-new", row.NewNum, row.NewEnd) b.WriteString(`` + signOf(row.Kind) + ``) 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 { log.Printf("web: rendering comments on %s: %v", r.in.Path, 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) } }