#30 feat(web): release card, latest release in About, g-sequence shortcuts
M README.md +1 -0
| @@ -139,6 +139,7 @@ Give the agent the [`skills/ermite`](skills/ermite/SKILL.md) skill so it knows t | |||
| 139 | 139 | - [Issues](docs/issues.md) | |
| 140 | 140 | - [Commit signatures](docs/commit-signatures.md): SSH and OpenPGP badges, trusted keys | |
| 141 | 141 | - [Web sign-in](docs/web-sign-in.md): passkeys and one-time links | |
| 142 | + | - [Keyboard shortcuts](docs/keyboard-shortcuts.md): web section navigation and page actions | |
| 142 | 143 | - [CI](docs/ci.md): runners, trust, concurrency, reusable workflows | |
| 143 | 144 | - [CI events](docs/ci-events.md) | |
| 144 | 145 | - [OIDC](docs/ci-oidc.md) | |
| @@ -139,6 +139,7 @@ Give the agent the [`skills/ermite`](skills/ermite/SKILL.md) skill so it knows t | |||||
| 139 | - [Issues](docs/issues.md) | 139 | - [Issues](docs/issues.md) | ||
| 140 | - [Commit signatures](docs/commit-signatures.md): SSH and OpenPGP badges, trusted keys | 140 | - [Commit signatures](docs/commit-signatures.md): SSH and OpenPGP badges, trusted keys | ||
| 141 | - [Web sign-in](docs/web-sign-in.md): passkeys and one-time links | 141 | - [Web sign-in](docs/web-sign-in.md): passkeys and one-time links | ||
| 142 | + | - [Keyboard shortcuts](docs/keyboard-shortcuts.md): web section navigation and page actions | |||
| 142 | - [CI](docs/ci.md): runners, trust, concurrency, reusable workflows | 143 | - [CI](docs/ci.md): runners, trust, concurrency, reusable workflows | ||
| 143 | - [CI events](docs/ci-events.md) | 144 | - [CI events](docs/ci-events.md) | ||
| 144 | - [OIDC](docs/ci-oidc.md) | 145 | - [OIDC](docs/ci-oidc.md) | ||
M docs/issues.md +1 -1
| @@ -50,7 +50,7 @@ Limits: the 100 most recent commits of the merge request, 16 KiB of each text, 1 | |||
| 50 | 50 | ||
| 51 | 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 **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; `g i` opens the issues tab. | |
| 54 | 54 | ||
| 55 | 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 | 56 | ||
| @@ -50,7 +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 **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; `g i` opens the issues tab. |
| 54 | 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. | 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 | 56 | ||||
A docs/keyboard-shortcuts.md +30 -0
| @@ -0,0 +1,30 @@ | |||
| 1 | + | # Web keyboard shortcuts | |
| 2 | + | ||
| 3 | + | Press `?` for the complete, page-specific list. Shortcuts do not run while typing | |
| 4 | + | in a field. Section navigation uses two successive keys, not a chord: | |
| 5 | + | ||
| 6 | + | | Keys | Destination | | |
| 7 | + | | --- | --- | | |
| 8 | + | | `g s` | Summary | | |
| 9 | + | | `g f` | Files | | |
| 10 | + | | `g c` | Commits | | |
| 11 | + | | `g b` | Branches & tags | | |
| 12 | + | | `g m` | Merge requests | | |
| 13 | + | | `g i` | Issues | | |
| 14 | + | | `g r` | Releases | | |
| 15 | + | | `g a` | Actions (when CI is enabled) | | |
| 16 | + | | `g h` | Home | | |
| 17 | + | | `g g` | Top / first row | | |
| 18 | + | | `G` (Shift+g) | Bottom / last row | | |
| 19 | + | ||
| 20 | + | There is no `g t` alias, and `s`, `t`, `c`, `r`, `m`, and `i` alone do | |
| 21 | + | not navigate sections. `b` still opens the branch/tag switcher; `f`, `:`, | |
| 22 | + | and Ctrl+K (Cmd+K on macOS) open the command palette. | |
| 23 | + | ||
| 24 | + | On merge requests, `l` focuses the files side panel; `g f` always navigates | |
| 25 | + | to the repository Files section. `h` returns to the content. Uppercase `M` | |
| 26 | + | opens the merge menu, `R` marks the head read, `E` expands every diff file, | |
| 27 | + | and `I` opens the signed-in owner's inbox. | |
| 28 | + | ||
| 29 | + | The help dialog can turn off character shortcuts, including `g` sequences | |
| 30 | + | and shifted letters. Ctrl+K / Cmd+K and Escape remain available. | |
| @@ -0,0 +1,30 @@ | |||||
| 1 | + | # Web keyboard shortcuts | |||
| 2 | + | ||||
| 3 | + | Press `?` for the complete, page-specific list. Shortcuts do not run while typing | |||
| 4 | + | in a field. Section navigation uses two successive keys, not a chord: | |||
| 5 | + | ||||
| 6 | + | | Keys | Destination | | |||
| 7 | + | | --- | --- | | |||
| 8 | + | | `g s` | Summary | | |||
| 9 | + | | `g f` | Files | | |||
| 10 | + | | `g c` | Commits | | |||
| 11 | + | | `g b` | Branches & tags | | |||
| 12 | + | | `g m` | Merge requests | | |||
| 13 | + | | `g i` | Issues | | |||
| 14 | + | | `g r` | Releases | | |||
| 15 | + | | `g a` | Actions (when CI is enabled) | | |||
| 16 | + | | `g h` | Home | | |||
| 17 | + | | `g g` | Top / first row | | |||
| 18 | + | | `G` (Shift+g) | Bottom / last row | | |||
| 19 | + | ||||
| 20 | + | There is no `g t` alias, and `s`, `t`, `c`, `r`, `m`, and `i` alone do | |||
| 21 | + | not navigate sections. `b` still opens the branch/tag switcher; `f`, `:`, | |||
| 22 | + | and Ctrl+K (Cmd+K on macOS) open the command palette. | |||
| 23 | + | ||||
| 24 | + | On merge requests, `l` focuses the files side panel; `g f` always navigates | |||
| 25 | + | to the repository Files section. `h` returns to the content. Uppercase `M` | |||
| 26 | + | opens the merge menu, `R` marks the head read, `E` expands every diff file, | |||
| 27 | + | and `I` opens the signed-in owner's inbox. | |||
| 28 | + | ||||
| 29 | + | The help dialog can turn off character shortcuts, including `g` sequences | |||
| 30 | + | and shifted letters. Ctrl+K / Cmd+K and Escape remain available. | |||
M docs/merge-requests.md +1 -1
| @@ -142,7 +142,7 @@ the diff opens the Checks panel and continues to update live. Commit totals use | |||
| 142 | 142 | `N+` when the branch list is capped: the reader's existing count operation counts all reachable | |
| 143 | 143 | ancestors, not the source-minus-target range. | |
| 144 | 144 | ||
| 145 | - | The rail keeps Files (including its `g f` shortcut) and Verdicts. Merge settings | |
| 145 | + | The rail keeps Files (including its `l` shortcut) and Verdicts. Merge settings | |
| 146 | 146 | are read-only information in the More (⋯) menu, available to every viewer. | |
| 147 | 147 | Signing-key information is also there when a merge box offers signed modes; | |
| 148 | 148 | merge actions remain restricted to their existing permissions. | |
| @@ -142,7 +142,7 @@ the diff opens the Checks panel and continues to update live. Commit totals use | |||||
| 142 | `N+` when the branch list is capped: the reader's existing count operation counts all reachable | 142 | `N+` when the branch list is capped: the reader's existing count operation counts all reachable | ||
| 143 | ancestors, not the source-minus-target range. | 143 | ancestors, not the source-minus-target range. | ||
| 144 | 144 | ||||
| 145 | - | The rail keeps Files (including its `g f` shortcut) and Verdicts. Merge settings | 145 | + | The rail keeps Files (including its `l` shortcut) and Verdicts. Merge settings |
| 146 | are read-only information in the More (⋯) menu, available to every viewer. | 146 | are read-only information in the More (⋯) menu, available to every viewer. | ||
| 147 | Signing-key information is also there when a merge box offers signed modes; | 147 | Signing-key information is also there when a merge box offers signed modes; | ||
| 148 | merge actions remain restricted to their existing permissions. | 148 | merge actions remain restricted to their existing permissions. | ||
M internal/adapters/kvstore/issues_test.go +1 -1
| @@ -364,7 +364,7 @@ func TestSchemaNewerThanThisBuildStopsIssuesData(t *testing.T) { | |||
| 364 | 364 | putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: kvstore.SchemaVersion + 1})) | |
| 365 | 365 | _, err := kvstore.Open(ctx, dir) | |
| 366 | 366 | require.ErrorIs(t, err, kvstore.ErrSchemaTooNew) | |
| 367 | - | assert.Equal(t, uint32(6), uint32(kvstore.SchemaVersion)) | |
| 367 | + | assert.Equal(t, uint32(7), uint32(kvstore.SchemaVersion)) | |
| 368 | 368 | } | |
| 369 | 369 | ||
| 370 | 370 | // A reopen racing the delete of the repository either lands before the delete | |
| @@ -364,7 +364,7 @@ func TestSchemaNewerThanThisBuildStopsIssuesData(t *testing.T) { | |||||
| 364 | putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: kvstore.SchemaVersion + 1})) | 364 | putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: kvstore.SchemaVersion + 1})) | ||
| 365 | _, err := kvstore.Open(ctx, dir) | 365 | _, err := kvstore.Open(ctx, dir) | ||
| 366 | require.ErrorIs(t, err, kvstore.ErrSchemaTooNew) | 366 | require.ErrorIs(t, err, kvstore.ErrSchemaTooNew) | ||
| 367 | - | assert.Equal(t, uint32(6), uint32(kvstore.SchemaVersion)) | 367 | + | assert.Equal(t, uint32(7), uint32(kvstore.SchemaVersion)) |
| 368 | } | 368 | } | ||
| 369 | 369 | ||||
| 370 | // A reopen racing the delete of the repository either lands before the delete | 370 | // A reopen racing the delete of the repository either lands before the delete | ||
M internal/adapters/kvstore/releases.go +28 -2
| @@ -36,7 +36,26 @@ func relNamePrefix(repo domain.RepoName, id int64) string { | |||
| 36 | 36 | func relNameKey(a domain.ReleaseAsset) []byte { | |
| 37 | 37 | return []byte(relNamePrefix(a.Repo, a.ReleaseID) + a.Name) | |
| 38 | 38 | } | |
| 39 | - | func relUseKey(repo domain.RepoName) []byte { return []byte("reluse/" + repo.String()) } | |
| 39 | + | func relLatestKey(repo domain.RepoName) []byte { return []byte("rellatest/" + repo.String()) } | |
| 40 | + | func relUseKey(repo domain.RepoName) []byte { return []byte("reluse/" + repo.String()) } | |
| 41 | + | ||
| 42 | + | // GetLatestRelease resolves the index and release from the same snapshot. | |
| 43 | + | func (c *Releases) GetLatestRelease(ctx context.Context, repo domain.RepoName) (domain.Release, error) { | |
| 44 | + | snap := c.db.Snapshot(ctx) | |
| 45 | + | item, ok := snap.Get(ctx, relLatestKey(repo)) | |
| 46 | + | if !ok { | |
| 47 | + | return domain.Release{}, domain.ErrNotFound | |
| 48 | + | } | |
| 49 | + | id, err := strconv.ParseInt(string(item.Value), 10, 64) | |
| 50 | + | if err != nil { | |
| 51 | + | return domain.Release{}, err | |
| 52 | + | } | |
| 53 | + | item, ok = snap.Get(ctx, relKey(repo, id)) | |
| 54 | + | if !ok { | |
| 55 | + | return domain.Release{}, domain.ErrNotFound | |
| 56 | + | } | |
| 57 | + | return decodeRelease(item) | |
| 58 | + | } | |
| 40 | 59 | func encodeRelease(r domain.Release) []byte { | |
| 41 | 60 | return mustMarshal(&storagepb.Release{ | |
| 42 | 61 | Id: r.ID, | |
| @@ -285,6 +304,13 @@ func (c *Releases) ApplyReleases(ctx context.Context, w ports.ReleaseWrite) erro | |||
| 285 | 304 | b.RequireVersion(relUseKey(w.Repo), w.UsageVersion) | |
| 286 | 305 | } | |
| 287 | 306 | b.Put(relUseKey(w.Repo), mustMarshal(&storagepb.ReleaseUsage{Used: w.Used})) | |
| 307 | + | if w.SetLatest { | |
| 308 | + | if w.LatestID > 0 { | |
| 309 | + | b.Put(relLatestKey(w.Repo), []byte(strconv.FormatInt(w.LatestID, 10))) | |
| 310 | + | } else { | |
| 311 | + | b.Delete(relLatestKey(w.Repo)) | |
| 312 | + | } | |
| 313 | + | } | |
| 288 | 314 | snap := c.db.Snapshot(ctx) | |
| 289 | 315 | for _, r := range w.DeleteReleases { | |
| 290 | 316 | if r.Repo != w.Repo || r.Version == 0 { | |
| @@ -388,7 +414,7 @@ func (c *Releases) ApplyReleases(ctx context.Context, w ports.ReleaseWrite) erro | |||
| 388 | 414 | ||
| 389 | 415 | // releaseKeys collects all repository-owned release keys, including global indexes. | |
| 390 | 416 | func releaseKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte { | |
| 391 | - | keys := [][]byte{relUseKey(repo)} | |
| 417 | + | keys := [][]byte{relUseKey(repo), relLatestKey(repo)} | |
| 392 | 418 | for _, prefix := range []string{"rel/", "reltag/", "relassets/"} { | |
| 393 | 419 | for key, item := range snap.Prefix(ctx, []byte(prefix+repo.String()+"/")) { | |
| 394 | 420 | keys = append(keys, slices.Clone(key)) | |
| @@ -36,7 +36,26 @@ func relNamePrefix(repo domain.RepoName, id int64) string { | |||||
| 36 | func relNameKey(a domain.ReleaseAsset) []byte { | 36 | func relNameKey(a domain.ReleaseAsset) []byte { | ||
| 37 | return []byte(relNamePrefix(a.Repo, a.ReleaseID) + a.Name) | 37 | return []byte(relNamePrefix(a.Repo, a.ReleaseID) + a.Name) | ||
| 38 | } | 38 | } | ||
| 39 | - | func relUseKey(repo domain.RepoName) []byte { return []byte("reluse/" + repo.String()) } | 39 | + | func relLatestKey(repo domain.RepoName) []byte { return []byte("rellatest/" + repo.String()) } |
| 40 | + | func relUseKey(repo domain.RepoName) []byte { return []byte("reluse/" + repo.String()) } | |||
| 41 | + | ||||
| 42 | + | // GetLatestRelease resolves the index and release from the same snapshot. | |||
| 43 | + | func (c *Releases) GetLatestRelease(ctx context.Context, repo domain.RepoName) (domain.Release, error) { | |||
| 44 | + | snap := c.db.Snapshot(ctx) | |||
| 45 | + | item, ok := snap.Get(ctx, relLatestKey(repo)) | |||
| 46 | + | if !ok { | |||
| 47 | + | return domain.Release{}, domain.ErrNotFound | |||
| 48 | + | } | |||
| 49 | + | id, err := strconv.ParseInt(string(item.Value), 10, 64) | |||
| 50 | + | if err != nil { | |||
| 51 | + | return domain.Release{}, err | |||
| 52 | + | } | |||
| 53 | + | item, ok = snap.Get(ctx, relKey(repo, id)) | |||
| 54 | + | if !ok { | |||
| 55 | + | return domain.Release{}, domain.ErrNotFound | |||
| 56 | + | } | |||
| 57 | + | return decodeRelease(item) | |||
| 58 | + | } | |||
| 40 | func encodeRelease(r domain.Release) []byte { | 59 | func encodeRelease(r domain.Release) []byte { | ||
| 41 | return mustMarshal(&storagepb.Release{ | 60 | return mustMarshal(&storagepb.Release{ | ||
| 42 | Id: r.ID, | 61 | Id: r.ID, | ||
| @@ -285,6 +304,13 @@ func (c *Releases) ApplyReleases(ctx context.Context, w ports.ReleaseWrite) erro | |||||
| 285 | b.RequireVersion(relUseKey(w.Repo), w.UsageVersion) | 304 | b.RequireVersion(relUseKey(w.Repo), w.UsageVersion) | ||
| 286 | } | 305 | } | ||
| 287 | b.Put(relUseKey(w.Repo), mustMarshal(&storagepb.ReleaseUsage{Used: w.Used})) | 306 | b.Put(relUseKey(w.Repo), mustMarshal(&storagepb.ReleaseUsage{Used: w.Used})) | ||
| 307 | + | if w.SetLatest { | |||
| 308 | + | if w.LatestID > 0 { | |||
| 309 | + | b.Put(relLatestKey(w.Repo), []byte(strconv.FormatInt(w.LatestID, 10))) | |||
| 310 | + | } else { | |||
| 311 | + | b.Delete(relLatestKey(w.Repo)) | |||
| 312 | + | } | |||
| 313 | + | } | |||
| 288 | snap := c.db.Snapshot(ctx) | 314 | snap := c.db.Snapshot(ctx) | ||
| 289 | for _, r := range w.DeleteReleases { | 315 | for _, r := range w.DeleteReleases { | ||
| 290 | if r.Repo != w.Repo || r.Version == 0 { | 316 | if r.Repo != w.Repo || r.Version == 0 { | ||
| @@ -388,7 +414,7 @@ func (c *Releases) ApplyReleases(ctx context.Context, w ports.ReleaseWrite) erro | |||||
| 388 | 414 | ||||
| 389 | // releaseKeys collects all repository-owned release keys, including global indexes. | 415 | // releaseKeys collects all repository-owned release keys, including global indexes. | ||
| 390 | func releaseKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte { | 416 | func releaseKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte { | ||
| 391 | - | keys := [][]byte{relUseKey(repo)} | 417 | + | keys := [][]byte{relUseKey(repo), relLatestKey(repo)} |
| 392 | for _, prefix := range []string{"rel/", "reltag/", "relassets/"} { | 418 | for _, prefix := range []string{"rel/", "reltag/", "relassets/"} { | ||
| 393 | for key, item := range snap.Prefix(ctx, []byte(prefix+repo.String()+"/")) { | 419 | for key, item := range snap.Prefix(ctx, []byte(prefix+repo.String()+"/")) { | ||
| 394 | keys = append(keys, slices.Clone(key)) | 420 | keys = append(keys, slices.Clone(key)) | ||
A internal/adapters/kvstore/releases_index.go +67 -0
| @@ -0,0 +1,67 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | package kvstore | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "fmt" | |
| 7 | + | "strconv" | |
| 8 | + | ||
| 9 | + | kv "bnema.dev/ermite-kv" | |
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | const releaseLatestIndexMarker = "meta/release-latest-index" | |
| 14 | + | ||
| 15 | + | // buildReleaseLatestIndex runs before the store is exposed to writers. A crash | |
| 16 | + | // before the marker is written safely repeats the pass on the next open. | |
| 17 | + | // Selection remains owned by domain.LatestRelease. | |
| 18 | + | func (s *Store) buildReleaseLatestIndex(ctx context.Context) error { | |
| 19 | + | if _, ok := s.db.Get(ctx, []byte(releaseLatestIndexMarker)); ok { | |
| 20 | + | return nil | |
| 21 | + | } | |
| 22 | + | var repo domain.RepoName | |
| 23 | + | var best domain.Release | |
| 24 | + | var eligible bool | |
| 25 | + | flush := func() error { | |
| 26 | + | var b kv.Batch | |
| 27 | + | if eligible { | |
| 28 | + | b.Put(relLatestKey(repo), []byte(strconv.FormatInt(best.ID, 10))) | |
| 29 | + | } else { | |
| 30 | + | b.Delete(relLatestKey(repo)) | |
| 31 | + | } | |
| 32 | + | if _, err := s.db.Apply(ctx, &b); err != nil { | |
| 33 | + | return fmt.Errorf("build latest release index: %w", err) | |
| 34 | + | } | |
| 35 | + | return nil | |
| 36 | + | } | |
| 37 | + | haveRepo := false | |
| 38 | + | for _, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte("rel/")) { | |
| 39 | + | rel, err := decodeRelease(item) | |
| 40 | + | if err != nil { | |
| 41 | + | return fmt.Errorf("build latest release index: %w", err) | |
| 42 | + | } | |
| 43 | + | if haveRepo && rel.Repo != repo { | |
| 44 | + | if err := flush(); err != nil { | |
| 45 | + | return err | |
| 46 | + | } | |
| 47 | + | best, eligible = domain.Release{}, false | |
| 48 | + | } | |
| 49 | + | repo, haveRepo = rel.Repo, true | |
| 50 | + | if eligible { | |
| 51 | + | best, eligible = domain.LatestRelease([]domain.Release{best, rel}) | |
| 52 | + | } else { | |
| 53 | + | best, eligible = domain.LatestRelease([]domain.Release{rel}) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | if haveRepo { | |
| 57 | + | if err := flush(); err != nil { | |
| 58 | + | return err | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | var b kv.Batch | |
| 62 | + | b.Put([]byte(releaseLatestIndexMarker), []byte{1}) | |
| 63 | + | if _, err := s.db.Apply(ctx, &b); err != nil { | |
| 64 | + | return fmt.Errorf("mark latest release index: %w", err) | |
| 65 | + | } | |
| 66 | + | return nil | |
| 67 | + | } | |
| @@ -0,0 +1,67 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | package kvstore | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | "fmt" | |||
| 7 | + | "strconv" | |||
| 8 | + | ||||
| 9 | + | kv "bnema.dev/ermite-kv" | |||
| 10 | + | "bnema.dev/ermite/internal/domain" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | const releaseLatestIndexMarker = "meta/release-latest-index" | |||
| 14 | + | ||||
| 15 | + | // buildReleaseLatestIndex runs before the store is exposed to writers. A crash | |||
| 16 | + | // before the marker is written safely repeats the pass on the next open. | |||
| 17 | + | // Selection remains owned by domain.LatestRelease. | |||
| 18 | + | func (s *Store) buildReleaseLatestIndex(ctx context.Context) error { | |||
| 19 | + | if _, ok := s.db.Get(ctx, []byte(releaseLatestIndexMarker)); ok { | |||
| 20 | + | return nil | |||
| 21 | + | } | |||
| 22 | + | var repo domain.RepoName | |||
| 23 | + | var best domain.Release | |||
| 24 | + | var eligible bool | |||
| 25 | + | flush := func() error { | |||
| 26 | + | var b kv.Batch | |||
| 27 | + | if eligible { | |||
| 28 | + | b.Put(relLatestKey(repo), []byte(strconv.FormatInt(best.ID, 10))) | |||
| 29 | + | } else { | |||
| 30 | + | b.Delete(relLatestKey(repo)) | |||
| 31 | + | } | |||
| 32 | + | if _, err := s.db.Apply(ctx, &b); err != nil { | |||
| 33 | + | return fmt.Errorf("build latest release index: %w", err) | |||
| 34 | + | } | |||
| 35 | + | return nil | |||
| 36 | + | } | |||
| 37 | + | haveRepo := false | |||
| 38 | + | for _, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte("rel/")) { | |||
| 39 | + | rel, err := decodeRelease(item) | |||
| 40 | + | if err != nil { | |||
| 41 | + | return fmt.Errorf("build latest release index: %w", err) | |||
| 42 | + | } | |||
| 43 | + | if haveRepo && rel.Repo != repo { | |||
| 44 | + | if err := flush(); err != nil { | |||
| 45 | + | return err | |||
| 46 | + | } | |||
| 47 | + | best, eligible = domain.Release{}, false | |||
| 48 | + | } | |||
| 49 | + | repo, haveRepo = rel.Repo, true | |||
| 50 | + | if eligible { | |||
| 51 | + | best, eligible = domain.LatestRelease([]domain.Release{best, rel}) | |||
| 52 | + | } else { | |||
| 53 | + | best, eligible = domain.LatestRelease([]domain.Release{rel}) | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | if haveRepo { | |||
| 57 | + | if err := flush(); err != nil { | |||
| 58 | + | return err | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | var b kv.Batch | |||
| 62 | + | b.Put([]byte(releaseLatestIndexMarker), []byte{1}) | |||
| 63 | + | if _, err := s.db.Apply(ctx, &b); err != nil { | |||
| 64 | + | return fmt.Errorf("mark latest release index: %w", err) | |||
| 65 | + | } | |||
| 66 | + | return nil | |||
| 67 | + | } | |||
A internal/adapters/kvstore/releases_index_test.go +97 -0
| @@ -0,0 +1,97 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | package kvstore | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | kv "bnema.dev/ermite-kv" | |
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | "bnema.dev/ermite/internal/ports" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestReleaseLatestIndexBackfill(t *testing.T) { | |
| 16 | + | ctx := t.Context() | |
| 17 | + | dir := t.TempDir() | |
| 18 | + | s, err := Open(ctx, dir) | |
| 19 | + | require.NoError(t, err) | |
| 20 | + | var repos []domain.RepoName | |
| 21 | + | for _, raw := range []string{"backfill", "empty", "newest"} { | |
| 22 | + | repo, err := domain.ParseRepoName(raw) | |
| 23 | + | require.NoError(t, err) | |
| 24 | + | repos = append(repos, repo) | |
| 25 | + | } | |
| 26 | + | for _, repo := range repos { | |
| 27 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPublic})) | |
| 28 | + | } | |
| 29 | + | var b kv.Batch | |
| 30 | + | for i, repo := range repos { | |
| 31 | + | for j := 1; j <= 5; j++ { | |
| 32 | + | rel := domain.Release{ID: int64(i*10 + j), Repo: repo, Tag: string(rune('a' + j)), CreatedAt: time.Unix(int64(j), 0)} | |
| 33 | + | rel.Draft = j == 5 || i == 1 | |
| 34 | + | rel.Prerelease = j == 4 | |
| 35 | + | rel.MakeLatest = j == 1 && i == 0 | |
| 36 | + | b.Put(relKey(repo, rel.ID), encodeRelease(rel)) | |
| 37 | + | } | |
| 38 | + | // A stale key must also be removed for a repo with no eligible release. | |
| 39 | + | b.Put(relLatestKey(repo), []byte("999")) | |
| 40 | + | } | |
| 41 | + | b.Delete([]byte(releaseLatestIndexMarker)) | |
| 42 | + | _, err = s.db.Apply(ctx, &b) | |
| 43 | + | require.NoError(t, err) | |
| 44 | + | require.NoError(t, s.Close(context.Background())) | |
| 45 | + | s, err = Open(ctx, dir) | |
| 46 | + | require.NoError(t, err) | |
| 47 | + | defer func() { require.NoError(t, s.Close(context.Background())) }() | |
| 48 | + | for i, want := range []int64{1, 0, 23} { | |
| 49 | + | got, err := s.Releases().GetLatestRelease(ctx, repos[i]) | |
| 50 | + | if want == 0 { | |
| 51 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 52 | + | } else { | |
| 53 | + | require.NoError(t, err) | |
| 54 | + | require.Equal(t, want, got.ID) | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | require.NoError(t, s.buildReleaseLatestIndex(ctx)) | |
| 58 | + | got, err := s.Releases().GetLatestRelease(ctx, repos[0]) | |
| 59 | + | require.NoError(t, err) | |
| 60 | + | require.EqualValues(t, 1, got.ID) | |
| 61 | + | require.NoError(t, s.Delete(ctx, repos[0])) | |
| 62 | + | _, err = s.Releases().GetLatestRelease(ctx, repos[0]) | |
| 63 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestGetLatestReleaseAtomicUpdate(t *testing.T) { | |
| 67 | + | ctx := t.Context() | |
| 68 | + | s, err := Open(ctx, t.TempDir()) | |
| 69 | + | require.NoError(t, err) | |
| 70 | + | defer func() { require.NoError(t, s.Close(context.Background())) }() | |
| 71 | + | repo, err := domain.ParseRepoName("atomic") | |
| 72 | + | require.NoError(t, err) | |
| 73 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPublic})) | |
| 74 | + | a := domain.Release{ID: 1, Repo: repo, Tag: "a"} | |
| 75 | + | b := domain.Release{ID: 2, Repo: repo, Tag: "b"} | |
| 76 | + | store := s.Releases() | |
| 77 | + | require.NoError(t, store.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, PutReleases: []domain.Release{a, b}, SetLatest: true, LatestID: a.ID})) | |
| 78 | + | a, err = store.GetLatestRelease(ctx, repo) | |
| 79 | + | require.NoError(t, err) | |
| 80 | + | require.EqualValues(t, 1, a.ID) | |
| 81 | + | _, version, err := store.ReleaseUsage(ctx, repo) | |
| 82 | + | require.NoError(t, err) | |
| 83 | + | a.Prerelease = true | |
| 84 | + | require.NoError(t, store.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, PutReleases: []domain.Release{a}, SetLatest: true, LatestID: b.ID})) | |
| 85 | + | got, err := store.GetLatestRelease(ctx, repo) | |
| 86 | + | require.NoError(t, err) | |
| 87 | + | require.EqualValues(t, 2, got.ID) | |
| 88 | + | // An asset-only/usage write leaves the index untouched, including its version. | |
| 89 | + | before, ok := s.db.Get(ctx, relLatestKey(repo)) | |
| 90 | + | require.True(t, ok) | |
| 91 | + | _, version, err = store.ReleaseUsage(ctx, repo) | |
| 92 | + | require.NoError(t, err) | |
| 93 | + | require.NoError(t, store.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, Used: 10})) | |
| 94 | + | after, ok := s.db.Get(ctx, relLatestKey(repo)) | |
| 95 | + | require.True(t, ok) | |
| 96 | + | require.Equal(t, before, after) | |
| 97 | + | } | |
| @@ -0,0 +1,97 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | package kvstore | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | kv "bnema.dev/ermite-kv" | |||
| 10 | + | "bnema.dev/ermite/internal/domain" | |||
| 11 | + | "bnema.dev/ermite/internal/ports" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestReleaseLatestIndexBackfill(t *testing.T) { | |||
| 16 | + | ctx := t.Context() | |||
| 17 | + | dir := t.TempDir() | |||
| 18 | + | s, err := Open(ctx, dir) | |||
| 19 | + | require.NoError(t, err) | |||
| 20 | + | var repos []domain.RepoName | |||
| 21 | + | for _, raw := range []string{"backfill", "empty", "newest"} { | |||
| 22 | + | repo, err := domain.ParseRepoName(raw) | |||
| 23 | + | require.NoError(t, err) | |||
| 24 | + | repos = append(repos, repo) | |||
| 25 | + | } | |||
| 26 | + | for _, repo := range repos { | |||
| 27 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPublic})) | |||
| 28 | + | } | |||
| 29 | + | var b kv.Batch | |||
| 30 | + | for i, repo := range repos { | |||
| 31 | + | for j := 1; j <= 5; j++ { | |||
| 32 | + | rel := domain.Release{ID: int64(i*10 + j), Repo: repo, Tag: string(rune('a' + j)), CreatedAt: time.Unix(int64(j), 0)} | |||
| 33 | + | rel.Draft = j == 5 || i == 1 | |||
| 34 | + | rel.Prerelease = j == 4 | |||
| 35 | + | rel.MakeLatest = j == 1 && i == 0 | |||
| 36 | + | b.Put(relKey(repo, rel.ID), encodeRelease(rel)) | |||
| 37 | + | } | |||
| 38 | + | // A stale key must also be removed for a repo with no eligible release. | |||
| 39 | + | b.Put(relLatestKey(repo), []byte("999")) | |||
| 40 | + | } | |||
| 41 | + | b.Delete([]byte(releaseLatestIndexMarker)) | |||
| 42 | + | _, err = s.db.Apply(ctx, &b) | |||
| 43 | + | require.NoError(t, err) | |||
| 44 | + | require.NoError(t, s.Close(context.Background())) | |||
| 45 | + | s, err = Open(ctx, dir) | |||
| 46 | + | require.NoError(t, err) | |||
| 47 | + | defer func() { require.NoError(t, s.Close(context.Background())) }() | |||
| 48 | + | for i, want := range []int64{1, 0, 23} { | |||
| 49 | + | got, err := s.Releases().GetLatestRelease(ctx, repos[i]) | |||
| 50 | + | if want == 0 { | |||
| 51 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |||
| 52 | + | } else { | |||
| 53 | + | require.NoError(t, err) | |||
| 54 | + | require.Equal(t, want, got.ID) | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | require.NoError(t, s.buildReleaseLatestIndex(ctx)) | |||
| 58 | + | got, err := s.Releases().GetLatestRelease(ctx, repos[0]) | |||
| 59 | + | require.NoError(t, err) | |||
| 60 | + | require.EqualValues(t, 1, got.ID) | |||
| 61 | + | require.NoError(t, s.Delete(ctx, repos[0])) | |||
| 62 | + | _, err = s.Releases().GetLatestRelease(ctx, repos[0]) | |||
| 63 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func TestGetLatestReleaseAtomicUpdate(t *testing.T) { | |||
| 67 | + | ctx := t.Context() | |||
| 68 | + | s, err := Open(ctx, t.TempDir()) | |||
| 69 | + | require.NoError(t, err) | |||
| 70 | + | defer func() { require.NoError(t, s.Close(context.Background())) }() | |||
| 71 | + | repo, err := domain.ParseRepoName("atomic") | |||
| 72 | + | require.NoError(t, err) | |||
| 73 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPublic})) | |||
| 74 | + | a := domain.Release{ID: 1, Repo: repo, Tag: "a"} | |||
| 75 | + | b := domain.Release{ID: 2, Repo: repo, Tag: "b"} | |||
| 76 | + | store := s.Releases() | |||
| 77 | + | require.NoError(t, store.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, PutReleases: []domain.Release{a, b}, SetLatest: true, LatestID: a.ID})) | |||
| 78 | + | a, err = store.GetLatestRelease(ctx, repo) | |||
| 79 | + | require.NoError(t, err) | |||
| 80 | + | require.EqualValues(t, 1, a.ID) | |||
| 81 | + | _, version, err := store.ReleaseUsage(ctx, repo) | |||
| 82 | + | require.NoError(t, err) | |||
| 83 | + | a.Prerelease = true | |||
| 84 | + | require.NoError(t, store.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, PutReleases: []domain.Release{a}, SetLatest: true, LatestID: b.ID})) | |||
| 85 | + | got, err := store.GetLatestRelease(ctx, repo) | |||
| 86 | + | require.NoError(t, err) | |||
| 87 | + | require.EqualValues(t, 2, got.ID) | |||
| 88 | + | // An asset-only/usage write leaves the index untouched, including its version. | |||
| 89 | + | before, ok := s.db.Get(ctx, relLatestKey(repo)) | |||
| 90 | + | require.True(t, ok) | |||
| 91 | + | _, version, err = store.ReleaseUsage(ctx, repo) | |||
| 92 | + | require.NoError(t, err) | |||
| 93 | + | require.NoError(t, store.ApplyReleases(ctx, ports.ReleaseWrite{Repo: repo, UsageVersion: version, Used: 10})) | |||
| 94 | + | after, ok := s.db.Get(ctx, relLatestKey(repo)) | |||
| 95 | + | require.True(t, ok) | |||
| 96 | + | require.Equal(t, before, after) | |||
| 97 | + | } | |||
M internal/adapters/kvstore/store.go +6 -1
| @@ -43,7 +43,8 @@ import ( | |||
| 43 | 43 | // holding all eleven previous scopes, preserving their owner rights. Version 6 | |
| 44 | 44 | // adds releases and release:write; older builds cannot clean up their metadata | |
| 45 | 45 | // or preserve the owner rights of upgraded tokens (see upgradeReleaseTokens). | |
| 46 | - | const SchemaVersion = 6 | |
| 46 | + | // Version 7 adds the latest-release index; older writers would leave it stale. | |
| 47 | + | const SchemaVersion = 7 | |
| 47 | 48 | ||
| 48 | 49 | const schemaKey = "meta/schema" | |
| 49 | 50 | ||
| @@ -76,6 +77,10 @@ func Open(ctx context.Context, dir string) (*Store, error) { | |||
| 76 | 77 | _ = db.Close(context.WithoutCancel(ctx)) | |
| 77 | 78 | return nil, err | |
| 78 | 79 | } | |
| 80 | + | if err := s.buildReleaseLatestIndex(ctx); err != nil { | |
| 81 | + | _ = db.Close(context.WithoutCancel(ctx)) | |
| 82 | + | return nil, err | |
| 83 | + | } | |
| 79 | 84 | return s, nil | |
| 80 | 85 | } | |
| 81 | 86 | ||
| @@ -43,7 +43,8 @@ import ( | |||||
| 43 | // holding all eleven previous scopes, preserving their owner rights. Version 6 | 43 | // holding all eleven previous scopes, preserving their owner rights. Version 6 | ||
| 44 | // adds releases and release:write; older builds cannot clean up their metadata | 44 | // adds releases and release:write; older builds cannot clean up their metadata | ||
| 45 | // or preserve the owner rights of upgraded tokens (see upgradeReleaseTokens). | 45 | // or preserve the owner rights of upgraded tokens (see upgradeReleaseTokens). | ||
| 46 | - | const SchemaVersion = 6 | 46 | + | // Version 7 adds the latest-release index; older writers would leave it stale. |
| 47 | + | const SchemaVersion = 7 | |||
| 47 | 48 | ||||
| 48 | const schemaKey = "meta/schema" | 49 | const schemaKey = "meta/schema" | ||
| 49 | 50 | ||||
| @@ -76,6 +77,10 @@ func Open(ctx context.Context, dir string) (*Store, error) { | |||||
| 76 | _ = db.Close(context.WithoutCancel(ctx)) | 77 | _ = db.Close(context.WithoutCancel(ctx)) | ||
| 77 | return nil, err | 78 | return nil, err | ||
| 78 | } | 79 | } | ||
| 80 | + | if err := s.buildReleaseLatestIndex(ctx); err != nil { | |||
| 81 | + | _ = db.Close(context.WithoutCancel(ctx)) | |||
| 82 | + | return nil, err | |||
| 83 | + | } | |||
| 79 | return s, nil | 84 | return s, nil | ||
| 80 | } | 85 | } | ||
| 81 | 86 | ||||
M internal/adapters/web/fragments.go +1 -1
| @@ -167,7 +167,7 @@ func (l *liveStream) gone(ctx context.Context, name string) ([]fragment, error) | |||
| 167 | 167 | func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragment, error) { | |
| 168 | 168 | switch ev := we.Event.(type) { | |
| 169 | 169 | case domain.ReleasePublished, domain.ReleaseUpdated, domain.ReleaseDeleted, domain.ReleaseUnpublished: | |
| 170 | - | if l.view == "releases" || l.view == "release" { | |
| 170 | + | if l.view == "releases" || l.view == "release" || l.view == "summary" { | |
| 171 | 171 | return gap, nil // notes, assets, latest or visibility changed: reload | |
| 172 | 172 | } | |
| 173 | 173 | return nil, nil | |
| @@ -167,7 +167,7 @@ func (l *liveStream) gone(ctx context.Context, name string) ([]fragment, error) | |||||
| 167 | func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragment, error) { | 167 | func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragment, error) { | ||
| 168 | switch ev := we.Event.(type) { | 168 | switch ev := we.Event.(type) { | ||
| 169 | case domain.ReleasePublished, domain.ReleaseUpdated, domain.ReleaseDeleted, domain.ReleaseUnpublished: | 169 | case domain.ReleasePublished, domain.ReleaseUpdated, domain.ReleaseDeleted, domain.ReleaseUnpublished: | ||
| 170 | - | if l.view == "releases" || l.view == "release" { | 170 | + | if l.view == "releases" || l.view == "release" || l.view == "summary" { |
| 171 | return gap, nil // notes, assets, latest or visibility changed: reload | 171 | return gap, nil // notes, assets, latest or visibility changed: reload | ||
| 172 | } | 172 | } | ||
| 173 | return nil, nil | 173 | return nil, nil | ||
M internal/adapters/web/fragments_test.go +3 -3
| @@ -101,8 +101,8 @@ func TestMRPushedHeadSignal(t *testing.T) { | |||
| 101 | 101 | } | |
| 102 | 102 | } | |
| 103 | 103 | ||
| 104 | - | // Release lifecycle events invalidate the release pages without putting notes | |
| 105 | - | // or asset metadata in SSE payloads. Other views need no release fragments. | |
| 104 | + | // Release lifecycle events invalidate release pages and the summary's release | |
| 105 | + | // fact without putting notes or asset metadata in SSE payloads. | |
| 106 | 106 | func TestReleaseLiveUpdates(t *testing.T) { | |
| 107 | 107 | for _, ev := range []domain.Event{domain.ReleasePublished{}, domain.ReleaseUpdated{}, domain.ReleaseDeleted{}, domain.ReleaseUnpublished{}} { | |
| 108 | 108 | we := ports.WatchEvent{Event: ev} | |
| @@ -111,7 +111,7 @@ func TestReleaseLiveUpdates(t *testing.T) { | |||
| 111 | 111 | st := &liveStream{repo: "proj", view: view} | |
| 112 | 112 | got, err := st.render(t.Context(), we) | |
| 113 | 113 | require.NoError(t, err) | |
| 114 | - | if view == "release" || view == "releases" { | |
| 114 | + | if view == "release" || view == "releases" || view == "summary" { | |
| 115 | 115 | assert.Equal(t, gap, got) | |
| 116 | 116 | } else { | |
| 117 | 117 | assert.Empty(t, got) | |
| @@ -101,8 +101,8 @@ func TestMRPushedHeadSignal(t *testing.T) { | |||||
| 101 | } | 101 | } | ||
| 102 | } | 102 | } | ||
| 103 | 103 | ||||
| 104 | - | // Release lifecycle events invalidate the release pages without putting notes | 104 | + | // Release lifecycle events invalidate release pages and the summary's release |
| 105 | - | // or asset metadata in SSE payloads. Other views need no release fragments. | 105 | + | // fact without putting notes or asset metadata in SSE payloads. |
| 106 | func TestReleaseLiveUpdates(t *testing.T) { | 106 | func TestReleaseLiveUpdates(t *testing.T) { | ||
| 107 | for _, ev := range []domain.Event{domain.ReleasePublished{}, domain.ReleaseUpdated{}, domain.ReleaseDeleted{}, domain.ReleaseUnpublished{}} { | 107 | for _, ev := range []domain.Event{domain.ReleasePublished{}, domain.ReleaseUpdated{}, domain.ReleaseDeleted{}, domain.ReleaseUnpublished{}} { | ||
| 108 | we := ports.WatchEvent{Event: ev} | 108 | we := ports.WatchEvent{Event: ev} | ||
| @@ -111,7 +111,7 @@ func TestReleaseLiveUpdates(t *testing.T) { | |||||
| 111 | st := &liveStream{repo: "proj", view: view} | 111 | st := &liveStream{repo: "proj", view: view} | ||
| 112 | got, err := st.render(t.Context(), we) | 112 | got, err := st.render(t.Context(), we) | ||
| 113 | require.NoError(t, err) | 113 | require.NoError(t, err) | ||
| 114 | - | if view == "release" || view == "releases" { | 114 | + | if view == "release" || view == "releases" || view == "summary" { |
| 115 | assert.Equal(t, gap, got) | 115 | assert.Equal(t, gap, got) | ||
| 116 | } else { | 116 | } else { | ||
| 117 | assert.Empty(t, got) | 117 | assert.Empty(t, got) | ||
M internal/adapters/web/issues_test.go +3 -3
| @@ -100,7 +100,7 @@ func TestIssueList(t *testing.T) { | |||
| 100 | 100 | assert.Contains(t, body, "crash <b>on</b> start") | |
| 101 | 101 | assert.Contains(t, body, `href="/proj/issues?page=tok2"`, "older page") | |
| 102 | 102 | assert.Contains(t, body, `href="/proj/issues?state=closed"`, "closed tab") | |
| 103 | - | assert.Contains(t, body, `href="/proj/issues" data-key="i"`, "the Issues tab") | |
| 103 | + | assert.Contains(t, body, `href="/proj/issues" data-key="g i"`, "the Issues tab") | |
| 104 | 104 | assert.NotContains(t, body, "New issue", "no form for an anonymous visitor") | |
| 105 | 105 | assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control")) | |
| 106 | 106 | ||
| @@ -169,7 +169,7 @@ func TestIssueNotFound(t *testing.T) { | |||
| 169 | 169 | } | |
| 170 | 170 | summary := off.do("GET", "/proj", false, nil) | |
| 171 | 171 | require.Equal(t, 200, summary.Code) | |
| 172 | - | assert.NotContains(t, summary.Body.String(), `data-key="i"`) | |
| 172 | + | assert.NotContains(t, summary.Body.String(), `data-key="g i"`) | |
| 173 | 173 | ||
| 174 | 174 | // The mode is unset in the settings: off as well. | |
| 175 | 175 | unset := newIssueRig(t, "", false) | |
| @@ -257,7 +257,7 @@ func TestIssueRoutesAbsentWithoutService(t *testing.T) { | |||
| 257 | 257 | } | |
| 258 | 258 | rec := httptest.NewRecorder() | |
| 259 | 259 | srv.ServeHTTP(rec, httptest.NewRequest("GET", "/proj", nil)) | |
| 260 | - | assert.NotContains(t, rec.Body.String(), `data-key="i"`) | |
| 260 | + | assert.NotContains(t, rec.Body.String(), `data-key="g i"`) | |
| 261 | 261 | } | |
| 262 | 262 | ||
| 263 | 263 | func TestIssueNumberRedirects(t *testing.T) { | |
| @@ -100,7 +100,7 @@ func TestIssueList(t *testing.T) { | |||||
| 100 | assert.Contains(t, body, "crash <b>on</b> start") | 100 | assert.Contains(t, body, "crash <b>on</b> start") | ||
| 101 | assert.Contains(t, body, `href="/proj/issues?page=tok2"`, "older page") | 101 | assert.Contains(t, body, `href="/proj/issues?page=tok2"`, "older page") | ||
| 102 | assert.Contains(t, body, `href="/proj/issues?state=closed"`, "closed tab") | 102 | assert.Contains(t, body, `href="/proj/issues?state=closed"`, "closed tab") | ||
| 103 | - | assert.Contains(t, body, `href="/proj/issues" data-key="i"`, "the Issues tab") | 103 | + | assert.Contains(t, body, `href="/proj/issues" data-key="g i"`, "the Issues tab") |
| 104 | assert.NotContains(t, body, "New issue", "no form for an anonymous visitor") | 104 | assert.NotContains(t, body, "New issue", "no form for an anonymous visitor") | ||
| 105 | assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control")) | 105 | assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control")) | ||
| 106 | 106 | ||||
| @@ -169,7 +169,7 @@ func TestIssueNotFound(t *testing.T) { | |||||
| 169 | } | 169 | } | ||
| 170 | summary := off.do("GET", "/proj", false, nil) | 170 | summary := off.do("GET", "/proj", false, nil) | ||
| 171 | require.Equal(t, 200, summary.Code) | 171 | require.Equal(t, 200, summary.Code) | ||
| 172 | - | assert.NotContains(t, summary.Body.String(), `data-key="i"`) | 172 | + | assert.NotContains(t, summary.Body.String(), `data-key="g i"`) |
| 173 | 173 | ||||
| 174 | // The mode is unset in the settings: off as well. | 174 | // The mode is unset in the settings: off as well. | ||
| 175 | unset := newIssueRig(t, "", false) | 175 | unset := newIssueRig(t, "", false) | ||
| @@ -257,7 +257,7 @@ func TestIssueRoutesAbsentWithoutService(t *testing.T) { | |||||
| 257 | } | 257 | } | ||
| 258 | rec := httptest.NewRecorder() | 258 | rec := httptest.NewRecorder() | ||
| 259 | srv.ServeHTTP(rec, httptest.NewRequest("GET", "/proj", nil)) | 259 | srv.ServeHTTP(rec, httptest.NewRequest("GET", "/proj", nil)) | ||
| 260 | - | assert.NotContains(t, rec.Body.String(), `data-key="i"`) | 260 | + | assert.NotContains(t, rec.Body.String(), `data-key="g i"`) |
| 261 | } | 261 | } | ||
| 262 | 262 | ||||
| 263 | func TestIssueNumberRedirects(t *testing.T) { | 263 | func TestIssueNumberRedirects(t *testing.T) { | ||
A internal/adapters/web/jstest/keyboard.test.mjs +76 -0
| @@ -0,0 +1,76 @@ | |||
| 1 | + | // Exercise the production key handler without a browser or dependencies. | |
| 2 | + | import assert from 'node:assert/strict'; | |
| 3 | + | import { readFileSync } from 'node:fs'; | |
| 4 | + | import { test } from 'node:test'; | |
| 5 | + | import { runInNewContext } from 'node:vm'; | |
| 6 | + | const source = readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8'); | |
| 7 | + | function keyboard({ owner = true, off = false, typing = false, dialog = false } = {}) { | |
| 8 | + | let handler; | |
| 9 | + | const fired = []; | |
| 10 | + | const timers = new Map(); | |
| 11 | + | let timerID = 0; | |
| 12 | + | runInNewContext(source.slice(source.indexOf(' const single ='), source.indexOf(' // 2. A [data-fire]')), { | |
| 13 | + | addEventListener: (_, fn) => { handler = fn; }, | |
| 14 | + | window: { ermiteKeys: { blocks: () => off } }, | |
| 15 | + | ermite: { fire: name => fired.push(name) }, | |
| 16 | + | $: selector => (owner && selector === '[data-inbox]') || (dialog && selector === 'dialog[open]'), | |
| 17 | + | closeMenus: () => false, | |
| 18 | + | clearTimeout: id => timers.delete(id), | |
| 19 | + | setTimeout: fn => { timers.set(++timerID, fn); return timerID; }, | |
| 20 | + | }); | |
| 21 | + | return { fired, expireTimers() { | |
| 22 | + | const callbacks = [...timers.values()]; | |
| 23 | + | timers.clear(); | |
| 24 | + | for (const callback of callbacks) callback(); | |
| 25 | + | }, press(key, shiftKey = false) { | |
| 26 | + | handler({ key, shiftKey, target: { closest: selector => typing && selector.startsWith('input'), blur() {} }, preventDefault() {} }); | |
| 27 | + | } }; | |
| 28 | + | } | |
| 29 | + | test('sections require g and have no old t alias', () => { | |
| 30 | + | const k = keyboard(); | |
| 31 | + | for (const key of ['s', 't', 'c', 'r', 'm', 'i']) k.press(key); | |
| 32 | + | assert.deepEqual(k.fired, []); | |
| 33 | + | for (const key of ['s', 'f', 'c', 'b', 'm', 'i', 'r', 'a', 'h', 'g', 't']) { k.press('g'); k.press(key); } | |
| 34 | + | assert.deepEqual(k.fired, ['s', 'f', 'c', 'b', 'm', 'i', 'r', 'a', 'gh', 'top']); | |
| 35 | + | }); | |
| 36 | + | test('uppercase actions work with either reported casing and interrupt prefixes', () => { | |
| 37 | + | for (const [key, action] of Object.entries({ G: 'bottom', M: 'merge', R: 'read', E: 'expandAll', I: 'inbox' })) { | |
| 38 | + | for (const reported of [key, key.toLowerCase()]) { | |
| 39 | + | const k = keyboard(); | |
| 40 | + | k.press('g'); k.press(reported, true); k.press(reported, true); | |
| 41 | + | assert.deepEqual(k.fired, [action, action]); | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | }); | |
| 45 | + | test('uppercase actions respect preference, fields and dialogs', () => { | |
| 46 | + | for (const options of [{ off: true }, { typing: true }, { dialog: true }]) { | |
| 47 | + | const k = keyboard(options); k.press('g', true); | |
| 48 | + | assert.deepEqual(k.fired, []); | |
| 49 | + | } | |
| 50 | + | }); | |
| 51 | + | ||
| 52 | + | test('Shift+I opens inbox while issues need the g i sequence', () => { | |
| 53 | + | const k = keyboard(); | |
| 54 | + | k.press('I', true); k.press('i'); | |
| 55 | + | assert.deepEqual(k.fired, ['inbox']); | |
| 56 | + | k.press('g'); k.press('i'); | |
| 57 | + | assert.deepEqual(k.fired, ['inbox', 'i']); | |
| 58 | + | }); | |
| 59 | + | test('inbox shortcut respects preference, fields, dialogs, and owner target', () => { | |
| 60 | + | for (const options of [{ off: true }, { typing: true }, { dialog: true }, { owner: false }]) { | |
| 61 | + | const k = keyboard(options); k.press('I', true); | |
| 62 | + | assert.deepEqual(k.fired, [], JSON.stringify(options)); | |
| 63 | + | } | |
| 64 | + | }); | |
| 65 | + | test('shifted punctuation still opens help and the command palette', () => { | |
| 66 | + | const k = keyboard(); | |
| 67 | + | k.press('?', true); k.press(':', true); | |
| 68 | + | assert.deepEqual(k.fired, ['help', 'cmd']); | |
| 69 | + | }); | |
| 70 | + | test('g prefix expires without scheduling real timers', () => { | |
| 71 | + | const k = keyboard(); | |
| 72 | + | k.press('g'); k.expireTimers(); k.press('s'); | |
| 73 | + | assert.deepEqual(k.fired, []); | |
| 74 | + | k.press('g'); k.press('s'); | |
| 75 | + | assert.deepEqual(k.fired, ['s']); | |
| 76 | + | }); | |
| @@ -0,0 +1,76 @@ | |||||
| 1 | + | // Exercise the production key handler without a browser or dependencies. | |||
| 2 | + | import assert from 'node:assert/strict'; | |||
| 3 | + | import { readFileSync } from 'node:fs'; | |||
| 4 | + | import { test } from 'node:test'; | |||
| 5 | + | import { runInNewContext } from 'node:vm'; | |||
| 6 | + | const source = readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8'); | |||
| 7 | + | function keyboard({ owner = true, off = false, typing = false, dialog = false } = {}) { | |||
| 8 | + | let handler; | |||
| 9 | + | const fired = []; | |||
| 10 | + | const timers = new Map(); | |||
| 11 | + | let timerID = 0; | |||
| 12 | + | runInNewContext(source.slice(source.indexOf(' const single ='), source.indexOf(' // 2. A [data-fire]')), { | |||
| 13 | + | addEventListener: (_, fn) => { handler = fn; }, | |||
| 14 | + | window: { ermiteKeys: { blocks: () => off } }, | |||
| 15 | + | ermite: { fire: name => fired.push(name) }, | |||
| 16 | + | $: selector => (owner && selector === '[data-inbox]') || (dialog && selector === 'dialog[open]'), | |||
| 17 | + | closeMenus: () => false, | |||
| 18 | + | clearTimeout: id => timers.delete(id), | |||
| 19 | + | setTimeout: fn => { timers.set(++timerID, fn); return timerID; }, | |||
| 20 | + | }); | |||
| 21 | + | return { fired, expireTimers() { | |||
| 22 | + | const callbacks = [...timers.values()]; | |||
| 23 | + | timers.clear(); | |||
| 24 | + | for (const callback of callbacks) callback(); | |||
| 25 | + | }, press(key, shiftKey = false) { | |||
| 26 | + | handler({ key, shiftKey, target: { closest: selector => typing && selector.startsWith('input'), blur() {} }, preventDefault() {} }); | |||
| 27 | + | } }; | |||
| 28 | + | } | |||
| 29 | + | test('sections require g and have no old t alias', () => { | |||
| 30 | + | const k = keyboard(); | |||
| 31 | + | for (const key of ['s', 't', 'c', 'r', 'm', 'i']) k.press(key); | |||
| 32 | + | assert.deepEqual(k.fired, []); | |||
| 33 | + | for (const key of ['s', 'f', 'c', 'b', 'm', 'i', 'r', 'a', 'h', 'g', 't']) { k.press('g'); k.press(key); } | |||
| 34 | + | assert.deepEqual(k.fired, ['s', 'f', 'c', 'b', 'm', 'i', 'r', 'a', 'gh', 'top']); | |||
| 35 | + | }); | |||
| 36 | + | test('uppercase actions work with either reported casing and interrupt prefixes', () => { | |||
| 37 | + | for (const [key, action] of Object.entries({ G: 'bottom', M: 'merge', R: 'read', E: 'expandAll', I: 'inbox' })) { | |||
| 38 | + | for (const reported of [key, key.toLowerCase()]) { | |||
| 39 | + | const k = keyboard(); | |||
| 40 | + | k.press('g'); k.press(reported, true); k.press(reported, true); | |||
| 41 | + | assert.deepEqual(k.fired, [action, action]); | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | }); | |||
| 45 | + | test('uppercase actions respect preference, fields and dialogs', () => { | |||
| 46 | + | for (const options of [{ off: true }, { typing: true }, { dialog: true }]) { | |||
| 47 | + | const k = keyboard(options); k.press('g', true); | |||
| 48 | + | assert.deepEqual(k.fired, []); | |||
| 49 | + | } | |||
| 50 | + | }); | |||
| 51 | + | ||||
| 52 | + | test('Shift+I opens inbox while issues need the g i sequence', () => { | |||
| 53 | + | const k = keyboard(); | |||
| 54 | + | k.press('I', true); k.press('i'); | |||
| 55 | + | assert.deepEqual(k.fired, ['inbox']); | |||
| 56 | + | k.press('g'); k.press('i'); | |||
| 57 | + | assert.deepEqual(k.fired, ['inbox', 'i']); | |||
| 58 | + | }); | |||
| 59 | + | test('inbox shortcut respects preference, fields, dialogs, and owner target', () => { | |||
| 60 | + | for (const options of [{ off: true }, { typing: true }, { dialog: true }, { owner: false }]) { | |||
| 61 | + | const k = keyboard(options); k.press('I', true); | |||
| 62 | + | assert.deepEqual(k.fired, [], JSON.stringify(options)); | |||
| 63 | + | } | |||
| 64 | + | }); | |||
| 65 | + | test('shifted punctuation still opens help and the command palette', () => { | |||
| 66 | + | const k = keyboard(); | |||
| 67 | + | k.press('?', true); k.press(':', true); | |||
| 68 | + | assert.deepEqual(k.fired, ['help', 'cmd']); | |||
| 69 | + | }); | |||
| 70 | + | test('g prefix expires without scheduling real timers', () => { | |||
| 71 | + | const k = keyboard(); | |||
| 72 | + | k.press('g'); k.expireTimers(); k.press('s'); | |||
| 73 | + | assert.deepEqual(k.fired, []); | |||
| 74 | + | k.press('g'); k.press('s'); | |||
| 75 | + | assert.deepEqual(k.fired, ['s']); | |||
| 76 | + | }); | |||
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/keys.templ +3 -2
| @@ -23,7 +23,9 @@ templ Keys() { | |||
| 23 | 23 | k-filter from:body -> ermite.focusFilter(); | |
| 24 | 24 | k-gh from:body -> ermite.go('.brand'); | |
| 25 | 25 | k-s from:body -> ermite.tab('s'); | |
| 26 | - | k-t from:body -> ermite.tab('t'); | |
| 26 | + | k-f from:body -> ermite.tab('f'); | |
| 27 | + | k-b from:body -> ermite.tab('b'); | |
| 28 | + | k-a from:body -> ermite.tab('a'); | |
| 27 | 29 | k-c from:body -> ermite.tab('c'); | |
| 28 | 30 | k-r from:body -> ermite.tab('r'); | |
| 29 | 31 | k-m from:body -> ermite.tab('m'); | |
| @@ -39,7 +41,6 @@ templ Keys() { | |||
| 39 | 41 | k-permalink from:body -> ermite.permalink(); | |
| 40 | 42 | k-merge from:body -> window.ermiteMR?.openMerge(); | |
| 41 | 43 | k-read from:body -> window.ermiteMR?.markRead(); | |
| 42 | - | k-gf from:body -> window.ermiteMR?.focusFiles(); | |
| 43 | 44 | k-keys from:body -> ermite.toggleKeys()" | |
| 44 | 45 | ></div> | |
| 45 | 46 | } | |
| @@ -23,7 +23,9 @@ templ Keys() { | |||||
| 23 | k-filter from:body -> ermite.focusFilter(); | 23 | k-filter from:body -> ermite.focusFilter(); | ||
| 24 | k-gh from:body -> ermite.go('.brand'); | 24 | k-gh from:body -> ermite.go('.brand'); | ||
| 25 | k-s from:body -> ermite.tab('s'); | 25 | k-s from:body -> ermite.tab('s'); | ||
| 26 | - | k-t from:body -> ermite.tab('t'); | 26 | + | k-f from:body -> ermite.tab('f'); |
| 27 | + | k-b from:body -> ermite.tab('b'); | |||
| 28 | + | k-a from:body -> ermite.tab('a'); | |||
| 27 | k-c from:body -> ermite.tab('c'); | 29 | k-c from:body -> ermite.tab('c'); | ||
| 28 | k-r from:body -> ermite.tab('r'); | 30 | k-r from:body -> ermite.tab('r'); | ||
| 29 | k-m from:body -> ermite.tab('m'); | 31 | k-m from:body -> ermite.tab('m'); | ||
| @@ -39,7 +41,6 @@ templ Keys() { | |||||
| 39 | k-permalink from:body -> ermite.permalink(); | 41 | k-permalink from:body -> ermite.permalink(); | ||
| 40 | k-merge from:body -> window.ermiteMR?.openMerge(); | 42 | k-merge from:body -> window.ermiteMR?.openMerge(); | ||
| 41 | k-read from:body -> window.ermiteMR?.markRead(); | 43 | k-read from:body -> window.ermiteMR?.markRead(); | ||
| 42 | - | k-gf from:body -> window.ermiteMR?.focusFiles(); | |||
| 43 | k-keys from:body -> ermite.toggleKeys()" | 44 | k-keys from:body -> ermite.toggleKeys()" | ||
| 44 | ></div> | 45 | ></div> | ||
| 45 | } | 46 | } | ||
M internal/adapters/web/keys_templ.go +1 -1 generated file: collapsed
A internal/adapters/web/keys_test.go +26 -0
| @@ -0,0 +1,26 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestSectionShortcutSequences(t *testing.T) { | |
| 12 | + | sections := []Section{} | |
| 13 | + | for _, key := range []string{"s", "f", "c", "b", "m", "i", "r", "a"} { | |
| 14 | + | sections = append(sections, Section{Key: key, Label: key, Href: "/" + key}) | |
| 15 | + | } | |
| 16 | + | header := renderToString(t, Header(Layout{Sections: sections})) | |
| 17 | + | bindings := renderToString(t, Keys()) | |
| 18 | + | for _, section := range sections { | |
| 19 | + | assert.Contains(t, header, `data-key="g `+section.Key+`"`) | |
| 20 | + | assert.NotContains(t, header, `aria-keyshortcuts="g `+section.Key+`"`) | |
| 21 | + | assert.Contains(t, header, `<kbd aria-hidden="true">g `+section.Key+`</kbd>`) | |
| 22 | + | assert.Contains(t, bindings, "ermite.tab('"+section.Key+"')") | |
| 23 | + | } | |
| 24 | + | assert.NotContains(t, bindings, "k-gf") | |
| 25 | + | assert.NotContains(t, bindings, "k-t from:body") | |
| 26 | + | } | |
| @@ -0,0 +1,26 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestSectionShortcutSequences(t *testing.T) { | |||
| 12 | + | sections := []Section{} | |||
| 13 | + | for _, key := range []string{"s", "f", "c", "b", "m", "i", "r", "a"} { | |||
| 14 | + | sections = append(sections, Section{Key: key, Label: key, Href: "/" + key}) | |||
| 15 | + | } | |||
| 16 | + | header := renderToString(t, Header(Layout{Sections: sections})) | |||
| 17 | + | bindings := renderToString(t, Keys()) | |||
| 18 | + | for _, section := range sections { | |||
| 19 | + | assert.Contains(t, header, `data-key="g `+section.Key+`"`) | |||
| 20 | + | assert.NotContains(t, header, `aria-keyshortcuts="g `+section.Key+`"`) | |||
| 21 | + | assert.Contains(t, header, `<kbd aria-hidden="true">g `+section.Key+`</kbd>`) | |||
| 22 | + | assert.Contains(t, bindings, "ermite.tab('"+section.Key+"')") | |||
| 23 | + | } | |||
| 24 | + | assert.NotContains(t, bindings, "k-gf") | |||
| 25 | + | assert.NotContains(t, bindings, "k-t from:body") | |||
| 26 | + | } | |||
M internal/adapters/web/mr.templ +1 -1
| @@ -430,7 +430,7 @@ templ mrFooter(p MRPage) { | |||
| 430 | 430 | templ mrRail(p MRPage) { | |
| 431 | 431 | <section aria-labelledby="r-files" class="r-files"> | |
| 432 | 432 | <h2 id="r-files"> | |
| 433 | - | Files <span class="count">{ itoa(len(p.Files)) }</span><kbd aria-hidden="true">g f</kbd> | |
| 433 | + | Files <span class="count">{ itoa(len(p.Files)) }</span><kbd aria-hidden="true">l</kbd> | |
| 434 | 434 | <button type="button" class="btn icon fold" data-js aria-expanded="true" title="fold the list of files" hx-on:click="ermiteMR.foldFiles()"> | |
| 435 | 435 | <span class="ico i-chevron" aria-hidden="true"></span><span class="sr">fold the list of files</span> | |
| 436 | 436 | </button> | |
| @@ -430,7 +430,7 @@ templ mrFooter(p MRPage) { | |||||
| 430 | templ mrRail(p MRPage) { | 430 | templ mrRail(p MRPage) { | ||
| 431 | <section aria-labelledby="r-files" class="r-files"> | 431 | <section aria-labelledby="r-files" class="r-files"> | ||
| 432 | <h2 id="r-files"> | 432 | <h2 id="r-files"> | ||
| 433 | - | Files <span class="count">{ itoa(len(p.Files)) }</span><kbd aria-hidden="true">g f</kbd> | 433 | + | Files <span class="count">{ itoa(len(p.Files)) }</span><kbd aria-hidden="true">l</kbd> |
| 434 | <button type="button" class="btn icon fold" data-js aria-expanded="true" title="fold the list of files" hx-on:click="ermiteMR.foldFiles()"> | 434 | <button type="button" class="btn icon fold" data-js aria-expanded="true" title="fold the list of files" hx-on:click="ermiteMR.foldFiles()"> | ||
| 435 | <span class="ico i-chevron" aria-hidden="true"></span><span class="sr">fold the list of files</span> | 435 | <span class="ico i-chevron" aria-hidden="true"></span><span class="sr">fold the list of files</span> | ||
| 436 | </button> | 436 | </button> | ||
M internal/adapters/web/mr_templ.go +1 -1
| @@ -2214,7 +2214,7 @@ func mrRail(p MRPage) templ.Component { | |||
| 2214 | 2214 | if templ_7745c5c3_Err != nil { | |
| 2215 | 2215 | return templ_7745c5c3_Err | |
| 2216 | 2216 | } | |
| 2217 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, "</span><kbd aria-hidden=\"true\">g f</kbd> <button type=\"button\" class=\"btn icon fold\" data-js aria-expanded=\"true\" title=\"fold the list of files\" hx-on:click=\"ermiteMR.foldFiles()\"><span class=\"ico i-chevron\" aria-hidden=\"true\"></span><span class=\"sr\">fold the list of files</span></button></h2>") | |
| 2217 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, "</span><kbd aria-hidden=\"true\">l</kbd> <button type=\"button\" class=\"btn icon fold\" data-js aria-expanded=\"true\" title=\"fold the list of files\" hx-on:click=\"ermiteMR.foldFiles()\"><span class=\"ico i-chevron\" aria-hidden=\"true\"></span><span class=\"sr\">fold the list of files</span></button></h2>") | |
| 2218 | 2218 | if templ_7745c5c3_Err != nil { | |
| 2219 | 2219 | return templ_7745c5c3_Err | |
| 2220 | 2220 | } | |
| @@ -2214,7 +2214,7 @@ func mrRail(p MRPage) templ.Component { | |||||
| 2214 | if templ_7745c5c3_Err != nil { | 2214 | if templ_7745c5c3_Err != nil { | ||
| 2215 | return templ_7745c5c3_Err | 2215 | return templ_7745c5c3_Err | ||
| 2216 | } | 2216 | } | ||
| 2217 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, "</span><kbd aria-hidden=\"true\">g f</kbd> <button type=\"button\" class=\"btn icon fold\" data-js aria-expanded=\"true\" title=\"fold the list of files\" hx-on:click=\"ermiteMR.foldFiles()\"><span class=\"ico i-chevron\" aria-hidden=\"true\"></span><span class=\"sr\">fold the list of files</span></button></h2>") | 2217 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, "</span><kbd aria-hidden=\"true\">l</kbd> <button type=\"button\" class=\"btn icon fold\" data-js aria-expanded=\"true\" title=\"fold the list of files\" hx-on:click=\"ermiteMR.foldFiles()\"><span class=\"ico i-chevron\" aria-hidden=\"true\"></span><span class=\"sr\">fold the list of files</span></button></h2>") |
| 2218 | if templ_7745c5c3_Err != nil { | 2218 | if templ_7745c5c3_Err != nil { | ||
| 2219 | return templ_7745c5c3_Err | 2219 | return templ_7745c5c3_Err | ||
| 2220 | } | 2220 | } | ||
M internal/adapters/web/mr_test.go +1 -1
| @@ -157,7 +157,7 @@ func TestMRPanelsAndMenus(t *testing.T) { | |||
| 157 | 157 | assert.Contains(t, footer, "more commits") | |
| 158 | 158 | assert.Contains(t, footer, "1/2 failed") | |
| 159 | 159 | rail := renderToString(t, mrRail(p)) | |
| 160 | - | assert.Contains(t, rail, "g f") | |
| 160 | + | assert.Contains(t, rail, `<kbd aria-hidden="true">l</kbd>`) | |
| 161 | 161 | for _, text := range []string{"Checks", "Commits", "Settings", "mr-checks"} { | |
| 162 | 162 | assert.NotContains(t, rail, text) | |
| 163 | 163 | } | |
| @@ -157,7 +157,7 @@ func TestMRPanelsAndMenus(t *testing.T) { | |||||
| 157 | assert.Contains(t, footer, "more commits") | 157 | assert.Contains(t, footer, "more commits") | ||
| 158 | assert.Contains(t, footer, "1/2 failed") | 158 | assert.Contains(t, footer, "1/2 failed") | ||
| 159 | rail := renderToString(t, mrRail(p)) | 159 | rail := renderToString(t, mrRail(p)) | ||
| 160 | - | assert.Contains(t, rail, "g f") | 160 | + | assert.Contains(t, rail, `<kbd aria-hidden="true">l</kbd>`) |
| 161 | for _, text := range []string{"Checks", "Commits", "Settings", "mr-checks"} { | 161 | for _, text := range []string{"Checks", "Commits", "Settings", "mr-checks"} { | ||
| 162 | assert.NotContains(t, rail, text) | 162 | assert.NotContains(t, rail, text) | ||
| 163 | } | 163 | } | ||
M internal/adapters/web/overlays.templ +2 -2
| @@ -26,7 +26,7 @@ templ OverlayHelp(mr bool) { | |||
| 26 | 26 | <div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div> | |
| 27 | 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 | 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 & tags, merge requests, issues</dd></div> | |
| 29 | + | <div><dt><kbd>g s</kbd> <kbd>g f</kbd> <kbd>g c</kbd> <kbd>g b</kbd> <kbd>g m</kbd> <kbd>g i</kbd> <kbd>g r</kbd> <kbd>g a</kbd></dt><dd>tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)</dd></div> | |
| 30 | 30 | if SignedIn(ctx) { | |
| 31 | 31 | <div><dt><kbd>I</kbd></dt><dd>inbox</dd></div> | |
| 32 | 32 | } | |
| @@ -37,7 +37,7 @@ templ OverlayHelp(mr bool) { | |||
| 37 | 37 | <div><dt><kbd>e</kbd> <kbd>E</kbd></dt><dd>diff: the whole file in view / every file, with the lines the hunks hide</dd></div> | |
| 38 | 38 | <div><dt><kbd>n</kbd></dt><dd>merge request: next note in the diff</dd></div> | |
| 39 | 39 | if mr { | |
| 40 | - | <div><dt><kbd>g f</kbd></dt><dd>merge request: the list of files (j k move, enter opens, esc back)</dd></div> | |
| 40 | + | <div><dt><kbd>l</kbd></dt><dd>merge request: the list of files (j k move, enter opens, esc back)</dd></div> | |
| 41 | 41 | <div><dt><kbd>M</kbd></dt><dd>merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges</dd></div> | |
| 42 | 42 | <div><dt><kbd>R</kbd></dt><dd>merge request: mark the head read</dd></div> | |
| 43 | 43 | } | |
| @@ -26,7 +26,7 @@ templ OverlayHelp(mr bool) { | |||||
| 26 | <div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div> | 26 | <div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</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> | 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 & tags, merge requests, issues</dd></div> | 29 | + | <div><dt><kbd>g s</kbd> <kbd>g f</kbd> <kbd>g c</kbd> <kbd>g b</kbd> <kbd>g m</kbd> <kbd>g i</kbd> <kbd>g r</kbd> <kbd>g a</kbd></dt><dd>tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)</dd></div> |
| 30 | if SignedIn(ctx) { | 30 | if SignedIn(ctx) { | ||
| 31 | <div><dt><kbd>I</kbd></dt><dd>inbox</dd></div> | 31 | <div><dt><kbd>I</kbd></dt><dd>inbox</dd></div> | ||
| 32 | } | 32 | } | ||
| @@ -37,7 +37,7 @@ templ OverlayHelp(mr bool) { | |||||
| 37 | <div><dt><kbd>e</kbd> <kbd>E</kbd></dt><dd>diff: the whole file in view / every file, with the lines the hunks hide</dd></div> | 37 | <div><dt><kbd>e</kbd> <kbd>E</kbd></dt><dd>diff: the whole file in view / every file, with the lines the hunks hide</dd></div> | ||
| 38 | <div><dt><kbd>n</kbd></dt><dd>merge request: next note in the diff</dd></div> | 38 | <div><dt><kbd>n</kbd></dt><dd>merge request: next note in the diff</dd></div> | ||
| 39 | if mr { | 39 | if mr { | ||
| 40 | - | <div><dt><kbd>g f</kbd></dt><dd>merge request: the list of files (j k move, enter opens, esc back)</dd></div> | 40 | + | <div><dt><kbd>l</kbd></dt><dd>merge request: the list of files (j k move, enter opens, esc back)</dd></div> |
| 41 | <div><dt><kbd>M</kbd></dt><dd>merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges</dd></div> | 41 | <div><dt><kbd>M</kbd></dt><dd>merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges</dd></div> | ||
| 42 | <div><dt><kbd>R</kbd></dt><dd>merge request: mark the head read</dd></div> | 42 | <div><dt><kbd>R</kbd></dt><dd>merge request: mark the head read</dd></div> | ||
| 43 | } | 43 | } | ||
M internal/adapters/web/overlays_templ.go +2 -2 generated file: collapsed
M internal/adapters/web/pages.templ +8 -0
| @@ -115,6 +115,14 @@ templ summaryRail(p SummaryPage) { | |||
| 115 | 115 | if p.Latest != nil { | |
| 116 | 116 | @latestTagFact(*p.Latest) | |
| 117 | 117 | } | |
| 118 | + | if r := p.Release; r != nil { | |
| 119 | + | <dt>release</dt> | |
| 120 | + | <dd> | |
| 121 | + | <a href={ templ.SafeURL(r.Href) }>{ r.Tag }</a>{ " · " } | |
| 122 | + | <time datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time>{ " · " } | |
| 123 | + | <a href={ templ.SafeURL(r.AllHref) }>all releases</a> | |
| 124 | + | </dd> | |
| 125 | + | } | |
| 118 | 126 | </dl> | |
| 119 | 127 | </section> | |
| 120 | 128 | if len(p.Recent) > 0 { | |
| @@ -115,6 +115,14 @@ templ summaryRail(p SummaryPage) { | |||||
| 115 | if p.Latest != nil { | 115 | if p.Latest != nil { | ||
| 116 | @latestTagFact(*p.Latest) | 116 | @latestTagFact(*p.Latest) | ||
| 117 | } | 117 | } | ||
| 118 | + | if r := p.Release; r != nil { | |||
| 119 | + | <dt>release</dt> | |||
| 120 | + | <dd> | |||
| 121 | + | <a href={ templ.SafeURL(r.Href) }>{ r.Tag }</a>{ " · " } | |||
| 122 | + | <time datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time>{ " · " } | |||
| 123 | + | <a href={ templ.SafeURL(r.AllHref) }>all releases</a> | |||
| 124 | + | </dd> | |||
| 125 | + | } | |||
| 118 | </dl> | 126 | </dl> | ||
| 119 | </section> | 127 | </section> | ||
| 120 | if len(p.Recent) > 0 { | 128 | if len(p.Recent) > 0 { | ||
M internal/adapters/web/pages_templ.go +371 -261 generated file: collapsed
M internal/adapters/web/releases.templ +16 -9
| @@ -40,15 +40,22 @@ templ Releases(l Layout, p ReleaseListPage) { | |||
| 40 | 40 | ||
| 41 | 41 | templ ReleaseDetail(l Layout, p ReleaseDetailPage) { | |
| 42 | 42 | @Shell(l, nil) { | |
| 43 | - | <h1>{ p.Title }</h1> | |
| 44 | - | <p> | |
| 45 | - | <span class="pill">{ p.Tag }</span> @ReleaseBadges(p.ReleaseRow) | |
| 46 | - | if p.SHA != "" { | |
| 47 | - | <a href={ templ.SafeURL(p.CommitHref) }><code>{ p.SHA }</code></a> | |
| 48 | - | } | |
| 49 | - | <span class="dim">{ p.Published }</span> | |
| 50 | - | </p> | |
| 51 | - | <div class="md">@templ.Raw(string(p.Notes))</div> | |
| 43 | + | <section class="mr-card"> | |
| 44 | + | <header class="show mr-h"> | |
| 45 | + | <h1>{ p.Title }</h1> | |
| 46 | + | <dl class="kv show-kv"> | |
| 47 | + | <dt>tag</dt> | |
| 48 | + | <dd><span class="pill">{ p.Tag }</span> @ReleaseBadges(p.ReleaseRow)</dd> | |
| 49 | + | if p.SHA != "" { | |
| 50 | + | <dt>commit</dt> | |
| 51 | + | <dd><a href={ templ.SafeURL(p.CommitHref) }><code>{ p.SHA }</code></a></dd> | |
| 52 | + | } | |
| 53 | + | <dt>published</dt> | |
| 54 | + | <dd>{ p.Published }</dd> | |
| 55 | + | </dl> | |
| 56 | + | </header> | |
| 57 | + | <article class="mr-desc prose">@templ.Raw(string(p.Notes))</article> | |
| 58 | + | </section> | |
| 52 | 59 | <h2>Assets</h2> | |
| 53 | 60 | if len(p.Assets) == 0 { | |
| 54 | 61 | <p class="dim">no assets.</p> | |
| @@ -40,15 +40,22 @@ templ Releases(l Layout, p ReleaseListPage) { | |||||
| 40 | 40 | ||||
| 41 | templ ReleaseDetail(l Layout, p ReleaseDetailPage) { | 41 | templ ReleaseDetail(l Layout, p ReleaseDetailPage) { | ||
| 42 | @Shell(l, nil) { | 42 | @Shell(l, nil) { | ||
| 43 | - | <h1>{ p.Title }</h1> | 43 | + | <section class="mr-card"> |
| 44 | - | <p> | 44 | + | <header class="show mr-h"> |
| 45 | - | <span class="pill">{ p.Tag }</span> @ReleaseBadges(p.ReleaseRow) | 45 | + | <h1>{ p.Title }</h1> |
| 46 | - | if p.SHA != "" { | 46 | + | <dl class="kv show-kv"> |
| 47 | - | <a href={ templ.SafeURL(p.CommitHref) }><code>{ p.SHA }</code></a> | 47 | + | <dt>tag</dt> |
| 48 | - | } | 48 | + | <dd><span class="pill">{ p.Tag }</span> @ReleaseBadges(p.ReleaseRow)</dd> |
| 49 | - | <span class="dim">{ p.Published }</span> | 49 | + | if p.SHA != "" { |
| 50 | - | </p> | 50 | + | <dt>commit</dt> |
| 51 | - | <div class="md">@templ.Raw(string(p.Notes))</div> | 51 | + | <dd><a href={ templ.SafeURL(p.CommitHref) }><code>{ p.SHA }</code></a></dd> |
| 52 | + | } | |||
| 53 | + | <dt>published</dt> | |||
| 54 | + | <dd>{ p.Published }</dd> | |||
| 55 | + | </dl> | |||
| 56 | + | </header> | |||
| 57 | + | <article class="mr-desc prose">@templ.Raw(string(p.Notes))</article> | |||
| 58 | + | </section> | |||
| 52 | <h2>Assets</h2> | 59 | <h2>Assets</h2> | ||
| 53 | if len(p.Assets) == 0 { | 60 | if len(p.Assets) == 0 { | ||
| 54 | <p class="dim">no assets.</p> | 61 | <p class="dim">no assets.</p> | ||
M internal/adapters/web/releases_templ.go +32 -28
M internal/adapters/web/releases_test.go +12 -2
M internal/adapters/web/repo.go +4 -4
M internal/adapters/web/shell.templ +2 -3
M internal/adapters/web/shell_templ.go +48 -61
M internal/adapters/web/static/js/ermite.js +13 -4
M internal/adapters/web/static/js/mr.js +2 -2
M internal/adapters/web/summary.go +21 -0
A internal/adapters/web/summary_releases_test.go +49 -0
M internal/adapters/web/view.go +7 -1
M internal/app/testdata/web/blob-binary.html +33 -30
M internal/app/testdata/web/blob-code.html +33 -30
M internal/app/testdata/web/blob-empty.html +33 -30
M internal/app/testdata/web/blob-image.html +33 -30
M internal/app/testdata/web/blob-markdown.html +33 -30
M internal/app/testdata/web/blob-svg.html +33 -30
M internal/app/testdata/web/blob-text.html +33 -30
M internal/app/testdata/web/blob-too-large.html +33 -30
M internal/app/testdata/web/blob-trojan.html +33 -30
M internal/app/testdata/web/commit-big.html +33 -30
M internal/app/testdata/web/commit-binary.html +33 -30
M internal/app/testdata/web/commit-merge.html +33 -30
M internal/app/testdata/web/commit-modify-split.html +33 -30
M internal/app/testdata/web/commit-modify.html +33 -30
M internal/app/testdata/web/commit-rename.html +33 -30
M internal/app/testdata/web/commit-root.html +33 -30
M internal/app/testdata/web/commit-trojan.html +33 -30
M internal/app/testdata/web/commits-page2.html +33 -30
M internal/app/testdata/web/commits-path.html +33 -30
M internal/app/testdata/web/commits-root.html +33 -30
M internal/app/testdata/web/commits-small.html +33 -30
M internal/app/testdata/web/empty.html +12 -9
M internal/app/testdata/web/home.html +12 -9
M internal/app/testdata/web/not-found-page.html +12 -9
M internal/app/testdata/web/not-found.html +12 -9
M internal/app/testdata/web/refs.html +33 -30
M internal/app/testdata/web/summary.html +33 -30
M internal/app/testdata/web/tree-nested.html +33 -30
M internal/app/testdata/web/tree-root.html +33 -30
M internal/app/testdata/web/tree-slash-ref.html +33 -30
M internal/app/testdata/web/tree-subdir.html +33 -30
M internal/app/web_issue_test.go +1 -1
M internal/app/web_mr_test.go +1 -1
M internal/ports/mocks/ReleaseStore.go +66 -0
M internal/ports/release.go +3 -0
M internal/usecase/releases.go +25 -9
M internal/usecase/releases_integration_test.go +59 -0
A internal/usecase/releases_write_test.go +27 -0
Comments0 open / 0
no comments yet.