Commit 77812a5bbf

77812a5bbf967dd3948ea04859b0592d8d28ede9

parent: 30185df312

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24 18:52 UTC

web: collapse MR diffs by default

The diff sits in a <details> (no JS), collapsed, with a summary showing
files changed and +/- line counts computed from the classified diff.
Inline review threads expand with it; detached threads stay outside.

Layout: unified · split

internal/httpd/web.go +58 −16
@@ -83,35 +83,61 @@ func (s *Server) describeAll(repos []store.Repo) []describedRepo {
83 return out 83 return out
84} 84}
85 85
86// index is the homepage: a dashboard for logged-in users, a landing page
87// for everyone else. The full public listing lives at /explore.
86func (s *Server) index(w http.ResponseWriter, r *http.Request) { 88func (s *Server) index(w http.ResponseWriter, r *http.Request) {
89 if s.cfg.Web.Mode == "accounts" {
90 if viewer := s.viewer(r); viewer.ID != 0 {
91 s.dashboard(w, r, viewer)
92 return
93 }
94 }
95 host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(
96 s.cfg.Server.SiteURL, "https://"), "http://"), "/")
97 s.render(w, "landing.html", struct {
98 Site string
99 Host string
100 Accounts bool
101 }{s.siteName(), host, s.cfg.Web.Mode == "accounts"})
102}
103
104func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.User) {
105 pinned, _ := s.st.PinnedRepos(viewer.ID)
106 var visible []store.Repo
107 for _, rp := range pinned {
108 grant, _ := s.st.AccessRole(rp.ID, viewer.ID)
109 if policy.CanRead(viewer, rp, grant) {
110 visible = append(visible, rp)
111 }
112 }
113 mrs, _ := s.st.DashboardMRs(viewer.ID)
114 issues, _ := s.st.DashboardIssues(viewer.ID)
115 s.render(w, "dashboard.html", struct {
116 Site string
117 Viewer string
118 Pinned []describedRepo
119 MRs []store.DashboardItem
120 Issues []store.DashboardItem
121 }{s.siteName(), viewer.Username, s.describeAll(visible), mrs, issues})
122}
123
124func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
87 repos, err := s.st.ListPublicRepos() 125 repos, err := s.st.ListPublicRepos()
88 if err != nil { 126 if err != nil {
89 http.Error(w, "internal error", http.StatusInternalServerError) 127 http.Error(w, "internal error", http.StatusInternalServerError)
90 return 128 return
91 } 129 }
92 var viewer store.User 130 var viewer store.User
93 var mine []store.Repo
94 if s.cfg.Web.Mode == "accounts" { 131 if s.cfg.Web.Mode == "accounts" {
95 if viewer = s.viewer(r); viewer.ID != 0 { 132 viewer = s.viewer(r)
96 all, err := s.st.ListReposForUser(viewer.ID)
97 if err == nil {
98 for _, rp := range all {
99 if rp.Visibility == "private" {
100 mine = append(mine, rp)
101 }
102 }
103 }
104 }
105 } 133 }
106 q := strings.TrimSpace(r.URL.Query().Get("q")) 134 q := strings.TrimSpace(r.URL.Query().Get("q"))
107 s.render(w, "index.html", struct { 135 s.render(w, "explore.html", struct {
108 Site string 136 Site string
109 Viewer string 137 Viewer string
110 Query string 138 Query string
111 Repos []describedRepo 139 Repos []describedRepo
112 Mine []describedRepo 140 }{s.siteName(), viewer.Username, q, s.filterRepos(q, s.describeAll(repos))})
113 }{s.siteName(), viewer.Username, q,
114 s.filterRepos(q, s.describeAll(repos)), s.filterRepos(q, s.describeAll(mine))})
115} 141}
116 142
117// filterRepos keeps repos whose path, description, or topics contain the 143// filterRepos keeps repos whose path, description, or topics contain the
@@ -1041,6 +1067,21 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1041 md := s.ugcFor(r, p.Repo) 1067 md := s.ugcFor(r, p.Repo)
1042 var detachedThreads []diffThread 1068 var detachedThreads []diffThread
1043 lines, detachedThreads = attachThreads(lines, diffComments, m.HeadSHA, md) 1069 lines, detachedThreads = attachThreads(lines, diffComments, m.HeadSHA, md)
1070 type diffStat struct{ Files, Adds, Dels int }
1071 var stat diffStat
1072 seenFiles := map[string]bool{}
1073 for _, l := range lines {
1074 switch l.Class {
1075 case "add":
1076 stat.Adds++
1077 case "del":
1078 stat.Dels++
1079 }
1080 if l.Path != "" && !seenFiles[l.Path] {
1081 seenFiles[l.Path] = true
1082 stat.Files++
1083 }
1084 }
1044 s.render(w, "mr.html", struct { 1085 s.render(w, "mr.html", struct {
1045 repoPage 1086 repoPage
1046 MR store.MR 1087 MR store.MR
@@ -1050,8 +1091,9 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1050 Comments []renderedComment 1091 Comments []renderedComment
1051 Reviews []store.MRReview 1092 Reviews []store.MRReview
1052 DiffLines []diffLine 1093 DiffLines []diffLine
1094 Stat diffStat
1053 DetachedThreads []diffThread 1095 DetachedThreads []diffThread
1054 }{p, m, md(m.Body), checks, store.CombinedStatus(checks), renderComments(comments, md), reviews, lines, detachedThreads}) 1096 }{p, m, md(m.Body), checks, store.CombinedStatus(checks), renderComments(comments, md), reviews, lines, stat, detachedThreads})
1055} 1097}
1056 1098
1057func (s *Server) refs(w http.ResponseWriter, r *http.Request) { 1099func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
internal/web/static/style.css +24
@@ -337,6 +337,20 @@ a.memberchip {
337a.memberchip:hover { text-decoration: none; border-color: var(--accent); color: var(--accent); } 337a.memberchip:hover { text-decoration: none; border-color: var(--accent); color: var(--accent); }
338a.memberchip .role { color: var(--muted); } 338a.memberchip .role { color: var(--muted); }
339 339
340/* landing page */
341.landing { max-width: 44rem; padding: var(--sp-6) 0; }
342.landing h1 { font-size: 1.8rem; margin-bottom: var(--sp-3); }
343.landing .lede { font-size: var(--fs-4); color: var(--muted); line-height: 1.7; margin: 0 0 var(--sp-4); }
344pre.quickstart {
345 background: var(--code-bg);
346 border: 1px solid var(--faint);
347 border-radius: var(--r-md);
348 padding: var(--sp-3) var(--sp-4);
349 overflow-x: auto;
350 margin: 0 0 var(--sp-4);
351}
352.landing .explorelink { font-weight: 550; }
353
340/* index toolbar */ 354/* index toolbar */
341p.toolbar { color: var(--muted); font-size: var(--fs-2); margin-top: var(--sp-4); } 355p.toolbar { color: var(--muted); font-size: var(--fs-2); margin-top: var(--sp-4); }
342form.inline { display: inline; } 356form.inline { display: inline; }
@@ -397,6 +411,16 @@ pre.message {
397} 411}
398 412
399/* diffs */ 413/* diffs */
414details.difffold summary {
415 cursor: pointer;
416 display: flex;
417 align-items: baseline;
418 gap: var(--sp-3);
419 margin: var(--sp-5) 0 var(--sp-2);
420}
421details.difffold summary h3 { display: inline; margin: 0; }
422details.difffold summary .add { color: var(--ok); }
423details.difffold summary .del { color: var(--bad); }
400pre.diff { 424pre.diff {
401 background: var(--code-bg); 425 background: var(--code-bg);
402 border: 1px solid var(--faint); 426 border: 1px solid var(--faint);
internal/web/templates/mr.html +3 −1
@@ -26,9 +26,11 @@
26<p><button type="submit">comment</button></p> 26<p><button type="submit">comment</button></p>
27</form> 27</form>
28{{end}} 28{{end}}
29<h3>diff</h3> 29<details class="difffold">
30<summary><h3>diff</h3><span class="meta">{{.Stat.Files}} file{{if ne .Stat.Files 1}}s{{end}} changed, <span class="add">+{{.Stat.Adds}}</span> <span class="del">−{{.Stat.Dels}}</span></span></summary>
30<pre class="diff">{{range .DiffLines}}<span class="{{.Class}}">{{.Text}}</span> 31<pre class="diff">{{range .DiffLines}}<span class="{{.Class}}">{{.Text}}</span>
31{{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}}</div><pre class="diff">{{end}}{{end}}</pre> 32{{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}}</div><pre class="diff">{{end}}{{end}}</pre>
33</details>
32{{if .DetachedThreads}}<h3>threads on earlier revisions</h3> 34{{if .DetachedThreads}}<h3>threads on earlier revisions</h3>
33{{range .DetachedThreads}}<div class="thread stale"><p class="threadstate">{{if .Stale}}stale{{end}}{{if .Resolved}}{{if .Stale}} · {{end}}resolved by {{.Resolved}}{{end}}</p>{{range .Comments}}<p class="commenthead"><strong>{{.Author}}</strong> <span class="when">{{when .CreatedAt}}</span></p><div class="rendered">{{.BodyHTML}}</div>{{end}}</div>{{end}}{{end}} 35{{range .DetachedThreads}}<div class="thread stale"><p class="threadstate">{{if .Stale}}stale{{end}}{{if .Resolved}}{{if .Stale}} · {{end}}resolved by {{.Resolved}}{{end}}</p>{{range .Comments}}<p class="commenthead"><strong>{{.Author}}</strong> <span class="when">{{when .CreatedAt}}</span></p><div class="rendered">{{.BodyHTML}}</div>{{end}}</div>{{end}}{{end}}
34{{end}} 36{{end}}