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