Commit 73c8e291f7
73c8e291f76ca4383165230e62a311bab3d4fa74
parent: fa1784cbc9
Verified · cmc ci/build: success
cmc <hello@cleberg.net> · 2026-08-26 05:14 UTC
web: check status and a path filter on the log, visible focus in the rail
The log shows each commit's combined check status, batched into one query
rather than fifty. The ?path= filter gets a form, so per-file history is
reachable without editing the URL.
The rail is black in both color schemes, where the light scheme's accent
made an invisible focus ring; it uses the shell's own mark instead.
Ref #35
Layout: unified · split
internal/httpd/web.go
+3 −1
| @@ -1183,12 +1183,14 @@ func (s *Server) log(w http.ResponseWriter, r *http.Request) { |
| 1183 | type row struct { |
1183 | type row struct { |
| 1184 | SHA, ShortSHA, Subject, AuthorName, AuthorEmail, AuthorUser, Date string |
1184 | SHA, ShortSHA, Subject, AuthorName, AuthorEmail, AuthorUser, Date string |
| 1185 | Sig sigView |
1185 | Sig sigView |
| |
1186 | Check string // combined status, "" when none ran |
| 1186 | } |
1187 | } |
| 1187 | names := s.authorNames() |
1188 | names := s.authorNames() |
| |
1189 | checks, _ := s.st.CombinedStatusFor(p.Repo.ID, shas) |
| 1188 | var rows []row |
1190 | var rows []row |
| 1189 | for _, sha := range shas { |
1191 | for _, sha := range shas { |
| 1190 | v, parsed := s.sigFor(p.Repo, p.Dir, sha) |
1192 | v, parsed := s.sigFor(p.Repo, p.Dir, sha) |
| 1191 | rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v} |
1193 | rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v, Check: checks[sha]} |
| 1192 | if parsed != nil { |
1194 | if parsed != nil { |
| 1193 | rw.Subject = parsed.Subject |
1195 | rw.Subject = parsed.Subject |
| 1194 | rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName) |
1196 | rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName) |
internal/store/statuses.go
+41
| @@ -1,5 +1,7 @@ |
| 1 | package store |
1 | package store |
| 2 | |
2 | |
| |
3 | import "strings" |
| |
4 | |
| 3 | type CommitStatus struct { |
5 | type CommitStatus struct { |
| 4 | Context string |
6 | Context string |
| 5 | State string // pending | success | failure | error |
7 | State string // pending | success | failure | error |
| @@ -50,6 +52,45 @@ func (s *Store) ListCommitStatuses(repoID int64, sha string) ([]CommitStatus, er |
| 50 | |
52 | |
| 51 | // CombinedStatus reduces per-context states to one: error/failure dominate, |
53 | // CombinedStatus reduces per-context states to one: error/failure dominate, |
| 52 | // then pending, then success; "" when no statuses exist. |
54 | // then pending, then success; "" when no statuses exist. |
| |
55 | // CombinedStatusFor returns the combined state for each of several commits |
| |
56 | // in one query. The log lists fifty commits at a time; asking per commit |
| |
57 | // turns one page into fifty round trips. |
| |
58 | func (s *Store) CombinedStatusFor(repoID int64, shas []string) (map[string]string, error) { |
| |
59 | out := map[string]string{} |
| |
60 | if len(shas) == 0 { |
| |
61 | return out, nil |
| |
62 | } |
| |
63 | args := make([]any, 0, len(shas)+1) |
| |
64 | args = append(args, repoID) |
| |
65 | for _, sha := range shas { |
| |
66 | args = append(args, sha) |
| |
67 | } |
| |
68 | q := `SELECT commit_sha, state FROM commit_statuses WHERE repo_id = ? AND commit_sha IN (?` + |
| |
69 | strings.Repeat(",?", len(shas)-1) + `)` |
| |
70 | rows, err := s.DB.Query(q, args...) |
| |
71 | if err != nil { |
| |
72 | return nil, err |
| |
73 | } |
| |
74 | defer rows.Close() |
| |
75 | for rows.Next() { |
| |
76 | var sha, state string |
| |
77 | if err := rows.Scan(&sha, &state); err != nil { |
| |
78 | return nil, err |
| |
79 | } |
| |
80 | // Same precedence as CombinedStatus, folded in as rows arrive. |
| |
81 | switch { |
| |
82 | case out[sha] == "failure": |
| |
83 | case state == "error" || state == "failure": |
| |
84 | out[sha] = "failure" |
| |
85 | case state == "pending": |
| |
86 | out[sha] = "pending" |
| |
87 | case out[sha] == "": |
| |
88 | out[sha] = "success" |
| |
89 | } |
| |
90 | } |
| |
91 | return out, rows.Err() |
| |
92 | } |
| |
93 | |
| 53 | func CombinedStatus(statuses []CommitStatus) string { |
94 | func CombinedStatus(statuses []CommitStatus) string { |
| 54 | if len(statuses) == 0 { |
95 | if len(statuses) == 0 { |
| 55 | return "" |
96 | return "" |
internal/web/static/style.css
+10
| @@ -144,6 +144,10 @@ a:hover { text-decoration: underline; } |
| 144 | outline-offset: 2px; |
144 | outline-offset: 2px; |
| 145 | border-radius: 1px; |
145 | border-radius: 1px; |
| 146 | } |
146 | } |
| |
147 | /* the shell is black in both schemes, and --accent is a dark blue in the |
| |
148 | light one — a ring nobody could see. The shell's own mark clears 7:1 on |
| |
149 | black either way. */ |
| |
150 | .rail :focus-visible { outline-color: var(--shell-mark); } |
| 147 | |
151 | |
| 148 | /* visually hidden, still read aloud: for headings a sighted reader gets |
152 | /* visually hidden, still read aloud: for headings a sighted reader gets |
| 149 | from the layout but a screen reader would otherwise miss */ |
153 | from the layout but a screen reader would otherwise miss */ |
| @@ -800,6 +804,12 @@ pre.message { |
| 800 | .teambody { padding: var(--sp-3); background: var(--bg); } |
804 | .teambody { padding: var(--sp-3); background: var(--bg); } |
| 801 | .teambody .memberchip { margin-right: var(--sp-2); } |
805 | .teambody .memberchip { margin-right: var(--sp-2); } |
| 802 | |
806 | |
| |
807 | form.logfilter { |
| |
808 | display: flex; align-items: center; gap: var(--sp-2); |
| |
809 | margin: 0 0 var(--sp-4); flex-wrap: wrap; |
| |
810 | } |
| |
811 | form.logfilter input { flex: 1; min-width: 12rem; max-width: 28rem; } |
| |
812 | |
| 803 | /* account settings */ |
813 | /* account settings */ |
| 804 | table.keys td { padding: var(--sp-2) var(--sp-4) var(--sp-2) 0; } |
814 | table.keys td { padding: var(--sp-2) var(--sp-4) var(--sp-2) 0; } |
| 805 | table.keys td.mono, .mono { font-family: var(--mono); font-size: var(--fs-1); overflow-wrap: anywhere; } |
815 | table.keys td.mono, .mono { font-family: var(--mono); font-size: var(--fs-1); overflow-wrap: anywhere; } |
internal/web/templates/log.html
+9 −2
| @@ -1,14 +1,21 @@ |
| 1 | {{define "title"}}log · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
1 | {{define "title"}}log · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | {{define "content"}} |
2 | {{define "content"}} |
| 3 | {{if .FilePath}}<p class="meta">history of <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/blob/{{.Ref}}/{{.FilePath}}"><code>{{.FilePath}}</code></a> · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log">full log</a></p>{{end}} |
| |
| 4 | <h1 class="vh">Commits</h1> |
3 | <h1 class="vh">Commits</h1> |
| |
4 | <form method="get" class="logfilter" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log/{{.Ref}}"> |
| |
5 | <label for="path">Path</label> |
| |
6 | <input type="text" id="path" name="path" value="{{.FilePath}}" placeholder="filter to a file or directory"> |
| |
7 | <button type="submit">Filter</button> |
| |
8 | {{if .FilePath}}<a class="act" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log/{{.Ref}}">Clear</a>{{end}} |
| |
9 | </form> |
| |
10 | {{if .FilePath}}<p class="meta">history of <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/blob/{{.Ref}}/{{.FilePath}}"><code>{{.FilePath}}</code></a></p>{{end}} |
| 5 | <ul class="loglist"> |
11 | <ul class="loglist"> |
| 6 | {{range .Commits}}<li> |
12 | {{range .Commits}}{{$c := .}}<li> |
| 7 | <div class="commitmain"> |
13 | <div class="commitmain"> |
| 8 | <p class="subject"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Subject}}</a></p> |
14 | <p class="subject"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Subject}}</a></p> |
| 9 | <p class="meta">{{template "authorname" dict "Name" .AuthorName "User" .AuthorUser "Email" .AuthorEmail}} · {{.Date}}</p> |
15 | <p class="meta">{{template "authorname" dict "Name" .AuthorName "User" .AuthorUser "Email" .AuthorEmail}} · {{.Date}}</p> |
| 10 | </div> |
16 | </div> |
| 11 | <div class="commitside"> |
17 | <div class="commitside"> |
| |
18 | {{with .Check}}<a class="badge badge-{{.}}" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{$c.SHA}}" title="checks: {{.}}">{{.}}</a>{{end}} |
| 12 | {{template "sigbadge" .Sig}} |
19 | {{template "sigbadge" .Sig}} |
| 13 | <code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.ShortSHA}}</a></code> |
20 | <code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.ShortSHA}}</a></code> |
| 14 | </div> |
21 | </div> |