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
@@ -51,17 +51,27 @@ func OverlayHelp(mr bool) templ.Component {
5151 if templ_7745c5c3_Err != nil {
5252 return templ_7745c5c3_Err
5353 }
54- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " branch or tag</dd></div><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><div><dt><kbd>g h</kbd></dt><dd>home</dd></div><div><dt><kbd>w</kbd></dt><dd>wide mode: use the whole width of the screen, or not (remembered)</dd></div><div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div><div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div><div><dt><kbd>e</kbd> <kbd>E</kbd></dt><dd>diff: the whole file in view / every file, with the lines the hunks hide</dd></div><div><dt><kbd>n</kbd></dt><dd>merge request: next note in the diff</dd></div>")
54+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " branch or tag</dd></div><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>")
55+ if templ_7745c5c3_Err != nil {
56+ return templ_7745c5c3_Err
57+ }
58+ if SignedIn(ctx) {
59+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div><dt><kbd>I</kbd></dt><dd>inbox</dd></div>")
60+ if templ_7745c5c3_Err != nil {
61+ return templ_7745c5c3_Err
62+ }
63+ }
64+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div><dt><kbd>g h</kbd></dt><dd>home</dd></div><div><dt><kbd>w</kbd></dt><dd>wide mode: use the whole width of the screen, or not (remembered)</dd></div><div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div><div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div><div><dt><kbd>e</kbd> <kbd>E</kbd></dt><dd>diff: the whole file in view / every file, with the lines the hunks hide</dd></div><div><dt><kbd>n</kbd></dt><dd>merge request: next note in the diff</dd></div>")
5565 if templ_7745c5c3_Err != nil {
5666 return templ_7745c5c3_Err
5767 }
5868 if mr {
59- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div><dt><kbd>g f</kbd></dt><dd>merge request: the list of files (j k move, enter opens, esc back)</dd></div><div><dt><kbd>M</kbd></dt><dd>merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges</dd></div><div><dt><kbd>R</kbd></dt><dd>merge request: mark the head read</dd></div>")
69+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div><dt><kbd>g f</kbd></dt><dd>merge request: the list of files (j k move, enter opens, esc back)</dd></div><div><dt><kbd>M</kbd></dt><dd>merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges</dd></div><div><dt><kbd>R</kbd></dt><dd>merge request: mark the head read</dd></div>")
6070 if templ_7745c5c3_Err != nil {
6171 return templ_7745c5c3_Err
6272 }
6373 }
64- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div><dt><kbd>esc</kbd></dt><dd>close a dialog, leave a field</dd></div></dl></dialog>")
74+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div><dt><kbd>esc</kbd></dt><dd>close a dialog, leave a field</dd></div></dl></dialog>")
6575 if templ_7745c5c3_Err != nil {
6676 return templ_7745c5c3_Err
6777 }
@@ -92,20 +102,20 @@ func OverlayRefs(l Layout) templ.Component {
92102 }
93103 ctx = templ.ClearChildren(ctx)
94104 if l.Ref != "" {
95- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<dialog id=\"panel-refs\" class=\"panel modal\" aria-label=\"Switch ref\" hx-on:keydown=\"event.key === 'Escape' && (event.preventDefault(), this.close())\" hx-on:click=\"event.target === this && this.close()\"><div class=\"panel-head\"><label for=\"refs-filter\" class=\"sr\">filter branches and tags</label> <input id=\"refs-filter\" type=\"search\" placeholder=\"Switch branch or tag…\" autocomplete=\"off\" data-pick=\"#panel-refs li\" hx-on:input=\"ermite.filter(this)\"> <button type=\"button\" class=\"panel-x\" aria-label=\"Close\" hx-on:click=\"closest('dialog').close()\"><kbd>esc</kbd></button></div><div class=\"panel-cols\" data-picklist><section aria-label=\"Branches\"><h2>Branches <span class=\"count\">")
105+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<dialog id=\"panel-refs\" class=\"panel modal\" aria-label=\"Switch ref\" hx-on:keydown=\"event.key === 'Escape' && (event.preventDefault(), this.close())\" hx-on:click=\"event.target === this && this.close()\"><div class=\"panel-head\"><label for=\"refs-filter\" class=\"sr\">filter branches and tags</label> <input id=\"refs-filter\" type=\"search\" placeholder=\"Switch branch or tag…\" autocomplete=\"off\" data-pick=\"#panel-refs li\" hx-on:input=\"ermite.filter(this)\"> <button type=\"button\" class=\"panel-x\" aria-label=\"Close\" hx-on:click=\"closest('dialog').close()\"><kbd>esc</kbd></button></div><div class=\"panel-cols\" data-picklist><section aria-label=\"Branches\"><h2>Branches <span class=\"count\">")
96106 if templ_7745c5c3_Err != nil {
97107 return templ_7745c5c3_Err
98108 }
99109 var templ_7745c5c3_Var4 string
100110 templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(l.Heads)))
101111 if templ_7745c5c3_Err != nil {
102- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 64, Col: 58}
112+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 67, Col: 58}
103113 }
104114 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
105115 if templ_7745c5c3_Err != nil {
106116 return templ_7745c5c3_Err
107117 }
108- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</span></h2><ul>")
118+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span></h2><ul>")
109119 if templ_7745c5c3_Err != nil {
110120 return templ_7745c5c3_Err
111121 }
@@ -115,20 +125,20 @@ func OverlayRefs(l Layout) templ.Component {
115125 return templ_7745c5c3_Err
116126 }
117127 }
118- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</ul></section><section aria-label=\"Tags\"><h2>Tags <span class=\"count\">")
128+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</ul></section><section aria-label=\"Tags\"><h2>Tags <span class=\"count\">")
119129 if templ_7745c5c3_Err != nil {
120130 return templ_7745c5c3_Err
121131 }
122132 var templ_7745c5c3_Var5 string
123133 templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(l.Tags)))
124134 if templ_7745c5c3_Err != nil {
125- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 72, Col: 53}
135+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 75, Col: 53}
126136 }
127137 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
128138 if templ_7745c5c3_Err != nil {
129139 return templ_7745c5c3_Err
130140 }
131- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span></h2><ul>")
141+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</span></h2><ul>")
132142 if templ_7745c5c3_Err != nil {
133143 return templ_7745c5c3_Err
134144 }
@@ -138,7 +148,7 @@ func OverlayRefs(l Layout) templ.Component {
138148 return templ_7745c5c3_Err
139149 }
140150 }
141- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</ul></section></div></dialog>")
151+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</ul></section></div></dialog>")
142152 if templ_7745c5c3_Err != nil {
143153 return templ_7745c5c3_Err
144154 }
@@ -168,58 +178,58 @@ func refItem(r RefItem) templ.Component {
168178 templ_7745c5c3_Var6 = templ.NopComponent
169179 }
170180 ctx = templ.ClearChildren(ctx)
171- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<li><a href=\"")
181+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<li><a href=\"")
172182 if templ_7745c5c3_Err != nil {
173183 return templ_7745c5c3_Err
174184 }
175185 var templ_7745c5c3_Var7 templ.SafeURL
176186 templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
177187 if templ_7745c5c3_Err != nil {
178- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 87, Col: 31}
188+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 90, Col: 31}
179189 }
180190 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
181191 if templ_7745c5c3_Err != nil {
182192 return templ_7745c5c3_Err
183193 }
184- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\"")
194+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\"")
185195 if templ_7745c5c3_Err != nil {
186196 return templ_7745c5c3_Err
187197 }
188198 if r.Current {
189- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " aria-current=\"true\"")
199+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, " aria-current=\"true\"")
190200 if templ_7745c5c3_Err != nil {
191201 return templ_7745c5c3_Err
192202 }
193203 }
194- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "><span class=\"mark\" aria-hidden=\"true\">")
204+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "><span class=\"mark\" aria-hidden=\"true\">")
195205 if templ_7745c5c3_Err != nil {
196206 return templ_7745c5c3_Err
197207 }
198208 if r.Current {
199- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "●")
209+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "●")
200210 if templ_7745c5c3_Err != nil {
201211 return templ_7745c5c3_Err
202212 }
203213 } else {
204- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "&nbsp;")
214+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "&nbsp;")
205215 if templ_7745c5c3_Err != nil {
206216 return templ_7745c5c3_Err
207217 }
208218 }
209- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</span>")
219+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</span>")
210220 if templ_7745c5c3_Err != nil {
211221 return templ_7745c5c3_Err
212222 }
213223 var templ_7745c5c3_Var8 string
214224 templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
215225 if templ_7745c5c3_Err != nil {
216- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 98, Col: 18}
226+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 101, Col: 18}
217227 }
218228 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
219229 if templ_7745c5c3_Err != nil {
220230 return templ_7745c5c3_Err
221231 }
222- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</a></li>")
232+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</a></li>")
223233 if templ_7745c5c3_Err != nil {
224234 return templ_7745c5c3_Err
225235 }
@@ -250,118 +260,118 @@ func OverlayCmd(items []CmdItem) templ.Component {
250260 templ_7745c5c3_Var9 = templ.NopComponent
251261 }
252262 ctx = templ.ClearChildren(ctx)
253- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<dialog id=\"panel-cmd\" class=\"panel modal\" aria-label=\"Go to\" hx-on:close=\"ermiteFinder.paletteClose()\" hx-on:keydown=\"event.key === 'Escape' && (event.preventDefault(), this.close())\" hx-on:click=\"event.target === this && this.close()\"><div class=\"panel-head\"><label for=\"cmd-q\" class=\"sr\">go to</label> <input id=\"cmd-q\" type=\"search\" placeholder=\"Go to a section, branch, tag, file or repository…\" autocomplete=\"off\" spellcheck=\"false\" role=\"combobox\" aria-autocomplete=\"list\" aria-expanded=\"true\" aria-controls=\"cmd-list\" aria-activedescendant=\"cmd-o0\" hx-on:input=\"ermite.cmdFilter(this); ermiteFinder.palette(this)\" hx-on:keydown=\"ermite.cmdKey(event)\"> <button type=\"button\" class=\"panel-x\" aria-label=\"Close\" hx-on:click=\"closest('dialog').close()\"><kbd>esc</kbd></button></div><ul id=\"cmd-list\" class=\"cmd-list\" role=\"listbox\" aria-label=\"Targets\">")
263+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<dialog id=\"panel-cmd\" class=\"panel modal\" aria-label=\"Go to\" hx-on:close=\"ermiteFinder.paletteClose()\" hx-on:keydown=\"event.key === 'Escape' && (event.preventDefault(), this.close())\" hx-on:click=\"event.target === this && this.close()\"><div class=\"panel-head\"><label for=\"cmd-q\" class=\"sr\">go to</label> <input id=\"cmd-q\" type=\"search\" placeholder=\"Go to a section, branch, tag, file or repository…\" autocomplete=\"off\" spellcheck=\"false\" role=\"combobox\" aria-autocomplete=\"list\" aria-expanded=\"true\" aria-controls=\"cmd-list\" aria-activedescendant=\"cmd-o0\" hx-on:input=\"ermite.cmdFilter(this); ermiteFinder.palette(this)\" hx-on:keydown=\"ermite.cmdKey(event)\"> <button type=\"button\" class=\"panel-x\" aria-label=\"Close\" hx-on:click=\"closest('dialog').close()\"><kbd>esc</kbd></button></div><ul id=\"cmd-list\" class=\"cmd-list\" role=\"listbox\" aria-label=\"Targets\">")
254264 if templ_7745c5c3_Err != nil {
255265 return templ_7745c5c3_Err
256266 }
257267 for i, c := range items {
258- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<li id=\"")
268+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<li id=\"")
259269 if templ_7745c5c3_Err != nil {
260270 return templ_7745c5c3_Err
261271 }
262272 var templ_7745c5c3_Var10 string
263273 templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("cmd-o" + itoa(i))
264274 if templ_7745c5c3_Err != nil {
265- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 127, Col: 30}
275+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 130, Col: 30}
266276 }
267277 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
268278 if templ_7745c5c3_Err != nil {
269279 return templ_7745c5c3_Err
270280 }
271- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" role=\"option\" aria-selected=\"")
281+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" role=\"option\" aria-selected=\"")
272282 if templ_7745c5c3_Err != nil {
273283 return templ_7745c5c3_Err
274284 }
275285 var templ_7745c5c3_Var11 string
276286 templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(strconv.FormatBool(i == 0))
277287 if templ_7745c5c3_Err != nil {
278- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 127, Col: 89}
288+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 130, Col: 89}
279289 }
280290 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
281291 if templ_7745c5c3_Err != nil {
282292 return templ_7745c5c3_Err
283293 }
284- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" data-k=\"")
294+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" data-k=\"")
285295 if templ_7745c5c3_Err != nil {
286296 return templ_7745c5c3_Err
287297 }
288298 var templ_7745c5c3_Var12 string
289299 templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(strings.ToLower(c.Kind + " " + c.Label))
290300 if templ_7745c5c3_Err != nil {
291- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 127, Col: 140}
301+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 130, Col: 140}
292302 }
293303 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
294304 if templ_7745c5c3_Err != nil {
295305 return templ_7745c5c3_Err
296306 }
297- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\"")
307+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\"")
298308 if templ_7745c5c3_Err != nil {
299309 return templ_7745c5c3_Err
300310 }
301311 if i == 0 {
302- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, " data-cursor")
312+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " data-cursor")
303313 if templ_7745c5c3_Err != nil {
304314 return templ_7745c5c3_Err
305315 }
306316 }
307- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "><a href=\"")
317+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "><a href=\"")
308318 if templ_7745c5c3_Err != nil {
309319 return templ_7745c5c3_Err
310320 }
311321 var templ_7745c5c3_Var13 templ.SafeURL
312322 templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
313323 if templ_7745c5c3_Err != nil {
314- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 127, Col: 198}
324+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 130, Col: 198}
315325 }
316326 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
317327 if templ_7745c5c3_Err != nil {
318328 return templ_7745c5c3_Err
319329 }
320- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" tabindex=\"-1\"><span class=\"lbl\">")
330+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" tabindex=\"-1\"><span class=\"lbl\">")
321331 if templ_7745c5c3_Err != nil {
322332 return templ_7745c5c3_Err
323333 }
324334 var templ_7745c5c3_Var14 string
325335 templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
326336 if templ_7745c5c3_Err != nil {
327- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 127, Col: 242}
337+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 130, Col: 242}
328338 }
329339 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
330340 if templ_7745c5c3_Err != nil {
331341 return templ_7745c5c3_Err
332342 }
333- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</span><span class=\"k\">")
343+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</span><span class=\"k\">")
334344 if templ_7745c5c3_Err != nil {
335345 return templ_7745c5c3_Err
336346 }
337347 var templ_7745c5c3_Var15 string
338348 templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(c.Kind)
339349 if templ_7745c5c3_Err != nil {
340- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 127, Col: 275}
350+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 130, Col: 275}
341351 }
342352 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
343353 if templ_7745c5c3_Err != nil {
344354 return templ_7745c5c3_Err
345355 }
346- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</span></a></li>")
356+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</span></a></li>")
347357 if templ_7745c5c3_Err != nil {
348358 return templ_7745c5c3_Err
349359 }
350360 }
351- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<li id=\"")
361+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<li id=\"")
352362 if templ_7745c5c3_Err != nil {
353363 return templ_7745c5c3_Err
354364 }
355365 var templ_7745c5c3_Var16 string
356366 templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("cmd-o" + itoa(len(items)))
357367 if templ_7745c5c3_Err != nil {
358- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 129, Col: 38}
368+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 132, Col: 38}
359369 }
360370 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
361371 if templ_7745c5c3_Err != nil {
362372 return templ_7745c5c3_Err
363373 }
364- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" role=\"option\" aria-selected=\"false\" data-k=\"action turn single-key shortcuts on off keyboard\"><a href=\"#\" data-fire=\"keys\" tabindex=\"-1\"><span class=\"lbl\">turn single-key shortcuts off</span><span class=\"k\">action</span></a></li></ul><p class=\"panel-foot\"><span><kbd>↑</kbd><kbd>↓</kbd> move</span><span><kbd>enter</kbd> open</span><span><kbd>esc</kbd> close</span></p></dialog>")
374+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" role=\"option\" aria-selected=\"false\" data-k=\"action turn single-key shortcuts on off keyboard\"><a href=\"#\" data-fire=\"keys\" tabindex=\"-1\"><span class=\"lbl\">turn single-key shortcuts off</span><span class=\"k\">action</span></a></li></ul><p class=\"panel-foot\"><span><kbd>↑</kbd><kbd>↓</kbd> move</span><span><kbd>enter</kbd> open</span><span><kbd>esc</kbd> close</span></p></dialog>")
365375 if templ_7745c5c3_Err != nil {
366376 return templ_7745c5c3_Err
367377 }
@@ -51,17 +51,27 @@ func OverlayHelp(mr bool) templ.Component {
51 if templ_7745c5c3_Err != nil {51 if templ_7745c5c3_Err != nil {
52 return templ_7745c5c3_Err52 return templ_7745c5c3_Err
53 }53 }
54- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " branch or tag</dd></div><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><div><dt><kbd>g h</kbd></dt><dd>home</dd></div><div><dt><kbd>w</kbd></dt><dd>wide mode: use the whole width of the screen, or not (remembered)</dd></div><div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div><div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div><div><dt><kbd>e</kbd> <kbd>E</kbd></dt><dd>diff: the whole file in view / every file, with the lines the hunks hide</dd></div><div><dt><kbd>n</kbd></dt><dd>merge request: next note in the diff</dd></div>")54+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " branch or tag</dd></div><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>")
55+ if templ_7745c5c3_Err != nil {
56+ return templ_7745c5c3_Err
57+ }
58+ if SignedIn(ctx) {
59+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div><dt><kbd>I</kbd></dt><dd>inbox</dd></div>")
60+ if templ_7745c5c3_Err != nil {
61+ return templ_7745c5c3_Err
62+ }
63+ }
64+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div><dt><kbd>g h</kbd></dt><dd>home</dd></div><div><dt><kbd>w</kbd></dt><dd>wide mode: use the whole width of the screen, or not (remembered)</dd></div><div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div><div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div><div><dt><kbd>e</kbd> <kbd>E</kbd></dt><dd>diff: the whole file in view / every file, with the lines the hunks hide</dd></div><div><dt><kbd>n</kbd></dt><dd>merge request: next note in the diff</dd></div>")
55 if templ_7745c5c3_Err != nil {65 if templ_7745c5c3_Err != nil {
56 return templ_7745c5c3_Err66 return templ_7745c5c3_Err
57 }67 }
58 if mr {68 if mr {
59- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div><dt><kbd>g f</kbd></dt><dd>merge request: the list of files (j k move, enter opens, esc back)</dd></div><div><dt><kbd>M</kbd></dt><dd>merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges</dd></div><div><dt><kbd>R</kbd></dt><dd>merge request: mark the head read</dd></div>")69+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div><dt><kbd>g f</kbd></dt><dd>merge request: the list of files (j k move, enter opens, esc back)</dd></div><div><dt><kbd>M</kbd></dt><dd>merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges</dd></div><div><dt><kbd>R</kbd></dt><dd>merge request: mark the head read</dd></div>")
60 if templ_7745c5c3_Err != nil {70 if templ_7745c5c3_Err != nil {
61 return templ_7745c5c3_Err71 return templ_7745c5c3_Err
62 }72 }
63 }73 }
64- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div><dt><kbd>esc</kbd></dt><dd>close a dialog, leave a field</dd></div></dl></dialog>")74+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div><dt><kbd>esc</kbd></dt><dd>close a dialog, leave a field</dd></div></dl></dialog>")
65 if templ_7745c5c3_Err != nil {75 if templ_7745c5c3_Err != nil {
66 return templ_7745c5c3_Err76 return templ_7745c5c3_Err
67 }77 }
@@ -92,20 +102,20 @@ func OverlayRefs(l Layout) templ.Component {
92 }102 }
93 ctx = templ.ClearChildren(ctx)103 ctx = templ.ClearChildren(ctx)
94 if l.Ref != "" {104 if l.Ref != "" {
95- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<dialog id=\"panel-refs\" class=\"panel modal\" aria-label=\"Switch ref\" hx-on:keydown=\"event.key === 'Escape' && (event.preventDefault(), this.close())\" hx-on:click=\"event.target === this && this.close()\"><div class=\"panel-head\"><label for=\"refs-filter\" class=\"sr\">filter branches and tags</label> <input id=\"refs-filter\" type=\"search\" placeholder=\"Switch branch or tag…\" autocomplete=\"off\" data-pick=\"#panel-refs li\" hx-on:input=\"ermite.filter(this)\"> <button type=\"button\" class=\"panel-x\" aria-label=\"Close\" hx-on:click=\"closest('dialog').close()\"><kbd>esc</kbd></button></div><div class=\"panel-cols\" data-picklist><section aria-label=\"Branches\"><h2>Branches <span class=\"count\">")105+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<dialog id=\"panel-refs\" class=\"panel modal\" aria-label=\"Switch ref\" hx-on:keydown=\"event.key === 'Escape' && (event.preventDefault(), this.close())\" hx-on:click=\"event.target === this && this.close()\"><div class=\"panel-head\"><label for=\"refs-filter\" class=\"sr\">filter branches and tags</label> <input id=\"refs-filter\" type=\"search\" placeholder=\"Switch branch or tag…\" autocomplete=\"off\" data-pick=\"#panel-refs li\" hx-on:input=\"ermite.filter(this)\"> <button type=\"button\" class=\"panel-x\" aria-label=\"Close\" hx-on:click=\"closest('dialog').close()\"><kbd>esc</kbd></button></div><div class=\"panel-cols\" data-picklist><section aria-label=\"Branches\"><h2>Branches <span class=\"count\">")
96 if templ_7745c5c3_Err != nil {106 if templ_7745c5c3_Err != nil {
97 return templ_7745c5c3_Err107 return templ_7745c5c3_Err
98 }108 }
99 var templ_7745c5c3_Var4 string109 var templ_7745c5c3_Var4 string
100 templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(l.Heads)))110 templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(l.Heads)))
101 if templ_7745c5c3_Err != nil {111 if templ_7745c5c3_Err != nil {
102- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 64, Col: 58}112+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 67, Col: 58}
103 }113 }
104 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))114 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
105 if templ_7745c5c3_Err != nil {115 if templ_7745c5c3_Err != nil {
106 return templ_7745c5c3_Err116 return templ_7745c5c3_Err
107 }117 }
108- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</span></h2><ul>")118+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span></h2><ul>")
109 if templ_7745c5c3_Err != nil {119 if templ_7745c5c3_Err != nil {
110 return templ_7745c5c3_Err120 return templ_7745c5c3_Err
111 }121 }
@@ -115,20 +125,20 @@ func OverlayRefs(l Layout) templ.Component {
115 return templ_7745c5c3_Err125 return templ_7745c5c3_Err
116 }126 }
117 }127 }
118- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</ul></section><section aria-label=\"Tags\"><h2>Tags <span class=\"count\">")128+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</ul></section><section aria-label=\"Tags\"><h2>Tags <span class=\"count\">")
119 if templ_7745c5c3_Err != nil {129 if templ_7745c5c3_Err != nil {
120 return templ_7745c5c3_Err130 return templ_7745c5c3_Err
121 }131 }
122 var templ_7745c5c3_Var5 string132 var templ_7745c5c3_Var5 string
123 templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(l.Tags)))133 templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(l.Tags)))
124 if templ_7745c5c3_Err != nil {134 if templ_7745c5c3_Err != nil {
125- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 72, Col: 53}135+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 75, Col: 53}
126 }136 }
127 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))137 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
128 if templ_7745c5c3_Err != nil {138 if templ_7745c5c3_Err != nil {
129 return templ_7745c5c3_Err139 return templ_7745c5c3_Err
130 }140 }
131- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span></h2><ul>")141+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</span></h2><ul>")
132 if templ_7745c5c3_Err != nil {142 if templ_7745c5c3_Err != nil {
133 return templ_7745c5c3_Err143 return templ_7745c5c3_Err
134 }144 }
@@ -138,7 +148,7 @@ func OverlayRefs(l Layout) templ.Component {
138 return templ_7745c5c3_Err148 return templ_7745c5c3_Err
139 }149 }
140 }150 }
141- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</ul></section></div></dialog>")151+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</ul></section></div></dialog>")
142 if templ_7745c5c3_Err != nil {152 if templ_7745c5c3_Err != nil {
143 return templ_7745c5c3_Err153 return templ_7745c5c3_Err
144 }154 }
@@ -168,58 +178,58 @@ func refItem(r RefItem) templ.Component {
168 templ_7745c5c3_Var6 = templ.NopComponent178 templ_7745c5c3_Var6 = templ.NopComponent
169 }179 }
170 ctx = templ.ClearChildren(ctx)180 ctx = templ.ClearChildren(ctx)
171- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<li><a href=\"")181+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<li><a href=\"")
172 if templ_7745c5c3_Err != nil {182 if templ_7745c5c3_Err != nil {
173 return templ_7745c5c3_Err183 return templ_7745c5c3_Err
174 }184 }
175 var templ_7745c5c3_Var7 templ.SafeURL185 var templ_7745c5c3_Var7 templ.SafeURL
176 templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))186 templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
177 if templ_7745c5c3_Err != nil {187 if templ_7745c5c3_Err != nil {
178- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 87, Col: 31}188+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 90, Col: 31}
179 }189 }
180 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))190 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
181 if templ_7745c5c3_Err != nil {191 if templ_7745c5c3_Err != nil {
182 return templ_7745c5c3_Err192 return templ_7745c5c3_Err
183 }193 }
184- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\"")194+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\"")
185 if templ_7745c5c3_Err != nil {195 if templ_7745c5c3_Err != nil {
186 return templ_7745c5c3_Err196 return templ_7745c5c3_Err
187 }197 }
188 if r.Current {198 if r.Current {
189- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " aria-current=\"true\"")199+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, " aria-current=\"true\"")
190 if templ_7745c5c3_Err != nil {200 if templ_7745c5c3_Err != nil {
191 return templ_7745c5c3_Err201 return templ_7745c5c3_Err
192 }202 }
193 }203 }
194- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "><span class=\"mark\" aria-hidden=\"true\">")204+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "><span class=\"mark\" aria-hidden=\"true\">")
195 if templ_7745c5c3_Err != nil {205 if templ_7745c5c3_Err != nil {
196 return templ_7745c5c3_Err206 return templ_7745c5c3_Err
197 }207 }
198 if r.Current {208 if r.Current {
199- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "●")209+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "●")
200 if templ_7745c5c3_Err != nil {210 if templ_7745c5c3_Err != nil {
201 return templ_7745c5c3_Err211 return templ_7745c5c3_Err
202 }212 }
203 } else {213 } else {
204- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "&nbsp;")214+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "&nbsp;")
205 if templ_7745c5c3_Err != nil {215 if templ_7745c5c3_Err != nil {
206 return templ_7745c5c3_Err216 return templ_7745c5c3_Err
207 }217 }
208 }218 }
209- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</span>")219+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</span>")
210 if templ_7745c5c3_Err != nil {220 if templ_7745c5c3_Err != nil {
211 return templ_7745c5c3_Err221 return templ_7745c5c3_Err
212 }222 }
213 var templ_7745c5c3_Var8 string223 var templ_7745c5c3_Var8 string
214 templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)224 templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
215 if templ_7745c5c3_Err != nil {225 if templ_7745c5c3_Err != nil {
216- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 98, Col: 18}226+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 101, Col: 18}
217 }227 }
218 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))228 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
219 if templ_7745c5c3_Err != nil {229 if templ_7745c5c3_Err != nil {
220 return templ_7745c5c3_Err230 return templ_7745c5c3_Err
221 }231 }
222- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</a></li>")232+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</a></li>")
223 if templ_7745c5c3_Err != nil {233 if templ_7745c5c3_Err != nil {
224 return templ_7745c5c3_Err234 return templ_7745c5c3_Err
225 }235 }
@@ -250,118 +260,118 @@ func OverlayCmd(items []CmdItem) templ.Component {
250 templ_7745c5c3_Var9 = templ.NopComponent260 templ_7745c5c3_Var9 = templ.NopComponent
251 }261 }
252 ctx = templ.ClearChildren(ctx)262 ctx = templ.ClearChildren(ctx)
253- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<dialog id=\"panel-cmd\" class=\"panel modal\" aria-label=\"Go to\" hx-on:close=\"ermiteFinder.paletteClose()\" hx-on:keydown=\"event.key === 'Escape' && (event.preventDefault(), this.close())\" hx-on:click=\"event.target === this && this.close()\"><div class=\"panel-head\"><label for=\"cmd-q\" class=\"sr\">go to</label> <input id=\"cmd-q\" type=\"search\" placeholder=\"Go to a section, branch, tag, file or repository…\" autocomplete=\"off\" spellcheck=\"false\" role=\"combobox\" aria-autocomplete=\"list\" aria-expanded=\"true\" aria-controls=\"cmd-list\" aria-activedescendant=\"cmd-o0\" hx-on:input=\"ermite.cmdFilter(this); ermiteFinder.palette(this)\" hx-on:keydown=\"ermite.cmdKey(event)\"> <button type=\"button\" class=\"panel-x\" aria-label=\"Close\" hx-on:click=\"closest('dialog').close()\"><kbd>esc</kbd></button></div><ul id=\"cmd-list\" class=\"cmd-list\" role=\"listbox\" aria-label=\"Targets\">")263+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<dialog id=\"panel-cmd\" class=\"panel modal\" aria-label=\"Go to\" hx-on:close=\"ermiteFinder.paletteClose()\" hx-on:keydown=\"event.key === 'Escape' && (event.preventDefault(), this.close())\" hx-on:click=\"event.target === this && this.close()\"><div class=\"panel-head\"><label for=\"cmd-q\" class=\"sr\">go to</label> <input id=\"cmd-q\" type=\"search\" placeholder=\"Go to a section, branch, tag, file or repository…\" autocomplete=\"off\" spellcheck=\"false\" role=\"combobox\" aria-autocomplete=\"list\" aria-expanded=\"true\" aria-controls=\"cmd-list\" aria-activedescendant=\"cmd-o0\" hx-on:input=\"ermite.cmdFilter(this); ermiteFinder.palette(this)\" hx-on:keydown=\"ermite.cmdKey(event)\"> <button type=\"button\" class=\"panel-x\" aria-label=\"Close\" hx-on:click=\"closest('dialog').close()\"><kbd>esc</kbd></button></div><ul id=\"cmd-list\" class=\"cmd-list\" role=\"listbox\" aria-label=\"Targets\">")
254 if templ_7745c5c3_Err != nil {264 if templ_7745c5c3_Err != nil {
255 return templ_7745c5c3_Err265 return templ_7745c5c3_Err
256 }266 }
257 for i, c := range items {267 for i, c := range items {
258- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<li id=\"")268+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<li id=\"")
259 if templ_7745c5c3_Err != nil {269 if templ_7745c5c3_Err != nil {
260 return templ_7745c5c3_Err270 return templ_7745c5c3_Err
261 }271 }
262 var templ_7745c5c3_Var10 string272 var templ_7745c5c3_Var10 string
263 templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("cmd-o" + itoa(i))273 templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("cmd-o" + itoa(i))
264 if templ_7745c5c3_Err != nil {274 if templ_7745c5c3_Err != nil {
265- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 127, Col: 30}275+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 130, Col: 30}
266 }276 }
267 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)277 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
268 if templ_7745c5c3_Err != nil {278 if templ_7745c5c3_Err != nil {
269 return templ_7745c5c3_Err279 return templ_7745c5c3_Err
270 }280 }
271- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" role=\"option\" aria-selected=\"")281+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" role=\"option\" aria-selected=\"")
272 if templ_7745c5c3_Err != nil {282 if templ_7745c5c3_Err != nil {
273 return templ_7745c5c3_Err283 return templ_7745c5c3_Err
274 }284 }
275 var templ_7745c5c3_Var11 string285 var templ_7745c5c3_Var11 string
276 templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(strconv.FormatBool(i == 0))286 templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(strconv.FormatBool(i == 0))
277 if templ_7745c5c3_Err != nil {287 if templ_7745c5c3_Err != nil {
278- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 127, Col: 89}288+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 130, Col: 89}
279 }289 }
280 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)290 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
281 if templ_7745c5c3_Err != nil {291 if templ_7745c5c3_Err != nil {
282 return templ_7745c5c3_Err292 return templ_7745c5c3_Err
283 }293 }
284- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" data-k=\"")294+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" data-k=\"")
285 if templ_7745c5c3_Err != nil {295 if templ_7745c5c3_Err != nil {
286 return templ_7745c5c3_Err296 return templ_7745c5c3_Err
287 }297 }
288 var templ_7745c5c3_Var12 string298 var templ_7745c5c3_Var12 string
289 templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(strings.ToLower(c.Kind + " " + c.Label))299 templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(strings.ToLower(c.Kind + " " + c.Label))
290 if templ_7745c5c3_Err != nil {300 if templ_7745c5c3_Err != nil {
291- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 127, Col: 140}301+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 130, Col: 140}
292 }302 }
293 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)303 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
294 if templ_7745c5c3_Err != nil {304 if templ_7745c5c3_Err != nil {
295 return templ_7745c5c3_Err305 return templ_7745c5c3_Err
296 }306 }
297- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\"")307+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\"")
298 if templ_7745c5c3_Err != nil {308 if templ_7745c5c3_Err != nil {
299 return templ_7745c5c3_Err309 return templ_7745c5c3_Err
300 }310 }
301 if i == 0 {311 if i == 0 {
302- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, " data-cursor")312+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " data-cursor")
303 if templ_7745c5c3_Err != nil {313 if templ_7745c5c3_Err != nil {
304 return templ_7745c5c3_Err314 return templ_7745c5c3_Err
305 }315 }
306 }316 }
307- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "><a href=\"")317+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "><a href=\"")
308 if templ_7745c5c3_Err != nil {318 if templ_7745c5c3_Err != nil {
309 return templ_7745c5c3_Err319 return templ_7745c5c3_Err
310 }320 }
311 var templ_7745c5c3_Var13 templ.SafeURL321 var templ_7745c5c3_Var13 templ.SafeURL
312 templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))322 templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
313 if templ_7745c5c3_Err != nil {323 if templ_7745c5c3_Err != nil {
314- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 127, Col: 198}324+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 130, Col: 198}
315 }325 }
316 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))326 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
317 if templ_7745c5c3_Err != nil {327 if templ_7745c5c3_Err != nil {
318 return templ_7745c5c3_Err328 return templ_7745c5c3_Err
319 }329 }
320- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" tabindex=\"-1\"><span class=\"lbl\">")330+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" tabindex=\"-1\"><span class=\"lbl\">")
321 if templ_7745c5c3_Err != nil {331 if templ_7745c5c3_Err != nil {
322 return templ_7745c5c3_Err332 return templ_7745c5c3_Err
323 }333 }
324 var templ_7745c5c3_Var14 string334 var templ_7745c5c3_Var14 string
325 templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)335 templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
326 if templ_7745c5c3_Err != nil {336 if templ_7745c5c3_Err != nil {
327- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 127, Col: 242}337+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 130, Col: 242}
328 }338 }
329 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))339 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
330 if templ_7745c5c3_Err != nil {340 if templ_7745c5c3_Err != nil {
331 return templ_7745c5c3_Err341 return templ_7745c5c3_Err
332 }342 }
333- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</span><span class=\"k\">")343+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</span><span class=\"k\">")
334 if templ_7745c5c3_Err != nil {344 if templ_7745c5c3_Err != nil {
335 return templ_7745c5c3_Err345 return templ_7745c5c3_Err
336 }346 }
337 var templ_7745c5c3_Var15 string347 var templ_7745c5c3_Var15 string
338 templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(c.Kind)348 templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(c.Kind)
339 if templ_7745c5c3_Err != nil {349 if templ_7745c5c3_Err != nil {
340- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 127, Col: 275}350+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 130, Col: 275}
341 }351 }
342 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))352 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
343 if templ_7745c5c3_Err != nil {353 if templ_7745c5c3_Err != nil {
344 return templ_7745c5c3_Err354 return templ_7745c5c3_Err
345 }355 }
346- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</span></a></li>")356+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</span></a></li>")
347 if templ_7745c5c3_Err != nil {357 if templ_7745c5c3_Err != nil {
348 return templ_7745c5c3_Err358 return templ_7745c5c3_Err
349 }359 }
350 }360 }
351- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<li id=\"")361+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<li id=\"")
352 if templ_7745c5c3_Err != nil {362 if templ_7745c5c3_Err != nil {
353 return templ_7745c5c3_Err363 return templ_7745c5c3_Err
354 }364 }
355 var templ_7745c5c3_Var16 string365 var templ_7745c5c3_Var16 string
356 templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("cmd-o" + itoa(len(items)))366 templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("cmd-o" + itoa(len(items)))
357 if templ_7745c5c3_Err != nil {367 if templ_7745c5c3_Err != nil {
358- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 129, Col: 38}368+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 132, Col: 38}
359 }369 }
360 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)370 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
361 if templ_7745c5c3_Err != nil {371 if templ_7745c5c3_Err != nil {
362 return templ_7745c5c3_Err372 return templ_7745c5c3_Err
363 }373 }
364- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" role=\"option\" aria-selected=\"false\" data-k=\"action turn single-key shortcuts on off keyboard\"><a href=\"#\" data-fire=\"keys\" tabindex=\"-1\"><span class=\"lbl\">turn single-key shortcuts off</span><span class=\"k\">action</span></a></li></ul><p class=\"panel-foot\"><span><kbd>↑</kbd><kbd>↓</kbd> move</span><span><kbd>enter</kbd> open</span><span><kbd>esc</kbd> close</span></p></dialog>")374+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" role=\"option\" aria-selected=\"false\" data-k=\"action turn single-key shortcuts on off keyboard\"><a href=\"#\" data-fire=\"keys\" tabindex=\"-1\"><span class=\"lbl\">turn single-key shortcuts off</span><span class=\"k\">action</span></a></li></ul><p class=\"panel-foot\"><span><kbd>↑</kbd><kbd>↓</kbd> move</span><span><kbd>enter</kbd> open</span><span><kbd>esc</kbd> close</span></p></dialog>")
365 if templ_7745c5c3_Err != nil {375 if templ_7745c5c3_Err != nil {
366 return templ_7745c5c3_Err376 return templ_7745c5c3_Err
367 }377 }
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 generated file: collapsed
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