detached cd99c65

refactor(web): one helper shapes the log lines of the page, raw feed and live feed

appendLogLine turns the runner's markers into structure and joins the pieces of
a line it cut, for the job page and the live feed (the SSE feed now sends merged
lines; a piece that opens a batch is marked and joined by the page). The dead
placeholders of actions.go are gone. The live log script moves out of ermite.js
into log.js with a testable follow(), covered by jstest/log.test.mjs (append,
join, scroll at the end only, close on ci-end).
commit
cd99c65322d694fa509569a3eaab1de8f1d5a9d4
author
brice · · 2026-10-12 19:20 UTC
parent
7513520
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
43 files · +502 -262
M internal/adapters/web/ASSETS.md +1 -0
@@ -9,6 +9,7 @@ content-hashed names. Nothing is fetched at runtime.
99 | `static/js/hx-live.min.js` | htmx 4 `hx-live` extension | `mockups/assets/htmx/ext/hx-live.min.js` | `adf9b90949d57baf20dc2eaf1d1fd46c81347ac82c090a5e2912ce6b37726734` |
1010 | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` |
1111 | `static/js/ermite.js` | key sequences to `k-*` events, the functions the keys and inputs call (`window.ermite`), the live fragments (`ermiteLive`), the diff table of contents observer | `mockups/d/js/ermite.js`, extended here | |
12+| `static/js/log.js` | the live log of a running job (`window.ermiteLog`, tested in Node by `jstest/log.test.mjs`) | written here | |
1213 | `static/js/files.js` | the Files view: directories open in place (`window.ermiteTree`), the fuzzy file finder and the palette's file hits (`window.ermiteFinder`; its core is tested in Node by `jstest/files.test.mjs`) | written here | |
1314 | `static/css/56-chroma.css` | syntax classes of the code view, mapped to the `--syn-*` tokens (no colour of its own) | generated by `go run scripts/chroma-css.go` from chroma's token classes | |
1415 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |
@@ -9,6 +9,7 @@ content-hashed names. Nothing is fetched at runtime.
9 | `static/js/hx-live.min.js` | htmx 4 `hx-live` extension | `mockups/assets/htmx/ext/hx-live.min.js` | `adf9b90949d57baf20dc2eaf1d1fd46c81347ac82c090a5e2912ce6b37726734` |9 | `static/js/hx-live.min.js` | htmx 4 `hx-live` extension | `mockups/assets/htmx/ext/hx-live.min.js` | `adf9b90949d57baf20dc2eaf1d1fd46c81347ac82c090a5e2912ce6b37726734` |
10 | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` |10 | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` |
11 | `static/js/ermite.js` | key sequences to `k-*` events, the functions the keys and inputs call (`window.ermite`), the live fragments (`ermiteLive`), the diff table of contents observer | `mockups/d/js/ermite.js`, extended here | |11 | `static/js/ermite.js` | key sequences to `k-*` events, the functions the keys and inputs call (`window.ermite`), the live fragments (`ermiteLive`), the diff table of contents observer | `mockups/d/js/ermite.js`, extended here | |
12+| `static/js/log.js` | the live log of a running job (`window.ermiteLog`, tested in Node by `jstest/log.test.mjs`) | written here | |
12 | `static/js/files.js` | the Files view: directories open in place (`window.ermiteTree`), the fuzzy file finder and the palette's file hits (`window.ermiteFinder`; its core is tested in Node by `jstest/files.test.mjs`) | written here | |13 | `static/js/files.js` | the Files view: directories open in place (`window.ermiteTree`), the fuzzy file finder and the palette's file hits (`window.ermiteFinder`; its core is tested in Node by `jstest/files.test.mjs`) | written here | |
13 | `static/css/56-chroma.css` | syntax classes of the code view, mapped to the `--syn-*` tokens (no colour of its own) | generated by `go run scripts/chroma-css.go` from chroma's token classes | |14 | `static/css/56-chroma.css` | syntax classes of the code view, mapped to the `--syn-*` tokens (no colour of its own) | generated by `go run scripts/chroma-css.go` from chroma's token classes | |
14 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |15 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |
M internal/adapters/web/actions.go +41 -54
@@ -198,6 +198,9 @@ type LogLineView struct {
198198 // Group marks "##[group]" and "##[endgroup]" lines and Error the
199199 // "##[error]" ones; the text is shown without the marker.
200200 Group, End, Error bool
201+ // Continued marks a piece of a line whose beginning is in an earlier batch
202+ // of the live feed: the page appends its text to the line before it.
203+ Continued bool
201204 }
202205
203206 // JobPage is one job with its steps and its log.
@@ -452,6 +455,30 @@ func (s *Server) job(ctx context.Context, r *http.Request) (templ.Component, int
452455 return Job(l, p), http.StatusOK, nil
453456 }
454457
458+// appendLogLine adds the line n of a job log to the lines shown. The markers
459+// of the runner ("##[group]", "##[endgroup]", "##[error]") become structure and
460+// are not shown as text. A piece that continues a line too long to send whole
461+// is joined to the line before it; one that opens the list (its line is in an
462+// earlier batch) keeps Continued, and the page appends it to that line.
463+func appendLogLine(out []LogLineView, n int64, l ports.JobLogLine) []LogLineView {
464+ text := l.Text
465+ v := LogLineView{N: n, Step: l.Step, Time: l.Time.UTC().Format("15:04:05"), Continued: l.Continued}
466+ switch {
467+ case strings.HasPrefix(text, "##[group]"):
468+ v.Group, text = true, strings.TrimPrefix(text, "##[group]")
469+ case strings.HasPrefix(text, "##[endgroup]"):
470+ v.End, text = true, strings.TrimPrefix(text, "##[endgroup]")
471+ case strings.HasPrefix(text, "##[error]"):
472+ v.Error, text = true, strings.TrimPrefix(text, "##[error]")
473+ }
474+ v.Text = visibleText(text)
475+ if l.Continued && len(out) > 0 {
476+ out[len(out)-1].Text += v.Text
477+ return out
478+ }
479+ return append(out, v)
480+}
481+
455482 // logClient is the key of the visitor for the limit of log streams per client.
456483 func (s *Server) logClient(r *http.Request) string {
457484 return clientlimit.Key(clientlimit.ClientAddr(r, s.cfg.TrustedProxies))
@@ -465,40 +492,14 @@ func (s *Server) readLog(ctx context.Context, client string, repo domain.RepoNam
465492 }
466493 var out []LogLineView
467494 next := from
468- ranges := j.StepRanges()
469- stepOf := func(n int64) int32 {
470- for i, r := range ranges {
471- if n >= r.Start && n < r.End {
472- return j.Steps[i].Number
473- }
474- }
475- return 0
476- }
477495 stop := errors.New("enough")
478496 err := s.cfg.Runs.GetJobLog(ctx, principal(ctx), repo, ports.GetJobLogParams{JobID: j.ID, Attempt: j.Attempt, Offset: from, Client: client}, func(b ports.LogBatch) error {
479497 for i, l := range b.Lines {
480- if len(out) >= max {
481- return stop
482- }
483- n := b.Offset + int64(i)
484- text := l.Text
485- v := LogLineView{N: n, Step: stepOf(n), Time: l.Time.UTC().Format("15:04:05")}
486- switch {
487- case strings.HasPrefix(text, "##[group]"):
488- v.Group, text = true, strings.TrimPrefix(text, "##[group]")
489- case strings.HasPrefix(text, "##[endgroup]"):
490- v.End, text = true, strings.TrimPrefix(text, "##[endgroup]")
491- case strings.HasPrefix(text, "##[error]"):
492- v.Error, text = true, strings.TrimPrefix(text, "##[error]")
498+ if len(out) >= max && !l.Continued {
499+ return stop // the rest of a cut line still belongs to this page
493500 }
494- if l.Continued && len(out) > 0 {
495- out[len(out)-1].Text += visibleText(text)
496- next = n + 1
497- continue
498- }
499- v.Text = visibleText(text)
500- out = append(out, v)
501- next = n + 1
501+ out = appendLogLine(out, b.Offset+int64(i), l)
502+ next = b.Offset + int64(i) + 1
502503 }
503504 return nil
504505 })
@@ -533,8 +534,9 @@ func (s *Server) rawLog(w http.ResponseWriter, r *http.Request) {
533534 }
534535 attempt = int32(n)
535536 }
536- view, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, attempt)
537- if err != nil {
537+ // The job is read first: a job that is not there, or not readable, is a 404
538+ // before any header of the log is sent.
539+ if _, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, attempt); err != nil {
538540 s.fail(ctx, w, r, err)
539541 return
540542 }
@@ -573,7 +575,6 @@ func (s *Server) rawLog(w http.ResponseWriter, r *http.Request) {
573575 _, _ = fmt.Fprintln(w)
574576 }
575577 if err != nil && !open && errors.Is(err, domain.ErrNotFound) {
576- _ = view
577578 w.WriteHeader(http.StatusNotFound)
578579 }
579580 }
@@ -714,7 +715,7 @@ func checksSummary(repo domain.RepoName, sha string, checks []ports.CheckView) C
714715
715716 // checksSection is the checks of the rail of a merge request, with the rule of
716717 // the repository that applies to them.
717-func checksSection(repo domain.Repo, checks []ports.CheckView, now time.Time) ChecksSection {
718+func checksSection(repo domain.Repo, checks []ports.CheckView) ChecksSection {
718719 sec := ChecksSection{}
719720 for _, c := range checks {
720721 row := CheckRow{
@@ -736,7 +737,6 @@ func checksSection(repo domain.Repo, checks []ports.CheckView, now time.Time) Ch
736737 default:
737738 sec.Note = "checks do not block the merge in this repository."
738739 }
739- _ = now
740740 return sec
741741 }
742742
@@ -770,8 +770,7 @@ func (s *Server) logStream(w http.ResponseWriter, r *http.Request) {
770770 s.denyEvents(ctx, w, errNotFound)
771771 return
772772 }
773- view, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, 0)
774- if err != nil {
773+ if _, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, 0); err != nil {
775774 s.denyEvents(ctx, w, err)
776775 return
777776 }
@@ -786,29 +785,17 @@ func (s *Server) logStream(w http.ResponseWriter, r *http.Request) {
786785 if out.raw(": connected\n\n") != nil {
787786 return
788787 }
789- ranges := view.StepRanges()
790- _ = ranges
791788 n := from
792789 err = s.cfg.Runs.GetJobLog(ctx, principal(ctx), d.repo.Name, ports.GetJobLogParams{
793790 JobID: jobID, Offset: from, Follow: true,
794791 Client: s.logClient(r),
795792 }, func(b ports.LogBatch) error {
796- var html strings.Builder
793+ var views []LogLineView
797794 for i, l := range b.Lines {
798- v := LogLineView{N: b.Offset + int64(i), Time: l.Time.UTC().Format("15:04:05"), Step: l.Step}
799- text := l.Text
800- switch {
801- case strings.HasPrefix(text, "##[group]"):
802- v.Group, text = true, strings.TrimPrefix(text, "##[group]")
803- case strings.HasPrefix(text, "##[endgroup]"):
804- v.End, text = true, strings.TrimPrefix(text, "##[endgroup]")
805- case strings.HasPrefix(text, "##[error]"):
806- v.Error, text = true, strings.TrimPrefix(text, "##[error]")
807- }
808- v.Text = visibleText(text)
809- if l.Continued {
810- continue // joined to its line by the page reload; rare and harmless
811- }
795+ views = appendLogLine(views, b.Offset+int64(i), l)
796+ }
797+ var html strings.Builder
798+ for _, v := range views {
812799 var b2 strings.Builder
813800 if err := LogLine(v).Render(ctx, &b2); err != nil {
814801 return err
@@ -198,6 +198,9 @@ type LogLineView struct {
198 // Group marks "##[group]" and "##[endgroup]" lines and Error the198 // Group marks "##[group]" and "##[endgroup]" lines and Error the
199 // "##[error]" ones; the text is shown without the marker.199 // "##[error]" ones; the text is shown without the marker.
200 Group, End, Error bool200 Group, End, Error bool
201+ // Continued marks a piece of a line whose beginning is in an earlier batch
202+ // of the live feed: the page appends its text to the line before it.
203+ Continued bool
201 }204 }
202 205
203 // JobPage is one job with its steps and its log.206 // JobPage is one job with its steps and its log.
@@ -452,6 +455,30 @@ func (s *Server) job(ctx context.Context, r *http.Request) (templ.Component, int
452 return Job(l, p), http.StatusOK, nil455 return Job(l, p), http.StatusOK, nil
453 }456 }
454 457
458+// appendLogLine adds the line n of a job log to the lines shown. The markers
459+// of the runner ("##[group]", "##[endgroup]", "##[error]") become structure and
460+// are not shown as text. A piece that continues a line too long to send whole
461+// is joined to the line before it; one that opens the list (its line is in an
462+// earlier batch) keeps Continued, and the page appends it to that line.
463+func appendLogLine(out []LogLineView, n int64, l ports.JobLogLine) []LogLineView {
464+ text := l.Text
465+ v := LogLineView{N: n, Step: l.Step, Time: l.Time.UTC().Format("15:04:05"), Continued: l.Continued}
466+ switch {
467+ case strings.HasPrefix(text, "##[group]"):
468+ v.Group, text = true, strings.TrimPrefix(text, "##[group]")
469+ case strings.HasPrefix(text, "##[endgroup]"):
470+ v.End, text = true, strings.TrimPrefix(text, "##[endgroup]")
471+ case strings.HasPrefix(text, "##[error]"):
472+ v.Error, text = true, strings.TrimPrefix(text, "##[error]")
473+ }
474+ v.Text = visibleText(text)
475+ if l.Continued && len(out) > 0 {
476+ out[len(out)-1].Text += v.Text
477+ return out
478+ }
479+ return append(out, v)
480+}
481+
455 // logClient is the key of the visitor for the limit of log streams per client.482 // logClient is the key of the visitor for the limit of log streams per client.
456 func (s *Server) logClient(r *http.Request) string {483 func (s *Server) logClient(r *http.Request) string {
457 return clientlimit.Key(clientlimit.ClientAddr(r, s.cfg.TrustedProxies))484 return clientlimit.Key(clientlimit.ClientAddr(r, s.cfg.TrustedProxies))
@@ -465,40 +492,14 @@ func (s *Server) readLog(ctx context.Context, client string, repo domain.RepoNam
465 }492 }
466 var out []LogLineView493 var out []LogLineView
467 next := from494 next := from
468- ranges := j.StepRanges()
469- stepOf := func(n int64) int32 {
470- for i, r := range ranges {
471- if n >= r.Start && n < r.End {
472- return j.Steps[i].Number
473- }
474- }
475- return 0
476- }
477 stop := errors.New("enough")495 stop := errors.New("enough")
478 err := s.cfg.Runs.GetJobLog(ctx, principal(ctx), repo, ports.GetJobLogParams{JobID: j.ID, Attempt: j.Attempt, Offset: from, Client: client}, func(b ports.LogBatch) error {496 err := s.cfg.Runs.GetJobLog(ctx, principal(ctx), repo, ports.GetJobLogParams{JobID: j.ID, Attempt: j.Attempt, Offset: from, Client: client}, func(b ports.LogBatch) error {
479 for i, l := range b.Lines {497 for i, l := range b.Lines {
480- if len(out) >= max {498+ if len(out) >= max && !l.Continued {
481- return stop499+ return stop // the rest of a cut line still belongs to this page
482- }
483- n := b.Offset + int64(i)
484- text := l.Text
485- v := LogLineView{N: n, Step: stepOf(n), Time: l.Time.UTC().Format("15:04:05")}
486- switch {
487- case strings.HasPrefix(text, "##[group]"):
488- v.Group, text = true, strings.TrimPrefix(text, "##[group]")
489- case strings.HasPrefix(text, "##[endgroup]"):
490- v.End, text = true, strings.TrimPrefix(text, "##[endgroup]")
491- case strings.HasPrefix(text, "##[error]"):
492- v.Error, text = true, strings.TrimPrefix(text, "##[error]")
493 }500 }
494- if l.Continued && len(out) > 0 {501+ out = appendLogLine(out, b.Offset+int64(i), l)
495- out[len(out)-1].Text += visibleText(text)502+ next = b.Offset + int64(i) + 1
496- next = n + 1
497- continue
498- }
499- v.Text = visibleText(text)
500- out = append(out, v)
501- next = n + 1
502 }503 }
503 return nil504 return nil
504 })505 })
@@ -533,8 +534,9 @@ func (s *Server) rawLog(w http.ResponseWriter, r *http.Request) {
533 }534 }
534 attempt = int32(n)535 attempt = int32(n)
535 }536 }
536- view, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, attempt)537+ // The job is read first: a job that is not there, or not readable, is a 404
537- if err != nil {538+ // before any header of the log is sent.
539+ if _, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, attempt); err != nil {
538 s.fail(ctx, w, r, err)540 s.fail(ctx, w, r, err)
539 return541 return
540 }542 }
@@ -573,7 +575,6 @@ func (s *Server) rawLog(w http.ResponseWriter, r *http.Request) {
573 _, _ = fmt.Fprintln(w)575 _, _ = fmt.Fprintln(w)
574 }576 }
575 if err != nil && !open && errors.Is(err, domain.ErrNotFound) {577 if err != nil && !open && errors.Is(err, domain.ErrNotFound) {
576- _ = view
577 w.WriteHeader(http.StatusNotFound)578 w.WriteHeader(http.StatusNotFound)
578 }579 }
579 }580 }
@@ -714,7 +715,7 @@ func checksSummary(repo domain.RepoName, sha string, checks []ports.CheckView) C
714 715
715 // checksSection is the checks of the rail of a merge request, with the rule of716 // checksSection is the checks of the rail of a merge request, with the rule of
716 // the repository that applies to them.717 // the repository that applies to them.
717-func checksSection(repo domain.Repo, checks []ports.CheckView, now time.Time) ChecksSection {718+func checksSection(repo domain.Repo, checks []ports.CheckView) ChecksSection {
718 sec := ChecksSection{}719 sec := ChecksSection{}
719 for _, c := range checks {720 for _, c := range checks {
720 row := CheckRow{721 row := CheckRow{
@@ -736,7 +737,6 @@ func checksSection(repo domain.Repo, checks []ports.CheckView, now time.Time) Ch
736 default:737 default:
737 sec.Note = "checks do not block the merge in this repository."738 sec.Note = "checks do not block the merge in this repository."
738 }739 }
739- _ = now
740 return sec740 return sec
741 }741 }
742 742
@@ -770,8 +770,7 @@ func (s *Server) logStream(w http.ResponseWriter, r *http.Request) {
770 s.denyEvents(ctx, w, errNotFound)770 s.denyEvents(ctx, w, errNotFound)
771 return771 return
772 }772 }
773- view, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, 0)773+ if _, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, 0); err != nil {
774- if err != nil {
775 s.denyEvents(ctx, w, err)774 s.denyEvents(ctx, w, err)
776 return775 return
777 }776 }
@@ -786,29 +785,17 @@ func (s *Server) logStream(w http.ResponseWriter, r *http.Request) {
786 if out.raw(": connected\n\n") != nil {785 if out.raw(": connected\n\n") != nil {
787 return786 return
788 }787 }
789- ranges := view.StepRanges()
790- _ = ranges
791 n := from788 n := from
792 err = s.cfg.Runs.GetJobLog(ctx, principal(ctx), d.repo.Name, ports.GetJobLogParams{789 err = s.cfg.Runs.GetJobLog(ctx, principal(ctx), d.repo.Name, ports.GetJobLogParams{
793 JobID: jobID, Offset: from, Follow: true,790 JobID: jobID, Offset: from, Follow: true,
794 Client: s.logClient(r),791 Client: s.logClient(r),
795 }, func(b ports.LogBatch) error {792 }, func(b ports.LogBatch) error {
796- var html strings.Builder793+ var views []LogLineView
797 for i, l := range b.Lines {794 for i, l := range b.Lines {
798- v := LogLineView{N: b.Offset + int64(i), Time: l.Time.UTC().Format("15:04:05"), Step: l.Step}795+ views = appendLogLine(views, b.Offset+int64(i), l)
799- text := l.Text796+ }
800- switch {797+ var html strings.Builder
801- case strings.HasPrefix(text, "##[group]"):798+ for _, v := range views {
802- v.Group, text = true, strings.TrimPrefix(text, "##[group]")
803- case strings.HasPrefix(text, "##[endgroup]"):
804- v.End, text = true, strings.TrimPrefix(text, "##[endgroup]")
805- case strings.HasPrefix(text, "##[error]"):
806- v.Error, text = true, strings.TrimPrefix(text, "##[error]")
807- }
808- v.Text = visibleText(text)
809- if l.Continued {
810- continue // joined to its line by the page reload; rare and harmless
811- }
812 var b2 strings.Builder799 var b2 strings.Builder
813 if err := LogLine(v).Render(ctx, &b2); err != nil {800 if err := LogLine(v).Render(ctx, &b2); err != nil {
814 return err801 return err
M internal/adapters/web/actions.templ +1 -1
@@ -208,7 +208,7 @@ templ Job(l Layout, p JobPage) {
208208 // LogLine is a line of the log. Group and error markers of the runner show as
209209 // headings and errors, never as raw text.
210210 templ LogLine(ln LogLineView) {
211- <li id={ "L" + itoa64(ln.N) } class={ logClass(ln) } data-step={ itoa(int(ln.Step)) }><time>{ ln.Time }</time><span class="t">{ ln.Text }</span></li>
211+ <li id={ "L" + itoa64(ln.N) } class={ logClass(ln) } data-step={ itoa(int(ln.Step)) } data-cont?={ ln.Continued }><time>{ ln.Time }</time><span class="t">{ ln.Text }</span></li>
212212 }
213213
214214 templ jobRail(p JobPage) {
@@ -208,7 +208,7 @@ templ Job(l Layout, p JobPage) {
208 // LogLine is a line of the log. Group and error markers of the runner show as208 // LogLine is a line of the log. Group and error markers of the runner show as
209 // headings and errors, never as raw text.209 // headings and errors, never as raw text.
210 templ LogLine(ln LogLineView) {210 templ LogLine(ln LogLineView) {
211- <li id={ "L" + itoa64(ln.N) } class={ logClass(ln) } data-step={ itoa(int(ln.Step)) }><time>{ ln.Time }</time><span class="t">{ ln.Text }</span></li>211+ <li id={ "L" + itoa64(ln.N) } class={ logClass(ln) } data-step={ itoa(int(ln.Step)) } data-cont?={ ln.Continued }><time>{ ln.Time }</time><span class="t">{ ln.Text }</span></li>
212 }212 }
213 213
214 templ jobRail(p JobPage) {214 templ jobRail(p JobPage) {
M internal/adapters/web/actions_templ.go +49 -39
@@ -1596,33 +1596,43 @@ func LogLine(ln LogLineView) templ.Component {
15961596 if templ_7745c5c3_Err != nil {
15971597 return templ_7745c5c3_Err
15981598 }
1599- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "\"><time>")
1599+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "\"")
1600+ if templ_7745c5c3_Err != nil {
1601+ return templ_7745c5c3_Err
1602+ }
1603+ if ln.Continued {
1604+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, " data-cont")
1605+ if templ_7745c5c3_Err != nil {
1606+ return templ_7745c5c3_Err
1607+ }
1608+ }
1609+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "><time>")
16001610 if templ_7745c5c3_Err != nil {
16011611 return templ_7745c5c3_Err
16021612 }
16031613 var templ_7745c5c3_Var86 string
16041614 templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Time)
16051615 if templ_7745c5c3_Err != nil {
1606- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 211, Col: 102}
1616+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 211, Col: 130}
16071617 }
16081618 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86))
16091619 if templ_7745c5c3_Err != nil {
16101620 return templ_7745c5c3_Err
16111621 }
1612- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "</time><span class=\"t\">")
1622+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "</time><span class=\"t\">")
16131623 if templ_7745c5c3_Err != nil {
16141624 return templ_7745c5c3_Err
16151625 }
16161626 var templ_7745c5c3_Var87 string
16171627 templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Text)
16181628 if templ_7745c5c3_Err != nil {
1619- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 211, Col: 136}
1629+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 211, Col: 164}
16201630 }
16211631 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var87))
16221632 if templ_7745c5c3_Err != nil {
16231633 return templ_7745c5c3_Err
16241634 }
1625- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "</span></li>")
1635+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "</span></li>")
16261636 if templ_7745c5c3_Err != nil {
16271637 return templ_7745c5c3_Err
16281638 }
@@ -1651,7 +1661,7 @@ func jobRail(p JobPage) templ.Component {
16511661 templ_7745c5c3_Var88 = templ.NopComponent
16521662 }
16531663 ctx = templ.ClearChildren(ctx)
1654- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "<section aria-labelledby=\"r-job\"><h2 id=\"r-job\">Steps</h2>")
1664+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "<section aria-labelledby=\"r-job\"><h2 id=\"r-job\">Steps</h2>")
16551665 if templ_7745c5c3_Err != nil {
16561666 return templ_7745c5c3_Err
16571667 }
@@ -1659,7 +1669,7 @@ func jobRail(p JobPage) templ.Component {
16591669 if templ_7745c5c3_Err != nil {
16601670 return templ_7745c5c3_Err
16611671 }
1662- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "</section><section aria-labelledby=\"r-jobf\"><h2 id=\"r-jobf\">Job</h2><dl class=\"kv\"><dt>run</dt><dd><a href=\"")
1672+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "</section><section aria-labelledby=\"r-jobf\"><h2 id=\"r-jobf\">Job</h2><dl class=\"kv\"><dt>run</dt><dd><a href=\"")
16631673 if templ_7745c5c3_Err != nil {
16641674 return templ_7745c5c3_Err
16651675 }
@@ -1672,7 +1682,7 @@ func jobRail(p JobPage) templ.Component {
16721682 if templ_7745c5c3_Err != nil {
16731683 return templ_7745c5c3_Err
16741684 }
1675- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "\">")
1685+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "\">")
16761686 if templ_7745c5c3_Err != nil {
16771687 return templ_7745c5c3_Err
16781688 }
@@ -1685,12 +1695,12 @@ func jobRail(p JobPage) templ.Component {
16851695 if templ_7745c5c3_Err != nil {
16861696 return templ_7745c5c3_Err
16871697 }
1688- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "</a></dd>")
1698+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "</a></dd>")
16891699 if templ_7745c5c3_Err != nil {
16901700 return templ_7745c5c3_Err
16911701 }
16921702 if p.Runner != "" {
1693- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "<dt>runner</dt><dd>")
1703+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "<dt>runner</dt><dd>")
16941704 if templ_7745c5c3_Err != nil {
16951705 return templ_7745c5c3_Err
16961706 }
@@ -1703,13 +1713,13 @@ func jobRail(p JobPage) templ.Component {
17031713 if templ_7745c5c3_Err != nil {
17041714 return templ_7745c5c3_Err
17051715 }
1706- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "</dd>")
1716+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "</dd>")
17071717 if templ_7745c5c3_Err != nil {
17081718 return templ_7745c5c3_Err
17091719 }
17101720 }
17111721 if p.Isolation != "" {
1712- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "<dt>isolation</dt><dd>")
1722+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "<dt>isolation</dt><dd>")
17131723 if templ_7745c5c3_Err != nil {
17141724 return templ_7745c5c3_Err
17151725 }
@@ -1722,12 +1732,12 @@ func jobRail(p JobPage) templ.Component {
17221732 if templ_7745c5c3_Err != nil {
17231733 return templ_7745c5c3_Err
17241734 }
1725- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "</dd>")
1735+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "</dd>")
17261736 if templ_7745c5c3_Err != nil {
17271737 return templ_7745c5c3_Err
17281738 }
17291739 }
1730- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "<dt>lines</dt><dd>")
1740+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "<dt>lines</dt><dd>")
17311741 if templ_7745c5c3_Err != nil {
17321742 return templ_7745c5c3_Err
17331743 }
@@ -1740,7 +1750,7 @@ func jobRail(p JobPage) templ.Component {
17401750 if templ_7745c5c3_Err != nil {
17411751 return templ_7745c5c3_Err
17421752 }
1743- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "</dd></dl><p><a href=\"")
1753+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "</dd></dl><p><a href=\"")
17441754 if templ_7745c5c3_Err != nil {
17451755 return templ_7745c5c3_Err
17461756 }
@@ -1753,7 +1763,7 @@ func jobRail(p JobPage) templ.Component {
17531763 if templ_7745c5c3_Err != nil {
17541764 return templ_7745c5c3_Err
17551765 }
1756- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "\">raw log</a></p></section>")
1766+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "\">raw log</a></p></section>")
17571767 if templ_7745c5c3_Err != nil {
17581768 return templ_7745c5c3_Err
17591769 }
@@ -1785,7 +1795,7 @@ func CommitChecks(c ChecksSummary) templ.Component {
17851795 }
17861796 ctx = templ.ClearChildren(ctx)
17871797 if c.Total > 0 {
1788- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "<a class=\"ck-sum\" href=\"")
1798+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "<a class=\"ck-sum\" href=\"")
17891799 if templ_7745c5c3_Err != nil {
17901800 return templ_7745c5c3_Err
17911801 }
@@ -1798,7 +1808,7 @@ func CommitChecks(c ChecksSummary) templ.Component {
17981808 if templ_7745c5c3_Err != nil {
17991809 return templ_7745c5c3_Err
18001810 }
1801- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "\" title=\"")
1811+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "\" title=\"")
18021812 if templ_7745c5c3_Err != nil {
18031813 return templ_7745c5c3_Err
18041814 }
@@ -1811,7 +1821,7 @@ func CommitChecks(c ChecksSummary) templ.Component {
18111821 if templ_7745c5c3_Err != nil {
18121822 return templ_7745c5c3_Err
18131823 }
1814- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "\">")
1824+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "\">")
18151825 if templ_7745c5c3_Err != nil {
18161826 return templ_7745c5c3_Err
18171827 }
@@ -1819,7 +1829,7 @@ func CommitChecks(c ChecksSummary) templ.Component {
18191829 if templ_7745c5c3_Err != nil {
18201830 return templ_7745c5c3_Err
18211831 }
1822- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "<span class=\"mute\">")
1832+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "<span class=\"mute\">")
18231833 if templ_7745c5c3_Err != nil {
18241834 return templ_7745c5c3_Err
18251835 }
@@ -1832,7 +1842,7 @@ func CommitChecks(c ChecksSummary) templ.Component {
18321842 if templ_7745c5c3_Err != nil {
18331843 return templ_7745c5c3_Err
18341844 }
1835- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "</span></a>")
1845+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "</span></a>")
18361846 if templ_7745c5c3_Err != nil {
18371847 return templ_7745c5c3_Err
18381848 }
@@ -1863,7 +1873,7 @@ func MRChecks(c ChecksSection) templ.Component {
18631873 templ_7745c5c3_Var99 = templ.NopComponent
18641874 }
18651875 ctx = templ.ClearChildren(ctx)
1866- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "<section aria-labelledby=\"r-checks\" id=\"mr-checks\"><h2 id=\"r-checks\">Checks <span class=\"count\">")
1876+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "<section aria-labelledby=\"r-checks\" id=\"mr-checks\"><h2 id=\"r-checks\">Checks <span class=\"count\">")
18671877 if templ_7745c5c3_Err != nil {
18681878 return templ_7745c5c3_Err
18691879 }
@@ -1876,22 +1886,22 @@ func MRChecks(c ChecksSection) templ.Component {
18761886 if templ_7745c5c3_Err != nil {
18771887 return templ_7745c5c3_Err
18781888 }
1879- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "</span></h2>")
1889+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "</span></h2>")
18801890 if templ_7745c5c3_Err != nil {
18811891 return templ_7745c5c3_Err
18821892 }
18831893 if len(c.Rows) == 0 {
1884- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "<p class=\"dim\">no checks</p>")
1894+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "<p class=\"dim\">no checks</p>")
18851895 if templ_7745c5c3_Err != nil {
18861896 return templ_7745c5c3_Err
18871897 }
18881898 } else {
1889- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "<ul class=\"checks\">")
1899+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "<ul class=\"checks\">")
18901900 if templ_7745c5c3_Err != nil {
18911901 return templ_7745c5c3_Err
18921902 }
18931903 for _, r := range c.Rows {
1894- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "<li>")
1904+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "<li>")
18951905 if templ_7745c5c3_Err != nil {
18961906 return templ_7745c5c3_Err
18971907 }
@@ -1900,7 +1910,7 @@ func MRChecks(c ChecksSection) templ.Component {
19001910 return templ_7745c5c3_Err
19011911 }
19021912 if r.Href != "" {
1903- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "<a href=\"")
1913+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "<a href=\"")
19041914 if templ_7745c5c3_Err != nil {
19051915 return templ_7745c5c3_Err
19061916 }
@@ -1913,7 +1923,7 @@ func MRChecks(c ChecksSection) templ.Component {
19131923 if templ_7745c5c3_Err != nil {
19141924 return templ_7745c5c3_Err
19151925 }
1916- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "\">")
1926+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "\">")
19171927 if templ_7745c5c3_Err != nil {
19181928 return templ_7745c5c3_Err
19191929 }
@@ -1926,12 +1936,12 @@ func MRChecks(c ChecksSection) templ.Component {
19261936 if templ_7745c5c3_Err != nil {
19271937 return templ_7745c5c3_Err
19281938 }
1929- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "</a> ")
1939+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "</a> ")
19301940 if templ_7745c5c3_Err != nil {
19311941 return templ_7745c5c3_Err
19321942 }
19331943 } else {
1934- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "<span>")
1944+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "<span>")
19351945 if templ_7745c5c3_Err != nil {
19361946 return templ_7745c5c3_Err
19371947 }
@@ -1944,12 +1954,12 @@ func MRChecks(c ChecksSection) templ.Component {
19441954 if templ_7745c5c3_Err != nil {
19451955 return templ_7745c5c3_Err
19461956 }
1947- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "</span> ")
1957+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "</span> ")
19481958 if templ_7745c5c3_Err != nil {
19491959 return templ_7745c5c3_Err
19501960 }
19511961 }
1952- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "<span class=\"dim\">")
1962+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "<span class=\"dim\">")
19531963 if templ_7745c5c3_Err != nil {
19541964 return templ_7745c5c3_Err
19551965 }
@@ -1962,28 +1972,28 @@ func MRChecks(c ChecksSection) templ.Component {
19621972 if templ_7745c5c3_Err != nil {
19631973 return templ_7745c5c3_Err
19641974 }
1965- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "</span> ")
1975+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "</span> ")
19661976 if templ_7745c5c3_Err != nil {
19671977 return templ_7745c5c3_Err
19681978 }
19691979 if r.Waiting {
1970- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "<span class=\"mute\">waiting for a runner</span>")
1980+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "<span class=\"mute\">waiting for a runner</span>")
19711981 if templ_7745c5c3_Err != nil {
19721982 return templ_7745c5c3_Err
19731983 }
19741984 }
1975- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "</li>")
1985+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "</li>")
19761986 if templ_7745c5c3_Err != nil {
19771987 return templ_7745c5c3_Err
19781988 }
19791989 }
1980- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "</ul>")
1990+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "</ul>")
19811991 if templ_7745c5c3_Err != nil {
19821992 return templ_7745c5c3_Err
19831993 }
19841994 }
19851995 if c.Note != "" {
1986- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "<p class=\"mute\">")
1996+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "<p class=\"mute\">")
19871997 if templ_7745c5c3_Err != nil {
19881998 return templ_7745c5c3_Err
19891999 }
@@ -1996,12 +2006,12 @@ func MRChecks(c ChecksSection) templ.Component {
19962006 if templ_7745c5c3_Err != nil {
19972007 return templ_7745c5c3_Err
19982008 }
1999- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "</p>")
2009+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "</p>")
20002010 if templ_7745c5c3_Err != nil {
20012011 return templ_7745c5c3_Err
20022012 }
20032013 }
2004- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "</section>")
2014+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "</section>")
20052015 if templ_7745c5c3_Err != nil {
20062016 return templ_7745c5c3_Err
20072017 }
@@ -1596,33 +1596,43 @@ func LogLine(ln LogLineView) templ.Component {
1596 if templ_7745c5c3_Err != nil {1596 if templ_7745c5c3_Err != nil {
1597 return templ_7745c5c3_Err1597 return templ_7745c5c3_Err
1598 }1598 }
1599- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "\"><time>")1599+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "\"")
1600+ if templ_7745c5c3_Err != nil {
1601+ return templ_7745c5c3_Err
1602+ }
1603+ if ln.Continued {
1604+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, " data-cont")
1605+ if templ_7745c5c3_Err != nil {
1606+ return templ_7745c5c3_Err
1607+ }
1608+ }
1609+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "><time>")
1600 if templ_7745c5c3_Err != nil {1610 if templ_7745c5c3_Err != nil {
1601 return templ_7745c5c3_Err1611 return templ_7745c5c3_Err
1602 }1612 }
1603 var templ_7745c5c3_Var86 string1613 var templ_7745c5c3_Var86 string
1604 templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Time)1614 templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Time)
1605 if templ_7745c5c3_Err != nil {1615 if templ_7745c5c3_Err != nil {
1606- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 211, Col: 102}1616+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 211, Col: 130}
1607 }1617 }
1608 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86))1618 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86))
1609 if templ_7745c5c3_Err != nil {1619 if templ_7745c5c3_Err != nil {
1610 return templ_7745c5c3_Err1620 return templ_7745c5c3_Err
1611 }1621 }
1612- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "</time><span class=\"t\">")1622+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "</time><span class=\"t\">")
1613 if templ_7745c5c3_Err != nil {1623 if templ_7745c5c3_Err != nil {
1614 return templ_7745c5c3_Err1624 return templ_7745c5c3_Err
1615 }1625 }
1616 var templ_7745c5c3_Var87 string1626 var templ_7745c5c3_Var87 string
1617 templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Text)1627 templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Text)
1618 if templ_7745c5c3_Err != nil {1628 if templ_7745c5c3_Err != nil {
1619- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 211, Col: 136}1629+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 211, Col: 164}
1620 }1630 }
1621 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var87))1631 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var87))
1622 if templ_7745c5c3_Err != nil {1632 if templ_7745c5c3_Err != nil {
1623 return templ_7745c5c3_Err1633 return templ_7745c5c3_Err
1624 }1634 }
1625- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "</span></li>")1635+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "</span></li>")
1626 if templ_7745c5c3_Err != nil {1636 if templ_7745c5c3_Err != nil {
1627 return templ_7745c5c3_Err1637 return templ_7745c5c3_Err
1628 }1638 }
@@ -1651,7 +1661,7 @@ func jobRail(p JobPage) templ.Component {
1651 templ_7745c5c3_Var88 = templ.NopComponent1661 templ_7745c5c3_Var88 = templ.NopComponent
1652 }1662 }
1653 ctx = templ.ClearChildren(ctx)1663 ctx = templ.ClearChildren(ctx)
1654- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "<section aria-labelledby=\"r-job\"><h2 id=\"r-job\">Steps</h2>")1664+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "<section aria-labelledby=\"r-job\"><h2 id=\"r-job\">Steps</h2>")
1655 if templ_7745c5c3_Err != nil {1665 if templ_7745c5c3_Err != nil {
1656 return templ_7745c5c3_Err1666 return templ_7745c5c3_Err
1657 }1667 }
@@ -1659,7 +1669,7 @@ func jobRail(p JobPage) templ.Component {
1659 if templ_7745c5c3_Err != nil {1669 if templ_7745c5c3_Err != nil {
1660 return templ_7745c5c3_Err1670 return templ_7745c5c3_Err
1661 }1671 }
1662- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "</section><section aria-labelledby=\"r-jobf\"><h2 id=\"r-jobf\">Job</h2><dl class=\"kv\"><dt>run</dt><dd><a href=\"")1672+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "</section><section aria-labelledby=\"r-jobf\"><h2 id=\"r-jobf\">Job</h2><dl class=\"kv\"><dt>run</dt><dd><a href=\"")
1663 if templ_7745c5c3_Err != nil {1673 if templ_7745c5c3_Err != nil {
1664 return templ_7745c5c3_Err1674 return templ_7745c5c3_Err
1665 }1675 }
@@ -1672,7 +1682,7 @@ func jobRail(p JobPage) templ.Component {
1672 if templ_7745c5c3_Err != nil {1682 if templ_7745c5c3_Err != nil {
1673 return templ_7745c5c3_Err1683 return templ_7745c5c3_Err
1674 }1684 }
1675- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "\">")1685+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "\">")
1676 if templ_7745c5c3_Err != nil {1686 if templ_7745c5c3_Err != nil {
1677 return templ_7745c5c3_Err1687 return templ_7745c5c3_Err
1678 }1688 }
@@ -1685,12 +1695,12 @@ func jobRail(p JobPage) templ.Component {
1685 if templ_7745c5c3_Err != nil {1695 if templ_7745c5c3_Err != nil {
1686 return templ_7745c5c3_Err1696 return templ_7745c5c3_Err
1687 }1697 }
1688- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "</a></dd>")1698+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "</a></dd>")
1689 if templ_7745c5c3_Err != nil {1699 if templ_7745c5c3_Err != nil {
1690 return templ_7745c5c3_Err1700 return templ_7745c5c3_Err
1691 }1701 }
1692 if p.Runner != "" {1702 if p.Runner != "" {
1693- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "<dt>runner</dt><dd>")1703+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "<dt>runner</dt><dd>")
1694 if templ_7745c5c3_Err != nil {1704 if templ_7745c5c3_Err != nil {
1695 return templ_7745c5c3_Err1705 return templ_7745c5c3_Err
1696 }1706 }
@@ -1703,13 +1713,13 @@ func jobRail(p JobPage) templ.Component {
1703 if templ_7745c5c3_Err != nil {1713 if templ_7745c5c3_Err != nil {
1704 return templ_7745c5c3_Err1714 return templ_7745c5c3_Err
1705 }1715 }
1706- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "</dd>")1716+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "</dd>")
1707 if templ_7745c5c3_Err != nil {1717 if templ_7745c5c3_Err != nil {
1708 return templ_7745c5c3_Err1718 return templ_7745c5c3_Err
1709 }1719 }
1710 }1720 }
1711 if p.Isolation != "" {1721 if p.Isolation != "" {
1712- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "<dt>isolation</dt><dd>")1722+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "<dt>isolation</dt><dd>")
1713 if templ_7745c5c3_Err != nil {1723 if templ_7745c5c3_Err != nil {
1714 return templ_7745c5c3_Err1724 return templ_7745c5c3_Err
1715 }1725 }
@@ -1722,12 +1732,12 @@ func jobRail(p JobPage) templ.Component {
1722 if templ_7745c5c3_Err != nil {1732 if templ_7745c5c3_Err != nil {
1723 return templ_7745c5c3_Err1733 return templ_7745c5c3_Err
1724 }1734 }
1725- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "</dd>")1735+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "</dd>")
1726 if templ_7745c5c3_Err != nil {1736 if templ_7745c5c3_Err != nil {
1727 return templ_7745c5c3_Err1737 return templ_7745c5c3_Err
1728 }1738 }
1729 }1739 }
1730- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "<dt>lines</dt><dd>")1740+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "<dt>lines</dt><dd>")
1731 if templ_7745c5c3_Err != nil {1741 if templ_7745c5c3_Err != nil {
1732 return templ_7745c5c3_Err1742 return templ_7745c5c3_Err
1733 }1743 }
@@ -1740,7 +1750,7 @@ func jobRail(p JobPage) templ.Component {
1740 if templ_7745c5c3_Err != nil {1750 if templ_7745c5c3_Err != nil {
1741 return templ_7745c5c3_Err1751 return templ_7745c5c3_Err
1742 }1752 }
1743- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "</dd></dl><p><a href=\"")1753+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "</dd></dl><p><a href=\"")
1744 if templ_7745c5c3_Err != nil {1754 if templ_7745c5c3_Err != nil {
1745 return templ_7745c5c3_Err1755 return templ_7745c5c3_Err
1746 }1756 }
@@ -1753,7 +1763,7 @@ func jobRail(p JobPage) templ.Component {
1753 if templ_7745c5c3_Err != nil {1763 if templ_7745c5c3_Err != nil {
1754 return templ_7745c5c3_Err1764 return templ_7745c5c3_Err
1755 }1765 }
1756- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "\">raw log</a></p></section>")1766+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "\">raw log</a></p></section>")
1757 if templ_7745c5c3_Err != nil {1767 if templ_7745c5c3_Err != nil {
1758 return templ_7745c5c3_Err1768 return templ_7745c5c3_Err
1759 }1769 }
@@ -1785,7 +1795,7 @@ func CommitChecks(c ChecksSummary) templ.Component {
1785 }1795 }
1786 ctx = templ.ClearChildren(ctx)1796 ctx = templ.ClearChildren(ctx)
1787 if c.Total > 0 {1797 if c.Total > 0 {
1788- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "<a class=\"ck-sum\" href=\"")1798+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "<a class=\"ck-sum\" href=\"")
1789 if templ_7745c5c3_Err != nil {1799 if templ_7745c5c3_Err != nil {
1790 return templ_7745c5c3_Err1800 return templ_7745c5c3_Err
1791 }1801 }
@@ -1798,7 +1808,7 @@ func CommitChecks(c ChecksSummary) templ.Component {
1798 if templ_7745c5c3_Err != nil {1808 if templ_7745c5c3_Err != nil {
1799 return templ_7745c5c3_Err1809 return templ_7745c5c3_Err
1800 }1810 }
1801- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "\" title=\"")1811+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "\" title=\"")
1802 if templ_7745c5c3_Err != nil {1812 if templ_7745c5c3_Err != nil {
1803 return templ_7745c5c3_Err1813 return templ_7745c5c3_Err
1804 }1814 }
@@ -1811,7 +1821,7 @@ func CommitChecks(c ChecksSummary) templ.Component {
1811 if templ_7745c5c3_Err != nil {1821 if templ_7745c5c3_Err != nil {
1812 return templ_7745c5c3_Err1822 return templ_7745c5c3_Err
1813 }1823 }
1814- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "\">")1824+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "\">")
1815 if templ_7745c5c3_Err != nil {1825 if templ_7745c5c3_Err != nil {
1816 return templ_7745c5c3_Err1826 return templ_7745c5c3_Err
1817 }1827 }
@@ -1819,7 +1829,7 @@ func CommitChecks(c ChecksSummary) templ.Component {
1819 if templ_7745c5c3_Err != nil {1829 if templ_7745c5c3_Err != nil {
1820 return templ_7745c5c3_Err1830 return templ_7745c5c3_Err
1821 }1831 }
1822- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "<span class=\"mute\">")1832+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "<span class=\"mute\">")
1823 if templ_7745c5c3_Err != nil {1833 if templ_7745c5c3_Err != nil {
1824 return templ_7745c5c3_Err1834 return templ_7745c5c3_Err
1825 }1835 }
@@ -1832,7 +1842,7 @@ func CommitChecks(c ChecksSummary) templ.Component {
1832 if templ_7745c5c3_Err != nil {1842 if templ_7745c5c3_Err != nil {
1833 return templ_7745c5c3_Err1843 return templ_7745c5c3_Err
1834 }1844 }
1835- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "</span></a>")1845+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "</span></a>")
1836 if templ_7745c5c3_Err != nil {1846 if templ_7745c5c3_Err != nil {
1837 return templ_7745c5c3_Err1847 return templ_7745c5c3_Err
1838 }1848 }
@@ -1863,7 +1873,7 @@ func MRChecks(c ChecksSection) templ.Component {
1863 templ_7745c5c3_Var99 = templ.NopComponent1873 templ_7745c5c3_Var99 = templ.NopComponent
1864 }1874 }
1865 ctx = templ.ClearChildren(ctx)1875 ctx = templ.ClearChildren(ctx)
1866- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "<section aria-labelledby=\"r-checks\" id=\"mr-checks\"><h2 id=\"r-checks\">Checks <span class=\"count\">")1876+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "<section aria-labelledby=\"r-checks\" id=\"mr-checks\"><h2 id=\"r-checks\">Checks <span class=\"count\">")
1867 if templ_7745c5c3_Err != nil {1877 if templ_7745c5c3_Err != nil {
1868 return templ_7745c5c3_Err1878 return templ_7745c5c3_Err
1869 }1879 }
@@ -1876,22 +1886,22 @@ func MRChecks(c ChecksSection) templ.Component {
1876 if templ_7745c5c3_Err != nil {1886 if templ_7745c5c3_Err != nil {
1877 return templ_7745c5c3_Err1887 return templ_7745c5c3_Err
1878 }1888 }
1879- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "</span></h2>")1889+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "</span></h2>")
1880 if templ_7745c5c3_Err != nil {1890 if templ_7745c5c3_Err != nil {
1881 return templ_7745c5c3_Err1891 return templ_7745c5c3_Err
1882 }1892 }
1883 if len(c.Rows) == 0 {1893 if len(c.Rows) == 0 {
1884- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "<p class=\"dim\">no checks</p>")1894+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "<p class=\"dim\">no checks</p>")
1885 if templ_7745c5c3_Err != nil {1895 if templ_7745c5c3_Err != nil {
1886 return templ_7745c5c3_Err1896 return templ_7745c5c3_Err
1887 }1897 }
1888 } else {1898 } else {
1889- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "<ul class=\"checks\">")1899+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "<ul class=\"checks\">")
1890 if templ_7745c5c3_Err != nil {1900 if templ_7745c5c3_Err != nil {
1891 return templ_7745c5c3_Err1901 return templ_7745c5c3_Err
1892 }1902 }
1893 for _, r := range c.Rows {1903 for _, r := range c.Rows {
1894- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "<li>")1904+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "<li>")
1895 if templ_7745c5c3_Err != nil {1905 if templ_7745c5c3_Err != nil {
1896 return templ_7745c5c3_Err1906 return templ_7745c5c3_Err
1897 }1907 }
@@ -1900,7 +1910,7 @@ func MRChecks(c ChecksSection) templ.Component {
1900 return templ_7745c5c3_Err1910 return templ_7745c5c3_Err
1901 }1911 }
1902 if r.Href != "" {1912 if r.Href != "" {
1903- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "<a href=\"")1913+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "<a href=\"")
1904 if templ_7745c5c3_Err != nil {1914 if templ_7745c5c3_Err != nil {
1905 return templ_7745c5c3_Err1915 return templ_7745c5c3_Err
1906 }1916 }
@@ -1913,7 +1923,7 @@ func MRChecks(c ChecksSection) templ.Component {
1913 if templ_7745c5c3_Err != nil {1923 if templ_7745c5c3_Err != nil {
1914 return templ_7745c5c3_Err1924 return templ_7745c5c3_Err
1915 }1925 }
1916- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "\">")1926+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "\">")
1917 if templ_7745c5c3_Err != nil {1927 if templ_7745c5c3_Err != nil {
1918 return templ_7745c5c3_Err1928 return templ_7745c5c3_Err
1919 }1929 }
@@ -1926,12 +1936,12 @@ func MRChecks(c ChecksSection) templ.Component {
1926 if templ_7745c5c3_Err != nil {1936 if templ_7745c5c3_Err != nil {
1927 return templ_7745c5c3_Err1937 return templ_7745c5c3_Err
1928 }1938 }
1929- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "</a> ")1939+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "</a> ")
1930 if templ_7745c5c3_Err != nil {1940 if templ_7745c5c3_Err != nil {
1931 return templ_7745c5c3_Err1941 return templ_7745c5c3_Err
1932 }1942 }
1933 } else {1943 } else {
1934- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "<span>")1944+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "<span>")
1935 if templ_7745c5c3_Err != nil {1945 if templ_7745c5c3_Err != nil {
1936 return templ_7745c5c3_Err1946 return templ_7745c5c3_Err
1937 }1947 }
@@ -1944,12 +1954,12 @@ func MRChecks(c ChecksSection) templ.Component {
1944 if templ_7745c5c3_Err != nil {1954 if templ_7745c5c3_Err != nil {
1945 return templ_7745c5c3_Err1955 return templ_7745c5c3_Err
1946 }1956 }
1947- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "</span> ")1957+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "</span> ")
1948 if templ_7745c5c3_Err != nil {1958 if templ_7745c5c3_Err != nil {
1949 return templ_7745c5c3_Err1959 return templ_7745c5c3_Err
1950 }1960 }
1951 }1961 }
1952- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "<span class=\"dim\">")1962+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "<span class=\"dim\">")
1953 if templ_7745c5c3_Err != nil {1963 if templ_7745c5c3_Err != nil {
1954 return templ_7745c5c3_Err1964 return templ_7745c5c3_Err
1955 }1965 }
@@ -1962,28 +1972,28 @@ func MRChecks(c ChecksSection) templ.Component {
1962 if templ_7745c5c3_Err != nil {1972 if templ_7745c5c3_Err != nil {
1963 return templ_7745c5c3_Err1973 return templ_7745c5c3_Err
1964 }1974 }
1965- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "</span> ")1975+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "</span> ")
1966 if templ_7745c5c3_Err != nil {1976 if templ_7745c5c3_Err != nil {
1967 return templ_7745c5c3_Err1977 return templ_7745c5c3_Err
1968 }1978 }
1969 if r.Waiting {1979 if r.Waiting {
1970- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "<span class=\"mute\">waiting for a runner</span>")1980+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "<span class=\"mute\">waiting for a runner</span>")
1971 if templ_7745c5c3_Err != nil {1981 if templ_7745c5c3_Err != nil {
1972 return templ_7745c5c3_Err1982 return templ_7745c5c3_Err
1973 }1983 }
1974 }1984 }
1975- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "</li>")1985+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "</li>")
1976 if templ_7745c5c3_Err != nil {1986 if templ_7745c5c3_Err != nil {
1977 return templ_7745c5c3_Err1987 return templ_7745c5c3_Err
1978 }1988 }
1979 }1989 }
1980- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "</ul>")1990+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "</ul>")
1981 if templ_7745c5c3_Err != nil {1991 if templ_7745c5c3_Err != nil {
1982 return templ_7745c5c3_Err1992 return templ_7745c5c3_Err
1983 }1993 }
1984 }1994 }
1985 if c.Note != "" {1995 if c.Note != "" {
1986- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "<p class=\"mute\">")1996+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "<p class=\"mute\">")
1987 if templ_7745c5c3_Err != nil {1997 if templ_7745c5c3_Err != nil {
1988 return templ_7745c5c3_Err1998 return templ_7745c5c3_Err
1989 }1999 }
@@ -1996,12 +2006,12 @@ func MRChecks(c ChecksSection) templ.Component {
1996 if templ_7745c5c3_Err != nil {2006 if templ_7745c5c3_Err != nil {
1997 return templ_7745c5c3_Err2007 return templ_7745c5c3_Err
1998 }2008 }
1999- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "</p>")2009+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "</p>")
2000 if templ_7745c5c3_Err != nil {2010 if templ_7745c5c3_Err != nil {
2001 return templ_7745c5c3_Err2011 return templ_7745c5c3_Err
2002 }2012 }
2003 }2013 }
2004- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "</section>")2014+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "</section>")
2005 if templ_7745c5c3_Err != nil {2015 if templ_7745c5c3_Err != nil {
2006 return templ_7745c5c3_Err2016 return templ_7745c5c3_Err
2007 }2017 }
M internal/adapters/web/actions_test.go +42 -0
@@ -68,3 +68,45 @@ func TestLogPagesPassTheClientKey(t *testing.T) {
6868 assert.Equal(t, want, c)
6969 }
7070 }
71+
72+// M4: one helper shapes the lines of the page, the raw feed and the live feed.
73+func TestAppendLogLine(t *testing.T) {
74+ t.Parallel()
75+ at := time.Date(2026, 10, 12, 10, 11, 12, 0, time.UTC)
76+ line := func(text string, cont bool) ports.JobLogLine {
77+ return ports.JobLogLine{Step: 2, CILogLine: domain.CILogLine{Time: at, Text: text, Continued: cont}}
78+ }
79+ var out []LogLineView
80+ out = appendLogLine(out, 10, line("##[group]Run make", false))
81+ out = appendLogLine(out, 11, line("plain <b>", false))
82+ out = appendLogLine(out, 12, line("##[error]Process completed with exit code 1.", false))
83+ out = appendLogLine(out, 13, line("##[endgroup]", false))
84+ require.Len(t, out, 4)
85+ assert.True(t, out[0].Group)
86+ assert.Equal(t, "Run make", out[0].Text, "the marker is structure, not text")
87+ assert.EqualValues(t, 10, out[0].N)
88+ assert.Equal(t, "10:11:12", out[0].Time)
89+ assert.EqualValues(t, 2, out[0].Step)
90+ assert.False(t, out[1].Group || out[1].Error || out[1].End)
91+ assert.True(t, out[2].Error)
92+ assert.Equal(t, "Process completed with exit code 1.", out[2].Text)
93+ assert.True(t, out[3].End)
94+
95+ // A cut line comes back as one line, with the number of its first piece.
96+ var cut []LogLineView
97+ cut = appendLogLine(cut, 20, line("first half, ", false))
98+ cut = appendLogLine(cut, 21, line("second half", true))
99+ require.Len(t, cut, 1)
100+ assert.Equal(t, "first half, second half", cut[0].Text)
101+ assert.EqualValues(t, 20, cut[0].N)
102+
103+ // A piece that opens a batch has no line before it here: it stays, marked,
104+ // for the page to join to the line it already holds.
105+ open := appendLogLine(nil, 30, line("rest", true))
106+ require.Len(t, open, 1)
107+ assert.True(t, open[0].Continued)
108+
109+ // Invisible characters of the runner's text are shown, never applied.
110+ hidden := appendLogLine(nil, 40, line("a\u202eb", false))
111+ assert.NotContains(t, hidden[0].Text, "\u202e")
112+}
@@ -68,3 +68,45 @@ func TestLogPagesPassTheClientKey(t *testing.T) {
68 assert.Equal(t, want, c)68 assert.Equal(t, want, c)
69 }69 }
70 }70 }
71+
72+// M4: one helper shapes the lines of the page, the raw feed and the live feed.
73+func TestAppendLogLine(t *testing.T) {
74+ t.Parallel()
75+ at := time.Date(2026, 10, 12, 10, 11, 12, 0, time.UTC)
76+ line := func(text string, cont bool) ports.JobLogLine {
77+ return ports.JobLogLine{Step: 2, CILogLine: domain.CILogLine{Time: at, Text: text, Continued: cont}}
78+ }
79+ var out []LogLineView
80+ out = appendLogLine(out, 10, line("##[group]Run make", false))
81+ out = appendLogLine(out, 11, line("plain <b>", false))
82+ out = appendLogLine(out, 12, line("##[error]Process completed with exit code 1.", false))
83+ out = appendLogLine(out, 13, line("##[endgroup]", false))
84+ require.Len(t, out, 4)
85+ assert.True(t, out[0].Group)
86+ assert.Equal(t, "Run make", out[0].Text, "the marker is structure, not text")
87+ assert.EqualValues(t, 10, out[0].N)
88+ assert.Equal(t, "10:11:12", out[0].Time)
89+ assert.EqualValues(t, 2, out[0].Step)
90+ assert.False(t, out[1].Group || out[1].Error || out[1].End)
91+ assert.True(t, out[2].Error)
92+ assert.Equal(t, "Process completed with exit code 1.", out[2].Text)
93+ assert.True(t, out[3].End)
94+
95+ // A cut line comes back as one line, with the number of its first piece.
96+ var cut []LogLineView
97+ cut = appendLogLine(cut, 20, line("first half, ", false))
98+ cut = appendLogLine(cut, 21, line("second half", true))
99+ require.Len(t, cut, 1)
100+ assert.Equal(t, "first half, second half", cut[0].Text)
101+ assert.EqualValues(t, 20, cut[0].N)
102+
103+ // A piece that opens a batch has no line before it here: it stays, marked,
104+ // for the page to join to the line it already holds.
105+ open := appendLogLine(nil, 30, line("rest", true))
106+ require.Len(t, open, 1)
107+ assert.True(t, open[0].Continued)
108+
109+ // Invisible characters of the runner's text are shown, never applied.
110+ hidden := appendLogLine(nil, 40, line("a\u202eb", false))
111+ assert.NotContains(t, hidden[0].Text, "\u202e")
112+}
M internal/adapters/web/assets.go +2 -1
@@ -23,7 +23,7 @@ const StaticPrefix = "/-/static/"
2323 // Assets are the URLs of the assets a page loads. Their names carry a hash of
2424 // the content, so they can be cached for ever.
2525 type Assets struct {
26- CSS, HTMX, Live, SSE, App, Files, Icon string
26+ CSS, HTMX, Live, SSE, App, Files, Log, Icon string
2727 // Font is the UI font, preloaded by every page.
2828 Font string
2929 // Rules is the speculation rules file named by the Speculation-Rules header.
@@ -56,6 +56,7 @@ var scripts = []struct {
5656 {"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }},
5757 {"ermite.js", func(a *Assets, u string) { a.App = u }},
5858 {"files.js", func(a *Assets, u string) { a.Files = u }},
59+ {"log.js", func(a *Assets, u string) { a.Log = u }},
5960 }
6061
6162 // sheetAssets are the files the stylesheet references as url(<dir>/<file>):
@@ -23,7 +23,7 @@ const StaticPrefix = "/-/static/"
23 // Assets are the URLs of the assets a page loads. Their names carry a hash of23 // Assets are the URLs of the assets a page loads. Their names carry a hash of
24 // the content, so they can be cached for ever.24 // the content, so they can be cached for ever.
25 type Assets struct {25 type Assets struct {
26- CSS, HTMX, Live, SSE, App, Files, Icon string26+ CSS, HTMX, Live, SSE, App, Files, Log, Icon string
27 // Font is the UI font, preloaded by every page.27 // Font is the UI font, preloaded by every page.
28 Font string28 Font string
29 // Rules is the speculation rules file named by the Speculation-Rules header.29 // Rules is the speculation rules file named by the Speculation-Rules header.
@@ -56,6 +56,7 @@ var scripts = []struct {
56 {"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }},56 {"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }},
57 {"ermite.js", func(a *Assets, u string) { a.App = u }},57 {"ermite.js", func(a *Assets, u string) { a.App = u }},
58 {"files.js", func(a *Assets, u string) { a.Files = u }},58 {"files.js", func(a *Assets, u string) { a.Files = u }},
59+ {"log.js", func(a *Assets, u string) { a.Log = u }},
59 }60 }
60 61
61 // sheetAssets are the files the stylesheet references as url(<dir>/<file>):62 // sheetAssets are the files the stylesheet references as url(<dir>/<file>):
A internal/adapters/web/jstest/log.test.mjs +103 -0
@@ -0,0 +1,103 @@
1+// Tests of the live job log of static/js/log.js (window.ermiteLog) with a stub
2+// EventSource on the tiny hand-rolled DOM. Run with
3+// node --test 'internal/adapters/web/jstest/*.test.mjs'
4+import assert from 'node:assert/strict';
5+import { test } from 'node:test';
6+import { el, installDom } from './dom.mjs';
7+
8+// A line is "text" or "+text" for a piece that continues the line before it.
9+function line(text) {
10+ const cont = text.startsWith('+');
11+ const li = el('li', { cls: 'lg', attrs: cont ? { 'data-cont': '' } : {} }, el('span', { cls: 't', text: cont ? text.slice(1) : text }));
12+ return li;
13+}
14+
15+installDom({ parseHTML: (data) => data.split('|').filter(Boolean).map(line) });
16+globalThis.document.addEventListener = () => {}; // log.js starts itself on DOMContentLoaded
17+
18+class StubEventSource {
19+ static CLOSED = 2;
20+ static last = null;
21+ constructor(url) {
22+ this.url = url;
23+ this.readyState = 1;
24+ this.handlers = new Map();
25+ StubEventSource.last = this;
26+ }
27+ addEventListener(name, fn) { this.handlers.set(name, fn); }
28+ close() { this.readyState = StubEventSource.CLOSED; }
29+ emit(name, data) { this.handlers.get(name)?.({ data }); }
30+}
31+globalThis.EventSource = StubEventSource;
32+
33+let scrolls = [];
34+globalThis.scrollTo = (x, y) => scrolls.push(y);
35+Object.assign(globalThis, { innerHeight: 800, scrollY: 0 });
36+
37+await import('../static/js/log.js');
38+const { follow } = window.ermiteLog;
39+
40+// page makes a job page body: the box with its list and the lines already there.
41+function page(attrs, ...lines) {
42+ const list = el('ol', { cls: 'log' }, ...lines.map(line));
43+ const box = el('div', { id: 'job-log', attrs }, list);
44+ const body = document.body;
45+ body.replaceChildren(box);
46+ Object.defineProperty(globalThis.document.body, 'scrollHeight', { value: 1000, configurable: true });
47+ return { box, list };
48+}
49+const running = { 'data-follow': '', 'data-stream': '/r/job/stream', 'data-next': '3' };
50+const texts = (list) => list.children.map((li) => li.querySelector('.t').textContent);
51+
52+test('it does nothing for a job that is over, or a page without a feed', () => {
53+ assert.equal(follow(null), null);
54+ assert.equal(follow(page({ 'data-stream': '/s' }).box), null, 'no data-follow: the job ended');
55+ assert.equal(follow(page({ 'data-follow': '' }).box), null, 'no stream URL');
56+});
57+
58+test('it opens the feed after the last line the page holds', () => {
59+ const { box } = page(running, 'a', 'b', 'c');
60+ const es = follow(box);
61+ assert.equal(es.url, '/r/job/stream?from=3');
62+});
63+
64+test('it appends the lines of a batch', () => {
65+ const { box, list } = page(running, 'a');
66+ const es = follow(box);
67+ es.emit('ci-log', 'b|c');
68+ es.emit('ci-log', 'd');
69+ assert.deepEqual(texts(list), ['a', 'b', 'c', 'd']);
70+});
71+
72+test('a piece that continues a line of an earlier batch joins that line', () => {
73+ const { box, list } = page(running, 'start of a long');
74+ const es = follow(box);
75+ es.emit('ci-log', '+ line|next');
76+ assert.deepEqual(texts(list), ['start of a long line', 'next']);
77+});
78+
79+test('it scrolls with the log only while the reader is at its end', () => {
80+ const { box } = page(running, 'a');
81+ const es = follow(box);
82+ scrolls = [];
83+ Object.assign(globalThis, { scrollY: 190 }); // 800 + 190 >= 1000 - 80: at the end
84+ es.emit('ci-log', 'b');
85+ assert.deepEqual(scrolls, [1000]);
86+ scrolls = [];
87+ Object.assign(globalThis, { scrollY: 0 }); // reading above
88+ es.emit('ci-log', 'c');
89+ assert.deepEqual(scrolls, []);
90+});
91+
92+test('ci-end closes the feed, and a closed connection stays closed', () => {
93+ const { box } = page(running, 'a');
94+ const es = follow(box);
95+ assert.equal(es.readyState, 1);
96+ es.emit('ci-end', 'end');
97+ assert.equal(es.readyState, StubEventSource.CLOSED);
98+
99+ const again = follow(page(running, 'a').box);
100+ again.readyState = StubEventSource.CLOSED;
101+ again.onerror();
102+ assert.equal(again.readyState, StubEventSource.CLOSED);
103+});
@@ -0,0 +1,103 @@
1+// Tests of the live job log of static/js/log.js (window.ermiteLog) with a stub
2+// EventSource on the tiny hand-rolled DOM. Run with
3+// node --test 'internal/adapters/web/jstest/*.test.mjs'
4+import assert from 'node:assert/strict';
5+import { test } from 'node:test';
6+import { el, installDom } from './dom.mjs';
7+
8+// A line is "text" or "+text" for a piece that continues the line before it.
9+function line(text) {
10+ const cont = text.startsWith('+');
11+ const li = el('li', { cls: 'lg', attrs: cont ? { 'data-cont': '' } : {} }, el('span', { cls: 't', text: cont ? text.slice(1) : text }));
12+ return li;
13+}
14+
15+installDom({ parseHTML: (data) => data.split('|').filter(Boolean).map(line) });
16+globalThis.document.addEventListener = () => {}; // log.js starts itself on DOMContentLoaded
17+
18+class StubEventSource {
19+ static CLOSED = 2;
20+ static last = null;
21+ constructor(url) {
22+ this.url = url;
23+ this.readyState = 1;
24+ this.handlers = new Map();
25+ StubEventSource.last = this;
26+ }
27+ addEventListener(name, fn) { this.handlers.set(name, fn); }
28+ close() { this.readyState = StubEventSource.CLOSED; }
29+ emit(name, data) { this.handlers.get(name)?.({ data }); }
30+}
31+globalThis.EventSource = StubEventSource;
32+
33+let scrolls = [];
34+globalThis.scrollTo = (x, y) => scrolls.push(y);
35+Object.assign(globalThis, { innerHeight: 800, scrollY: 0 });
36+
37+await import('../static/js/log.js');
38+const { follow } = window.ermiteLog;
39+
40+// page makes a job page body: the box with its list and the lines already there.
41+function page(attrs, ...lines) {
42+ const list = el('ol', { cls: 'log' }, ...lines.map(line));
43+ const box = el('div', { id: 'job-log', attrs }, list);
44+ const body = document.body;
45+ body.replaceChildren(box);
46+ Object.defineProperty(globalThis.document.body, 'scrollHeight', { value: 1000, configurable: true });
47+ return { box, list };
48+}
49+const running = { 'data-follow': '', 'data-stream': '/r/job/stream', 'data-next': '3' };
50+const texts = (list) => list.children.map((li) => li.querySelector('.t').textContent);
51+
52+test('it does nothing for a job that is over, or a page without a feed', () => {
53+ assert.equal(follow(null), null);
54+ assert.equal(follow(page({ 'data-stream': '/s' }).box), null, 'no data-follow: the job ended');
55+ assert.equal(follow(page({ 'data-follow': '' }).box), null, 'no stream URL');
56+});
57+
58+test('it opens the feed after the last line the page holds', () => {
59+ const { box } = page(running, 'a', 'b', 'c');
60+ const es = follow(box);
61+ assert.equal(es.url, '/r/job/stream?from=3');
62+});
63+
64+test('it appends the lines of a batch', () => {
65+ const { box, list } = page(running, 'a');
66+ const es = follow(box);
67+ es.emit('ci-log', 'b|c');
68+ es.emit('ci-log', 'd');
69+ assert.deepEqual(texts(list), ['a', 'b', 'c', 'd']);
70+});
71+
72+test('a piece that continues a line of an earlier batch joins that line', () => {
73+ const { box, list } = page(running, 'start of a long');
74+ const es = follow(box);
75+ es.emit('ci-log', '+ line|next');
76+ assert.deepEqual(texts(list), ['start of a long line', 'next']);
77+});
78+
79+test('it scrolls with the log only while the reader is at its end', () => {
80+ const { box } = page(running, 'a');
81+ const es = follow(box);
82+ scrolls = [];
83+ Object.assign(globalThis, { scrollY: 190 }); // 800 + 190 >= 1000 - 80: at the end
84+ es.emit('ci-log', 'b');
85+ assert.deepEqual(scrolls, [1000]);
86+ scrolls = [];
87+ Object.assign(globalThis, { scrollY: 0 }); // reading above
88+ es.emit('ci-log', 'c');
89+ assert.deepEqual(scrolls, []);
90+});
91+
92+test('ci-end closes the feed, and a closed connection stays closed', () => {
93+ const { box } = page(running, 'a');
94+ const es = follow(box);
95+ assert.equal(es.readyState, 1);
96+ es.emit('ci-end', 'end');
97+ assert.equal(es.readyState, StubEventSource.CLOSED);
98+
99+ const again = follow(page(running, 'a').box);
100+ again.readyState = StubEventSource.CLOSED;
101+ again.onerror();
102+ assert.equal(again.readyState, StubEventSource.CLOSED);
103+});
M internal/adapters/web/mr.go +2 -2
@@ -459,9 +459,9 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
459459 }
460460 p.ChecksBlock = d.repo.Settings.FailingChecksBlock
461461 if open && head != "" {
462- p.Checks = checksSection(d.repo.Repo, s.checksFor(ctx, d.repo.Name, head), now)
462+ p.Checks = checksSection(d.repo.Repo, s.checksFor(ctx, d.repo.Name, head))
463463 } else if head != "" {
464- p.Checks = checksSection(d.repo.Repo, s.checksFor(ctx, d.repo.Name, head), now)
464+ p.Checks = checksSection(d.repo.Repo, s.checksFor(ctx, d.repo.Name, head))
465465 p.Checks.Note = ""
466466 }
467467 p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)
@@ -459,9 +459,9 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
459 }459 }
460 p.ChecksBlock = d.repo.Settings.FailingChecksBlock460 p.ChecksBlock = d.repo.Settings.FailingChecksBlock
461 if open && head != "" {461 if open && head != "" {
462- p.Checks = checksSection(d.repo.Repo, s.checksFor(ctx, d.repo.Name, head), now)462+ p.Checks = checksSection(d.repo.Repo, s.checksFor(ctx, d.repo.Name, head))
463 } else if head != "" {463 } else if head != "" {
464- p.Checks = checksSection(d.repo.Repo, s.checksFor(ctx, d.repo.Name, head), now)464+ p.Checks = checksSection(d.repo.Repo, s.checksFor(ctx, d.repo.Name, head))
465 p.Checks.Note = ""465 p.Checks.Note = ""
466 }466 }
467 p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)467 p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)
M internal/adapters/web/shell.templ +1 -0
@@ -22,6 +22,7 @@ templ Shell(l Layout, rail templ.Component) {
2222 <script src={ l.Assets.SSE } defer></script>
2323 <script src={ l.Assets.App } defer></script>
2424 <script src={ l.Assets.Files } defer></script>
25+ <script src={ l.Assets.Log } defer></script>
2526 </head>
2627 <body
2728 data-ref={ l.RefPath }
@@ -22,6 +22,7 @@ templ Shell(l Layout, rail templ.Component) {
22 <script src={ l.Assets.SSE } defer></script>22 <script src={ l.Assets.SSE } defer></script>
23 <script src={ l.Assets.App } defer></script>23 <script src={ l.Assets.App } defer></script>
24 <script src={ l.Assets.Files } defer></script>24 <script src={ l.Assets.Files } defer></script>
25+ <script src={ l.Assets.Log } defer></script>
25 </head>26 </head>
26 <body27 <body
27 data-ref={ l.RefPath }28 data-ref={ l.RefPath }
M internal/adapters/web/shell_templ.go +155 -142 generated file: collapsed
M internal/adapters/web/static/js/ermite.js +0 -23
@@ -363,26 +363,3 @@ window.ermiteLive = (() => {
363363 },
364364 };
365365 })();
366-
367-// ermiteLog follows the log of a running job on its page: the lines arrive as
368-// "ci-log" events (internal/adapters/web/actions.go, logStream) and are
369-// appended to the list. The browser reconnects by itself with Last-Event-ID,
370-// the number of the next line; "ci-end" closes the feed when the job is over.
371-// The page scrolls with the log only while the reader is at its end.
372-(() => {
373- const box = document.getElementById('job-log');
374- if (!box || !box.hasAttribute('data-follow') || !box.dataset.stream || !window.EventSource) return;
375- const list = box.querySelector('ol.log');
376- if (!list) return;
377- const url = box.dataset.stream + '?from=' + encodeURIComponent(box.dataset.next || '0');
378- const es = new EventSource(url);
379- es.addEventListener('ci-log', (e) => {
380- const t = document.createElement('template');
381- t.innerHTML = e.data;
382- const atEnd = window.innerHeight + window.scrollY >= document.body.scrollHeight - 80;
383- list.append(...t.content.children);
384- if (atEnd) window.scrollTo(0, document.body.scrollHeight);
385- });
386- es.addEventListener('ci-end', () => es.close());
387- es.onerror = () => { if (es.readyState === EventSource.CLOSED) es.close(); };
388-})();
@@ -363,26 +363,3 @@ window.ermiteLive = (() => {
363 },363 },
364 };364 };
365 })();365 })();
366-
367-// ermiteLog follows the log of a running job on its page: the lines arrive as
368-// "ci-log" events (internal/adapters/web/actions.go, logStream) and are
369-// appended to the list. The browser reconnects by itself with Last-Event-ID,
370-// the number of the next line; "ci-end" closes the feed when the job is over.
371-// The page scrolls with the log only while the reader is at its end.
372-(() => {
373- const box = document.getElementById('job-log');
374- if (!box || !box.hasAttribute('data-follow') || !box.dataset.stream || !window.EventSource) return;
375- const list = box.querySelector('ol.log');
376- if (!list) return;
377- const url = box.dataset.stream + '?from=' + encodeURIComponent(box.dataset.next || '0');
378- const es = new EventSource(url);
379- es.addEventListener('ci-log', (e) => {
380- const t = document.createElement('template');
381- t.innerHTML = e.data;
382- const atEnd = window.innerHeight + window.scrollY >= document.body.scrollHeight - 80;
383- list.append(...t.content.children);
384- if (atEnd) window.scrollTo(0, document.body.scrollHeight);
385- });
386- es.addEventListener('ci-end', () => es.close());
387- es.onerror = () => { if (es.readyState === EventSource.CLOSED) es.close(); };
388-})();
A internal/adapters/web/static/js/log.js +43 -0
@@ -0,0 +1,43 @@
1+// log.js: the live log of a running job (page /<repo>/actions/runs/<id>/job/<id>).
2+// The lines arrive as "ci-log" events (internal/adapters/web/actions.go,
3+// logStream), each one the <li> of the lines of a batch, and are appended to the
4+// list. The browser reconnects by itself with Last-Event-ID, the number of the
5+// next line; "ci-end" closes the feed when the job is over. The page follows the
6+// log (scrolls to its end) only while the reader is at the end already. A piece
7+// that continues a line begun in an earlier batch (data-cont) is joined to the
8+// last line of the list.
9+window.ermiteLog = (() => {
10+ const nearEnd = () => window.innerHeight + window.scrollY >= document.body.scrollHeight - 80;
11+
12+ // follow starts the feed of the box (#job-log) and returns the EventSource,
13+ // or null when the box is not a running job's.
14+ function follow(box) {
15+ if (!box || !box.hasAttribute('data-follow') || !box.dataset.stream || !window.EventSource) return null;
16+ const list = box.querySelector('ol.log');
17+ if (!list) return null;
18+ const es = new window.EventSource(box.dataset.stream + '?from=' + encodeURIComponent(box.dataset.next || '0'));
19+ es.addEventListener('ci-log', (e) => {
20+ const t = document.createElement('template');
21+ t.innerHTML = e.data;
22+ const atEnd = nearEnd();
23+ for (const line of [...t.content.children]) {
24+ const last = list.children[list.children.length - 1];
25+ if (line.hasAttribute('data-cont') && last) {
26+ const text = line.querySelector('.t');
27+ const into = last.querySelector('.t');
28+ if (text && into) into.textContent += text.textContent;
29+ } else {
30+ list.append(line);
31+ }
32+ }
33+ if (atEnd) window.scrollTo(0, document.body.scrollHeight);
34+ });
35+ es.addEventListener('ci-end', () => es.close());
36+ es.onerror = () => { if (es.readyState === window.EventSource.CLOSED) es.close(); };
37+ return es;
38+ }
39+
40+ return { follow };
41+})();
42+
43+document.addEventListener('DOMContentLoaded', () => window.ermiteLog.follow(document.getElementById('job-log')));
@@ -0,0 +1,43 @@
1+// log.js: the live log of a running job (page /<repo>/actions/runs/<id>/job/<id>).
2+// The lines arrive as "ci-log" events (internal/adapters/web/actions.go,
3+// logStream), each one the <li> of the lines of a batch, and are appended to the
4+// list. The browser reconnects by itself with Last-Event-ID, the number of the
5+// next line; "ci-end" closes the feed when the job is over. The page follows the
6+// log (scrolls to its end) only while the reader is at the end already. A piece
7+// that continues a line begun in an earlier batch (data-cont) is joined to the
8+// last line of the list.
9+window.ermiteLog = (() => {
10+ const nearEnd = () => window.innerHeight + window.scrollY >= document.body.scrollHeight - 80;
11+
12+ // follow starts the feed of the box (#job-log) and returns the EventSource,
13+ // or null when the box is not a running job's.
14+ function follow(box) {
15+ if (!box || !box.hasAttribute('data-follow') || !box.dataset.stream || !window.EventSource) return null;
16+ const list = box.querySelector('ol.log');
17+ if (!list) return null;
18+ const es = new window.EventSource(box.dataset.stream + '?from=' + encodeURIComponent(box.dataset.next || '0'));
19+ es.addEventListener('ci-log', (e) => {
20+ const t = document.createElement('template');
21+ t.innerHTML = e.data;
22+ const atEnd = nearEnd();
23+ for (const line of [...t.content.children]) {
24+ const last = list.children[list.children.length - 1];
25+ if (line.hasAttribute('data-cont') && last) {
26+ const text = line.querySelector('.t');
27+ const into = last.querySelector('.t');
28+ if (text && into) into.textContent += text.textContent;
29+ } else {
30+ list.append(line);
31+ }
32+ }
33+ if (atEnd) window.scrollTo(0, document.body.scrollHeight);
34+ });
35+ es.addEventListener('ci-end', () => es.close());
36+ es.onerror = () => { if (es.readyState === window.EventSource.CLOSED) es.close(); };
37+ return es;
38+ }
39+
40+ return { follow };
41+})();
42+
43+document.addEventListener('DOMContentLoaded', () => window.ermiteLog.follow(document.getElementById('job-log')));
M internal/app/testdata/web/blob-binary.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/blob-code.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/blob-empty.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/blob-image.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/blob-markdown.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/blob-svg.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/blob-text.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/blob-too-large.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/blob-trojan.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-big.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">25 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-binary.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">25 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-merge.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">25 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-modify-split.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">25 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-modify.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">25 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-rename.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">25 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-root.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">25 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-trojan.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">25 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commits-page2.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commits-path.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commits-root.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commits-small.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/empty.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="" data-sel="" data-nav="">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="" data-sha="" data-sel="" data-nav="">25 <body data-ref="" data-sha="" data-sel="" data-nav="">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/home.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="" data-sel="" data-nav="">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="" data-sha="" data-sel="" data-nav="">25 <body data-ref="" data-sha="" data-sel="" data-nav="">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/not-found-page.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="" data-sel="" data-nav="">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="" data-sha="" data-sel="" data-nav="">25 <body data-ref="" data-sha="" data-sel="" data-nav="">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/not-found.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="" data-sel="" data-nav="">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="" data-sha="" data-sel="" data-nav="">25 <body data-ref="" data-sha="" data-sel="" data-nav="">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/refs.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/summary.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/tree-nested.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/tree-root.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/tree-slash-ref.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="feature/x" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/feature/x">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="feature/x" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/feature/x">25 <body data-ref="feature/x" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/feature/x">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/tree-subdir.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>

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 45

↑↓ moveenter openesc close