detached e9c6360

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

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

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

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

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

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

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

Checks 4

64 files · +1924 -2040
4/4
M docs/issues.md +1 -3
@@ -50,9 +50,7 @@ Limits: the 100 most recent commits of the merge request, 16 KiB of each text, 1
5050
5151 The signed-in owner has an Inbox at `/-/inbox`, reached from the **Inbox** button at the top left of the home page, which shows the number of open items. It lists the open merge requests and the open issues of every repository, most recently updated first. The counters at the top (`N merge requests · M issues`) always count everything; `?repo=<name>` restricts the two lists to one repository, and the repository links under the counters set it. Repositories with issues off contribute no issues.
5252
53-The **Running** section, after **Awaiting moderation**, lists queued and in-progress CI runs newest first, with links to each run, its repository, ref, and start time. The `N running` counter counts every repository; the repository filter also applies to this section. **Shift+I** (`I`) opens the inbox from any page for the signed-in owner, unless single-key shortcuts are off; lowercase `i` still opens the issues tab.
54-
55-The page updates live: run status changes, merge request and issue events of any repository refresh the counters and the lists. Anyone who is not the signed-in owner, including a token, gets the same 404 as for an unknown page, and the same answer for the `owner` event stream it follows.
53+The page updates live: merge request and issue events of any repository refresh the counters and the lists. Anyone who is not the signed-in owner, including a token, gets the same 404 as for an unknown page, and the same answer for the `owner` event stream it follows.
5654
5755 ## Public form and moderation
5856
@@ -50,9 +50,7 @@ Limits: the 100 most recent commits of the merge request, 16 KiB of each text, 1
50 50
51 The signed-in owner has an Inbox at `/-/inbox`, reached from the **Inbox** button at the top left of the home page, which shows the number of open items. It lists the open merge requests and the open issues of every repository, most recently updated first. The counters at the top (`N merge requests · M issues`) always count everything; `?repo=<name>` restricts the two lists to one repository, and the repository links under the counters set it. Repositories with issues off contribute no issues.51 The signed-in owner has an Inbox at `/-/inbox`, reached from the **Inbox** button at the top left of the home page, which shows the number of open items. It lists the open merge requests and the open issues of every repository, most recently updated first. The counters at the top (`N merge requests · M issues`) always count everything; `?repo=<name>` restricts the two lists to one repository, and the repository links under the counters set it. Repositories with issues off contribute no issues.
52 52
53-The **Running** section, after **Awaiting moderation**, lists queued and in-progress CI runs newest first, with links to each run, its repository, ref, and start time. The `N running` counter counts every repository; the repository filter also applies to this section. **Shift+I** (`I`) opens the inbox from any page for the signed-in owner, unless single-key shortcuts are off; lowercase `i` still opens the issues tab.53+The page updates live: merge request and issue events of any repository refresh the counters and the lists. Anyone who is not the signed-in owner, including a token, gets the same 404 as for an unknown page, and the same answer for the `owner` event stream it follows.
54-
55-The page updates live: run status changes, merge request and issue events of any repository refresh the counters and the lists. Anyone who is not the signed-in owner, including a token, gets the same 404 as for an unknown page, and the same answer for the `owner` event stream it follows.
56 54
57 ## Public form and moderation55 ## Public form and moderation
58 56
M docs/releases.md +5 -44
@@ -57,60 +57,21 @@ the S3 endpoint. Stored-token publishers need `release:write` and, for private
5757 repository reads, `repo:read`. The owner component is `ERMITE_OWNER` (`owner` by
5858 default).
5959
60-GoReleaser does not read `GITHUB_API_URL` or `GITHUB_SERVER_URL` by itself, so
61-template `github_urls` from them (replace the fallback host and repository):
60+GoReleaser configuration (replace the host and repository):
6261
6362 ```yaml
6463 version: 2
65-# In CI the runner sets GITHUB_API_URL and GITHUB_SERVER_URL to the address the
66-# job can reach; the public URLs are the fallback for a local run.
6764 github_urls:
68- api: '{{ or (index .Env "GITHUB_API_URL") "https://git.example.org/-/github/api" }}/'
69- upload: '{{ or (index .Env "GITHUB_API_URL") "https://git.example.org/-/github/api" }}/uploads/'
70- download: '{{ or (index .Env "GITHUB_SERVER_URL") "https://git.example.org" }}/'
65+ api: https://git.example.org/-/github/api/
66+ upload: https://git.example.org/-/github/api/uploads/
67+ download: https://git.example.org/
7168 release:
7269 github:
7370 owner: owner
7471 name: app
7572 ```
7673
77-In a CI job the runner rewrites the server and API URLs to an origin the job
78-can reach, which may differ from the public URL (for example `http://ermite:7341`
79-with a relayed forge). Sandboxes block private and CGNAT ranges, so a job that
80-targets only the public URL times out (`dial tcp 100.x:443: i/o timeout`). A
81-plain-http relay needs no TLS option. The API base gets `api/v3/` appended by
82-the client; ermite accepts that alias. Locally, run
83-`GITHUB_TOKEN=<token> goreleaser release --clean` from a tagged checkout; the
84-fallbacks apply.
85-
86-Workflow:
87-
88-```yaml
89-name: release
90-on:
91- push:
92- tags: ['v*']
93-permissions:
94- contents: write
95-jobs:
96- release:
97- runs-on: ubuntu-latest
98- steps:
99- - uses: actions/checkout@v4
100- with:
101- fetch-depth: 0
102- - uses: actions/setup-go@v5
103- with:
104- go-version-file: go.mod
105- - uses: goreleaser/goreleaser-action@v6
106- with:
107- args: release --clean
108- env:
109- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
110-```
111-
112-An existing GitHub workflow must be enabled on ermite:
113-`ermite workflow enable <repo> .github/workflows/release.yml`.
74+Run `GITHUB_TOKEN=<token> goreleaser release --clean` from a tagged checkout.
11475
11576 On an ermite runner, the job supplies `GITHUB_API_URL`, `GITHUB_SERVER_URL` and
11677 `GITHUB_TOKEN`; no endpoint override is needed for action-gh-release:
@@ -57,60 +57,21 @@ the S3 endpoint. Stored-token publishers need `release:write` and, for private
57 repository reads, `repo:read`. The owner component is `ERMITE_OWNER` (`owner` by57 repository reads, `repo:read`. The owner component is `ERMITE_OWNER` (`owner` by
58 default).58 default).
59 59
60-GoReleaser does not read `GITHUB_API_URL` or `GITHUB_SERVER_URL` by itself, so60+GoReleaser configuration (replace the host and repository):
61-template `github_urls` from them (replace the fallback host and repository):
62 61
63 ```yaml62 ```yaml
64 version: 263 version: 2
65-# In CI the runner sets GITHUB_API_URL and GITHUB_SERVER_URL to the address the
66-# job can reach; the public URLs are the fallback for a local run.
67 github_urls:64 github_urls:
68- api: '{{ or (index .Env "GITHUB_API_URL") "https://git.example.org/-/github/api" }}/'65+ api: https://git.example.org/-/github/api/
69- upload: '{{ or (index .Env "GITHUB_API_URL") "https://git.example.org/-/github/api" }}/uploads/'66+ upload: https://git.example.org/-/github/api/uploads/
70- download: '{{ or (index .Env "GITHUB_SERVER_URL") "https://git.example.org" }}/'67+ download: https://git.example.org/
71 release:68 release:
72 github:69 github:
73 owner: owner70 owner: owner
74 name: app71 name: app
75 ```72 ```
76 73
77-In a CI job the runner rewrites the server and API URLs to an origin the job74+Run `GITHUB_TOKEN=<token> goreleaser release --clean` from a tagged checkout.
78-can reach, which may differ from the public URL (for example `http://ermite:7341`
79-with a relayed forge). Sandboxes block private and CGNAT ranges, so a job that
80-targets only the public URL times out (`dial tcp 100.x:443: i/o timeout`). A
81-plain-http relay needs no TLS option. The API base gets `api/v3/` appended by
82-the client; ermite accepts that alias. Locally, run
83-`GITHUB_TOKEN=<token> goreleaser release --clean` from a tagged checkout; the
84-fallbacks apply.
85-
86-Workflow:
87-
88-```yaml
89-name: release
90-on:
91- push:
92- tags: ['v*']
93-permissions:
94- contents: write
95-jobs:
96- release:
97- runs-on: ubuntu-latest
98- steps:
99- - uses: actions/checkout@v4
100- with:
101- fetch-depth: 0
102- - uses: actions/setup-go@v5
103- with:
104- go-version-file: go.mod
105- - uses: goreleaser/goreleaser-action@v6
106- with:
107- args: release --clean
108- env:
109- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
110-```
111-
112-An existing GitHub workflow must be enabled on ermite:
113-`ermite workflow enable <repo> .github/workflows/release.yml`.
114 75
115 On an ermite runner, the job supplies `GITHUB_API_URL`, `GITHUB_SERVER_URL` and76 On an ermite runner, the job supplies `GITHUB_API_URL`, `GITHUB_SERVER_URL` and
116 `GITHUB_TOKEN`; no endpoint override is needed for action-gh-release:77 `GITHUB_TOKEN`; no endpoint override is needed for action-gh-release:
M go.mod +5 -5
@@ -1,6 +1,6 @@
11 module bnema.dev/ermite
22
3-go 1.27
3+go 1.27.1
44
55 tool (
66 github.com/a-h/templ/cmd/templ
@@ -12,10 +12,10 @@ tool (
1212 )
1313
1414 require (
15- bnema.dev/ermite-actions v0.2.1
16- bnema.dev/ermite-api v0.16.1
17- bnema.dev/ermite-git v0.1.1
18- bnema.dev/ermite-kv v0.1.1
15+ bnema.dev/ermite-actions v0.2.0
16+ bnema.dev/ermite-api v0.16.0
17+ bnema.dev/ermite-git v0.1.0
18+ bnema.dev/ermite-kv v0.1.0
1919 connectrpc.com/connect v1.21.0
2020 github.com/ProtonMail/go-crypto v1.4.1
2121 github.com/a-h/templ v0.3.1020
@@ -1,6 +1,6 @@
1 module bnema.dev/ermite1 module bnema.dev/ermite
2 2
3-go 1.273+go 1.27.1
4 4
5 tool (5 tool (
6 github.com/a-h/templ/cmd/templ6 github.com/a-h/templ/cmd/templ
@@ -12,10 +12,10 @@ tool (
12 )12 )
13 13
14 require (14 require (
15- bnema.dev/ermite-actions v0.2.115+ bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.16.116+ bnema.dev/ermite-api v0.16.0
17- bnema.dev/ermite-git v0.1.117+ bnema.dev/ermite-git v0.1.0
18- bnema.dev/ermite-kv v0.1.118+ bnema.dev/ermite-kv v0.1.0
19 connectrpc.com/connect v1.21.019 connectrpc.com/connect v1.21.0
20 github.com/ProtonMail/go-crypto v1.4.120 github.com/ProtonMail/go-crypto v1.4.1
21 github.com/a-h/templ v0.3.102021 github.com/a-h/templ v0.3.1020
M go.sum +8 -8 generated file: collapsed
M internal/adapters/web/fragments.go +15 -0
@@ -6,6 +6,7 @@ import (
66 "bytes"
77 "context"
88 "errors"
9+ "net/http"
910 "strconv"
1011 "strings"
1112 "sync"
@@ -192,6 +193,20 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
192193 case domain.MergeRequestOpened:
193194 return l.mrNotice(ctx, ev.MergeRequest.Number, "a merge request was opened — reload")
194195 case domain.MergeRequestMerged:
196+ if l.view == "mr" && strconv.FormatInt(ev.MergeRequest.Number, 10) == l.n {
197+ r, err := http.NewRequestWithContext(ctx, http.MethodGet, mrHref(l.repo, ev.MergeRequest.Number), nil)
198+ if err != nil {
199+ return nil, err
200+ }
201+ r.SetPathValue("repo", l.repo)
202+ r.SetPathValue("n", l.n)
203+ c, _, err := l.s.mrPageView(ctx, r, false, true)
204+ if err != nil {
205+ return nil, err
206+ }
207+ f, err := renderFragment(ctx, "mr-mergeability", c)
208+ return []fragment{f}, err
209+ }
195210 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was merged — reload")
196211 case domain.MergeRequestClosed:
197212 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")
@@ -6,6 +6,7 @@ import (
6 "bytes"6 "bytes"
7 "context"7 "context"
8 "errors"8 "errors"
9+ "net/http"
9 "strconv"10 "strconv"
10 "strings"11 "strings"
11 "sync"12 "sync"
@@ -192,6 +193,20 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
192 case domain.MergeRequestOpened:193 case domain.MergeRequestOpened:
193 return l.mrNotice(ctx, ev.MergeRequest.Number, "a merge request was opened — reload")194 return l.mrNotice(ctx, ev.MergeRequest.Number, "a merge request was opened — reload")
194 case domain.MergeRequestMerged:195 case domain.MergeRequestMerged:
196+ if l.view == "mr" && strconv.FormatInt(ev.MergeRequest.Number, 10) == l.n {
197+ r, err := http.NewRequestWithContext(ctx, http.MethodGet, mrHref(l.repo, ev.MergeRequest.Number), nil)
198+ if err != nil {
199+ return nil, err
200+ }
201+ r.SetPathValue("repo", l.repo)
202+ r.SetPathValue("n", l.n)
203+ c, _, err := l.s.mrPageView(ctx, r, false, true)
204+ if err != nil {
205+ return nil, err
206+ }
207+ f, err := renderFragment(ctx, "mr-mergeability", c)
208+ return []fragment{f}, err
209+ }
195 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was merged — reload")210 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was merged — reload")
196 case domain.MergeRequestClosed:211 case domain.MergeRequestClosed:
197 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")212 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")
M internal/adapters/web/inbox.go +3 -25
@@ -67,17 +67,8 @@ type InboxRepoLink struct {
6767 Current bool
6868 }
6969
70-// InboxRunRow adds repository and start time to the Actions run display.
71-type InboxRunRow struct {
72- RunRow
73- Repo, RepoHref, When, WhenAt string
74-}
75-
76-// InboxPage is the inbox: counters, repository filter and item lists.
70+// InboxPage is the inbox: the counters, the filter and the two lists.
7771 type InboxPage struct {
78- Running int
79- RunsEnabled bool
80- Runs []InboxRunRow
8172 // MergeRequests and Issues count every open item, whatever the filter.
8273 MergeRequests, Issues int
8374 // PendingCount counts every issue awaiting moderation.
@@ -115,19 +106,6 @@ func inboxFilter(r *http.Request) (domain.RepoName, error) {
115106 func (s *Server) inboxPage(ov ports.InboxOverview, filter domain.RepoName) InboxPage {
116107 now := s.cfg.Now()
117108 p := InboxPage{MergeRequests: ov.Counts.MergeRequests, Issues: ov.Counts.Issues, PendingCount: ov.Counts.Pending, Filter: filter.String()}
118- p.Running, p.RunsEnabled = ov.Counts.Running, ov.RunsEnabled
119- for _, run := range ov.Runs {
120- r := InboxRunRow{RunRow: s.runRow(run.Repo.String(), run, now), Repo: run.Repo.String(), RepoHref: repoHref(run.Repo.String())}
121- if run.Status == domain.CIInProgress {
122- r.State = "in progress"
123- }
124- ts := run.StartedAt
125- if ts.IsZero() {
126- ts = run.CreatedAt
127- }
128- r.When, r.WhenAt = whenOf(now, ts)
129- p.Runs = append(p.Runs, r)
130- }
131109 for _, pi := range ov.Pending {
132110 p.Pending = append(p.Pending, s.pendingRow(now, pi, filter))
133111 }
@@ -288,7 +266,7 @@ func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment
288266 switch we.Event.(type) {
289267 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
290268 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved,
291- domain.RepoUpdated, domain.RepoDeleted, domain.RunUpdated:
269+ domain.RepoUpdated, domain.RepoDeleted:
292270 default:
293271 return nil, nil
294272 }
@@ -305,7 +283,7 @@ func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment
305283 }
306284 p := l.s.inboxPage(ov, filter)
307285 var out []fragment
308- for _, c := range []templ.Component{InboxCounters(p), InboxRepos(p), InboxPending(p), InboxRuns(p), InboxMRs(p), InboxIssues(p)} {
286+ for _, c := range []templ.Component{InboxCounters(p), InboxRepos(p), InboxPending(p), InboxMRs(p), InboxIssues(p)} {
309287 f, err := renderFragment(ctx, inboxEvent, c)
310288 if err != nil {
311289 return nil, err
@@ -67,17 +67,8 @@ type InboxRepoLink struct {
67 Current bool67 Current bool
68 }68 }
69 69
70-// InboxRunRow adds repository and start time to the Actions run display.70+// InboxPage is the inbox: the counters, the filter and the two lists.
71-type InboxRunRow struct {
72- RunRow
73- Repo, RepoHref, When, WhenAt string
74-}
75-
76-// InboxPage is the inbox: counters, repository filter and item lists.
77 type InboxPage struct {71 type InboxPage struct {
78- Running int
79- RunsEnabled bool
80- Runs []InboxRunRow
81 // MergeRequests and Issues count every open item, whatever the filter.72 // MergeRequests and Issues count every open item, whatever the filter.
82 MergeRequests, Issues int73 MergeRequests, Issues int
83 // PendingCount counts every issue awaiting moderation.74 // PendingCount counts every issue awaiting moderation.
@@ -115,19 +106,6 @@ func inboxFilter(r *http.Request) (domain.RepoName, error) {
115 func (s *Server) inboxPage(ov ports.InboxOverview, filter domain.RepoName) InboxPage {106 func (s *Server) inboxPage(ov ports.InboxOverview, filter domain.RepoName) InboxPage {
116 now := s.cfg.Now()107 now := s.cfg.Now()
117 p := InboxPage{MergeRequests: ov.Counts.MergeRequests, Issues: ov.Counts.Issues, PendingCount: ov.Counts.Pending, Filter: filter.String()}108 p := InboxPage{MergeRequests: ov.Counts.MergeRequests, Issues: ov.Counts.Issues, PendingCount: ov.Counts.Pending, Filter: filter.String()}
118- p.Running, p.RunsEnabled = ov.Counts.Running, ov.RunsEnabled
119- for _, run := range ov.Runs {
120- r := InboxRunRow{RunRow: s.runRow(run.Repo.String(), run, now), Repo: run.Repo.String(), RepoHref: repoHref(run.Repo.String())}
121- if run.Status == domain.CIInProgress {
122- r.State = "in progress"
123- }
124- ts := run.StartedAt
125- if ts.IsZero() {
126- ts = run.CreatedAt
127- }
128- r.When, r.WhenAt = whenOf(now, ts)
129- p.Runs = append(p.Runs, r)
130- }
131 for _, pi := range ov.Pending {109 for _, pi := range ov.Pending {
132 p.Pending = append(p.Pending, s.pendingRow(now, pi, filter))110 p.Pending = append(p.Pending, s.pendingRow(now, pi, filter))
133 }111 }
@@ -288,7 +266,7 @@ func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment
288 switch we.Event.(type) {266 switch we.Event.(type) {
289 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,267 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
290 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved,268 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved,
291- domain.RepoUpdated, domain.RepoDeleted, domain.RunUpdated:269+ domain.RepoUpdated, domain.RepoDeleted:
292 default:270 default:
293 return nil, nil271 return nil, nil
294 }272 }
@@ -305,7 +283,7 @@ func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment
305 }283 }
306 p := l.s.inboxPage(ov, filter)284 p := l.s.inboxPage(ov, filter)
307 var out []fragment285 var out []fragment
308- for _, c := range []templ.Component{InboxCounters(p), InboxRepos(p), InboxPending(p), InboxRuns(p), InboxMRs(p), InboxIssues(p)} {286+ for _, c := range []templ.Component{InboxCounters(p), InboxRepos(p), InboxPending(p), InboxMRs(p), InboxIssues(p)} {
309 f, err := renderFragment(ctx, inboxEvent, c)287 f, err := renderFragment(ctx, inboxEvent, c)
310 if err != nil {288 if err != nil {
311 return nil, err289 return nil, err
M internal/adapters/web/inbox.templ +1 -34
@@ -12,7 +12,6 @@ templ Inbox(l Layout, p InboxPage) {
1212 @InboxCounters(p)
1313 @InboxRepos(p)
1414 @InboxPending(p)
15- @InboxRuns(p)
1615 @InboxMRs(p)
1716 @InboxIssues(p)
1817 </div>
@@ -21,7 +20,7 @@ templ Inbox(l Layout, p InboxPage) {
2120
2221 templ InboxCounters(p InboxPage) {
2322 <p id="inbox-counts" class="inbox-counts">
24- <strong>{ itoa(p.MergeRequests) }</strong> merge requests · <strong>{ itoa(p.Issues) }</strong> issues · <strong>{ itoa(p.PendingCount) }</strong> awaiting moderation · <strong>{ itoa(p.Running) }</strong> running
23+ <strong>{ itoa(p.MergeRequests) }</strong> merge requests · <strong>{ itoa(p.Issues) }</strong> issues · <strong>{ itoa(p.PendingCount) }</strong> awaiting moderation
2524 </p>
2625 }
2726
@@ -114,35 +113,3 @@ templ inboxRows(rows []InboxRow, label string) {
114113 </table>
115114 }
116115 }
117-
118-// InboxRuns stays as an empty fragment when CI is disabled.
119-templ InboxRuns(p InboxPage) {
120- <section id="inbox-runs" hidden?={ !p.RunsEnabled }
121- if p.RunsEnabled {
122- aria-labelledby="inbox-runs-h"
123- }
124- >
125- if p.RunsEnabled {
126- <h2 id="inbox-runs-h" class="rule-h"><span>Running</span><span class="count">{ itoa(len(p.Runs)) }</span></h2>
127- if len(p.Runs) == 0 {
128- <p class="lede dim">no runs in progress.</p>
129- } else {
130- <table class="list mrs" aria-label="Running">
131- <tbody data-rows>
132- for _, r := range p.Runs {
133- <tr>
134- <td class="c-st">@CheckIcon(r.Kind, r.State)<span class="dim">{ r.State }</span></td>
135- <td class="c-subject"><a href={ templ.SafeURL(r.Href) }>{ r.Workflow }{ " " }{ r.Number }</a> <a class="mute" href={ templ.SafeURL(r.RepoHref) }>{ r.Repo }</a> <span class="dim">{ r.Ref }{ " " }{ r.Event }</span></td>
136- <td class="c-when nowrap dim">
137- if r.WhenAt != "" {
138- <time datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time>
139- }
140- </td>
141- </tr>
142- }
143- </tbody>
144- </table>
145- }
146- }
147- </section>
148-}
@@ -12,7 +12,6 @@ templ Inbox(l Layout, p InboxPage) {
12 @InboxCounters(p)12 @InboxCounters(p)
13 @InboxRepos(p)13 @InboxRepos(p)
14 @InboxPending(p)14 @InboxPending(p)
15- @InboxRuns(p)
16 @InboxMRs(p)15 @InboxMRs(p)
17 @InboxIssues(p)16 @InboxIssues(p)
18 </div>17 </div>
@@ -21,7 +20,7 @@ templ Inbox(l Layout, p InboxPage) {
21 20
22 templ InboxCounters(p InboxPage) {21 templ InboxCounters(p InboxPage) {
23 <p id="inbox-counts" class="inbox-counts">22 <p id="inbox-counts" class="inbox-counts">
24- <strong>{ itoa(p.MergeRequests) }</strong> merge requests · <strong>{ itoa(p.Issues) }</strong> issues · <strong>{ itoa(p.PendingCount) }</strong> awaiting moderation · <strong>{ itoa(p.Running) }</strong> running23+ <strong>{ itoa(p.MergeRequests) }</strong> merge requests · <strong>{ itoa(p.Issues) }</strong> issues · <strong>{ itoa(p.PendingCount) }</strong> awaiting moderation
25 </p>24 </p>
26 }25 }
27 26
@@ -114,35 +113,3 @@ templ inboxRows(rows []InboxRow, label string) {
114 </table>113 </table>
115 }114 }
116 }115 }
117-
118-// InboxRuns stays as an empty fragment when CI is disabled.
119-templ InboxRuns(p InboxPage) {
120- <section id="inbox-runs" hidden?={ !p.RunsEnabled }
121- if p.RunsEnabled {
122- aria-labelledby="inbox-runs-h"
123- }
124- >
125- if p.RunsEnabled {
126- <h2 id="inbox-runs-h" class="rule-h"><span>Running</span><span class="count">{ itoa(len(p.Runs)) }</span></h2>
127- if len(p.Runs) == 0 {
128- <p class="lede dim">no runs in progress.</p>
129- } else {
130- <table class="list mrs" aria-label="Running">
131- <tbody data-rows>
132- for _, r := range p.Runs {
133- <tr>
134- <td class="c-st">@CheckIcon(r.Kind, r.State)<span class="dim">{ r.State }</span></td>
135- <td class="c-subject"><a href={ templ.SafeURL(r.Href) }>{ r.Workflow }{ " " }{ r.Number }</a> <a class="mute" href={ templ.SafeURL(r.RepoHref) }>{ r.Repo }</a> <span class="dim">{ r.Ref }{ " " }{ r.Event }</span></td>
136- <td class="c-when nowrap dim">
137- if r.WhenAt != "" {
138- <time datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time>
139- }
140- </td>
141- </tr>
142- }
143- </tbody>
144- </table>
145- }
146- }
147- </section>
148-}
M internal/adapters/web/inbox_templ.go +173 -451 generated file: collapsed
M internal/adapters/web/inbox_test.go +4 -6
@@ -180,13 +180,12 @@ func TestInboxLiveFragments(t *testing.T) {
180180 ls := &liveStream{s: srv, view: "inbox"}
181181 frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: domain.IssueOpened{Issue: domain.Issue{Repo: repoName(t, "beta"), Number: 7}}})
182182 require.NoError(t, err)
183- require.Len(t, frags, 6)
183+ require.Len(t, frags, 5)
184184 assert.Contains(t, frags[0].html, `id="inbox-counts"`)
185185 assert.Contains(t, frags[1].html, `id="inbox-repos"`)
186186 assert.Contains(t, frags[2].html, `id="inbox-pending"`)
187- assert.Contains(t, frags[3].html, `id="inbox-runs"`)
188- assert.Contains(t, frags[4].html, `id="inbox-mrs"`)
189- assert.Contains(t, frags[5].html, `id="inbox-issues"`)
187+ assert.Contains(t, frags[3].html, `id="inbox-mrs"`)
188+ assert.Contains(t, frags[4].html, `id="inbox-issues"`)
190189
191190 none, err := ls.render(withOwner(t.Context()), ports.WatchEvent{Event: domain.PushEvent{Repo: repoName(t, "beta")}})
192191 require.NoError(t, err)
@@ -287,7 +286,6 @@ func TestInboxRefreshesOnRepoEvents(t *testing.T) {
287286 t.Parallel()
288287 for name, ev := range map[string]domain.Event{
289288 "issues turned off": domain.RepoUpdated{Repo: repoName(t, "beta")},
290- "run updated": domain.RunUpdated{},
291289 "repo deleted": domain.RepoDeleted{Repo: repoName(t, "beta")},
292290 } {
293291 t.Run(name, func(t *testing.T) {
@@ -299,7 +297,7 @@ func TestInboxRefreshesOnRepoEvents(t *testing.T) {
299297 ls := &liveStream{s: srv, view: "inbox"}
300298 frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: ev})
301299 require.NoError(t, err)
302- require.Len(t, frags, 6)
300+ require.Len(t, frags, 5)
303301 all := ""
304302 for _, f := range frags {
305303 all += f.html
@@ -180,13 +180,12 @@ func TestInboxLiveFragments(t *testing.T) {
180 ls := &liveStream{s: srv, view: "inbox"}180 ls := &liveStream{s: srv, view: "inbox"}
181 frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: domain.IssueOpened{Issue: domain.Issue{Repo: repoName(t, "beta"), Number: 7}}})181 frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: domain.IssueOpened{Issue: domain.Issue{Repo: repoName(t, "beta"), Number: 7}}})
182 require.NoError(t, err)182 require.NoError(t, err)
183- require.Len(t, frags, 6)183+ require.Len(t, frags, 5)
184 assert.Contains(t, frags[0].html, `id="inbox-counts"`)184 assert.Contains(t, frags[0].html, `id="inbox-counts"`)
185 assert.Contains(t, frags[1].html, `id="inbox-repos"`)185 assert.Contains(t, frags[1].html, `id="inbox-repos"`)
186 assert.Contains(t, frags[2].html, `id="inbox-pending"`)186 assert.Contains(t, frags[2].html, `id="inbox-pending"`)
187- assert.Contains(t, frags[3].html, `id="inbox-runs"`)187+ assert.Contains(t, frags[3].html, `id="inbox-mrs"`)
188- assert.Contains(t, frags[4].html, `id="inbox-mrs"`)188+ assert.Contains(t, frags[4].html, `id="inbox-issues"`)
189- assert.Contains(t, frags[5].html, `id="inbox-issues"`)
190 189
191 none, err := ls.render(withOwner(t.Context()), ports.WatchEvent{Event: domain.PushEvent{Repo: repoName(t, "beta")}})190 none, err := ls.render(withOwner(t.Context()), ports.WatchEvent{Event: domain.PushEvent{Repo: repoName(t, "beta")}})
192 require.NoError(t, err)191 require.NoError(t, err)
@@ -287,7 +286,6 @@ func TestInboxRefreshesOnRepoEvents(t *testing.T) {
287 t.Parallel()286 t.Parallel()
288 for name, ev := range map[string]domain.Event{287 for name, ev := range map[string]domain.Event{
289 "issues turned off": domain.RepoUpdated{Repo: repoName(t, "beta")},288 "issues turned off": domain.RepoUpdated{Repo: repoName(t, "beta")},
290- "run updated": domain.RunUpdated{},
291 "repo deleted": domain.RepoDeleted{Repo: repoName(t, "beta")},289 "repo deleted": domain.RepoDeleted{Repo: repoName(t, "beta")},
292 } {290 } {
293 t.Run(name, func(t *testing.T) {291 t.Run(name, func(t *testing.T) {
@@ -299,7 +297,7 @@ func TestInboxRefreshesOnRepoEvents(t *testing.T) {
299 ls := &liveStream{s: srv, view: "inbox"}297 ls := &liveStream{s: srv, view: "inbox"}
300 frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: ev})298 frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: ev})
301 require.NoError(t, err)299 require.NoError(t, err)
302- require.Len(t, frags, 6)300+ require.Len(t, frags, 5)
303 all := ""301 all := ""
304 for _, f := range frags {302 for _, f := range frags {
305 all += f.html303 all += f.html
D internal/adapters/web/jstest/keys.test.mjs +0 -29
@@ -1,29 +0,0 @@
1-// Exercise the production keydown handler without loading the rest of the page.
2-import assert from 'node:assert/strict';
3-import { readFileSync } from 'node:fs';
4-import { test } from 'node:test';
5-const src = readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8');
6-const block = src.slice(src.indexOf(' // 1. Keys.'), src.indexOf(' // 2. A [data-fire]'));
7-
8-function page({ owner = true, off = false, typing = false, dialog = false } = {}) {
9- let handler;
10- const fired = [];
11- const window = { ermiteKeys: { blocks: () => off } };
12- const $ = (s) => s === 'dialog[open]' ? dialog : s === '[data-inbox]' ? owner : null;
13- new Function('window', '$', 'ermite', 'addEventListener', block)(window, $, { fire: (n) => fired.push(n) }, (_, f) => { handler = f; });
14- return (key) => {
15- const e = { key, shiftKey: key === 'I', preventDefault() {}, target: { closest: (s) => s.startsWith('input') && typing } };
16- handler(e);
17- return fired;
18- };
19-}
20-test('Shift+I opens inbox while lowercase i retains the issues tab', () => {
21- const press = page();
22- assert.deepEqual(press('I'), ['inbox']);
23- assert.deepEqual(press('i'), ['inbox', 'i']);
24-});
25-test('inbox shortcut respects preference, fields, dialogs, and owner target', () => {
26- for (const options of [{ off: true }, { typing: true }, { dialog: true }, { owner: false }]) {
27- assert.deepEqual(page(options)('I'), [], JSON.stringify(options));
28- }
29-});
@@ -1,29 +0,0 @@
1-// Exercise the production keydown handler without loading the rest of the page.
2-import assert from 'node:assert/strict';
3-import { readFileSync } from 'node:fs';
4-import { test } from 'node:test';
5-const src = readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8');
6-const block = src.slice(src.indexOf(' // 1. Keys.'), src.indexOf(' // 2. A [data-fire]'));
7-
8-function page({ owner = true, off = false, typing = false, dialog = false } = {}) {
9- let handler;
10- const fired = [];
11- const window = { ermiteKeys: { blocks: () => off } };
12- const $ = (s) => s === 'dialog[open]' ? dialog : s === '[data-inbox]' ? owner : null;
13- new Function('window', '$', 'ermite', 'addEventListener', block)(window, $, { fire: (n) => fired.push(n) }, (_, f) => { handler = f; });
14- return (key) => {
15- const e = { key, shiftKey: key === 'I', preventDefault() {}, target: { closest: (s) => s.startsWith('input') && typing } };
16- handler(e);
17- return fired;
18- };
19-}
20-test('Shift+I opens inbox while lowercase i retains the issues tab', () => {
21- const press = page();
22- assert.deepEqual(press('I'), ['inbox']);
23- assert.deepEqual(press('i'), ['inbox', 'i']);
24-});
25-test('inbox shortcut respects preference, fields, dialogs, and owner target', () => {
26- for (const options of [{ off: true }, { typing: true }, { dialog: true }, { owner: false }]) {
27- assert.deepEqual(page(options)('I'), [], JSON.stringify(options));
28- }
29-});
M internal/adapters/web/jstest/mr.test.mjs +12 -4
@@ -152,8 +152,10 @@ test('footer tabs: closed by default, toggle, switch, arrows, hash', () => {
152152 test('footer: #mr-ck syncs the checks label; its click opens Checks without toggling', () => {
153153 const text0 = { v: '' }; let writes = 0;
154154 const label = { get textContent() { return text0.v; }, set textContent(v) { writes++; text0.v = v; } };
155- const summary = { title: '1/2 failed', textContent: ' 1/2 ', parentElement: {} };
156- const h = footerHarness({ summary });
155+ const bar = {};
156+ const summary = { title: '1/2 failed', textContent: ' 1/2 ', closest: (s) => s === '.mr-bar' ? bar : null };
157+ const extra = { summary };
158+ const h = footerHarness(extra);
157159 let observed = null, callback = null;
158160 globalThis.MutationObserver = class { constructor(fn) { callback = fn; } observe(target, opts) { observed = [target, opts]; } };
159161 const nav = document.querySelector('[data-mr-tabs]');
@@ -161,8 +163,14 @@ test('footer: #mr-ck syncs the checks label; its click opens Checks without togg
161163 try {
162164 window.ermiteMR.panels();
163165 assert.equal(label.textContent, '1/2 failed', 'initial label sync');
164- assert.equal(observed[0], summary.parentElement);
165- assert.deepEqual(observed[1], { childList: true }, 'only #mr-ck replacements wake the observer');
166+ assert.equal(observed[0], bar);
167+ assert.deepEqual(observed[1], { childList: true, subtree: true }, 'the stable bar observes action and badge replacements');
168+ // Replace #mr-actions, including its badge, then replace the badge alone.
169+ extra.summary = { title: 'checks running', textContent: '' }; callback();
170+ assert.equal(label.textContent, 'checks running');
171+ extra.summary = { title: 'checks passed', textContent: '' }; callback();
172+ assert.equal(label.textContent, 'checks passed');
173+ extra.summary = summary; callback();
166174 const before = writes; callback(); callback();
167175 assert.equal(writes, before, 'an unchanged label is not written');
168176 summary.title = ''; callback();
@@ -152,8 +152,10 @@ test('footer tabs: closed by default, toggle, switch, arrows, hash', () => {
152 test('footer: #mr-ck syncs the checks label; its click opens Checks without toggling', () => {152 test('footer: #mr-ck syncs the checks label; its click opens Checks without toggling', () => {
153 const text0 = { v: '' }; let writes = 0;153 const text0 = { v: '' }; let writes = 0;
154 const label = { get textContent() { return text0.v; }, set textContent(v) { writes++; text0.v = v; } };154 const label = { get textContent() { return text0.v; }, set textContent(v) { writes++; text0.v = v; } };
155- const summary = { title: '1/2 failed', textContent: ' 1/2 ', parentElement: {} };155+ const bar = {};
156- const h = footerHarness({ summary });156+ const summary = { title: '1/2 failed', textContent: ' 1/2 ', closest: (s) => s === '.mr-bar' ? bar : null };
157+ const extra = { summary };
158+ const h = footerHarness(extra);
157 let observed = null, callback = null;159 let observed = null, callback = null;
158 globalThis.MutationObserver = class { constructor(fn) { callback = fn; } observe(target, opts) { observed = [target, opts]; } };160 globalThis.MutationObserver = class { constructor(fn) { callback = fn; } observe(target, opts) { observed = [target, opts]; } };
159 const nav = document.querySelector('[data-mr-tabs]');161 const nav = document.querySelector('[data-mr-tabs]');
@@ -161,8 +163,14 @@ test('footer: #mr-ck syncs the checks label; its click opens Checks without togg
161 try {163 try {
162 window.ermiteMR.panels();164 window.ermiteMR.panels();
163 assert.equal(label.textContent, '1/2 failed', 'initial label sync');165 assert.equal(label.textContent, '1/2 failed', 'initial label sync');
164- assert.equal(observed[0], summary.parentElement);166+ assert.equal(observed[0], bar);
165- assert.deepEqual(observed[1], { childList: true }, 'only #mr-ck replacements wake the observer');167+ assert.deepEqual(observed[1], { childList: true, subtree: true }, 'the stable bar observes action and badge replacements');
168+ // Replace #mr-actions, including its badge, then replace the badge alone.
169+ extra.summary = { title: 'checks running', textContent: '' }; callback();
170+ assert.equal(label.textContent, 'checks running');
171+ extra.summary = { title: 'checks passed', textContent: '' }; callback();
172+ assert.equal(label.textContent, 'checks passed');
173+ extra.summary = summary; callback();
166 const before = writes; callback(); callback();174 const before = writes; callback(); callback();
167 assert.equal(writes, before, 'an unchanged label is not written');175 assert.equal(writes, before, 'an unchanged label is not written');
168 summary.title = ''; callback();176 summary.title = ''; callback();
M internal/adapters/web/keys.templ +0 -1
@@ -28,7 +28,6 @@ templ Keys() {
2828 k-r from:body -> ermite.tab('r');
2929 k-m from:body -> ermite.tab('m');
3030 k-i from:body -> ermite.tab('i');
31- k-inbox from:body -> ermite.go('[data-inbox]');
3231 k-next from:body -> ermite.nextNote();
3332 k-split from:body -> ermite.layout('split');
3433 k-unified from:body -> ermite.layout('unified');
@@ -28,7 +28,6 @@ templ Keys() {
28 k-r from:body -> ermite.tab('r');28 k-r from:body -> ermite.tab('r');
29 k-m from:body -> ermite.tab('m');29 k-m from:body -> ermite.tab('m');
30 k-i from:body -> ermite.tab('i');30 k-i from:body -> ermite.tab('i');
31- k-inbox from:body -> ermite.go('[data-inbox]');
32 k-next from:body -> ermite.nextNote();31 k-next from:body -> ermite.nextNote();
33 k-split from:body -> ermite.layout('split');32 k-split from:body -> ermite.layout('split');
34 k-unified from:body -> ermite.layout('unified');33 k-unified from:body -> ermite.layout('unified');
M internal/adapters/web/keys_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/mr.go +7 -0
@@ -558,6 +558,10 @@ func (s *Server) mrSince(ctx context.Context, r *http.Request) (templ.Component,
558558 }
559559
560560 func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ.Component, int, error) {
561+ return s.mrPageView(ctx, r, since, false)
562+}
563+
564+func (s *Server) mrPageView(ctx context.Context, r *http.Request, since, fragment bool) (templ.Component, int, error) {
561565 if since && !SignedIn(ctx) {
562566 return nil, 0, errNotFound
563567 }
@@ -649,6 +653,9 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
649653 }
650654 }
651655
656+ if fragment {
657+ return MRStateFragment(p), http.StatusOK, nil
658+ }
652659 l := s.repoLayout(ctx, d, "merge-requests")
653660 l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
654661 if head != "" {
@@ -558,6 +558,10 @@ func (s *Server) mrSince(ctx context.Context, r *http.Request) (templ.Component,
558 }558 }
559 559
560 func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ.Component, int, error) {560 func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ.Component, int, error) {
561+ return s.mrPageView(ctx, r, since, false)
562+}
563+
564+func (s *Server) mrPageView(ctx context.Context, r *http.Request, since, fragment bool) (templ.Component, int, error) {
561 if since && !SignedIn(ctx) {565 if since && !SignedIn(ctx) {
562 return nil, 0, errNotFound566 return nil, 0, errNotFound
563 }567 }
@@ -649,6 +653,9 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
649 }653 }
650 }654 }
651 655
656+ if fragment {
657+ return MRStateFragment(p), http.StatusOK, nil
658+ }
652 l := s.repoLayout(ctx, d, "merge-requests")659 l := s.repoLayout(ctx, d, "merge-requests")
653 l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance660 l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
654 if head != "" {661 if head != "" {
M internal/adapters/web/mr.templ +62 -29
@@ -13,7 +13,7 @@ templ MRList(l Layout, p MRListPage) {
1313 @Shell(l, mrListRail(p)) {
1414 if p.Live {
1515 @LiveSlot("mr-notice")
16- <div id="mr-refresh-status" role="status" aria-live="polite"></div>
16+ <div id="mr-refresh-status" role="status" aria-live="polite"></div>
1717 }
1818 <div class="filter">
1919 <label for="mr-filter" class="sr">filter</label>
@@ -119,12 +119,7 @@ templ MergeRequest(l Layout, p MRPage) {
119119 <h1><span class="mute">{ p.Number }</span> { p.Title }</h1>
120120 <dl class="kv show-kv">
121121 <dt>state</dt>
122- <dd class="mr-state" data-state={ p.State }>
123- { p.State }
124- if p.MergedShort != "" {
125- { " · " }<a href={ templ.SafeURL(p.MergedHref) }>{ p.MergedShort }</a>{ " · " + p.MergeMode }
126- }
127- </dd>
122+ @mrState(p)
128123 <dt>branch</dt>
129124 <dd>
130125 @branchLink(p.Source, p.SourceHref)
@@ -215,19 +210,19 @@ templ MergeRequest(l Layout, p MRPage) {
215210 }
216211 </div>
217212 <div id="mr-comments">
218- <h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(p.NUnresolved) } open / { itoa(p.NComments) }</span></h2>
219- for _, c := range p.Loose {
220- @commentView(c, "")
221- }
222- for _, c := range p.General {
223- @commentView(c, "")
224- }
225- if p.NComments == 0 {
226- <p class="dim">no comments yet.</p>
227- }
228- if p.GeneralForm != nil {
229- @commentForm(*p.GeneralForm, "")
230- }
213+ <h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(p.NUnresolved) } open / { itoa(p.NComments) }</span></h2>
214+ for _, c := range p.Loose {
215+ @commentView(c, "")
216+ }
217+ for _, c := range p.General {
218+ @commentView(c, "")
219+ }
220+ if p.NComments == 0 {
221+ <p class="dim">no comments yet.</p>
222+ }
223+ if p.GeneralForm != nil {
224+ @commentForm(*p.GeneralForm, "")
225+ }
231226 </div>
232227 }
233228 }
@@ -467,13 +462,7 @@ templ mrBar(p MRPage) {
467462 <div class="diff-tools mr-bar" role="group" aria-label="Diff view and merge">
468463 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
469464 @diffViewMenu(p.DiffView)
470- <div class="mr-acts">
471- @MRChecksSum(p.ChecksSum)
472- if p.Box != nil {
473- @mergeMenu(p)
474- }
475- @moreMenu(p)
476- </div>
465+ @mrActions(p, false)
477466 </div>
478467 }
479468
@@ -523,7 +512,15 @@ templ mergeMenu(p MRPage) {
523512 </div>
524513 </form>
525514 }
526- <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }>
515+ <form
516+ class="d-acts"
517+ method="post"
518+ action={ templ.SafeURL(p.Box.MergeAction) }
519+ hx-post={ p.Box.MergeAction }
520+ hx-swap="none"
521+ hx-sync="this:drop"
522+ hx-disable="find button[type=submit]"
523+ >
527524 <input type="hidden" name="head_id" value={ p.Box.Head }/>
528525 <fieldset class="d-modes">
529526 <legend class="sr">merge mode</legend>
@@ -557,9 +554,10 @@ templ mergeMenu(p MRPage) {
557554 data-default
558555 disabled?={ !p.Box.CanMerge() }
559556 title={ p.Box.MergeTitle() }
560- :disabled="!this.form.querySelector('input[name=mode]:checked:not(:disabled)')"
557+ :disabled="this.form.classList.contains('htmx-request') || !this.form.querySelector('input[name=mode]:checked:not(:disabled)')"
561558 :title="(this.form.querySelector('input[name=mode]:checked') || {}).title || ''"
562559 >Merge into { p.Target }</button>
560+ <span class="htmx-indicator" role="status">Merging…</span>
563561 </div>
564562 </form>
565563 if p.Box.CanAutoMerge {
@@ -779,3 +777,38 @@ templ MRMergeChecks(c ChecksSummary) {
779777 }
780778 </div>
781779 }
780+
781+// MRStateFragment updates only state and actions, leaving review drafts alone.
782+templ MRStateFragment(p MRPage) {
783+ @mrState(p)
784+ @mrActions(p, true)
785+}
786+
787+templ mrState(p MRPage) {
788+ <dd id="mr-state" hx-swap-oob="true" class="mr-state" data-state={ p.State }>
789+ { p.State }
790+ if p.MergedShort != "" {
791+ { " · " }<a href={ templ.SafeURL(p.MergedHref) }>{ p.MergedShort }</a>{ " · " + p.MergeMode }
792+ }
793+ </dd>
794+}
795+
796+templ mrActions(p MRPage, fragment bool) {
797+ <div id="mr-actions" hx-swap-oob="true" class="mr-acts">
798+ @MRChecksSum(p.ChecksSum)
799+ if p.Box != nil {
800+ @mergeMenu(p)
801+ }
802+ @moreMenu(p)
803+ if fragment && p.MergeError != "" {
804+ <p class="d-err" role="alert">{ p.MergeError }</p>
805+ }
806+ </div>
807+}
808+
809+// MRActionStatus remains visible even when the request detail cannot be read.
810+templ MRActionStatus(message, href string) {
811+ <div id="mr-actions" class="mr-acts" hx-swap-oob="true">
812+ <p class="d-err" role="alert">{ message } <a href={ templ.SafeURL(href) }>reload</a></p>
813+ </div>
814+}
@@ -13,7 +13,7 @@ templ MRList(l Layout, p MRListPage) {
13 @Shell(l, mrListRail(p)) {13 @Shell(l, mrListRail(p)) {
14 if p.Live {14 if p.Live {
15 @LiveSlot("mr-notice")15 @LiveSlot("mr-notice")
16- <div id="mr-refresh-status" role="status" aria-live="polite"></div>16+ <div id="mr-refresh-status" role="status" aria-live="polite"></div>
17 }17 }
18 <div class="filter">18 <div class="filter">
19 <label for="mr-filter" class="sr">filter</label>19 <label for="mr-filter" class="sr">filter</label>
@@ -119,12 +119,7 @@ templ MergeRequest(l Layout, p MRPage) {
119 <h1><span class="mute">{ p.Number }</span> { p.Title }</h1>119 <h1><span class="mute">{ p.Number }</span> { p.Title }</h1>
120 <dl class="kv show-kv">120 <dl class="kv show-kv">
121 <dt>state</dt>121 <dt>state</dt>
122- <dd class="mr-state" data-state={ p.State }>122+ @mrState(p)
123- { p.State }
124- if p.MergedShort != "" {
125- { " · " }<a href={ templ.SafeURL(p.MergedHref) }>{ p.MergedShort }</a>{ " · " + p.MergeMode }
126- }
127- </dd>
128 <dt>branch</dt>123 <dt>branch</dt>
129 <dd>124 <dd>
130 @branchLink(p.Source, p.SourceHref)125 @branchLink(p.Source, p.SourceHref)
@@ -215,19 +210,19 @@ templ MergeRequest(l Layout, p MRPage) {
215 }210 }
216 </div>211 </div>
217 <div id="mr-comments">212 <div id="mr-comments">
218- <h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(p.NUnresolved) } open / { itoa(p.NComments) }</span></h2>213+ <h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(p.NUnresolved) } open / { itoa(p.NComments) }</span></h2>
219- for _, c := range p.Loose {214+ for _, c := range p.Loose {
220- @commentView(c, "")215+ @commentView(c, "")
221- }216+ }
222- for _, c := range p.General {217+ for _, c := range p.General {
223- @commentView(c, "")218+ @commentView(c, "")
224- }219+ }
225- if p.NComments == 0 {220+ if p.NComments == 0 {
226- <p class="dim">no comments yet.</p>221+ <p class="dim">no comments yet.</p>
227- }222+ }
228- if p.GeneralForm != nil {223+ if p.GeneralForm != nil {
229- @commentForm(*p.GeneralForm, "")224+ @commentForm(*p.GeneralForm, "")
230- }225+ }
231 </div>226 </div>
232 }227 }
233 }228 }
@@ -467,13 +462,7 @@ templ mrBar(p MRPage) {
467 <div class="diff-tools mr-bar" role="group" aria-label="Diff view and merge">462 <div class="diff-tools mr-bar" role="group" aria-label="Diff view and merge">
468 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>463 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
469 @diffViewMenu(p.DiffView)464 @diffViewMenu(p.DiffView)
470- <div class="mr-acts">465+ @mrActions(p, false)
471- @MRChecksSum(p.ChecksSum)
472- if p.Box != nil {
473- @mergeMenu(p)
474- }
475- @moreMenu(p)
476- </div>
477 </div>466 </div>
478 }467 }
479 468
@@ -523,7 +512,15 @@ templ mergeMenu(p MRPage) {
523 </div>512 </div>
524 </form>513 </form>
525 }514 }
526- <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }>515+ <form
516+ class="d-acts"
517+ method="post"
518+ action={ templ.SafeURL(p.Box.MergeAction) }
519+ hx-post={ p.Box.MergeAction }
520+ hx-swap="none"
521+ hx-sync="this:drop"
522+ hx-disable="find button[type=submit]"
523+ >
527 <input type="hidden" name="head_id" value={ p.Box.Head }/>524 <input type="hidden" name="head_id" value={ p.Box.Head }/>
528 <fieldset class="d-modes">525 <fieldset class="d-modes">
529 <legend class="sr">merge mode</legend>526 <legend class="sr">merge mode</legend>
@@ -557,9 +554,10 @@ templ mergeMenu(p MRPage) {
557 data-default554 data-default
558 disabled?={ !p.Box.CanMerge() }555 disabled?={ !p.Box.CanMerge() }
559 title={ p.Box.MergeTitle() }556 title={ p.Box.MergeTitle() }
560- :disabled="!this.form.querySelector('input[name=mode]:checked:not(:disabled)')"557+ :disabled="this.form.classList.contains('htmx-request') || !this.form.querySelector('input[name=mode]:checked:not(:disabled)')"
561 :title="(this.form.querySelector('input[name=mode]:checked') || {}).title || ''"558 :title="(this.form.querySelector('input[name=mode]:checked') || {}).title || ''"
562 >Merge into { p.Target }</button>559 >Merge into { p.Target }</button>
560+ <span class="htmx-indicator" role="status">Merging…</span>
563 </div>561 </div>
564 </form>562 </form>
565 if p.Box.CanAutoMerge {563 if p.Box.CanAutoMerge {
@@ -779,3 +777,38 @@ templ MRMergeChecks(c ChecksSummary) {
779 }777 }
780 </div>778 </div>
781 }779 }
780+
781+// MRStateFragment updates only state and actions, leaving review drafts alone.
782+templ MRStateFragment(p MRPage) {
783+ @mrState(p)
784+ @mrActions(p, true)
785+}
786+
787+templ mrState(p MRPage) {
788+ <dd id="mr-state" hx-swap-oob="true" class="mr-state" data-state={ p.State }>
789+ { p.State }
790+ if p.MergedShort != "" {
791+ { " · " }<a href={ templ.SafeURL(p.MergedHref) }>{ p.MergedShort }</a>{ " · " + p.MergeMode }
792+ }
793+ </dd>
794+}
795+
796+templ mrActions(p MRPage, fragment bool) {
797+ <div id="mr-actions" hx-swap-oob="true" class="mr-acts">
798+ @MRChecksSum(p.ChecksSum)
799+ if p.Box != nil {
800+ @mergeMenu(p)
801+ }
802+ @moreMenu(p)
803+ if fragment && p.MergeError != "" {
804+ <p class="d-err" role="alert">{ p.MergeError }</p>
805+ }
806+ </div>
807+}
808+
809+// MRActionStatus remains visible even when the request detail cannot be read.
810+templ MRActionStatus(message, href string) {
811+ <div id="mr-actions" class="mr-acts" hx-swap-oob="true">
812+ <p class="d-err" role="alert">{ message } <a href={ templ.SafeURL(href) }>reload</a></p>
813+ </div>
814+}
A internal/adapters/web/mr_async_test.go +166 -0
@@ -0,0 +1,166 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "net/http"
9+ "net/http/httptest"
10+ "strings"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/mock"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+ "bnema.dev/ermite/internal/ports/mocks"
21+)
22+
23+func asyncMergeServer(t *testing.T, state domain.MRState) (*Server, *mocks.MergeRequestService) {
24+ t.Helper()
25+ svc := mocks.NewMergeRequestService(t)
26+ browse := mocks.NewBrowseService(t)
27+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RefPage{Refs: []domain.Ref{{Kind: domain.RefKindBranch, ShortName: "feature"}}}, nil).Maybe()
28+ svc.EXPECT().Get(mock.Anything, mock.Anything, repoName(t, "demo"), int64(3), mock.Anything).Return(ports.MergeRequestDetail{
29+ MergeRequest: domain.MergeRequest{Repo: repoName(t, "demo"), Number: 3, State: state, HeadID: objectID(t, strings.Repeat("a", domain.ObjectIDLen)), Source: "feature", Target: "main"},
30+ Mergeability: domain.Mergeability{State: domain.MergeabilityUpToDate},
31+ }, nil).Maybe()
32+ return newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "demo")), Browse: browse, MergeRequests: svc, Now: time.Now}), svc
33+}
34+
35+func TestAsyncMergePost(t *testing.T) {
36+ for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash, domain.MergeModeMerge} {
37+ for _, hx := range []bool{false, true} {
38+ t.Run(mode.String()+"/"+boolText(hx), func(t *testing.T) {
39+ s, svc := asyncMergeServer(t, domain.MRStateMerged)
40+ svc.EXPECT().Merge(mock.Anything, mock.Anything, repoName(t, "demo"), int64(3), ports.MergeParams{Mode: mode}).Return(ports.MergeResult{}, nil).Once()
41+ r := autoMergeRequest("/demo/merge-requests/3/merge", "mode="+mode.String())
42+ if hx {
43+ r.Header.Set("HX-Request", "true")
44+ }
45+ w := httptest.NewRecorder()
46+ s.mrAction(s.mrMerge)(w, r)
47+ if !hx {
48+ assert.Equal(t, http.StatusSeeOther, w.Code)
49+ return
50+ }
51+ assert.Equal(t, http.StatusOK, w.Code)
52+ assert.Empty(t, w.Header().Get("Location"))
53+ assert.Contains(t, w.Body.String(), `id="mr-state"`)
54+ assert.Contains(t, w.Body.String(), `data-state="merged"`)
55+ assert.Contains(t, w.Body.String(), `id="mr-actions"`)
56+ assert.NotContains(t, w.Body.String(), "<html")
57+ assert.NotContains(t, w.Body.String(), `id="merge-menu"`)
58+ })
59+ }
60+ }
61+}
62+
63+func TestAsyncMergeRefusal(t *testing.T) {
64+ s, svc := asyncMergeServer(t, domain.MRStateOpen)
65+ svc.EXPECT().Merge(mock.Anything, mock.Anything, mock.Anything, int64(3), mock.Anything).Return(ports.MergeResult{}, domain.ErrConflict)
66+ r := autoMergeRequest("/demo/merge-requests/3/merge", "mode=squash")
67+ r = r.WithContext(context.WithValue(r.Context(), signedInKey{}, struct{}{}))
68+ r.Header.Set("HX-Request", "true")
69+ w := httptest.NewRecorder()
70+ s.mrAction(s.mrMerge)(w, r)
71+ assert.Equal(t, http.StatusConflict, w.Code)
72+ assert.Contains(t, w.Body.String(), `role="alert"`)
73+ assert.Contains(t, w.Body.String(), domain.ErrConflict.Error())
74+ assert.Contains(t, w.Body.String(), `hx-post="/demo/merge-requests/3/merge"`)
75+ assert.Contains(t, w.Body.String(), `hx-sync="this:drop"`)
76+ assert.Contains(t, w.Body.String(), "Merging…")
77+ assert.NotContains(t, w.Body.String(), "<html")
78+}
79+
80+func TestAsyncMergeLive(t *testing.T) {
81+ s, _ := asyncMergeServer(t, domain.MRStateMerged)
82+ l := liveStream{s: s, repo: "demo", view: "mr", n: "3"}
83+ fs, err := l.repoPage(t.Context(), watchOf(domain.MergeRequestMerged{MergeRequest: domain.MergeRequest{Number: 3}}))
84+ require.NoError(t, err)
85+ require.Len(t, fs, 1)
86+ assert.Equal(t, "mr-mergeability", fs[0].event)
87+ assert.Contains(t, fs[0].html, `data-state="merged"`)
88+ assert.Contains(t, fs[0].html, `id="mr-actions"`)
89+ assert.False(t, strings.Contains(fs[0].html, "reload"))
90+ fs, err = l.repoPage(t.Context(), watchOf(domain.MergeRequestMerged{MergeRequest: domain.MergeRequest{Number: 4}}))
91+ require.NoError(t, err)
92+ assert.Empty(t, fs)
93+}
94+
95+func TestAsyncMergeOtherFailures(t *testing.T) {
96+ for _, tt := range []struct {
97+ name string
98+ mergeErr error
99+ readFails bool
100+ status int
101+ message string
102+ }{
103+ {"forbidden", domain.ErrForbidden, false, http.StatusForbidden, "the session is not allowed"},
104+ {"internal", errors.New("private backend detail"), false, http.StatusInternalServerError, "reload to check its state"},
105+ {"post-merge read", nil, true, http.StatusOK, "merged, but the updated page could not be loaded"},
106+ } {
107+ t.Run(tt.name, func(t *testing.T) {
108+ s, svc := asyncMergeServer(t, domain.MRStateMerged)
109+ if tt.readFails {
110+ svc.On("Get", mock.Anything, mock.Anything, repoName(t, "demo"), int64(3), mock.Anything).Unset()
111+ svc.EXPECT().Get(mock.Anything, mock.Anything, repoName(t, "demo"), int64(3), mock.Anything).Return(ports.MergeRequestDetail{}, errors.New("private read detail")).Once()
112+ }
113+ svc.EXPECT().Merge(mock.Anything, mock.Anything, mock.Anything, int64(3), mock.Anything).Return(ports.MergeResult{}, tt.mergeErr).Once()
114+ r := autoMergeRequest("/demo/merge-requests/3/merge", "mode=squash")
115+ r.Header.Set("HX-Request", "true")
116+ w := httptest.NewRecorder()
117+ s.mrAction(s.mrMerge)(w, r)
118+ assert.Equal(t, tt.status, w.Code)
119+ assert.Contains(t, w.Body.String(), `id="mr-actions"`)
120+ assert.Contains(t, w.Body.String(), `hx-swap-oob="true"`)
121+ assert.Contains(t, w.Body.String(), `role="alert"`)
122+ assert.Contains(t, w.Body.String(), tt.message)
123+ assert.NotContains(t, w.Body.String(), "private")
124+ assert.NotContains(t, w.Body.String(), "<html")
125+ })
126+ }
127+}
128+
129+func TestMergeGateRefusalFragment(t *testing.T) {
130+ s, _ := newSessionServer(t, newSessions(t, "tok"), testPublicURL)
131+ for _, origin := range []string{testPublicURL, "https://other.example"} {
132+ for _, hx := range []bool{false, true} {
133+ r := httptest.NewRequest(http.MethodPost, "/demo/merge-requests/3/merge", strings.NewReader("mode=squash"))
134+ r.Header.Set("Origin", origin)
135+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
136+ if hx {
137+ r.Header.Set("HX-Request", "true")
138+ }
139+ w := httptest.NewRecorder()
140+ s.ServeHTTP(w, r)
141+ assert.Equal(t, http.StatusForbidden, w.Code)
142+ if hx {
143+ assert.Contains(t, w.Body.String(), `hx-swap-oob="true"`)
144+ assert.Contains(t, w.Body.String(), `id="mr-actions"`)
145+ if origin == testPublicURL {
146+ assert.Contains(t, w.Body.String(), "your session has expired")
147+ } else {
148+ assert.Contains(t, w.Body.String(), "origin was not accepted")
149+ }
150+ } else {
151+ assert.NotContains(t, w.Body.String(), "hx-swap-oob")
152+ }
153+ }
154+ }
155+}
156+
157+func TestNonHXMergeRefusalHasOneAlert(t *testing.T) {
158+ s, svc := asyncMergeServer(t, domain.MRStateMerged)
159+ svc.EXPECT().CountOpen(mock.Anything, mock.Anything, mock.Anything).Return(0, nil)
160+ svc.EXPECT().Merge(mock.Anything, mock.Anything, mock.Anything, int64(3), mock.Anything).Return(ports.MergeResult{}, domain.ErrConflict).Once()
161+ r := autoMergeRequest("/demo/merge-requests/3/merge", "mode=squash")
162+ w := httptest.NewRecorder()
163+ s.mrAction(s.mrMerge)(w, r)
164+ assert.Equal(t, http.StatusConflict, w.Code)
165+ assert.Equal(t, 1, strings.Count(w.Body.String(), `role="alert"`))
166+}
@@ -0,0 +1,166 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "net/http"
9+ "net/http/httptest"
10+ "strings"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/mock"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+ "bnema.dev/ermite/internal/ports/mocks"
21+)
22+
23+func asyncMergeServer(t *testing.T, state domain.MRState) (*Server, *mocks.MergeRequestService) {
24+ t.Helper()
25+ svc := mocks.NewMergeRequestService(t)
26+ browse := mocks.NewBrowseService(t)
27+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RefPage{Refs: []domain.Ref{{Kind: domain.RefKindBranch, ShortName: "feature"}}}, nil).Maybe()
28+ svc.EXPECT().Get(mock.Anything, mock.Anything, repoName(t, "demo"), int64(3), mock.Anything).Return(ports.MergeRequestDetail{
29+ MergeRequest: domain.MergeRequest{Repo: repoName(t, "demo"), Number: 3, State: state, HeadID: objectID(t, strings.Repeat("a", domain.ObjectIDLen)), Source: "feature", Target: "main"},
30+ Mergeability: domain.Mergeability{State: domain.MergeabilityUpToDate},
31+ }, nil).Maybe()
32+ return newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "demo")), Browse: browse, MergeRequests: svc, Now: time.Now}), svc
33+}
34+
35+func TestAsyncMergePost(t *testing.T) {
36+ for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash, domain.MergeModeMerge} {
37+ for _, hx := range []bool{false, true} {
38+ t.Run(mode.String()+"/"+boolText(hx), func(t *testing.T) {
39+ s, svc := asyncMergeServer(t, domain.MRStateMerged)
40+ svc.EXPECT().Merge(mock.Anything, mock.Anything, repoName(t, "demo"), int64(3), ports.MergeParams{Mode: mode}).Return(ports.MergeResult{}, nil).Once()
41+ r := autoMergeRequest("/demo/merge-requests/3/merge", "mode="+mode.String())
42+ if hx {
43+ r.Header.Set("HX-Request", "true")
44+ }
45+ w := httptest.NewRecorder()
46+ s.mrAction(s.mrMerge)(w, r)
47+ if !hx {
48+ assert.Equal(t, http.StatusSeeOther, w.Code)
49+ return
50+ }
51+ assert.Equal(t, http.StatusOK, w.Code)
52+ assert.Empty(t, w.Header().Get("Location"))
53+ assert.Contains(t, w.Body.String(), `id="mr-state"`)
54+ assert.Contains(t, w.Body.String(), `data-state="merged"`)
55+ assert.Contains(t, w.Body.String(), `id="mr-actions"`)
56+ assert.NotContains(t, w.Body.String(), "<html")
57+ assert.NotContains(t, w.Body.String(), `id="merge-menu"`)
58+ })
59+ }
60+ }
61+}
62+
63+func TestAsyncMergeRefusal(t *testing.T) {
64+ s, svc := asyncMergeServer(t, domain.MRStateOpen)
65+ svc.EXPECT().Merge(mock.Anything, mock.Anything, mock.Anything, int64(3), mock.Anything).Return(ports.MergeResult{}, domain.ErrConflict)
66+ r := autoMergeRequest("/demo/merge-requests/3/merge", "mode=squash")
67+ r = r.WithContext(context.WithValue(r.Context(), signedInKey{}, struct{}{}))
68+ r.Header.Set("HX-Request", "true")
69+ w := httptest.NewRecorder()
70+ s.mrAction(s.mrMerge)(w, r)
71+ assert.Equal(t, http.StatusConflict, w.Code)
72+ assert.Contains(t, w.Body.String(), `role="alert"`)
73+ assert.Contains(t, w.Body.String(), domain.ErrConflict.Error())
74+ assert.Contains(t, w.Body.String(), `hx-post="/demo/merge-requests/3/merge"`)
75+ assert.Contains(t, w.Body.String(), `hx-sync="this:drop"`)
76+ assert.Contains(t, w.Body.String(), "Merging…")
77+ assert.NotContains(t, w.Body.String(), "<html")
78+}
79+
80+func TestAsyncMergeLive(t *testing.T) {
81+ s, _ := asyncMergeServer(t, domain.MRStateMerged)
82+ l := liveStream{s: s, repo: "demo", view: "mr", n: "3"}
83+ fs, err := l.repoPage(t.Context(), watchOf(domain.MergeRequestMerged{MergeRequest: domain.MergeRequest{Number: 3}}))
84+ require.NoError(t, err)
85+ require.Len(t, fs, 1)
86+ assert.Equal(t, "mr-mergeability", fs[0].event)
87+ assert.Contains(t, fs[0].html, `data-state="merged"`)
88+ assert.Contains(t, fs[0].html, `id="mr-actions"`)
89+ assert.False(t, strings.Contains(fs[0].html, "reload"))
90+ fs, err = l.repoPage(t.Context(), watchOf(domain.MergeRequestMerged{MergeRequest: domain.MergeRequest{Number: 4}}))
91+ require.NoError(t, err)
92+ assert.Empty(t, fs)
93+}
94+
95+func TestAsyncMergeOtherFailures(t *testing.T) {
96+ for _, tt := range []struct {
97+ name string
98+ mergeErr error
99+ readFails bool
100+ status int
101+ message string
102+ }{
103+ {"forbidden", domain.ErrForbidden, false, http.StatusForbidden, "the session is not allowed"},
104+ {"internal", errors.New("private backend detail"), false, http.StatusInternalServerError, "reload to check its state"},
105+ {"post-merge read", nil, true, http.StatusOK, "merged, but the updated page could not be loaded"},
106+ } {
107+ t.Run(tt.name, func(t *testing.T) {
108+ s, svc := asyncMergeServer(t, domain.MRStateMerged)
109+ if tt.readFails {
110+ svc.On("Get", mock.Anything, mock.Anything, repoName(t, "demo"), int64(3), mock.Anything).Unset()
111+ svc.EXPECT().Get(mock.Anything, mock.Anything, repoName(t, "demo"), int64(3), mock.Anything).Return(ports.MergeRequestDetail{}, errors.New("private read detail")).Once()
112+ }
113+ svc.EXPECT().Merge(mock.Anything, mock.Anything, mock.Anything, int64(3), mock.Anything).Return(ports.MergeResult{}, tt.mergeErr).Once()
114+ r := autoMergeRequest("/demo/merge-requests/3/merge", "mode=squash")
115+ r.Header.Set("HX-Request", "true")
116+ w := httptest.NewRecorder()
117+ s.mrAction(s.mrMerge)(w, r)
118+ assert.Equal(t, tt.status, w.Code)
119+ assert.Contains(t, w.Body.String(), `id="mr-actions"`)
120+ assert.Contains(t, w.Body.String(), `hx-swap-oob="true"`)
121+ assert.Contains(t, w.Body.String(), `role="alert"`)
122+ assert.Contains(t, w.Body.String(), tt.message)
123+ assert.NotContains(t, w.Body.String(), "private")
124+ assert.NotContains(t, w.Body.String(), "<html")
125+ })
126+ }
127+}
128+
129+func TestMergeGateRefusalFragment(t *testing.T) {
130+ s, _ := newSessionServer(t, newSessions(t, "tok"), testPublicURL)
131+ for _, origin := range []string{testPublicURL, "https://other.example"} {
132+ for _, hx := range []bool{false, true} {
133+ r := httptest.NewRequest(http.MethodPost, "/demo/merge-requests/3/merge", strings.NewReader("mode=squash"))
134+ r.Header.Set("Origin", origin)
135+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
136+ if hx {
137+ r.Header.Set("HX-Request", "true")
138+ }
139+ w := httptest.NewRecorder()
140+ s.ServeHTTP(w, r)
141+ assert.Equal(t, http.StatusForbidden, w.Code)
142+ if hx {
143+ assert.Contains(t, w.Body.String(), `hx-swap-oob="true"`)
144+ assert.Contains(t, w.Body.String(), `id="mr-actions"`)
145+ if origin == testPublicURL {
146+ assert.Contains(t, w.Body.String(), "your session has expired")
147+ } else {
148+ assert.Contains(t, w.Body.String(), "origin was not accepted")
149+ }
150+ } else {
151+ assert.NotContains(t, w.Body.String(), "hx-swap-oob")
152+ }
153+ }
154+ }
155+}
156+
157+func TestNonHXMergeRefusalHasOneAlert(t *testing.T) {
158+ s, svc := asyncMergeServer(t, domain.MRStateMerged)
159+ svc.EXPECT().CountOpen(mock.Anything, mock.Anything, mock.Anything).Return(0, nil)
160+ svc.EXPECT().Merge(mock.Anything, mock.Anything, mock.Anything, int64(3), mock.Anything).Return(ports.MergeResult{}, domain.ErrConflict).Once()
161+ r := autoMergeRequest("/demo/merge-requests/3/merge", "mode=squash")
162+ w := httptest.NewRecorder()
163+ s.mrAction(s.mrMerge)(w, r)
164+ assert.Equal(t, http.StatusConflict, w.Code)
165+ assert.Equal(t, 1, strings.Count(w.Body.String(), `role="alert"`))
166+}
M internal/adapters/web/mr_post.go +33 -1
@@ -68,6 +68,20 @@ func (s *Server) mrAction(h func(ctx context.Context, r *http.Request) (to strin
6868 s.actionFailed(ctx, w, r, err, back)
6969 return
7070 }
71+ if isHXMerge(r) {
72+ c, _, err := s.mrPageView(ctx, r, false, true)
73+ if err != nil {
74+ id := requestID()
75+ log := zerowrap.FromCtx(ctx)
76+ log.Error().Err(err).Str("request_id", id).Msg("web merged state failed")
77+ w.Header().Set("Cache-Control", "no-store")
78+ s.write(ctx, w, r, http.StatusOK, MRActionStatus("merged, but the updated page could not be loaded. reload to see the result. reference: "+id, back))
79+ return
80+ }
81+ w.Header().Set("Cache-Control", "no-store")
82+ s.write(ctx, w, r, http.StatusOK, c)
83+ return
84+ }
7185 seeOther(w, r, to)
7286 }
7387 }
@@ -100,7 +114,7 @@ func mergeErrorOf(ctx context.Context) string {
100114 // It reports false when the page cannot be built; the caller then answers as
101115 // for any refused action.
102116 func (s *Server) rerenderMR(ctx context.Context, w http.ResponseWriter, r *http.Request, re *rerender) bool {
103- c, _, err := s.mrPage(withMergeError(ctx, re.msg), r, false)
117+ c, _, err := s.mrPageView(withMergeError(ctx, re.msg), r, false, isHXMerge(r))
104118 if err != nil {
105119 return false
106120 }
@@ -120,6 +134,10 @@ func (s *Server) actionFailed(ctx context.Context, w http.ResponseWriter, r *htt
120134 // The browser is gone.
121135 case errors.Is(err, domain.ErrNotFound):
122136 w.Header().Set("Cache-Control", "no-store")
137+ if isHXMerge(r) {
138+ s.write(ctx, w, r, http.StatusNotFound, MRActionStatus("the merge request was not found. reload to check its state.", back))
139+ return
140+ }
123141 s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage())
124142 case errors.Is(err, domain.ErrAlreadyExists):
125143 s.reject(ctx, w, r, http.StatusConflict, "already there", rule(msg, "this already exists."), back)
@@ -143,6 +161,10 @@ func (s *Server) internalFailure(ctx context.Context, w http.ResponseWriter, r *
143161 log := zerowrap.FromCtx(ctx)
144162 log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web action failed")
145163 w.Header().Set("Cache-Control", "no-store")
164+ if isHXMerge(r) {
165+ s.write(ctx, w, r, http.StatusInternalServerError, MRActionStatus("the merge could not be completed. reload to check its state. reference: "+id, strings.TrimSuffix(r.URL.Path, "/merge")))
166+ return
167+ }
146168 s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id))
147169 }
148170
@@ -161,6 +183,11 @@ func (s *Server) reject(ctx context.Context, w http.ResponseWriter, r *http.Requ
161183 if back == "" {
162184 back = "/"
163185 }
186+ if isHXMerge(r) {
187+ w.Header().Set("Cache-Control", "no-store")
188+ s.write(ctx, w, r, status, MRActionStatus(line, back))
189+ return
190+ }
164191 l := s.stateLayout(title, []Crumb{{Label: title, Current: true}})
165192 w.Header().Set("Cache-Control", "no-store")
166193 s.write(ctx, w, r, status, State(l, StatePage{
@@ -523,3 +550,8 @@ func (s *Server) mrDeleteComment(ctx context.Context, r *http.Request) (string,
523550 c, err := s.cfg.MergeRequests.DeleteComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")))
524551 return back + "#c" + strconv.FormatInt(c.ID, 10), back, err
525552 }
553+
554+// isHXMerge limits fragment responses to the progressively enhanced merge form.
555+func isHXMerge(r *http.Request) bool {
556+ return r.Header.Get("HX-Request") == "true" && strings.HasSuffix(r.URL.Path, "/merge")
557+}
@@ -68,6 +68,20 @@ func (s *Server) mrAction(h func(ctx context.Context, r *http.Request) (to strin
68 s.actionFailed(ctx, w, r, err, back)68 s.actionFailed(ctx, w, r, err, back)
69 return69 return
70 }70 }
71+ if isHXMerge(r) {
72+ c, _, err := s.mrPageView(ctx, r, false, true)
73+ if err != nil {
74+ id := requestID()
75+ log := zerowrap.FromCtx(ctx)
76+ log.Error().Err(err).Str("request_id", id).Msg("web merged state failed")
77+ w.Header().Set("Cache-Control", "no-store")
78+ s.write(ctx, w, r, http.StatusOK, MRActionStatus("merged, but the updated page could not be loaded. reload to see the result. reference: "+id, back))
79+ return
80+ }
81+ w.Header().Set("Cache-Control", "no-store")
82+ s.write(ctx, w, r, http.StatusOK, c)
83+ return
84+ }
71 seeOther(w, r, to)85 seeOther(w, r, to)
72 }86 }
73 }87 }
@@ -100,7 +114,7 @@ func mergeErrorOf(ctx context.Context) string {
100 // It reports false when the page cannot be built; the caller then answers as114 // It reports false when the page cannot be built; the caller then answers as
101 // for any refused action.115 // for any refused action.
102 func (s *Server) rerenderMR(ctx context.Context, w http.ResponseWriter, r *http.Request, re *rerender) bool {116 func (s *Server) rerenderMR(ctx context.Context, w http.ResponseWriter, r *http.Request, re *rerender) bool {
103- c, _, err := s.mrPage(withMergeError(ctx, re.msg), r, false)117+ c, _, err := s.mrPageView(withMergeError(ctx, re.msg), r, false, isHXMerge(r))
104 if err != nil {118 if err != nil {
105 return false119 return false
106 }120 }
@@ -120,6 +134,10 @@ func (s *Server) actionFailed(ctx context.Context, w http.ResponseWriter, r *htt
120 // The browser is gone.134 // The browser is gone.
121 case errors.Is(err, domain.ErrNotFound):135 case errors.Is(err, domain.ErrNotFound):
122 w.Header().Set("Cache-Control", "no-store")136 w.Header().Set("Cache-Control", "no-store")
137+ if isHXMerge(r) {
138+ s.write(ctx, w, r, http.StatusNotFound, MRActionStatus("the merge request was not found. reload to check its state.", back))
139+ return
140+ }
123 s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage())141 s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage())
124 case errors.Is(err, domain.ErrAlreadyExists):142 case errors.Is(err, domain.ErrAlreadyExists):
125 s.reject(ctx, w, r, http.StatusConflict, "already there", rule(msg, "this already exists."), back)143 s.reject(ctx, w, r, http.StatusConflict, "already there", rule(msg, "this already exists."), back)
@@ -143,6 +161,10 @@ func (s *Server) internalFailure(ctx context.Context, w http.ResponseWriter, r *
143 log := zerowrap.FromCtx(ctx)161 log := zerowrap.FromCtx(ctx)
144 log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web action failed")162 log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web action failed")
145 w.Header().Set("Cache-Control", "no-store")163 w.Header().Set("Cache-Control", "no-store")
164+ if isHXMerge(r) {
165+ s.write(ctx, w, r, http.StatusInternalServerError, MRActionStatus("the merge could not be completed. reload to check its state. reference: "+id, strings.TrimSuffix(r.URL.Path, "/merge")))
166+ return
167+ }
146 s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id))168 s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id))
147 }169 }
148 170
@@ -161,6 +183,11 @@ func (s *Server) reject(ctx context.Context, w http.ResponseWriter, r *http.Requ
161 if back == "" {183 if back == "" {
162 back = "/"184 back = "/"
163 }185 }
186+ if isHXMerge(r) {
187+ w.Header().Set("Cache-Control", "no-store")
188+ s.write(ctx, w, r, status, MRActionStatus(line, back))
189+ return
190+ }
164 l := s.stateLayout(title, []Crumb{{Label: title, Current: true}})191 l := s.stateLayout(title, []Crumb{{Label: title, Current: true}})
165 w.Header().Set("Cache-Control", "no-store")192 w.Header().Set("Cache-Control", "no-store")
166 s.write(ctx, w, r, status, State(l, StatePage{193 s.write(ctx, w, r, status, State(l, StatePage{
@@ -523,3 +550,8 @@ func (s *Server) mrDeleteComment(ctx context.Context, r *http.Request) (string,
523 c, err := s.cfg.MergeRequests.DeleteComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")))550 c, err := s.cfg.MergeRequests.DeleteComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")))
524 return back + "#c" + strconv.FormatInt(c.ID, 10), back, err551 return back + "#c" + strconv.FormatInt(c.ID, 10), back, err
525 }552 }
553+
554+// isHXMerge limits fragment responses to the progressively enhanced merge form.
555+func isHXMerge(r *http.Request) bool {
556+ return r.Header.Get("HX-Request") == "true" && strings.HasSuffix(r.URL.Path, "/merge")
557+}
M internal/adapters/web/mr_templ.go +1226 -1034 generated file: collapsed
M internal/adapters/web/overlays.templ +0 -3
@@ -27,9 +27,6 @@ templ OverlayHelp(mr bool) {
2727 <div><dt><kbd>/</kbd></dt><dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd></div>
2828 <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div>
2929 <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd> <kbd>i</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd></div>
30- if SignedIn(ctx) {
31- <div><dt><kbd>I</kbd></dt><dd>inbox</dd></div>
32- }
3330 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>
3431 <div><dt><kbd>w</kbd></dt><dd>wide mode: use the whole width of the screen, or not (remembered)</dd></div>
3532 <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div>
@@ -27,9 +27,6 @@ templ OverlayHelp(mr bool) {
27 <div><dt><kbd>/</kbd></dt><dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd></div>27 <div><dt><kbd>/</kbd></dt><dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd></div>
28 <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div>28 <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div>
29 <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd> <kbd>i</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd></div>29 <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd> <kbd>i</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd></div>
30- if SignedIn(ctx) {
31- <div><dt><kbd>I</kbd></dt><dd>inbox</dd></div>
32- }
33 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>30 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>
34 <div><dt><kbd>w</kbd></dt><dd>wide mode: use the whole width of the screen, or not (remembered)</dd></div>31 <div><dt><kbd>w</kbd></dt><dd>wide mode: use the whole width of the screen, or not (remembered)</dd></div>
35 <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div>32 <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div>
M internal/adapters/web/overlays_templ.go +39 -49 generated file: collapsed
M internal/adapters/web/session.go +12 -2
@@ -178,11 +178,11 @@ func (s *Server) gate(w http.ResponseWriter, r *http.Request) bool {
178178 // Host header, which the client chooses. A missing header, "null" and any
179179 // other origin are refused.
180180 if r.Header.Get("Origin") != s.origin {
181- denyForbidden(w)
181+ s.denyMergeGate(w, r, "the request origin was not accepted. reload this page and try again.")
182182 return false
183183 }
184184 if !SignedIn(r.Context()) && !s.anonymousPost(r) {
185- denyForbidden(w)
185+ s.denyMergeGate(w, r, "your session has expired. sign in again and reload.")
186186 return false
187187 }
188188 return true
@@ -335,3 +335,13 @@ func (s *Server) loginRejectedPage() templ.Component {
335335 return s.stateOnly("sign in", "404", "login link not valid",
336336 "this link has expired, was already used, or never existed.", "run `ermite web login` for a new one.")
337337 }
338+
339+// denyMergeGate keeps gate refusals visible to the enhanced merge form.
340+func (s *Server) denyMergeGate(w http.ResponseWriter, r *http.Request, message string) {
341+ if !isHXMerge(r) {
342+ denyForbidden(w)
343+ return
344+ }
345+ w.Header().Set("Cache-Control", "no-store")
346+ s.write(r.Context(), w, r, http.StatusForbidden, MRActionStatus(message, strings.TrimSuffix(r.URL.Path, "/merge")))
347+}
@@ -178,11 +178,11 @@ func (s *Server) gate(w http.ResponseWriter, r *http.Request) bool {
178 // Host header, which the client chooses. A missing header, "null" and any178 // Host header, which the client chooses. A missing header, "null" and any
179 // other origin are refused.179 // other origin are refused.
180 if r.Header.Get("Origin") != s.origin {180 if r.Header.Get("Origin") != s.origin {
181- denyForbidden(w)181+ s.denyMergeGate(w, r, "the request origin was not accepted. reload this page and try again.")
182 return false182 return false
183 }183 }
184 if !SignedIn(r.Context()) && !s.anonymousPost(r) {184 if !SignedIn(r.Context()) && !s.anonymousPost(r) {
185- denyForbidden(w)185+ s.denyMergeGate(w, r, "your session has expired. sign in again and reload.")
186 return false186 return false
187 }187 }
188 return true188 return true
@@ -335,3 +335,13 @@ func (s *Server) loginRejectedPage() templ.Component {
335 return s.stateOnly("sign in", "404", "login link not valid",335 return s.stateOnly("sign in", "404", "login link not valid",
336 "this link has expired, was already used, or never existed.", "run `ermite web login` for a new one.")336 "this link has expired, was already used, or never existed.", "run `ermite web login` for a new one.")
337 }337 }
338+
339+// denyMergeGate keeps gate refusals visible to the enhanced merge form.
340+func (s *Server) denyMergeGate(w http.ResponseWriter, r *http.Request, message string) {
341+ if !isHXMerge(r) {
342+ denyForbidden(w)
343+ return
344+ }
345+ w.Header().Set("Cache-Control", "no-store")
346+ s.write(r.Context(), w, r, http.StatusForbidden, MRActionStatus(message, strings.TrimSuffix(r.URL.Path, "/merge")))
347+}
M internal/adapters/web/shell.templ +1 -5
@@ -67,9 +67,6 @@ templ Shell(l Layout, rail templ.Component) {
6767 </div>
6868 @Footer(l)
6969 @Hint(l)
70- if SignedIn(ctx) {
71- <a hidden data-inbox href={ inboxPath }>Inbox</a>
72- }
7370 @OverlayHelp(l.MR)
7471 @OverlayRefs(l)
7572 @OverlayCmd(l.Cmd)
@@ -90,8 +87,7 @@ templ Header(l Layout) {
9087 <a
9188 class="btn inbox-btn"
9289 href={ templ.SafeURL(l.Inbox.Href) }
93- title="inbox (I)"
94- aria-keyshortcuts="Shift+I"
90+ title="inbox"
9591 >
9692 <span class="ico i-mailbox" aria-hidden="true"></span>
9793 Inbox
@@ -67,9 +67,6 @@ templ Shell(l Layout, rail templ.Component) {
67 </div>67 </div>
68 @Footer(l)68 @Footer(l)
69 @Hint(l)69 @Hint(l)
70- if SignedIn(ctx) {
71- <a hidden data-inbox href={ inboxPath }>Inbox</a>
72- }
73 @OverlayHelp(l.MR)70 @OverlayHelp(l.MR)
74 @OverlayRefs(l)71 @OverlayRefs(l)
75 @OverlayCmd(l.Cmd)72 @OverlayCmd(l.Cmd)
@@ -90,8 +87,7 @@ templ Header(l Layout) {
90 <a87 <a
91 class="btn inbox-btn"88 class="btn inbox-btn"
92 href={ templ.SafeURL(l.Inbox.Href) }89 href={ templ.SafeURL(l.Inbox.Href) }
93- title="inbox (I)"90+ title="inbox"
94- aria-keyshortcuts="Shift+I"
95 >91 >
96 <span class="ico i-mailbox" aria-hidden="true"></span>92 <span class="ico i-mailbox" aria-hidden="true"></span>
97 Inbox93 Inbox
M internal/adapters/web/shell_templ.go +133 -152
@@ -426,25 +426,6 @@ func Shell(l Layout, rail templ.Component) templ.Component {
426426 if templ_7745c5c3_Err != nil {
427427 return templ_7745c5c3_Err
428428 }
429- if SignedIn(ctx) {
430- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<a hidden data-inbox href=\"")
431- if templ_7745c5c3_Err != nil {
432- return templ_7745c5c3_Err
433- }
434- var templ_7745c5c3_Var26 templ.SafeURL
435- templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinURLErrs(inboxPath)
436- if templ_7745c5c3_Err != nil {
437- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 71, Col: 41}
438- }
439- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
440- if templ_7745c5c3_Err != nil {
441- return templ_7745c5c3_Err
442- }
443- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\">Inbox</a>")
444- if templ_7745c5c3_Err != nil {
445- return templ_7745c5c3_Err
446- }
447- }
448429 templ_7745c5c3_Err = OverlayHelp(l.MR).Render(ctx, templ_7745c5c3_Buffer)
449430 if templ_7745c5c3_Err != nil {
450431 return templ_7745c5c3_Err
@@ -461,7 +442,7 @@ func Shell(l Layout, rail templ.Component) templ.Component {
461442 if templ_7745c5c3_Err != nil {
462443 return templ_7745c5c3_Err
463444 }
464- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</body></html>")
445+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</body></html>")
465446 if templ_7745c5c3_Err != nil {
466447 return templ_7745c5c3_Err
467448 }
@@ -487,366 +468,366 @@ func Header(l Layout) templ.Component {
487468 }()
488469 }
489470 ctx = templ.InitializeContext(ctx)
490- templ_7745c5c3_Var27 := templ.GetChildren(ctx)
491- if templ_7745c5c3_Var27 == nil {
492- templ_7745c5c3_Var27 = templ.NopComponent
471+ templ_7745c5c3_Var26 := templ.GetChildren(ctx)
472+ if templ_7745c5c3_Var26 == nil {
473+ templ_7745c5c3_Var26 = templ.NopComponent
493474 }
494475 ctx = templ.ClearChildren(ctx)
495- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<header class=\"hdr\"><div class=\"hdr-top\"><a class=\"brand\" href=\"/\" data-g=\"h\" aria-label=\"home\" title=\"home (g h)\"><span class=\"brand-mark\" aria-hidden=\"true\"></span></a> ")
476+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<header class=\"hdr\"><div class=\"hdr-top\"><a class=\"brand\" href=\"/\" data-g=\"h\" aria-label=\"home\" title=\"home (g h)\"><span class=\"brand-mark\" aria-hidden=\"true\"></span></a> ")
496477 if templ_7745c5c3_Err != nil {
497478 return templ_7745c5c3_Err
498479 }
499480 if l.Inbox != nil {
500- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<a class=\"btn inbox-btn\" href=\"")
481+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<a class=\"btn inbox-btn\" href=\"")
501482 if templ_7745c5c3_Err != nil {
502483 return templ_7745c5c3_Err
503484 }
504- var templ_7745c5c3_Var28 templ.SafeURL
505- templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.Inbox.Href))
485+ var templ_7745c5c3_Var27 templ.SafeURL
486+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.Inbox.Href))
506487 if templ_7745c5c3_Err != nil {
507- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 92, Col: 39}
488+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 89, Col: 39}
508489 }
509- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
490+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
510491 if templ_7745c5c3_Err != nil {
511492 return templ_7745c5c3_Err
512493 }
513- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\" title=\"inbox (I)\" aria-keyshortcuts=\"Shift+I\"><span class=\"ico i-mailbox\" aria-hidden=\"true\"></span> Inbox ")
494+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\" title=\"inbox\"><span class=\"ico i-mailbox\" aria-hidden=\"true\"></span> Inbox ")
514495 if templ_7745c5c3_Err != nil {
515496 return templ_7745c5c3_Err
516497 }
517498 if l.Inbox.Count >= 0 {
518- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<span class=\"count\">")
499+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<span class=\"count\">")
519500 if templ_7745c5c3_Err != nil {
520501 return templ_7745c5c3_Err
521502 }
522- var templ_7745c5c3_Var29 string
523- templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(l.Inbox.Count))
503+ var templ_7745c5c3_Var28 string
504+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(l.Inbox.Count))
524505 if templ_7745c5c3_Err != nil {
525- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 99, Col: 47}
506+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 95, Col: 47}
526507 }
527- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
508+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
528509 if templ_7745c5c3_Err != nil {
529510 return templ_7745c5c3_Err
530511 }
531- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</span>")
512+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</span>")
532513 if templ_7745c5c3_Err != nil {
533514 return templ_7745c5c3_Err
534515 }
535516 }
536- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</a> ")
517+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</a> ")
537518 if templ_7745c5c3_Err != nil {
538519 return templ_7745c5c3_Err
539520 }
540521 }
541522 if len(l.Crumbs) > 0 {
542- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<nav class=\"crumbs\" aria-label=\"Path\"><ol>")
523+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<nav class=\"crumbs\" aria-label=\"Path\"><ol>")
543524 if templ_7745c5c3_Err != nil {
544525 return templ_7745c5c3_Err
545526 }
546527 for _, c := range l.Crumbs {
547- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<li>")
528+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<li>")
548529 if templ_7745c5c3_Err != nil {
549530 return templ_7745c5c3_Err
550531 }
551532 if c.Href != "" {
552- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<a href=\"")
533+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<a href=\"")
553534 if templ_7745c5c3_Err != nil {
554535 return templ_7745c5c3_Err
555536 }
556- var templ_7745c5c3_Var30 templ.SafeURL
557- templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
537+ var templ_7745c5c3_Var29 templ.SafeURL
538+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
558539 if templ_7745c5c3_Err != nil {
559- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 110, Col: 38}
540+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 106, Col: 38}
560541 }
561- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
542+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
562543 if templ_7745c5c3_Err != nil {
563544 return templ_7745c5c3_Err
564545 }
565- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\"")
546+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\"")
566547 if templ_7745c5c3_Err != nil {
567548 return templ_7745c5c3_Err
568549 }
569550 if c.Current {
570- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, " aria-current=\"page\"")
551+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, " aria-current=\"page\"")
571552 if templ_7745c5c3_Err != nil {
572553 return templ_7745c5c3_Err
573554 }
574555 }
575- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, ">")
556+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, ">")
576557 if templ_7745c5c3_Err != nil {
577558 return templ_7745c5c3_Err
578559 }
579- var templ_7745c5c3_Var31 string
580- templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
560+ var templ_7745c5c3_Var30 string
561+ templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
581562 if templ_7745c5c3_Err != nil {
582- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 114, Col: 19}
563+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 110, Col: 19}
583564 }
584- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
565+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
585566 if templ_7745c5c3_Err != nil {
586567 return templ_7745c5c3_Err
587568 }
588- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</a>")
569+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</a>")
589570 if templ_7745c5c3_Err != nil {
590571 return templ_7745c5c3_Err
591572 }
592573 } else {
593- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<span")
574+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<span")
594575 if templ_7745c5c3_Err != nil {
595576 return templ_7745c5c3_Err
596577 }
597578 if c.Current {
598- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, " aria-current=\"page\"")
579+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, " aria-current=\"page\"")
599580 if templ_7745c5c3_Err != nil {
600581 return templ_7745c5c3_Err
601582 }
602583 }
603- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, ">")
584+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, ">")
604585 if templ_7745c5c3_Err != nil {
605586 return templ_7745c5c3_Err
606587 }
607- var templ_7745c5c3_Var32 string
608- templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
588+ var templ_7745c5c3_Var31 string
589+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
609590 if templ_7745c5c3_Err != nil {
610- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 120, Col: 19}
591+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 116, Col: 19}
611592 }
612- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
593+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
613594 if templ_7745c5c3_Err != nil {
614595 return templ_7745c5c3_Err
615596 }
616- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</span>")
597+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</span>")
617598 if templ_7745c5c3_Err != nil {
618599 return templ_7745c5c3_Err
619600 }
620601 }
621- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</li>")
602+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</li>")
622603 if templ_7745c5c3_Err != nil {
623604 return templ_7745c5c3_Err
624605 }
625606 }
626- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</ol></nav>")
607+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</ol></nav>")
627608 if templ_7745c5c3_Err != nil {
628609 return templ_7745c5c3_Err
629610 }
630611 }
631- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<div class=\"hdr-acts\">")
612+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<div class=\"hdr-acts\">")
632613 if templ_7745c5c3_Err != nil {
633614 return templ_7745c5c3_Err
634615 }
635616 if l.Ref != "" {
636- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<a class=\"btn ref\" href=\"")
617+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<a class=\"btn ref\" href=\"")
637618 if templ_7745c5c3_Err != nil {
638619 return templ_7745c5c3_Err
639620 }
640- var templ_7745c5c3_Var33 templ.SafeURL
641- templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(repoHref(l.Repo, "refs")))
621+ var templ_7745c5c3_Var32 templ.SafeURL
622+ templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(repoHref(l.Repo, "refs")))
642623 if templ_7745c5c3_Err != nil {
643- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 131, Col: 52}
624+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 127, Col: 52}
644625 }
645- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
626+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
646627 if templ_7745c5c3_Err != nil {
647628 return templ_7745c5c3_Err
648629 }
649- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\" data-fire=\"refs\" aria-keyshortcuts=\"b\" title=\"switch branch or tag (b)\"><span class=\"ico i-branch\" aria-hidden=\"true\"></span> <span class=\"ref-name\">")
630+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\" data-fire=\"refs\" aria-keyshortcuts=\"b\" title=\"switch branch or tag (b)\"><span class=\"ico i-branch\" aria-hidden=\"true\"></span> <span class=\"ref-name\">")
650631 if templ_7745c5c3_Err != nil {
651632 return templ_7745c5c3_Err
652633 }
653- var templ_7745c5c3_Var34 string
654- templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(l.Ref)
634+ var templ_7745c5c3_Var33 string
635+ templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(l.Ref)
655636 if templ_7745c5c3_Err != nil {
656- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 137, Col: 36}
637+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 133, Col: 36}
657638 }
658- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
639+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
659640 if templ_7745c5c3_Err != nil {
660641 return templ_7745c5c3_Err
661642 }
662- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</span> <span class=\"sha\">")
643+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</span> <span class=\"sha\">")
663644 if templ_7745c5c3_Err != nil {
664645 return templ_7745c5c3_Err
665646 }
666- var templ_7745c5c3_Var35 string
667- templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(l.Short)
647+ var templ_7745c5c3_Var34 string
648+ templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(l.Short)
668649 if templ_7745c5c3_Err != nil {
669- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 138, Col: 33}
650+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 134, Col: 33}
670651 }
671- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
652+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
672653 if templ_7745c5c3_Err != nil {
673654 return templ_7745c5c3_Err
674655 }
675- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</span></a> ")
656+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</span></a> ")
676657 if templ_7745c5c3_Err != nil {
677658 return templ_7745c5c3_Err
678659 }
679660 }
680- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<button type=\"button\" class=\"btn search\" data-js aria-haspopup=\"dialog\" aria-controls=\"panel-cmd\" aria-keyshortcuts=\"Control+K :\" title=\"go to a section, ref, file or repo (ctrl k or :)\" data-fire=\"cmd\"><span class=\"ico i-search\" aria-hidden=\"true\"></span> <span class=\"search-label\">Go to…</span> <kbd aria-hidden=\"true\">ctrl k</kbd></button> ")
661+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<button type=\"button\" class=\"btn search\" data-js aria-haspopup=\"dialog\" aria-controls=\"panel-cmd\" aria-keyshortcuts=\"Control+K :\" title=\"go to a section, ref, file or repo (ctrl k or :)\" data-fire=\"cmd\"><span class=\"ico i-search\" aria-hidden=\"true\"></span> <span class=\"search-label\">Go to…</span> <kbd aria-hidden=\"true\">ctrl k</kbd></button> ")
681662 if templ_7745c5c3_Err != nil {
682663 return templ_7745c5c3_Err
683664 }
684665 if SignedIn(ctx) {
685- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<form class=\"owner\" method=\"post\" action=\"/-/logout\"><span class=\"owner-badge\" title=\"signed in as the owner\"><span class=\"ico i-user\" aria-hidden=\"true\"></span>")
666+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<form class=\"owner\" method=\"post\" action=\"/-/logout\"><span class=\"owner-badge\" title=\"signed in as the owner\"><span class=\"ico i-user\" aria-hidden=\"true\"></span>")
686667 if templ_7745c5c3_Err != nil {
687668 return templ_7745c5c3_Err
688669 }
689- var templ_7745c5c3_Var36 string
690- templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(l.Owner)
670+ var templ_7745c5c3_Var35 string
671+ templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(l.Owner)
691672 if templ_7745c5c3_Err != nil {
692- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 157, Col: 123}
673+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 153, Col: 123}
693674 }
694- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
675+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
695676 if templ_7745c5c3_Err != nil {
696677 return templ_7745c5c3_Err
697678 }
698- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</span> ")
679+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</span> ")
699680 if templ_7745c5c3_Err != nil {
700681 return templ_7745c5c3_Err
701682 }
702683 if l.Passkeys {
703- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<a class=\"btn icon\" href=\"/-/passkeys\" aria-label=\"Passkeys\" title=\"passkeys: add or remove the passkeys that sign you in\"><span class=\"ico i-key\" aria-hidden=\"true\"></span></a> ")
684+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<a class=\"btn icon\" href=\"/-/passkeys\" aria-label=\"Passkeys\" title=\"passkeys: add or remove the passkeys that sign you in\"><span class=\"ico i-key\" aria-hidden=\"true\"></span></a> ")
704685 if templ_7745c5c3_Err != nil {
705686 return templ_7745c5c3_Err
706687 }
707688 }
708- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<button type=\"submit\" class=\"btn icon\" aria-label=\"Sign out\" title=\"sign out of this browser\"><span class=\"ico i-log-out\" aria-hidden=\"true\"></span></button></form>")
689+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<button type=\"submit\" class=\"btn icon\" aria-label=\"Sign out\" title=\"sign out of this browser\"><span class=\"ico i-log-out\" aria-hidden=\"true\"></span></button></form>")
709690 if templ_7745c5c3_Err != nil {
710691 return templ_7745c5c3_Err
711692 }
712693 } else if l.Passkeys {
713- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<a class=\"btn icon\" href=\"/-/login\" aria-label=\"Sign in\" title=\"sign in with a passkey\"><span class=\"ico i-log-in\" aria-hidden=\"true\"></span></a>")
694+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<a class=\"btn icon\" href=\"/-/login\" aria-label=\"Sign in\" title=\"sign in with a passkey\"><span class=\"ico i-log-in\" aria-hidden=\"true\"></span></a>")
714695 if templ_7745c5c3_Err != nil {
715696 return templ_7745c5c3_Err
716697 }
717698 }
718- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</div></div>")
699+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</div></div>")
719700 if templ_7745c5c3_Err != nil {
720701 return templ_7745c5c3_Err
721702 }
722703 if len(l.Sections) > 0 {
723- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<nav class=\"tabs\" aria-label=\"Sections\"><ul>")
704+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<nav class=\"tabs\" aria-label=\"Sections\"><ul>")
724705 if templ_7745c5c3_Err != nil {
725706 return templ_7745c5c3_Err
726707 }
727708 for _, s := range l.Sections {
728- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<li><a href=\"")
709+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<li><a href=\"")
729710 if templ_7745c5c3_Err != nil {
730711 return templ_7745c5c3_Err
731712 }
732- var templ_7745c5c3_Var37 templ.SafeURL
733- templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href))
713+ var templ_7745c5c3_Var36 templ.SafeURL
714+ templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href))
734715 if templ_7745c5c3_Err != nil {
735- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 174, Col: 36}
716+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 170, Col: 36}
736717 }
737- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
718+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
738719 if templ_7745c5c3_Err != nil {
739720 return templ_7745c5c3_Err
740721 }
741- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\" data-key=\"")
722+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\" data-key=\"")
742723 if templ_7745c5c3_Err != nil {
743724 return templ_7745c5c3_Err
744725 }
745- var templ_7745c5c3_Var38 string
746- templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
726+ var templ_7745c5c3_Var37 string
727+ templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
747728 if templ_7745c5c3_Err != nil {
748- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 175, Col: 24}
729+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 171, Col: 24}
749730 }
750- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
731+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
751732 if templ_7745c5c3_Err != nil {
752733 return templ_7745c5c3_Err
753734 }
754- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\" aria-keyshortcuts=\"")
735+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "\" aria-keyshortcuts=\"")
755736 if templ_7745c5c3_Err != nil {
756737 return templ_7745c5c3_Err
757738 }
758- var templ_7745c5c3_Var39 string
759- templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
739+ var templ_7745c5c3_Var38 string
740+ templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
760741 if templ_7745c5c3_Err != nil {
761- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 176, Col: 33}
742+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 172, Col: 33}
762743 }
763- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
744+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
764745 if templ_7745c5c3_Err != nil {
765746 return templ_7745c5c3_Err
766747 }
767- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "\"")
748+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\"")
768749 if templ_7745c5c3_Err != nil {
769750 return templ_7745c5c3_Err
770751 }
771752 if s.Current {
772- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, " aria-current=\"page\"")
753+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, " aria-current=\"page\"")
773754 if templ_7745c5c3_Err != nil {
774755 return templ_7745c5c3_Err
775756 }
776757 }
777- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, ">")
758+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, ">")
778759 if templ_7745c5c3_Err != nil {
779760 return templ_7745c5c3_Err
780761 }
781- var templ_7745c5c3_Var40 string
782- templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label)
762+ var templ_7745c5c3_Var39 string
763+ templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label)
783764 if templ_7745c5c3_Err != nil {
784- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 181, Col: 17}
765+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 177, Col: 17}
785766 }
786- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
767+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
787768 if templ_7745c5c3_Err != nil {
788769 return templ_7745c5c3_Err
789770 }
790- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, " ")
771+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, " ")
791772 if templ_7745c5c3_Err != nil {
792773 return templ_7745c5c3_Err
793774 }
794775 if s.Count != "" {
795- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<span class=\"count\">")
776+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<span class=\"count\">")
796777 if templ_7745c5c3_Err != nil {
797778 return templ_7745c5c3_Err
798779 }
799- var templ_7745c5c3_Var41 string
800- templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(s.Count)
780+ var templ_7745c5c3_Var40 string
781+ templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(s.Count)
801782 if templ_7745c5c3_Err != nil {
802- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 183, Col: 38}
783+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 179, Col: 38}
803784 }
804- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
785+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
805786 if templ_7745c5c3_Err != nil {
806787 return templ_7745c5c3_Err
807788 }
808- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<span class=\"sr\">")
789+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<span class=\"sr\">")
809790 if templ_7745c5c3_Err != nil {
810791 return templ_7745c5c3_Err
811792 }
812- var templ_7745c5c3_Var42 string
813- templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(" open")
793+ var templ_7745c5c3_Var41 string
794+ templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(" open")
814795 if templ_7745c5c3_Err != nil {
815- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 183, Col: 66}
796+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 179, Col: 66}
816797 }
817- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
798+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
818799 if templ_7745c5c3_Err != nil {
819800 return templ_7745c5c3_Err
820801 }
821- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</span></span> ")
802+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</span></span> ")
822803 if templ_7745c5c3_Err != nil {
823804 return templ_7745c5c3_Err
824805 }
825806 }
826- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<kbd aria-hidden=\"true\">")
807+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<kbd aria-hidden=\"true\">")
827808 if templ_7745c5c3_Err != nil {
828809 return templ_7745c5c3_Err
829810 }
830- var templ_7745c5c3_Var43 string
831- templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(s.Key)
811+ var templ_7745c5c3_Var42 string
812+ templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(s.Key)
832813 if templ_7745c5c3_Err != nil {
833- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 185, Col: 39}
814+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 181, Col: 39}
834815 }
835- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
816+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
836817 if templ_7745c5c3_Err != nil {
837818 return templ_7745c5c3_Err
838819 }
839- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</kbd></a></li>")
820+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</kbd></a></li>")
840821 if templ_7745c5c3_Err != nil {
841822 return templ_7745c5c3_Err
842823 }
843824 }
844- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</ul></nav>")
825+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</ul></nav>")
845826 if templ_7745c5c3_Err != nil {
846827 return templ_7745c5c3_Err
847828 }
848829 }
849- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</header>")
830+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</header>")
850831 if templ_7745c5c3_Err != nil {
851832 return templ_7745c5c3_Err
852833 }
@@ -872,38 +853,38 @@ func Footer(l Layout) templ.Component {
872853 }()
873854 }
874855 ctx = templ.InitializeContext(ctx)
875- templ_7745c5c3_Var44 := templ.GetChildren(ctx)
876- if templ_7745c5c3_Var44 == nil {
877- templ_7745c5c3_Var44 = templ.NopComponent
856+ templ_7745c5c3_Var43 := templ.GetChildren(ctx)
857+ if templ_7745c5c3_Var43 == nil {
858+ templ_7745c5c3_Var43 = templ.NopComponent
878859 }
879860 ctx = templ.ClearChildren(ctx)
880- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "<footer class=\"foot\"><span>ermite ")
861+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<footer class=\"foot\"><span>ermite ")
881862 if templ_7745c5c3_Err != nil {
882863 return templ_7745c5c3_Err
883864 }
884- var templ_7745c5c3_Var45 string
885- templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version)
865+ var templ_7745c5c3_Var44 string
866+ templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version)
886867 if templ_7745c5c3_Err != nil {
887- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 199, Col: 26}
868+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 195, Col: 26}
888869 }
889- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
870+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
890871 if templ_7745c5c3_Err != nil {
891872 return templ_7745c5c3_Err
892873 }
893- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</span> <a href=\"")
874+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</span> <a href=\"")
894875 if templ_7745c5c3_Err != nil {
895876 return templ_7745c5c3_Err
896877 }
897- var templ_7745c5c3_Var46 templ.SafeURL
898- templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL))
878+ var templ_7745c5c3_Var45 templ.SafeURL
879+ templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL))
899880 if templ_7745c5c3_Err != nil {
900- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 200, Col: 38}
881+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 196, Col: 38}
901882 }
902- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
883+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
903884 if templ_7745c5c3_Err != nil {
904885 return templ_7745c5c3_Err
905886 }
906- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\" rel=\"noopener\" title=\"source of this version (AGPL-3.0-or-later)\">source</a> <button type=\"button\" class=\"foot-keys\" data-js data-fire=\"help\" aria-haspopup=\"dialog\" aria-controls=\"panel-help\" aria-keyshortcuts=\"?\"><kbd aria-hidden=\"true\">?</kbd> keyboard shortcuts</button></footer>")
887+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "\" rel=\"noopener\" title=\"source of this version (AGPL-3.0-or-later)\">source</a> <button type=\"button\" class=\"foot-keys\" data-js data-fire=\"help\" aria-haspopup=\"dialog\" aria-controls=\"panel-help\" aria-keyshortcuts=\"?\"><kbd aria-hidden=\"true\">?</kbd> keyboard shortcuts</button></footer>")
907888 if templ_7745c5c3_Err != nil {
908889 return templ_7745c5c3_Err
909890 }
@@ -931,12 +912,12 @@ func Hint(l Layout) templ.Component {
931912 }()
932913 }
933914 ctx = templ.InitializeContext(ctx)
934- templ_7745c5c3_Var47 := templ.GetChildren(ctx)
935- if templ_7745c5c3_Var47 == nil {
936- templ_7745c5c3_Var47 = templ.NopComponent
915+ templ_7745c5c3_Var46 := templ.GetChildren(ctx)
916+ if templ_7745c5c3_Var46 == nil {
917+ templ_7745c5c3_Var46 = templ.NopComponent
937918 }
938919 ctx = templ.ClearChildren(ctx)
939- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<div class=\"hint\" role=\"status\" aria-live=\"polite\"><span class=\"hint-nav\" :text=\"document.body.dataset.nav || ''\"></span> <span class=\"hint-sel\" :text=\"document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''\"></span> <span class=\"hint-flash\" :text=\"document.body.dataset.flash || ''\"></span>")
920+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<div class=\"hint\" role=\"status\" aria-live=\"polite\"><span class=\"hint-nav\" :text=\"document.body.dataset.nav || ''\"></span> <span class=\"hint-sel\" :text=\"document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''\"></span> <span class=\"hint-flash\" :text=\"document.body.dataset.flash || ''\"></span>")
940921 if templ_7745c5c3_Err != nil {
941922 return templ_7745c5c3_Err
942923 }
@@ -944,7 +925,7 @@ func Hint(l Layout) templ.Component {
944925 if templ_7745c5c3_Err != nil {
945926 return templ_7745c5c3_Err
946927 }
947- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "</div>")
928+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</div>")
948929 if templ_7745c5c3_Err != nil {
949930 return templ_7745c5c3_Err
950931 }
@@ -426,25 +426,6 @@ func Shell(l Layout, rail templ.Component) templ.Component {
426 if templ_7745c5c3_Err != nil {426 if templ_7745c5c3_Err != nil {
427 return templ_7745c5c3_Err427 return templ_7745c5c3_Err
428 }428 }
429- if SignedIn(ctx) {
430- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<a hidden data-inbox href=\"")
431- if templ_7745c5c3_Err != nil {
432- return templ_7745c5c3_Err
433- }
434- var templ_7745c5c3_Var26 templ.SafeURL
435- templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinURLErrs(inboxPath)
436- if templ_7745c5c3_Err != nil {
437- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 71, Col: 41}
438- }
439- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
440- if templ_7745c5c3_Err != nil {
441- return templ_7745c5c3_Err
442- }
443- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\">Inbox</a>")
444- if templ_7745c5c3_Err != nil {
445- return templ_7745c5c3_Err
446- }
447- }
448 templ_7745c5c3_Err = OverlayHelp(l.MR).Render(ctx, templ_7745c5c3_Buffer)429 templ_7745c5c3_Err = OverlayHelp(l.MR).Render(ctx, templ_7745c5c3_Buffer)
449 if templ_7745c5c3_Err != nil {430 if templ_7745c5c3_Err != nil {
450 return templ_7745c5c3_Err431 return templ_7745c5c3_Err
@@ -461,7 +442,7 @@ func Shell(l Layout, rail templ.Component) templ.Component {
461 if templ_7745c5c3_Err != nil {442 if templ_7745c5c3_Err != nil {
462 return templ_7745c5c3_Err443 return templ_7745c5c3_Err
463 }444 }
464- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</body></html>")445+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</body></html>")
465 if templ_7745c5c3_Err != nil {446 if templ_7745c5c3_Err != nil {
466 return templ_7745c5c3_Err447 return templ_7745c5c3_Err
467 }448 }
@@ -487,366 +468,366 @@ func Header(l Layout) templ.Component {
487 }()468 }()
488 }469 }
489 ctx = templ.InitializeContext(ctx)470 ctx = templ.InitializeContext(ctx)
490- templ_7745c5c3_Var27 := templ.GetChildren(ctx)471+ templ_7745c5c3_Var26 := templ.GetChildren(ctx)
491- if templ_7745c5c3_Var27 == nil {472+ if templ_7745c5c3_Var26 == nil {
492- templ_7745c5c3_Var27 = templ.NopComponent473+ templ_7745c5c3_Var26 = templ.NopComponent
493 }474 }
494 ctx = templ.ClearChildren(ctx)475 ctx = templ.ClearChildren(ctx)
495- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<header class=\"hdr\"><div class=\"hdr-top\"><a class=\"brand\" href=\"/\" data-g=\"h\" aria-label=\"home\" title=\"home (g h)\"><span class=\"brand-mark\" aria-hidden=\"true\"></span></a> ")476+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<header class=\"hdr\"><div class=\"hdr-top\"><a class=\"brand\" href=\"/\" data-g=\"h\" aria-label=\"home\" title=\"home (g h)\"><span class=\"brand-mark\" aria-hidden=\"true\"></span></a> ")
496 if templ_7745c5c3_Err != nil {477 if templ_7745c5c3_Err != nil {
497 return templ_7745c5c3_Err478 return templ_7745c5c3_Err
498 }479 }
499 if l.Inbox != nil {480 if l.Inbox != nil {
500- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<a class=\"btn inbox-btn\" href=\"")481+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<a class=\"btn inbox-btn\" href=\"")
501 if templ_7745c5c3_Err != nil {482 if templ_7745c5c3_Err != nil {
502 return templ_7745c5c3_Err483 return templ_7745c5c3_Err
503 }484 }
504- var templ_7745c5c3_Var28 templ.SafeURL485+ var templ_7745c5c3_Var27 templ.SafeURL
505- templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.Inbox.Href))486+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.Inbox.Href))
506 if templ_7745c5c3_Err != nil {487 if templ_7745c5c3_Err != nil {
507- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 92, Col: 39}488+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 89, Col: 39}
508 }489 }
509- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))490+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
510 if templ_7745c5c3_Err != nil {491 if templ_7745c5c3_Err != nil {
511 return templ_7745c5c3_Err492 return templ_7745c5c3_Err
512 }493 }
513- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\" title=\"inbox (I)\" aria-keyshortcuts=\"Shift+I\"><span class=\"ico i-mailbox\" aria-hidden=\"true\"></span> Inbox ")494+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\" title=\"inbox\"><span class=\"ico i-mailbox\" aria-hidden=\"true\"></span> Inbox ")
514 if templ_7745c5c3_Err != nil {495 if templ_7745c5c3_Err != nil {
515 return templ_7745c5c3_Err496 return templ_7745c5c3_Err
516 }497 }
517 if l.Inbox.Count >= 0 {498 if l.Inbox.Count >= 0 {
518- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<span class=\"count\">")499+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<span class=\"count\">")
519 if templ_7745c5c3_Err != nil {500 if templ_7745c5c3_Err != nil {
520 return templ_7745c5c3_Err501 return templ_7745c5c3_Err
521 }502 }
522- var templ_7745c5c3_Var29 string503+ var templ_7745c5c3_Var28 string
523- templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(l.Inbox.Count))504+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(l.Inbox.Count))
524 if templ_7745c5c3_Err != nil {505 if templ_7745c5c3_Err != nil {
525- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 99, Col: 47}506+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 95, Col: 47}
526 }507 }
527- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))508+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
528 if templ_7745c5c3_Err != nil {509 if templ_7745c5c3_Err != nil {
529 return templ_7745c5c3_Err510 return templ_7745c5c3_Err
530 }511 }
531- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</span>")512+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</span>")
532 if templ_7745c5c3_Err != nil {513 if templ_7745c5c3_Err != nil {
533 return templ_7745c5c3_Err514 return templ_7745c5c3_Err
534 }515 }
535 }516 }
536- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</a> ")517+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</a> ")
537 if templ_7745c5c3_Err != nil {518 if templ_7745c5c3_Err != nil {
538 return templ_7745c5c3_Err519 return templ_7745c5c3_Err
539 }520 }
540 }521 }
541 if len(l.Crumbs) > 0 {522 if len(l.Crumbs) > 0 {
542- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<nav class=\"crumbs\" aria-label=\"Path\"><ol>")523+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<nav class=\"crumbs\" aria-label=\"Path\"><ol>")
543 if templ_7745c5c3_Err != nil {524 if templ_7745c5c3_Err != nil {
544 return templ_7745c5c3_Err525 return templ_7745c5c3_Err
545 }526 }
546 for _, c := range l.Crumbs {527 for _, c := range l.Crumbs {
547- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<li>")528+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<li>")
548 if templ_7745c5c3_Err != nil {529 if templ_7745c5c3_Err != nil {
549 return templ_7745c5c3_Err530 return templ_7745c5c3_Err
550 }531 }
551 if c.Href != "" {532 if c.Href != "" {
552- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<a href=\"")533+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<a href=\"")
553 if templ_7745c5c3_Err != nil {534 if templ_7745c5c3_Err != nil {
554 return templ_7745c5c3_Err535 return templ_7745c5c3_Err
555 }536 }
556- var templ_7745c5c3_Var30 templ.SafeURL537+ var templ_7745c5c3_Var29 templ.SafeURL
557- templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))538+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
558 if templ_7745c5c3_Err != nil {539 if templ_7745c5c3_Err != nil {
559- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 110, Col: 38}540+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 106, Col: 38}
560 }541 }
561- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))542+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
562 if templ_7745c5c3_Err != nil {543 if templ_7745c5c3_Err != nil {
563 return templ_7745c5c3_Err544 return templ_7745c5c3_Err
564 }545 }
565- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\"")546+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\"")
566 if templ_7745c5c3_Err != nil {547 if templ_7745c5c3_Err != nil {
567 return templ_7745c5c3_Err548 return templ_7745c5c3_Err
568 }549 }
569 if c.Current {550 if c.Current {
570- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, " aria-current=\"page\"")551+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, " aria-current=\"page\"")
571 if templ_7745c5c3_Err != nil {552 if templ_7745c5c3_Err != nil {
572 return templ_7745c5c3_Err553 return templ_7745c5c3_Err
573 }554 }
574 }555 }
575- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, ">")556+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, ">")
576 if templ_7745c5c3_Err != nil {557 if templ_7745c5c3_Err != nil {
577 return templ_7745c5c3_Err558 return templ_7745c5c3_Err
578 }559 }
579- var templ_7745c5c3_Var31 string560+ var templ_7745c5c3_Var30 string
580- templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)561+ templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
581 if templ_7745c5c3_Err != nil {562 if templ_7745c5c3_Err != nil {
582- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 114, Col: 19}563+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 110, Col: 19}
583 }564 }
584- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))565+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
585 if templ_7745c5c3_Err != nil {566 if templ_7745c5c3_Err != nil {
586 return templ_7745c5c3_Err567 return templ_7745c5c3_Err
587 }568 }
588- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</a>")569+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</a>")
589 if templ_7745c5c3_Err != nil {570 if templ_7745c5c3_Err != nil {
590 return templ_7745c5c3_Err571 return templ_7745c5c3_Err
591 }572 }
592 } else {573 } else {
593- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<span")574+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<span")
594 if templ_7745c5c3_Err != nil {575 if templ_7745c5c3_Err != nil {
595 return templ_7745c5c3_Err576 return templ_7745c5c3_Err
596 }577 }
597 if c.Current {578 if c.Current {
598- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, " aria-current=\"page\"")579+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, " aria-current=\"page\"")
599 if templ_7745c5c3_Err != nil {580 if templ_7745c5c3_Err != nil {
600 return templ_7745c5c3_Err581 return templ_7745c5c3_Err
601 }582 }
602 }583 }
603- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, ">")584+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, ">")
604 if templ_7745c5c3_Err != nil {585 if templ_7745c5c3_Err != nil {
605 return templ_7745c5c3_Err586 return templ_7745c5c3_Err
606 }587 }
607- var templ_7745c5c3_Var32 string588+ var templ_7745c5c3_Var31 string
608- templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)589+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
609 if templ_7745c5c3_Err != nil {590 if templ_7745c5c3_Err != nil {
610- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 120, Col: 19}591+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 116, Col: 19}
611 }592 }
612- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))593+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
613 if templ_7745c5c3_Err != nil {594 if templ_7745c5c3_Err != nil {
614 return templ_7745c5c3_Err595 return templ_7745c5c3_Err
615 }596 }
616- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</span>")597+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</span>")
617 if templ_7745c5c3_Err != nil {598 if templ_7745c5c3_Err != nil {
618 return templ_7745c5c3_Err599 return templ_7745c5c3_Err
619 }600 }
620 }601 }
621- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</li>")602+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</li>")
622 if templ_7745c5c3_Err != nil {603 if templ_7745c5c3_Err != nil {
623 return templ_7745c5c3_Err604 return templ_7745c5c3_Err
624 }605 }
625 }606 }
626- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</ol></nav>")607+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</ol></nav>")
627 if templ_7745c5c3_Err != nil {608 if templ_7745c5c3_Err != nil {
628 return templ_7745c5c3_Err609 return templ_7745c5c3_Err
629 }610 }
630 }611 }
631- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<div class=\"hdr-acts\">")612+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<div class=\"hdr-acts\">")
632 if templ_7745c5c3_Err != nil {613 if templ_7745c5c3_Err != nil {
633 return templ_7745c5c3_Err614 return templ_7745c5c3_Err
634 }615 }
635 if l.Ref != "" {616 if l.Ref != "" {
636- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<a class=\"btn ref\" href=\"")617+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<a class=\"btn ref\" href=\"")
637 if templ_7745c5c3_Err != nil {618 if templ_7745c5c3_Err != nil {
638 return templ_7745c5c3_Err619 return templ_7745c5c3_Err
639 }620 }
640- var templ_7745c5c3_Var33 templ.SafeURL621+ var templ_7745c5c3_Var32 templ.SafeURL
641- templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(repoHref(l.Repo, "refs")))622+ templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(repoHref(l.Repo, "refs")))
642 if templ_7745c5c3_Err != nil {623 if templ_7745c5c3_Err != nil {
643- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 131, Col: 52}624+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 127, Col: 52}
644 }625 }
645- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))626+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
646 if templ_7745c5c3_Err != nil {627 if templ_7745c5c3_Err != nil {
647 return templ_7745c5c3_Err628 return templ_7745c5c3_Err
648 }629 }
649- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\" data-fire=\"refs\" aria-keyshortcuts=\"b\" title=\"switch branch or tag (b)\"><span class=\"ico i-branch\" aria-hidden=\"true\"></span> <span class=\"ref-name\">")630+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\" data-fire=\"refs\" aria-keyshortcuts=\"b\" title=\"switch branch or tag (b)\"><span class=\"ico i-branch\" aria-hidden=\"true\"></span> <span class=\"ref-name\">")
650 if templ_7745c5c3_Err != nil {631 if templ_7745c5c3_Err != nil {
651 return templ_7745c5c3_Err632 return templ_7745c5c3_Err
652 }633 }
653- var templ_7745c5c3_Var34 string634+ var templ_7745c5c3_Var33 string
654- templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(l.Ref)635+ templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(l.Ref)
655 if templ_7745c5c3_Err != nil {636 if templ_7745c5c3_Err != nil {
656- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 137, Col: 36}637+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 133, Col: 36}
657 }638 }
658- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))639+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
659 if templ_7745c5c3_Err != nil {640 if templ_7745c5c3_Err != nil {
660 return templ_7745c5c3_Err641 return templ_7745c5c3_Err
661 }642 }
662- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</span> <span class=\"sha\">")643+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</span> <span class=\"sha\">")
663 if templ_7745c5c3_Err != nil {644 if templ_7745c5c3_Err != nil {
664 return templ_7745c5c3_Err645 return templ_7745c5c3_Err
665 }646 }
666- var templ_7745c5c3_Var35 string647+ var templ_7745c5c3_Var34 string
667- templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(l.Short)648+ templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(l.Short)
668 if templ_7745c5c3_Err != nil {649 if templ_7745c5c3_Err != nil {
669- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 138, Col: 33}650+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 134, Col: 33}
670 }651 }
671- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))652+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
672 if templ_7745c5c3_Err != nil {653 if templ_7745c5c3_Err != nil {
673 return templ_7745c5c3_Err654 return templ_7745c5c3_Err
674 }655 }
675- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</span></a> ")656+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</span></a> ")
676 if templ_7745c5c3_Err != nil {657 if templ_7745c5c3_Err != nil {
677 return templ_7745c5c3_Err658 return templ_7745c5c3_Err
678 }659 }
679 }660 }
680- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<button type=\"button\" class=\"btn search\" data-js aria-haspopup=\"dialog\" aria-controls=\"panel-cmd\" aria-keyshortcuts=\"Control+K :\" title=\"go to a section, ref, file or repo (ctrl k or :)\" data-fire=\"cmd\"><span class=\"ico i-search\" aria-hidden=\"true\"></span> <span class=\"search-label\">Go to…</span> <kbd aria-hidden=\"true\">ctrl k</kbd></button> ")661+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<button type=\"button\" class=\"btn search\" data-js aria-haspopup=\"dialog\" aria-controls=\"panel-cmd\" aria-keyshortcuts=\"Control+K :\" title=\"go to a section, ref, file or repo (ctrl k or :)\" data-fire=\"cmd\"><span class=\"ico i-search\" aria-hidden=\"true\"></span> <span class=\"search-label\">Go to…</span> <kbd aria-hidden=\"true\">ctrl k</kbd></button> ")
681 if templ_7745c5c3_Err != nil {662 if templ_7745c5c3_Err != nil {
682 return templ_7745c5c3_Err663 return templ_7745c5c3_Err
683 }664 }
684 if SignedIn(ctx) {665 if SignedIn(ctx) {
685- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<form class=\"owner\" method=\"post\" action=\"/-/logout\"><span class=\"owner-badge\" title=\"signed in as the owner\"><span class=\"ico i-user\" aria-hidden=\"true\"></span>")666+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<form class=\"owner\" method=\"post\" action=\"/-/logout\"><span class=\"owner-badge\" title=\"signed in as the owner\"><span class=\"ico i-user\" aria-hidden=\"true\"></span>")
686 if templ_7745c5c3_Err != nil {667 if templ_7745c5c3_Err != nil {
687 return templ_7745c5c3_Err668 return templ_7745c5c3_Err
688 }669 }
689- var templ_7745c5c3_Var36 string670+ var templ_7745c5c3_Var35 string
690- templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(l.Owner)671+ templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(l.Owner)
691 if templ_7745c5c3_Err != nil {672 if templ_7745c5c3_Err != nil {
692- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 157, Col: 123}673+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 153, Col: 123}
693 }674 }
694- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))675+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
695 if templ_7745c5c3_Err != nil {676 if templ_7745c5c3_Err != nil {
696 return templ_7745c5c3_Err677 return templ_7745c5c3_Err
697 }678 }
698- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</span> ")679+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</span> ")
699 if templ_7745c5c3_Err != nil {680 if templ_7745c5c3_Err != nil {
700 return templ_7745c5c3_Err681 return templ_7745c5c3_Err
701 }682 }
702 if l.Passkeys {683 if l.Passkeys {
703- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<a class=\"btn icon\" href=\"/-/passkeys\" aria-label=\"Passkeys\" title=\"passkeys: add or remove the passkeys that sign you in\"><span class=\"ico i-key\" aria-hidden=\"true\"></span></a> ")684+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<a class=\"btn icon\" href=\"/-/passkeys\" aria-label=\"Passkeys\" title=\"passkeys: add or remove the passkeys that sign you in\"><span class=\"ico i-key\" aria-hidden=\"true\"></span></a> ")
704 if templ_7745c5c3_Err != nil {685 if templ_7745c5c3_Err != nil {
705 return templ_7745c5c3_Err686 return templ_7745c5c3_Err
706 }687 }
707 }688 }
708- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<button type=\"submit\" class=\"btn icon\" aria-label=\"Sign out\" title=\"sign out of this browser\"><span class=\"ico i-log-out\" aria-hidden=\"true\"></span></button></form>")689+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<button type=\"submit\" class=\"btn icon\" aria-label=\"Sign out\" title=\"sign out of this browser\"><span class=\"ico i-log-out\" aria-hidden=\"true\"></span></button></form>")
709 if templ_7745c5c3_Err != nil {690 if templ_7745c5c3_Err != nil {
710 return templ_7745c5c3_Err691 return templ_7745c5c3_Err
711 }692 }
712 } else if l.Passkeys {693 } else if l.Passkeys {
713- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<a class=\"btn icon\" href=\"/-/login\" aria-label=\"Sign in\" title=\"sign in with a passkey\"><span class=\"ico i-log-in\" aria-hidden=\"true\"></span></a>")694+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<a class=\"btn icon\" href=\"/-/login\" aria-label=\"Sign in\" title=\"sign in with a passkey\"><span class=\"ico i-log-in\" aria-hidden=\"true\"></span></a>")
714 if templ_7745c5c3_Err != nil {695 if templ_7745c5c3_Err != nil {
715 return templ_7745c5c3_Err696 return templ_7745c5c3_Err
716 }697 }
717 }698 }
718- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</div></div>")699+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</div></div>")
719 if templ_7745c5c3_Err != nil {700 if templ_7745c5c3_Err != nil {
720 return templ_7745c5c3_Err701 return templ_7745c5c3_Err
721 }702 }
722 if len(l.Sections) > 0 {703 if len(l.Sections) > 0 {
723- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<nav class=\"tabs\" aria-label=\"Sections\"><ul>")704+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<nav class=\"tabs\" aria-label=\"Sections\"><ul>")
724 if templ_7745c5c3_Err != nil {705 if templ_7745c5c3_Err != nil {
725 return templ_7745c5c3_Err706 return templ_7745c5c3_Err
726 }707 }
727 for _, s := range l.Sections {708 for _, s := range l.Sections {
728- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<li><a href=\"")709+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<li><a href=\"")
729 if templ_7745c5c3_Err != nil {710 if templ_7745c5c3_Err != nil {
730 return templ_7745c5c3_Err711 return templ_7745c5c3_Err
731 }712 }
732- var templ_7745c5c3_Var37 templ.SafeURL713+ var templ_7745c5c3_Var36 templ.SafeURL
733- templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href))714+ templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href))
734 if templ_7745c5c3_Err != nil {715 if templ_7745c5c3_Err != nil {
735- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 174, Col: 36}716+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 170, Col: 36}
736 }717 }
737- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))718+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
738 if templ_7745c5c3_Err != nil {719 if templ_7745c5c3_Err != nil {
739 return templ_7745c5c3_Err720 return templ_7745c5c3_Err
740 }721 }
741- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\" data-key=\"")722+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\" data-key=\"")
742 if templ_7745c5c3_Err != nil {723 if templ_7745c5c3_Err != nil {
743 return templ_7745c5c3_Err724 return templ_7745c5c3_Err
744 }725 }
745- var templ_7745c5c3_Var38 string726+ var templ_7745c5c3_Var37 string
746- templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)727+ templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
747 if templ_7745c5c3_Err != nil {728 if templ_7745c5c3_Err != nil {
748- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 175, Col: 24}729+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 171, Col: 24}
749 }730 }
750- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)731+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
751 if templ_7745c5c3_Err != nil {732 if templ_7745c5c3_Err != nil {
752 return templ_7745c5c3_Err733 return templ_7745c5c3_Err
753 }734 }
754- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\" aria-keyshortcuts=\"")735+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "\" aria-keyshortcuts=\"")
755 if templ_7745c5c3_Err != nil {736 if templ_7745c5c3_Err != nil {
756 return templ_7745c5c3_Err737 return templ_7745c5c3_Err
757 }738 }
758- var templ_7745c5c3_Var39 string739+ var templ_7745c5c3_Var38 string
759- templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)740+ templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
760 if templ_7745c5c3_Err != nil {741 if templ_7745c5c3_Err != nil {
761- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 176, Col: 33}742+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 172, Col: 33}
762 }743 }
763- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)744+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
764 if templ_7745c5c3_Err != nil {745 if templ_7745c5c3_Err != nil {
765 return templ_7745c5c3_Err746 return templ_7745c5c3_Err
766 }747 }
767- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "\"")748+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\"")
768 if templ_7745c5c3_Err != nil {749 if templ_7745c5c3_Err != nil {
769 return templ_7745c5c3_Err750 return templ_7745c5c3_Err
770 }751 }
771 if s.Current {752 if s.Current {
772- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, " aria-current=\"page\"")753+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, " aria-current=\"page\"")
773 if templ_7745c5c3_Err != nil {754 if templ_7745c5c3_Err != nil {
774 return templ_7745c5c3_Err755 return templ_7745c5c3_Err
775 }756 }
776 }757 }
777- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, ">")758+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, ">")
778 if templ_7745c5c3_Err != nil {759 if templ_7745c5c3_Err != nil {
779 return templ_7745c5c3_Err760 return templ_7745c5c3_Err
780 }761 }
781- var templ_7745c5c3_Var40 string762+ var templ_7745c5c3_Var39 string
782- templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label)763+ templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label)
783 if templ_7745c5c3_Err != nil {764 if templ_7745c5c3_Err != nil {
784- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 181, Col: 17}765+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 177, Col: 17}
785 }766 }
786- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))767+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
787 if templ_7745c5c3_Err != nil {768 if templ_7745c5c3_Err != nil {
788 return templ_7745c5c3_Err769 return templ_7745c5c3_Err
789 }770 }
790- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, " ")771+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, " ")
791 if templ_7745c5c3_Err != nil {772 if templ_7745c5c3_Err != nil {
792 return templ_7745c5c3_Err773 return templ_7745c5c3_Err
793 }774 }
794 if s.Count != "" {775 if s.Count != "" {
795- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<span class=\"count\">")776+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<span class=\"count\">")
796 if templ_7745c5c3_Err != nil {777 if templ_7745c5c3_Err != nil {
797 return templ_7745c5c3_Err778 return templ_7745c5c3_Err
798 }779 }
799- var templ_7745c5c3_Var41 string780+ var templ_7745c5c3_Var40 string
800- templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(s.Count)781+ templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(s.Count)
801 if templ_7745c5c3_Err != nil {782 if templ_7745c5c3_Err != nil {
802- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 183, Col: 38}783+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 179, Col: 38}
803 }784 }
804- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))785+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
805 if templ_7745c5c3_Err != nil {786 if templ_7745c5c3_Err != nil {
806 return templ_7745c5c3_Err787 return templ_7745c5c3_Err
807 }788 }
808- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<span class=\"sr\">")789+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<span class=\"sr\">")
809 if templ_7745c5c3_Err != nil {790 if templ_7745c5c3_Err != nil {
810 return templ_7745c5c3_Err791 return templ_7745c5c3_Err
811 }792 }
812- var templ_7745c5c3_Var42 string793+ var templ_7745c5c3_Var41 string
813- templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(" open")794+ templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(" open")
814 if templ_7745c5c3_Err != nil {795 if templ_7745c5c3_Err != nil {
815- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 183, Col: 66}796+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 179, Col: 66}
816 }797 }
817- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))798+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
818 if templ_7745c5c3_Err != nil {799 if templ_7745c5c3_Err != nil {
819 return templ_7745c5c3_Err800 return templ_7745c5c3_Err
820 }801 }
821- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</span></span> ")802+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</span></span> ")
822 if templ_7745c5c3_Err != nil {803 if templ_7745c5c3_Err != nil {
823 return templ_7745c5c3_Err804 return templ_7745c5c3_Err
824 }805 }
825 }806 }
826- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<kbd aria-hidden=\"true\">")807+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<kbd aria-hidden=\"true\">")
827 if templ_7745c5c3_Err != nil {808 if templ_7745c5c3_Err != nil {
828 return templ_7745c5c3_Err809 return templ_7745c5c3_Err
829 }810 }
830- var templ_7745c5c3_Var43 string811+ var templ_7745c5c3_Var42 string
831- templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(s.Key)812+ templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(s.Key)
832 if templ_7745c5c3_Err != nil {813 if templ_7745c5c3_Err != nil {
833- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 185, Col: 39}814+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 181, Col: 39}
834 }815 }
835- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))816+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
836 if templ_7745c5c3_Err != nil {817 if templ_7745c5c3_Err != nil {
837 return templ_7745c5c3_Err818 return templ_7745c5c3_Err
838 }819 }
839- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</kbd></a></li>")820+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</kbd></a></li>")
840 if templ_7745c5c3_Err != nil {821 if templ_7745c5c3_Err != nil {
841 return templ_7745c5c3_Err822 return templ_7745c5c3_Err
842 }823 }
843 }824 }
844- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</ul></nav>")825+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</ul></nav>")
845 if templ_7745c5c3_Err != nil {826 if templ_7745c5c3_Err != nil {
846 return templ_7745c5c3_Err827 return templ_7745c5c3_Err
847 }828 }
848 }829 }
849- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</header>")830+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</header>")
850 if templ_7745c5c3_Err != nil {831 if templ_7745c5c3_Err != nil {
851 return templ_7745c5c3_Err832 return templ_7745c5c3_Err
852 }833 }
@@ -872,38 +853,38 @@ func Footer(l Layout) templ.Component {
872 }()853 }()
873 }854 }
874 ctx = templ.InitializeContext(ctx)855 ctx = templ.InitializeContext(ctx)
875- templ_7745c5c3_Var44 := templ.GetChildren(ctx)856+ templ_7745c5c3_Var43 := templ.GetChildren(ctx)
876- if templ_7745c5c3_Var44 == nil {857+ if templ_7745c5c3_Var43 == nil {
877- templ_7745c5c3_Var44 = templ.NopComponent858+ templ_7745c5c3_Var43 = templ.NopComponent
878 }859 }
879 ctx = templ.ClearChildren(ctx)860 ctx = templ.ClearChildren(ctx)
880- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "<footer class=\"foot\"><span>ermite ")861+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<footer class=\"foot\"><span>ermite ")
881 if templ_7745c5c3_Err != nil {862 if templ_7745c5c3_Err != nil {
882 return templ_7745c5c3_Err863 return templ_7745c5c3_Err
883 }864 }
884- var templ_7745c5c3_Var45 string865+ var templ_7745c5c3_Var44 string
885- templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version)866+ templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version)
886 if templ_7745c5c3_Err != nil {867 if templ_7745c5c3_Err != nil {
887- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 199, Col: 26}868+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 195, Col: 26}
888 }869 }
889- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))870+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
890 if templ_7745c5c3_Err != nil {871 if templ_7745c5c3_Err != nil {
891 return templ_7745c5c3_Err872 return templ_7745c5c3_Err
892 }873 }
893- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</span> <a href=\"")874+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</span> <a href=\"")
894 if templ_7745c5c3_Err != nil {875 if templ_7745c5c3_Err != nil {
895 return templ_7745c5c3_Err876 return templ_7745c5c3_Err
896 }877 }
897- var templ_7745c5c3_Var46 templ.SafeURL878+ var templ_7745c5c3_Var45 templ.SafeURL
898- templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL))879+ templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL))
899 if templ_7745c5c3_Err != nil {880 if templ_7745c5c3_Err != nil {
900- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 200, Col: 38}881+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 196, Col: 38}
901 }882 }
902- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))883+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
903 if templ_7745c5c3_Err != nil {884 if templ_7745c5c3_Err != nil {
904 return templ_7745c5c3_Err885 return templ_7745c5c3_Err
905 }886 }
906- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\" rel=\"noopener\" title=\"source of this version (AGPL-3.0-or-later)\">source</a> <button type=\"button\" class=\"foot-keys\" data-js data-fire=\"help\" aria-haspopup=\"dialog\" aria-controls=\"panel-help\" aria-keyshortcuts=\"?\"><kbd aria-hidden=\"true\">?</kbd> keyboard shortcuts</button></footer>")887+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "\" rel=\"noopener\" title=\"source of this version (AGPL-3.0-or-later)\">source</a> <button type=\"button\" class=\"foot-keys\" data-js data-fire=\"help\" aria-haspopup=\"dialog\" aria-controls=\"panel-help\" aria-keyshortcuts=\"?\"><kbd aria-hidden=\"true\">?</kbd> keyboard shortcuts</button></footer>")
907 if templ_7745c5c3_Err != nil {888 if templ_7745c5c3_Err != nil {
908 return templ_7745c5c3_Err889 return templ_7745c5c3_Err
909 }890 }
@@ -931,12 +912,12 @@ func Hint(l Layout) templ.Component {
931 }()912 }()
932 }913 }
933 ctx = templ.InitializeContext(ctx)914 ctx = templ.InitializeContext(ctx)
934- templ_7745c5c3_Var47 := templ.GetChildren(ctx)915+ templ_7745c5c3_Var46 := templ.GetChildren(ctx)
935- if templ_7745c5c3_Var47 == nil {916+ if templ_7745c5c3_Var46 == nil {
936- templ_7745c5c3_Var47 = templ.NopComponent917+ templ_7745c5c3_Var46 = templ.NopComponent
937 }918 }
938 ctx = templ.ClearChildren(ctx)919 ctx = templ.ClearChildren(ctx)
939- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<div class=\"hint\" role=\"status\" aria-live=\"polite\"><span class=\"hint-nav\" :text=\"document.body.dataset.nav || ''\"></span> <span class=\"hint-sel\" :text=\"document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''\"></span> <span class=\"hint-flash\" :text=\"document.body.dataset.flash || ''\"></span>")920+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<div class=\"hint\" role=\"status\" aria-live=\"polite\"><span class=\"hint-nav\" :text=\"document.body.dataset.nav || ''\"></span> <span class=\"hint-sel\" :text=\"document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''\"></span> <span class=\"hint-flash\" :text=\"document.body.dataset.flash || ''\"></span>")
940 if templ_7745c5c3_Err != nil {921 if templ_7745c5c3_Err != nil {
941 return templ_7745c5c3_Err922 return templ_7745c5c3_Err
942 }923 }
@@ -944,7 +925,7 @@ func Hint(l Layout) templ.Component {
944 if templ_7745c5c3_Err != nil {925 if templ_7745c5c3_Err != nil {
945 return templ_7745c5c3_Err926 return templ_7745c5c3_Err
946 }927 }
947- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "</div>")928+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</div>")
948 if templ_7745c5c3_Err != nil {929 if templ_7745c5c3_Err != nil {
949 return templ_7745c5c3_Err930 return templ_7745c5c3_Err
950 }931 }
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