clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/standalone-relay
tinyrelay / tinyclient / review.go
review.go 7.2K
1package tinyclient23import (4 "html/template"5 "net/url"6 "strconv"7 "strings"8)910// Review rendering for the pull request page. The diff is split into rows11// that know their file and their line number on each side, so a comment12// carrying file and line tags can be placed under the row it names and every13// row can offer a link that opens the compose with that line preset.1415// diffLine is one row of a unified diff. Old and New are the line numbers on16// each side of the hunk the row belongs to; a row outside a hunk, or on the17// side it does not touch, has 0 there.18type diffLine struct {19 Number int20 Text string21 Wrapper string22 File string23 Old int24 New int25}2627// diffLines parses rendered diff text into rows. Header lines keep the ins28// 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, 033 inHunk := false34 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 = true41 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 = path47 }48 case !inHunk && strings.HasPrefix(line, "--- "):49 row.Wrapper = "del"50 if path := diffPath(line[4:], "a/"); path != "" && file == "" {51 file = path52 }53 case inHunk && strings.HasPrefix(line, "+"):54 row.Wrapper, row.New = "ins", next55 next++56 case inHunk && strings.HasPrefix(line, "-"):57 row.Wrapper, row.Old = "del", old58 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, next63 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 = file71 out = append(out, row)72 }73 return out74}7576// hunkStarts reads the old and new starting line numbers of a hunk header77// 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, 082 }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 091 }92 return n93 }94 return start(fields[1], "-"), start(fields[2], "+")95}9697// diffPath reads the path from a "--- a/path" or "+++ b/path" header. Git98// 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 = unquoted107 }108 }109 if value == "/dev/null" {110 return ""111 }112 return strings.TrimPrefix(value, prefix)113}114115// 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 to117// it, oldest first.118type reviewLine struct {119 diffLine120 HTML template.HTML121 CommentURL string122 Comments []any123}124125// reviewDiff is the pull request diff with its anchored comments placed.126// Thread holds the replies that are not shown inline, so the conversation127// below the diff lists each comment once.128type reviewDiff struct {129 Available bool130 Truncated bool131 Lines []reviewLine132 Thread []any133}134135// reviewDiffView builds the diff for a pull request page. A signed-in136// viewer gets a comment link on each numbered line. Anchored comments whose137// 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 view145 }146 view.Available = true147 providedTruncated, _ := m["diff_truncated"].(bool)148 view.Truncated = providedTruncated || len(diff) > sourcePreviewLimit149 if view.Truncated {150 diff = boundedContent(diff)151 }152 lines, linesTruncated := previewLines(diff)153 view.Truncated = view.Truncated || linesTruncated154 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.Builder159 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 continue185 }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 = comments194 }195 return view196}197198func reviewKey(file, side string, line int) string {199 return side + ":" + strconv.Itoa(line) + ":" + file200}201202// reviewCommentURL opens the pull request page with the compose preset to203// 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.New210 if line == 0 {211 side, line = "old", row.Old212 }213 values := url.Values{"file": {row.File}, "line": {strconv.Itoa(line)}, "side": {side}}214 return repoURL(query, "pr", id) + "&" + values.Encode() + "#reply-compose"215}216217// reviewAnchorQuery reads the line a compose should be preset to from the218// 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 nil222 }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 nil227 }228 return map[string]any{"File": file, "Line": line, "Side": side}229}230231// reviewAnchorLabel describes a reply's anchor for the thread, or returns232// 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}