Commit 73c8e291f7
73c8e291f76ca4383165230e62a311bab3d4fa74
parent: fa1784cbc9
Verified · cmc ci/build: success
cmc <hello@cleberg.net> · 2026-08-26T05:14:30Z
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
internal/httpd/web.go
+3 −1
| @@ -1183,12 +1183,14 @@ func (s *Server) log(w http.ResponseWriter, r *http.Request) { |
| 1183 | 1183 | type row struct { |
| 1184 | 1184 | SHA, ShortSHA, Subject, AuthorName, AuthorEmail, AuthorUser, Date string |
| 1185 | 1185 | Sig sigView |
| 1186 | Check string // combined status, "" when none ran |
| 1186 | 1187 | } |
| 1187 | 1188 | names := s.authorNames() |
| 1189 | checks, _ := s.st.CombinedStatusFor(p.Repo.ID, shas) |
| 1188 | 1190 | var rows []row |
| 1189 | 1191 | for _, sha := range shas { |
| 1190 | 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 | 1194 | if parsed != nil { |
| 1193 | 1195 | rw.Subject = parsed.Subject |
| 1194 | 1196 | rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName) |
internal/store/statuses.go
+41
| @@ -1,5 +1,7 @@ |
| 1 | 1 | package store |
| 2 | 2 | |
| 3 | import "strings" |
| 4 | |
| 3 | 5 | type CommitStatus struct { |
| 4 | 6 | Context string |
| 5 | 7 | State string // pending | success | failure | error |
| @@ -50,6 +52,45 @@ func (s *Store) ListCommitStatuses(repoID int64, sha string) ([]CommitStatus, er |
| 50 | 52 | |
| 51 | 53 | // CombinedStatus reduces per-context states to one: error/failure dominate, |
| 52 | 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 | 94 | func CombinedStatus(statuses []CommitStatus) string { |
| 54 | 95 | if len(statuses) == 0 { |
| 55 | 96 | return "" |
internal/web/static/style.css
+10
| @@ -144,6 +144,10 @@ a:hover { text-decoration: underline; } |
| 144 | 144 | outline-offset: 2px; |
| 145 | 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 | 152 | /* visually hidden, still read aloud: for headings a sighted reader gets |
| 149 | 153 | from the layout but a screen reader would otherwise miss */ |
| @@ -800,6 +804,12 @@ pre.message { |
| 800 | 804 | .teambody { padding: var(--sp-3); background: var(--bg); } |
| 801 | 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 | 813 | /* account settings */ |
| 804 | 814 | table.keys td { padding: var(--sp-2) var(--sp-4) var(--sp-2) 0; } |
| 805 | 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 | 1 | {{define "title"}}log · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | 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 | 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 | 11 | <ul class="loglist"> |
| 6 | | {{range .Commits}}<li> |
| 12 | {{range .Commits}}{{$c := .}}<li> |
| 7 | 13 | <div class="commitmain"> |
| 8 | 14 | <p class="subject"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Subject}}</a></p> |
| 9 | 15 | <p class="meta">{{template "authorname" dict "Name" .AuthorName "User" .AuthorUser "Email" .AuthorEmail}} · {{.Date}}</p> |
| 10 | 16 | </div> |
| 11 | 17 | <div class="commitside"> |
| 18 | {{with .Check}}<a class="badge badge-{{.}}" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{$c.SHA}}" title="checks: {{.}}">{{.}}</a>{{end}} |
| 12 | 19 | {{template "sigbadge" .Sig}} |
| 13 | 20 | <code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.ShortSHA}}</a></code> |
| 14 | 21 | </div> |