Monorepo for Tangled
0

Configure Feed

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

wip

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

Seongmin Lee (Jul 2, 2026, 12:51 PM +0900) f1c231da 067abad8

+869 -209
+39
appview/models/pull2.go
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + type Pull2 struct { 10 + ID int64 // repo-specific PR id 11 + 12 + RepoDid syntax.DID 13 + AuthorDid syntax.DID 14 + Rkey syntax.RecordKey 15 + 16 + Title string 17 + Body string 18 + TargetBranch string 19 + Versions []PullVersion 20 + Created time.Time 21 + 22 + // optionally, populate this when querying for reverse mappings 23 + Repo *Repo 24 + } 25 + 26 + func (p *Pull2) LatestVersionId() int { 27 + return len(p.Versions) - 1 28 + } 29 + 30 + func (p *Pull2) LatestVersion() PullVersion { 31 + return p.Versions[p.LatestVersionId()] 32 + } 33 + 34 + type PullVersion struct { 35 + SourceRepo syntax.DID 36 + Head string // head commit ID 37 + Base string // base commit ID 38 + Created time.Time 39 + }
+2 -2
appview/pages/pages.go
··· 1429 1429 1430 1430 type PullPageBaseParams struct { 1431 1431 BaseParams 1432 - Pull *models.Pull 1432 + Pull *models.Pull2 1433 1433 1434 1434 Backlinks []models.RichReferenceLink 1435 1435 Comments []models.Comment ··· 1469 1469 } 1470 1470 1471 1471 func (p *Pages) PullDiff(w io.Writer, params PullDiffParams) error { 1472 - panic("unimplemented") 1472 + return p.executeRepo("repo/pulls/single", w, params) 1473 1473 } 1474 1474 1475 1475 func (p *Pages) PullInterdiff(w io.Writer, params PullInterdiffParams) error {
+66
appview/pages/templates/repo/pulls/single.html
··· 1 + {{ define "title" }} 2 + wip 3 + {{ end }} 4 + 5 + {{ define "mainLayout" }} 6 + {{ $version := .Pull.LatestVersion }} 7 + <style> 8 + .debug { 9 + border-width: 1px; 10 + border-color: red; 11 + } 12 + .debug-section { 13 + border-width: 1px; 14 + border-color: red; 15 + padding: 1rem; 16 + } 17 + </style> 18 + <div class="flex"> 19 + <div class="flex-1"> 20 + <div class="grid grid-cols-1 lg:grid-cols-[1fr_22.5rem]"> 21 + <div class="debug-section min-w-0 lg:row-start-1 lg:col-start-1"> 22 + <h2 class="">{{ .Pull.Title }}</h2> 23 + <div>{{ .Pull.Body | markdown }}</div> 24 + </div> 25 + <div class="debug-section lg:row-start-1 lg:row-end-3 lg:col-start-2"> 26 + labels 27 + </div> 28 + <div class="debug-section lg:row-start-2 lg:col-start-1"> 29 + commits 30 + </div> 31 + </div> 32 + <div class="debug"> 33 + <div class="debug flex items-center"> 34 + <div class="md:block hidden"> 35 + <label for="diff-tree-toggle" title="Toggle filetree panel" class="btn-flat cursor-pointer"> 36 + <span class="peer-checked:hidden">{{ i "panel-left-open" "size-4" }}</span> 37 + <span class="hidden peer-checked:inline">{{ i "panel-left-close" "size-4" }}</span> 38 + </label> 39 + </div> 40 + diff header 41 + </div> 42 + <div class="flex"> 43 + <input type="checkbox" id="diff-tree-toggle" class="peer hidden" checked> 44 + <div class="debug-section hidden peer-checked:block"> 45 + diff tree 46 + </div> 47 + <div class="debug-section flex-1"> 48 + diff body 49 + <div hx-get="/{{ .Pull.RepoDid }}/pulls/test?base={{ $version.Base }}&head={{ $version.Head }}"> 50 + loading... 51 + </div> 52 + </div> 53 + </div> 54 + </div> 55 + </div> 56 + <div class="debug-section w-96"> 57 + discussion 58 + </div> 59 + </div> 60 + {{ end }} 61 + 62 + {{ define "repoContentLayout" }} 63 + <div class="px-1 flex-grow flex flex-col gap-4"> 64 + <p>repo content</p> 65 + </div> 66 + {{ end }}
+1
appview/pulls/diff.go
··· 1 + package pulls
+144 -91
appview/pulls/diff_hunks_test.go
··· 26 26 return &gitmirrorv1.Hunk{Lines: pairs} 27 27 } 28 28 29 - func TestAlignFile_Modification(t *testing.T) { 30 - base := lines(10, map[int]string{5: "old"}) 31 - head := lines(10, map[int]string{5: "new"}) 32 - hunks := []*gitmirrorv1.Hunk{hunk(&gitmirrorv1.LinePair{Lhs: u32(5), Rhs: u32(5)})} 29 + // identity opposite maps + generous bounds, for exercising the merge/context logic directly. 30 + func identityOpp(n int) (map[int]int, map[int]int) { 31 + l, r := map[int]int{}, map[int]int{} 32 + for i := 0; i < n; i++ { 33 + l[i], r[i] = i, i 34 + } 35 + return l, r 36 + } 33 37 34 - pairs, change := alignFile(base, head, hunks) 35 - // Whole file is represented (10 lines), exactly one change at the line-5 pair. 36 - if len(pairs) != 10 || len(change) != 10 { 37 - t.Fatalf("want 10 pairs, got %d", len(pairs)) 38 + func TestExtractLines_FillsInteriorGap(t *testing.T) { 39 + got := extractLines([]linePair{{0, 0}, {2, 2}}) 40 + want := []linePair{{0, 0}, {1, 1}, {2, 2}} 41 + if len(got) != len(want) { 42 + t.Fatalf("got %+v, want %+v", got, want) 38 43 } 39 - nChange := 0 40 - for i, c := range change { 41 - if c { 42 - nChange++ 43 - if pairs[i].lhs != 5 || pairs[i].rhs != 5 { 44 - t.Fatalf("change pair = %+v, want {5,5}", pairs[i]) 45 - } 44 + for i := range want { 45 + if got[i] != want[i] { 46 + t.Fatalf("pair %d = %+v, want %+v", i, got[i], want[i]) 46 47 } 47 - } 48 - if nChange != 1 { 49 - t.Fatalf("want 1 change, got %d", nChange) 50 48 } 51 49 } 52 50 53 - func TestGroupHunks_SingleWindowClamped(t *testing.T) { 54 - base := lines(10, map[int]string{5: "old"}) 55 - head := lines(10, map[int]string{5: "new"}) 56 - _, change := alignFile(base, head, []*gitmirrorv1.Hunk{hunk(&gitmirrorv1.LinePair{Lhs: u32(5), Rhs: u32(5)})}) 57 - 58 - got := groupHunks(change) 59 - if len(got) != 1 { 60 - t.Fatalf("want 1 window, got %d: %v", len(got), got) 51 + func TestPadBeforeAfter_ClampAndCount(t *testing.T) { 52 + // n+1 = 4 lines when far from the edges. 53 + if got := padBefore(10, numContextLines); len(got) != 4 || got[0] != 6 || got[3] != 9 { 54 + t.Fatalf("padBefore(10) = %v", got) 55 + } 56 + if got := padAfter(10, 100, numContextLines); len(got) != 4 || got[0] != 11 || got[3] != 14 { 57 + t.Fatalf("padAfter(10,100) = %v", got) 58 + } 59 + // Clamp at start of file. 60 + if got := padBefore(2, numContextLines); len(got) != 2 || got[0] != 0 || got[1] != 1 { 61 + t.Fatalf("padBefore(2) = %v", got) 61 62 } 62 - // Change at index 5, ctx = n+1 = 4 -> [1, 10) clamped to file end. 63 - if got[0] != [2]int{1, 10} { 64 - t.Fatalf("window = %v, want [1 10)", got[0]) 63 + // Clamp at end of file. 64 + if got := padAfter(98, 100, numContextLines); len(got) != 2 || got[0] != 99 || got[1] != 100 { 65 + t.Fatalf("padAfter(98,100) = %v", got) 65 66 } 66 67 } 67 68 68 - func TestGroupHunks_MergeClose(t *testing.T) { 69 - // Changes 5 lines apart: windows overlap -> single merged window. 70 - base := lines(20, map[int]string{4: "a", 9: "b"}) 71 - head := lines(20, map[int]string{4: "A", 9: "B"}) 72 - _, change := alignFile(base, head, []*gitmirrorv1.Hunk{ 73 - hunk(&gitmirrorv1.LinePair{Lhs: u32(4), Rhs: u32(4)}), 74 - hunk(&gitmirrorv1.LinePair{Lhs: u32(9), Rhs: u32(9)}), 75 - }) 76 - got := groupHunks(change) 69 + func TestMergeAdjacent_MergeClose(t *testing.T) { 70 + oppL, oppR := identityOpp(200) 71 + // Two deletions 2 lines apart on the lhs -> context windows overlap -> merge. 72 + got := mergeAdjacent([][]linePair{{{2, -1}}, {{4, -1}}}, oppL, oppR, 199, 199, numContextLines) 77 73 if len(got) != 1 { 78 - t.Fatalf("want 1 merged window, got %d: %v", len(got), got) 74 + t.Fatalf("want 1 merged hunk, got %d: %+v", len(got), got) 79 75 } 80 76 } 81 77 82 - func TestGroupHunks_SplitFar(t *testing.T) { 83 - // Changes 20 lines apart: windows disjoint -> two windows. 84 - base := lines(40, map[int]string{4: "a", 25: "b"}) 85 - head := lines(40, map[int]string{4: "A", 25: "B"}) 86 - _, change := alignFile(base, head, []*gitmirrorv1.Hunk{ 87 - hunk(&gitmirrorv1.LinePair{Lhs: u32(4), Rhs: u32(4)}), 88 - hunk(&gitmirrorv1.LinePair{Lhs: u32(25), Rhs: u32(25)}), 89 - }) 90 - got := groupHunks(change) 78 + func TestMergeAdjacent_SplitFar(t *testing.T) { 79 + oppL, oppR := identityOpp(200) 80 + got := mergeAdjacent([][]linePair{{{2, -1}}, {{40, -1}}}, oppL, oppR, 199, 199, numContextLines) 91 81 if len(got) != 2 { 92 - t.Fatalf("want 2 windows, got %d: %v", len(got), got) 82 + t.Fatalf("want 2 hunks, got %d: %+v", len(got), got) 83 + } 84 + } 85 + 86 + func TestMergeAdjacent_LargeOneSidedInsertion(t *testing.T) { 87 + oppL, oppR := identityOpp(200) 88 + // Two modifications adjacent on the lhs (2 and 4) but far apart on the rhs (2 and 40), 89 + // as if 30+ lines were inserted between them. difftastic merges by per-side line 90 + // distance, so the lhs overlap keeps them one hunk — the case aligned-index windowing 91 + // would have split. 92 + got := mergeAdjacent([][]linePair{{{2, 2}}, {{4, 40}}}, oppL, oppR, 199, 199, numContextLines) 93 + if len(got) != 1 { 94 + t.Fatalf("want 1 merged hunk (lhs overlap), got %d: %+v", len(got), got) 93 95 } 94 96 } 95 97 96 - func TestAlignFile_PureInsertionAtTop(t *testing.T) { 97 - base := lines(5, nil) 98 - head := append([]string{"x", "y"}, base...) // 2 inserted lines, then base 99 - hunks := []*gitmirrorv1.Hunk{hunk( 100 - &gitmirrorv1.LinePair{Rhs: u32(0)}, 101 - &gitmirrorv1.LinePair{Rhs: u32(1)}, 102 - )} 103 - pairs, change := alignFile(base, head, hunks) 98 + func TestBuildOpposites_DeletionPairing(t *testing.T) { 99 + // Delete base line 4 of a 10-line file -> head has 9 lines. Unchanged base 5..9 must 100 + // pair with head 4..8 (the bijection difftastic's opposite_positions encodes). 101 + base := lines(10, nil) 102 + head := lines(9, nil) 103 + hunks := []*gitmirrorv1.Hunk{hunk(&gitmirrorv1.LinePair{Lhs: u32(4)})} 104 104 105 - // First two pairs are the insertions (lhs absent), then 5 context pairs offset by 2. 106 - if pairs[0] != (linePair{lhs: -1, rhs: 0}) || pairs[1] != (linePair{lhs: -1, rhs: 1}) { 107 - t.Fatalf("insertion pairs = %+v %+v", pairs[0], pairs[1]) 105 + oppLhs, oppRhs, maxLhs, maxRhs := buildOpposites(base, head, hunks) 106 + if maxLhs != 9 || maxRhs != 8 { 107 + t.Fatalf("max lines = %d,%d want 9,8", maxLhs, maxRhs) 108 108 } 109 - if !change[0] || !change[1] { 110 - t.Fatalf("insertion pairs should be marked changed") 109 + for l := 5; l <= 9; l++ { 110 + if oppLhs[l] != l-1 { 111 + t.Fatalf("oppLhs[%d] = %d, want %d", l, oppLhs[l], l-1) 112 + } 111 113 } 112 - if pairs[2] != (linePair{lhs: 0, rhs: 2}) { 113 - t.Fatalf("first context pair = %+v, want {0,2}", pairs[2]) 114 + if oppRhs[4] != 5 { // head line 4 <-> base line 5 115 + t.Fatalf("oppRhs[4] = %d, want 5", oppRhs[4]) 114 116 } 115 - if len(pairs) != 7 { 116 - t.Fatalf("want 7 pairs, got %d", len(pairs)) 117 + // The deleted base line 4 has no counterpart. 118 + if _, ok := oppLhs[4]; ok { 119 + t.Fatalf("deleted base line 4 should have no opposite") 117 120 } 118 121 } 119 122 120 - func TestAlignFile_PureDeletionKeepsColumnsPaired(t *testing.T) { 121 - // Delete base line index 4 of a 10-line file -> head has 9 lines. The unchanged tail 122 - // (base 5..9) must stay paired with head 4..8, not drift. 123 - base := lines(10, nil) 124 - head := append(append([]string{}, lines(4, nil)...), lines(10, nil)[5:]...) // line 4 removed 125 - hunks := []*gitmirrorv1.Hunk{hunk(&gitmirrorv1.LinePair{Lhs: u32(4)})} 123 + func TestBuildHunks_MarksNovelAndAddsContext(t *testing.T) { 124 + base := lines(10, map[int]string{5: "old"}) 125 + head := lines(10, map[int]string{5: "new"}) 126 + built := buildHunks(base, head, []*gitmirrorv1.Hunk{hunk(&gitmirrorv1.LinePair{Lhs: u32(5), Rhs: u32(5)})}) 127 + if len(built) != 1 { 128 + t.Fatalf("want 1 hunk, got %d", len(built)) 129 + } 130 + var nChanged, nContext int 131 + for _, r := range built[0].rows { 132 + if r.changed { 133 + nChanged++ 134 + if r.lhs != 5 || r.rhs != 5 { 135 + t.Fatalf("changed row = %+v, want {5,5}", r) 136 + } 137 + } else { 138 + nContext++ 139 + } 140 + } 141 + if nChanged != 1 { 142 + t.Fatalf("want 1 changed row, got %d", nChanged) 143 + } 144 + // Side-by-side display shows numContextLines (3) context rows each side, index-based 145 + // (difftastic's matched_lines_indexes_for_hunk), both clamped inside the 10-line file. 146 + if nContext != 6 { 147 + t.Fatalf("want 6 context rows, got %d: %+v", nContext, built[0].rows) 148 + } 149 + } 126 150 127 - pairs, change := alignFile(base, head, hunks) 151 + func TestBuildHunks_MergedKeepsInteriorContext(t *testing.T) { 152 + // Regression: a deletion and a nearby addition, merged into one hunk, are separated by 153 + // unchanged lines. Those interior context rows must survive (reconstructing context via 154 + // extractLines/fillBetween dropped them across the deletion->addition boundary). 155 + // Base: 20 lines; delete base line 5. Head: base with line 5 removed and a new line 156 + // inserted at head index 8 (a few lines after the deletion point). 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.. 128 163 129 - // The deletion pair. 130 - del := -1 131 - for i, c := range change { 132 - if c { 133 - del = i 134 - break 164 + hunks := []*gitmirrorv1.Hunk{ 165 + hunk(&gitmirrorv1.LinePair{Lhs: u32(5)}), // delete base line 5 166 + hunk(&gitmirrorv1.LinePair{Rhs: u32(8)}), // insert head line 8 167 + } 168 + built := buildHunks(base, head, hunks) 169 + if len(built) != 1 { 170 + t.Fatalf("want 1 merged hunk, got %d", len(built)) 171 + } 172 + // The unchanged lines between the deletion (base 5) and the insertion (head 8, i.e. base 173 + // 8) — base lines 6,7,8 — must appear as context rows (both sides present, unchanged). 174 + want := map[int]bool{6: false, 7: false, 8: false} 175 + got := map[int]bool{} 176 + for _, r := range built[0].rows { 177 + if !r.changed && r.lhs >= 0 { 178 + if _, ok := want[r.lhs]; ok { 179 + got[r.lhs] = true 180 + } 135 181 } 136 182 } 137 - if del < 0 || pairs[del] != (linePair{lhs: 4, rhs: -1}) { 138 - t.Fatalf("deletion pair = %+v (idx %d)", pairs[del], del) 183 + for ln := range want { 184 + if !got[ln] { 185 + t.Fatalf("interior context base line %d missing from merged hunk: %+v", ln, built[0].rows) 186 + } 139 187 } 140 - // Trailing context after the deletion: base 5..9 <-> head 4..8. 141 - want := []linePair{{5, 4}, {6, 5}, {7, 6}, {8, 7}, {9, 8}} 142 - got := pairs[del+1:] 143 - if len(got) != len(want) { 144 - t.Fatalf("trailing context len = %d, want %d: %+v", len(got), len(want), got) 188 + } 189 + 190 + func TestIndexesForHunk_SliceAndClamp(t *testing.T) { 191 + base := lines(20, nil) 192 + head := lines(20, nil) 193 + // Single change at line 10. 194 + pairs, _ := alignFile(base, head, []*gitmirrorv1.Hunk{hunk(&gitmirrorv1.LinePair{Lhs: u32(10), Rhs: u32(10)})}) 195 + lo, hi := indexesForHunk(pairs, []linePair{{10, 10}}, numContextLines) 196 + if lo != 7 || hi != 14 { // [10-3 .. 10+3+1) 197 + t.Fatalf("slice = [%d,%d), want [7,14)", lo, hi) 145 198 } 146 - for i := range want { 147 - if got[i] != want[i] { 148 - t.Fatalf("trailing pair %d = %+v, want %+v", i, got[i], want[i]) 149 - } 199 + // Clamp at start of file. 200 + pairs2, _ := alignFile(base, head, []*gitmirrorv1.Hunk{hunk(&gitmirrorv1.LinePair{Lhs: u32(1), Rhs: u32(1)})}) 201 + lo2, _ := indexesForHunk(pairs2, []linePair{{1, 1}}, numContextLines) 202 + if lo2 != 0 { 203 + t.Fatalf("start clamp = %d, want 0", lo2) 150 204 } 151 205 } 152 206 ··· 201 255 t.Fatalf("interior context should have no +/- marker: %q", line) 202 256 } 203 257 } 204 - // The actual changes still render. 205 258 for _, want := range []string{"- old5", "+ new5", "- old10", "+ new10"} { 206 259 if !strings.Contains(out, want) { 207 260 t.Fatalf("missing %q in:\n%s", want, out)
+582 -83
appview/pulls/pull2.go
··· 6 6 "fmt" 7 7 "io" 8 8 "net/http" 9 + "slices" 9 10 "strconv" 10 11 "strings" 12 + "time" 11 13 14 + "github.com/bluesky-social/indigo/api/atproto" 12 15 "github.com/bluesky-social/indigo/atproto/syntax" 13 16 "github.com/bluesky-social/indigo/lex/util" 14 17 indigoxrpc "github.com/bluesky-social/indigo/xrpc" ··· 36 39 // 1. rebase B<-C to D 37 40 // 2. compare tree of C and D 38 41 42 + var pull = &models.Pull2{ 43 + ID: 123, 44 + RepoDid: syntax.DID("did:plc:ofcpzigpnwrgtpl3ojrpg3y7"), 45 + AuthorDid: syntax.DID("did:example:alice"), 46 + Rkey: syntax.RecordKey("pr-rkey"), 47 + Title: "Spindle-owned data", 48 + Body: "something something", 49 + TargetBranch: "master", 50 + Versions: []models.PullVersion{ 51 + { 52 + SourceRepo: syntax.DID("did:plc:ofcpzigpnwrgtpl3ojrpg3y7"), 53 + Head: "443b2e347c3f77bc9aa2481adb518440f204b870", 54 + Base: "5c97f1cc886344bb8a9eba315a39c0fc14cee51f", 55 + }, 56 + }, 57 + Created: time.Now(), 58 + } 59 + 60 + var comments = []*models.Comment{ 61 + { 62 + Did: syntax.DID("did:example:alice"), 63 + Collection: syntax.NSID(tangled.FeedCommentNSID), 64 + Rkey: syntax.RecordKey("comment"), 65 + Cid: syntax.CID(""), 66 + Subject: atproto.RepoStrongRef{ 67 + Uri: "at://did:example:alice/sh.tangled.repo.pull/pr-rkey", 68 + Cid: "", 69 + }, 70 + Body: tangled.MarkupMarkdown{ 71 + Text: "review comment", 72 + }, 73 + Created: time.Now(), 74 + }, 75 + } 76 + 39 77 // PullInterDiff is router for /pulls/{pull}/{version}..{version}/{change} 40 78 // 41 79 // Examples: 42 80 // - /pulls/123/0..2/all 43 81 // - /pulls/123/0..2/nrpytyzw 44 82 func (s *Pulls) PullInterDiff(w http.ResponseWriter, r *http.Request) { 45 - l := s.logger.With("handler", "PullRound") 83 + l := s.logger.With("handler", "PullInterDiff") 46 84 ctx := r.Context() 47 85 48 - pull, ok := r.Context().Value("pull").(*models.Pull) 49 - if !ok { 50 - l.Error("failed to get pull") 51 - s.pages.Error500(w) 52 - return 53 - } 86 + _ = l 54 87 55 88 var ( 56 89 version1 = 0 ··· 84 117 var commits1, commits2 []types.Commit 85 118 g, gctx := errgroup.WithContext(ctx) 86 119 g.Go(func() error { 87 - commits1, err = getTempListCommits(gctx, xrpcc, pull.SourceRepoDid(), base, head1) 120 + commits1, err = getTempListCommits(gctx, xrpcc, pull.LatestVersion().SourceRepo, base, head1) 88 121 return err 89 122 }) 90 123 g.Go(func() error { 91 - commits2, err = getTempListCommits(gctx, xrpcc, pull.SourceRepoDid(), base, head2) 124 + commits2, err = getTempListCommits(gctx, xrpcc, pull.LatestVersion().SourceRepo, base, head2) 92 125 return err 93 126 }) 94 127 if err := g.Wait(); err != nil { ··· 128 161 // - /pulls/123/2/a53ab251e..d8add468c 129 162 // - /pulls/123/2/d8add468c 130 163 func (s *Pulls) PullDiff(w http.ResponseWriter, r *http.Request) { 131 - l := s.logger.With("handler", "PullRound") 164 + l := s.logger.With("handler", "PullDiff") 132 165 ctx := r.Context() 133 166 134 - pull, ok := r.Context().Value("pull").(*models.Pull) 135 - if !ok { 136 - l.Error("failed to get pull") 137 - s.pages.Error500(w) 138 - return 139 - } 167 + _ = l 140 168 141 169 var err error 142 170 143 171 var version int 144 172 var versionRaw = chi.URLParam(r, "version") 145 173 if versionRaw == "latest" { 146 - version = pull.LastRoundNumber() 174 + version = pull.LatestVersionId() 147 175 } else { 148 176 version, err = strconv.Atoi(versionRaw) 149 177 if err != nil { 150 178 // invalid version number. redirect 151 179 http.Redirect(w, r, 152 - fmt.Sprintf("/%s/pulls/%d/latest", pull.Repo.RepoIdentifier(), pull.ID), 180 + fmt.Sprintf("/%s/pulls/%d/latest", pull.RepoDid, pull.ID), 153 181 http.StatusSeeOther, 154 182 ) 155 183 return ··· 160 188 base, head, err := parseRevRange(range_) 161 189 if err != nil { 162 190 http.Redirect(w, r, 163 - fmt.Sprintf("/%s/pulls/%d/%s", pull.Repo.RepoIdentifier(), pull.ID, versionRaw), 191 + fmt.Sprintf("/%s/pulls/%d/%s", pull.RepoDid, pull.ID, versionRaw), 164 192 http.StatusSeeOther, 165 193 ) 166 194 return ··· 185 213 base = branch.Hash 186 214 } 187 215 if head == "head" { 188 - head = pull.HEAD() 216 + head = pull.LatestVersion().Head 189 217 } 190 218 191 - sourceRepoDid := pull.SourceRepoDid() 219 + sourceRepoDid := pull.LatestVersion().SourceRepo 192 220 193 221 // 2. list diverged commits using knotmirror (BASE..HEAD) -> ([]commit) 194 222 // - knotmirror needs on-demand fetch implementation for this ··· 273 301 if err != nil { 274 302 return err 275 303 } 276 - f.baseLines = splitLines(baseBlob) 277 - f.headLines = splitLines(headBlob) 304 + f.baseLines = strings.Split(strings.TrimSuffix(string(baseBlob), "\n"), "\n") 305 + f.headLines = strings.Split(strings.TrimSuffix(string(headBlob), "\n"), "\n") 278 306 return nil 279 307 }) 280 308 } ··· 282 310 panic(err) 283 311 } 284 312 313 + // <div><span>code</div></div> 314 + 285 315 // c. render each file's diff, following difftastic's display layout 286 316 // (github.com/Wilfred/difftastic src/display): side-by-side by default, inline 287 317 // when ?view=unified. Word/token highlighting and line wrapping are omitted. ··· 332 362 rows []diffRow 333 363 } 334 364 335 - // buildHunks aligns the whole file (alignFile), groups changes into context windows 336 - // (groupHunks), and slices each window into a displayHunk of rows. 365 + // maxDistance mirrors difftastic's MAX_DISTANCE (hunks.rs): the most unchanged lines that 366 + // may sit between two changes before they are split into separate hunks (before context). 367 + const maxDistance = 4 368 + 369 + // buildHunks ports difftastic's src/display pre-processing (hunks.rs + context.rs): it 370 + // regroups the changed line pairs into hunks by per-side line distance, merges hunks whose 371 + // context windows overlap, and expands each with context lines, yielding displayHunks. 372 + // 373 + // gitmirror's Hunk.Lines are difftastic's novel line pairs; we lack its token-level 374 + // MatchedPos, but a line-granular diff's unchanged lines form a 1:1 bijection, which stands 375 + // in for difftastic's opposite_positions map (buildOpposites). 337 376 func buildHunks(baseLines, headLines []string, hunks []*gitmirrorv1.Hunk) []displayHunk { 338 - pairs, change := alignFile(baseLines, headLines, hunks) 377 + oppLhs, oppRhs, maxLhs, maxRhs := buildOpposites(baseLines, headLines, hunks) 378 + 379 + var flat []linePair 380 + for _, h := range hunks { 381 + for _, lp := range h.Lines { 382 + flat = append(flat, toPair(lp)) 383 + } 384 + } 385 + 386 + merged := mergeAdjacent(linesToHunks(flat), oppLhs, oppRhs, maxLhs, maxRhs, numContextLines) 387 + 388 + // Display rows come from the whole-file aligned list sliced per hunk (difftastic's 389 + // side_by_side: all_matched_lines_filled + matched_lines_indexes_for_hunk). Slicing a 390 + // contiguous list keeps the interior context lines of merged hunks, which reconstructing 391 + // via extractLines/fillBetween drops across a deletion->addition boundary. 392 + pairs, changed := alignFile(baseLines, headLines, hunks) 393 + 339 394 var out []displayHunk 340 - for _, rng := range groupHunks(change) { 341 - var h displayHunk 342 - for i := rng[0]; i < rng[1]; i++ { 343 - h.rows = append(h.rows, diffRow{lhs: pairs[i].lhs, rhs: pairs[i].rhs, changed: change[i]}) 395 + prevEnd := 0 396 + for _, h := range merged { 397 + lo, hi := indexesForHunk(pairs, h, numContextLines) 398 + if lo < prevEnd { 399 + lo = prevEnd // don't re-emit rows shared with the previous hunk's slice 400 + } 401 + var dh displayHunk 402 + for i := lo; i < hi; i++ { 403 + dh.rows = append(dh.rows, diffRow{lhs: pairs[i].lhs, rhs: pairs[i].rhs, changed: changed[i]}) 344 404 } 345 - out = append(out, h) 405 + out = append(out, dh) 406 + prevEnd = hi 346 407 } 347 408 return out 348 409 } 349 410 350 - // alignFile walks both blobs in lockstep and produces one linePair per displayed line of 351 - // the whole file, plus a parallel `change` flag marking pairs that came from a gitmirror 352 - // hunk's changed Lines (everything else is unchanged context). 353 - // 354 - // gitmirror hunks carry only changed lines; unchanged lines are a 1:1 bijection between 355 - // the blobs, so the two cursors (li/ri) advance together across the gaps. A hunk side that 356 - // has no lines (pure insertion/deletion) gets its start derived from the other side. 357 - func alignFile(baseLines, headLines []string, hunks []*gitmirrorv1.Hunk) ([]linePair, []bool) { 358 - var pairs []linePair 359 - var change []bool 411 + // alignFile builds the whole-file aligned list (difftastic's all_matched_lines_filled, 412 + // specialized to a line-granular diff): every displayed line as a pair, plus a parallel 413 + // `changed` flag for pairs that came from a gitmirror hunk. Unchanged lines are a 1:1 414 + // bijection, so the two cursors advance together across gaps. 415 + func alignFile(baseLines, headLines []string, hunks []*gitmirrorv1.Hunk) (pairs []linePair, changed []bool) { 416 + li, ri := 0, 0 417 + emitContext := func(n int) { 418 + for k := range n { 419 + pairs = append(pairs, linePair{lhs: li + k, rhs: ri + k}) 420 + changed = append(changed, false) 421 + } 422 + li += n 423 + ri += n 424 + } 425 + for _, h := range hunks { 426 + lhsStart, _, ok := hunkStart(h, li, ri) 427 + if !ok { 428 + continue 429 + } 430 + emitContext(lhsStart - li) // unchanged gap before this change (== rhsStart-ri) 431 + for _, lp := range h.Lines { 432 + p := toPair(lp) 433 + if p.lhs >= 0 { 434 + li = p.lhs + 1 435 + } 436 + if p.rhs >= 0 { 437 + ri = p.rhs + 1 438 + } 439 + pairs = append(pairs, p) 440 + changed = append(changed, true) 441 + } 442 + } 443 + // Trailing unchanged tail (both sides advance equally). 444 + for li < len(baseLines) && ri < len(headLines) { 445 + pairs = append(pairs, linePair{lhs: li, rhs: ri}) 446 + changed = append(changed, false) 447 + li++ 448 + ri++ 449 + } 450 + return pairs, changed 451 + } 452 + 453 + // indexesForHunk returns the [start,end) slice of the whole-file aligned pairs to display 454 + // for a merged hunk: the span from its smallest to largest novel line, expanded by n context 455 + // lines on each side and clamped (difftastic's matched_lines_indexes_for_hunk). 456 + func indexesForHunk(pairs, hunkLines []linePair, n int) (start, end int) { 457 + minLhs, minRhs, maxLhs, maxRhs := -1, -1, -1, -1 458 + for _, lp := range hunkLines { 459 + if lp.lhs >= 0 { 460 + if minLhs < 0 { 461 + minLhs = lp.lhs 462 + } 463 + maxLhs = lp.lhs 464 + } 465 + if lp.rhs >= 0 { 466 + if minRhs < 0 { 467 + minRhs = lp.rhs 468 + } 469 + maxRhs = lp.rhs 470 + } 471 + } 472 + smallest, largest := linePair{minLhs, minRhs}, linePair{maxLhs, maxRhs} 473 + 474 + start = 0 475 + for i, p := range pairs { 476 + if eitherSideEqual(p, smallest) { 477 + start = i 478 + break 479 + } 480 + } 481 + end = len(pairs) 482 + for i := len(pairs) - 1; i >= 0; i-- { 483 + if eitherSideEqual(pairs[i], largest) { 484 + end = i + 1 485 + break 486 + } 487 + } 488 + 489 + start = max(0, start-n) 490 + end = min(len(pairs), end+n) 491 + return start, end 492 + } 493 + 494 + // eitherSideEqual reports whether a and b share a present line number on the same side 495 + // (difftastic's either_side_equal). 496 + func eitherSideEqual(a, b linePair) bool { 497 + if a.lhs >= 0 && a.lhs == b.lhs { 498 + return true 499 + } 500 + if a.rhs >= 0 && a.rhs == b.rhs { 501 + return true 502 + } 503 + return false 504 + } 505 + 506 + // toPair converts a proto LinePair to a linePair, mapping absent sides to -1. 507 + func toPair(lp *gitmirrorv1.LinePair) linePair { 508 + p := linePair{lhs: -1, rhs: -1} 509 + if lp.Lhs != nil { 510 + p.lhs = int(*lp.Lhs) 511 + } 512 + if lp.Rhs != nil { 513 + p.rhs = int(*lp.Rhs) 514 + } 515 + return p 516 + } 517 + 518 + // buildOpposites is our stand-in for difftastic's opposite_positions: the 1:1 mapping 519 + // between unchanged base and head lines. It walks the gitmirror hunks with two cursors — 520 + // unchanged runs advance both sides together — recording each unchanged line's counterpart. 521 + // 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) { 523 + oppLhs, oppRhs = map[int]int{}, map[int]int{} 360 524 li, ri := 0, 0 361 525 362 - // emit appends a run of unchanged context pairs [lx..lx+n) <-> [rx..rx+n) and advances 363 - // both cursors past them (lx == li and rx == ri at every call site). 364 - emitContext := func(lx, rx, n int) { 365 - for j := range n { 366 - pairs = append(pairs, linePair{lhs: lx + j, rhs: rx + j}) 367 - change = append(change, false) 526 + pair := func(gap int) { 527 + for k := range gap { 528 + oppLhs[li+k] = ri + k 529 + oppRhs[ri+k] = li + k 368 530 } 369 - li = lx + n 370 - ri = rx + n 531 + li += gap 532 + ri += gap 371 533 } 372 534 373 535 for _, h := range hunks { ··· 375 537 if !ok { 376 538 continue 377 539 } 378 - emitContext(li, ri, lhsStart-li) // gap before this change (== rhsStart-ri) 379 - 540 + pair(lhsStart - li) // unchanged gap before this change 380 541 for _, lp := range h.Lines { 381 - p := linePair{lhs: -1, rhs: -1} 382 542 if lp.Lhs != nil { 383 - p.lhs = int(*lp.Lhs) 384 - li = p.lhs + 1 543 + li = int(*lp.Lhs) + 1 385 544 } 386 545 if lp.Rhs != nil { 387 - p.rhs = int(*lp.Rhs) 388 - ri = p.rhs + 1 546 + ri = int(*lp.Rhs) + 1 389 547 } 390 - pairs = append(pairs, p) 391 - change = append(change, true) 392 548 } 393 549 } 394 - // Trailing unchanged tail. 395 - emitContext(li, ri, len(baseLines)-li) 396 - return pairs, change 550 + // Trailing unchanged tail (both sides advance equally). 551 + for li < len(baseLines) && ri < len(headLines) { 552 + oppLhs[li] = ri 553 + oppRhs[ri] = li 554 + li++ 555 + ri++ 556 + } 557 + return oppLhs, oppRhs, len(baseLines) - 1, len(headLines) - 1 397 558 } 398 559 399 560 // hunkStart returns the first changed line number on each side, deriving the empty side ··· 420 581 return lhsStart, rhsStart, true 421 582 } 422 583 423 - // groupHunks expands every changed line into a context window of numContextLines+1 on each 424 - // side and unions overlapping/adjacent windows, yielding [start,end) ranges into the 425 - // aligned pairs. This is difftastic's merge_adjacent expressed over the pre-aligned list. 426 - func groupHunks(change []bool) [][2]int { 427 - const ctx = numContextLines + 1 428 - var out [][2]int 429 - for i, c := range change { 430 - if !c { 584 + // zipPadShorter pairs a[i] with b[i], padding the shorter side with -1 (difftastic's 585 + // zip_pad_shorter). 586 + func zipPadShorter(a, b []int) []linePair { 587 + out := make([]linePair, 0, max(len(a), len(b))) 588 + for i := 0; i < len(a) || i < len(b); i++ { 589 + p := linePair{lhs: -1, rhs: -1} 590 + if i < len(a) { 591 + p.lhs = a[i] 592 + } 593 + if i < len(b) { 594 + p.rhs = b[i] 595 + } 596 + out = append(out, p) 597 + } 598 + return out 599 + } 600 + 601 + // fillBetween returns the unchanged line pairs strictly between two changed pairs, so a 602 + // hunk's lines become contiguous per side (difftastic's fill_between). 603 + func fillBetween(prevLhs, nextLhs, prevRhs, nextRhs int) []linePair { 604 + var lhs, rhs []int 605 + if prevLhs >= 0 && nextLhs >= 0 { 606 + for x := prevLhs + 1; x < nextLhs; x++ { 607 + lhs = append(lhs, x) 608 + } 609 + } 610 + if prevRhs >= 0 && nextRhs >= 0 { 611 + for x := prevRhs + 1; x < nextRhs; x++ { 612 + rhs = append(rhs, x) 613 + } 614 + } 615 + return zipPadShorter(lhs, rhs) 616 + } 617 + 618 + // extractLines fills the interior gaps of a hunk's line pairs (difftastic's extract_lines). 619 + func extractLines(lines []linePair) []linePair { 620 + maxLhs, maxRhs := -1, -1 621 + var out []linePair 622 + for _, lp := range lines { 623 + out = append(out, fillBetween(maxLhs, lp.lhs, maxRhs, lp.rhs)...) 624 + if lp.lhs >= 0 { 625 + maxLhs = lp.lhs 626 + } 627 + if lp.rhs >= 0 { 628 + maxRhs = lp.rhs 629 + } 630 + out = append(out, lp) 631 + } 632 + return out 633 + } 634 + 635 + // padBefore returns up to numContextLines+1 line numbers immediately before ln, clamped at 636 + // 0, ascending (difftastic's pad_before). The extra line lets immediately-adjacent hunks 637 + // merge. 638 + func padBefore(ln, n int) []int { 639 + var out []int 640 + cur := ln 641 + for i := 0; i < n+1; i++ { 642 + if cur == 0 { 643 + break 644 + } 645 + cur-- 646 + out = append(out, cur) 647 + } 648 + slices.Reverse(out) 649 + return out 650 + } 651 + 652 + // padAfter returns up to numContextLines+1 line numbers immediately after ln, clamped at 653 + // maxLine (difftastic's pad_after). 654 + func padAfter(ln, maxLine, n int) []int { 655 + var out []int 656 + cur := ln 657 + for i := 0; i < n+1; i++ { 658 + if cur >= maxLine { 659 + break 660 + } 661 + cur++ 662 + out = append(out, cur) 663 + } 664 + return out 665 + } 666 + 667 + // beforeWithOpposites assigns each before-context line its counterpart on the opposite 668 + // side, seeding from the opposite map then walking backwards (difftastic's 669 + // before_with_opposites). Input lines are ascending; result pairs are (line, opposite). 670 + func beforeWithOpposites(before []int, opp map[int]int) []linePair { 671 + lines := slices.Clone(before) 672 + slices.Reverse(lines) 673 + 674 + prevOpp := -1 675 + var res []linePair 676 + for _, line := range lines { 677 + cur := -1 678 + if prevOpp >= 0 { 679 + if prevOpp > 0 { 680 + cur = prevOpp - 1 681 + } 682 + } else if v, ok := opp[line]; ok { 683 + cur = v 684 + } 685 + res = append(res, linePair{lhs: line, rhs: cur}) 686 + if cur >= 0 { 687 + prevOpp = cur 688 + } 689 + } 690 + slices.Reverse(res) 691 + return res 692 + } 693 + 694 + // afterWithOpposites is the forward counterpart of beforeWithOpposites (difftastic's 695 + // after_with_opposites): opposites walk forward and stop at maxOpp; the first (map-seeded) 696 + // opposite is dropped if it is not past prevMaxOpp (-1 = none). 697 + func afterWithOpposites(after []int, opp map[int]int, prevMaxOpp, maxOpp int) []linePair { 698 + prevOpp := -1 699 + var res []linePair 700 + for _, line := range after { 701 + cur := -1 702 + if prevOpp >= 0 { 703 + if prevOpp < maxOpp { 704 + cur = prevOpp + 1 705 + } 706 + } else if v, ok := opp[line]; ok { 707 + if prevMaxOpp < 0 || v > prevMaxOpp { 708 + cur = v 709 + } 710 + } 711 + res = append(res, linePair{lhs: line, rhs: cur}) 712 + if cur >= 0 { 713 + prevOpp = cur 714 + } 715 + } 716 + return res 717 + } 718 + 719 + // flipPairs swaps the lhs/rhs of each pair (difftastic's flip_tuples). 720 + func flipPairs(pairs []linePair) []linePair { 721 + out := make([]linePair, len(pairs)) 722 + for i, p := range pairs { 723 + out[i] = linePair{lhs: p.rhs, rhs: p.lhs} 724 + } 725 + return out 726 + } 727 + 728 + // calculateBeforeContext returns the context pairs preceding a hunk, anchored to whichever 729 + // side the first line has (difftastic's calculate_before_context). 730 + func calculateBeforeContext(lines []linePair, oppLhs, oppRhs map[int]int, n int) []linePair { 731 + if len(lines) == 0 { 732 + return nil 733 + } 734 + first := lines[0] 735 + switch { 736 + case first.lhs >= 0: 737 + return beforeWithOpposites(padBefore(first.lhs, n), oppLhs) 738 + case first.rhs >= 0: 739 + return flipPairs(beforeWithOpposites(padBefore(first.rhs, n), oppRhs)) 740 + } 741 + return nil 742 + } 743 + 744 + // calculateAfterContext returns the context pairs following a hunk (difftastic's 745 + // calculate_after_context). maxLhs/maxRhs are the blobs' last line numbers. 746 + func calculateAfterContext(lines []linePair, oppLhs, oppRhs map[int]int, maxLhs, maxRhs, n int) []linePair { 747 + if len(lines) == 0 { 748 + return nil 749 + } 750 + last := lines[len(lines)-1] 751 + switch { 752 + case last.lhs >= 0: 753 + maxOpp := -1 754 + for _, lp := range lines { 755 + if lp.rhs >= 0 { 756 + maxOpp = lp.rhs 757 + } 758 + } 759 + return afterWithOpposites(padAfter(last.lhs, maxLhs, n), oppLhs, maxOpp, maxRhs) 760 + case last.rhs >= 0: 761 + maxOpp := -1 762 + for _, lp := range lines { 763 + if lp.lhs >= 0 { 764 + maxOpp = lp.lhs 765 + } 766 + } 767 + return flipPairs(afterWithOpposites(padAfter(last.rhs, maxRhs, n), oppRhs, maxOpp, maxLhs)) 768 + } 769 + return nil 770 + } 771 + 772 + // addContext prepends before-context and appends after-context to a hunk's lines 773 + // (difftastic's add_context). 774 + func addContext(lines []linePair, oppLhs, oppRhs map[int]int, maxLhs, maxRhs, n int) []linePair { 775 + before := calculateBeforeContext(lines, oppLhs, oppRhs, n) 776 + after := calculateAfterContext(append(slices.Clone(before), lines...), oppLhs, oppRhs, maxLhs, maxRhs, n) 777 + 778 + out := make([]linePair, 0, len(before)+len(lines)+len(after)) 779 + out = append(out, before...) 780 + out = append(out, lines...) 781 + out = append(out, after...) 782 + return out 783 + } 784 + 785 + // enforceIncreasing drops any line number that would go backwards, keeping each side 786 + // monotonically increasing (difftastic's enforce_increasing). 787 + func enforceIncreasing(lines []linePair) []linePair { 788 + var out []linePair 789 + maxLhs, maxRhs := -1, -1 790 + for _, lp := range lines { 791 + l, r := lp.lhs, lp.rhs 792 + if maxLhs < 0 { 793 + maxLhs = l 794 + } else if l >= 0 && l > maxLhs { 795 + maxLhs = l 796 + } else { 797 + l = -1 798 + } 799 + if maxRhs < 0 { 800 + maxRhs = r 801 + } else if r >= 0 && r > maxRhs { 802 + maxRhs = r 803 + } else { 804 + r = -1 805 + } 806 + if l >= 0 || r >= 0 { 807 + out = append(out, linePair{lhs: l, rhs: r}) 808 + } 809 + } 810 + return out 811 + } 812 + 813 + // linesAreClose reports whether a line is within maxDistance of the last seen line on 814 + // either side (difftastic's lines_are_close). 815 + func linesAreClose(maxLhs, maxRhs int, lp linePair) bool { 816 + if maxLhs >= 0 && lp.lhs >= 0 && lp.lhs <= maxLhs+maxDistance { 817 + return true 818 + } 819 + if maxRhs >= 0 && lp.rhs >= 0 && lp.rhs <= maxRhs+maxDistance { 820 + return true 821 + } 822 + return false 823 + } 824 + 825 + // linesToHunks splits changed line pairs into hunks by per-side proximity (difftastic's 826 + // lines_to_hunks over matched novel lines). 827 + func linesToHunks(flat []linePair) [][]linePair { 828 + var hunks [][]linePair 829 + var cur []linePair 830 + maxLhs, maxRhs := -1, -1 831 + for _, lp := range enforceIncreasing(flat) { 832 + if len(cur) == 0 || linesAreClose(maxLhs, maxRhs, lp) { 833 + cur = append(cur, lp) 834 + } else { 835 + hunks = append(hunks, cur) 836 + cur = []linePair{lp} 837 + } 838 + if lp.lhs >= 0 { 839 + maxLhs = lp.lhs 840 + } 841 + if lp.rhs >= 0 { 842 + maxRhs = lp.rhs 843 + } 844 + } 845 + if len(cur) > 0 { 846 + hunks = append(hunks, cur) 847 + } 848 + return hunks 849 + } 850 + 851 + // mergeAdjacent merges hunks whose context-padded line sets overlap on either side 852 + // (difftastic's merge_adjacent). Merging concatenates and de-duplicates lines. 853 + func mergeAdjacent(hunks [][]linePair, oppLhs, oppRhs map[int]int, maxLhs, maxRhs, n int) [][]linePair { 854 + var merged [][]linePair 855 + var prev []linePair 856 + var prevLhs, prevRhs map[int]struct{} 857 + 858 + for _, h := range hunks { 859 + lhsSet, rhsSet := lineSets(addContext(extractLines(h), oppLhs, oppRhs, maxLhs, maxRhs, n)) 860 + if prev == nil { 861 + prev, prevLhs, prevRhs = h, lhsSet, rhsSet 431 862 continue 432 863 } 433 - start := max(0, i-ctx) 434 - end := min(len(change), i+ctx+1) 435 - if n := len(out); n > 0 && start <= out[n-1][1] { 436 - out[n-1][1] = max(out[n-1][1], end) // merge into previous window 864 + if disjoint(lhsSet, prevLhs) && disjoint(rhsSet, prevRhs) { 865 + merged = append(merged, prev) 866 + prev, prevLhs, prevRhs = h, lhsSet, rhsSet 437 867 } else { 438 - out = append(out, [2]int{start, end}) 868 + prev = mergeHunkLines(prev, h) 869 + unionInto(prevLhs, lhsSet) 870 + unionInto(prevRhs, rhsSet) 439 871 } 440 872 } 873 + if prev != nil { 874 + merged = append(merged, prev) 875 + } 876 + return merged 877 + } 878 + 879 + // mergeHunkLines concatenates two hunks' lines and drops duplicate line numbers per side, 880 + // nulling the already-seen side (difftastic's Hunk::merge). 881 + func mergeHunkLines(a, b []linePair) []linePair { 882 + lhsSeen, rhsSeen := map[int]struct{}{}, map[int]struct{}{} 883 + var out []linePair 884 + for _, lp := range append(slices.Clone(a), b...) { 885 + lhsDupe, rhsDupe := false, false 886 + if lp.lhs >= 0 { 887 + _, lhsDupe = lhsSeen[lp.lhs] 888 + lhsSeen[lp.lhs] = struct{}{} 889 + } 890 + if lp.rhs >= 0 { 891 + _, rhsDupe = rhsSeen[lp.rhs] 892 + rhsSeen[lp.rhs] = struct{}{} 893 + } 894 + if lhsDupe && rhsDupe { 895 + continue 896 + } 897 + np := lp 898 + if lhsDupe { 899 + np.lhs = -1 900 + } 901 + if rhsDupe { 902 + np.rhs = -1 903 + } 904 + out = append(out, np) 905 + } 441 906 return out 442 907 } 443 908 909 + // lineSets returns the sets of present lhs and rhs line numbers in a run of pairs. 910 + func lineSets(lines []linePair) (lhs, rhs map[int]struct{}) { 911 + lhs, rhs = map[int]struct{}{}, map[int]struct{}{} 912 + for _, lp := range lines { 913 + if lp.lhs >= 0 { 914 + lhs[lp.lhs] = struct{}{} 915 + } 916 + if lp.rhs >= 0 { 917 + rhs[lp.rhs] = struct{}{} 918 + } 919 + } 920 + return 921 + } 922 + 923 + func disjoint(a, b map[int]struct{}) bool { 924 + for k := range a { 925 + if _, ok := b[k]; ok { 926 + return false 927 + } 928 + } 929 + return true 930 + } 931 + 932 + func unionInto(dst, src map[int]struct{}) { 933 + for k := range src { 934 + dst[k] = struct{}{} 935 + } 936 + } 937 + 444 938 // renderSideBySide lays out a hunk's rows in two columns: 445 939 // 446 940 // [lhsNum][- ]lhsContent [rhsNum][+ ]rhsContent ··· 465 959 } 466 960 lmark := mark(r.changed && r.lhs >= 0, '-') 467 961 rmark := mark(r.changed && r.rhs >= 0, '+') 468 - fmt.Fprintf(b, "%s%s%s%-*s%s|%s%s\n", 469 - gutter(r.lhs), gutter(r.rhs), lmark, contentWidth, lhs, spacer, rmark, rhs) 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) 470 964 } 471 965 } 472 966 ··· 478 972 r := h.rows[i] 479 973 if !r.changed { 480 974 if r.lhs >= 0 { 481 - fmt.Fprintf(b, "%s%s%s\n", gutter(r.lhs), mark(false, ' '), baseLines[r.lhs]) 975 + fmt.Fprintf(b, "%s%s%s%s\n", gutter(r.lhs), gutter(r.rhs), mark(false, ' '), baseLines[r.lhs]) 482 976 } 483 977 i++ 484 978 continue ··· 488 982 for j < len(h.rows) && h.rows[j].changed { 489 983 j++ 490 984 } 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). 491 987 for _, cr := range h.rows[i:j] { 492 988 if cr.lhs >= 0 { 493 - fmt.Fprintf(b, "%s%s%s\n", gutter(cr.lhs), mark(true, '-'), baseLines[cr.lhs]) 989 + fmt.Fprintf(b, "%s%s%s%s\n", gutter(cr.lhs), gutter(-1), mark(true, '-'), baseLines[cr.lhs]) 494 990 } 495 991 } 992 + // Added lines carry only the rhs number; the lhs gutter is blank. 496 993 for _, cr := range h.rows[i:j] { 497 994 if cr.rhs >= 0 { 498 - fmt.Fprintf(b, "%s%s%s\n", gutter(cr.rhs), mark(true, '+'), headLines[cr.rhs]) 995 + fmt.Fprintf(b, "%s%s%s%s\n", gutter(-1), gutter(cr.rhs), mark(true, '+'), headLines[cr.rhs]) 499 996 } 500 997 } 501 998 i = j ··· 589 1086 // parseRevRange parses <head>..<base> string. 590 1087 // base and head will default to "base" and "head" when omitted. 591 1088 func parseRevRange(range_ string) (base string, head string, err error) { 592 - panic("unimplemented") 1089 + return "base", "head", nil 1090 + // panic("unimplemented") 593 1091 } 594 1092 595 1093 // parseVersionRange parses <version>..<version> string. 596 1094 // Each versions will default to "base" and "latest" when omitted. 597 1095 func parseVersionRange(range_ string) (base string, head string, err error) { 598 - panic("unimplemented") 1096 + return "base", "latest", nil 599 1097 } 600 1098 601 1099 func getTempListCommits(ctx context.Context, xrpcc util.LexClient, repo syntax.DID, base, head string) ([]types.Commit, error) { 602 - panic("unimplemented") 1100 + // panic("unimplemented") 1101 + return nil, nil 603 1102 // raw, err := tangled.GitTempListCommits(ctx, xrpcc, "", 1000, head, repo.String()) 604 1103 // if err != nil { 605 1104 // return nil, err ··· 621 1120 // gitmirror 622 1121 // - git.ListCommitsSinceMergeBase(repo, base, head) 623 1122 // - git.Diff(repo, base, head, mode) 624 - // - git.Interdiff(repo, 1123 + // - git.Interdiff(repo, 625 1124 626 1125 // for interdiff, we want: from{start,end}, to{start,end} 627 1126 // 1. squash from.start ~ from.end into one commit
+35 -33
appview/pulls/router.go
··· 19 19 r.Post("/", s.NewPull) 20 20 }) 21 21 22 - r.Route("/{pull}", func(r chi.Router) { 23 - r.Use(mw.ResolvePull()) 24 - r.Get("/", s.RepoSinglePull) 25 - r.Get("/opengraph", s.PullOpenGraphSummary) 22 + r.Get("/{pull}/{version}/*", s.PullDiff) 26 23 27 - r.Route("/round/{round}", func(r chi.Router) { 28 - r.Get("/", s.RepoPullPatch) 29 - r.Get("/interdiff", s.RepoPullInterdiff) 30 - r.Get("/actions", s.PullActions) 31 - r.Get("/comment", s.PullComment) 32 - }) 33 - 34 - r.Route("/round/{round}.patch", func(r chi.Router) { 35 - r.Get("/", s.RepoPullPatchRaw) 36 - }) 37 - 38 - r.Group(func(r chi.Router) { 39 - r.Use(middleware.AuthMiddleware(s.oauth)) 40 - r.Route("/resubmit", func(r chi.Router) { 41 - r.Get("/", s.ResubmitPull) 42 - r.Post("/", s.ResubmitPull) 43 - }) 44 - // permissions here require us to know pull author 45 - // it is handled within the route 46 - r.Post("/close", s.ClosePull) 47 - r.Post("/reopen", s.ReopenPull) 48 - // collaborators only 49 - r.Group(func(r chi.Router) { 50 - r.Use(mw.RepoPermissionMiddleware("repo:push")) 51 - r.Post("/merge", s.MergePull) 52 - // maybe lock, etc. 53 - }) 54 - }) 55 - }) 24 + // r.Route("/{pull}", func(r chi.Router) { 25 + // r.Use(mw.ResolvePull()) 26 + // r.Get("/", s.RepoSinglePull) 27 + // r.Get("/opengraph", s.PullOpenGraphSummary) 28 + // 29 + // r.Route("/round/{round}", func(r chi.Router) { 30 + // r.Get("/", s.RepoPullPatch) 31 + // r.Get("/interdiff", s.RepoPullInterdiff) 32 + // r.Get("/actions", s.PullActions) 33 + // r.Get("/comment", s.PullComment) 34 + // }) 35 + // 36 + // r.Route("/round/{round}.patch", func(r chi.Router) { 37 + // r.Get("/", s.RepoPullPatchRaw) 38 + // }) 39 + // 40 + // r.Group(func(r chi.Router) { 41 + // r.Use(middleware.AuthMiddleware(s.oauth)) 42 + // r.Route("/resubmit", func(r chi.Router) { 43 + // r.Get("/", s.ResubmitPull) 44 + // r.Post("/", s.ResubmitPull) 45 + // }) 46 + // // permissions here require us to know pull author 47 + // // it is handled within the route 48 + // r.Post("/close", s.ClosePull) 49 + // r.Post("/reopen", s.ReopenPull) 50 + // // collaborators only 51 + // r.Group(func(r chi.Router) { 52 + // r.Use(mw.RepoPermissionMiddleware("repo:push")) 53 + // r.Post("/merge", s.MergePull) 54 + // // maybe lock, etc. 55 + // }) 56 + // }) 57 + // }) 56 58 return r 57 59 58 60 }