Monorepo for Tangled
0

Configure Feed

Select the types of activity you want to include in your feed.

slop

Signed-off-by: Seongmin Lee <git@boltless.me>

Seongmin Lee (Jul 2, 2026, 12:51 PM +0900) 5f64372a f1c231da

+309 -152
+40 -2
appview/pages/pages.go
··· 1432 1432 Pull *models.Pull2 1433 1433 1434 1434 Backlinks []models.RichReferenceLink 1435 - Comments []models.Comment 1435 + Comments []*models.Comment 1436 1436 Commits []types.Commit // all commits between <target>..<pr/head> 1437 1437 1438 1438 LabelDefs map[string]*models.LabelDefinition ··· 1464 1464 ErrorMsg string 1465 1465 } 1466 1466 1467 + // DiffLine is one row of a unified (inline) diff. Old/New are 1-based line numbers into the 1468 + // base/head blob, or -1 when that side has no line here. Content is pre-rendered, safe HTML. 1469 + type DiffLine struct { 1470 + Op string // " " context, "-" removed, "+" added 1471 + Old int 1472 + New int 1473 + Content template.HTML 1474 + } 1475 + 1476 + // DiffCell is one side of a side-by-side row. Kind is "ctx", "del", "add", or "empty" (a 1477 + // blank padding cell). Num is the 1-based line number, or -1 when empty. 1478 + type DiffCell struct { 1479 + Kind string 1480 + Num int 1481 + Content template.HTML 1482 + } 1483 + 1484 + // DiffRow is one side-by-side row: the left (base) and right (head) cells. 1485 + type DiffRow struct { 1486 + Left DiffCell 1487 + Right DiffCell 1488 + } 1489 + 1490 + // DiffHunk holds a hunk's rows; exactly one of Lines (unified) / Rows (split) is populated, 1491 + // depending on PullDiffFragmentParams.Split. 1492 + type DiffHunk struct { 1493 + Lines []DiffLine 1494 + Rows []DiffRow 1495 + } 1496 + 1497 + // DiffFile is one changed file. Note is set (and Hunks empty) for binary/submodule files. 1498 + type DiffFile struct { 1499 + Path string 1500 + Note string 1501 + Hunks []DiffHunk 1502 + } 1503 + 1467 1504 type PullDiffFragmentParams struct { 1468 - Diff string 1505 + Files []DiffFile 1506 + Split bool // side-by-side when true, unified when false 1469 1507 } 1470 1508 1471 1509 func (p *Pages) PullDiff(w io.Writer, params PullDiffParams) error {
+82 -3
appview/pages/templates/repo/pulls/fragments/diff.html
··· 1 1 {{ define "repo/pulls/fragments/diff" }} 2 - <div> 3 - <pre>{{ .Diff }}</pre> 4 - </div> 2 + <div class="flex flex-col gap-4"> 3 + {{ range .Files }} 4 + <div class="border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-800 drop-shadow-sm overflow-hidden"> 5 + <div class="px-3 py-2 font-mono text-sm border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40 overflow-x-auto whitespace-pre">{{ .Path }}</div> 6 + {{ if .Note }} 7 + <div class="px-3 py-4 text-center text-gray-400 dark:text-gray-500 text-sm">{{ .Note }}</div> 8 + {{ else if .Split }} 9 + {{ range .Hunks }}{{ template "pullDiffSplitHunk" . }}{{ end }} 10 + {{ else }} 11 + {{ range .Hunks }}{{ template "pullDiffUnifiedHunk" . }}{{ end }} 12 + {{ end }} 13 + </div> 14 + {{ end }} 15 + </div> 16 + {{ end }} 17 + 18 + {{ define "pullDiffUnifiedHunk" }} 19 + {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right px-2" -}} 20 + {{- $sepStyle := "border-r border-gray-200 dark:border-gray-700" -}} 21 + {{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}} 22 + {{- $contentStyle := "px-2 whitespace-pre" -}} 23 + {{- $rowStyle := "inline-flex w-full items-center" -}} 24 + {{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400" -}} 25 + {{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400" -}} 26 + {{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}} 27 + <div class="overflow-x-auto font-mono leading-normal text-sm"> 28 + <span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</span> 29 + <div class="inline-flex flex-col min-w-full"> 30 + {{- range .Lines -}} 31 + {{- $style := $ctxStyle -}} 32 + {{- if eq .Op "+" -}}{{- $style = $addStyle -}}{{- else if eq .Op "-" -}}{{- $style = $delStyle -}}{{- end -}} 33 + <span class="{{ $style }} {{ $rowStyle }}"> 34 + <span class="{{ $lineNrStyle }}">{{ if ge .Old 0 }}{{ .Old }}{{ end }}</span> 35 + <span class="{{ $lineNrStyle }} {{ $sepStyle }}">{{ if ge .New 0 }}{{ .New }}{{ end }}</span> 36 + <span class="{{ $opStyle }}">{{ .Op }}</span> 37 + <span class="{{ $contentStyle }}">{{ .Content }}</span> 38 + </span> 39 + {{- end -}} 40 + </div> 41 + </div> 42 + {{ end }} 43 + 44 + {{ define "pullDiffSplitHunk" }} 45 + <div class="grid grid-cols-2 divide-x divide-gray-200 dark:divide-gray-700 font-mono leading-normal text-sm"> 46 + <div class="overflow-x-auto"> 47 + <span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</span> 48 + <div class="inline-flex flex-col min-w-full"> 49 + {{- range .Rows -}}{{- template "pullDiffSplitCell" .Left -}}{{- end -}} 50 + </div> 51 + </div> 52 + <div class="overflow-x-auto"> 53 + <span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</span> 54 + <div class="inline-flex flex-col min-w-full"> 55 + {{- range .Rows -}}{{- template "pullDiffSplitCell" .Right -}}{{- end -}} 56 + </div> 57 + </div> 58 + </div> 59 + {{ end }} 60 + 61 + {{/* pullDiffSplitCell renders one side of a split row. Dot is a DiffCell. */}} 62 + {{ define "pullDiffSplitCell" }} 63 + {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right px-2 border-r border-gray-200 dark:border-gray-700" -}} 64 + {{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}} 65 + {{- $contentStyle := "px-2 whitespace-pre" -}} 66 + {{- $rowStyle := "inline-flex w-full items-center" -}} 67 + {{- if eq .Kind "empty" -}} 68 + <span class="bg-gray-200/30 dark:bg-gray-700/30 {{ $rowStyle }}"> 69 + <span class="{{ $lineNrStyle }}">&nbsp;</span> 70 + <span class="{{ $opStyle }}"></span> 71 + <span class="{{ $contentStyle }}">&nbsp;</span> 72 + </span> 73 + {{- else -}} 74 + {{- $style := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}} 75 + {{- $op := " " -}} 76 + {{- if eq .Kind "add" -}}{{- $style = "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400" -}}{{- $op = "+" -}}{{- end -}} 77 + {{- if eq .Kind "del" -}}{{- $style = "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400" -}}{{- $op = "-" -}}{{- end -}} 78 + <span class="{{ $style }} {{ $rowStyle }}"> 79 + <span class="{{ $lineNrStyle }}">{{ if ge .Num 0 }}{{ .Num }}{{ end }}</span> 80 + <span class="{{ $opStyle }}">{{ $op }}</span> 81 + <span class="{{ $contentStyle }}">{{ .Content }}</span> 82 + </span> 83 + {{- end -}} 5 84 {{ end }}
+77 -1
appview/pages/templates/repo/pulls/single.html
··· 45 45 diff tree 46 46 </div> 47 47 <div class="debug-section flex-1"> 48 - diff body 48 + <div class="space-y-4"> 49 + {{ template "block" }} 50 + {{ template "block" }} 51 + {{ template "block" }} 52 + {{ template "block" }} 53 + {{ template "block" }} 54 + {{ template "block" }} 55 + {{ template "block" }} 56 + {{ template "block" }} 57 + {{ template "block" }} 58 + {{ template "block" }} 59 + {{ template "block" }} 60 + {{ template "block" }} 61 + {{ template "block" }} 62 + {{ template "block" }} 63 + {{ template "block" }} 64 + {{ template "block" }} 65 + {{ template "block" }} 66 + </div> 49 67 <div hx-get="/{{ .Pull.RepoDid }}/pulls/test?base={{ $version.Base }}&head={{ $version.Head }}"> 50 68 loading... 51 69 </div> ··· 64 82 <p>repo content</p> 65 83 </div> 66 84 {{ end }} 85 + 86 + {{ define "block" }} 87 + <div class="bg-white dark:bg-gray-800 drop-shadow-sm rounded border border-gray-200 dark:border-gray-700 overflow-clip"> 88 + <details class="group w-full" open> 89 + <summary class="list-none cursor-pointer sticky top-0 group-open:border-b border-gray-200 dark:border-gray-700"> 90 + <div class="flex justify-between bg-white dark:bg-gray-800"> 91 + <div class="p-2 flex gap-2 items-center overflow-x-auto"> 92 + <span class="group-open:hidden inline">{{ i "chevron-right" "w-4 h-4" }}</span> 93 + <span class="hidden group-open:inline">{{ i "chevron-down" "w-4 h-4" }}</span> 94 + {{ template "repo/fragments/diffStatPill" (dict "Insertions" 27 "Deletions" 27) }} 95 + <span>api/tangled/cbor_gen.go</span> 96 + </div> 97 + <div> 98 + </div> 99 + </div> 100 + </summary> 101 + unified 102 + <table class="diff"> 103 + <colgroup> 104 + <col width="40" /> 105 + <col width="40" /> 106 + <col /> 107 + </colgroup> 108 + <tbody> 109 + <tr class="splitter"><td colspan="3">&middot;&middot;&middot;</td></tr> 110 + <tr class="deleted"> 111 + <td class="num">665</td> 112 + <td class="num"></td> 113 + <td class="content" data-diff-marker="-">func (t *CiDefs_Pipeline) MarshalCBOR(w io.Writer) error {</td> 114 + </tr> 115 + <tr class="added"> 116 + <td class="num"></td> 117 + <td class="num">665</td> 118 + <td class="content" data-diff-marker="+">func (t *CiPipeline) MarshalCBOR(w io.Writer) error {</td> 119 + </tr> 120 + </tbody> 121 + </table> 122 + split 123 + <table class="diff"> 124 + <colgroup> 125 + <col width="40" /> 126 + <col /> 127 + <col width="40" /> 128 + <col /> 129 + </colgroup> 130 + <tbody> 131 + <tr class="splitter"><td colspan="4">&middot;&middot;&middot;</td></tr> 132 + <tr class=""> 133 + <td class="deleted num">1</td> 134 + <td class="deleted content" data-diff-marker="-">thingsome</td> 135 + <td class="added num">1</td> 136 + <td class="added content" data-diff-marker="+">something</td> 137 + </tr> 138 + </tbody> 139 + </table> 140 + </details> 141 + </div> 142 + {{ end }}
+10 -67
appview/pulls/diff_hunks_test.go
··· 1 1 package pulls 2 2 3 3 import ( 4 - "strings" 4 + "html/template" 5 5 "testing" 6 6 7 7 gitmirrorv1 "tangled.org/core/gitmirror/proto/gen" ··· 10 10 func u32(v uint32) *uint32 { return &v } 11 11 12 12 // lines returns n lines named l0..l{n-1}, optionally overriding some by index. 13 - func lines(n int, override map[int]string) []string { 14 - out := make([]string, n) 13 + func lines(n int, override map[int]string) []template.HTML { 14 + out := make([]template.HTML, n) 15 15 for i := range out { 16 16 if v, ok := override[i]; ok { 17 - out[i] = v 17 + out[i] = template.HTML(v) 18 18 } else { 19 - out[i] = "l" + string(rune('0'+i%10)) 19 + out[i] = template.HTML("l" + string(rune('0'+i%10))) 20 20 } 21 21 } 22 22 return out ··· 155 155 // Base: 20 lines; delete base line 5. Head: base with line 5 removed and a new line 156 156 // inserted at head index 8 (a few lines after the deletion point). 157 157 base := lines(20, nil) 158 - head := make([]string, 0, 20) 159 - head = append(head, base[:5]...) // head 0..4 == base 0..4 160 - head = append(head, base[6:9]...) // head 5..7 == base 6..8 161 - head = append(head, "INSERTED") // head 8 (new) 162 - head = append(head, base[9:]...) // head 9.. == base 9.. 158 + head := make([]template.HTML, 0, 20) 159 + head = append(head, base[:5]...) // head 0..4 == base 0..4 160 + head = append(head, base[6:9]...) // head 5..7 == base 6..8 161 + head = append(head, template.HTML("INSERTED")) // head 8 (new) 162 + head = append(head, base[9:]...) // head 9.. == base 9.. 163 163 164 164 hunks := []*gitmirrorv1.Hunk{ 165 165 hunk(&gitmirrorv1.LinePair{Lhs: u32(5)}), // delete base line 5 ··· 204 204 } 205 205 } 206 206 207 - func TestRenderInline_Ordering(t *testing.T) { 208 - base := lines(10, map[int]string{5: "old"}) 209 - head := lines(10, map[int]string{5: "new"}) 210 - h := buildHunks(base, head, []*gitmirrorv1.Hunk{hunk(&gitmirrorv1.LinePair{Lhs: u32(5), Rhs: u32(5)})})[0] 211 - 212 - var b strings.Builder 213 - renderInline(&b, h, base, head) 214 - out := b.String() 215 - 216 - // "old" (removed, LHS) must appear before "new" (added, RHS). 217 - io, in := strings.Index(out, "old"), strings.Index(out, "new") 218 - if io < 0 || in < 0 || io > in { 219 - t.Fatalf("inline ordering wrong (old=%d new=%d):\n%s", io, in, out) 220 - } 221 - // Removed line carries '-', added line carries '+', context ' '. 222 - for line := range strings.SplitSeq(strings.TrimRight(out, "\n"), "\n") { 223 - switch { 224 - case strings.Contains(line, "old") && !strings.Contains(line, "- old"): 225 - t.Fatalf("removed line missing '-' marker: %q", line) 226 - case strings.Contains(line, "new") && !strings.Contains(line, "+ new"): 227 - t.Fatalf("added line missing '+' marker: %q", line) 228 - } 229 - } 230 - } 231 - 232 - func TestRenderInline_MergedKeepsInteriorContextOnce(t *testing.T) { 233 - // Two changes 5 lines apart merge into one hunk. The interior context line (index 7, 234 - // "l7") must appear exactly once, with no +/- marker (not duplicated into both blocks). 235 - base := lines(20, map[int]string{5: "old5", 10: "old10"}) 236 - head := lines(20, map[int]string{5: "new5", 10: "new10"}) 237 - hunks := []*gitmirrorv1.Hunk{ 238 - hunk(&gitmirrorv1.LinePair{Lhs: u32(5), Rhs: u32(5)}), 239 - hunk(&gitmirrorv1.LinePair{Lhs: u32(10), Rhs: u32(10)}), 240 - } 241 - built := buildHunks(base, head, hunks) 242 - if len(built) != 1 { 243 - t.Fatalf("want 1 merged hunk, got %d", len(built)) 244 - } 245 - 246 - var b strings.Builder 247 - renderInline(&b, built[0], base, head) 248 - out := b.String() 249 - 250 - if n := strings.Count(out, "l7"); n != 1 { 251 - t.Fatalf("interior context 'l7' should appear once, got %d:\n%s", n, out) 252 - } 253 - for line := range strings.SplitSeq(strings.TrimRight(out, "\n"), "\n") { 254 - if strings.Contains(line, "l7") && (strings.Contains(line, "- l7") || strings.Contains(line, "+ l7")) { 255 - t.Fatalf("interior context should have no +/- marker: %q", line) 256 - } 257 - } 258 - for _, want := range []string{"- old5", "+ new5", "- old10", "+ new10"} { 259 - if !strings.Contains(out, want) { 260 - t.Fatalf("missing %q in:\n%s", want, out) 261 - } 262 - } 263 - }
+77 -79
appview/pulls/pull2.go
··· 4 4 "context" 5 5 "errors" 6 6 "fmt" 7 + "html/template" 7 8 "io" 8 9 "net/http" 9 10 "slices" ··· 198 199 var params pages.PullDiffParams 199 200 params.Pull = pull 200 201 params.Version = version 202 + params.Comments = comments 201 203 defer s.pages.PullDiff(w, params) 202 204 203 205 // 1. resolve target branch -> (branch, commit) ··· 232 234 // fileDiff pairs a streamed FileDiff with the line content of its base/head blobs. 233 235 type fileDiff struct { 234 236 diff *gitmirrorv1.FileDiff 235 - baseLines []string // lines of the base (lhs) blob; nil for binary/submodule/absent 236 - headLines []string // lines of the head (rhs) blob; nil for binary/submodule/absent 237 + baseLines []template.HTML // lines of the base (lhs) blob; nil for binary/submodule/absent 238 + headLines []template.HTML // lines of the head (rhs) blob; nil for binary/submodule/absent 237 239 } 238 240 239 241 // htmx fragment. render diff between commits ··· 301 303 if err != nil { 302 304 return err 303 305 } 304 - f.baseLines = strings.Split(strings.TrimSuffix(string(baseBlob), "\n"), "\n") 305 - f.headLines = strings.Split(strings.TrimSuffix(string(headBlob), "\n"), "\n") 306 + 307 + // TODO: highlight each blobs 308 + // pass lhs_positions & rhs_positions so highlighter can apply diff highlights 309 + for line := range strings.SplitSeq(strings.TrimSuffix(string(baseBlob), "\n"), "\n") { 310 + f.baseLines = append(f.baseLines, template.HTML(fmt.Sprintf("<div><span>%s</span></div>", template.HTMLEscapeString(line)))) 311 + } 312 + for line := range strings.SplitSeq(strings.TrimSuffix(string(headBlob), "\n"), "\n") { 313 + f.headLines = append(f.headLines, template.HTML(fmt.Sprintf("<div><span>%s</span></div>", template.HTMLEscapeString(line)))) 314 + } 315 + 316 + // f.baseLines = strings.Split(strings.TrimSuffix(string(baseBlob), "\n"), "\n") 317 + // f.headLines = strings.Split(strings.TrimSuffix(string(headBlob), "\n"), "\n") 306 318 return nil 307 319 }) 308 320 } ··· 310 322 panic(err) 311 323 } 312 324 313 - // <div><span>code</div></div> 314 - 315 - // c. render each file's diff, following difftastic's display layout 316 - // (github.com/Wilfred/difftastic src/display): side-by-side by default, inline 317 - // when ?view=unified. Word/token highlighting and line wrapping are omitted. 318 - var b strings.Builder 325 + // c. build the render model, following difftastic's display layout 326 + // (github.com/Wilfred/difftastic src/display): side-by-side by default, inline when 327 + // ?view=unified. All markup/styling lives in the template; here we only assemble data. 328 + params.Split = !unified 319 329 for _, f := range files { 320 - b.WriteString(f.diff.GetRhsSrc().GetPath()) 321 - b.WriteByte('\n') 330 + df := pages.DiffFile{Path: f.diff.GetRhsSrc().GetPath()} 322 331 323 332 if isBinaryOrSubmodule(f.diff.GetLhsSrc()) || isBinaryOrSubmodule(f.diff.GetRhsSrc()) { 324 - b.WriteString(" (binary or submodule)\n\n") 333 + df.Note = "binary or submodule" 334 + params.Files = append(params.Files, df) 325 335 continue 326 336 } 327 337 328 338 for _, h := range buildHunks(f.baseLines, f.headLines, f.diff.Hunks) { 329 - b.WriteString("@@\n") 339 + var dh pages.DiffHunk 330 340 if unified { 331 - renderInline(&b, h, f.baseLines, f.headLines) 341 + dh.Lines = buildInlineLines(h, f.baseLines, f.headLines) 332 342 } else { 333 - renderSideBySide(&b, h, f.baseLines, f.headLines) 343 + dh.Rows = buildSplitRows(h, f.baseLines, f.headLines) 334 344 } 345 + df.Hunks = append(df.Hunks, dh) 335 346 } 336 - b.WriteByte('\n') 347 + params.Files = append(params.Files, df) 337 348 } 338 - params.Diff = b.String() 339 349 } 340 350 341 351 // numContextLines is difftastic's default (`num_context_lines`); pad_before/pad_after add ··· 373 383 // gitmirror's Hunk.Lines are difftastic's novel line pairs; we lack its token-level 374 384 // MatchedPos, but a line-granular diff's unchanged lines form a 1:1 bijection, which stands 375 385 // in for difftastic's opposite_positions map (buildOpposites). 376 - func buildHunks(baseLines, headLines []string, hunks []*gitmirrorv1.Hunk) []displayHunk { 386 + func buildHunks(baseLines, headLines []template.HTML, hunks []*gitmirrorv1.Hunk) []displayHunk { 377 387 oppLhs, oppRhs, maxLhs, maxRhs := buildOpposites(baseLines, headLines, hunks) 378 388 379 389 var flat []linePair ··· 412 422 // specialized to a line-granular diff): every displayed line as a pair, plus a parallel 413 423 // `changed` flag for pairs that came from a gitmirror hunk. Unchanged lines are a 1:1 414 424 // bijection, so the two cursors advance together across gaps. 415 - func alignFile(baseLines, headLines []string, hunks []*gitmirrorv1.Hunk) (pairs []linePair, changed []bool) { 425 + func alignFile(baseLines, headLines []template.HTML, hunks []*gitmirrorv1.Hunk) (pairs []linePair, changed []bool) { 416 426 li, ri := 0, 0 417 427 emitContext := func(n int) { 418 428 for k := range n { ··· 519 529 // between unchanged base and head lines. It walks the gitmirror hunks with two cursors — 520 530 // unchanged runs advance both sides together — recording each unchanged line's counterpart. 521 531 // maxLhs/maxRhs are the 0-based last line numbers of each blob. 522 - func buildOpposites(baseLines, headLines []string, hunks []*gitmirrorv1.Hunk) (oppLhs, oppRhs map[int]int, maxLhs, maxRhs int) { 532 + func buildOpposites(baseLines, headLines []template.HTML, hunks []*gitmirrorv1.Hunk) (oppLhs, oppRhs map[int]int, maxLhs, maxRhs int) { 523 533 oppLhs, oppRhs = map[int]int{}, map[int]int{} 524 534 li, ri := 0, 0 525 535 ··· 935 945 } 936 946 } 937 947 938 - // renderSideBySide lays out a hunk's rows in two columns: 939 - // 940 - // [lhsNum][- ]lhsContent [rhsNum][+ ]rhsContent 941 - // 942 - // A changed side gets a `-` (left) or `+` (right) marker; context gets a space. An absent 943 - // side (-1) renders as a blank gutter, blank marker, and empty content. 944 - func renderSideBySide(b *strings.Builder, h displayHunk, baseLines, headLines []string) { 945 - const spacer = " " 946 - contentWidth := 0 947 - for _, r := range h.rows { 948 - if r.lhs >= 0 && len(baseLines[r.lhs]) > contentWidth { 949 - contentWidth = len(baseLines[r.lhs]) 950 - } 948 + // dispNum maps a 0-based line number to its 1-based display value, passing through the -1 949 + // absent sentinel unchanged. 950 + func dispNum(n int) int { 951 + if n < 0 { 952 + return -1 951 953 } 954 + return n + 1 955 + } 956 + 957 + // buildSplitRows turns a hunk into side-by-side rows (difftastic's SideBySideShowBoth): each 958 + // row always has a left (base) and right (head) cell. Unchanged rows show context on both 959 + // sides; a changed row shows a deletion on the left and/or an addition on the right, padding 960 + // the missing side with an empty cell. 961 + func buildSplitRows(h displayHunk, baseLines, headLines []template.HTML) []pages.DiffRow { 962 + rows := make([]pages.DiffRow, 0, len(h.rows)) 952 963 for _, r := range h.rows { 953 - lhs, rhs := "", "" 954 - if r.lhs >= 0 { 955 - lhs = baseLines[r.lhs] 964 + var row pages.DiffRow 965 + if !r.changed { 966 + row.Left = pages.DiffCell{Kind: "ctx", Num: dispNum(r.lhs), Content: lineAt(baseLines, r.lhs)} 967 + row.Right = pages.DiffCell{Kind: "ctx", Num: dispNum(r.rhs), Content: lineAt(headLines, r.rhs)} 968 + } else { 969 + if r.lhs >= 0 { 970 + row.Left = pages.DiffCell{Kind: "del", Num: dispNum(r.lhs), Content: lineAt(baseLines, r.lhs)} 971 + } else { 972 + row.Left = pages.DiffCell{Kind: "empty", Num: -1} 973 + } 974 + if r.rhs >= 0 { 975 + row.Right = pages.DiffCell{Kind: "add", Num: dispNum(r.rhs), Content: lineAt(headLines, r.rhs)} 976 + } else { 977 + row.Right = pages.DiffCell{Kind: "empty", Num: -1} 978 + } 956 979 } 957 - if r.rhs >= 0 { 958 - rhs = headLines[r.rhs] 959 - } 960 - lmark := mark(r.changed && r.lhs >= 0, '-') 961 - rmark := mark(r.changed && r.rhs >= 0, '+') 962 - fmt.Fprintf(b, "%s%s%-*s%s|%s%s%s\n", 963 - gutter(r.lhs), lmark, contentWidth, lhs, spacer, gutter(r.rhs), rmark, rhs) 980 + rows = append(rows, row) 964 981 } 982 + return rows 965 983 } 966 984 967 - // renderInline lays out a hunk as a unified diff: context lines shown once (no marker), 968 - // and each contiguous run of changed rows printed as all removed (LHS, `-`) then all added 969 - // (RHS, `+`). Interior context between merged changes stays a single unmarked line. 970 - func renderInline(b *strings.Builder, h displayHunk, baseLines, headLines []string) { 985 + // buildInlineLines turns a hunk into unified-diff lines: context lines shown once (no 986 + // marker), and each contiguous run of changed rows emitted as all removed (LHS, "-") then 987 + // all added (RHS, "+"). Interior context between merged changes stays a single line. Removed 988 + // lines carry only the old number, added lines only the new number. 989 + func buildInlineLines(h displayHunk, baseLines, headLines []template.HTML) []pages.DiffLine { 990 + var out []pages.DiffLine 971 991 for i := 0; i < len(h.rows); { 972 992 r := h.rows[i] 973 993 if !r.changed { 974 994 if r.lhs >= 0 { 975 - fmt.Fprintf(b, "%s%s%s%s\n", gutter(r.lhs), gutter(r.rhs), mark(false, ' '), baseLines[r.lhs]) 995 + out = append(out, pages.DiffLine{Op: " ", Old: dispNum(r.lhs), New: dispNum(r.rhs), Content: lineAt(baseLines, r.lhs)}) 976 996 } 977 997 i++ 978 998 continue 979 999 } 980 - // Contiguous run of changed rows: all removed, then all added. 981 1000 j := i 982 1001 for j < len(h.rows) && h.rows[j].changed { 983 1002 j++ 984 1003 } 985 - // Removed lines carry only the lhs number; the rhs gutter is blank (the paired rhs 986 - // belongs to the added line printed below, not to this removed line). 987 1004 for _, cr := range h.rows[i:j] { 988 1005 if cr.lhs >= 0 { 989 - fmt.Fprintf(b, "%s%s%s%s\n", gutter(cr.lhs), gutter(-1), mark(true, '-'), baseLines[cr.lhs]) 1006 + out = append(out, pages.DiffLine{Op: "-", Old: dispNum(cr.lhs), New: -1, Content: lineAt(baseLines, cr.lhs)}) 990 1007 } 991 1008 } 992 - // Added lines carry only the rhs number; the lhs gutter is blank. 993 1009 for _, cr := range h.rows[i:j] { 994 1010 if cr.rhs >= 0 { 995 - fmt.Fprintf(b, "%s%s%s%s\n", gutter(-1), gutter(cr.rhs), mark(true, '+'), headLines[cr.rhs]) 1011 + out = append(out, pages.DiffLine{Op: "+", Old: -1, New: dispNum(cr.rhs), Content: lineAt(headLines, cr.rhs)}) 996 1012 } 997 1013 } 998 1014 i = j 999 1015 } 1016 + return out 1000 1017 } 1001 1018 1002 - // mark returns the 2-char marker cell placed between the line-number gutter and content: 1003 - // "<sign> " when set, " " otherwise. 1004 - func mark(set bool, sign byte) string { 1005 - if set { 1006 - return string(sign) + " " 1019 + // lineAt returns lines[n], or "" when n is the -1 absent sentinel or out of range. 1020 + func lineAt(lines []template.HTML, n int) template.HTML { 1021 + if n < 0 || n >= len(lines) { 1022 + return "" 1007 1023 } 1008 - return " " 1009 - } 1010 - 1011 - // gutter renders a 1-based line number right-aligned to width w plus a trailing space, or 1012 - // blanks when the line is absent (-1). 1013 - func gutter(n int) string { 1014 - if n < 0 { 1015 - return strings.Repeat(" ", 5) 1016 - } 1017 - return fmt.Sprintf("%*d ", 4, n+1) 1024 + return lines[n] 1018 1025 } 1019 1026 1020 1027 // getBlob streams a blob's bytes from gitmirror by OID and concatenates them. ··· 1056 1063 } 1057 1064 } 1058 1065 return true 1059 - } 1060 - 1061 - // splitLines splits blob bytes into lines, dropping the trailing empty element 1062 - // produced by a final newline. 1063 - func splitLines(b []byte) []string { 1064 - if len(b) == 0 { 1065 - return nil 1066 - } 1067 - return strings.Split(strings.TrimSuffix(string(b), "\n"), "\n") 1068 1066 } 1069 1067 1070 1068 // htmx fragment. render interdiff between changes
+23
input.css
··· 306 306 } 307 307 } 308 308 309 + table.diff { 310 + @apply w-full border-collapse font-mono; 311 + } 312 + table.diff tr { 313 + @apply bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400; 314 + } 315 + table.diff .added { 316 + @apply bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400; 317 + } 318 + table.diff .deleted { 319 + @apply bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400; 320 + } 321 + table.diff tr.splitter { 322 + @apply bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center; 323 + } 324 + table.diff td.num { 325 + @apply min-w-10 text-right select-none align-top whitespace-nowrap; 326 + } 327 + table.diff td.content::before { 328 + padding: 0 0.5rem; 329 + content: attr(data-diff-marker); 330 + } 331 + 309 332 .prose { 310 333 overflow-wrap: anywhere; 311 334 }