···14321432 Pull *models.Pull2
1433143314341434 Backlinks []models.RichReferenceLink
14351435- Comments []models.Comment
14351435+ Comments []*models.Comment
14361436 Commits []types.Commit // all commits between <target>..<pr/head>
1437143714381438 LabelDefs map[string]*models.LabelDefinition
···14641464 ErrorMsg string
14651465}
1466146614671467+// DiffLine is one row of a unified (inline) diff. Old/New are 1-based line numbers into the
14681468+// base/head blob, or -1 when that side has no line here. Content is pre-rendered, safe HTML.
14691469+type DiffLine struct {
14701470+ Op string // " " context, "-" removed, "+" added
14711471+ Old int
14721472+ New int
14731473+ Content template.HTML
14741474+}
14751475+14761476+// DiffCell is one side of a side-by-side row. Kind is "ctx", "del", "add", or "empty" (a
14771477+// blank padding cell). Num is the 1-based line number, or -1 when empty.
14781478+type DiffCell struct {
14791479+ Kind string
14801480+ Num int
14811481+ Content template.HTML
14821482+}
14831483+14841484+// DiffRow is one side-by-side row: the left (base) and right (head) cells.
14851485+type DiffRow struct {
14861486+ Left DiffCell
14871487+ Right DiffCell
14881488+}
14891489+14901490+// DiffHunk holds a hunk's rows; exactly one of Lines (unified) / Rows (split) is populated,
14911491+// depending on PullDiffFragmentParams.Split.
14921492+type DiffHunk struct {
14931493+ Lines []DiffLine
14941494+ Rows []DiffRow
14951495+}
14961496+14971497+// DiffFile is one changed file. Note is set (and Hunks empty) for binary/submodule files.
14981498+type DiffFile struct {
14991499+ Path string
15001500+ Note string
15011501+ Hunks []DiffHunk
15021502+}
15031503+14671504type PullDiffFragmentParams struct {
14681468- Diff string
15051505+ Files []DiffFile
15061506+ Split bool // side-by-side when true, unified when false
14691507}
1470150814711509func (p *Pages) PullDiff(w io.Writer, params PullDiffParams) error {
···11package pulls
2233import (
44- "strings"
44+ "html/template"
55 "testing"
6677 gitmirrorv1 "tangled.org/core/gitmirror/proto/gen"
···1010func u32(v uint32) *uint32 { return &v }
11111212// lines returns n lines named l0..l{n-1}, optionally overriding some by index.
1313-func lines(n int, override map[int]string) []string {
1414- out := make([]string, n)
1313+func lines(n int, override map[int]string) []template.HTML {
1414+ out := make([]template.HTML, n)
1515 for i := range out {
1616 if v, ok := override[i]; ok {
1717- out[i] = v
1717+ out[i] = template.HTML(v)
1818 } else {
1919- out[i] = "l" + string(rune('0'+i%10))
1919+ out[i] = template.HTML("l" + string(rune('0'+i%10)))
2020 }
2121 }
2222 return out
···155155 // Base: 20 lines; delete base line 5. Head: base with line 5 removed and a new line
156156 // inserted at head index 8 (a few lines after the deletion point).
157157 base := lines(20, nil)
158158- head := make([]string, 0, 20)
159159- head = append(head, base[:5]...) // head 0..4 == base 0..4
160160- head = append(head, base[6:9]...) // head 5..7 == base 6..8
161161- head = append(head, "INSERTED") // head 8 (new)
162162- head = append(head, base[9:]...) // head 9.. == base 9..
158158+ head := make([]template.HTML, 0, 20)
159159+ head = append(head, base[:5]...) // head 0..4 == base 0..4
160160+ head = append(head, base[6:9]...) // head 5..7 == base 6..8
161161+ head = append(head, template.HTML("INSERTED")) // head 8 (new)
162162+ head = append(head, base[9:]...) // head 9.. == base 9..
163163164164 hunks := []*gitmirrorv1.Hunk{
165165 hunk(&gitmirrorv1.LinePair{Lhs: u32(5)}), // delete base line 5
···204204 }
205205}
206206207207-func TestRenderInline_Ordering(t *testing.T) {
208208- base := lines(10, map[int]string{5: "old"})
209209- head := lines(10, map[int]string{5: "new"})
210210- h := buildHunks(base, head, []*gitmirrorv1.Hunk{hunk(&gitmirrorv1.LinePair{Lhs: u32(5), Rhs: u32(5)})})[0]
211211-212212- var b strings.Builder
213213- renderInline(&b, h, base, head)
214214- out := b.String()
215215-216216- // "old" (removed, LHS) must appear before "new" (added, RHS).
217217- io, in := strings.Index(out, "old"), strings.Index(out, "new")
218218- if io < 0 || in < 0 || io > in {
219219- t.Fatalf("inline ordering wrong (old=%d new=%d):\n%s", io, in, out)
220220- }
221221- // Removed line carries '-', added line carries '+', context ' '.
222222- for line := range strings.SplitSeq(strings.TrimRight(out, "\n"), "\n") {
223223- switch {
224224- case strings.Contains(line, "old") && !strings.Contains(line, "- old"):
225225- t.Fatalf("removed line missing '-' marker: %q", line)
226226- case strings.Contains(line, "new") && !strings.Contains(line, "+ new"):
227227- t.Fatalf("added line missing '+' marker: %q", line)
228228- }
229229- }
230230-}
231231-232232-func TestRenderInline_MergedKeepsInteriorContextOnce(t *testing.T) {
233233- // Two changes 5 lines apart merge into one hunk. The interior context line (index 7,
234234- // "l7") must appear exactly once, with no +/- marker (not duplicated into both blocks).
235235- base := lines(20, map[int]string{5: "old5", 10: "old10"})
236236- head := lines(20, map[int]string{5: "new5", 10: "new10"})
237237- hunks := []*gitmirrorv1.Hunk{
238238- hunk(&gitmirrorv1.LinePair{Lhs: u32(5), Rhs: u32(5)}),
239239- hunk(&gitmirrorv1.LinePair{Lhs: u32(10), Rhs: u32(10)}),
240240- }
241241- built := buildHunks(base, head, hunks)
242242- if len(built) != 1 {
243243- t.Fatalf("want 1 merged hunk, got %d", len(built))
244244- }
245245-246246- var b strings.Builder
247247- renderInline(&b, built[0], base, head)
248248- out := b.String()
249249-250250- if n := strings.Count(out, "l7"); n != 1 {
251251- t.Fatalf("interior context 'l7' should appear once, got %d:\n%s", n, out)
252252- }
253253- for line := range strings.SplitSeq(strings.TrimRight(out, "\n"), "\n") {
254254- if strings.Contains(line, "l7") && (strings.Contains(line, "- l7") || strings.Contains(line, "+ l7")) {
255255- t.Fatalf("interior context should have no +/- marker: %q", line)
256256- }
257257- }
258258- for _, want := range []string{"- old5", "+ new5", "- old10", "+ new10"} {
259259- if !strings.Contains(out, want) {
260260- t.Fatalf("missing %q in:\n%s", want, out)
261261- }
262262- }
263263-}
+77-79
appview/pulls/pull2.go
···44 "context"
55 "errors"
66 "fmt"
77+ "html/template"
78 "io"
89 "net/http"
910 "slices"
···198199 var params pages.PullDiffParams
199200 params.Pull = pull
200201 params.Version = version
202202+ params.Comments = comments
201203 defer s.pages.PullDiff(w, params)
202204203205 // 1. resolve target branch -> (branch, commit)
···232234// fileDiff pairs a streamed FileDiff with the line content of its base/head blobs.
233235type fileDiff struct {
234236 diff *gitmirrorv1.FileDiff
235235- baseLines []string // lines of the base (lhs) blob; nil for binary/submodule/absent
236236- headLines []string // lines of the head (rhs) blob; nil for binary/submodule/absent
237237+ baseLines []template.HTML // lines of the base (lhs) blob; nil for binary/submodule/absent
238238+ headLines []template.HTML // lines of the head (rhs) blob; nil for binary/submodule/absent
237239}
238240239241// htmx fragment. render diff between commits
···301303 if err != nil {
302304 return err
303305 }
304304- f.baseLines = strings.Split(strings.TrimSuffix(string(baseBlob), "\n"), "\n")
305305- f.headLines = strings.Split(strings.TrimSuffix(string(headBlob), "\n"), "\n")
306306+307307+ // TODO: highlight each blobs
308308+ // pass lhs_positions & rhs_positions so highlighter can apply diff highlights
309309+ for line := range strings.SplitSeq(strings.TrimSuffix(string(baseBlob), "\n"), "\n") {
310310+ f.baseLines = append(f.baseLines, template.HTML(fmt.Sprintf("<div><span>%s</span></div>", template.HTMLEscapeString(line))))
311311+ }
312312+ for line := range strings.SplitSeq(strings.TrimSuffix(string(headBlob), "\n"), "\n") {
313313+ f.headLines = append(f.headLines, template.HTML(fmt.Sprintf("<div><span>%s</span></div>", template.HTMLEscapeString(line))))
314314+ }
315315+316316+ // f.baseLines = strings.Split(strings.TrimSuffix(string(baseBlob), "\n"), "\n")
317317+ // f.headLines = strings.Split(strings.TrimSuffix(string(headBlob), "\n"), "\n")
306318 return nil
307319 })
308320 }
···310322 panic(err)
311323 }
312324313313- // <div><span>code</div></div>
314314-315315- // c. render each file's diff, following difftastic's display layout
316316- // (github.com/Wilfred/difftastic src/display): side-by-side by default, inline
317317- // when ?view=unified. Word/token highlighting and line wrapping are omitted.
318318- var b strings.Builder
325325+ // c. build the render model, following difftastic's display layout
326326+ // (github.com/Wilfred/difftastic src/display): side-by-side by default, inline when
327327+ // ?view=unified. All markup/styling lives in the template; here we only assemble data.
328328+ params.Split = !unified
319329 for _, f := range files {
320320- b.WriteString(f.diff.GetRhsSrc().GetPath())
321321- b.WriteByte('\n')
330330+ df := pages.DiffFile{Path: f.diff.GetRhsSrc().GetPath()}
322331323332 if isBinaryOrSubmodule(f.diff.GetLhsSrc()) || isBinaryOrSubmodule(f.diff.GetRhsSrc()) {
324324- b.WriteString(" (binary or submodule)\n\n")
333333+ df.Note = "binary or submodule"
334334+ params.Files = append(params.Files, df)
325335 continue
326336 }
327337328338 for _, h := range buildHunks(f.baseLines, f.headLines, f.diff.Hunks) {
329329- b.WriteString("@@\n")
339339+ var dh pages.DiffHunk
330340 if unified {
331331- renderInline(&b, h, f.baseLines, f.headLines)
341341+ dh.Lines = buildInlineLines(h, f.baseLines, f.headLines)
332342 } else {
333333- renderSideBySide(&b, h, f.baseLines, f.headLines)
343343+ dh.Rows = buildSplitRows(h, f.baseLines, f.headLines)
334344 }
345345+ df.Hunks = append(df.Hunks, dh)
335346 }
336336- b.WriteByte('\n')
347347+ params.Files = append(params.Files, df)
337348 }
338338- params.Diff = b.String()
339349}
340350341351// numContextLines is difftastic's default (`num_context_lines`); pad_before/pad_after add
···373383// gitmirror's Hunk.Lines are difftastic's novel line pairs; we lack its token-level
374384// MatchedPos, but a line-granular diff's unchanged lines form a 1:1 bijection, which stands
375385// in for difftastic's opposite_positions map (buildOpposites).
376376-func buildHunks(baseLines, headLines []string, hunks []*gitmirrorv1.Hunk) []displayHunk {
386386+func buildHunks(baseLines, headLines []template.HTML, hunks []*gitmirrorv1.Hunk) []displayHunk {
377387 oppLhs, oppRhs, maxLhs, maxRhs := buildOpposites(baseLines, headLines, hunks)
378388379389 var flat []linePair
···412422// specialized to a line-granular diff): every displayed line as a pair, plus a parallel
413423// `changed` flag for pairs that came from a gitmirror hunk. Unchanged lines are a 1:1
414424// bijection, so the two cursors advance together across gaps.
415415-func alignFile(baseLines, headLines []string, hunks []*gitmirrorv1.Hunk) (pairs []linePair, changed []bool) {
425425+func alignFile(baseLines, headLines []template.HTML, hunks []*gitmirrorv1.Hunk) (pairs []linePair, changed []bool) {
416426 li, ri := 0, 0
417427 emitContext := func(n int) {
418428 for k := range n {
···519529// between unchanged base and head lines. It walks the gitmirror hunks with two cursors —
520530// unchanged runs advance both sides together — recording each unchanged line's counterpart.
521531// maxLhs/maxRhs are the 0-based last line numbers of each blob.
522522-func buildOpposites(baseLines, headLines []string, hunks []*gitmirrorv1.Hunk) (oppLhs, oppRhs map[int]int, maxLhs, maxRhs int) {
532532+func buildOpposites(baseLines, headLines []template.HTML, hunks []*gitmirrorv1.Hunk) (oppLhs, oppRhs map[int]int, maxLhs, maxRhs int) {
523533 oppLhs, oppRhs = map[int]int{}, map[int]int{}
524534 li, ri := 0, 0
525535···935945 }
936946}
937947938938-// renderSideBySide lays out a hunk's rows in two columns:
939939-//
940940-// [lhsNum][- ]lhsContent [rhsNum][+ ]rhsContent
941941-//
942942-// A changed side gets a `-` (left) or `+` (right) marker; context gets a space. An absent
943943-// side (-1) renders as a blank gutter, blank marker, and empty content.
944944-func renderSideBySide(b *strings.Builder, h displayHunk, baseLines, headLines []string) {
945945- const spacer = " "
946946- contentWidth := 0
947947- for _, r := range h.rows {
948948- if r.lhs >= 0 && len(baseLines[r.lhs]) > contentWidth {
949949- contentWidth = len(baseLines[r.lhs])
950950- }
948948+// dispNum maps a 0-based line number to its 1-based display value, passing through the -1
949949+// absent sentinel unchanged.
950950+func dispNum(n int) int {
951951+ if n < 0 {
952952+ return -1
951953 }
954954+ return n + 1
955955+}
956956+957957+// buildSplitRows turns a hunk into side-by-side rows (difftastic's SideBySideShowBoth): each
958958+// row always has a left (base) and right (head) cell. Unchanged rows show context on both
959959+// sides; a changed row shows a deletion on the left and/or an addition on the right, padding
960960+// the missing side with an empty cell.
961961+func buildSplitRows(h displayHunk, baseLines, headLines []template.HTML) []pages.DiffRow {
962962+ rows := make([]pages.DiffRow, 0, len(h.rows))
952963 for _, r := range h.rows {
953953- lhs, rhs := "", ""
954954- if r.lhs >= 0 {
955955- lhs = baseLines[r.lhs]
964964+ var row pages.DiffRow
965965+ if !r.changed {
966966+ row.Left = pages.DiffCell{Kind: "ctx", Num: dispNum(r.lhs), Content: lineAt(baseLines, r.lhs)}
967967+ row.Right = pages.DiffCell{Kind: "ctx", Num: dispNum(r.rhs), Content: lineAt(headLines, r.rhs)}
968968+ } else {
969969+ if r.lhs >= 0 {
970970+ row.Left = pages.DiffCell{Kind: "del", Num: dispNum(r.lhs), Content: lineAt(baseLines, r.lhs)}
971971+ } else {
972972+ row.Left = pages.DiffCell{Kind: "empty", Num: -1}
973973+ }
974974+ if r.rhs >= 0 {
975975+ row.Right = pages.DiffCell{Kind: "add", Num: dispNum(r.rhs), Content: lineAt(headLines, r.rhs)}
976976+ } else {
977977+ row.Right = pages.DiffCell{Kind: "empty", Num: -1}
978978+ }
956979 }
957957- if r.rhs >= 0 {
958958- rhs = headLines[r.rhs]
959959- }
960960- lmark := mark(r.changed && r.lhs >= 0, '-')
961961- rmark := mark(r.changed && r.rhs >= 0, '+')
962962- fmt.Fprintf(b, "%s%s%-*s%s|%s%s%s\n",
963963- gutter(r.lhs), lmark, contentWidth, lhs, spacer, gutter(r.rhs), rmark, rhs)
980980+ rows = append(rows, row)
964981 }
982982+ return rows
965983}
966984967967-// renderInline lays out a hunk as a unified diff: context lines shown once (no marker),
968968-// and each contiguous run of changed rows printed as all removed (LHS, `-`) then all added
969969-// (RHS, `+`). Interior context between merged changes stays a single unmarked line.
970970-func renderInline(b *strings.Builder, h displayHunk, baseLines, headLines []string) {
985985+// buildInlineLines turns a hunk into unified-diff lines: context lines shown once (no
986986+// marker), and each contiguous run of changed rows emitted as all removed (LHS, "-") then
987987+// all added (RHS, "+"). Interior context between merged changes stays a single line. Removed
988988+// lines carry only the old number, added lines only the new number.
989989+func buildInlineLines(h displayHunk, baseLines, headLines []template.HTML) []pages.DiffLine {
990990+ var out []pages.DiffLine
971991 for i := 0; i < len(h.rows); {
972992 r := h.rows[i]
973993 if !r.changed {
974994 if r.lhs >= 0 {
975975- fmt.Fprintf(b, "%s%s%s%s\n", gutter(r.lhs), gutter(r.rhs), mark(false, ' '), baseLines[r.lhs])
995995+ out = append(out, pages.DiffLine{Op: " ", Old: dispNum(r.lhs), New: dispNum(r.rhs), Content: lineAt(baseLines, r.lhs)})
976996 }
977997 i++
978998 continue
979999 }
980980- // Contiguous run of changed rows: all removed, then all added.
9811000 j := i
9821001 for j < len(h.rows) && h.rows[j].changed {
9831002 j++
9841003 }
985985- // Removed lines carry only the lhs number; the rhs gutter is blank (the paired rhs
986986- // belongs to the added line printed below, not to this removed line).
9871004 for _, cr := range h.rows[i:j] {
9881005 if cr.lhs >= 0 {
989989- fmt.Fprintf(b, "%s%s%s%s\n", gutter(cr.lhs), gutter(-1), mark(true, '-'), baseLines[cr.lhs])
10061006+ out = append(out, pages.DiffLine{Op: "-", Old: dispNum(cr.lhs), New: -1, Content: lineAt(baseLines, cr.lhs)})
9901007 }
9911008 }
992992- // Added lines carry only the rhs number; the lhs gutter is blank.
9931009 for _, cr := range h.rows[i:j] {
9941010 if cr.rhs >= 0 {
995995- fmt.Fprintf(b, "%s%s%s%s\n", gutter(-1), gutter(cr.rhs), mark(true, '+'), headLines[cr.rhs])
10111011+ out = append(out, pages.DiffLine{Op: "+", Old: -1, New: dispNum(cr.rhs), Content: lineAt(headLines, cr.rhs)})
9961012 }
9971013 }
9981014 i = j
9991015 }
10161016+ return out
10001017}
1001101810021002-// mark returns the 2-char marker cell placed between the line-number gutter and content:
10031003-// "<sign> " when set, " " otherwise.
10041004-func mark(set bool, sign byte) string {
10051005- if set {
10061006- return string(sign) + " "
10191019+// lineAt returns lines[n], or "" when n is the -1 absent sentinel or out of range.
10201020+func lineAt(lines []template.HTML, n int) template.HTML {
10211021+ if n < 0 || n >= len(lines) {
10221022+ return ""
10071023 }
10081008- return " "
10091009-}
10101010-10111011-// gutter renders a 1-based line number right-aligned to width w plus a trailing space, or
10121012-// blanks when the line is absent (-1).
10131013-func gutter(n int) string {
10141014- if n < 0 {
10151015- return strings.Repeat(" ", 5)
10161016- }
10171017- return fmt.Sprintf("%*d ", 4, n+1)
10241024+ return lines[n]
10181025}
1019102610201027// getBlob streams a blob's bytes from gitmirror by OID and concatenates them.
···10561063 }
10571064 }
10581065 return true
10591059-}
10601060-10611061-// splitLines splits blob bytes into lines, dropping the trailing empty element
10621062-// produced by a final newline.
10631063-func splitLines(b []byte) []string {
10641064- if len(b) == 0 {
10651065- return nil
10661066- }
10671067- return strings.Split(strings.TrimSuffix(string(b), "\n"), "\n")
10681066}
1069106710701068// htmx fragment. render interdiff between changes