detached 1769e4f

feat(ci): add the Actions pages, live job logs and check marks

/<repo>/actions lists the runs, /<repo>/actions/runs/<id> shows a run
with its jobs and steps (attempt switcher, cancel and re-run for the
owner), and the job page shows the steps and the log with folded groups,
a raw log link and a live feed of the lines over Server-Sent Events. The
repository event stream gets the views actions, run and job: they carry
states only, never log lines. The commit page, the commit and refs lists
and the merge request page show the checks of their commit, and the merge
request rail says when a job waits for a runner. The web reads and writes
through ports.RunService only.
commit
1769e4f987d0ec226c85f70fe6f2e07a0d129f39
author
brice · · 2026-10-12 18:39 UTC
parent
1bd4fe7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
54 files · +4219 -261
A internal/adapters/web/actions.go +824 -0
@@ -0,0 +1,824 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "net/http"
11+ "strconv"
12+ "strings"
13+ "time"
14+
15+ "github.com/a-h/templ"
16+ "github.com/bnema/zerowrap"
17+
18+ "bnema.dev/ermite/internal/clientlimit"
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+)
22+
23+// The CI pages (ADR 0025): the runs of a repository, one run with its jobs,
24+// one job with its steps and its log. They read through ports.RunService and
25+// follow the events RunUpdated and JobUpdated of the repository topic.
26+//
27+// GET /<repo>/actions the runs, newest first
28+// GET /<repo>/actions/runs/<id> one run: jobs and steps
29+// GET /<repo>/actions/runs/<id>/job/<job id> one job: steps and live log
30+// GET /<repo>/actions/runs/<id>/job/<job id>/raw the log as text
31+// POST /<repo>/actions/runs/<id>/cancel | rerun | rerun-failed (the owner)
32+
33+const (
34+ // actionsPageSize is the number of runs one page lists.
35+ actionsPageSize = 30
36+ // jobLogPageLines is the number of log lines a job page shows at once; the
37+ // rest is reached with ?from=.
38+ jobLogPageLines = 2000
39+ // rawLogMaxBytes bounds the raw log the server writes to one response.
40+ rawLogMaxBytes = 64 << 20
41+)
42+
43+func actionsBase(repo string) string { return repoHref(repo, "actions") }
44+func runHref(repo string, id int64) string {
45+ return repoHref(repo, "actions", "runs", strconv.FormatInt(id, 10))
46+}
47+func jobHref(repo string, runID int64, jobID string) string {
48+ return runHref(repo, runID) + "/job/" + jobID
49+}
50+
51+// ciState is the one word shown for a status and a conclusion: what is going
52+// on, or how it ended.
53+func ciState(status domain.CIStatus, c domain.CIConclusion) string {
54+ switch status {
55+ case domain.CIQueued:
56+ return "queued"
57+ case domain.CIInProgress:
58+ return "running"
59+ }
60+ switch c {
61+ case domain.CISuccess:
62+ return "success"
63+ case domain.CIFailure:
64+ return "failure"
65+ case domain.CICancelled:
66+ return "cancelled"
67+ case domain.CISkipped:
68+ return "skipped"
69+ case domain.CITimedOut:
70+ return "timed out"
71+ case domain.CINeutral:
72+ return "neutral"
73+ case domain.CIActionRequired:
74+ return "action required"
75+ }
76+ return "done"
77+}
78+
79+// ciKind groups the states into the few that have an icon and a colour.
80+func ciKind(status domain.CIStatus, c domain.CIConclusion) string {
81+ switch {
82+ case status == domain.CIQueued:
83+ return "queued"
84+ case status == domain.CIInProgress:
85+ return "running"
86+ case c == domain.CISuccess, c == domain.CINeutral:
87+ return "ok"
88+ case c == domain.CISkipped:
89+ return "skipped"
90+ case c == domain.CICancelled:
91+ return "cancelled"
92+ }
93+ return "bad"
94+}
95+
96+// duration is a short elapsed time between two instants; empty when the
97+// start is unknown, running up to now when the end is not.
98+func duration(start, end, now time.Time) string {
99+ if start.IsZero() {
100+ return ""
101+ }
102+ if end.IsZero() {
103+ end = now
104+ }
105+ d := end.Sub(start).Round(time.Second)
106+ switch {
107+ case d < 0:
108+ return ""
109+ case d < time.Minute:
110+ return fmt.Sprintf("%ds", int(d.Seconds()))
111+ case d < time.Hour:
112+ return fmt.Sprintf("%dm %02ds", int(d.Minutes()), int(d.Seconds())%60)
113+ }
114+ return fmt.Sprintf("%dh %02dm", int(d.Hours()), int(d.Minutes())%60)
115+}
116+
117+// RunRow is a run of the list.
118+type RunRow struct {
119+ ID string
120+ Href string
121+ Kind string // ciKind
122+ State string // ciState
123+ Workflow string
124+ Title string
125+ Ref string
126+ RefHref string
127+ Short string
128+ CHref string
129+ Actor string
130+ When string
131+ WhenAt string
132+ Duration string
133+ Number string
134+ Attempt int32
135+}
136+
137+// ActionsPage lists the runs of a repository.
138+type ActionsPage struct {
139+ Runs []RunRow
140+ // More is the link to the next page, "" on the last.
141+ More string
142+ // NoRunner says that CI has no runner to run on yet.
143+ NoRunner bool
144+}
145+
146+// StepView is a step of a job.
147+type StepView struct {
148+ Number int32
149+ Name string
150+ Kind, State string
151+ Duration string
152+ // LogHref jumps to the lines of the step in the job log.
153+ LogStart, LogEnd int64
154+}
155+
156+// JobRow is a job of a run.
157+type JobRow struct {
158+ ID, Name, Href string
159+ Kind, State string
160+ Runner string
161+ Isolation string
162+ Duration string
163+ Error string
164+ Needs []string
165+ Steps []StepView
166+ Attempt int32
167+ // Waiting says that the job is queued for a runner that no registered
168+ // runner can be.
169+ Waiting bool
170+}
171+
172+// RunPage is one run with its jobs.
173+type RunPage struct {
174+ RunRow
175+ Sha, Error string
176+ Attempt int32
177+ Latest int32
178+ Attempts []AttemptLink
179+ Jobs []JobRow
180+ Active bool
181+ // Forms are the owner's actions; nil/empty when the visitor cannot.
182+ CancelAction, RerunAction, RerunFailedAction string
183+ CanRerunFailed bool
184+}
185+
186+// AttemptLink switches between the attempts of a run.
187+type AttemptLink struct {
188+ Label, Href string
189+ Current bool
190+}
191+
192+// LogLineView is a line of a job log, as shown.
193+type LogLineView struct {
194+ N int64
195+ Step int32
196+ Text string
197+ Time string
198+ // Group marks "##[group]" and "##[endgroup]" lines and Error the
199+ // "##[error]" ones; the text is shown without the marker.
200+ Group, End, Error bool
201+}
202+
203+// JobPage is one job with its steps and its log.
204+type JobPage struct {
205+ JobRow
206+ RunHref string
207+ RunName string
208+ Run string
209+ Steps []StepView
210+ Lines []LogLineView
211+ // From is the first line shown; Next is the link to the lines after the
212+ // last shown, "" when it is the end; Follow says the log is still being
213+ // written, so the page follows it live.
214+ From int64
215+ Next string
216+ Prev string
217+ Total int64
218+ Follow bool
219+ RawHref string
220+ // StreamHref is the live feed of the lines (logStream).
221+ StreamHref string
222+ Note string
223+}
224+
225+// --- handlers ---
226+
227+// actions is GET /<repo>/actions.
228+func (s *Server) actions(ctx context.Context, r *http.Request) (templ.Component, int, error) {
229+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
230+ if err != nil {
231+ return nil, 0, err
232+ }
233+ token := r.URL.Query().Get("page")
234+ page, err := s.cfg.Runs.ListRuns(ctx, principal(ctx), d.repo.Name, ports.ListRunsParams{PageSize: actionsPageSize, PageToken: token})
235+ if err != nil {
236+ return nil, 0, err
237+ }
238+ now := s.cfg.Now()
239+ p := ActionsPage{}
240+ for _, run := range page.Runs {
241+ p.Runs = append(p.Runs, s.runRow(d.name(), run, now))
242+ }
243+ if page.NextPageToken != "" {
244+ p.More = actionsBase(d.name()) + "?page=" + page.NextPageToken
245+ }
246+ l := s.repoLayout(ctx, d, "actions")
247+ l.Title = "actions · " + d.name() + " · " + s.instance
248+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=actions")
249+ l.Crumbs[0].Current = false
250+ l.Crumbs = append(l.Crumbs, Crumb{Label: "actions", Current: true})
251+ return Actions(l, p), http.StatusOK, nil
252+}
253+
254+func (s *Server) runRow(repo string, run domain.CIRun, now time.Time) RunRow {
255+ row := RunRow{
256+ ID: strconv.FormatInt(run.ID, 10), Href: runHref(repo, run.ID),
257+ Kind: ciKind(run.Status, run.Conclusion), State: ciState(run.Status, run.Conclusion),
258+ Workflow: visibleText(run.WorkflowName), Title: visibleText(run.Title),
259+ Ref: visibleText(strings.TrimPrefix(strings.TrimPrefix(run.Ref, "refs/heads/"), "refs/tags/")),
260+ Short: short(run.Sha), CHref: repoHref(repo, "commit", run.Sha), Actor: visibleText(run.Actor),
261+ Duration: duration(run.StartedAt, run.CompletedAt, now), Number: "#" + strconv.FormatInt(run.Number, 10), Attempt: run.Attempt,
262+ }
263+ row.RefHref = repoHref(repo, "tree", strings.TrimPrefix(strings.TrimPrefix(run.Ref, "refs/heads/"), "refs/tags/"))
264+ row.When, row.WhenAt = whenOf(now, run.CreatedAt)
265+ return row
266+}
267+
268+func stepViews(j ports.JobView, now time.Time) []StepView {
269+ ranges := j.StepRanges()
270+ out := make([]StepView, 0, len(j.Steps))
271+ for i, st := range j.Steps {
272+ status := domain.CIQueued
273+ switch st.State {
274+ case domain.CIStepInProgress:
275+ status = domain.CIInProgress
276+ case domain.CIStepCompleted:
277+ status = domain.CICompleted
278+ }
279+ out = append(out, StepView{
280+ Number: st.Number, Name: visibleText(st.Name), Kind: ciKind(status, st.Conclusion), State: ciState(status, st.Conclusion),
281+ Duration: duration(st.StartedAt, st.CompletedAt, now), LogStart: ranges[i].Start, LogEnd: ranges[i].End,
282+ })
283+ }
284+ return out
285+}
286+
287+func (s *Server) jobRow(repo string, j ports.JobView, now time.Time) JobRow {
288+ return JobRow{
289+ ID: j.ID, Name: visibleText(j.Name), Href: jobHref(repo, j.RunID, j.ID), Kind: ciKind(j.Status(), j.Conclusion),
290+ State: ciState(j.Status(), j.Conclusion), Runner: visibleText(j.RunnerName), Isolation: string(j.Isolation),
291+ Duration: duration(j.StartedAt, j.CompletedAt, now), Error: visibleText(j.Error), Needs: j.Needs,
292+ Steps: stepViews(j, now), Attempt: j.Attempt,
293+ }
294+}
295+
296+func parseRunPath(r *http.Request) (int64, error) {
297+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
298+ if err != nil || id < 1 {
299+ return 0, errNotFound
300+ }
301+ return id, nil
302+}
303+
304+// run is GET /<repo>/actions/runs/<id>.
305+func (s *Server) run(ctx context.Context, r *http.Request) (templ.Component, int, error) {
306+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
307+ if err != nil {
308+ return nil, 0, err
309+ }
310+ id, err := parseRunPath(r)
311+ if err != nil {
312+ return nil, 0, err
313+ }
314+ var attempt int32
315+ if a := r.URL.Query().Get("attempt"); a != "" {
316+ n, perr := strconv.ParseInt(a, 10, 32)
317+ if perr != nil || n < 1 {
318+ return nil, 0, errNotFound
319+ }
320+ attempt = int32(n)
321+ }
322+ p, err := s.runPage(ctx, d, id, attempt)
323+ if err != nil {
324+ return nil, 0, err
325+ }
326+ l := s.repoLayout(ctx, d, "actions")
327+ l.Title = "run " + p.ID + " · " + d.name() + " · " + s.instance
328+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=run&n="+p.ID)
329+ l.Crumbs[0].Current = false
330+ l.Crumbs = append(l.Crumbs, Crumb{Label: "actions", Href: actionsBase(d.name())}, Crumb{Label: p.Workflow + " " + p.Number, Current: true})
331+ l.HasRail = true
332+ return RunDetail(l, p), http.StatusOK, nil
333+}
334+
335+//hexcheck:ignore suspicious-business-logic-in-adapter builds the run page from the use case results; no rule is decided here
336+func (s *Server) runPage(ctx context.Context, d *repoData, id int64, attempt int32) (RunPage, error) {
337+ detail, err := s.cfg.Runs.GetRun(ctx, principal(ctx), d.repo.Name, id, attempt)
338+ if err != nil {
339+ return RunPage{}, err
340+ }
341+ now := s.cfg.Now()
342+ run := detail.Run
343+ p := RunPage{
344+ RunRow: s.runRow(d.name(), run, now), Sha: run.Sha, Error: visibleText(run.Error),
345+ Attempt: run.Attempt, Latest: detail.LatestAttempt, Active: run.Status != domain.CICompleted,
346+ }
347+ for a := detail.LatestAttempt; a >= 1 && detail.LatestAttempt > 1; a-- {
348+ p.Attempts = append(p.Attempts, AttemptLink{
349+ Label: "attempt " + strconv.Itoa(int(a)), Href: runHref(d.name(), id) + "?attempt=" + strconv.Itoa(int(a)), Current: a == run.Attempt,
350+ })
351+ }
352+ for _, j := range detail.Jobs {
353+ row := s.jobRow(d.name(), j, now)
354+ if run.Attempt == detail.LatestAttempt {
355+ // An earlier attempt keeps the job as it ran then.
356+ row.Href = jobHref(d.name(), id, j.ID)
357+ }
358+ p.Jobs = append(p.Jobs, row)
359+ }
360+ if SignedIn(ctx) && run.Attempt == detail.LatestAttempt {
361+ base := runHref(d.name(), id)
362+ if p.Active {
363+ p.CancelAction = base + "/cancel"
364+ } else if len(detail.Jobs) > 0 {
365+ p.RerunAction = base + "/rerun"
366+ for _, j := range detail.Jobs {
367+ if j.Conclusion.IsFailure() || j.Conclusion == domain.CICancelled {
368+ p.RerunFailedAction, p.CanRerunFailed = base+"/rerun-failed", true
369+ break
370+ }
371+ }
372+ }
373+ }
374+ return p, nil
375+}
376+
377+// job is GET /<repo>/actions/runs/<id>/job/<job id>.
378+func (s *Server) job(ctx context.Context, r *http.Request) (templ.Component, int, error) {
379+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
380+ if err != nil {
381+ return nil, 0, err
382+ }
383+ id, err := parseRunPath(r)
384+ if err != nil {
385+ return nil, 0, err
386+ }
387+ jobID := r.PathValue("job")
388+ if runOf, _, _, ok := domain.ParseJobID(jobID); !ok || runOf != id {
389+ return nil, 0, errNotFound
390+ }
391+ var attempt int32
392+ if a := r.URL.Query().Get("attempt"); a != "" {
393+ n, perr := strconv.ParseInt(a, 10, 32)
394+ if perr != nil || n < 1 {
395+ return nil, 0, errNotFound
396+ }
397+ attempt = int32(n)
398+ }
399+ view, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, attempt)
400+ if err != nil {
401+ return nil, 0, err
402+ }
403+ run, err := s.cfg.Runs.GetRun(ctx, principal(ctx), d.repo.Name, id, 0)
404+ if err != nil {
405+ return nil, 0, err
406+ }
407+ now := s.cfg.Now()
408+ var from int64
409+ if f := r.URL.Query().Get("from"); f != "" {
410+ from, err = strconv.ParseInt(f, 10, 64)
411+ if err != nil || from < 0 {
412+ return nil, 0, errNotFound
413+ }
414+ }
415+ p := JobPage{
416+ JobRow: s.jobRow(d.name(), view, now), RunHref: runHref(d.name(), id), RunName: visibleText(run.Run.WorkflowName),
417+ Run: strconv.FormatInt(id, 10), Steps: stepViews(view, now), From: from, Total: view.Lines,
418+ RawHref: jobHref(d.name(), id, jobID) + "/raw",
419+ }
420+ if attempt > 0 {
421+ p.RawHref += "?attempt=" + strconv.Itoa(int(attempt))
422+ }
423+ p.Follow = !view.Ended()
424+ p.StreamHref = jobHref(d.name(), id, jobID) + "/stream"
425+ lines, next, err := s.readLog(ctx, d.repo.Name, view, from, jobLogPageLines)
426+ if err != nil {
427+ return nil, 0, err
428+ }
429+ p.Lines = lines
430+ if next < view.Lines {
431+ p.Next = jobHref(d.name(), id, jobID) + "?from=" + strconv.FormatInt(next, 10)
432+ p.Follow = false // more is waiting before the live end
433+ }
434+ if from > 0 {
435+ p.Prev = jobHref(d.name(), id, jobID) + "?from=" + strconv.FormatInt(max(from-jobLogPageLines, 0), 10)
436+ }
437+ if view.Phase == domain.CIPhaseWaiting || view.Phase == domain.CIPhaseReady {
438+ p.Note = "the job has not started: no log yet."
439+ } else if view.Lines == 0 && view.Ended() {
440+ p.Note = "the job wrote no log."
441+ }
442+ l := s.repoLayout(ctx, d, "actions")
443+ l.Title = view.Name + " · run " + p.Run + " · " + d.name() + " · " + s.instance
444+ l.Live = true
445+ l.LiveURL = liveURL("repo:"+d.name(), "view=job&job="+jobID)
446+ l.Crumbs[0].Current = false
447+ l.Crumbs = append(l.Crumbs,
448+ Crumb{Label: "actions", Href: actionsBase(d.name())},
449+ Crumb{Label: p.RunName + " #" + strconv.FormatInt(run.Run.Number, 10), Href: p.RunHref},
450+ Crumb{Label: p.Name, Current: true})
451+ l.HasRail = true
452+ return Job(l, p), http.StatusOK, nil
453+}
454+
455+// readLog reads up to max lines from a line number, through the log stream of
456+// the use case, and returns the line after the last read.
457+func (s *Server) readLog(ctx context.Context, repo domain.RepoName, j ports.JobView, from int64, max int) ([]LogLineView, int64, error) {
458+ if from > j.Lines || j.Lines == 0 {
459+ return nil, from, nil
460+ }
461+ var out []LogLineView
462+ next := from
463+ ranges := j.StepRanges()
464+ stepOf := func(n int64) int32 {
465+ for i, r := range ranges {
466+ if n >= r.Start && n < r.End {
467+ return j.Steps[i].Number
468+ }
469+ }
470+ return 0
471+ }
472+ stop := errors.New("enough")
473+ err := s.cfg.Runs.GetJobLog(ctx, principal(ctx), repo, ports.GetJobLogParams{JobID: j.ID, Attempt: j.Attempt, Offset: from}, func(b ports.LogBatch) error {
474+ for i, l := range b.Lines {
475+ if len(out) >= max {
476+ return stop
477+ }
478+ n := b.Offset + int64(i)
479+ text := l.Text
480+ v := LogLineView{N: n, Step: stepOf(n), Time: l.Time.UTC().Format("15:04:05")}
481+ switch {
482+ case strings.HasPrefix(text, "##[group]"):
483+ v.Group, text = true, strings.TrimPrefix(text, "##[group]")
484+ case strings.HasPrefix(text, "##[endgroup]"):
485+ v.End, text = true, strings.TrimPrefix(text, "##[endgroup]")
486+ case strings.HasPrefix(text, "##[error]"):
487+ v.Error, text = true, strings.TrimPrefix(text, "##[error]")
488+ }
489+ if l.Continued && len(out) > 0 {
490+ out[len(out)-1].Text += visibleText(text)
491+ next = n + 1
492+ continue
493+ }
494+ v.Text = visibleText(text)
495+ out = append(out, v)
496+ next = n + 1
497+ }
498+ return nil
499+ })
500+ if err != nil && !errors.Is(err, stop) && !errors.Is(err, domain.ErrNotFound) {
501+ return nil, from, err
502+ }
503+ return out, next, nil
504+}
505+
506+// rawLog is GET /<repo>/actions/runs/<id>/job/<job id>/raw: the log as text,
507+// one line per line with its time, joined where the runner cut a long line.
508+func (s *Server) rawLog(w http.ResponseWriter, r *http.Request) {
509+ ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
510+ defer cancel()
511+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
512+ if err != nil {
513+ s.fail(ctx, w, r, err)
514+ return
515+ }
516+ id, err := parseRunPath(r)
517+ jobID := r.PathValue("job")
518+ if runOf, _, _, ok := domain.ParseJobID(jobID); err != nil || !ok || runOf != id {
519+ s.fail(ctx, w, r, errNotFound)
520+ return
521+ }
522+ var attempt int32
523+ if a := r.URL.Query().Get("attempt"); a != "" {
524+ n, perr := strconv.ParseInt(a, 10, 32)
525+ if perr != nil || n < 1 {
526+ s.fail(ctx, w, r, errNotFound)
527+ return
528+ }
529+ attempt = int32(n)
530+ }
531+ view, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, attempt)
532+ if err != nil {
533+ s.fail(ctx, w, r, err)
534+ return
535+ }
536+ h := w.Header()
537+ h.Set("Content-Type", "text/plain; charset=utf-8")
538+ h.Set("X-Content-Type-Options", "nosniff")
539+ h.Set("Content-Security-Policy", "default-src 'none'; sandbox")
540+ h.Set("Content-Disposition", fmt.Sprintf(`inline; filename="job-%s.log"`, jobID))
541+ cacheHeaders(ctx, h, "no-cache", sessionCache)
542+ written := 0
543+ open := false
544+ err = s.cfg.Runs.GetJobLog(ctx, principal(ctx), d.repo.Name, ports.GetJobLogParams{JobID: jobID, Attempt: attempt}, func(b ports.LogBatch) error {
545+ for _, l := range b.Lines {
546+ if written > rawLogMaxBytes {
547+ return errors.New("log too large")
548+ }
549+ var line string
550+ switch {
551+ case l.Continued && open:
552+ line = l.Text
553+ case open:
554+ line = "\n" + l.Time.UTC().Format(time.RFC3339Nano) + " " + l.Text
555+ default:
556+ line = l.Time.UTC().Format(time.RFC3339Nano) + " " + l.Text
557+ }
558+ open = true
559+ n, werr := fmt.Fprint(w, line)
560+ written += n
561+ if werr != nil {
562+ return werr
563+ }
564+ }
565+ return nil
566+ })
567+ if open {
568+ _, _ = fmt.Fprintln(w)
569+ }
570+ if err != nil && !open && errors.Is(err, domain.ErrNotFound) {
571+ _ = view
572+ w.WriteHeader(http.StatusNotFound)
573+ }
574+}
575+
576+// --- the owner's actions ---
577+
578+// ciAction wraps a POST of the actions pages: the form gate, the bounded
579+// context and the mapping of a refusal to a page.
580+func (s *Server) ciAction(h func(ctx context.Context, r *http.Request) (to string, err error)) http.HandlerFunc {
581+ return s.mrAction(func(ctx context.Context, r *http.Request) (string, string, error) {
582+ to, err := h(ctx, r)
583+ return to, actionsBase(r.PathValue("repo")), err
584+ })
585+}
586+
587+func (s *Server) runCancel(ctx context.Context, r *http.Request) (string, error) {
588+ name, id, err := s.runTarget(r)
589+ if err != nil {
590+ return "", err
591+ }
592+ if _, err := s.cfg.Runs.CancelRun(ctx, principal(ctx), name, id); err != nil {
593+ return "", err
594+ }
595+ return runHref(name.String(), id), nil
596+}
597+
598+func (s *Server) runRerun(failedOnly bool) func(ctx context.Context, r *http.Request) (string, error) {
599+ return func(ctx context.Context, r *http.Request) (string, error) {
600+ name, id, err := s.runTarget(r)
601+ if err != nil {
602+ return "", err
603+ }
604+ if _, err := s.cfg.Runs.RerunRun(ctx, principal(ctx), name, id, failedOnly); err != nil {
605+ return "", err
606+ }
607+ return runHref(name.String(), id), nil
608+ }
609+}
610+
611+func (s *Server) runTarget(r *http.Request) (domain.RepoName, int64, error) {
612+ name, err := domain.ParseRepoName(r.PathValue("repo"))
613+ if err != nil {
614+ return domain.RepoName{}, 0, errNotFound
615+ }
616+ id, err := parseRunPath(r)
617+ return name, id, err
618+}
619+
620+func itoa64(n int64) string { return strconv.FormatInt(n, 10) }
621+
622+// runnerText names the runner of a job and how it isolated it.
623+func runnerText(j JobRow) string {
624+ if j.Isolation == "" {
625+ return j.Runner
626+ }
627+ return j.Runner + " (" + j.Isolation + ")"
628+}
629+
630+func joinWords(ws []string) string { return strings.Join(ws, ", ") }
631+
632+// logClass is the class of a line of the log.
633+func logClass(ln LogLineView) string {
634+ switch {
635+ case ln.Group:
636+ return "lg group"
637+ case ln.End:
638+ return "lg endgroup"
639+ case ln.Error:
640+ return "lg error"
641+ }
642+ return "lg"
643+}
644+
645+// ChecksSummary is the combined state of the checks of a commit.
646+type ChecksSummary struct {
647+ Total int
648+ Kind, State string
649+ Label, Title string
650+ Href string
651+}
652+
653+// CheckRow is a check of a commit or a merge request.
654+type CheckRow struct {
655+ Name, Kind, State, Href string
656+ Waiting bool
657+}
658+
659+// ChecksSection is the checks of the head of a merge request.
660+type ChecksSection struct {
661+ Rows []CheckRow
662+ // Note explains the rule that applies to them.
663+ Note string
664+}
665+
666+// checksFor reads the checks of a commit as the caller of the request. A page
667+// shows them when it can: a failure of the read is logged and the page goes on
668+// without, since the code and the review do not depend on CI.
669+func (s *Server) checksFor(ctx context.Context, repo domain.RepoName, sha string) []ports.CheckView {
670+ if s.cfg.Runs == nil || sha == "" {
671+ return nil
672+ }
673+ res, err := s.cfg.Runs.ListChecks(ctx, principal(ctx), repo, sha)
674+ if err != nil {
675+ if !errors.Is(err, domain.ErrNotFound) {
676+ log := zerowrap.FromCtx(ctx)
677+ log.Warn().Err(err).Str("repo", repo.String()).Msg("read the checks of a commit")
678+ }
679+ return nil
680+ }
681+ return res.Checks
682+}
683+
684+// checksSummary reduces checks to the mark a list shows next to a commit.
685+func checksSummary(repo domain.RepoName, sha string, checks []ports.CheckView) ChecksSummary {
686+ if len(checks) == 0 {
687+ return ChecksSummary{}
688+ }
689+ plain := make([]domain.CICheck, 0, len(checks))
690+ passed := 0
691+ for _, c := range checks {
692+ plain = append(plain, c.CICheck)
693+ if c.Status == domain.CICompleted && c.Conclusion.PassesMerge() {
694+ passed++
695+ }
696+ }
697+ sum := ChecksSummary{Total: len(checks), Href: repoHref(repo.String(), "actions"), Label: fmt.Sprintf("%d/%d", passed, len(checks))}
698+ switch domain.CombineChecks(plain) {
699+ case domain.CheckStateSuccess:
700+ sum.Kind, sum.State = "ok", "checks passed"
701+ case domain.CheckStateFailure:
702+ sum.Kind, sum.State = "bad", "checks failed"
703+ default:
704+ sum.Kind, sum.State = "running", "checks pending"
705+ }
706+ sum.Title = sum.State + ": " + sum.Label
707+ return sum
708+}
709+
710+// checksSection is the checks of the rail of a merge request, with the rule of
711+// the repository that applies to them.
712+func checksSection(repo domain.Repo, checks []ports.CheckView, now time.Time) ChecksSection {
713+ sec := ChecksSection{}
714+ for _, c := range checks {
715+ row := CheckRow{
716+ Name: visibleText(c.Name), Kind: ciKind(c.Status, c.Conclusion), State: ciState(c.Status, c.Conclusion),
717+ Waiting: c.WaitingForRunner,
718+ }
719+ if c.JobID != "" {
720+ row.Href = jobHref(repo.Name.String(), c.RunID, c.JobID)
721+ } else {
722+ row.Href = runHref(repo.Name.String(), c.RunID)
723+ }
724+ sec.Rows = append(sec.Rows, row)
725+ }
726+ switch {
727+ case len(checks) == 0:
728+ sec.Note = "no check on this commit: nothing blocks the merge."
729+ case repo.Settings.FailingChecksBlock:
730+ sec.Note = "a failing or unfinished check blocks the merge."
731+ default:
732+ sec.Note = "checks do not block the merge in this repository."
733+ }
734+ _ = now
735+ return sec
736+}
737+
738+// logStream is GET /<repo>/actions/runs/<id>/job/<job id>/stream?from=N: the
739+// lines of a job log from line N as Server-Sent Events, following the job while
740+// it runs. Each "ci-log" event holds the <li> of the lines of one batch and has
741+// the number of the next line as its id, so that a reconnection (Last-Event-ID)
742+// goes on where it stopped. "ci-end" tells the job is over and every line was
743+// sent. The job events of the repository stream carry no line (see events.go).
744+func (s *Server) logStream(w http.ResponseWriter, r *http.Request) {
745+ ctx, cancel := context.WithCancel(r.Context())
746+ defer cancel()
747+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
748+ if err != nil {
749+ s.denyEvents(ctx, w, err)
750+ return
751+ }
752+ id, err := parseRunPath(r)
753+ jobID := r.PathValue("job")
754+ if runOf, _, _, ok := domain.ParseJobID(jobID); err != nil || !ok || runOf != id {
755+ s.denyEvents(ctx, w, errNotFound)
756+ return
757+ }
758+ from := int64(0)
759+ if v := r.Header.Get("Last-Event-ID"); v != "" {
760+ from, err = strconv.ParseInt(v, 10, 64)
761+ } else if v := r.URL.Query().Get("from"); v != "" {
762+ from, err = strconv.ParseInt(v, 10, 64)
763+ }
764+ if err != nil || from < 0 {
765+ s.denyEvents(ctx, w, errNotFound)
766+ return
767+ }
768+ view, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, 0)
769+ if err != nil {
770+ s.denyEvents(ctx, w, err)
771+ return
772+ }
773+
774+ rc := http.NewResponseController(w)
775+ hdr := w.Header()
776+ hdr.Set("Content-Type", "text/event-stream")
777+ cacheHeaders(ctx, hdr, "no-cache, no-transform", sessionCache+", no-transform")
778+ hdr.Set("X-Accel-Buffering", "no")
779+ w.WriteHeader(http.StatusOK)
780+ out := &sseWriter{w: w, rc: rc}
781+ if out.raw(": connected\n\n") != nil {
782+ return
783+ }
784+ ranges := view.StepRanges()
785+ _ = ranges
786+ n := from
787+ err = s.cfg.Runs.GetJobLog(ctx, principal(ctx), d.repo.Name, ports.GetJobLogParams{
788+ JobID: jobID, Offset: from, Follow: true,
789+ Client: clientlimit.Key(clientlimit.ClientAddr(r, s.cfg.TrustedProxies)),
790+ }, func(b ports.LogBatch) error {
791+ var html strings.Builder
792+ for i, l := range b.Lines {
793+ v := LogLineView{N: b.Offset + int64(i), Time: l.Time.UTC().Format("15:04:05"), Step: l.Step}
794+ text := l.Text
795+ switch {
796+ case strings.HasPrefix(text, "##[group]"):
797+ v.Group, text = true, strings.TrimPrefix(text, "##[group]")
798+ case strings.HasPrefix(text, "##[endgroup]"):
799+ v.End, text = true, strings.TrimPrefix(text, "##[endgroup]")
800+ case strings.HasPrefix(text, "##[error]"):
801+ v.Error, text = true, strings.TrimPrefix(text, "##[error]")
802+ }
803+ v.Text = visibleText(text)
804+ if l.Continued {
805+ continue // joined to its line by the page reload; rare and harmless
806+ }
807+ var b2 strings.Builder
808+ if err := LogLine(v).Render(ctx, &b2); err != nil {
809+ return err
810+ }
811+ html.WriteString(strings.ReplaceAll(b2.String(), "\n", ""))
812+ }
813+ n = b.Offset + int64(len(b.Lines))
814+ _ = rc.SetWriteDeadline(time.Now().Add(writeWindow))
815+ _, werr := io.WriteString(w, "event: ci-log\nid: "+strconv.FormatInt(n, 10)+"\ndata: "+html.String()+"\n\n")
816+ if werr != nil {
817+ return werr
818+ }
819+ return rc.Flush()
820+ })
821+ if err == nil {
822+ _ = out.raw("event: ci-end\nid: " + strconv.FormatInt(n, 10) + "\ndata: end\n\n")
823+ }
824+}
A internal/adapters/web/actions.templ +278 -0
@@ -0,0 +1,278 @@
1+package web
2+
3+// The CI pages: the runs of a repository, a run with its jobs, a job with its
4+// steps and its log (actions.go). A state is always a word as well as a
5+// symbol and a colour.
6+
7+// CheckIcon is the symbol of a state: a mark in the colour of its kind, with
8+// the state as its text for a screen reader.
9+templ CheckIcon(kind, state string) {
10+ <span class={ "ck", "ck-" + kind } role="img" aria-label={ state } title={ state }></span>
11+}
12+
13+templ Actions(l Layout, p ActionsPage) {
14+ @Shell(l, nil) {
15+ if len(p.Runs) == 0 {
16+ <p class="dim">no runs yet: a push that matches a workflow of .github/workflows starts one.</p>
17+ } else {
18+ <table class="list runs" aria-label="Runs">
19+ <thead>
20+ <tr><th class="c-st"></th><th class="c-name">workflow</th><th class="c-sub">commit</th><th class="c-ref">ref</th><th class="c-who">actor</th><th class="c-dur">time</th><th class="c-when">created</th></tr>
21+ </thead>
22+ <tbody id="run-rows" data-rows>
23+ for _, r := range p.Runs {
24+ @RunListRow(r)
25+ }
26+ </tbody>
27+ </table>
28+ }
29+ if p.More != "" {
30+ <p class="more"><a href={ templ.SafeURL(p.More) } rel="next">older runs</a></p>
31+ }
32+ }
33+}
34+
35+// RunListRow is a row of the list of runs; its id is the target of the live
36+// updates.
37+templ RunListRow(r RunRow) {
38+ <tr id={ "run-" + r.ID }>
39+ <td class="c-st">@CheckIcon(r.Kind, r.State)</td>
40+ <td class="c-name">
41+ <a href={ templ.SafeURL(r.Href) }>{ r.Workflow }</a>
42+ <span class="mute">{ r.Number }</span>
43+ if r.Attempt > 1 {
44+ <span class="pill">attempt { itoa(int(r.Attempt)) }</span>
45+ }
46+ </td>
47+ <td class="c-sub"><a href={ templ.SafeURL(r.CHref) }>{ r.Short }</a> { r.Title }</td>
48+ <td class="c-ref"><a href={ templ.SafeURL(r.RefHref) }>{ r.Ref }</a></td>
49+ <td class="c-who dim">{ r.Actor }</td>
50+ <td class="c-dur dim">{ r.State }{ " " }{ r.Duration }</td>
51+ <td class="c-when dim">
52+ if r.WhenAt != "" {
53+ <time datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time>
54+ }
55+ </td>
56+ </tr>
57+}
58+
59+templ RunDetail(l Layout, p RunPage) {
60+ @Shell(l, runRail(p)) {
61+ @LiveSlot("run-notice")
62+ <div id="run-body">
63+ @RunBody(p)
64+ </div>
65+ }
66+}
67+
68+// runBodyFragment is the body of the run page as a live fragment.
69+templ runBodyFragment(p RunPage) {
70+ <div id="run-body">
71+ @RunBody(p)
72+ </div>
73+}
74+
75+// RunBody is the jobs of a run, replaced by the live updates.
76+templ RunBody(p RunPage) {
77+ <h2 class="run-h">@CheckIcon(p.Kind, p.State)<span>{ p.Workflow }</span> <span class="mute">{ p.Number }</span></h2>
78+ if p.Error != "" {
79+ <pre class="run-err">{ p.Error }</pre>
80+ }
81+ <ul class="jobs">
82+ for _, j := range p.Jobs {
83+ <li id={ "job-" + j.ID }>
84+ <a class="job-h" href={ templ.SafeURL(j.Href) }>
85+ @CheckIcon(j.Kind, j.State)
86+ <span class="j-name">{ j.Name }</span>
87+ <span class="dim">{ j.State }{ " " }{ j.Duration }</span>
88+ </a>
89+ if j.Runner != "" {
90+ <span class="mute">on { runnerText(j) }</span>
91+ }
92+ if len(j.Needs) > 0 {
93+ <span class="mute">needs { joinWords(j.Needs) }</span>
94+ }
95+ if j.Error != "" {
96+ <p class="job-err">{ j.Error }</p>
97+ }
98+ if len(j.Steps) > 0 {
99+ <ol class="steps">
100+ for _, s := range j.Steps {
101+ <li>@CheckIcon(s.Kind, s.State) <span>{ s.Name }</span> <span class="dim">{ s.Duration }</span></li>
102+ }
103+ </ol>
104+ }
105+ </li>
106+ }
107+ </ul>
108+}
109+
110+templ runRail(p RunPage) {
111+ <section aria-labelledby="r-run">
112+ <h2 id="r-run">Run</h2>
113+ <dl class="kv">
114+ <dt>result</dt>
115+ <dd>{ p.State }</dd>
116+ <dt>commit</dt>
117+ <dd><a href={ templ.SafeURL(p.CHref) }>{ p.Short }</a></dd>
118+ <dt>ref</dt>
119+ <dd><a href={ templ.SafeURL(p.RefHref) }>{ p.Ref }</a></dd>
120+ <dt>actor</dt>
121+ <dd>{ p.Actor }</dd>
122+ <dt>attempt</dt>
123+ <dd>{ itoa(int(p.Attempt)) } of { itoa(int(p.Latest)) }</dd>
124+ </dl>
125+ if len(p.Attempts) > 0 {
126+ <ul class="attempts">
127+ for _, a := range p.Attempts {
128+ <li>
129+ if a.Current {
130+ <span aria-current="page">{ a.Label }</span>
131+ } else {
132+ <a href={ templ.SafeURL(a.Href) }>{ a.Label }</a>
133+ }
134+ </li>
135+ }
136+ </ul>
137+ }
138+ </section>
139+ if p.CancelAction != "" || p.RerunAction != "" {
140+ <section aria-labelledby="r-act" class="decide">
141+ <h2 id="r-act">Actions</h2>
142+ <div class="d-btns">
143+ if p.CancelAction != "" {
144+ <form method="post" action={ templ.SafeURL(p.CancelAction) }><button type="submit" class="btn">Cancel run</button></form>
145+ }
146+ if p.RerunAction != "" {
147+ <form method="post" action={ templ.SafeURL(p.RerunAction) }><button type="submit" class="btn primary">Re-run all jobs</button></form>
148+ }
149+ if p.CanRerunFailed {
150+ <form method="post" action={ templ.SafeURL(p.RerunFailedAction) }><button type="submit" class="btn">Re-run failed jobs</button></form>
151+ }
152+ </div>
153+ </section>
154+ }
155+}
156+
157+// JobHead is the title of the job page with its state and its error, replaced
158+// by the live updates. total is the number of lines the log holds.
159+templ JobHead(j JobRow, total int64) {
160+ <div id="job-head" data-total={ itoa64(total) }>
161+ <h2 class="run-h">@CheckIcon(j.Kind, j.State)<span>{ j.Name }</span> <span class="dim">{ j.State }{ " " }{ j.Duration }</span></h2>
162+ if j.Error != "" {
163+ <p class="job-err">{ j.Error }</p>
164+ }
165+ </div>
166+}
167+
168+// JobSteps is the steps of the job in the rail, replaced by the live updates.
169+templ JobSteps(steps []StepView) {
170+ <ol class="steps" id="job-steps">
171+ for _, s := range steps {
172+ <li>
173+ @CheckIcon(s.Kind, s.State)
174+ if s.LogEnd > s.LogStart {
175+ <a href={ templ.SafeURL("#L" + itoa64(s.LogStart)) }>{ s.Name }</a>
176+ } else {
177+ <span>{ s.Name }</span>
178+ }
179+ <span class="dim">{ s.Duration }</span>
180+ </li>
181+ }
182+ </ol>
183+}
184+
185+templ Job(l Layout, p JobPage) {
186+ @Shell(l, jobRail(p)) {
187+ @LiveSlot("job-notice")
188+ @JobHead(p.JobRow, p.Total)
189+ <div id="job-log" class="joblog" data-total={ itoa64(p.Total) } data-follow?={ p.Follow } data-stream={ p.StreamHref } data-next={ itoa64(p.From + int64(len(p.Lines))) }>
190+ if p.Note != "" {
191+ <p class="dim">{ p.Note }</p>
192+ }
193+ if p.Prev != "" {
194+ <p class="more"><a href={ templ.SafeURL(p.Prev) } rel="prev">earlier lines</a></p>
195+ }
196+ <ol class="log" start={ itoa64(p.From + 1) }>
197+ for _, ln := range p.Lines {
198+ @LogLine(ln)
199+ }
200+ </ol>
201+ if p.Next != "" {
202+ <p class="more"><a href={ templ.SafeURL(p.Next) } rel="next">more lines</a></p>
203+ }
204+ </div>
205+ }
206+}
207+
208+// LogLine is a line of the log. Group and error markers of the runner show as
209+// headings and errors, never as raw text.
210+templ LogLine(ln LogLineView) {
211+ <li id={ "L" + itoa64(ln.N) } class={ logClass(ln) } data-step={ itoa(int(ln.Step)) }><time>{ ln.Time }</time><span class="t">{ ln.Text }</span></li>
212+}
213+
214+templ jobRail(p JobPage) {
215+ <section aria-labelledby="r-job">
216+ <h2 id="r-job">Steps</h2>
217+ @JobSteps(p.Steps)
218+ </section>
219+ <section aria-labelledby="r-jobf">
220+ <h2 id="r-jobf">Job</h2>
221+ <dl class="kv">
222+ <dt>run</dt>
223+ <dd><a href={ templ.SafeURL(p.RunHref) }>{ p.RunName }</a></dd>
224+ if p.Runner != "" {
225+ <dt>runner</dt>
226+ <dd>{ p.Runner }</dd>
227+ }
228+ if p.Isolation != "" {
229+ <dt>isolation</dt>
230+ <dd>{ p.Isolation }</dd>
231+ }
232+ <dt>lines</dt>
233+ <dd>{ itoa64(p.Total) }</dd>
234+ </dl>
235+ <p><a href={ templ.SafeURL(p.RawHref) }>raw log</a></p>
236+ </section>
237+}
238+
239+// CommitChecks is the combined state of the checks of a commit, as a mark
240+// with a count, linking to the page that explains it.
241+templ CommitChecks(c ChecksSummary) {
242+ if c.Total > 0 {
243+ <a class="ck-sum" href={ templ.SafeURL(c.Href) } title={ c.Title }>
244+ @CheckIcon(c.Kind, c.State)
245+ <span class="mute">{ c.Label }</span>
246+ </a>
247+ }
248+}
249+
250+// MRChecks is the checks section of the rail of a merge request.
251+templ MRChecks(c ChecksSection) {
252+ <section aria-labelledby="r-checks" id="mr-checks">
253+ <h2 id="r-checks">Checks <span class="count">{ itoa(len(c.Rows)) }</span></h2>
254+ if len(c.Rows) == 0 {
255+ <p class="dim">no checks</p>
256+ } else {
257+ <ul class="checks">
258+ for _, r := range c.Rows {
259+ <li>
260+ @CheckIcon(r.Kind, r.State)
261+ if r.Href != "" {
262+ <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>
263+ } else {
264+ <span>{ r.Name }</span>
265+ }
266+ <span class="dim">{ r.State }</span>
267+ if r.Waiting {
268+ <span class="mute">waiting for a runner</span>
269+ }
270+ </li>
271+ }
272+ </ul>
273+ }
274+ if c.Note != "" {
275+ <p class="mute">{ c.Note }</p>
276+ }
277+ </section>
278+}
A internal/adapters/web/actions_templ.go +2012 -0 generated file: collapsed
M internal/adapters/web/commit.go +3 -0
@@ -44,6 +44,8 @@ type CommitPage struct {
4444 // Invisible is true when some line holds an invisible character, shown as a
4545 // marker.
4646 Invisible bool
47+ // Checks is the combined state of the CI checks of the commit.
48+ Checks ChecksSummary
4749 }
4850
4951 // DiffView is how a page lays its diff out: the layouts in the page and the
@@ -138,6 +140,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
138140 }
139141 }
140142 p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)
143+ p.Checks = checksSummary(d.repo.Name, cid, s.checksFor(ctx, d.repo.Name, cid))
141144
142145 lines, invisible := diffLines(p.Files)
143146 p.Invisible = invisible
M internal/adapters/web/commits.go +9 -2
@@ -73,7 +73,7 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component,
7373 if token != "" {
7474 p.Newest = base
7575 }
76- s.fillDays(&p, d, cs)
76+ s.fillDays(ctx, &p, d, cs)
7777 if sel.path != "" && token == "" {
7878 p.Open = s.pathLink(ctx, d, sel)
7979 }
@@ -95,14 +95,21 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component,
9595 return Commits(l, p), http.StatusOK, nil
9696 }
9797
98+// maxCheckLookups bounds the commits of one page whose checks are read: each
99+// is one read of the store.
100+const maxCheckLookups = 100
101+
98102 // fillDays groups the commits of a page by day and lists the refs among them.
99-func (s *Server) fillDays(p *CommitsPage, d *repoData, cs []domain.Commit) {
103+func (s *Server) fillDays(ctx context.Context, p *CommitsPage, d *repoData, cs []domain.Commit) {
100104 now := s.cfg.Now()
101105 branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags)
102106 for i, c := range cs {
103107 row := commitRow(d.name(), c, now)
104108 row.ID, row.Cursor = "c-"+row.Short, i == 0
105109 row.Branches, row.Tags = branchesAt[c.ID.String()], tagsAt[c.ID.String()]
110+ if i < maxCheckLookups {
111+ row.Checks = checksSummary(d.repo.Name, c.ID.String(), s.checksFor(ctx, d.repo.Name, c.ID.String()))
112+ }
106113 day := "unknown date"
107114 if t := c.Committer.Time; !t.IsZero() {
108115 day = t.UTC().Format("2006-01-02")
M internal/adapters/web/components.templ +1 -0
@@ -112,6 +112,7 @@ templ CommitRows(rows []CommitRow) {
112112 <span class="pill tag"><span class="sr">tag </span>{ t }</span>
113113 }
114114 </td>
115+ <td class="c-ck">@CommitChecks(c.Checks)</td>
115116 <td class="c-who dim">{ c.Author }</td>
116117 <td class="c-when nowrap dim"><time datetime={ c.WhenAt } title={ c.WhenAt }>{ c.When }</time></td>
117118 </tr>
M internal/adapters/web/components_templ.go +18 -10 generated file: collapsed
M internal/adapters/web/diff.templ +4 -0
@@ -40,6 +40,10 @@ templ Commit(l Layout, p CommitPage) {
4040 { " · diff against the first parent" }
4141 }
4242 </dd>
43+ if p.Checks.Total > 0 {
44+ <dt>checks</dt>
45+ <dd>@CommitChecks(p.Checks)</dd>
46+ }
4347 if len(p.Refs) > 0 {
4448 <dt>refs</dt>
4549 <dd>
M internal/adapters/web/diff_templ.go +180 -166
@@ -202,75 +202,89 @@ func Commit(l Layout, p CommitPage) templ.Component {
202202 if templ_7745c5c3_Err != nil {
203203 return templ_7745c5c3_Err
204204 }
205+ if p.Checks.Total > 0 {
206+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<dt>checks</dt><dd>")
207+ if templ_7745c5c3_Err != nil {
208+ return templ_7745c5c3_Err
209+ }
210+ templ_7745c5c3_Err = CommitChecks(p.Checks).Render(ctx, templ_7745c5c3_Buffer)
211+ if templ_7745c5c3_Err != nil {
212+ return templ_7745c5c3_Err
213+ }
214+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</dd>")
215+ if templ_7745c5c3_Err != nil {
216+ return templ_7745c5c3_Err
217+ }
218+ }
205219 if len(p.Refs) > 0 {
206- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<dt>refs</dt><dd>")
220+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<dt>refs</dt><dd>")
207221 if templ_7745c5c3_Err != nil {
208222 return templ_7745c5c3_Err
209223 }
210224 for _, r := range p.Refs {
211- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<span class=\"pill\">")
225+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<span class=\"pill\">")
212226 if templ_7745c5c3_Err != nil {
213227 return templ_7745c5c3_Err
214228 }
215229 var templ_7745c5c3_Var10 string
216230 templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(r)
217231 if templ_7745c5c3_Err != nil {
218- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 47, Col: 29}
232+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 51, Col: 29}
219233 }
220234 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
221235 if templ_7745c5c3_Err != nil {
222236 return templ_7745c5c3_Err
223237 }
224- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</span>")
238+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</span>")
225239 if templ_7745c5c3_Err != nil {
226240 return templ_7745c5c3_Err
227241 }
228242 }
229- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</dd>")
243+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</dd>")
230244 if templ_7745c5c3_Err != nil {
231245 return templ_7745c5c3_Err
232246 }
233247 }
234- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</dl></header><div class=\"diff-tools\" role=\"toolbar\" aria-label=\"Diff view\"><span class=\"dim\">")
248+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</dl></header><div class=\"diff-tools\" role=\"toolbar\" aria-label=\"Diff view\"><span class=\"dim\">")
235249 if templ_7745c5c3_Err != nil {
236250 return templ_7745c5c3_Err
237251 }
238252 var templ_7745c5c3_Var11 string
239253 templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(plural(p.NFiles, "file", "files"))
240254 if templ_7745c5c3_Err != nil {
241- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 54, Col: 56}
255+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 58, Col: 56}
242256 }
243257 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
244258 if templ_7745c5c3_Err != nil {
245259 return templ_7745c5c3_Err
246260 }
247- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " · <span class=\"plus\">+")
261+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " · <span class=\"plus\">+")
248262 if templ_7745c5c3_Err != nil {
249263 return templ_7745c5c3_Err
250264 }
251265 var templ_7745c5c3_Var12 string
252266 templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Adds))
253267 if templ_7745c5c3_Err != nil {
254- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 54, Col: 96}
268+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 58, Col: 96}
255269 }
256270 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
257271 if templ_7745c5c3_Err != nil {
258272 return templ_7745c5c3_Err
259273 }
260- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</span> <span class=\"minus\">-")
274+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</span> <span class=\"minus\">-")
261275 if templ_7745c5c3_Err != nil {
262276 return templ_7745c5c3_Err
263277 }
264278 var templ_7745c5c3_Var13 string
265279 templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Dels))
266280 if templ_7745c5c3_Err != nil {
267- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 54, Col: 141}
281+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 58, Col: 141}
268282 }
269283 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
270284 if templ_7745c5c3_Err != nil {
271285 return templ_7745c5c3_Err
272286 }
273- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</span></span> <span class=\"grow\"></span>")
287+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</span></span> <span class=\"grow\"></span>")
274288 if templ_7745c5c3_Err != nil {
275289 return templ_7745c5c3_Err
276290 }
@@ -286,87 +300,87 @@ func Commit(l Layout, p CommitPage) templ.Component {
286300 if templ_7745c5c3_Err != nil {
287301 return templ_7745c5c3_Err
288302 }
289- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</div>")
303+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</div>")
290304 if templ_7745c5c3_Err != nil {
291305 return templ_7745c5c3_Err
292306 }
293307 if p.Invisible {
294- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<p class=\"notice\">this diff holds invisible characters (direction controls, zero-width characters). They are shown as <span class=\"inv\">&lt;U+XXXX&gt;</span> markers, never applied.</p>")
308+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<p class=\"notice\">this diff holds invisible characters (direction controls, zero-width characters). They are shown as <span class=\"inv\">&lt;U+XXXX&gt;</span> markers, never applied.</p>")
295309 if templ_7745c5c3_Err != nil {
296310 return templ_7745c5c3_Err
297311 }
298312 }
299- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, " ")
313+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " ")
300314 if templ_7745c5c3_Err != nil {
301315 return templ_7745c5c3_Err
302316 }
303317 if p.Note != "" {
304- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<p class=\"notice\">")
318+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<p class=\"notice\">")
305319 if templ_7745c5c3_Err != nil {
306320 return templ_7745c5c3_Err
307321 }
308322 var templ_7745c5c3_Var14 string
309323 templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
310324 if templ_7745c5c3_Err != nil {
311- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 67, Col: 29}
325+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 71, Col: 29}
312326 }
313327 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
314328 if templ_7745c5c3_Err != nil {
315329 return templ_7745c5c3_Err
316330 }
317- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</p>")
331+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</p>")
318332 if templ_7745c5c3_Err != nil {
319333 return templ_7745c5c3_Err
320334 }
321335 }
322- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " <div id=\"diffs\"")
336+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " <div id=\"diffs\"")
323337 if templ_7745c5c3_Err != nil {
324338 return templ_7745c5c3_Err
325339 }
326340 if p.Both {
327- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, " data-diff=\"")
341+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " data-diff=\"")
328342 if templ_7745c5c3_Err != nil {
329343 return templ_7745c5c3_Err
330344 }
331345 var templ_7745c5c3_Var15 string
332346 templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
333347 if templ_7745c5c3_Err != nil {
334- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 72, Col: 22}
348+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 76, Col: 22}
335349 }
336350 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
337351 if templ_7745c5c3_Err != nil {
338352 return templ_7745c5c3_Err
339353 }
340- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\"")
354+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\"")
341355 if templ_7745c5c3_Err != nil {
342356 return templ_7745c5c3_Err
343357 }
344358 } else {
345- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " data-view=\"")
359+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, " data-view=\"")
346360 if templ_7745c5c3_Err != nil {
347361 return templ_7745c5c3_Err
348362 }
349363 var templ_7745c5c3_Var16 string
350364 templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
351365 if templ_7745c5c3_Err != nil {
352- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 74, Col: 22}
366+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 78, Col: 22}
353367 }
354368 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
355369 if templ_7745c5c3_Err != nil {
356370 return templ_7745c5c3_Err
357371 }
358- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\"")
372+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\"")
359373 if templ_7745c5c3_Err != nil {
360374 return templ_7745c5c3_Err
361375 }
362376 }
363377 if p.Both && !p.Pinned {
364- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, " hx-live=\"this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff\"")
378+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, " hx-live=\"this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff\"")
365379 if templ_7745c5c3_Err != nil {
366380 return templ_7745c5c3_Err
367381 }
368382 }
369- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, ">")
383+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, ">")
370384 if templ_7745c5c3_Err != nil {
371385 return templ_7745c5c3_Err
372386 }
@@ -377,40 +391,40 @@ func Commit(l Layout, p CommitPage) templ.Component {
377391 }
378392 }
379393 if len(p.Files) == 0 {
380- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<p class=\"note\">no changed files")
394+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<p class=\"note\">no changed files")
381395 if templ_7745c5c3_Err != nil {
382396 return templ_7745c5c3_Err
383397 }
384398 var templ_7745c5c3_Var17 string
385399 templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
386400 if templ_7745c5c3_Err != nil {
387- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 84, Col: 41}
401+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 88, Col: 41}
388402 }
389403 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
390404 if templ_7745c5c3_Err != nil {
391405 return templ_7745c5c3_Err
392406 }
393- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " ")
407+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " ")
394408 if templ_7745c5c3_Err != nil {
395409 return templ_7745c5c3_Err
396410 }
397411 if p.Merge {
398- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "against the first parent.")
412+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "against the first parent.")
399413 if templ_7745c5c3_Err != nil {
400414 return templ_7745c5c3_Err
401415 }
402416 } else {
403- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "in this commit.")
417+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "in this commit.")
404418 if templ_7745c5c3_Err != nil {
405419 return templ_7745c5c3_Err
406420 }
407421 }
408- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</p>")
422+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</p>")
409423 if templ_7745c5c3_Err != nil {
410424 return templ_7745c5c3_Err
411425 }
412426 }
413- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</div>")
427+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</div>")
414428 if templ_7745c5c3_Err != nil {
415429 return templ_7745c5c3_Err
416430 }
@@ -448,101 +462,101 @@ func viewLink(p DiffView, view, label, key, href string) templ.Component {
448462 templ_7745c5c3_Var18 = templ.NopComponent
449463 }
450464 ctx = templ.ClearChildren(ctx)
451- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<a href=\"")
465+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<a href=\"")
452466 if templ_7745c5c3_Err != nil {
453467 return templ_7745c5c3_Err
454468 }
455469 var templ_7745c5c3_Var19 templ.SafeURL
456470 templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(href))
457471 if templ_7745c5c3_Err != nil {
458- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 101, Col: 28}
472+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 105, Col: 28}
459473 }
460474 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
461475 if templ_7745c5c3_Err != nil {
462476 return templ_7745c5c3_Err
463477 }
464- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" class=\"btn vbtn\" data-view=\"")
478+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\" class=\"btn vbtn\" data-view=\"")
465479 if templ_7745c5c3_Err != nil {
466480 return templ_7745c5c3_Err
467481 }
468482 var templ_7745c5c3_Var20 string
469483 templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(view)
470484 if templ_7745c5c3_Err != nil {
471- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 103, Col: 18}
485+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 107, Col: 18}
472486 }
473487 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
474488 if templ_7745c5c3_Err != nil {
475489 return templ_7745c5c3_Err
476490 }
477- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\" aria-keyshortcuts=\"")
491+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\" aria-keyshortcuts=\"")
478492 if templ_7745c5c3_Err != nil {
479493 return templ_7745c5c3_Err
480494 }
481495 var templ_7745c5c3_Var21 string
482496 templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(key)
483497 if templ_7745c5c3_Err != nil {
484- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 104, Col: 25}
498+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 108, Col: 25}
485499 }
486500 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
487501 if templ_7745c5c3_Err != nil {
488502 return templ_7745c5c3_Err
489503 }
490- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\"")
504+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\"")
491505 if templ_7745c5c3_Err != nil {
492506 return templ_7745c5c3_Err
493507 }
494508 if p.View == view {
495- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, " aria-current=\"true\"")
509+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, " aria-current=\"true\"")
496510 if templ_7745c5c3_Err != nil {
497511 return templ_7745c5c3_Err
498512 }
499513 }
500514 if p.Both {
501- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " data-fire=\"")
515+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " data-fire=\"")
502516 if templ_7745c5c3_Err != nil {
503517 return templ_7745c5c3_Err
504518 }
505519 var templ_7745c5c3_Var22 string
506520 templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(view)
507521 if templ_7745c5c3_Err != nil {
508- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 109, Col: 19}
522+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 113, Col: 19}
509523 }
510524 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
511525 if templ_7745c5c3_Err != nil {
512526 return templ_7745c5c3_Err
513527 }
514- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\" :aria-current=\"q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false\"")
528+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\" :aria-current=\"q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false\"")
515529 if templ_7745c5c3_Err != nil {
516530 return templ_7745c5c3_Err
517531 }
518532 }
519- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, ">")
533+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, ">")
520534 if templ_7745c5c3_Err != nil {
521535 return templ_7745c5c3_Err
522536 }
523537 var templ_7745c5c3_Var23 string
524538 templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(label)
525539 if templ_7745c5c3_Err != nil {
526- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 112, Col: 9}
540+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 116, Col: 9}
527541 }
528542 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
529543 if templ_7745c5c3_Err != nil {
530544 return templ_7745c5c3_Err
531545 }
532- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<kbd aria-hidden=\"true\">")
546+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<kbd aria-hidden=\"true\">")
533547 if templ_7745c5c3_Err != nil {
534548 return templ_7745c5c3_Err
535549 }
536550 var templ_7745c5c3_Var24 string
537551 templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(key)
538552 if templ_7745c5c3_Err != nil {
539- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 112, Col: 40}
553+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 116, Col: 40}
540554 }
541555 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
542556 if templ_7745c5c3_Err != nil {
543557 return templ_7745c5c3_Err
544558 }
545- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</kbd></a>")
559+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</kbd></a>")
546560 if templ_7745c5c3_Err != nil {
547561 return templ_7745c5c3_Err
548562 }
@@ -573,7 +587,7 @@ func collapseButton() templ.Component {
573587 templ_7745c5c3_Var25 = templ.NopComponent
574588 }
575589 ctx = templ.ClearChildren(ctx)
576- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<button type=\"button\" aria-keyshortcuts=\"x\" data-js data-fire=\"collapse\" class=\"btn\"><span hx-live=\"this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'\">Collapse all</span><kbd aria-hidden=\"true\">x</kbd></button>")
590+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<button type=\"button\" aria-keyshortcuts=\"x\" data-js data-fire=\"collapse\" class=\"btn\"><span hx-live=\"this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'\">Collapse all</span><kbd aria-hidden=\"true\">x</kbd></button>")
577591 if templ_7745c5c3_Err != nil {
578592 return templ_7745c5c3_Err
579593 }
@@ -605,13 +619,13 @@ func personView(pr Person) templ.Component {
605619 var templ_7745c5c3_Var27 string
606620 templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(pr.Name)
607621 if templ_7745c5c3_Err != nil {
608- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 124, Col: 10}
622+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 128, Col: 10}
609623 }
610624 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
611625 if templ_7745c5c3_Err != nil {
612626 return templ_7745c5c3_Err
613627 }
614- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " ")
628+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " ")
615629 if templ_7745c5c3_Err != nil {
616630 return templ_7745c5c3_Err
617631 }
@@ -619,59 +633,59 @@ func personView(pr Person) templ.Component {
619633 var templ_7745c5c3_Var28 string
620634 templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
621635 if templ_7745c5c3_Err != nil {
622- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 126, Col: 10}
636+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 130, Col: 10}
623637 }
624638 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
625639 if templ_7745c5c3_Err != nil {
626640 return templ_7745c5c3_Err
627641 }
628- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<time datetime=\"")
642+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<time datetime=\"")
629643 if templ_7745c5c3_Err != nil {
630644 return templ_7745c5c3_Err
631645 }
632646 var templ_7745c5c3_Var29 string
633647 templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(pr.WhenAt)
634648 if templ_7745c5c3_Err != nil {
635- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 126, Col: 38}
649+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 130, Col: 38}
636650 }
637651 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
638652 if templ_7745c5c3_Err != nil {
639653 return templ_7745c5c3_Err
640654 }
641- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\" title=\"")
655+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\" title=\"")
642656 if templ_7745c5c3_Err != nil {
643657 return templ_7745c5c3_Err
644658 }
645659 var templ_7745c5c3_Var30 string
646660 templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(pr.Abs)
647661 if templ_7745c5c3_Err != nil {
648- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 126, Col: 55}
662+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 130, Col: 55}
649663 }
650664 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
651665 if templ_7745c5c3_Err != nil {
652666 return templ_7745c5c3_Err
653667 }
654- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\">")
668+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\">")
655669 if templ_7745c5c3_Err != nil {
656670 return templ_7745c5c3_Err
657671 }
658672 var templ_7745c5c3_Var31 string
659673 templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(pr.When)
660674 if templ_7745c5c3_Err != nil {
661- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 126, Col: 67}
675+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 130, Col: 67}
662676 }
663677 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
664678 if templ_7745c5c3_Err != nil {
665679 return templ_7745c5c3_Err
666680 }
667- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</time>")
681+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</time>")
668682 if templ_7745c5c3_Err != nil {
669683 return templ_7745c5c3_Err
670684 }
671685 var templ_7745c5c3_Var32 string
672686 templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(" · " + pr.Abs)
673687 if templ_7745c5c3_Err != nil {
674- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 126, Col: 93}
688+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 130, Col: 93}
675689 }
676690 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
677691 if templ_7745c5c3_Err != nil {
@@ -703,20 +717,20 @@ func commitRail(p CommitPage) templ.Component {
703717 templ_7745c5c3_Var33 = templ.NopComponent
704718 }
705719 ctx = templ.ClearChildren(ctx)
706- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<section aria-labelledby=\"r-files\"><h2 id=\"r-files\">Files <span class=\"count\">")
720+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<section aria-labelledby=\"r-files\"><h2 id=\"r-files\">Files <span class=\"count\">")
707721 if templ_7745c5c3_Err != nil {
708722 return templ_7745c5c3_Err
709723 }
710724 var templ_7745c5c3_Var34 string
711725 templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
712726 if templ_7745c5c3_Err != nil {
713- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 132, Col: 65}
727+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 136, Col: 65}
714728 }
715729 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
716730 if templ_7745c5c3_Err != nil {
717731 return templ_7745c5c3_Err
718732 }
719- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</span></h2>")
733+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</span></h2>")
720734 if templ_7745c5c3_Err != nil {
721735 return templ_7745c5c3_Err
722736 }
@@ -724,7 +738,7 @@ func commitRail(p CommitPage) templ.Component {
724738 if templ_7745c5c3_Err != nil {
725739 return templ_7745c5c3_Err
726740 }
727- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</section>")
741+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</section>")
728742 if templ_7745c5c3_Err != nil {
729743 return templ_7745c5c3_Err
730744 }
@@ -755,147 +769,147 @@ func FileList(files []DiffFile) templ.Component {
755769 templ_7745c5c3_Var35 = templ.NopComponent
756770 }
757771 ctx = templ.ClearChildren(ctx)
758- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<ol class=\"files\" aria-label=\"Changed files\">")
772+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<ol class=\"files\" aria-label=\"Changed files\">")
759773 if templ_7745c5c3_Err != nil {
760774 return templ_7745c5c3_Err
761775 }
762776 for _, f := range files {
763- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<li><a href=\"")
777+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<li><a href=\"")
764778 if templ_7745c5c3_Err != nil {
765779 return templ_7745c5c3_Err
766780 }
767781 var templ_7745c5c3_Var36 templ.SafeURL
768782 templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("#" + f.ID))
769783 if templ_7745c5c3_Err != nil {
770- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 144, Col: 37}
784+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 148, Col: 37}
771785 }
772786 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
773787 if templ_7745c5c3_Err != nil {
774788 return templ_7745c5c3_Err
775789 }
776- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\" hx-on:click=\"document.getElementById(this.hash.slice(1)).open = true\"><span class=\"st\" title=\"")
790+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "\" hx-on:click=\"document.getElementById(this.hash.slice(1)).open = true\"><span class=\"st\" title=\"")
777791 if templ_7745c5c3_Err != nil {
778792 return templ_7745c5c3_Err
779793 }
780794 var templ_7745c5c3_Var37 string
781795 templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Status)
782796 if templ_7745c5c3_Err != nil {
783- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 147, Col: 38}
797+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 151, Col: 38}
784798 }
785799 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
786800 if templ_7745c5c3_Err != nil {
787801 return templ_7745c5c3_Err
788802 }
789- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\">")
803+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\">")
790804 if templ_7745c5c3_Err != nil {
791805 return templ_7745c5c3_Err
792806 }
793807 var templ_7745c5c3_Var38 string
794808 templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(f.Status)
795809 if templ_7745c5c3_Err != nil {
796- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 147, Col: 51}
810+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 151, Col: 51}
797811 }
798812 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
799813 if templ_7745c5c3_Err != nil {
800814 return templ_7745c5c3_Err
801815 }
802- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</span> <span class=\"p\" title=\"")
816+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</span> <span class=\"p\" title=\"")
803817 if templ_7745c5c3_Err != nil {
804818 return templ_7745c5c3_Err
805819 }
806820 var templ_7745c5c3_Var39 string
807821 templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path)
808822 if templ_7745c5c3_Err != nil {
809- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 148, Col: 35}
823+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 152, Col: 35}
810824 }
811825 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
812826 if templ_7745c5c3_Err != nil {
813827 return templ_7745c5c3_Err
814828 }
815- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\">")
829+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\">")
816830 if templ_7745c5c3_Err != nil {
817831 return templ_7745c5c3_Err
818832 }
819833 var templ_7745c5c3_Var40 string
820834 templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(f.Path)
821835 if templ_7745c5c3_Err != nil {
822- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 148, Col: 46}
836+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 152, Col: 46}
823837 }
824838 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
825839 if templ_7745c5c3_Err != nil {
826840 return templ_7745c5c3_Err
827841 }
828- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</span> <span class=\"n\"><span class=\"plus\">+")
842+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</span> <span class=\"n\"><span class=\"plus\">+")
829843 if templ_7745c5c3_Err != nil {
830844 return templ_7745c5c3_Err
831845 }
832846 var templ_7745c5c3_Var41 string
833847 templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Add))
834848 if templ_7745c5c3_Err != nil {
835- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 149, Col: 54}
849+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 153, Col: 54}
836850 }
837851 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
838852 if templ_7745c5c3_Err != nil {
839853 return templ_7745c5c3_Err
840854 }
841- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</span> <span class=\"minus\">-")
855+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</span> <span class=\"minus\">-")
842856 if templ_7745c5c3_Err != nil {
843857 return templ_7745c5c3_Err
844858 }
845859 var templ_7745c5c3_Var42 string
846860 templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Del))
847861 if templ_7745c5c3_Err != nil {
848- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 149, Col: 98}
862+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 153, Col: 98}
849863 }
850864 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
851865 if templ_7745c5c3_Err != nil {
852866 return templ_7745c5c3_Err
853867 }
854- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</span></span> <span class=\"b\" aria-hidden=\"true\"><span class=\"plus\">")
868+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</span></span> <span class=\"b\" aria-hidden=\"true\"><span class=\"plus\">")
855869 if templ_7745c5c3_Err != nil {
856870 return templ_7745c5c3_Err
857871 }
858872 var templ_7745c5c3_Var43 string
859873 templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(f.Bar.Add)
860874 if templ_7745c5c3_Err != nil {
861- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 150, Col: 70}
875+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 154, Col: 70}
862876 }
863877 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
864878 if templ_7745c5c3_Err != nil {
865879 return templ_7745c5c3_Err
866880 }
867- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</span><span class=\"minus\">")
881+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</span><span class=\"minus\">")
868882 if templ_7745c5c3_Err != nil {
869883 return templ_7745c5c3_Err
870884 }
871885 var templ_7745c5c3_Var44 string
872886 templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(f.Bar.Del)
873887 if templ_7745c5c3_Err != nil {
874- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 150, Col: 110}
888+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 154, Col: 110}
875889 }
876890 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
877891 if templ_7745c5c3_Err != nil {
878892 return templ_7745c5c3_Err
879893 }
880- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</span>")
894+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</span>")
881895 if templ_7745c5c3_Err != nil {
882896 return templ_7745c5c3_Err
883897 }
884898 var templ_7745c5c3_Var45 string
885899 templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(f.Bar.Rest)
886900 if templ_7745c5c3_Err != nil {
887- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 150, Col: 131}
901+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 154, Col: 131}
888902 }
889903 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
890904 if templ_7745c5c3_Err != nil {
891905 return templ_7745c5c3_Err
892906 }
893- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</span></a></li>")
907+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</span></a></li>")
894908 if templ_7745c5c3_Err != nil {
895909 return templ_7745c5c3_Err
896910 }
897911 }
898- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</ol>")
912+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</ol>")
899913 if templ_7745c5c3_Err != nil {
900914 return templ_7745c5c3_Err
901915 }
@@ -928,121 +942,121 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
928942 templ_7745c5c3_Var46 = templ.NopComponent
929943 }
930944 ctx = templ.ClearChildren(ctx)
931- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<details class=\"dfile\" id=\"")
945+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<details class=\"dfile\" id=\"")
932946 if templ_7745c5c3_Err != nil {
933947 return templ_7745c5c3_Err
934948 }
935949 var templ_7745c5c3_Var47 string
936950 templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ID)
937951 if templ_7745c5c3_Err != nil {
938- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 164, Col: 11}
952+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 168, Col: 11}
939953 }
940954 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47)
941955 if templ_7745c5c3_Err != nil {
942956 return templ_7745c5c3_Err
943957 }
944- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "\" data-file")
958+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "\" data-file")
945959 if templ_7745c5c3_Err != nil {
946960 return templ_7745c5c3_Err
947961 }
948962 if f.Open {
949- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, " open")
963+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, " open")
950964 if templ_7745c5c3_Err != nil {
951965 return templ_7745c5c3_Err
952966 }
953967 }
954- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "><summary><span class=\"st\">")
968+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "><summary><span class=\"st\">")
955969 if templ_7745c5c3_Err != nil {
956970 return templ_7745c5c3_Err
957971 }
958972 var templ_7745c5c3_Var48 string
959973 templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(f.Status)
960974 if templ_7745c5c3_Err != nil {
961- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 171, Col: 30}
975+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 175, Col: 30}
962976 }
963977 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
964978 if templ_7745c5c3_Err != nil {
965979 return templ_7745c5c3_Err
966980 }
967- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</span>")
981+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</span>")
968982 if templ_7745c5c3_Err != nil {
969983 return templ_7745c5c3_Err
970984 }
971985 var templ_7745c5c3_Var49 string
972986 templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
973987 if templ_7745c5c3_Err != nil {
974- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 171, Col: 44}
988+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 175, Col: 44}
975989 }
976990 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
977991 if templ_7745c5c3_Err != nil {
978992 return templ_7745c5c3_Err
979993 }
980- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "<span class=\"p\">")
994+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<span class=\"p\">")
981995 if templ_7745c5c3_Err != nil {
982996 return templ_7745c5c3_Err
983997 }
984998 var templ_7745c5c3_Var50 string
985999 templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(f.Path)
9861000 if templ_7745c5c3_Err != nil {
987- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 171, Col: 70}
1001+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 175, Col: 70}
9881002 }
9891003 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
9901004 if templ_7745c5c3_Err != nil {
9911005 return templ_7745c5c3_Err
9921006 }
993- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</span>")
1007+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</span>")
9941008 if templ_7745c5c3_Err != nil {
9951009 return templ_7745c5c3_Err
9961010 }
9971011 var templ_7745c5c3_Var51 string
9981012 templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
9991013 if templ_7745c5c3_Err != nil {
1000- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 171, Col: 84}
1014+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 175, Col: 84}
10011015 }
10021016 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
10031017 if templ_7745c5c3_Err != nil {
10041018 return templ_7745c5c3_Err
10051019 }
1006- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<span class=\"plus\">+")
1020+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<span class=\"plus\">+")
10071021 if templ_7745c5c3_Err != nil {
10081022 return templ_7745c5c3_Err
10091023 }
10101024 var templ_7745c5c3_Var52 string
10111025 templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Add))
10121026 if templ_7745c5c3_Err != nil {
1013- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 171, Col: 119}
1027+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 175, Col: 119}
10141028 }
10151029 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
10161030 if templ_7745c5c3_Err != nil {
10171031 return templ_7745c5c3_Err
10181032 }
1019- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</span>")
1033+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</span>")
10201034 if templ_7745c5c3_Err != nil {
10211035 return templ_7745c5c3_Err
10221036 }
10231037 var templ_7745c5c3_Var53 string
10241038 templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
10251039 if templ_7745c5c3_Err != nil {
1026- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 171, Col: 133}
1040+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 175, Col: 133}
10271041 }
10281042 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
10291043 if templ_7745c5c3_Err != nil {
10301044 return templ_7745c5c3_Err
10311045 }
1032- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<span class=\"minus\">-")
1046+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<span class=\"minus\">-")
10331047 if templ_7745c5c3_Err != nil {
10341048 return templ_7745c5c3_Err
10351049 }
10361050 var templ_7745c5c3_Var54 string
10371051 templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Del))
10381052 if templ_7745c5c3_Err != nil {
1039- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 171, Col: 169}
1053+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 175, Col: 169}
10401054 }
10411055 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
10421056 if templ_7745c5c3_Err != nil {
10431057 return templ_7745c5c3_Err
10441058 }
1045- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</span> ")
1059+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</span> ")
10461060 if templ_7745c5c3_Err != nil {
10471061 return templ_7745c5c3_Err
10481062 }
@@ -1050,72 +1064,72 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
10501064 var templ_7745c5c3_Var55 string
10511065 templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
10521066 if templ_7745c5c3_Err != nil {
1053- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 173, Col: 9}
1067+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 177, Col: 9}
10541068 }
10551069 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
10561070 if templ_7745c5c3_Err != nil {
10571071 return templ_7745c5c3_Err
10581072 }
1059- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<span class=\"note\">")
1073+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<span class=\"note\">")
10601074 if templ_7745c5c3_Err != nil {
10611075 return templ_7745c5c3_Err
10621076 }
10631077 var templ_7745c5c3_Var56 string
10641078 templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note)
10651079 if templ_7745c5c3_Err != nil {
1066- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 173, Col: 38}
1080+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 177, Col: 38}
10671081 }
10681082 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
10691083 if templ_7745c5c3_Err != nil {
10701084 return templ_7745c5c3_Err
10711085 }
1072- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</span>")
1086+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "</span>")
10731087 if templ_7745c5c3_Err != nil {
10741088 return templ_7745c5c3_Err
10751089 }
10761090 }
1077- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "</summary> ")
1091+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</summary> ")
10781092 if templ_7745c5c3_Err != nil {
10791093 return templ_7745c5c3_Err
10801094 }
10811095 if len(f.Hunks) > 0 {
1082- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "<div class=\"dscroll\">")
1096+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "<div class=\"dscroll\">")
10831097 if templ_7745c5c3_Err != nil {
10841098 return templ_7745c5c3_Err
10851099 }
10861100 if p.ShowUnified {
1087- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "<table class=\"dt uni\" aria-label=\"")
1101+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "<table class=\"dt uni\" aria-label=\"")
10881102 if templ_7745c5c3_Err != nil {
10891103 return templ_7745c5c3_Err
10901104 }
10911105 var templ_7745c5c3_Var57 string
10921106 templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", unified")
10931107 if templ_7745c5c3_Err != nil {
1094- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 179, Col: 60}
1108+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 183, Col: 60}
10951109 }
10961110 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var57)
10971111 if templ_7745c5c3_Err != nil {
10981112 return templ_7745c5c3_Err
10991113 }
1100- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "\">")
1114+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "\">")
11011115 if templ_7745c5c3_Err != nil {
11021116 return templ_7745c5c3_Err
11031117 }
11041118 for _, h := range f.Hunks {
1105- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "<tbody><tr class=\"hunk\"><td colspan=\"4\">")
1119+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "<tbody><tr class=\"hunk\"><td colspan=\"4\">")
11061120 if templ_7745c5c3_Err != nil {
11071121 return templ_7745c5c3_Err
11081122 }
11091123 var templ_7745c5c3_Var58 string
11101124 templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)
11111125 if templ_7745c5c3_Err != nil {
1112- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 182, Col: 51}
1126+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 186, Col: 51}
11131127 }
11141128 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58))
11151129 if templ_7745c5c3_Err != nil {
11161130 return templ_7745c5c3_Err
11171131 }
1118- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "</td></tr>")
1132+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "</td></tr>")
11191133 if templ_7745c5c3_Err != nil {
11201134 return templ_7745c5c3_Err
11211135 }
@@ -1125,7 +1139,7 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
11251139 if templ_7745c5c3_Err != nil {
11261140 return templ_7745c5c3_Err
11271141 }
1128- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "<tr class=\"")
1142+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "<tr class=\"")
11291143 if templ_7745c5c3_Err != nil {
11301144 return templ_7745c5c3_Err
11311145 }
@@ -1138,7 +1152,7 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
11381152 if templ_7745c5c3_Err != nil {
11391153 return templ_7745c5c3_Err
11401154 }
1141- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "\"><td class=\"ln\">")
1155+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "\"><td class=\"ln\">")
11421156 if templ_7745c5c3_Err != nil {
11431157 return templ_7745c5c3_Err
11441158 }
@@ -1146,7 +1160,7 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
11461160 if templ_7745c5c3_Err != nil {
11471161 return templ_7745c5c3_Err
11481162 }
1149- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "</td><td class=\"ln\">")
1163+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "</td><td class=\"ln\">")
11501164 if templ_7745c5c3_Err != nil {
11511165 return templ_7745c5c3_Err
11521166 }
@@ -1154,20 +1168,20 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
11541168 if templ_7745c5c3_Err != nil {
11551169 return templ_7745c5c3_Err
11561170 }
1157- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "</td><td class=\"m\">")
1171+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "</td><td class=\"m\">")
11581172 if templ_7745c5c3_Err != nil {
11591173 return templ_7745c5c3_Err
11601174 }
11611175 var templ_7745c5c3_Var61 string
11621176 templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
11631177 if templ_7745c5c3_Err != nil {
1164- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 187, Col: 33}
1178+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 191, Col: 33}
11651179 }
11661180 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61))
11671181 if templ_7745c5c3_Err != nil {
11681182 return templ_7745c5c3_Err
11691183 }
1170- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "</td><td class=\"tx\">")
1184+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</td><td class=\"tx\">")
11711185 if templ_7745c5c3_Err != nil {
11721186 return templ_7745c5c3_Err
11731187 }
@@ -1175,12 +1189,12 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
11751189 if templ_7745c5c3_Err != nil {
11761190 return templ_7745c5c3_Err
11771191 }
1178- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "</td></tr>")
1192+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "</td></tr>")
11791193 if templ_7745c5c3_Err != nil {
11801194 return templ_7745c5c3_Err
11811195 }
11821196 if len(ln.Notes) > 0 || ln.Form != nil {
1183- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "<tr class=\"fnote\"><td colspan=\"3\"></td><td>")
1197+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "<tr class=\"fnote\"><td colspan=\"3\"></td><td>")
11841198 if templ_7745c5c3_Err != nil {
11851199 return templ_7745c5c3_Err
11861200 }
@@ -1188,60 +1202,60 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
11881202 if templ_7745c5c3_Err != nil {
11891203 return templ_7745c5c3_Err
11901204 }
1191- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "</td></tr>")
1205+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "</td></tr>")
11921206 if templ_7745c5c3_Err != nil {
11931207 return templ_7745c5c3_Err
11941208 }
11951209 }
11961210 }
1197- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "</tbody>")
1211+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "</tbody>")
11981212 if templ_7745c5c3_Err != nil {
11991213 return templ_7745c5c3_Err
12001214 }
12011215 }
1202- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "</table>")
1216+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "</table>")
12031217 if templ_7745c5c3_Err != nil {
12041218 return templ_7745c5c3_Err
12051219 }
12061220 }
12071221 if p.ShowSplit {
1208- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "<table class=\"dt spl\" aria-label=\"")
1222+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "<table class=\"dt spl\" aria-label=\"")
12091223 if templ_7745c5c3_Err != nil {
12101224 return templ_7745c5c3_Err
12111225 }
12121226 var templ_7745c5c3_Var62 string
12131227 templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", split")
12141228 if templ_7745c5c3_Err != nil {
1215- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 202, Col: 58}
1229+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 206, Col: 58}
12161230 }
12171231 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var62)
12181232 if templ_7745c5c3_Err != nil {
12191233 return templ_7745c5c3_Err
12201234 }
1221- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "\"><colgroup><col class=\"c-ln\"><col class=\"c-m\"><col><col class=\"c-ln\"><col class=\"c-m\"><col></colgroup> ")
1235+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "\"><colgroup><col class=\"c-ln\"><col class=\"c-m\"><col><col class=\"c-ln\"><col class=\"c-m\"><col></colgroup> ")
12221236 if templ_7745c5c3_Err != nil {
12231237 return templ_7745c5c3_Err
12241238 }
12251239 for _, h := range f.Hunks {
1226- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "<tbody><tr class=\"hunk\"><td colspan=\"6\">")
1240+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "<tbody><tr class=\"hunk\"><td colspan=\"6\">")
12271241 if templ_7745c5c3_Err != nil {
12281242 return templ_7745c5c3_Err
12291243 }
12301244 var templ_7745c5c3_Var63 string
12311245 templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header)
12321246 if templ_7745c5c3_Err != nil {
1233- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 206, Col: 51}
1247+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 210, Col: 51}
12341248 }
12351249 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63))
12361250 if templ_7745c5c3_Err != nil {
12371251 return templ_7745c5c3_Err
12381252 }
1239- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "</td></tr>")
1253+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "</td></tr>")
12401254 if templ_7745c5c3_Err != nil {
12411255 return templ_7745c5c3_Err
12421256 }
12431257 for _, row := range h.Split {
1244- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "<tr>")
1258+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "<tr>")
12451259 if templ_7745c5c3_Err != nil {
12461260 return templ_7745c5c3_Err
12471261 }
@@ -1253,12 +1267,12 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
12531267 if templ_7745c5c3_Err != nil {
12541268 return templ_7745c5c3_Err
12551269 }
1256- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "</tr>")
1270+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "</tr>")
12571271 if templ_7745c5c3_Err != nil {
12581272 return templ_7745c5c3_Err
12591273 }
12601274 if notes, form := row.Notes(), row.Form(); len(notes) > 0 || form != nil {
1261- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "<tr class=\"fnote\"><td colspan=\"6\">")
1275+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "<tr class=\"fnote\"><td colspan=\"6\">")
12621276 if templ_7745c5c3_Err != nil {
12631277 return templ_7745c5c3_Err
12641278 }
@@ -1266,28 +1280,28 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
12661280 if templ_7745c5c3_Err != nil {
12671281 return templ_7745c5c3_Err
12681282 }
1269- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "</td></tr>")
1283+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "</td></tr>")
12701284 if templ_7745c5c3_Err != nil {
12711285 return templ_7745c5c3_Err
12721286 }
12731287 }
12741288 }
1275- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "</tbody>")
1289+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "</tbody>")
12761290 if templ_7745c5c3_Err != nil {
12771291 return templ_7745c5c3_Err
12781292 }
12791293 }
1280- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "</table>")
1294+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "</table>")
12811295 if templ_7745c5c3_Err != nil {
12821296 return templ_7745c5c3_Err
12831297 }
12841298 }
1285- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "</div>")
1299+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "</div>")
12861300 if templ_7745c5c3_Err != nil {
12871301 return templ_7745c5c3_Err
12881302 }
12891303 } else {
1290- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "<p class=\"note pad\">")
1304+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "<p class=\"note pad\">")
12911305 if templ_7745c5c3_Err != nil {
12921306 return templ_7745c5c3_Err
12931307 }
@@ -1295,24 +1309,24 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component {
12951309 var templ_7745c5c3_Var64 string
12961310 templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note)
12971311 if templ_7745c5c3_Err != nil {
1298- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 226, Col: 13}
1312+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 230, Col: 13}
12991313 }
13001314 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64))
13011315 if templ_7745c5c3_Err != nil {
13021316 return templ_7745c5c3_Err
13031317 }
13041318 } else {
1305- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "no textual change.")
1319+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "no textual change.")
13061320 if templ_7745c5c3_Err != nil {
13071321 return templ_7745c5c3_Err
13081322 }
13091323 }
1310- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "</p>")
1324+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "</p>")
13111325 if templ_7745c5c3_Err != nil {
13121326 return templ_7745c5c3_Err
13131327 }
13141328 }
1315- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "</details>")
1329+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "</details>")
13161330 if templ_7745c5c3_Err != nil {
13171331 return templ_7745c5c3_Err
13181332 }
@@ -1344,7 +1358,7 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
13441358 }
13451359 ctx = templ.ClearChildren(ctx)
13461360 if ln == nil {
1347- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "<td class=\"ln none\"></td><td class=\"m none\"></td><td class=\"tx none\"></td>")
1361+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "<td class=\"ln none\"></td><td class=\"m none\"></td><td class=\"tx none\"></td>")
13481362 if templ_7745c5c3_Err != nil {
13491363 return templ_7745c5c3_Err
13501364 }
@@ -1354,7 +1368,7 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
13541368 if templ_7745c5c3_Err != nil {
13551369 return templ_7745c5c3_Err
13561370 }
1357- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "<td class=\"")
1371+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "<td class=\"")
13581372 if templ_7745c5c3_Err != nil {
13591373 return templ_7745c5c3_Err
13601374 }
@@ -1367,7 +1381,7 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
13671381 if templ_7745c5c3_Err != nil {
13681382 return templ_7745c5c3_Err
13691383 }
1370- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "\">")
1384+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "\">")
13711385 if templ_7745c5c3_Err != nil {
13721386 return templ_7745c5c3_Err
13731387 }
@@ -1382,7 +1396,7 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
13821396 return templ_7745c5c3_Err
13831397 }
13841398 }
1385- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "</td>")
1399+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "</td>")
13861400 if templ_7745c5c3_Err != nil {
13871401 return templ_7745c5c3_Err
13881402 }
@@ -1391,7 +1405,7 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
13911405 if templ_7745c5c3_Err != nil {
13921406 return templ_7745c5c3_Err
13931407 }
1394- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "<td class=\"")
1408+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "<td class=\"")
13951409 if templ_7745c5c3_Err != nil {
13961410 return templ_7745c5c3_Err
13971411 }
@@ -1404,20 +1418,20 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
14041418 if templ_7745c5c3_Err != nil {
14051419 return templ_7745c5c3_Err
14061420 }
1407- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "\">")
1421+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "\">")
14081422 if templ_7745c5c3_Err != nil {
14091423 return templ_7745c5c3_Err
14101424 }
14111425 var templ_7745c5c3_Var70 string
14121426 templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark)
14131427 if templ_7745c5c3_Err != nil {
1414- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 250, Col: 38}
1428+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 254, Col: 38}
14151429 }
14161430 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70))
14171431 if templ_7745c5c3_Err != nil {
14181432 return templ_7745c5c3_Err
14191433 }
1420- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "</td>")
1434+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "</td>")
14211435 if templ_7745c5c3_Err != nil {
14221436 return templ_7745c5c3_Err
14231437 }
@@ -1426,7 +1440,7 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
14261440 if templ_7745c5c3_Err != nil {
14271441 return templ_7745c5c3_Err
14281442 }
1429- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<td class=\"")
1443+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "<td class=\"")
14301444 if templ_7745c5c3_Err != nil {
14311445 return templ_7745c5c3_Err
14321446 }
@@ -1439,7 +1453,7 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
14391453 if templ_7745c5c3_Err != nil {
14401454 return templ_7745c5c3_Err
14411455 }
1442- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "\">")
1456+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "\">")
14431457 if templ_7745c5c3_Err != nil {
14441458 return templ_7745c5c3_Err
14451459 }
@@ -1447,7 +1461,7 @@ func splitSide(ln *DiffLine, old bool) templ.Component {
14471461 if templ_7745c5c3_Err != nil {
14481462 return templ_7745c5c3_Err
14491463 }
1450- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "</td>")
1464+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "</td>")
14511465 if templ_7745c5c3_Err != nil {
14521466 return templ_7745c5c3_Err
14531467 }
@@ -1480,33 +1494,33 @@ func gutter(n, href string) templ.Component {
14801494 }
14811495 ctx = templ.ClearChildren(ctx)
14821496 if href != "" {
1483- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "<a href=\"")
1497+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "<a href=\"")
14841498 if templ_7745c5c3_Err != nil {
14851499 return templ_7745c5c3_Err
14861500 }
14871501 var templ_7745c5c3_Var74 templ.SafeURL
14881502 templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(href))
14891503 if templ_7745c5c3_Err != nil {
1490- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 259, Col: 31}
1504+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 263, Col: 31}
14911505 }
14921506 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var74))
14931507 if templ_7745c5c3_Err != nil {
14941508 return templ_7745c5c3_Err
14951509 }
1496- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "\" title=\"comment on this line\">")
1510+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "\" title=\"comment on this line\">")
14971511 if templ_7745c5c3_Err != nil {
14981512 return templ_7745c5c3_Err
14991513 }
15001514 var templ_7745c5c3_Var75 string
15011515 templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.JoinStringErrs(n)
15021516 if templ_7745c5c3_Err != nil {
1503- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 259, Col: 66}
1517+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 263, Col: 66}
15041518 }
15051519 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75))
15061520 if templ_7745c5c3_Err != nil {
15071521 return templ_7745c5c3_Err
15081522 }
1509- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "</a>")
1523+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "</a>")
15101524 if templ_7745c5c3_Err != nil {
15111525 return templ_7745c5c3_Err
15121526 }
@@ -1514,7 +1528,7 @@ func gutter(n, href string) templ.Component {
15141528 var templ_7745c5c3_Var76 string
15151529 templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinStringErrs(n)
15161530 if templ_7745c5c3_Err != nil {
1517- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 261, Col: 5}
1531+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 265, Col: 5}
15181532 }
15191533 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))
15201534 if templ_7745c5c3_Err != nil {
M internal/adapters/web/events.go +9 -1
@@ -31,6 +31,9 @@ import (
3131 // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha>
3232 // GET /-/events?topic=repo:<name>&view=mrs
3333 // GET /-/events?topic=repo:<name>&view=mr&n=<number>&ref=<source branch>
34+// GET /-/events?topic=repo:<name>&view=actions
35+// GET /-/events?topic=repo:<name>&view=run&n=<run id>
36+// GET /-/events?topic=repo:<name>&view=job&job=<job id>
3437 //
3538 // topic is the Watch topic. view says which fragments of a repository the
3639 // page shows, so that the server renders only those (summary when absent).
@@ -50,6 +53,11 @@ import (
5053 // commits-head data: <div id="commits-head">N new commits — reload</div>
5154 // mr-notice data: <div id="mr-notice">…</div>, on the merge request pages:
5255 // something changed, with the link that reloads the page
56+// ci-run data: <tr id="run-<id>">…</tr> (the list of runs) or
57+// <div id="run-body">…</div> (one run)
58+// ci-job data: <h2 id="job-head">…</h2> and <ol id="job-steps">…</ol>; the log
59+// itself is never sent in the stream, the page reads the lines after
60+// the last it holds from /raw?from=
5361 // gap data: gap; events were lost, or what the page shows is gone or
5462 // changed: the page fetches itself again (ermiteLive in ermite.js)
5563 //
@@ -194,7 +202,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
194202 return
195203 }
196204
197- st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n")}
205+ st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n"), job: q.Get("job")}
198206 handle := func(m recvd) bool {
199207 if m.err != nil {
200208 if ctx.Err() == nil && !errors.Is(m.err, context.Canceled) {
M internal/adapters/web/fragments.go +109 -2
@@ -7,6 +7,7 @@ import (
77 "context"
88 "errors"
99 "strconv"
10+ "strings"
1011 "sync"
1112 "time"
1213
@@ -23,7 +24,8 @@ type liveStream struct {
2324 view string // "summary", "refs" or "commits"
2425 ref string // commits: the branch listed
2526 head string // commits: the commit listed first
26- n string // mr: the number of the merge request shown
27+ n string // mr: the number of the merge request shown; run: the run id
28+ job string // job: the id of the job shown
2729 }
2830
2931 // renderFragment renders a component to the data of an event.
@@ -46,7 +48,8 @@ func liveEvent(we ports.WatchEvent) bool {
4648 }
4749 switch we.Event.(type) {
4850 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
49- domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.CommentAdded:
51+ domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.CommentAdded,
52+ domain.RunUpdated, domain.JobUpdated:
5053 return true
5154 }
5255 return false
@@ -117,6 +120,10 @@ func (l *liveStream) gone(ctx context.Context, name string) ([]fragment, error)
117120 // repoPage handles the events of the topic of one repository.
118121 func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
119122 switch ev := we.Event.(type) {
123+ case domain.RunUpdated:
124+ return l.runEvent(ctx, ev)
125+ case domain.JobUpdated:
126+ return l.jobEvent(ctx, ev)
120127 case domain.RepoDeleted, domain.RepoCreated:
121128 // Gone, or a new repository under the same name: whatever the page
122129 // shows is not true any more.
@@ -395,3 +402,103 @@ func (l *liveStream) notice(ctx context.Context, text, href string) ([]fragment,
395402 f, err := renderFragment(ctx, "mr-notice", MRNotice(text, href))
396403 return []fragment{f}, err
397404 }
405+
406+// runEvent updates the pages of CI that a RunUpdated changes: the row of the
407+// list of runs, or the body of the run page shown. Only a state is sent: the
408+// events carry no log and no text of the workflow.
409+func (l *liveStream) runEvent(ctx context.Context, ev domain.RunUpdated) ([]fragment, error) {
410+ if l.s.cfg.Runs == nil {
411+ return nil, nil
412+ }
413+ switch l.view {
414+ case "actions":
415+ f, err := renderFragment(ctx, "ci-run", RunListRow(l.s.runRow(l.repo, ev.Run, l.s.cfg.Now())))
416+ if err != nil {
417+ return nil, err
418+ }
419+ if id, ok := rowIsNew(ev.Run); ok {
420+ f.html = strings.Replace(f.html, "<tr id=", "<tr data-new id=", 1)
421+ _ = id
422+ }
423+ return []fragment{f}, nil
424+ case "run":
425+ if strconv.FormatInt(ev.Run.ID, 10) != l.n {
426+ return nil, nil
427+ }
428+ return l.runBody(ctx)
429+ }
430+ return nil, nil
431+}
432+
433+// rowIsNew is true for a run created a moment ago: the list adds its row.
434+func rowIsNew(run domain.CIRun) (int64, bool) { return run.ID, run.Status == domain.CIQueued }
435+
436+// jobEvent updates the body of the run page and the steps of the job page.
437+func (l *liveStream) jobEvent(ctx context.Context, ev domain.JobUpdated) ([]fragment, error) {
438+ if l.s.cfg.Runs == nil {
439+ return nil, nil
440+ }
441+ switch l.view {
442+ case "run":
443+ if strconv.FormatInt(ev.Job.RunID, 10) != l.n {
444+ return nil, nil
445+ }
446+ return l.runBody(ctx)
447+ case "job":
448+ if ev.Job.ID != l.job {
449+ return nil, nil
450+ }
451+ return l.jobState(ctx)
452+ }
453+ return nil, nil
454+}
455+
456+// runBody renders again the jobs of the run page.
457+func (l *liveStream) runBody(ctx context.Context) ([]fragment, error) {
458+ id, err := strconv.ParseInt(l.n, 10, 64)
459+ if err != nil {
460+ return nil, nil
461+ }
462+ d, err := l.s.loadRepo(ctx, l.repo)
463+ if err != nil {
464+ return nil, err
465+ }
466+ p, err := l.s.runPage(ctx, d, id, 0)
467+ if err != nil {
468+ return nil, err
469+ }
470+ body, err := renderFragment(ctx, "ci-run", runBodyFragment(p))
471+ if err != nil {
472+ return nil, err
473+ }
474+ // The buttons depend on the state: the page fetches itself again when the
475+ // run completed, so that the owner gets the right ones.
476+ if !p.Active {
477+ return []fragment{body, {event: "gap", html: "gap"}}, nil
478+ }
479+ return []fragment{body}, nil
480+}
481+
482+// jobState tells the job page that its state or its steps changed: the page
483+// reloads the part of the log after the last line it holds (ermite.js).
484+func (l *liveStream) jobState(ctx context.Context) ([]fragment, error) {
485+ d, err := l.s.loadRepo(ctx, l.repo)
486+ if err != nil {
487+ return nil, err
488+ }
489+ view, err := l.s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, l.job, 0)
490+ if err != nil {
491+ return nil, err
492+ }
493+ now := l.s.cfg.Now()
494+ row := l.s.jobRow(d.name(), view, now)
495+ f, err := renderFragment(ctx, "ci-job", JobHead(row, view.Lines))
496+ if err != nil {
497+ return nil, err
498+ }
499+ steps, err := renderFragment(ctx, "ci-job", JobSteps(stepViews(view, now)))
500+ if err != nil {
501+ return nil, err
502+ }
503+ return []fragment{f, steps}, nil
504+}
M internal/adapters/web/fragments.templ +1 -1
@@ -101,7 +101,7 @@ templ Live(l Layout) {
101101 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
102102 htmx:sse:error -> ermiteLive.error(event.detail.status);
103103 htmx:sse:before:connection -> ermiteLive.gate(event);
104- home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
104+ home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data);
105105 gap -> ermiteLive.reload()"
106106 ></span>
107107 }
M internal/adapters/web/fragments_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/mr.go +8 -0
@@ -205,6 +205,8 @@ type MRPage struct {
205205 CommitsNote string
206206 Modes []string
207207 ChecksBlock bool
208+ // Checks are the CI checks of the head of the request.
209+ Checks ChecksSection
208210
209211 Box *MergeBox
210212 // Live is true when the page announces changes.
@@ -456,6 +458,12 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
456458 }
457459 }
458460 p.ChecksBlock = d.repo.Settings.FailingChecksBlock
461+ if open && head != "" {
462+ p.Checks = checksSection(d.repo.Repo, s.checksFor(ctx, d.repo.Name, head), now)
463+ } else if head != "" {
464+ p.Checks = checksSection(d.repo.Repo, s.checksFor(ctx, d.repo.Name, head), now)
465+ p.Checks.Note = ""
466+ }
459467 p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)
460468
461469 // The commits of the request, newest first.
M internal/adapters/web/mr.templ +1 -5
@@ -302,11 +302,7 @@ templ mrRail(p MRPage) {
302302 if p.Box != nil {
303303 @mergeBox(p)
304304 }
305- <section aria-labelledby="r-checks">
306- <h2 id="r-checks">Checks <span class="count">0</span></h2>
307- <p class="dim">no checks</p>
308- <p class="mute">there is no CI yet: nothing blocks the merge.</p>
309- </section>
305+ @MRChecks(p.Checks)
310306 <section aria-labelledby="r-settings">
311307 <h2 id="r-settings">Settings</h2>
312308 <dl class="kv">
M internal/adapters/web/mr_templ.go +56 -52 generated file: collapsed
M internal/adapters/web/pages.templ +1 -0
@@ -128,6 +128,7 @@ templ refRow(kind string, r RefRow) {
128128 } else {
129129 { r.Short }
130130 }
131+ @CommitChecks(r.Checks)
131132 </td>
132133 <td class="c-sub">{ r.Subject }</td>
133134 <td class="c-when dim">
M internal/adapters/web/pages_templ.go +17 -13 generated file: collapsed
M internal/adapters/web/refspage.go +4 -3
@@ -43,14 +43,14 @@ func (s *Server) refsPage(ctx context.Context, d *repoData) RefsPage {
4343 return a && !b
4444 })
4545 for i, b := range branches {
46- p.Branches = append(p.Branches, s.refRow(d, b, commits, b.ShortName == def, i == 0))
46+ p.Branches = append(p.Branches, s.refRow(ctx, d, b, commits, b.ShortName == def, i == 0))
4747 }
4848 tags := append([]domain.Ref(nil), d.tags...)
4949 sort.SliceStable(tags, func(i, j int) bool {
5050 return commits[tags[i].CommitID.String()].at > commits[tags[j].CommitID.String()].at
5151 })
5252 for i, t := range tags {
53- p.Tags = append(p.Tags, s.refRow(d, t, commits, false, len(p.Branches) == 0 && i == 0))
53+ p.Tags = append(p.Tags, s.refRow(ctx, d, t, commits, false, len(p.Branches) == 0 && i == 0))
5454 }
5555 if len(tags) > 0 {
5656 p.Latest = tags[0].ShortName
@@ -61,7 +61,7 @@ func (s *Server) refsPage(ctx context.Context, d *repoData) RefsPage {
6161 return p
6262 }
6363
64-func (s *Server) refRow(d *repoData, r domain.Ref, commits map[string]refCommit, isDefault, cursor bool) RefRow {
64+func (s *Server) refRow(ctx context.Context, d *repoData, r domain.Ref, commits map[string]refCommit, isDefault, cursor bool) RefRow {
6565 id := r.CommitID.String()
6666 c := commits[id]
6767 row := RefRow{
@@ -71,6 +71,7 @@ func (s *Server) refRow(d *repoData, r domain.Ref, commits map[string]refCommit,
7171 }
7272 if id != "" {
7373 row.CHref = repoHref(d.name(), "commit", id)
74+ row.Checks = checksSummary(d.repo.Name, id, s.checksFor(ctx, d.repo.Name, id))
7475 }
7576 return row
7677 }
M internal/adapters/web/repo.go +9 -0
@@ -135,6 +135,7 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
135135 if s.cfg.MergeRequests != nil {
136136 l.Sections = append(l.Sections, mk("m", "Merge requests", mrBase(d.name()), "merge-requests"))
137137 }
138+ l.Sections = s.withActions(l.Sections, d.name(), section)
138139 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}
139140
140141 l.Cmd = append(l.Cmd, CmdItem{Kind: "go", Label: "home", Href: "/"})
@@ -161,6 +162,14 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
161162 return l
162163 }
163164
165+// withActions adds the Actions tab when the server runs CI.
166+func (s *Server) withActions(sections []Section, repo, current string) []Section {
167+ if s.cfg.Runs == nil {
168+ return sections
169+ }
170+ return append(sections, Section{Key: "a", Label: "Actions", Href: actionsBase(repo), Current: current == "actions"})
171+}
172+
164173 // cloneURL is the HTTPS clone URL of a repository.
165174 func (s *Server) cloneURL(name string) string {
166175 return s.cfg.PublicURL + "/" + name + ".git"
A internal/adapters/web/static/css/65-ci.css +53 -0
@@ -0,0 +1,53 @@
1+@layer components {
2+ /* CI: runs, jobs, steps, checks and the log. Hue only where it carries a
3+ verdict, and every mark has its word beside it or in its label. */
4+ .ck {
5+ display: inline-block; width: 14px; height: 14px; margin-right: var(--s1); vertical-align: -2px;
6+ border: 2px solid var(--ink-dim); border-radius: 50%; box-sizing: border-box; flex: none;
7+ }
8+ .ck-ok { background: var(--teal); border-color: var(--teal); }
9+ .ck-bad { background: var(--red); border-color: var(--red); border-radius: 3px; transform: rotate(45deg) scale(.85); }
10+ .ck-cancelled { border-style: dashed; }
11+ .ck-skipped { border-color: var(--border-strong); background: var(--bg-raised); }
12+ .ck-queued { border-style: dotted; }
13+ .ck-running { border-color: var(--teal); border-top-color: transparent; animation: ck-spin 1s linear infinite; }
14+ @keyframes ck-spin { to { transform: rotate(360deg); } }
15+ @media (prefers-reduced-motion: reduce) { .ck-running { animation: none; border-top-color: var(--teal); } }
16+
17+ .ck-sum { display: inline-flex; align-items: center; gap: var(--s1); margin-left: var(--s2); font-size: var(--fs-xs); white-space: nowrap; }
18+ .c-ck { width: 1%; white-space: nowrap; }
19+
20+ .runs .c-st { width: 1%; }
21+ .runs .c-dur, .runs .c-who { white-space: nowrap; }
22+ .runs tr[data-new] { background: var(--bg-raised); }
23+
24+ .run-h { display: flex; align-items: center; gap: var(--s2); margin: 0 0 var(--s4); }
25+ .run-err, .job-err {
26+ max-width: var(--measure); padding: var(--s3) var(--s4); margin: 0 0 var(--s4); white-space: pre-wrap; overflow-wrap: anywhere;
27+ background: var(--bg-surface); border: 1px solid var(--border); border-left: 3px solid var(--red); border-radius: var(--r-s);
28+ font: inherit; font-size: var(--fs-s);
29+ }
30+
31+ .jobs { display: grid; gap: var(--s4); padding: 0; list-style: none; }
32+ .jobs > li { padding: var(--s3) var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); }
33+ .job-h { display: flex; align-items: center; gap: var(--s2); font-weight: 700; }
34+ .steps { display: grid; gap: 2px; padding: 0; margin: var(--s2) 0 0; list-style: none; font-size: var(--fs-s); }
35+ .steps li { display: flex; align-items: center; gap: var(--s1); }
36+ .attempts { padding: 0; margin: var(--s2) 0 0; list-style: none; font-size: var(--fs-s); }
37+ .checks { display: grid; gap: var(--s1); padding: 0; margin: 0; list-style: none; font-size: var(--fs-s); }
38+ .checks li { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s1); }
39+
40+ .joblog { margin-top: var(--s4); }
41+ .log {
42+ margin: 0; padding: var(--s2) 0; list-style: none; overflow-x: auto;
43+ font: var(--fs-s) / 1.5 var(--mono); background: var(--bg-inset); border: 1px solid var(--border); border-radius: var(--r-s);
44+ }
45+ .log .lg { display: flex; gap: var(--s3); padding: 0 var(--s3); white-space: pre-wrap; overflow-wrap: anywhere; }
46+ .log .lg:target { background: var(--bg-raised); }
47+ .log .lg time { flex: none; color: var(--ink-dim); user-select: none; }
48+ .log .lg .t { min-width: 0; }
49+ .log .group .t { font-weight: 700; }
50+ .log .endgroup { display: none; }
51+ .log .error .t { color: var(--red); }
52+ .joblog .more { margin: var(--s2) 0; font-size: var(--fs-s); }
53+}
M internal/adapters/web/static/js/ermite.js +23 -0
@@ -363,3 +363,26 @@ window.ermiteLive = (() => {
363363 },
364364 };
365365 })();
366+
367+// ermiteLog follows the log of a running job on its page: the lines arrive as
368+// "ci-log" events (internal/adapters/web/actions.go, logStream) and are
369+// appended to the list. The browser reconnects by itself with Last-Event-ID,
370+// the number of the next line; "ci-end" closes the feed when the job is over.
371+// The page scrolls with the log only while the reader is at its end.
372+(() => {
373+ const box = document.getElementById('job-log');
374+ if (!box || !box.hasAttribute('data-follow') || !box.dataset.stream || !window.EventSource) return;
375+ const list = box.querySelector('ol.log');
376+ if (!list) return;
377+ const url = box.dataset.stream + '?from=' + encodeURIComponent(box.dataset.next || '0');
378+ const es = new EventSource(url);
379+ es.addEventListener('ci-log', (e) => {
380+ const t = document.createElement('template');
381+ t.innerHTML = e.data;
382+ const atEnd = window.innerHeight + window.scrollY >= document.body.scrollHeight - 80;
383+ list.append(...t.content.children);
384+ if (atEnd) window.scrollTo(0, document.body.scrollHeight);
385+ });
386+ es.addEventListener('ci-end', () => es.close());
387+ es.onerror = () => { if (es.readyState === EventSource.CLOSED) es.close(); };
388+})();
M internal/adapters/web/view.go +5 -0
@@ -135,6 +135,9 @@ type CommitRow struct {
135135 Branches []string
136136 Tags []string
137137 Cursor bool
138+ // Checks is the combined state of the CI checks of the commit; empty when
139+ // it has none.
140+ Checks ChecksSummary
138141 }
139142
140143 // SummaryPage is the repository summary.
@@ -162,6 +165,8 @@ type LatestTag struct {
162165 type RefRow struct {
163166 Name, Href, Short, CHref, Subject, When, WhenAt string
164167 Default, Cursor bool
168+ // Checks is the combined state of the CI checks of the commit.
169+ Checks ChecksSummary
165170 }
166171
167172 // RefsPage lists the branches and tags of a repository.
M internal/adapters/web/web.go +15 -0
@@ -54,6 +54,10 @@ type Config struct {
5454 // there are no such pages, nor section for them. Leave it unset rather
5555 // than assigning a typed nil pointer.
5656 MergeRequests ports.MergeRequestService
57+ // Runs serves the CI pages (/<repo>/actions) and the checks shown next to
58+ // commits and merge requests. Without it there are no such pages. Leave it
59+ // unset rather than assigning a typed nil pointer.
60+ Runs ports.RunService
5761 // Events is the source of the event stream behind /-/events. Without
5862 // it the pages are not live and the route answers 404. Leave it unset
5963 // rather than assigning a typed nil pointer.
@@ -161,6 +165,17 @@ func (s *Server) routes() *http.ServeMux {
161165 mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose))
162166 mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead))
163167 }
168+ if s.cfg.Runs != nil {
169+ mux.HandleFunc("GET /{repo}/actions", s.page(s.actions))
170+ mux.HandleFunc("GET /{repo}/actions/{$}", s.page(s.actions))
171+ mux.HandleFunc("GET /{repo}/actions/runs/{id}", s.page(s.run))
172+ mux.HandleFunc("GET /{repo}/actions/runs/{id}/job/{job}", s.page(s.job))
173+ mux.HandleFunc("GET /{repo}/actions/runs/{id}/job/{job}/raw", s.rawLog)
174+ mux.HandleFunc("GET /{repo}/actions/runs/{id}/job/{job}/stream", s.logStream)
175+ mux.HandleFunc("POST /{repo}/actions/runs/{id}/cancel", s.ciAction(s.runCancel))
176+ mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun", s.ciAction(s.runRerun(false)))
177+ mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun-failed", s.ciAction(s.runRerun(true)))
178+ }
164179 mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound))
165180 return mux
166181 }
A internal/app/ci_web_test.go +174 -0
@@ -0,0 +1,174 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "net/url"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "connectrpc.com/connect"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+ "google.golang.org/protobuf/types/known/fieldmaskpb"
15+
16+ pb "bnema.dev/ermite-api/gen/ermite"
17+ "bnema.dev/ermite/internal/domain"
18+)
19+
20+// AC5, AC6: the pages of CI: the list of runs, a run, a job with its log (page,
21+// raw and stream), the owner's buttons, and the checks on the commit, the
22+// branch list, the commit list and the merge request.
23+func TestWebActionsPages(t *testing.T) {
24+ ci := newCIHarness(t, nil)
25+ c := &cliHarness{harness: ci.harness, url: ci.srv.URL}
26+ b := browser{ci.harness}
27+ runner := ci.registerRunner("alpha", "ubuntu-latest")
28+ _, err := ci.root.repo.UpdateRepo(ci.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
29+ Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}},
30+ }))
31+ require.NoError(t, err)
32+ ci.push("main", map[string]string{"README": "x\n"}, "")
33+ sha := ci.push("feat", map[string]string{".github/workflows/ci.yml": ciWorkflow}, "add ci")
34+ ci.waitFor("the run", func() bool { return len(ci.runs()) == 1 })
35+
36+ // Queued: the list shows it, with a word for its state.
37+ list := b.get("/proj/actions", nil)
38+ require.Equal(t, 200, list.status, list.body)
39+ assert.Contains(t, list.body, "queued")
40+ assert.Contains(t, list.body, `href="/proj/actions/runs/1"`)
41+ assert.Contains(t, list.body, `aria-label="queued"`)
42+ assert.Contains(t, list.body, "/-/events?topic=repo%3Aproj&amp;view=actions", "the page follows the repository stream")
43+ assert.Contains(t, list.body, ">Actions<", "the tab")
44+
45+ // The MR page says "waiting for a runner" only when no runner matches.
46+ mr, err := ci.root.mr.CreateMergeRequest(ci.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{Repo: "proj", Title: "Add CI", SourceBranch: "feat"}))
47+ require.NoError(t, err)
48+ mrPage := b.get("/proj/merge-requests/1", nil)
49+ require.Equal(t, 200, mrPage.status)
50+ assert.Contains(t, mrPage.body, `id="mr-checks"`)
51+ assert.Contains(t, mrPage.body, "build")
52+ assert.Contains(t, mrPage.body, "a failing or unfinished check blocks the merge", "the rule shown is the one that applies")
53+ assert.NotContains(t, mrPage.body, "waiting for a runner", "a runner matches")
54+ _ = mr
55+
56+ job := runner.await()
57+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "##[group]Run echo hello", "hello <b>bold</b>", "##[endgroup]")
58+ next := runner.await()
59+ runner.runJob(next, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "##[error]Process completed with exit code 1.")
60+ ci.waitFor("the run completes", func() bool { return ci.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
61+
62+ run := b.get("/proj/actions/runs/1", nil)
63+ require.Equal(t, 200, run.status, run.body)
64+ assert.Contains(t, run.body, "build")
65+ assert.Contains(t, run.body, "test")
66+ assert.Contains(t, run.body, "failure")
67+ assert.Contains(t, run.body, "on alpha (vm)", "the runner and its isolation are named")
68+ assert.NotContains(t, run.body, `action="/proj/actions/runs/1/rerun"`, "no button for a visitor")
69+
70+ jobPage := b.get("/proj/actions/runs/1/job/"+job.GetJobId(), nil)
71+ require.Equal(t, 200, jobPage.status, jobPage.body)
72+ assert.Contains(t, jobPage.body, "Run echo hello")
73+ assert.Contains(t, jobPage.body, "hello &lt;b&gt;bold&lt;/b&gt;", "log text is escaped, never markup")
74+ assert.NotContains(t, jobPage.body, "<b>bold</b>")
75+ assert.NotContains(t, jobPage.body, "##[group]", "markers are shown as structure")
76+ assert.Contains(t, jobPage.body, `class="lg group"`)
77+ failed := b.get("/proj/actions/runs/1/job/"+next.GetJobId(), nil)
78+ assert.Contains(t, failed.body, `class="lg error"`)
79+
80+ raw := b.get("/proj/actions/runs/1/job/"+job.GetJobId()+"/raw", nil)
81+ require.Equal(t, 200, raw.status)
82+ assert.Equal(t, "text/plain; charset=utf-8", raw.hdr.Get("Content-Type"))
83+ assert.Equal(t, "nosniff", raw.hdr.Get("X-Content-Type-Options"))
84+ assert.Contains(t, raw.hdr.Get("Content-Security-Policy"), "sandbox")
85+ assert.Contains(t, raw.body, "hello <b>bold</b>", "raw is raw text")
86+ assert.Regexp(t, `(?m)^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z ##\[group\]Run echo hello$`, raw.body)
87+
88+ // The stream of a finished job sends its lines then ends.
89+ stream := b.get("/proj/actions/runs/1/job/"+job.GetJobId()+"/stream?from=1", nil)
90+ require.Equal(t, 200, stream.status)
91+ assert.Equal(t, "text/event-stream", stream.hdr.Get("Content-Type"))
92+ assert.Contains(t, stream.body, "event: ci-log")
93+ assert.Contains(t, stream.body, "event: ci-end")
94+ assert.NotContains(t, stream.body, "Run echo hello", "from=1 skips the first line")
95+
96+ // Checks next to the commit, the commits list and the branch list.
97+ commit := b.get("/proj/commit/"+sha, nil)
98+ require.Equal(t, 200, commit.status)
99+ assert.Contains(t, commit.body, "checks failed")
100+ assert.Contains(t, b.get("/proj/commits/feat", nil).body, "checks failed")
101+ assert.Contains(t, b.get("/proj/refs", nil).body, "checks failed")
102+
103+ // Private repository: nothing to anonymous.
104+ _, err = ci.root.repo.UpdateRepo(ci.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
105+ Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PRIVATE, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}},
106+ }))
107+ require.NoError(t, err)
108+ for _, p := range []string{"/proj/actions", "/proj/actions/runs/1", "/proj/actions/runs/1/job/" + job.GetJobId(), "/proj/actions/runs/1/job/" + job.GetJobId() + "/raw", "/proj/actions/runs/1/job/" + job.GetJobId() + "/stream"} {
109+ assert.Equal(t, 404, b.get(p, nil).status, p)
110+ }
111+
112+ // The owner sees the buttons and re-runs the failed job.
113+ token := c.signIn(t)
114+ owner := b.get("/proj/actions/runs/1", as(token))
115+ require.Equal(t, 200, owner.status)
116+ assert.Contains(t, owner.body, `action="/proj/actions/runs/1/rerun"`)
117+ assert.Contains(t, owner.body, `action="/proj/actions/runs/1/rerun-failed"`)
118+ assert.NotContains(t, owner.body, `action="/proj/actions/runs/1/cancel"`)
119+ // A POST needs the owner's session and the origin.
120+ assert.Equal(t, 403, b.do("POST", "/proj/actions/runs/1/rerun-failed", map[string]string{"Origin": publicOrigin}, "").status, "anonymous")
121+ res := b.form("/proj/actions/runs/1/rerun-failed", token, url.Values{})
122+ require.Equal(t, 303, res.status, res.body)
123+ ci.waitFor("attempt 2 is queued", func() bool { return ci.runs()[0].GetAttempt() == 2 })
124+ again := b.get("/proj/actions/runs/1?attempt=1", as(token))
125+ assert.Contains(t, again.body, "attempt 1")
126+ assert.NotContains(t, again.body, `action="/proj/actions/runs/1/rerun"`, "an earlier attempt has no button")
127+
128+ cancel := b.form("/proj/actions/runs/1/cancel", token, url.Values{})
129+ require.Equal(t, 303, cancel.status, cancel.body)
130+ ci.waitFor("cancelled", func() bool {
131+ return ci.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED && ci.runs()[0].GetConclusion() == pb.CiConclusion_CI_CONCLUSION_CANCELLED
132+ })
133+ assert.Equal(t, 409, b.form("/proj/actions/runs/1/cancel", token, url.Values{}).status, "a completed run cannot be cancelled")
134+}
135+
136+// The live updates of the pages: the run page gets the body of the run when a
137+// job changes, and the job page its state, never a line of the log.
138+func TestWebActionsLiveFragments(t *testing.T) {
139+ ci := newCIHarness(t, nil)
140+ c := &cliHarness{harness: ci.harness, url: ci.srv.URL}
141+ runner := ci.registerRunner("alpha", "ubuntu-latest")
142+ _, err := ci.root.repo.UpdateRepo(ci.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
143+ Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}},
144+ }))
145+ require.NoError(t, err)
146+ ci.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "")
147+ ci.waitFor("the run", func() bool { return len(ci.runs()) == 1 })
148+ token := c.signIn(t)
149+
150+ run := ci.sse("topic=repo:proj&view=run&n=1", "")
151+ list := ci.sse("topic=repo:proj&view=actions", "")
152+ job := runner.await()
153+ jobStream := ci.sse("topic=repo:proj&view=job&job="+job.GetJobId(), "")
154+ _ = token
155+
156+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "a secret value in the log")
157+ deadline := time.After(10 * time.Second)
158+ var sawRun, sawList, sawJob bool
159+ for !sawRun || !sawList || !sawJob {
160+ select {
161+ case ev := <-run.events:
162+ sawRun = sawRun || (ev.name == "ci-run" && strings.Contains(ev.data, `id="run-body"`))
163+ assert.NotContains(t, ev.data, "a secret value in the log")
164+ case ev := <-list.events:
165+ sawList = sawList || (ev.name == "ci-run" && strings.Contains(ev.data, `id="run-1"`))
166+ case ev := <-jobStream.events:
167+ sawJob = sawJob || (ev.name == "ci-job" && strings.Contains(ev.data, `id="job-head"`))
168+ assert.NotContains(t, ev.data, "a secret value in the log", "the stream of states never carries the log")
169+ case <-deadline:
170+ t.Fatalf("fragments: run %v, list %v, job %v", sawRun, sawList, sawJob)
171+ }
172+ }
173+ _ = domain.CISuccess
174+}
M internal/app/testdata/web/blob-binary.html +10 -0
@@ -83,6 +83,10 @@
8383 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8484 </a>
8585 </li>
86+<li>
87+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
88+</a>
89+</li>
8690 </ul>
8791 </nav>
8892 </header>
@@ -308,6 +312,12 @@
308312 <span class="k">section</span>
309313 </a>
310314 </li>
315+<li data-k="section actions">
316+<a href="/files/actions" tabindex="-1">
317+<span class="lbl">actions</span>
318+<span class="k">section</span>
319+</a>
320+</li>
311321 <li data-k="branch feature/x">
312322 <a href="/files/tree/feature/x" tabindex="-1">
313323 <span class="lbl">feature/x</span>
M internal/app/testdata/web/blob-code.html +10 -0
@@ -83,6 +83,10 @@
8383 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8484 </a>
8585 </li>
86+<li>
87+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
88+</a>
89+</li>
8690 </ul>
8791 </nav>
8892 </header>
@@ -378,6 +382,12 @@
378382 <span class="k">section</span>
379383 </a>
380384 </li>
385+<li data-k="section actions">
386+<a href="/files/actions" tabindex="-1">
387+<span class="lbl">actions</span>
388+<span class="k">section</span>
389+</a>
390+</li>
381391 <li data-k="branch feature/x">
382392 <a href="/files/tree/feature/x" tabindex="-1">
383393 <span class="lbl">feature/x</span>
M internal/app/testdata/web/blob-empty.html +10 -0
@@ -83,6 +83,10 @@
8383 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8484 </a>
8585 </li>
86+<li>
87+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
88+</a>
89+</li>
8690 </ul>
8791 </nav>
8892 </header>
@@ -308,6 +312,12 @@
308312 <span class="k">section</span>
309313 </a>
310314 </li>
315+<li data-k="section actions">
316+<a href="/files/actions" tabindex="-1">
317+<span class="lbl">actions</span>
318+<span class="k">section</span>
319+</a>
320+</li>
311321 <li data-k="branch feature/x">
312322 <a href="/files/tree/feature/x" tabindex="-1">
313323 <span class="lbl">feature/x</span>
M internal/app/testdata/web/blob-image.html +10 -0
@@ -83,6 +83,10 @@
8383 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8484 </a>
8585 </li>
86+<li>
87+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
88+</a>
89+</li>
8690 </ul>
8791 </nav>
8892 </header>
@@ -310,6 +314,12 @@
310314 <span class="k">section</span>
311315 </a>
312316 </li>
317+<li data-k="section actions">
318+<a href="/files/actions" tabindex="-1">
319+<span class="lbl">actions</span>
320+<span class="k">section</span>
321+</a>
322+</li>
313323 <li data-k="branch feature/x">
314324 <a href="/files/tree/feature/x" tabindex="-1">
315325 <span class="lbl">feature/x</span>
M internal/app/testdata/web/blob-markdown.html +10 -0
@@ -86,6 +86,10 @@
8686 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8787 </a>
8888 </li>
89+<li>
90+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
91+</a>
92+</li>
8993 </ul>
9094 </nav>
9195 </header>
@@ -375,6 +379,12 @@
375379 <span class="k">section</span>
376380 </a>
377381 </li>
382+<li data-k="section actions">
383+<a href="/files/actions" tabindex="-1">
384+<span class="lbl">actions</span>
385+<span class="k">section</span>
386+</a>
387+</li>
378388 <li data-k="branch feature/x">
379389 <a href="/files/tree/feature/x" tabindex="-1">
380390 <span class="lbl">feature/x</span>
M internal/app/testdata/web/blob-svg.html +10 -0
@@ -83,6 +83,10 @@
8383 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8484 </a>
8585 </li>
86+<li>
87+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
88+</a>
89+</li>
8690 </ul>
8791 </nav>
8892 </header>
@@ -333,6 +337,12 @@
333337 <span class="k">section</span>
334338 </a>
335339 </li>
340+<li data-k="section actions">
341+<a href="/files/actions" tabindex="-1">
342+<span class="lbl">actions</span>
343+<span class="k">section</span>
344+</a>
345+</li>
336346 <li data-k="branch feature/x">
337347 <a href="/files/tree/feature/x" tabindex="-1">
338348 <span class="lbl">feature/x</span>
M internal/app/testdata/web/blob-text.html +10 -0
@@ -83,6 +83,10 @@
8383 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8484 </a>
8585 </li>
86+<li>
87+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
88+</a>
89+</li>
8690 </ul>
8791 </nav>
8892 </header>
@@ -328,6 +332,12 @@
328332 <span class="k">section</span>
329333 </a>
330334 </li>
335+<li data-k="section actions">
336+<a href="/files/actions" tabindex="-1">
337+<span class="lbl">actions</span>
338+<span class="k">section</span>
339+</a>
340+</li>
331341 <li data-k="branch feature/x">
332342 <a href="/files/tree/feature/x" tabindex="-1">
333343 <span class="lbl">feature/x</span>
M internal/app/testdata/web/blob-too-large.html +10 -0
@@ -83,6 +83,10 @@
8383 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8484 </a>
8585 </li>
86+<li>
87+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
88+</a>
89+</li>
8690 </ul>
8791 </nav>
8892 </header>
@@ -308,6 +312,12 @@
308312 <span class="k">section</span>
309313 </a>
310314 </li>
315+<li data-k="section actions">
316+<a href="/files/actions" tabindex="-1">
317+<span class="lbl">actions</span>
318+<span class="k">section</span>
319+</a>
320+</li>
311321 <li data-k="branch feature/x">
312322 <a href="/files/tree/feature/x" tabindex="-1">
313323 <span class="lbl">feature/x</span>
M internal/app/testdata/web/blob-trojan.html +10 -0
@@ -83,6 +83,10 @@
8383 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8484 </a>
8585 </li>
86+<li>
87+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
88+</a>
89+</li>
8690 </ul>
8791 </nav>
8892 </header>
@@ -375,6 +379,12 @@
375379 <span class="k">section</span>
376380 </a>
377381 </li>
382+<li data-k="section actions">
383+<a href="/files/actions" tabindex="-1">
384+<span class="lbl">actions</span>
385+<span class="k">section</span>
386+</a>
387+</li>
378388 <li data-k="branch feature/x">
379389 <a href="/files/tree/feature/x" tabindex="-1">
380390 <span class="lbl">feature/x</span>
M internal/app/testdata/web/commit-big.html +10 -0
@@ -80,6 +80,10 @@
8080 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8181 </a>
8282 </li>
83+<li>
84+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
85+</a>
86+</li>
8387 </ul>
8488 </nav>
8589 </header>
@@ -345,6 +349,12 @@
345349 <span class="k">section</span>
346350 </a>
347351 </li>
352+<li data-k="section actions">
353+<a href="/diffs/actions" tabindex="-1">
354+<span class="lbl">actions</span>
355+<span class="k">section</span>
356+</a>
357+</li>
348358 <li data-k="branch main">
349359 <a href="/diffs/tree/main" tabindex="-1">
350360 <span class="lbl">main</span>
M internal/app/testdata/web/commit-binary.html +10 -0
@@ -80,6 +80,10 @@
8080 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8181 </a>
8282 </li>
83+<li>
84+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
85+</a>
86+</li>
8387 </ul>
8488 </nav>
8589 </header>
@@ -340,6 +344,12 @@
340344 <span class="k">section</span>
341345 </a>
342346 </li>
347+<li data-k="section actions">
348+<a href="/diffs/actions" tabindex="-1">
349+<span class="lbl">actions</span>
350+<span class="k">section</span>
351+</a>
352+</li>
343353 <li data-k="branch main">
344354 <a href="/diffs/tree/main" tabindex="-1">
345355 <span class="lbl">main</span>
M internal/app/testdata/web/commit-merge.html +10 -0
@@ -80,6 +80,10 @@
8080 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8181 </a>
8282 </li>
83+<li>
84+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
85+</a>
86+</li>
8387 </ul>
8488 </nav>
8589 </header>
@@ -379,6 +383,12 @@
379383 <span class="k">section</span>
380384 </a>
381385 </li>
386+<li data-k="section actions">
387+<a href="/diffs/actions" tabindex="-1">
388+<span class="lbl">actions</span>
389+<span class="k">section</span>
390+</a>
391+</li>
382392 <li data-k="branch main">
383393 <a href="/diffs/tree/main" tabindex="-1">
384394 <span class="lbl">main</span>
M internal/app/testdata/web/commit-modify-split.html +10 -0
@@ -80,6 +80,10 @@
8080 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8181 </a>
8282 </li>
83+<li>
84+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
85+</a>
86+</li>
8387 </ul>
8488 </nav>
8589 </header>
@@ -500,6 +504,12 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
500504 <span class="k">section</span>
501505 </a>
502506 </li>
507+<li data-k="section actions">
508+<a href="/diffs/actions" tabindex="-1">
509+<span class="lbl">actions</span>
510+<span class="k">section</span>
511+</a>
512+</li>
503513 <li data-k="branch main">
504514 <a href="/diffs/tree/main" tabindex="-1">
505515 <span class="lbl">main</span>
M internal/app/testdata/web/commit-modify.html +10 -0
@@ -80,6 +80,10 @@
8080 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8181 </a>
8282 </li>
83+<li>
84+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
85+</a>
86+</li>
8387 </ul>
8488 </nav>
8589 </header>
@@ -500,6 +504,12 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
500504 <span class="k">section</span>
501505 </a>
502506 </li>
507+<li data-k="section actions">
508+<a href="/diffs/actions" tabindex="-1">
509+<span class="lbl">actions</span>
510+<span class="k">section</span>
511+</a>
512+</li>
503513 <li data-k="branch main">
504514 <a href="/diffs/tree/main" tabindex="-1">
505515 <span class="lbl">main</span>
M internal/app/testdata/web/commit-rename.html +10 -0
@@ -80,6 +80,10 @@
8080 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8181 </a>
8282 </li>
83+<li>
84+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
85+</a>
86+</li>
8387 </ul>
8488 </nav>
8589 </header>
@@ -405,6 +409,12 @@
405409 <span class="k">section</span>
406410 </a>
407411 </li>
412+<li data-k="section actions">
413+<a href="/diffs/actions" tabindex="-1">
414+<span class="lbl">actions</span>
415+<span class="k">section</span>
416+</a>
417+</li>
408418 <li data-k="branch main">
409419 <a href="/diffs/tree/main" tabindex="-1">
410420 <span class="lbl">main</span>
M internal/app/testdata/web/commit-root.html +10 -0
@@ -80,6 +80,10 @@
8080 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8181 </a>
8282 </li>
83+<li>
84+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
85+</a>
86+</li>
8387 </ul>
8488 </nav>
8589 </header>
@@ -721,6 +725,12 @@
721725 <span class="k">section</span>
722726 </a>
723727 </li>
728+<li data-k="section actions">
729+<a href="/diffs/actions" tabindex="-1">
730+<span class="lbl">actions</span>
731+<span class="k">section</span>
732+</a>
733+</li>
724734 <li data-k="branch main">
725735 <a href="/diffs/tree/main" tabindex="-1">
726736 <span class="lbl">main</span>
M internal/app/testdata/web/commit-trojan.html +10 -0
@@ -80,6 +80,10 @@
8080 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8181 </a>
8282 </li>
83+<li>
84+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
85+</a>
86+</li>
8387 </ul>
8488 </nav>
8589 </header>
@@ -514,6 +518,12 @@
514518 <span class="k">section</span>
515519 </a>
516520 </li>
521+<li data-k="section actions">
522+<a href="/diffs/actions" tabindex="-1">
523+<span class="lbl">actions</span>
524+<span class="k">section</span>
525+</a>
526+</li>
517527 <li data-k="branch main">
518528 <a href="/diffs/tree/main" tabindex="-1">
519529 <span class="lbl">main</span>
M internal/app/testdata/web/commits-page2.html +14 -0
@@ -80,6 +80,10 @@
8080 <a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8181 </a>
8282 </li>
83+<li>
84+<a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
85+</a>
86+</li>
8387 </ul>
8488 </nav>
8589 </header>
@@ -103,6 +107,8 @@
103107 <td class="c-subject">
104108 <a href="/long/commit/SHA40">commit number 1</a>
105109 </td>
110+<td class="c-ck">
111+</td>
106112 <td class="c-who dim">T</td>
107113 <td class="c-when nowrap dim">
108114 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -115,6 +121,8 @@
115121 <td class="c-subject">
116122 <a href="/long/commit/SHA40">commit number 0</a>
117123 </td>
124+<td class="c-ck">
125+</td>
118126 <td class="c-who dim">T</td>
119127 <td class="c-when nowrap dim">
120128 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -327,6 +335,12 @@
327335 <span class="k">section</span>
328336 </a>
329337 </li>
338+<li data-k="section actions">
339+<a href="/long/actions" tabindex="-1">
340+<span class="lbl">actions</span>
341+<span class="k">section</span>
342+</a>
343+</li>
330344 <li data-k="branch main">
331345 <a href="/long/tree/main" tabindex="-1">
332346 <span class="lbl">main</span>
M internal/app/testdata/web/commits-path.html +14 -0
@@ -83,6 +83,10 @@
8383 <a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8484 </a>
8585 </li>
86+<li>
87+<a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
88+</a>
89+</li>
8690 </ul>
8791 </nav>
8892 </header>
@@ -107,6 +111,8 @@
107111 <td class="c-subject">
108112 <a href="/long/commit/SHA40">commit number 30</a>
109113 </td>
114+<td class="c-ck">
115+</td>
110116 <td class="c-who dim">T</td>
111117 <td class="c-when nowrap dim">
112118 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -128,6 +134,8 @@
128134 <td class="c-subject">
129135 <a href="/long/commit/SHA40">commit number 3</a>
130136 </td>
137+<td class="c-ck">
138+</td>
131139 <td class="c-who dim">T</td>
132140 <td class="c-when nowrap dim">
133141 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -342,6 +350,12 @@
342350 <span class="k">section</span>
343351 </a>
344352 </li>
353+<li data-k="section actions">
354+<a href="/long/actions" tabindex="-1">
355+<span class="lbl">actions</span>
356+<span class="k">section</span>
357+</a>
358+</li>
345359 <li data-k="branch main">
346360 <a href="/long/tree/main" tabindex="-1">
347361 <span class="lbl">main</span>
M internal/app/testdata/web/commits-root.html +111 -1
@@ -80,6 +80,10 @@
8080 <a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8181 </a>
8282 </li>
83+<li>
84+<a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
85+</a>
86+</li>
8387 </ul>
8488 </nav>
8589 </header>
@@ -106,6 +110,8 @@
106110 <a href="/long/commit/SHA40">commit number 51</a>
107111 <span class="pill">main</span>
108112 </td>
113+<td class="c-ck">
114+</td>
109115 <td class="c-who dim">T</td>
110116 <td class="c-when nowrap dim">
111117 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -118,6 +124,8 @@
118124 <td class="c-subject">
119125 <a href="/long/commit/SHA40">commit number 50</a>
120126 </td>
127+<td class="c-ck">
128+</td>
121129 <td class="c-who dim">T</td>
122130 <td class="c-when nowrap dim">
123131 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -139,6 +147,8 @@
139147 <td class="c-subject">
140148 <a href="/long/commit/SHA40">commit number 49</a>
141149 </td>
150+<td class="c-ck">
151+</td>
142152 <td class="c-who dim">T</td>
143153 <td class="c-when nowrap dim">
144154 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -151,6 +161,8 @@
151161 <td class="c-subject">
152162 <a href="/long/commit/SHA40">commit number 48</a>
153163 </td>
164+<td class="c-ck">
165+</td>
154166 <td class="c-who dim">T</td>
155167 <td class="c-when nowrap dim">
156168 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -172,6 +184,8 @@
172184 <td class="c-subject">
173185 <a href="/long/commit/SHA40">commit number 47</a>
174186 </td>
187+<td class="c-ck">
188+</td>
175189 <td class="c-who dim">T</td>
176190 <td class="c-when nowrap dim">
177191 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -184,6 +198,8 @@
184198 <td class="c-subject">
185199 <a href="/long/commit/SHA40">commit number 46</a>
186200 </td>
201+<td class="c-ck">
202+</td>
187203 <td class="c-who dim">T</td>
188204 <td class="c-when nowrap dim">
189205 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -205,6 +221,8 @@
205221 <td class="c-subject">
206222 <a href="/long/commit/SHA40">commit number 45</a>
207223 </td>
224+<td class="c-ck">
225+</td>
208226 <td class="c-who dim">T</td>
209227 <td class="c-when nowrap dim">
210228 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -217,6 +235,8 @@
217235 <td class="c-subject">
218236 <a href="/long/commit/SHA40">commit number 44</a>
219237 </td>
238+<td class="c-ck">
239+</td>
220240 <td class="c-who dim">T</td>
221241 <td class="c-when nowrap dim">
222242 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -238,6 +258,8 @@
238258 <td class="c-subject">
239259 <a href="/long/commit/SHA40">commit number 43</a>
240260 </td>
261+<td class="c-ck">
262+</td>
241263 <td class="c-who dim">T</td>
242264 <td class="c-when nowrap dim">
243265 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -250,6 +272,8 @@
250272 <td class="c-subject">
251273 <a href="/long/commit/SHA40">commit number 42</a>
252274 </td>
275+<td class="c-ck">
276+</td>
253277 <td class="c-who dim">T</td>
254278 <td class="c-when nowrap dim">
255279 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -271,6 +295,8 @@
271295 <td class="c-subject">
272296 <a href="/long/commit/SHA40">commit number 41</a>
273297 </td>
298+<td class="c-ck">
299+</td>
274300 <td class="c-who dim">T</td>
275301 <td class="c-when nowrap dim">
276302 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -283,6 +309,8 @@
283309 <td class="c-subject">
284310 <a href="/long/commit/SHA40">commit number 40</a>
285311 </td>
312+<td class="c-ck">
313+</td>
286314 <td class="c-who dim">T</td>
287315 <td class="c-when nowrap dim">
288316 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -304,6 +332,8 @@
304332 <td class="c-subject">
305333 <a href="/long/commit/SHA40">commit number 39</a>
306334 </td>
335+<td class="c-ck">
336+</td>
307337 <td class="c-who dim">T</td>
308338 <td class="c-when nowrap dim">
309339 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -316,6 +346,8 @@
316346 <td class="c-subject">
317347 <a href="/long/commit/SHA40">commit number 38</a>
318348 </td>
349+<td class="c-ck">
350+</td>
319351 <td class="c-who dim">T</td>
320352 <td class="c-when nowrap dim">
321353 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -337,6 +369,8 @@
337369 <td class="c-subject">
338370 <a href="/long/commit/SHA40">commit number 37</a>
339371 </td>
372+<td class="c-ck">
373+</td>
340374 <td class="c-who dim">T</td>
341375 <td class="c-when nowrap dim">
342376 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -349,6 +383,8 @@
349383 <td class="c-subject">
350384 <a href="/long/commit/SHA40">commit number 36</a>
351385 </td>
386+<td class="c-ck">
387+</td>
352388 <td class="c-who dim">T</td>
353389 <td class="c-when nowrap dim">
354390 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -370,6 +406,8 @@
370406 <td class="c-subject">
371407 <a href="/long/commit/SHA40">commit number 35</a>
372408 </td>
409+<td class="c-ck">
410+</td>
373411 <td class="c-who dim">T</td>
374412 <td class="c-when nowrap dim">
375413 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -382,6 +420,8 @@
382420 <td class="c-subject">
383421 <a href="/long/commit/SHA40">commit number 34</a>
384422 </td>
423+<td class="c-ck">
424+</td>
385425 <td class="c-who dim">T</td>
386426 <td class="c-when nowrap dim">
387427 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -403,6 +443,8 @@
403443 <td class="c-subject">
404444 <a href="/long/commit/SHA40">commit number 33</a>
405445 </td>
446+<td class="c-ck">
447+</td>
406448 <td class="c-who dim">T</td>
407449 <td class="c-when nowrap dim">
408450 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -415,6 +457,8 @@
415457 <td class="c-subject">
416458 <a href="/long/commit/SHA40">commit number 32</a>
417459 </td>
460+<td class="c-ck">
461+</td>
418462 <td class="c-who dim">T</td>
419463 <td class="c-when nowrap dim">
420464 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -436,6 +480,8 @@
436480 <td class="c-subject">
437481 <a href="/long/commit/SHA40">commit number 31</a>
438482 </td>
483+<td class="c-ck">
484+</td>
439485 <td class="c-who dim">T</td>
440486 <td class="c-when nowrap dim">
441487 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -448,6 +494,8 @@
448494 <td class="c-subject">
449495 <a href="/long/commit/SHA40">commit number 30</a>
450496 </td>
497+<td class="c-ck">
498+</td>
451499 <td class="c-who dim">T</td>
452500 <td class="c-when nowrap dim">
453501 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -469,6 +517,8 @@
469517 <td class="c-subject">
470518 <a href="/long/commit/SHA40">commit number 29</a>
471519 </td>
520+<td class="c-ck">
521+</td>
472522 <td class="c-who dim">T</td>
473523 <td class="c-when nowrap dim">
474524 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -481,6 +531,8 @@
481531 <td class="c-subject">
482532 <a href="/long/commit/SHA40">commit number 28</a>
483533 </td>
534+<td class="c-ck">
535+</td>
484536 <td class="c-who dim">T</td>
485537 <td class="c-when nowrap dim">
486538 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -502,6 +554,8 @@
502554 <td class="c-subject">
503555 <a href="/long/commit/SHA40">commit number 27</a>
504556 </td>
557+<td class="c-ck">
558+</td>
505559 <td class="c-who dim">T</td>
506560 <td class="c-when nowrap dim">
507561 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -514,6 +568,8 @@
514568 <td class="c-subject">
515569 <a href="/long/commit/SHA40">commit number 26</a>
516570 </td>
571+<td class="c-ck">
572+</td>
517573 <td class="c-who dim">T</td>
518574 <td class="c-when nowrap dim">
519575 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -535,6 +591,8 @@
535591 <td class="c-subject">
536592 <a href="/long/commit/SHA40">commit number 25</a>
537593 </td>
594+<td class="c-ck">
595+</td>
538596 <td class="c-who dim">T</td>
539597 <td class="c-when nowrap dim">
540598 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -547,6 +605,8 @@
547605 <td class="c-subject">
548606 <a href="/long/commit/SHA40">commit number 24</a>
549607 </td>
608+<td class="c-ck">
609+</td>
550610 <td class="c-who dim">T</td>
551611 <td class="c-when nowrap dim">
552612 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -568,6 +628,8 @@
568628 <td class="c-subject">
569629 <a href="/long/commit/SHA40">commit number 23</a>
570630 </td>
631+<td class="c-ck">
632+</td>
571633 <td class="c-who dim">T</td>
572634 <td class="c-when nowrap dim">
573635 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -580,6 +642,8 @@
580642 <td class="c-subject">
581643 <a href="/long/commit/SHA40">commit number 22</a>
582644 </td>
645+<td class="c-ck">
646+</td>
583647 <td class="c-who dim">T</td>
584648 <td class="c-when nowrap dim">
585649 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -601,6 +665,8 @@
601665 <td class="c-subject">
602666 <a href="/long/commit/SHA40">commit number 21</a>
603667 </td>
668+<td class="c-ck">
669+</td>
604670 <td class="c-who dim">T</td>
605671 <td class="c-when nowrap dim">
606672 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -613,6 +679,8 @@
613679 <td class="c-subject">
614680 <a href="/long/commit/SHA40">commit number 20</a>
615681 </td>
682+<td class="c-ck">
683+</td>
616684 <td class="c-who dim">T</td>
617685 <td class="c-when nowrap dim">
618686 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -634,6 +702,8 @@
634702 <td class="c-subject">
635703 <a href="/long/commit/SHA40">commit number 19</a>
636704 </td>
705+<td class="c-ck">
706+</td>
637707 <td class="c-who dim">T</td>
638708 <td class="c-when nowrap dim">
639709 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -646,6 +716,8 @@
646716 <td class="c-subject">
647717 <a href="/long/commit/SHA40">commit number 18</a>
648718 </td>
719+<td class="c-ck">
720+</td>
649721 <td class="c-who dim">T</td>
650722 <td class="c-when nowrap dim">
651723 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -667,6 +739,8 @@
667739 <td class="c-subject">
668740 <a href="/long/commit/SHA40">commit number 17</a>
669741 </td>
742+<td class="c-ck">
743+</td>
670744 <td class="c-who dim">T</td>
671745 <td class="c-when nowrap dim">
672746 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -679,6 +753,8 @@
679753 <td class="c-subject">
680754 <a href="/long/commit/SHA40">commit number 16</a>
681755 </td>
756+<td class="c-ck">
757+</td>
682758 <td class="c-who dim">T</td>
683759 <td class="c-when nowrap dim">
684760 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -700,6 +776,8 @@
700776 <td class="c-subject">
701777 <a href="/long/commit/SHA40">commit number 15</a>
702778 </td>
779+<td class="c-ck">
780+</td>
703781 <td class="c-who dim">T</td>
704782 <td class="c-when nowrap dim">
705783 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -712,6 +790,8 @@
712790 <td class="c-subject">
713791 <a href="/long/commit/SHA40">commit number 14</a>
714792 </td>
793+<td class="c-ck">
794+</td>
715795 <td class="c-who dim">T</td>
716796 <td class="c-when nowrap dim">
717797 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -733,6 +813,8 @@
733813 <td class="c-subject">
734814 <a href="/long/commit/SHA40">commit number 13</a>
735815 </td>
816+<td class="c-ck">
817+</td>
736818 <td class="c-who dim">T</td>
737819 <td class="c-when nowrap dim">
738820 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -745,6 +827,8 @@
745827 <td class="c-subject">
746828 <a href="/long/commit/SHA40">commit number 12</a>
747829 </td>
830+<td class="c-ck">
831+</td>
748832 <td class="c-who dim">T</td>
749833 <td class="c-when nowrap dim">
750834 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -766,6 +850,8 @@
766850 <td class="c-subject">
767851 <a href="/long/commit/SHA40">commit number 11</a>
768852 </td>
853+<td class="c-ck">
854+</td>
769855 <td class="c-who dim">T</td>
770856 <td class="c-when nowrap dim">
771857 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -778,6 +864,8 @@
778864 <td class="c-subject">
779865 <a href="/long/commit/SHA40">commit number 10</a>
780866 </td>
867+<td class="c-ck">
868+</td>
781869 <td class="c-who dim">T</td>
782870 <td class="c-when nowrap dim">
783871 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -799,6 +887,8 @@
799887 <td class="c-subject">
800888 <a href="/long/commit/SHA40">commit number 9</a>
801889 </td>
890+<td class="c-ck">
891+</td>
802892 <td class="c-who dim">T</td>
803893 <td class="c-when nowrap dim">
804894 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -811,6 +901,8 @@
811901 <td class="c-subject">
812902 <a href="/long/commit/SHA40">commit number 8</a>
813903 </td>
904+<td class="c-ck">
905+</td>
814906 <td class="c-who dim">T</td>
815907 <td class="c-when nowrap dim">
816908 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -832,6 +924,8 @@
832924 <td class="c-subject">
833925 <a href="/long/commit/SHA40">commit number 7</a>
834926 </td>
927+<td class="c-ck">
928+</td>
835929 <td class="c-who dim">T</td>
836930 <td class="c-when nowrap dim">
837931 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -844,6 +938,8 @@
844938 <td class="c-subject">
845939 <a href="/long/commit/SHA40">commit number 6</a>
846940 </td>
941+<td class="c-ck">
942+</td>
847943 <td class="c-who dim">T</td>
848944 <td class="c-when nowrap dim">
849945 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -865,6 +961,8 @@
865961 <td class="c-subject">
866962 <a href="/long/commit/SHA40">commit number 5</a>
867963 </td>
964+<td class="c-ck">
965+</td>
868966 <td class="c-who dim">T</td>
869967 <td class="c-when nowrap dim">
870968 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -877,6 +975,8 @@
877975 <td class="c-subject">
878976 <a href="/long/commit/SHA40">commit number 4</a>
879977 </td>
978+<td class="c-ck">
979+</td>
880980 <td class="c-who dim">T</td>
881981 <td class="c-when nowrap dim">
882982 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -898,6 +998,8 @@
898998 <td class="c-subject">
899999 <a href="/long/commit/SHA40">commit number 3</a>
9001000 </td>
1001+<td class="c-ck">
1002+</td>
9011003 <td class="c-who dim">T</td>
9021004 <td class="c-when nowrap dim">
9031005 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -910,6 +1012,8 @@
9101012 <td class="c-subject">
9111013 <a href="/long/commit/SHA40">commit number 2</a>
9121014 </td>
1015+<td class="c-ck">
1016+</td>
9131017 <td class="c-who dim">T</td>
9141018 <td class="c-when nowrap dim">
9151019 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -959,7 +1063,7 @@
9591063 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
9601064 htmx:sse:error -> ermiteLive.error(event.detail.status);
9611065 htmx:sse:before:connection -> ermiteLive.gate(event);
962- home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
1066+ home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data);
9631067 gap -> ermiteLive.reload()">
9641068 </span>
9651069 </div>
@@ -1131,6 +1235,12 @@
11311235 <span class="k">section</span>
11321236 </a>
11331237 </li>
1238+<li data-k="section actions">
1239+<a href="/long/actions" tabindex="-1">
1240+<span class="lbl">actions</span>
1241+<span class="k">section</span>
1242+</a>
1243+</li>
11341244 <li data-k="branch main">
11351245 <a href="/long/tree/main" tabindex="-1">
11361246 <span class="lbl">main</span>
M internal/app/testdata/web/commits-small.html +27 -1
@@ -80,6 +80,10 @@
8080 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8181 </a>
8282 </li>
83+<li>
84+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
85+</a>
86+</li>
8387 </ul>
8488 </nav>
8589 </header>
@@ -106,6 +110,8 @@
106110 <a href="/diffs/commit/SHA40">add a very large file</a>
107111 <span class="pill">main</span>
108112 </td>
113+<td class="c-ck">
114+</td>
109115 <td class="c-who dim">T</td>
110116 <td class="c-when nowrap dim">
111117 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -127,6 +133,8 @@
127133 <td class="c-subject">
128134 <a href="/diffs/commit/SHA40">add evil.go</a>
129135 </td>
136+<td class="c-ck">
137+</td>
130138 <td class="c-who dim">T</td>
131139 <td class="c-when nowrap dim">
132140 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -149,6 +157,8 @@
149157 <a href="/diffs/commit/SHA40">merge the side branch</a>
150158 <span class="pill">merge</span>
151159 </td>
160+<td class="c-ck">
161+</td>
152162 <td class="c-who dim">T</td>
153163 <td class="c-when nowrap dim">
154164 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -171,6 +181,8 @@
171181 <a href="/diffs/commit/SHA40">work on the side</a>
172182 <span class="pill">side</span>
173183 </td>
184+<td class="c-ck">
185+</td>
174186 <td class="c-who dim">T</td>
175187 <td class="c-when nowrap dim">
176188 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -192,6 +204,8 @@
192204 <td class="c-subject">
193205 <a href="/diffs/commit/SHA40">change a binary file</a>
194206 </td>
207+<td class="c-ck">
208+</td>
195209 <td class="c-who dim">T</td>
196210 <td class="c-when nowrap dim">
197211 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -213,6 +227,8 @@
213227 <td class="c-subject">
214228 <a href="/diffs/commit/SHA40">rename a file and delete another</a>
215229 </td>
230+<td class="c-ck">
231+</td>
216232 <td class="c-who dim">T</td>
217233 <td class="c-when nowrap dim">
218234 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -236,6 +252,8 @@
236252 <span class="pill tag">
237253 <span class="sr">tag </span>v1.0.0</span>
238254 </td>
255+<td class="c-ck">
256+</td>
239257 <td class="c-who dim">T</td>
240258 <td class="c-when nowrap dim">
241259 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -257,6 +275,8 @@
257275 <td class="c-subject">
258276 <a href="/diffs/commit/SHA40">root: the first files</a>
259277 </td>
278+<td class="c-ck">
279+</td>
260280 <td class="c-who dim">T</td>
261281 <td class="c-when nowrap dim">
262282 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -304,7 +324,7 @@
304324 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
305325 htmx:sse:error -> ermiteLive.error(event.detail.status);
306326 htmx:sse:before:connection -> ermiteLive.gate(event);
307- home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
327+ home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data);
308328 gap -> ermiteLive.reload()">
309329 </span>
310330 </div>
@@ -484,6 +504,12 @@
484504 <span class="k">section</span>
485505 </a>
486506 </li>
507+<li data-k="section actions">
508+<a href="/diffs/actions" tabindex="-1">
509+<span class="lbl">actions</span>
510+<span class="k">section</span>
511+</a>
512+</li>
487513 <li data-k="branch main">
488514 <a href="/diffs/tree/main" tabindex="-1">
489515 <span class="lbl">main</span>
M internal/app/testdata/web/home.html +1 -1
@@ -113,7 +113,7 @@
113113 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
114114 htmx:sse:error -> ermiteLive.error(event.detail.status);
115115 htmx:sse:before:connection -> ermiteLive.gate(event);
116- home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
116+ home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data);
117117 gap -> ermiteLive.reload()">
118118 </span>
119119 </div>
M internal/app/testdata/web/refs.html +11 -1
@@ -77,6 +77,10 @@
7777 <a href="/demo/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
7878 </a>
7979 </li>
80+<li>
81+<a href="/demo/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
82+</a>
83+</li>
8084 </ul>
8185 </nav>
8286 </header>
@@ -196,7 +200,7 @@
196200 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
197201 htmx:sse:error -> ermiteLive.error(event.detail.status);
198202 htmx:sse:before:connection -> ermiteLive.gate(event);
199- home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
203+ home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data);
200204 gap -> ermiteLive.reload()">
201205 </span>
202206 </div>
@@ -380,6 +384,12 @@
380384 <span class="k">section</span>
381385 </a>
382386 </li>
387+<li data-k="section actions">
388+<a href="/demo/actions" tabindex="-1">
389+<span class="lbl">actions</span>
390+<span class="k">section</span>
391+</a>
392+</li>
383393 <li data-k="branch feature/x">
384394 <a href="/demo/tree/feature/x" tabindex="-1">
385395 <span class="lbl">feature/x</span>
M internal/app/testdata/web/summary.html +15 -1
@@ -74,6 +74,10 @@
7474 <a href="/demo/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
7575 </a>
7676 </li>
77+<li>
78+<a href="/demo/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
79+</a>
80+</li>
7781 </ul>
7882 </nav>
7983 </header>
@@ -166,6 +170,8 @@
166170 <span class="pill tag">
167171 <span class="sr">tag </span>v0.2.0</span>
168172 </td>
173+<td class="c-ck">
174+</td>
169175 <td class="c-who dim">T</td>
170176 <td class="c-when nowrap dim">
171177 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -180,6 +186,8 @@
180186 <span class="pill tag">
181187 <span class="sr">tag </span>v0.1.0</span>
182188 </td>
189+<td class="c-ck">
190+</td>
183191 <td class="c-who dim">T</td>
184192 <td class="c-when nowrap dim">
185193 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -215,7 +223,7 @@
215223 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
216224 htmx:sse:error -> ermiteLive.error(event.detail.status);
217225 htmx:sse:before:connection -> ermiteLive.gate(event);
218- home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
226+ home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data);
219227 gap -> ermiteLive.reload()">
220228 </span>
221229 </div>
@@ -399,6 +407,12 @@
399407 <span class="k">section</span>
400408 </a>
401409 </li>
410+<li data-k="section actions">
411+<a href="/demo/actions" tabindex="-1">
412+<span class="lbl">actions</span>
413+<span class="k">section</span>
414+</a>
415+</li>
402416 <li data-k="branch feature/x">
403417 <a href="/demo/tree/feature/x" tabindex="-1">
404418 <span class="lbl">feature/x</span>
M internal/app/testdata/web/tree-nested.html +10 -0
@@ -86,6 +86,10 @@
8686 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8787 </a>
8888 </li>
89+<li>
90+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
91+</a>
92+</li>
8993 </ul>
9094 </nav>
9195 </header>
@@ -328,6 +332,12 @@
328332 <span class="k">section</span>
329333 </a>
330334 </li>
335+<li data-k="section actions">
336+<a href="/files/actions" tabindex="-1">
337+<span class="lbl">actions</span>
338+<span class="k">section</span>
339+</a>
340+</li>
331341 <li data-k="branch feature/x">
332342 <a href="/files/tree/feature/x" tabindex="-1">
333343 <span class="lbl">feature/x</span>
M internal/app/testdata/web/tree-root.html +10 -0
@@ -80,6 +80,10 @@
8080 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8181 </a>
8282 </li>
83+<li>
84+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
85+</a>
86+</li>
8387 </ul>
8488 </nav>
8589 </header>
@@ -555,6 +559,12 @@
555559 <span class="k">section</span>
556560 </a>
557561 </li>
562+<li data-k="section actions">
563+<a href="/files/actions" tabindex="-1">
564+<span class="lbl">actions</span>
565+<span class="k">section</span>
566+</a>
567+</li>
558568 <li data-k="branch feature/x">
559569 <a href="/files/tree/feature/x" tabindex="-1">
560570 <span class="lbl">feature/x</span>
M internal/app/testdata/web/tree-slash-ref.html +10 -0
@@ -83,6 +83,10 @@
8383 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8484 </a>
8585 </li>
86+<li>
87+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
88+</a>
89+</li>
8690 </ul>
8791 </nav>
8892 </header>
@@ -325,6 +329,12 @@
325329 <span class="k">section</span>
326330 </a>
327331 </li>
332+<li data-k="section actions">
333+<a href="/files/actions" tabindex="-1">
334+<span class="lbl">actions</span>
335+<span class="k">section</span>
336+</a>
337+</li>
328338 <li data-k="branch feature/x">
329339 <a href="/files/tree/feature/x" tabindex="-1">
330340 <span class="lbl">feature/x</span>
M internal/app/testdata/web/tree-subdir.html +10 -0
@@ -83,6 +83,10 @@
8383 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8484 </a>
8585 </li>
86+<li>
87+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
88+</a>
89+</li>
8690 </ul>
8791 </nav>
8892 </header>
@@ -350,6 +354,12 @@
350354 <span class="k">section</span>
351355 </a>
352356 </li>
357+<li data-k="section actions">
358+<a href="/files/actions" tabindex="-1">
359+<span class="lbl">actions</span>
360+<span class="k">section</span>
361+</a>
362+</li>
353363 <li data-k="branch feature/x">
354364 <a href="/files/tree/feature/x" tabindex="-1">
355365 <span class="lbl">feature/x</span>
M internal/app/web.go +1 -0
@@ -33,6 +33,7 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han
3333 Repos: f.Repos,
3434 Browse: f.Browse,
3535 MergeRequests: f.MergeRequests,
36+ Runs: f.CI,
3637 Events: f.EventLog,
3738 PublicURL: cfg.PublicURL,
3839 SourceURL: cfg.SourceURL,

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close