detached 302b124

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

state
merged · 801147f · squash
branch
feat/inbox-runs → main
opened
bnema ·
updated

The owner's inbox lists queued and in-progress CI runs in a Running section (after Awaiting moderation), with a running count, the ?repo= filter, and live refresh on run status changes. CI off: no section.

Shift+I opens the inbox from any page for the signed-in owner; lowercase i still opens the issues tab. The help dialog and the inbox button announce it.

Checks: go build, go vet, go test ./internal/..., node --test jstest (199 pass). Wrap-up review (go + js) done; one a11y finding fixed.

CommitSubjectAuthorDateSignatureStatus
302b124feat(web): announce the Shift+I shortcut on the inbox buttonbriceGPG Verified
0f18a24feat(web): list running CI runs in the inbox and open it with Shift+IbriceGPG Verified

Checks 4

52 files · +902 -425
4/4
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 docs/releases.md +5 -44
@@ -57,60 +57,21 @@ the S3 endpoint. Stored-token publishers need `release:write` and, for private
5757 repository reads, `repo:read`. The owner component is `ERMITE_OWNER` (`owner` by
5858 default).
5959
60-GoReleaser does not read `GITHUB_API_URL` or `GITHUB_SERVER_URL` by itself, so
61-template `github_urls` from them (replace the fallback host and repository):
60+GoReleaser configuration (replace the host and repository):
6261
6362 ```yaml
6463 version: 2
65-# In CI the runner sets GITHUB_API_URL and GITHUB_SERVER_URL to the address the
66-# job can reach; the public URLs are the fallback for a local run.
6764 github_urls:
68- api: '{{ or (index .Env "GITHUB_API_URL") "https://git.example.org/-/github/api" }}/'
69- upload: '{{ or (index .Env "GITHUB_API_URL") "https://git.example.org/-/github/api" }}/uploads/'
70- download: '{{ or (index .Env "GITHUB_SERVER_URL") "https://git.example.org" }}/'
65+ api: https://git.example.org/-/github/api/
66+ upload: https://git.example.org/-/github/api/uploads/
67+ download: https://git.example.org/
7168 release:
7269 github:
7370 owner: owner
7471 name: app
7572 ```
7673
77-In a CI job the runner rewrites the server and API URLs to an origin the job
78-can reach, which may differ from the public URL (for example `http://ermite:7341`
79-with a relayed forge). Sandboxes block private and CGNAT ranges, so a job that
80-targets only the public URL times out (`dial tcp 100.x:443: i/o timeout`). A
81-plain-http relay needs no TLS option. The API base gets `api/v3/` appended by
82-the client; ermite accepts that alias. Locally, run
83-`GITHUB_TOKEN=<token> goreleaser release --clean` from a tagged checkout; the
84-fallbacks apply.
85-
86-Workflow:
87-
88-```yaml
89-name: release
90-on:
91- push:
92- tags: ['v*']
93-permissions:
94- contents: write
95-jobs:
96- release:
97- runs-on: ubuntu-latest
98- steps:
99- - uses: actions/checkout@v4
100- with:
101- fetch-depth: 0
102- - uses: actions/setup-go@v5
103- with:
104- go-version-file: go.mod
105- - uses: goreleaser/goreleaser-action@v6
106- with:
107- args: release --clean
108- env:
109- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
110-```
111-
112-An existing GitHub workflow must be enabled on ermite:
113-`ermite workflow enable <repo> .github/workflows/release.yml`.
74+Run `GITHUB_TOKEN=<token> goreleaser release --clean` from a tagged checkout.
11475
11576 On an ermite runner, the job supplies `GITHUB_API_URL`, `GITHUB_SERVER_URL` and
11677 `GITHUB_TOKEN`; no endpoint override is needed for action-gh-release:
@@ -57,60 +57,21 @@ the S3 endpoint. Stored-token publishers need `release:write` and, for private
57 repository reads, `repo:read`. The owner component is `ERMITE_OWNER` (`owner` by57 repository reads, `repo:read`. The owner component is `ERMITE_OWNER` (`owner` by
58 default).58 default).
59 59
60-GoReleaser does not read `GITHUB_API_URL` or `GITHUB_SERVER_URL` by itself, so60+GoReleaser configuration (replace the host and repository):
61-template `github_urls` from them (replace the fallback host and repository):
62 61
63 ```yaml62 ```yaml
64 version: 263 version: 2
65-# In CI the runner sets GITHUB_API_URL and GITHUB_SERVER_URL to the address the
66-# job can reach; the public URLs are the fallback for a local run.
67 github_urls:64 github_urls:
68- api: '{{ or (index .Env "GITHUB_API_URL") "https://git.example.org/-/github/api" }}/'65+ api: https://git.example.org/-/github/api/
69- upload: '{{ or (index .Env "GITHUB_API_URL") "https://git.example.org/-/github/api" }}/uploads/'66+ upload: https://git.example.org/-/github/api/uploads/
70- download: '{{ or (index .Env "GITHUB_SERVER_URL") "https://git.example.org" }}/'67+ download: https://git.example.org/
71 release:68 release:
72 github:69 github:
73 owner: owner70 owner: owner
74 name: app71 name: app
75 ```72 ```
76 73
77-In a CI job the runner rewrites the server and API URLs to an origin the job74+Run `GITHUB_TOKEN=<token> goreleaser release --clean` from a tagged checkout.
78-can reach, which may differ from the public URL (for example `http://ermite:7341`
79-with a relayed forge). Sandboxes block private and CGNAT ranges, so a job that
80-targets only the public URL times out (`dial tcp 100.x:443: i/o timeout`). A
81-plain-http relay needs no TLS option. The API base gets `api/v3/` appended by
82-the client; ermite accepts that alias. Locally, run
83-`GITHUB_TOKEN=<token> goreleaser release --clean` from a tagged checkout; the
84-fallbacks apply.
85-
86-Workflow:
87-
88-```yaml
89-name: release
90-on:
91- push:
92- tags: ['v*']
93-permissions:
94- contents: write
95-jobs:
96- release:
97- runs-on: ubuntu-latest
98- steps:
99- - uses: actions/checkout@v4
100- with:
101- fetch-depth: 0
102- - uses: actions/setup-go@v5
103- with:
104- go-version-file: go.mod
105- - uses: goreleaser/goreleaser-action@v6
106- with:
107- args: release --clean
108- env:
109- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
110-```
111-
112-An existing GitHub workflow must be enabled on ermite:
113-`ermite workflow enable <repo> .github/workflows/release.yml`.
114 75
115 On an ermite runner, the job supplies `GITHUB_API_URL`, `GITHUB_SERVER_URL` and76 On an ermite runner, the job supplies `GITHUB_API_URL`, `GITHUB_SERVER_URL` and
116 `GITHUB_TOKEN`; no endpoint override is needed for action-gh-release:77 `GITHUB_TOKEN`; no endpoint override is needed for action-gh-release:
M internal/adapters/web/inbox.go +25 -3
@@ -67,8 +67,17 @@ type InboxRepoLink struct {
6767 Current bool
6868 }
6969
70-// InboxPage is the inbox: the counters, the filter and the two lists.
70+// InboxRunRow adds repository and start time to the Actions run display.
71+type InboxRunRow struct {
72+ RunRow
73+ Repo, RepoHref, When, WhenAt string
74+}
75+
76+// InboxPage is the inbox: counters, repository filter and item lists.
7177 type InboxPage struct {
78+ Running int
79+ RunsEnabled bool
80+ Runs []InboxRunRow
7281 // MergeRequests and Issues count every open item, whatever the filter.
7382 MergeRequests, Issues int
7483 // PendingCount counts every issue awaiting moderation.
@@ -106,6 +115,19 @@ func inboxFilter(r *http.Request) (domain.RepoName, error) {
106115 func (s *Server) inboxPage(ov ports.InboxOverview, filter domain.RepoName) InboxPage {
107116 now := s.cfg.Now()
108117 p := InboxPage{MergeRequests: ov.Counts.MergeRequests, Issues: ov.Counts.Issues, PendingCount: ov.Counts.Pending, Filter: filter.String()}
118+ p.Running, p.RunsEnabled = ov.Counts.Running, ov.RunsEnabled
119+ for _, run := range ov.Runs {
120+ r := InboxRunRow{RunRow: s.runRow(run.Repo.String(), run, now), Repo: run.Repo.String(), RepoHref: repoHref(run.Repo.String())}
121+ if run.Status == domain.CIInProgress {
122+ r.State = "in progress"
123+ }
124+ ts := run.StartedAt
125+ if ts.IsZero() {
126+ ts = run.CreatedAt
127+ }
128+ r.When, r.WhenAt = whenOf(now, ts)
129+ p.Runs = append(p.Runs, r)
130+ }
109131 for _, pi := range ov.Pending {
110132 p.Pending = append(p.Pending, s.pendingRow(now, pi, filter))
111133 }
@@ -266,7 +288,7 @@ func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment
266288 switch we.Event.(type) {
267289 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
268290 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved,
269- domain.RepoUpdated, domain.RepoDeleted:
291+ domain.RepoUpdated, domain.RepoDeleted, domain.RunUpdated:
270292 default:
271293 return nil, nil
272294 }
@@ -283,7 +305,7 @@ func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment
283305 }
284306 p := l.s.inboxPage(ov, filter)
285307 var out []fragment
286- for _, c := range []templ.Component{InboxCounters(p), InboxRepos(p), InboxPending(p), InboxMRs(p), InboxIssues(p)} {
308+ for _, c := range []templ.Component{InboxCounters(p), InboxRepos(p), InboxPending(p), InboxRuns(p), InboxMRs(p), InboxIssues(p)} {
287309 f, err := renderFragment(ctx, inboxEvent, c)
288310 if err != nil {
289311 return nil, err
@@ -67,8 +67,17 @@ type InboxRepoLink struct {
67 Current bool67 Current bool
68 }68 }
69 69
70-// InboxPage is the inbox: the counters, the filter and the two lists.70+// InboxRunRow adds repository and start time to the Actions run display.
71+type InboxRunRow struct {
72+ RunRow
73+ Repo, RepoHref, When, WhenAt string
74+}
75+
76+// InboxPage is the inbox: counters, repository filter and item lists.
71 type InboxPage struct {77 type InboxPage struct {
78+ Running int
79+ RunsEnabled bool
80+ Runs []InboxRunRow
72 // MergeRequests and Issues count every open item, whatever the filter.81 // MergeRequests and Issues count every open item, whatever the filter.
73 MergeRequests, Issues int82 MergeRequests, Issues int
74 // PendingCount counts every issue awaiting moderation.83 // PendingCount counts every issue awaiting moderation.
@@ -106,6 +115,19 @@ func inboxFilter(r *http.Request) (domain.RepoName, error) {
106 func (s *Server) inboxPage(ov ports.InboxOverview, filter domain.RepoName) InboxPage {115 func (s *Server) inboxPage(ov ports.InboxOverview, filter domain.RepoName) InboxPage {
107 now := s.cfg.Now()116 now := s.cfg.Now()
108 p := InboxPage{MergeRequests: ov.Counts.MergeRequests, Issues: ov.Counts.Issues, PendingCount: ov.Counts.Pending, Filter: filter.String()}117 p := InboxPage{MergeRequests: ov.Counts.MergeRequests, Issues: ov.Counts.Issues, PendingCount: ov.Counts.Pending, Filter: filter.String()}
118+ p.Running, p.RunsEnabled = ov.Counts.Running, ov.RunsEnabled
119+ for _, run := range ov.Runs {
120+ r := InboxRunRow{RunRow: s.runRow(run.Repo.String(), run, now), Repo: run.Repo.String(), RepoHref: repoHref(run.Repo.String())}
121+ if run.Status == domain.CIInProgress {
122+ r.State = "in progress"
123+ }
124+ ts := run.StartedAt
125+ if ts.IsZero() {
126+ ts = run.CreatedAt
127+ }
128+ r.When, r.WhenAt = whenOf(now, ts)
129+ p.Runs = append(p.Runs, r)
130+ }
109 for _, pi := range ov.Pending {131 for _, pi := range ov.Pending {
110 p.Pending = append(p.Pending, s.pendingRow(now, pi, filter))132 p.Pending = append(p.Pending, s.pendingRow(now, pi, filter))
111 }133 }
@@ -266,7 +288,7 @@ func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment
266 switch we.Event.(type) {288 switch we.Event.(type) {
267 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,289 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
268 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved,290 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved,
269- domain.RepoUpdated, domain.RepoDeleted:291+ domain.RepoUpdated, domain.RepoDeleted, domain.RunUpdated:
270 default:292 default:
271 return nil, nil293 return nil, nil
272 }294 }
@@ -283,7 +305,7 @@ func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment
283 }305 }
284 p := l.s.inboxPage(ov, filter)306 p := l.s.inboxPage(ov, filter)
285 var out []fragment307 var out []fragment
286- for _, c := range []templ.Component{InboxCounters(p), InboxRepos(p), InboxPending(p), InboxMRs(p), InboxIssues(p)} {308+ for _, c := range []templ.Component{InboxCounters(p), InboxRepos(p), InboxPending(p), InboxRuns(p), InboxMRs(p), InboxIssues(p)} {
287 f, err := renderFragment(ctx, inboxEvent, c)309 f, err := renderFragment(ctx, inboxEvent, c)
288 if err != nil {310 if err != nil {
289 return nil, err311 return nil, err
M internal/adapters/web/inbox.templ +34 -1
@@ -12,6 +12,7 @@ templ Inbox(l Layout, p InboxPage) {
1212 @InboxCounters(p)
1313 @InboxRepos(p)
1414 @InboxPending(p)
15+ @InboxRuns(p)
1516 @InboxMRs(p)
1617 @InboxIssues(p)
1718 </div>
@@ -20,7 +21,7 @@ templ Inbox(l Layout, p InboxPage) {
2021
2122 templ InboxCounters(p InboxPage) {
2223 <p id="inbox-counts" class="inbox-counts">
23- <strong>{ itoa(p.MergeRequests) }</strong> merge requests · <strong>{ itoa(p.Issues) }</strong> issues · <strong>{ itoa(p.PendingCount) }</strong> awaiting moderation
24+ <strong>{ itoa(p.MergeRequests) }</strong> merge requests · <strong>{ itoa(p.Issues) }</strong> issues · <strong>{ itoa(p.PendingCount) }</strong> awaiting moderation · <strong>{ itoa(p.Running) }</strong> running
2425 </p>
2526 }
2627
@@ -113,3 +114,35 @@ templ inboxRows(rows []InboxRow, label string) {
113114 </table>
114115 }
115116 }
117+
118+// InboxRuns stays as an empty fragment when CI is disabled.
119+templ InboxRuns(p InboxPage) {
120+ <section id="inbox-runs" hidden?={ !p.RunsEnabled }
121+ if p.RunsEnabled {
122+ aria-labelledby="inbox-runs-h"
123+ }
124+ >
125+ if p.RunsEnabled {
126+ <h2 id="inbox-runs-h" class="rule-h"><span>Running</span><span class="count">{ itoa(len(p.Runs)) }</span></h2>
127+ if len(p.Runs) == 0 {
128+ <p class="lede dim">no runs in progress.</p>
129+ } else {
130+ <table class="list mrs" aria-label="Running">
131+ <tbody data-rows>
132+ for _, r := range p.Runs {
133+ <tr>
134+ <td class="c-st">@CheckIcon(r.Kind, r.State)<span class="dim">{ r.State }</span></td>
135+ <td class="c-subject"><a href={ templ.SafeURL(r.Href) }>{ r.Workflow }{ " " }{ r.Number }</a> <a class="mute" href={ templ.SafeURL(r.RepoHref) }>{ r.Repo }</a> <span class="dim">{ r.Ref }{ " " }{ r.Event }</span></td>
136+ <td class="c-when nowrap dim">
137+ if r.WhenAt != "" {
138+ <time datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time>
139+ }
140+ </td>
141+ </tr>
142+ }
143+ </tbody>
144+ </table>
145+ }
146+ }
147+ </section>
148+}
@@ -12,6 +12,7 @@ templ Inbox(l Layout, p InboxPage) {
12 @InboxCounters(p)12 @InboxCounters(p)
13 @InboxRepos(p)13 @InboxRepos(p)
14 @InboxPending(p)14 @InboxPending(p)
15+ @InboxRuns(p)
15 @InboxMRs(p)16 @InboxMRs(p)
16 @InboxIssues(p)17 @InboxIssues(p)
17 </div>18 </div>
@@ -20,7 +21,7 @@ templ Inbox(l Layout, p InboxPage) {
20 21
21 templ InboxCounters(p InboxPage) {22 templ InboxCounters(p InboxPage) {
22 <p id="inbox-counts" class="inbox-counts">23 <p id="inbox-counts" class="inbox-counts">
23- <strong>{ itoa(p.MergeRequests) }</strong> merge requests · <strong>{ itoa(p.Issues) }</strong> issues · <strong>{ itoa(p.PendingCount) }</strong> awaiting moderation24+ <strong>{ itoa(p.MergeRequests) }</strong> merge requests · <strong>{ itoa(p.Issues) }</strong> issues · <strong>{ itoa(p.PendingCount) }</strong> awaiting moderation · <strong>{ itoa(p.Running) }</strong> running
24 </p>25 </p>
25 }26 }
26 27
@@ -113,3 +114,35 @@ templ inboxRows(rows []InboxRow, label string) {
113 </table>114 </table>
114 }115 }
115 }116 }
117+
118+// InboxRuns stays as an empty fragment when CI is disabled.
119+templ InboxRuns(p InboxPage) {
120+ <section id="inbox-runs" hidden?={ !p.RunsEnabled }
121+ if p.RunsEnabled {
122+ aria-labelledby="inbox-runs-h"
123+ }
124+ >
125+ if p.RunsEnabled {
126+ <h2 id="inbox-runs-h" class="rule-h"><span>Running</span><span class="count">{ itoa(len(p.Runs)) }</span></h2>
127+ if len(p.Runs) == 0 {
128+ <p class="lede dim">no runs in progress.</p>
129+ } else {
130+ <table class="list mrs" aria-label="Running">
131+ <tbody data-rows>
132+ for _, r := range p.Runs {
133+ <tr>
134+ <td class="c-st">@CheckIcon(r.Kind, r.State)<span class="dim">{ r.State }</span></td>
135+ <td class="c-subject"><a href={ templ.SafeURL(r.Href) }>{ r.Workflow }{ " " }{ r.Number }</a> <a class="mute" href={ templ.SafeURL(r.RepoHref) }>{ r.Repo }</a> <span class="dim">{ r.Ref }{ " " }{ r.Event }</span></td>
136+ <td class="c-when nowrap dim">
137+ if r.WhenAt != "" {
138+ <time datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time>
139+ }
140+ </td>
141+ </tr>
142+ }
143+ </tbody>
144+ </table>
145+ }
146+ }
147+ </section>
148+}
M internal/adapters/web/inbox_templ.go +451 -173 generated file: collapsed
M internal/adapters/web/inbox_test.go +6 -4
@@ -180,12 +180,13 @@ func TestInboxLiveFragments(t *testing.T) {
180180 ls := &liveStream{s: srv, view: "inbox"}
181181 frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: domain.IssueOpened{Issue: domain.Issue{Repo: repoName(t, "beta"), Number: 7}}})
182182 require.NoError(t, err)
183- require.Len(t, frags, 5)
183+ require.Len(t, frags, 6)
184184 assert.Contains(t, frags[0].html, `id="inbox-counts"`)
185185 assert.Contains(t, frags[1].html, `id="inbox-repos"`)
186186 assert.Contains(t, frags[2].html, `id="inbox-pending"`)
187- assert.Contains(t, frags[3].html, `id="inbox-mrs"`)
188- assert.Contains(t, frags[4].html, `id="inbox-issues"`)
187+ assert.Contains(t, frags[3].html, `id="inbox-runs"`)
188+ assert.Contains(t, frags[4].html, `id="inbox-mrs"`)
189+ assert.Contains(t, frags[5].html, `id="inbox-issues"`)
189190
190191 none, err := ls.render(withOwner(t.Context()), ports.WatchEvent{Event: domain.PushEvent{Repo: repoName(t, "beta")}})
191192 require.NoError(t, err)
@@ -286,6 +287,7 @@ func TestInboxRefreshesOnRepoEvents(t *testing.T) {
286287 t.Parallel()
287288 for name, ev := range map[string]domain.Event{
288289 "issues turned off": domain.RepoUpdated{Repo: repoName(t, "beta")},
290+ "run updated": domain.RunUpdated{},
289291 "repo deleted": domain.RepoDeleted{Repo: repoName(t, "beta")},
290292 } {
291293 t.Run(name, func(t *testing.T) {
@@ -297,7 +299,7 @@ func TestInboxRefreshesOnRepoEvents(t *testing.T) {
297299 ls := &liveStream{s: srv, view: "inbox"}
298300 frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: ev})
299301 require.NoError(t, err)
300- require.Len(t, frags, 5)
302+ require.Len(t, frags, 6)
301303 all := ""
302304 for _, f := range frags {
303305 all += f.html
@@ -180,12 +180,13 @@ func TestInboxLiveFragments(t *testing.T) {
180 ls := &liveStream{s: srv, view: "inbox"}180 ls := &liveStream{s: srv, view: "inbox"}
181 frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: domain.IssueOpened{Issue: domain.Issue{Repo: repoName(t, "beta"), Number: 7}}})181 frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: domain.IssueOpened{Issue: domain.Issue{Repo: repoName(t, "beta"), Number: 7}}})
182 require.NoError(t, err)182 require.NoError(t, err)
183- require.Len(t, frags, 5)183+ require.Len(t, frags, 6)
184 assert.Contains(t, frags[0].html, `id="inbox-counts"`)184 assert.Contains(t, frags[0].html, `id="inbox-counts"`)
185 assert.Contains(t, frags[1].html, `id="inbox-repos"`)185 assert.Contains(t, frags[1].html, `id="inbox-repos"`)
186 assert.Contains(t, frags[2].html, `id="inbox-pending"`)186 assert.Contains(t, frags[2].html, `id="inbox-pending"`)
187- assert.Contains(t, frags[3].html, `id="inbox-mrs"`)187+ assert.Contains(t, frags[3].html, `id="inbox-runs"`)
188- assert.Contains(t, frags[4].html, `id="inbox-issues"`)188+ assert.Contains(t, frags[4].html, `id="inbox-mrs"`)
189+ assert.Contains(t, frags[5].html, `id="inbox-issues"`)
189 190
190 none, err := ls.render(withOwner(t.Context()), ports.WatchEvent{Event: domain.PushEvent{Repo: repoName(t, "beta")}})191 none, err := ls.render(withOwner(t.Context()), ports.WatchEvent{Event: domain.PushEvent{Repo: repoName(t, "beta")}})
191 require.NoError(t, err)192 require.NoError(t, err)
@@ -286,6 +287,7 @@ func TestInboxRefreshesOnRepoEvents(t *testing.T) {
286 t.Parallel()287 t.Parallel()
287 for name, ev := range map[string]domain.Event{288 for name, ev := range map[string]domain.Event{
288 "issues turned off": domain.RepoUpdated{Repo: repoName(t, "beta")},289 "issues turned off": domain.RepoUpdated{Repo: repoName(t, "beta")},
290+ "run updated": domain.RunUpdated{},
289 "repo deleted": domain.RepoDeleted{Repo: repoName(t, "beta")},291 "repo deleted": domain.RepoDeleted{Repo: repoName(t, "beta")},
290 } {292 } {
291 t.Run(name, func(t *testing.T) {293 t.Run(name, func(t *testing.T) {
@@ -297,7 +299,7 @@ func TestInboxRefreshesOnRepoEvents(t *testing.T) {
297 ls := &liveStream{s: srv, view: "inbox"}299 ls := &liveStream{s: srv, view: "inbox"}
298 frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: ev})300 frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: ev})
299 require.NoError(t, err)301 require.NoError(t, err)
300- require.Len(t, frags, 5)302+ require.Len(t, frags, 6)
301 all := ""303 all := ""
302 for _, f := range frags {304 for _, f := range frags {
303 all += f.html305 all += f.html
A internal/adapters/web/jstest/keys.test.mjs +29 -0
@@ -0,0 +1,29 @@
1+// Exercise the production keydown handler without loading the rest of the page.
2+import assert from 'node:assert/strict';
3+import { readFileSync } from 'node:fs';
4+import { test } from 'node:test';
5+const src = readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8');
6+const block = src.slice(src.indexOf(' // 1. Keys.'), src.indexOf(' // 2. A [data-fire]'));
7+
8+function page({ owner = true, off = false, typing = false, dialog = false } = {}) {
9+ let handler;
10+ const fired = [];
11+ const window = { ermiteKeys: { blocks: () => off } };
12+ const $ = (s) => s === 'dialog[open]' ? dialog : s === '[data-inbox]' ? owner : null;
13+ new Function('window', '$', 'ermite', 'addEventListener', block)(window, $, { fire: (n) => fired.push(n) }, (_, f) => { handler = f; });
14+ return (key) => {
15+ const e = { key, shiftKey: key === 'I', preventDefault() {}, target: { closest: (s) => s.startsWith('input') && typing } };
16+ handler(e);
17+ return fired;
18+ };
19+}
20+test('Shift+I opens inbox while lowercase i retains the issues tab', () => {
21+ const press = page();
22+ assert.deepEqual(press('I'), ['inbox']);
23+ assert.deepEqual(press('i'), ['inbox', 'i']);
24+});
25+test('inbox shortcut respects preference, fields, dialogs, and owner target', () => {
26+ for (const options of [{ off: true }, { typing: true }, { dialog: true }, { owner: false }]) {
27+ assert.deepEqual(page(options)('I'), [], JSON.stringify(options));
28+ }
29+});
@@ -0,0 +1,29 @@
1+// Exercise the production keydown handler without loading the rest of the page.
2+import assert from 'node:assert/strict';
3+import { readFileSync } from 'node:fs';
4+import { test } from 'node:test';
5+const src = readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8');
6+const block = src.slice(src.indexOf(' // 1. Keys.'), src.indexOf(' // 2. A [data-fire]'));
7+
8+function page({ owner = true, off = false, typing = false, dialog = false } = {}) {
9+ let handler;
10+ const fired = [];
11+ const window = { ermiteKeys: { blocks: () => off } };
12+ const $ = (s) => s === 'dialog[open]' ? dialog : s === '[data-inbox]' ? owner : null;
13+ new Function('window', '$', 'ermite', 'addEventListener', block)(window, $, { fire: (n) => fired.push(n) }, (_, f) => { handler = f; });
14+ return (key) => {
15+ const e = { key, shiftKey: key === 'I', preventDefault() {}, target: { closest: (s) => s.startsWith('input') && typing } };
16+ handler(e);
17+ return fired;
18+ };
19+}
20+test('Shift+I opens inbox while lowercase i retains the issues tab', () => {
21+ const press = page();
22+ assert.deepEqual(press('I'), ['inbox']);
23+ assert.deepEqual(press('i'), ['inbox', 'i']);
24+});
25+test('inbox shortcut respects preference, fields, dialogs, and owner target', () => {
26+ for (const options of [{ off: true }, { typing: true }, { dialog: true }, { owner: false }]) {
27+ assert.deepEqual(page(options)('I'), [], JSON.stringify(options));
28+ }
29+});
M internal/adapters/web/keys.templ +1 -0
@@ -28,6 +28,7 @@ templ Keys() {
2828 k-r from:body -> ermite.tab('r');
2929 k-m from:body -> ermite.tab('m');
3030 k-i from:body -> ermite.tab('i');
31+ k-inbox from:body -> ermite.go('[data-inbox]');
3132 k-next from:body -> ermite.nextNote();
3233 k-split from:body -> ermite.layout('split');
3334 k-unified from:body -> ermite.layout('unified');
@@ -28,6 +28,7 @@ templ Keys() {
28 k-r from:body -> ermite.tab('r');28 k-r from:body -> ermite.tab('r');
29 k-m from:body -> ermite.tab('m');29 k-m from:body -> ermite.tab('m');
30 k-i from:body -> ermite.tab('i');30 k-i from:body -> ermite.tab('i');
31+ k-inbox from:body -> ermite.go('[data-inbox]');
31 k-next from:body -> ermite.nextNote();32 k-next from:body -> ermite.nextNote();
32 k-split from:body -> ermite.layout('split');33 k-split from:body -> ermite.layout('split');
33 k-unified from:body -> ermite.layout('unified');34 k-unified from:body -> ermite.layout('unified');
M internal/adapters/web/keys_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/overlays.templ +3 -0
@@ -27,6 +27,9 @@ templ OverlayHelp(mr bool) {
2727 <div><dt><kbd>/</kbd></dt><dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd></div>
2828 <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div>
2929 <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd> <kbd>i</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd></div>
30+ if SignedIn(ctx) {
31+ <div><dt><kbd>I</kbd></dt><dd>inbox</dd></div>
32+ }
3033 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>
3134 <div><dt><kbd>w</kbd></dt><dd>wide mode: use the whole width of the screen, or not (remembered)</dd></div>
3235 <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div>
@@ -27,6 +27,9 @@ templ OverlayHelp(mr bool) {
27 <div><dt><kbd>/</kbd></dt><dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd></div>27 <div><dt><kbd>/</kbd></dt><dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd></div>
28 <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div>28 <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div>
29 <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd> <kbd>i</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd></div>29 <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd> <kbd>i</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd></div>
30+ if SignedIn(ctx) {
31+ <div><dt><kbd>I</kbd></dt><dd>inbox</dd></div>
32+ }
30 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>33 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>
31 <div><dt><kbd>w</kbd></dt><dd>wide mode: use the whole width of the screen, or not (remembered)</dd></div>34 <div><dt><kbd>w</kbd></dt><dd>wide mode: use the whole width of the screen, or not (remembered)</dd></div>
32 <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div>35 <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div>
M internal/adapters/web/overlays_templ.go +49 -39 generated file: collapsed
M internal/adapters/web/shell.templ +5 -1
@@ -67,6 +67,9 @@ templ Shell(l Layout, rail templ.Component) {
6767 </div>
6868 @Footer(l)
6969 @Hint(l)
70+ if SignedIn(ctx) {
71+ <a hidden data-inbox href={ inboxPath }>Inbox</a>
72+ }
7073 @OverlayHelp(l.MR)
7174 @OverlayRefs(l)
7275 @OverlayCmd(l.Cmd)
@@ -87,7 +90,8 @@ templ Header(l Layout) {
8790 <a
8891 class="btn inbox-btn"
8992 href={ templ.SafeURL(l.Inbox.Href) }
90- title="inbox"
93+ title="inbox (I)"
94+ aria-keyshortcuts="Shift+I"
9195 >
9296 <span class="ico i-mailbox" aria-hidden="true"></span>
9397 Inbox
@@ -67,6 +67,9 @@ templ Shell(l Layout, rail templ.Component) {
67 </div>67 </div>
68 @Footer(l)68 @Footer(l)
69 @Hint(l)69 @Hint(l)
70+ if SignedIn(ctx) {
71+ <a hidden data-inbox href={ inboxPath }>Inbox</a>
72+ }
70 @OverlayHelp(l.MR)73 @OverlayHelp(l.MR)
71 @OverlayRefs(l)74 @OverlayRefs(l)
72 @OverlayCmd(l.Cmd)75 @OverlayCmd(l.Cmd)
@@ -87,7 +90,8 @@ templ Header(l Layout) {
87 <a90 <a
88 class="btn inbox-btn"91 class="btn inbox-btn"
89 href={ templ.SafeURL(l.Inbox.Href) }92 href={ templ.SafeURL(l.Inbox.Href) }
90- title="inbox"93+ title="inbox (I)"
94+ aria-keyshortcuts="Shift+I"
91 >95 >
92 <span class="ico i-mailbox" aria-hidden="true"></span>96 <span class="ico i-mailbox" aria-hidden="true"></span>
93 Inbox97 Inbox
M internal/adapters/web/shell_templ.go +152 -133 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
M internal/app/testdata/web/commit-merge.html +1 -0
M internal/app/testdata/web/commit-modify-split.html +1 -0
M internal/app/testdata/web/commit-modify.html +1 -0
M internal/app/testdata/web/commit-rename.html +1 -0
M internal/app/testdata/web/commit-root.html +1 -0
M internal/app/testdata/web/commit-trojan.html +1 -0
M internal/app/testdata/web/commits-page2.html +1 -0
M internal/app/testdata/web/commits-path.html +1 -0
M internal/app/testdata/web/commits-root.html +1 -0
M internal/app/testdata/web/commits-small.html +1 -0
M internal/app/testdata/web/empty.html +1 -0
M internal/app/testdata/web/home.html +1 -0
M internal/app/testdata/web/not-found-page.html +1 -0
M internal/app/testdata/web/not-found.html +1 -0
M internal/app/testdata/web/refs.html +1 -0
M internal/app/testdata/web/summary.html +1 -0
M internal/app/testdata/web/tree-nested.html +1 -0
M internal/app/testdata/web/tree-root.html +1 -0
M internal/app/testdata/web/tree-slash-ref.html +1 -0
M internal/app/testdata/web/tree-subdir.html +1 -0
M internal/app/web_inbox_test.go +32 -2
M internal/ports/dto.go +6 -3
M internal/usecase/events.go +2 -2
M internal/usecase/inbox.go +31 -2
M internal/usecase/inbox_test.go +33 -0
M skills/ermite/SKILL.md +0 -13

Comments0 open / 0

no comments yet.

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
l
merge request: the list of files (j k move, enter opens, esc back)
M
merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges
R
merge request: mark the head read
esc
close a dialog, leave a field

Branches 2

Tags 42

↑↓ moveenter openesc close