detached e9c6360

#29 feat(web): merge merge requests asynchronously with htmx

state
merged · cf26365 · squash
branch
feat/mr-async-merge → main
opened
bnema ·
updated

The merge request Merge action now runs through htmx for every mode (fast-forward, squash, merge) instead of a full-page POST and 303 reload.

  • The form posts asynchronously, drops double submits and shows a pending indicator; without JS it still posts normally.
  • htmx merges get OOB fragments (state badge + action area). Refusals, failures, expired sessions and rejected origins render a status in the merge box.
  • Other open tabs of the same MR receive the merged state through the live stream.
  • The checks-label observer watches the stable MR bar so it survives swaps.

Deferred: the fragment path still builds the full page view (diff, comments) before rendering only state/actions; a dedicated state view builder is a follow-up.

Checks: go test ./..., go vet, node jstest suite (198 pass). Not manually tested in a browser.

CommitSubjectAuthorDateSignatureStatus
e9c6360fix(web): show session refusals in the async merge box and keep a single merge error alertbriceGPG Verified
5fea81ffeat(web): merge merge requests asynchronously with htmx and live state updatesbriceGPG Verified

Checks 4

64 files · +1924 -2040
4/4
M docs/issues.md +1 -3
@@ -50,9 +50,7 @@ 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 **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.
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.
5654
5755 ## Public form and moderation
5856
@@ -50,9 +50,7 @@ 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 **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.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.
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.
56 54
57 ## Public form and moderation55 ## Public form and moderation
58 56
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 go.mod +5 -5
@@ -1,6 +1,6 @@
11 module bnema.dev/ermite
22
3-go 1.27
3+go 1.27.1
44
55 tool (
66 github.com/a-h/templ/cmd/templ
@@ -12,10 +12,10 @@ tool (
1212 )
1313
1414 require (
15- bnema.dev/ermite-actions v0.2.1
16- bnema.dev/ermite-api v0.16.1
17- bnema.dev/ermite-git v0.1.1
18- bnema.dev/ermite-kv v0.1.1
15+ bnema.dev/ermite-actions v0.2.0
16+ bnema.dev/ermite-api v0.16.0
17+ bnema.dev/ermite-git v0.1.0
18+ bnema.dev/ermite-kv v0.1.0
1919 connectrpc.com/connect v1.21.0
2020 github.com/ProtonMail/go-crypto v1.4.1
2121 github.com/a-h/templ v0.3.1020
@@ -1,6 +1,6 @@
1 module bnema.dev/ermite1 module bnema.dev/ermite
2 2
3-go 1.273+go 1.27.1
4 4
5 tool (5 tool (
6 github.com/a-h/templ/cmd/templ6 github.com/a-h/templ/cmd/templ
@@ -12,10 +12,10 @@ tool (
12 )12 )
13 13
14 require (14 require (
15- bnema.dev/ermite-actions v0.2.115+ bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.16.116+ bnema.dev/ermite-api v0.16.0
17- bnema.dev/ermite-git v0.1.117+ bnema.dev/ermite-git v0.1.0
18- bnema.dev/ermite-kv v0.1.118+ bnema.dev/ermite-kv v0.1.0
19 connectrpc.com/connect v1.21.019 connectrpc.com/connect v1.21.0
20 github.com/ProtonMail/go-crypto v1.4.120 github.com/ProtonMail/go-crypto v1.4.1
21 github.com/a-h/templ v0.3.102021 github.com/a-h/templ v0.3.1020
M go.sum +8 -8 generated file: collapsed
M internal/adapters/web/fragments.go +15 -0
@@ -6,6 +6,7 @@ import (
66 "bytes"
77 "context"
88 "errors"
9+ "net/http"
910 "strconv"
1011 "strings"
1112 "sync"
@@ -192,6 +193,20 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
192193 case domain.MergeRequestOpened:
193194 return l.mrNotice(ctx, ev.MergeRequest.Number, "a merge request was opened — reload")
194195 case domain.MergeRequestMerged:
196+ if l.view == "mr" && strconv.FormatInt(ev.MergeRequest.Number, 10) == l.n {
197+ r, err := http.NewRequestWithContext(ctx, http.MethodGet, mrHref(l.repo, ev.MergeRequest.Number), nil)
198+ if err != nil {
199+ return nil, err
200+ }
201+ r.SetPathValue("repo", l.repo)
202+ r.SetPathValue("n", l.n)
203+ c, _, err := l.s.mrPageView(ctx, r, false, true)
204+ if err != nil {
205+ return nil, err
206+ }
207+ f, err := renderFragment(ctx, "mr-mergeability", c)
208+ return []fragment{f}, err
209+ }
195210 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was merged — reload")
196211 case domain.MergeRequestClosed:
197212 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")
@@ -6,6 +6,7 @@ import (
6 "bytes"6 "bytes"
7 "context"7 "context"
8 "errors"8 "errors"
9+ "net/http"
9 "strconv"10 "strconv"
10 "strings"11 "strings"
11 "sync"12 "sync"
@@ -192,6 +193,20 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
192 case domain.MergeRequestOpened:193 case domain.MergeRequestOpened:
193 return l.mrNotice(ctx, ev.MergeRequest.Number, "a merge request was opened — reload")194 return l.mrNotice(ctx, ev.MergeRequest.Number, "a merge request was opened — reload")
194 case domain.MergeRequestMerged:195 case domain.MergeRequestMerged:
196+ if l.view == "mr" && strconv.FormatInt(ev.MergeRequest.Number, 10) == l.n {
197+ r, err := http.NewRequestWithContext(ctx, http.MethodGet, mrHref(l.repo, ev.MergeRequest.Number), nil)
198+ if err != nil {
199+ return nil, err
200+ }
201+ r.SetPathValue("repo", l.repo)
202+ r.SetPathValue("n", l.n)
203+ c, _, err := l.s.mrPageView(ctx, r, false, true)
204+ if err != nil {
205+ return nil, err
206+ }
207+ f, err := renderFragment(ctx, "mr-mergeability", c)
208+ return []fragment{f}, err
209+ }
195 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was merged — reload")210 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was merged — reload")
196 case domain.MergeRequestClosed:211 case domain.MergeRequestClosed:
197 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")212 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")
M internal/adapters/web/inbox.go +3 -25
@@ -67,17 +67,8 @@ type InboxRepoLink struct {
6767 Current bool
6868 }
6969
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.
70+// InboxPage is the inbox: the counters, the filter and the two lists.
7771 type InboxPage struct {
78- Running int
79- RunsEnabled bool
80- Runs []InboxRunRow
8172 // MergeRequests and Issues count every open item, whatever the filter.
8273 MergeRequests, Issues int
8374 // PendingCount counts every issue awaiting moderation.
@@ -115,19 +106,6 @@ func inboxFilter(r *http.Request) (domain.RepoName, error) {
115106 func (s *Server) inboxPage(ov ports.InboxOverview, filter domain.RepoName) InboxPage {
116107 now := s.cfg.Now()
117108 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- }
131109 for _, pi := range ov.Pending {
132110 p.Pending = append(p.Pending, s.pendingRow(now, pi, filter))
133111 }
@@ -288,7 +266,7 @@ func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment
288266 switch we.Event.(type) {
289267 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
290268 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved,
291- domain.RepoUpdated, domain.RepoDeleted, domain.RunUpdated:
269+ domain.RepoUpdated, domain.RepoDeleted:
292270 default:
293271 return nil, nil
294272 }
@@ -305,7 +283,7 @@ func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment
305283 }
306284 p := l.s.inboxPage(ov, filter)
307285 var out []fragment
308- for _, c := range []templ.Component{InboxCounters(p), InboxRepos(p), InboxPending(p), InboxRuns(p), InboxMRs(p), InboxIssues(p)} {
286+ for _, c := range []templ.Component{InboxCounters(p), InboxRepos(p), InboxPending(p), InboxMRs(p), InboxIssues(p)} {
309287 f, err := renderFragment(ctx, inboxEvent, c)
310288 if err != nil {
311289 return nil, err
@@ -67,17 +67,8 @@ type InboxRepoLink struct {
67 Current bool67 Current bool
68 }68 }
69 69
70-// InboxRunRow adds repository and start time to the Actions run display.70+// InboxPage is the inbox: the counters, the filter and the two lists.
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.
77 type InboxPage struct {71 type InboxPage struct {
78- Running int
79- RunsEnabled bool
80- Runs []InboxRunRow
81 // MergeRequests and Issues count every open item, whatever the filter.72 // MergeRequests and Issues count every open item, whatever the filter.
82 MergeRequests, Issues int73 MergeRequests, Issues int
83 // PendingCount counts every issue awaiting moderation.74 // PendingCount counts every issue awaiting moderation.
@@ -115,19 +106,6 @@ func inboxFilter(r *http.Request) (domain.RepoName, error) {
115 func (s *Server) inboxPage(ov ports.InboxOverview, filter domain.RepoName) InboxPage {106 func (s *Server) inboxPage(ov ports.InboxOverview, filter domain.RepoName) InboxPage {
116 now := s.cfg.Now()107 now := s.cfg.Now()
117 p := InboxPage{MergeRequests: ov.Counts.MergeRequests, Issues: ov.Counts.Issues, PendingCount: ov.Counts.Pending, Filter: filter.String()}108 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- }
131 for _, pi := range ov.Pending {109 for _, pi := range ov.Pending {
132 p.Pending = append(p.Pending, s.pendingRow(now, pi, filter))110 p.Pending = append(p.Pending, s.pendingRow(now, pi, filter))
133 }111 }
@@ -288,7 +266,7 @@ func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment
288 switch we.Event.(type) {266 switch we.Event.(type) {
289 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,267 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
290 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved,268 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved,
291- domain.RepoUpdated, domain.RepoDeleted, domain.RunUpdated:269+ domain.RepoUpdated, domain.RepoDeleted:
292 default:270 default:
293 return nil, nil271 return nil, nil
294 }272 }
@@ -305,7 +283,7 @@ func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment
305 }283 }
306 p := l.s.inboxPage(ov, filter)284 p := l.s.inboxPage(ov, filter)
307 var out []fragment285 var out []fragment
308- for _, c := range []templ.Component{InboxCounters(p), InboxRepos(p), InboxPending(p), InboxRuns(p), InboxMRs(p), InboxIssues(p)} {286+ for _, c := range []templ.Component{InboxCounters(p), InboxRepos(p), InboxPending(p), InboxMRs(p), InboxIssues(p)} {
309 f, err := renderFragment(ctx, inboxEvent, c)287 f, err := renderFragment(ctx, inboxEvent, c)
310 if err != nil {288 if err != nil {
311 return nil, err289 return nil, err
M internal/adapters/web/inbox.templ +1 -34
@@ -12,7 +12,6 @@ templ Inbox(l Layout, p InboxPage) {
1212 @InboxCounters(p)
1313 @InboxRepos(p)
1414 @InboxPending(p)
15- @InboxRuns(p)
1615 @InboxMRs(p)
1716 @InboxIssues(p)
1817 </div>
@@ -21,7 +20,7 @@ templ Inbox(l Layout, p InboxPage) {
2120
2221 templ InboxCounters(p InboxPage) {
2322 <p id="inbox-counts" class="inbox-counts">
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
23+ <strong>{ itoa(p.MergeRequests) }</strong> merge requests · <strong>{ itoa(p.Issues) }</strong> issues · <strong>{ itoa(p.PendingCount) }</strong> awaiting moderation
2524 </p>
2625 }
2726
@@ -114,35 +113,3 @@ templ inboxRows(rows []InboxRow, label string) {
114113 </table>
115114 }
116115 }
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,7 +12,6 @@ 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)
16 @InboxMRs(p)15 @InboxMRs(p)
17 @InboxIssues(p)16 @InboxIssues(p)
18 </div>17 </div>
@@ -21,7 +20,7 @@ templ Inbox(l Layout, p InboxPage) {
21 20
22 templ InboxCounters(p InboxPage) {21 templ InboxCounters(p InboxPage) {
23 <p id="inbox-counts" class="inbox-counts">22 <p id="inbox-counts" class="inbox-counts">
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> running23+ <strong>{ itoa(p.MergeRequests) }</strong> merge requests · <strong>{ itoa(p.Issues) }</strong> issues · <strong>{ itoa(p.PendingCount) }</strong> awaiting moderation
25 </p>24 </p>
26 }25 }
27 26
@@ -114,35 +113,3 @@ templ inboxRows(rows []InboxRow, label string) {
114 </table>113 </table>
115 }114 }
116 }115 }
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 +173 -451 generated file: collapsed
M internal/adapters/web/inbox_test.go +4 -6
@@ -180,13 +180,12 @@ 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, 6)
183+ require.Len(t, frags, 5)
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-runs"`)
188- assert.Contains(t, frags[4].html, `id="inbox-mrs"`)
189- assert.Contains(t, frags[5].html, `id="inbox-issues"`)
187+ assert.Contains(t, frags[3].html, `id="inbox-mrs"`)
188+ assert.Contains(t, frags[4].html, `id="inbox-issues"`)
190189
191190 none, err := ls.render(withOwner(t.Context()), ports.WatchEvent{Event: domain.PushEvent{Repo: repoName(t, "beta")}})
192191 require.NoError(t, err)
@@ -287,7 +286,6 @@ func TestInboxRefreshesOnRepoEvents(t *testing.T) {
287286 t.Parallel()
288287 for name, ev := range map[string]domain.Event{
289288 "issues turned off": domain.RepoUpdated{Repo: repoName(t, "beta")},
290- "run updated": domain.RunUpdated{},
291289 "repo deleted": domain.RepoDeleted{Repo: repoName(t, "beta")},
292290 } {
293291 t.Run(name, func(t *testing.T) {
@@ -299,7 +297,7 @@ func TestInboxRefreshesOnRepoEvents(t *testing.T) {
299297 ls := &liveStream{s: srv, view: "inbox"}
300298 frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: ev})
301299 require.NoError(t, err)
302- require.Len(t, frags, 6)
300+ require.Len(t, frags, 5)
303301 all := ""
304302 for _, f := range frags {
305303 all += f.html
@@ -180,13 +180,12 @@ 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, 6)183+ require.Len(t, frags, 5)
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-runs"`)187+ assert.Contains(t, frags[3].html, `id="inbox-mrs"`)
188- assert.Contains(t, frags[4].html, `id="inbox-mrs"`)188+ assert.Contains(t, frags[4].html, `id="inbox-issues"`)
189- assert.Contains(t, frags[5].html, `id="inbox-issues"`)
190 189
191 none, err := ls.render(withOwner(t.Context()), ports.WatchEvent{Event: domain.PushEvent{Repo: repoName(t, "beta")}})190 none, err := ls.render(withOwner(t.Context()), ports.WatchEvent{Event: domain.PushEvent{Repo: repoName(t, "beta")}})
192 require.NoError(t, err)191 require.NoError(t, err)
@@ -287,7 +286,6 @@ func TestInboxRefreshesOnRepoEvents(t *testing.T) {
287 t.Parallel()286 t.Parallel()
288 for name, ev := range map[string]domain.Event{287 for name, ev := range map[string]domain.Event{
289 "issues turned off": domain.RepoUpdated{Repo: repoName(t, "beta")},288 "issues turned off": domain.RepoUpdated{Repo: repoName(t, "beta")},
290- "run updated": domain.RunUpdated{},
291 "repo deleted": domain.RepoDeleted{Repo: repoName(t, "beta")},289 "repo deleted": domain.RepoDeleted{Repo: repoName(t, "beta")},
292 } {290 } {
293 t.Run(name, func(t *testing.T) {291 t.Run(name, func(t *testing.T) {
@@ -299,7 +297,7 @@ func TestInboxRefreshesOnRepoEvents(t *testing.T) {
299 ls := &liveStream{s: srv, view: "inbox"}297 ls := &liveStream{s: srv, view: "inbox"}
300 frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: ev})298 frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: ev})
301 require.NoError(t, err)299 require.NoError(t, err)
302- require.Len(t, frags, 6)300+ require.Len(t, frags, 5)
303 all := ""301 all := ""
304 for _, f := range frags {302 for _, f := range frags {
305 all += f.html303 all += f.html
D internal/adapters/web/jstest/keys.test.mjs +0 -29
@@ -1,29 +0,0 @@
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-});
@@ -1,29 +0,0 @@
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/jstest/mr.test.mjs +12 -4
@@ -152,8 +152,10 @@ test('footer tabs: closed by default, toggle, switch, arrows, hash', () => {
152152 test('footer: #mr-ck syncs the checks label; its click opens Checks without toggling', () => {
153153 const text0 = { v: '' }; let writes = 0;
154154 const label = { get textContent() { return text0.v; }, set textContent(v) { writes++; text0.v = v; } };
155- const summary = { title: '1/2 failed', textContent: ' 1/2 ', parentElement: {} };
156- const h = footerHarness({ summary });
155+ const bar = {};
156+ const summary = { title: '1/2 failed', textContent: ' 1/2 ', closest: (s) => s === '.mr-bar' ? bar : null };
157+ const extra = { summary };
158+ const h = footerHarness(extra);
157159 let observed = null, callback = null;
158160 globalThis.MutationObserver = class { constructor(fn) { callback = fn; } observe(target, opts) { observed = [target, opts]; } };
159161 const nav = document.querySelector('[data-mr-tabs]');
@@ -161,8 +163,14 @@ test('footer: #mr-ck syncs the checks label; its click opens Checks without togg
161163 try {
162164 window.ermiteMR.panels();
163165 assert.equal(label.textContent, '1/2 failed', 'initial label sync');
164- assert.equal(observed[0], summary.parentElement);
165- assert.deepEqual(observed[1], { childList: true }, 'only #mr-ck replacements wake the observer');
166+ assert.equal(observed[0], bar);
167+ assert.deepEqual(observed[1], { childList: true, subtree: true }, 'the stable bar observes action and badge replacements');
168+ // Replace #mr-actions, including its badge, then replace the badge alone.
169+ extra.summary = { title: 'checks running', textContent: '' }; callback();
170+ assert.equal(label.textContent, 'checks running');
171+ extra.summary = { title: 'checks passed', textContent: '' }; callback();
172+ assert.equal(label.textContent, 'checks passed');
173+ extra.summary = summary; callback();
166174 const before = writes; callback(); callback();
167175 assert.equal(writes, before, 'an unchanged label is not written');
168176 summary.title = ''; callback();
@@ -152,8 +152,10 @@ test('footer tabs: closed by default, toggle, switch, arrows, hash', () => {
152 test('footer: #mr-ck syncs the checks label; its click opens Checks without toggling', () => {152 test('footer: #mr-ck syncs the checks label; its click opens Checks without toggling', () => {
153 const text0 = { v: '' }; let writes = 0;153 const text0 = { v: '' }; let writes = 0;
154 const label = { get textContent() { return text0.v; }, set textContent(v) { writes++; text0.v = v; } };154 const label = { get textContent() { return text0.v; }, set textContent(v) { writes++; text0.v = v; } };
155- const summary = { title: '1/2 failed', textContent: ' 1/2 ', parentElement: {} };155+ const bar = {};
156- const h = footerHarness({ summary });156+ const summary = { title: '1/2 failed', textContent: ' 1/2 ', closest: (s) => s === '.mr-bar' ? bar : null };
157+ const extra = { summary };
158+ const h = footerHarness(extra);
157 let observed = null, callback = null;159 let observed = null, callback = null;
158 globalThis.MutationObserver = class { constructor(fn) { callback = fn; } observe(target, opts) { observed = [target, opts]; } };160 globalThis.MutationObserver = class { constructor(fn) { callback = fn; } observe(target, opts) { observed = [target, opts]; } };
159 const nav = document.querySelector('[data-mr-tabs]');161 const nav = document.querySelector('[data-mr-tabs]');
@@ -161,8 +163,14 @@ test('footer: #mr-ck syncs the checks label; its click opens Checks without togg
161 try {163 try {
162 window.ermiteMR.panels();164 window.ermiteMR.panels();
163 assert.equal(label.textContent, '1/2 failed', 'initial label sync');165 assert.equal(label.textContent, '1/2 failed', 'initial label sync');
164- assert.equal(observed[0], summary.parentElement);166+ assert.equal(observed[0], bar);
165- assert.deepEqual(observed[1], { childList: true }, 'only #mr-ck replacements wake the observer');167+ assert.deepEqual(observed[1], { childList: true, subtree: true }, 'the stable bar observes action and badge replacements');
168+ // Replace #mr-actions, including its badge, then replace the badge alone.
169+ extra.summary = { title: 'checks running', textContent: '' }; callback();
170+ assert.equal(label.textContent, 'checks running');
171+ extra.summary = { title: 'checks passed', textContent: '' }; callback();
172+ assert.equal(label.textContent, 'checks passed');
173+ extra.summary = summary; callback();
166 const before = writes; callback(); callback();174 const before = writes; callback(); callback();
167 assert.equal(writes, before, 'an unchanged label is not written');175 assert.equal(writes, before, 'an unchanged label is not written');
168 summary.title = ''; callback();176 summary.title = ''; callback();
M internal/adapters/web/keys.templ +0 -1
@@ -28,7 +28,6 @@ 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]');
3231 k-next from:body -> ermite.nextNote();
3332 k-split from:body -> ermite.layout('split');
3433 k-unified from:body -> ermite.layout('unified');
@@ -28,7 +28,6 @@ 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]');
32 k-next from:body -> ermite.nextNote();31 k-next from:body -> ermite.nextNote();
33 k-split from:body -> ermite.layout('split');32 k-split from:body -> ermite.layout('split');
34 k-unified from:body -> ermite.layout('unified');33 k-unified from:body -> ermite.layout('unified');
M internal/adapters/web/keys_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/mr.go +7 -0
@@ -558,6 +558,10 @@ func (s *Server) mrSince(ctx context.Context, r *http.Request) (templ.Component,
558558 }
559559
560560 func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ.Component, int, error) {
561+ return s.mrPageView(ctx, r, since, false)
562+}
563+
564+func (s *Server) mrPageView(ctx context.Context, r *http.Request, since, fragment bool) (templ.Component, int, error) {
561565 if since && !SignedIn(ctx) {
562566 return nil, 0, errNotFound
563567 }
@@ -649,6 +653,9 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
649653 }
650654 }
651655
656+ if fragment {
657+ return MRStateFragment(p), http.StatusOK, nil
658+ }
652659 l := s.repoLayout(ctx, d, "merge-requests")
653660 l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
654661 if head != "" {
@@ -558,6 +558,10 @@ func (s *Server) mrSince(ctx context.Context, r *http.Request) (templ.Component,
558 }558 }
559 559
560 func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ.Component, int, error) {560 func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ.Component, int, error) {
561+ return s.mrPageView(ctx, r, since, false)
562+}
563+
564+func (s *Server) mrPageView(ctx context.Context, r *http.Request, since, fragment bool) (templ.Component, int, error) {
561 if since && !SignedIn(ctx) {565 if since && !SignedIn(ctx) {
562 return nil, 0, errNotFound566 return nil, 0, errNotFound
563 }567 }
@@ -649,6 +653,9 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
649 }653 }
650 }654 }
651 655
656+ if fragment {
657+ return MRStateFragment(p), http.StatusOK, nil
658+ }
652 l := s.repoLayout(ctx, d, "merge-requests")659 l := s.repoLayout(ctx, d, "merge-requests")
653 l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance660 l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
654 if head != "" {661 if head != "" {
M internal/adapters/web/mr.templ +62 -29
@@ -13,7 +13,7 @@ templ MRList(l Layout, p MRListPage) {
1313 @Shell(l, mrListRail(p)) {
1414 if p.Live {
1515 @LiveSlot("mr-notice")
16- <div id="mr-refresh-status" role="status" aria-live="polite"></div>
16+ <div id="mr-refresh-status" role="status" aria-live="polite"></div>
1717 }
1818 <div class="filter">
1919 <label for="mr-filter" class="sr">filter</label>
@@ -119,12 +119,7 @@ templ MergeRequest(l Layout, p MRPage) {
119119 <h1><span class="mute">{ p.Number }</span> { p.Title }</h1>
120120 <dl class="kv show-kv">
121121 <dt>state</dt>
122- <dd class="mr-state" data-state={ p.State }>
123- { p.State }
124- if p.MergedShort != "" {
125- { " · " }<a href={ templ.SafeURL(p.MergedHref) }>{ p.MergedShort }</a>{ " · " + p.MergeMode }
126- }
127- </dd>
122+ @mrState(p)
128123 <dt>branch</dt>
129124 <dd>
130125 @branchLink(p.Source, p.SourceHref)
@@ -215,19 +210,19 @@ templ MergeRequest(l Layout, p MRPage) {
215210 }
216211 </div>
217212 <div id="mr-comments">
218- <h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(p.NUnresolved) } open / { itoa(p.NComments) }</span></h2>
219- for _, c := range p.Loose {
220- @commentView(c, "")
221- }
222- for _, c := range p.General {
223- @commentView(c, "")
224- }
225- if p.NComments == 0 {
226- <p class="dim">no comments yet.</p>
227- }
228- if p.GeneralForm != nil {
229- @commentForm(*p.GeneralForm, "")
230- }
213+ <h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(p.NUnresolved) } open / { itoa(p.NComments) }</span></h2>
214+ for _, c := range p.Loose {
215+ @commentView(c, "")
216+ }
217+ for _, c := range p.General {
218+ @commentView(c, "")
219+ }
220+ if p.NComments == 0 {
221+ <p class="dim">no comments yet.</p>
222+ }
223+ if p.GeneralForm != nil {
224+ @commentForm(*p.GeneralForm, "")
225+ }
231226 </div>
232227 }
233228 }
@@ -467,13 +462,7 @@ templ mrBar(p MRPage) {
467462 <div class="diff-tools mr-bar" role="group" aria-label="Diff view and merge">
468463 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
469464 @diffViewMenu(p.DiffView)
470- <div class="mr-acts">
471- @MRChecksSum(p.ChecksSum)
472- if p.Box != nil {
473- @mergeMenu(p)
474- }
475- @moreMenu(p)
476- </div>
465+ @mrActions(p, false)
477466 </div>
478467 }
479468
@@ -523,7 +512,15 @@ templ mergeMenu(p MRPage) {
523512 </div>
524513 </form>
525514 }
526- <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }>
515+ <form
516+ class="d-acts"
517+ method="post"
518+ action={ templ.SafeURL(p.Box.MergeAction) }
519+ hx-post={ p.Box.MergeAction }
520+ hx-swap="none"
521+ hx-sync="this:drop"
522+ hx-disable="find button[type=submit]"
523+ >
527524 <input type="hidden" name="head_id" value={ p.Box.Head }/>
528525 <fieldset class="d-modes">
529526 <legend class="sr">merge mode</legend>
@@ -557,9 +554,10 @@ templ mergeMenu(p MRPage) {
557554 data-default
558555 disabled?={ !p.Box.CanMerge() }
559556 title={ p.Box.MergeTitle() }
560- :disabled="!this.form.querySelector('input[name=mode]:checked:not(:disabled)')"
557+ :disabled="this.form.classList.contains('htmx-request') || !this.form.querySelector('input[name=mode]:checked:not(:disabled)')"
561558 :title="(this.form.querySelector('input[name=mode]:checked') || {}).title || ''"
562559 >Merge into { p.Target }</button>
560+ <span class="htmx-indicator" role="status">Merging…</span>
563561 </div>
564562 </form>
565563 if p.Box.CanAutoMerge {
@@ -779,3 +777,38 @@ templ MRMergeChecks(c ChecksSummary) {
779777 }
780778 </div>
781779 }
780+
781+// MRStateFragment updates only state and actions, leaving review drafts alone.
782+templ MRStateFragment(p MRPage) {
783+ @mrState(p)
784+ @mrActions(p, true)
785+}
786+
787+templ mrState(p MRPage) {
788+ <dd id="mr-state" hx-swap-oob="true" class="mr-state" data-state={ p.State }>
789+ { p.State }
790+ if p.MergedShort != "" {
791+ { " · " }<a href={ templ.SafeURL(p.MergedHref) }>{ p.MergedShort }</a>{ " · " + p.MergeMode }
792+ }
793+ </dd>
794+}
795+
796+templ mrActions(p MRPage, fragment bool) {
797+ <div id="mr-actions" hx-swap-oob="true" class="mr-acts">
798+ @MRChecksSum(p.ChecksSum)
799+ if p.Box != nil {
800+ @mergeMenu(p)
801+ }
802+ @moreMenu(p)
803+ if fragment && p.MergeError != "" {
804+ <p class="d-err" role="alert">{ p.MergeError }</p>
805+ }
806+ </div>
807+}
808+
809+// MRActionStatus remains visible even when the request detail cannot be read.
810+templ MRActionStatus(message, href string) {
811+ <div id="mr-actions" class="mr-acts" hx-swap-oob="true">
812+ <p class="d-err" role="alert">{ message } <a href={ templ.SafeURL(href) }>reload</a></p>
813+ </div>
814+}
@@ -13,7 +13,7 @@ templ MRList(l Layout, p MRListPage) {
13 @Shell(l, mrListRail(p)) {13 @Shell(l, mrListRail(p)) {
14 if p.Live {14 if p.Live {
15 @LiveSlot("mr-notice")15 @LiveSlot("mr-notice")
16- <div id="mr-refresh-status" role="status" aria-live="polite"></div>16+ <div id="mr-refresh-status" role="status" aria-live="polite"></div>
17 }17 }
18 <div class="filter">18 <div class="filter">
19 <label for="mr-filter" class="sr">filter</label>19 <label for="mr-filter" class="sr">filter</label>
@@ -119,12 +119,7 @@ templ MergeRequest(l Layout, p MRPage) {
119 <h1><span class="mute">{ p.Number }</span> { p.Title }</h1>119 <h1><span class="mute">{ p.Number }</span> { p.Title }</h1>
120 <dl class="kv show-kv">120 <dl class="kv show-kv">
121 <dt>state</dt>121 <dt>state</dt>
122- <dd class="mr-state" data-state={ p.State }>122+ @mrState(p)
123- { p.State }
124- if p.MergedShort != "" {
125- { " · " }<a href={ templ.SafeURL(p.MergedHref) }>{ p.MergedShort }</a>{ " · " + p.MergeMode }
126- }
127- </dd>
128 <dt>branch</dt>123 <dt>branch</dt>
129 <dd>124 <dd>
130 @branchLink(p.Source, p.SourceHref)125 @branchLink(p.Source, p.SourceHref)
@@ -215,19 +210,19 @@ templ MergeRequest(l Layout, p MRPage) {
215 }210 }
216 </div>211 </div>
217 <div id="mr-comments">212 <div id="mr-comments">
218- <h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(p.NUnresolved) } open / { itoa(p.NComments) }</span></h2>213+ <h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(p.NUnresolved) } open / { itoa(p.NComments) }</span></h2>
219- for _, c := range p.Loose {214+ for _, c := range p.Loose {
220- @commentView(c, "")215+ @commentView(c, "")
221- }216+ }
222- for _, c := range p.General {217+ for _, c := range p.General {
223- @commentView(c, "")218+ @commentView(c, "")
224- }219+ }
225- if p.NComments == 0 {220+ if p.NComments == 0 {
226- <p class="dim">no comments yet.</p>221+ <p class="dim">no comments yet.</p>
227- }222+ }
228- if p.GeneralForm != nil {223+ if p.GeneralForm != nil {
229- @commentForm(*p.GeneralForm, "")224+ @commentForm(*p.GeneralForm, "")
230- }225+ }
231 </div>226 </div>
232 }227 }
233 }228 }
@@ -467,13 +462,7 @@ templ mrBar(p MRPage) {
467 <div class="diff-tools mr-bar" role="group" aria-label="Diff view and merge">462 <div class="diff-tools mr-bar" role="group" aria-label="Diff view and merge">
468 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>463 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
469 @diffViewMenu(p.DiffView)464 @diffViewMenu(p.DiffView)
470- <div class="mr-acts">465+ @mrActions(p, false)
471- @MRChecksSum(p.ChecksSum)
472- if p.Box != nil {
473- @mergeMenu(p)
474- }
475- @moreMenu(p)
476- </div>
477 </div>466 </div>
478 }467 }
479 468
@@ -523,7 +512,15 @@ templ mergeMenu(p MRPage) {
523 </div>512 </div>
524 </form>513 </form>
525 }514 }
526- <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }>515+ <form
516+ class="d-acts"
517+ method="post"
518+ action={ templ.SafeURL(p.Box.MergeAction) }
519+ hx-post={ p.Box.MergeAction }
520+ hx-swap="none"
521+ hx-sync="this:drop"
522+ hx-disable="find button[type=submit]"
523+ >
527 <input type="hidden" name="head_id" value={ p.Box.Head }/>524 <input type="hidden" name="head_id" value={ p.Box.Head }/>
528 <fieldset class="d-modes">525 <fieldset class="d-modes">
529 <legend class="sr">merge mode</legend>526 <legend class="sr">merge mode</legend>
@@ -557,9 +554,10 @@ templ mergeMenu(p MRPage) {
557 data-default554 data-default
558 disabled?={ !p.Box.CanMerge() }555 disabled?={ !p.Box.CanMerge() }
559 title={ p.Box.MergeTitle() }556 title={ p.Box.MergeTitle() }
560- :disabled="!this.form.querySelector('input[name=mode]:checked:not(:disabled)')"557+ :disabled="this.form.classList.contains('htmx-request') || !this.form.querySelector('input[name=mode]:checked:not(:disabled)')"
561 :title="(this.form.querySelector('input[name=mode]:checked') || {}).title || ''"558 :title="(this.form.querySelector('input[name=mode]:checked') || {}).title || ''"
562 >Merge into { p.Target }</button>559 >Merge into { p.Target }</button>
560+ <span class="htmx-indicator" role="status">Merging…</span>
563 </div>561 </div>
564 </form>562 </form>
565 if p.Box.CanAutoMerge {563 if p.Box.CanAutoMerge {
@@ -779,3 +777,38 @@ templ MRMergeChecks(c ChecksSummary) {
779 }777 }
780 </div>778 </div>
781 }779 }
780+
781+// MRStateFragment updates only state and actions, leaving review drafts alone.
782+templ MRStateFragment(p MRPage) {
783+ @mrState(p)
784+ @mrActions(p, true)
785+}
786+
787+templ mrState(p MRPage) {
788+ <dd id="mr-state" hx-swap-oob="true" class="mr-state" data-state={ p.State }>
789+ { p.State }
790+ if p.MergedShort != "" {
791+ { " · " }<a href={ templ.SafeURL(p.MergedHref) }>{ p.MergedShort }</a>{ " · " + p.MergeMode }
792+ }
793+ </dd>
794+}
795+
796+templ mrActions(p MRPage, fragment bool) {
797+ <div id="mr-actions" hx-swap-oob="true" class="mr-acts">
798+ @MRChecksSum(p.ChecksSum)
799+ if p.Box != nil {
800+ @mergeMenu(p)
801+ }
802+ @moreMenu(p)
803+ if fragment && p.MergeError != "" {
804+ <p class="d-err" role="alert">{ p.MergeError }</p>
805+ }
806+ </div>
807+}
808+
809+// MRActionStatus remains visible even when the request detail cannot be read.
810+templ MRActionStatus(message, href string) {
811+ <div id="mr-actions" class="mr-acts" hx-swap-oob="true">
812+ <p class="d-err" role="alert">{ message } <a href={ templ.SafeURL(href) }>reload</a></p>
813+ </div>
814+}
A internal/adapters/web/mr_async_test.go +166 -0
@@ -0,0 +1,166 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "net/http"
9+ "net/http/httptest"
10+ "strings"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/mock"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+ "bnema.dev/ermite/internal/ports/mocks"
21+)
22+
23+func asyncMergeServer(t *testing.T, state domain.MRState) (*Server, *mocks.MergeRequestService) {
24+ t.Helper()
25+ svc := mocks.NewMergeRequestService(t)
26+ browse := mocks.NewBrowseService(t)
27+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RefPage{Refs: []domain.Ref{{Kind: domain.RefKindBranch, ShortName: "feature"}}}, nil).Maybe()
28+ svc.EXPECT().Get(mock.Anything, mock.Anything, repoName(t, "demo"), int64(3), mock.Anything).Return(ports.MergeRequestDetail{
29+ MergeRequest: domain.MergeRequest{Repo: repoName(t, "demo"), Number: 3, State: state, HeadID: objectID(t, strings.Repeat("a", domain.ObjectIDLen)), Source: "feature", Target: "main"},
30+ Mergeability: domain.Mergeability{State: domain.MergeabilityUpToDate},
31+ }, nil).Maybe()
32+ return newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "demo")), Browse: browse, MergeRequests: svc, Now: time.Now}), svc
33+}
34+
35+func TestAsyncMergePost(t *testing.T) {
36+ for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash, domain.MergeModeMerge} {
37+ for _, hx := range []bool{false, true} {
38+ t.Run(mode.String()+"/"+boolText(hx), func(t *testing.T) {
39+ s, svc := asyncMergeServer(t, domain.MRStateMerged)
40+ svc.EXPECT().Merge(mock.Anything, mock.Anything, repoName(t, "demo"), int64(3), ports.MergeParams{Mode: mode}).Return(ports.MergeResult{}, nil).Once()
41+ r := autoMergeRequest("/demo/merge-requests/3/merge", "mode="+mode.String())
42+ if hx {
43+ r.Header.Set("HX-Request", "true")
44+ }
45+ w := httptest.NewRecorder()
46+ s.mrAction(s.mrMerge)(w, r)
47+ if !hx {
48+ assert.Equal(t, http.StatusSeeOther, w.Code)
49+ return
50+ }
51+ assert.Equal(t, http.StatusOK, w.Code)
52+ assert.Empty(t, w.Header().Get("Location"))
53+ assert.Contains(t, w.Body.String(), `id="mr-state"`)
54+ assert.Contains(t, w.Body.String(), `data-state="merged"`)
55+ assert.Contains(t, w.Body.String(), `id="mr-actions"`)
56+ assert.NotContains(t, w.Body.String(), "<html")
57+ assert.NotContains(t, w.Body.String(), `id="merge-menu"`)
58+ })
59+ }
60+ }
61+}
62+
63+func TestAsyncMergeRefusal(t *testing.T) {
64+ s, svc := asyncMergeServer(t, domain.MRStateOpen)
65+ svc.EXPECT().Merge(mock.Anything, mock.Anything, mock.Anything, int64(3), mock.Anything).Return(ports.MergeResult{}, domain.ErrConflict)
66+ r := autoMergeRequest("/demo/merge-requests/3/merge", "mode=squash")
67+ r = r.WithContext(context.WithValue(r.Context(), signedInKey{}, struct{}{}))
68+ r.Header.Set("HX-Request", "true")
69+ w := httptest.NewRecorder()
70+ s.mrAction(s.mrMerge)(w, r)
71+ assert.Equal(t, http.StatusConflict, w.Code)
72+ assert.Contains(t, w.Body.String(), `role="alert"`)
73+ assert.Contains(t, w.Body.String(), domain.ErrConflict.Error())
74+ assert.Contains(t, w.Body.String(), `hx-post="/demo/merge-requests/3/merge"`)
75+ assert.Contains(t, w.Body.String(), `hx-sync="this:drop"`)
76+ assert.Contains(t, w.Body.String(), "Merging…")
77+ assert.NotContains(t, w.Body.String(), "<html")
78+}
79+
80+func TestAsyncMergeLive(t *testing.T) {
81+ s, _ := asyncMergeServer(t, domain.MRStateMerged)
82+ l := liveStream{s: s, repo: "demo", view: "mr", n: "3"}
83+ fs, err := l.repoPage(t.Context(), watchOf(domain.MergeRequestMerged{MergeRequest: domain.MergeRequest{Number: 3}}))
84+ require.NoError(t, err)
85+ require.Len(t, fs, 1)
86+ assert.Equal(t, "mr-mergeability", fs[0].event)
87+ assert.Contains(t, fs[0].html, `data-state="merged"`)
88+ assert.Contains(t, fs[0].html, `id="mr-actions"`)
89+ assert.False(t, strings.Contains(fs[0].html, "reload"))
90+ fs, err = l.repoPage(t.Context(), watchOf(domain.MergeRequestMerged{MergeRequest: domain.MergeRequest{Number: 4}}))
91+ require.NoError(t, err)
92+ assert.Empty(t, fs)
93+}
94+
95+func TestAsyncMergeOtherFailures(t *testing.T) {
96+ for _, tt := range []struct {
97+ name string
98+ mergeErr error
99+ readFails bool
100+ status int
101+ message string
102+ }{
103+ {"forbidden", domain.ErrForbidden, false, http.StatusForbidden, "the session is not allowed"},
104+ {"internal", errors.New("private backend detail"), false, http.StatusInternalServerError, "reload to check its state"},
105+ {"post-merge read", nil, true, http.StatusOK, "merged, but the updated page could not be loaded"},
106+ } {
107+ t.Run(tt.name, func(t *testing.T) {
108+ s, svc := asyncMergeServer(t, domain.MRStateMerged)
109+ if tt.readFails {
110+ svc.On("Get", mock.Anything, mock.Anything, repoName(t, "demo"), int64(3), mock.Anything).Unset()
111+ svc.EXPECT().Get(mock.Anything, mock.Anything, repoName(t, "demo"), int64(3), mock.Anything).Return(ports.MergeRequestDetail{}, errors.New("private read detail")).Once()
112+ }
113+ svc.EXPECT().Merge(mock.Anything, mock.Anything, mock.Anything, int64(3), mock.Anything).Return(ports.MergeResult{}, tt.mergeErr).Once()
114+ r := autoMergeRequest("/demo/merge-requests/3/merge", "mode=squash")
115+ r.Header.Set("HX-Request", "true")
116+ w := httptest.NewRecorder()
117+ s.mrAction(s.mrMerge)(w, r)
118+ assert.Equal(t, tt.status, w.Code)
119+ assert.Contains(t, w.Body.String(), `id="mr-actions"`)
120+ assert.Contains(t, w.Body.String(), `hx-swap-oob="true"`)
121+ assert.Contains(t, w.Body.String(), `role="alert"`)
122+ assert.Contains(t, w.Body.String(), tt.message)
123+ assert.NotContains(t, w.Body.String(), "private")
124+ assert.NotContains(t, w.Body.String(), "<html")
125+ })
126+ }
127+}
128+
129+func TestMergeGateRefusalFragment(t *testing.T) {
130+ s, _ := newSessionServer(t, newSessions(t, "tok"), testPublicURL)
131+ for _, origin := range []string{testPublicURL, "https://other.example"} {
132+ for _, hx := range []bool{false, true} {
133+ r := httptest.NewRequest(http.MethodPost, "/demo/merge-requests/3/merge", strings.NewReader("mode=squash"))
134+ r.Header.Set("Origin", origin)
135+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
136+ if hx {
137+ r.Header.Set("HX-Request", "true")
138+ }
139+ w := httptest.NewRecorder()
140+ s.ServeHTTP(w, r)
141+ assert.Equal(t, http.StatusForbidden, w.Code)
142+ if hx {
143+ assert.Contains(t, w.Body.String(), `hx-swap-oob="true"`)
144+ assert.Contains(t, w.Body.String(), `id="mr-actions"`)
145+ if origin == testPublicURL {
146+ assert.Contains(t, w.Body.String(), "your session has expired")
147+ } else {
148+ assert.Contains(t, w.Body.String(), "origin was not accepted")
149+ }
150+ } else {
151+ assert.NotContains(t, w.Body.String(), "hx-swap-oob")
152+ }
153+ }
154+ }
155+}
156+
157+func TestNonHXMergeRefusalHasOneAlert(t *testing.T) {
158+ s, svc := asyncMergeServer(t, domain.MRStateMerged)
159+ svc.EXPECT().CountOpen(mock.Anything, mock.Anything, mock.Anything).Return(0, nil)
160+ svc.EXPECT().Merge(mock.Anything, mock.Anything, mock.Anything, int64(3), mock.Anything).Return(ports.MergeResult{}, domain.ErrConflict).Once()
161+ r := autoMergeRequest("/demo/merge-requests/3/merge", "mode=squash")
162+ w := httptest.NewRecorder()
163+ s.mrAction(s.mrMerge)(w, r)
164+ assert.Equal(t, http.StatusConflict, w.Code)
165+ assert.Equal(t, 1, strings.Count(w.Body.String(), `role="alert"`))
166+}
@@ -0,0 +1,166 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "net/http"
9+ "net/http/httptest"
10+ "strings"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/mock"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+ "bnema.dev/ermite/internal/ports/mocks"
21+)
22+
23+func asyncMergeServer(t *testing.T, state domain.MRState) (*Server, *mocks.MergeRequestService) {
24+ t.Helper()
25+ svc := mocks.NewMergeRequestService(t)
26+ browse := mocks.NewBrowseService(t)
27+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RefPage{Refs: []domain.Ref{{Kind: domain.RefKindBranch, ShortName: "feature"}}}, nil).Maybe()
28+ svc.EXPECT().Get(mock.Anything, mock.Anything, repoName(t, "demo"), int64(3), mock.Anything).Return(ports.MergeRequestDetail{
29+ MergeRequest: domain.MergeRequest{Repo: repoName(t, "demo"), Number: 3, State: state, HeadID: objectID(t, strings.Repeat("a", domain.ObjectIDLen)), Source: "feature", Target: "main"},
30+ Mergeability: domain.Mergeability{State: domain.MergeabilityUpToDate},
31+ }, nil).Maybe()
32+ return newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "demo")), Browse: browse, MergeRequests: svc, Now: time.Now}), svc
33+}
34+
35+func TestAsyncMergePost(t *testing.T) {
36+ for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash, domain.MergeModeMerge} {
37+ for _, hx := range []bool{false, true} {
38+ t.Run(mode.String()+"/"+boolText(hx), func(t *testing.T) {
39+ s, svc := asyncMergeServer(t, domain.MRStateMerged)
40+ svc.EXPECT().Merge(mock.Anything, mock.Anything, repoName(t, "demo"), int64(3), ports.MergeParams{Mode: mode}).Return(ports.MergeResult{}, nil).Once()
41+ r := autoMergeRequest("/demo/merge-requests/3/merge", "mode="+mode.String())
42+ if hx {
43+ r.Header.Set("HX-Request", "true")
44+ }
45+ w := httptest.NewRecorder()
46+ s.mrAction(s.mrMerge)(w, r)
47+ if !hx {
48+ assert.Equal(t, http.StatusSeeOther, w.Code)
49+ return
50+ }
51+ assert.Equal(t, http.StatusOK, w.Code)
52+ assert.Empty(t, w.Header().Get("Location"))
53+ assert.Contains(t, w.Body.String(), `id="mr-state"`)
54+ assert.Contains(t, w.Body.String(), `data-state="merged"`)
55+ assert.Contains(t, w.Body.String(), `id="mr-actions"`)
56+ assert.NotContains(t, w.Body.String(), "<html")
57+ assert.NotContains(t, w.Body.String(), `id="merge-menu"`)
58+ })
59+ }
60+ }
61+}
62+
63+func TestAsyncMergeRefusal(t *testing.T) {
64+ s, svc := asyncMergeServer(t, domain.MRStateOpen)
65+ svc.EXPECT().Merge(mock.Anything, mock.Anything, mock.Anything, int64(3), mock.Anything).Return(ports.MergeResult{}, domain.ErrConflict)
66+ r := autoMergeRequest("/demo/merge-requests/3/merge", "mode=squash")
67+ r = r.WithContext(context.WithValue(r.Context(), signedInKey{}, struct{}{}))
68+ r.Header.Set("HX-Request", "true")
69+ w := httptest.NewRecorder()
70+ s.mrAction(s.mrMerge)(w, r)
71+ assert.Equal(t, http.StatusConflict, w.Code)
72+ assert.Contains(t, w.Body.String(), `role="alert"`)
73+ assert.Contains(t, w.Body.String(), domain.ErrConflict.Error())
74+ assert.Contains(t, w.Body.String(), `hx-post="/demo/merge-requests/3/merge"`)
75+ assert.Contains(t, w.Body.String(), `hx-sync="this:drop"`)
76+ assert.Contains(t, w.Body.String(), "Merging…")
77+ assert.NotContains(t, w.Body.String(), "<html")
78+}
79+
80+func TestAsyncMergeLive(t *testing.T) {
81+ s, _ := asyncMergeServer(t, domain.MRStateMerged)
82+ l := liveStream{s: s, repo: "demo", view: "mr", n: "3"}
83+ fs, err := l.repoPage(t.Context(), watchOf(domain.MergeRequestMerged{MergeRequest: domain.MergeRequest{Number: 3}}))
84+ require.NoError(t, err)
85+ require.Len(t, fs, 1)
86+ assert.Equal(t, "mr-mergeability", fs[0].event)
87+ assert.Contains(t, fs[0].html, `data-state="merged"`)
88+ assert.Contains(t, fs[0].html, `id="mr-actions"`)
89+ assert.False(t, strings.Contains(fs[0].html, "reload"))
90+ fs, err = l.repoPage(t.Context(), watchOf(domain.MergeRequestMerged{MergeRequest: domain.MergeRequest{Number: 4}}))
91+ require.NoError(t, err)
92+ assert.Empty(t, fs)
93+}
94+
95+func TestAsyncMergeOtherFailures(t *testing.T) {
96+ for _, tt := range []struct {
97+ name string
98+ mergeErr error
99+ readFails bool
100+ status int
101+ message string
102+ }{
103+ {"forbidden", domain.ErrForbidden, false, http.StatusForbidden, "the session is not allowed"},
104+ {"internal", errors.New("private backend detail"), false, http.StatusInternalServerError, "reload to check its state"},
105+ {"post-merge read", nil, true, http.StatusOK, "merged, but the updated page could not be loaded"},
106+ } {
107+ t.Run(tt.name, func(t *testing.T) {
108+ s, svc := asyncMergeServer(t, domain.MRStateMerged)
109+ if tt.readFails {
110+ svc.On("Get", mock.Anything, mock.Anything, repoName(t, "demo"), int64(3), mock.Anything).Unset()
111+ svc.EXPECT().Get(mock.Anything, mock.Anything, repoName(t, "demo"), int64(3), mock.Anything).Return(ports.MergeRequestDetail{}, errors.New("private read detail")).Once()
112+ }
113+ svc.EXPECT().Merge(mock.Anything, mock.Anything, mock.Anything, int64(3), mock.Anything).Return(ports.MergeResult{}, tt.mergeErr).Once()
114+ r := autoMergeRequest("/demo/merge-requests/3/merge", "mode=squash")
115+ r.Header.Set("HX-Request", "true")
116+ w := httptest.NewRecorder()
117+ s.mrAction(s.mrMerge)(w, r)
118+ assert.Equal(t, tt.status, w.Code)
119+ assert.Contains(t, w.Body.String(), `id="mr-actions"`)
120+ assert.Contains(t, w.Body.String(), `hx-swap-oob="true"`)
121+ assert.Contains(t, w.Body.String(), `role="alert"`)
122+ assert.Contains(t, w.Body.String(), tt.message)
123+ assert.NotContains(t, w.Body.String(), "private")
124+ assert.NotContains(t, w.Body.String(), "<html")
125+ })
126+ }
127+}
128+
129+func TestMergeGateRefusalFragment(t *testing.T) {
130+ s, _ := newSessionServer(t, newSessions(t, "tok"), testPublicURL)
131+ for _, origin := range []string{testPublicURL, "https://other.example"} {
132+ for _, hx := range []bool{false, true} {
133+ r := httptest.NewRequest(http.MethodPost, "/demo/merge-requests/3/merge", strings.NewReader("mode=squash"))
134+ r.Header.Set("Origin", origin)
135+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
136+ if hx {
137+ r.Header.Set("HX-Request", "true")
138+ }
139+ w := httptest.NewRecorder()
140+ s.ServeHTTP(w, r)
141+ assert.Equal(t, http.StatusForbidden, w.Code)
142+ if hx {
143+ assert.Contains(t, w.Body.String(), `hx-swap-oob="true"`)
144+ assert.Contains(t, w.Body.String(), `id="mr-actions"`)
145+ if origin == testPublicURL {
146+ assert.Contains(t, w.Body.String(), "your session has expired")
147+ } else {
148+ assert.Contains(t, w.Body.String(), "origin was not accepted")
149+ }
150+ } else {
151+ assert.NotContains(t, w.Body.String(), "hx-swap-oob")
152+ }
153+ }
154+ }
155+}
156+
157+func TestNonHXMergeRefusalHasOneAlert(t *testing.T) {
158+ s, svc := asyncMergeServer(t, domain.MRStateMerged)
159+ svc.EXPECT().CountOpen(mock.Anything, mock.Anything, mock.Anything).Return(0, nil)
160+ svc.EXPECT().Merge(mock.Anything, mock.Anything, mock.Anything, int64(3), mock.Anything).Return(ports.MergeResult{}, domain.ErrConflict).Once()
161+ r := autoMergeRequest("/demo/merge-requests/3/merge", "mode=squash")
162+ w := httptest.NewRecorder()
163+ s.mrAction(s.mrMerge)(w, r)
164+ assert.Equal(t, http.StatusConflict, w.Code)
165+ assert.Equal(t, 1, strings.Count(w.Body.String(), `role="alert"`))
166+}
M internal/adapters/web/mr_post.go +33 -1
@@ -68,6 +68,20 @@ func (s *Server) mrAction(h func(ctx context.Context, r *http.Request) (to strin
6868 s.actionFailed(ctx, w, r, err, back)
6969 return
7070 }
71+ if isHXMerge(r) {
72+ c, _, err := s.mrPageView(ctx, r, false, true)
73+ if err != nil {
74+ id := requestID()
75+ log := zerowrap.FromCtx(ctx)
76+ log.Error().Err(err).Str("request_id", id).Msg("web merged state failed")
77+ w.Header().Set("Cache-Control", "no-store")
78+ s.write(ctx, w, r, http.StatusOK, MRActionStatus("merged, but the updated page could not be loaded. reload to see the result. reference: "+id, back))
79+ return
80+ }
81+ w.Header().Set("Cache-Control", "no-store")
82+ s.write(ctx, w, r, http.StatusOK, c)
83+ return
84+ }
7185 seeOther(w, r, to)
7286 }
7387 }
@@ -100,7 +114,7 @@ func mergeErrorOf(ctx context.Context) string {
100114 // It reports false when the page cannot be built; the caller then answers as
101115 // for any refused action.
102116 func (s *Server) rerenderMR(ctx context.Context, w http.ResponseWriter, r *http.Request, re *rerender) bool {
103- c, _, err := s.mrPage(withMergeError(ctx, re.msg), r, false)
117+ c, _, err := s.mrPageView(withMergeError(ctx, re.msg), r, false, isHXMerge(r))
104118 if err != nil {
105119 return false
106120 }
@@ -120,6 +134,10 @@ func (s *Server) actionFailed(ctx context.Context, w http.ResponseWriter, r *htt
120134 // The browser is gone.
121135 case errors.Is(err, domain.ErrNotFound):
122136 w.Header().Set("Cache-Control", "no-store")
137+ if isHXMerge(r) {
138+ s.write(ctx, w, r, http.StatusNotFound, MRActionStatus("the merge request was not found. reload to check its state.", back))
139+ return
140+ }
123141 s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage())
124142 case errors.Is(err, domain.ErrAlreadyExists):
125143 s.reject(ctx, w, r, http.StatusConflict, "already there", rule(msg, "this already exists."), back)
@@ -143,6 +161,10 @@ func (s *Server) internalFailure(ctx context.Context, w http.ResponseWriter, r *
143161 log := zerowrap.FromCtx(ctx)
144162 log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web action failed")
145163 w.Header().Set("Cache-Control", "no-store")
164+ if isHXMerge(r) {
165+ s.write(ctx, w, r, http.StatusInternalServerError, MRActionStatus("the merge could not be completed. reload to check its state. reference: "+id, strings.TrimSuffix(r.URL.Path, "/merge")))
166+ return
167+ }
146168 s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id))
147169 }
148170
@@ -161,6 +183,11 @@ func (s *Server) reject(ctx context.Context, w http.ResponseWriter, r *http.Requ
161183 if back == "" {
162184 back = "/"
163185 }
186+ if isHXMerge(r) {
187+ w.Header().Set("Cache-Control", "no-store")
188+ s.write(ctx, w, r, status, MRActionStatus(line, back))
189+ return
190+ }
164191 l := s.stateLayout(title, []Crumb{{Label: title, Current: true}})
165192 w.Header().Set("Cache-Control", "no-store")
166193 s.write(ctx, w, r, status, State(l, StatePage{
@@ -523,3 +550,8 @@ func (s *Server) mrDeleteComment(ctx context.Context, r *http.Request) (string,
523550 c, err := s.cfg.MergeRequests.DeleteComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")))
524551 return back + "#c" + strconv.FormatInt(c.ID, 10), back, err
525552 }
553+
554+// isHXMerge limits fragment responses to the progressively enhanced merge form.
555+func isHXMerge(r *http.Request) bool {
556+ return r.Header.Get("HX-Request") == "true" && strings.HasSuffix(r.URL.Path, "/merge")
557+}
@@ -68,6 +68,20 @@ func (s *Server) mrAction(h func(ctx context.Context, r *http.Request) (to strin
68 s.actionFailed(ctx, w, r, err, back)68 s.actionFailed(ctx, w, r, err, back)
69 return69 return
70 }70 }
71+ if isHXMerge(r) {
72+ c, _, err := s.mrPageView(ctx, r, false, true)
73+ if err != nil {
74+ id := requestID()
75+ log := zerowrap.FromCtx(ctx)
76+ log.Error().Err(err).Str("request_id", id).Msg("web merged state failed")
77+ w.Header().Set("Cache-Control", "no-store")
78+ s.write(ctx, w, r, http.StatusOK, MRActionStatus("merged, but the updated page could not be loaded. reload to see the result. reference: "+id, back))
79+ return
80+ }
81+ w.Header().Set("Cache-Control", "no-store")
82+ s.write(ctx, w, r, http.StatusOK, c)
83+ return
84+ }
71 seeOther(w, r, to)85 seeOther(w, r, to)
72 }86 }
73 }87 }
@@ -100,7 +114,7 @@ func mergeErrorOf(ctx context.Context) string {
100 // It reports false when the page cannot be built; the caller then answers as114 // It reports false when the page cannot be built; the caller then answers as
101 // for any refused action.115 // for any refused action.
102 func (s *Server) rerenderMR(ctx context.Context, w http.ResponseWriter, r *http.Request, re *rerender) bool {116 func (s *Server) rerenderMR(ctx context.Context, w http.ResponseWriter, r *http.Request, re *rerender) bool {
103- c, _, err := s.mrPage(withMergeError(ctx, re.msg), r, false)117+ c, _, err := s.mrPageView(withMergeError(ctx, re.msg), r, false, isHXMerge(r))
104 if err != nil {118 if err != nil {
105 return false119 return false
106 }120 }
@@ -120,6 +134,10 @@ func (s *Server) actionFailed(ctx context.Context, w http.ResponseWriter, r *htt
120 // The browser is gone.134 // The browser is gone.
121 case errors.Is(err, domain.ErrNotFound):135 case errors.Is(err, domain.ErrNotFound):
122 w.Header().Set("Cache-Control", "no-store")136 w.Header().Set("Cache-Control", "no-store")
137+ if isHXMerge(r) {
138+ s.write(ctx, w, r, http.StatusNotFound, MRActionStatus("the merge request was not found. reload to check its state.", back))
139+ return
140+ }
123 s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage())141 s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage())
124 case errors.Is(err, domain.ErrAlreadyExists):142 case errors.Is(err, domain.ErrAlreadyExists):
125 s.reject(ctx, w, r, http.StatusConflict, "already there", rule(msg, "this already exists."), back)143 s.reject(ctx, w, r, http.StatusConflict, "already there", rule(msg, "this already exists."), back)
@@ -143,6 +161,10 @@ func (s *Server) internalFailure(ctx context.Context, w http.ResponseWriter, r *
143 log := zerowrap.FromCtx(ctx)161 log := zerowrap.FromCtx(ctx)
144 log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web action failed")162 log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web action failed")
145 w.Header().Set("Cache-Control", "no-store")163 w.Header().Set("Cache-Control", "no-store")
164+ if isHXMerge(r) {
165+ s.write(ctx, w, r, http.StatusInternalServerError, MRActionStatus("the merge could not be completed. reload to check its state. reference: "+id, strings.TrimSuffix(r.URL.Path, "/merge")))
166+ return
167+ }
146 s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id))168 s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id))
147 }169 }
148 170
@@ -161,6 +183,11 @@ func (s *Server) reject(ctx context.Context, w http.ResponseWriter, r *http.Requ
161 if back == "" {183 if back == "" {
162 back = "/"184 back = "/"
163 }185 }
186+ if isHXMerge(r) {
187+ w.Header().Set("Cache-Control", "no-store")
188+ s.write(ctx, w, r, status, MRActionStatus(line, back))
189+ return
190+ }
164 l := s.stateLayout(title, []Crumb{{Label: title, Current: true}})191 l := s.stateLayout(title, []Crumb{{Label: title, Current: true}})
165 w.Header().Set("Cache-Control", "no-store")192 w.Header().Set("Cache-Control", "no-store")
166 s.write(ctx, w, r, status, State(l, StatePage{193 s.write(ctx, w, r, status, State(l, StatePage{
@@ -523,3 +550,8 @@ func (s *Server) mrDeleteComment(ctx context.Context, r *http.Request) (string,
523 c, err := s.cfg.MergeRequests.DeleteComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")))550 c, err := s.cfg.MergeRequests.DeleteComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")))
524 return back + "#c" + strconv.FormatInt(c.ID, 10), back, err551 return back + "#c" + strconv.FormatInt(c.ID, 10), back, err
525 }552 }
553+
554+// isHXMerge limits fragment responses to the progressively enhanced merge form.
555+func isHXMerge(r *http.Request) bool {
556+ return r.Header.Get("HX-Request") == "true" && strings.HasSuffix(r.URL.Path, "/merge")
557+}
M internal/adapters/web/mr_templ.go +1226 -1034 generated file: collapsed
M internal/adapters/web/overlays.templ +0 -3
@@ -27,9 +27,6 @@ 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- }
3330 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>
3431 <div><dt><kbd>w</kbd></dt><dd>wide mode: use the whole width of the screen, or not (remembered)</dd></div>
3532 <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div>
@@ -27,9 +27,6 @@ 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- }
33 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>30 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>
34 <div><dt><kbd>w</kbd></dt><dd>wide mode: use the whole width of the screen, or not (remembered)</dd></div>31 <div><dt><kbd>w</kbd></dt><dd>wide mode: use the whole width of the screen, or not (remembered)</dd></div>
35 <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div>32 <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div>
M internal/adapters/web/overlays_templ.go +39 -49 generated file: collapsed
M internal/adapters/web/session.go +12 -2
@@ -178,11 +178,11 @@ func (s *Server) gate(w http.ResponseWriter, r *http.Request) bool {
178178 // Host header, which the client chooses. A missing header, "null" and any
179179 // other origin are refused.
180180 if r.Header.Get("Origin") != s.origin {
181- denyForbidden(w)
181+ s.denyMergeGate(w, r, "the request origin was not accepted. reload this page and try again.")
182182 return false
183183 }
184184 if !SignedIn(r.Context()) && !s.anonymousPost(r) {
185- denyForbidden(w)
185+ s.denyMergeGate(w, r, "your session has expired. sign in again and reload.")
186186 return false
187187 }
188188 return true
@@ -335,3 +335,13 @@ func (s *Server) loginRejectedPage() templ.Component {
335335 return s.stateOnly("sign in", "404", "login link not valid",
336336 "this link has expired, was already used, or never existed.", "run `ermite web login` for a new one.")
337337 }
338+
339+// denyMergeGate keeps gate refusals visible to the enhanced merge form.
340+func (s *Server) denyMergeGate(w http.ResponseWriter, r *http.Request, message string) {
341+ if !isHXMerge(r) {
342+ denyForbidden(w)
343+ return
344+ }
345+ w.Header().Set("Cache-Control", "no-store")
346+ s.write(r.Context(), w, r, http.StatusForbidden, MRActionStatus(message, strings.TrimSuffix(r.URL.Path, "/merge")))
347+}
@@ -178,11 +178,11 @@ func (s *Server) gate(w http.ResponseWriter, r *http.Request) bool {
178 // Host header, which the client chooses. A missing header, "null" and any178 // Host header, which the client chooses. A missing header, "null" and any
179 // other origin are refused.179 // other origin are refused.
180 if r.Header.Get("Origin") != s.origin {180 if r.Header.Get("Origin") != s.origin {
181- denyForbidden(w)181+ s.denyMergeGate(w, r, "the request origin was not accepted. reload this page and try again.")
182 return false182 return false
183 }183 }
184 if !SignedIn(r.Context()) && !s.anonymousPost(r) {184 if !SignedIn(r.Context()) && !s.anonymousPost(r) {
185- denyForbidden(w)185+ s.denyMergeGate(w, r, "your session has expired. sign in again and reload.")
186 return false186 return false
187 }187 }
188 return true188 return true
@@ -335,3 +335,13 @@ func (s *Server) loginRejectedPage() templ.Component {
335 return s.stateOnly("sign in", "404", "login link not valid",335 return s.stateOnly("sign in", "404", "login link not valid",
336 "this link has expired, was already used, or never existed.", "run `ermite web login` for a new one.")336 "this link has expired, was already used, or never existed.", "run `ermite web login` for a new one.")
337 }337 }
338+
339+// denyMergeGate keeps gate refusals visible to the enhanced merge form.
340+func (s *Server) denyMergeGate(w http.ResponseWriter, r *http.Request, message string) {
341+ if !isHXMerge(r) {
342+ denyForbidden(w)
343+ return
344+ }
345+ w.Header().Set("Cache-Control", "no-store")
346+ s.write(r.Context(), w, r, http.StatusForbidden, MRActionStatus(message, strings.TrimSuffix(r.URL.Path, "/merge")))
347+}
M internal/adapters/web/shell.templ +1 -5
@@ -67,9 +67,6 @@ 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- }
7370 @OverlayHelp(l.MR)
7471 @OverlayRefs(l)
7572 @OverlayCmd(l.Cmd)
@@ -90,8 +87,7 @@ templ Header(l Layout) {
9087 <a
9188 class="btn inbox-btn"
9289 href={ templ.SafeURL(l.Inbox.Href) }
93- title="inbox (I)"
94- aria-keyshortcuts="Shift+I"
90+ title="inbox"
9591 >
9692 <span class="ico i-mailbox" aria-hidden="true"></span>
9793 Inbox
@@ -67,9 +67,6 @@ 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- }
73 @OverlayHelp(l.MR)70 @OverlayHelp(l.MR)
74 @OverlayRefs(l)71 @OverlayRefs(l)
75 @OverlayCmd(l.Cmd)72 @OverlayCmd(l.Cmd)
@@ -90,8 +87,7 @@ templ Header(l Layout) {
90 <a87 <a
91 class="btn inbox-btn"88 class="btn inbox-btn"
92 href={ templ.SafeURL(l.Inbox.Href) }89 href={ templ.SafeURL(l.Inbox.Href) }
93- title="inbox (I)"90+ title="inbox"
94- aria-keyshortcuts="Shift+I"
95 >91 >
96 <span class="ico i-mailbox" aria-hidden="true"></span>92 <span class="ico i-mailbox" aria-hidden="true"></span>
97 Inbox93 Inbox
M internal/adapters/web/shell_templ.go +133 -152 generated file: collapsed
M internal/adapters/web/static/css/60-mr.css +2 -0
@@ -1,6 +1,8 @@
11 @layer components {
22 /* merge requests: the review page. Hue only where it carries a verdict. */
33 .mr-h .show-kv { margin-top: 0; }
4+ #merge-menu .htmx-indicator { display: none; }
5+ #merge-menu .htmx-request .htmx-indicator { display: inline; }
46 /* one card: the header (title, facts) a shade lighter, the description below */
57 .mr-card {
68 background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); overflow: hidden;
@@ -1,6 +1,8 @@
1 @layer components {1 @layer components {
2 /* merge requests: the review page. Hue only where it carries a verdict. */2 /* merge requests: the review page. Hue only where it carries a verdict. */
3 .mr-h .show-kv { margin-top: 0; }3 .mr-h .show-kv { margin-top: 0; }
4+ #merge-menu .htmx-indicator { display: none; }
5+ #merge-menu .htmx-request .htmx-indicator { display: inline; }
4 /* one card: the header (title, facts) a shade lighter, the description below */6 /* one card: the header (title, facts) a shade lighter, the description below */
5 .mr-card {7 .mr-card {
6 background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); overflow: hidden;8 background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); overflow: hidden;
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', I: 'inbox', 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', 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 || (name === 'inbox' && !$('[data-inbox]'))) return;
657+ if (!name) 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', I: 'inbox', 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', 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 || (name === 'inbox' && !$('[data-inbox]'))) return;657+ if (!name) 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/adapters/web/static/js/mr.js +3 -3
M internal/app/forge.go +1 -1
M internal/app/testdata/web/blob-binary.html +0 -1
M internal/app/testdata/web/blob-code.html +0 -1
M internal/app/testdata/web/blob-empty.html +0 -1
M internal/app/testdata/web/blob-image.html +0 -1
M internal/app/testdata/web/blob-markdown.html +0 -1
M internal/app/testdata/web/blob-svg.html +0 -1
M internal/app/testdata/web/blob-text.html +0 -1
M internal/app/testdata/web/blob-too-large.html +0 -1
M internal/app/testdata/web/blob-trojan.html +0 -1
M internal/app/testdata/web/commit-big.html +0 -1
M internal/app/testdata/web/commit-binary.html +0 -1
M internal/app/testdata/web/commit-merge.html +0 -1
M internal/app/testdata/web/commit-modify-split.html +0 -1
M internal/app/testdata/web/commit-modify.html +0 -1
M internal/app/testdata/web/commit-rename.html +0 -1
M internal/app/testdata/web/commit-root.html +0 -1
M internal/app/testdata/web/commit-trojan.html +0 -1
M internal/app/testdata/web/commits-page2.html +0 -1
M internal/app/testdata/web/commits-path.html +0 -1
M internal/app/testdata/web/commits-root.html +0 -1
M internal/app/testdata/web/commits-small.html +0 -1
M internal/app/testdata/web/empty.html +0 -1
M internal/app/testdata/web/home.html +0 -1
M internal/app/testdata/web/not-found-page.html +0 -1
M internal/app/testdata/web/not-found.html +0 -1
M internal/app/testdata/web/refs.html +0 -1
M internal/app/testdata/web/summary.html +0 -1
M internal/app/testdata/web/tree-nested.html +0 -1
M internal/app/testdata/web/tree-root.html +0 -1
M internal/app/testdata/web/tree-slash-ref.html +0 -1
M internal/app/testdata/web/tree-subdir.html +0 -1
M internal/app/web_inbox_test.go +2 -32
M internal/ports/dto.go +3 -6
M internal/usecase/events.go +2 -2
M internal/usecase/inbox.go +2 -31
M internal/usecase/inbox_test.go +0 -33
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 41

↑↓ moveenter openesc close