Commit d122636fc9
Verified · cmc ci/build: success
e2e/commitrefs_test.go +17 −1
| @@ -34,8 +34,10 @@ func TestCommitMessageIssueActions(t *testing.T) { | ||
| 34 | 34 | // A closing keyword on the default branch closes the issue with a |
| 35 | 35 | // comment; a bare reference (and a nonexistent #99) only comments. |
| 36 | 36 | os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b\n"), 0o644) |
| 37 | aliceEnv := append(append([]string{}, env...), | |
| 38 | "GIT_AUTHOR_NAME=Alice", "GIT_AUTHOR_EMAIL=alice@example.test") | |
| 37 | 39 | mustGit(t, dir, env, "add", ".") |
| 38 | mustGit(t, dir, env, "commit", "-q", "-m", "repair the widget\n\nFixes #1. Related to #2 but not #99.") | |
| 40 | mustGit(t, dir, aliceEnv, "commit", "-q", "-m", "repair the widget\n\nFixes #1. Related to #2 but not #99.") | |
| 39 | 41 | mustGit(t, dir, env, "push", "-q", "origin", "main") |
| 40 | 42 | |
| 41 | 43 | out, _, _ := inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "1", "--json") |
| @@ -55,6 +57,15 @@ func TestCommitMessageIssueActions(t *testing.T) { | ||
| 55 | 57 | !strings.Contains(out, "repair the widget") { |
| 56 | 58 | t.Fatalf("issue 2 not referenced: %s", out) |
| 57 | 59 | } |
| 60 | // The reference names who wrote the commit, and links them when the | |
| 61 | // author email is verified on an account here. | |
| 62 | if !strings.Contains(out, "by [alice](/alice)") { | |
| 63 | t.Fatalf("reference does not attribute the author: %s", out) | |
| 64 | } | |
| 65 | if status, body := inst.get(t, "/alice/app/issues/2"); status != 200 || | |
| 66 | !strings.Contains(body, `href="/alice"`) { | |
| 67 | t.Fatalf("web reference does not link the author: %d\n%s", status, body) | |
| 68 | } | |
| 58 | 69 | |
| 59 | 70 | // Commits on a feature branch do nothing until they land on the |
| 60 | 71 | // default branch via a merge — then the merge path acts exactly once. |
| @@ -78,6 +89,11 @@ func TestCommitMessageIssueActions(t *testing.T) { | ||
| 78 | 89 | if !strings.Contains(out, `"state":"closed"`) || !strings.Contains(out, "closed by commit") { |
| 79 | 90 | t.Fatalf("merge did not close issue 3: %s", out) |
| 80 | 91 | } |
| 92 | // This one was authored by an address nobody has verified, so it names | |
| 93 | // git's author without inventing a profile link for them. | |
| 94 | if !strings.Contains(out, "by t:") || strings.Contains(out, "by [t]") { | |
| 95 | t.Fatalf("unresolved author should stay plain text: %s", out) | |
| 96 | } | |
| 81 | 97 | if strings.Count(out, "closed by commit") != 1 { |
| 82 | 98 | t.Fatalf("duplicate close comments: %s", out) |
| 83 | 99 | } |
e2e/dashboard_test.go +2 −2
| @@ -110,11 +110,11 @@ func TestDashboard(t *testing.T) { | ||
| 110 | 110 | if !strings.Contains(body, `href="/alice/app/mrs/1?view=diff"`) { |
| 111 | 111 | t.Fatal("merge request missing the files-changed view") |
| 112 | 112 | } |
| 113 | if strings.Contains(body, `class="diff"`) { | |
| 113 | if strings.Contains(body, `class="difftable"`) { | |
| 114 | 114 | t.Fatal("diff rendered on the conversation view") |
| 115 | 115 | } |
| 116 | 116 | _, body = inst.get(t, "/alice/app/mrs/1?view=diff") |
| 117 | if !strings.Contains(body, "1 file changed") || !strings.Contains(body, `class="diff"`) { | |
| 117 | if !strings.Contains(body, "1 file changed") || !strings.Contains(body, `class="difftable"`) { | |
| 118 | 118 | t.Fatalf("diff view missing stat or patch:\n%s", body) |
| 119 | 119 | } |
| 120 | 120 | } |
e2e/diffweb_test.go added +104
| @@ -0,0 +1,104 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "os" | |
| 5 | "path/filepath" | |
| 6 | "strings" | |
| 7 | "testing" | |
| 8 | ) | |
| 9 | ||
| 10 | // TestDiffRendering covers the shared diff view: per-file folds with stats, | |
| 11 | // line-number gutters, syntax highlighting, and binary files declared | |
| 12 | // rather than dumped. | |
| 13 | func TestDiffRendering(t *testing.T) { | |
| 14 | inst := startInstance(t) | |
| 15 | aliceKey := inst.newKey(t, "alice") | |
| 16 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | |
| 17 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | |
| 18 | t.Fatalf("repo create: %s", errOut) | |
| 19 | } | |
| 20 | ||
| 21 | work := t.TempDir() | |
| 22 | env := inst.gitEnv(aliceKey) | |
| 23 | mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w") | |
| 24 | dir := filepath.Join(work, "w") | |
| 25 | write := func(name, body string) { | |
| 26 | os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644) | |
| 27 | } | |
| 28 | write("main.go", "package main\n\nfunc greet() string {\n\treturn \"hello\"\n}\n") | |
| 29 | write("notes.txt", "old title\n") | |
| 30 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | |
| 31 | mustGit(t, dir, env, "add", ".") | |
| 32 | mustGit(t, dir, env, "commit", "-q", "-m", "base") | |
| 33 | mustGit(t, dir, env, "push", "-q", "origin", "main") | |
| 34 | ||
| 35 | // One commit touching three files in three different ways. | |
| 36 | write("main.go", "package main\n\nfunc greet() string {\n\t// now with feeling\n\treturn \"HELLO\"\n}\n") | |
| 37 | os.Rename(filepath.Join(dir, "notes.txt"), filepath.Join(dir, "README.md")) | |
| 38 | os.WriteFile(filepath.Join(dir, "logo.png"), []byte("\x89PNG\r\n\x1a\n\x00\x00binary"), 0o644) | |
| 39 | mustGit(t, dir, env, "add", "-A") | |
| 40 | mustGit(t, dir, env, "commit", "-q", "-m", "rework") | |
| 41 | mustGit(t, dir, env, "push", "-q", "origin", "main") | |
| 42 | ||
| 43 | out, _, _ := inst.ssh(t, aliceKey, "", "repo", "log", "alice/app", "--json") | |
| 44 | sha := jsonField(out, "sha") | |
| 45 | if sha == "" { | |
| 46 | t.Fatalf("no sha in log: %s", out) | |
| 47 | } | |
| 48 | ||
| 49 | status, body := inst.get(t, "/alice/app/commit/"+sha) | |
| 50 | if status != 200 { | |
| 51 | t.Fatalf("commit page: %d", status) | |
| 52 | } | |
| 53 | ||
| 54 | // A fold per file, each naming its path. | |
| 55 | for _, path := range []string{"main.go", "logo.png"} { | |
| 56 | if !strings.Contains(body, ">"+path+"<") { | |
| 57 | t.Errorf("no section for %s", path) | |
| 58 | } | |
| 59 | } | |
| 60 | if strings.Count(body, `<details class="difffold"`) != 3 { | |
| 61 | t.Errorf("want 3 file sections, got %d", strings.Count(body, `<details class="difffold"`)) | |
| 62 | } | |
| 63 | // The rename is shown as one, not as an add plus a delete. | |
| 64 | if !strings.Contains(body, "notes.txt") || !strings.Contains(body, "renamed") { | |
| 65 | t.Error("rename not shown as a rename") | |
| 66 | } | |
| 67 | // Binary content is declared, never dumped into the page. | |
| 68 | if !strings.Contains(body, "Binary file not shown") { | |
| 69 | t.Error("binary file not declared") | |
| 70 | } | |
| 71 | if strings.Contains(body, "\x89PNG") { | |
| 72 | t.Error("binary content leaked into the diff") | |
| 73 | } | |
| 74 | // Line-number gutters and per-file stats. | |
| 75 | if !strings.Contains(body, `<td class="ln">`) { | |
| 76 | t.Error("no line-number gutter") | |
| 77 | } | |
| 78 | if !strings.Contains(body, `<span class="add">+`) || !strings.Contains(body, `<span class="del">−`) { | |
| 79 | t.Error("no per-file stat") | |
| 80 | } | |
| 81 | // Go is a type chroma knows, so the added line carries token markup. | |
| 82 | if !strings.Contains(body, "class=\"k\"") && !strings.Contains(body, "class=\"kd\"") { | |
| 83 | t.Error("diff content is not syntax highlighted") | |
| 84 | } | |
| 85 | // The +/- markers are CSS, so a copied selection is real source. | |
| 86 | if strings.Contains(body, `<td class="code">+`) { | |
| 87 | t.Error("diff markers are in the markup, not the stylesheet") | |
| 88 | } | |
| 89 | } | |
| 90 | ||
| 91 | // jsonField pulls the first "name":"value" string out of a JSON blob. | |
| 92 | func jsonField(blob, name string) string { | |
| 93 | key := `"` + name + `":"` | |
| 94 | i := strings.Index(blob, key) | |
| 95 | if i < 0 { | |
| 96 | return "" | |
| 97 | } | |
| 98 | rest := blob[i+len(key):] | |
| 99 | j := strings.IndexByte(rest, '"') | |
| 100 | if j < 0 { | |
| 101 | return "" | |
| 102 | } | |
| 103 | return rest[:j] | |
| 104 | } | |
internal/control/commitrefs.go +19 −5
| @@ -47,11 +47,12 @@ func ProcessCommitMessages(st *store.Store, dir string, repo store.Repo, actorID | ||
| 47 | 47 | } |
| 48 | 48 | } |
| 49 | 49 | subject, _, _ := strings.Cut(m.Message, "\n") |
| 50 | author := authorLink(st, m.AuthorName, m.AuthorEmail) | |
| 50 | 51 | for n := range closes { |
| 51 | actOnIssue(st, repo, actorID, m.SHA, n, true, subject) | |
| 52 | actOnIssue(st, repo, actorID, m.SHA, n, true, subject, author) | |
| 52 | 53 | } |
| 53 | 54 | for n := range refs { |
| 54 | actOnIssue(st, repo, actorID, m.SHA, n, false, subject) | |
| 55 | actOnIssue(st, repo, actorID, m.SHA, n, false, subject, author) | |
| 55 | 56 | } |
| 56 | 57 | } |
| 57 | 58 | } |
| @@ -72,7 +73,20 @@ func RecordLandedCommits(st *store.Store, dir string, repo store.Repo, old, new | ||
| 72 | 73 | } |
| 73 | 74 | } |
| 74 | 75 | |
| 75 | func actOnIssue(st *store.Store, repo store.Repo, actorID int64, sha string, number int64, close bool, subject string) { | |
| 76 | // authorLink renders the commit's author for a system comment: a link to | |
| 77 | // their profile when the author email is verified on an account here, and | |
| 78 | // the name git recorded otherwise. | |
| 79 | func authorLink(st *store.Store, name, email string) string { | |
| 80 | if user, ok := st.UsernameByVerifiedEmail(email); ok { | |
| 81 | return fmt.Sprintf("[%s](/%s)", user, user) | |
| 82 | } | |
| 83 | if name == "" { | |
| 84 | return email | |
| 85 | } | |
| 86 | return name | |
| 87 | } | |
| 88 | ||
| 89 | func actOnIssue(st *store.Store, repo store.Repo, actorID int64, sha string, number int64, close bool, subject, author string) { | |
| 76 | 90 | issue, err := st.IssueByNumber(repo.ID, number) |
| 77 | 91 | if err != nil { |
| 78 | 92 | return // no such issue: the reference is just text |
| @@ -93,9 +107,9 @@ func actOnIssue(st *store.Store, repo store.Repo, actorID int64, sha string, num | ||
| 93 | 107 | slog.Error("commit refs: closing issue", "issue", number, "err", err) |
| 94 | 108 | return |
| 95 | 109 | } |
| 96 | st.AddIssueSystemComment(issue.ID, actorID, fmt.Sprintf("closed by commit %s: %s", link, subject)) | |
| 110 | st.AddIssueSystemComment(issue.ID, actorID, fmt.Sprintf("closed by commit %s by %s: %s", link, author, subject)) | |
| 97 | 111 | st.RecordEvent(repo.ID, actorID, "issue.closed", fmt.Sprintf(`{"number":%d,"sha":%q}`, number, sha)) |
| 98 | 112 | return |
| 99 | 113 | } |
| 100 | st.AddIssueSystemComment(issue.ID, actorID, fmt.Sprintf("referenced in commit %s: %s", link, subject)) | |
| 114 | st.AddIssueSystemComment(issue.ID, actorID, fmt.Sprintf("referenced in commit %s by %s: %s", link, author, subject)) | |
| 101 | 115 | } |
internal/gitutil/messages.go +19 −4
| @@ -62,15 +62,17 @@ func HasCommit(dir, sha string) bool { | ||
| 62 | 62 | } |
| 63 | 63 | |
| 64 | 64 | type CommitMsg struct { |
| 65 | SHA string | |
| 66 | Message string | |
| 65 | SHA string | |
| 66 | Message string | |
| 67 | AuthorName string | |
| 68 | AuthorEmail string | |
| 67 | 69 | } |
| 68 | 70 | |
| 69 | 71 | // RevListMessages returns sha and full message for commits reachable from |
| 70 | 72 | // new but not old, newest first, capped at max. An empty or zero old (new |
| 71 | 73 | // branch) lists from new alone, still capped. |
| 72 | 74 | func RevListMessages(dir, old, new string, max int) ([]CommitMsg, error) { |
| 73 | args := []string{"-C", dir, "rev-list", fmt.Sprintf("-n%d", max), "--format=%B%x00", new} | |
| 75 | args := []string{"-C", dir, "rev-list", fmt.Sprintf("-n%d", max), "--format=%an%x01%ae%x01%B%x00", new} | |
| 74 | 76 | if old != "" && old != zeroSHA { |
| 75 | 77 | args = append(args, "^"+old) |
| 76 | 78 | } |
| @@ -88,7 +90,20 @@ func RevListMessages(dir, old, new string, max int) ([]CommitMsg, error) { | ||
| 88 | 90 | if !ok || !strings.HasPrefix(header, "commit ") { |
| 89 | 91 | continue |
| 90 | 92 | } |
| 91 | msgs = append(msgs, CommitMsg{SHA: strings.TrimPrefix(header, "commit "), Message: strings.TrimSpace(body)}) | |
| 93 | name, rest, ok := strings.Cut(body, "\x01") | |
| 94 | if !ok { | |
| 95 | continue | |
| 96 | } | |
| 97 | email, message, ok := strings.Cut(rest, "\x01") | |
| 98 | if !ok { | |
| 99 | continue | |
| 100 | } | |
| 101 | msgs = append(msgs, CommitMsg{ | |
| 102 | SHA: strings.TrimPrefix(header, "commit "), | |
| 103 | Message: strings.TrimSpace(message), | |
| 104 | AuthorName: name, | |
| 105 | AuthorEmail: email, | |
| 106 | }) | |
| 92 | 107 | } |
| 93 | 108 | return msgs, nil |
| 94 | 109 | } |
internal/httpd/diff.go added +305
| @@ -0,0 +1,305 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "bytes" | |
| 5 | "html/template" | |
| 6 | "regexp" | |
| 7 | "strconv" | |
| 8 | "strings" | |
| 9 | ||
| 10 | "github.com/alecthomas/chroma/v2" | |
| 11 | "github.com/alecthomas/chroma/v2/formatters/html" | |
| 12 | "github.com/alecthomas/chroma/v2/lexers" | |
| 13 | "github.com/alecthomas/chroma/v2/styles" | |
| 14 | ) | |
| 15 | ||
| 16 | type diffLine struct { | |
| 17 | Class string // meta | hunk | add | del | ctx | |
| 18 | Text string // the raw diff line, marker included | |
| 19 | Content string // the line without its +/- marker | |
| 20 | Code template.HTML // Content highlighted; empty when the type is unknown | |
| 21 | Path string // file this line belongs to | |
| 22 | NewLine int64 // line number in the new file (0 when absent) | |
| 23 | OldLine int64 // line number in the old file (0 when absent) | |
| 24 | Threads []diffThread | |
| 25 | } | |
| 26 | ||
| 27 | // diffFile is one file's worth of a unified diff: the header lines are | |
| 28 | // consumed into the fields here, so the template renders a section rather | |
| 29 | // than replaying "diff --git" at the reader. | |
| 30 | type diffFile struct { | |
| 31 | Path string // new path; the old one for a delete | |
| 32 | OldPath string // set only on a rename | |
| 33 | Status string // added | deleted | renamed | modified | |
| 34 | Adds int | |
| 35 | Dels int | |
| 36 | Binary bool | |
| 37 | Lines []diffLine | |
| 38 | Threads int // threads anchored in this file, so it can stay unfolded | |
| 39 | Open bool // rendered unfolded: small files, and anything under review | |
| 40 | } | |
| 41 | ||
| 42 | type diffStat struct{ Files, Adds, Dels int } | |
| 43 | ||
| 44 | var hunkPat = regexp.MustCompile(`^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@`) | |
| 45 | ||
| 46 | // parseDiff splits a unified diff into per-file sections, tracking old and | |
| 47 | // new line numbers so review threads can anchor inline. | |
| 48 | func parseDiff(patch string) []diffFile { | |
| 49 | var files []diffFile | |
| 50 | var cur *diffFile | |
| 51 | var oldN, newN int64 | |
| 52 | ||
| 53 | // Paths arrive both in "diff --git a/x b/y" and in the ---/+++ pair. | |
| 54 | // The latter is authoritative (it survives quoting oddities), so the | |
| 55 | // git line only opens the section. | |
| 56 | start := func() *diffFile { | |
| 57 | files = append(files, diffFile{Status: "modified"}) | |
| 58 | return &files[len(files)-1] | |
| 59 | } | |
| 60 | ||
| 61 | for _, l := range strings.Split(patch, "\n") { | |
| 62 | switch { | |
| 63 | case strings.HasPrefix(l, "diff --git "): | |
| 64 | cur = start() | |
| 65 | if a, b, ok := gitHeaderPaths(l); ok { | |
| 66 | cur.OldPath, cur.Path = a, b | |
| 67 | } | |
| 68 | continue | |
| 69 | case cur == nil: | |
| 70 | continue // preamble before the first file | |
| 71 | case strings.HasPrefix(l, "new file mode"): | |
| 72 | cur.Status = "added" | |
| 73 | continue | |
| 74 | case strings.HasPrefix(l, "deleted file mode"): | |
| 75 | cur.Status = "deleted" | |
| 76 | continue | |
| 77 | case strings.HasPrefix(l, "rename from "): | |
| 78 | cur.Status, cur.OldPath = "renamed", strings.TrimPrefix(l, "rename from ") | |
| 79 | continue | |
| 80 | case strings.HasPrefix(l, "rename to "): | |
| 81 | cur.Status, cur.Path = "renamed", strings.TrimPrefix(l, "rename to ") | |
| 82 | continue | |
| 83 | case strings.HasPrefix(l, "Binary files "), strings.HasPrefix(l, "GIT binary patch"): | |
| 84 | cur.Binary = true | |
| 85 | continue | |
| 86 | case strings.HasPrefix(l, "--- "): | |
| 87 | if p := strings.TrimPrefix(l, "--- "); p != "/dev/null" { | |
| 88 | cur.OldPath = strings.TrimPrefix(p, "a/") | |
| 89 | } | |
| 90 | continue | |
| 91 | case strings.HasPrefix(l, "+++ "): | |
| 92 | if p := strings.TrimPrefix(l, "+++ "); p != "/dev/null" { | |
| 93 | cur.Path = strings.TrimPrefix(p, "b/") | |
| 94 | } | |
| 95 | continue | |
| 96 | case strings.HasPrefix(l, "index "), strings.HasPrefix(l, "old mode "), | |
| 97 | strings.HasPrefix(l, "new mode "), strings.HasPrefix(l, "similarity index "), | |
| 98 | strings.HasPrefix(l, "dissimilarity index "): | |
| 99 | continue | |
| 100 | } | |
| 101 | ||
| 102 | d := diffLine{Text: l, Content: l, Path: cur.Path} | |
| 103 | switch { | |
| 104 | case strings.HasPrefix(l, "@@"): | |
| 105 | d.Class, d.Path = "hunk", "" | |
| 106 | if m := hunkPat.FindStringSubmatch(l); m != nil { | |
| 107 | oldN, _ = strconv.ParseInt(m[1], 10, 64) | |
| 108 | newN, _ = strconv.ParseInt(m[2], 10, 64) | |
| 109 | } | |
| 110 | case strings.HasPrefix(l, "+"): | |
| 111 | d.Class, d.Content, d.NewLine = "add", l[1:], newN | |
| 112 | newN++ | |
| 113 | cur.Adds++ | |
| 114 | case strings.HasPrefix(l, "-"): | |
| 115 | d.Class, d.Content, d.OldLine = "del", l[1:], oldN | |
| 116 | oldN++ | |
| 117 | cur.Dels++ | |
| 118 | case l == `\ No newline at end of file`: | |
| 119 | d.Class, d.Path = "meta", "" | |
| 120 | case l == "": | |
| 121 | continue // trailing newline from the split | |
| 122 | default: | |
| 123 | d.Class, d.Content, d.OldLine, d.NewLine = "ctx", l[1:], oldN, newN | |
| 124 | oldN++ | |
| 125 | newN++ | |
| 126 | } | |
| 127 | cur.Lines = append(cur.Lines, d) | |
| 128 | } | |
| 129 | ||
| 130 | for i := range files { | |
| 131 | if files[i].Path == "" { | |
| 132 | files[i].Path = files[i].OldPath | |
| 133 | } | |
| 134 | if files[i].Status == "renamed" && files[i].OldPath == files[i].Path { | |
| 135 | files[i].Status = "modified" | |
| 136 | } | |
| 137 | highlightFile(&files[i]) | |
| 138 | // Big files fold shut so a large diff is navigable; anything | |
| 139 | // carrying review threads stays open regardless. | |
| 140 | files[i].Open = len(files[i].Lines) <= 300 | |
| 141 | } | |
| 142 | return files | |
| 143 | } | |
| 144 | ||
| 145 | // gitHeaderPaths pulls both paths out of a "diff --git a/x b/y" line. Paths | |
| 146 | // with spaces make this ambiguous in general; git quotes those, and the | |
| 147 | // ---/+++ lines correct us either way. | |
| 148 | func gitHeaderPaths(l string) (string, string, bool) { | |
| 149 | rest := strings.TrimPrefix(l, "diff --git ") | |
| 150 | i := strings.Index(rest, " b/") | |
| 151 | if !strings.HasPrefix(rest, "a/") || i < 0 { | |
| 152 | return "", "", false | |
| 153 | } | |
| 154 | return rest[2:i], rest[i+3:], true | |
| 155 | } | |
| 156 | ||
| 157 | // diffFormatter is the blob formatter without line numbers: the diff | |
| 158 | // supplies its own gutters. | |
| 159 | var diffFormatter = html.New(html.WithClasses(true)) | |
| 160 | ||
| 161 | // highlightFile syntax-highlights a file's diff content one hunk at a time, | |
| 162 | // each side separately. A hunk's context+deletions are contiguous lines of | |
| 163 | // the old file and its context+additions are contiguous lines of the new | |
| 164 | // one, so each side lexes as real code — highlighting line by line instead | |
| 165 | // would break every multi-line string and block comment. | |
| 166 | func highlightFile(f *diffFile) { | |
| 167 | if f.Binary || len(f.Lines) == 0 { | |
| 168 | return | |
| 169 | } | |
| 170 | lexer := lexers.Match(f.Path) | |
| 171 | if lexer == nil { | |
| 172 | return // unknown type: plain text reads fine, and guessing is worse | |
| 173 | } | |
| 174 | for start := 0; start < len(f.Lines); { | |
| 175 | if f.Lines[start].Class == "hunk" || f.Lines[start].Class == "meta" { | |
| 176 | start++ | |
| 177 | continue | |
| 178 | } | |
| 179 | end := start | |
| 180 | for end < len(f.Lines) && f.Lines[end].Class != "hunk" && f.Lines[end].Class != "meta" { | |
| 181 | end++ | |
| 182 | } | |
| 183 | hunk := f.Lines[start:end] | |
| 184 | assign(hunk, "del", highlightLines(lexer, sideText(hunk, "del"))) | |
| 185 | assign(hunk, "add", highlightLines(lexer, sideText(hunk, "add"))) | |
| 186 | start = end | |
| 187 | } | |
| 188 | } | |
| 189 | ||
| 190 | // sideText joins one side of a hunk: context plus the given change class. | |
| 191 | func sideText(hunk []diffLine, class string) string { | |
| 192 | var b strings.Builder | |
| 193 | for _, l := range hunk { | |
| 194 | if l.Class == "ctx" || l.Class == class { | |
| 195 | b.WriteString(strings.TrimPrefix(strings.TrimPrefix(l.Text, "+"), "-")) | |
| 196 | b.WriteByte('\n') | |
| 197 | } | |
| 198 | } | |
| 199 | return b.String() | |
| 200 | } | |
| 201 | ||
| 202 | // assign hands highlighted lines back to the diff lines they came from. | |
| 203 | // Context lines take whichever side ran last; both sides hold identical | |
| 204 | // text there, so the result is the same either way. | |
| 205 | func assign(hunk []diffLine, class string, out []template.HTML) { | |
| 206 | i := 0 | |
| 207 | for j := range hunk { | |
| 208 | if hunk[j].Class != "ctx" && hunk[j].Class != class { | |
| 209 | continue | |
| 210 | } | |
| 211 | if i < len(out) { | |
| 212 | hunk[j].Code = out[i] | |
| 213 | } | |
| 214 | i++ | |
| 215 | } | |
| 216 | } | |
| 217 | ||
| 218 | // highlightLines formats source and splits the result back into lines. | |
| 219 | // chroma emits tokens that may span newlines, so the split happens on the | |
| 220 | // rendered HTML with tags reopened per line. | |
| 221 | func highlightLines(lexer chroma.Lexer, src string) []template.HTML { | |
| 222 | if src == "" { | |
| 223 | return nil | |
| 224 | } | |
| 225 | it, err := lexer.Tokenise(nil, src) | |
| 226 | if err != nil { | |
| 227 | return nil | |
| 228 | } | |
| 229 | var buf bytes.Buffer | |
| 230 | if err := diffFormatter.Format(&buf, styles.Get("friendly"), it); err != nil { | |
| 231 | return nil | |
| 232 | } | |
| 233 | body := buf.String() | |
| 234 | // Strip the wrapper chroma puts around the whole block. | |
| 235 | if i := strings.Index(body, "<code"); i >= 0 { | |
| 236 | if j := strings.IndexByte(body[i:], '>'); j >= 0 { | |
| 237 | body = body[i+j+1:] | |
| 238 | } | |
| 239 | } | |
| 240 | body = strings.TrimSuffix(strings.TrimSuffix(body, "</pre>"), "</code>") | |
| 241 | body = strings.TrimSuffix(body, "\n") | |
| 242 | ||
| 243 | var out []template.HTML | |
| 244 | for _, line := range splitHighlighted(body) { | |
| 245 | out = append(out, template.HTML(line)) | |
| 246 | } | |
| 247 | return out | |
| 248 | } | |
| 249 | ||
| 250 | // splitHighlighted breaks formatted HTML on newlines that sit outside a | |
| 251 | // tag, closing and reopening the spans that straddle the break so every | |
| 252 | // line is balanced markup on its own. | |
| 253 | func splitHighlighted(body string) []string { | |
| 254 | var lines []string | |
| 255 | var open []string | |
| 256 | var cur strings.Builder | |
| 257 | for i := 0; i < len(body); { | |
| 258 | switch body[i] { | |
| 259 | case '<': | |
| 260 | j := strings.IndexByte(body[i:], '>') | |
| 261 | if j < 0 { | |
| 262 | cur.WriteString(body[i:]) | |
| 263 | i = len(body) | |
| 264 | continue | |
| 265 | } | |
| 266 | tag := body[i : i+j+1] | |
| 267 | if strings.HasPrefix(tag, "</") { | |
| 268 | if len(open) > 0 { | |
| 269 | open = open[:len(open)-1] | |
| 270 | } | |
| 271 | } else if !strings.HasSuffix(tag, "/>") { | |
| 272 | open = append(open, tag) | |
| 273 | } | |
| 274 | cur.WriteString(tag) | |
| 275 | i += j + 1 | |
| 276 | case '\n': | |
| 277 | for range open { | |
| 278 | cur.WriteString("</span>") | |
| 279 | } | |
| 280 | lines = append(lines, cur.String()) | |
| 281 | cur.Reset() | |
| 282 | for _, t := range open { | |
| 283 | cur.WriteString(t) | |
| 284 | } | |
| 285 | i++ | |
| 286 | default: | |
| 287 | cur.WriteByte(body[i]) | |
| 288 | i++ | |
| 289 | } | |
| 290 | } | |
| 291 | if cur.Len() > 0 { | |
| 292 | lines = append(lines, cur.String()) | |
| 293 | } | |
| 294 | return lines | |
| 295 | } | |
| 296 | ||
| 297 | // statOf totals a parsed diff for the summary line. | |
| 298 | func statOf(files []diffFile) diffStat { | |
| 299 | st := diffStat{Files: len(files)} | |
| 300 | for _, f := range files { | |
| 301 | st.Adds += f.Adds | |
| 302 | st.Dels += f.Dels | |
| 303 | } | |
| 304 | return st | |
| 305 | } | |
internal/httpd/diff_test.go added +174
| @@ -0,0 +1,174 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "strings" | |
| 5 | "testing" | |
| 6 | ) | |
| 7 | ||
| 8 | const samplePatch = `diff --git a/main.go b/main.go | |
| 9 | index 1234567..89abcde 100644 | |
| 10 | -- a/main.go | |
| 11 | +++ b/main.go | |
| 12 | @@ -1,6 +1,7 @@ | |
| 13 | package main | |
| 14 | ||
| 15 | func old() string { | |
| 16 | return "a" | |
| 17 | +func replaced() string { | |
| 18 | + // a comment | |
| 19 | + return "b" | |
| 20 | } | |
| 21 | diff --git a/notes.txt b/README.md | |
| 22 | similarity index 60% | |
| 23 | rename from notes.txt | |
| 24 | rename to README.md | |
| 25 | -- a/notes.txt | |
| 26 | +++ b/README.md | |
| 27 | @@ -1 +1 @@ | |
| 28 | old title | |
| 29 | +new title | |
| 30 | diff --git a/logo.png b/logo.png | |
| 31 | new file mode 100644 | |
| 32 | index 0000000..1111111 | |
| 33 | Binary files /dev/null and b/logo.png differ | |
| 34 | ` | |
| 35 | ||
| 36 | func TestParseDiffFiles(t *testing.T) { | |
| 37 | files := parseDiff(samplePatch) | |
| 38 | if len(files) != 3 { | |
| 39 | t.Fatalf("got %d files, want 3", len(files)) | |
| 40 | } | |
| 41 | ||
| 42 | if got := files[0].Path; got != "main.go" { | |
| 43 | t.Errorf("file 0 path = %q", got) | |
| 44 | } | |
| 45 | if files[0].Adds != 3 || files[0].Dels != 2 { | |
| 46 | t.Errorf("main.go stat = +%d -%d, want +3 -2", files[0].Adds, files[0].Dels) | |
| 47 | } | |
| 48 | if files[0].Status != "modified" { | |
| 49 | t.Errorf("main.go status = %q", files[0].Status) | |
| 50 | } | |
| 51 | ||
| 52 | if files[1].Status != "renamed" || files[1].OldPath != "notes.txt" || files[1].Path != "README.md" { | |
| 53 | t.Errorf("rename = %q %q -> %q", files[1].Status, files[1].OldPath, files[1].Path) | |
| 54 | } | |
| 55 | ||
| 56 | if !files[2].Binary || files[2].Status != "added" || files[2].Path != "logo.png" { | |
| 57 | t.Errorf("binary add = %+v", files[2]) | |
| 58 | } | |
| 59 | if len(files[2].Lines) != 0 { | |
| 60 | t.Errorf("binary file has %d lines, want none", len(files[2].Lines)) | |
| 61 | } | |
| 62 | ||
| 63 | if st := statOf(files); st.Files != 3 || st.Adds != 4 || st.Dels != 3 { | |
| 64 | t.Errorf("stat = %+v, want 3 files +4 -3", st) | |
| 65 | } | |
| 66 | } | |
| 67 | ||
| 68 | // Line numbers anchor review threads, so an off-by-one here silently moves | |
| 69 | // every comment on a merge request. | |
| 70 | func TestParseDiffLineNumbers(t *testing.T) { | |
| 71 | lines := parseDiff(samplePatch)[0].Lines | |
| 72 | type want struct { | |
| 73 | class string | |
| 74 | old, new int64 | |
| 75 | content string | |
| 76 | } | |
| 77 | wants := []want{ | |
| 78 | {"hunk", 0, 0, ""}, | |
| 79 | {"ctx", 1, 1, "package main"}, | |
| 80 | {"ctx", 2, 2, ""}, | |
| 81 | {"del", 3, 0, "func old() string {"}, | |
| 82 | {"del", 4, 0, "\treturn \"a\""}, | |
| 83 | {"add", 0, 3, "func replaced() string {"}, | |
| 84 | {"add", 0, 4, "\t// a comment"}, | |
| 85 | {"add", 0, 5, "\treturn \"b\""}, | |
| 86 | {"ctx", 5, 6, "}"}, | |
| 87 | } | |
| 88 | if len(lines) != len(wants) { | |
| 89 | t.Fatalf("got %d lines, want %d: %+v", len(lines), len(wants), lines) | |
| 90 | } | |
| 91 | for i, w := range wants { | |
| 92 | got := lines[i] | |
| 93 | if got.Class != w.class || got.OldLine != w.old || got.NewLine != w.new { | |
| 94 | t.Errorf("line %d = %s old=%d new=%d, want %s old=%d new=%d", | |
| 95 | i, got.Class, got.OldLine, got.NewLine, w.class, w.old, w.new) | |
| 96 | } | |
| 97 | if w.class != "hunk" && got.Content != w.content { | |
| 98 | t.Errorf("line %d content = %q, want %q", i, got.Content, w.content) | |
| 99 | } | |
| 100 | } | |
| 101 | } | |
| 102 | ||
| 103 | // Highlighting runs per hunk side and is mapped back line by line; the | |
| 104 | // mapping is what breaks, so check that every code line got markup and that | |
| 105 | // it still says what the source said. | |
| 106 | func TestParseDiffHighlighting(t *testing.T) { | |
| 107 | for _, l := range parseDiff(samplePatch)[0].Lines { | |
| 108 | if l.Class == "hunk" || l.Content == "" { | |
| 109 | continue | |
| 110 | } | |
| 111 | if l.Code == "" { | |
| 112 | t.Errorf("%s line %q got no highlighted markup", l.Class, l.Content) | |
| 113 | continue | |
| 114 | } | |
| 115 | if text := strings.TrimSpace(stripTags(string(l.Code))); text != strings.TrimSpace(l.Content) { | |
| 116 | t.Errorf("highlighted %q reads as %q", l.Content, text) | |
| 117 | } | |
| 118 | } | |
| 119 | } | |
| 120 | ||
| 121 | // A file whose type chroma does not know renders as plain text rather than | |
| 122 | // being guessed at. | |
| 123 | func TestParseDiffUnknownType(t *testing.T) { | |
| 124 | files := parseDiff(`diff --git a/x.zzz b/x.zzz | |
| 125 | -- a/x.zzz | |
| 126 | +++ b/x.zzz | |
| 127 | @@ -1 +1 @@ | |
| 128 | before | |
| 129 | +after | |
| 130 | `) | |
| 131 | if len(files) != 1 { | |
| 132 | t.Fatalf("got %d files", len(files)) | |
| 133 | } | |
| 134 | for _, l := range files[0].Lines { | |
| 135 | if l.Class == "hunk" { | |
| 136 | continue | |
| 137 | } | |
| 138 | if l.Code != "" { | |
| 139 | t.Errorf("unknown type got markup: %q", l.Code) | |
| 140 | } | |
| 141 | } | |
| 142 | } | |
| 143 | ||
| 144 | // splitHighlighted has to close and reopen spans that straddle a newline, | |
| 145 | // or one unterminated tag swallows the rest of the file. | |
| 146 | func TestSplitHighlightedBalancesTags(t *testing.T) { | |
| 147 | got := splitHighlighted(`<span class="c">line one | |
| 148 | line two</span>plain`) | |
| 149 | want := []string{`<span class="c">line one</span>`, `<span class="c">line two</span>plain`} | |
| 150 | if len(got) != len(want) { | |
| 151 | t.Fatalf("got %d lines: %q", len(got), got) | |
| 152 | } | |
| 153 | for i := range want { | |
| 154 | if got[i] != want[i] { | |
| 155 | t.Errorf("line %d = %q, want %q", i, got[i], want[i]) | |
| 156 | } | |
| 157 | } | |
| 158 | } | |
| 159 | ||
| 160 | func stripTags(s string) string { | |
| 161 | var b strings.Builder | |
| 162 | depth := 0 | |
| 163 | for _, r := range s { | |
| 164 | switch { | |
| 165 | case r == '<': | |
| 166 | depth++ | |
| 167 | case r == '>': | |
| 168 | depth-- | |
| 169 | case depth == 0: | |
| 170 | b.WriteRune(r) | |
| 171 | } | |
| 172 | } | |
| 173 | return strings.ReplaceAll(b.String(), """, `"`) | |
| 174 | } | |
internal/httpd/web.go +29 −85
| @@ -1060,53 +1060,6 @@ func renderReadme(name string, raw []byte) template.HTML { | ||
| 1060 | 1060 | } |
| 1061 | 1061 | } |
| 1062 | 1062 | |
| 1063 | type diffLine struct { | |
| 1064 | Class string | |
| 1065 | Text string | |
| 1066 | Path string // file this line belongs to | |
| 1067 | NewLine int64 // line number in the new file (0 when absent) | |
| 1068 | OldLine int64 // line number in the old file (0 when absent) | |
| 1069 | Threads []diffThread | |
| 1070 | } | |
| 1071 | ||
| 1072 | var hunkPat = regexp.MustCompile(`^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@`) | |
| 1073 | ||
| 1074 | // classifyDiff parses a unified diff into rendered lines, tracking the | |
| 1075 | // file and old/new line numbers so review threads can anchor inline. | |
| 1076 | func classifyDiff(patch string) []diffLine { | |
| 1077 | var lines []diffLine | |
| 1078 | path := "" | |
| 1079 | var oldN, newN int64 | |
| 1080 | for _, l := range strings.Split(patch, "\n") { | |
| 1081 | d := diffLine{Text: l} | |
| 1082 | switch { | |
| 1083 | case strings.HasPrefix(l, "+++ "): | |
| 1084 | d.Class = "meta" | |
| 1085 | path = strings.TrimPrefix(strings.TrimPrefix(l, "+++ "), "b/") | |
| 1086 | case strings.HasPrefix(l, "--- "), strings.HasPrefix(l, "diff "), strings.HasPrefix(l, "index "): | |
| 1087 | d.Class = "meta" | |
| 1088 | case strings.HasPrefix(l, "@@"): | |
| 1089 | d.Class = "hunk" | |
| 1090 | if m := hunkPat.FindStringSubmatch(l); m != nil { | |
| 1091 | oldN, _ = strconv.ParseInt(m[1], 10, 64) | |
| 1092 | newN, _ = strconv.ParseInt(m[2], 10, 64) | |
| 1093 | } | |
| 1094 | case strings.HasPrefix(l, "+"): | |
| 1095 | d.Class, d.Path, d.NewLine = "add", path, newN | |
| 1096 | newN++ | |
| 1097 | case strings.HasPrefix(l, "-"): | |
| 1098 | d.Class, d.Path, d.OldLine = "del", path, oldN | |
| 1099 | oldN++ | |
| 1100 | default: | |
| 1101 | d.Path, d.OldLine, d.NewLine = path, oldN, newN | |
| 1102 | oldN++ | |
| 1103 | newN++ | |
| 1104 | } | |
| 1105 | lines = append(lines, d) | |
| 1106 | } | |
| 1107 | return lines | |
| 1108 | } | |
| 1109 | ||
| 1110 | 1063 | type diffThread struct { |
| 1111 | 1064 | ID int64 |
| 1112 | 1065 | Resolved string |
| @@ -1117,7 +1070,7 @@ type diffThread struct { | ||
| 1117 | 1070 | // attachThreads injects review threads under their anchored diff lines; |
| 1118 | 1071 | // threads whose anchor no longer appears (stale after force-push, or on a |
| 1119 | 1072 | // context line outside the current diff) are returned separately. |
| 1120 | func attachThreads(lines []diffLine, comments []store.DiffComment, headSHA string, md func(string) template.HTML) ([]diffLine, []diffThread) { | |
| 1073 | func attachThreads(files []diffFile, comments []store.DiffComment, headSHA string, md func(string) template.HTML) ([]diffFile, []diffThread) { | |
| 1121 | 1074 | type anchor struct { |
| 1122 | 1075 | path string |
| 1123 | 1076 | side string |
| @@ -1137,19 +1090,24 @@ func attachThreads(lines []diffLine, comments []store.DiffComment, headSHA strin | ||
| 1137 | 1090 | } |
| 1138 | 1091 | } |
| 1139 | 1092 | placed := map[int64]bool{} |
| 1140 | for i := range lines { | |
| 1141 | for _, id := range order { | |
| 1142 | if placed[id] || threads[id].Stale { | |
| 1143 | continue | |
| 1144 | } | |
| 1145 | a := anchors[id] | |
| 1146 | if lines[i].Path != a.path { | |
| 1147 | continue | |
| 1148 | } | |
| 1149 | if (a.side == "new" && lines[i].NewLine == a.line && lines[i].Class != "del") || | |
| 1150 | (a.side == "old" && lines[i].OldLine == a.line && lines[i].Class == "del") { | |
| 1151 | lines[i].Threads = append(lines[i].Threads, *threads[id]) | |
| 1152 | placed[id] = true | |
| 1093 | for f := range files { | |
| 1094 | lines := files[f].Lines | |
| 1095 | for i := range lines { | |
| 1096 | for _, id := range order { | |
| 1097 | if placed[id] || threads[id].Stale { | |
| 1098 | continue | |
| 1099 | } | |
| 1100 | a := anchors[id] | |
| 1101 | if lines[i].Path != a.path { | |
| 1102 | continue | |
| 1103 | } | |
| 1104 | if (a.side == "new" && lines[i].NewLine == a.line && lines[i].Class != "del") || | |
| 1105 | (a.side == "old" && lines[i].OldLine == a.line && lines[i].Class == "del") { | |
| 1106 | lines[i].Threads = append(lines[i].Threads, *threads[id]) | |
| 1107 | files[f].Threads++ | |
| 1108 | files[f].Open = true | |
| 1109 | placed[id] = true | |
| 1110 | } | |
| 1153 | 1111 | } |
| 1154 | 1112 | } |
| 1155 | 1113 | } |
| @@ -1159,7 +1117,7 @@ func attachThreads(lines []diffLine, comments []store.DiffComment, headSHA strin | ||
| 1159 | 1117 | unplaced = append(unplaced, *threads[id]) |
| 1160 | 1118 | } |
| 1161 | 1119 | } |
| 1162 | return lines, unplaced | |
| 1120 | return files, unplaced | |
| 1163 | 1121 | } |
| 1164 | 1122 | |
| 1165 | 1123 | type sigView struct { |
| @@ -1263,7 +1221,7 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request) { | ||
| 1263 | 1221 | return |
| 1264 | 1222 | } |
| 1265 | 1223 | patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20) |
| 1266 | lines := classifyDiff(patch) | |
| 1224 | files := parseDiff(patch) | |
| 1267 | 1225 | committerEmail := "" |
| 1268 | 1226 | if parsed.CommitterEmail != parsed.AuthorEmail { |
| 1269 | 1227 | committerEmail = parsed.CommitterEmail |
| @@ -1281,10 +1239,10 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request) { | ||
| 1281 | 1239 | Parents []string |
| 1282 | 1240 | Sig sigView |
| 1283 | 1241 | Checks []store.CommitStatus |
| 1284 | DiffLines []diffLine | |
| 1242 | DiffFiles []diffFile | |
| 1285 | 1243 | }{p, full, full[:10], commitNames.name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, commitUser, committerEmail, |
| 1286 | 1244 | time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg, |
| 1287 | gitutil.Parents(p.Dir, full), v, checks, lines}) | |
| 1245 | gitutil.Parents(p.Dir, full), v, checks, files}) | |
| 1288 | 1246 | } |
| 1289 | 1247 | |
| 1290 | 1248 | // labelPalette provides default label chip colors: mid-tone hues that stay |
| @@ -1472,7 +1430,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) { | ||
| 1472 | 1430 | diffComments, _ := s.st.ListDiffComments(m.ID) |
| 1473 | 1431 | |
| 1474 | 1432 | headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number) |
| 1475 | var lines []diffLine | |
| 1433 | var files []diffFile | |
| 1476 | 1434 | base := m.MergedBase |
| 1477 | 1435 | if base == "" { |
| 1478 | 1436 | if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil { |
| @@ -1481,27 +1439,13 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) { | ||
| 1481 | 1439 | } |
| 1482 | 1440 | if base != "" { |
| 1483 | 1441 | if patch, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil { |
| 1484 | lines = classifyDiff(patch) | |
| 1442 | files = parseDiff(patch) | |
| 1485 | 1443 | } |
| 1486 | 1444 | } |
| 1487 | 1445 | md := s.ugcFor(r, p.Repo) |
| 1488 | 1446 | var detachedThreads []diffThread |
| 1489 | lines, detachedThreads = attachThreads(lines, diffComments, m.HeadSHA, md) | |
| 1490 | type diffStat struct{ Files, Adds, Dels int } | |
| 1491 | var stat diffStat | |
| 1492 | seenFiles := map[string]bool{} | |
| 1493 | for _, l := range lines { | |
| 1494 | switch l.Class { | |
| 1495 | case "add": | |
| 1496 | stat.Adds++ | |
| 1497 | case "del": | |
| 1498 | stat.Dels++ | |
| 1499 | } | |
| 1500 | if l.Path != "" && !seenFiles[l.Path] { | |
| 1501 | seenFiles[l.Path] = true | |
| 1502 | stat.Files++ | |
| 1503 | } | |
| 1504 | } | |
| 1447 | files, detachedThreads = attachThreads(files, diffComments, m.HeadSHA, md) | |
| 1448 | stat := statOf(files) | |
| 1505 | 1449 | // The commits this MR carries: base..head, the same range as the diff. |
| 1506 | 1450 | type commitRow struct { |
| 1507 | 1451 | SHA, ShortSHA, Subject, AuthorName, AuthorUser, Date string |
| @@ -1544,7 +1488,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) { | ||
| 1544 | 1488 | Combined string |
| 1545 | 1489 | Comments []renderedComment |
| 1546 | 1490 | Reviews []store.MRReview |
| 1547 | DiffLines []diffLine | |
| 1491 | DiffFiles []diffFile | |
| 1548 | 1492 | Stat diffStat |
| 1549 | 1493 | Commits []commitRow |
| 1550 | 1494 | CanEdit bool |
| @@ -1553,7 +1497,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) { | ||
| 1553 | 1497 | Notice string |
| 1554 | 1498 | DetachedThreads []diffThread |
| 1555 | 1499 | }{p, m, view, md(m.Body), checks, store.CombinedStatus(checks), renderComments(comments, md), |
| 1556 | reviews, lines, stat, commits, s.canEditItem(r, p.Repo, m.Author), | |
| 1500 | reviews, files, stat, commits, s.canEditItem(r, p.Repo, m.Author), | |
| 1557 | 1501 | s.canWriteRepo(r, p.Repo), unresolved, r.URL.Query().Get("e"), detachedThreads}) |
| 1558 | 1502 | } |
| 1559 | 1503 | |
internal/web/static/style.css +71 −17
| @@ -42,6 +42,10 @@ | ||
| 42 | 42 | --surface: #ffffff; |
| 43 | 43 | --hover: #f5f5f5; |
| 44 | 44 | --code-bg: #f5f5f5; |
| 45 | --fill-subtle: #ececec; /* hunk headers, summary hover */ | |
| 46 | --diff-add: #e4f6ea; /* row grounds: tinted enough to scan, light | |
| 47 | enough that syntax colors stay legible */ | |
| 48 | --diff-del: #fdeaea; | |
| 45 | 49 | |
| 46 | 50 | /* blue: what you can do */ |
| 47 | 51 | --accent: #0000f0; /* links, focus — 9.30:1 on white */ |
| @@ -104,6 +108,9 @@ | ||
| 104 | 108 | --surface: #0f0f0f; |
| 105 | 109 | --hover: #161616; |
| 106 | 110 | --code-bg: #050505; |
| 111 | --fill-subtle: #161616; | |
| 112 | --diff-add: #0d2a18; | |
| 113 | --diff-del: #2c1113; | |
| 107 | 114 | --accent: #5b6bff; /* 4.71:1 — pure #0000f0 is 2.13:1 here */ |
| 108 | 115 | --accent-fg: #ffffff; |
| 109 | 116 | --fill: #0000f0; /* fills stay pure; white on it clears 9:1 */ |
| @@ -787,34 +794,81 @@ pre.message { | ||
| 787 | 794 | overflow-wrap: anywhere; |
| 788 | 795 | } |
| 789 | 796 | |
| 790 | /* diffs */ | |
| 797 | /* diffs: one foldable section per file, a table so the line-number | |
| 798 | gutters stay put while the code scrolls */ | |
| 799 | details.difffold { | |
| 800 | border: 1px solid var(--line); | |
| 801 | border-radius: var(--r-md); | |
| 802 | margin: 0 0 var(--sp-3); | |
| 803 | background: var(--code-bg); | |
| 804 | } | |
| 791 | 805 | details.difffold summary { |
| 792 | 806 | cursor: pointer; |
| 793 | 807 | display: flex; |
| 794 | 808 | align-items: baseline; |
| 795 | 809 | gap: var(--sp-3); |
| 796 | margin: var(--sp-5) 0 var(--sp-2); | |
| 810 | padding: var(--sp-2) var(--sp-3); | |
| 797 | 811 | list-style: none; |
| 812 | font-size: var(--fs-2); | |
| 798 | 813 | } |
| 814 | details.difffold[open] summary { border-bottom: 1px solid var(--line); } | |
| 799 | 815 | details.difffold summary::-webkit-details-marker { display: none; } |
| 800 | 816 | /* flex summaries lose the native disclosure marker; draw our own */ |
| 801 | details.difffold summary::before { content: "▸"; color: var(--muted); } | |
| 802 | details.difffold[open] summary::before { content: "▾"; } | |
| 803 | details.difffold summary h3 { display: inline; margin: 0; } | |
| 804 | details.difffold summary .add { color: var(--ok); } | |
| 805 | details.difffold summary .del { color: var(--bad); } | |
| 806 | pre.diff { | |
| 807 | background: var(--code-bg); | |
| 808 | border: 1px solid var(--line); | |
| 809 | padding: var(--sp-3) var(--sp-4); | |
| 810 | border-radius: var(--r-md); | |
| 811 | overflow-x: auto; | |
| 817 | details.difffold summary::before { content: "\25b8"; color: var(--muted); } | |
| 818 | details.difffold[open] summary::before { content: "\25be"; } | |
| 819 | details.difffold summary:hover { background: var(--fill-subtle); } | |
| 820 | summary .fpath { font-family: var(--mono); overflow-wrap: anywhere; } | |
| 821 | summary .fpath .was { color: var(--muted); } | |
| 822 | summary .fstat { margin-left: auto; font-family: var(--mono); white-space: nowrap; } | |
| 823 | details.difffold .add { color: var(--ok); } | |
| 824 | details.difffold .del { color: var(--bad); } | |
| 825 | details.difffold > .none { padding: var(--sp-3); margin: 0; } | |
| 826 | details.difffold .tablewrap { border-radius: 0 0 var(--r-md) var(--r-md); } | |
| 827 | ||
| 828 | table.difftable { | |
| 829 | border-collapse: collapse; | |
| 830 | width: 100%; | |
| 831 | font-family: var(--mono); | |
| 832 | font-size: var(--fs-1); | |
| 812 | 833 | line-height: 1.5; |
| 813 | 834 | } |
| 814 | pre.diff .add { color: var(--ok); } | |
| 815 | pre.diff .del { color: var(--bad); } | |
| 816 | pre.diff .hunk { color: var(--accent); } | |
| 817 | pre.diff .meta { color: var(--muted); } | |
| 835 | table.difftable td { | |
| 836 | border: 0; | |
| 837 | padding: 0; | |
| 838 | vertical-align: top; | |
| 839 | font-size: inherit; | |
| 840 | } | |
| 841 | table.difftable td.ln { | |
| 842 | width: 1%; | |
| 843 | min-width: 2.5rem; | |
| 844 | padding: 0 var(--sp-2); | |
| 845 | text-align: right; | |
| 846 | color: var(--muted); | |
| 847 | user-select: none; | |
| 848 | border-right: 1px solid var(--line); | |
| 849 | white-space: nowrap; | |
| 850 | } | |
| 851 | table.difftable td.code { | |
| 852 | padding: 0 var(--sp-3); | |
| 853 | white-space: pre-wrap; | |
| 854 | overflow-wrap: anywhere; | |
| 855 | } | |
| 856 | /* the marker is decoration, so it lives in CSS and stays out of a copied | |
| 857 | selection */ | |
| 858 | table.difftable tr.add td.code::before { content: "+"; color: var(--ok); } | |
| 859 | table.difftable tr.del td.code::before { content: "\2212"; color: var(--bad); } | |
| 860 | table.difftable tr.ctx td.code::before { content: " "; } | |
| 861 | table.difftable tr.add { background: var(--diff-add); } | |
| 862 | table.difftable tr.del { background: var(--diff-del); } | |
| 863 | table.difftable tr.hunk td, table.difftable tr.dmeta td { | |
| 864 | color: var(--muted); | |
| 865 | padding: var(--sp-1) var(--sp-3); | |
| 866 | background: var(--fill-subtle); | |
| 867 | border-top: 1px solid var(--line); | |
| 868 | border-bottom: 1px solid var(--line); | |
| 869 | } | |
| 870 | table.difftable tr.hunk td.code { color: var(--accent); } | |
| 871 | table.difftable tr.threadrow td { padding: var(--sp-2) var(--sp-3); background: var(--bg); } | |
| 818 | 872 | |
| 819 | 873 | /* badges: signature and check states. Semantic colors stay distinct: |
| 820 | 874 | green verified/success, amber pending/stale, red bad/failure, muted |
internal/web/templates/commit.html +1 −2
| @@ -9,6 +9,5 @@ | ||
| 9 | 9 | {{if .CommitterEmail}}<br>committer: <{{.CommitterEmail}}>{{end}}</p> |
| 10 | 10 | </div> |
| 11 | 11 | <pre class="message">{{.Message}}</pre> |
| 12 | <pre class="diff">{{range .DiffLines}}<span class="{{.Class}}">{{.Text}}</span> | |
| 13 | {{end}}</pre> | |
| 12 | {{template "difffiles" dict "Files" .DiffFiles "Base" "" "Can" false}} | |
| 14 | 13 | {{end}} |
internal/web/templates/layout.html +24
| @@ -109,3 +109,27 @@ | ||
| 109 | 109 | {{define "sigbadge"}}<span class="badge badge-{{.State}}" title="{{.Fingerprint}}">{{sigLabel .State}}{{if .Signer}} · {{.Signer}}{{end}}</span>{{end}} |
| 110 | 110 | |
| 111 | 111 | {{define "authorname"}}{{if .User}}<a class="authorlink" href="/{{.User}}" title="{{.Email}}">{{.Name}}</a>{{else}}<span title="{{.Email}}">{{.Name}}</span>{{end}}{{end}} |
| 112 | ||
| 113 | {{/* difffiles renders a parsed diff: one foldable section per file, with | |
| 114 | line-number gutters and review threads inline. Base and Can are the | |
| 115 | MR's comment endpoint and the viewer's write permission; the commit | |
| 116 | page passes neither and gets the same diff without thread controls. */}} | |
| 117 | {{define "difffiles"}}{{$base := .Base}}{{$can := .Can}} | |
| 118 | {{range .Files}}<details class="difffold"{{if .Open}} open{{end}}> | |
| 119 | <summary> | |
| 120 | <span class="fpath">{{if eq .Status "renamed"}}<span class="was">{{.OldPath}} →</span> {{end}}{{.Path}}</span> | |
| 121 | {{if ne .Status "modified"}}<span class="chip">{{.Status}}</span>{{end}} | |
| 122 | <span class="fstat">{{if .Adds}}<span class="add">+{{.Adds}}</span>{{end}}{{if .Dels}} <span class="del">−{{.Dels}}</span>{{end}}</span> | |
| 123 | </summary> | |
| 124 | {{if .Binary}}<p class="none">Binary file not shown.</p> | |
| 125 | {{else}}<div class="tablewrap"><table class="difftable"> | |
| 126 | {{range .Lines}}{{if eq .Class "hunk"}}<tr class="hunk"><td class="ln" colspan="2"></td><td class="code">{{.Text}}</td></tr> | |
| 127 | {{else if eq .Class "meta"}}<tr class="dmeta"><td class="ln" colspan="2"></td><td class="code">{{.Text}}</td></tr> | |
| 128 | {{else}}<tr class="{{.Class}}"><td class="ln">{{if .OldLine}}{{.OldLine}}{{end}}</td><td class="ln">{{if .NewLine}}{{.NewLine}}{{end}}</td><td class="code">{{if .Code}}{{.Code}}{{else}}{{.Content}}{{end}}</td></tr> | |
| 129 | {{end}}{{range .Threads}}<tr class="threadrow"><td colspan="3"><div class="thread{{if .Resolved}} resolved{{end}}">{{if .Resolved}}<p class="threadstate">resolved by {{.Resolved}}</p>{{end}}{{range .Comments}}<p class="commenthead"><strong>{{.Author}}</strong> <span class="when">{{when .CreatedAt}}</span></p><div class="rendered">{{.BodyHTML}}</div>{{end}}{{template "threadact" dict "ID" .ID "Resolved" .Resolved "Base" $base "Can" $can}}</div></td></tr> | |
| 130 | {{end}}{{end}} | |
| 131 | </table></div>{{end}} | |
| 132 | </details> | |
| 133 | {{end}}{{end}} | |
| 134 | ||
| 135 | {{define "threadact"}}{{if .Can}}<form method="post" action="{{.Base}}/thread" class="threadact"><input type="hidden" name="thread" value="{{.ID}}"><button type="submit" name="action" value="{{if .Resolved}}unresolve{{else}}resolve{{end}}" class="linklike">{{if .Resolved}}Reopen thread{{else}}Resolve thread{{end}}</button></form>{{end}}{{end}} | |
internal/web/templates/mr.html +1 −3
| @@ -61,8 +61,7 @@ | ||
| 61 | 61 | |
| 62 | 62 | {{else}} |
| 63 | 63 | <p class="diffstat">{{.Stat.Files}} file{{if ne .Stat.Files 1}}s{{end}} changed, <span class="add">+{{.Stat.Adds}}</span> <span class="del">−{{.Stat.Dels}}</span></p> |
| 64 | <pre class="diff">{{range .DiffLines}}<span class="{{.Class}}">{{.Text}}</span> | |
| 65 | {{range .Threads}}</pre><div class="thread{{if .Resolved}} resolved{{end}}">{{if .Resolved}}<p class="threadstate">resolved by {{.Resolved}}</p>{{end}}{{range .Comments}}<p class="commenthead"><strong>{{.Author}}</strong> <span class="when">{{when .CreatedAt}}</span></p><div class="rendered">{{.BodyHTML}}</div>{{end}}{{template "threadact" dict "ID" .ID "Resolved" .Resolved "Base" $base "Can" $.CanWrite}}</div><pre class="diff">{{end}}{{end}}</pre> | |
| 64 | {{template "difffiles" dict "Files" .DiffFiles "Base" $base "Can" .CanWrite}} | |
| 66 | 65 | {{end}} |
| 67 | 66 | |
| 68 | 67 | </div> |
| @@ -119,4 +118,3 @@ | ||
| 119 | 118 | </div> |
| 120 | 119 | {{end}} |
| 121 | 120 | |
| 122 | {{define "threadact"}}{{if .Can}}<form method="post" action="{{.Base}}/thread" class="threadact"><input type="hidden" name="thread" value="{{.ID}}"><button type="submit" name="action" value="{{if .Resolved}}unresolve{{else}}resolve{{end}}" class="linklike">{{if .Resolved}}Reopen thread{{else}}Resolve thread{{end}}</button></form>{{end}}{{end}} | |