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 /, 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(`
`) lastPath := "\x00" // impossible path, so the first real one always prints inHunk := false closeHunk := func() { if inHunk { b.WriteString(`
`) 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(`
`) b.WriteString(template.HTMLEscapeString(shown)) b.WriteString(`
`) inHunk = true } r.writeBlock(&b, c) } closeHunk() b.WriteString(`
`) 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, `
%s moved away (now line %d)
`, 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, `
%s`, class, idAttr(anchor, commentable), template.HTMLEscapeString(label)) writeChangedBody(b, c) if commentable { r.writeComments(b, anchor, key, threads) } b.WriteString(`
`) } // 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, `
%s`, 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(`
`) } // 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(`
`)
		writeLineSpans(b, c.Lines)
		b.WriteString(`
`) return } if !twoColumn(c) { b.WriteString(`
`) writeInlineSpans(b, c.Words) b.WriteString(`
`) 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(`
`) writeColumnSpans(b, c.Words, true) b.WriteString(`
`) writeColumnSpans(b, c.Words, false) b.WriteString(`
`) } // 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
 when it is not prose.
func writeBody(b *strings.Builder, text string, prose bool) {
	if prose {
		b.WriteString(`
`) b.WriteString(template.HTMLEscapeString(text)) b.WriteString(`
`) return } b.WriteString(`
`)
	b.WriteString(template.HTMLEscapeString(text))
	b.WriteString(`
`) } // 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 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) } } // writeLineSpans renders a line-oriented edit script (a code fence, frontmatter // or HTML block) one line per row inside a
, 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, `%s`+"\n", class, template.HTMLEscapeString(s.Text))
	}
}