| 1 | package tinyclient |
| 2 | |
| 3 | import ( |
| 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. |
| 18 | type 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. |
| 29 | func 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 @@". |
| 78 | func 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. |
| 99 | func 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. |
| 118 | type 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. |
| 128 | type 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. |
| 138 | func 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 | |
| 198 | func 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. |
| 204 | func 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. |
| 219 | func 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. |
| 233 | func 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 | } |