detached 0947903

feat(web): recent actions on the summary and visible status marks (#25)

commit
09479031802551efcc45e4ac5c1782372a8df4f3
author
brice · · 2026-10-14 15:58 UTC
committer
bnema · · 2026-10-14 15:58 UTC
parent
dd8597a
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
13 files · +1125 -656
M internal/adapters/kvstore/ci.go +21 -8
@@ -660,19 +660,32 @@ func (c *CI) ListRuns(ctx context.Context, repo domain.RepoName, before int64, l
660660 if before > 0 {
661661 end = runKey(repo, before)
662662 }
663- // Collect then reverse: the snapshot iterates in key order only.
664- var all []domain.CIRun
663+ // The snapshot iterates in key order only: keep the last limit items in a
664+ // ring and decode only those, so the cost of a page does not grow with the
665+ // decoded history of the repository.
666+ type entry struct {
667+ key []byte
668+ item kv.Item
669+ }
670+ var ring []entry
671+ next := 0
665672 for key, item := range c.db.Snapshot(ctx).Range(ctx, start, end) {
666- r, err := decodeRun(item)
673+ if limit <= 0 || len(ring) < limit {
674+ ring = append(ring, entry{key, item})
675+ continue
676+ }
677+ ring[next] = entry{key, item}
678+ next = (next + 1) % limit
679+ }
680+ ordered := append(ring[next:len(ring):len(ring)], ring[:next]...)
681+ all := make([]domain.CIRun, 0, len(ordered))
682+ for i := len(ordered) - 1; i >= 0; i-- {
683+ r, err := decodeRun(ordered[i].item)
667684 if err != nil {
668- return nil, fmt.Errorf("decode %s: %w", key, err)
685+ return nil, fmt.Errorf("decode %s: %w", ordered[i].key, err)
669686 }
670687 all = append(all, r)
671688 }
672- slices.Reverse(all)
673- if limit > 0 && len(all) > limit {
674- all = all[:limit]
675- }
676689 return all, nil
677690 }
678691
@@ -660,19 +660,32 @@ func (c *CI) ListRuns(ctx context.Context, repo domain.RepoName, before int64, l
660 if before > 0 {660 if before > 0 {
661 end = runKey(repo, before)661 end = runKey(repo, before)
662 }662 }
663- // Collect then reverse: the snapshot iterates in key order only.663+ // The snapshot iterates in key order only: keep the last limit items in a
664- var all []domain.CIRun664+ // ring and decode only those, so the cost of a page does not grow with the
665+ // decoded history of the repository.
666+ type entry struct {
667+ key []byte
668+ item kv.Item
669+ }
670+ var ring []entry
671+ next := 0
665 for key, item := range c.db.Snapshot(ctx).Range(ctx, start, end) {672 for key, item := range c.db.Snapshot(ctx).Range(ctx, start, end) {
666- r, err := decodeRun(item)673+ if limit <= 0 || len(ring) < limit {
674+ ring = append(ring, entry{key, item})
675+ continue
676+ }
677+ ring[next] = entry{key, item}
678+ next = (next + 1) % limit
679+ }
680+ ordered := append(ring[next:len(ring):len(ring)], ring[:next]...)
681+ all := make([]domain.CIRun, 0, len(ordered))
682+ for i := len(ordered) - 1; i >= 0; i-- {
683+ r, err := decodeRun(ordered[i].item)
667 if err != nil {684 if err != nil {
668- return nil, fmt.Errorf("decode %s: %w", key, err)685+ return nil, fmt.Errorf("decode %s: %w", ordered[i].key, err)
669 }686 }
670 all = append(all, r)687 all = append(all, r)
671 }688 }
672- slices.Reverse(all)
673- if limit > 0 && len(all) > limit {
674- all = all[:limit]
675- }
676 return all, nil689 return all, nil
677 }690 }
678 691
M internal/adapters/kvstore/ci_test.go +5 -0
@@ -204,6 +204,11 @@ func TestCIListRunsNewestFirstAndBounded(t *testing.T) {
204204 runs, err = ci.ListRuns(ctx, n, 4, 10)
205205 require.NoError(t, err)
206206 assert.Equal(t, []int64{3, 2}, []int64{runs[0].ID, runs[1].ID})
207+ runs, err = ci.ListRuns(ctx, n, 0, 0)
208+ require.NoError(t, err)
209+ require.Len(t, runs, 5)
210+ assert.Equal(t, int64(6), runs[0].ID)
211+ assert.Equal(t, int64(2), runs[4].ID)
207212 ids, err := ci.RunIDs(ctx, n)
208213 require.NoError(t, err)
209214 assert.Len(t, ids, 5)
@@ -204,6 +204,11 @@ func TestCIListRunsNewestFirstAndBounded(t *testing.T) {
204 runs, err = ci.ListRuns(ctx, n, 4, 10)204 runs, err = ci.ListRuns(ctx, n, 4, 10)
205 require.NoError(t, err)205 require.NoError(t, err)
206 assert.Equal(t, []int64{3, 2}, []int64{runs[0].ID, runs[1].ID})206 assert.Equal(t, []int64{3, 2}, []int64{runs[0].ID, runs[1].ID})
207+ runs, err = ci.ListRuns(ctx, n, 0, 0)
208+ require.NoError(t, err)
209+ require.Len(t, runs, 5)
210+ assert.Equal(t, int64(6), runs[0].ID)
211+ assert.Equal(t, int64(2), runs[4].ID)
207 ids, err := ci.RunIDs(ctx, n)212 ids, err := ci.RunIDs(ctx, n)
208 require.NoError(t, err)213 require.NoError(t, err)
209 assert.Len(t, ids, 5)214 assert.Len(t, ids, 5)
M internal/adapters/web/actions.templ +21 -0
@@ -56,6 +56,27 @@ templ RunListRow(r RunRow) {
5656 </tr>
5757 }
5858
59+// RecentActionsBlock is the "Recent actions" section of the summary rail; its
60+// id is the target of the live updates. Without a run it is hidden.
61+templ RecentActionsBlock(a RecentActions) {
62+ <section id="recent-actions" aria-labelledby="r-actions" hidden?={ len(a.Runs) == 0 }>
63+ <h2 id="r-actions">Recent actions <a href={ templ.SafeURL(a.AllHref) }>View all</a></h2>
64+ <ul class="rruns">
65+ for _, r := range a.Runs {
66+ <li>
67+ @CheckIcon(r.Kind, r.State)
68+ <a class="rr-name" href={ templ.SafeURL(r.Href) } title={ r.Workflow + " " + r.Number + " · " + r.State }>{ r.Workflow }</a>
69+ <span class="mute">{ r.Number }</span>
70+ <a class="rr-ref" href={ templ.SafeURL(r.RefHref) }>{ r.Ref }</a>
71+ if r.WhenAt != "" {
72+ <time class="dim" datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time>
73+ }
74+ </li>
75+ }
76+ </ul>
77+ </section>
78+}
79+
5980 templ RunDetail(l Layout, p RunPage) {
6081 @Shell(l, runRail(p)) {
6182 @LiveSlot("run-notice")
@@ -56,6 +56,27 @@ templ RunListRow(r RunRow) {
56 </tr>56 </tr>
57 }57 }
58 58
59+// RecentActionsBlock is the "Recent actions" section of the summary rail; its
60+// id is the target of the live updates. Without a run it is hidden.
61+templ RecentActionsBlock(a RecentActions) {
62+ <section id="recent-actions" aria-labelledby="r-actions" hidden?={ len(a.Runs) == 0 }>
63+ <h2 id="r-actions">Recent actions <a href={ templ.SafeURL(a.AllHref) }>View all</a></h2>
64+ <ul class="rruns">
65+ for _, r := range a.Runs {
66+ <li>
67+ @CheckIcon(r.Kind, r.State)
68+ <a class="rr-name" href={ templ.SafeURL(r.Href) } title={ r.Workflow + " " + r.Number + " · " + r.State }>{ r.Workflow }</a>
69+ <span class="mute">{ r.Number }</span>
70+ <a class="rr-ref" href={ templ.SafeURL(r.RefHref) }>{ r.Ref }</a>
71+ if r.WhenAt != "" {
72+ <time class="dim" datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time>
73+ }
74+ </li>
75+ }
76+ </ul>
77+ </section>
78+}
79+
59 templ RunDetail(l Layout, p RunPage) {80 templ RunDetail(l Layout, p RunPage) {
60 @Shell(l, runRail(p)) {81 @Shell(l, runRail(p)) {
61 @LiveSlot("run-notice")82 @LiveSlot("run-notice")
M internal/adapters/web/actions_templ.go +808 -609 generated file: collapsed
M internal/adapters/web/events.go +2 -1
@@ -53,7 +53,8 @@ import (
5353 // commits-head data: <div id="commits-head">N new commits — reload</div>
5454 // mr-notice data: <div id="mr-notice">…</div>, on the merge request pages:
5555 // something changed, with the link that reloads the page
56-// ci-run data: <tr id="run-<id>">…</tr> (the list of runs) or
56+// ci-run data: <tr id="run-<id>">…</tr> (the list of runs),
57+// <section id="recent-actions">…</section> (the rail of the summary) or
5758 // <div id="run-body">…</div> (one run)
5859 // ci-job data: <h2 id="job-head">…</h2> and <ol id="job-steps">…</ol>; the log
5960 // itself is never sent in this stream: the page follows it on its own
@@ -53,7 +53,8 @@ import (
53 // commits-head data: <div id="commits-head">N new commits — reload</div>53 // commits-head data: <div id="commits-head">N new commits — reload</div>
54 // mr-notice data: <div id="mr-notice">…</div>, on the merge request pages:54 // mr-notice data: <div id="mr-notice">…</div>, on the merge request pages:
55 // something changed, with the link that reloads the page55 // something changed, with the link that reloads the page
56-// ci-run data: <tr id="run-<id>">…</tr> (the list of runs) or56+// ci-run data: <tr id="run-<id>">…</tr> (the list of runs),
57+// <section id="recent-actions">…</section> (the rail of the summary) or
57 // <div id="run-body">…</div> (one run)58 // <div id="run-body">…</div> (one run)
58 // ci-job data: <h2 id="job-head">…</h2> and <ol id="job-steps">…</ol>; the log59 // ci-job data: <h2 id="job-head">…</h2> and <ol id="job-steps">…</ol>; the log
59 // itself is never sent in this stream: the page follows it on its own60 // itself is never sent in this stream: the page follows it on its own
M internal/adapters/web/fragments.go +23 -1
@@ -407,7 +407,8 @@ func (l *liveStream) notice(ctx context.Context, text, href string) ([]fragment,
407407 }
408408
409409 // runEvent updates the pages of CI that a RunUpdated changes: the row of the
410-// list of runs, or the body of the run page shown. Only a state is sent: the
410+// list of runs, the recent actions of the summary, or the body of the run page
411+// shown. Only a state is sent: the
411412 // events carry no log and no text of the workflow.
412413 func (l *liveStream) runEvent(ctx context.Context, ev domain.RunUpdated) ([]fragment, error) {
413414 if l.s.cfg.Runs == nil {
@@ -423,6 +424,8 @@ func (l *liveStream) runEvent(ctx context.Context, ev domain.RunUpdated) ([]frag
423424 f.html = strings.Replace(f.html, "<tr id=", "<tr data-new id=", 1)
424425 }
425426 return []fragment{f}, nil
427+ case "summary":
428+ return l.recentActions(ctx)
426429 case "run":
427430 if strconv.FormatInt(ev.Run.ID, 10) != l.n {
428431 return nil, nil
@@ -432,6 +435,25 @@ func (l *liveStream) runEvent(ctx context.Context, ev domain.RunUpdated) ([]frag
432435 return nil, nil
433436 }
434437
438+// recentActions is the "Recent actions" block of the summary rail, read again
439+// as the principal of the stream: the newest runs change with every event, and
440+// a viewer who cannot read CI any more gets the block hidden and empty.
441+func (l *liveStream) recentActions(ctx context.Context) ([]fragment, error) {
442+ name, err := domain.ParseRepoName(l.repo)
443+ if err != nil {
444+ return nil, nil
445+ }
446+ a, err := l.s.listRecentActions(ctx, name, l.s.cfg.Now())
447+ switch {
448+ case errors.Is(err, domain.ErrNotFound):
449+ a = RecentActions{AllHref: actionsBase(l.repo)}
450+ case err != nil:
451+ return nil, err
452+ }
453+ f, err := renderFragment(ctx, "ci-run", RecentActionsBlock(a))
454+ return []fragment{f}, err
455+}
456+
435457 // jobEvent updates the body of the run page and the steps of the job page.
436458 func (l *liveStream) jobEvent(ctx context.Context, ev domain.JobUpdated) ([]fragment, error) {
437459 if l.s.cfg.Runs == nil {
@@ -407,7 +407,8 @@ func (l *liveStream) notice(ctx context.Context, text, href string) ([]fragment,
407 }407 }
408 408
409 // runEvent updates the pages of CI that a RunUpdated changes: the row of the409 // runEvent updates the pages of CI that a RunUpdated changes: the row of the
410-// list of runs, or the body of the run page shown. Only a state is sent: the410+// list of runs, the recent actions of the summary, or the body of the run page
411+// shown. Only a state is sent: the
411 // events carry no log and no text of the workflow.412 // events carry no log and no text of the workflow.
412 func (l *liveStream) runEvent(ctx context.Context, ev domain.RunUpdated) ([]fragment, error) {413 func (l *liveStream) runEvent(ctx context.Context, ev domain.RunUpdated) ([]fragment, error) {
413 if l.s.cfg.Runs == nil {414 if l.s.cfg.Runs == nil {
@@ -423,6 +424,8 @@ func (l *liveStream) runEvent(ctx context.Context, ev domain.RunUpdated) ([]frag
423 f.html = strings.Replace(f.html, "<tr id=", "<tr data-new id=", 1)424 f.html = strings.Replace(f.html, "<tr id=", "<tr data-new id=", 1)
424 }425 }
425 return []fragment{f}, nil426 return []fragment{f}, nil
427+ case "summary":
428+ return l.recentActions(ctx)
426 case "run":429 case "run":
427 if strconv.FormatInt(ev.Run.ID, 10) != l.n {430 if strconv.FormatInt(ev.Run.ID, 10) != l.n {
428 return nil, nil431 return nil, nil
@@ -432,6 +435,25 @@ func (l *liveStream) runEvent(ctx context.Context, ev domain.RunUpdated) ([]frag
432 return nil, nil435 return nil, nil
433 }436 }
434 437
438+// recentActions is the "Recent actions" block of the summary rail, read again
439+// as the principal of the stream: the newest runs change with every event, and
440+// a viewer who cannot read CI any more gets the block hidden and empty.
441+func (l *liveStream) recentActions(ctx context.Context) ([]fragment, error) {
442+ name, err := domain.ParseRepoName(l.repo)
443+ if err != nil {
444+ return nil, nil
445+ }
446+ a, err := l.s.listRecentActions(ctx, name, l.s.cfg.Now())
447+ switch {
448+ case errors.Is(err, domain.ErrNotFound):
449+ a = RecentActions{AllHref: actionsBase(l.repo)}
450+ case err != nil:
451+ return nil, err
452+ }
453+ f, err := renderFragment(ctx, "ci-run", RecentActionsBlock(a))
454+ return []fragment{f}, err
455+}
456+
435 // jobEvent updates the body of the run page and the steps of the job page.457 // jobEvent updates the body of the run page and the steps of the job page.
436 func (l *liveStream) jobEvent(ctx context.Context, ev domain.JobUpdated) ([]fragment, error) {458 func (l *liveStream) jobEvent(ctx context.Context, ev domain.JobUpdated) ([]fragment, error) {
437 if l.s.cfg.Runs == nil {459 if l.s.cfg.Runs == nil {
M internal/adapters/web/pages.templ +3 -0
@@ -80,6 +80,9 @@ templ summaryRail(p SummaryPage) {
8080 <section aria-labelledby="r-recent"><h2 id="r-recent">Recent commits <a href={ templ.SafeURL(p.AllHref) }>View all</a></h2>@CommitRows(p.Recent)
8181 </section>
8282 }
83+ if p.Actions != nil {
84+ @RecentActionsBlock(*p.Actions)
85+ }
8386 if len(p.Outline) > 0 && p.Readme != nil {
8487 <section aria-labelledby="r-outline"><h2 id="r-outline">On this page</h2>@Outline(p.Outline)
8588 </section>
@@ -80,6 +80,9 @@ templ summaryRail(p SummaryPage) {
80 <section aria-labelledby="r-recent"><h2 id="r-recent">Recent commits <a href={ templ.SafeURL(p.AllHref) }>View all</a></h2>@CommitRows(p.Recent)80 <section aria-labelledby="r-recent"><h2 id="r-recent">Recent commits <a href={ templ.SafeURL(p.AllHref) }>View all</a></h2>@CommitRows(p.Recent)
81 </section>81 </section>
82 }82 }
83+ if p.Actions != nil {
84+ @RecentActionsBlock(*p.Actions)
85+ }
83 if len(p.Outline) > 0 && p.Readme != nil {86 if len(p.Outline) > 0 && p.Readme != nil {
84 <section aria-labelledby="r-outline"><h2 id="r-outline">On this page</h2>@Outline(p.Outline)87 <section aria-labelledby="r-outline"><h2 id="r-outline">On this page</h2>@Outline(p.Outline)
85 </section>88 </section>
M internal/adapters/web/pages_templ.go +42 -36
@@ -431,6 +431,12 @@ func summaryRail(p SummaryPage) templ.Component {
431431 return templ_7745c5c3_Err
432432 }
433433 }
434+ if p.Actions != nil {
435+ templ_7745c5c3_Err = RecentActionsBlock(*p.Actions).Render(ctx, templ_7745c5c3_Buffer)
436+ if templ_7745c5c3_Err != nil {
437+ return templ_7745c5c3_Err
438+ }
439+ }
434440 if len(p.Outline) > 0 && p.Readme != nil {
435441 templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<section aria-labelledby=\"r-outline\"><h2 id=\"r-outline\">On this page</h2>")
436442 if templ_7745c5c3_Err != nil {
@@ -503,7 +509,7 @@ func Refs(l Layout, p RefsPage) templ.Component {
503509 var templ_7745c5c3_Var24 string
504510 templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
505511 if templ_7745c5c3_Err != nil {
506- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 110, Col: 27}
512+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 113, Col: 27}
507513 }
508514 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
509515 if templ_7745c5c3_Err != nil {
@@ -562,7 +568,7 @@ func refRow(kind string, r RefRow) templ.Component {
562568 var templ_7745c5c3_Var26 string
563569 templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(kind)
564570 if templ_7745c5c3_Err != nil {
565- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 118, Col: 32}
571+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 121, Col: 32}
566572 }
567573 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
568574 if templ_7745c5c3_Err != nil {
@@ -575,7 +581,7 @@ func refRow(kind string, r RefRow) templ.Component {
575581 var templ_7745c5c3_Var27 templ.SafeURL
576582 templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
577583 if templ_7745c5c3_Err != nil {
578- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 120, Col: 34}
584+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 123, Col: 34}
579585 }
580586 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
581587 if templ_7745c5c3_Err != nil {
@@ -588,7 +594,7 @@ func refRow(kind string, r RefRow) templ.Component {
588594 var templ_7745c5c3_Var28 string
589595 templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
590596 if templ_7745c5c3_Err != nil {
591- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 120, Col: 45}
597+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 123, Col: 45}
592598 }
593599 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
594600 if templ_7745c5c3_Err != nil {
@@ -616,7 +622,7 @@ func refRow(kind string, r RefRow) templ.Component {
616622 var templ_7745c5c3_Var29 templ.SafeURL
617623 templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CHref))
618624 if templ_7745c5c3_Err != nil {
619- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 127, Col: 36}
625+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 130, Col: 36}
620626 }
621627 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
622628 if templ_7745c5c3_Err != nil {
@@ -629,7 +635,7 @@ func refRow(kind string, r RefRow) templ.Component {
629635 var templ_7745c5c3_Var30 string
630636 templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)
631637 if templ_7745c5c3_Err != nil {
632- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 127, Col: 48}
638+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 130, Col: 48}
633639 }
634640 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
635641 if templ_7745c5c3_Err != nil {
@@ -643,7 +649,7 @@ func refRow(kind string, r RefRow) templ.Component {
643649 var templ_7745c5c3_Var31 string
644650 templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)
645651 if templ_7745c5c3_Err != nil {
646- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 129, Col: 13}
652+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 132, Col: 13}
647653 }
648654 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
649655 if templ_7745c5c3_Err != nil {
@@ -661,7 +667,7 @@ func refRow(kind string, r RefRow) templ.Component {
661667 var templ_7745c5c3_Var32 string
662668 templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(r.Subject)
663669 if templ_7745c5c3_Err != nil {
664- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 133, Col: 31}
670+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 136, Col: 31}
665671 }
666672 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
667673 if templ_7745c5c3_Err != nil {
@@ -679,7 +685,7 @@ func refRow(kind string, r RefRow) templ.Component {
679685 var templ_7745c5c3_Var33 string
680686 templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
681687 if templ_7745c5c3_Err != nil {
682- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 136, Col: 29}
688+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 139, Col: 29}
683689 }
684690 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
685691 if templ_7745c5c3_Err != nil {
@@ -692,7 +698,7 @@ func refRow(kind string, r RefRow) templ.Component {
692698 var templ_7745c5c3_Var34 string
693699 templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
694700 if templ_7745c5c3_Err != nil {
695- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 136, Col: 48}
701+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 139, Col: 48}
696702 }
697703 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
698704 if templ_7745c5c3_Err != nil {
@@ -705,7 +711,7 @@ func refRow(kind string, r RefRow) templ.Component {
705711 var templ_7745c5c3_Var35 string
706712 templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(r.When)
707713 if templ_7745c5c3_Err != nil {
708- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 136, Col: 59}
714+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 139, Col: 59}
709715 }
710716 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
711717 if templ_7745c5c3_Err != nil {
@@ -802,7 +808,7 @@ func State(l Layout, p StatePage) templ.Component {
802808 var templ_7745c5c3_Var39 string
803809 templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(p.Code)
804810 if templ_7745c5c3_Err != nil {
805- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 153, Col: 30}
811+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 156, Col: 30}
806812 }
807813 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
808814 if templ_7745c5c3_Err != nil {
@@ -815,7 +821,7 @@ func State(l Layout, p StatePage) templ.Component {
815821 var templ_7745c5c3_Var40 string
816822 templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
817823 if templ_7745c5c3_Err != nil {
818- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 154, Col: 33}
824+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 157, Col: 33}
819825 }
820826 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
821827 if templ_7745c5c3_Err != nil {
@@ -833,7 +839,7 @@ func State(l Layout, p StatePage) templ.Component {
833839 var templ_7745c5c3_Var41 string
834840 templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(line)
835841 if templ_7745c5c3_Err != nil {
836- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 156, Col: 25}
842+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 159, Col: 25}
837843 }
838844 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
839845 if templ_7745c5c3_Err != nil {
@@ -862,7 +868,7 @@ func State(l Layout, p StatePage) templ.Component {
862868 var templ_7745c5c3_Var42 templ.SafeURL
863869 templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(b.Href))
864870 if templ_7745c5c3_Err != nil {
865- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 163, Col: 36}
871+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 166, Col: 36}
866872 }
867873 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
868874 if templ_7745c5c3_Err != nil {
@@ -875,7 +881,7 @@ func State(l Layout, p StatePage) templ.Component {
875881 var templ_7745c5c3_Var43 string
876882 templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(b.Label)
877883 if templ_7745c5c3_Err != nil {
878- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 163, Col: 48}
884+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 166, Col: 48}
879885 }
880886 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
881887 if templ_7745c5c3_Err != nil {
@@ -942,7 +948,7 @@ func Login(l Layout, p LoginPage) templ.Component {
942948 var templ_7745c5c3_Var46 string
943949 templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
944950 if templ_7745c5c3_Err != nil {
945- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 175, Col: 33}
951+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 178, Col: 33}
946952 }
947953 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
948954 if templ_7745c5c3_Err != nil {
@@ -960,7 +966,7 @@ func Login(l Layout, p LoginPage) templ.Component {
960966 var templ_7745c5c3_Var47 string
961967 templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(line)
962968 if templ_7745c5c3_Err != nil {
963- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 177, Col: 25}
969+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 180, Col: 25}
964970 }
965971 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
966972 if templ_7745c5c3_Err != nil {
@@ -978,7 +984,7 @@ func Login(l Layout, p LoginPage) templ.Component {
978984 var templ_7745c5c3_Var48 templ.SafeURL
979985 templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Action))
980986 if templ_7745c5c3_Err != nil {
981- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 179, Col: 73}
987+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 182, Col: 73}
982988 }
983989 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
984990 if templ_7745c5c3_Err != nil {
@@ -991,7 +997,7 @@ func Login(l Layout, p LoginPage) templ.Component {
991997 var templ_7745c5c3_Var49 string
992998 templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)
993999 if templ_7745c5c3_Err != nil {
994- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 180, Col: 56}
1000+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 183, Col: 56}
9951001 }
9961002 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
9971003 if templ_7745c5c3_Err != nil {
@@ -1053,7 +1059,7 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
10531059 var templ_7745c5c3_Var52 string
10541060 templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
10551061 if templ_7745c5c3_Err != nil {
1056- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 192, Col: 33}
1062+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 195, Col: 33}
10571063 }
10581064 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
10591065 if templ_7745c5c3_Err != nil {
@@ -1066,7 +1072,7 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
10661072 var templ_7745c5c3_Var53 string
10671073 templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)
10681074 if templ_7745c5c3_Err != nil {
1069- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 194, Col: 87}
1075+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 197, Col: 87}
10701076 }
10711077 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var53)
10721078 if templ_7745c5c3_Err != nil {
@@ -1079,7 +1085,7 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
10791085 var templ_7745c5c3_Var54 string
10801086 templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)
10811087 if templ_7745c5c3_Err != nil {
1082- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 194, Col: 112}
1088+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 197, Col: 112}
10831089 }
10841090 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54)
10851091 if templ_7745c5c3_Err != nil {
@@ -1092,7 +1098,7 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
10921098 var templ_7745c5c3_Var55 string
10931099 templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)
10941100 if templ_7745c5c3_Err != nil {
1095- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 194, Col: 125}
1101+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 197, Col: 125}
10961102 }
10971103 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
10981104 if templ_7745c5c3_Err != nil {
@@ -1110,7 +1116,7 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
11101116 var templ_7745c5c3_Var56 string
11111117 templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(line)
11121118 if templ_7745c5c3_Err != nil {
1113- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 198, Col: 25}
1119+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 201, Col: 25}
11141120 }
11151121 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
11161122 if templ_7745c5c3_Err != nil {
@@ -1176,7 +1182,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
11761182 var templ_7745c5c3_Var59 string
11771183 templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Rows)))
11781184 if templ_7745c5c3_Err != nil {
1179- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 208, Col: 81}
1185+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 211, Col: 81}
11801186 }
11811187 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))
11821188 if templ_7745c5c3_Err != nil {
@@ -1199,7 +1205,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
11991205 var templ_7745c5c3_Var60 string
12001206 templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
12011207 if templ_7745c5c3_Err != nil {
1202- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 226, Col: 34}
1208+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 229, Col: 34}
12031209 }
12041210 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60))
12051211 if templ_7745c5c3_Err != nil {
@@ -1217,7 +1223,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
12171223 var templ_7745c5c3_Var61 string
12181224 templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.CreatedAt)
12191225 if templ_7745c5c3_Err != nil {
1220- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 230, Col: 32}
1226+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 233, Col: 32}
12211227 }
12221228 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var61)
12231229 if templ_7745c5c3_Err != nil {
@@ -1235,7 +1241,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
12351241 var templ_7745c5c3_Var62 string
12361242 templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(r.Created)
12371243 if templ_7745c5c3_Err != nil {
1238- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 232, Col: 20}
1244+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 235, Col: 20}
12391245 }
12401246 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))
12411247 if templ_7745c5c3_Err != nil {
@@ -1253,7 +1259,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
12531259 var templ_7745c5c3_Var63 string
12541260 templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.LastUsedAt)
12551261 if templ_7745c5c3_Err != nil {
1256- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 237, Col: 33}
1262+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 240, Col: 33}
12571263 }
12581264 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var63)
12591265 if templ_7745c5c3_Err != nil {
@@ -1271,7 +1277,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
12711277 var templ_7745c5c3_Var64 string
12721278 templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(r.LastUsed)
12731279 if templ_7745c5c3_Err != nil {
1274- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 239, Col: 21}
1280+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 242, Col: 21}
12751281 }
12761282 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64))
12771283 if templ_7745c5c3_Err != nil {
@@ -1299,7 +1305,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
12991305 var templ_7745c5c3_Var65 templ.SafeURL
13001306 templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Remove))
13011307 if templ_7745c5c3_Err != nil {
1302- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 249, Col: 60}
1308+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 252, Col: 60}
13031309 }
13041310 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65))
13051311 if templ_7745c5c3_Err != nil {
@@ -1312,7 +1318,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
13121318 var templ_7745c5c3_Var66 string
13131319 templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.ResolveAttributeValue("Remove passkey " + r.Name)
13141320 if templ_7745c5c3_Err != nil {
1315- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 250, Col: 89}
1321+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 253, Col: 89}
13161322 }
13171323 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var66)
13181324 if templ_7745c5c3_Err != nil {
@@ -1345,7 +1351,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
13451351 var templ_7745c5c3_Var67 string
13461352 templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Max))
13471353 if templ_7745c5c3_Err != nil {
1348- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 261, Col: 31}
1354+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 264, Col: 31}
13491355 }
13501356 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67))
13511357 if templ_7745c5c3_Err != nil {
@@ -1363,7 +1369,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
13631369 var templ_7745c5c3_Var68 string
13641370 templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)
13651371 if templ_7745c5c3_Err != nil {
1366- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 266, Col: 90}
1372+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 269, Col: 90}
13671373 }
13681374 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var68)
13691375 if templ_7745c5c3_Err != nil {
@@ -1376,7 +1382,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
13761382 var templ_7745c5c3_Var69 string
13771383 templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)
13781384 if templ_7745c5c3_Err != nil {
1379- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 266, Col: 115}
1385+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 269, Col: 115}
13801386 }
13811387 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69)
13821388 if templ_7745c5c3_Err != nil {
@@ -431,6 +431,12 @@ func summaryRail(p SummaryPage) templ.Component {
431 return templ_7745c5c3_Err431 return templ_7745c5c3_Err
432 }432 }
433 }433 }
434+ if p.Actions != nil {
435+ templ_7745c5c3_Err = RecentActionsBlock(*p.Actions).Render(ctx, templ_7745c5c3_Buffer)
436+ if templ_7745c5c3_Err != nil {
437+ return templ_7745c5c3_Err
438+ }
439+ }
434 if len(p.Outline) > 0 && p.Readme != nil {440 if len(p.Outline) > 0 && p.Readme != nil {
435 templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<section aria-labelledby=\"r-outline\"><h2 id=\"r-outline\">On this page</h2>")441 templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<section aria-labelledby=\"r-outline\"><h2 id=\"r-outline\">On this page</h2>")
436 if templ_7745c5c3_Err != nil {442 if templ_7745c5c3_Err != nil {
@@ -503,7 +509,7 @@ func Refs(l Layout, p RefsPage) templ.Component {
503 var templ_7745c5c3_Var24 string509 var templ_7745c5c3_Var24 string
504 templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)510 templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
505 if templ_7745c5c3_Err != nil {511 if templ_7745c5c3_Err != nil {
506- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 110, Col: 27}512+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 113, Col: 27}
507 }513 }
508 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))514 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
509 if templ_7745c5c3_Err != nil {515 if templ_7745c5c3_Err != nil {
@@ -562,7 +568,7 @@ func refRow(kind string, r RefRow) templ.Component {
562 var templ_7745c5c3_Var26 string568 var templ_7745c5c3_Var26 string
563 templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(kind)569 templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(kind)
564 if templ_7745c5c3_Err != nil {570 if templ_7745c5c3_Err != nil {
565- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 118, Col: 32}571+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 121, Col: 32}
566 }572 }
567 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))573 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
568 if templ_7745c5c3_Err != nil {574 if templ_7745c5c3_Err != nil {
@@ -575,7 +581,7 @@ func refRow(kind string, r RefRow) templ.Component {
575 var templ_7745c5c3_Var27 templ.SafeURL581 var templ_7745c5c3_Var27 templ.SafeURL
576 templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))582 templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
577 if templ_7745c5c3_Err != nil {583 if templ_7745c5c3_Err != nil {
578- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 120, Col: 34}584+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 123, Col: 34}
579 }585 }
580 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))586 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
581 if templ_7745c5c3_Err != nil {587 if templ_7745c5c3_Err != nil {
@@ -588,7 +594,7 @@ func refRow(kind string, r RefRow) templ.Component {
588 var templ_7745c5c3_Var28 string594 var templ_7745c5c3_Var28 string
589 templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)595 templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
590 if templ_7745c5c3_Err != nil {596 if templ_7745c5c3_Err != nil {
591- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 120, Col: 45}597+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 123, Col: 45}
592 }598 }
593 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))599 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
594 if templ_7745c5c3_Err != nil {600 if templ_7745c5c3_Err != nil {
@@ -616,7 +622,7 @@ func refRow(kind string, r RefRow) templ.Component {
616 var templ_7745c5c3_Var29 templ.SafeURL622 var templ_7745c5c3_Var29 templ.SafeURL
617 templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CHref))623 templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CHref))
618 if templ_7745c5c3_Err != nil {624 if templ_7745c5c3_Err != nil {
619- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 127, Col: 36}625+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 130, Col: 36}
620 }626 }
621 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))627 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
622 if templ_7745c5c3_Err != nil {628 if templ_7745c5c3_Err != nil {
@@ -629,7 +635,7 @@ func refRow(kind string, r RefRow) templ.Component {
629 var templ_7745c5c3_Var30 string635 var templ_7745c5c3_Var30 string
630 templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)636 templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)
631 if templ_7745c5c3_Err != nil {637 if templ_7745c5c3_Err != nil {
632- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 127, Col: 48}638+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 130, Col: 48}
633 }639 }
634 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))640 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
635 if templ_7745c5c3_Err != nil {641 if templ_7745c5c3_Err != nil {
@@ -643,7 +649,7 @@ func refRow(kind string, r RefRow) templ.Component {
643 var templ_7745c5c3_Var31 string649 var templ_7745c5c3_Var31 string
644 templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)650 templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)
645 if templ_7745c5c3_Err != nil {651 if templ_7745c5c3_Err != nil {
646- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 129, Col: 13}652+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 132, Col: 13}
647 }653 }
648 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))654 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
649 if templ_7745c5c3_Err != nil {655 if templ_7745c5c3_Err != nil {
@@ -661,7 +667,7 @@ func refRow(kind string, r RefRow) templ.Component {
661 var templ_7745c5c3_Var32 string667 var templ_7745c5c3_Var32 string
662 templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(r.Subject)668 templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(r.Subject)
663 if templ_7745c5c3_Err != nil {669 if templ_7745c5c3_Err != nil {
664- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 133, Col: 31}670+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 136, Col: 31}
665 }671 }
666 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))672 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
667 if templ_7745c5c3_Err != nil {673 if templ_7745c5c3_Err != nil {
@@ -679,7 +685,7 @@ func refRow(kind string, r RefRow) templ.Component {
679 var templ_7745c5c3_Var33 string685 var templ_7745c5c3_Var33 string
680 templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)686 templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
681 if templ_7745c5c3_Err != nil {687 if templ_7745c5c3_Err != nil {
682- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 136, Col: 29}688+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 139, Col: 29}
683 }689 }
684 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)690 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
685 if templ_7745c5c3_Err != nil {691 if templ_7745c5c3_Err != nil {
@@ -692,7 +698,7 @@ func refRow(kind string, r RefRow) templ.Component {
692 var templ_7745c5c3_Var34 string698 var templ_7745c5c3_Var34 string
693 templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)699 templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
694 if templ_7745c5c3_Err != nil {700 if templ_7745c5c3_Err != nil {
695- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 136, Col: 48}701+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 139, Col: 48}
696 }702 }
697 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)703 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
698 if templ_7745c5c3_Err != nil {704 if templ_7745c5c3_Err != nil {
@@ -705,7 +711,7 @@ func refRow(kind string, r RefRow) templ.Component {
705 var templ_7745c5c3_Var35 string711 var templ_7745c5c3_Var35 string
706 templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(r.When)712 templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(r.When)
707 if templ_7745c5c3_Err != nil {713 if templ_7745c5c3_Err != nil {
708- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 136, Col: 59}714+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 139, Col: 59}
709 }715 }
710 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))716 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
711 if templ_7745c5c3_Err != nil {717 if templ_7745c5c3_Err != nil {
@@ -802,7 +808,7 @@ func State(l Layout, p StatePage) templ.Component {
802 var templ_7745c5c3_Var39 string808 var templ_7745c5c3_Var39 string
803 templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(p.Code)809 templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(p.Code)
804 if templ_7745c5c3_Err != nil {810 if templ_7745c5c3_Err != nil {
805- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 153, Col: 30}811+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 156, Col: 30}
806 }812 }
807 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))813 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
808 if templ_7745c5c3_Err != nil {814 if templ_7745c5c3_Err != nil {
@@ -815,7 +821,7 @@ func State(l Layout, p StatePage) templ.Component {
815 var templ_7745c5c3_Var40 string821 var templ_7745c5c3_Var40 string
816 templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)822 templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
817 if templ_7745c5c3_Err != nil {823 if templ_7745c5c3_Err != nil {
818- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 154, Col: 33}824+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 157, Col: 33}
819 }825 }
820 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))826 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
821 if templ_7745c5c3_Err != nil {827 if templ_7745c5c3_Err != nil {
@@ -833,7 +839,7 @@ func State(l Layout, p StatePage) templ.Component {
833 var templ_7745c5c3_Var41 string839 var templ_7745c5c3_Var41 string
834 templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(line)840 templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(line)
835 if templ_7745c5c3_Err != nil {841 if templ_7745c5c3_Err != nil {
836- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 156, Col: 25}842+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 159, Col: 25}
837 }843 }
838 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))844 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
839 if templ_7745c5c3_Err != nil {845 if templ_7745c5c3_Err != nil {
@@ -862,7 +868,7 @@ func State(l Layout, p StatePage) templ.Component {
862 var templ_7745c5c3_Var42 templ.SafeURL868 var templ_7745c5c3_Var42 templ.SafeURL
863 templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(b.Href))869 templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(b.Href))
864 if templ_7745c5c3_Err != nil {870 if templ_7745c5c3_Err != nil {
865- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 163, Col: 36}871+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 166, Col: 36}
866 }872 }
867 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))873 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
868 if templ_7745c5c3_Err != nil {874 if templ_7745c5c3_Err != nil {
@@ -875,7 +881,7 @@ func State(l Layout, p StatePage) templ.Component {
875 var templ_7745c5c3_Var43 string881 var templ_7745c5c3_Var43 string
876 templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(b.Label)882 templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(b.Label)
877 if templ_7745c5c3_Err != nil {883 if templ_7745c5c3_Err != nil {
878- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 163, Col: 48}884+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 166, Col: 48}
879 }885 }
880 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))886 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
881 if templ_7745c5c3_Err != nil {887 if templ_7745c5c3_Err != nil {
@@ -942,7 +948,7 @@ func Login(l Layout, p LoginPage) templ.Component {
942 var templ_7745c5c3_Var46 string948 var templ_7745c5c3_Var46 string
943 templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)949 templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
944 if templ_7745c5c3_Err != nil {950 if templ_7745c5c3_Err != nil {
945- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 175, Col: 33}951+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 178, Col: 33}
946 }952 }
947 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))953 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
948 if templ_7745c5c3_Err != nil {954 if templ_7745c5c3_Err != nil {
@@ -960,7 +966,7 @@ func Login(l Layout, p LoginPage) templ.Component {
960 var templ_7745c5c3_Var47 string966 var templ_7745c5c3_Var47 string
961 templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(line)967 templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(line)
962 if templ_7745c5c3_Err != nil {968 if templ_7745c5c3_Err != nil {
963- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 177, Col: 25}969+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 180, Col: 25}
964 }970 }
965 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))971 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
966 if templ_7745c5c3_Err != nil {972 if templ_7745c5c3_Err != nil {
@@ -978,7 +984,7 @@ func Login(l Layout, p LoginPage) templ.Component {
978 var templ_7745c5c3_Var48 templ.SafeURL984 var templ_7745c5c3_Var48 templ.SafeURL
979 templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Action))985 templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Action))
980 if templ_7745c5c3_Err != nil {986 if templ_7745c5c3_Err != nil {
981- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 179, Col: 73}987+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 182, Col: 73}
982 }988 }
983 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))989 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
984 if templ_7745c5c3_Err != nil {990 if templ_7745c5c3_Err != nil {
@@ -991,7 +997,7 @@ func Login(l Layout, p LoginPage) templ.Component {
991 var templ_7745c5c3_Var49 string997 var templ_7745c5c3_Var49 string
992 templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)998 templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)
993 if templ_7745c5c3_Err != nil {999 if templ_7745c5c3_Err != nil {
994- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 180, Col: 56}1000+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 183, Col: 56}
995 }1001 }
996 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))1002 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
997 if templ_7745c5c3_Err != nil {1003 if templ_7745c5c3_Err != nil {
@@ -1053,7 +1059,7 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
1053 var templ_7745c5c3_Var52 string1059 var templ_7745c5c3_Var52 string
1054 templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)1060 templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
1055 if templ_7745c5c3_Err != nil {1061 if templ_7745c5c3_Err != nil {
1056- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 192, Col: 33}1062+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 195, Col: 33}
1057 }1063 }
1058 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))1064 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
1059 if templ_7745c5c3_Err != nil {1065 if templ_7745c5c3_Err != nil {
@@ -1066,7 +1072,7 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
1066 var templ_7745c5c3_Var53 string1072 var templ_7745c5c3_Var53 string
1067 templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)1073 templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)
1068 if templ_7745c5c3_Err != nil {1074 if templ_7745c5c3_Err != nil {
1069- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 194, Col: 87}1075+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 197, Col: 87}
1070 }1076 }
1071 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var53)1077 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var53)
1072 if templ_7745c5c3_Err != nil {1078 if templ_7745c5c3_Err != nil {
@@ -1079,7 +1085,7 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
1079 var templ_7745c5c3_Var54 string1085 var templ_7745c5c3_Var54 string
1080 templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)1086 templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)
1081 if templ_7745c5c3_Err != nil {1087 if templ_7745c5c3_Err != nil {
1082- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 194, Col: 112}1088+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 197, Col: 112}
1083 }1089 }
1084 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54)1090 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54)
1085 if templ_7745c5c3_Err != nil {1091 if templ_7745c5c3_Err != nil {
@@ -1092,7 +1098,7 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
1092 var templ_7745c5c3_Var55 string1098 var templ_7745c5c3_Var55 string
1093 templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)1099 templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)
1094 if templ_7745c5c3_Err != nil {1100 if templ_7745c5c3_Err != nil {
1095- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 194, Col: 125}1101+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 197, Col: 125}
1096 }1102 }
1097 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))1103 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
1098 if templ_7745c5c3_Err != nil {1104 if templ_7745c5c3_Err != nil {
@@ -1110,7 +1116,7 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
1110 var templ_7745c5c3_Var56 string1116 var templ_7745c5c3_Var56 string
1111 templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(line)1117 templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(line)
1112 if templ_7745c5c3_Err != nil {1118 if templ_7745c5c3_Err != nil {
1113- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 198, Col: 25}1119+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 201, Col: 25}
1114 }1120 }
1115 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))1121 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
1116 if templ_7745c5c3_Err != nil {1122 if templ_7745c5c3_Err != nil {
@@ -1176,7 +1182,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
1176 var templ_7745c5c3_Var59 string1182 var templ_7745c5c3_Var59 string
1177 templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Rows)))1183 templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Rows)))
1178 if templ_7745c5c3_Err != nil {1184 if templ_7745c5c3_Err != nil {
1179- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 208, Col: 81}1185+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 211, Col: 81}
1180 }1186 }
1181 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))1187 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))
1182 if templ_7745c5c3_Err != nil {1188 if templ_7745c5c3_Err != nil {
@@ -1199,7 +1205,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
1199 var templ_7745c5c3_Var60 string1205 var templ_7745c5c3_Var60 string
1200 templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)1206 templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
1201 if templ_7745c5c3_Err != nil {1207 if templ_7745c5c3_Err != nil {
1202- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 226, Col: 34}1208+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 229, Col: 34}
1203 }1209 }
1204 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60))1210 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60))
1205 if templ_7745c5c3_Err != nil {1211 if templ_7745c5c3_Err != nil {
@@ -1217,7 +1223,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
1217 var templ_7745c5c3_Var61 string1223 var templ_7745c5c3_Var61 string
1218 templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.CreatedAt)1224 templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.CreatedAt)
1219 if templ_7745c5c3_Err != nil {1225 if templ_7745c5c3_Err != nil {
1220- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 230, Col: 32}1226+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 233, Col: 32}
1221 }1227 }
1222 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var61)1228 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var61)
1223 if templ_7745c5c3_Err != nil {1229 if templ_7745c5c3_Err != nil {
@@ -1235,7 +1241,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
1235 var templ_7745c5c3_Var62 string1241 var templ_7745c5c3_Var62 string
1236 templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(r.Created)1242 templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(r.Created)
1237 if templ_7745c5c3_Err != nil {1243 if templ_7745c5c3_Err != nil {
1238- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 232, Col: 20}1244+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 235, Col: 20}
1239 }1245 }
1240 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))1246 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))
1241 if templ_7745c5c3_Err != nil {1247 if templ_7745c5c3_Err != nil {
@@ -1253,7 +1259,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
1253 var templ_7745c5c3_Var63 string1259 var templ_7745c5c3_Var63 string
1254 templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.LastUsedAt)1260 templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.LastUsedAt)
1255 if templ_7745c5c3_Err != nil {1261 if templ_7745c5c3_Err != nil {
1256- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 237, Col: 33}1262+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 240, Col: 33}
1257 }1263 }
1258 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var63)1264 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var63)
1259 if templ_7745c5c3_Err != nil {1265 if templ_7745c5c3_Err != nil {
@@ -1271,7 +1277,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
1271 var templ_7745c5c3_Var64 string1277 var templ_7745c5c3_Var64 string
1272 templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(r.LastUsed)1278 templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(r.LastUsed)
1273 if templ_7745c5c3_Err != nil {1279 if templ_7745c5c3_Err != nil {
1274- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 239, Col: 21}1280+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 242, Col: 21}
1275 }1281 }
1276 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64))1282 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64))
1277 if templ_7745c5c3_Err != nil {1283 if templ_7745c5c3_Err != nil {
@@ -1299,7 +1305,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
1299 var templ_7745c5c3_Var65 templ.SafeURL1305 var templ_7745c5c3_Var65 templ.SafeURL
1300 templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Remove))1306 templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Remove))
1301 if templ_7745c5c3_Err != nil {1307 if templ_7745c5c3_Err != nil {
1302- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 249, Col: 60}1308+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 252, Col: 60}
1303 }1309 }
1304 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65))1310 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65))
1305 if templ_7745c5c3_Err != nil {1311 if templ_7745c5c3_Err != nil {
@@ -1312,7 +1318,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
1312 var templ_7745c5c3_Var66 string1318 var templ_7745c5c3_Var66 string
1313 templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.ResolveAttributeValue("Remove passkey " + r.Name)1319 templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.ResolveAttributeValue("Remove passkey " + r.Name)
1314 if templ_7745c5c3_Err != nil {1320 if templ_7745c5c3_Err != nil {
1315- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 250, Col: 89}1321+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 253, Col: 89}
1316 }1322 }
1317 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var66)1323 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var66)
1318 if templ_7745c5c3_Err != nil {1324 if templ_7745c5c3_Err != nil {
@@ -1345,7 +1351,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
1345 var templ_7745c5c3_Var67 string1351 var templ_7745c5c3_Var67 string
1346 templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Max))1352 templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Max))
1347 if templ_7745c5c3_Err != nil {1353 if templ_7745c5c3_Err != nil {
1348- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 261, Col: 31}1354+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 264, Col: 31}
1349 }1355 }
1350 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67))1356 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67))
1351 if templ_7745c5c3_Err != nil {1357 if templ_7745c5c3_Err != nil {
@@ -1363,7 +1369,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
1363 var templ_7745c5c3_Var68 string1369 var templ_7745c5c3_Var68 string
1364 templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)1370 templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)
1365 if templ_7745c5c3_Err != nil {1371 if templ_7745c5c3_Err != nil {
1366- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 266, Col: 90}1372+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 269, Col: 90}
1367 }1373 }
1368 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var68)1374 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var68)
1369 if templ_7745c5c3_Err != nil {1375 if templ_7745c5c3_Err != nil {
@@ -1376,7 +1382,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
1376 var templ_7745c5c3_Var69 string1382 var templ_7745c5c3_Var69 string
1377 templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)1383 templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)
1378 if templ_7745c5c3_Err != nil {1384 if templ_7745c5c3_Err != nil {
1379- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 266, Col: 115}1385+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 269, Col: 115}
1380 }1386 }
1381 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69)1387 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69)
1382 if templ_7745c5c3_Err != nil {1388 if templ_7745c5c3_Err != nil {
M internal/adapters/web/static/css/65-ci.css +19 -1
@@ -15,9 +15,27 @@
1515 @media (prefers-reduced-motion: reduce) { .ck-running { animation: none; border-top-color: var(--teal); } }
1616
1717 .ck-sum { display: inline-flex; align-items: center; gap: var(--s1); margin-left: var(--s2); font-size: var(--fs-xs); white-space: nowrap; }
18+ /* The lists are table-layout: fixed, where a percentage is only a sliver:
19+ the check column holds the mark, its count and the padding of the cells;
20+ the refs list has the mark next to the sha. */
1821 .c-ck { width: 1%; white-space: nowrap; }
22+ .list .c-ck { width: 92px; }
23+ .rail .list.commits .c-ck { width: 76px; }
24+ .list.refs .c-sha { width: 150px; }
1925
20- .runs .c-st { width: 1%; }
26+ /* the table is fixed: the status column is the icon, its margin and the
27+ padding of the cells (a percentage would leave a sliver) */
28+ .runs .c-st { width: calc(14px + var(--s1) + 2 * var(--s3)); }
29+ /* the recent runs of the summary rail */
30+ .rruns {
31+ display: grid; padding: 0; list-style: none; font-size: var(--fs-s);
32+ & li { display: flex; align-items: center; gap: var(--s1); min-width: 0; padding: 2px 0; }
33+ & .ck { margin-right: var(--s1); }
34+ & .rr-name { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink-hi); text-decoration: none; }
35+ & .rr-name:hover { text-decoration: underline; }
36+ & .rr-ref { flex: 1 1 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-left: var(--s1); font-family: var(--mono); color: var(--ink-dim); text-decoration: none; }
37+ & time { flex: none; margin-left: auto; padding-left: var(--s2); white-space: nowrap; }
38+ }
2139 .runs .c-dur, .runs .c-who { white-space: nowrap; }
2240 .runs tr[data-new] { background: var(--bg-raised); }
2341
@@ -15,9 +15,27 @@
15 @media (prefers-reduced-motion: reduce) { .ck-running { animation: none; border-top-color: var(--teal); } }15 @media (prefers-reduced-motion: reduce) { .ck-running { animation: none; border-top-color: var(--teal); } }
16 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; }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+ /* The lists are table-layout: fixed, where a percentage is only a sliver:
19+ the check column holds the mark, its count and the padding of the cells;
20+ the refs list has the mark next to the sha. */
18 .c-ck { width: 1%; white-space: nowrap; }21 .c-ck { width: 1%; white-space: nowrap; }
22+ .list .c-ck { width: 92px; }
23+ .rail .list.commits .c-ck { width: 76px; }
24+ .list.refs .c-sha { width: 150px; }
19 25
20- .runs .c-st { width: 1%; }26+ /* the table is fixed: the status column is the icon, its margin and the
27+ padding of the cells (a percentage would leave a sliver) */
28+ .runs .c-st { width: calc(14px + var(--s1) + 2 * var(--s3)); }
29+ /* the recent runs of the summary rail */
30+ .rruns {
31+ display: grid; padding: 0; list-style: none; font-size: var(--fs-s);
32+ & li { display: flex; align-items: center; gap: var(--s1); min-width: 0; padding: 2px 0; }
33+ & .ck { margin-right: var(--s1); }
34+ & .rr-name { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink-hi); text-decoration: none; }
35+ & .rr-name:hover { text-decoration: underline; }
36+ & .rr-ref { flex: 1 1 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-left: var(--s1); font-family: var(--mono); color: var(--ink-dim); text-decoration: none; }
37+ & time { flex: none; margin-left: auto; padding-left: var(--s2); white-space: nowrap; }
38+ }
21 .runs .c-dur, .runs .c-who { white-space: nowrap; }39 .runs .c-dur, .runs .c-who { white-space: nowrap; }
22 .runs tr[data-new] { background: var(--bg-raised); }40 .runs tr[data-new] { background: var(--bg-raised); }
23 41
M internal/adapters/web/summary.go +37 -0
@@ -4,6 +4,7 @@ package web
44
55 import (
66 "context"
7+ "errors"
78 "fmt"
89 "html"
910 "html/template"
@@ -26,6 +27,9 @@ const maxReadmeBytes = 512 << 10
2627 // recentCommits is how many commits the rail of the summary lists.
2728 const recentCommits = 5
2829
30+// recentRuns is how many runs the rail of the summary lists.
31+const recentRuns = 5
32+
2933 // readmeNames are the README file names in order of preference (compared
3034 // lowercase): Markdown first.
3135 var readmeNames = []string{"readme.md", "readme.markdown", "readme", "readme.txt"}
@@ -63,6 +67,7 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component,
6367
6468 p.Facts = s.facts(d, now)
6569 p.Latest = s.latest(ctx, d)
70+ p.Actions = s.recentActions(ctx, d.repo.Name, now)
6671
6772 if rd, err := s.readme(ctx, d, ref); err != nil {
6873 return nil, 0, err
@@ -232,3 +237,35 @@ func humanSize(n int64) string {
232237 }
233238 return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20))
234239 }
240+
241+// recentActions is the newest runs for the rail, as the caller of the request
242+// sees them (the permission of the actions page). It is nil when the server
243+// runs no CI or the caller cannot read it; a failure of the read is logged and
244+// the page goes on without, since the code does not depend on CI.
245+func (s *Server) recentActions(ctx context.Context, repo domain.RepoName, now time.Time) *RecentActions {
246+ if s.cfg.Runs == nil {
247+ return nil
248+ }
249+ a, err := s.listRecentActions(ctx, repo, now)
250+ if err != nil {
251+ if !errors.Is(err, domain.ErrNotFound) {
252+ log := zerowrap.FromCtx(ctx)
253+ log.Warn().Err(err).Str("repo", repo.String()).Msg("read the recent runs")
254+ }
255+ return nil
256+ }
257+ return &a
258+}
259+
260+// listRecentActions reads the newest runs of a repository.
261+func (s *Server) listRecentActions(ctx context.Context, repo domain.RepoName, now time.Time) (RecentActions, error) {
262+ page, err := s.cfg.Runs.ListRuns(ctx, principal(ctx), repo, ports.ListRunsParams{PageSize: recentRuns})
263+ if err != nil {
264+ return RecentActions{}, err
265+ }
266+ a := RecentActions{AllHref: actionsBase(repo.String())}
267+ for _, run := range page.Runs {
268+ a.Runs = append(a.Runs, s.runRow(repo.String(), run, now))
269+ }
270+ return a, nil
271+}
@@ -4,6 +4,7 @@ package web
4 4
5 import (5 import (
6 "context"6 "context"
7+ "errors"
7 "fmt"8 "fmt"
8 "html"9 "html"
9 "html/template"10 "html/template"
@@ -26,6 +27,9 @@ const maxReadmeBytes = 512 << 10
26 // recentCommits is how many commits the rail of the summary lists.27 // recentCommits is how many commits the rail of the summary lists.
27 const recentCommits = 528 const recentCommits = 5
28 29
30+// recentRuns is how many runs the rail of the summary lists.
31+const recentRuns = 5
32+
29 // readmeNames are the README file names in order of preference (compared33 // readmeNames are the README file names in order of preference (compared
30 // lowercase): Markdown first.34 // lowercase): Markdown first.
31 var readmeNames = []string{"readme.md", "readme.markdown", "readme", "readme.txt"}35 var readmeNames = []string{"readme.md", "readme.markdown", "readme", "readme.txt"}
@@ -63,6 +67,7 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component,
63 67
64 p.Facts = s.facts(d, now)68 p.Facts = s.facts(d, now)
65 p.Latest = s.latest(ctx, d)69 p.Latest = s.latest(ctx, d)
70+ p.Actions = s.recentActions(ctx, d.repo.Name, now)
66 71
67 if rd, err := s.readme(ctx, d, ref); err != nil {72 if rd, err := s.readme(ctx, d, ref); err != nil {
68 return nil, 0, err73 return nil, 0, err
@@ -232,3 +237,35 @@ func humanSize(n int64) string {
232 }237 }
233 return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20))238 return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20))
234 }239 }
240+
241+// recentActions is the newest runs for the rail, as the caller of the request
242+// sees them (the permission of the actions page). It is nil when the server
243+// runs no CI or the caller cannot read it; a failure of the read is logged and
244+// the page goes on without, since the code does not depend on CI.
245+func (s *Server) recentActions(ctx context.Context, repo domain.RepoName, now time.Time) *RecentActions {
246+ if s.cfg.Runs == nil {
247+ return nil
248+ }
249+ a, err := s.listRecentActions(ctx, repo, now)
250+ if err != nil {
251+ if !errors.Is(err, domain.ErrNotFound) {
252+ log := zerowrap.FromCtx(ctx)
253+ log.Warn().Err(err).Str("repo", repo.String()).Msg("read the recent runs")
254+ }
255+ return nil
256+ }
257+ return &a
258+}
259+
260+// listRecentActions reads the newest runs of a repository.
261+func (s *Server) listRecentActions(ctx context.Context, repo domain.RepoName, now time.Time) (RecentActions, error) {
262+ page, err := s.cfg.Runs.ListRuns(ctx, principal(ctx), repo, ports.ListRunsParams{PageSize: recentRuns})
263+ if err != nil {
264+ return RecentActions{}, err
265+ }
266+ a := RecentActions{AllHref: actionsBase(repo.String())}
267+ for _, run := range page.Runs {
268+ a.Runs = append(a.Runs, s.runRow(repo.String(), run, now))
269+ }
270+ return a, nil
271+}
A internal/adapters/web/summary_actions_test.go +127 -0
@@ -0,0 +1,127 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "bytes"
7+ "errors"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+ "bnema.dev/ermite/internal/ports/mocks"
19+)
20+
21+func testRuns(repo domain.RepoName, n int) []domain.CIRun {
22+ var runs []domain.CIRun
23+ for i := n; i >= 1; i-- {
24+ runs = append(runs, domain.CIRun{
25+ Repo: repo, ID: int64(i), Number: int64(i), Attempt: 1, WorkflowName: "ci", Ref: "refs/heads/main",
26+ Sha: strings.Repeat("a", 40), Status: domain.CICompleted, Conclusion: domain.CISuccess,
27+ CreatedAt: time.Now().Add(-time.Duration(i) * time.Minute),
28+ })
29+ }
30+ return runs
31+}
32+
33+func servingRuns(t *testing.T, repo domain.RepoName, runs []domain.CIRun, err error) (*Server, *mocks.RunService) {
34+ t.Helper()
35+ m := mocks.NewRunService(t)
36+ m.EXPECT().ListRuns(mock.Anything, mock.Anything, repo, ports.ListRunsParams{PageSize: recentRuns}).
37+ Return(ports.RunPage{Runs: runs}, err).Maybe()
38+ return newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, repo.String())), Browse: mocks.NewBrowseService(t), Runs: m}), m
39+}
40+
41+func renderRail(t *testing.T, p SummaryPage) string {
42+ t.Helper()
43+ var b bytes.Buffer
44+ require.NoError(t, summaryRail(p).Render(t.Context(), &b))
45+ return b.String()
46+}
47+
48+func TestSummaryRailRecentActions(t *testing.T) {
49+ t.Parallel()
50+ repo := repoName(t, "proj")
51+ srv, _ := servingRuns(t, repo, testRuns(repo, 5), nil)
52+
53+ p := SummaryPage{Actions: srv.recentActions(t.Context(), repo, time.Now())}
54+ require.NotNil(t, p.Actions)
55+ out := renderRail(t, p)
56+ assert.Contains(t, out, `id="recent-actions"`)
57+ assert.Contains(t, out, "Recent actions")
58+ assert.Contains(t, out, `href="/proj/actions"`, "view all")
59+ assert.Equal(t, 5, strings.Count(out, `class="rr-name"`))
60+ assert.Contains(t, out, `href="/proj/actions/runs/5"`)
61+ assert.Contains(t, out, `aria-label="success"`, "the status icon")
62+ assert.Contains(t, out, `href="/proj/tree/main"`, "the ref")
63+ assert.Contains(t, out, "#5")
64+ assert.NotContains(t, out, `id="recent-actions" aria-labelledby="r-actions" hidden`)
65+}
66+
67+func TestSummaryRailRecentActionsHidden(t *testing.T) {
68+ t.Parallel()
69+ repo := repoName(t, "proj")
70+
71+ // The viewer cannot read CI: no block at all, not even to receive events.
72+ srv, _ := servingRuns(t, repo, nil, domain.ErrNotFound)
73+ assert.Nil(t, srv.recentActions(t.Context(), repo, time.Now()))
74+ assert.NotContains(t, renderRail(t, SummaryPage{}), "recent-actions")
75+
76+ // A failure of the read does not break the page.
77+ srv, _ = servingRuns(t, repo, nil, errors.New("boom"))
78+ assert.Nil(t, srv.recentActions(t.Context(), repo, time.Now()))
79+
80+ // No CI on this server.
81+ noCI := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t)})
82+ assert.Nil(t, noCI.recentActions(t.Context(), repo, time.Now()))
83+
84+ // No run yet: the block is there for the first run, but hidden.
85+ srv, _ = servingRuns(t, repo, nil, nil)
86+ a := srv.recentActions(t.Context(), repo, time.Now())
87+ require.NotNil(t, a)
88+ assert.Contains(t, renderRail(t, SummaryPage{Actions: a}), `id="recent-actions" aria-labelledby="r-actions" hidden`)
89+}
90+
91+func TestRunUpdatedReplacesRecentActionsOnSummary(t *testing.T) {
92+ t.Parallel()
93+ repo := repoName(t, "proj")
94+ runs := testRuns(repo, 3)
95+ runs[0].Status, runs[0].Conclusion = domain.CIInProgress, ""
96+ srv, _ := servingRuns(t, repo, runs, nil)
97+
98+ ev := ports.WatchEvent{Event: domain.RunUpdated{Run: runs[0]}}
99+ ls := &liveStream{s: srv, repo: "proj", view: "summary"}
100+ got, err := ls.render(t.Context(), ev)
101+ require.NoError(t, err)
102+ require.Len(t, got, 1)
103+ assert.Equal(t, "ci-run", got[0].event)
104+ assert.True(t, strings.HasPrefix(got[0].html, `<section id="recent-actions"`), got[0].html)
105+ assert.Equal(t, 3, strings.Count(got[0].html, `class="rr-name"`))
106+ assert.Contains(t, got[0].html, `aria-label="running"`)
107+
108+ // The other pages do not get the block.
109+ for _, view := range []string{"refs", "commits", "mrs"} {
110+ got, err = (&liveStream{s: srv, repo: "proj", view: view}).render(t.Context(), ev)
111+ require.NoError(t, err, view)
112+ assert.Empty(t, got, view)
113+ }
114+}
115+
116+func TestRunUpdatedOnSummaryWhenCIIsNotReadable(t *testing.T) {
117+ t.Parallel()
118+ repo := repoName(t, "proj")
119+ srv, _ := servingRuns(t, repo, nil, domain.ErrNotFound)
120+
121+ ls := &liveStream{s: srv, repo: "proj", view: "summary"}
122+ got, err := ls.render(t.Context(), ports.WatchEvent{Event: domain.RunUpdated{Run: testRuns(repo, 1)[0]}})
123+ require.NoError(t, err)
124+ require.Len(t, got, 1)
125+ assert.Contains(t, got[0].html, "hidden", "the block empties and hides, no run leaks")
126+ assert.NotContains(t, got[0].html, "rr-name")
127+}
@@ -0,0 +1,127 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "bytes"
7+ "errors"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+ "bnema.dev/ermite/internal/ports/mocks"
19+)
20+
21+func testRuns(repo domain.RepoName, n int) []domain.CIRun {
22+ var runs []domain.CIRun
23+ for i := n; i >= 1; i-- {
24+ runs = append(runs, domain.CIRun{
25+ Repo: repo, ID: int64(i), Number: int64(i), Attempt: 1, WorkflowName: "ci", Ref: "refs/heads/main",
26+ Sha: strings.Repeat("a", 40), Status: domain.CICompleted, Conclusion: domain.CISuccess,
27+ CreatedAt: time.Now().Add(-time.Duration(i) * time.Minute),
28+ })
29+ }
30+ return runs
31+}
32+
33+func servingRuns(t *testing.T, repo domain.RepoName, runs []domain.CIRun, err error) (*Server, *mocks.RunService) {
34+ t.Helper()
35+ m := mocks.NewRunService(t)
36+ m.EXPECT().ListRuns(mock.Anything, mock.Anything, repo, ports.ListRunsParams{PageSize: recentRuns}).
37+ Return(ports.RunPage{Runs: runs}, err).Maybe()
38+ return newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, repo.String())), Browse: mocks.NewBrowseService(t), Runs: m}), m
39+}
40+
41+func renderRail(t *testing.T, p SummaryPage) string {
42+ t.Helper()
43+ var b bytes.Buffer
44+ require.NoError(t, summaryRail(p).Render(t.Context(), &b))
45+ return b.String()
46+}
47+
48+func TestSummaryRailRecentActions(t *testing.T) {
49+ t.Parallel()
50+ repo := repoName(t, "proj")
51+ srv, _ := servingRuns(t, repo, testRuns(repo, 5), nil)
52+
53+ p := SummaryPage{Actions: srv.recentActions(t.Context(), repo, time.Now())}
54+ require.NotNil(t, p.Actions)
55+ out := renderRail(t, p)
56+ assert.Contains(t, out, `id="recent-actions"`)
57+ assert.Contains(t, out, "Recent actions")
58+ assert.Contains(t, out, `href="/proj/actions"`, "view all")
59+ assert.Equal(t, 5, strings.Count(out, `class="rr-name"`))
60+ assert.Contains(t, out, `href="/proj/actions/runs/5"`)
61+ assert.Contains(t, out, `aria-label="success"`, "the status icon")
62+ assert.Contains(t, out, `href="/proj/tree/main"`, "the ref")
63+ assert.Contains(t, out, "#5")
64+ assert.NotContains(t, out, `id="recent-actions" aria-labelledby="r-actions" hidden`)
65+}
66+
67+func TestSummaryRailRecentActionsHidden(t *testing.T) {
68+ t.Parallel()
69+ repo := repoName(t, "proj")
70+
71+ // The viewer cannot read CI: no block at all, not even to receive events.
72+ srv, _ := servingRuns(t, repo, nil, domain.ErrNotFound)
73+ assert.Nil(t, srv.recentActions(t.Context(), repo, time.Now()))
74+ assert.NotContains(t, renderRail(t, SummaryPage{}), "recent-actions")
75+
76+ // A failure of the read does not break the page.
77+ srv, _ = servingRuns(t, repo, nil, errors.New("boom"))
78+ assert.Nil(t, srv.recentActions(t.Context(), repo, time.Now()))
79+
80+ // No CI on this server.
81+ noCI := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t)})
82+ assert.Nil(t, noCI.recentActions(t.Context(), repo, time.Now()))
83+
84+ // No run yet: the block is there for the first run, but hidden.
85+ srv, _ = servingRuns(t, repo, nil, nil)
86+ a := srv.recentActions(t.Context(), repo, time.Now())
87+ require.NotNil(t, a)
88+ assert.Contains(t, renderRail(t, SummaryPage{Actions: a}), `id="recent-actions" aria-labelledby="r-actions" hidden`)
89+}
90+
91+func TestRunUpdatedReplacesRecentActionsOnSummary(t *testing.T) {
92+ t.Parallel()
93+ repo := repoName(t, "proj")
94+ runs := testRuns(repo, 3)
95+ runs[0].Status, runs[0].Conclusion = domain.CIInProgress, ""
96+ srv, _ := servingRuns(t, repo, runs, nil)
97+
98+ ev := ports.WatchEvent{Event: domain.RunUpdated{Run: runs[0]}}
99+ ls := &liveStream{s: srv, repo: "proj", view: "summary"}
100+ got, err := ls.render(t.Context(), ev)
101+ require.NoError(t, err)
102+ require.Len(t, got, 1)
103+ assert.Equal(t, "ci-run", got[0].event)
104+ assert.True(t, strings.HasPrefix(got[0].html, `<section id="recent-actions"`), got[0].html)
105+ assert.Equal(t, 3, strings.Count(got[0].html, `class="rr-name"`))
106+ assert.Contains(t, got[0].html, `aria-label="running"`)
107+
108+ // The other pages do not get the block.
109+ for _, view := range []string{"refs", "commits", "mrs"} {
110+ got, err = (&liveStream{s: srv, repo: "proj", view: view}).render(t.Context(), ev)
111+ require.NoError(t, err, view)
112+ assert.Empty(t, got, view)
113+ }
114+}
115+
116+func TestRunUpdatedOnSummaryWhenCIIsNotReadable(t *testing.T) {
117+ t.Parallel()
118+ repo := repoName(t, "proj")
119+ srv, _ := servingRuns(t, repo, nil, domain.ErrNotFound)
120+
121+ ls := &liveStream{s: srv, repo: "proj", view: "summary"}
122+ got, err := ls.render(t.Context(), ports.WatchEvent{Event: domain.RunUpdated{Run: testRuns(repo, 1)[0]}})
123+ require.NoError(t, err)
124+ require.Len(t, got, 1)
125+ assert.Contains(t, got[0].html, "hidden", "the block empties and hides, no run leaks")
126+ assert.NotContains(t, got[0].html, "rr-name")
127+}
M internal/adapters/web/view.go +11 -0
@@ -153,6 +153,17 @@ type SummaryPage struct {
153153 AllHref string
154154 // Latest is the newest tag, nil when the repository has none.
155155 Latest *LatestTag
156+ // Actions is the recent runs of the rail, nil when the viewer cannot read
157+ // CI (or the server runs none).
158+ Actions *RecentActions
159+}
160+
161+// RecentActions is the "Recent actions" block of the summary rail: the newest
162+// runs. With no run the block is in the page but hidden, so that the first
163+// run reaches it live.
164+type RecentActions struct {
165+ Runs []RunRow
166+ AllHref string
156167 }
157168
158169 // LatestTag is the newest tag of a repository, shown in the summary rail.
@@ -153,6 +153,17 @@ type SummaryPage struct {
153 AllHref string153 AllHref string
154 // Latest is the newest tag, nil when the repository has none.154 // Latest is the newest tag, nil when the repository has none.
155 Latest *LatestTag155 Latest *LatestTag
156+ // Actions is the recent runs of the rail, nil when the viewer cannot read
157+ // CI (or the server runs none).
158+ Actions *RecentActions
159+}
160+
161+// RecentActions is the "Recent actions" block of the summary rail: the newest
162+// runs. With no run the block is in the page but hidden, so that the first
163+// run reaches it live.
164+type RecentActions struct {
165+ Runs []RunRow
166+ AllHref string
156 }167 }
157 168
158 // LatestTag is the newest tag of a repository, shown in the summary rail.169 // LatestTag is the newest tag of a repository, shown in the summary rail.
M internal/app/testdata/web/summary.html +6 -0
@@ -200,6 +200,12 @@
200200 </tbody>
201201 </table>
202202 </section>
203+<section id="recent-actions" aria-labelledby="r-actions" hidden>
204+<h2 id="r-actions">Recent actions <a href="/demo/actions">View all</a>
205+</h2>
206+<ul class="rruns">
207+</ul>
208+</section>
203209 <section aria-labelledby="r-outline">
204210 <h2 id="r-outline">On this page</h2>
205211 <nav class="outline" aria-label="Outline">
@@ -200,6 +200,12 @@
200 </tbody>200 </tbody>
201 </table>201 </table>
202 </section>202 </section>
203+<section id="recent-actions" aria-labelledby="r-actions" hidden>
204+<h2 id="r-actions">Recent actions <a href="/demo/actions">View all</a>
205+</h2>
206+<ul class="rruns">
207+</ul>
208+</section>
203 <section aria-labelledby="r-outline">209 <section aria-labelledby="r-outline">
204 <h2 id="r-outline">On this page</h2>210 <h2 id="r-outline">On this page</h2>
205 <nav class="outline" aria-label="Outline">211 <nav class="outline" aria-label="Outline">

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 2

Tags 42

↑↓ moveenter openesc close