detached 4c072b1

feat(web): the home page lists repositories as rows with recent commits and CI state (#61)

commit
4c072b15186634e3703e8c5f82e779ca62845221
author
brice · · 2026-10-18 09:14 UTC
committer
bnema · · 2026-10-18 09:14 UTC
parent
c117990
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
refs
tag: v0.22.0
12 files · +863 -173
M internal/adapters/web/components.templ +48 -24
@@ -32,34 +32,58 @@ templ CloneBlock(c Clone) {
3232 </div>
3333 }
3434
35-// HomeRowView is the card of a repository in the home list. Its id is the
36-// target of SSE swaps; the name link comes first, `enter` follows it.
35+// HomeRowView is the row of a repository in the home list: what it is, its
36+// latest run, and its newest commits. Its id is the target of SSE swaps; the
37+// name link comes first, `enter` follows it.
3738 templ HomeRowView(r HomeRow) {
3839 <li id={ r.ID } class="repo" data-cursor?={ r.Cursor }>
39- <h2 class="repo-name">
40- <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>
41- if r.Private {
42- <span class="pill">private</span>
43- }
44- if r.Fresh {
45- <span class="pill new">new push</span>
46- }
47- </h2>
48- if r.Desc != "" {
49- <p class="repo-desc">{ r.Desc }</p>
50- } else {
51- <p class="repo-desc mute">no description</p>
52- }
53- <p class="repo-meta">
54- if r.Branch != "" {
55- <span class="repo-branch"><span class="ico i-branch" aria-hidden="true"></span>{ r.Branch }</span>
56- }
57- if r.PushedAt != "" {
58- <span>pushed <time datetime={ r.PushedAt } title={ r.PushedAt }>{ r.Pushed }</time></span>
40+ <div class="repo-main">
41+ <h2 class="repo-name">
42+ <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>
43+ if r.Private {
44+ <span class="pill">private</span>
45+ }
46+ if r.Fresh {
47+ <span class="pill new">new push</span>
48+ }
49+ </h2>
50+ if r.Desc != "" {
51+ <p class="repo-desc">{ r.Desc }</p>
5952 } else {
60- <span>never pushed</span>
53+ <p class="repo-desc mute">no description</p>
6154 }
62- </p>
55+ <p class="repo-meta">
56+ if r.Branch != "" {
57+ <span class="repo-branch"><span class="ico i-branch" aria-hidden="true"></span>{ r.Branch }</span>
58+ }
59+ if r.PushedAt != "" {
60+ <span>pushed <time datetime={ r.PushedAt } title={ r.PushedAt }>{ r.Pushed }</time></span>
61+ } else {
62+ <span>never pushed</span>
63+ }
64+ </p>
65+ </div>
66+ if r.CI != nil {
67+ <a class="repo-ci" href={ templ.SafeURL(r.CI.Href) } title={ r.CI.Title }>
68+ <span class={ "ck", "ck-" + r.CI.Kind } aria-hidden="true"></span>
69+ <span class="sr">CI: </span>{ r.CI.Label }
70+ </a>
71+ }
72+ if len(r.Commits) > 0 {
73+ <ul class="repo-commits" aria-label={ "Recent commits of " + r.Name }>
74+ for _, c := range r.Commits {
75+ <li>
76+ <a class="rc-sha" href={ templ.SafeURL(c.CHref) }>{ c.Short }</a>
77+ <span class="rc-subject" title={ c.Subject }>{ c.Subject }</span>
78+ if c.WhenAt != "" {
79+ <time class="dim" datetime={ c.WhenAt } title={ c.WhenAt }>{ c.When }</time>
80+ }
81+ </li>
82+ }
83+ </ul>
84+ } else if r.NoCommits {
85+ <p class="repo-commits mute">no commits yet</p>
86+ }
6387 </li>
6488 }
6589
@@ -32,34 +32,58 @@ templ CloneBlock(c Clone) {
32 </div>32 </div>
33 }33 }
34 34
35-// HomeRowView is the card of a repository in the home list. Its id is the35+// HomeRowView is the row of a repository in the home list: what it is, its
36-// target of SSE swaps; the name link comes first, `enter` follows it.36+// latest run, and its newest commits. Its id is the target of SSE swaps; the
37+// name link comes first, `enter` follows it.
37 templ HomeRowView(r HomeRow) {38 templ HomeRowView(r HomeRow) {
38 <li id={ r.ID } class="repo" data-cursor?={ r.Cursor }>39 <li id={ r.ID } class="repo" data-cursor?={ r.Cursor }>
39- <h2 class="repo-name">40+ <div class="repo-main">
40- <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>41+ <h2 class="repo-name">
41- if r.Private {42+ <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>
42- <span class="pill">private</span>43+ if r.Private {
43- }44+ <span class="pill">private</span>
44- if r.Fresh {45+ }
45- <span class="pill new">new push</span>46+ if r.Fresh {
46- }47+ <span class="pill new">new push</span>
47- </h2>48+ }
48- if r.Desc != "" {49+ </h2>
49- <p class="repo-desc">{ r.Desc }</p>50+ if r.Desc != "" {
50- } else {51+ <p class="repo-desc">{ r.Desc }</p>
51- <p class="repo-desc mute">no description</p>
52- }
53- <p class="repo-meta">
54- if r.Branch != "" {
55- <span class="repo-branch"><span class="ico i-branch" aria-hidden="true"></span>{ r.Branch }</span>
56- }
57- if r.PushedAt != "" {
58- <span>pushed <time datetime={ r.PushedAt } title={ r.PushedAt }>{ r.Pushed }</time></span>
59 } else {52 } else {
60- <span>never pushed</span>53+ <p class="repo-desc mute">no description</p>
61 }54 }
62- </p>55+ <p class="repo-meta">
56+ if r.Branch != "" {
57+ <span class="repo-branch"><span class="ico i-branch" aria-hidden="true"></span>{ r.Branch }</span>
58+ }
59+ if r.PushedAt != "" {
60+ <span>pushed <time datetime={ r.PushedAt } title={ r.PushedAt }>{ r.Pushed }</time></span>
61+ } else {
62+ <span>never pushed</span>
63+ }
64+ </p>
65+ </div>
66+ if r.CI != nil {
67+ <a class="repo-ci" href={ templ.SafeURL(r.CI.Href) } title={ r.CI.Title }>
68+ <span class={ "ck", "ck-" + r.CI.Kind } aria-hidden="true"></span>
69+ <span class="sr">CI: </span>{ r.CI.Label }
70+ </a>
71+ }
72+ if len(r.Commits) > 0 {
73+ <ul class="repo-commits" aria-label={ "Recent commits of " + r.Name }>
74+ for _, c := range r.Commits {
75+ <li>
76+ <a class="rc-sha" href={ templ.SafeURL(c.CHref) }>{ c.Short }</a>
77+ <span class="rc-subject" title={ c.Subject }>{ c.Subject }</span>
78+ if c.WhenAt != "" {
79+ <time class="dim" datetime={ c.WhenAt } title={ c.WhenAt }>{ c.When }</time>
80+ }
81+ </li>
82+ }
83+ </ul>
84+ } else if r.NoCommits {
85+ <p class="repo-commits mute">no commits yet</p>
86+ }
63 </li>87 </li>
64 }88 }
65 89
M internal/adapters/web/components_templ.go +331 -124
@@ -147,8 +147,9 @@ func CloneBlock(c Clone) templ.Component {
147147 })
148148 }
149149
150-// HomeRowView is the card of a repository in the home list. Its id is the
151-// target of SSE swaps; the name link comes first, `enter` follows it.
150+// HomeRowView is the row of a repository in the home list: what it is, its
151+// latest run, and its newest commits. Its id is the target of SSE swaps; the
152+// name link comes first, `enter` follows it.
152153 func HomeRowView(r HomeRow) templ.Component {
153154 return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
154155 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
@@ -177,7 +178,7 @@ func HomeRowView(r HomeRow) templ.Component {
177178 var templ_7745c5c3_Var8 string
178179 templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ID)
179180 if templ_7745c5c3_Err != nil {
180- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 38, Col: 14}
181+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 39, Col: 14}
181182 }
182183 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
183184 if templ_7745c5c3_Err != nil {
@@ -193,14 +194,14 @@ func HomeRowView(r HomeRow) templ.Component {
193194 return templ_7745c5c3_Err
194195 }
195196 }
196- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "><h2 class=\"repo-name\"><a href=\"")
197+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "><div class=\"repo-main\"><h2 class=\"repo-name\"><a href=\"")
197198 if templ_7745c5c3_Err != nil {
198199 return templ_7745c5c3_Err
199200 }
200201 var templ_7745c5c3_Var9 templ.SafeURL
201202 templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
202203 if templ_7745c5c3_Err != nil {
203- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 40, Col: 34}
204+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 42, Col: 35}
204205 }
205206 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
206207 if templ_7745c5c3_Err != nil {
@@ -213,7 +214,7 @@ func HomeRowView(r HomeRow) templ.Component {
213214 var templ_7745c5c3_Var10 string
214215 templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
215216 if templ_7745c5c3_Err != nil {
216- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 40, Col: 45}
217+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 42, Col: 46}
217218 }
218219 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
219220 if templ_7745c5c3_Err != nil {
@@ -247,7 +248,7 @@ func HomeRowView(r HomeRow) templ.Component {
247248 var templ_7745c5c3_Var11 string
248249 templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(r.Desc)
249250 if templ_7745c5c3_Err != nil {
250- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 49, Col: 32}
251+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 51, Col: 33}
251252 }
252253 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
253254 if templ_7745c5c3_Err != nil {
@@ -275,7 +276,7 @@ func HomeRowView(r HomeRow) templ.Component {
275276 var templ_7745c5c3_Var12 string
276277 templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(r.Branch)
277278 if templ_7745c5c3_Err != nil {
278- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 55, Col: 93}
279+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 57, Col: 94}
279280 }
280281 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
281282 if templ_7745c5c3_Err != nil {
@@ -294,7 +295,7 @@ func HomeRowView(r HomeRow) templ.Component {
294295 var templ_7745c5c3_Var13 string
295296 templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.PushedAt)
296297 if templ_7745c5c3_Err != nil {
297- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 58, Col: 44}
298+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 60, Col: 45}
298299 }
299300 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
300301 if templ_7745c5c3_Err != nil {
@@ -307,7 +308,7 @@ func HomeRowView(r HomeRow) templ.Component {
307308 var templ_7745c5c3_Var14 string
308309 templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.PushedAt)
309310 if templ_7745c5c3_Err != nil {
310- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 58, Col: 65}
311+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 60, Col: 66}
311312 }
312313 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
313314 if templ_7745c5c3_Err != nil {
@@ -320,7 +321,7 @@ func HomeRowView(r HomeRow) templ.Component {
320321 var templ_7745c5c3_Var15 string
321322 templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(r.Pushed)
322323 if templ_7745c5c3_Err != nil {
323- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 58, Col: 78}
324+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 60, Col: 79}
324325 }
325326 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
326327 if templ_7745c5c3_Err != nil {
@@ -336,7 +337,213 @@ func HomeRowView(r HomeRow) templ.Component {
336337 return templ_7745c5c3_Err
337338 }
338339 }
339- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</p></li>")
340+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</p></div>")
341+ if templ_7745c5c3_Err != nil {
342+ return templ_7745c5c3_Err
343+ }
344+ if r.CI != nil {
345+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<a class=\"repo-ci\" href=\"")
346+ if templ_7745c5c3_Err != nil {
347+ return templ_7745c5c3_Err
348+ }
349+ var templ_7745c5c3_Var16 templ.SafeURL
350+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CI.Href))
351+ if templ_7745c5c3_Err != nil {
352+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 67, Col: 53}
353+ }
354+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
355+ if templ_7745c5c3_Err != nil {
356+ return templ_7745c5c3_Err
357+ }
358+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" title=\"")
359+ if templ_7745c5c3_Err != nil {
360+ return templ_7745c5c3_Err
361+ }
362+ var templ_7745c5c3_Var17 string
363+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.CI.Title)
364+ if templ_7745c5c3_Err != nil {
365+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 67, Col: 74}
366+ }
367+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
368+ if templ_7745c5c3_Err != nil {
369+ return templ_7745c5c3_Err
370+ }
371+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\">")
372+ if templ_7745c5c3_Err != nil {
373+ return templ_7745c5c3_Err
374+ }
375+ var templ_7745c5c3_Var18 = []any{"ck", "ck-" + r.CI.Kind}
376+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var18...)
377+ if templ_7745c5c3_Err != nil {
378+ return templ_7745c5c3_Err
379+ }
380+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<span class=\"")
381+ if templ_7745c5c3_Err != nil {
382+ return templ_7745c5c3_Err
383+ }
384+ var templ_7745c5c3_Var19 string
385+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var18).String())
386+ if templ_7745c5c3_Err != nil {
387+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 1, Col: 0}
388+ }
389+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
390+ if templ_7745c5c3_Err != nil {
391+ return templ_7745c5c3_Err
392+ }
393+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\" aria-hidden=\"true\"></span> <span class=\"sr\">CI: </span>")
394+ if templ_7745c5c3_Err != nil {
395+ return templ_7745c5c3_Err
396+ }
397+ var templ_7745c5c3_Var20 string
398+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(r.CI.Label)
399+ if templ_7745c5c3_Err != nil {
400+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 69, Col: 44}
401+ }
402+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
403+ if templ_7745c5c3_Err != nil {
404+ return templ_7745c5c3_Err
405+ }
406+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</a> ")
407+ if templ_7745c5c3_Err != nil {
408+ return templ_7745c5c3_Err
409+ }
410+ }
411+ if len(r.Commits) > 0 {
412+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<ul class=\"repo-commits\" aria-label=\"")
413+ if templ_7745c5c3_Err != nil {
414+ return templ_7745c5c3_Err
415+ }
416+ var templ_7745c5c3_Var21 string
417+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("Recent commits of " + r.Name)
418+ if templ_7745c5c3_Err != nil {
419+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 73, Col: 70}
420+ }
421+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
422+ if templ_7745c5c3_Err != nil {
423+ return templ_7745c5c3_Err
424+ }
425+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\">")
426+ if templ_7745c5c3_Err != nil {
427+ return templ_7745c5c3_Err
428+ }
429+ for _, c := range r.Commits {
430+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<li><a class=\"rc-sha\" href=\"")
431+ if templ_7745c5c3_Err != nil {
432+ return templ_7745c5c3_Err
433+ }
434+ var templ_7745c5c3_Var22 templ.SafeURL
435+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
436+ if templ_7745c5c3_Err != nil {
437+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 76, Col: 53}
438+ }
439+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
440+ if templ_7745c5c3_Err != nil {
441+ return templ_7745c5c3_Err
442+ }
443+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\">")
444+ if templ_7745c5c3_Err != nil {
445+ return templ_7745c5c3_Err
446+ }
447+ var templ_7745c5c3_Var23 string
448+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
449+ if templ_7745c5c3_Err != nil {
450+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 76, Col: 65}
451+ }
452+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
453+ if templ_7745c5c3_Err != nil {
454+ return templ_7745c5c3_Err
455+ }
456+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</a> <span class=\"rc-subject\" title=\"")
457+ if templ_7745c5c3_Err != nil {
458+ return templ_7745c5c3_Err
459+ }
460+ var templ_7745c5c3_Var24 string
461+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.Subject)
462+ if templ_7745c5c3_Err != nil {
463+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 77, Col: 48}
464+ }
465+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
466+ if templ_7745c5c3_Err != nil {
467+ return templ_7745c5c3_Err
468+ }
469+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\">")
470+ if templ_7745c5c3_Err != nil {
471+ return templ_7745c5c3_Err
472+ }
473+ var templ_7745c5c3_Var25 string
474+ templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
475+ if templ_7745c5c3_Err != nil {
476+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 77, Col: 62}
477+ }
478+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
479+ if templ_7745c5c3_Err != nil {
480+ return templ_7745c5c3_Err
481+ }
482+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</span> ")
483+ if templ_7745c5c3_Err != nil {
484+ return templ_7745c5c3_Err
485+ }
486+ if c.WhenAt != "" {
487+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<time class=\"dim\" datetime=\"")
488+ if templ_7745c5c3_Err != nil {
489+ return templ_7745c5c3_Err
490+ }
491+ var templ_7745c5c3_Var26 string
492+ templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
493+ if templ_7745c5c3_Err != nil {
494+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 79, Col: 44}
495+ }
496+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
497+ if templ_7745c5c3_Err != nil {
498+ return templ_7745c5c3_Err
499+ }
500+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" title=\"")
501+ if templ_7745c5c3_Err != nil {
502+ return templ_7745c5c3_Err
503+ }
504+ var templ_7745c5c3_Var27 string
505+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
506+ if templ_7745c5c3_Err != nil {
507+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 79, Col: 63}
508+ }
509+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
510+ if templ_7745c5c3_Err != nil {
511+ return templ_7745c5c3_Err
512+ }
513+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\">")
514+ if templ_7745c5c3_Err != nil {
515+ return templ_7745c5c3_Err
516+ }
517+ var templ_7745c5c3_Var28 string
518+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
519+ if templ_7745c5c3_Err != nil {
520+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 79, Col: 74}
521+ }
522+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
523+ if templ_7745c5c3_Err != nil {
524+ return templ_7745c5c3_Err
525+ }
526+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</time>")
527+ if templ_7745c5c3_Err != nil {
528+ return templ_7745c5c3_Err
529+ }
530+ }
531+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</li>")
532+ if templ_7745c5c3_Err != nil {
533+ return templ_7745c5c3_Err
534+ }
535+ }
536+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</ul>")
537+ if templ_7745c5c3_Err != nil {
538+ return templ_7745c5c3_Err
539+ }
540+ } else if r.NoCommits {
541+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<p class=\"repo-commits mute\">no commits yet</p>")
542+ if templ_7745c5c3_Err != nil {
543+ return templ_7745c5c3_Err
544+ }
545+ }
546+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</li>")
340547 if templ_7745c5c3_Err != nil {
341548 return templ_7745c5c3_Err
342549 }
@@ -361,66 +568,66 @@ func Outline(headings []Heading) templ.Component {
361568 }()
362569 }
363570 ctx = templ.InitializeContext(ctx)
364- templ_7745c5c3_Var16 := templ.GetChildren(ctx)
365- if templ_7745c5c3_Var16 == nil {
366- templ_7745c5c3_Var16 = templ.NopComponent
571+ templ_7745c5c3_Var29 := templ.GetChildren(ctx)
572+ if templ_7745c5c3_Var29 == nil {
573+ templ_7745c5c3_Var29 = templ.NopComponent
367574 }
368575 ctx = templ.ClearChildren(ctx)
369- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<nav class=\"outline\" aria-label=\"Outline\">")
576+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<nav class=\"outline\" aria-label=\"Outline\">")
370577 if templ_7745c5c3_Err != nil {
371578 return templ_7745c5c3_Err
372579 }
373580 for _, h := range headings {
374- var templ_7745c5c3_Var17 = []any{"l" + itoa(h.Level)}
375- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var17...)
581+ var templ_7745c5c3_Var30 = []any{"l" + itoa(h.Level)}
582+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var30...)
376583 if templ_7745c5c3_Err != nil {
377584 return templ_7745c5c3_Err
378585 }
379- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<a class=\"")
586+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<a class=\"")
380587 if templ_7745c5c3_Err != nil {
381588 return templ_7745c5c3_Err
382589 }
383- var templ_7745c5c3_Var18 string
384- templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var17).String())
590+ var templ_7745c5c3_Var31 string
591+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var30).String())
385592 if templ_7745c5c3_Err != nil {
386593 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 1, Col: 0}
387594 }
388- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
595+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
389596 if templ_7745c5c3_Err != nil {
390597 return templ_7745c5c3_Err
391598 }
392- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" href=\"")
599+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\" href=\"")
393600 if templ_7745c5c3_Err != nil {
394601 return templ_7745c5c3_Err
395602 }
396- var templ_7745c5c3_Var19 templ.SafeURL
397- templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("#" + h.ID))
603+ var templ_7745c5c3_Var32 templ.SafeURL
604+ templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("#" + h.ID))
398605 if templ_7745c5c3_Err != nil {
399- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 70, Col: 68}
606+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 94, Col: 68}
400607 }
401- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
608+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
402609 if templ_7745c5c3_Err != nil {
403610 return templ_7745c5c3_Err
404611 }
405- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\">")
612+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\">")
406613 if templ_7745c5c3_Err != nil {
407614 return templ_7745c5c3_Err
408615 }
409- var templ_7745c5c3_Var20 string
410- templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(h.Text)
616+ var templ_7745c5c3_Var33 string
617+ templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(h.Text)
411618 if templ_7745c5c3_Err != nil {
412- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 70, Col: 79}
619+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 94, Col: 79}
413620 }
414- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
621+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
415622 if templ_7745c5c3_Err != nil {
416623 return templ_7745c5c3_Err
417624 }
418- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</a>")
625+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</a>")
419626 if templ_7745c5c3_Err != nil {
420627 return templ_7745c5c3_Err
421628 }
422629 }
423- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</nav>")
630+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</nav>")
424631 if templ_7745c5c3_Err != nil {
425632 return templ_7745c5c3_Err
426633 }
@@ -445,25 +652,25 @@ func ReadmeCard(r Readme) templ.Component {
445652 }()
446653 }
447654 ctx = templ.InitializeContext(ctx)
448- templ_7745c5c3_Var21 := templ.GetChildren(ctx)
449- if templ_7745c5c3_Var21 == nil {
450- templ_7745c5c3_Var21 = templ.NopComponent
655+ templ_7745c5c3_Var34 := templ.GetChildren(ctx)
656+ if templ_7745c5c3_Var34 == nil {
657+ templ_7745c5c3_Var34 = templ.NopComponent
451658 }
452659 ctx = templ.ClearChildren(ctx)
453- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<section class=\"card readme\" aria-labelledby=\"readme-h\"><h2 class=\"card-h\" id=\"readme-h\"><span class=\"ico i-file\" aria-hidden=\"true\"></span> ")
660+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<section class=\"card readme\" aria-labelledby=\"readme-h\"><h2 class=\"card-h\" id=\"readme-h\"><span class=\"ico i-file\" aria-hidden=\"true\"></span> ")
454661 if templ_7745c5c3_Err != nil {
455662 return templ_7745c5c3_Err
456663 }
457- var templ_7745c5c3_Var22 string
458- templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
664+ var templ_7745c5c3_Var35 string
665+ templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
459666 if templ_7745c5c3_Err != nil {
460- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 80, Col: 11}
667+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 104, Col: 11}
461668 }
462- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
669+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
463670 if templ_7745c5c3_Err != nil {
464671 return templ_7745c5c3_Err
465672 }
466- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</h2>")
673+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</h2>")
467674 if templ_7745c5c3_Err != nil {
468675 return templ_7745c5c3_Err
469676 }
@@ -471,7 +678,7 @@ func ReadmeCard(r Readme) templ.Component {
471678 if templ_7745c5c3_Err != nil {
472679 return templ_7745c5c3_Err
473680 }
474- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</section>")
681+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</section>")
475682 if templ_7745c5c3_Err != nil {
476683 return templ_7745c5c3_Err
477684 }
@@ -497,43 +704,43 @@ func ReadmeView(r Readme) templ.Component {
497704 }()
498705 }
499706 ctx = templ.InitializeContext(ctx)
500- templ_7745c5c3_Var23 := templ.GetChildren(ctx)
501- if templ_7745c5c3_Var23 == nil {
502- templ_7745c5c3_Var23 = templ.NopComponent
707+ templ_7745c5c3_Var36 := templ.GetChildren(ctx)
708+ if templ_7745c5c3_Var36 == nil {
709+ templ_7745c5c3_Var36 = templ.NopComponent
503710 }
504711 ctx = templ.ClearChildren(ctx)
505- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<article class=\"prose\" aria-label=\"")
712+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<article class=\"prose\" aria-label=\"")
506713 if templ_7745c5c3_Err != nil {
507714 return templ_7745c5c3_Err
508715 }
509- var templ_7745c5c3_Var24 string
510- templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name)
716+ var templ_7745c5c3_Var37 string
717+ templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name)
511718 if templ_7745c5c3_Err != nil {
512- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 89, Col: 43}
719+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 113, Col: 43}
513720 }
514- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
721+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
515722 if templ_7745c5c3_Err != nil {
516723 return templ_7745c5c3_Err
517724 }
518- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\" hx-ignore>")
725+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" hx-ignore>")
519726 if templ_7745c5c3_Err != nil {
520727 return templ_7745c5c3_Err
521728 }
522729 if r.Note != "" {
523- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<p class=\"dim\">")
730+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<p class=\"dim\">")
524731 if templ_7745c5c3_Err != nil {
525732 return templ_7745c5c3_Err
526733 }
527- var templ_7745c5c3_Var25 string
528- templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(r.Note)
734+ var templ_7745c5c3_Var38 string
735+ templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(r.Note)
529736 if templ_7745c5c3_Err != nil {
530- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 91, Col: 26}
737+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 115, Col: 26}
531738 }
532- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
739+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
533740 if templ_7745c5c3_Err != nil {
534741 return templ_7745c5c3_Err
535742 }
536- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</p>")
743+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</p>")
537744 if templ_7745c5c3_Err != nil {
538745 return templ_7745c5c3_Err
539746 }
@@ -543,7 +750,7 @@ func ReadmeView(r Readme) templ.Component {
543750 return templ_7745c5c3_Err
544751 }
545752 }
546- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</article>")
753+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</article>")
547754 if templ_7745c5c3_Err != nil {
548755 return templ_7745c5c3_Err
549756 }
@@ -569,133 +776,133 @@ func CommitRows(rows []CommitRow) templ.Component {
569776 }()
570777 }
571778 ctx = templ.InitializeContext(ctx)
572- templ_7745c5c3_Var26 := templ.GetChildren(ctx)
573- if templ_7745c5c3_Var26 == nil {
574- templ_7745c5c3_Var26 = templ.NopComponent
779+ templ_7745c5c3_Var39 := templ.GetChildren(ctx)
780+ if templ_7745c5c3_Var39 == nil {
781+ templ_7745c5c3_Var39 = templ.NopComponent
575782 }
576783 ctx = templ.ClearChildren(ctx)
577- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<table class=\"list commits\" aria-label=\"Commits\"><tbody data-rows>")
784+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<table class=\"list commits\" aria-label=\"Commits\"><tbody data-rows>")
578785 if templ_7745c5c3_Err != nil {
579786 return templ_7745c5c3_Err
580787 }
581788 for _, c := range rows {
582- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<tr")
789+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<tr")
583790 if templ_7745c5c3_Err != nil {
584791 return templ_7745c5c3_Err
585792 }
586793 if c.ID != "" {
587- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " id=\"")
794+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, " id=\"")
588795 if templ_7745c5c3_Err != nil {
589796 return templ_7745c5c3_Err
590797 }
591- var templ_7745c5c3_Var27 string
592- templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.ID)
798+ var templ_7745c5c3_Var40 string
799+ templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.ID)
593800 if templ_7745c5c3_Err != nil {
594- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 106, Col: 15}
801+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 130, Col: 15}
595802 }
596- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
803+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var40)
597804 if templ_7745c5c3_Err != nil {
598805 return templ_7745c5c3_Err
599806 }
600- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\"")
807+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\"")
601808 if templ_7745c5c3_Err != nil {
602809 return templ_7745c5c3_Err
603810 }
604811 }
605812 if c.Cursor {
606- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " data-cursor")
813+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, " data-cursor")
607814 if templ_7745c5c3_Err != nil {
608815 return templ_7745c5c3_Err
609816 }
610817 }
611- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "><td class=\"cur\" aria-hidden=\"true\"></td><td class=\"c-sha mute\">")
818+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "><td class=\"cur\" aria-hidden=\"true\"></td><td class=\"c-sha mute\">")
612819 if templ_7745c5c3_Err != nil {
613820 return templ_7745c5c3_Err
614821 }
615- var templ_7745c5c3_Var28 string
616- templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
822+ var templ_7745c5c3_Var41 string
823+ templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
617824 if templ_7745c5c3_Err != nil {
618- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 111, Col: 37}
825+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 135, Col: 37}
619826 }
620- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
827+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
621828 if templ_7745c5c3_Err != nil {
622829 return templ_7745c5c3_Err
623830 }
624- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</td><td class=\"c-subject\"><a href=\"")
831+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</td><td class=\"c-subject\"><a href=\"")
625832 if templ_7745c5c3_Err != nil {
626833 return templ_7745c5c3_Err
627834 }
628- var templ_7745c5c3_Var29 templ.SafeURL
629- templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
835+ var templ_7745c5c3_Var42 templ.SafeURL
836+ templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
630837 if templ_7745c5c3_Err != nil {
631- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 113, Col: 38}
838+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 137, Col: 38}
632839 }
633- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
840+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
634841 if templ_7745c5c3_Err != nil {
635842 return templ_7745c5c3_Err
636843 }
637- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\">")
844+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\">")
638845 if templ_7745c5c3_Err != nil {
639846 return templ_7745c5c3_Err
640847 }
641- var templ_7745c5c3_Var30 string
642- templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
848+ var templ_7745c5c3_Var43 string
849+ templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
643850 if templ_7745c5c3_Err != nil {
644- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 113, Col: 52}
851+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 137, Col: 52}
645852 }
646- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
853+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
647854 if templ_7745c5c3_Err != nil {
648855 return templ_7745c5c3_Err
649856 }
650- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</a> ")
857+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</a> ")
651858 if templ_7745c5c3_Err != nil {
652859 return templ_7745c5c3_Err
653860 }
654861 if c.Merge {
655- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<span class=\"pill\">merge</span> ")
862+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<span class=\"pill\">merge</span> ")
656863 if templ_7745c5c3_Err != nil {
657864 return templ_7745c5c3_Err
658865 }
659866 }
660867 for _, b := range c.Branches {
661- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<span class=\"pill\">")
868+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<span class=\"pill\">")
662869 if templ_7745c5c3_Err != nil {
663870 return templ_7745c5c3_Err
664871 }
665- var templ_7745c5c3_Var31 string
666- templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(b)
872+ var templ_7745c5c3_Var44 string
873+ templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(b)
667874 if templ_7745c5c3_Err != nil {
668- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 118, Col: 29}
875+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 142, Col: 29}
669876 }
670- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
877+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
671878 if templ_7745c5c3_Err != nil {
672879 return templ_7745c5c3_Err
673880 }
674- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</span> ")
881+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</span> ")
675882 if templ_7745c5c3_Err != nil {
676883 return templ_7745c5c3_Err
677884 }
678885 }
679886 for _, t := range c.Tags {
680- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<span class=\"pill tag\"><span class=\"sr\">tag </span>")
887+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<span class=\"pill tag\"><span class=\"sr\">tag </span>")
681888 if templ_7745c5c3_Err != nil {
682889 return templ_7745c5c3_Err
683890 }
684- var templ_7745c5c3_Var32 string
685- templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(t)
891+ var templ_7745c5c3_Var45 string
892+ templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(t)
686893 if templ_7745c5c3_Err != nil {
687- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 121, Col: 61}
894+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 145, Col: 61}
688895 }
689- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
896+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
690897 if templ_7745c5c3_Err != nil {
691898 return templ_7745c5c3_Err
692899 }
693- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</span>")
900+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</span>")
694901 if templ_7745c5c3_Err != nil {
695902 return templ_7745c5c3_Err
696903 }
697904 }
698- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</td><td class=\"c-ck\">")
905+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</td><td class=\"c-ck\">")
699906 if templ_7745c5c3_Err != nil {
700907 return templ_7745c5c3_Err
701908 }
@@ -703,64 +910,64 @@ func CommitRows(rows []CommitRow) templ.Component {
703910 if templ_7745c5c3_Err != nil {
704911 return templ_7745c5c3_Err
705912 }
706- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</td><td class=\"c-who dim\">")
913+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</td><td class=\"c-who dim\">")
707914 if templ_7745c5c3_Err != nil {
708915 return templ_7745c5c3_Err
709916 }
710- var templ_7745c5c3_Var33 string
711- templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
917+ var templ_7745c5c3_Var46 string
918+ templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
712919 if templ_7745c5c3_Err != nil {
713- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 125, Col: 37}
920+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 149, Col: 37}
714921 }
715- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
922+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
716923 if templ_7745c5c3_Err != nil {
717924 return templ_7745c5c3_Err
718925 }
719- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</td><td class=\"c-when nowrap dim\"><time datetime=\"")
926+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</td><td class=\"c-when nowrap dim\"><time datetime=\"")
720927 if templ_7745c5c3_Err != nil {
721928 return templ_7745c5c3_Err
722929 }
723- var templ_7745c5c3_Var34 string
724- templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
930+ var templ_7745c5c3_Var47 string
931+ templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
725932 if templ_7745c5c3_Err != nil {
726- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 126, Col: 60}
933+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 150, Col: 60}
727934 }
728- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
935+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47)
729936 if templ_7745c5c3_Err != nil {
730937 return templ_7745c5c3_Err
731938 }
732- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" title=\"")
939+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\" title=\"")
733940 if templ_7745c5c3_Err != nil {
734941 return templ_7745c5c3_Err
735942 }
736- var templ_7745c5c3_Var35 string
737- templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
943+ var templ_7745c5c3_Var48 string
944+ templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
738945 if templ_7745c5c3_Err != nil {
739- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 126, Col: 79}
946+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 150, Col: 79}
740947 }
741- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
948+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var48)
742949 if templ_7745c5c3_Err != nil {
743950 return templ_7745c5c3_Err
744951 }
745- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\">")
952+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "\">")
746953 if templ_7745c5c3_Err != nil {
747954 return templ_7745c5c3_Err
748955 }
749- var templ_7745c5c3_Var36 string
750- templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
956+ var templ_7745c5c3_Var49 string
957+ templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
751958 if templ_7745c5c3_Err != nil {
752- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 126, Col: 90}
959+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 150, Col: 90}
753960 }
754- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
961+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
755962 if templ_7745c5c3_Err != nil {
756963 return templ_7745c5c3_Err
757964 }
758- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</time></td></tr>")
965+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</time></td></tr>")
759966 if templ_7745c5c3_Err != nil {
760967 return templ_7745c5c3_Err
761968 }
762969 }
763- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</tbody></table>")
970+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</tbody></table>")
764971 if templ_7745c5c3_Err != nil {
765972 return templ_7745c5c3_Err
766973 }
@@ -147,8 +147,9 @@ func CloneBlock(c Clone) templ.Component {
147 })147 })
148 }148 }
149 149
150-// HomeRowView is the card of a repository in the home list. Its id is the150+// HomeRowView is the row of a repository in the home list: what it is, its
151-// target of SSE swaps; the name link comes first, `enter` follows it.151+// latest run, and its newest commits. Its id is the target of SSE swaps; the
152+// name link comes first, `enter` follows it.
152 func HomeRowView(r HomeRow) templ.Component {153 func HomeRowView(r HomeRow) templ.Component {
153 return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {154 return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
154 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context155 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
@@ -177,7 +178,7 @@ func HomeRowView(r HomeRow) templ.Component {
177 var templ_7745c5c3_Var8 string178 var templ_7745c5c3_Var8 string
178 templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ID)179 templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ID)
179 if templ_7745c5c3_Err != nil {180 if templ_7745c5c3_Err != nil {
180- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 38, Col: 14}181+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 39, Col: 14}
181 }182 }
182 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)183 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
183 if templ_7745c5c3_Err != nil {184 if templ_7745c5c3_Err != nil {
@@ -193,14 +194,14 @@ func HomeRowView(r HomeRow) templ.Component {
193 return templ_7745c5c3_Err194 return templ_7745c5c3_Err
194 }195 }
195 }196 }
196- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "><h2 class=\"repo-name\"><a href=\"")197+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "><div class=\"repo-main\"><h2 class=\"repo-name\"><a href=\"")
197 if templ_7745c5c3_Err != nil {198 if templ_7745c5c3_Err != nil {
198 return templ_7745c5c3_Err199 return templ_7745c5c3_Err
199 }200 }
200 var templ_7745c5c3_Var9 templ.SafeURL201 var templ_7745c5c3_Var9 templ.SafeURL
201 templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))202 templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
202 if templ_7745c5c3_Err != nil {203 if templ_7745c5c3_Err != nil {
203- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 40, Col: 34}204+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 42, Col: 35}
204 }205 }
205 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))206 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
206 if templ_7745c5c3_Err != nil {207 if templ_7745c5c3_Err != nil {
@@ -213,7 +214,7 @@ func HomeRowView(r HomeRow) templ.Component {
213 var templ_7745c5c3_Var10 string214 var templ_7745c5c3_Var10 string
214 templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)215 templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
215 if templ_7745c5c3_Err != nil {216 if templ_7745c5c3_Err != nil {
216- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 40, Col: 45}217+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 42, Col: 46}
217 }218 }
218 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))219 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
219 if templ_7745c5c3_Err != nil {220 if templ_7745c5c3_Err != nil {
@@ -247,7 +248,7 @@ func HomeRowView(r HomeRow) templ.Component {
247 var templ_7745c5c3_Var11 string248 var templ_7745c5c3_Var11 string
248 templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(r.Desc)249 templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(r.Desc)
249 if templ_7745c5c3_Err != nil {250 if templ_7745c5c3_Err != nil {
250- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 49, Col: 32}251+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 51, Col: 33}
251 }252 }
252 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))253 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
253 if templ_7745c5c3_Err != nil {254 if templ_7745c5c3_Err != nil {
@@ -275,7 +276,7 @@ func HomeRowView(r HomeRow) templ.Component {
275 var templ_7745c5c3_Var12 string276 var templ_7745c5c3_Var12 string
276 templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(r.Branch)277 templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(r.Branch)
277 if templ_7745c5c3_Err != nil {278 if templ_7745c5c3_Err != nil {
278- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 55, Col: 93}279+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 57, Col: 94}
279 }280 }
280 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))281 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
281 if templ_7745c5c3_Err != nil {282 if templ_7745c5c3_Err != nil {
@@ -294,7 +295,7 @@ func HomeRowView(r HomeRow) templ.Component {
294 var templ_7745c5c3_Var13 string295 var templ_7745c5c3_Var13 string
295 templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.PushedAt)296 templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.PushedAt)
296 if templ_7745c5c3_Err != nil {297 if templ_7745c5c3_Err != nil {
297- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 58, Col: 44}298+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 60, Col: 45}
298 }299 }
299 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)300 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
300 if templ_7745c5c3_Err != nil {301 if templ_7745c5c3_Err != nil {
@@ -307,7 +308,7 @@ func HomeRowView(r HomeRow) templ.Component {
307 var templ_7745c5c3_Var14 string308 var templ_7745c5c3_Var14 string
308 templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.PushedAt)309 templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.PushedAt)
309 if templ_7745c5c3_Err != nil {310 if templ_7745c5c3_Err != nil {
310- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 58, Col: 65}311+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 60, Col: 66}
311 }312 }
312 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)313 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
313 if templ_7745c5c3_Err != nil {314 if templ_7745c5c3_Err != nil {
@@ -320,7 +321,7 @@ func HomeRowView(r HomeRow) templ.Component {
320 var templ_7745c5c3_Var15 string321 var templ_7745c5c3_Var15 string
321 templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(r.Pushed)322 templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(r.Pushed)
322 if templ_7745c5c3_Err != nil {323 if templ_7745c5c3_Err != nil {
323- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 58, Col: 78}324+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 60, Col: 79}
324 }325 }
325 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))326 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
326 if templ_7745c5c3_Err != nil {327 if templ_7745c5c3_Err != nil {
@@ -336,7 +337,213 @@ func HomeRowView(r HomeRow) templ.Component {
336 return templ_7745c5c3_Err337 return templ_7745c5c3_Err
337 }338 }
338 }339 }
339- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</p></li>")340+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</p></div>")
341+ if templ_7745c5c3_Err != nil {
342+ return templ_7745c5c3_Err
343+ }
344+ if r.CI != nil {
345+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<a class=\"repo-ci\" href=\"")
346+ if templ_7745c5c3_Err != nil {
347+ return templ_7745c5c3_Err
348+ }
349+ var templ_7745c5c3_Var16 templ.SafeURL
350+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CI.Href))
351+ if templ_7745c5c3_Err != nil {
352+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 67, Col: 53}
353+ }
354+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
355+ if templ_7745c5c3_Err != nil {
356+ return templ_7745c5c3_Err
357+ }
358+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" title=\"")
359+ if templ_7745c5c3_Err != nil {
360+ return templ_7745c5c3_Err
361+ }
362+ var templ_7745c5c3_Var17 string
363+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.CI.Title)
364+ if templ_7745c5c3_Err != nil {
365+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 67, Col: 74}
366+ }
367+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
368+ if templ_7745c5c3_Err != nil {
369+ return templ_7745c5c3_Err
370+ }
371+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\">")
372+ if templ_7745c5c3_Err != nil {
373+ return templ_7745c5c3_Err
374+ }
375+ var templ_7745c5c3_Var18 = []any{"ck", "ck-" + r.CI.Kind}
376+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var18...)
377+ if templ_7745c5c3_Err != nil {
378+ return templ_7745c5c3_Err
379+ }
380+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<span class=\"")
381+ if templ_7745c5c3_Err != nil {
382+ return templ_7745c5c3_Err
383+ }
384+ var templ_7745c5c3_Var19 string
385+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var18).String())
386+ if templ_7745c5c3_Err != nil {
387+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 1, Col: 0}
388+ }
389+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
390+ if templ_7745c5c3_Err != nil {
391+ return templ_7745c5c3_Err
392+ }
393+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\" aria-hidden=\"true\"></span> <span class=\"sr\">CI: </span>")
394+ if templ_7745c5c3_Err != nil {
395+ return templ_7745c5c3_Err
396+ }
397+ var templ_7745c5c3_Var20 string
398+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(r.CI.Label)
399+ if templ_7745c5c3_Err != nil {
400+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 69, Col: 44}
401+ }
402+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
403+ if templ_7745c5c3_Err != nil {
404+ return templ_7745c5c3_Err
405+ }
406+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</a> ")
407+ if templ_7745c5c3_Err != nil {
408+ return templ_7745c5c3_Err
409+ }
410+ }
411+ if len(r.Commits) > 0 {
412+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<ul class=\"repo-commits\" aria-label=\"")
413+ if templ_7745c5c3_Err != nil {
414+ return templ_7745c5c3_Err
415+ }
416+ var templ_7745c5c3_Var21 string
417+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("Recent commits of " + r.Name)
418+ if templ_7745c5c3_Err != nil {
419+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 73, Col: 70}
420+ }
421+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
422+ if templ_7745c5c3_Err != nil {
423+ return templ_7745c5c3_Err
424+ }
425+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\">")
426+ if templ_7745c5c3_Err != nil {
427+ return templ_7745c5c3_Err
428+ }
429+ for _, c := range r.Commits {
430+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<li><a class=\"rc-sha\" href=\"")
431+ if templ_7745c5c3_Err != nil {
432+ return templ_7745c5c3_Err
433+ }
434+ var templ_7745c5c3_Var22 templ.SafeURL
435+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
436+ if templ_7745c5c3_Err != nil {
437+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 76, Col: 53}
438+ }
439+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
440+ if templ_7745c5c3_Err != nil {
441+ return templ_7745c5c3_Err
442+ }
443+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\">")
444+ if templ_7745c5c3_Err != nil {
445+ return templ_7745c5c3_Err
446+ }
447+ var templ_7745c5c3_Var23 string
448+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
449+ if templ_7745c5c3_Err != nil {
450+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 76, Col: 65}
451+ }
452+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
453+ if templ_7745c5c3_Err != nil {
454+ return templ_7745c5c3_Err
455+ }
456+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</a> <span class=\"rc-subject\" title=\"")
457+ if templ_7745c5c3_Err != nil {
458+ return templ_7745c5c3_Err
459+ }
460+ var templ_7745c5c3_Var24 string
461+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.Subject)
462+ if templ_7745c5c3_Err != nil {
463+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 77, Col: 48}
464+ }
465+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
466+ if templ_7745c5c3_Err != nil {
467+ return templ_7745c5c3_Err
468+ }
469+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\">")
470+ if templ_7745c5c3_Err != nil {
471+ return templ_7745c5c3_Err
472+ }
473+ var templ_7745c5c3_Var25 string
474+ templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
475+ if templ_7745c5c3_Err != nil {
476+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 77, Col: 62}
477+ }
478+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
479+ if templ_7745c5c3_Err != nil {
480+ return templ_7745c5c3_Err
481+ }
482+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</span> ")
483+ if templ_7745c5c3_Err != nil {
484+ return templ_7745c5c3_Err
485+ }
486+ if c.WhenAt != "" {
487+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<time class=\"dim\" datetime=\"")
488+ if templ_7745c5c3_Err != nil {
489+ return templ_7745c5c3_Err
490+ }
491+ var templ_7745c5c3_Var26 string
492+ templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
493+ if templ_7745c5c3_Err != nil {
494+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 79, Col: 44}
495+ }
496+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
497+ if templ_7745c5c3_Err != nil {
498+ return templ_7745c5c3_Err
499+ }
500+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" title=\"")
501+ if templ_7745c5c3_Err != nil {
502+ return templ_7745c5c3_Err
503+ }
504+ var templ_7745c5c3_Var27 string
505+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
506+ if templ_7745c5c3_Err != nil {
507+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 79, Col: 63}
508+ }
509+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
510+ if templ_7745c5c3_Err != nil {
511+ return templ_7745c5c3_Err
512+ }
513+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\">")
514+ if templ_7745c5c3_Err != nil {
515+ return templ_7745c5c3_Err
516+ }
517+ var templ_7745c5c3_Var28 string
518+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
519+ if templ_7745c5c3_Err != nil {
520+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 79, Col: 74}
521+ }
522+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
523+ if templ_7745c5c3_Err != nil {
524+ return templ_7745c5c3_Err
525+ }
526+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</time>")
527+ if templ_7745c5c3_Err != nil {
528+ return templ_7745c5c3_Err
529+ }
530+ }
531+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</li>")
532+ if templ_7745c5c3_Err != nil {
533+ return templ_7745c5c3_Err
534+ }
535+ }
536+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</ul>")
537+ if templ_7745c5c3_Err != nil {
538+ return templ_7745c5c3_Err
539+ }
540+ } else if r.NoCommits {
541+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<p class=\"repo-commits mute\">no commits yet</p>")
542+ if templ_7745c5c3_Err != nil {
543+ return templ_7745c5c3_Err
544+ }
545+ }
546+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</li>")
340 if templ_7745c5c3_Err != nil {547 if templ_7745c5c3_Err != nil {
341 return templ_7745c5c3_Err548 return templ_7745c5c3_Err
342 }549 }
@@ -361,66 +568,66 @@ func Outline(headings []Heading) templ.Component {
361 }()568 }()
362 }569 }
363 ctx = templ.InitializeContext(ctx)570 ctx = templ.InitializeContext(ctx)
364- templ_7745c5c3_Var16 := templ.GetChildren(ctx)571+ templ_7745c5c3_Var29 := templ.GetChildren(ctx)
365- if templ_7745c5c3_Var16 == nil {572+ if templ_7745c5c3_Var29 == nil {
366- templ_7745c5c3_Var16 = templ.NopComponent573+ templ_7745c5c3_Var29 = templ.NopComponent
367 }574 }
368 ctx = templ.ClearChildren(ctx)575 ctx = templ.ClearChildren(ctx)
369- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<nav class=\"outline\" aria-label=\"Outline\">")576+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<nav class=\"outline\" aria-label=\"Outline\">")
370 if templ_7745c5c3_Err != nil {577 if templ_7745c5c3_Err != nil {
371 return templ_7745c5c3_Err578 return templ_7745c5c3_Err
372 }579 }
373 for _, h := range headings {580 for _, h := range headings {
374- var templ_7745c5c3_Var17 = []any{"l" + itoa(h.Level)}581+ var templ_7745c5c3_Var30 = []any{"l" + itoa(h.Level)}
375- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var17...)582+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var30...)
376 if templ_7745c5c3_Err != nil {583 if templ_7745c5c3_Err != nil {
377 return templ_7745c5c3_Err584 return templ_7745c5c3_Err
378 }585 }
379- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<a class=\"")586+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<a class=\"")
380 if templ_7745c5c3_Err != nil {587 if templ_7745c5c3_Err != nil {
381 return templ_7745c5c3_Err588 return templ_7745c5c3_Err
382 }589 }
383- var templ_7745c5c3_Var18 string590+ var templ_7745c5c3_Var31 string
384- templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var17).String())591+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var30).String())
385 if templ_7745c5c3_Err != nil {592 if templ_7745c5c3_Err != nil {
386 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 1, Col: 0}593 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 1, Col: 0}
387 }594 }
388- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)595+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
389 if templ_7745c5c3_Err != nil {596 if templ_7745c5c3_Err != nil {
390 return templ_7745c5c3_Err597 return templ_7745c5c3_Err
391 }598 }
392- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" href=\"")599+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\" href=\"")
393 if templ_7745c5c3_Err != nil {600 if templ_7745c5c3_Err != nil {
394 return templ_7745c5c3_Err601 return templ_7745c5c3_Err
395 }602 }
396- var templ_7745c5c3_Var19 templ.SafeURL603+ var templ_7745c5c3_Var32 templ.SafeURL
397- templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("#" + h.ID))604+ templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("#" + h.ID))
398 if templ_7745c5c3_Err != nil {605 if templ_7745c5c3_Err != nil {
399- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 70, Col: 68}606+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 94, Col: 68}
400 }607 }
401- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))608+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
402 if templ_7745c5c3_Err != nil {609 if templ_7745c5c3_Err != nil {
403 return templ_7745c5c3_Err610 return templ_7745c5c3_Err
404 }611 }
405- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\">")612+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\">")
406 if templ_7745c5c3_Err != nil {613 if templ_7745c5c3_Err != nil {
407 return templ_7745c5c3_Err614 return templ_7745c5c3_Err
408 }615 }
409- var templ_7745c5c3_Var20 string616+ var templ_7745c5c3_Var33 string
410- templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(h.Text)617+ templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(h.Text)
411 if templ_7745c5c3_Err != nil {618 if templ_7745c5c3_Err != nil {
412- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 70, Col: 79}619+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 94, Col: 79}
413 }620 }
414- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))621+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
415 if templ_7745c5c3_Err != nil {622 if templ_7745c5c3_Err != nil {
416 return templ_7745c5c3_Err623 return templ_7745c5c3_Err
417 }624 }
418- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</a>")625+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</a>")
419 if templ_7745c5c3_Err != nil {626 if templ_7745c5c3_Err != nil {
420 return templ_7745c5c3_Err627 return templ_7745c5c3_Err
421 }628 }
422 }629 }
423- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</nav>")630+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</nav>")
424 if templ_7745c5c3_Err != nil {631 if templ_7745c5c3_Err != nil {
425 return templ_7745c5c3_Err632 return templ_7745c5c3_Err
426 }633 }
@@ -445,25 +652,25 @@ func ReadmeCard(r Readme) templ.Component {
445 }()652 }()
446 }653 }
447 ctx = templ.InitializeContext(ctx)654 ctx = templ.InitializeContext(ctx)
448- templ_7745c5c3_Var21 := templ.GetChildren(ctx)655+ templ_7745c5c3_Var34 := templ.GetChildren(ctx)
449- if templ_7745c5c3_Var21 == nil {656+ if templ_7745c5c3_Var34 == nil {
450- templ_7745c5c3_Var21 = templ.NopComponent657+ templ_7745c5c3_Var34 = templ.NopComponent
451 }658 }
452 ctx = templ.ClearChildren(ctx)659 ctx = templ.ClearChildren(ctx)
453- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<section class=\"card readme\" aria-labelledby=\"readme-h\"><h2 class=\"card-h\" id=\"readme-h\"><span class=\"ico i-file\" aria-hidden=\"true\"></span> ")660+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<section class=\"card readme\" aria-labelledby=\"readme-h\"><h2 class=\"card-h\" id=\"readme-h\"><span class=\"ico i-file\" aria-hidden=\"true\"></span> ")
454 if templ_7745c5c3_Err != nil {661 if templ_7745c5c3_Err != nil {
455 return templ_7745c5c3_Err662 return templ_7745c5c3_Err
456 }663 }
457- var templ_7745c5c3_Var22 string664+ var templ_7745c5c3_Var35 string
458- templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)665+ templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
459 if templ_7745c5c3_Err != nil {666 if templ_7745c5c3_Err != nil {
460- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 80, Col: 11}667+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 104, Col: 11}
461 }668 }
462- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))669+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
463 if templ_7745c5c3_Err != nil {670 if templ_7745c5c3_Err != nil {
464 return templ_7745c5c3_Err671 return templ_7745c5c3_Err
465 }672 }
466- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</h2>")673+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</h2>")
467 if templ_7745c5c3_Err != nil {674 if templ_7745c5c3_Err != nil {
468 return templ_7745c5c3_Err675 return templ_7745c5c3_Err
469 }676 }
@@ -471,7 +678,7 @@ func ReadmeCard(r Readme) templ.Component {
471 if templ_7745c5c3_Err != nil {678 if templ_7745c5c3_Err != nil {
472 return templ_7745c5c3_Err679 return templ_7745c5c3_Err
473 }680 }
474- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</section>")681+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</section>")
475 if templ_7745c5c3_Err != nil {682 if templ_7745c5c3_Err != nil {
476 return templ_7745c5c3_Err683 return templ_7745c5c3_Err
477 }684 }
@@ -497,43 +704,43 @@ func ReadmeView(r Readme) templ.Component {
497 }()704 }()
498 }705 }
499 ctx = templ.InitializeContext(ctx)706 ctx = templ.InitializeContext(ctx)
500- templ_7745c5c3_Var23 := templ.GetChildren(ctx)707+ templ_7745c5c3_Var36 := templ.GetChildren(ctx)
501- if templ_7745c5c3_Var23 == nil {708+ if templ_7745c5c3_Var36 == nil {
502- templ_7745c5c3_Var23 = templ.NopComponent709+ templ_7745c5c3_Var36 = templ.NopComponent
503 }710 }
504 ctx = templ.ClearChildren(ctx)711 ctx = templ.ClearChildren(ctx)
505- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<article class=\"prose\" aria-label=\"")712+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<article class=\"prose\" aria-label=\"")
506 if templ_7745c5c3_Err != nil {713 if templ_7745c5c3_Err != nil {
507 return templ_7745c5c3_Err714 return templ_7745c5c3_Err
508 }715 }
509- var templ_7745c5c3_Var24 string716+ var templ_7745c5c3_Var37 string
510- templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name)717+ templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name)
511 if templ_7745c5c3_Err != nil {718 if templ_7745c5c3_Err != nil {
512- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 89, Col: 43}719+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 113, Col: 43}
513 }720 }
514- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)721+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
515 if templ_7745c5c3_Err != nil {722 if templ_7745c5c3_Err != nil {
516 return templ_7745c5c3_Err723 return templ_7745c5c3_Err
517 }724 }
518- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\" hx-ignore>")725+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" hx-ignore>")
519 if templ_7745c5c3_Err != nil {726 if templ_7745c5c3_Err != nil {
520 return templ_7745c5c3_Err727 return templ_7745c5c3_Err
521 }728 }
522 if r.Note != "" {729 if r.Note != "" {
523- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<p class=\"dim\">")730+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<p class=\"dim\">")
524 if templ_7745c5c3_Err != nil {731 if templ_7745c5c3_Err != nil {
525 return templ_7745c5c3_Err732 return templ_7745c5c3_Err
526 }733 }
527- var templ_7745c5c3_Var25 string734+ var templ_7745c5c3_Var38 string
528- templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(r.Note)735+ templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(r.Note)
529 if templ_7745c5c3_Err != nil {736 if templ_7745c5c3_Err != nil {
530- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 91, Col: 26}737+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 115, Col: 26}
531 }738 }
532- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))739+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
533 if templ_7745c5c3_Err != nil {740 if templ_7745c5c3_Err != nil {
534 return templ_7745c5c3_Err741 return templ_7745c5c3_Err
535 }742 }
536- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</p>")743+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</p>")
537 if templ_7745c5c3_Err != nil {744 if templ_7745c5c3_Err != nil {
538 return templ_7745c5c3_Err745 return templ_7745c5c3_Err
539 }746 }
@@ -543,7 +750,7 @@ func ReadmeView(r Readme) templ.Component {
543 return templ_7745c5c3_Err750 return templ_7745c5c3_Err
544 }751 }
545 }752 }
546- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</article>")753+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</article>")
547 if templ_7745c5c3_Err != nil {754 if templ_7745c5c3_Err != nil {
548 return templ_7745c5c3_Err755 return templ_7745c5c3_Err
549 }756 }
@@ -569,133 +776,133 @@ func CommitRows(rows []CommitRow) templ.Component {
569 }()776 }()
570 }777 }
571 ctx = templ.InitializeContext(ctx)778 ctx = templ.InitializeContext(ctx)
572- templ_7745c5c3_Var26 := templ.GetChildren(ctx)779+ templ_7745c5c3_Var39 := templ.GetChildren(ctx)
573- if templ_7745c5c3_Var26 == nil {780+ if templ_7745c5c3_Var39 == nil {
574- templ_7745c5c3_Var26 = templ.NopComponent781+ templ_7745c5c3_Var39 = templ.NopComponent
575 }782 }
576 ctx = templ.ClearChildren(ctx)783 ctx = templ.ClearChildren(ctx)
577- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<table class=\"list commits\" aria-label=\"Commits\"><tbody data-rows>")784+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<table class=\"list commits\" aria-label=\"Commits\"><tbody data-rows>")
578 if templ_7745c5c3_Err != nil {785 if templ_7745c5c3_Err != nil {
579 return templ_7745c5c3_Err786 return templ_7745c5c3_Err
580 }787 }
581 for _, c := range rows {788 for _, c := range rows {
582- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<tr")789+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<tr")
583 if templ_7745c5c3_Err != nil {790 if templ_7745c5c3_Err != nil {
584 return templ_7745c5c3_Err791 return templ_7745c5c3_Err
585 }792 }
586 if c.ID != "" {793 if c.ID != "" {
587- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " id=\"")794+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, " id=\"")
588 if templ_7745c5c3_Err != nil {795 if templ_7745c5c3_Err != nil {
589 return templ_7745c5c3_Err796 return templ_7745c5c3_Err
590 }797 }
591- var templ_7745c5c3_Var27 string798+ var templ_7745c5c3_Var40 string
592- templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.ID)799+ templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.ID)
593 if templ_7745c5c3_Err != nil {800 if templ_7745c5c3_Err != nil {
594- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 106, Col: 15}801+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 130, Col: 15}
595 }802 }
596- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)803+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var40)
597 if templ_7745c5c3_Err != nil {804 if templ_7745c5c3_Err != nil {
598 return templ_7745c5c3_Err805 return templ_7745c5c3_Err
599 }806 }
600- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\"")807+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\"")
601 if templ_7745c5c3_Err != nil {808 if templ_7745c5c3_Err != nil {
602 return templ_7745c5c3_Err809 return templ_7745c5c3_Err
603 }810 }
604 }811 }
605 if c.Cursor {812 if c.Cursor {
606- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " data-cursor")813+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, " data-cursor")
607 if templ_7745c5c3_Err != nil {814 if templ_7745c5c3_Err != nil {
608 return templ_7745c5c3_Err815 return templ_7745c5c3_Err
609 }816 }
610 }817 }
611- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "><td class=\"cur\" aria-hidden=\"true\"></td><td class=\"c-sha mute\">")818+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "><td class=\"cur\" aria-hidden=\"true\"></td><td class=\"c-sha mute\">")
612 if templ_7745c5c3_Err != nil {819 if templ_7745c5c3_Err != nil {
613 return templ_7745c5c3_Err820 return templ_7745c5c3_Err
614 }821 }
615- var templ_7745c5c3_Var28 string822+ var templ_7745c5c3_Var41 string
616- templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)823+ templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
617 if templ_7745c5c3_Err != nil {824 if templ_7745c5c3_Err != nil {
618- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 111, Col: 37}825+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 135, Col: 37}
619 }826 }
620- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))827+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
621 if templ_7745c5c3_Err != nil {828 if templ_7745c5c3_Err != nil {
622 return templ_7745c5c3_Err829 return templ_7745c5c3_Err
623 }830 }
624- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</td><td class=\"c-subject\"><a href=\"")831+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</td><td class=\"c-subject\"><a href=\"")
625 if templ_7745c5c3_Err != nil {832 if templ_7745c5c3_Err != nil {
626 return templ_7745c5c3_Err833 return templ_7745c5c3_Err
627 }834 }
628- var templ_7745c5c3_Var29 templ.SafeURL835+ var templ_7745c5c3_Var42 templ.SafeURL
629- templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))836+ templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
630 if templ_7745c5c3_Err != nil {837 if templ_7745c5c3_Err != nil {
631- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 113, Col: 38}838+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 137, Col: 38}
632 }839 }
633- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))840+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
634 if templ_7745c5c3_Err != nil {841 if templ_7745c5c3_Err != nil {
635 return templ_7745c5c3_Err842 return templ_7745c5c3_Err
636 }843 }
637- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\">")844+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\">")
638 if templ_7745c5c3_Err != nil {845 if templ_7745c5c3_Err != nil {
639 return templ_7745c5c3_Err846 return templ_7745c5c3_Err
640 }847 }
641- var templ_7745c5c3_Var30 string848+ var templ_7745c5c3_Var43 string
642- templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)849+ templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
643 if templ_7745c5c3_Err != nil {850 if templ_7745c5c3_Err != nil {
644- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 113, Col: 52}851+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 137, Col: 52}
645 }852 }
646- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))853+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
647 if templ_7745c5c3_Err != nil {854 if templ_7745c5c3_Err != nil {
648 return templ_7745c5c3_Err855 return templ_7745c5c3_Err
649 }856 }
650- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</a> ")857+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</a> ")
651 if templ_7745c5c3_Err != nil {858 if templ_7745c5c3_Err != nil {
652 return templ_7745c5c3_Err859 return templ_7745c5c3_Err
653 }860 }
654 if c.Merge {861 if c.Merge {
655- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<span class=\"pill\">merge</span> ")862+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<span class=\"pill\">merge</span> ")
656 if templ_7745c5c3_Err != nil {863 if templ_7745c5c3_Err != nil {
657 return templ_7745c5c3_Err864 return templ_7745c5c3_Err
658 }865 }
659 }866 }
660 for _, b := range c.Branches {867 for _, b := range c.Branches {
661- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<span class=\"pill\">")868+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<span class=\"pill\">")
662 if templ_7745c5c3_Err != nil {869 if templ_7745c5c3_Err != nil {
663 return templ_7745c5c3_Err870 return templ_7745c5c3_Err
664 }871 }
665- var templ_7745c5c3_Var31 string872+ var templ_7745c5c3_Var44 string
666- templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(b)873+ templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(b)
667 if templ_7745c5c3_Err != nil {874 if templ_7745c5c3_Err != nil {
668- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 118, Col: 29}875+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 142, Col: 29}
669 }876 }
670- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))877+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
671 if templ_7745c5c3_Err != nil {878 if templ_7745c5c3_Err != nil {
672 return templ_7745c5c3_Err879 return templ_7745c5c3_Err
673 }880 }
674- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</span> ")881+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</span> ")
675 if templ_7745c5c3_Err != nil {882 if templ_7745c5c3_Err != nil {
676 return templ_7745c5c3_Err883 return templ_7745c5c3_Err
677 }884 }
678 }885 }
679 for _, t := range c.Tags {886 for _, t := range c.Tags {
680- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<span class=\"pill tag\"><span class=\"sr\">tag </span>")887+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<span class=\"pill tag\"><span class=\"sr\">tag </span>")
681 if templ_7745c5c3_Err != nil {888 if templ_7745c5c3_Err != nil {
682 return templ_7745c5c3_Err889 return templ_7745c5c3_Err
683 }890 }
684- var templ_7745c5c3_Var32 string891+ var templ_7745c5c3_Var45 string
685- templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(t)892+ templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(t)
686 if templ_7745c5c3_Err != nil {893 if templ_7745c5c3_Err != nil {
687- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 121, Col: 61}894+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 145, Col: 61}
688 }895 }
689- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))896+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
690 if templ_7745c5c3_Err != nil {897 if templ_7745c5c3_Err != nil {
691 return templ_7745c5c3_Err898 return templ_7745c5c3_Err
692 }899 }
693- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</span>")900+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</span>")
694 if templ_7745c5c3_Err != nil {901 if templ_7745c5c3_Err != nil {
695 return templ_7745c5c3_Err902 return templ_7745c5c3_Err
696 }903 }
697 }904 }
698- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</td><td class=\"c-ck\">")905+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</td><td class=\"c-ck\">")
699 if templ_7745c5c3_Err != nil {906 if templ_7745c5c3_Err != nil {
700 return templ_7745c5c3_Err907 return templ_7745c5c3_Err
701 }908 }
@@ -703,64 +910,64 @@ func CommitRows(rows []CommitRow) templ.Component {
703 if templ_7745c5c3_Err != nil {910 if templ_7745c5c3_Err != nil {
704 return templ_7745c5c3_Err911 return templ_7745c5c3_Err
705 }912 }
706- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</td><td class=\"c-who dim\">")913+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</td><td class=\"c-who dim\">")
707 if templ_7745c5c3_Err != nil {914 if templ_7745c5c3_Err != nil {
708 return templ_7745c5c3_Err915 return templ_7745c5c3_Err
709 }916 }
710- var templ_7745c5c3_Var33 string917+ var templ_7745c5c3_Var46 string
711- templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)918+ templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
712 if templ_7745c5c3_Err != nil {919 if templ_7745c5c3_Err != nil {
713- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 125, Col: 37}920+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 149, Col: 37}
714 }921 }
715- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))922+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
716 if templ_7745c5c3_Err != nil {923 if templ_7745c5c3_Err != nil {
717 return templ_7745c5c3_Err924 return templ_7745c5c3_Err
718 }925 }
719- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</td><td class=\"c-when nowrap dim\"><time datetime=\"")926+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</td><td class=\"c-when nowrap dim\"><time datetime=\"")
720 if templ_7745c5c3_Err != nil {927 if templ_7745c5c3_Err != nil {
721 return templ_7745c5c3_Err928 return templ_7745c5c3_Err
722 }929 }
723- var templ_7745c5c3_Var34 string930+ var templ_7745c5c3_Var47 string
724- templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)931+ templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
725 if templ_7745c5c3_Err != nil {932 if templ_7745c5c3_Err != nil {
726- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 126, Col: 60}933+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 150, Col: 60}
727 }934 }
728- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)935+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47)
729 if templ_7745c5c3_Err != nil {936 if templ_7745c5c3_Err != nil {
730 return templ_7745c5c3_Err937 return templ_7745c5c3_Err
731 }938 }
732- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" title=\"")939+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\" title=\"")
733 if templ_7745c5c3_Err != nil {940 if templ_7745c5c3_Err != nil {
734 return templ_7745c5c3_Err941 return templ_7745c5c3_Err
735 }942 }
736- var templ_7745c5c3_Var35 string943+ var templ_7745c5c3_Var48 string
737- templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)944+ templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
738 if templ_7745c5c3_Err != nil {945 if templ_7745c5c3_Err != nil {
739- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 126, Col: 79}946+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 150, Col: 79}
740 }947 }
741- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)948+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var48)
742 if templ_7745c5c3_Err != nil {949 if templ_7745c5c3_Err != nil {
743 return templ_7745c5c3_Err950 return templ_7745c5c3_Err
744 }951 }
745- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\">")952+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "\">")
746 if templ_7745c5c3_Err != nil {953 if templ_7745c5c3_Err != nil {
747 return templ_7745c5c3_Err954 return templ_7745c5c3_Err
748 }955 }
749- var templ_7745c5c3_Var36 string956+ var templ_7745c5c3_Var49 string
750- templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)957+ templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
751 if templ_7745c5c3_Err != nil {958 if templ_7745c5c3_Err != nil {
752- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 126, Col: 90}959+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 150, Col: 90}
753 }960 }
754- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))961+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
755 if templ_7745c5c3_Err != nil {962 if templ_7745c5c3_Err != nil {
756 return templ_7745c5c3_Err963 return templ_7745c5c3_Err
757 }964 }
758- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</time></td></tr>")965+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</time></td></tr>")
759 if templ_7745c5c3_Err != nil {966 if templ_7745c5c3_Err != nil {
760 return templ_7745c5c3_Err967 return templ_7745c5c3_Err
761 }968 }
762 }969 }
763- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</tbody></table>")970+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</tbody></table>")
764 if templ_7745c5c3_Err != nil {971 if templ_7745c5c3_Err != nil {
765 return templ_7745c5c3_Err972 return templ_7745c5c3_Err
766 }973 }
M internal/adapters/web/fragments.go +8 -1
@@ -106,6 +106,13 @@ func (l *liveStream) home(ctx context.Context, we ports.WatchEvent) ([]fragment,
106106 name = ev.Repo.String()
107107 case domain.PushEvent:
108108 name, fresh = ev.Repo.String(), true
109+ case domain.RunUpdated:
110+ name = ev.Run.Repo.String() // the CI state of the row
111+ case domain.JobUpdated:
112+ if ev.Job.Phase != domain.CIPhaseEnded {
113+ return nil, nil
114+ }
115+ name = ev.Job.Repo.String() // a failed job turns the state to failing
109116 default:
110117 return nil, nil
111118 }
@@ -116,7 +123,7 @@ func (l *liveStream) home(ctx context.Context, we ports.WatchEvent) ([]fragment,
116123 case err != nil:
117124 return nil, err
118125 }
119- row := homeRow(view, l.s.cfg.Now(), false, SignedIn(ctx))
126+ row := l.s.homeRowOf(ctx, view, l.s.cfg.Now(), false)
120127 row.Fresh = fresh
121128 f, err := renderFragment(ctx, "home-list", HomeRowView(row))
122129 return []fragment{f}, err
@@ -106,6 +106,13 @@ func (l *liveStream) home(ctx context.Context, we ports.WatchEvent) ([]fragment,
106 name = ev.Repo.String()106 name = ev.Repo.String()
107 case domain.PushEvent:107 case domain.PushEvent:
108 name, fresh = ev.Repo.String(), true108 name, fresh = ev.Repo.String(), true
109+ case domain.RunUpdated:
110+ name = ev.Run.Repo.String() // the CI state of the row
111+ case domain.JobUpdated:
112+ if ev.Job.Phase != domain.CIPhaseEnded {
113+ return nil, nil
114+ }
115+ name = ev.Job.Repo.String() // a failed job turns the state to failing
109 default:116 default:
110 return nil, nil117 return nil, nil
111 }118 }
@@ -116,7 +123,7 @@ func (l *liveStream) home(ctx context.Context, we ports.WatchEvent) ([]fragment,
116 case err != nil:123 case err != nil:
117 return nil, err124 return nil, err
118 }125 }
119- row := homeRow(view, l.s.cfg.Now(), false, SignedIn(ctx))126+ row := l.s.homeRowOf(ctx, view, l.s.cfg.Now(), false)
120 row.Fresh = fresh127 row.Fresh = fresh
121 f, err := renderFragment(ctx, "home-list", HomeRowView(row))128 f, err := renderFragment(ctx, "home-list", HomeRowView(row))
122 return []fragment{f}, err129 return []fragment{f}, err
M internal/adapters/web/home.go +103 -6
@@ -4,12 +4,16 @@ package web
44
55 import (
66 "context"
7+ "errors"
78 "net/http"
9+ "slices"
810 "sort"
911 "strings"
12+ "sync"
1013 "time"
1114
1215 "github.com/a-h/templ"
16+ "github.com/bnema/zerowrap"
1317
1418 "bnema.dev/ermite/internal/domain"
1519 "bnema.dev/ermite/internal/ports"
@@ -99,7 +103,6 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in
99103 sortRepos(repos, key, desc)
100104
101105 now := s.cfg.Now()
102- signed := SignedIn(ctx)
103106 p := HomePage{}
104107 for _, c := range homeSorts {
105108 sl := SortLink{Label: c.label}
@@ -117,9 +120,7 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in
117120 sl.Href = "/?sort=" + c.key + "&dir=" + next
118121 p.Sorts = append(p.Sorts, sl)
119122 }
120- for i, repo := range repos {
121- p.Rows = append(p.Rows, homeRow(repo, now, i == 0, signed))
122- }
123+ p.Rows = s.homeRows(ctx, repos, now)
123124
124125 l := s.base()
125126 l.Title = s.instance
@@ -128,8 +129,104 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in
128129 return Home(l, p), http.StatusOK, nil
129130 }
130131
131-// homeRow is the card of a repository in the home list. The private badge is
132-// for a signed-in visitor: an anonymous one never gets a private repository.
132+// homeRecent is how many commits a row of the home list shows.
133+const homeRecent = 5
134+
135+// homeWorkers bounds the repositories whose commits and run are read at once.
136+const homeWorkers = 8
137+
138+// homeRows builds the rows of repos, in order: each repository costs a read
139+// of its commits and of its latest run, so they are read a few at a time. A
140+// repository whose reads fail still has its row.
141+func (s *Server) homeRows(ctx context.Context, repos []ports.RepoView, now time.Time) []HomeRow {
142+ rows := make([]HomeRow, len(repos))
143+ var (
144+ sem = make(chan struct{}, homeWorkers)
145+ wg sync.WaitGroup
146+ )
147+ for i, repo := range repos {
148+ sem <- struct{}{}
149+ wg.Add(1)
150+ go func() {
151+ defer func() { <-sem; wg.Done() }()
152+ rows[i] = s.homeRowOf(ctx, repo, now, i == 0)
153+ }()
154+ }
155+ wg.Wait()
156+ return rows
157+}
158+
159+// homeRowOf is the row of a repository with what the visitor may read of it:
160+// the newest commits of its default branch and the state of its latest run.
161+func (s *Server) homeRowOf(ctx context.Context, r ports.RepoView, now time.Time, cursor bool) HomeRow {
162+ row := homeRow(r, now, cursor, SignedIn(ctx))
163+ if r.PushedAt.IsZero() || r.DefaultBranch == "" {
164+ row.NoCommits = r.PushedAt.IsZero()
165+ return row
166+ }
167+ var head string
168+ row.Commits, head, row.NoCommits = s.homeCommits(ctx, r, now)
169+ if head != "" {
170+ row.CI = s.homeCI(ctx, r, head)
171+ }
172+ return row
173+}
174+
175+// homeCommits reads the newest commits of the default branch. It reports an
176+// empty branch; a failed read is logged and gives neither commits nor that
177+// note, since the row does not depend on them.
178+func (s *Server) homeCommits(ctx context.Context, r ports.RepoView, now time.Time) (rows []CommitRow, head string, none bool) {
179+ // By its full name, so that a tag of the same name is not read.
180+ page, err := s.commitList(ctx, r.Name, "refs/heads/"+r.DefaultBranch, "", homeRecent, "")
181+ switch {
182+ case errors.Is(err, domain.ErrNotFound):
183+ return nil, "", true
184+ case err != nil:
185+ log := zerowrap.FromCtx(ctx)
186+ log.Warn().Err(err).Str("repo", r.Name.String()).Msg("read the recent commits of the home page")
187+ return nil, "", false
188+ }
189+ for _, c := range page.Commits[:min(len(page.Commits), homeRecent)] {
190+ rows = append(rows, commitRow(r.Name.String(), c, now))
191+ }
192+ if len(page.Commits) > 0 {
193+ head = page.Commits[0].ID.String()
194+ }
195+ return rows, head, len(rows) == 0
196+}
197+
198+// homeCI is the state of the CI on the head of the default branch, as the
199+// caller of the request sees it: the combined checks of that commit, read by
200+// its id so the cost does not grow with the CI history. nil when the server
201+// runs no CI, the caller cannot read it, the commit has no check, or the read
202+// fails (checksFor logs it).
203+func (s *Server) homeCI(ctx context.Context, r ports.RepoView, head string) *HomeCI {
204+ checks := s.checksFor(ctx, r.Name, head)
205+ if len(checks) == 0 {
206+ return nil
207+ }
208+ sum := checksSummary(r.Name, head, checks)
209+ ci := &HomeCI{Kind: sum.Kind, Label: ciLabel(sum.Kind), Title: sum.Title, Href: repoHref(r.Name.String(), "commit", head)}
210+ if run := checks[0].RunID; !slices.ContainsFunc(checks, func(c ports.CheckView) bool { return c.RunID != run }) {
211+ ci.Href = runHref(r.Name.String(), run) // one run: straight to it
212+ }
213+ return ci
214+}
215+
216+// ciLabel is the word next to the mark of the CI state on the home page.
217+func ciLabel(kind string) string {
218+ switch kind {
219+ case "ok":
220+ return "passing"
221+ case "bad":
222+ return "failing"
223+ }
224+ return "running"
225+}
226+
227+// homeRow is the row of a repository in the home list, without its commits
228+// and run. The private badge is for a signed-in visitor: an anonymous one
229+// never gets a private repository.
133230 func homeRow(r ports.RepoView, now time.Time, cursor, signed bool) HomeRow {
134231 name := r.Name.String()
135232 row := HomeRow{
@@ -4,12 +4,16 @@ package web
4 4
5 import (5 import (
6 "context"6 "context"
7+ "errors"
7 "net/http"8 "net/http"
9+ "slices"
8 "sort"10 "sort"
9 "strings"11 "strings"
12+ "sync"
10 "time"13 "time"
11 14
12 "github.com/a-h/templ"15 "github.com/a-h/templ"
16+ "github.com/bnema/zerowrap"
13 17
14 "bnema.dev/ermite/internal/domain"18 "bnema.dev/ermite/internal/domain"
15 "bnema.dev/ermite/internal/ports"19 "bnema.dev/ermite/internal/ports"
@@ -99,7 +103,6 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in
99 sortRepos(repos, key, desc)103 sortRepos(repos, key, desc)
100 104
101 now := s.cfg.Now()105 now := s.cfg.Now()
102- signed := SignedIn(ctx)
103 p := HomePage{}106 p := HomePage{}
104 for _, c := range homeSorts {107 for _, c := range homeSorts {
105 sl := SortLink{Label: c.label}108 sl := SortLink{Label: c.label}
@@ -117,9 +120,7 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in
117 sl.Href = "/?sort=" + c.key + "&dir=" + next120 sl.Href = "/?sort=" + c.key + "&dir=" + next
118 p.Sorts = append(p.Sorts, sl)121 p.Sorts = append(p.Sorts, sl)
119 }122 }
120- for i, repo := range repos {123+ p.Rows = s.homeRows(ctx, repos, now)
121- p.Rows = append(p.Rows, homeRow(repo, now, i == 0, signed))
122- }
123 124
124 l := s.base()125 l := s.base()
125 l.Title = s.instance126 l.Title = s.instance
@@ -128,8 +129,104 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in
128 return Home(l, p), http.StatusOK, nil129 return Home(l, p), http.StatusOK, nil
129 }130 }
130 131
131-// homeRow is the card of a repository in the home list. The private badge is132+// homeRecent is how many commits a row of the home list shows.
132-// for a signed-in visitor: an anonymous one never gets a private repository.133+const homeRecent = 5
134+
135+// homeWorkers bounds the repositories whose commits and run are read at once.
136+const homeWorkers = 8
137+
138+// homeRows builds the rows of repos, in order: each repository costs a read
139+// of its commits and of its latest run, so they are read a few at a time. A
140+// repository whose reads fail still has its row.
141+func (s *Server) homeRows(ctx context.Context, repos []ports.RepoView, now time.Time) []HomeRow {
142+ rows := make([]HomeRow, len(repos))
143+ var (
144+ sem = make(chan struct{}, homeWorkers)
145+ wg sync.WaitGroup
146+ )
147+ for i, repo := range repos {
148+ sem <- struct{}{}
149+ wg.Add(1)
150+ go func() {
151+ defer func() { <-sem; wg.Done() }()
152+ rows[i] = s.homeRowOf(ctx, repo, now, i == 0)
153+ }()
154+ }
155+ wg.Wait()
156+ return rows
157+}
158+
159+// homeRowOf is the row of a repository with what the visitor may read of it:
160+// the newest commits of its default branch and the state of its latest run.
161+func (s *Server) homeRowOf(ctx context.Context, r ports.RepoView, now time.Time, cursor bool) HomeRow {
162+ row := homeRow(r, now, cursor, SignedIn(ctx))
163+ if r.PushedAt.IsZero() || r.DefaultBranch == "" {
164+ row.NoCommits = r.PushedAt.IsZero()
165+ return row
166+ }
167+ var head string
168+ row.Commits, head, row.NoCommits = s.homeCommits(ctx, r, now)
169+ if head != "" {
170+ row.CI = s.homeCI(ctx, r, head)
171+ }
172+ return row
173+}
174+
175+// homeCommits reads the newest commits of the default branch. It reports an
176+// empty branch; a failed read is logged and gives neither commits nor that
177+// note, since the row does not depend on them.
178+func (s *Server) homeCommits(ctx context.Context, r ports.RepoView, now time.Time) (rows []CommitRow, head string, none bool) {
179+ // By its full name, so that a tag of the same name is not read.
180+ page, err := s.commitList(ctx, r.Name, "refs/heads/"+r.DefaultBranch, "", homeRecent, "")
181+ switch {
182+ case errors.Is(err, domain.ErrNotFound):
183+ return nil, "", true
184+ case err != nil:
185+ log := zerowrap.FromCtx(ctx)
186+ log.Warn().Err(err).Str("repo", r.Name.String()).Msg("read the recent commits of the home page")
187+ return nil, "", false
188+ }
189+ for _, c := range page.Commits[:min(len(page.Commits), homeRecent)] {
190+ rows = append(rows, commitRow(r.Name.String(), c, now))
191+ }
192+ if len(page.Commits) > 0 {
193+ head = page.Commits[0].ID.String()
194+ }
195+ return rows, head, len(rows) == 0
196+}
197+
198+// homeCI is the state of the CI on the head of the default branch, as the
199+// caller of the request sees it: the combined checks of that commit, read by
200+// its id so the cost does not grow with the CI history. nil when the server
201+// runs no CI, the caller cannot read it, the commit has no check, or the read
202+// fails (checksFor logs it).
203+func (s *Server) homeCI(ctx context.Context, r ports.RepoView, head string) *HomeCI {
204+ checks := s.checksFor(ctx, r.Name, head)
205+ if len(checks) == 0 {
206+ return nil
207+ }
208+ sum := checksSummary(r.Name, head, checks)
209+ ci := &HomeCI{Kind: sum.Kind, Label: ciLabel(sum.Kind), Title: sum.Title, Href: repoHref(r.Name.String(), "commit", head)}
210+ if run := checks[0].RunID; !slices.ContainsFunc(checks, func(c ports.CheckView) bool { return c.RunID != run }) {
211+ ci.Href = runHref(r.Name.String(), run) // one run: straight to it
212+ }
213+ return ci
214+}
215+
216+// ciLabel is the word next to the mark of the CI state on the home page.
217+func ciLabel(kind string) string {
218+ switch kind {
219+ case "ok":
220+ return "passing"
221+ case "bad":
222+ return "failing"
223+ }
224+ return "running"
225+}
226+
227+// homeRow is the row of a repository in the home list, without its commits
228+// and run. The private badge is for a signed-in visitor: an anonymous one
229+// never gets a private repository.
133 func homeRow(r ports.RepoView, now time.Time, cursor, signed bool) HomeRow {230 func homeRow(r ports.RepoView, now time.Time, cursor, signed bool) HomeRow {
134 name := r.Name.String()231 name := r.Name.String()
135 row := HomeRow{232 row := HomeRow{
A internal/adapters/web/home_test.go +246 -0
@@ -0,0 +1,246 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "bytes"
7+ "context"
8+ "errors"
9+ "fmt"
10+ "net/http"
11+ "net/http/httptest"
12+ "strings"
13+ "sync/atomic"
14+ "testing"
15+ "time"
16+
17+ "github.com/stretchr/testify/assert"
18+ "github.com/stretchr/testify/mock"
19+ "github.com/stretchr/testify/require"
20+
21+ "bnema.dev/ermite/internal/domain"
22+ "bnema.dev/ermite/internal/ports"
23+ "bnema.dev/ermite/internal/ports/mocks"
24+)
25+
26+// pushedRepo is a public repository that was pushed to.
27+func pushedRepo(t testing.TB, name string) ports.RepoView {
28+ t.Helper()
29+ v := publicRepo(t, name)
30+ v.PushedAt = time.Now().Add(-time.Hour)
31+ return v
32+}
33+
34+// homeCommits are n commits, newest first, with distinct ids and subjects.
35+func homeCommits(t testing.TB, n int) []domain.Commit {
36+ t.Helper()
37+ var out []domain.Commit
38+ for i := n; i >= 1; i-- {
39+ out = append(out, domain.Commit{
40+ ID: objectID(t, fmt.Sprintf("%040x", i)),
41+ Summary: fmt.Sprintf("subject number %d", i),
42+ Committer: domain.Signature{Time: time.Now().Add(-time.Duration(i) * time.Minute)},
43+ })
44+ }
45+ return out
46+}
47+
48+func renderRow(t testing.TB, r HomeRow) string {
49+ t.Helper()
50+ var b bytes.Buffer
51+ require.NoError(t, HomeRowView(r).Render(context.Background(), &b))
52+ return b.String()
53+}
54+
55+func mustRevision(t testing.TB, s string) domain.Revision {
56+ t.Helper()
57+ r, err := domain.ParseRevision(s)
58+ require.NoError(t, err)
59+ return r
60+}
61+
62+// Each row lists at most five commits of the default branch, read by the full
63+// name of the branch.
64+func TestHomeRowShowsFiveCommits(t *testing.T) {
65+ t.Parallel()
66+ browse := mocks.NewBrowseService(t)
67+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "proj"), ports.ListCommitsParams{
68+ Revision: mustRevision(t, "refs/heads/main"), PageSize: homeRecent,
69+ }).Return(ports.HistoryPage{Commits: homeCommits(t, 7)}, nil).Once()
70+ srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "proj")), Browse: browse})
71+
72+ row := srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), time.Now(), false)
73+ require.Len(t, row.Commits, homeRecent)
74+ assert.Equal(t, "subject number 7", row.Commits[0].Subject)
75+ assert.False(t, row.NoCommits)
76+ assert.Nil(t, row.CI, "a server without CI shows no state")
77+
78+ out := renderRow(t, row)
79+ assert.Equal(t, homeRecent, strings.Count(out, `class="rc-sha"`))
80+ assert.Contains(t, out, `href="/proj/commit/`+fmt.Sprintf("%040x", 7)+`"`)
81+ assert.Contains(t, out, `aria-label="Recent commits of proj"`)
82+ assert.NotContains(t, out, `class="repo-ci"`)
83+}
84+
85+func TestHomeRowEmptyRepository(t *testing.T) {
86+ t.Parallel()
87+ // Never pushed: nothing is read.
88+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "new")), Browse: mocks.NewBrowseService(t), Runs: mocks.NewRunService(t)})
89+ row := srv.homeRowOf(context.Background(), publicRepo(t, "new"), time.Now(), false)
90+ assert.True(t, row.NoCommits)
91+ out := renderRow(t, row)
92+ assert.Contains(t, out, "no commits yet")
93+ assert.Contains(t, out, "never pushed")
94+ assert.NotContains(t, out, "repo-commits\" aria-label")
95+}
96+
97+func TestHomeRowCI(t *testing.T) {
98+ t.Parallel()
99+ repo := repoName(t, "proj")
100+ head := fmt.Sprintf("%040x", 2) // the newest of homeCommits(t, 2)
101+ check := func(run int64, status domain.CIStatus, c domain.CIConclusion) ports.CheckView {
102+ return ports.CheckView{CICheck: domain.CICheck{Repo: repo, Sha: head, Name: "ci / test", RunID: run, Status: status, Conclusion: c}}
103+ }
104+ tests := []struct {
105+ name string
106+ checks []ports.CheckView
107+ err error
108+ label string // "" when no state is shown
109+ href string
110+ }{
111+ {"passing", []ports.CheckView{check(9, domain.CICompleted, domain.CISuccess)}, nil, "passing", "/proj/actions/runs/9"},
112+ {"failing", []ports.CheckView{check(9, domain.CICompleted, domain.CISuccess), check(9, domain.CICompleted, domain.CIFailure)}, nil, "failing", "/proj/actions/runs/9"},
113+ {"running", []ports.CheckView{check(9, domain.CIInProgress, "")}, nil, "running", "/proj/actions/runs/9"},
114+ {"several runs link the commit", []ports.CheckView{check(9, domain.CICompleted, domain.CISuccess), check(10, domain.CICompleted, domain.CISuccess)}, nil, "passing", "/proj/commit/" + head},
115+ {"no check on the head", nil, nil, "", ""},
116+ {"the viewer cannot read CI", nil, domain.ErrNotFound, "", ""},
117+ {"the read fails", nil, errors.New("boom"), "", ""},
118+ }
119+ for _, tt := range tests {
120+ t.Run(tt.name, func(t *testing.T) {
121+ t.Parallel()
122+ browse := mocks.NewBrowseService(t)
123+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)
124+ runs := mocks.NewRunService(t)
125+ runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).
126+ Return(ports.ChecksResult{Checks: tt.checks}, tt.err).Once()
127+ srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "proj")), Browse: browse, Runs: runs})
128+
129+ row := srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), time.Now(), false)
130+ require.Len(t, row.Commits, 2, "the commits do not depend on CI")
131+ out := renderRow(t, row)
132+ if tt.label == "" {
133+ assert.Nil(t, row.CI)
134+ assert.NotContains(t, out, "repo-ci")
135+ return
136+ }
137+ require.NotNil(t, row.CI)
138+ assert.Contains(t, out, `class="repo-ci" href="`+tt.href+`"`)
139+ assert.Contains(t, out, tt.label, "the state has a word, not only a mark")
140+ assert.Contains(t, out, `<span class="sr">CI: </span>`)
141+ })
142+ }
143+}
144+
145+// The private badge is for the signed-in owner only.
146+func TestHomeRowPrivateBadge(t *testing.T) {
147+ t.Parallel()
148+ priv := pushedRepo(t, "secret")
149+ priv.Visibility = domain.VisibilityPrivate
150+ browse := mocks.NewBrowseService(t)
151+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.HistoryPage{}, nil).Maybe()
152+ srv := newTestServer(t, Config{Repos: reposServing(t, priv, pushedRepo(t, "open")), Browse: browse})
153+
154+ signed := context.WithValue(context.Background(), signedInKey{}, struct{}{})
155+ assert.Contains(t, renderRow(t, srv.homeRowOf(signed, priv, time.Now(), false)), `<span class="pill">private</span>`)
156+ assert.NotContains(t, renderRow(t, srv.homeRowOf(context.Background(), priv, time.Now(), false)), "private")
157+ assert.NotContains(t, renderRow(t, srv.homeRowOf(signed, pushedRepo(t, "open"), time.Now(), false)), "private")
158+}
159+
160+// A run update re-renders the home row, so its CI state follows the run.
161+func TestRunUpdatedRefreshesTheHomeRow(t *testing.T) {
162+ t.Parallel()
163+ repo := repoName(t, "proj")
164+ head := fmt.Sprintf("%040x", 2)
165+ browse := mocks.NewBrowseService(t)
166+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)
167+ runs := mocks.NewRunService(t)
168+ runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).Return(ports.ChecksResult{Checks: []ports.CheckView{
169+ {CICheck: domain.CICheck{Repo: repo, Sha: head, RunID: 9, Status: domain.CICompleted, Conclusion: domain.CISuccess}},
170+ }}, nil).Once()
171+ srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "proj")), Browse: browse, Runs: runs})
172+
173+ got, err := (&liveStream{s: srv}).render(t.Context(), ports.WatchEvent{Event: domain.RunUpdated{Run: domain.CIRun{Repo: repo, ID: 9}}})
174+ require.NoError(t, err)
175+ require.Len(t, got, 1)
176+ assert.Equal(t, "home-list", got[0].event)
177+ assert.Contains(t, got[0].html, `id="repo-proj"`)
178+ assert.Contains(t, got[0].html, "passing")
179+ assert.NotContains(t, got[0].html, "new push", "a run is not a push")
180+
181+ // A job that ended refreshes the row too; one in progress does not.
182+ got, err = (&liveStream{s: srv}).render(t.Context(), ports.WatchEvent{Event: domain.JobUpdated{Job: domain.CIJob{Repo: repo, Phase: domain.CIPhaseRunning}}})
183+ require.NoError(t, err)
184+ assert.Empty(t, got)
185+ runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).Return(ports.ChecksResult{Checks: []ports.CheckView{
186+ {CICheck: domain.CICheck{Repo: repo, Sha: head, RunID: 9, Status: domain.CICompleted, Conclusion: domain.CIFailure}},
187+ {CICheck: domain.CICheck{Repo: repo, Sha: head, RunID: 9, Status: domain.CIInProgress}},
188+ }}, nil).Once()
189+ got, err = (&liveStream{s: srv}).render(t.Context(), ports.WatchEvent{Event: domain.JobUpdated{Job: domain.CIJob{Repo: repo, Phase: domain.CIPhaseEnded}}})
190+ require.NoError(t, err)
191+ require.Len(t, got, 1)
192+ assert.Contains(t, got[0].html, "failing", "a failed job shows before its run ends")
193+}
194+
195+// A repository whose commits cannot be read keeps its row, and the page is
196+// served; the others are not affected.
197+func TestHomeSurvivesABrokenRepository(t *testing.T) {
198+ t.Parallel()
199+ browse := mocks.NewBrowseService(t)
200+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "broken"), mock.Anything).
201+ Return(ports.HistoryPage{}, errors.New("git exploded")).Once()
202+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "fine"), mock.Anything).
203+ Return(ports.HistoryPage{Commits: homeCommits(t, 3)}, nil).Once()
204+ srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "broken"), pushedRepo(t, "fine")), Browse: browse})
205+
206+ rec := httptest.NewRecorder()
207+ srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/?sort=name", nil))
208+ require.Equal(t, http.StatusOK, rec.Code)
209+ body := rec.Body.String()
210+ assert.Contains(t, body, `id="repo-broken"`)
211+ assert.Contains(t, body, `id="repo-fine"`)
212+ assert.Equal(t, 3, strings.Count(body, `class="rc-sha"`), "only the readable repository lists commits")
213+ assert.NotContains(t, body, "no commits yet", "a failed read is not an empty branch")
214+}
215+
216+// The rows are read a few at a time, in the order of the list.
217+func TestHomeRowsAreBoundedAndOrdered(t *testing.T) {
218+ t.Parallel()
219+ const n = 40
220+ var views []ports.RepoView
221+ for i := range n {
222+ views = append(views, pushedRepo(t, fmt.Sprintf("r%02d", i)))
223+ }
224+ var running, peak atomic.Int32
225+ browse := mocks.NewBrowseService(t)
226+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
227+ func(context.Context, domain.Principal, domain.RepoName, ports.ListCommitsParams) (ports.HistoryPage, error) {
228+ cur := running.Add(1)
229+ for p := peak.Load(); cur > p && !peak.CompareAndSwap(p, cur); p = peak.Load() {
230+ }
231+ time.Sleep(2 * time.Millisecond)
232+ running.Add(-1)
233+ return ports.HistoryPage{Commits: homeCommits(t, 1)}, nil
234+ })
235+ srv := newTestServer(t, Config{Repos: reposServing(t, views...), Browse: browse})
236+
237+ rows := srv.homeRows(context.Background(), views, time.Now())
238+ require.Len(t, rows, n)
239+ for i, r := range rows {
240+ assert.Equal(t, fmt.Sprintf("r%02d", i), r.Name)
241+ assert.Len(t, r.Commits, 1)
242+ }
243+ assert.True(t, rows[0].Cursor)
244+ assert.LessOrEqual(t, int(peak.Load()), homeWorkers)
245+ assert.Greater(t, int(peak.Load()), 1, "the reads overlap")
246+}
@@ -0,0 +1,246 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "bytes"
7+ "context"
8+ "errors"
9+ "fmt"
10+ "net/http"
11+ "net/http/httptest"
12+ "strings"
13+ "sync/atomic"
14+ "testing"
15+ "time"
16+
17+ "github.com/stretchr/testify/assert"
18+ "github.com/stretchr/testify/mock"
19+ "github.com/stretchr/testify/require"
20+
21+ "bnema.dev/ermite/internal/domain"
22+ "bnema.dev/ermite/internal/ports"
23+ "bnema.dev/ermite/internal/ports/mocks"
24+)
25+
26+// pushedRepo is a public repository that was pushed to.
27+func pushedRepo(t testing.TB, name string) ports.RepoView {
28+ t.Helper()
29+ v := publicRepo(t, name)
30+ v.PushedAt = time.Now().Add(-time.Hour)
31+ return v
32+}
33+
34+// homeCommits are n commits, newest first, with distinct ids and subjects.
35+func homeCommits(t testing.TB, n int) []domain.Commit {
36+ t.Helper()
37+ var out []domain.Commit
38+ for i := n; i >= 1; i-- {
39+ out = append(out, domain.Commit{
40+ ID: objectID(t, fmt.Sprintf("%040x", i)),
41+ Summary: fmt.Sprintf("subject number %d", i),
42+ Committer: domain.Signature{Time: time.Now().Add(-time.Duration(i) * time.Minute)},
43+ })
44+ }
45+ return out
46+}
47+
48+func renderRow(t testing.TB, r HomeRow) string {
49+ t.Helper()
50+ var b bytes.Buffer
51+ require.NoError(t, HomeRowView(r).Render(context.Background(), &b))
52+ return b.String()
53+}
54+
55+func mustRevision(t testing.TB, s string) domain.Revision {
56+ t.Helper()
57+ r, err := domain.ParseRevision(s)
58+ require.NoError(t, err)
59+ return r
60+}
61+
62+// Each row lists at most five commits of the default branch, read by the full
63+// name of the branch.
64+func TestHomeRowShowsFiveCommits(t *testing.T) {
65+ t.Parallel()
66+ browse := mocks.NewBrowseService(t)
67+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "proj"), ports.ListCommitsParams{
68+ Revision: mustRevision(t, "refs/heads/main"), PageSize: homeRecent,
69+ }).Return(ports.HistoryPage{Commits: homeCommits(t, 7)}, nil).Once()
70+ srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "proj")), Browse: browse})
71+
72+ row := srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), time.Now(), false)
73+ require.Len(t, row.Commits, homeRecent)
74+ assert.Equal(t, "subject number 7", row.Commits[0].Subject)
75+ assert.False(t, row.NoCommits)
76+ assert.Nil(t, row.CI, "a server without CI shows no state")
77+
78+ out := renderRow(t, row)
79+ assert.Equal(t, homeRecent, strings.Count(out, `class="rc-sha"`))
80+ assert.Contains(t, out, `href="/proj/commit/`+fmt.Sprintf("%040x", 7)+`"`)
81+ assert.Contains(t, out, `aria-label="Recent commits of proj"`)
82+ assert.NotContains(t, out, `class="repo-ci"`)
83+}
84+
85+func TestHomeRowEmptyRepository(t *testing.T) {
86+ t.Parallel()
87+ // Never pushed: nothing is read.
88+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "new")), Browse: mocks.NewBrowseService(t), Runs: mocks.NewRunService(t)})
89+ row := srv.homeRowOf(context.Background(), publicRepo(t, "new"), time.Now(), false)
90+ assert.True(t, row.NoCommits)
91+ out := renderRow(t, row)
92+ assert.Contains(t, out, "no commits yet")
93+ assert.Contains(t, out, "never pushed")
94+ assert.NotContains(t, out, "repo-commits\" aria-label")
95+}
96+
97+func TestHomeRowCI(t *testing.T) {
98+ t.Parallel()
99+ repo := repoName(t, "proj")
100+ head := fmt.Sprintf("%040x", 2) // the newest of homeCommits(t, 2)
101+ check := func(run int64, status domain.CIStatus, c domain.CIConclusion) ports.CheckView {
102+ return ports.CheckView{CICheck: domain.CICheck{Repo: repo, Sha: head, Name: "ci / test", RunID: run, Status: status, Conclusion: c}}
103+ }
104+ tests := []struct {
105+ name string
106+ checks []ports.CheckView
107+ err error
108+ label string // "" when no state is shown
109+ href string
110+ }{
111+ {"passing", []ports.CheckView{check(9, domain.CICompleted, domain.CISuccess)}, nil, "passing", "/proj/actions/runs/9"},
112+ {"failing", []ports.CheckView{check(9, domain.CICompleted, domain.CISuccess), check(9, domain.CICompleted, domain.CIFailure)}, nil, "failing", "/proj/actions/runs/9"},
113+ {"running", []ports.CheckView{check(9, domain.CIInProgress, "")}, nil, "running", "/proj/actions/runs/9"},
114+ {"several runs link the commit", []ports.CheckView{check(9, domain.CICompleted, domain.CISuccess), check(10, domain.CICompleted, domain.CISuccess)}, nil, "passing", "/proj/commit/" + head},
115+ {"no check on the head", nil, nil, "", ""},
116+ {"the viewer cannot read CI", nil, domain.ErrNotFound, "", ""},
117+ {"the read fails", nil, errors.New("boom"), "", ""},
118+ }
119+ for _, tt := range tests {
120+ t.Run(tt.name, func(t *testing.T) {
121+ t.Parallel()
122+ browse := mocks.NewBrowseService(t)
123+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)
124+ runs := mocks.NewRunService(t)
125+ runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).
126+ Return(ports.ChecksResult{Checks: tt.checks}, tt.err).Once()
127+ srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "proj")), Browse: browse, Runs: runs})
128+
129+ row := srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), time.Now(), false)
130+ require.Len(t, row.Commits, 2, "the commits do not depend on CI")
131+ out := renderRow(t, row)
132+ if tt.label == "" {
133+ assert.Nil(t, row.CI)
134+ assert.NotContains(t, out, "repo-ci")
135+ return
136+ }
137+ require.NotNil(t, row.CI)
138+ assert.Contains(t, out, `class="repo-ci" href="`+tt.href+`"`)
139+ assert.Contains(t, out, tt.label, "the state has a word, not only a mark")
140+ assert.Contains(t, out, `<span class="sr">CI: </span>`)
141+ })
142+ }
143+}
144+
145+// The private badge is for the signed-in owner only.
146+func TestHomeRowPrivateBadge(t *testing.T) {
147+ t.Parallel()
148+ priv := pushedRepo(t, "secret")
149+ priv.Visibility = domain.VisibilityPrivate
150+ browse := mocks.NewBrowseService(t)
151+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.HistoryPage{}, nil).Maybe()
152+ srv := newTestServer(t, Config{Repos: reposServing(t, priv, pushedRepo(t, "open")), Browse: browse})
153+
154+ signed := context.WithValue(context.Background(), signedInKey{}, struct{}{})
155+ assert.Contains(t, renderRow(t, srv.homeRowOf(signed, priv, time.Now(), false)), `<span class="pill">private</span>`)
156+ assert.NotContains(t, renderRow(t, srv.homeRowOf(context.Background(), priv, time.Now(), false)), "private")
157+ assert.NotContains(t, renderRow(t, srv.homeRowOf(signed, pushedRepo(t, "open"), time.Now(), false)), "private")
158+}
159+
160+// A run update re-renders the home row, so its CI state follows the run.
161+func TestRunUpdatedRefreshesTheHomeRow(t *testing.T) {
162+ t.Parallel()
163+ repo := repoName(t, "proj")
164+ head := fmt.Sprintf("%040x", 2)
165+ browse := mocks.NewBrowseService(t)
166+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)
167+ runs := mocks.NewRunService(t)
168+ runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).Return(ports.ChecksResult{Checks: []ports.CheckView{
169+ {CICheck: domain.CICheck{Repo: repo, Sha: head, RunID: 9, Status: domain.CICompleted, Conclusion: domain.CISuccess}},
170+ }}, nil).Once()
171+ srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "proj")), Browse: browse, Runs: runs})
172+
173+ got, err := (&liveStream{s: srv}).render(t.Context(), ports.WatchEvent{Event: domain.RunUpdated{Run: domain.CIRun{Repo: repo, ID: 9}}})
174+ require.NoError(t, err)
175+ require.Len(t, got, 1)
176+ assert.Equal(t, "home-list", got[0].event)
177+ assert.Contains(t, got[0].html, `id="repo-proj"`)
178+ assert.Contains(t, got[0].html, "passing")
179+ assert.NotContains(t, got[0].html, "new push", "a run is not a push")
180+
181+ // A job that ended refreshes the row too; one in progress does not.
182+ got, err = (&liveStream{s: srv}).render(t.Context(), ports.WatchEvent{Event: domain.JobUpdated{Job: domain.CIJob{Repo: repo, Phase: domain.CIPhaseRunning}}})
183+ require.NoError(t, err)
184+ assert.Empty(t, got)
185+ runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).Return(ports.ChecksResult{Checks: []ports.CheckView{
186+ {CICheck: domain.CICheck{Repo: repo, Sha: head, RunID: 9, Status: domain.CICompleted, Conclusion: domain.CIFailure}},
187+ {CICheck: domain.CICheck{Repo: repo, Sha: head, RunID: 9, Status: domain.CIInProgress}},
188+ }}, nil).Once()
189+ got, err = (&liveStream{s: srv}).render(t.Context(), ports.WatchEvent{Event: domain.JobUpdated{Job: domain.CIJob{Repo: repo, Phase: domain.CIPhaseEnded}}})
190+ require.NoError(t, err)
191+ require.Len(t, got, 1)
192+ assert.Contains(t, got[0].html, "failing", "a failed job shows before its run ends")
193+}
194+
195+// A repository whose commits cannot be read keeps its row, and the page is
196+// served; the others are not affected.
197+func TestHomeSurvivesABrokenRepository(t *testing.T) {
198+ t.Parallel()
199+ browse := mocks.NewBrowseService(t)
200+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "broken"), mock.Anything).
201+ Return(ports.HistoryPage{}, errors.New("git exploded")).Once()
202+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "fine"), mock.Anything).
203+ Return(ports.HistoryPage{Commits: homeCommits(t, 3)}, nil).Once()
204+ srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "broken"), pushedRepo(t, "fine")), Browse: browse})
205+
206+ rec := httptest.NewRecorder()
207+ srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/?sort=name", nil))
208+ require.Equal(t, http.StatusOK, rec.Code)
209+ body := rec.Body.String()
210+ assert.Contains(t, body, `id="repo-broken"`)
211+ assert.Contains(t, body, `id="repo-fine"`)
212+ assert.Equal(t, 3, strings.Count(body, `class="rc-sha"`), "only the readable repository lists commits")
213+ assert.NotContains(t, body, "no commits yet", "a failed read is not an empty branch")
214+}
215+
216+// The rows are read a few at a time, in the order of the list.
217+func TestHomeRowsAreBoundedAndOrdered(t *testing.T) {
218+ t.Parallel()
219+ const n = 40
220+ var views []ports.RepoView
221+ for i := range n {
222+ views = append(views, pushedRepo(t, fmt.Sprintf("r%02d", i)))
223+ }
224+ var running, peak atomic.Int32
225+ browse := mocks.NewBrowseService(t)
226+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
227+ func(context.Context, domain.Principal, domain.RepoName, ports.ListCommitsParams) (ports.HistoryPage, error) {
228+ cur := running.Add(1)
229+ for p := peak.Load(); cur > p && !peak.CompareAndSwap(p, cur); p = peak.Load() {
230+ }
231+ time.Sleep(2 * time.Millisecond)
232+ running.Add(-1)
233+ return ports.HistoryPage{Commits: homeCommits(t, 1)}, nil
234+ })
235+ srv := newTestServer(t, Config{Repos: reposServing(t, views...), Browse: browse})
236+
237+ rows := srv.homeRows(context.Background(), views, time.Now())
238+ require.Len(t, rows, n)
239+ for i, r := range rows {
240+ assert.Equal(t, fmt.Sprintf("r%02d", i), r.Name)
241+ assert.Len(t, r.Commits, 1)
242+ }
243+ assert.True(t, rows[0].Cursor)
244+ assert.LessOrEqual(t, int(peak.Load()), homeWorkers)
245+ assert.Greater(t, int(peak.Load()), 1, "the reads overlap")
246+}
M internal/adapters/web/static/css/30-list.css +27 -10
@@ -28,8 +28,8 @@
2828 & .c-sub { color: var(--ink-dim); }
2929 }
3030
31- /* home: the repositories as cards in a grid (one column on a phone); the
32- page is a stack of sections, so others can sit beside or below */
31+ /* home: the repositories as rows in a vertical list; the page is a stack of
32+ sections, so others can sit beside or below */
3333 .home {
3434 display: flex; flex-direction: column; gap: var(--s5);
3535 & .home-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--s3) var(--s4); margin-bottom: var(--s4); }
@@ -43,32 +43,49 @@
4343 & .sort a:hover { color: var(--ink-hi); background: var(--bg-raised); }
4444 & .sort a[aria-current="true"] { color: var(--ink-hi); background: var(--bg-raised); border-color: var(--border-strong); }
4545 }
46+ /* a row: name, description and facts on the left; the state of the latest
47+ run at the top right, the newest commits under it. Stacked on a phone. */
4648 .repos {
47- display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 380px), 1fr)); gap: var(--s3);
49+ display: flex; flex-direction: column; gap: var(--s3);
4850 margin: 0; padding: 0; list-style: none;
4951 & .repo {
50- position: relative; display: flex; flex-direction: column; gap: var(--s2); min-width: 0;
52+ display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); grid-template-areas: "main ci" "main recent";
53+ grid-template-rows: auto 1fr; column-gap: var(--s6); min-width: 0;
5154 padding: var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r);
5255 }
5356 & .repo:hover { background: var(--bg-raised); border-color: var(--border-strong); }
5457 & .repo[data-cursor] { background: var(--bg-raised); border-color: var(--ink-mute); box-shadow: inset 3px 0 0 var(--ink-hi); }
5558 & .repo[hidden] { display: none; }
56- & .repo-name { display: flex; align-items: center; flex-wrap: wrap; gap: var(--s1) var(--s2); font-size: 17px; line-height: 26px; overflow-wrap: anywhere; }
59+ & .repo-main { grid-area: main; display: flex; flex-direction: column; gap: var(--s2); min-width: 0; }
60+ & .repo-name { display: flex; align-items: center; flex-wrap: wrap; gap: var(--s1) var(--s2); font-family: var(--mono); font-size: 17px; line-height: 26px; overflow-wrap: anywhere; }
5761 & .repo-name a { color: var(--ink-hi); text-decoration: none; }
58- /* the whole card is the target of the name link */
59- & .repo-name a::after { content: ""; position: absolute; inset: 0; border-radius: var(--r); }
60- & .repo-name a:focus-visible { outline: none; }
61- & .repo-name a:focus-visible::after { outline: 2px solid var(--focus); outline-offset: 1px; }
6262 & .repo:hover .repo-name a, & .repo[data-cursor] .repo-name a { text-decoration: underline; }
6363 & .repo-name .pill { margin-left: 0; }
6464 & .repo-desc {
65- flex: 1; color: var(--ink-dim);
65+ color: var(--ink-dim);
6666 display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden;
6767 }
6868 & .repo-desc.mute { color: var(--ink-mute); font-style: italic; }
6969 & .repo-meta { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s1) var(--s4); font-size: var(--fs-s); color: var(--ink-mute); }
7070 & .repo-meta time { color: var(--ink-dim); }
7171 & .repo-branch { display: inline-flex; align-items: center; gap: 6px; font-family: var(--mono); color: var(--ink-dim); }
72+ & .repo-ci {
73+ grid-area: ci; justify-self: end; display: inline-flex; align-items: center; gap: var(--s1); margin-bottom: var(--s2);
74+ font-size: var(--fs-s); color: var(--ink-dim); text-decoration: none;
75+ }
76+ & .repo-ci:hover { color: var(--ink-hi); text-decoration: underline; }
77+ & .repo-ci .ck { margin: 0; }
78+ & .repo-commits { grid-area: recent; min-width: 0; margin: 0; padding: 0; list-style: none; font-size: var(--fs-s); }
79+ & p.repo-commits { font-style: italic; }
80+ & .repo-commits li { display: flex; align-items: baseline; gap: var(--s2); min-width: 0; padding: 2px 0; }
81+ & .rc-sha { flex: none; font-family: var(--mono); color: var(--ink-dim); text-decoration: none; }
82+ & .rc-sha:hover { color: var(--ink-hi); text-decoration: underline; }
83+ & .rc-subject { flex: 1 1 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink-hi); }
84+ & .repo-commits time { flex: none; white-space: nowrap; }
85+ @media (max-width: 720px) {
86+ & .repo { grid-template-columns: minmax(0, 1fr); grid-template-areas: "main" "ci" "recent"; grid-template-rows: none; row-gap: var(--s3); }
87+ & .repo-ci { justify-self: start; margin-bottom: 0; }
88+ }
7289 }
7390 .list.passkeys {
7491 & .c-name { width: auto; font-weight: 600; }
@@ -28,8 +28,8 @@
28 & .c-sub { color: var(--ink-dim); }28 & .c-sub { color: var(--ink-dim); }
29 }29 }
30 30
31- /* home: the repositories as cards in a grid (one column on a phone); the31+ /* home: the repositories as rows in a vertical list; the page is a stack of
32- page is a stack of sections, so others can sit beside or below */32+ sections, so others can sit beside or below */
33 .home {33 .home {
34 display: flex; flex-direction: column; gap: var(--s5);34 display: flex; flex-direction: column; gap: var(--s5);
35 & .home-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--s3) var(--s4); margin-bottom: var(--s4); }35 & .home-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--s3) var(--s4); margin-bottom: var(--s4); }
@@ -43,32 +43,49 @@
43 & .sort a:hover { color: var(--ink-hi); background: var(--bg-raised); }43 & .sort a:hover { color: var(--ink-hi); background: var(--bg-raised); }
44 & .sort a[aria-current="true"] { color: var(--ink-hi); background: var(--bg-raised); border-color: var(--border-strong); }44 & .sort a[aria-current="true"] { color: var(--ink-hi); background: var(--bg-raised); border-color: var(--border-strong); }
45 }45 }
46+ /* a row: name, description and facts on the left; the state of the latest
47+ run at the top right, the newest commits under it. Stacked on a phone. */
46 .repos {48 .repos {
47- display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 380px), 1fr)); gap: var(--s3);49+ display: flex; flex-direction: column; gap: var(--s3);
48 margin: 0; padding: 0; list-style: none;50 margin: 0; padding: 0; list-style: none;
49 & .repo {51 & .repo {
50- position: relative; display: flex; flex-direction: column; gap: var(--s2); min-width: 0;52+ display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); grid-template-areas: "main ci" "main recent";
53+ grid-template-rows: auto 1fr; column-gap: var(--s6); min-width: 0;
51 padding: var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r);54 padding: var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r);
52 }55 }
53 & .repo:hover { background: var(--bg-raised); border-color: var(--border-strong); }56 & .repo:hover { background: var(--bg-raised); border-color: var(--border-strong); }
54 & .repo[data-cursor] { background: var(--bg-raised); border-color: var(--ink-mute); box-shadow: inset 3px 0 0 var(--ink-hi); }57 & .repo[data-cursor] { background: var(--bg-raised); border-color: var(--ink-mute); box-shadow: inset 3px 0 0 var(--ink-hi); }
55 & .repo[hidden] { display: none; }58 & .repo[hidden] { display: none; }
56- & .repo-name { display: flex; align-items: center; flex-wrap: wrap; gap: var(--s1) var(--s2); font-size: 17px; line-height: 26px; overflow-wrap: anywhere; }59+ & .repo-main { grid-area: main; display: flex; flex-direction: column; gap: var(--s2); min-width: 0; }
60+ & .repo-name { display: flex; align-items: center; flex-wrap: wrap; gap: var(--s1) var(--s2); font-family: var(--mono); font-size: 17px; line-height: 26px; overflow-wrap: anywhere; }
57 & .repo-name a { color: var(--ink-hi); text-decoration: none; }61 & .repo-name a { color: var(--ink-hi); text-decoration: none; }
58- /* the whole card is the target of the name link */
59- & .repo-name a::after { content: ""; position: absolute; inset: 0; border-radius: var(--r); }
60- & .repo-name a:focus-visible { outline: none; }
61- & .repo-name a:focus-visible::after { outline: 2px solid var(--focus); outline-offset: 1px; }
62 & .repo:hover .repo-name a, & .repo[data-cursor] .repo-name a { text-decoration: underline; }62 & .repo:hover .repo-name a, & .repo[data-cursor] .repo-name a { text-decoration: underline; }
63 & .repo-name .pill { margin-left: 0; }63 & .repo-name .pill { margin-left: 0; }
64 & .repo-desc {64 & .repo-desc {
65- flex: 1; color: var(--ink-dim);65+ color: var(--ink-dim);
66 display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden;66 display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden;
67 }67 }
68 & .repo-desc.mute { color: var(--ink-mute); font-style: italic; }68 & .repo-desc.mute { color: var(--ink-mute); font-style: italic; }
69 & .repo-meta { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s1) var(--s4); font-size: var(--fs-s); color: var(--ink-mute); }69 & .repo-meta { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s1) var(--s4); font-size: var(--fs-s); color: var(--ink-mute); }
70 & .repo-meta time { color: var(--ink-dim); }70 & .repo-meta time { color: var(--ink-dim); }
71 & .repo-branch { display: inline-flex; align-items: center; gap: 6px; font-family: var(--mono); color: var(--ink-dim); }71 & .repo-branch { display: inline-flex; align-items: center; gap: 6px; font-family: var(--mono); color: var(--ink-dim); }
72+ & .repo-ci {
73+ grid-area: ci; justify-self: end; display: inline-flex; align-items: center; gap: var(--s1); margin-bottom: var(--s2);
74+ font-size: var(--fs-s); color: var(--ink-dim); text-decoration: none;
75+ }
76+ & .repo-ci:hover { color: var(--ink-hi); text-decoration: underline; }
77+ & .repo-ci .ck { margin: 0; }
78+ & .repo-commits { grid-area: recent; min-width: 0; margin: 0; padding: 0; list-style: none; font-size: var(--fs-s); }
79+ & p.repo-commits { font-style: italic; }
80+ & .repo-commits li { display: flex; align-items: baseline; gap: var(--s2); min-width: 0; padding: 2px 0; }
81+ & .rc-sha { flex: none; font-family: var(--mono); color: var(--ink-dim); text-decoration: none; }
82+ & .rc-sha:hover { color: var(--ink-hi); text-decoration: underline; }
83+ & .rc-subject { flex: 1 1 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink-hi); }
84+ & .repo-commits time { flex: none; white-space: nowrap; }
85+ @media (max-width: 720px) {
86+ & .repo { grid-template-columns: minmax(0, 1fr); grid-template-areas: "main" "ci" "recent"; grid-template-rows: none; row-gap: var(--s3); }
87+ & .repo-ci { justify-self: start; margin-bottom: 0; }
88+ }
72 }89 }
73 .list.passkeys {90 .list.passkeys {
74 & .c-name { width: auto; font-weight: 600; }91 & .c-name { width: auto; font-weight: 600; }
M internal/adapters/web/view.go +15 -0
@@ -88,6 +88,21 @@ type HomeRow struct {
8888 Private bool
8989 // Fresh marks a row that a push just updated (live updates only).
9090 Fresh bool
91+ // Commits are the newest of the default branch. NoCommits says there is
92+ // none (never pushed, or an empty branch); a failed read says nothing.
93+ Commits []CommitRow
94+ NoCommits bool
95+ // CI is the latest run of the default branch, nil when there is none to
96+ // show.
97+ CI *HomeCI
98+}
99+
100+// HomeCI is the state of the latest run of the default branch of a repository.
101+type HomeCI struct {
102+ Href string
103+ Kind string // the mark: ok, bad, running, queued, skipped, cancelled
104+ Label string // the word next to it
105+ Title string
91106 }
92107
93108 // SortLink is one way to order the home list; it links to that order.
@@ -88,6 +88,21 @@ type HomeRow struct {
88 Private bool88 Private bool
89 // Fresh marks a row that a push just updated (live updates only).89 // Fresh marks a row that a push just updated (live updates only).
90 Fresh bool90 Fresh bool
91+ // Commits are the newest of the default branch. NoCommits says there is
92+ // none (never pushed, or an empty branch); a failed read says nothing.
93+ Commits []CommitRow
94+ NoCommits bool
95+ // CI is the latest run of the default branch, nil when there is none to
96+ // show.
97+ CI *HomeCI
98+}
99+
100+// HomeCI is the state of the latest run of the default branch of a repository.
101+type HomeCI struct {
102+ Href string
103+ Kind string // the mark: ok, bad, running, queued, skipped, cancelled
104+ Label string // the word next to it
105+ Title string
91 }106 }
92 107
93 // SortLink is one way to order the home list; it links to that order.108 // SortLink is one way to order the home list; it links to that order.
M internal/app/ci_flow_test.go +21 -3
@@ -518,7 +518,9 @@ func TestCIRepositoryDeletionRemovesRunsAndLogs(t *testing.T) {
518518 assert.Greater(t, c.runs()[0].GetId(), int64(1), "run ids are never reused")
519519 }
520520
521-// The events of a run and its jobs are published on the repository topic.
521+// The events of a run and its jobs are published on the repository topic;
522+// "home", which shows the CI state of each repository, gets the runs and the
523+// jobs that ended only.
522524 func TestCIEventsAreStreamedOnTheRepositoryTopic(t *testing.T) {
523525 c := newCIHarness(t, nil)
524526 runner := c.registerRunner("alpha", "ubuntu-latest")
@@ -549,11 +551,27 @@ func TestCIEventsAreStreamedOnTheRepositoryTopic(t *testing.T) {
549551 assert.GreaterOrEqual(t, runUpdates, 2)
550552 assert.GreaterOrEqual(t, jobUpdates, 2)
551553
552- // They are never sent on "home".
553554 home := c.watch(c.root, "", "home")
554555 _, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: lastRun.GetId()}))
555556 require.NoError(t, err)
556- c.sentinel(t, home, "sentinel")
557+ ev := home.next(t, watchWait)
558+ require.NotNil(t, ev.GetRunUpdated(), "home gets the run: %v", ev)
559+ assert.Equal(t, "proj", ev.GetRunUpdated().GetRun().GetRepo())
560+ // The rest of the rerun before the sentinel: runs, no job in progress.
561+ _, err = c.forge.Repos.Create(c.ctx, domain.Root(), ports.CreateRepoParams{Name: c.name("sentinel"), Visibility: domain.VisibilityPublic})
562+ require.NoError(t, err)
563+ for {
564+ ev = home.next(t, watchWait)
565+ if ev.GetRepoCreated() != nil {
566+ assert.Equal(t, "sentinel", ev.GetRepoCreated().GetRepo().GetName())
567+ break
568+ }
569+ if j := ev.GetJobUpdated(); j != nil {
570+ assert.Equal(t, pb.CiStatus_CI_STATUS_COMPLETED, j.GetJob().GetStatus(), "a job in progress does not reach home")
571+ continue
572+ }
573+ require.NotNil(t, ev.GetRunUpdated(), "only CI state reaches home: %v", ev)
574+ }
557575 }
558576
559577 // A new branch at a commit that already exists, and a branch rewound to an
@@ -518,7 +518,9 @@ func TestCIRepositoryDeletionRemovesRunsAndLogs(t *testing.T) {
518 assert.Greater(t, c.runs()[0].GetId(), int64(1), "run ids are never reused")518 assert.Greater(t, c.runs()[0].GetId(), int64(1), "run ids are never reused")
519 }519 }
520 520
521-// The events of a run and its jobs are published on the repository topic.521+// The events of a run and its jobs are published on the repository topic;
522+// "home", which shows the CI state of each repository, gets the runs and the
523+// jobs that ended only.
522 func TestCIEventsAreStreamedOnTheRepositoryTopic(t *testing.T) {524 func TestCIEventsAreStreamedOnTheRepositoryTopic(t *testing.T) {
523 c := newCIHarness(t, nil)525 c := newCIHarness(t, nil)
524 runner := c.registerRunner("alpha", "ubuntu-latest")526 runner := c.registerRunner("alpha", "ubuntu-latest")
@@ -549,11 +551,27 @@ func TestCIEventsAreStreamedOnTheRepositoryTopic(t *testing.T) {
549 assert.GreaterOrEqual(t, runUpdates, 2)551 assert.GreaterOrEqual(t, runUpdates, 2)
550 assert.GreaterOrEqual(t, jobUpdates, 2)552 assert.GreaterOrEqual(t, jobUpdates, 2)
551 553
552- // They are never sent on "home".
553 home := c.watch(c.root, "", "home")554 home := c.watch(c.root, "", "home")
554 _, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: lastRun.GetId()}))555 _, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: lastRun.GetId()}))
555 require.NoError(t, err)556 require.NoError(t, err)
556- c.sentinel(t, home, "sentinel")557+ ev := home.next(t, watchWait)
558+ require.NotNil(t, ev.GetRunUpdated(), "home gets the run: %v", ev)
559+ assert.Equal(t, "proj", ev.GetRunUpdated().GetRun().GetRepo())
560+ // The rest of the rerun before the sentinel: runs, no job in progress.
561+ _, err = c.forge.Repos.Create(c.ctx, domain.Root(), ports.CreateRepoParams{Name: c.name("sentinel"), Visibility: domain.VisibilityPublic})
562+ require.NoError(t, err)
563+ for {
564+ ev = home.next(t, watchWait)
565+ if ev.GetRepoCreated() != nil {
566+ assert.Equal(t, "sentinel", ev.GetRepoCreated().GetRepo().GetName())
567+ break
568+ }
569+ if j := ev.GetJobUpdated(); j != nil {
570+ assert.Equal(t, pb.CiStatus_CI_STATUS_COMPLETED, j.GetJob().GetStatus(), "a job in progress does not reach home")
571+ continue
572+ }
573+ require.NotNil(t, ev.GetRunUpdated(), "only CI state reaches home: %v", ev)
574+ }
557 }575 }
558 576
559 // A new branch at a commit that already exists, and a branch rewound to an577 // A new branch at a commit that already exists, and a branch rewound to an
M internal/app/testdata/web/home.html +17 -0
@@ -67,6 +67,7 @@
6767 </div>
6868 <ul class="repos" id="repo-rows" data-rows>
6969 <li id="repo-demo" class="repo" data-cursor>
70+<div class="repo-main">
7071 <h2 class="repo-name">
7172 <a href="/demo">demo</a>
7273 </h2>
@@ -78,8 +79,22 @@
7879 <span>pushed <time datetime="STAMP" title="STAMP">AGO</time>
7980 </span>
8081 </p>
82+</div>
83+<ul class="repo-commits" aria-label="Recent commits of demo">
84+<li>
85+<a class="rc-sha" href="/demo/commit/SHA40">SHA7</a>
86+<span class="rc-subject" title="add main.go">add main.go</span>
87+<time class="dim" datetime="STAMP" title="STAMP">AGO</time>
88+</li>
89+<li>
90+<a class="rc-sha" href="/demo/commit/SHA40">SHA7</a>
91+<span class="rc-subject" title="first commit">first commit</span>
92+<time class="dim" datetime="STAMP" title="STAMP">AGO</time>
93+</li>
94+</ul>
8195 </li>
8296 <li id="repo-quiet" class="repo">
97+<div class="repo-main">
8398 <h2 class="repo-name">
8499 <a href="/quiet">quiet</a>
85100 </h2>
@@ -90,6 +105,8 @@
90105 </span>main</span>
91106 <span>never pushed</span>
92107 </p>
108+</div>
109+<p class="repo-commits mute">no commits yet</p>
93110 </li>
94111 </ul>
95112 </section>
@@ -67,6 +67,7 @@
67 </div>67 </div>
68 <ul class="repos" id="repo-rows" data-rows>68 <ul class="repos" id="repo-rows" data-rows>
69 <li id="repo-demo" class="repo" data-cursor>69 <li id="repo-demo" class="repo" data-cursor>
70+<div class="repo-main">
70 <h2 class="repo-name">71 <h2 class="repo-name">
71 <a href="/demo">demo</a>72 <a href="/demo">demo</a>
72 </h2>73 </h2>
@@ -78,8 +79,22 @@
78 <span>pushed <time datetime="STAMP" title="STAMP">AGO</time>79 <span>pushed <time datetime="STAMP" title="STAMP">AGO</time>
79 </span>80 </span>
80 </p>81 </p>
82+</div>
83+<ul class="repo-commits" aria-label="Recent commits of demo">
84+<li>
85+<a class="rc-sha" href="/demo/commit/SHA40">SHA7</a>
86+<span class="rc-subject" title="add main.go">add main.go</span>
87+<time class="dim" datetime="STAMP" title="STAMP">AGO</time>
88+</li>
89+<li>
90+<a class="rc-sha" href="/demo/commit/SHA40">SHA7</a>
91+<span class="rc-subject" title="first commit">first commit</span>
92+<time class="dim" datetime="STAMP" title="STAMP">AGO</time>
93+</li>
94+</ul>
81 </li>95 </li>
82 <li id="repo-quiet" class="repo">96 <li id="repo-quiet" class="repo">
97+<div class="repo-main">
83 <h2 class="repo-name">98 <h2 class="repo-name">
84 <a href="/quiet">quiet</a>99 <a href="/quiet">quiet</a>
85 </h2>100 </h2>
@@ -90,6 +105,8 @@
90 </span>main</span>105 </span>main</span>
91 <span>never pushed</span>106 <span>never pushed</span>
92 </p>107 </p>
108+</div>
109+<p class="repo-commits mute">no commits yet</p>
93 </li>110 </li>
94 </ul>111 </ul>
95 </section>112 </section>
M internal/app/web_events_test.go +1 -0
@@ -125,6 +125,7 @@ func TestWebEventsHome(t *testing.T) {
125125 assert.Contains(t, ev.data, `<li id="repo-pub"`)
126126 assert.Contains(t, ev.data, `new push`)
127127 assert.Contains(t, ev.data, `href="/pub"`)
128+ assert.Contains(t, ev.data, `<ul class="repo-commits" aria-label="Recent commits of pub">`, "the live row lists the commits too")
128129
129130 // A new repository is a row; so is a repository created by a push.
130131 _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("fresh"), Description: "a <b>fresh</b> one", Visibility: domain.VisibilityPublic})
@@ -125,6 +125,7 @@ func TestWebEventsHome(t *testing.T) {
125 assert.Contains(t, ev.data, `<li id="repo-pub"`)125 assert.Contains(t, ev.data, `<li id="repo-pub"`)
126 assert.Contains(t, ev.data, `new push`)126 assert.Contains(t, ev.data, `new push`)
127 assert.Contains(t, ev.data, `href="/pub"`)127 assert.Contains(t, ev.data, `href="/pub"`)
128+ assert.Contains(t, ev.data, `<ul class="repo-commits" aria-label="Recent commits of pub">`, "the live row lists the commits too")
128 129
129 // A new repository is a row; so is a repository created by a push.130 // A new repository is a row; so is a repository created by a push.
130 _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("fresh"), Description: "a <b>fresh</b> one", Visibility: domain.VisibilityPublic})131 _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("fresh"), Description: "a <b>fresh</b> one", Visibility: domain.VisibilityPublic})
M internal/usecase/events.go +5 -5
@@ -488,13 +488,13 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
488488 case domain.CommentAdded:
489489 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
490490 case domain.RunUpdated:
491- // CI events go on the topic of their repository only, never on home.
492- if !w.repos[ev.Run.Repo] {
493- return nil
494- }
491+ // Home shows the CI state of each repository.
495492 return e.deliverRepo(ctx, w, ev.Run.Repo, false, be, out, send)
496493 case domain.JobUpdated:
497- if !w.repos[ev.Job.Repo] {
494+ // Home gets a job when it ends only: a failed job turns the state to
495+ // failing before its run ends. Its progress stays on the topic of its
496+ // repository.
497+ if !w.repos[ev.Job.Repo] && ev.Job.Phase != domain.CIPhaseEnded {
498498 return nil
499499 }
500500 return e.deliverRepo(ctx, w, ev.Job.Repo, false, be, out, send)
@@ -488,13 +488,13 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
488 case domain.CommentAdded:488 case domain.CommentAdded:
489 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)489 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
490 case domain.RunUpdated:490 case domain.RunUpdated:
491- // CI events go on the topic of their repository only, never on home.491+ // Home shows the CI state of each repository.
492- if !w.repos[ev.Run.Repo] {
493- return nil
494- }
495 return e.deliverRepo(ctx, w, ev.Run.Repo, false, be, out, send)492 return e.deliverRepo(ctx, w, ev.Run.Repo, false, be, out, send)
496 case domain.JobUpdated:493 case domain.JobUpdated:
497- if !w.repos[ev.Job.Repo] {494+ // Home gets a job when it ends only: a failed job turns the state to
495+ // failing before its run ends. Its progress stays on the topic of its
496+ // repository.
497+ if !w.repos[ev.Job.Repo] && ev.Job.Phase != domain.CIPhaseEnded {
498 return nil498 return nil
499 }499 }
500 return e.deliverRepo(ctx, w, ev.Job.Repo, false, be, out, send)500 return e.deliverRepo(ctx, w, ev.Job.Repo, false, be, out, send)
M internal/usecase/events_test.go +41 -0
@@ -881,3 +881,44 @@ func TestPushEventsKeepTheirSnapshot(t *testing.T) {
881881 })
882882 })
883883 }
884+
885+// Home shows the CI state of each repository: a run update reaches it, for the
886+// watchers who may read the repository, and so does a job that ended; the
887+// progress of a job stays on the repository topic.
888+func TestWatchHomeGetsRunUpdates(t *testing.T) {
889+ t.Parallel()
890+ synctest.Test(t, func(t *testing.T) {
891+ r := newEventRig(t, 0)
892+ pub := r.repo("pub", domain.VisibilityPublic)
893+ priv := r.repo("priv", domain.VisibilityPrivate)
894+ ctx, cancel := context.WithCancel(context.Background())
895+ defer cancel()
896+
897+ anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{Topics: []string{"home"}})
898+ root := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"home"}})
899+ synctest.Wait()
900+ require.Equal(t, 2, r.events.Watchers())
901+
902+ run := func(n domain.RepoName, vis domain.Visibility) domain.RunUpdated {
903+ return domain.RunUpdated{Run: domain.CIRun{Repo: n, ID: 1}, At: now, Visibility: vis, CreatedAt: now}
904+ }
905+ job := func(n domain.RepoName, vis domain.Visibility, phase domain.CIJobPhase) domain.JobUpdated {
906+ return domain.JobUpdated{Job: domain.CIJob{Repo: n, Phase: phase}, At: now, Visibility: vis, CreatedAt: now}
907+ }
908+ r.feed <- job(pub, domain.VisibilityPublic, domain.CIPhaseRunning)
909+ r.feed <- run(priv, domain.VisibilityPrivate)
910+ r.feed <- job(priv, domain.VisibilityPrivate, domain.CIPhaseEnded)
911+ r.feed <- run(pub, domain.VisibilityPublic)
912+ r.feed <- job(pub, domain.VisibilityPublic, domain.CIPhaseEnded)
913+
914+ assert.Equal(t, pub, anon.next(t).Event.(domain.RunUpdated).Run.Repo, "anonymous: no private run, no running job")
915+ assert.Equal(t, pub, anon.next(t).Event.(domain.JobUpdated).Job.Repo)
916+ anon.quiet(t, r, pub)
917+
918+ assert.Equal(t, priv, root.next(t).Event.(domain.RunUpdated).Run.Repo)
919+ assert.Equal(t, priv, root.next(t).Event.(domain.JobUpdated).Job.Repo)
920+ assert.Equal(t, pub, root.next(t).Event.(domain.RunUpdated).Run.Repo)
921+ assert.Equal(t, pub, root.next(t).Event.(domain.JobUpdated).Job.Repo)
922+ root.quiet(t, r, pub)
923+ })
924+}
@@ -881,3 +881,44 @@ func TestPushEventsKeepTheirSnapshot(t *testing.T) {
881 })881 })
882 })882 })
883 }883 }
884+
885+// Home shows the CI state of each repository: a run update reaches it, for the
886+// watchers who may read the repository, and so does a job that ended; the
887+// progress of a job stays on the repository topic.
888+func TestWatchHomeGetsRunUpdates(t *testing.T) {
889+ t.Parallel()
890+ synctest.Test(t, func(t *testing.T) {
891+ r := newEventRig(t, 0)
892+ pub := r.repo("pub", domain.VisibilityPublic)
893+ priv := r.repo("priv", domain.VisibilityPrivate)
894+ ctx, cancel := context.WithCancel(context.Background())
895+ defer cancel()
896+
897+ anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{Topics: []string{"home"}})
898+ root := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"home"}})
899+ synctest.Wait()
900+ require.Equal(t, 2, r.events.Watchers())
901+
902+ run := func(n domain.RepoName, vis domain.Visibility) domain.RunUpdated {
903+ return domain.RunUpdated{Run: domain.CIRun{Repo: n, ID: 1}, At: now, Visibility: vis, CreatedAt: now}
904+ }
905+ job := func(n domain.RepoName, vis domain.Visibility, phase domain.CIJobPhase) domain.JobUpdated {
906+ return domain.JobUpdated{Job: domain.CIJob{Repo: n, Phase: phase}, At: now, Visibility: vis, CreatedAt: now}
907+ }
908+ r.feed <- job(pub, domain.VisibilityPublic, domain.CIPhaseRunning)
909+ r.feed <- run(priv, domain.VisibilityPrivate)
910+ r.feed <- job(priv, domain.VisibilityPrivate, domain.CIPhaseEnded)
911+ r.feed <- run(pub, domain.VisibilityPublic)
912+ r.feed <- job(pub, domain.VisibilityPublic, domain.CIPhaseEnded)
913+
914+ assert.Equal(t, pub, anon.next(t).Event.(domain.RunUpdated).Run.Repo, "anonymous: no private run, no running job")
915+ assert.Equal(t, pub, anon.next(t).Event.(domain.JobUpdated).Job.Repo)
916+ anon.quiet(t, r, pub)
917+
918+ assert.Equal(t, priv, root.next(t).Event.(domain.RunUpdated).Run.Repo)
919+ assert.Equal(t, priv, root.next(t).Event.(domain.JobUpdated).Job.Repo)
920+ assert.Equal(t, pub, root.next(t).Event.(domain.RunUpdated).Run.Repo)
921+ assert.Equal(t, pub, root.next(t).Event.(domain.JobUpdated).Job.Repo)
922+ root.quiet(t, r, pub)
923+ })
924+}

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 44

↑↓ moveenter openesc close