| 1 |
|
package web |
| 2 |
|
|
| 3 |
|
import ( |
| 4 |
|
"fmt" |
| 5 |
|
"strings" |
| 6 |
|
|
| 7 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/core" |
| 8 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/prosediff" |
| 9 |
|
) |
| 10 |
|
|
| 11 |
|
// This file is the row model of the unified diff: the arithmetic of which line |
| 12 |
|
// number goes in which gutter track, and nothing else. It emits no HTML on |
| 13 |
|
// purpose. Line attribution is the part of this renderer that can be quietly |
| 14 |
|
// wrong — a number off by one invites a reviewer to comment on text that was |
| 15 |
|
// never there — and a model built out of structs can be tested by reading its |
| 16 |
|
// fields instead of by matching substrings of markup. |
| 17 |
|
|
| 18 |
|
// rowKind is what one row says happened to its line. The values are the |
| 19 |
|
// suffixes of the ph-r-* classes the markup contract pins, so the HTML writer |
| 20 |
|
// concatenates rather than translates. |
| 21 |
|
type rowKind string |
| 22 |
|
|
| 23 |
|
const ( |
| 24 |
|
rowEqual rowKind = "eq" |
| 25 |
|
rowInsert rowKind = "ins" |
| 26 |
|
rowDelete rowKind = "del" |
| 27 |
|
rowMove rowKind = "move" |
| 28 |
|
// rowNotes is not a line. It marks the place in the stream where a block's |
| 29 |
|
// threads and its compose form belong. It lives in the model rather than in |
| 30 |
|
// the writer because where it goes — after the last row of its block, before |
| 31 |
|
// the first row of the next — is a statement about order, and because the |
| 32 |
|
// folder has to know a block's comment UI is there before it hides the block. |
| 33 |
|
rowNotes rowKind = "notes" |
| 34 |
|
) |
| 35 |
|
|
| 36 |
|
// Folding thresholds. A run of unchanged lines is collapsed so a screenful of |
| 37 |
|
// context never competes with what changed, but collapsing is not free: it |
| 38 |
|
// costs a click and it hides text a reviewer may want to point at. |
| 39 |
|
// |
| 40 |
|
// foldMinRun and foldMinHidden are two gates, not one. A run of exactly |
| 41 |
|
// foldMinRun rows keeps foldKeepEdge rows at each end and would therefore hide |
| 42 |
|
// two, which is a bad trade — one toggle row replacing two lines of prose — so |
| 43 |
|
// the second gate rejects it and folding effectively starts at seven rows. The |
| 44 |
|
// markup contract states both numbers and they do not quite agree at the |
| 45 |
|
// boundary; the resolution here is the conservative one, because a fold that |
| 46 |
|
// saves nothing is a click that buys nothing. |
| 47 |
|
const ( |
| 48 |
|
foldMinRun = 6 |
| 49 |
|
foldKeepEdge = 2 |
| 50 |
|
foldMinHidden = 3 |
| 51 |
|
) |
| 52 |
|
|
| 53 |
|
// rowBlock is what every row of one block shares. Rows point at it rather than |
| 54 |
|
// copying it so that the folder can ask a question about the *block* — does |
| 55 |
|
// anyone have a comment on it — while walking rows. |
| 56 |
|
type rowBlock struct { |
| 57 |
|
// Anchor and Key are the block's comment identity; both are zero when |
| 58 |
|
// Commentable is false, which is the move-out marker and nothing else today. |
| 59 |
|
Anchor core.CommentAnchor |
| 60 |
|
Key blockKey |
| 61 |
|
Commentable bool |
| 62 |
|
|
| 63 |
|
// Notes says a notes row was emitted for this block: it has threads, or the |
| 64 |
|
// viewer is the owner and gets a compose form. A block with neither gets no |
| 65 |
|
// row, because an empty one would be a gap in the table for no reason. |
| 66 |
|
Notes bool |
| 67 |
|
// HasThreads is only about folding: an unchanged block someone has already |
| 68 |
|
// commented on is no longer merely context. |
| 69 |
|
HasThreads bool |
| 70 |
|
|
| 71 |
|
// Context marks a block that is unchanged on both sides. It, and not the row |
| 72 |
|
// kind, is what the folder groups by — a block's notes row is not a ph-r-eq |
| 73 |
|
// row but belongs inside the fold with the lines it hangs off. |
| 74 |
|
Context bool |
| 75 |
|
// Heading drives the sticky section readout; Mono drives the monospaced text |
| 76 |
|
// cell of a code fence, frontmatter or HTML block. |
| 77 |
|
Heading bool |
| 78 |
|
Mono bool |
| 79 |
|
} |
| 80 |
|
|
| 81 |
|
// diffRow is one <tr> of the unified diff, before it is one. |
| 82 |
|
// |
| 83 |
|
// The two number fields are independent on purpose: a row states a number for |
| 84 |
|
// the side it came from and leaves the other side empty. That is what makes it |
| 85 |
|
// impossible to attribute a line number to the wrong revision — the alternative, |
| 86 |
|
// carrying one number plus a side flag, puts the decision in the writer, where |
| 87 |
|
// a rewrapped block would have to guess. |
| 88 |
|
type diffRow struct { |
| 89 |
|
Kind rowKind |
| 90 |
|
Block *rowBlock |
| 91 |
|
|
| 92 |
|
// OldNum and NewNum are 1-based source line numbers, or zero for "this side |
| 93 |
|
// has no number for this row". Zero renders as an empty cell and never as a |
| 94 |
|
// 0: an honest blank beats a number nobody can defend. |
| 95 |
|
OldNum, NewNum int |
| 96 |
|
// OldEnd and NewEnd close a line range on a region row, and are zero |
| 97 |
|
// everywhere else. |
| 98 |
|
OldEnd, NewEnd int |
| 99 |
|
// Region marks the fallback row that stands for a whole block rather than |
| 100 |
|
// for one line — see regionRows. |
| 101 |
|
Region bool |
| 102 |
|
|
| 103 |
|
// Spans is the row's content as an edit script: one equal span for an |
| 104 |
|
// untouched line, several for a line carrying word-level marks. Empty when |
| 105 |
|
// Note is set. |
| 106 |
|
Spans []prosediff.Span |
| 107 |
|
// Note is renderer-authored text rather than document content — the move |
| 108 |
|
// markers. It is still escaped on the way out, because a block label can |
| 109 |
|
// carry a code fence's info string, which the document wrote. |
| 110 |
|
Note string |
| 111 |
|
|
| 112 |
|
// Start marks the first row of a block: the row that carries the id a |
| 113 |
|
// comment link scrolls to. |
| 114 |
|
Start bool |
| 115 |
|
// Folded marks a row hidden until its fold is opened. |
| 116 |
|
Folded bool |
| 117 |
|
} |
| 118 |
|
|
| 119 |
|
// rowGroup is one <tbody>. Grouping exists only to make folding a pure CSS |
| 120 |
|
// affordance: a fold group holds the rows a toggle hides, and everything else |
| 121 |
|
// accumulates into plain groups. Block identity is never carried by a group — |
| 122 |
|
// a fold boundary can and does cut a block in half. |
| 123 |
|
type rowGroup struct { |
| 124 |
|
Fold bool |
| 125 |
|
// Index numbers the fold groups of one document, so their checkboxes get |
| 126 |
|
// distinct ids on a page that renders several documents. |
| 127 |
|
Index int |
| 128 |
|
// Hidden is how many *lines* the fold hides, which is what its label says. |
| 129 |
|
// Notes rows are hidden with them but are not lines and are not counted. |
| 130 |
|
Hidden int |
| 131 |
|
Rows []diffRow |
| 132 |
|
} |
| 133 |
|
|
| 134 |
|
// blockInfo is what the row builder cannot work out for itself: the comment |
| 135 |
|
// identity of a change and whether anything has been said about it. It is |
| 136 |
|
// passed in as a function so the model can be built — and tested — without a |
| 137 |
|
// thread store, a document path or a template behind it. |
| 138 |
|
type blockInfo struct { |
| 139 |
|
Anchor core.CommentAnchor |
| 140 |
|
Key blockKey |
| 141 |
|
Commentable bool |
| 142 |
|
HasThreads bool |
| 143 |
|
Notes bool |
| 144 |
|
} |
| 145 |
|
|
| 146 |
|
// buildRows turns a document's block changes into the unified row stream, in |
| 147 |
|
// document order. |
| 148 |
|
// |
| 149 |
|
// Every change contributes at least one row. A block that produced none would |
| 150 |
|
// be document content that silently left the page, which is worse than a row |
| 151 |
|
// that only says the block is empty. |
| 152 |
2848 |
func buildRows(changes []prosediff.BlockChange, info func(prosediff.BlockChange) blockInfo) []diffRow { |
| 153 |
2848 |
var rows []diffRow |
| 154 |
10098 |
for _, c := range changes { |
| 155 |
10098 |
blk := newRowBlock(c, info(c)) |
| 156 |
10098 |
at := len(rows) |
| 157 |
10098 |
rows = append(rows, changeRows(c, blk)...) |
| 158 |
10098 |
if len(rows) == at { |
| 159 |
0 |
continue |
| 160 |
|
} |
| 161 |
10098 |
rows[at].Start = true |
| 162 |
10098 |
if blk.Notes { |
| 163 |
53 |
rows = append(rows, diffRow{Kind: rowNotes, Block: blk}) |
| 164 |
53 |
} |
| 165 |
|
} |
| 166 |
2848 |
return rows |
| 167 |
|
} |
| 168 |
|
|
| 169 |
|
// newRowBlock derives the per-block facts every row of a change shares. |
| 170 |
|
// |
| 171 |
|
// The structural facts come from the side the rows are drawn from — the old |
| 172 |
|
// block for a deletion and for a move-out marker, the new one otherwise — so a |
| 173 |
|
// block that changed kind (a paragraph promoted to a heading) is described by |
| 174 |
|
// the revision the reader is looking at. |
| 175 |
10098 |
func newRowBlock(c prosediff.BlockChange, info blockInfo) *rowBlock { |
| 176 |
10098 |
src := c.New |
| 177 |
10098 |
if c.Kind == prosediff.ChangeDelete || c.Kind == prosediff.ChangeMoveOut { |
| 178 |
1579 |
src = c.Old |
| 179 |
1579 |
} |
| 180 |
10098 |
blk := &rowBlock{ |
| 181 |
10098 |
Anchor: info.Anchor, |
| 182 |
10098 |
Key: info.Key, |
| 183 |
10098 |
Commentable: info.Commentable, |
| 184 |
10098 |
Notes: info.Notes, |
| 185 |
10098 |
HasThreads: info.HasThreads, |
| 186 |
10098 |
Context: c.Kind == prosediff.ChangeEqual, |
| 187 |
10098 |
} |
| 188 |
10098 |
if src != nil { |
| 189 |
10098 |
blk.Heading = src.Kind == prosediff.KindHeading |
| 190 |
10098 |
blk.Mono = !src.Kind.Prose() |
| 191 |
10098 |
} |
| 192 |
10098 |
return blk |
| 193 |
|
} |
| 194 |
|
|
| 195 |
|
// changeRows renders one block change into rows. The five kinds that carry a |
| 196 |
|
// whole block map onto their lines directly and exactly; only a modification |
| 197 |
|
// has to recover which line a word edit fell on, which is modifyRows' problem. |
| 198 |
10098 |
func changeRows(c prosediff.BlockChange, blk *rowBlock) []diffRow { |
| 199 |
10098 |
switch c.Kind { |
| 200 |
5948 |
case prosediff.ChangeEqual: |
| 201 |
5948 |
return equalRows(c, blk) |
| 202 |
1434 |
case prosediff.ChangeInsert: |
| 203 |
1434 |
return wholeBlockRows(c.New, rowInsert, false, blk) |
| 204 |
1437 |
case prosediff.ChangeDelete: |
| 205 |
1437 |
return wholeBlockRows(c.Old, rowDelete, true, blk) |
| 206 |
142 |
case prosediff.ChangeMoveIn: |
| 207 |
142 |
// The marker first, then the text. A move-in is commentable — the block |
| 208 |
142 |
// is at its new position and this is where a reviewer objects to it — so |
| 209 |
142 |
// it shows its lines; a comment control on text the reviewer cannot see |
| 210 |
142 |
// is a control on nothing. |
| 211 |
142 |
note := diffRow{ |
| 212 |
142 |
Kind: rowMove, |
| 213 |
142 |
Block: blk, |
| 214 |
142 |
Note: fmt.Sprintf("%s moved here (was line %d)", c.New.Label(), c.Old.StartLine), |
| 215 |
142 |
} |
| 216 |
142 |
return append([]diffRow{note}, wholeBlockRows(c.New, rowMove, false, blk)...) |
| 217 |
142 |
case prosediff.ChangeMoveOut: |
| 218 |
142 |
// One marker and no text: the block is rendered in full at its new |
| 219 |
142 |
// position, and showing it twice would give one paragraph two places to |
| 220 |
142 |
// be commented on. |
| 221 |
142 |
return []diffRow{{ |
| 222 |
142 |
Kind: rowMove, |
| 223 |
142 |
Block: blk, |
| 224 |
142 |
OldNum: c.Old.StartLine, |
| 225 |
142 |
Note: fmt.Sprintf("%s moved away (now line %d)", c.Old.Label(), c.New.StartLine), |
| 226 |
142 |
}} |
| 227 |
995 |
case prosediff.ChangeModify: |
| 228 |
995 |
return modifyRows(c, blk) |
| 229 |
|
} |
| 230 |
0 |
return nil |
| 231 |
|
} |
| 232 |
|
|
| 233 |
|
// equalRows renders an unchanged block as context. |
| 234 |
|
// |
| 235 |
|
// A row states an old line number only when the old revision really does hold |
| 236 |
|
// this text on that line. The rule used to be that the two sides' line *counts* |
| 237 |
|
// agreeing was proof enough of a 1:1 correspondence, and it is not: the block is |
| 238 |
|
// equal at the token level, which is what makes a rewrap invisible to the |
| 239 |
|
// differ, so words can move across the line breaks while the count stays the |
| 240 |
|
// same. "alpha beta / gamma delta" rewrapped to "alpha / beta gamma delta" is |
| 241 |
|
// two lines before and after, and pairing them by position numbered a row 2 |
| 242 |
|
// whose text was never on old line 2 — a reviewer selecting it would have |
| 243 |
|
// commented on text that does not exist in that revision, which is the exact |
| 244 |
|
// failure prosediff.WordsByLine refuses to risk. |
| 245 |
|
// |
| 246 |
|
// So each row is checked on its own, and an unpaired row leaves the old cell |
| 247 |
|
// empty exactly as the count-mismatch case already did. Blank beats fabricated. |
| 248 |
5948 |
func equalRows(c prosediff.BlockChange, blk *rowBlock) []diffRow { |
| 249 |
5948 |
nw := blockLines(c.New) |
| 250 |
5948 |
old := blockLines(c.Old) |
| 251 |
5948 |
prose := c.New.Kind.Prose() |
| 252 |
5948 |
|
| 253 |
5948 |
rows := make([]diffRow, len(nw)) |
| 254 |
12153 |
for i, ln := range nw { |
| 255 |
12153 |
rows[i] = diffRow{ |
| 256 |
12153 |
Kind: rowEqual, |
| 257 |
12153 |
Block: blk, |
| 258 |
12153 |
NewNum: c.New.StartLine + i, |
| 259 |
12153 |
Spans: plainSpans(ln), |
| 260 |
12153 |
} |
| 261 |
12153 |
if i < len(old) && sameSourceLine(old[i], ln, prose) { |
| 262 |
11477 |
rows[i].OldNum = c.Old.StartLine + i |
| 263 |
11477 |
} |
| 264 |
|
} |
| 265 |
5948 |
return rows |
| 266 |
|
} |
| 267 |
|
|
| 268 |
|
// sameSourceLine reports whether two revisions' copies of a line hold the same |
| 269 |
|
// text, by the same yardstick finishBlock uses to hash the block: prose |
| 270 |
|
// compares normalized, because the tokenizer is what the differ ran on and the |
| 271 |
|
// space between two words is not a difference anyone can see; everything else |
| 272 |
|
// compares verbatim, because in a code fence it is. |
| 273 |
12022 |
func sameSourceLine(old, nw string, prose bool) bool { |
| 274 |
12022 |
if !prose { |
| 275 |
2346 |
return old == nw |
| 276 |
2346 |
} |
| 277 |
9676 |
return prosediff.Normalize(old) == prosediff.Normalize(nw) |
| 278 |
|
} |
| 279 |
|
|
| 280 |
|
// wholeBlockRows renders every line of a block on one side of the diff: an |
| 281 |
|
// insertion, a deletion, or the body of a move-in. |
| 282 |
3013 |
func wholeBlockRows(src *prosediff.Block, kind rowKind, old bool, blk *rowBlock) []diffRow { |
| 283 |
3013 |
lines := blockLines(src) |
| 284 |
3013 |
rows := make([]diffRow, len(lines)) |
| 285 |
5914 |
for i, ln := range lines { |
| 286 |
5914 |
rows[i] = diffRow{Kind: kind, Block: blk, Spans: plainSpans(ln)} |
| 287 |
5914 |
if old { |
| 288 |
2691 |
rows[i].OldNum = src.StartLine + i |
| 289 |
3223 |
} else { |
| 290 |
3223 |
rows[i].NewNum = src.StartLine + i |
| 291 |
3223 |
} |
| 292 |
|
} |
| 293 |
3013 |
return rows |
| 294 |
|
} |
| 295 |
|
|
| 296 |
|
// modifyRows renders an edited block, choosing among the three presentations |
| 297 |
|
// the design pins. |
| 298 |
|
// |
| 299 |
|
// A code fence, frontmatter or HTML block already has a line-oriented script |
| 300 |
|
// and needs no recovery. A prose block that stayed similar enough to follow has |
| 301 |
|
// its word script spread back over its source lines. A prose block rewritten |
| 302 |
|
// past that point — or one whose lines and text disagree, so the spreading |
| 303 |
|
// cannot be trusted — falls back to a pair of region rows. |
| 304 |
995 |
func modifyRows(c prosediff.BlockChange, blk *rowBlock) []diffRow { |
| 305 |
995 |
if len(c.Lines) > 0 { |
| 306 |
194 |
return append(infoRows(c, blk), lineScriptRows(c, blk)...) |
| 307 |
194 |
} |
| 308 |
801 |
if c.Similarity >= inlineSimilarityThreshold { |
| 309 |
652 |
if old, nw, ok := prosediff.WordsByLine(c); ok { |
| 310 |
652 |
return mergeLineWords(old, nw, blk) |
| 311 |
652 |
} |
| 312 |
|
} |
| 313 |
149 |
return regionRows(c, blk) |
| 314 |
|
} |
| 315 |
|
|
| 316 |
|
// infoRows is the one marker a modified code fence may need: the language on its |
| 317 |
|
// opening delimiter changed. |
| 318 |
|
// |
| 319 |
|
// prosediff hashes a block's Info, so ```go becoming ```python pairs the two |
| 320 |
|
// fences as a modification — but Block.Lines holds the fence's contents without |
| 321 |
|
// its delimiters, so the line script is entirely equal and every row renders as |
| 322 |
|
// context. The page would then say the document changed and show nothing that |
| 323 |
|
// did. The fence delimiters are not rows of this table and inventing a number |
| 324 |
|
// for one would be a guess, so the change is stated as a marker instead. |
| 325 |
|
// |
| 326 |
|
// Only a code fence is covered. Info also carries a list item's marker and a |
| 327 |
|
// table's column count, and neither is a language: "- became *" is noise, and a |
| 328 |
|
// table whose column count changed already differs in its cells. |
| 329 |
194 |
func infoRows(c prosediff.BlockChange, blk *rowBlock) []diffRow { |
| 330 |
194 |
if c.Old.Kind != prosediff.KindCode || c.Old.Info == c.New.Info { |
| 331 |
192 |
return nil |
| 332 |
192 |
} |
| 333 |
2 |
return []diffRow{{ |
| 334 |
2 |
Kind: rowMove, |
| 335 |
2 |
Block: blk, |
| 336 |
2 |
Note: fmt.Sprintf("code block: %s → %s", infoLabel(c.Old.Info), infoLabel(c.New.Info)), |
| 337 |
2 |
}} |
| 338 |
|
} |
| 339 |
|
|
| 340 |
|
// infoLabel spells the two Info values that are states rather than languages: a |
| 341 |
|
// bare ``` fence, and an indented block, which the segmenter records as |
| 342 |
|
// "indented" and which has no delimiter line at all. |
| 343 |
4 |
func infoLabel(info string) string { |
| 344 |
4 |
switch info { |
| 345 |
1 |
case "": |
| 346 |
1 |
return "no language" |
| 347 |
0 |
case "indented": |
| 348 |
0 |
return "indented, unfenced" |
| 349 |
|
} |
| 350 |
3 |
return info |
| 351 |
|
} |
| 352 |
|
|
| 353 |
|
// lineScriptRows renders a modified non-prose block. prosediff.DiffLines emits |
| 354 |
|
// exactly one span per line, so the two counters walk the two revisions in step |
| 355 |
|
// and every row's number is read off, not derived. |
| 356 |
194 |
func lineScriptRows(c prosediff.BlockChange, blk *rowBlock) []diffRow { |
| 357 |
194 |
oldNo, newNo := c.Old.StartLine, c.New.StartLine |
| 358 |
194 |
rows := make([]diffRow, 0, len(c.Lines)) |
| 359 |
784 |
for _, s := range c.Lines { |
| 360 |
784 |
row := diffRow{Block: blk, Spans: plainSpans(s.Text)} |
| 361 |
784 |
switch s.Op { |
| 362 |
80 |
case prosediff.OpDelete: |
| 363 |
80 |
row.Kind, row.OldNum = rowDelete, oldNo |
| 364 |
80 |
oldNo++ |
| 365 |
219 |
case prosediff.OpInsert: |
| 366 |
219 |
row.Kind, row.NewNum = rowInsert, newNo |
| 367 |
219 |
newNo++ |
| 368 |
485 |
default: |
| 369 |
485 |
row.Kind, row.OldNum, row.NewNum = rowEqual, oldNo, newNo |
| 370 |
485 |
oldNo++ |
| 371 |
485 |
newNo++ |
| 372 |
|
} |
| 373 |
784 |
rows = append(rows, row) |
| 374 |
|
} |
| 375 |
194 |
return rows |
| 376 |
|
} |
| 377 |
|
|
| 378 |
|
// mergeLineWords interleaves the two sides of a spread word script into one |
| 379 |
|
// unified column. |
| 380 |
|
// |
| 381 |
|
// The two sides are separate sequences of lines with no correspondence stored |
| 382 |
|
// between them — prosediff.WordsByLine hands back the old block's lines and the |
| 383 |
|
// new block's lines, each carrying its own share of the script — so the order |
| 384 |
|
// they appear in is this function's choice. Two cursors walk them: |
| 385 |
|
// |
| 386 |
|
// 1. a line neither side marked, with the same text on both, is one context |
| 387 |
|
// row carrying both numbers; |
| 388 |
|
// 2. otherwise an old line carrying a deletion is emitted alone, old track only; |
| 389 |
|
// 3. otherwise a new line carrying an insertion is emitted alone, new track only; |
| 390 |
|
// 4. otherwise the two lines differ without either being marked, which is a |
| 391 |
|
// rewrap: the words did not change but the lines did, so the pair is emitted |
| 392 |
|
// as a removed line followed by an added one, adjacent. |
| 393 |
|
// |
| 394 |
|
// Case 4 is the judgement call. The alternative was to emit the pair as one |
| 395 |
|
// context row and let the gutter show both numbers, which reads better but says |
| 396 |
|
// two lines are the same line when their text differs; in a table whose whole |
| 397 |
|
// contract is "the number in the gutter is the number in the file", that is the |
| 398 |
|
// wrong lie to tell. The alternative to case 2 before 3 — pairing a marked old |
| 399 |
|
// line with a marked new line on one row — was rejected because it re-invents a |
| 400 |
|
// correspondence the differ deliberately did not compute. |
| 401 |
|
// |
| 402 |
|
// Whatever the interleaving does, a row's number always comes from its own |
| 403 |
|
// side's LineWords, so an imperfect order costs readability and never |
| 404 |
|
// correctness. |
| 405 |
652 |
func mergeLineWords(old, nw []prosediff.LineWords, blk *rowBlock) []diffRow { |
| 406 |
652 |
var rows []diffRow |
| 407 |
652 |
delRow := func(l prosediff.LineWords) diffRow { |
| 408 |
523 |
return diffRow{Kind: rowDelete, Block: blk, OldNum: l.Line, Spans: l.Spans} |
| 409 |
523 |
} |
| 410 |
800 |
insRow := func(l prosediff.LineWords) diffRow { |
| 411 |
800 |
return diffRow{Kind: rowInsert, Block: blk, NewNum: l.Line, Spans: l.Spans} |
| 412 |
800 |
} |
| 413 |
|
|
| 414 |
652 |
i, j := 0, 0 |
| 415 |
2216 |
for i < len(old) && j < len(nw) { |
| 416 |
2216 |
o, n := old[i], nw[j] |
| 417 |
2216 |
switch { |
| 418 |
|
case !marked(o.Spans, prosediff.OpDelete) && !marked(n.Spans, prosediff.OpInsert) && |
| 419 |
1351 |
lineText(o) == lineText(n): |
| 420 |
1351 |
rows = append(rows, diffRow{ |
| 421 |
1351 |
Kind: rowEqual, Block: blk, |
| 422 |
1351 |
OldNum: o.Line, NewNum: n.Line, Spans: n.Spans, |
| 423 |
1351 |
}) |
| 424 |
1351 |
i++ |
| 425 |
1351 |
j++ |
| 426 |
316 |
case marked(o.Spans, prosediff.OpDelete): |
| 427 |
316 |
rows = append(rows, delRow(o)) |
| 428 |
316 |
i++ |
| 429 |
454 |
case marked(n.Spans, prosediff.OpInsert): |
| 430 |
454 |
rows = append(rows, insRow(n)) |
| 431 |
454 |
j++ |
| 432 |
95 |
default: |
| 433 |
95 |
rows = append(rows, delRow(o), insRow(n)) |
| 434 |
95 |
i++ |
| 435 |
95 |
j++ |
| 436 |
|
} |
| 437 |
|
} |
| 438 |
652 |
for ; i < len(old); i++ { |
| 439 |
112 |
rows = append(rows, delRow(old[i])) |
| 440 |
112 |
} |
| 441 |
652 |
for ; j < len(nw); j++ { |
| 442 |
251 |
rows = append(rows, insRow(nw[j])) |
| 443 |
251 |
} |
| 444 |
652 |
return rows |
| 445 |
|
} |
| 446 |
|
|
| 447 |
|
// regionRows is the honest fallback: one row for the old side of the block and |
| 448 |
|
// one for the new, each labelled by the line range it covers rather than by a |
| 449 |
|
// line number. |
| 450 |
|
// |
| 451 |
|
// It fires for a block rewritten past the point where inline marks stay |
| 452 |
|
// readable — the Phase 0 verdict's one review in eight — and for a block whose |
| 453 |
|
// word script could not be spread back over its lines. In both cases a per-line |
| 454 |
|
// number would be a guess, and the design's rule is that a range the reader can |
| 455 |
|
// check beats a number they cannot. |
| 456 |
149 |
func regionRows(c prosediff.BlockChange, blk *rowBlock) []diffRow { |
| 457 |
149 |
return []diffRow{ |
| 458 |
149 |
{ |
| 459 |
149 |
Kind: rowDelete, Block: blk, Region: true, |
| 460 |
149 |
OldNum: c.Old.StartLine, OldEnd: c.Old.EndLine, |
| 461 |
149 |
Spans: sideSpans(c.Words, true), |
| 462 |
149 |
}, |
| 463 |
149 |
{ |
| 464 |
149 |
Kind: rowInsert, Block: blk, Region: true, |
| 465 |
149 |
NewNum: c.New.StartLine, NewEnd: c.New.EndLine, |
| 466 |
149 |
Spans: sideSpans(c.Words, false), |
| 467 |
149 |
}, |
| 468 |
149 |
} |
| 469 |
149 |
} |
| 470 |
|
|
| 471 |
|
// sideSpans keeps one side of a word script: the old side keeps equal and |
| 472 |
|
// deleted words, the new side keeps equal and inserted ones. Both keep their |
| 473 |
|
// marks, so each region row is a readable paragraph that also shows what moved. |
| 474 |
|
// |
| 475 |
|
// The separator of a dropped span moves onto the next kept one. Span.Space says |
| 476 |
|
// a space preceded that span *in the combined rendering*, and an insertion that |
| 477 |
|
// directly replaces a deletion carries Space=false because the deletion in front |
| 478 |
|
// of it already carried the space — prosediff's own note on the matter. Split |
| 479 |
|
// onto one side that deletion is gone, and without this the row would read "the |
| 480 |
|
// committeerejected the budget". |
| 481 |
298 |
func sideSpans(spans []prosediff.Span, old bool) []prosediff.Span { |
| 482 |
298 |
out := make([]prosediff.Span, 0, len(spans)) |
| 483 |
298 |
space := false |
| 484 |
876 |
for _, s := range spans { |
| 485 |
876 |
switch { |
| 486 |
|
case s.Op == prosediff.OpEqual, |
| 487 |
|
old && s.Op == prosediff.OpDelete, |
| 488 |
|
!old && s.Op == prosediff.OpInsert: |
| 489 |
239 |
default: |
| 490 |
239 |
space = space || s.Space |
| 491 |
239 |
continue |
| 492 |
|
} |
| 493 |
637 |
s.Space = s.Space || space |
| 494 |
637 |
space = false |
| 495 |
637 |
out = append(out, s) |
| 496 |
|
} |
| 497 |
298 |
return out |
| 498 |
|
} |
| 499 |
|
|
| 500 |
|
// groupRows cuts the row stream into <tbody> groups, collapsing long runs of |
| 501 |
|
// context. |
| 502 |
|
// |
| 503 |
|
// A run is delimited by blocks, not by rows: a block is foldable when it is |
| 504 |
|
// unchanged and carries no threads, and then all of its rows fold, its notes |
| 505 |
|
// row included. Keying on the row kind instead would let a changed block's |
| 506 |
|
// compose form — which is not a ph-r-eq row but sits between two of them — |
| 507 |
|
// either break every run or be swallowed into a fold it does not belong to. |
| 508 |
34 |
func groupRows(rows []diffRow) []rowGroup { |
| 509 |
34 |
var groups []rowGroup |
| 510 |
34 |
var plain []diffRow |
| 511 |
34 |
folds := 0 |
| 512 |
34 |
|
| 513 |
43 |
flush := func() { |
| 514 |
43 |
if len(plain) > 0 { |
| 515 |
43 |
groups = append(groups, rowGroup{Rows: plain}) |
| 516 |
43 |
plain = nil |
| 517 |
43 |
} |
| 518 |
|
} |
| 519 |
|
|
| 520 |
103 |
for i := 0; i < len(rows); { |
| 521 |
103 |
if !foldable(rows[i]) { |
| 522 |
68 |
plain = append(plain, rows[i]) |
| 523 |
68 |
i++ |
| 524 |
68 |
continue |
| 525 |
|
} |
| 526 |
35 |
j := i |
| 527 |
194 |
for j < len(rows) && foldable(rows[j]) { |
| 528 |
194 |
j++ |
| 529 |
194 |
} |
| 530 |
35 |
run := rows[i:j] |
| 531 |
35 |
i = j |
| 532 |
35 |
|
| 533 |
35 |
lo, hi, ok := foldWindow(run) |
| 534 |
35 |
if !ok { |
| 535 |
26 |
plain = append(plain, run...) |
| 536 |
26 |
continue |
| 537 |
|
} |
| 538 |
9 |
plain = append(plain, run[:lo]...) |
| 539 |
9 |
flush() |
| 540 |
9 |
|
| 541 |
9 |
hidden := make([]diffRow, hi-lo) |
| 542 |
9 |
lines := 0 |
| 543 |
73 |
for k, row := range run[lo:hi] { |
| 544 |
73 |
row.Folded = true |
| 545 |
73 |
hidden[k] = row |
| 546 |
73 |
if row.Kind != rowNotes { |
| 547 |
59 |
lines++ |
| 548 |
59 |
} |
| 549 |
|
} |
| 550 |
9 |
groups = append(groups, rowGroup{Fold: true, Index: folds, Hidden: lines, Rows: hidden}) |
| 551 |
9 |
folds++ |
| 552 |
9 |
plain = append(plain, run[hi:]...) |
| 553 |
|
} |
| 554 |
34 |
flush() |
| 555 |
34 |
return groups |
| 556 |
|
} |
| 557 |
|
|
| 558 |
|
// foldable reports whether a row may be hidden inside a fold. A block someone |
| 559 |
|
// has commented on never is: it stopped being context the moment somebody had |
| 560 |
|
// something to say about it, and a comment behind a closed fold is a comment |
| 561 |
|
// nobody reads. |
| 562 |
324 |
func foldable(row diffRow) bool { |
| 563 |
324 |
return row.Block.Context && !row.Block.HasThreads |
| 564 |
324 |
} |
| 565 |
|
|
| 566 |
|
// foldWindow picks the half-open range of a context run to hide: everything |
| 567 |
|
// between the first foldKeepEdge lines and the last foldKeepEdge lines, which |
| 568 |
|
// keeps a couple of lines of orientation on each side of the gap. The window is |
| 569 |
|
// measured in rows so that a notes row falling inside the gap is hidden with |
| 570 |
|
// it, and counted in lines so that both gates judge the same thing the label |
| 571 |
|
// will report. |
| 572 |
|
// |
| 573 |
|
// The rule the window has to respect is that a block's notes row is hidden |
| 574 |
|
// exactly when the whole block is. A compose form for lines nobody can see is a |
| 575 |
|
// control on nothing — the same reason a move-in renders its text — and a |
| 576 |
|
// compose form hidden away from lines that *are* visible is worse still, |
| 577 |
|
// because the block looks uncommentable. Only the opening edge can break it: a |
| 578 |
|
// window that starts inside a block would keep that block's first lines visible |
| 579 |
|
// and swallow the trailer that follows its last one, so in that case the window |
| 580 |
|
// opens after the trailer instead. The closing edge cannot break it, because hi |
| 581 |
|
// is a line row by construction and a trailer always directly follows its |
| 582 |
|
// block's last line. |
| 583 |
35 |
func foldWindow(run []diffRow) (lo, hi int, ok bool) { |
| 584 |
35 |
var lines []int |
| 585 |
194 |
for i, row := range run { |
| 586 |
194 |
if row.Kind != rowNotes { |
| 587 |
157 |
lines = append(lines, i) |
| 588 |
157 |
} |
| 589 |
|
} |
| 590 |
35 |
if len(lines) < foldMinRun || len(lines)-2*foldKeepEdge < foldMinHidden { |
| 591 |
26 |
return 0, 0, false |
| 592 |
26 |
} |
| 593 |
9 |
lo, hi = lines[foldKeepEdge], lines[len(lines)-foldKeepEdge] |
| 594 |
9 |
|
| 595 |
9 |
if !run[lo].Start { |
| 596 |
8 |
for k := lo; k < hi && !run[k].Start; k++ { |
| 597 |
8 |
if run[k].Kind == rowNotes { |
| 598 |
2 |
lo = k + 1 |
| 599 |
2 |
break |
| 600 |
|
} |
| 601 |
|
} |
| 602 |
|
} |
| 603 |
|
// Giving that trailer back can leave too little to be worth a click, so the |
| 604 |
|
// gate is asked again about what is actually left. |
| 605 |
9 |
hidden := 0 |
| 606 |
101 |
for _, i := range lines { |
| 607 |
101 |
if i >= lo && i < hi { |
| 608 |
59 |
hidden++ |
| 609 |
59 |
} |
| 610 |
|
} |
| 611 |
9 |
if hidden < foldMinHidden { |
| 612 |
0 |
return 0, 0, false |
| 613 |
0 |
} |
| 614 |
9 |
return lo, hi, true |
| 615 |
|
} |
| 616 |
|
|
| 617 |
|
// blockLines is a block's source lines, with the block's whole text as the one |
| 618 |
|
// line of a block that records none. Nothing in the segmenter produces such a |
| 619 |
|
// block today; this is what keeps that assumption from silently deleting a |
| 620 |
|
// block from the page if one ever does. |
| 621 |
14909 |
func blockLines(src *prosediff.Block) []string { |
| 622 |
14909 |
if len(src.Lines) > 0 { |
| 623 |
14909 |
return src.Lines |
| 624 |
14909 |
} |
| 625 |
0 |
return []string{src.Text} |
| 626 |
|
} |
| 627 |
|
|
| 628 |
|
// plainSpans is a line with no word-level marks on it, as a one-span script, so |
| 629 |
|
// every row's content has the same shape whatever produced it. |
| 630 |
18851 |
func plainSpans(text string) []prosediff.Span { |
| 631 |
18851 |
return []prosediff.Span{{Op: prosediff.OpEqual, Text: text}} |
| 632 |
18851 |
} |
| 633 |
|
|
| 634 |
|
// marked reports whether a line's share of a word script carries an op. |
| 635 |
5530 |
func marked(spans []prosediff.Span, op prosediff.Op) bool { |
| 636 |
6048 |
for _, s := range spans { |
| 637 |
6048 |
if s.Op == op { |
| 638 |
1540 |
return true |
| 639 |
1540 |
} |
| 640 |
|
} |
| 641 |
3990 |
return false |
| 642 |
|
} |
| 643 |
|
|
| 644 |
|
// lineText rebuilds a spread line's text for comparison. |
| 645 |
|
// |
| 646 |
|
// It compares reconstructed tokens rather than the source lines because that is |
| 647 |
|
// what "the same line" has to mean here: the tokenizer is what the diff ran on, |
| 648 |
|
// so two lines differing only in how much whitespace separates their words are |
| 649 |
|
// the same line to every part of this package. |
| 650 |
2892 |
func lineText(l prosediff.LineWords) string { |
| 651 |
2892 |
var b strings.Builder |
| 652 |
2892 |
for i, s := range l.Spans { |
| 653 |
2892 |
if s.Space && i > 0 { |
| 654 |
0 |
b.WriteByte(' ') |
| 655 |
0 |
} |
| 656 |
2892 |
b.WriteString(s.Text) |
| 657 |
|
} |
| 658 |
2892 |
return b.String() |
| 659 |
|
} |