web: a real diff view, and commit references name their author !76
12 files changed, +766 −119
Layout: unified · split
e2e/commitrefs_test.go +17 −1
| @@ -34,8 +34,10 @@ func TestCommitMessageIssueActions(t *testing.T) { | |||
| 34 | // A closing keyword on the default branch closes the issue with a | 34 | // A closing keyword on the default branch closes the issue with a |
| 35 | // comment; a bare reference (and a nonexistent #99) only comments. | 35 | // comment; a bare reference (and a nonexistent #99) only comments. |
| 36 | os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b\n"), 0o644) | 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 | mustGit(t, dir, env, "add", ".") | 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 | mustGit(t, dir, env, "push", "-q", "origin", "main") | 41 | mustGit(t, dir, env, "push", "-q", "origin", "main") |
| 40 | 42 | ||
| 41 | out, _, _ := inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "1", "--json") | 43 | out, _, _ := inst.ssh(t, aliceKey, "", "issue", "show", "alice/app", "1", "--json") |
| @@ -55,6 +57,15 @@ func TestCommitMessageIssueActions(t *testing.T) { | |||
| 55 | !strings.Contains(out, "repair the widget") { | 57 | !strings.Contains(out, "repair the widget") { |
| 56 | t.Fatalf("issue 2 not referenced: %s", out) | 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 | // Commits on a feature branch do nothing until they land on the | 70 | // Commits on a feature branch do nothing until they land on the |
| 60 | // default branch via a merge — then the merge path acts exactly once. | 71 | // default branch via a merge — then the merge path acts exactly once. |
| @@ -78,6 +89,11 @@ func TestCommitMessageIssueActions(t *testing.T) { | |||
| 78 | if !strings.Contains(out, `"state":"closed"`) || !strings.Contains(out, "closed by commit") { | 89 | if !strings.Contains(out, `"state":"closed"`) || !strings.Contains(out, "closed by commit") { |
| 79 | t.Fatalf("merge did not close issue 3: %s", out) | 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 | if strings.Count(out, "closed by commit") != 1 { | 97 | if strings.Count(out, "closed by commit") != 1 { |
| 82 | t.Fatalf("duplicate close comments: %s", out) | 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 | if !strings.Contains(body, `href="/alice/app/mrs/1?view=diff"`) { | 110 | if !strings.Contains(body, `href="/alice/app/mrs/1?view=diff"`) { |
| 111 | t.Fatal("merge request missing the files-changed view") | 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 | t.Fatal("diff rendered on the conversation view") | 114 | t.Fatal("diff rendered on the conversation view") |
| 115 | } | 115 | } |
| 116 | _, body = inst.get(t, "/alice/app/mrs/1?view=diff") | 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 | t.Fatalf("diff view missing stat or patch:\n%s", body) | 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 | subject, _, _ := strings.Cut(m.Message, "\n") | 49 | subject, _, _ := strings.Cut(m.Message, "\n") |
| 50 | author := authorLink(st, m.AuthorName, m.AuthorEmail) | ||
| 50 | for n := range closes { | 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 | for n := range refs { | 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 | issue, err := st.IssueByNumber(repo.ID, number) | 90 | issue, err := st.IssueByNumber(repo.ID, number) |
| 77 | if err != nil { | 91 | if err != nil { |
| 78 | return // no such issue: the reference is just text | 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 | slog.Error("commit refs: closing issue", "issue", number, "err", err) | 107 | slog.Error("commit refs: closing issue", "issue", number, "err", err) |
| 94 | return | 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 | st.RecordEvent(repo.ID, actorID, "issue.closed", fmt.Sprintf(`{"number":%d,"sha":%q}`, number, sha)) | 111 | st.RecordEvent(repo.ID, actorID, "issue.closed", fmt.Sprintf(`{"number":%d,"sha":%q}`, number, sha)) |
| 98 | return | 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 | type CommitMsg struct { | 64 | type CommitMsg struct { |
| 65 | SHA string | 65 | SHA string |
| 66 | Message string | 66 | Message string |
| 67 | AuthorName string | ||
| 68 | AuthorEmail string | ||
| 67 | } | 69 | } |
| 68 | 70 | ||
| 69 | // RevListMessages returns sha and full message for commits reachable from | 71 | // RevListMessages returns sha and full message for commits reachable from |
| 70 | // new but not old, newest first, capped at max. An empty or zero old (new | 72 | // new but not old, newest first, capped at max. An empty or zero old (new |
| 71 | // branch) lists from new alone, still capped. | 73 | // branch) lists from new alone, still capped. |
| 72 | func RevListMessages(dir, old, new string, max int) ([]CommitMsg, error) { | 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 | if old != "" && old != zeroSHA { | 76 | if old != "" && old != zeroSHA { |
| 75 | args = append(args, "^"+old) | 77 | args = append(args, "^"+old) |
| 76 | } | 78 | } |
| @@ -88,7 +90,20 @@ func RevListMessages(dir, old, new string, max int) ([]CommitMsg, error) { | |||
| 88 | if !ok || !strings.HasPrefix(header, "commit ") { | 90 | if !ok || !strings.HasPrefix(header, "commit ") { |
| 89 | continue | 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 | return msgs, nil | 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 | type diffThread struct { | 1063 | type diffThread struct { |
| 1111 | ID int64 | 1064 | ID int64 |
| 1112 | Resolved string | 1065 | Resolved string |
| @@ -1117,7 +1070,7 @@ type diffThread struct { | |||
| 1117 | // attachThreads injects review threads under their anchored diff lines; | 1070 | // attachThreads injects review threads under their anchored diff lines; |
| 1118 | // threads whose anchor no longer appears (stale after force-push, or on a | 1071 | // threads whose anchor no longer appears (stale after force-push, or on a |
| 1119 | // context line outside the current diff) are returned separately. | 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 | type anchor struct { | 1074 | type anchor struct { |
| 1122 | path string | 1075 | path string |
| 1123 | side string | 1076 | side string |
| @@ -1137,19 +1090,24 @@ func attachThreads(lines []diffLine, comments []store.DiffComment, headSHA strin | |||
| 1137 | } | 1090 | } |
| 1138 | } | 1091 | } |
| 1139 | placed := map[int64]bool{} | 1092 | placed := map[int64]bool{} |
| 1140 | for i := range lines { | 1093 | for f := range files { |
| 1141 | for _, id := range order { | 1094 | lines := files[f].Lines |
| 1142 | if placed[id] || threads[id].Stale { | 1095 | for i := range lines { |
| 1143 | continue | 1096 | for _, id := range order { |
| 1144 | } | 1097 | if placed[id] || threads[id].Stale { |
| 1145 | a := anchors[id] | 1098 | continue |
| 1146 | if lines[i].Path != a.path { | 1099 | } |
| 1147 | continue | 1100 | a := anchors[id] |
| 1148 | } | 1101 | if lines[i].Path != a.path { |
| 1149 | if (a.side == "new" && lines[i].NewLine == a.line && lines[i].Class != "del") || | 1102 | continue |
| 1150 | (a.side == "old" && lines[i].OldLine == a.line && lines[i].Class == "del") { | 1103 | } |
| 1151 | lines[i].Threads = append(lines[i].Threads, *threads[id]) | 1104 | if (a.side == "new" && lines[i].NewLine == a.line && lines[i].Class != "del") || |
| 1152 | placed[id] = true | 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 | unplaced = append(unplaced, *threads[id]) | 1117 | unplaced = append(unplaced, *threads[id]) |
| 1160 | } | 1118 | } |
| 1161 | } | 1119 | } |
| 1162 | return lines, unplaced | 1120 | return files, unplaced |
| 1163 | } | 1121 | } |
| 1164 | 1122 | ||
| 1165 | type sigView struct { | 1123 | type sigView struct { |
| @@ -1263,7 +1221,7 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request) { | |||
| 1263 | return | 1221 | return |
| 1264 | } | 1222 | } |
| 1265 | patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20) | 1223 | patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20) |
| 1266 | lines := classifyDiff(patch) | 1224 | files := parseDiff(patch) |
| 1267 | committerEmail := "" | 1225 | committerEmail := "" |
| 1268 | if parsed.CommitterEmail != parsed.AuthorEmail { | 1226 | if parsed.CommitterEmail != parsed.AuthorEmail { |
| 1269 | committerEmail = parsed.CommitterEmail | 1227 | committerEmail = parsed.CommitterEmail |
| @@ -1281,10 +1239,10 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request) { | |||
| 1281 | Parents []string | 1239 | Parents []string |
| 1282 | Sig sigView | 1240 | Sig sigView |
| 1283 | Checks []store.CommitStatus | 1241 | Checks []store.CommitStatus |
| 1284 | DiffLines []diffLine | 1242 | DiffFiles []diffFile |
| 1285 | }{p, full, full[:10], commitNames.name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, commitUser, committerEmail, | 1243 | }{p, full, full[:10], commitNames.name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, commitUser, committerEmail, |
| 1286 | time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg, | 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 | // labelPalette provides default label chip colors: mid-tone hues that stay | 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 | diffComments, _ := s.st.ListDiffComments(m.ID) | 1430 | diffComments, _ := s.st.ListDiffComments(m.ID) |
| 1473 | 1431 | ||
| 1474 | headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number) | 1432 | headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number) |
| 1475 | var lines []diffLine | 1433 | var files []diffFile |
| 1476 | base := m.MergedBase | 1434 | base := m.MergedBase |
| 1477 | if base == "" { | 1435 | if base == "" { |
| 1478 | if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil { | 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 | if base != "" { | 1440 | if base != "" { |
| 1483 | if patch, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil { | 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 | md := s.ugcFor(r, p.Repo) | 1445 | md := s.ugcFor(r, p.Repo) |
| 1488 | var detachedThreads []diffThread | 1446 | var detachedThreads []diffThread |
| 1489 | lines, detachedThreads = attachThreads(lines, diffComments, m.HeadSHA, md) | 1447 | files, detachedThreads = attachThreads(files, diffComments, m.HeadSHA, md) |
| 1490 | type diffStat struct{ Files, Adds, Dels int } | 1448 | stat := statOf(files) |
| 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 | } | ||
| 1505 | // The commits this MR carries: base..head, the same range as the diff. | 1449 | // The commits this MR carries: base..head, the same range as the diff. |
| 1506 | type commitRow struct { | 1450 | type commitRow struct { |
| 1507 | SHA, ShortSHA, Subject, AuthorName, AuthorUser, Date string | 1451 | SHA, ShortSHA, Subject, AuthorName, AuthorUser, Date string |
| @@ -1544,7 +1488,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) { | |||
| 1544 | Combined string | 1488 | Combined string |
| 1545 | Comments []renderedComment | 1489 | Comments []renderedComment |
| 1546 | Reviews []store.MRReview | 1490 | Reviews []store.MRReview |
| 1547 | DiffLines []diffLine | 1491 | DiffFiles []diffFile |
| 1548 | Stat diffStat | 1492 | Stat diffStat |
| 1549 | Commits []commitRow | 1493 | Commits []commitRow |
| 1550 | CanEdit bool | 1494 | CanEdit bool |
| @@ -1553,7 +1497,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) { | |||
| 1553 | Notice string | 1497 | Notice string |
| 1554 | DetachedThreads []diffThread | 1498 | DetachedThreads []diffThread |
| 1555 | }{p, m, view, md(m.Body), checks, store.CombinedStatus(checks), renderComments(comments, md), | 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 | s.canWriteRepo(r, p.Repo), unresolved, r.URL.Query().Get("e"), detachedThreads}) | 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 | --surface: #ffffff; | 42 | --surface: #ffffff; |
| 43 | --hover: #f5f5f5; | 43 | --hover: #f5f5f5; |
| 44 | --code-bg: #f5f5f5; | 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 | /* blue: what you can do */ | 50 | /* blue: what you can do */ |
| 47 | --accent: #0000f0; /* links, focus — 9.30:1 on white */ | 51 | --accent: #0000f0; /* links, focus — 9.30:1 on white */ |
| @@ -104,6 +108,9 @@ | |||
| 104 | --surface: #0f0f0f; | 108 | --surface: #0f0f0f; |
| 105 | --hover: #161616; | 109 | --hover: #161616; |
| 106 | --code-bg: #050505; | 110 | --code-bg: #050505; |
| 111 | --fill-subtle: #161616; | ||
| 112 | --diff-add: #0d2a18; | ||
| 113 | --diff-del: #2c1113; | ||
| 107 | --accent: #5b6bff; /* 4.71:1 — pure #0000f0 is 2.13:1 here */ | 114 | --accent: #5b6bff; /* 4.71:1 — pure #0000f0 is 2.13:1 here */ |
| 108 | --accent-fg: #ffffff; | 115 | --accent-fg: #ffffff; |
| 109 | --fill: #0000f0; /* fills stay pure; white on it clears 9:1 */ | 116 | --fill: #0000f0; /* fills stay pure; white on it clears 9:1 */ |
| @@ -787,34 +794,81 @@ pre.message { | |||
| 787 | overflow-wrap: anywhere; | 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 | details.difffold summary { | 805 | details.difffold summary { |
| 792 | cursor: pointer; | 806 | cursor: pointer; |
| 793 | display: flex; | 807 | display: flex; |
| 794 | align-items: baseline; | 808 | align-items: baseline; |
| 795 | gap: var(--sp-3); | 809 | gap: var(--sp-3); |
| 796 | margin: var(--sp-5) 0 var(--sp-2); | 810 | padding: var(--sp-2) var(--sp-3); |
| 797 | list-style: none; | 811 | list-style: none; |
| 812 | font-size: var(--fs-2); | ||
| 798 | } | 813 | } |
| 814 | details.difffold[open] summary { border-bottom: 1px solid var(--line); } | ||
| 799 | details.difffold summary::-webkit-details-marker { display: none; } | 815 | details.difffold summary::-webkit-details-marker { display: none; } |
| 800 | /* flex summaries lose the native disclosure marker; draw our own */ | 816 | /* flex summaries lose the native disclosure marker; draw our own */ |
| 801 | details.difffold summary::before { content: "▸"; color: var(--muted); } | 817 | details.difffold summary::before { content: "\25b8"; color: var(--muted); } |
| 802 | details.difffold[open] summary::before { content: "▾"; } | 818 | details.difffold[open] summary::before { content: "\25be"; } |
| 803 | details.difffold summary h3 { display: inline; margin: 0; } | 819 | details.difffold summary:hover { background: var(--fill-subtle); } |
| 804 | details.difffold summary .add { color: var(--ok); } | 820 | summary .fpath { font-family: var(--mono); overflow-wrap: anywhere; } |
| 805 | details.difffold summary .del { color: var(--bad); } | 821 | summary .fpath .was { color: var(--muted); } |
| 806 | pre.diff { | 822 | summary .fstat { margin-left: auto; font-family: var(--mono); white-space: nowrap; } |
| 807 | background: var(--code-bg); | 823 | details.difffold .add { color: var(--ok); } |
| 808 | border: 1px solid var(--line); | 824 | details.difffold .del { color: var(--bad); } |
| 809 | padding: var(--sp-3) var(--sp-4); | 825 | details.difffold > .none { padding: var(--sp-3); margin: 0; } |
| 810 | border-radius: var(--r-md); | 826 | details.difffold .tablewrap { border-radius: 0 0 var(--r-md) var(--r-md); } |
| 811 | overflow-x: auto; | 827 | |
| 828 | table.difftable { | ||
| 829 | border-collapse: collapse; | ||
| 830 | width: 100%; | ||
| 831 | font-family: var(--mono); | ||
| 832 | font-size: var(--fs-1); | ||
| 812 | line-height: 1.5; | 833 | line-height: 1.5; |
| 813 | } | 834 | } |
| 814 | pre.diff .add { color: var(--ok); } | 835 | table.difftable td { |
| 815 | pre.diff .del { color: var(--bad); } | 836 | border: 0; |
| 816 | pre.diff .hunk { color: var(--accent); } | 837 | padding: 0; |
| 817 | pre.diff .meta { color: var(--muted); } | 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 | /* badges: signature and check states. Semantic colors stay distinct: | 873 | /* badges: signature and check states. Semantic colors stay distinct: |
| 820 | green verified/success, amber pending/stale, red bad/failure, muted | 874 | green verified/success, amber pending/stale, red bad/failure, muted |
internal/web/templates/commit.html +1 −2
| @@ -9,6 +9,5 @@ | |||
| 9 | {{if .CommitterEmail}}<br>committer: <{{.CommitterEmail}}>{{end}}</p> | 9 | {{if .CommitterEmail}}<br>committer: <{{.CommitterEmail}}>{{end}}</p> |
| 10 | </div> | 10 | </div> |
| 11 | <pre class="message">{{.Message}}</pre> | 11 | <pre class="message">{{.Message}}</pre> |
| 12 | <pre class="diff">{{range .DiffLines}}<span class="{{.Class}}">{{.Text}}</span> | 12 | {{template "difffiles" dict "Files" .DiffFiles "Base" "" "Can" false}} |
| 13 | {{end}}</pre> | ||
| 14 | {{end}} | 13 | {{end}} |
internal/web/templates/layout.html +24
| @@ -109,3 +109,27 @@ | |||
| 109 | {{define "sigbadge"}}<span class="badge badge-{{.State}}" title="{{.Fingerprint}}">{{sigLabel .State}}{{if .Signer}} · {{.Signer}}{{end}}</span>{{end}} | 109 | {{define "sigbadge"}}<span class="badge badge-{{.State}}" title="{{.Fingerprint}}">{{sigLabel .State}}{{if .Signer}} · {{.Signer}}{{end}}</span>{{end}} |
| 110 | 110 | ||
| 111 | {{define "authorname"}}{{if .User}}<a class="authorlink" href="/{{.User}}" title="{{.Email}}">{{.Name}}</a>{{else}}<span title="{{.Email}}">{{.Name}}</span>{{end}}{{end}} | 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 | {{else}} | 62 | {{else}} |
| 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> | 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> | 64 | {{template "difffiles" dict "Files" .DiffFiles "Base" $base "Can" .CanWrite}} |
| 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> | ||
| 66 | {{end}} | 65 | {{end}} |
| 67 | 66 | ||
| 68 | </div> | 67 | </div> |
| @@ -119,4 +118,3 @@ | |||
| 119 | </div> | 118 | </div> |
| 120 | {{end}} | 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}} | ||