package pulls import ( "context" "errors" "html/template" "io" "github.com/bluesky-social/indigo/atproto/syntax" "tangled.org/core/appview/pages" gitmirrorv1 "tangled.org/core/gitmirror/proto/gen" ) type fileDiff struct { diff *gitmirrorv1.FileDiff baseLines []template.HTML headLines []template.HTML } const ( numContextLines = 3 maxDistance = 4 ) type linePair struct { lhs int // 0-based line number, -1 when empty rhs int // 0-based line number, -1 when empty } type displayHunk struct { rows []diffRow } type diffRow struct { lhs int // 0-based line number, -1 when empty rhs int // 0-based line number, -1 when empty changed bool } func buildHunks(baseLines, headLines []template.HTML, hunks []*gitmirrorv1.Hunk) []displayHunk { var flat []linePair for _, h := range hunks { for _, lp := range h.Lines { flat = append(flat, toPair(lp)) } } pairs, changed := alignFile(baseLines, headLines, hunks) merged := mergeAdjacent(linesToHunks(flat), pairs) var out []displayHunk prevEnd := 0 for _, h := range merged { lo, hi := indexesForHunk(pairs, h, numContextLines) if lo < prevEnd { lo = prevEnd // don't re-emit rows shared with the previous hunk's slice } var dh displayHunk for i := lo; i < hi; i++ { dh.rows = append(dh.rows, diffRow{lhs: pairs[i].lhs, rhs: pairs[i].rhs, changed: changed[i]}) } out = append(out, dh) prevEnd = hi } return out } // alignFile builds the whole-file aligned list: every displayed line as a pair, // plus a parallel `changed` flag for pairs that came from a gitmirror hunk. // Unchanged lines are a 1:1 bijection, so the two cursors advance together // across gaps. func alignFile(baseLines, headLines []template.HTML, hunks []*gitmirrorv1.Hunk) (pairs []linePair, changed []bool) { li, ri := 0, 0 emitContext := func(n int) { for k := range n { pairs = append(pairs, linePair{lhs: li + k, rhs: ri + k}) changed = append(changed, false) } li += n ri += n } for _, h := range hunks { lhsStart, _, ok := hunkStart(h, li, ri) if !ok { continue } emitContext(lhsStart - li) // unchanged gap before this change (== rhsStart-ri) for _, lp := range h.Lines { p := toPair(lp) if p.lhs >= 0 { li = p.lhs + 1 } if p.rhs >= 0 { ri = p.rhs + 1 } pairs = append(pairs, p) changed = append(changed, true) } } for li < len(baseLines) && ri < len(headLines) { pairs = append(pairs, linePair{lhs: li, rhs: ri}) changed = append(changed, false) li++ ri++ } return pairs, changed } // indexesForHunk returns the [start,end) slice of the aligned pairs to display // for a hunk: the span from its smallest to largest novel line, expanded by n // context lines each side and clamped. func indexesForHunk(pairs, hunkLines []linePair, n int) (start, end int) { minLhs, minRhs, maxLhs, maxRhs := -1, -1, -1, -1 for _, lp := range hunkLines { if lp.lhs >= 0 { if minLhs < 0 { minLhs = lp.lhs } maxLhs = lp.lhs } if lp.rhs >= 0 { if minRhs < 0 { minRhs = lp.rhs } maxRhs = lp.rhs } } smallest, largest := linePair{minLhs, minRhs}, linePair{maxLhs, maxRhs} start = 0 for i, p := range pairs { if eitherSideEqual(p, smallest) { start = i break } } end = len(pairs) for i := len(pairs) - 1; i >= 0; i-- { if eitherSideEqual(pairs[i], largest) { end = i + 1 break } } start = max(0, start-n) end = min(len(pairs), end+n) return start, end } // eitherSideEqual reports whether a and b share a present line number on the same side. func eitherSideEqual(a, b linePair) bool { if a.lhs >= 0 && a.lhs == b.lhs { return true } if a.rhs >= 0 && a.rhs == b.rhs { return true } return false } func toPair(lp *gitmirrorv1.LinePair) linePair { p := linePair{lhs: -1, rhs: -1} if lp.Lhs != nil { p.lhs = int(*lp.Lhs) } if lp.Rhs != nil { p.rhs = int(*lp.Rhs) } return p } // hunkStart returns the first changed line number on each side, deriving the // empty side from the cursors (unchanged lines advance both sides equally). ok // is false for an empty hunk. func hunkStart(h *gitmirrorv1.Hunk, li, ri int) (lhsStart, rhsStart int, ok bool) { lhsStart, rhsStart = -1, -1 for _, lp := range h.Lines { if lp.Lhs != nil && lhsStart < 0 { lhsStart = int(*lp.Lhs) } if lp.Rhs != nil && rhsStart < 0 { rhsStart = int(*lp.Rhs) } } switch { case lhsStart < 0 && rhsStart < 0: return 0, 0, false case lhsStart < 0: // pure insertion lhsStart = li + (rhsStart - ri) case rhsStart < 0: // pure deletion rhsStart = ri + (lhsStart - li) } return lhsStart, rhsStart, true } // enforceIncreasing drops any line number that would go backwards, keeping each // side monotonically increasing. func enforceIncreasing(lines []linePair) []linePair { var out []linePair maxLhs, maxRhs := -1, -1 for _, lp := range lines { l, r := lp.lhs, lp.rhs if maxLhs < 0 { maxLhs = l } else if l >= 0 && l > maxLhs { maxLhs = l } else { l = -1 } if maxRhs < 0 { maxRhs = r } else if r >= 0 && r > maxRhs { maxRhs = r } else { r = -1 } if l >= 0 || r >= 0 { out = append(out, linePair{lhs: l, rhs: r}) } } return out } // linesAreClose reports whether a line is within maxDistance of the last seen // line on either side. func linesAreClose(maxLhs, maxRhs int, lp linePair) bool { if maxLhs >= 0 && lp.lhs >= 0 && lp.lhs <= maxLhs+maxDistance { return true } if maxRhs >= 0 && lp.rhs >= 0 && lp.rhs <= maxRhs+maxDistance { return true } return false } // linesToHunks splits changed line pairs into hunks by per-side proximity. func linesToHunks(flat []linePair) [][]linePair { var hunks [][]linePair var cur []linePair maxLhs, maxRhs := -1, -1 for _, lp := range enforceIncreasing(flat) { if len(cur) == 0 || linesAreClose(maxLhs, maxRhs, lp) { cur = append(cur, lp) } else { hunks = append(hunks, cur) cur = []linePair{lp} } if lp.lhs >= 0 { maxLhs = lp.lhs } if lp.rhs >= 0 { maxRhs = lp.rhs } } if len(cur) > 0 { hunks = append(hunks, cur) } return hunks } // mergeAdjacent folds consecutive hunks whose context windows overlap in the // aligned pair list into one group. It pads by numContextLines+1 (one more than // the displayed context) so hunks separated only by shared context merge. func mergeAdjacent(hunks [][]linePair, pairs []linePair) [][]linePair { var merged [][]linePair prevHi := -1 for _, h := range hunks { lo, hi := indexesForHunk(pairs, h, numContextLines+1) if len(merged) > 0 && lo < prevHi { last := len(merged) - 1 merged[last] = append(merged[last], h...) if hi > prevHi { prevHi = hi } continue } merged = append(merged, h) prevHi = hi } return merged } func buildSplitRows(h displayHunk, baseLines, headLines []template.HTML) []pages.DiffRow { rows := make([]pages.DiffRow, 0, len(h.rows)) for _, r := range h.rows { var row pages.DiffRow if !r.changed { row.Left = pages.DiffCell{Kind: "ctx", Num: r.lhs + 1, Content: lineAt(baseLines, r.lhs)} row.Right = pages.DiffCell{Kind: "ctx", Num: r.rhs + 1, Content: lineAt(headLines, r.rhs)} } else { if r.lhs >= 0 { row.Left = pages.DiffCell{Kind: "del", Num: r.lhs + 1, Content: lineAt(baseLines, r.lhs)} } else { row.Left = pages.DiffCell{Kind: "empty", Num: 0} } if r.rhs >= 0 { row.Right = pages.DiffCell{Kind: "add", Num: r.rhs + 1, Content: lineAt(headLines, r.rhs)} } else { row.Right = pages.DiffCell{Kind: "empty", Num: 0} } } rows = append(rows, row) } return rows } func buildUnifiedLines(h displayHunk, baseLines, headLines []template.HTML) []pages.DiffLine { var out []pages.DiffLine for i := 0; i < len(h.rows); { r := h.rows[i] if !r.changed { if r.lhs >= 0 { out = append(out, pages.DiffLine{Op: " ", Old: r.lhs + 1, New: r.rhs + 1, Content: lineAt(baseLines, r.lhs)}) } i++ continue } j := i for j < len(h.rows) && h.rows[j].changed { j++ } for _, cr := range h.rows[i:j] { if cr.lhs >= 0 { out = append(out, pages.DiffLine{Op: "-", Old: cr.lhs + 1, New: 0, Content: lineAt(baseLines, cr.lhs)}) } } for _, cr := range h.rows[i:j] { if cr.rhs >= 0 { out = append(out, pages.DiffLine{Op: "+", Old: 0, New: cr.rhs + 1, Content: lineAt(headLines, cr.rhs)}) } } i = j } return out } func lineAt(lines []template.HTML, n int) template.HTML { if n < 0 || n >= len(lines) { return "" } return lines[n] } func (s *Pulls) getBlob(ctx context.Context, repo syntax.DID, oid string) ([]byte, error) { if isNullOid(oid) { return nil, nil } stream, err := s.gitmirror.GetBlob(ctx, &gitmirrorv1.GetBlobRequest{Repo: repo.String(), Oid: oid}) if err != nil { return nil, err } var buf []byte for { chunk, err := stream.Recv() if errors.Is(err, io.EOF) { break } if err != nil { return nil, err } buf = append(buf, chunk.GetData()...) } return buf, nil } func isBinaryOrSubmodule(fc *gitmirrorv1.FileContent) bool { return fc != nil && (fc.GetIsBinary() || fc.GetIsSubmodule()) } func isNullOid(oid string) bool { if oid == "" { return true } for _, c := range oid { if c != '0' { return false } } return true }