This repository has no description
0

Configure Feed

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

core / appview / pulls / diff_helpers.go
9.2 kB 380 lines
1package pulls 2 3import ( 4 "context" 5 "errors" 6 "html/template" 7 "io" 8 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 "tangled.org/core/appview/pages" 11 gitmirrorv1 "tangled.org/core/gitmirror/proto/gen" 12) 13 14type fileDiff struct { 15 diff *gitmirrorv1.FileDiff 16 baseLines []template.HTML 17 headLines []template.HTML 18} 19 20const ( 21 numContextLines = 3 22 maxDistance = 4 23) 24 25type linePair struct { 26 lhs int // 0-based line number, -1 when empty 27 rhs int // 0-based line number, -1 when empty 28} 29 30type displayHunk struct { 31 rows []diffRow 32} 33 34type diffRow struct { 35 lhs int // 0-based line number, -1 when empty 36 rhs int // 0-based line number, -1 when empty 37 changed bool 38} 39 40func buildHunks(baseLines, headLines []template.HTML, hunks []*gitmirrorv1.Hunk) []displayHunk { 41 var flat []linePair 42 for _, h := range hunks { 43 for _, lp := range h.Lines { 44 flat = append(flat, toPair(lp)) 45 } 46 } 47 48 pairs, changed := alignFile(baseLines, headLines, hunks) 49 merged := mergeAdjacent(linesToHunks(flat), pairs) 50 51 var out []displayHunk 52 prevEnd := 0 53 for _, h := range merged { 54 lo, hi := indexesForHunk(pairs, h, numContextLines) 55 if lo < prevEnd { 56 lo = prevEnd // don't re-emit rows shared with the previous hunk's slice 57 } 58 var dh displayHunk 59 for i := lo; i < hi; i++ { 60 dh.rows = append(dh.rows, diffRow{lhs: pairs[i].lhs, rhs: pairs[i].rhs, changed: changed[i]}) 61 } 62 out = append(out, dh) 63 prevEnd = hi 64 } 65 return out 66} 67 68// alignFile builds the whole-file aligned list: every displayed line as a pair, 69// plus a parallel `changed` flag for pairs that came from a gitmirror hunk. 70// Unchanged lines are a 1:1 bijection, so the two cursors advance together 71// across gaps. 72func alignFile(baseLines, headLines []template.HTML, hunks []*gitmirrorv1.Hunk) (pairs []linePair, changed []bool) { 73 li, ri := 0, 0 74 emitContext := func(n int) { 75 for k := range n { 76 pairs = append(pairs, linePair{lhs: li + k, rhs: ri + k}) 77 changed = append(changed, false) 78 } 79 li += n 80 ri += n 81 } 82 for _, h := range hunks { 83 lhsStart, _, ok := hunkStart(h, li, ri) 84 if !ok { 85 continue 86 } 87 emitContext(lhsStart - li) // unchanged gap before this change (== rhsStart-ri) 88 for _, lp := range h.Lines { 89 p := toPair(lp) 90 if p.lhs >= 0 { 91 li = p.lhs + 1 92 } 93 if p.rhs >= 0 { 94 ri = p.rhs + 1 95 } 96 pairs = append(pairs, p) 97 changed = append(changed, true) 98 } 99 } 100 for li < len(baseLines) && ri < len(headLines) { 101 pairs = append(pairs, linePair{lhs: li, rhs: ri}) 102 changed = append(changed, false) 103 li++ 104 ri++ 105 } 106 return pairs, changed 107} 108 109// indexesForHunk returns the [start,end) slice of the aligned pairs to display 110// for a hunk: the span from its smallest to largest novel line, expanded by n 111// context lines each side and clamped. 112func indexesForHunk(pairs, hunkLines []linePair, n int) (start, end int) { 113 minLhs, minRhs, maxLhs, maxRhs := -1, -1, -1, -1 114 for _, lp := range hunkLines { 115 if lp.lhs >= 0 { 116 if minLhs < 0 { 117 minLhs = lp.lhs 118 } 119 maxLhs = lp.lhs 120 } 121 if lp.rhs >= 0 { 122 if minRhs < 0 { 123 minRhs = lp.rhs 124 } 125 maxRhs = lp.rhs 126 } 127 } 128 smallest, largest := linePair{minLhs, minRhs}, linePair{maxLhs, maxRhs} 129 130 start = 0 131 for i, p := range pairs { 132 if eitherSideEqual(p, smallest) { 133 start = i 134 break 135 } 136 } 137 end = len(pairs) 138 for i := len(pairs) - 1; i >= 0; i-- { 139 if eitherSideEqual(pairs[i], largest) { 140 end = i + 1 141 break 142 } 143 } 144 145 start = max(0, start-n) 146 end = min(len(pairs), end+n) 147 return start, end 148} 149 150// eitherSideEqual reports whether a and b share a present line number on the same side. 151func eitherSideEqual(a, b linePair) bool { 152 if a.lhs >= 0 && a.lhs == b.lhs { 153 return true 154 } 155 if a.rhs >= 0 && a.rhs == b.rhs { 156 return true 157 } 158 return false 159} 160 161func toPair(lp *gitmirrorv1.LinePair) linePair { 162 p := linePair{lhs: -1, rhs: -1} 163 if lp.Lhs != nil { 164 p.lhs = int(*lp.Lhs) 165 } 166 if lp.Rhs != nil { 167 p.rhs = int(*lp.Rhs) 168 } 169 return p 170} 171 172// hunkStart returns the first changed line number on each side, deriving the 173// empty side from the cursors (unchanged lines advance both sides equally). ok 174// is false for an empty hunk. 175func hunkStart(h *gitmirrorv1.Hunk, li, ri int) (lhsStart, rhsStart int, ok bool) { 176 lhsStart, rhsStart = -1, -1 177 for _, lp := range h.Lines { 178 if lp.Lhs != nil && lhsStart < 0 { 179 lhsStart = int(*lp.Lhs) 180 } 181 if lp.Rhs != nil && rhsStart < 0 { 182 rhsStart = int(*lp.Rhs) 183 } 184 } 185 switch { 186 case lhsStart < 0 && rhsStart < 0: 187 return 0, 0, false 188 case lhsStart < 0: // pure insertion 189 lhsStart = li + (rhsStart - ri) 190 case rhsStart < 0: // pure deletion 191 rhsStart = ri + (lhsStart - li) 192 } 193 return lhsStart, rhsStart, true 194} 195 196// enforceIncreasing drops any line number that would go backwards, keeping each 197// side monotonically increasing. 198func enforceIncreasing(lines []linePair) []linePair { 199 var out []linePair 200 maxLhs, maxRhs := -1, -1 201 for _, lp := range lines { 202 l, r := lp.lhs, lp.rhs 203 if maxLhs < 0 { 204 maxLhs = l 205 } else if l >= 0 && l > maxLhs { 206 maxLhs = l 207 } else { 208 l = -1 209 } 210 if maxRhs < 0 { 211 maxRhs = r 212 } else if r >= 0 && r > maxRhs { 213 maxRhs = r 214 } else { 215 r = -1 216 } 217 if l >= 0 || r >= 0 { 218 out = append(out, linePair{lhs: l, rhs: r}) 219 } 220 } 221 return out 222} 223 224// linesAreClose reports whether a line is within maxDistance of the last seen 225// line on either side. 226func linesAreClose(maxLhs, maxRhs int, lp linePair) bool { 227 if maxLhs >= 0 && lp.lhs >= 0 && lp.lhs <= maxLhs+maxDistance { 228 return true 229 } 230 if maxRhs >= 0 && lp.rhs >= 0 && lp.rhs <= maxRhs+maxDistance { 231 return true 232 } 233 return false 234} 235 236// linesToHunks splits changed line pairs into hunks by per-side proximity. 237func linesToHunks(flat []linePair) [][]linePair { 238 var hunks [][]linePair 239 var cur []linePair 240 maxLhs, maxRhs := -1, -1 241 for _, lp := range enforceIncreasing(flat) { 242 if len(cur) == 0 || linesAreClose(maxLhs, maxRhs, lp) { 243 cur = append(cur, lp) 244 } else { 245 hunks = append(hunks, cur) 246 cur = []linePair{lp} 247 } 248 if lp.lhs >= 0 { 249 maxLhs = lp.lhs 250 } 251 if lp.rhs >= 0 { 252 maxRhs = lp.rhs 253 } 254 } 255 if len(cur) > 0 { 256 hunks = append(hunks, cur) 257 } 258 return hunks 259} 260 261// mergeAdjacent folds consecutive hunks whose context windows overlap in the 262// aligned pair list into one group. It pads by numContextLines+1 (one more than 263// the displayed context) so hunks separated only by shared context merge. 264func mergeAdjacent(hunks [][]linePair, pairs []linePair) [][]linePair { 265 var merged [][]linePair 266 prevHi := -1 267 for _, h := range hunks { 268 lo, hi := indexesForHunk(pairs, h, numContextLines+1) 269 if len(merged) > 0 && lo < prevHi { 270 last := len(merged) - 1 271 merged[last] = append(merged[last], h...) 272 if hi > prevHi { 273 prevHi = hi 274 } 275 continue 276 } 277 merged = append(merged, h) 278 prevHi = hi 279 } 280 return merged 281} 282 283func buildSplitRows(h displayHunk, baseLines, headLines []template.HTML) []pages.DiffRow { 284 rows := make([]pages.DiffRow, 0, len(h.rows)) 285 for _, r := range h.rows { 286 var row pages.DiffRow 287 if !r.changed { 288 row.Left = pages.DiffCell{Kind: "ctx", Num: r.lhs + 1, Content: lineAt(baseLines, r.lhs)} 289 row.Right = pages.DiffCell{Kind: "ctx", Num: r.rhs + 1, Content: lineAt(headLines, r.rhs)} 290 } else { 291 if r.lhs >= 0 { 292 row.Left = pages.DiffCell{Kind: "del", Num: r.lhs + 1, Content: lineAt(baseLines, r.lhs)} 293 } else { 294 row.Left = pages.DiffCell{Kind: "empty", Num: 0} 295 } 296 if r.rhs >= 0 { 297 row.Right = pages.DiffCell{Kind: "add", Num: r.rhs + 1, Content: lineAt(headLines, r.rhs)} 298 } else { 299 row.Right = pages.DiffCell{Kind: "empty", Num: 0} 300 } 301 } 302 rows = append(rows, row) 303 } 304 return rows 305} 306 307func buildUnifiedLines(h displayHunk, baseLines, headLines []template.HTML) []pages.DiffLine { 308 var out []pages.DiffLine 309 for i := 0; i < len(h.rows); { 310 r := h.rows[i] 311 if !r.changed { 312 if r.lhs >= 0 { 313 out = append(out, pages.DiffLine{Op: " ", Old: r.lhs + 1, New: r.rhs + 1, Content: lineAt(baseLines, r.lhs)}) 314 } 315 i++ 316 continue 317 } 318 j := i 319 for j < len(h.rows) && h.rows[j].changed { 320 j++ 321 } 322 for _, cr := range h.rows[i:j] { 323 if cr.lhs >= 0 { 324 out = append(out, pages.DiffLine{Op: "-", Old: cr.lhs + 1, New: 0, Content: lineAt(baseLines, cr.lhs)}) 325 } 326 } 327 for _, cr := range h.rows[i:j] { 328 if cr.rhs >= 0 { 329 out = append(out, pages.DiffLine{Op: "+", Old: 0, New: cr.rhs + 1, Content: lineAt(headLines, cr.rhs)}) 330 } 331 } 332 i = j 333 } 334 return out 335} 336 337func lineAt(lines []template.HTML, n int) template.HTML { 338 if n < 0 || n >= len(lines) { 339 return "" 340 } 341 return lines[n] 342} 343 344func (s *Pulls) getBlob(ctx context.Context, repo syntax.DID, oid string) ([]byte, error) { 345 if isNullOid(oid) { 346 return nil, nil 347 } 348 stream, err := s.gitmirror.GetBlob(ctx, &gitmirrorv1.GetBlobRequest{Repo: repo.String(), Oid: oid}) 349 if err != nil { 350 return nil, err 351 } 352 var buf []byte 353 for { 354 chunk, err := stream.Recv() 355 if errors.Is(err, io.EOF) { 356 break 357 } 358 if err != nil { 359 return nil, err 360 } 361 buf = append(buf, chunk.GetData()...) 362 } 363 return buf, nil 364} 365 366func isBinaryOrSubmodule(fc *gitmirrorv1.FileContent) bool { 367 return fc != nil && (fc.GetIsBinary() || fc.GetIsSubmodule()) 368} 369 370func isNullOid(oid string) bool { 371 if oid == "" { 372 return true 373 } 374 for _, c := range oid { 375 if c != '0' { 376 return false 377 } 378 } 379 return true 380}