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 generated file: collapsed
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