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
@@ -51,27 +51,17 @@ func OverlayHelp(mr bool) templ.Component {
5151 if templ_7745c5c3_Err != nil {
5252 return templ_7745c5c3_Err
5353 }
54- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " branch or tag</dd></div><div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd> <kbd>i</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd></div>")
55- if templ_7745c5c3_Err != nil {
56- return templ_7745c5c3_Err
57- }
58- if SignedIn(ctx) {
59- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div><dt><kbd>I</kbd></dt><dd>inbox</dd></div>")
60- if templ_7745c5c3_Err != nil {
61- return templ_7745c5c3_Err
62- }
63- }
64- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div><dt><kbd>g h</kbd></dt><dd>home</dd></div><div><dt><kbd>w</kbd></dt><dd>wide mode: use the whole width of the screen, or not (remembered)</dd></div><div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div><div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div><div><dt><kbd>e</kbd> <kbd>E</kbd></dt><dd>diff: the whole file in view / every file, with the lines the hunks hide</dd></div><div><dt><kbd>n</kbd></dt><dd>merge request: next note in the diff</dd></div>")
54+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " branch or tag</dd></div><div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd> <kbd>i</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd></div><div><dt><kbd>g h</kbd></dt><dd>home</dd></div><div><dt><kbd>w</kbd></dt><dd>wide mode: use the whole width of the screen, or not (remembered)</dd></div><div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div><div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div><div><dt><kbd>e</kbd> <kbd>E</kbd></dt><dd>diff: the whole file in view / every file, with the lines the hunks hide</dd></div><div><dt><kbd>n</kbd></dt><dd>merge request: next note in the diff</dd></div>")
6555 if templ_7745c5c3_Err != nil {
6656 return templ_7745c5c3_Err
6757 }
6858 if mr {
69- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div><dt><kbd>g f</kbd></dt><dd>merge request: the list of files (j k move, enter opens, esc back)</dd></div><div><dt><kbd>M</kbd></dt><dd>merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges</dd></div><div><dt><kbd>R</kbd></dt><dd>merge request: mark the head read</dd></div>")
59+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div><dt><kbd>g f</kbd></dt><dd>merge request: the list of files (j k move, enter opens, esc back)</dd></div><div><dt><kbd>M</kbd></dt><dd>merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges</dd></div><div><dt><kbd>R</kbd></dt><dd>merge request: mark the head read</dd></div>")
7060 if templ_7745c5c3_Err != nil {
7161 return templ_7745c5c3_Err
7262 }
7363 }
74- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div><dt><kbd>esc</kbd></dt><dd>close a dialog, leave a field</dd></div></dl></dialog>")
64+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div><dt><kbd>esc</kbd></dt><dd>close a dialog, leave a field</dd></div></dl></dialog>")
7565 if templ_7745c5c3_Err != nil {
7666 return templ_7745c5c3_Err
7767 }
@@ -102,20 +92,20 @@ func OverlayRefs(l Layout) templ.Component {
10292 }
10393 ctx = templ.ClearChildren(ctx)
10494 if l.Ref != "" {
105- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<dialog id=\"panel-refs\" class=\"panel modal\" aria-label=\"Switch ref\" hx-on:keydown=\"event.key === 'Escape' && (event.preventDefault(), this.close())\" hx-on:click=\"event.target === this && this.close()\"><div class=\"panel-head\"><label for=\"refs-filter\" class=\"sr\">filter branches and tags</label> <input id=\"refs-filter\" type=\"search\" placeholder=\"Switch branch or tag…\" autocomplete=\"off\" data-pick=\"#panel-refs li\" hx-on:input=\"ermite.filter(this)\"> <button type=\"button\" class=\"panel-x\" aria-label=\"Close\" hx-on:click=\"closest('dialog').close()\"><kbd>esc</kbd></button></div><div class=\"panel-cols\" data-picklist><section aria-label=\"Branches\"><h2>Branches <span class=\"count\">")
95+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<dialog id=\"panel-refs\" class=\"panel modal\" aria-label=\"Switch ref\" hx-on:keydown=\"event.key === 'Escape' && (event.preventDefault(), this.close())\" hx-on:click=\"event.target === this && this.close()\"><div class=\"panel-head\"><label for=\"refs-filter\" class=\"sr\">filter branches and tags</label> <input id=\"refs-filter\" type=\"search\" placeholder=\"Switch branch or tag…\" autocomplete=\"off\" data-pick=\"#panel-refs li\" hx-on:input=\"ermite.filter(this)\"> <button type=\"button\" class=\"panel-x\" aria-label=\"Close\" hx-on:click=\"closest('dialog').close()\"><kbd>esc</kbd></button></div><div class=\"panel-cols\" data-picklist><section aria-label=\"Branches\"><h2>Branches <span class=\"count\">")
10696 if templ_7745c5c3_Err != nil {
10797 return templ_7745c5c3_Err
10898 }
10999 var templ_7745c5c3_Var4 string
110100 templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(l.Heads)))
111101 if templ_7745c5c3_Err != nil {
112- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 67, Col: 58}
102+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 64, Col: 58}
113103 }
114104 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
115105 if templ_7745c5c3_Err != nil {
116106 return templ_7745c5c3_Err
117107 }
118- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span></h2><ul>")
108+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</span></h2><ul>")
119109 if templ_7745c5c3_Err != nil {
120110 return templ_7745c5c3_Err
121111 }
@@ -125,20 +115,20 @@ func OverlayRefs(l Layout) templ.Component {
125115 return templ_7745c5c3_Err
126116 }
127117 }
128- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</ul></section><section aria-label=\"Tags\"><h2>Tags <span class=\"count\">")
118+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</ul></section><section aria-label=\"Tags\"><h2>Tags <span class=\"count\">")
129119 if templ_7745c5c3_Err != nil {
130120 return templ_7745c5c3_Err
131121 }
132122 var templ_7745c5c3_Var5 string
133123 templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(l.Tags)))
134124 if templ_7745c5c3_Err != nil {
135- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 75, Col: 53}
125+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 72, Col: 53}
136126 }
137127 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
138128 if templ_7745c5c3_Err != nil {
139129 return templ_7745c5c3_Err
140130 }
141- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</span></h2><ul>")
131+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span></h2><ul>")
142132 if templ_7745c5c3_Err != nil {
143133 return templ_7745c5c3_Err
144134 }
@@ -148,7 +138,7 @@ func OverlayRefs(l Layout) templ.Component {
148138 return templ_7745c5c3_Err
149139 }
150140 }
151- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</ul></section></div></dialog>")
141+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</ul></section></div></dialog>")
152142 if templ_7745c5c3_Err != nil {
153143 return templ_7745c5c3_Err
154144 }
@@ -178,58 +168,58 @@ func refItem(r RefItem) templ.Component {
178168 templ_7745c5c3_Var6 = templ.NopComponent
179169 }
180170 ctx = templ.ClearChildren(ctx)
181- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<li><a href=\"")
171+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<li><a href=\"")
182172 if templ_7745c5c3_Err != nil {
183173 return templ_7745c5c3_Err
184174 }
185175 var templ_7745c5c3_Var7 templ.SafeURL
186176 templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
187177 if templ_7745c5c3_Err != nil {
188- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 90, Col: 31}
178+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 87, Col: 31}
189179 }
190180 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
191181 if templ_7745c5c3_Err != nil {
192182 return templ_7745c5c3_Err
193183 }
194- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\"")
184+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\"")
195185 if templ_7745c5c3_Err != nil {
196186 return templ_7745c5c3_Err
197187 }
198188 if r.Current {
199- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, " aria-current=\"true\"")
189+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " aria-current=\"true\"")
200190 if templ_7745c5c3_Err != nil {
201191 return templ_7745c5c3_Err
202192 }
203193 }
204- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "><span class=\"mark\" aria-hidden=\"true\">")
194+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "><span class=\"mark\" aria-hidden=\"true\">")
205195 if templ_7745c5c3_Err != nil {
206196 return templ_7745c5c3_Err
207197 }
208198 if r.Current {
209- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "●")
199+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "●")
210200 if templ_7745c5c3_Err != nil {
211201 return templ_7745c5c3_Err
212202 }
213203 } else {
214- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "&nbsp;")
204+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "&nbsp;")
215205 if templ_7745c5c3_Err != nil {
216206 return templ_7745c5c3_Err
217207 }
218208 }
219- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</span>")
209+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</span>")
220210 if templ_7745c5c3_Err != nil {
221211 return templ_7745c5c3_Err
222212 }
223213 var templ_7745c5c3_Var8 string
224214 templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
225215 if templ_7745c5c3_Err != nil {
226- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 101, Col: 18}
216+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 98, Col: 18}
227217 }
228218 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
229219 if templ_7745c5c3_Err != nil {
230220 return templ_7745c5c3_Err
231221 }
232- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</a></li>")
222+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</a></li>")
233223 if templ_7745c5c3_Err != nil {
234224 return templ_7745c5c3_Err
235225 }
@@ -260,118 +250,118 @@ func OverlayCmd(items []CmdItem) templ.Component {
260250 templ_7745c5c3_Var9 = templ.NopComponent
261251 }
262252 ctx = templ.ClearChildren(ctx)
263- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<dialog id=\"panel-cmd\" class=\"panel modal\" aria-label=\"Go to\" hx-on:close=\"ermiteFinder.paletteClose()\" hx-on:keydown=\"event.key === 'Escape' && (event.preventDefault(), this.close())\" hx-on:click=\"event.target === this && this.close()\"><div class=\"panel-head\"><label for=\"cmd-q\" class=\"sr\">go to</label> <input id=\"cmd-q\" type=\"search\" placeholder=\"Go to a section, branch, tag, file or repository…\" autocomplete=\"off\" spellcheck=\"false\" role=\"combobox\" aria-autocomplete=\"list\" aria-expanded=\"true\" aria-controls=\"cmd-list\" aria-activedescendant=\"cmd-o0\" hx-on:input=\"ermite.cmdFilter(this); ermiteFinder.palette(this)\" hx-on:keydown=\"ermite.cmdKey(event)\"> <button type=\"button\" class=\"panel-x\" aria-label=\"Close\" hx-on:click=\"closest('dialog').close()\"><kbd>esc</kbd></button></div><ul id=\"cmd-list\" class=\"cmd-list\" role=\"listbox\" aria-label=\"Targets\">")
253+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<dialog id=\"panel-cmd\" class=\"panel modal\" aria-label=\"Go to\" hx-on:close=\"ermiteFinder.paletteClose()\" hx-on:keydown=\"event.key === 'Escape' && (event.preventDefault(), this.close())\" hx-on:click=\"event.target === this && this.close()\"><div class=\"panel-head\"><label for=\"cmd-q\" class=\"sr\">go to</label> <input id=\"cmd-q\" type=\"search\" placeholder=\"Go to a section, branch, tag, file or repository…\" autocomplete=\"off\" spellcheck=\"false\" role=\"combobox\" aria-autocomplete=\"list\" aria-expanded=\"true\" aria-controls=\"cmd-list\" aria-activedescendant=\"cmd-o0\" hx-on:input=\"ermite.cmdFilter(this); ermiteFinder.palette(this)\" hx-on:keydown=\"ermite.cmdKey(event)\"> <button type=\"button\" class=\"panel-x\" aria-label=\"Close\" hx-on:click=\"closest('dialog').close()\"><kbd>esc</kbd></button></div><ul id=\"cmd-list\" class=\"cmd-list\" role=\"listbox\" aria-label=\"Targets\">")
264254 if templ_7745c5c3_Err != nil {
265255 return templ_7745c5c3_Err
266256 }
267257 for i, c := range items {
268- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<li id=\"")
258+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<li id=\"")
269259 if templ_7745c5c3_Err != nil {
270260 return templ_7745c5c3_Err
271261 }
272262 var templ_7745c5c3_Var10 string
273263 templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("cmd-o" + itoa(i))
274264 if templ_7745c5c3_Err != nil {
275- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 130, Col: 30}
265+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 127, Col: 30}
276266 }
277267 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
278268 if templ_7745c5c3_Err != nil {
279269 return templ_7745c5c3_Err
280270 }
281- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" role=\"option\" aria-selected=\"")
271+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" role=\"option\" aria-selected=\"")
282272 if templ_7745c5c3_Err != nil {
283273 return templ_7745c5c3_Err
284274 }
285275 var templ_7745c5c3_Var11 string
286276 templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(strconv.FormatBool(i == 0))
287277 if templ_7745c5c3_Err != nil {
288- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 130, Col: 89}
278+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 127, Col: 89}
289279 }
290280 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
291281 if templ_7745c5c3_Err != nil {
292282 return templ_7745c5c3_Err
293283 }
294- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" data-k=\"")
284+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" data-k=\"")
295285 if templ_7745c5c3_Err != nil {
296286 return templ_7745c5c3_Err
297287 }
298288 var templ_7745c5c3_Var12 string
299289 templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(strings.ToLower(c.Kind + " " + c.Label))
300290 if templ_7745c5c3_Err != nil {
301- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 130, Col: 140}
291+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 127, Col: 140}
302292 }
303293 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
304294 if templ_7745c5c3_Err != nil {
305295 return templ_7745c5c3_Err
306296 }
307- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\"")
297+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\"")
308298 if templ_7745c5c3_Err != nil {
309299 return templ_7745c5c3_Err
310300 }
311301 if i == 0 {
312- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " data-cursor")
302+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, " data-cursor")
313303 if templ_7745c5c3_Err != nil {
314304 return templ_7745c5c3_Err
315305 }
316306 }
317- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "><a href=\"")
307+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "><a href=\"")
318308 if templ_7745c5c3_Err != nil {
319309 return templ_7745c5c3_Err
320310 }
321311 var templ_7745c5c3_Var13 templ.SafeURL
322312 templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
323313 if templ_7745c5c3_Err != nil {
324- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 130, Col: 198}
314+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 127, Col: 198}
325315 }
326316 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
327317 if templ_7745c5c3_Err != nil {
328318 return templ_7745c5c3_Err
329319 }
330- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" tabindex=\"-1\"><span class=\"lbl\">")
320+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" tabindex=\"-1\"><span class=\"lbl\">")
331321 if templ_7745c5c3_Err != nil {
332322 return templ_7745c5c3_Err
333323 }
334324 var templ_7745c5c3_Var14 string
335325 templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
336326 if templ_7745c5c3_Err != nil {
337- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 130, Col: 242}
327+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 127, Col: 242}
338328 }
339329 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
340330 if templ_7745c5c3_Err != nil {
341331 return templ_7745c5c3_Err
342332 }
343- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</span><span class=\"k\">")
333+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</span><span class=\"k\">")
344334 if templ_7745c5c3_Err != nil {
345335 return templ_7745c5c3_Err
346336 }
347337 var templ_7745c5c3_Var15 string
348338 templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(c.Kind)
349339 if templ_7745c5c3_Err != nil {
350- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 130, Col: 275}
340+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 127, Col: 275}
351341 }
352342 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
353343 if templ_7745c5c3_Err != nil {
354344 return templ_7745c5c3_Err
355345 }
356- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</span></a></li>")
346+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</span></a></li>")
357347 if templ_7745c5c3_Err != nil {
358348 return templ_7745c5c3_Err
359349 }
360350 }
361- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<li id=\"")
351+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<li id=\"")
362352 if templ_7745c5c3_Err != nil {
363353 return templ_7745c5c3_Err
364354 }
365355 var templ_7745c5c3_Var16 string
366356 templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("cmd-o" + itoa(len(items)))
367357 if templ_7745c5c3_Err != nil {
368- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 132, Col: 38}
358+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 129, Col: 38}
369359 }
370360 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
371361 if templ_7745c5c3_Err != nil {
372362 return templ_7745c5c3_Err
373363 }
374- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" role=\"option\" aria-selected=\"false\" data-k=\"action turn single-key shortcuts on off keyboard\"><a href=\"#\" data-fire=\"keys\" tabindex=\"-1\"><span class=\"lbl\">turn single-key shortcuts off</span><span class=\"k\">action</span></a></li></ul><p class=\"panel-foot\"><span><kbd>↑</kbd><kbd>↓</kbd> move</span><span><kbd>enter</kbd> open</span><span><kbd>esc</kbd> close</span></p></dialog>")
364+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" role=\"option\" aria-selected=\"false\" data-k=\"action turn single-key shortcuts on off keyboard\"><a href=\"#\" data-fire=\"keys\" tabindex=\"-1\"><span class=\"lbl\">turn single-key shortcuts off</span><span class=\"k\">action</span></a></li></ul><p class=\"panel-foot\"><span><kbd>↑</kbd><kbd>↓</kbd> move</span><span><kbd>enter</kbd> open</span><span><kbd>esc</kbd> close</span></p></dialog>")
375365 if templ_7745c5c3_Err != nil {
376366 return templ_7745c5c3_Err
377367 }
@@ -51,27 +51,17 @@ func OverlayHelp(mr bool) templ.Component {
51 if templ_7745c5c3_Err != nil {51 if templ_7745c5c3_Err != nil {
52 return templ_7745c5c3_Err52 return templ_7745c5c3_Err
53 }53 }
54- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " branch or tag</dd></div><div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd> <kbd>i</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd></div>")54+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " branch or tag</dd></div><div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd> <kbd>i</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd></div><div><dt><kbd>g h</kbd></dt><dd>home</dd></div><div><dt><kbd>w</kbd></dt><dd>wide mode: use the whole width of the screen, or not (remembered)</dd></div><div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div><div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div><div><dt><kbd>e</kbd> <kbd>E</kbd></dt><dd>diff: the whole file in view / every file, with the lines the hunks hide</dd></div><div><dt><kbd>n</kbd></dt><dd>merge request: next note in the diff</dd></div>")
55- if templ_7745c5c3_Err != nil {
56- return templ_7745c5c3_Err
57- }
58- if SignedIn(ctx) {
59- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div><dt><kbd>I</kbd></dt><dd>inbox</dd></div>")
60- if templ_7745c5c3_Err != nil {
61- return templ_7745c5c3_Err
62- }
63- }
64- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div><dt><kbd>g h</kbd></dt><dd>home</dd></div><div><dt><kbd>w</kbd></dt><dd>wide mode: use the whole width of the screen, or not (remembered)</dd></div><div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div><div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div><div><dt><kbd>e</kbd> <kbd>E</kbd></dt><dd>diff: the whole file in view / every file, with the lines the hunks hide</dd></div><div><dt><kbd>n</kbd></dt><dd>merge request: next note in the diff</dd></div>")
65 if templ_7745c5c3_Err != nil {55 if templ_7745c5c3_Err != nil {
66 return templ_7745c5c3_Err56 return templ_7745c5c3_Err
67 }57 }
68 if mr {58 if mr {
69- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div><dt><kbd>g f</kbd></dt><dd>merge request: the list of files (j k move, enter opens, esc back)</dd></div><div><dt><kbd>M</kbd></dt><dd>merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges</dd></div><div><dt><kbd>R</kbd></dt><dd>merge request: mark the head read</dd></div>")59+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div><dt><kbd>g f</kbd></dt><dd>merge request: the list of files (j k move, enter opens, esc back)</dd></div><div><dt><kbd>M</kbd></dt><dd>merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges</dd></div><div><dt><kbd>R</kbd></dt><dd>merge request: mark the head read</dd></div>")
70 if templ_7745c5c3_Err != nil {60 if templ_7745c5c3_Err != nil {
71 return templ_7745c5c3_Err61 return templ_7745c5c3_Err
72 }62 }
73 }63 }
74- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div><dt><kbd>esc</kbd></dt><dd>close a dialog, leave a field</dd></div></dl></dialog>")64+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div><dt><kbd>esc</kbd></dt><dd>close a dialog, leave a field</dd></div></dl></dialog>")
75 if templ_7745c5c3_Err != nil {65 if templ_7745c5c3_Err != nil {
76 return templ_7745c5c3_Err66 return templ_7745c5c3_Err
77 }67 }
@@ -102,20 +92,20 @@ func OverlayRefs(l Layout) templ.Component {
102 }92 }
103 ctx = templ.ClearChildren(ctx)93 ctx = templ.ClearChildren(ctx)
104 if l.Ref != "" {94 if l.Ref != "" {
105- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<dialog id=\"panel-refs\" class=\"panel modal\" aria-label=\"Switch ref\" hx-on:keydown=\"event.key === 'Escape' && (event.preventDefault(), this.close())\" hx-on:click=\"event.target === this && this.close()\"><div class=\"panel-head\"><label for=\"refs-filter\" class=\"sr\">filter branches and tags</label> <input id=\"refs-filter\" type=\"search\" placeholder=\"Switch branch or tag…\" autocomplete=\"off\" data-pick=\"#panel-refs li\" hx-on:input=\"ermite.filter(this)\"> <button type=\"button\" class=\"panel-x\" aria-label=\"Close\" hx-on:click=\"closest('dialog').close()\"><kbd>esc</kbd></button></div><div class=\"panel-cols\" data-picklist><section aria-label=\"Branches\"><h2>Branches <span class=\"count\">")95+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<dialog id=\"panel-refs\" class=\"panel modal\" aria-label=\"Switch ref\" hx-on:keydown=\"event.key === 'Escape' && (event.preventDefault(), this.close())\" hx-on:click=\"event.target === this && this.close()\"><div class=\"panel-head\"><label for=\"refs-filter\" class=\"sr\">filter branches and tags</label> <input id=\"refs-filter\" type=\"search\" placeholder=\"Switch branch or tag…\" autocomplete=\"off\" data-pick=\"#panel-refs li\" hx-on:input=\"ermite.filter(this)\"> <button type=\"button\" class=\"panel-x\" aria-label=\"Close\" hx-on:click=\"closest('dialog').close()\"><kbd>esc</kbd></button></div><div class=\"panel-cols\" data-picklist><section aria-label=\"Branches\"><h2>Branches <span class=\"count\">")
106 if templ_7745c5c3_Err != nil {96 if templ_7745c5c3_Err != nil {
107 return templ_7745c5c3_Err97 return templ_7745c5c3_Err
108 }98 }
109 var templ_7745c5c3_Var4 string99 var templ_7745c5c3_Var4 string
110 templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(l.Heads)))100 templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(l.Heads)))
111 if templ_7745c5c3_Err != nil {101 if templ_7745c5c3_Err != nil {
112- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 67, Col: 58}102+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 64, Col: 58}
113 }103 }
114 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))104 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
115 if templ_7745c5c3_Err != nil {105 if templ_7745c5c3_Err != nil {
116 return templ_7745c5c3_Err106 return templ_7745c5c3_Err
117 }107 }
118- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span></h2><ul>")108+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</span></h2><ul>")
119 if templ_7745c5c3_Err != nil {109 if templ_7745c5c3_Err != nil {
120 return templ_7745c5c3_Err110 return templ_7745c5c3_Err
121 }111 }
@@ -125,20 +115,20 @@ func OverlayRefs(l Layout) templ.Component {
125 return templ_7745c5c3_Err115 return templ_7745c5c3_Err
126 }116 }
127 }117 }
128- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</ul></section><section aria-label=\"Tags\"><h2>Tags <span class=\"count\">")118+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</ul></section><section aria-label=\"Tags\"><h2>Tags <span class=\"count\">")
129 if templ_7745c5c3_Err != nil {119 if templ_7745c5c3_Err != nil {
130 return templ_7745c5c3_Err120 return templ_7745c5c3_Err
131 }121 }
132 var templ_7745c5c3_Var5 string122 var templ_7745c5c3_Var5 string
133 templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(l.Tags)))123 templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(l.Tags)))
134 if templ_7745c5c3_Err != nil {124 if templ_7745c5c3_Err != nil {
135- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 75, Col: 53}125+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 72, Col: 53}
136 }126 }
137 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))127 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
138 if templ_7745c5c3_Err != nil {128 if templ_7745c5c3_Err != nil {
139 return templ_7745c5c3_Err129 return templ_7745c5c3_Err
140 }130 }
141- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</span></h2><ul>")131+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span></h2><ul>")
142 if templ_7745c5c3_Err != nil {132 if templ_7745c5c3_Err != nil {
143 return templ_7745c5c3_Err133 return templ_7745c5c3_Err
144 }134 }
@@ -148,7 +138,7 @@ func OverlayRefs(l Layout) templ.Component {
148 return templ_7745c5c3_Err138 return templ_7745c5c3_Err
149 }139 }
150 }140 }
151- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</ul></section></div></dialog>")141+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</ul></section></div></dialog>")
152 if templ_7745c5c3_Err != nil {142 if templ_7745c5c3_Err != nil {
153 return templ_7745c5c3_Err143 return templ_7745c5c3_Err
154 }144 }
@@ -178,58 +168,58 @@ func refItem(r RefItem) templ.Component {
178 templ_7745c5c3_Var6 = templ.NopComponent168 templ_7745c5c3_Var6 = templ.NopComponent
179 }169 }
180 ctx = templ.ClearChildren(ctx)170 ctx = templ.ClearChildren(ctx)
181- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<li><a href=\"")171+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<li><a href=\"")
182 if templ_7745c5c3_Err != nil {172 if templ_7745c5c3_Err != nil {
183 return templ_7745c5c3_Err173 return templ_7745c5c3_Err
184 }174 }
185 var templ_7745c5c3_Var7 templ.SafeURL175 var templ_7745c5c3_Var7 templ.SafeURL
186 templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))176 templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
187 if templ_7745c5c3_Err != nil {177 if templ_7745c5c3_Err != nil {
188- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 90, Col: 31}178+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 87, Col: 31}
189 }179 }
190 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))180 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
191 if templ_7745c5c3_Err != nil {181 if templ_7745c5c3_Err != nil {
192 return templ_7745c5c3_Err182 return templ_7745c5c3_Err
193 }183 }
194- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\"")184+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\"")
195 if templ_7745c5c3_Err != nil {185 if templ_7745c5c3_Err != nil {
196 return templ_7745c5c3_Err186 return templ_7745c5c3_Err
197 }187 }
198 if r.Current {188 if r.Current {
199- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, " aria-current=\"true\"")189+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " aria-current=\"true\"")
200 if templ_7745c5c3_Err != nil {190 if templ_7745c5c3_Err != nil {
201 return templ_7745c5c3_Err191 return templ_7745c5c3_Err
202 }192 }
203 }193 }
204- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "><span class=\"mark\" aria-hidden=\"true\">")194+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "><span class=\"mark\" aria-hidden=\"true\">")
205 if templ_7745c5c3_Err != nil {195 if templ_7745c5c3_Err != nil {
206 return templ_7745c5c3_Err196 return templ_7745c5c3_Err
207 }197 }
208 if r.Current {198 if r.Current {
209- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "●")199+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "●")
210 if templ_7745c5c3_Err != nil {200 if templ_7745c5c3_Err != nil {
211 return templ_7745c5c3_Err201 return templ_7745c5c3_Err
212 }202 }
213 } else {203 } else {
214- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "&nbsp;")204+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "&nbsp;")
215 if templ_7745c5c3_Err != nil {205 if templ_7745c5c3_Err != nil {
216 return templ_7745c5c3_Err206 return templ_7745c5c3_Err
217 }207 }
218 }208 }
219- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</span>")209+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</span>")
220 if templ_7745c5c3_Err != nil {210 if templ_7745c5c3_Err != nil {
221 return templ_7745c5c3_Err211 return templ_7745c5c3_Err
222 }212 }
223 var templ_7745c5c3_Var8 string213 var templ_7745c5c3_Var8 string
224 templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)214 templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
225 if templ_7745c5c3_Err != nil {215 if templ_7745c5c3_Err != nil {
226- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 101, Col: 18}216+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 98, Col: 18}
227 }217 }
228 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))218 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
229 if templ_7745c5c3_Err != nil {219 if templ_7745c5c3_Err != nil {
230 return templ_7745c5c3_Err220 return templ_7745c5c3_Err
231 }221 }
232- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</a></li>")222+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</a></li>")
233 if templ_7745c5c3_Err != nil {223 if templ_7745c5c3_Err != nil {
234 return templ_7745c5c3_Err224 return templ_7745c5c3_Err
235 }225 }
@@ -260,118 +250,118 @@ func OverlayCmd(items []CmdItem) templ.Component {
260 templ_7745c5c3_Var9 = templ.NopComponent250 templ_7745c5c3_Var9 = templ.NopComponent
261 }251 }
262 ctx = templ.ClearChildren(ctx)252 ctx = templ.ClearChildren(ctx)
263- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<dialog id=\"panel-cmd\" class=\"panel modal\" aria-label=\"Go to\" hx-on:close=\"ermiteFinder.paletteClose()\" hx-on:keydown=\"event.key === 'Escape' && (event.preventDefault(), this.close())\" hx-on:click=\"event.target === this && this.close()\"><div class=\"panel-head\"><label for=\"cmd-q\" class=\"sr\">go to</label> <input id=\"cmd-q\" type=\"search\" placeholder=\"Go to a section, branch, tag, file or repository…\" autocomplete=\"off\" spellcheck=\"false\" role=\"combobox\" aria-autocomplete=\"list\" aria-expanded=\"true\" aria-controls=\"cmd-list\" aria-activedescendant=\"cmd-o0\" hx-on:input=\"ermite.cmdFilter(this); ermiteFinder.palette(this)\" hx-on:keydown=\"ermite.cmdKey(event)\"> <button type=\"button\" class=\"panel-x\" aria-label=\"Close\" hx-on:click=\"closest('dialog').close()\"><kbd>esc</kbd></button></div><ul id=\"cmd-list\" class=\"cmd-list\" role=\"listbox\" aria-label=\"Targets\">")253+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<dialog id=\"panel-cmd\" class=\"panel modal\" aria-label=\"Go to\" hx-on:close=\"ermiteFinder.paletteClose()\" hx-on:keydown=\"event.key === 'Escape' && (event.preventDefault(), this.close())\" hx-on:click=\"event.target === this && this.close()\"><div class=\"panel-head\"><label for=\"cmd-q\" class=\"sr\">go to</label> <input id=\"cmd-q\" type=\"search\" placeholder=\"Go to a section, branch, tag, file or repository…\" autocomplete=\"off\" spellcheck=\"false\" role=\"combobox\" aria-autocomplete=\"list\" aria-expanded=\"true\" aria-controls=\"cmd-list\" aria-activedescendant=\"cmd-o0\" hx-on:input=\"ermite.cmdFilter(this); ermiteFinder.palette(this)\" hx-on:keydown=\"ermite.cmdKey(event)\"> <button type=\"button\" class=\"panel-x\" aria-label=\"Close\" hx-on:click=\"closest('dialog').close()\"><kbd>esc</kbd></button></div><ul id=\"cmd-list\" class=\"cmd-list\" role=\"listbox\" aria-label=\"Targets\">")
264 if templ_7745c5c3_Err != nil {254 if templ_7745c5c3_Err != nil {
265 return templ_7745c5c3_Err255 return templ_7745c5c3_Err
266 }256 }
267 for i, c := range items {257 for i, c := range items {
268- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<li id=\"")258+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<li id=\"")
269 if templ_7745c5c3_Err != nil {259 if templ_7745c5c3_Err != nil {
270 return templ_7745c5c3_Err260 return templ_7745c5c3_Err
271 }261 }
272 var templ_7745c5c3_Var10 string262 var templ_7745c5c3_Var10 string
273 templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("cmd-o" + itoa(i))263 templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("cmd-o" + itoa(i))
274 if templ_7745c5c3_Err != nil {264 if templ_7745c5c3_Err != nil {
275- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 130, Col: 30}265+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 127, Col: 30}
276 }266 }
277 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)267 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
278 if templ_7745c5c3_Err != nil {268 if templ_7745c5c3_Err != nil {
279 return templ_7745c5c3_Err269 return templ_7745c5c3_Err
280 }270 }
281- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" role=\"option\" aria-selected=\"")271+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" role=\"option\" aria-selected=\"")
282 if templ_7745c5c3_Err != nil {272 if templ_7745c5c3_Err != nil {
283 return templ_7745c5c3_Err273 return templ_7745c5c3_Err
284 }274 }
285 var templ_7745c5c3_Var11 string275 var templ_7745c5c3_Var11 string
286 templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(strconv.FormatBool(i == 0))276 templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(strconv.FormatBool(i == 0))
287 if templ_7745c5c3_Err != nil {277 if templ_7745c5c3_Err != nil {
288- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 130, Col: 89}278+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 127, Col: 89}
289 }279 }
290 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)280 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
291 if templ_7745c5c3_Err != nil {281 if templ_7745c5c3_Err != nil {
292 return templ_7745c5c3_Err282 return templ_7745c5c3_Err
293 }283 }
294- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" data-k=\"")284+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" data-k=\"")
295 if templ_7745c5c3_Err != nil {285 if templ_7745c5c3_Err != nil {
296 return templ_7745c5c3_Err286 return templ_7745c5c3_Err
297 }287 }
298 var templ_7745c5c3_Var12 string288 var templ_7745c5c3_Var12 string
299 templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(strings.ToLower(c.Kind + " " + c.Label))289 templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(strings.ToLower(c.Kind + " " + c.Label))
300 if templ_7745c5c3_Err != nil {290 if templ_7745c5c3_Err != nil {
301- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 130, Col: 140}291+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 127, Col: 140}
302 }292 }
303 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)293 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
304 if templ_7745c5c3_Err != nil {294 if templ_7745c5c3_Err != nil {
305 return templ_7745c5c3_Err295 return templ_7745c5c3_Err
306 }296 }
307- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\"")297+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\"")
308 if templ_7745c5c3_Err != nil {298 if templ_7745c5c3_Err != nil {
309 return templ_7745c5c3_Err299 return templ_7745c5c3_Err
310 }300 }
311 if i == 0 {301 if i == 0 {
312- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " data-cursor")302+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, " data-cursor")
313 if templ_7745c5c3_Err != nil {303 if templ_7745c5c3_Err != nil {
314 return templ_7745c5c3_Err304 return templ_7745c5c3_Err
315 }305 }
316 }306 }
317- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "><a href=\"")307+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "><a href=\"")
318 if templ_7745c5c3_Err != nil {308 if templ_7745c5c3_Err != nil {
319 return templ_7745c5c3_Err309 return templ_7745c5c3_Err
320 }310 }
321 var templ_7745c5c3_Var13 templ.SafeURL311 var templ_7745c5c3_Var13 templ.SafeURL
322 templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))312 templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
323 if templ_7745c5c3_Err != nil {313 if templ_7745c5c3_Err != nil {
324- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 130, Col: 198}314+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 127, Col: 198}
325 }315 }
326 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))316 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
327 if templ_7745c5c3_Err != nil {317 if templ_7745c5c3_Err != nil {
328 return templ_7745c5c3_Err318 return templ_7745c5c3_Err
329 }319 }
330- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" tabindex=\"-1\"><span class=\"lbl\">")320+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" tabindex=\"-1\"><span class=\"lbl\">")
331 if templ_7745c5c3_Err != nil {321 if templ_7745c5c3_Err != nil {
332 return templ_7745c5c3_Err322 return templ_7745c5c3_Err
333 }323 }
334 var templ_7745c5c3_Var14 string324 var templ_7745c5c3_Var14 string
335 templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)325 templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
336 if templ_7745c5c3_Err != nil {326 if templ_7745c5c3_Err != nil {
337- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 130, Col: 242}327+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 127, Col: 242}
338 }328 }
339 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))329 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
340 if templ_7745c5c3_Err != nil {330 if templ_7745c5c3_Err != nil {
341 return templ_7745c5c3_Err331 return templ_7745c5c3_Err
342 }332 }
343- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</span><span class=\"k\">")333+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</span><span class=\"k\">")
344 if templ_7745c5c3_Err != nil {334 if templ_7745c5c3_Err != nil {
345 return templ_7745c5c3_Err335 return templ_7745c5c3_Err
346 }336 }
347 var templ_7745c5c3_Var15 string337 var templ_7745c5c3_Var15 string
348 templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(c.Kind)338 templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(c.Kind)
349 if templ_7745c5c3_Err != nil {339 if templ_7745c5c3_Err != nil {
350- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 130, Col: 275}340+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 127, Col: 275}
351 }341 }
352 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))342 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
353 if templ_7745c5c3_Err != nil {343 if templ_7745c5c3_Err != nil {
354 return templ_7745c5c3_Err344 return templ_7745c5c3_Err
355 }345 }
356- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</span></a></li>")346+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</span></a></li>")
357 if templ_7745c5c3_Err != nil {347 if templ_7745c5c3_Err != nil {
358 return templ_7745c5c3_Err348 return templ_7745c5c3_Err
359 }349 }
360 }350 }
361- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<li id=\"")351+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<li id=\"")
362 if templ_7745c5c3_Err != nil {352 if templ_7745c5c3_Err != nil {
363 return templ_7745c5c3_Err353 return templ_7745c5c3_Err
364 }354 }
365 var templ_7745c5c3_Var16 string355 var templ_7745c5c3_Var16 string
366 templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("cmd-o" + itoa(len(items)))356 templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("cmd-o" + itoa(len(items)))
367 if templ_7745c5c3_Err != nil {357 if templ_7745c5c3_Err != nil {
368- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 132, Col: 38}358+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 129, Col: 38}
369 }359 }
370 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)360 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
371 if templ_7745c5c3_Err != nil {361 if templ_7745c5c3_Err != nil {
372 return templ_7745c5c3_Err362 return templ_7745c5c3_Err
373 }363 }
374- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" role=\"option\" aria-selected=\"false\" data-k=\"action turn single-key shortcuts on off keyboard\"><a href=\"#\" data-fire=\"keys\" tabindex=\"-1\"><span class=\"lbl\">turn single-key shortcuts off</span><span class=\"k\">action</span></a></li></ul><p class=\"panel-foot\"><span><kbd>↑</kbd><kbd>↓</kbd> move</span><span><kbd>enter</kbd> open</span><span><kbd>esc</kbd> close</span></p></dialog>")364+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" role=\"option\" aria-selected=\"false\" data-k=\"action turn single-key shortcuts on off keyboard\"><a href=\"#\" data-fire=\"keys\" tabindex=\"-1\"><span class=\"lbl\">turn single-key shortcuts off</span><span class=\"k\">action</span></a></li></ul><p class=\"panel-foot\"><span><kbd>↑</kbd><kbd>↓</kbd> move</span><span><kbd>enter</kbd> open</span><span><kbd>esc</kbd> close</span></p></dialog>")
375 if templ_7745c5c3_Err != nil {365 if templ_7745c5c3_Err != nil {
376 return templ_7745c5c3_Err366 return templ_7745c5c3_Err
377 }367 }
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 42

↑↓ moveenter openesc close