detached 801147f

feat(web): list running CI runs in the inbox and open it with Shift+I (!28)

- feat(web): list running CI runs in the inbox and open it with Shift+I
- feat(web): announce the Shift+I shortcut on the inbox button
commit
801147f0f11f433513f19e73261445c99d7678a6
author
brice · · 2026-10-22 00:47 UTC
committer
ermite · · 2026-10-22 00:57 UTC
parent
993fa25
signature
SSH VerifiedSHA256:lY9fILbB/A+hln6Vh/iHoiiog74bbw/4qWrf/lrTU3U · ermite instance key Verified for the committer email
checks
4/4
50 files · +897 -368
M docs/issues.md +3 -1
@@ -50,7 +50,9 @@ Limits: the 100 most recent commits of the merge request, 16 KiB of each text, 1
5050
5151 The signed-in owner has an Inbox at `/-/inbox`, reached from the **Inbox** button at the top left of the home page, which shows the number of open items. It lists the open merge requests and the open issues of every repository, most recently updated first. The counters at the top (`N merge requests · M issues`) always count everything; `?repo=<name>` restricts the two lists to one repository, and the repository links under the counters set it. Repositories with issues off contribute no issues.
5252
53-The page updates live: merge request and issue events of any repository refresh the counters and the lists. Anyone who is not the signed-in owner, including a token, gets the same 404 as for an unknown page, and the same answer for the `owner` event stream it follows.
53+The **Running** section, after **Awaiting moderation**, lists queued and in-progress CI runs newest first, with links to each run, its repository, ref, and start time. The `N running` counter counts every repository; the repository filter also applies to this section. **Shift+I** (`I`) opens the inbox from any page for the signed-in owner, unless single-key shortcuts are off; lowercase `i` still opens the issues tab.
54+
55+The page updates live: run status changes, merge request and issue events of any repository refresh the counters and the lists. Anyone who is not the signed-in owner, including a token, gets the same 404 as for an unknown page, and the same answer for the `owner` event stream it follows.
5456
5557 ## Public form and moderation
5658
@@ -50,7 +50,9 @@ Limits: the 100 most recent commits of the merge request, 16 KiB of each text, 1
50 50
51 The signed-in owner has an Inbox at `/-/inbox`, reached from the **Inbox** button at the top left of the home page, which shows the number of open items. It lists the open merge requests and the open issues of every repository, most recently updated first. The counters at the top (`N merge requests · M issues`) always count everything; `?repo=<name>` restricts the two lists to one repository, and the repository links under the counters set it. Repositories with issues off contribute no issues.51 The signed-in owner has an Inbox at `/-/inbox`, reached from the **Inbox** button at the top left of the home page, which shows the number of open items. It lists the open merge requests and the open issues of every repository, most recently updated first. The counters at the top (`N merge requests · M issues`) always count everything; `?repo=<name>` restricts the two lists to one repository, and the repository links under the counters set it. Repositories with issues off contribute no issues.
52 52
53-The page updates live: merge request and issue events of any repository refresh the counters and the lists. Anyone who is not the signed-in owner, including a token, gets the same 404 as for an unknown page, and the same answer for the `owner` event stream it follows.53+The **Running** section, after **Awaiting moderation**, lists queued and in-progress CI runs newest first, with links to each run, its repository, ref, and start time. The `N running` counter counts every repository; the repository filter also applies to this section. **Shift+I** (`I`) opens the inbox from any page for the signed-in owner, unless single-key shortcuts are off; lowercase `i` still opens the issues tab.
54+
55+The page updates live: run status changes, merge request and issue events of any repository refresh the counters and the lists. Anyone who is not the signed-in owner, including a token, gets the same 404 as for an unknown page, and the same answer for the `owner` event stream it follows.
54 56
55 ## Public form and moderation57 ## Public form and moderation
56 58
M internal/adapters/web/inbox.go +25 -3
@@ -67,8 +67,17 @@ type InboxRepoLink struct {
6767 Current bool
6868 }
6969
70-// InboxPage is the inbox: the counters, the filter and the two lists.
70+// InboxRunRow adds repository and start time to the Actions run display.
71+type InboxRunRow struct {
72+ RunRow
73+ Repo, RepoHref, When, WhenAt string
74+}
75+
76+// InboxPage is the inbox: counters, repository filter and item lists.
7177 type InboxPage struct {
78+ Running int
79+ RunsEnabled bool
80+ Runs []InboxRunRow
7281 // MergeRequests and Issues count every open item, whatever the filter.
7382 MergeRequests, Issues int
7483 // PendingCount counts every issue awaiting moderation.
@@ -106,6 +115,19 @@ func inboxFilter(r *http.Request) (domain.RepoName, error) {
106115 func (s *Server) inboxPage(ov ports.InboxOverview, filter domain.RepoName) InboxPage {
107116 now := s.cfg.Now()
108117 p := InboxPage{MergeRequests: ov.Counts.MergeRequests, Issues: ov.Counts.Issues, PendingCount: ov.Counts.Pending, Filter: filter.String()}
118+ p.Running, p.RunsEnabled = ov.Counts.Running, ov.RunsEnabled
119+ for _, run := range ov.Runs {
120+ r := InboxRunRow{RunRow: s.runRow(run.Repo.String(), run, now), Repo: run.Repo.String(), RepoHref: repoHref(run.Repo.String())}
121+ if run.Status == domain.CIInProgress {
122+ r.State = "in progress"
123+ }
124+ ts := run.StartedAt
125+ if ts.IsZero() {
126+ ts = run.CreatedAt
127+ }
128+ r.When, r.WhenAt = whenOf(now, ts)
129+ p.Runs = append(p.Runs, r)
130+ }
109131 for _, pi := range ov.Pending {
110132 p.Pending = append(p.Pending, s.pendingRow(now, pi, filter))
111133 }
@@ -266,7 +288,7 @@ func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment
266288 switch we.Event.(type) {
267289 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
268290 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved,
269- domain.RepoUpdated, domain.RepoDeleted:
291+ domain.RepoUpdated, domain.RepoDeleted, domain.RunUpdated:
270292 default:
271293 return nil, nil
272294 }
@@ -283,7 +305,7 @@ func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment
283305 }
284306 p := l.s.inboxPage(ov, filter)
285307 var out []fragment
286- for _, c := range []templ.Component{InboxCounters(p), InboxRepos(p), InboxPending(p), InboxMRs(p), InboxIssues(p)} {
308+ for _, c := range []templ.Component{InboxCounters(p), InboxRepos(p), InboxPending(p), InboxRuns(p), InboxMRs(p), InboxIssues(p)} {
287309 f, err := renderFragment(ctx, inboxEvent, c)
288310 if err != nil {
289311 return nil, err
@@ -67,8 +67,17 @@ type InboxRepoLink struct {
67 Current bool67 Current bool
68 }68 }
69 69
70-// InboxPage is the inbox: the counters, the filter and the two lists.70+// InboxRunRow adds repository and start time to the Actions run display.
71+type InboxRunRow struct {
72+ RunRow
73+ Repo, RepoHref, When, WhenAt string
74+}
75+
76+// InboxPage is the inbox: counters, repository filter and item lists.
71 type InboxPage struct {77 type InboxPage struct {
78+ Running int
79+ RunsEnabled bool
80+ Runs []InboxRunRow
72 // MergeRequests and Issues count every open item, whatever the filter.81 // MergeRequests and Issues count every open item, whatever the filter.
73 MergeRequests, Issues int82 MergeRequests, Issues int
74 // PendingCount counts every issue awaiting moderation.83 // PendingCount counts every issue awaiting moderation.
@@ -106,6 +115,19 @@ func inboxFilter(r *http.Request) (domain.RepoName, error) {
106 func (s *Server) inboxPage(ov ports.InboxOverview, filter domain.RepoName) InboxPage {115 func (s *Server) inboxPage(ov ports.InboxOverview, filter domain.RepoName) InboxPage {
107 now := s.cfg.Now()116 now := s.cfg.Now()
108 p := InboxPage{MergeRequests: ov.Counts.MergeRequests, Issues: ov.Counts.Issues, PendingCount: ov.Counts.Pending, Filter: filter.String()}117 p := InboxPage{MergeRequests: ov.Counts.MergeRequests, Issues: ov.Counts.Issues, PendingCount: ov.Counts.Pending, Filter: filter.String()}
118+ p.Running, p.RunsEnabled = ov.Counts.Running, ov.RunsEnabled
119+ for _, run := range ov.Runs {
120+ r := InboxRunRow{RunRow: s.runRow(run.Repo.String(), run, now), Repo: run.Repo.String(), RepoHref: repoHref(run.Repo.String())}
121+ if run.Status == domain.CIInProgress {
122+ r.State = "in progress"
123+ }
124+ ts := run.StartedAt
125+ if ts.IsZero() {
126+ ts = run.CreatedAt
127+ }
128+ r.When, r.WhenAt = whenOf(now, ts)
129+ p.Runs = append(p.Runs, r)
130+ }
109 for _, pi := range ov.Pending {131 for _, pi := range ov.Pending {
110 p.Pending = append(p.Pending, s.pendingRow(now, pi, filter))132 p.Pending = append(p.Pending, s.pendingRow(now, pi, filter))
111 }133 }
@@ -266,7 +288,7 @@ func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment
266 switch we.Event.(type) {288 switch we.Event.(type) {
267 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,289 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
268 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved,290 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved,
269- domain.RepoUpdated, domain.RepoDeleted:291+ domain.RepoUpdated, domain.RepoDeleted, domain.RunUpdated:
270 default:292 default:
271 return nil, nil293 return nil, nil
272 }294 }
@@ -283,7 +305,7 @@ func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment
283 }305 }
284 p := l.s.inboxPage(ov, filter)306 p := l.s.inboxPage(ov, filter)
285 var out []fragment307 var out []fragment
286- for _, c := range []templ.Component{InboxCounters(p), InboxRepos(p), InboxPending(p), InboxMRs(p), InboxIssues(p)} {308+ for _, c := range []templ.Component{InboxCounters(p), InboxRepos(p), InboxPending(p), InboxRuns(p), InboxMRs(p), InboxIssues(p)} {
287 f, err := renderFragment(ctx, inboxEvent, c)309 f, err := renderFragment(ctx, inboxEvent, c)
288 if err != nil {310 if err != nil {
289 return nil, err311 return nil, err
M internal/adapters/web/inbox.templ +34 -1
@@ -12,6 +12,7 @@ templ Inbox(l Layout, p InboxPage) {
1212 @InboxCounters(p)
1313 @InboxRepos(p)
1414 @InboxPending(p)
15+ @InboxRuns(p)
1516 @InboxMRs(p)
1617 @InboxIssues(p)
1718 </div>
@@ -20,7 +21,7 @@ templ Inbox(l Layout, p InboxPage) {
2021
2122 templ InboxCounters(p InboxPage) {
2223 <p id="inbox-counts" class="inbox-counts">
23- <strong>{ itoa(p.MergeRequests) }</strong> merge requests · <strong>{ itoa(p.Issues) }</strong> issues · <strong>{ itoa(p.PendingCount) }</strong> awaiting moderation
24+ <strong>{ itoa(p.MergeRequests) }</strong> merge requests · <strong>{ itoa(p.Issues) }</strong> issues · <strong>{ itoa(p.PendingCount) }</strong> awaiting moderation · <strong>{ itoa(p.Running) }</strong> running
2425 </p>
2526 }
2627
@@ -113,3 +114,35 @@ templ inboxRows(rows []InboxRow, label string) {
113114 </table>
114115 }
115116 }
117+
118+// InboxRuns stays as an empty fragment when CI is disabled.
119+templ InboxRuns(p InboxPage) {
120+ <section id="inbox-runs" hidden?={ !p.RunsEnabled }
121+ if p.RunsEnabled {
122+ aria-labelledby="inbox-runs-h"
123+ }
124+ >
125+ if p.RunsEnabled {
126+ <h2 id="inbox-runs-h" class="rule-h"><span>Running</span><span class="count">{ itoa(len(p.Runs)) }</span></h2>
127+ if len(p.Runs) == 0 {
128+ <p class="lede dim">no runs in progress.</p>
129+ } else {
130+ <table class="list mrs" aria-label="Running">
131+ <tbody data-rows>
132+ for _, r := range p.Runs {
133+ <tr>
134+ <td class="c-st">@CheckIcon(r.Kind, r.State)<span class="dim">{ r.State }</span></td>
135+ <td class="c-subject"><a href={ templ.SafeURL(r.Href) }>{ r.Workflow }{ " " }{ r.Number }</a> <a class="mute" href={ templ.SafeURL(r.RepoHref) }>{ r.Repo }</a> <span class="dim">{ r.Ref }{ " " }{ r.Event }</span></td>
136+ <td class="c-when nowrap dim">
137+ if r.WhenAt != "" {
138+ <time datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time>
139+ }
140+ </td>
141+ </tr>
142+ }
143+ </tbody>
144+ </table>
145+ }
146+ }
147+ </section>
148+}
@@ -12,6 +12,7 @@ templ Inbox(l Layout, p InboxPage) {
12 @InboxCounters(p)12 @InboxCounters(p)
13 @InboxRepos(p)13 @InboxRepos(p)
14 @InboxPending(p)14 @InboxPending(p)
15+ @InboxRuns(p)
15 @InboxMRs(p)16 @InboxMRs(p)
16 @InboxIssues(p)17 @InboxIssues(p)
17 </div>18 </div>
@@ -20,7 +21,7 @@ templ Inbox(l Layout, p InboxPage) {
20 21
21 templ InboxCounters(p InboxPage) {22 templ InboxCounters(p InboxPage) {
22 <p id="inbox-counts" class="inbox-counts">23 <p id="inbox-counts" class="inbox-counts">
23- <strong>{ itoa(p.MergeRequests) }</strong> merge requests · <strong>{ itoa(p.Issues) }</strong> issues · <strong>{ itoa(p.PendingCount) }</strong> awaiting moderation24+ <strong>{ itoa(p.MergeRequests) }</strong> merge requests · <strong>{ itoa(p.Issues) }</strong> issues · <strong>{ itoa(p.PendingCount) }</strong> awaiting moderation · <strong>{ itoa(p.Running) }</strong> running
24 </p>25 </p>
25 }26 }
26 27
@@ -113,3 +114,35 @@ templ inboxRows(rows []InboxRow, label string) {
113 </table>114 </table>
114 }115 }
115 }116 }
117+
118+// InboxRuns stays as an empty fragment when CI is disabled.
119+templ InboxRuns(p InboxPage) {
120+ <section id="inbox-runs" hidden?={ !p.RunsEnabled }
121+ if p.RunsEnabled {
122+ aria-labelledby="inbox-runs-h"
123+ }
124+ >
125+ if p.RunsEnabled {
126+ <h2 id="inbox-runs-h" class="rule-h"><span>Running</span><span class="count">{ itoa(len(p.Runs)) }</span></h2>
127+ if len(p.Runs) == 0 {
128+ <p class="lede dim">no runs in progress.</p>
129+ } else {
130+ <table class="list mrs" aria-label="Running">
131+ <tbody data-rows>
132+ for _, r := range p.Runs {
133+ <tr>
134+ <td class="c-st">@CheckIcon(r.Kind, r.State)<span class="dim">{ r.State }</span></td>
135+ <td class="c-subject"><a href={ templ.SafeURL(r.Href) }>{ r.Workflow }{ " " }{ r.Number }</a> <a class="mute" href={ templ.SafeURL(r.RepoHref) }>{ r.Repo }</a> <span class="dim">{ r.Ref }{ " " }{ r.Event }</span></td>
136+ <td class="c-when nowrap dim">
137+ if r.WhenAt != "" {
138+ <time datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time>
139+ }
140+ </td>
141+ </tr>
142+ }
143+ </tbody>
144+ </table>
145+ }
146+ }
147+ </section>
148+}
M internal/adapters/web/inbox_templ.go +451 -173 generated file: collapsed
M internal/adapters/web/inbox_test.go +6 -4
@@ -180,12 +180,13 @@ func TestInboxLiveFragments(t *testing.T) {
180180 ls := &liveStream{s: srv, view: "inbox"}
181181 frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: domain.IssueOpened{Issue: domain.Issue{Repo: repoName(t, "beta"), Number: 7}}})
182182 require.NoError(t, err)
183- require.Len(t, frags, 5)
183+ require.Len(t, frags, 6)
184184 assert.Contains(t, frags[0].html, `id="inbox-counts"`)
185185 assert.Contains(t, frags[1].html, `id="inbox-repos"`)
186186 assert.Contains(t, frags[2].html, `id="inbox-pending"`)
187- assert.Contains(t, frags[3].html, `id="inbox-mrs"`)
188- assert.Contains(t, frags[4].html, `id="inbox-issues"`)
187+ assert.Contains(t, frags[3].html, `id="inbox-runs"`)
188+ assert.Contains(t, frags[4].html, `id="inbox-mrs"`)
189+ assert.Contains(t, frags[5].html, `id="inbox-issues"`)
189190
190191 none, err := ls.render(withOwner(t.Context()), ports.WatchEvent{Event: domain.PushEvent{Repo: repoName(t, "beta")}})
191192 require.NoError(t, err)
@@ -286,6 +287,7 @@ func TestInboxRefreshesOnRepoEvents(t *testing.T) {
286287 t.Parallel()
287288 for name, ev := range map[string]domain.Event{
288289 "issues turned off": domain.RepoUpdated{Repo: repoName(t, "beta")},
290+ "run updated": domain.RunUpdated{},
289291 "repo deleted": domain.RepoDeleted{Repo: repoName(t, "beta")},
290292 } {
291293 t.Run(name, func(t *testing.T) {
@@ -297,7 +299,7 @@ func TestInboxRefreshesOnRepoEvents(t *testing.T) {
297299 ls := &liveStream{s: srv, view: "inbox"}
298300 frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: ev})
299301 require.NoError(t, err)
300- require.Len(t, frags, 5)
302+ require.Len(t, frags, 6)
301303 all := ""
302304 for _, f := range frags {
303305 all += f.html
@@ -180,12 +180,13 @@ func TestInboxLiveFragments(t *testing.T) {
180 ls := &liveStream{s: srv, view: "inbox"}180 ls := &liveStream{s: srv, view: "inbox"}
181 frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: domain.IssueOpened{Issue: domain.Issue{Repo: repoName(t, "beta"), Number: 7}}})181 frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: domain.IssueOpened{Issue: domain.Issue{Repo: repoName(t, "beta"), Number: 7}}})
182 require.NoError(t, err)182 require.NoError(t, err)
183- require.Len(t, frags, 5)183+ require.Len(t, frags, 6)
184 assert.Contains(t, frags[0].html, `id="inbox-counts"`)184 assert.Contains(t, frags[0].html, `id="inbox-counts"`)
185 assert.Contains(t, frags[1].html, `id="inbox-repos"`)185 assert.Contains(t, frags[1].html, `id="inbox-repos"`)
186 assert.Contains(t, frags[2].html, `id="inbox-pending"`)186 assert.Contains(t, frags[2].html, `id="inbox-pending"`)
187- assert.Contains(t, frags[3].html, `id="inbox-mrs"`)187+ assert.Contains(t, frags[3].html, `id="inbox-runs"`)
188- assert.Contains(t, frags[4].html, `id="inbox-issues"`)188+ assert.Contains(t, frags[4].html, `id="inbox-mrs"`)
189+ assert.Contains(t, frags[5].html, `id="inbox-issues"`)
189 190
190 none, err := ls.render(withOwner(t.Context()), ports.WatchEvent{Event: domain.PushEvent{Repo: repoName(t, "beta")}})191 none, err := ls.render(withOwner(t.Context()), ports.WatchEvent{Event: domain.PushEvent{Repo: repoName(t, "beta")}})
191 require.NoError(t, err)192 require.NoError(t, err)
@@ -286,6 +287,7 @@ func TestInboxRefreshesOnRepoEvents(t *testing.T) {
286 t.Parallel()287 t.Parallel()
287 for name, ev := range map[string]domain.Event{288 for name, ev := range map[string]domain.Event{
288 "issues turned off": domain.RepoUpdated{Repo: repoName(t, "beta")},289 "issues turned off": domain.RepoUpdated{Repo: repoName(t, "beta")},
290+ "run updated": domain.RunUpdated{},
289 "repo deleted": domain.RepoDeleted{Repo: repoName(t, "beta")},291 "repo deleted": domain.RepoDeleted{Repo: repoName(t, "beta")},
290 } {292 } {
291 t.Run(name, func(t *testing.T) {293 t.Run(name, func(t *testing.T) {
@@ -297,7 +299,7 @@ func TestInboxRefreshesOnRepoEvents(t *testing.T) {
297 ls := &liveStream{s: srv, view: "inbox"}299 ls := &liveStream{s: srv, view: "inbox"}
298 frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: ev})300 frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: ev})
299 require.NoError(t, err)301 require.NoError(t, err)
300- require.Len(t, frags, 5)302+ require.Len(t, frags, 6)
301 all := ""303 all := ""
302 for _, f := range frags {304 for _, f := range frags {
303 all += f.html305 all += f.html
A internal/adapters/web/jstest/keys.test.mjs +29 -0
@@ -0,0 +1,29 @@
1+// Exercise the production keydown handler without loading the rest of the page.
2+import assert from 'node:assert/strict';
3+import { readFileSync } from 'node:fs';
4+import { test } from 'node:test';
5+const src = readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8');
6+const block = src.slice(src.indexOf(' // 1. Keys.'), src.indexOf(' // 2. A [data-fire]'));
7+
8+function page({ owner = true, off = false, typing = false, dialog = false } = {}) {
9+ let handler;
10+ const fired = [];
11+ const window = { ermiteKeys: { blocks: () => off } };
12+ const $ = (s) => s === 'dialog[open]' ? dialog : s === '[data-inbox]' ? owner : null;
13+ new Function('window', '$', 'ermite', 'addEventListener', block)(window, $, { fire: (n) => fired.push(n) }, (_, f) => { handler = f; });
14+ return (key) => {
15+ const e = { key, shiftKey: key === 'I', preventDefault() {}, target: { closest: (s) => s.startsWith('input') && typing } };
16+ handler(e);
17+ return fired;
18+ };
19+}
20+test('Shift+I opens inbox while lowercase i retains the issues tab', () => {
21+ const press = page();
22+ assert.deepEqual(press('I'), ['inbox']);
23+ assert.deepEqual(press('i'), ['inbox', 'i']);
24+});
25+test('inbox shortcut respects preference, fields, dialogs, and owner target', () => {
26+ for (const options of [{ off: true }, { typing: true }, { dialog: true }, { owner: false }]) {
27+ assert.deepEqual(page(options)('I'), [], JSON.stringify(options));
28+ }
29+});
@@ -0,0 +1,29 @@
1+// Exercise the production keydown handler without loading the rest of the page.
2+import assert from 'node:assert/strict';
3+import { readFileSync } from 'node:fs';
4+import { test } from 'node:test';
5+const src = readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8');
6+const block = src.slice(src.indexOf(' // 1. Keys.'), src.indexOf(' // 2. A [data-fire]'));
7+
8+function page({ owner = true, off = false, typing = false, dialog = false } = {}) {
9+ let handler;
10+ const fired = [];
11+ const window = { ermiteKeys: { blocks: () => off } };
12+ const $ = (s) => s === 'dialog[open]' ? dialog : s === '[data-inbox]' ? owner : null;
13+ new Function('window', '$', 'ermite', 'addEventListener', block)(window, $, { fire: (n) => fired.push(n) }, (_, f) => { handler = f; });
14+ return (key) => {
15+ const e = { key, shiftKey: key === 'I', preventDefault() {}, target: { closest: (s) => s.startsWith('input') && typing } };
16+ handler(e);
17+ return fired;
18+ };
19+}
20+test('Shift+I opens inbox while lowercase i retains the issues tab', () => {
21+ const press = page();
22+ assert.deepEqual(press('I'), ['inbox']);
23+ assert.deepEqual(press('i'), ['inbox', 'i']);
24+});
25+test('inbox shortcut respects preference, fields, dialogs, and owner target', () => {
26+ for (const options of [{ off: true }, { typing: true }, { dialog: true }, { owner: false }]) {
27+ assert.deepEqual(page(options)('I'), [], JSON.stringify(options));
28+ }
29+});
M internal/adapters/web/keys.templ +1 -0
@@ -28,6 +28,7 @@ templ Keys() {
2828 k-r from:body -> ermite.tab('r');
2929 k-m from:body -> ermite.tab('m');
3030 k-i from:body -> ermite.tab('i');
31+ k-inbox from:body -> ermite.go('[data-inbox]');
3132 k-next from:body -> ermite.nextNote();
3233 k-split from:body -> ermite.layout('split');
3334 k-unified from:body -> ermite.layout('unified');
@@ -28,6 +28,7 @@ templ Keys() {
28 k-r from:body -> ermite.tab('r');28 k-r from:body -> ermite.tab('r');
29 k-m from:body -> ermite.tab('m');29 k-m from:body -> ermite.tab('m');
30 k-i from:body -> ermite.tab('i');30 k-i from:body -> ermite.tab('i');
31+ k-inbox from:body -> ermite.go('[data-inbox]');
31 k-next from:body -> ermite.nextNote();32 k-next from:body -> ermite.nextNote();
32 k-split from:body -> ermite.layout('split');33 k-split from:body -> ermite.layout('split');
33 k-unified from:body -> ermite.layout('unified');34 k-unified from:body -> ermite.layout('unified');
M internal/adapters/web/keys_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/overlays.templ +3 -0
@@ -27,6 +27,9 @@ templ OverlayHelp(mr bool) {
2727 <div><dt><kbd>/</kbd></dt><dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd></div>
2828 <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div>
2929 <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd> <kbd>i</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd></div>
30+ if SignedIn(ctx) {
31+ <div><dt><kbd>I</kbd></dt><dd>inbox</dd></div>
32+ }
3033 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>
3134 <div><dt><kbd>w</kbd></dt><dd>wide mode: use the whole width of the screen, or not (remembered)</dd></div>
3235 <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div>
@@ -27,6 +27,9 @@ templ OverlayHelp(mr bool) {
27 <div><dt><kbd>/</kbd></dt><dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd></div>27 <div><dt><kbd>/</kbd></dt><dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd></div>
28 <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div>28 <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div>
29 <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd> <kbd>i</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd></div>29 <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd> <kbd>i</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd></div>
30+ if SignedIn(ctx) {
31+ <div><dt><kbd>I</kbd></dt><dd>inbox</dd></div>
32+ }
30 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>33 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>
31 <div><dt><kbd>w</kbd></dt><dd>wide mode: use the whole width of the screen, or not (remembered)</dd></div>34 <div><dt><kbd>w</kbd></dt><dd>wide mode: use the whole width of the screen, or not (remembered)</dd></div>
32 <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div>35 <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div>
M internal/adapters/web/overlays_templ.go +49 -39 generated file: collapsed
M internal/adapters/web/shell.templ +5 -1
@@ -67,6 +67,9 @@ templ Shell(l Layout, rail templ.Component) {
6767 </div>
6868 @Footer(l)
6969 @Hint(l)
70+ if SignedIn(ctx) {
71+ <a hidden data-inbox href={ inboxPath }>Inbox</a>
72+ }
7073 @OverlayHelp(l.MR)
7174 @OverlayRefs(l)
7275 @OverlayCmd(l.Cmd)
@@ -87,7 +90,8 @@ templ Header(l Layout) {
8790 <a
8891 class="btn inbox-btn"
8992 href={ templ.SafeURL(l.Inbox.Href) }
90- title="inbox"
93+ title="inbox (I)"
94+ aria-keyshortcuts="Shift+I"
9195 >
9296 <span class="ico i-mailbox" aria-hidden="true"></span>
9397 Inbox
@@ -67,6 +67,9 @@ templ Shell(l Layout, rail templ.Component) {
67 </div>67 </div>
68 @Footer(l)68 @Footer(l)
69 @Hint(l)69 @Hint(l)
70+ if SignedIn(ctx) {
71+ <a hidden data-inbox href={ inboxPath }>Inbox</a>
72+ }
70 @OverlayHelp(l.MR)73 @OverlayHelp(l.MR)
71 @OverlayRefs(l)74 @OverlayRefs(l)
72 @OverlayCmd(l.Cmd)75 @OverlayCmd(l.Cmd)
@@ -87,7 +90,8 @@ templ Header(l Layout) {
87 <a90 <a
88 class="btn inbox-btn"91 class="btn inbox-btn"
89 href={ templ.SafeURL(l.Inbox.Href) }92 href={ templ.SafeURL(l.Inbox.Href) }
90- title="inbox"93+ title="inbox (I)"
94+ aria-keyshortcuts="Shift+I"
91 >95 >
92 <span class="ico i-mailbox" aria-hidden="true"></span>96 <span class="ico i-mailbox" aria-hidden="true"></span>
93 Inbox97 Inbox
M internal/adapters/web/shell_templ.go +152 -133
@@ -426,6 +426,25 @@ func Shell(l Layout, rail templ.Component) templ.Component {
426426 if templ_7745c5c3_Err != nil {
427427 return templ_7745c5c3_Err
428428 }
429+ if SignedIn(ctx) {
430+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<a hidden data-inbox href=\"")
431+ if templ_7745c5c3_Err != nil {
432+ return templ_7745c5c3_Err
433+ }
434+ var templ_7745c5c3_Var26 templ.SafeURL
435+ templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinURLErrs(inboxPath)
436+ if templ_7745c5c3_Err != nil {
437+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 71, Col: 41}
438+ }
439+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
440+ if templ_7745c5c3_Err != nil {
441+ return templ_7745c5c3_Err
442+ }
443+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\">Inbox</a>")
444+ if templ_7745c5c3_Err != nil {
445+ return templ_7745c5c3_Err
446+ }
447+ }
429448 templ_7745c5c3_Err = OverlayHelp(l.MR).Render(ctx, templ_7745c5c3_Buffer)
430449 if templ_7745c5c3_Err != nil {
431450 return templ_7745c5c3_Err
@@ -442,7 +461,7 @@ func Shell(l Layout, rail templ.Component) templ.Component {
442461 if templ_7745c5c3_Err != nil {
443462 return templ_7745c5c3_Err
444463 }
445- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</body></html>")
464+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</body></html>")
446465 if templ_7745c5c3_Err != nil {
447466 return templ_7745c5c3_Err
448467 }
@@ -468,366 +487,366 @@ func Header(l Layout) templ.Component {
468487 }()
469488 }
470489 ctx = templ.InitializeContext(ctx)
471- templ_7745c5c3_Var26 := templ.GetChildren(ctx)
472- if templ_7745c5c3_Var26 == nil {
473- templ_7745c5c3_Var26 = templ.NopComponent
490+ templ_7745c5c3_Var27 := templ.GetChildren(ctx)
491+ if templ_7745c5c3_Var27 == nil {
492+ templ_7745c5c3_Var27 = templ.NopComponent
474493 }
475494 ctx = templ.ClearChildren(ctx)
476- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<header class=\"hdr\"><div class=\"hdr-top\"><a class=\"brand\" href=\"/\" data-g=\"h\" aria-label=\"home\" title=\"home (g h)\"><span class=\"brand-mark\" aria-hidden=\"true\"></span></a> ")
495+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<header class=\"hdr\"><div class=\"hdr-top\"><a class=\"brand\" href=\"/\" data-g=\"h\" aria-label=\"home\" title=\"home (g h)\"><span class=\"brand-mark\" aria-hidden=\"true\"></span></a> ")
477496 if templ_7745c5c3_Err != nil {
478497 return templ_7745c5c3_Err
479498 }
480499 if l.Inbox != nil {
481- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<a class=\"btn inbox-btn\" href=\"")
500+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<a class=\"btn inbox-btn\" href=\"")
482501 if templ_7745c5c3_Err != nil {
483502 return templ_7745c5c3_Err
484503 }
485- var templ_7745c5c3_Var27 templ.SafeURL
486- templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.Inbox.Href))
504+ var templ_7745c5c3_Var28 templ.SafeURL
505+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.Inbox.Href))
487506 if templ_7745c5c3_Err != nil {
488- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 89, Col: 39}
507+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 92, Col: 39}
489508 }
490- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
509+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
491510 if templ_7745c5c3_Err != nil {
492511 return templ_7745c5c3_Err
493512 }
494- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\" title=\"inbox\"><span class=\"ico i-mailbox\" aria-hidden=\"true\"></span> Inbox ")
513+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\" title=\"inbox (I)\" aria-keyshortcuts=\"Shift+I\"><span class=\"ico i-mailbox\" aria-hidden=\"true\"></span> Inbox ")
495514 if templ_7745c5c3_Err != nil {
496515 return templ_7745c5c3_Err
497516 }
498517 if l.Inbox.Count >= 0 {
499- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<span class=\"count\">")
518+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<span class=\"count\">")
500519 if templ_7745c5c3_Err != nil {
501520 return templ_7745c5c3_Err
502521 }
503- var templ_7745c5c3_Var28 string
504- templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(l.Inbox.Count))
522+ var templ_7745c5c3_Var29 string
523+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(l.Inbox.Count))
505524 if templ_7745c5c3_Err != nil {
506- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 95, Col: 47}
525+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 99, Col: 47}
507526 }
508- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
527+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
509528 if templ_7745c5c3_Err != nil {
510529 return templ_7745c5c3_Err
511530 }
512- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</span>")
531+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</span>")
513532 if templ_7745c5c3_Err != nil {
514533 return templ_7745c5c3_Err
515534 }
516535 }
517- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</a> ")
536+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</a> ")
518537 if templ_7745c5c3_Err != nil {
519538 return templ_7745c5c3_Err
520539 }
521540 }
522541 if len(l.Crumbs) > 0 {
523- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<nav class=\"crumbs\" aria-label=\"Path\"><ol>")
542+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<nav class=\"crumbs\" aria-label=\"Path\"><ol>")
524543 if templ_7745c5c3_Err != nil {
525544 return templ_7745c5c3_Err
526545 }
527546 for _, c := range l.Crumbs {
528- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<li>")
547+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<li>")
529548 if templ_7745c5c3_Err != nil {
530549 return templ_7745c5c3_Err
531550 }
532551 if c.Href != "" {
533- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<a href=\"")
552+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<a href=\"")
534553 if templ_7745c5c3_Err != nil {
535554 return templ_7745c5c3_Err
536555 }
537- var templ_7745c5c3_Var29 templ.SafeURL
538- templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
556+ var templ_7745c5c3_Var30 templ.SafeURL
557+ templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
539558 if templ_7745c5c3_Err != nil {
540- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 106, Col: 38}
559+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 110, Col: 38}
541560 }
542- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
561+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
543562 if templ_7745c5c3_Err != nil {
544563 return templ_7745c5c3_Err
545564 }
546- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\"")
565+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\"")
547566 if templ_7745c5c3_Err != nil {
548567 return templ_7745c5c3_Err
549568 }
550569 if c.Current {
551- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, " aria-current=\"page\"")
570+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, " aria-current=\"page\"")
552571 if templ_7745c5c3_Err != nil {
553572 return templ_7745c5c3_Err
554573 }
555574 }
556- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, ">")
575+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, ">")
557576 if templ_7745c5c3_Err != nil {
558577 return templ_7745c5c3_Err
559578 }
560- var templ_7745c5c3_Var30 string
561- templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
579+ var templ_7745c5c3_Var31 string
580+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
562581 if templ_7745c5c3_Err != nil {
563- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 110, Col: 19}
582+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 114, Col: 19}
564583 }
565- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
584+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
566585 if templ_7745c5c3_Err != nil {
567586 return templ_7745c5c3_Err
568587 }
569- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</a>")
588+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</a>")
570589 if templ_7745c5c3_Err != nil {
571590 return templ_7745c5c3_Err
572591 }
573592 } else {
574- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<span")
593+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<span")
575594 if templ_7745c5c3_Err != nil {
576595 return templ_7745c5c3_Err
577596 }
578597 if c.Current {
579- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, " aria-current=\"page\"")
598+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, " aria-current=\"page\"")
580599 if templ_7745c5c3_Err != nil {
581600 return templ_7745c5c3_Err
582601 }
583602 }
584- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, ">")
603+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, ">")
585604 if templ_7745c5c3_Err != nil {
586605 return templ_7745c5c3_Err
587606 }
588- var templ_7745c5c3_Var31 string
589- templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
607+ var templ_7745c5c3_Var32 string
608+ templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
590609 if templ_7745c5c3_Err != nil {
591- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 116, Col: 19}
610+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 120, Col: 19}
592611 }
593- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
612+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
594613 if templ_7745c5c3_Err != nil {
595614 return templ_7745c5c3_Err
596615 }
597- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</span>")
616+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</span>")
598617 if templ_7745c5c3_Err != nil {
599618 return templ_7745c5c3_Err
600619 }
601620 }
602- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</li>")
621+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</li>")
603622 if templ_7745c5c3_Err != nil {
604623 return templ_7745c5c3_Err
605624 }
606625 }
607- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</ol></nav>")
626+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</ol></nav>")
608627 if templ_7745c5c3_Err != nil {
609628 return templ_7745c5c3_Err
610629 }
611630 }
612- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<div class=\"hdr-acts\">")
631+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<div class=\"hdr-acts\">")
613632 if templ_7745c5c3_Err != nil {
614633 return templ_7745c5c3_Err
615634 }
616635 if l.Ref != "" {
617- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<a class=\"btn ref\" href=\"")
636+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<a class=\"btn ref\" href=\"")
618637 if templ_7745c5c3_Err != nil {
619638 return templ_7745c5c3_Err
620639 }
621- var templ_7745c5c3_Var32 templ.SafeURL
622- templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(repoHref(l.Repo, "refs")))
640+ var templ_7745c5c3_Var33 templ.SafeURL
641+ templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(repoHref(l.Repo, "refs")))
623642 if templ_7745c5c3_Err != nil {
624- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 127, Col: 52}
643+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 131, Col: 52}
625644 }
626- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
645+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
627646 if templ_7745c5c3_Err != nil {
628647 return templ_7745c5c3_Err
629648 }
630- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\" data-fire=\"refs\" aria-keyshortcuts=\"b\" title=\"switch branch or tag (b)\"><span class=\"ico i-branch\" aria-hidden=\"true\"></span> <span class=\"ref-name\">")
649+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\" data-fire=\"refs\" aria-keyshortcuts=\"b\" title=\"switch branch or tag (b)\"><span class=\"ico i-branch\" aria-hidden=\"true\"></span> <span class=\"ref-name\">")
631650 if templ_7745c5c3_Err != nil {
632651 return templ_7745c5c3_Err
633652 }
634- var templ_7745c5c3_Var33 string
635- templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(l.Ref)
653+ var templ_7745c5c3_Var34 string
654+ templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(l.Ref)
636655 if templ_7745c5c3_Err != nil {
637- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 133, Col: 36}
656+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 137, Col: 36}
638657 }
639- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
658+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
640659 if templ_7745c5c3_Err != nil {
641660 return templ_7745c5c3_Err
642661 }
643- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</span> <span class=\"sha\">")
662+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</span> <span class=\"sha\">")
644663 if templ_7745c5c3_Err != nil {
645664 return templ_7745c5c3_Err
646665 }
647- var templ_7745c5c3_Var34 string
648- templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(l.Short)
666+ var templ_7745c5c3_Var35 string
667+ templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(l.Short)
649668 if templ_7745c5c3_Err != nil {
650- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 134, Col: 33}
669+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 138, Col: 33}
651670 }
652- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
671+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
653672 if templ_7745c5c3_Err != nil {
654673 return templ_7745c5c3_Err
655674 }
656- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</span></a> ")
675+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</span></a> ")
657676 if templ_7745c5c3_Err != nil {
658677 return templ_7745c5c3_Err
659678 }
660679 }
661- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<button type=\"button\" class=\"btn search\" data-js aria-haspopup=\"dialog\" aria-controls=\"panel-cmd\" aria-keyshortcuts=\"Control+K :\" title=\"go to a section, ref, file or repo (ctrl k or :)\" data-fire=\"cmd\"><span class=\"ico i-search\" aria-hidden=\"true\"></span> <span class=\"search-label\">Go to…</span> <kbd aria-hidden=\"true\">ctrl k</kbd></button> ")
680+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<button type=\"button\" class=\"btn search\" data-js aria-haspopup=\"dialog\" aria-controls=\"panel-cmd\" aria-keyshortcuts=\"Control+K :\" title=\"go to a section, ref, file or repo (ctrl k or :)\" data-fire=\"cmd\"><span class=\"ico i-search\" aria-hidden=\"true\"></span> <span class=\"search-label\">Go to…</span> <kbd aria-hidden=\"true\">ctrl k</kbd></button> ")
662681 if templ_7745c5c3_Err != nil {
663682 return templ_7745c5c3_Err
664683 }
665684 if SignedIn(ctx) {
666- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<form class=\"owner\" method=\"post\" action=\"/-/logout\"><span class=\"owner-badge\" title=\"signed in as the owner\"><span class=\"ico i-user\" aria-hidden=\"true\"></span>")
685+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<form class=\"owner\" method=\"post\" action=\"/-/logout\"><span class=\"owner-badge\" title=\"signed in as the owner\"><span class=\"ico i-user\" aria-hidden=\"true\"></span>")
667686 if templ_7745c5c3_Err != nil {
668687 return templ_7745c5c3_Err
669688 }
670- var templ_7745c5c3_Var35 string
671- templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(l.Owner)
689+ var templ_7745c5c3_Var36 string
690+ templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(l.Owner)
672691 if templ_7745c5c3_Err != nil {
673- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 153, Col: 123}
692+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 157, Col: 123}
674693 }
675- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
694+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
676695 if templ_7745c5c3_Err != nil {
677696 return templ_7745c5c3_Err
678697 }
679- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</span> ")
698+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</span> ")
680699 if templ_7745c5c3_Err != nil {
681700 return templ_7745c5c3_Err
682701 }
683702 if l.Passkeys {
684- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<a class=\"btn icon\" href=\"/-/passkeys\" aria-label=\"Passkeys\" title=\"passkeys: add or remove the passkeys that sign you in\"><span class=\"ico i-key\" aria-hidden=\"true\"></span></a> ")
703+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<a class=\"btn icon\" href=\"/-/passkeys\" aria-label=\"Passkeys\" title=\"passkeys: add or remove the passkeys that sign you in\"><span class=\"ico i-key\" aria-hidden=\"true\"></span></a> ")
685704 if templ_7745c5c3_Err != nil {
686705 return templ_7745c5c3_Err
687706 }
688707 }
689- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<button type=\"submit\" class=\"btn icon\" aria-label=\"Sign out\" title=\"sign out of this browser\"><span class=\"ico i-log-out\" aria-hidden=\"true\"></span></button></form>")
708+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<button type=\"submit\" class=\"btn icon\" aria-label=\"Sign out\" title=\"sign out of this browser\"><span class=\"ico i-log-out\" aria-hidden=\"true\"></span></button></form>")
690709 if templ_7745c5c3_Err != nil {
691710 return templ_7745c5c3_Err
692711 }
693712 } else if l.Passkeys {
694- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<a class=\"btn icon\" href=\"/-/login\" aria-label=\"Sign in\" title=\"sign in with a passkey\"><span class=\"ico i-log-in\" aria-hidden=\"true\"></span></a>")
713+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<a class=\"btn icon\" href=\"/-/login\" aria-label=\"Sign in\" title=\"sign in with a passkey\"><span class=\"ico i-log-in\" aria-hidden=\"true\"></span></a>")
695714 if templ_7745c5c3_Err != nil {
696715 return templ_7745c5c3_Err
697716 }
698717 }
699- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</div></div>")
718+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</div></div>")
700719 if templ_7745c5c3_Err != nil {
701720 return templ_7745c5c3_Err
702721 }
703722 if len(l.Sections) > 0 {
704- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<nav class=\"tabs\" aria-label=\"Sections\"><ul>")
723+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<nav class=\"tabs\" aria-label=\"Sections\"><ul>")
705724 if templ_7745c5c3_Err != nil {
706725 return templ_7745c5c3_Err
707726 }
708727 for _, s := range l.Sections {
709- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<li><a href=\"")
728+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<li><a href=\"")
710729 if templ_7745c5c3_Err != nil {
711730 return templ_7745c5c3_Err
712731 }
713- var templ_7745c5c3_Var36 templ.SafeURL
714- templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href))
732+ var templ_7745c5c3_Var37 templ.SafeURL
733+ templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href))
715734 if templ_7745c5c3_Err != nil {
716- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 170, Col: 36}
735+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 174, Col: 36}
717736 }
718- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
737+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
719738 if templ_7745c5c3_Err != nil {
720739 return templ_7745c5c3_Err
721740 }
722- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\" data-key=\"")
741+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\" data-key=\"")
723742 if templ_7745c5c3_Err != nil {
724743 return templ_7745c5c3_Err
725744 }
726- var templ_7745c5c3_Var37 string
727- templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
745+ var templ_7745c5c3_Var38 string
746+ templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
728747 if templ_7745c5c3_Err != nil {
729- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 171, Col: 24}
748+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 175, Col: 24}
730749 }
731- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
750+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
732751 if templ_7745c5c3_Err != nil {
733752 return templ_7745c5c3_Err
734753 }
735- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "\" aria-keyshortcuts=\"")
754+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\" aria-keyshortcuts=\"")
736755 if templ_7745c5c3_Err != nil {
737756 return templ_7745c5c3_Err
738757 }
739- var templ_7745c5c3_Var38 string
740- templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
758+ var templ_7745c5c3_Var39 string
759+ templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
741760 if templ_7745c5c3_Err != nil {
742- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 172, Col: 33}
761+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 176, Col: 33}
743762 }
744- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
763+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
745764 if templ_7745c5c3_Err != nil {
746765 return templ_7745c5c3_Err
747766 }
748- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\"")
767+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "\"")
749768 if templ_7745c5c3_Err != nil {
750769 return templ_7745c5c3_Err
751770 }
752771 if s.Current {
753- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, " aria-current=\"page\"")
772+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, " aria-current=\"page\"")
754773 if templ_7745c5c3_Err != nil {
755774 return templ_7745c5c3_Err
756775 }
757776 }
758- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, ">")
777+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, ">")
759778 if templ_7745c5c3_Err != nil {
760779 return templ_7745c5c3_Err
761780 }
762- var templ_7745c5c3_Var39 string
763- templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label)
781+ var templ_7745c5c3_Var40 string
782+ templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label)
764783 if templ_7745c5c3_Err != nil {
765- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 177, Col: 17}
784+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 181, Col: 17}
766785 }
767- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
786+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
768787 if templ_7745c5c3_Err != nil {
769788 return templ_7745c5c3_Err
770789 }
771- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, " ")
790+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, " ")
772791 if templ_7745c5c3_Err != nil {
773792 return templ_7745c5c3_Err
774793 }
775794 if s.Count != "" {
776- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<span class=\"count\">")
795+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<span class=\"count\">")
777796 if templ_7745c5c3_Err != nil {
778797 return templ_7745c5c3_Err
779798 }
780- var templ_7745c5c3_Var40 string
781- templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(s.Count)
799+ var templ_7745c5c3_Var41 string
800+ templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(s.Count)
782801 if templ_7745c5c3_Err != nil {
783- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 179, Col: 38}
802+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 183, Col: 38}
784803 }
785- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
804+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
786805 if templ_7745c5c3_Err != nil {
787806 return templ_7745c5c3_Err
788807 }
789- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<span class=\"sr\">")
808+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<span class=\"sr\">")
790809 if templ_7745c5c3_Err != nil {
791810 return templ_7745c5c3_Err
792811 }
793- var templ_7745c5c3_Var41 string
794- templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(" open")
812+ var templ_7745c5c3_Var42 string
813+ templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(" open")
795814 if templ_7745c5c3_Err != nil {
796- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 179, Col: 66}
815+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 183, Col: 66}
797816 }
798- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
817+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
799818 if templ_7745c5c3_Err != nil {
800819 return templ_7745c5c3_Err
801820 }
802- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</span></span> ")
821+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</span></span> ")
803822 if templ_7745c5c3_Err != nil {
804823 return templ_7745c5c3_Err
805824 }
806825 }
807- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<kbd aria-hidden=\"true\">")
826+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<kbd aria-hidden=\"true\">")
808827 if templ_7745c5c3_Err != nil {
809828 return templ_7745c5c3_Err
810829 }
811- var templ_7745c5c3_Var42 string
812- templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(s.Key)
830+ var templ_7745c5c3_Var43 string
831+ templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(s.Key)
813832 if templ_7745c5c3_Err != nil {
814- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 181, Col: 39}
833+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 185, Col: 39}
815834 }
816- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
835+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
817836 if templ_7745c5c3_Err != nil {
818837 return templ_7745c5c3_Err
819838 }
820- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</kbd></a></li>")
839+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</kbd></a></li>")
821840 if templ_7745c5c3_Err != nil {
822841 return templ_7745c5c3_Err
823842 }
824843 }
825- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</ul></nav>")
844+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</ul></nav>")
826845 if templ_7745c5c3_Err != nil {
827846 return templ_7745c5c3_Err
828847 }
829848 }
830- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</header>")
849+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</header>")
831850 if templ_7745c5c3_Err != nil {
832851 return templ_7745c5c3_Err
833852 }
@@ -853,38 +872,38 @@ func Footer(l Layout) templ.Component {
853872 }()
854873 }
855874 ctx = templ.InitializeContext(ctx)
856- templ_7745c5c3_Var43 := templ.GetChildren(ctx)
857- if templ_7745c5c3_Var43 == nil {
858- templ_7745c5c3_Var43 = templ.NopComponent
875+ templ_7745c5c3_Var44 := templ.GetChildren(ctx)
876+ if templ_7745c5c3_Var44 == nil {
877+ templ_7745c5c3_Var44 = templ.NopComponent
859878 }
860879 ctx = templ.ClearChildren(ctx)
861- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<footer class=\"foot\"><span>ermite ")
880+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "<footer class=\"foot\"><span>ermite ")
862881 if templ_7745c5c3_Err != nil {
863882 return templ_7745c5c3_Err
864883 }
865- var templ_7745c5c3_Var44 string
866- templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version)
884+ var templ_7745c5c3_Var45 string
885+ templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version)
867886 if templ_7745c5c3_Err != nil {
868- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 195, Col: 26}
887+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 199, Col: 26}
869888 }
870- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
889+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
871890 if templ_7745c5c3_Err != nil {
872891 return templ_7745c5c3_Err
873892 }
874- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</span> <a href=\"")
893+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</span> <a href=\"")
875894 if templ_7745c5c3_Err != nil {
876895 return templ_7745c5c3_Err
877896 }
878- var templ_7745c5c3_Var45 templ.SafeURL
879- templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL))
897+ var templ_7745c5c3_Var46 templ.SafeURL
898+ templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL))
880899 if templ_7745c5c3_Err != nil {
881- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 196, Col: 38}
900+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 200, Col: 38}
882901 }
883- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
902+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
884903 if templ_7745c5c3_Err != nil {
885904 return templ_7745c5c3_Err
886905 }
887- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "\" rel=\"noopener\" title=\"source of this version (AGPL-3.0-or-later)\">source</a> <button type=\"button\" class=\"foot-keys\" data-js data-fire=\"help\" aria-haspopup=\"dialog\" aria-controls=\"panel-help\" aria-keyshortcuts=\"?\"><kbd aria-hidden=\"true\">?</kbd> keyboard shortcuts</button></footer>")
906+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\" rel=\"noopener\" title=\"source of this version (AGPL-3.0-or-later)\">source</a> <button type=\"button\" class=\"foot-keys\" data-js data-fire=\"help\" aria-haspopup=\"dialog\" aria-controls=\"panel-help\" aria-keyshortcuts=\"?\"><kbd aria-hidden=\"true\">?</kbd> keyboard shortcuts</button></footer>")
888907 if templ_7745c5c3_Err != nil {
889908 return templ_7745c5c3_Err
890909 }
@@ -912,12 +931,12 @@ func Hint(l Layout) templ.Component {
912931 }()
913932 }
914933 ctx = templ.InitializeContext(ctx)
915- templ_7745c5c3_Var46 := templ.GetChildren(ctx)
916- if templ_7745c5c3_Var46 == nil {
917- templ_7745c5c3_Var46 = templ.NopComponent
934+ templ_7745c5c3_Var47 := templ.GetChildren(ctx)
935+ if templ_7745c5c3_Var47 == nil {
936+ templ_7745c5c3_Var47 = templ.NopComponent
918937 }
919938 ctx = templ.ClearChildren(ctx)
920- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<div class=\"hint\" role=\"status\" aria-live=\"polite\"><span class=\"hint-nav\" :text=\"document.body.dataset.nav || ''\"></span> <span class=\"hint-sel\" :text=\"document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''\"></span> <span class=\"hint-flash\" :text=\"document.body.dataset.flash || ''\"></span>")
939+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<div class=\"hint\" role=\"status\" aria-live=\"polite\"><span class=\"hint-nav\" :text=\"document.body.dataset.nav || ''\"></span> <span class=\"hint-sel\" :text=\"document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''\"></span> <span class=\"hint-flash\" :text=\"document.body.dataset.flash || ''\"></span>")
921940 if templ_7745c5c3_Err != nil {
922941 return templ_7745c5c3_Err
923942 }
@@ -925,7 +944,7 @@ func Hint(l Layout) templ.Component {
925944 if templ_7745c5c3_Err != nil {
926945 return templ_7745c5c3_Err
927946 }
928- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</div>")
947+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "</div>")
929948 if templ_7745c5c3_Err != nil {
930949 return templ_7745c5c3_Err
931950 }
@@ -426,6 +426,25 @@ func Shell(l Layout, rail templ.Component) templ.Component {
426 if templ_7745c5c3_Err != nil {426 if templ_7745c5c3_Err != nil {
427 return templ_7745c5c3_Err427 return templ_7745c5c3_Err
428 }428 }
429+ if SignedIn(ctx) {
430+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<a hidden data-inbox href=\"")
431+ if templ_7745c5c3_Err != nil {
432+ return templ_7745c5c3_Err
433+ }
434+ var templ_7745c5c3_Var26 templ.SafeURL
435+ templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinURLErrs(inboxPath)
436+ if templ_7745c5c3_Err != nil {
437+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 71, Col: 41}
438+ }
439+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
440+ if templ_7745c5c3_Err != nil {
441+ return templ_7745c5c3_Err
442+ }
443+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\">Inbox</a>")
444+ if templ_7745c5c3_Err != nil {
445+ return templ_7745c5c3_Err
446+ }
447+ }
429 templ_7745c5c3_Err = OverlayHelp(l.MR).Render(ctx, templ_7745c5c3_Buffer)448 templ_7745c5c3_Err = OverlayHelp(l.MR).Render(ctx, templ_7745c5c3_Buffer)
430 if templ_7745c5c3_Err != nil {449 if templ_7745c5c3_Err != nil {
431 return templ_7745c5c3_Err450 return templ_7745c5c3_Err
@@ -442,7 +461,7 @@ func Shell(l Layout, rail templ.Component) templ.Component {
442 if templ_7745c5c3_Err != nil {461 if templ_7745c5c3_Err != nil {
443 return templ_7745c5c3_Err462 return templ_7745c5c3_Err
444 }463 }
445- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</body></html>")464+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</body></html>")
446 if templ_7745c5c3_Err != nil {465 if templ_7745c5c3_Err != nil {
447 return templ_7745c5c3_Err466 return templ_7745c5c3_Err
448 }467 }
@@ -468,366 +487,366 @@ func Header(l Layout) templ.Component {
468 }()487 }()
469 }488 }
470 ctx = templ.InitializeContext(ctx)489 ctx = templ.InitializeContext(ctx)
471- templ_7745c5c3_Var26 := templ.GetChildren(ctx)490+ templ_7745c5c3_Var27 := templ.GetChildren(ctx)
472- if templ_7745c5c3_Var26 == nil {491+ if templ_7745c5c3_Var27 == nil {
473- templ_7745c5c3_Var26 = templ.NopComponent492+ templ_7745c5c3_Var27 = templ.NopComponent
474 }493 }
475 ctx = templ.ClearChildren(ctx)494 ctx = templ.ClearChildren(ctx)
476- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<header class=\"hdr\"><div class=\"hdr-top\"><a class=\"brand\" href=\"/\" data-g=\"h\" aria-label=\"home\" title=\"home (g h)\"><span class=\"brand-mark\" aria-hidden=\"true\"></span></a> ")495+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<header class=\"hdr\"><div class=\"hdr-top\"><a class=\"brand\" href=\"/\" data-g=\"h\" aria-label=\"home\" title=\"home (g h)\"><span class=\"brand-mark\" aria-hidden=\"true\"></span></a> ")
477 if templ_7745c5c3_Err != nil {496 if templ_7745c5c3_Err != nil {
478 return templ_7745c5c3_Err497 return templ_7745c5c3_Err
479 }498 }
480 if l.Inbox != nil {499 if l.Inbox != nil {
481- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<a class=\"btn inbox-btn\" href=\"")500+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<a class=\"btn inbox-btn\" href=\"")
482 if templ_7745c5c3_Err != nil {501 if templ_7745c5c3_Err != nil {
483 return templ_7745c5c3_Err502 return templ_7745c5c3_Err
484 }503 }
485- var templ_7745c5c3_Var27 templ.SafeURL504+ var templ_7745c5c3_Var28 templ.SafeURL
486- templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.Inbox.Href))505+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.Inbox.Href))
487 if templ_7745c5c3_Err != nil {506 if templ_7745c5c3_Err != nil {
488- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 89, Col: 39}507+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 92, Col: 39}
489 }508 }
490- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))509+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
491 if templ_7745c5c3_Err != nil {510 if templ_7745c5c3_Err != nil {
492 return templ_7745c5c3_Err511 return templ_7745c5c3_Err
493 }512 }
494- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\" title=\"inbox\"><span class=\"ico i-mailbox\" aria-hidden=\"true\"></span> Inbox ")513+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\" title=\"inbox (I)\" aria-keyshortcuts=\"Shift+I\"><span class=\"ico i-mailbox\" aria-hidden=\"true\"></span> Inbox ")
495 if templ_7745c5c3_Err != nil {514 if templ_7745c5c3_Err != nil {
496 return templ_7745c5c3_Err515 return templ_7745c5c3_Err
497 }516 }
498 if l.Inbox.Count >= 0 {517 if l.Inbox.Count >= 0 {
499- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<span class=\"count\">")518+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<span class=\"count\">")
500 if templ_7745c5c3_Err != nil {519 if templ_7745c5c3_Err != nil {
501 return templ_7745c5c3_Err520 return templ_7745c5c3_Err
502 }521 }
503- var templ_7745c5c3_Var28 string522+ var templ_7745c5c3_Var29 string
504- templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(l.Inbox.Count))523+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(l.Inbox.Count))
505 if templ_7745c5c3_Err != nil {524 if templ_7745c5c3_Err != nil {
506- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 95, Col: 47}525+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 99, Col: 47}
507 }526 }
508- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))527+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
509 if templ_7745c5c3_Err != nil {528 if templ_7745c5c3_Err != nil {
510 return templ_7745c5c3_Err529 return templ_7745c5c3_Err
511 }530 }
512- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</span>")531+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</span>")
513 if templ_7745c5c3_Err != nil {532 if templ_7745c5c3_Err != nil {
514 return templ_7745c5c3_Err533 return templ_7745c5c3_Err
515 }534 }
516 }535 }
517- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</a> ")536+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</a> ")
518 if templ_7745c5c3_Err != nil {537 if templ_7745c5c3_Err != nil {
519 return templ_7745c5c3_Err538 return templ_7745c5c3_Err
520 }539 }
521 }540 }
522 if len(l.Crumbs) > 0 {541 if len(l.Crumbs) > 0 {
523- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<nav class=\"crumbs\" aria-label=\"Path\"><ol>")542+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<nav class=\"crumbs\" aria-label=\"Path\"><ol>")
524 if templ_7745c5c3_Err != nil {543 if templ_7745c5c3_Err != nil {
525 return templ_7745c5c3_Err544 return templ_7745c5c3_Err
526 }545 }
527 for _, c := range l.Crumbs {546 for _, c := range l.Crumbs {
528- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<li>")547+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<li>")
529 if templ_7745c5c3_Err != nil {548 if templ_7745c5c3_Err != nil {
530 return templ_7745c5c3_Err549 return templ_7745c5c3_Err
531 }550 }
532 if c.Href != "" {551 if c.Href != "" {
533- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<a href=\"")552+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<a href=\"")
534 if templ_7745c5c3_Err != nil {553 if templ_7745c5c3_Err != nil {
535 return templ_7745c5c3_Err554 return templ_7745c5c3_Err
536 }555 }
537- var templ_7745c5c3_Var29 templ.SafeURL556+ var templ_7745c5c3_Var30 templ.SafeURL
538- templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))557+ templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
539 if templ_7745c5c3_Err != nil {558 if templ_7745c5c3_Err != nil {
540- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 106, Col: 38}559+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 110, Col: 38}
541 }560 }
542- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))561+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
543 if templ_7745c5c3_Err != nil {562 if templ_7745c5c3_Err != nil {
544 return templ_7745c5c3_Err563 return templ_7745c5c3_Err
545 }564 }
546- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\"")565+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\"")
547 if templ_7745c5c3_Err != nil {566 if templ_7745c5c3_Err != nil {
548 return templ_7745c5c3_Err567 return templ_7745c5c3_Err
549 }568 }
550 if c.Current {569 if c.Current {
551- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, " aria-current=\"page\"")570+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, " aria-current=\"page\"")
552 if templ_7745c5c3_Err != nil {571 if templ_7745c5c3_Err != nil {
553 return templ_7745c5c3_Err572 return templ_7745c5c3_Err
554 }573 }
555 }574 }
556- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, ">")575+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, ">")
557 if templ_7745c5c3_Err != nil {576 if templ_7745c5c3_Err != nil {
558 return templ_7745c5c3_Err577 return templ_7745c5c3_Err
559 }578 }
560- var templ_7745c5c3_Var30 string579+ var templ_7745c5c3_Var31 string
561- templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)580+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
562 if templ_7745c5c3_Err != nil {581 if templ_7745c5c3_Err != nil {
563- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 110, Col: 19}582+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 114, Col: 19}
564 }583 }
565- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))584+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
566 if templ_7745c5c3_Err != nil {585 if templ_7745c5c3_Err != nil {
567 return templ_7745c5c3_Err586 return templ_7745c5c3_Err
568 }587 }
569- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</a>")588+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</a>")
570 if templ_7745c5c3_Err != nil {589 if templ_7745c5c3_Err != nil {
571 return templ_7745c5c3_Err590 return templ_7745c5c3_Err
572 }591 }
573 } else {592 } else {
574- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<span")593+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<span")
575 if templ_7745c5c3_Err != nil {594 if templ_7745c5c3_Err != nil {
576 return templ_7745c5c3_Err595 return templ_7745c5c3_Err
577 }596 }
578 if c.Current {597 if c.Current {
579- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, " aria-current=\"page\"")598+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, " aria-current=\"page\"")
580 if templ_7745c5c3_Err != nil {599 if templ_7745c5c3_Err != nil {
581 return templ_7745c5c3_Err600 return templ_7745c5c3_Err
582 }601 }
583 }602 }
584- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, ">")603+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, ">")
585 if templ_7745c5c3_Err != nil {604 if templ_7745c5c3_Err != nil {
586 return templ_7745c5c3_Err605 return templ_7745c5c3_Err
587 }606 }
588- var templ_7745c5c3_Var31 string607+ var templ_7745c5c3_Var32 string
589- templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)608+ templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
590 if templ_7745c5c3_Err != nil {609 if templ_7745c5c3_Err != nil {
591- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 116, Col: 19}610+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 120, Col: 19}
592 }611 }
593- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))612+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
594 if templ_7745c5c3_Err != nil {613 if templ_7745c5c3_Err != nil {
595 return templ_7745c5c3_Err614 return templ_7745c5c3_Err
596 }615 }
597- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</span>")616+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</span>")
598 if templ_7745c5c3_Err != nil {617 if templ_7745c5c3_Err != nil {
599 return templ_7745c5c3_Err618 return templ_7745c5c3_Err
600 }619 }
601 }620 }
602- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</li>")621+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</li>")
603 if templ_7745c5c3_Err != nil {622 if templ_7745c5c3_Err != nil {
604 return templ_7745c5c3_Err623 return templ_7745c5c3_Err
605 }624 }
606 }625 }
607- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</ol></nav>")626+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</ol></nav>")
608 if templ_7745c5c3_Err != nil {627 if templ_7745c5c3_Err != nil {
609 return templ_7745c5c3_Err628 return templ_7745c5c3_Err
610 }629 }
611 }630 }
612- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<div class=\"hdr-acts\">")631+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<div class=\"hdr-acts\">")
613 if templ_7745c5c3_Err != nil {632 if templ_7745c5c3_Err != nil {
614 return templ_7745c5c3_Err633 return templ_7745c5c3_Err
615 }634 }
616 if l.Ref != "" {635 if l.Ref != "" {
617- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<a class=\"btn ref\" href=\"")636+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<a class=\"btn ref\" href=\"")
618 if templ_7745c5c3_Err != nil {637 if templ_7745c5c3_Err != nil {
619 return templ_7745c5c3_Err638 return templ_7745c5c3_Err
620 }639 }
621- var templ_7745c5c3_Var32 templ.SafeURL640+ var templ_7745c5c3_Var33 templ.SafeURL
622- templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(repoHref(l.Repo, "refs")))641+ templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(repoHref(l.Repo, "refs")))
623 if templ_7745c5c3_Err != nil {642 if templ_7745c5c3_Err != nil {
624- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 127, Col: 52}643+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 131, Col: 52}
625 }644 }
626- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))645+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
627 if templ_7745c5c3_Err != nil {646 if templ_7745c5c3_Err != nil {
628 return templ_7745c5c3_Err647 return templ_7745c5c3_Err
629 }648 }
630- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\" data-fire=\"refs\" aria-keyshortcuts=\"b\" title=\"switch branch or tag (b)\"><span class=\"ico i-branch\" aria-hidden=\"true\"></span> <span class=\"ref-name\">")649+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\" data-fire=\"refs\" aria-keyshortcuts=\"b\" title=\"switch branch or tag (b)\"><span class=\"ico i-branch\" aria-hidden=\"true\"></span> <span class=\"ref-name\">")
631 if templ_7745c5c3_Err != nil {650 if templ_7745c5c3_Err != nil {
632 return templ_7745c5c3_Err651 return templ_7745c5c3_Err
633 }652 }
634- var templ_7745c5c3_Var33 string653+ var templ_7745c5c3_Var34 string
635- templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(l.Ref)654+ templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(l.Ref)
636 if templ_7745c5c3_Err != nil {655 if templ_7745c5c3_Err != nil {
637- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 133, Col: 36}656+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 137, Col: 36}
638 }657 }
639- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))658+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
640 if templ_7745c5c3_Err != nil {659 if templ_7745c5c3_Err != nil {
641 return templ_7745c5c3_Err660 return templ_7745c5c3_Err
642 }661 }
643- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</span> <span class=\"sha\">")662+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</span> <span class=\"sha\">")
644 if templ_7745c5c3_Err != nil {663 if templ_7745c5c3_Err != nil {
645 return templ_7745c5c3_Err664 return templ_7745c5c3_Err
646 }665 }
647- var templ_7745c5c3_Var34 string666+ var templ_7745c5c3_Var35 string
648- templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(l.Short)667+ templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(l.Short)
649 if templ_7745c5c3_Err != nil {668 if templ_7745c5c3_Err != nil {
650- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 134, Col: 33}669+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 138, Col: 33}
651 }670 }
652- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))671+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
653 if templ_7745c5c3_Err != nil {672 if templ_7745c5c3_Err != nil {
654 return templ_7745c5c3_Err673 return templ_7745c5c3_Err
655 }674 }
656- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</span></a> ")675+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</span></a> ")
657 if templ_7745c5c3_Err != nil {676 if templ_7745c5c3_Err != nil {
658 return templ_7745c5c3_Err677 return templ_7745c5c3_Err
659 }678 }
660 }679 }
661- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<button type=\"button\" class=\"btn search\" data-js aria-haspopup=\"dialog\" aria-controls=\"panel-cmd\" aria-keyshortcuts=\"Control+K :\" title=\"go to a section, ref, file or repo (ctrl k or :)\" data-fire=\"cmd\"><span class=\"ico i-search\" aria-hidden=\"true\"></span> <span class=\"search-label\">Go to…</span> <kbd aria-hidden=\"true\">ctrl k</kbd></button> ")680+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<button type=\"button\" class=\"btn search\" data-js aria-haspopup=\"dialog\" aria-controls=\"panel-cmd\" aria-keyshortcuts=\"Control+K :\" title=\"go to a section, ref, file or repo (ctrl k or :)\" data-fire=\"cmd\"><span class=\"ico i-search\" aria-hidden=\"true\"></span> <span class=\"search-label\">Go to…</span> <kbd aria-hidden=\"true\">ctrl k</kbd></button> ")
662 if templ_7745c5c3_Err != nil {681 if templ_7745c5c3_Err != nil {
663 return templ_7745c5c3_Err682 return templ_7745c5c3_Err
664 }683 }
665 if SignedIn(ctx) {684 if SignedIn(ctx) {
666- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<form class=\"owner\" method=\"post\" action=\"/-/logout\"><span class=\"owner-badge\" title=\"signed in as the owner\"><span class=\"ico i-user\" aria-hidden=\"true\"></span>")685+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<form class=\"owner\" method=\"post\" action=\"/-/logout\"><span class=\"owner-badge\" title=\"signed in as the owner\"><span class=\"ico i-user\" aria-hidden=\"true\"></span>")
667 if templ_7745c5c3_Err != nil {686 if templ_7745c5c3_Err != nil {
668 return templ_7745c5c3_Err687 return templ_7745c5c3_Err
669 }688 }
670- var templ_7745c5c3_Var35 string689+ var templ_7745c5c3_Var36 string
671- templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(l.Owner)690+ templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(l.Owner)
672 if templ_7745c5c3_Err != nil {691 if templ_7745c5c3_Err != nil {
673- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 153, Col: 123}692+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 157, Col: 123}
674 }693 }
675- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))694+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
676 if templ_7745c5c3_Err != nil {695 if templ_7745c5c3_Err != nil {
677 return templ_7745c5c3_Err696 return templ_7745c5c3_Err
678 }697 }
679- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</span> ")698+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</span> ")
680 if templ_7745c5c3_Err != nil {699 if templ_7745c5c3_Err != nil {
681 return templ_7745c5c3_Err700 return templ_7745c5c3_Err
682 }701 }
683 if l.Passkeys {702 if l.Passkeys {
684- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<a class=\"btn icon\" href=\"/-/passkeys\" aria-label=\"Passkeys\" title=\"passkeys: add or remove the passkeys that sign you in\"><span class=\"ico i-key\" aria-hidden=\"true\"></span></a> ")703+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<a class=\"btn icon\" href=\"/-/passkeys\" aria-label=\"Passkeys\" title=\"passkeys: add or remove the passkeys that sign you in\"><span class=\"ico i-key\" aria-hidden=\"true\"></span></a> ")
685 if templ_7745c5c3_Err != nil {704 if templ_7745c5c3_Err != nil {
686 return templ_7745c5c3_Err705 return templ_7745c5c3_Err
687 }706 }
688 }707 }
689- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<button type=\"submit\" class=\"btn icon\" aria-label=\"Sign out\" title=\"sign out of this browser\"><span class=\"ico i-log-out\" aria-hidden=\"true\"></span></button></form>")708+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<button type=\"submit\" class=\"btn icon\" aria-label=\"Sign out\" title=\"sign out of this browser\"><span class=\"ico i-log-out\" aria-hidden=\"true\"></span></button></form>")
690 if templ_7745c5c3_Err != nil {709 if templ_7745c5c3_Err != nil {
691 return templ_7745c5c3_Err710 return templ_7745c5c3_Err
692 }711 }
693 } else if l.Passkeys {712 } else if l.Passkeys {
694- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<a class=\"btn icon\" href=\"/-/login\" aria-label=\"Sign in\" title=\"sign in with a passkey\"><span class=\"ico i-log-in\" aria-hidden=\"true\"></span></a>")713+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<a class=\"btn icon\" href=\"/-/login\" aria-label=\"Sign in\" title=\"sign in with a passkey\"><span class=\"ico i-log-in\" aria-hidden=\"true\"></span></a>")
695 if templ_7745c5c3_Err != nil {714 if templ_7745c5c3_Err != nil {
696 return templ_7745c5c3_Err715 return templ_7745c5c3_Err
697 }716 }
698 }717 }
699- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</div></div>")718+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</div></div>")
700 if templ_7745c5c3_Err != nil {719 if templ_7745c5c3_Err != nil {
701 return templ_7745c5c3_Err720 return templ_7745c5c3_Err
702 }721 }
703 if len(l.Sections) > 0 {722 if len(l.Sections) > 0 {
704- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<nav class=\"tabs\" aria-label=\"Sections\"><ul>")723+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<nav class=\"tabs\" aria-label=\"Sections\"><ul>")
705 if templ_7745c5c3_Err != nil {724 if templ_7745c5c3_Err != nil {
706 return templ_7745c5c3_Err725 return templ_7745c5c3_Err
707 }726 }
708 for _, s := range l.Sections {727 for _, s := range l.Sections {
709- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<li><a href=\"")728+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<li><a href=\"")
710 if templ_7745c5c3_Err != nil {729 if templ_7745c5c3_Err != nil {
711 return templ_7745c5c3_Err730 return templ_7745c5c3_Err
712 }731 }
713- var templ_7745c5c3_Var36 templ.SafeURL732+ var templ_7745c5c3_Var37 templ.SafeURL
714- templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href))733+ templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href))
715 if templ_7745c5c3_Err != nil {734 if templ_7745c5c3_Err != nil {
716- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 170, Col: 36}735+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 174, Col: 36}
717 }736 }
718- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))737+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
719 if templ_7745c5c3_Err != nil {738 if templ_7745c5c3_Err != nil {
720 return templ_7745c5c3_Err739 return templ_7745c5c3_Err
721 }740 }
722- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\" data-key=\"")741+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\" data-key=\"")
723 if templ_7745c5c3_Err != nil {742 if templ_7745c5c3_Err != nil {
724 return templ_7745c5c3_Err743 return templ_7745c5c3_Err
725 }744 }
726- var templ_7745c5c3_Var37 string745+ var templ_7745c5c3_Var38 string
727- templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)746+ templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
728 if templ_7745c5c3_Err != nil {747 if templ_7745c5c3_Err != nil {
729- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 171, Col: 24}748+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 175, Col: 24}
730 }749 }
731- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)750+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
732 if templ_7745c5c3_Err != nil {751 if templ_7745c5c3_Err != nil {
733 return templ_7745c5c3_Err752 return templ_7745c5c3_Err
734 }753 }
735- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "\" aria-keyshortcuts=\"")754+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\" aria-keyshortcuts=\"")
736 if templ_7745c5c3_Err != nil {755 if templ_7745c5c3_Err != nil {
737 return templ_7745c5c3_Err756 return templ_7745c5c3_Err
738 }757 }
739- var templ_7745c5c3_Var38 string758+ var templ_7745c5c3_Var39 string
740- templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)759+ templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
741 if templ_7745c5c3_Err != nil {760 if templ_7745c5c3_Err != nil {
742- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 172, Col: 33}761+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 176, Col: 33}
743 }762 }
744- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)763+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
745 if templ_7745c5c3_Err != nil {764 if templ_7745c5c3_Err != nil {
746 return templ_7745c5c3_Err765 return templ_7745c5c3_Err
747 }766 }
748- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\"")767+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "\"")
749 if templ_7745c5c3_Err != nil {768 if templ_7745c5c3_Err != nil {
750 return templ_7745c5c3_Err769 return templ_7745c5c3_Err
751 }770 }
752 if s.Current {771 if s.Current {
753- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, " aria-current=\"page\"")772+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, " aria-current=\"page\"")
754 if templ_7745c5c3_Err != nil {773 if templ_7745c5c3_Err != nil {
755 return templ_7745c5c3_Err774 return templ_7745c5c3_Err
756 }775 }
757 }776 }
758- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, ">")777+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, ">")
759 if templ_7745c5c3_Err != nil {778 if templ_7745c5c3_Err != nil {
760 return templ_7745c5c3_Err779 return templ_7745c5c3_Err
761 }780 }
762- var templ_7745c5c3_Var39 string781+ var templ_7745c5c3_Var40 string
763- templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label)782+ templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label)
764 if templ_7745c5c3_Err != nil {783 if templ_7745c5c3_Err != nil {
765- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 177, Col: 17}784+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 181, Col: 17}
766 }785 }
767- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))786+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
768 if templ_7745c5c3_Err != nil {787 if templ_7745c5c3_Err != nil {
769 return templ_7745c5c3_Err788 return templ_7745c5c3_Err
770 }789 }
771- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, " ")790+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, " ")
772 if templ_7745c5c3_Err != nil {791 if templ_7745c5c3_Err != nil {
773 return templ_7745c5c3_Err792 return templ_7745c5c3_Err
774 }793 }
775 if s.Count != "" {794 if s.Count != "" {
776- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<span class=\"count\">")795+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<span class=\"count\">")
777 if templ_7745c5c3_Err != nil {796 if templ_7745c5c3_Err != nil {
778 return templ_7745c5c3_Err797 return templ_7745c5c3_Err
779 }798 }
780- var templ_7745c5c3_Var40 string799+ var templ_7745c5c3_Var41 string
781- templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(s.Count)800+ templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(s.Count)
782 if templ_7745c5c3_Err != nil {801 if templ_7745c5c3_Err != nil {
783- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 179, Col: 38}802+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 183, Col: 38}
784 }803 }
785- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))804+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
786 if templ_7745c5c3_Err != nil {805 if templ_7745c5c3_Err != nil {
787 return templ_7745c5c3_Err806 return templ_7745c5c3_Err
788 }807 }
789- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<span class=\"sr\">")808+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<span class=\"sr\">")
790 if templ_7745c5c3_Err != nil {809 if templ_7745c5c3_Err != nil {
791 return templ_7745c5c3_Err810 return templ_7745c5c3_Err
792 }811 }
793- var templ_7745c5c3_Var41 string812+ var templ_7745c5c3_Var42 string
794- templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(" open")813+ templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(" open")
795 if templ_7745c5c3_Err != nil {814 if templ_7745c5c3_Err != nil {
796- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 179, Col: 66}815+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 183, Col: 66}
797 }816 }
798- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))817+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
799 if templ_7745c5c3_Err != nil {818 if templ_7745c5c3_Err != nil {
800 return templ_7745c5c3_Err819 return templ_7745c5c3_Err
801 }820 }
802- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</span></span> ")821+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</span></span> ")
803 if templ_7745c5c3_Err != nil {822 if templ_7745c5c3_Err != nil {
804 return templ_7745c5c3_Err823 return templ_7745c5c3_Err
805 }824 }
806 }825 }
807- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<kbd aria-hidden=\"true\">")826+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<kbd aria-hidden=\"true\">")
808 if templ_7745c5c3_Err != nil {827 if templ_7745c5c3_Err != nil {
809 return templ_7745c5c3_Err828 return templ_7745c5c3_Err
810 }829 }
811- var templ_7745c5c3_Var42 string830+ var templ_7745c5c3_Var43 string
812- templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(s.Key)831+ templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(s.Key)
813 if templ_7745c5c3_Err != nil {832 if templ_7745c5c3_Err != nil {
814- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 181, Col: 39}833+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 185, Col: 39}
815 }834 }
816- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))835+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
817 if templ_7745c5c3_Err != nil {836 if templ_7745c5c3_Err != nil {
818 return templ_7745c5c3_Err837 return templ_7745c5c3_Err
819 }838 }
820- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</kbd></a></li>")839+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</kbd></a></li>")
821 if templ_7745c5c3_Err != nil {840 if templ_7745c5c3_Err != nil {
822 return templ_7745c5c3_Err841 return templ_7745c5c3_Err
823 }842 }
824 }843 }
825- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</ul></nav>")844+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</ul></nav>")
826 if templ_7745c5c3_Err != nil {845 if templ_7745c5c3_Err != nil {
827 return templ_7745c5c3_Err846 return templ_7745c5c3_Err
828 }847 }
829 }848 }
830- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</header>")849+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</header>")
831 if templ_7745c5c3_Err != nil {850 if templ_7745c5c3_Err != nil {
832 return templ_7745c5c3_Err851 return templ_7745c5c3_Err
833 }852 }
@@ -853,38 +872,38 @@ func Footer(l Layout) templ.Component {
853 }()872 }()
854 }873 }
855 ctx = templ.InitializeContext(ctx)874 ctx = templ.InitializeContext(ctx)
856- templ_7745c5c3_Var43 := templ.GetChildren(ctx)875+ templ_7745c5c3_Var44 := templ.GetChildren(ctx)
857- if templ_7745c5c3_Var43 == nil {876+ if templ_7745c5c3_Var44 == nil {
858- templ_7745c5c3_Var43 = templ.NopComponent877+ templ_7745c5c3_Var44 = templ.NopComponent
859 }878 }
860 ctx = templ.ClearChildren(ctx)879 ctx = templ.ClearChildren(ctx)
861- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<footer class=\"foot\"><span>ermite ")880+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "<footer class=\"foot\"><span>ermite ")
862 if templ_7745c5c3_Err != nil {881 if templ_7745c5c3_Err != nil {
863 return templ_7745c5c3_Err882 return templ_7745c5c3_Err
864 }883 }
865- var templ_7745c5c3_Var44 string884+ var templ_7745c5c3_Var45 string
866- templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version)885+ templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version)
867 if templ_7745c5c3_Err != nil {886 if templ_7745c5c3_Err != nil {
868- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 195, Col: 26}887+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 199, Col: 26}
869 }888 }
870- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))889+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
871 if templ_7745c5c3_Err != nil {890 if templ_7745c5c3_Err != nil {
872 return templ_7745c5c3_Err891 return templ_7745c5c3_Err
873 }892 }
874- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</span> <a href=\"")893+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</span> <a href=\"")
875 if templ_7745c5c3_Err != nil {894 if templ_7745c5c3_Err != nil {
876 return templ_7745c5c3_Err895 return templ_7745c5c3_Err
877 }896 }
878- var templ_7745c5c3_Var45 templ.SafeURL897+ var templ_7745c5c3_Var46 templ.SafeURL
879- templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL))898+ templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL))
880 if templ_7745c5c3_Err != nil {899 if templ_7745c5c3_Err != nil {
881- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 196, Col: 38}900+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 200, Col: 38}
882 }901 }
883- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))902+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
884 if templ_7745c5c3_Err != nil {903 if templ_7745c5c3_Err != nil {
885 return templ_7745c5c3_Err904 return templ_7745c5c3_Err
886 }905 }
887- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "\" rel=\"noopener\" title=\"source of this version (AGPL-3.0-or-later)\">source</a> <button type=\"button\" class=\"foot-keys\" data-js data-fire=\"help\" aria-haspopup=\"dialog\" aria-controls=\"panel-help\" aria-keyshortcuts=\"?\"><kbd aria-hidden=\"true\">?</kbd> keyboard shortcuts</button></footer>")906+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\" rel=\"noopener\" title=\"source of this version (AGPL-3.0-or-later)\">source</a> <button type=\"button\" class=\"foot-keys\" data-js data-fire=\"help\" aria-haspopup=\"dialog\" aria-controls=\"panel-help\" aria-keyshortcuts=\"?\"><kbd aria-hidden=\"true\">?</kbd> keyboard shortcuts</button></footer>")
888 if templ_7745c5c3_Err != nil {907 if templ_7745c5c3_Err != nil {
889 return templ_7745c5c3_Err908 return templ_7745c5c3_Err
890 }909 }
@@ -912,12 +931,12 @@ func Hint(l Layout) templ.Component {
912 }()931 }()
913 }932 }
914 ctx = templ.InitializeContext(ctx)933 ctx = templ.InitializeContext(ctx)
915- templ_7745c5c3_Var46 := templ.GetChildren(ctx)934+ templ_7745c5c3_Var47 := templ.GetChildren(ctx)
916- if templ_7745c5c3_Var46 == nil {935+ if templ_7745c5c3_Var47 == nil {
917- templ_7745c5c3_Var46 = templ.NopComponent936+ templ_7745c5c3_Var47 = templ.NopComponent
918 }937 }
919 ctx = templ.ClearChildren(ctx)938 ctx = templ.ClearChildren(ctx)
920- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<div class=\"hint\" role=\"status\" aria-live=\"polite\"><span class=\"hint-nav\" :text=\"document.body.dataset.nav || ''\"></span> <span class=\"hint-sel\" :text=\"document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''\"></span> <span class=\"hint-flash\" :text=\"document.body.dataset.flash || ''\"></span>")939+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<div class=\"hint\" role=\"status\" aria-live=\"polite\"><span class=\"hint-nav\" :text=\"document.body.dataset.nav || ''\"></span> <span class=\"hint-sel\" :text=\"document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''\"></span> <span class=\"hint-flash\" :text=\"document.body.dataset.flash || ''\"></span>")
921 if templ_7745c5c3_Err != nil {940 if templ_7745c5c3_Err != nil {
922 return templ_7745c5c3_Err941 return templ_7745c5c3_Err
923 }942 }
@@ -925,7 +944,7 @@ func Hint(l Layout) templ.Component {
925 if templ_7745c5c3_Err != nil {944 if templ_7745c5c3_Err != nil {
926 return templ_7745c5c3_Err945 return templ_7745c5c3_Err
927 }946 }
928- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</div>")947+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "</div>")
929 if templ_7745c5c3_Err != nil {948 if templ_7745c5c3_Err != nil {
930 return templ_7745c5c3_Err949 return templ_7745c5c3_Err
931 }950 }
M internal/adapters/web/static/js/ermite.js +2 -2
@@ -612,7 +612,7 @@
612612
613613 // 1. Keys.
614614 const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',
615- s: 's', t: 't', c: 'c', r: 'r', m: 'm', i: 'i', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', w: 'wide', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc',
615+ s: 's', t: 't', c: 'c', r: 'r', m: 'm', i: 'i', I: 'inbox', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', w: 'wide', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc',
616616 // the merge request page (mr.js): the merge menu, mark read
617617 M: 'merge', R: 'read' };
618618 // "g t" is the key "t": the tabs have one event each.
@@ -654,7 +654,7 @@
654654 // page-level keys, as on the other lists.
655655 if (!e.shiftKey && e.target.closest?.('#tree-rows') && window.ermiteTree?.arrow(e.key)) { e.preventDefault(); return; }
656656 const name = single[e.key];
657- if (!name) return;
657+ if (!name || (name === 'inbox' && !$('[data-inbox]'))) return;
658658 if (e.key !== 'Enter' || !$('dialog[open]')) e.preventDefault();
659659 ermite.fire(name);
660660 });
@@ -612,7 +612,7 @@
612 612
613 // 1. Keys.613 // 1. Keys.
614 const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',614 const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',
615- s: 's', t: 't', c: 'c', r: 'r', m: 'm', i: 'i', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', w: 'wide', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc',615+ s: 's', t: 't', c: 'c', r: 'r', m: 'm', i: 'i', I: 'inbox', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', w: 'wide', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc',
616 // the merge request page (mr.js): the merge menu, mark read616 // the merge request page (mr.js): the merge menu, mark read
617 M: 'merge', R: 'read' };617 M: 'merge', R: 'read' };
618 // "g t" is the key "t": the tabs have one event each.618 // "g t" is the key "t": the tabs have one event each.
@@ -654,7 +654,7 @@
654 // page-level keys, as on the other lists.654 // page-level keys, as on the other lists.
655 if (!e.shiftKey && e.target.closest?.('#tree-rows') && window.ermiteTree?.arrow(e.key)) { e.preventDefault(); return; }655 if (!e.shiftKey && e.target.closest?.('#tree-rows') && window.ermiteTree?.arrow(e.key)) { e.preventDefault(); return; }
656 const name = single[e.key];656 const name = single[e.key];
657- if (!name) return;657+ if (!name || (name === 'inbox' && !$('[data-inbox]'))) return;
658 if (e.key !== 'Enter' || !$('dialog[open]')) e.preventDefault();658 if (e.key !== 'Enter' || !$('dialog[open]')) e.preventDefault();
659 ermite.fire(name);659 ermite.fire(name);
660 });660 });
M internal/app/forge.go +1 -1
@@ -412,7 +412,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
412412 BaseDifficulty: opts.issuePoW, MaxDifficulty: opts.issuePoW, MinFillTime: fastFill(opts.issuePoW),
413413 })
414414 f.Inbox = usecase.NewInbox(usecase.InboxConfig{
415- Meta: store, MergeRequests: store.MergeRequests(), Issues: store.Issues(), Pending: store.PendingIssues(),
415+ Meta: store, MergeRequests: store.MergeRequests(), Issues: store.Issues(), Pending: store.PendingIssues(), CI: store.CI(),
416416 })
417417 f.CI.SetPullGate(f.MergeRequests)
418418 f.AutoMerge.Start()
@@ -412,7 +412,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
412 BaseDifficulty: opts.issuePoW, MaxDifficulty: opts.issuePoW, MinFillTime: fastFill(opts.issuePoW),412 BaseDifficulty: opts.issuePoW, MaxDifficulty: opts.issuePoW, MinFillTime: fastFill(opts.issuePoW),
413 })413 })
414 f.Inbox = usecase.NewInbox(usecase.InboxConfig{414 f.Inbox = usecase.NewInbox(usecase.InboxConfig{
415- Meta: store, MergeRequests: store.MergeRequests(), Issues: store.Issues(), Pending: store.PendingIssues(),415+ Meta: store, MergeRequests: store.MergeRequests(), Issues: store.Issues(), Pending: store.PendingIssues(), CI: store.CI(),
416 })416 })
417 f.CI.SetPullGate(f.MergeRequests)417 f.CI.SetPullGate(f.MergeRequests)
418 f.AutoMerge.Start()418 f.AutoMerge.Start()
M internal/app/testdata/web/blob-binary.html +1 -0
@@ -428,6 +428,7 @@
428428 k-r from:body -> ermite.tab('r');
429429 k-m from:body -> ermite.tab('m');
430430 k-i from:body -> ermite.tab('i');
431+ k-inbox from:body -> ermite.go('[data-inbox]');
431432 k-next from:body -> ermite.nextNote();
432433 k-split from:body -> ermite.layout('split');
433434 k-unified from:body -> ermite.layout('unified');
@@ -428,6 +428,7 @@
428 k-r from:body -> ermite.tab('r');428 k-r from:body -> ermite.tab('r');
429 k-m from:body -> ermite.tab('m');429 k-m from:body -> ermite.tab('m');
430 k-i from:body -> ermite.tab('i');430 k-i from:body -> ermite.tab('i');
431+ k-inbox from:body -> ermite.go('[data-inbox]');
431 k-next from:body -> ermite.nextNote();432 k-next from:body -> ermite.nextNote();
432 k-split from:body -> ermite.layout('split');433 k-split from:body -> ermite.layout('split');
433 k-unified from:body -> ermite.layout('unified');434 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-code.html +1 -0
@@ -498,6 +498,7 @@
498498 k-r from:body -> ermite.tab('r');
499499 k-m from:body -> ermite.tab('m');
500500 k-i from:body -> ermite.tab('i');
501+ k-inbox from:body -> ermite.go('[data-inbox]');
501502 k-next from:body -> ermite.nextNote();
502503 k-split from:body -> ermite.layout('split');
503504 k-unified from:body -> ermite.layout('unified');
@@ -498,6 +498,7 @@
498 k-r from:body -> ermite.tab('r');498 k-r from:body -> ermite.tab('r');
499 k-m from:body -> ermite.tab('m');499 k-m from:body -> ermite.tab('m');
500 k-i from:body -> ermite.tab('i');500 k-i from:body -> ermite.tab('i');
501+ k-inbox from:body -> ermite.go('[data-inbox]');
501 k-next from:body -> ermite.nextNote();502 k-next from:body -> ermite.nextNote();
502 k-split from:body -> ermite.layout('split');503 k-split from:body -> ermite.layout('split');
503 k-unified from:body -> ermite.layout('unified');504 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-empty.html +1 -0
@@ -428,6 +428,7 @@
428428 k-r from:body -> ermite.tab('r');
429429 k-m from:body -> ermite.tab('m');
430430 k-i from:body -> ermite.tab('i');
431+ k-inbox from:body -> ermite.go('[data-inbox]');
431432 k-next from:body -> ermite.nextNote();
432433 k-split from:body -> ermite.layout('split');
433434 k-unified from:body -> ermite.layout('unified');
@@ -428,6 +428,7 @@
428 k-r from:body -> ermite.tab('r');428 k-r from:body -> ermite.tab('r');
429 k-m from:body -> ermite.tab('m');429 k-m from:body -> ermite.tab('m');
430 k-i from:body -> ermite.tab('i');430 k-i from:body -> ermite.tab('i');
431+ k-inbox from:body -> ermite.go('[data-inbox]');
431 k-next from:body -> ermite.nextNote();432 k-next from:body -> ermite.nextNote();
432 k-split from:body -> ermite.layout('split');433 k-split from:body -> ermite.layout('split');
433 k-unified from:body -> ermite.layout('unified');434 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-image.html +1 -0
@@ -430,6 +430,7 @@
430430 k-r from:body -> ermite.tab('r');
431431 k-m from:body -> ermite.tab('m');
432432 k-i from:body -> ermite.tab('i');
433+ k-inbox from:body -> ermite.go('[data-inbox]');
433434 k-next from:body -> ermite.nextNote();
434435 k-split from:body -> ermite.layout('split');
435436 k-unified from:body -> ermite.layout('unified');
@@ -430,6 +430,7 @@
430 k-r from:body -> ermite.tab('r');430 k-r from:body -> ermite.tab('r');
431 k-m from:body -> ermite.tab('m');431 k-m from:body -> ermite.tab('m');
432 k-i from:body -> ermite.tab('i');432 k-i from:body -> ermite.tab('i');
433+ k-inbox from:body -> ermite.go('[data-inbox]');
433 k-next from:body -> ermite.nextNote();434 k-next from:body -> ermite.nextNote();
434 k-split from:body -> ermite.layout('split');435 k-split from:body -> ermite.layout('split');
435 k-unified from:body -> ermite.layout('unified');436 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-markdown.html +1 -0
@@ -495,6 +495,7 @@
495495 k-r from:body -> ermite.tab('r');
496496 k-m from:body -> ermite.tab('m');
497497 k-i from:body -> ermite.tab('i');
498+ k-inbox from:body -> ermite.go('[data-inbox]');
498499 k-next from:body -> ermite.nextNote();
499500 k-split from:body -> ermite.layout('split');
500501 k-unified from:body -> ermite.layout('unified');
@@ -495,6 +495,7 @@
495 k-r from:body -> ermite.tab('r');495 k-r from:body -> ermite.tab('r');
496 k-m from:body -> ermite.tab('m');496 k-m from:body -> ermite.tab('m');
497 k-i from:body -> ermite.tab('i');497 k-i from:body -> ermite.tab('i');
498+ k-inbox from:body -> ermite.go('[data-inbox]');
498 k-next from:body -> ermite.nextNote();499 k-next from:body -> ermite.nextNote();
499 k-split from:body -> ermite.layout('split');500 k-split from:body -> ermite.layout('split');
500 k-unified from:body -> ermite.layout('unified');501 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-svg.html +1 -0
@@ -453,6 +453,7 @@
453453 k-r from:body -> ermite.tab('r');
454454 k-m from:body -> ermite.tab('m');
455455 k-i from:body -> ermite.tab('i');
456+ k-inbox from:body -> ermite.go('[data-inbox]');
456457 k-next from:body -> ermite.nextNote();
457458 k-split from:body -> ermite.layout('split');
458459 k-unified from:body -> ermite.layout('unified');
@@ -453,6 +453,7 @@
453 k-r from:body -> ermite.tab('r');453 k-r from:body -> ermite.tab('r');
454 k-m from:body -> ermite.tab('m');454 k-m from:body -> ermite.tab('m');
455 k-i from:body -> ermite.tab('i');455 k-i from:body -> ermite.tab('i');
456+ k-inbox from:body -> ermite.go('[data-inbox]');
456 k-next from:body -> ermite.nextNote();457 k-next from:body -> ermite.nextNote();
457 k-split from:body -> ermite.layout('split');458 k-split from:body -> ermite.layout('split');
458 k-unified from:body -> ermite.layout('unified');459 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-text.html +1 -0
@@ -448,6 +448,7 @@
448448 k-r from:body -> ermite.tab('r');
449449 k-m from:body -> ermite.tab('m');
450450 k-i from:body -> ermite.tab('i');
451+ k-inbox from:body -> ermite.go('[data-inbox]');
451452 k-next from:body -> ermite.nextNote();
452453 k-split from:body -> ermite.layout('split');
453454 k-unified from:body -> ermite.layout('unified');
@@ -448,6 +448,7 @@
448 k-r from:body -> ermite.tab('r');448 k-r from:body -> ermite.tab('r');
449 k-m from:body -> ermite.tab('m');449 k-m from:body -> ermite.tab('m');
450 k-i from:body -> ermite.tab('i');450 k-i from:body -> ermite.tab('i');
451+ k-inbox from:body -> ermite.go('[data-inbox]');
451 k-next from:body -> ermite.nextNote();452 k-next from:body -> ermite.nextNote();
452 k-split from:body -> ermite.layout('split');453 k-split from:body -> ermite.layout('split');
453 k-unified from:body -> ermite.layout('unified');454 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-too-large.html +1 -0
@@ -428,6 +428,7 @@
428428 k-r from:body -> ermite.tab('r');
429429 k-m from:body -> ermite.tab('m');
430430 k-i from:body -> ermite.tab('i');
431+ k-inbox from:body -> ermite.go('[data-inbox]');
431432 k-next from:body -> ermite.nextNote();
432433 k-split from:body -> ermite.layout('split');
433434 k-unified from:body -> ermite.layout('unified');
@@ -428,6 +428,7 @@
428 k-r from:body -> ermite.tab('r');428 k-r from:body -> ermite.tab('r');
429 k-m from:body -> ermite.tab('m');429 k-m from:body -> ermite.tab('m');
430 k-i from:body -> ermite.tab('i');430 k-i from:body -> ermite.tab('i');
431+ k-inbox from:body -> ermite.go('[data-inbox]');
431 k-next from:body -> ermite.nextNote();432 k-next from:body -> ermite.nextNote();
432 k-split from:body -> ermite.layout('split');433 k-split from:body -> ermite.layout('split');
433 k-unified from:body -> ermite.layout('unified');434 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-trojan.html +1 -0
@@ -495,6 +495,7 @@
495495 k-r from:body -> ermite.tab('r');
496496 k-m from:body -> ermite.tab('m');
497497 k-i from:body -> ermite.tab('i');
498+ k-inbox from:body -> ermite.go('[data-inbox]');
498499 k-next from:body -> ermite.nextNote();
499500 k-split from:body -> ermite.layout('split');
500501 k-unified from:body -> ermite.layout('unified');
@@ -495,6 +495,7 @@
495 k-r from:body -> ermite.tab('r');495 k-r from:body -> ermite.tab('r');
496 k-m from:body -> ermite.tab('m');496 k-m from:body -> ermite.tab('m');
497 k-i from:body -> ermite.tab('i');497 k-i from:body -> ermite.tab('i');
498+ k-inbox from:body -> ermite.go('[data-inbox]');
498 k-next from:body -> ermite.nextNote();499 k-next from:body -> ermite.nextNote();
499 k-split from:body -> ermite.layout('split');500 k-split from:body -> ermite.layout('split');
500 k-unified from:body -> ermite.layout('unified');501 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commit-big.html +1 -0
@@ -480,6 +480,7 @@
480480 k-r from:body -> ermite.tab('r');
481481 k-m from:body -> ermite.tab('m');
482482 k-i from:body -> ermite.tab('i');
483+ k-inbox from:body -> ermite.go('[data-inbox]');
483484 k-next from:body -> ermite.nextNote();
484485 k-split from:body -> ermite.layout('split');
485486 k-unified from:body -> ermite.layout('unified');
@@ -480,6 +480,7 @@
480 k-r from:body -> ermite.tab('r');480 k-r from:body -> ermite.tab('r');
481 k-m from:body -> ermite.tab('m');481 k-m from:body -> ermite.tab('m');
482 k-i from:body -> ermite.tab('i');482 k-i from:body -> ermite.tab('i');
483+ k-inbox from:body -> ermite.go('[data-inbox]');
483 k-next from:body -> ermite.nextNote();484 k-next from:body -> ermite.nextNote();
484 k-split from:body -> ermite.layout('split');485 k-split from:body -> ermite.layout('split');
485 k-unified from:body -> ermite.layout('unified');486 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commit-binary.html +1 -0
@@ -471,6 +471,7 @@
471471 k-r from:body -> ermite.tab('r');
472472 k-m from:body -> ermite.tab('m');
473473 k-i from:body -> ermite.tab('i');
474+ k-inbox from:body -> ermite.go('[data-inbox]');
474475 k-next from:body -> ermite.nextNote();
475476 k-split from:body -> ermite.layout('split');
476477 k-unified from:body -> ermite.layout('unified');
@@ -471,6 +471,7 @@
471 k-r from:body -> ermite.tab('r');471 k-r from:body -> ermite.tab('r');
472 k-m from:body -> ermite.tab('m');472 k-m from:body -> ermite.tab('m');
473 k-i from:body -> ermite.tab('i');473 k-i from:body -> ermite.tab('i');
474+ k-inbox from:body -> ermite.go('[data-inbox]');
474 k-next from:body -> ermite.nextNote();475 k-next from:body -> ermite.nextNote();
475 k-split from:body -> ermite.layout('split');476 k-split from:body -> ermite.layout('split');
476 k-unified from:body -> ermite.layout('unified');477 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commit-merge.html +1 -0
@@ -513,6 +513,7 @@
513513 k-r from:body -> ermite.tab('r');
514514 k-m from:body -> ermite.tab('m');
515515 k-i from:body -> ermite.tab('i');
516+ k-inbox from:body -> ermite.go('[data-inbox]');
516517 k-next from:body -> ermite.nextNote();
517518 k-split from:body -> ermite.layout('split');
518519 k-unified from:body -> ermite.layout('unified');
@@ -513,6 +513,7 @@
513 k-r from:body -> ermite.tab('r');513 k-r from:body -> ermite.tab('r');
514 k-m from:body -> ermite.tab('m');514 k-m from:body -> ermite.tab('m');
515 k-i from:body -> ermite.tab('i');515 k-i from:body -> ermite.tab('i');
516+ k-inbox from:body -> ermite.go('[data-inbox]');
516 k-next from:body -> ermite.nextNote();517 k-next from:body -> ermite.nextNote();
517 k-split from:body -> ermite.layout('split');518 k-split from:body -> ermite.layout('split');
518 k-unified from:body -> ermite.layout('unified');519 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commit-modify-split.html +1 -0
@@ -636,6 +636,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
636636 k-r from:body -> ermite.tab('r');
637637 k-m from:body -> ermite.tab('m');
638638 k-i from:body -> ermite.tab('i');
639+ k-inbox from:body -> ermite.go('[data-inbox]');
639640 k-next from:body -> ermite.nextNote();
640641 k-split from:body -> ermite.layout('split');
641642 k-unified from:body -> ermite.layout('unified');
@@ -636,6 +636,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
636 k-r from:body -> ermite.tab('r');636 k-r from:body -> ermite.tab('r');
637 k-m from:body -> ermite.tab('m');637 k-m from:body -> ermite.tab('m');
638 k-i from:body -> ermite.tab('i');638 k-i from:body -> ermite.tab('i');
639+ k-inbox from:body -> ermite.go('[data-inbox]');
639 k-next from:body -> ermite.nextNote();640 k-next from:body -> ermite.nextNote();
640 k-split from:body -> ermite.layout('split');641 k-split from:body -> ermite.layout('split');
641 k-unified from:body -> ermite.layout('unified');642 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commit-modify.html +1 -0
@@ -636,6 +636,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
636636 k-r from:body -> ermite.tab('r');
637637 k-m from:body -> ermite.tab('m');
638638 k-i from:body -> ermite.tab('i');
639+ k-inbox from:body -> ermite.go('[data-inbox]');
639640 k-next from:body -> ermite.nextNote();
640641 k-split from:body -> ermite.layout('split');
641642 k-unified from:body -> ermite.layout('unified');
@@ -636,6 +636,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
636 k-r from:body -> ermite.tab('r');636 k-r from:body -> ermite.tab('r');
637 k-m from:body -> ermite.tab('m');637 k-m from:body -> ermite.tab('m');
638 k-i from:body -> ermite.tab('i');638 k-i from:body -> ermite.tab('i');
639+ k-inbox from:body -> ermite.go('[data-inbox]');
639 k-next from:body -> ermite.nextNote();640 k-next from:body -> ermite.nextNote();
640 k-split from:body -> ermite.layout('split');641 k-split from:body -> ermite.layout('split');
641 k-unified from:body -> ermite.layout('unified');642 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commit-rename.html +1 -0
@@ -540,6 +540,7 @@
540540 k-r from:body -> ermite.tab('r');
541541 k-m from:body -> ermite.tab('m');
542542 k-i from:body -> ermite.tab('i');
543+ k-inbox from:body -> ermite.go('[data-inbox]');
543544 k-next from:body -> ermite.nextNote();
544545 k-split from:body -> ermite.layout('split');
545546 k-unified from:body -> ermite.layout('unified');
@@ -540,6 +540,7 @@
540 k-r from:body -> ermite.tab('r');540 k-r from:body -> ermite.tab('r');
541 k-m from:body -> ermite.tab('m');541 k-m from:body -> ermite.tab('m');
542 k-i from:body -> ermite.tab('i');542 k-i from:body -> ermite.tab('i');
543+ k-inbox from:body -> ermite.go('[data-inbox]');
543 k-next from:body -> ermite.nextNote();544 k-next from:body -> ermite.nextNote();
544 k-split from:body -> ermite.layout('split');545 k-split from:body -> ermite.layout('split');
545 k-unified from:body -> ermite.layout('unified');546 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commit-root.html +1 -0
@@ -859,6 +859,7 @@
859859 k-r from:body -> ermite.tab('r');
860860 k-m from:body -> ermite.tab('m');
861861 k-i from:body -> ermite.tab('i');
862+ k-inbox from:body -> ermite.go('[data-inbox]');
862863 k-next from:body -> ermite.nextNote();
863864 k-split from:body -> ermite.layout('split');
864865 k-unified from:body -> ermite.layout('unified');
@@ -859,6 +859,7 @@
859 k-r from:body -> ermite.tab('r');859 k-r from:body -> ermite.tab('r');
860 k-m from:body -> ermite.tab('m');860 k-m from:body -> ermite.tab('m');
861 k-i from:body -> ermite.tab('i');861 k-i from:body -> ermite.tab('i');
862+ k-inbox from:body -> ermite.go('[data-inbox]');
862 k-next from:body -> ermite.nextNote();863 k-next from:body -> ermite.nextNote();
863 k-split from:body -> ermite.layout('split');864 k-split from:body -> ermite.layout('split');
864 k-unified from:body -> ermite.layout('unified');865 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commit-trojan.html +1 -0
@@ -688,6 +688,7 @@
688688 k-r from:body -> ermite.tab('r');
689689 k-m from:body -> ermite.tab('m');
690690 k-i from:body -> ermite.tab('i');
691+ k-inbox from:body -> ermite.go('[data-inbox]');
691692 k-next from:body -> ermite.nextNote();
692693 k-split from:body -> ermite.layout('split');
693694 k-unified from:body -> ermite.layout('unified');
@@ -688,6 +688,7 @@
688 k-r from:body -> ermite.tab('r');688 k-r from:body -> ermite.tab('r');
689 k-m from:body -> ermite.tab('m');689 k-m from:body -> ermite.tab('m');
690 k-i from:body -> ermite.tab('i');690 k-i from:body -> ermite.tab('i');
691+ k-inbox from:body -> ermite.go('[data-inbox]');
691 k-next from:body -> ermite.nextNote();692 k-next from:body -> ermite.nextNote();
692 k-split from:body -> ermite.layout('split');693 k-split from:body -> ermite.layout('split');
693 k-unified from:body -> ermite.layout('unified');694 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commits-page2.html +1 -0
@@ -441,6 +441,7 @@
441441 k-r from:body -> ermite.tab('r');
442442 k-m from:body -> ermite.tab('m');
443443 k-i from:body -> ermite.tab('i');
444+ k-inbox from:body -> ermite.go('[data-inbox]');
444445 k-next from:body -> ermite.nextNote();
445446 k-split from:body -> ermite.layout('split');
446447 k-unified from:body -> ermite.layout('unified');
@@ -441,6 +441,7 @@
441 k-r from:body -> ermite.tab('r');441 k-r from:body -> ermite.tab('r');
442 k-m from:body -> ermite.tab('m');442 k-m from:body -> ermite.tab('m');
443 k-i from:body -> ermite.tab('i');443 k-i from:body -> ermite.tab('i');
444+ k-inbox from:body -> ermite.go('[data-inbox]');
444 k-next from:body -> ermite.nextNote();445 k-next from:body -> ermite.nextNote();
445 k-split from:body -> ermite.layout('split');446 k-split from:body -> ermite.layout('split');
446 k-unified from:body -> ermite.layout('unified');447 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commits-path.html +1 -0
@@ -456,6 +456,7 @@
456456 k-r from:body -> ermite.tab('r');
457457 k-m from:body -> ermite.tab('m');
458458 k-i from:body -> ermite.tab('i');
459+ k-inbox from:body -> ermite.go('[data-inbox]');
459460 k-next from:body -> ermite.nextNote();
460461 k-split from:body -> ermite.layout('split');
461462 k-unified from:body -> ermite.layout('unified');
@@ -456,6 +456,7 @@
456 k-r from:body -> ermite.tab('r');456 k-r from:body -> ermite.tab('r');
457 k-m from:body -> ermite.tab('m');457 k-m from:body -> ermite.tab('m');
458 k-i from:body -> ermite.tab('i');458 k-i from:body -> ermite.tab('i');
459+ k-inbox from:body -> ermite.go('[data-inbox]');
459 k-next from:body -> ermite.nextNote();460 k-next from:body -> ermite.nextNote();
460 k-split from:body -> ermite.layout('split');461 k-split from:body -> ermite.layout('split');
461 k-unified from:body -> ermite.layout('unified');462 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commits-root.html +1 -0
@@ -1246,6 +1246,7 @@
12461246 k-r from:body -> ermite.tab('r');
12471247 k-m from:body -> ermite.tab('m');
12481248 k-i from:body -> ermite.tab('i');
1249+ k-inbox from:body -> ermite.go('[data-inbox]');
12491250 k-next from:body -> ermite.nextNote();
12501251 k-split from:body -> ermite.layout('split');
12511252 k-unified from:body -> ermite.layout('unified');
@@ -1246,6 +1246,7 @@
1246 k-r from:body -> ermite.tab('r');1246 k-r from:body -> ermite.tab('r');
1247 k-m from:body -> ermite.tab('m');1247 k-m from:body -> ermite.tab('m');
1248 k-i from:body -> ermite.tab('i');1248 k-i from:body -> ermite.tab('i');
1249+ k-inbox from:body -> ermite.go('[data-inbox]');
1249 k-next from:body -> ermite.nextNote();1250 k-next from:body -> ermite.nextNote();
1250 k-split from:body -> ermite.layout('split');1251 k-split from:body -> ermite.layout('split');
1251 k-unified from:body -> ermite.layout('unified');1252 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commits-small.html +1 -0
@@ -611,6 +611,7 @@
611611 k-r from:body -> ermite.tab('r');
612612 k-m from:body -> ermite.tab('m');
613613 k-i from:body -> ermite.tab('i');
614+ k-inbox from:body -> ermite.go('[data-inbox]');
614615 k-next from:body -> ermite.nextNote();
615616 k-split from:body -> ermite.layout('split');
616617 k-unified from:body -> ermite.layout('unified');
@@ -611,6 +611,7 @@
611 k-r from:body -> ermite.tab('r');611 k-r from:body -> ermite.tab('r');
612 k-m from:body -> ermite.tab('m');612 k-m from:body -> ermite.tab('m');
613 k-i from:body -> ermite.tab('i');613 k-i from:body -> ermite.tab('i');
614+ k-inbox from:body -> ermite.go('[data-inbox]');
614 k-next from:body -> ermite.nextNote();615 k-next from:body -> ermite.nextNote();
615 k-split from:body -> ermite.layout('split');616 k-split from:body -> ermite.layout('split');
616 k-unified from:body -> ermite.layout('unified');617 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/empty.html +1 -0
@@ -284,6 +284,7 @@
284284 k-r from:body -> ermite.tab('r');
285285 k-m from:body -> ermite.tab('m');
286286 k-i from:body -> ermite.tab('i');
287+ k-inbox from:body -> ermite.go('[data-inbox]');
287288 k-next from:body -> ermite.nextNote();
288289 k-split from:body -> ermite.layout('split');
289290 k-unified from:body -> ermite.layout('unified');
@@ -284,6 +284,7 @@
284 k-r from:body -> ermite.tab('r');284 k-r from:body -> ermite.tab('r');
285 k-m from:body -> ermite.tab('m');285 k-m from:body -> ermite.tab('m');
286 k-i from:body -> ermite.tab('i');286 k-i from:body -> ermite.tab('i');
287+ k-inbox from:body -> ermite.go('[data-inbox]');
287 k-next from:body -> ermite.nextNote();288 k-next from:body -> ermite.nextNote();
288 k-split from:body -> ermite.layout('split');289 k-split from:body -> ermite.layout('split');
289 k-unified from:body -> ermite.layout('unified');290 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/home.html +1 -0
@@ -394,6 +394,7 @@
394394 k-r from:body -> ermite.tab('r');
395395 k-m from:body -> ermite.tab('m');
396396 k-i from:body -> ermite.tab('i');
397+ k-inbox from:body -> ermite.go('[data-inbox]');
397398 k-next from:body -> ermite.nextNote();
398399 k-split from:body -> ermite.layout('split');
399400 k-unified from:body -> ermite.layout('unified');
@@ -394,6 +394,7 @@
394 k-r from:body -> ermite.tab('r');394 k-r from:body -> ermite.tab('r');
395 k-m from:body -> ermite.tab('m');395 k-m from:body -> ermite.tab('m');
396 k-i from:body -> ermite.tab('i');396 k-i from:body -> ermite.tab('i');
397+ k-inbox from:body -> ermite.go('[data-inbox]');
397 k-next from:body -> ermite.nextNote();398 k-next from:body -> ermite.nextNote();
398 k-split from:body -> ermite.layout('split');399 k-split from:body -> ermite.layout('split');
399 k-unified from:body -> ermite.layout('unified');400 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/not-found-page.html +1 -0
@@ -267,6 +267,7 @@
267267 k-r from:body -> ermite.tab('r');
268268 k-m from:body -> ermite.tab('m');
269269 k-i from:body -> ermite.tab('i');
270+ k-inbox from:body -> ermite.go('[data-inbox]');
270271 k-next from:body -> ermite.nextNote();
271272 k-split from:body -> ermite.layout('split');
272273 k-unified from:body -> ermite.layout('unified');
@@ -267,6 +267,7 @@
267 k-r from:body -> ermite.tab('r');267 k-r from:body -> ermite.tab('r');
268 k-m from:body -> ermite.tab('m');268 k-m from:body -> ermite.tab('m');
269 k-i from:body -> ermite.tab('i');269 k-i from:body -> ermite.tab('i');
270+ k-inbox from:body -> ermite.go('[data-inbox]');
270 k-next from:body -> ermite.nextNote();271 k-next from:body -> ermite.nextNote();
271 k-split from:body -> ermite.layout('split');272 k-split from:body -> ermite.layout('split');
272 k-unified from:body -> ermite.layout('unified');273 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/not-found.html +1 -0
@@ -267,6 +267,7 @@
267267 k-r from:body -> ermite.tab('r');
268268 k-m from:body -> ermite.tab('m');
269269 k-i from:body -> ermite.tab('i');
270+ k-inbox from:body -> ermite.go('[data-inbox]');
270271 k-next from:body -> ermite.nextNote();
271272 k-split from:body -> ermite.layout('split');
272273 k-unified from:body -> ermite.layout('unified');
@@ -267,6 +267,7 @@
267 k-r from:body -> ermite.tab('r');267 k-r from:body -> ermite.tab('r');
268 k-m from:body -> ermite.tab('m');268 k-m from:body -> ermite.tab('m');
269 k-i from:body -> ermite.tab('i');269 k-i from:body -> ermite.tab('i');
270+ k-inbox from:body -> ermite.go('[data-inbox]');
270 k-next from:body -> ermite.nextNote();271 k-next from:body -> ermite.nextNote();
271 k-split from:body -> ermite.layout('split');272 k-split from:body -> ermite.layout('split');
272 k-unified from:body -> ermite.layout('unified');273 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/refs.html +1 -0
@@ -503,6 +503,7 @@
503503 k-r from:body -> ermite.tab('r');
504504 k-m from:body -> ermite.tab('m');
505505 k-i from:body -> ermite.tab('i');
506+ k-inbox from:body -> ermite.go('[data-inbox]');
506507 k-next from:body -> ermite.nextNote();
507508 k-split from:body -> ermite.layout('split');
508509 k-unified from:body -> ermite.layout('unified');
@@ -503,6 +503,7 @@
503 k-r from:body -> ermite.tab('r');503 k-r from:body -> ermite.tab('r');
504 k-m from:body -> ermite.tab('m');504 k-m from:body -> ermite.tab('m');
505 k-i from:body -> ermite.tab('i');505 k-i from:body -> ermite.tab('i');
506+ k-inbox from:body -> ermite.go('[data-inbox]');
506 k-next from:body -> ermite.nextNote();507 k-next from:body -> ermite.nextNote();
507 k-split from:body -> ermite.layout('split');508 k-split from:body -> ermite.layout('split');
508 k-unified from:body -> ermite.layout('unified');509 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/summary.html +1 -0
@@ -529,6 +529,7 @@
529529 k-r from:body -> ermite.tab('r');
530530 k-m from:body -> ermite.tab('m');
531531 k-i from:body -> ermite.tab('i');
532+ k-inbox from:body -> ermite.go('[data-inbox]');
532533 k-next from:body -> ermite.nextNote();
533534 k-split from:body -> ermite.layout('split');
534535 k-unified from:body -> ermite.layout('unified');
@@ -529,6 +529,7 @@
529 k-r from:body -> ermite.tab('r');529 k-r from:body -> ermite.tab('r');
530 k-m from:body -> ermite.tab('m');530 k-m from:body -> ermite.tab('m');
531 k-i from:body -> ermite.tab('i');531 k-i from:body -> ermite.tab('i');
532+ k-inbox from:body -> ermite.go('[data-inbox]');
532 k-next from:body -> ermite.nextNote();533 k-next from:body -> ermite.nextNote();
533 k-split from:body -> ermite.layout('split');534 k-split from:body -> ermite.layout('split');
534 k-unified from:body -> ermite.layout('unified');535 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/tree-nested.html +1 -0
@@ -443,6 +443,7 @@
443443 k-r from:body -> ermite.tab('r');
444444 k-m from:body -> ermite.tab('m');
445445 k-i from:body -> ermite.tab('i');
446+ k-inbox from:body -> ermite.go('[data-inbox]');
446447 k-next from:body -> ermite.nextNote();
447448 k-split from:body -> ermite.layout('split');
448449 k-unified from:body -> ermite.layout('unified');
@@ -443,6 +443,7 @@
443 k-r from:body -> ermite.tab('r');443 k-r from:body -> ermite.tab('r');
444 k-m from:body -> ermite.tab('m');444 k-m from:body -> ermite.tab('m');
445 k-i from:body -> ermite.tab('i');445 k-i from:body -> ermite.tab('i');
446+ k-inbox from:body -> ermite.go('[data-inbox]');
446 k-next from:body -> ermite.nextNote();447 k-next from:body -> ermite.nextNote();
447 k-split from:body -> ermite.layout('split');448 k-split from:body -> ermite.layout('split');
448 k-unified from:body -> ermite.layout('unified');449 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/tree-root.html +1 -0
@@ -664,6 +664,7 @@
664664 k-r from:body -> ermite.tab('r');
665665 k-m from:body -> ermite.tab('m');
666666 k-i from:body -> ermite.tab('i');
667+ k-inbox from:body -> ermite.go('[data-inbox]');
667668 k-next from:body -> ermite.nextNote();
668669 k-split from:body -> ermite.layout('split');
669670 k-unified from:body -> ermite.layout('unified');
@@ -664,6 +664,7 @@
664 k-r from:body -> ermite.tab('r');664 k-r from:body -> ermite.tab('r');
665 k-m from:body -> ermite.tab('m');665 k-m from:body -> ermite.tab('m');
666 k-i from:body -> ermite.tab('i');666 k-i from:body -> ermite.tab('i');
667+ k-inbox from:body -> ermite.go('[data-inbox]');
667 k-next from:body -> ermite.nextNote();668 k-next from:body -> ermite.nextNote();
668 k-split from:body -> ermite.layout('split');669 k-split from:body -> ermite.layout('split');
669 k-unified from:body -> ermite.layout('unified');670 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/tree-slash-ref.html +1 -0
@@ -440,6 +440,7 @@
440440 k-r from:body -> ermite.tab('r');
441441 k-m from:body -> ermite.tab('m');
442442 k-i from:body -> ermite.tab('i');
443+ k-inbox from:body -> ermite.go('[data-inbox]');
443444 k-next from:body -> ermite.nextNote();
444445 k-split from:body -> ermite.layout('split');
445446 k-unified from:body -> ermite.layout('unified');
@@ -440,6 +440,7 @@
440 k-r from:body -> ermite.tab('r');440 k-r from:body -> ermite.tab('r');
441 k-m from:body -> ermite.tab('m');441 k-m from:body -> ermite.tab('m');
442 k-i from:body -> ermite.tab('i');442 k-i from:body -> ermite.tab('i');
443+ k-inbox from:body -> ermite.go('[data-inbox]');
443 k-next from:body -> ermite.nextNote();444 k-next from:body -> ermite.nextNote();
444 k-split from:body -> ermite.layout('split');445 k-split from:body -> ermite.layout('split');
445 k-unified from:body -> ermite.layout('unified');446 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/tree-subdir.html +1 -0
@@ -456,6 +456,7 @@
456456 k-r from:body -> ermite.tab('r');
457457 k-m from:body -> ermite.tab('m');
458458 k-i from:body -> ermite.tab('i');
459+ k-inbox from:body -> ermite.go('[data-inbox]');
459460 k-next from:body -> ermite.nextNote();
460461 k-split from:body -> ermite.layout('split');
461462 k-unified from:body -> ermite.layout('unified');
@@ -456,6 +456,7 @@
456 k-r from:body -> ermite.tab('r');456 k-r from:body -> ermite.tab('r');
457 k-m from:body -> ermite.tab('m');457 k-m from:body -> ermite.tab('m');
458 k-i from:body -> ermite.tab('i');458 k-i from:body -> ermite.tab('i');
459+ k-inbox from:body -> ermite.go('[data-inbox]');
459 k-next from:body -> ermite.nextNote();460 k-next from:body -> ermite.nextNote();
460 k-split from:body -> ermite.layout('split');461 k-split from:body -> ermite.layout('split');
461 k-unified from:body -> ermite.layout('unified');462 k-unified from:body -> ermite.layout('unified');
M internal/app/web_inbox_test.go +32 -2
@@ -8,6 +8,7 @@ import (
88 "net/http"
99 "strings"
1010 "testing"
11+ "time"
1112
1213 "github.com/stretchr/testify/assert"
1314 "github.com/stretchr/testify/require"
@@ -42,22 +43,51 @@ func TestWebInboxEndToEnd(t *testing.T) {
4243 assert.Contains(t, empty.body, "no open merge requests.")
4344 assert.Contains(t, empty.body, "no open issues.")
4445
46+ // A queued run is visible and its run-level events refresh the owner stream.
47+ draft, err := r.forge.store.CI().CreateRun(r.ctx, r.name("proj"), ".github/workflows/build.yml", func(id, number int64) ports.CIRunDraft {
48+ return ports.CIRunDraft{Run: domain.CIRun{Repo: r.name("proj"), ID: id, Number: number, Attempt: 1, WorkflowName: "Inbox build", Status: domain.CIQueued, Ref: "refs/heads/main", Event: "push", CreatedAt: time.Now()}}
49+ })
50+ require.NoError(t, err)
51+ runs := b.get("/-/inbox", as(token))
52+ assert.Contains(t, runs.body, "Inbox build")
53+ assert.Contains(t, runs.body, "<strong>1</strong> running")
54+ assert.Contains(t, runs.body, `data-inbox href="/-/inbox"`)
55+
4556 // The owner stream sees both repositories.
4657 stream := ownerStream(t, r.harness, token)
4758
59+ r.forge.Bus.Publish(r.ctx, domain.RunUpdated{Run: draft.Run, At: time.Now(), Visibility: domain.VisibilityPublic, CreatedAt: time.Now()})
60+ var runFragments strings.Builder
61+ for range 6 {
62+ runFragments.WriteString(nextFragment(t, stream).data)
63+ }
64+ assert.Contains(t, runFragments.String(), "Inbox build")
65+ // Completion removes the row and updates the total in the same live fragments.
66+ draft.Run.Status, draft.Run.Conclusion = domain.CICompleted, domain.CISuccess
67+ _, err = r.forge.store.CI().Apply(r.ctx, ports.CIWrite{Runs: []domain.CIRun{draft.Run}})
68+ require.NoError(t, err)
69+ r.forge.Bus.Publish(r.ctx, domain.RunUpdated{Run: draft.Run, At: time.Now(), Visibility: domain.VisibilityPublic, CreatedAt: time.Now()})
70+ runFragments.Reset()
71+ for range 6 {
72+ runFragments.WriteString(nextFragment(t, stream).data)
73+ }
74+ assert.NotContains(t, runFragments.String(), "Inbox build")
75+ assert.Contains(t, runFragments.String(), "no runs in progress.")
76+ assert.Contains(t, runFragments.String(), "<strong>0</strong> running")
77+
4878 root := domain.Root()
4979 _, err = r.forge.MergeRequests.Create(r.ctx, root, r.name("proj"), ports.CreateMergeRequestParams{Title: "add search", Source: "feat/search", Target: "main"})
5080 require.NoError(t, err)
5181 ev := nextFragment(t, stream)
5282 assert.Equal(t, "inbox", ev.name)
5383 assert.Contains(t, ev.data, `id="inbox-counts"`)
54- for range 4 { // the other fragments of this event
84+ for range 5 { // the other fragments of this event
5585 nextFragment(t, stream)
5686 }
5787 _, err = r.forge.Issues.Create(r.ctx, root, r.name("other"), ports.CreateIssueParams{Title: "search is slow"})
5888 require.NoError(t, err)
5989 var seen strings.Builder
60- for range 5 { // the five fragments of this event
90+ for range 6 { // the six fragments of this event
6191 seen.WriteString(nextFragment(t, stream).data)
6292 }
6393 assert.Contains(t, seen.String(), "search is slow", "an event of the second repository")
@@ -8,6 +8,7 @@ import (
8 "net/http"8 "net/http"
9 "strings"9 "strings"
10 "testing"10 "testing"
11+ "time"
11 12
12 "github.com/stretchr/testify/assert"13 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
@@ -42,22 +43,51 @@ func TestWebInboxEndToEnd(t *testing.T) {
42 assert.Contains(t, empty.body, "no open merge requests.")43 assert.Contains(t, empty.body, "no open merge requests.")
43 assert.Contains(t, empty.body, "no open issues.")44 assert.Contains(t, empty.body, "no open issues.")
44 45
46+ // A queued run is visible and its run-level events refresh the owner stream.
47+ draft, err := r.forge.store.CI().CreateRun(r.ctx, r.name("proj"), ".github/workflows/build.yml", func(id, number int64) ports.CIRunDraft {
48+ return ports.CIRunDraft{Run: domain.CIRun{Repo: r.name("proj"), ID: id, Number: number, Attempt: 1, WorkflowName: "Inbox build", Status: domain.CIQueued, Ref: "refs/heads/main", Event: "push", CreatedAt: time.Now()}}
49+ })
50+ require.NoError(t, err)
51+ runs := b.get("/-/inbox", as(token))
52+ assert.Contains(t, runs.body, "Inbox build")
53+ assert.Contains(t, runs.body, "<strong>1</strong> running")
54+ assert.Contains(t, runs.body, `data-inbox href="/-/inbox"`)
55+
45 // The owner stream sees both repositories.56 // The owner stream sees both repositories.
46 stream := ownerStream(t, r.harness, token)57 stream := ownerStream(t, r.harness, token)
47 58
59+ r.forge.Bus.Publish(r.ctx, domain.RunUpdated{Run: draft.Run, At: time.Now(), Visibility: domain.VisibilityPublic, CreatedAt: time.Now()})
60+ var runFragments strings.Builder
61+ for range 6 {
62+ runFragments.WriteString(nextFragment(t, stream).data)
63+ }
64+ assert.Contains(t, runFragments.String(), "Inbox build")
65+ // Completion removes the row and updates the total in the same live fragments.
66+ draft.Run.Status, draft.Run.Conclusion = domain.CICompleted, domain.CISuccess
67+ _, err = r.forge.store.CI().Apply(r.ctx, ports.CIWrite{Runs: []domain.CIRun{draft.Run}})
68+ require.NoError(t, err)
69+ r.forge.Bus.Publish(r.ctx, domain.RunUpdated{Run: draft.Run, At: time.Now(), Visibility: domain.VisibilityPublic, CreatedAt: time.Now()})
70+ runFragments.Reset()
71+ for range 6 {
72+ runFragments.WriteString(nextFragment(t, stream).data)
73+ }
74+ assert.NotContains(t, runFragments.String(), "Inbox build")
75+ assert.Contains(t, runFragments.String(), "no runs in progress.")
76+ assert.Contains(t, runFragments.String(), "<strong>0</strong> running")
77+
48 root := domain.Root()78 root := domain.Root()
49 _, err = r.forge.MergeRequests.Create(r.ctx, root, r.name("proj"), ports.CreateMergeRequestParams{Title: "add search", Source: "feat/search", Target: "main"})79 _, err = r.forge.MergeRequests.Create(r.ctx, root, r.name("proj"), ports.CreateMergeRequestParams{Title: "add search", Source: "feat/search", Target: "main"})
50 require.NoError(t, err)80 require.NoError(t, err)
51 ev := nextFragment(t, stream)81 ev := nextFragment(t, stream)
52 assert.Equal(t, "inbox", ev.name)82 assert.Equal(t, "inbox", ev.name)
53 assert.Contains(t, ev.data, `id="inbox-counts"`)83 assert.Contains(t, ev.data, `id="inbox-counts"`)
54- for range 4 { // the other fragments of this event84+ for range 5 { // the other fragments of this event
55 nextFragment(t, stream)85 nextFragment(t, stream)
56 }86 }
57 _, err = r.forge.Issues.Create(r.ctx, root, r.name("other"), ports.CreateIssueParams{Title: "search is slow"})87 _, err = r.forge.Issues.Create(r.ctx, root, r.name("other"), ports.CreateIssueParams{Title: "search is slow"})
58 require.NoError(t, err)88 require.NoError(t, err)
59 var seen strings.Builder89 var seen strings.Builder
60- for range 5 { // the five fragments of this event90+ for range 6 { // the six fragments of this event
61 seen.WriteString(nextFragment(t, stream).data)91 seen.WriteString(nextFragment(t, stream).data)
62 }92 }
63 assert.Contains(t, seen.String(), "search is slow", "an event of the second repository")93 assert.Contains(t, seen.String(), "search is slow", "an event of the second repository")
M internal/ports/dto.go +6 -3
@@ -408,13 +408,16 @@ type LoginLink struct {
408408
409409 // InboxCounts are the numbers of open items across every repository, whatever
410410 // the filter.
411-type InboxCounts struct{ MergeRequests, Issues, Pending int }
411+type InboxCounts struct{ MergeRequests, Issues, Pending, Running int }
412412
413413 // InboxOverview is what the owner has to look at across every repository: the
414-// open merge requests and the open issues, most recently updated first. A
415-// section for the items awaiting moderation can be added next to them.
414+// open merge requests and issues, ongoing CI runs, and issues awaiting moderation.
416415 type InboxOverview struct {
417416 Counts InboxCounts
417+ // Runs are queued or in progress, newest first, respecting the filter.
418+ Runs []domain.CIRun
419+ // RunsEnabled distinguishes disabled CI from an empty queue.
420+ RunsEnabled bool
418421 // MergeRequests and Issues are those of the filtered repository, or of
419422 // every repository when there is no filter. Counts always counts all.
420423 MergeRequests []domain.MergeRequest
@@ -408,13 +408,16 @@ type LoginLink struct {
408 408
409 // InboxCounts are the numbers of open items across every repository, whatever409 // InboxCounts are the numbers of open items across every repository, whatever
410 // the filter.410 // the filter.
411-type InboxCounts struct{ MergeRequests, Issues, Pending int }411+type InboxCounts struct{ MergeRequests, Issues, Pending, Running int }
412 412
413 // InboxOverview is what the owner has to look at across every repository: the413 // InboxOverview is what the owner has to look at across every repository: the
414-// open merge requests and the open issues, most recently updated first. A414+// open merge requests and issues, ongoing CI runs, and issues awaiting moderation.
415-// section for the items awaiting moderation can be added next to them.
416 type InboxOverview struct {415 type InboxOverview struct {
417 Counts InboxCounts416 Counts InboxCounts
417+ // Runs are queued or in progress, newest first, respecting the filter.
418+ Runs []domain.CIRun
419+ // RunsEnabled distinguishes disabled CI from an empty queue.
420+ RunsEnabled bool
418 // MergeRequests and Issues are those of the filtered repository, or of421 // MergeRequests and Issues are those of the filtered repository, or of
419 // every repository when there is no filter. Counts always counts all.422 // every repository when there is no filter. Counts always counts all.
420 MergeRequests []domain.MergeRequest423 MergeRequests []domain.MergeRequest
M internal/usecase/events.go +2 -2
@@ -690,12 +690,12 @@ func isRepoLifecycleEvent(ev domain.Event) bool {
690690 return false
691691 }
692692
693-// isItemEvent reports whether ev is about a merge request or an issue.
693+// isItemEvent reports whether ev changes an inbox item, including CI runs.
694694 func isItemEvent(ev domain.Event) bool {
695695 switch ev.(type) {
696696 case domain.IssueReferenced:
697697 return false // back-reference refreshes do not change the inbox
698- case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,
698+ case domain.RunUpdated, domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,
699699 domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled,
700700 domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded,
701701 domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved:
@@ -690,12 +690,12 @@ func isRepoLifecycleEvent(ev domain.Event) bool {
690 return false690 return false
691 }691 }
692 692
693-// isItemEvent reports whether ev is about a merge request or an issue.693+// isItemEvent reports whether ev changes an inbox item, including CI runs.
694 func isItemEvent(ev domain.Event) bool {694 func isItemEvent(ev domain.Event) bool {
695 switch ev.(type) {695 switch ev.(type) {
696 case domain.IssueReferenced:696 case domain.IssueReferenced:
697 return false // back-reference refreshes do not change the inbox697 return false // back-reference refreshes do not change the inbox
698- case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,698+ case domain.RunUpdated, domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,
699 domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled,699 domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled,
700 domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded,700 domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded,
701 domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved:701 domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved:
M internal/usecase/inbox.go +31 -2
@@ -24,11 +24,12 @@ type Inbox struct {
2424 issues ports.IssueStore
2525 // pending is nil when the public form is not wired.
2626 pending ports.PendingIssueStore
27+ ci ports.CIStore
2728 }
2829
2930 var _ ports.InboxService = (*Inbox)(nil)
3031
31-// InboxConfig holds what the inbox needs. Every field is required.
32+// InboxConfig holds what the inbox needs; Pending and CI are optional.
3233 type InboxConfig struct {
3334 Meta ports.RepoMetaStore
3435 MergeRequests ports.MergeRequestStore
@@ -36,11 +37,12 @@ type InboxConfig struct {
3637 // Pending lists the issues awaiting moderation. Optional: without it the
3738 // inbox has no pending section.
3839 Pending ports.PendingIssueStore
40+ CI ports.CIStore
3941 }
4042
4143 // NewInbox builds the inbox use case.
4244 func NewInbox(cfg InboxConfig) *Inbox {
43- return &Inbox{meta: cfg.Meta, mrs: cfg.MergeRequests, issues: cfg.Issues, pending: cfg.Pending}
45+ return &Inbox{meta: cfg.Meta, mrs: cfg.MergeRequests, issues: cfg.Issues, pending: cfg.Pending, ci: cfg.CI}
4446 }
4547
4648 // Overview lists what is open, through the indexes of open items. The counts
@@ -126,6 +128,33 @@ func (in *Inbox) Overview(ctx context.Context, p domain.Principal, filter domain
126128 out.Counts.Pending = len(out.Pending)
127129 }
128130
131+ if in.ci != nil {
132+ out.RunsEnabled = true
133+ runs, err := in.ci.ListOpenRuns(ctx)
134+ if err != nil {
135+ return out, fmt.Errorf("list open runs: %w", err)
136+ }
137+ for _, run := range runs {
138+ if run.Status != domain.CIQueued && run.Status != domain.CIInProgress {
139+ continue
140+ }
141+ if err := look(run.Repo); err != nil {
142+ return out, err
143+ }
144+ if !exists[run.Repo] {
145+ continue
146+ }
147+ repos[run.Repo] = true
148+ out.Counts.Running++
149+ if filter.IsZero() || run.Repo == filter {
150+ out.Runs = append(out.Runs, run)
151+ }
152+ }
153+ slices.SortStableFunc(out.Runs, func(a, b domain.CIRun) int {
154+ return compareRecent(a.CreatedAt, a.CreatedAt, a.Repo, a.ID, b.CreatedAt, b.CreatedAt, b.Repo, b.ID)
155+ })
156+ }
157+
129158 slices.SortStableFunc(out.MergeRequests, func(a, b domain.MergeRequest) int {
130159 return compareRecent(a.UpdatedAt, a.CreatedAt, a.Repo, a.Number, b.UpdatedAt, b.CreatedAt, b.Repo, b.Number)
131160 })
@@ -24,11 +24,12 @@ type Inbox struct {
24 issues ports.IssueStore24 issues ports.IssueStore
25 // pending is nil when the public form is not wired.25 // pending is nil when the public form is not wired.
26 pending ports.PendingIssueStore26 pending ports.PendingIssueStore
27+ ci ports.CIStore
27 }28 }
28 29
29 var _ ports.InboxService = (*Inbox)(nil)30 var _ ports.InboxService = (*Inbox)(nil)
30 31
31-// InboxConfig holds what the inbox needs. Every field is required.32+// InboxConfig holds what the inbox needs; Pending and CI are optional.
32 type InboxConfig struct {33 type InboxConfig struct {
33 Meta ports.RepoMetaStore34 Meta ports.RepoMetaStore
34 MergeRequests ports.MergeRequestStore35 MergeRequests ports.MergeRequestStore
@@ -36,11 +37,12 @@ type InboxConfig struct {
36 // Pending lists the issues awaiting moderation. Optional: without it the37 // Pending lists the issues awaiting moderation. Optional: without it the
37 // inbox has no pending section.38 // inbox has no pending section.
38 Pending ports.PendingIssueStore39 Pending ports.PendingIssueStore
40+ CI ports.CIStore
39 }41 }
40 42
41 // NewInbox builds the inbox use case.43 // NewInbox builds the inbox use case.
42 func NewInbox(cfg InboxConfig) *Inbox {44 func NewInbox(cfg InboxConfig) *Inbox {
43- return &Inbox{meta: cfg.Meta, mrs: cfg.MergeRequests, issues: cfg.Issues, pending: cfg.Pending}45+ return &Inbox{meta: cfg.Meta, mrs: cfg.MergeRequests, issues: cfg.Issues, pending: cfg.Pending, ci: cfg.CI}
44 }46 }
45 47
46 // Overview lists what is open, through the indexes of open items. The counts48 // Overview lists what is open, through the indexes of open items. The counts
@@ -126,6 +128,33 @@ func (in *Inbox) Overview(ctx context.Context, p domain.Principal, filter domain
126 out.Counts.Pending = len(out.Pending)128 out.Counts.Pending = len(out.Pending)
127 }129 }
128 130
131+ if in.ci != nil {
132+ out.RunsEnabled = true
133+ runs, err := in.ci.ListOpenRuns(ctx)
134+ if err != nil {
135+ return out, fmt.Errorf("list open runs: %w", err)
136+ }
137+ for _, run := range runs {
138+ if run.Status != domain.CIQueued && run.Status != domain.CIInProgress {
139+ continue
140+ }
141+ if err := look(run.Repo); err != nil {
142+ return out, err
143+ }
144+ if !exists[run.Repo] {
145+ continue
146+ }
147+ repos[run.Repo] = true
148+ out.Counts.Running++
149+ if filter.IsZero() || run.Repo == filter {
150+ out.Runs = append(out.Runs, run)
151+ }
152+ }
153+ slices.SortStableFunc(out.Runs, func(a, b domain.CIRun) int {
154+ return compareRecent(a.CreatedAt, a.CreatedAt, a.Repo, a.ID, b.CreatedAt, b.CreatedAt, b.Repo, b.ID)
155+ })
156+ }
157+
129 slices.SortStableFunc(out.MergeRequests, func(a, b domain.MergeRequest) int {158 slices.SortStableFunc(out.MergeRequests, func(a, b domain.MergeRequest) int {
130 return compareRecent(a.UpdatedAt, a.CreatedAt, a.Repo, a.Number, b.UpdatedAt, b.CreatedAt, b.Repo, b.Number)159 return compareRecent(a.UpdatedAt, a.CreatedAt, a.Repo, a.Number, b.UpdatedAt, b.CreatedAt, b.Repo, b.Number)
131 })160 })
M internal/usecase/inbox_test.go +33 -0
@@ -108,3 +108,36 @@ func TestInboxListsPendingIssuesWithoutTheirHash(t *testing.T) {
108108 assert.Nil(t, ov.Pending[0].IPHash)
109109 assert.Equal(t, []domain.RepoName{name(t, "on")}, ov.Repos)
110110 }
111+
112+func TestInboxRuns(t *testing.T) {
113+ t.Parallel()
114+ mrs, issues, meta, ci := mocks.NewMergeRequestStore(t), mocks.NewIssueStore(t), mocks.NewRepoMetaStore(t), mocks.NewCIStore(t)
115+ mrs.EXPECT().ListOpenAll(mock.Anything).Return(nil, nil)
116+ issues.EXPECT().ListOpenAll(mock.Anything).Return(nil, nil)
117+ now := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
118+ alpha, beta := name(t, "alpha"), name(t, "beta")
119+ ci.EXPECT().ListOpenRuns(mock.Anything).Return([]domain.CIRun{
120+ {Repo: alpha, ID: 1, Status: domain.CIQueued, CreatedAt: now},
121+ {Repo: beta, ID: 2, Status: domain.CIInProgress, CreatedAt: now.Add(time.Hour)},
122+ {Repo: alpha, ID: 3, Status: domain.CICompleted, CreatedAt: now.Add(2 * time.Hour)},
123+ {Repo: name(t, "gone"), ID: 4, Status: domain.CIQueued, CreatedAt: now},
124+ }, nil)
125+ for _, n := range []string{"alpha", "beta"} {
126+ meta.EXPECT().Get(mock.Anything, name(t, n)).Return(repoWithIssues(t, n, domain.IssuesOff), nil)
127+ }
128+ meta.EXPECT().Get(mock.Anything, name(t, "gone")).Return(domain.Repo{}, domain.ErrNotFound)
129+ in := usecase.NewInbox(usecase.InboxConfig{Meta: meta, MergeRequests: mrs, Issues: issues, CI: ci})
130+ all, err := in.Overview(t.Context(), domain.Owner(), domain.RepoName{})
131+ require.NoError(t, err)
132+ assert.True(t, all.RunsEnabled)
133+ assert.Equal(t, 2, all.Counts.Running)
134+ require.Len(t, all.Runs, 2)
135+ assert.Equal(t, int64(2), all.Runs[0].ID)
136+ assert.Equal(t, int64(1), all.Runs[1].ID)
137+ assert.Equal(t, []domain.RepoName{alpha, beta}, all.Repos)
138+ filtered, err := in.Overview(t.Context(), domain.Owner(), alpha)
139+ require.NoError(t, err)
140+ assert.Equal(t, 2, filtered.Counts.Running)
141+ require.Len(t, filtered.Runs, 1)
142+ assert.Equal(t, alpha, filtered.Runs[0].Repo)
143+}
@@ -108,3 +108,36 @@ func TestInboxListsPendingIssuesWithoutTheirHash(t *testing.T) {
108 assert.Nil(t, ov.Pending[0].IPHash)108 assert.Nil(t, ov.Pending[0].IPHash)
109 assert.Equal(t, []domain.RepoName{name(t, "on")}, ov.Repos)109 assert.Equal(t, []domain.RepoName{name(t, "on")}, ov.Repos)
110 }110 }
111+
112+func TestInboxRuns(t *testing.T) {
113+ t.Parallel()
114+ mrs, issues, meta, ci := mocks.NewMergeRequestStore(t), mocks.NewIssueStore(t), mocks.NewRepoMetaStore(t), mocks.NewCIStore(t)
115+ mrs.EXPECT().ListOpenAll(mock.Anything).Return(nil, nil)
116+ issues.EXPECT().ListOpenAll(mock.Anything).Return(nil, nil)
117+ now := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
118+ alpha, beta := name(t, "alpha"), name(t, "beta")
119+ ci.EXPECT().ListOpenRuns(mock.Anything).Return([]domain.CIRun{
120+ {Repo: alpha, ID: 1, Status: domain.CIQueued, CreatedAt: now},
121+ {Repo: beta, ID: 2, Status: domain.CIInProgress, CreatedAt: now.Add(time.Hour)},
122+ {Repo: alpha, ID: 3, Status: domain.CICompleted, CreatedAt: now.Add(2 * time.Hour)},
123+ {Repo: name(t, "gone"), ID: 4, Status: domain.CIQueued, CreatedAt: now},
124+ }, nil)
125+ for _, n := range []string{"alpha", "beta"} {
126+ meta.EXPECT().Get(mock.Anything, name(t, n)).Return(repoWithIssues(t, n, domain.IssuesOff), nil)
127+ }
128+ meta.EXPECT().Get(mock.Anything, name(t, "gone")).Return(domain.Repo{}, domain.ErrNotFound)
129+ in := usecase.NewInbox(usecase.InboxConfig{Meta: meta, MergeRequests: mrs, Issues: issues, CI: ci})
130+ all, err := in.Overview(t.Context(), domain.Owner(), domain.RepoName{})
131+ require.NoError(t, err)
132+ assert.True(t, all.RunsEnabled)
133+ assert.Equal(t, 2, all.Counts.Running)
134+ require.Len(t, all.Runs, 2)
135+ assert.Equal(t, int64(2), all.Runs[0].ID)
136+ assert.Equal(t, int64(1), all.Runs[1].ID)
137+ assert.Equal(t, []domain.RepoName{alpha, beta}, all.Repos)
138+ filtered, err := in.Overview(t.Context(), domain.Owner(), alpha)
139+ require.NoError(t, err)
140+ assert.Equal(t, 2, filtered.Counts.Running)
141+ require.Len(t, filtered.Runs, 1)
142+ assert.Equal(t, alpha, filtered.Runs[0].Repo)
143+}

Keyboard shortcuts

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

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

Branches 2

Tags 42

↑↓ moveenter openesc close