tinyrelayfile
tinyrelay/tinyclient/review.go/

tinyclient/review.go raw
1package tinyclient
2
3import (
4 "html/template"
5 "net/url"
6 "strconv"
7 "strings"
8)
9
10// Review rendering for the pull request page. The diff is split into rows
11// that know their file and their line number on each side, so a comment
12// carrying file and line tags can be placed under the row it names and every
13// row can offer a link that opens the compose with that line preset.
14
15// diffLine is one row of a unified diff. Old and New are the line numbers on
16// each side of the hunk the row belongs to; a row outside a hunk, or on the
17// side it does not touch, has 0 there.
18type diffLine struct {
19 Number int
20 Text string
21 Wrapper string
22 File string
23 Old int
24 New int
25}
26
27// diffLines parses rendered diff text into rows. Header lines keep the ins
28// and del markers of the plain renderer; only hunk bodies are numbered.
29func diffLines(lines []string) []diffLine {
30 out := make([]diffLine, 0, len(lines))
31 file := ""
32 old, next := 0, 0
33 inHunk := false
34 for index, line := range lines {
35 row := diffLine{Number: index + 1, Text: line}
36 switch {
37 case strings.HasPrefix(line, "diff --git "):
38 inHunk, file = false, ""
39 case strings.HasPrefix(line, "@@"):
40 inHunk = true
41 old, next = hunkStarts(line)
42 row.Wrapper = "b"
43 case !inHunk && strings.HasPrefix(line, "+++ "):
44 row.Wrapper = "ins"
45 if path := diffPath(line[4:], "b/"); path != "" {
46 file = path
47 }
48 case !inHunk && strings.HasPrefix(line, "--- "):
49 row.Wrapper = "del"
50 if path := diffPath(line[4:], "a/"); path != "" && file == "" {
51 file = path
52 }
53 case inHunk && strings.HasPrefix(line, "+"):
54 row.Wrapper, row.New = "ins", next
55 next++
56 case inHunk && strings.HasPrefix(line, "-"):
57 row.Wrapper, row.Old = "del", old
58 old++
59 case inHunk && strings.HasPrefix(line, "\\"):
60 // "\ No newline at end of file" belongs to no line.
61 case inHunk:
62 row.Old, row.New = old, next
63 old++
64 next++
65 case strings.HasPrefix(line, "+"):
66 row.Wrapper = "ins"
67 case strings.HasPrefix(line, "-"):
68 row.Wrapper = "del"
69 }
70 row.File = file
71 out = append(out, row)
72 }
73 return out
74}
75
76// hunkStarts reads the old and new starting line numbers of a hunk header
77// such as "@@ -12,7 +12,8 @@".
78func hunkStarts(header string) (int, int) {
79 fields := strings.Fields(header)
80 if len(fields) < 3 {
81 return 0, 0
82 }
83 start := func(value, prefix string) int {
84 value = strings.TrimPrefix(value, prefix)
85 if comma := strings.IndexByte(value, ','); comma >= 0 {
86 value = value[:comma]
87 }
88 n, err := strconv.Atoi(value)
89 if err != nil || n < 0 {
90 return 0
91 }
92 return n
93 }
94 return start(fields[1], "-"), start(fields[2], "+")
95}
96
97// diffPath reads the path from a "--- a/path" or "+++ b/path" header. Git
98// quotes paths with unusual characters; /dev/null means no file on that side.
99func diffPath(value, prefix string) string {
100 value = strings.TrimSpace(value)
101 if tab := strings.IndexByte(value, '\t'); tab >= 0 {
102 value = value[:tab]
103 }
104 if strings.HasPrefix(value, `"`) {
105 if unquoted, err := strconv.Unquote(value); err == nil {
106 value = unquoted
107 }
108 }
109 if value == "/dev/null" {
110 return ""
111 }
112 return strings.TrimPrefix(value, prefix)
113}
114
115// reviewLine is one diff row ready for the template: its escaped markup,
116// the link that starts a comment on it and the comments already anchored to
117// it, oldest first.
118type reviewLine struct {
119 diffLine
120 HTML template.HTML
121 CommentURL string
122 Comments []any
123}
124
125// reviewDiff is the pull request diff with its anchored comments placed.
126// Thread holds the replies that are not shown inline, so the conversation
127// below the diff lists each comment once.
128type reviewDiff struct {
129 Available bool
130 Truncated bool
131 Lines []reviewLine
132 Thread []any
133}
134
135// reviewDiffView builds the diff for a pull request page. A signed-in
136// viewer gets a comment link on each numbered line. Anchored comments whose
137// line is not in the diff stay in the thread with their anchor shown.
138func reviewDiffView(value any, query url.Values, actor string) reviewDiff {
139 m := valueMap(value)
140 replies := collaborationReplies(value)
141 view := reviewDiff{Thread: replies}
142 diff, _ := m["diff"].(string)
143 if diff == "" {
144 return view
145 }
146 view.Available = true
147 providedTruncated, _ := m["diff_truncated"].(bool)
148 view.Truncated = providedTruncated || len(diff) > sourcePreviewLimit
149 if view.Truncated {
150 diff = boundedContent(diff)
151 }
152 lines, linesTruncated := previewLines(diff)
153 view.Truncated = view.Truncated || linesTruncated
154 rows := diffLines(lines)
155 view.Lines = make([]reviewLine, 0, len(rows))
156 index := make(map[string]int, len(rows))
157 for _, row := range rows {
158 var markup strings.Builder
159 writeWrapped(&markup, row.Wrapper, row.Text)
160 line := reviewLine{diffLine: row, HTML: template.HTML(markup.String())}
161 if row.File != "" {
162 if row.New > 0 {
163 index[reviewKey(row.File, "new", row.New)] = len(view.Lines)
164 }
165 if row.Old > 0 {
166 index[reviewKey(row.File, "old", row.Old)] = len(view.Lines)
167 }
168 if actor != "" && (row.New > 0 || row.Old > 0) {
169 line.CommentURL = reviewCommentURL(query, row)
170 }
171 }
172 view.Lines = append(view.Lines, line)
173 }
174 view.Thread = view.Thread[:0]
175 placed := make(map[int][]any)
176 for _, reply := range replies {
177 r := valueMap(reply)
178 file := plainString(r["file"])
179 lineNumber := int(unixSeconds(r["line"]))
180 side := plainString(r["side"])
181 at, ok := index[reviewKey(file, side, lineNumber)]
182 if file == "" || lineNumber < 1 || !ok {
183 view.Thread = append(view.Thread, reply)
184 continue
185 }
186 placed[at] = append(placed[at], reply)
187 }
188 for at, comments := range placed {
189 // Replies arrive newest first; read a line's comments in order.
190 for left, right := 0, len(comments)-1; left < right; left, right = left+1, right-1 {
191 comments[left], comments[right] = comments[right], comments[left]
192 }
193 view.Lines[at].Comments = comments
194 }
195 return view
196}
197
198func reviewKey(file, side string, line int) string {
199 return side + ":" + strconv.Itoa(line) + ":" + file
200}
201
202// reviewCommentURL opens the pull request page with the compose preset to
203// one line. Added and context lines name the new side; removed lines the old.
204func reviewCommentURL(query url.Values, row diffLine) string {
205 id := query.Get("id")
206 if id == "" {
207 id = query.Get("path")
208 }
209 side, line := "new", row.New
210 if line == 0 {
211 side, line = "old", row.Old
212 }
213 values := url.Values{"file": {row.File}, "line": {strconv.Itoa(line)}, "side": {side}}
214 return repoURL(query, "pr", id) + "&" + values.Encode() + "#reply-compose"
215}
216
217// reviewAnchorQuery reads the line a compose should be preset to from the
218// page query. It returns nil unless every part is valid.
219func reviewAnchorQuery(query url.Values) map[string]any {
220 if repoView(query) != "pr" {
221 return nil
222 }
223 file, side := query.Get("file"), query.Get("side")
224 line, err := strconv.Atoi(query.Get("line"))
225 if strings.TrimSpace(file) == "" || err != nil || line < 1 || (side != "old" && side != "new") {
226 return nil
227 }
228 return map[string]any{"File": file, "Line": line, "Side": side}
229}
230
231// reviewAnchorLabel describes a reply's anchor for the thread, or returns
232// an empty string for an unanchored reply.
233func reviewAnchorLabel(reply any) string {
234 r := valueMap(reply)
235 file := plainString(r["file"])
236 if file == "" {
237 return ""
238 }
239 return file + " line " + plainString(r["line"]) + " (" + plainString(r["side"]) + ")"
240}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs