detached 4ce8534

feat(web): review page polish and home page ordered by last activity (!6)

- feat(web): one merge request card, a diff view menu, a wide diff mode and quieter hunk rows
- feat(web): make the wide mode a global w shortcut, set before the first paint, and tighten the merge request header
- refactor(web): drop the Wide button of the diff bar, the w shortcut covers it
- fix(web): a selected line range is one block, its title names the whole range and its hidden lines stay selected once loaded
- test(app): refresh the web golden pages for the view menu, the w shortcut and the store script
- feat(web): move the inbox button of the home page next to the logo, with a mailbox icon and a darker count badge
- feat(web): order the home page by last activity and move repositories up live
- fix(web): match loaded lines to a selection by file key, keep the focus on a closing menu, close menus with escape on every page and restore the hunk header contrast
- fix(repos): mutate metadata atomically and record approved issue activity
- test: run the activity store test outside the usecase package and its home row subtests in parallel
commit
4ce8534eb93973f791dae675c90e3ae5305b5647
author
brice · · 2026-10-19 15:05 UTC
committer
ermite · · 2026-10-19 18:25 UTC
parent
d8e7d5b
signature
SSH VerifiedSHA256:lY9fILbB/A+hln6Vh/iHoiiog74bbw/4qWrf/lrTU3U · ermite instance key Verified for the committer email
checks
3/4
92 files · +2426 -1392
M gen/ermite/storage/repo.pb.go +22 -11 generated file: collapsed
M internal/adapters/kvstore/issues_test.go +4 -2
@@ -258,13 +258,15 @@ func TestRepoSettingsKeepTheIssuesMode(t *testing.T) {
258258 assert.Equal(t, domain.IssuesOff, got.Settings.Issues)
259259 for _, m := range []domain.IssuesMode{domain.IssuesOwner, domain.IssuesPublic, domain.IssuesOff} {
260260 got.Settings.Issues = m
261- require.NoError(t, s.Update(ctx, got))
261+ _, err = s.Mutate(ctx, n, func(r *domain.Repo) (bool, error) { r.Settings = got.Settings; return true, nil })
262+ require.NoError(t, err)
262263 again, err := s.Get(ctx, n)
263264 require.NoError(t, err)
264265 assert.Equal(t, m, again.Settings.Issues)
265266 }
266267 got.Settings.Issues = "everyone"
267- assert.ErrorIs(t, s.Update(ctx, got), domain.ErrInvalid)
268+ _, err = s.Mutate(ctx, n, func(r *domain.Repo) (bool, error) { r.Settings = got.Settings; return true, nil })
269+ assert.ErrorIs(t, err, domain.ErrInvalid)
268270 }
269271
270272 // TestSchema4UpgradesTheTokensWithAllScopes: the tokens that held every
@@ -258,13 +258,15 @@ func TestRepoSettingsKeepTheIssuesMode(t *testing.T) {
258 assert.Equal(t, domain.IssuesOff, got.Settings.Issues)258 assert.Equal(t, domain.IssuesOff, got.Settings.Issues)
259 for _, m := range []domain.IssuesMode{domain.IssuesOwner, domain.IssuesPublic, domain.IssuesOff} {259 for _, m := range []domain.IssuesMode{domain.IssuesOwner, domain.IssuesPublic, domain.IssuesOff} {
260 got.Settings.Issues = m260 got.Settings.Issues = m
261- require.NoError(t, s.Update(ctx, got))261+ _, err = s.Mutate(ctx, n, func(r *domain.Repo) (bool, error) { r.Settings = got.Settings; return true, nil })
262+ require.NoError(t, err)
262 again, err := s.Get(ctx, n)263 again, err := s.Get(ctx, n)
263 require.NoError(t, err)264 require.NoError(t, err)
264 assert.Equal(t, m, again.Settings.Issues)265 assert.Equal(t, m, again.Settings.Issues)
265 }266 }
266 got.Settings.Issues = "everyone"267 got.Settings.Issues = "everyone"
267- assert.ErrorIs(t, s.Update(ctx, got), domain.ErrInvalid)268+ _, err = s.Mutate(ctx, n, func(r *domain.Repo) (bool, error) { r.Settings = got.Settings; return true, nil })
269+ assert.ErrorIs(t, err, domain.ErrInvalid)
268 }270 }
269 271
270 // TestSchema4UpgradesTheTokensWithAllScopes: the tokens that held every272 // TestSchema4UpgradesTheTokensWithAllScopes: the tokens that held every
M internal/adapters/kvstore/mergerequests_test.go +4 -2
@@ -442,13 +442,15 @@ func TestRepoSettings(t *testing.T) {
442442 assert.Equal(t, domain.DefaultRepoSettings(), got.Settings)
443443
444444 got.Settings = domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false, Issues: domain.IssuesOwner}
445- require.NoError(t, s.Update(ctx, got))
445+ _, err = s.Mutate(ctx, n, func(r *domain.Repo) (bool, error) { r.Settings = got.Settings; return true, nil })
446+ require.NoError(t, err)
446447 again, err := s.Get(ctx, n)
447448 require.NoError(t, err)
448449 assert.Equal(t, got.Settings, again.Settings)
449450
450451 got.Settings = domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{"rebase"}}
451- assert.ErrorIs(t, s.Update(ctx, got), domain.ErrInvalid)
452+ _, err = s.Mutate(ctx, n, func(r *domain.Repo) (bool, error) { r.Settings = got.Settings; return true, nil })
453+ assert.ErrorIs(t, err, domain.ErrInvalid)
452454 }
453455
454456 // TestRepoWithoutStoredSettingsReadsTheDefaults: a repository stored before
@@ -442,13 +442,15 @@ func TestRepoSettings(t *testing.T) {
442 assert.Equal(t, domain.DefaultRepoSettings(), got.Settings)442 assert.Equal(t, domain.DefaultRepoSettings(), got.Settings)
443 443
444 got.Settings = domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false, Issues: domain.IssuesOwner}444 got.Settings = domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false, Issues: domain.IssuesOwner}
445- require.NoError(t, s.Update(ctx, got))445+ _, err = s.Mutate(ctx, n, func(r *domain.Repo) (bool, error) { r.Settings = got.Settings; return true, nil })
446+ require.NoError(t, err)
446 again, err := s.Get(ctx, n)447 again, err := s.Get(ctx, n)
447 require.NoError(t, err)448 require.NoError(t, err)
448 assert.Equal(t, got.Settings, again.Settings)449 assert.Equal(t, got.Settings, again.Settings)
449 450
450 got.Settings = domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{"rebase"}}451 got.Settings = domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{"rebase"}}
451- assert.ErrorIs(t, s.Update(ctx, got), domain.ErrInvalid)452+ _, err = s.Mutate(ctx, n, func(r *domain.Repo) (bool, error) { r.Settings = got.Settings; return true, nil })
453+ assert.ErrorIs(t, err, domain.ErrInvalid)
452 }454 }
453 455
454 // TestRepoWithoutStoredSettingsReadsTheDefaults: a repository stored before456 // TestRepoWithoutStoredSettingsReadsTheDefaults: a repository stored before
M internal/adapters/kvstore/repos.go +44 -22
@@ -64,31 +64,51 @@ func (s *Store) List(ctx context.Context) ([]domain.Repo, error) {
6464 return repos, nil
6565 }
6666
67-// Update replaces the stored repository. It returns domain.ErrNotFound if
68-// there is none and domain.ErrConflict if another write changed it since it
69-// was read.
67+const repoMutationTries = 8
68+
69+// Mutate applies a retry-safe mutation under the version it read.
7070 //
71-//hexcheck:ignore suspicious-business-logic-in-adapter port method name only
72-func (s *Store) Update(ctx context.Context, repo domain.Repo) error {
73- val, err := encodeRepo(repo)
74- if err != nil {
75- return err
76- }
77- key := repoKey(repo.Name)
78- item, ok := s.db.Get(ctx, key)
79- if !ok {
80- return domain.ErrNotFound
81- }
82- var b kv.Batch
83- b.RequireVersion(key, item.Version)
84- b.Put(key, val)
85- if _, err := s.db.Apply(ctx, &b); err != nil {
86- if errors.Is(err, kv.ErrConflict) {
87- return domain.ErrConflict
71+//hexcheck:ignore suspicious-business-logic-in-adapter optimistic concurrency is a storage concern
72+func (s *Store) Mutate(ctx context.Context, name domain.RepoName, fn func(*domain.Repo) (bool, error)) (domain.Repo, error) {
73+ key := repoKey(name)
74+ for range repoMutationTries {
75+ if err := ctx.Err(); err != nil {
76+ return domain.Repo{}, err
77+ }
78+ item, ok := s.db.Get(ctx, key)
79+ if !ok {
80+ return domain.Repo{}, domain.ErrNotFound
8881 }
89- return fmt.Errorf("update repository %q: %w", repo.Name, err)
82+ repo, err := decodeRepo(item.Value)
83+ if err != nil {
84+ return domain.Repo{}, err
85+ }
86+ changed, err := fn(&repo)
87+ if err != nil {
88+ return domain.Repo{}, err
89+ }
90+ if repo.Name != name {
91+ return domain.Repo{}, fmt.Errorf("mutate repository name: %w", domain.ErrInvalidName)
92+ }
93+ if !changed {
94+ return decodeRepo(item.Value)
95+ }
96+ val, err := encodeRepo(repo)
97+ if err != nil {
98+ return domain.Repo{}, err
99+ }
100+ var b kv.Batch
101+ b.RequireVersion(key, item.Version)
102+ b.Put(key, val)
103+ if _, err := s.db.Apply(ctx, &b); err != nil {
104+ if errors.Is(err, kv.ErrConflict) {
105+ continue
106+ }
107+ return domain.Repo{}, fmt.Errorf("mutate repository %q: %w", name, err)
108+ }
109+ return decodeRepo(val)
90110 }
91- return nil
111+ return domain.Repo{}, domain.ErrConflict
92112 }
93113
94114 // guardRepoKey makes the batch depend on the repository: it requires the
@@ -208,6 +228,7 @@ func encodeRepo(r domain.Repo) ([]byte, error) {
208228 Visibility: vis,
209229 CreatedAt: encodeTime(r.CreatedAt),
210230 PushedAt: encodeTime(r.PushedAt),
231+ ActiveAt: encodeTime(r.ActiveAt),
211232 Settings: pbSettings,
212233 }
213234 return mustMarshal(pb), nil
@@ -229,6 +250,7 @@ func decodeRepo(val []byte) (domain.Repo, error) {
229250 Visibility: decodeVisibility(pb.GetVisibility()),
230251 CreatedAt: decodeTime(pb.GetCreatedAt()),
231252 PushedAt: decodeTime(pb.GetPushedAt()),
253+ ActiveAt: decodeTime(pb.GetActiveAt()),
232254 Settings: decodeSettings(pb.GetSettings()),
233255 }, nil
234256 }
@@ -64,31 +64,51 @@ func (s *Store) List(ctx context.Context) ([]domain.Repo, error) {
64 return repos, nil64 return repos, nil
65 }65 }
66 66
67-// Update replaces the stored repository. It returns domain.ErrNotFound if67+const repoMutationTries = 8
68-// there is none and domain.ErrConflict if another write changed it since it68+
69-// was read.69+// Mutate applies a retry-safe mutation under the version it read.
70 //70 //
71-//hexcheck:ignore suspicious-business-logic-in-adapter port method name only71+//hexcheck:ignore suspicious-business-logic-in-adapter optimistic concurrency is a storage concern
72-func (s *Store) Update(ctx context.Context, repo domain.Repo) error {72+func (s *Store) Mutate(ctx context.Context, name domain.RepoName, fn func(*domain.Repo) (bool, error)) (domain.Repo, error) {
73- val, err := encodeRepo(repo)73+ key := repoKey(name)
74- if err != nil {74+ for range repoMutationTries {
75- return err75+ if err := ctx.Err(); err != nil {
76- }76+ return domain.Repo{}, err
77- key := repoKey(repo.Name)77+ }
78- item, ok := s.db.Get(ctx, key)78+ item, ok := s.db.Get(ctx, key)
79- if !ok {79+ if !ok {
80- return domain.ErrNotFound80+ return domain.Repo{}, domain.ErrNotFound
81- }
82- var b kv.Batch
83- b.RequireVersion(key, item.Version)
84- b.Put(key, val)
85- if _, err := s.db.Apply(ctx, &b); err != nil {
86- if errors.Is(err, kv.ErrConflict) {
87- return domain.ErrConflict
88 }81 }
89- return fmt.Errorf("update repository %q: %w", repo.Name, err)82+ repo, err := decodeRepo(item.Value)
83+ if err != nil {
84+ return domain.Repo{}, err
85+ }
86+ changed, err := fn(&repo)
87+ if err != nil {
88+ return domain.Repo{}, err
89+ }
90+ if repo.Name != name {
91+ return domain.Repo{}, fmt.Errorf("mutate repository name: %w", domain.ErrInvalidName)
92+ }
93+ if !changed {
94+ return decodeRepo(item.Value)
95+ }
96+ val, err := encodeRepo(repo)
97+ if err != nil {
98+ return domain.Repo{}, err
99+ }
100+ var b kv.Batch
101+ b.RequireVersion(key, item.Version)
102+ b.Put(key, val)
103+ if _, err := s.db.Apply(ctx, &b); err != nil {
104+ if errors.Is(err, kv.ErrConflict) {
105+ continue
106+ }
107+ return domain.Repo{}, fmt.Errorf("mutate repository %q: %w", name, err)
108+ }
109+ return decodeRepo(val)
90 }110 }
91- return nil111+ return domain.Repo{}, domain.ErrConflict
92 }112 }
93 113
94 // guardRepoKey makes the batch depend on the repository: it requires the114 // guardRepoKey makes the batch depend on the repository: it requires the
@@ -208,6 +228,7 @@ func encodeRepo(r domain.Repo) ([]byte, error) {
208 Visibility: vis,228 Visibility: vis,
209 CreatedAt: encodeTime(r.CreatedAt),229 CreatedAt: encodeTime(r.CreatedAt),
210 PushedAt: encodeTime(r.PushedAt),230 PushedAt: encodeTime(r.PushedAt),
231+ ActiveAt: encodeTime(r.ActiveAt),
211 Settings: pbSettings,232 Settings: pbSettings,
212 }233 }
213 return mustMarshal(pb), nil234 return mustMarshal(pb), nil
@@ -229,6 +250,7 @@ func decodeRepo(val []byte) (domain.Repo, error) {
229 Visibility: decodeVisibility(pb.GetVisibility()),250 Visibility: decodeVisibility(pb.GetVisibility()),
230 CreatedAt: decodeTime(pb.GetCreatedAt()),251 CreatedAt: decodeTime(pb.GetCreatedAt()),
231 PushedAt: decodeTime(pb.GetPushedAt()),252 PushedAt: decodeTime(pb.GetPushedAt()),
253+ ActiveAt: decodeTime(pb.GetActiveAt()),
232 Settings: decodeSettings(pb.GetSettings()),254 Settings: decodeSettings(pb.GetSettings()),
233 }, nil255 }, nil
234 }256 }
A internal/adapters/kvstore/repos_mutate_test.go +87 -0
@@ -0,0 +1,87 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore_test
4+
5+import (
6+ "errors"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+func TestRepoMutateInterleavedWrites(t *testing.T) {
16+ ctx := t.Context()
17+ s := openStore(t, t.TempDir())
18+ n := name(t, "proj")
19+ original := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: created}
20+ require.NoError(t, s.Create(ctx, original))
21+ later := created.Add(2 * time.Hour)
22+ calls := 0
23+ got, err := s.Mutate(ctx, n, func(r *domain.Repo) (bool, error) {
24+ calls++
25+ // Test-only interleaving: inject a committed write after the outer read.
26+ if calls == 1 {
27+ _, err := s.Mutate(ctx, n, func(current *domain.Repo) (bool, error) {
28+ current.Visibility = domain.VisibilityPrivate
29+ current.ActiveAt = later
30+ current.PushedAt = later
31+ return true, nil
32+ })
33+ require.NoError(t, err)
34+ }
35+ r.Description = "updated"
36+ at := created.Add(time.Hour)
37+ if at.After(r.ActiveAt) {
38+ r.ActiveAt = at
39+ }
40+ if at.After(r.PushedAt) {
41+ r.PushedAt = at
42+ }
43+ return true, nil
44+ })
45+ require.NoError(t, err)
46+ require.Equal(t, 2, calls)
47+ require.Equal(t, domain.VisibilityPrivate, got.Visibility)
48+ require.Equal(t, "updated", got.Description)
49+ require.Equal(t, later, got.ActiveAt)
50+ require.Equal(t, later, got.PushedAt)
51+ stored, err := s.Get(ctx, n)
52+ require.NoError(t, err)
53+ require.Equal(t, got, stored)
54+}
55+
56+func TestRepoMutateContracts(t *testing.T) {
57+ ctx := t.Context()
58+ s := openStore(t, t.TempDir())
59+ n := name(t, "proj")
60+ require.NoError(t, s.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: created}))
61+ original, err := s.Get(ctx, n)
62+ require.NoError(t, err)
63+ boom := errors.New("callback failed")
64+ _, err = s.Mutate(ctx, n, func(r *domain.Repo) (bool, error) { r.Description = "discard"; return true, boom })
65+ require.ErrorIs(t, err, boom)
66+ got, err := s.Mutate(ctx, n, func(r *domain.Repo) (bool, error) { r.Description = "discard"; return false, nil })
67+ require.NoError(t, err)
68+ require.Equal(t, original, got)
69+ for _, changed := range []bool{false, true} {
70+ _, err = s.Mutate(ctx, n, func(r *domain.Repo) (bool, error) { r.Name = name(t, "other"); return changed, nil })
71+ require.ErrorIs(t, err, domain.ErrInvalidName)
72+ }
73+ got, err = s.Get(ctx, n)
74+ require.NoError(t, err)
75+ require.Equal(t, original, got)
76+ calls := 0
77+ _, err = s.Mutate(ctx, n, func(r *domain.Repo) (bool, error) {
78+ calls++
79+ _, err := s.Mutate(ctx, n, func(current *domain.Repo) (bool, error) { current.Description = "concurrent"; return true, nil })
80+ require.NoError(t, err)
81+ return true, nil
82+ })
83+ require.ErrorIs(t, err, domain.ErrConflict)
84+ require.Equal(t, 8, calls)
85+ _, err = s.Mutate(ctx, n, func(r *domain.Repo) (bool, error) { require.NoError(t, s.Delete(ctx, n)); return true, nil })
86+ require.ErrorIs(t, err, domain.ErrNotFound)
87+}
@@ -0,0 +1,87 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore_test
4+
5+import (
6+ "errors"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+func TestRepoMutateInterleavedWrites(t *testing.T) {
16+ ctx := t.Context()
17+ s := openStore(t, t.TempDir())
18+ n := name(t, "proj")
19+ original := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: created}
20+ require.NoError(t, s.Create(ctx, original))
21+ later := created.Add(2 * time.Hour)
22+ calls := 0
23+ got, err := s.Mutate(ctx, n, func(r *domain.Repo) (bool, error) {
24+ calls++
25+ // Test-only interleaving: inject a committed write after the outer read.
26+ if calls == 1 {
27+ _, err := s.Mutate(ctx, n, func(current *domain.Repo) (bool, error) {
28+ current.Visibility = domain.VisibilityPrivate
29+ current.ActiveAt = later
30+ current.PushedAt = later
31+ return true, nil
32+ })
33+ require.NoError(t, err)
34+ }
35+ r.Description = "updated"
36+ at := created.Add(time.Hour)
37+ if at.After(r.ActiveAt) {
38+ r.ActiveAt = at
39+ }
40+ if at.After(r.PushedAt) {
41+ r.PushedAt = at
42+ }
43+ return true, nil
44+ })
45+ require.NoError(t, err)
46+ require.Equal(t, 2, calls)
47+ require.Equal(t, domain.VisibilityPrivate, got.Visibility)
48+ require.Equal(t, "updated", got.Description)
49+ require.Equal(t, later, got.ActiveAt)
50+ require.Equal(t, later, got.PushedAt)
51+ stored, err := s.Get(ctx, n)
52+ require.NoError(t, err)
53+ require.Equal(t, got, stored)
54+}
55+
56+func TestRepoMutateContracts(t *testing.T) {
57+ ctx := t.Context()
58+ s := openStore(t, t.TempDir())
59+ n := name(t, "proj")
60+ require.NoError(t, s.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: created}))
61+ original, err := s.Get(ctx, n)
62+ require.NoError(t, err)
63+ boom := errors.New("callback failed")
64+ _, err = s.Mutate(ctx, n, func(r *domain.Repo) (bool, error) { r.Description = "discard"; return true, boom })
65+ require.ErrorIs(t, err, boom)
66+ got, err := s.Mutate(ctx, n, func(r *domain.Repo) (bool, error) { r.Description = "discard"; return false, nil })
67+ require.NoError(t, err)
68+ require.Equal(t, original, got)
69+ for _, changed := range []bool{false, true} {
70+ _, err = s.Mutate(ctx, n, func(r *domain.Repo) (bool, error) { r.Name = name(t, "other"); return changed, nil })
71+ require.ErrorIs(t, err, domain.ErrInvalidName)
72+ }
73+ got, err = s.Get(ctx, n)
74+ require.NoError(t, err)
75+ require.Equal(t, original, got)
76+ calls := 0
77+ _, err = s.Mutate(ctx, n, func(r *domain.Repo) (bool, error) {
78+ calls++
79+ _, err := s.Mutate(ctx, n, func(current *domain.Repo) (bool, error) { current.Description = "concurrent"; return true, nil })
80+ require.NoError(t, err)
81+ return true, nil
82+ })
83+ require.ErrorIs(t, err, domain.ErrConflict)
84+ require.Equal(t, 8, calls)
85+ _, err = s.Mutate(ctx, n, func(r *domain.Repo) (bool, error) { require.NoError(t, s.Delete(ctx, n)); return true, nil })
86+ require.ErrorIs(t, err, domain.ErrNotFound)
87+}
M internal/adapters/kvstore/store_test.go +8 -4
@@ -43,7 +43,7 @@ func TestCreateGetListUpdateDelete(t *testing.T) {
4343 s := openStore(t, t.TempDir())
4444
4545 b := domain.Repo{Name: name(t, "b"), Description: "second", Topics: []string{"cli", "go"}, Visibility: domain.VisibilityPublic, CreatedAt: created, Settings: domain.DefaultRepoSettings()}
46- a := domain.Repo{Name: name(t, "a"), Visibility: domain.VisibilityPrivate, CreatedAt: created, PushedAt: created.Add(time.Hour), Settings: domain.DefaultRepoSettings()}
46+ a := domain.Repo{Name: name(t, "a"), Visibility: domain.VisibilityPrivate, CreatedAt: created, PushedAt: created.Add(time.Hour), ActiveAt: created.Add(2 * time.Hour), Settings: domain.DefaultRepoSettings()}
4747 require.NoError(t, s.Create(ctx, b))
4848 require.NoError(t, s.Create(ctx, a))
4949
@@ -54,6 +54,7 @@ func TestCreateGetListUpdateDelete(t *testing.T) {
5454 require.NoError(t, err)
5555 assert.Equal(t, a, got)
5656 assert.True(t, got.PushedAt.Equal(a.PushedAt))
57+ assert.True(t, got.ActiveAt.Equal(a.ActiveAt))
5758
5859 list, err := s.List(ctx)
5960 require.NoError(t, err)
@@ -65,13 +66,15 @@ func TestCreateGetListUpdateDelete(t *testing.T) {
6566 b.Description = "changed"
6667 b.Topics = []string{"forge"}
6768 b.Visibility = domain.VisibilityPrivate
68- require.NoError(t, s.Update(ctx, b))
69+ _, err = s.Mutate(ctx, b.Name, func(r *domain.Repo) (bool, error) { *r = b; return true, nil })
70+ require.NoError(t, err)
6971 got, err = s.Get(ctx, b.Name)
7072 require.NoError(t, err)
7173 assert.Equal(t, b, got)
7274
7375 b.Topics = nil
74- require.NoError(t, s.Update(ctx, b))
76+ _, err = s.Mutate(ctx, b.Name, func(r *domain.Repo) (bool, error) { *r = b; return true, nil })
77+ require.NoError(t, err)
7578 got, err = s.Get(ctx, b.Name)
7679 require.NoError(t, err)
7780 assert.Nil(t, got.Topics, "cleared topics read back as nil")
@@ -80,7 +83,8 @@ func TestCreateGetListUpdateDelete(t *testing.T) {
8083 _, err = s.Get(ctx, b.Name)
8184 assert.ErrorIs(t, err, domain.ErrNotFound)
8285 require.NoError(t, s.Delete(ctx, b.Name), "deleting a missing repository is not an error")
83- assert.ErrorIs(t, s.Update(ctx, b), domain.ErrNotFound)
86+ _, err = s.Mutate(ctx, b.Name, func(r *domain.Repo) (bool, error) { return true, nil })
87+ assert.ErrorIs(t, err, domain.ErrNotFound)
8488
8589 _, err = s.Get(ctx, name(t, "never"))
8690 assert.ErrorIs(t, err, domain.ErrNotFound)
@@ -43,7 +43,7 @@ func TestCreateGetListUpdateDelete(t *testing.T) {
43 s := openStore(t, t.TempDir())43 s := openStore(t, t.TempDir())
44 44
45 b := domain.Repo{Name: name(t, "b"), Description: "second", Topics: []string{"cli", "go"}, Visibility: domain.VisibilityPublic, CreatedAt: created, Settings: domain.DefaultRepoSettings()}45 b := domain.Repo{Name: name(t, "b"), Description: "second", Topics: []string{"cli", "go"}, Visibility: domain.VisibilityPublic, CreatedAt: created, Settings: domain.DefaultRepoSettings()}
46- a := domain.Repo{Name: name(t, "a"), Visibility: domain.VisibilityPrivate, CreatedAt: created, PushedAt: created.Add(time.Hour), Settings: domain.DefaultRepoSettings()}46+ a := domain.Repo{Name: name(t, "a"), Visibility: domain.VisibilityPrivate, CreatedAt: created, PushedAt: created.Add(time.Hour), ActiveAt: created.Add(2 * time.Hour), Settings: domain.DefaultRepoSettings()}
47 require.NoError(t, s.Create(ctx, b))47 require.NoError(t, s.Create(ctx, b))
48 require.NoError(t, s.Create(ctx, a))48 require.NoError(t, s.Create(ctx, a))
49 49
@@ -54,6 +54,7 @@ func TestCreateGetListUpdateDelete(t *testing.T) {
54 require.NoError(t, err)54 require.NoError(t, err)
55 assert.Equal(t, a, got)55 assert.Equal(t, a, got)
56 assert.True(t, got.PushedAt.Equal(a.PushedAt))56 assert.True(t, got.PushedAt.Equal(a.PushedAt))
57+ assert.True(t, got.ActiveAt.Equal(a.ActiveAt))
57 58
58 list, err := s.List(ctx)59 list, err := s.List(ctx)
59 require.NoError(t, err)60 require.NoError(t, err)
@@ -65,13 +66,15 @@ func TestCreateGetListUpdateDelete(t *testing.T) {
65 b.Description = "changed"66 b.Description = "changed"
66 b.Topics = []string{"forge"}67 b.Topics = []string{"forge"}
67 b.Visibility = domain.VisibilityPrivate68 b.Visibility = domain.VisibilityPrivate
68- require.NoError(t, s.Update(ctx, b))69+ _, err = s.Mutate(ctx, b.Name, func(r *domain.Repo) (bool, error) { *r = b; return true, nil })
70+ require.NoError(t, err)
69 got, err = s.Get(ctx, b.Name)71 got, err = s.Get(ctx, b.Name)
70 require.NoError(t, err)72 require.NoError(t, err)
71 assert.Equal(t, b, got)73 assert.Equal(t, b, got)
72 74
73 b.Topics = nil75 b.Topics = nil
74- require.NoError(t, s.Update(ctx, b))76+ _, err = s.Mutate(ctx, b.Name, func(r *domain.Repo) (bool, error) { *r = b; return true, nil })
77+ require.NoError(t, err)
75 got, err = s.Get(ctx, b.Name)78 got, err = s.Get(ctx, b.Name)
76 require.NoError(t, err)79 require.NoError(t, err)
77 assert.Nil(t, got.Topics, "cleared topics read back as nil")80 assert.Nil(t, got.Topics, "cleared topics read back as nil")
@@ -80,7 +83,8 @@ func TestCreateGetListUpdateDelete(t *testing.T) {
80 _, err = s.Get(ctx, b.Name)83 _, err = s.Get(ctx, b.Name)
81 assert.ErrorIs(t, err, domain.ErrNotFound)84 assert.ErrorIs(t, err, domain.ErrNotFound)
82 require.NoError(t, s.Delete(ctx, b.Name), "deleting a missing repository is not an error")85 require.NoError(t, s.Delete(ctx, b.Name), "deleting a missing repository is not an error")
83- assert.ErrorIs(t, s.Update(ctx, b), domain.ErrNotFound)86+ _, err = s.Mutate(ctx, b.Name, func(r *domain.Repo) (bool, error) { return true, nil })
87+ assert.ErrorIs(t, err, domain.ErrNotFound)
84 88
85 _, err = s.Get(ctx, name(t, "never"))89 _, err = s.Get(ctx, name(t, "never"))
86 assert.ErrorIs(t, err, domain.ErrNotFound)90 assert.ErrorIs(t, err, domain.ErrNotFound)
M internal/adapters/web/components.templ +2 -2
@@ -37,7 +37,7 @@ templ CloneBlock(c Clone) {
3737 // right its figures and CI state over its newest commits. Its id is the
3838 // target of SSE swaps; the name link comes first, `enter` follows it.
3939 templ HomeRowView(r HomeRow) {
40- <li id={ r.ID } class="repo" data-cursor?={ r.Cursor }>
40+ <li id={ r.ID } class="repo" data-cursor?={ r.Cursor } data-activity?={ r.Fresh }>
4141 <div class="repo-main">
4242 <h2 class="repo-name">
4343 <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>
@@ -45,7 +45,7 @@ templ HomeRowView(r HomeRow) {
4545 <span class="pill">private</span>
4646 }
4747 if r.Fresh {
48- <span class="pill new">new push</span>
48+ <span class="pill new">new activity</span>
4949 }
5050 </h2>
5151 if r.Desc != "" {
@@ -37,7 +37,7 @@ templ CloneBlock(c Clone) {
37 // right its figures and CI state over its newest commits. Its id is the37 // right its figures and CI state over its newest commits. Its id is the
38 // target of SSE swaps; the name link comes first, `enter` follows it.38 // target of SSE swaps; the name link comes first, `enter` follows it.
39 templ HomeRowView(r HomeRow) {39 templ HomeRowView(r HomeRow) {
40- <li id={ r.ID } class="repo" data-cursor?={ r.Cursor }>40+ <li id={ r.ID } class="repo" data-cursor?={ r.Cursor } data-activity?={ r.Fresh }>
41 <div class="repo-main">41 <div class="repo-main">
42 <h2 class="repo-name">42 <h2 class="repo-name">
43 <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>43 <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>
@@ -45,7 +45,7 @@ templ HomeRowView(r HomeRow) {
45 <span class="pill">private</span>45 <span class="pill">private</span>
46 }46 }
47 if r.Fresh {47 if r.Fresh {
48- <span class="pill new">new push</span>48+ <span class="pill new">new activity</span>
49 }49 }
50 </h2>50 </h2>
51 if r.Desc != "" {51 if r.Desc != "" {
M internal/adapters/web/components_templ.go +119 -113 generated file: collapsed
M internal/adapters/web/diff.templ +22 -4
@@ -72,9 +72,7 @@ templ Commit(l Layout, p CommitPage) {
7272 <div class="diff-tools" role="group" aria-label="Diff view">
7373 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
7474 <span class="grow"></span>
75- @viewLink(p.DiffView, "unified", "Unified", "u", p.UnifiedHref)
76- @viewLink(p.DiffView, "split", "Split", "d", p.SplitHref)
77- @collapseButton()
75+ @diffViewMenu(p.DiffView)
7876 </div>
7977 if p.Invisible {
8078 <p class="notice">
@@ -112,6 +110,26 @@ templ Commit(l Layout, p CommitPage) {
112110 }
113111 }
114112
113+// diffViewMenu is the layout of the diff (unified, the default, or split) and
114+// the fold of its files, in one menu: a <details> that works without scripts;
115+// u, d and x still act without opening it. Its button names the layout shown.
116+templ diffViewMenu(p DiffView) {
117+ <details class="menu vmenu">
118+ <summary class="btn" aria-label="Diff view">
119+ if p.Both {
120+ <span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">{ viewLabel(p.View) }</span>
121+ } else {
122+ { viewLabel(p.View) }
123+ }
124+ </summary>
125+ <div class="menu-pop" role="group" aria-label="Diff view">
126+ @viewLink(p, "unified", "Unified", "u", p.UnifiedHref)
127+ @viewLink(p, "split", "Split", "d", p.SplitHref)
128+ @collapseButton()
129+ </div>
130+ </details>
131+}
132+
115133 // viewLink is a button of the diff tools. It is a link, so that the split
116134 // layout works without JavaScript; with it, and when both layouts are in the
117135 // page, a click only flips the layout.
@@ -203,7 +221,7 @@ templ DiffFileView(f DiffFile, p DiffView) {
203221 <details
204222 class={ "dfile", "lnw-" + itoa(f.Digits) }
205223 id={ f.ID }
206- data-file
224+ data-file={ f.Key }
207225 if f.Open {
208226 open
209227 }
@@ -72,9 +72,7 @@ templ Commit(l Layout, p CommitPage) {
72 <div class="diff-tools" role="group" aria-label="Diff view">72 <div class="diff-tools" role="group" aria-label="Diff view">
73 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>73 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
74 <span class="grow"></span>74 <span class="grow"></span>
75- @viewLink(p.DiffView, "unified", "Unified", "u", p.UnifiedHref)75+ @diffViewMenu(p.DiffView)
76- @viewLink(p.DiffView, "split", "Split", "d", p.SplitHref)
77- @collapseButton()
78 </div>76 </div>
79 if p.Invisible {77 if p.Invisible {
80 <p class="notice">78 <p class="notice">
@@ -112,6 +110,26 @@ templ Commit(l Layout, p CommitPage) {
112 }110 }
113 }111 }
114 112
113+// diffViewMenu is the layout of the diff (unified, the default, or split) and
114+// the fold of its files, in one menu: a <details> that works without scripts;
115+// u, d and x still act without opening it. Its button names the layout shown.
116+templ diffViewMenu(p DiffView) {
117+ <details class="menu vmenu">
118+ <summary class="btn" aria-label="Diff view">
119+ if p.Both {
120+ <span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">{ viewLabel(p.View) }</span>
121+ } else {
122+ { viewLabel(p.View) }
123+ }
124+ </summary>
125+ <div class="menu-pop" role="group" aria-label="Diff view">
126+ @viewLink(p, "unified", "Unified", "u", p.UnifiedHref)
127+ @viewLink(p, "split", "Split", "d", p.SplitHref)
128+ @collapseButton()
129+ </div>
130+ </details>
131+}
132+
115 // viewLink is a button of the diff tools. It is a link, so that the split133 // viewLink is a button of the diff tools. It is a link, so that the split
116 // layout works without JavaScript; with it, and when both layouts are in the134 // layout works without JavaScript; with it, and when both layouts are in the
117 // page, a click only flips the layout.135 // page, a click only flips the layout.
@@ -203,7 +221,7 @@ templ DiffFileView(f DiffFile, p DiffView) {
203 <details221 <details
204 class={ "dfile", "lnw-" + itoa(f.Digits) }222 class={ "dfile", "lnw-" + itoa(f.Digits) }
205 id={ f.ID }223 id={ f.ID }
206- data-file224+ data-file={ f.Key }
207 if f.Open {225 if f.Open {
208 open226 open
209 }227 }
M internal/adapters/web/diff_templ.go +523 -437 generated file: collapsed
M internal/adapters/web/events.go +2 -0
@@ -50,6 +50,8 @@ import (
5050 //
5151 // home-list data: <li id="repo-<name>">…</li>, or <li id="repo-<name>" data-gone></li>
5252 // for a repository that is deleted or no longer visible
53+// Activity rows carry data-activity: push, MR lifecycle/comment,
54+// or issue lifecycle/comment (never pending moderation or CI).
5355 // repo-last-push data: <dd id="last-push-<name>">…</dd>
5456 // refs-list data: <tbody id="ref-rows">…</tbody>, and again with
5557 // <dl id="refs-kv">…</dl>, the facts of the rail
@@ -50,6 +50,8 @@ import (
50 //50 //
51 // home-list data: <li id="repo-<name>">…</li>, or <li id="repo-<name>" data-gone></li>51 // home-list data: <li id="repo-<name>">…</li>, or <li id="repo-<name>" data-gone></li>
52 // for a repository that is deleted or no longer visible52 // for a repository that is deleted or no longer visible
53+// Activity rows carry data-activity: push, MR lifecycle/comment,
54+// or issue lifecycle/comment (never pending moderation or CI).
53 // repo-last-push data: <dd id="last-push-<name>">…</dd>55 // repo-last-push data: <dd id="last-push-<name>">…</dd>
54 // refs-list data: <tbody id="ref-rows">…</tbody>, and again with56 // refs-list data: <tbody id="ref-rows">…</tbody>, and again with
55 // <dl id="refs-kv">…</dl>, the facts of the rail57 // <dl id="refs-kv">…</dl>, the facts of the rail
M internal/adapters/web/format.go +8 -0
@@ -61,6 +61,14 @@ func grouped(n int) string {
6161 return string(b)
6262 }
6363
64+// viewLabel names a diff layout on the button of the view menu.
65+func viewLabel(view string) string {
66+ if view == "split" {
67+ return "Split"
68+ }
69+ return "Unified"
70+}
71+
6472 func plural(n int, one, many string) string {
6573 if n == 1 {
6674 return "1 " + one
@@ -61,6 +61,14 @@ func grouped(n int) string {
61 return string(b)61 return string(b)
62 }62 }
63 63
64+// viewLabel names a diff layout on the button of the view menu.
65+func viewLabel(view string) string {
66+ if view == "split" {
67+ return "Split"
68+ }
69+ return "Unified"
70+}
71+
64 func plural(n int, one, many string) string {72 func plural(n int, one, many string) string {
65 if n == 1 {73 if n == 1 {
66 return "1 " + one74 return "1 " + one
M internal/adapters/web/fragments.go +18 -0
@@ -112,6 +112,24 @@ func (l *liveStream) home(ctx context.Context, we ports.WatchEvent) ([]fragment,
112112 name = ev.Repo.String()
113113 case domain.PushEvent:
114114 name, fresh = ev.Repo.String(), true
115+ case domain.MergeRequestOpened:
116+ name, fresh = ev.MergeRequest.Repo.String(), true
117+ case domain.MergeRequestMerged:
118+ name, fresh = ev.MergeRequest.Repo.String(), true
119+ case domain.MergeRequestClosed:
120+ name, fresh = ev.MergeRequest.Repo.String(), true
121+ case domain.MergeRequestReady:
122+ name, fresh = ev.MergeRequest.Repo.String(), true
123+ case domain.CommentAdded:
124+ name, fresh = ev.Repo.String(), true
125+ case domain.IssueOpened:
126+ name, fresh = ev.Issue.Repo.String(), true
127+ case domain.IssueClosed:
128+ name, fresh = ev.Issue.Repo.String(), true
129+ case domain.IssueReopened:
130+ name, fresh = ev.Issue.Repo.String(), true
131+ case domain.IssueCommented:
132+ name, fresh = ev.Repo.String(), true
115133 case domain.RunUpdated:
116134 name = ev.Run.Repo.String() // the CI state of the row
117135 case domain.JobUpdated:
@@ -112,6 +112,24 @@ func (l *liveStream) home(ctx context.Context, we ports.WatchEvent) ([]fragment,
112 name = ev.Repo.String()112 name = ev.Repo.String()
113 case domain.PushEvent:113 case domain.PushEvent:
114 name, fresh = ev.Repo.String(), true114 name, fresh = ev.Repo.String(), true
115+ case domain.MergeRequestOpened:
116+ name, fresh = ev.MergeRequest.Repo.String(), true
117+ case domain.MergeRequestMerged:
118+ name, fresh = ev.MergeRequest.Repo.String(), true
119+ case domain.MergeRequestClosed:
120+ name, fresh = ev.MergeRequest.Repo.String(), true
121+ case domain.MergeRequestReady:
122+ name, fresh = ev.MergeRequest.Repo.String(), true
123+ case domain.CommentAdded:
124+ name, fresh = ev.Repo.String(), true
125+ case domain.IssueOpened:
126+ name, fresh = ev.Issue.Repo.String(), true
127+ case domain.IssueClosed:
128+ name, fresh = ev.Issue.Repo.String(), true
129+ case domain.IssueReopened:
130+ name, fresh = ev.Issue.Repo.String(), true
131+ case domain.IssueCommented:
132+ name, fresh = ev.Repo.String(), true
115 case domain.RunUpdated:133 case domain.RunUpdated:
116 name = ev.Run.Repo.String() // the CI state of the row134 name = ev.Run.Repo.String() // the CI state of the row
117 case domain.JobUpdated:135 case domain.JobUpdated:
M internal/adapters/web/home.go +24 -14
@@ -56,12 +56,12 @@ func (s *Server) listRepos(ctx context.Context) ([]ports.RepoView, error) {
5656 // homeSorts are the orders the home list offers, in order. The query also
5757 // accepts "desc" and "branch", which the page does not link to.
5858 var homeSorts = []struct{ key, label string }{
59- {"push", "last push"},
59+ {"activity", "last activity"},
6060 {"name", "name"},
6161 }
6262
6363 // homeKeys are the keys of ?sort= that sortRepos knows.
64-var homeKeys = []string{"push", "name", "desc", "branch"}
64+var homeKeys = []string{"activity", "name", "desc", "branch"}
6565
6666 // sortRepos orders repos by the column key; ties keep the order by name.
6767 func sortRepos(repos []ports.RepoView, key string, desc bool) {
@@ -71,8 +71,8 @@ func sortRepos(repos []ports.RepoView, key string, desc bool) {
7171 return strings.ToLower(a.Description) < strings.ToLower(b.Description)
7272 case "branch":
7373 return a.DefaultBranch < b.DefaultBranch
74- case "push":
75- return a.PushedAt.Before(b.PushedAt)
74+ case "activity":
75+ return repoActiveAt(a).Before(repoActiveAt(b))
7676 }
7777 return a.Name.String() < b.Name.String()
7878 }
@@ -84,17 +84,17 @@ func sortRepos(repos []ports.RepoView, key string, desc bool) {
8484 })
8585 }
8686
87-// home lists the repositories: /?sort=<col>&dir=asc|desc, newest push first
87+// home lists the repositories: /?sort=<col>&dir=asc|desc, newest activity first
8888 // by default.
8989 func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, int, error) {
9090 repos, err := s.listRepos(ctx)
9191 if err != nil {
9292 return nil, 0, err
9393 }
94- key, desc := "push", true
94+ key, desc := "activity", true
9595 for _, k := range homeKeys {
9696 if r.URL.Query().Get("sort") == k {
97- key, desc = k, k == "push"
97+ key, desc = k, k == "activity"
9898 }
9999 }
100100 if d := r.URL.Query().Get("dir"); d == "asc" || d == "desc" {
@@ -103,11 +103,11 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in
103103 sortRepos(repos, key, desc)
104104
105105 now := s.cfg.Now()
106- p := HomePage{}
106+ p := HomePage{Sort: key, Desc: desc}
107107 for _, c := range homeSorts {
108108 sl := SortLink{Label: c.label}
109109 next := "asc"
110- if c.key == "push" {
110+ if c.key == "activity" {
111111 next = "desc"
112112 }
113113 if c.key == key {
@@ -121,11 +121,10 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in
121121 p.Sorts = append(p.Sorts, sl)
122122 }
123123 p.Rows = s.homeRows(ctx, repos, now)
124+ l := s.base()
124125 if n, show := s.inboxCount(ctx); show {
125- p.Inbox = &InboxButton{Href: inboxPath, Count: n}
126+ l.Inbox = &InboxButton{Href: inboxPath, Count: n}
126127 }
127-
128- l := s.base()
129128 l.Title = s.instance
130129 l.Live, l.LiveURL = true, liveURL("home", "")
131130 l.Cmd = append([]CmdItem{{Kind: "go", Label: "home", Href: "/"}}, repoCmds(repos, "")...)
@@ -268,8 +267,8 @@ func homeRow(r ports.RepoView, now time.Time, cursor, signed bool) HomeRow {
268267 Desc: r.Description, Topics: r.Topics, Cursor: cursor,
269268 Private: signed && r.Visibility == domain.VisibilityPrivate,
270269 }
271- if !r.PushedAt.IsZero() {
272- row.Active, row.ActiveAt = ago(now, r.PushedAt), rfc3339(r.PushedAt)
270+ if at := repoActiveAt(r); !at.IsZero() {
271+ row.Active, row.ActiveAt = ago(now, at), rfc3339(at)
273272 }
274273 return row
275274 }
@@ -285,3 +284,14 @@ func repoCmds(repos []ports.RepoView, except string) []CmdItem {
285284 }
286285 return out
287286 }
287+
288+// repoActiveAt falls back for repositories without recorded activity.
289+func repoActiveAt(r ports.RepoView) time.Time {
290+ if !r.ActiveAt.IsZero() {
291+ return r.ActiveAt
292+ }
293+ if r.PushedAt.After(r.CreatedAt) {
294+ return r.PushedAt
295+ }
296+ return r.CreatedAt
297+}
@@ -56,12 +56,12 @@ func (s *Server) listRepos(ctx context.Context) ([]ports.RepoView, error) {
56 // homeSorts are the orders the home list offers, in order. The query also56 // homeSorts are the orders the home list offers, in order. The query also
57 // accepts "desc" and "branch", which the page does not link to.57 // accepts "desc" and "branch", which the page does not link to.
58 var homeSorts = []struct{ key, label string }{58 var homeSorts = []struct{ key, label string }{
59- {"push", "last push"},59+ {"activity", "last activity"},
60 {"name", "name"},60 {"name", "name"},
61 }61 }
62 62
63 // homeKeys are the keys of ?sort= that sortRepos knows.63 // homeKeys are the keys of ?sort= that sortRepos knows.
64-var homeKeys = []string{"push", "name", "desc", "branch"}64+var homeKeys = []string{"activity", "name", "desc", "branch"}
65 65
66 // sortRepos orders repos by the column key; ties keep the order by name.66 // sortRepos orders repos by the column key; ties keep the order by name.
67 func sortRepos(repos []ports.RepoView, key string, desc bool) {67 func sortRepos(repos []ports.RepoView, key string, desc bool) {
@@ -71,8 +71,8 @@ func sortRepos(repos []ports.RepoView, key string, desc bool) {
71 return strings.ToLower(a.Description) < strings.ToLower(b.Description)71 return strings.ToLower(a.Description) < strings.ToLower(b.Description)
72 case "branch":72 case "branch":
73 return a.DefaultBranch < b.DefaultBranch73 return a.DefaultBranch < b.DefaultBranch
74- case "push":74+ case "activity":
75- return a.PushedAt.Before(b.PushedAt)75+ return repoActiveAt(a).Before(repoActiveAt(b))
76 }76 }
77 return a.Name.String() < b.Name.String()77 return a.Name.String() < b.Name.String()
78 }78 }
@@ -84,17 +84,17 @@ func sortRepos(repos []ports.RepoView, key string, desc bool) {
84 })84 })
85 }85 }
86 86
87-// home lists the repositories: /?sort=<col>&dir=asc|desc, newest push first87+// home lists the repositories: /?sort=<col>&dir=asc|desc, newest activity first
88 // by default.88 // by default.
89 func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, int, error) {89 func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, int, error) {
90 repos, err := s.listRepos(ctx)90 repos, err := s.listRepos(ctx)
91 if err != nil {91 if err != nil {
92 return nil, 0, err92 return nil, 0, err
93 }93 }
94- key, desc := "push", true94+ key, desc := "activity", true
95 for _, k := range homeKeys {95 for _, k := range homeKeys {
96 if r.URL.Query().Get("sort") == k {96 if r.URL.Query().Get("sort") == k {
97- key, desc = k, k == "push"97+ key, desc = k, k == "activity"
98 }98 }
99 }99 }
100 if d := r.URL.Query().Get("dir"); d == "asc" || d == "desc" {100 if d := r.URL.Query().Get("dir"); d == "asc" || d == "desc" {
@@ -103,11 +103,11 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in
103 sortRepos(repos, key, desc)103 sortRepos(repos, key, desc)
104 104
105 now := s.cfg.Now()105 now := s.cfg.Now()
106- p := HomePage{}106+ p := HomePage{Sort: key, Desc: desc}
107 for _, c := range homeSorts {107 for _, c := range homeSorts {
108 sl := SortLink{Label: c.label}108 sl := SortLink{Label: c.label}
109 next := "asc"109 next := "asc"
110- if c.key == "push" {110+ if c.key == "activity" {
111 next = "desc"111 next = "desc"
112 }112 }
113 if c.key == key {113 if c.key == key {
@@ -121,11 +121,10 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in
121 p.Sorts = append(p.Sorts, sl)121 p.Sorts = append(p.Sorts, sl)
122 }122 }
123 p.Rows = s.homeRows(ctx, repos, now)123 p.Rows = s.homeRows(ctx, repos, now)
124+ l := s.base()
124 if n, show := s.inboxCount(ctx); show {125 if n, show := s.inboxCount(ctx); show {
125- p.Inbox = &InboxButton{Href: inboxPath, Count: n}126+ l.Inbox = &InboxButton{Href: inboxPath, Count: n}
126 }127 }
127-
128- l := s.base()
129 l.Title = s.instance128 l.Title = s.instance
130 l.Live, l.LiveURL = true, liveURL("home", "")129 l.Live, l.LiveURL = true, liveURL("home", "")
131 l.Cmd = append([]CmdItem{{Kind: "go", Label: "home", Href: "/"}}, repoCmds(repos, "")...)130 l.Cmd = append([]CmdItem{{Kind: "go", Label: "home", Href: "/"}}, repoCmds(repos, "")...)
@@ -268,8 +267,8 @@ func homeRow(r ports.RepoView, now time.Time, cursor, signed bool) HomeRow {
268 Desc: r.Description, Topics: r.Topics, Cursor: cursor,267 Desc: r.Description, Topics: r.Topics, Cursor: cursor,
269 Private: signed && r.Visibility == domain.VisibilityPrivate,268 Private: signed && r.Visibility == domain.VisibilityPrivate,
270 }269 }
271- if !r.PushedAt.IsZero() {270+ if at := repoActiveAt(r); !at.IsZero() {
272- row.Active, row.ActiveAt = ago(now, r.PushedAt), rfc3339(r.PushedAt)271+ row.Active, row.ActiveAt = ago(now, at), rfc3339(at)
273 }272 }
274 return row273 return row
275 }274 }
@@ -285,3 +284,14 @@ func repoCmds(repos []ports.RepoView, except string) []CmdItem {
285 }284 }
286 return out285 return out
287 }286 }
287+
288+// repoActiveAt falls back for repositories without recorded activity.
289+func repoActiveAt(r ports.RepoView) time.Time {
290+ if !r.ActiveAt.IsZero() {
291+ return r.ActiveAt
292+ }
293+ if r.PushedAt.After(r.CreatedAt) {
294+ return r.PushedAt
295+ }
296+ return r.CreatedAt
297+}
M internal/adapters/web/home_test.go +50 -1
@@ -252,7 +252,7 @@ func TestRunUpdatedRefreshesTheHomeRow(t *testing.T) {
252252 assert.Equal(t, "home-list", got[0].event)
253253 assert.Contains(t, got[0].html, `id="repo-proj"`)
254254 assert.Contains(t, got[0].html, "passing")
255- assert.NotContains(t, got[0].html, "new push", "a run is not a push")
255+ assert.NotContains(t, got[0].html, "new activity", "a run is not a push")
256256
257257 // A job that ended refreshes the row too; one in progress does not.
258258 got, err = (&liveStream{s: srv}).render(t.Context(), ports.WatchEvent{Event: domain.JobUpdated{Job: domain.CIJob{Repo: repo, Phase: domain.CIPhaseRunning}}})
@@ -344,3 +344,52 @@ func TestHomeOffersTheLayouts(t *testing.T) {
344344 require.NotEqual(t, -1, store)
345345 assert.Less(t, store, app, "the store loads before the app script")
346346 }
347+
348+func TestHomeActivityOrderAndFallback(t *testing.T) {
349+ t.Parallel()
350+ now := time.Now().UTC()
351+ a, b, c := publicRepo(t, "a"), publicRepo(t, "b"), publicRepo(t, "c")
352+ a.PushedAt = now.Add(-time.Hour)
353+ a.ActiveAt = now
354+ b.PushedAt = now.Add(-time.Minute)
355+ c.CreatedAt = now.Add(-30 * time.Minute)
356+ repos := []ports.RepoView{b, c, a}
357+ sortRepos(repos, "activity", true)
358+ assert.Equal(t, []ports.RepoView{a, b, c}, repos)
359+ sortRepos(repos, "activity", false)
360+ assert.Equal(t, []ports.RepoView{c, b, a}, repos)
361+ assert.Equal(t, rfc3339(now), homeRow(a, now, false, false).ActiveAt)
362+ c.PushedAt = c.CreatedAt.Add(-time.Hour)
363+ assert.Equal(t, c.CreatedAt, repoActiveAt(c), "fallback is the later of creation and push")
364+}
365+
366+func TestActivityEventsRefreshTheHomeRow(t *testing.T) {
367+ t.Parallel()
368+ repo := publicRepo(t, "proj")
369+ srv := newTestServer(t, Config{Repos: reposServing(t, repo), Browse: homeBrowse(t)})
370+ mr := domain.MergeRequest{Repo: repo.Name}
371+ issue := domain.Issue{Repo: repo.Name}
372+ for _, ev := range []domain.Event{
373+ domain.PushEvent{Repo: repo.Name},
374+ domain.MergeRequestOpened{MergeRequest: mr}, domain.MergeRequestMerged{MergeRequest: mr},
375+ domain.MergeRequestClosed{MergeRequest: mr}, domain.MergeRequestReady{MergeRequest: mr},
376+ domain.CommentAdded{Repo: repo.Name}, domain.IssueOpened{Issue: issue},
377+ domain.IssueClosed{Issue: issue}, domain.IssueReopened{Issue: issue}, domain.IssueCommented{Repo: repo.Name},
378+ } {
379+ t.Run(ev.EventKind(), func(t *testing.T) {
380+ t.Parallel()
381+ got, err := (&liveStream{s: srv}).home(t.Context(), ports.WatchEvent{Event: ev})
382+ require.NoError(t, err)
383+ require.Len(t, got, 1)
384+ assert.Equal(t, "home-list", got[0].event)
385+ assert.Contains(t, got[0].html, `id="repo-proj"`)
386+ assert.Contains(t, got[0].html, `data-activity`)
387+ assert.Contains(t, got[0].html, "new activity")
388+ })
389+ }
390+ for _, ev := range []domain.Event{domain.IssueSubmitted{Repo: repo.Name}, domain.IssuePendingRemoved{Repo: repo.Name}} {
391+ got, err := (&liveStream{s: srv}).home(t.Context(), ports.WatchEvent{Event: ev})
392+ require.NoError(t, err)
393+ assert.Empty(t, got)
394+ }
395+}
@@ -252,7 +252,7 @@ func TestRunUpdatedRefreshesTheHomeRow(t *testing.T) {
252 assert.Equal(t, "home-list", got[0].event)252 assert.Equal(t, "home-list", got[0].event)
253 assert.Contains(t, got[0].html, `id="repo-proj"`)253 assert.Contains(t, got[0].html, `id="repo-proj"`)
254 assert.Contains(t, got[0].html, "passing")254 assert.Contains(t, got[0].html, "passing")
255- assert.NotContains(t, got[0].html, "new push", "a run is not a push")255+ assert.NotContains(t, got[0].html, "new activity", "a run is not a push")
256 256
257 // A job that ended refreshes the row too; one in progress does not.257 // A job that ended refreshes the row too; one in progress does not.
258 got, err = (&liveStream{s: srv}).render(t.Context(), ports.WatchEvent{Event: domain.JobUpdated{Job: domain.CIJob{Repo: repo, Phase: domain.CIPhaseRunning}}})258 got, err = (&liveStream{s: srv}).render(t.Context(), ports.WatchEvent{Event: domain.JobUpdated{Job: domain.CIJob{Repo: repo, Phase: domain.CIPhaseRunning}}})
@@ -344,3 +344,52 @@ func TestHomeOffersTheLayouts(t *testing.T) {
344 require.NotEqual(t, -1, store)344 require.NotEqual(t, -1, store)
345 assert.Less(t, store, app, "the store loads before the app script")345 assert.Less(t, store, app, "the store loads before the app script")
346 }346 }
347+
348+func TestHomeActivityOrderAndFallback(t *testing.T) {
349+ t.Parallel()
350+ now := time.Now().UTC()
351+ a, b, c := publicRepo(t, "a"), publicRepo(t, "b"), publicRepo(t, "c")
352+ a.PushedAt = now.Add(-time.Hour)
353+ a.ActiveAt = now
354+ b.PushedAt = now.Add(-time.Minute)
355+ c.CreatedAt = now.Add(-30 * time.Minute)
356+ repos := []ports.RepoView{b, c, a}
357+ sortRepos(repos, "activity", true)
358+ assert.Equal(t, []ports.RepoView{a, b, c}, repos)
359+ sortRepos(repos, "activity", false)
360+ assert.Equal(t, []ports.RepoView{c, b, a}, repos)
361+ assert.Equal(t, rfc3339(now), homeRow(a, now, false, false).ActiveAt)
362+ c.PushedAt = c.CreatedAt.Add(-time.Hour)
363+ assert.Equal(t, c.CreatedAt, repoActiveAt(c), "fallback is the later of creation and push")
364+}
365+
366+func TestActivityEventsRefreshTheHomeRow(t *testing.T) {
367+ t.Parallel()
368+ repo := publicRepo(t, "proj")
369+ srv := newTestServer(t, Config{Repos: reposServing(t, repo), Browse: homeBrowse(t)})
370+ mr := domain.MergeRequest{Repo: repo.Name}
371+ issue := domain.Issue{Repo: repo.Name}
372+ for _, ev := range []domain.Event{
373+ domain.PushEvent{Repo: repo.Name},
374+ domain.MergeRequestOpened{MergeRequest: mr}, domain.MergeRequestMerged{MergeRequest: mr},
375+ domain.MergeRequestClosed{MergeRequest: mr}, domain.MergeRequestReady{MergeRequest: mr},
376+ domain.CommentAdded{Repo: repo.Name}, domain.IssueOpened{Issue: issue},
377+ domain.IssueClosed{Issue: issue}, domain.IssueReopened{Issue: issue}, domain.IssueCommented{Repo: repo.Name},
378+ } {
379+ t.Run(ev.EventKind(), func(t *testing.T) {
380+ t.Parallel()
381+ got, err := (&liveStream{s: srv}).home(t.Context(), ports.WatchEvent{Event: ev})
382+ require.NoError(t, err)
383+ require.Len(t, got, 1)
384+ assert.Equal(t, "home-list", got[0].event)
385+ assert.Contains(t, got[0].html, `id="repo-proj"`)
386+ assert.Contains(t, got[0].html, `data-activity`)
387+ assert.Contains(t, got[0].html, "new activity")
388+ })
389+ }
390+ for _, ev := range []domain.Event{domain.IssueSubmitted{Repo: repo.Name}, domain.IssuePendingRemoved{Repo: repo.Name}} {
391+ got, err := (&liveStream{s: srv}).home(t.Context(), ports.WatchEvent{Event: ev})
392+ require.NoError(t, err)
393+ assert.Empty(t, got)
394+ }
395+}
M internal/adapters/web/issues.templ +24 -22
@@ -136,32 +136,34 @@ templ issueNewRail() {
136136 // owner, the form to comment and the button that closes or reopens it.
137137 templ IssueShow(l Layout, p IssuePage) {
138138 @Shell(l, issueRail(p)) {
139- <header class="show mr-h">
140- <h1><span class="mute">{ p.Number }</span> { p.Title }</h1>
141- <dl class="kv show-kv">
142- <dt>state</dt>
143- <dd class="mr-state" data-state={ p.State }>{ p.State }</dd>
144- <dt>opened</dt>
145- <dd>
146- { p.Opened.Name }
147- if p.Opened.WhenAt != "" {
148- { " · " }<time datetime={ p.Opened.WhenAt } title={ p.Opened.Abs }>{ p.Opened.When }</time>
139+ <section class="mr-card">
140+ <header class="show mr-h">
141+ <h1><span class="mute">{ p.Number }</span> { p.Title }</h1>
142+ <dl class="kv show-kv">
143+ <dt>state</dt>
144+ <dd class="mr-state" data-state={ p.State }>{ p.State }</dd>
145+ <dt>opened</dt>
146+ <dd>
147+ { p.Opened.Name }
148+ if p.Opened.WhenAt != "" {
149+ { " · " }<time datetime={ p.Opened.WhenAt } title={ p.Opened.Abs }>{ p.Opened.When }</time>
150+ }
151+ </dd>
152+ if p.UpdatedAt != "" {
153+ <dt>updated</dt>
154+ <dd><time datetime={ p.UpdatedAt }>{ p.Updated }</time></dd>
149155 }
150- </dd>
151- if p.UpdatedAt != "" {
152- <dt>updated</dt>
153- <dd><time datetime={ p.UpdatedAt }>{ p.Updated }</time></dd>
154- }
155- </dl>
156- </header>
156+ </dl>
157+ </header>
158+ if p.Desc != "" {
159+ <article class="mr-desc prose" aria-label="description" hx-ignore>
160+ @templ.Raw(string(p.Desc))
161+ </article>
162+ }
163+ </section>
157164 if p.Live {
158165 @LiveSlot("mr-notice")
159166 }
160- if p.Desc != "" {
161- <article class="mr-desc prose" aria-label="description" hx-ignore>
162- @templ.Raw(string(p.Desc))
163- </article>
164- }
165167 <h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(len(p.Comments)) }</span></h2>
166168 for _, c := range p.Comments {
167169 @commentView(c, "")
@@ -136,32 +136,34 @@ templ issueNewRail() {
136 // owner, the form to comment and the button that closes or reopens it.136 // owner, the form to comment and the button that closes or reopens it.
137 templ IssueShow(l Layout, p IssuePage) {137 templ IssueShow(l Layout, p IssuePage) {
138 @Shell(l, issueRail(p)) {138 @Shell(l, issueRail(p)) {
139- <header class="show mr-h">139+ <section class="mr-card">
140- <h1><span class="mute">{ p.Number }</span> { p.Title }</h1>140+ <header class="show mr-h">
141- <dl class="kv show-kv">141+ <h1><span class="mute">{ p.Number }</span> { p.Title }</h1>
142- <dt>state</dt>142+ <dl class="kv show-kv">
143- <dd class="mr-state" data-state={ p.State }>{ p.State }</dd>143+ <dt>state</dt>
144- <dt>opened</dt>144+ <dd class="mr-state" data-state={ p.State }>{ p.State }</dd>
145- <dd>145+ <dt>opened</dt>
146- { p.Opened.Name }146+ <dd>
147- if p.Opened.WhenAt != "" {147+ { p.Opened.Name }
148- { " · " }<time datetime={ p.Opened.WhenAt } title={ p.Opened.Abs }>{ p.Opened.When }</time>148+ if p.Opened.WhenAt != "" {
149+ { " · " }<time datetime={ p.Opened.WhenAt } title={ p.Opened.Abs }>{ p.Opened.When }</time>
150+ }
151+ </dd>
152+ if p.UpdatedAt != "" {
153+ <dt>updated</dt>
154+ <dd><time datetime={ p.UpdatedAt }>{ p.Updated }</time></dd>
149 }155 }
150- </dd>156+ </dl>
151- if p.UpdatedAt != "" {157+ </header>
152- <dt>updated</dt>158+ if p.Desc != "" {
153- <dd><time datetime={ p.UpdatedAt }>{ p.Updated }</time></dd>159+ <article class="mr-desc prose" aria-label="description" hx-ignore>
154- }160+ @templ.Raw(string(p.Desc))
155- </dl>161+ </article>
156- </header>162+ }
163+ </section>
157 if p.Live {164 if p.Live {
158 @LiveSlot("mr-notice")165 @LiveSlot("mr-notice")
159 }166 }
160- if p.Desc != "" {
161- <article class="mr-desc prose" aria-label="description" hx-ignore>
162- @templ.Raw(string(p.Desc))
163- </article>
164- }
165 <h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(len(p.Comments)) }</span></h2>167 <h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(len(p.Comments)) }</span></h2>
166 for _, c := range p.Comments {168 for _, c := range p.Comments {
167 @commentView(c, "")169 @commentView(c, "")
M internal/adapters/web/issues_templ.go +26 -26 generated file: collapsed
M internal/adapters/web/jstest/a11y.test.mjs +25 -0
@@ -8,6 +8,7 @@ import { test } from 'node:test';
88 import { dom, El, el, flushFrames, installDom, settle } from './dom.mjs';
99
1010 // The few things ermite.js uses that dom.mjs does not have.
11+El.prototype.prepend = function (...nodes) { this.nodes.unshift(...this.adopt(nodes)); };
1112 El.prototype.replaceWith = function (n) {
1213 const p = this.parent;
1314 const i = p.nodes.indexOf(this);
@@ -333,3 +334,27 @@ test('the preference is read from storage when the page loads', async () => {
333334 assert.deepEqual(dom.body.events, []);
334335 store.set('keys-off', '');
335336 });
337+
338+
339+test('activity moves a home row up only in newest activity order, preserving focus and cursor', () => {
340+ for (const [sort, desc, activity, moves] of [
341+ ['activity', 'true', true, true],
342+ ['activity', 'false', true, false],
343+ ['name', 'true', true, false],
344+ ['name', 'false', true, false],
345+ ['activity', 'true', false, false], // CI or metadata, not activity
346+ ]) {
347+ const r = rows();
348+ r.list.dataset.sort = sort;
349+ r.list.dataset.desc = desc;
350+ const old = r.list.children[1];
351+ old.setAttribute('data-cursor', '');
352+ active = old.children[0];
353+ const fresh = r.mk('repo-b', '/b');
354+ if (activity) fresh.setAttribute('data-activity', '');
355+ swapWith(`activity-${sort}-${desc}-${activity}`, fresh);
356+ assert.equal(r.list.children.indexOf(fresh), moves ? 0 : 1);
357+ assert.equal(active, fresh.children[0]);
358+ assert.ok(fresh.hasAttribute('data-cursor'));
359+ }
360+});
@@ -8,6 +8,7 @@ import { test } from 'node:test';
8 import { dom, El, el, flushFrames, installDom, settle } from './dom.mjs';8 import { dom, El, el, flushFrames, installDom, settle } from './dom.mjs';
9 9
10 // The few things ermite.js uses that dom.mjs does not have.10 // The few things ermite.js uses that dom.mjs does not have.
11+El.prototype.prepend = function (...nodes) { this.nodes.unshift(...this.adopt(nodes)); };
11 El.prototype.replaceWith = function (n) {12 El.prototype.replaceWith = function (n) {
12 const p = this.parent;13 const p = this.parent;
13 const i = p.nodes.indexOf(this);14 const i = p.nodes.indexOf(this);
@@ -333,3 +334,27 @@ test('the preference is read from storage when the page loads', async () => {
333 assert.deepEqual(dom.body.events, []);334 assert.deepEqual(dom.body.events, []);
334 store.set('keys-off', '');335 store.set('keys-off', '');
335 });336 });
337+
338+
339+test('activity moves a home row up only in newest activity order, preserving focus and cursor', () => {
340+ for (const [sort, desc, activity, moves] of [
341+ ['activity', 'true', true, true],
342+ ['activity', 'false', true, false],
343+ ['name', 'true', true, false],
344+ ['name', 'false', true, false],
345+ ['activity', 'true', false, false], // CI or metadata, not activity
346+ ]) {
347+ const r = rows();
348+ r.list.dataset.sort = sort;
349+ r.list.dataset.desc = desc;
350+ const old = r.list.children[1];
351+ old.setAttribute('data-cursor', '');
352+ active = old.children[0];
353+ const fresh = r.mk('repo-b', '/b');
354+ if (activity) fresh.setAttribute('data-activity', '');
355+ swapWith(`activity-${sort}-${desc}-${activity}`, fresh);
356+ assert.equal(r.list.children.indexOf(fresh), moves ? 0 : 1);
357+ assert.equal(active, fresh.children[0]);
358+ assert.ok(fresh.hasAttribute('data-cursor'));
359+ }
360+});
M internal/adapters/web/keys.templ +1 -0
@@ -32,6 +32,7 @@ templ Keys() {
3232 k-split from:body -> ermite.layout('split');
3333 k-unified from:body -> ermite.layout('unified');
3434 k-collapse from:body -> ermite.collapse();
35+ k-wide from:body -> window.ermiteWide.toggle();
3536 k-expand from:body -> ermite.expandFile();
3637 k-expandAll from:body -> ermite.expandAll();
3738 k-permalink from:body -> ermite.permalink();
@@ -32,6 +32,7 @@ templ Keys() {
32 k-split from:body -> ermite.layout('split');32 k-split from:body -> ermite.layout('split');
33 k-unified from:body -> ermite.layout('unified');33 k-unified from:body -> ermite.layout('unified');
34 k-collapse from:body -> ermite.collapse();34 k-collapse from:body -> ermite.collapse();
35+ k-wide from:body -> window.ermiteWide.toggle();
35 k-expand from:body -> ermite.expandFile();36 k-expand from:body -> ermite.expandFile();
36 k-expandAll from:body -> ermite.expandAll();37 k-expandAll from:body -> ermite.expandAll();
37 k-permalink from:body -> ermite.permalink();38 k-permalink from:body -> ermite.permalink();
M internal/adapters/web/keys_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/mr.go +6 -1
@@ -910,10 +910,15 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
910910 ln.Selected = true
911911 }
912912 }
913+ // The title names the whole range a shift + click selected: a.go:21-149.
914+ lines := itoa(on.line)
915+ if start < on.line {
916+ lines = itoa(start) + "-" + lines
917+ }
913918 if ln := find(on.path, side, on.line); ln != nil {
914919 ln.Form = &CommentForm{
915920 StartLine: optionalLine(on.start), ID: "cf-line", Action: mrHref(repo, n, "comments"), Path: on.path, Line: itoa(on.line), Side: on.side,
916- Title: "comment on " + visibleText(on.path) + ":" + itoa(on.line) + " (" + on.side + ")",
921+ Title: "comment on " + visibleText(on.path) + ":" + lines + " (" + on.side + ")",
917922 Cancel: withFrom(self, windowStart(fileAt[on.path])) + "#" + fileIDs[on.path], Focus: true,
918923 From: windowStart(fileAt[on.path]),
919924 }
@@ -910,10 +910,15 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
910 ln.Selected = true910 ln.Selected = true
911 }911 }
912 }912 }
913+ // The title names the whole range a shift + click selected: a.go:21-149.
914+ lines := itoa(on.line)
915+ if start < on.line {
916+ lines = itoa(start) + "-" + lines
917+ }
913 if ln := find(on.path, side, on.line); ln != nil {918 if ln := find(on.path, side, on.line); ln != nil {
914 ln.Form = &CommentForm{919 ln.Form = &CommentForm{
915 StartLine: optionalLine(on.start), ID: "cf-line", Action: mrHref(repo, n, "comments"), Path: on.path, Line: itoa(on.line), Side: on.side,920 StartLine: optionalLine(on.start), ID: "cf-line", Action: mrHref(repo, n, "comments"), Path: on.path, Line: itoa(on.line), Side: on.side,
916- Title: "comment on " + visibleText(on.path) + ":" + itoa(on.line) + " (" + on.side + ")",921+ Title: "comment on " + visibleText(on.path) + ":" + lines + " (" + on.side + ")",
917 Cancel: withFrom(self, windowStart(fileAt[on.path])) + "#" + fileIDs[on.path], Focus: true,922 Cancel: withFrom(self, windowStart(fileAt[on.path])) + "#" + fileIDs[on.path], Focus: true,
918 From: windowStart(fileAt[on.path]),923 From: windowStart(fileAt[on.path]),
919 }924 }
M internal/adapters/web/mr.templ +37 -39
@@ -109,41 +109,43 @@ templ mrListRail(p MRListPage) {
109109 // form, the checks, the settings, the commits and the files.
110110 templ MergeRequest(l Layout, p MRPage) {
111111 @Shell(l, mrRail(p)) {
112- <header class="show mr-h">
113- <h1><span class="mute">{ p.Number }</span> { p.Title }</h1>
114- <dl class="kv show-kv">
115- <dt>state</dt>
116- <dd class="mr-state" data-state={ p.State }>
117- { p.State }
118- if p.MergedShort != "" {
119- { " · " }<a href={ templ.SafeURL(p.MergedHref) }>{ p.MergedShort }</a>{ " · " + p.MergeMode }
120- }
121- </dd>
122- <dt>branch</dt>
123- <dd>
124- @branchLink(p.Source, p.SourceHref)
125- { " → " }
126- @branchLink(p.Target, p.TargetHref)
127- </dd>
128- <dt>opened</dt>
129- <dd>
130- { p.Opened.Name }
131- if p.Opened.WhenAt != "" {
132- { " · " }<time datetime={ p.Opened.WhenAt } title={ p.Opened.Abs }>{ p.Opened.When }</time>
112+ <section class="mr-card">
113+ <header class="show mr-h">
114+ <h1><span class="mute">{ p.Number }</span> { p.Title }</h1>
115+ <dl class="kv show-kv">
116+ <dt>state</dt>
117+ <dd class="mr-state" data-state={ p.State }>
118+ { p.State }
119+ if p.MergedShort != "" {
120+ { " · " }<a href={ templ.SafeURL(p.MergedHref) }>{ p.MergedShort }</a>{ " · " + p.MergeMode }
121+ }
122+ </dd>
123+ <dt>branch</dt>
124+ <dd>
125+ @branchLink(p.Source, p.SourceHref)
126+ { " → " }
127+ @branchLink(p.Target, p.TargetHref)
128+ </dd>
129+ <dt>opened</dt>
130+ <dd>
131+ { p.Opened.Name }
132+ if p.Opened.WhenAt != "" {
133+ { " · " }<time datetime={ p.Opened.WhenAt } title={ p.Opened.Abs }>{ p.Opened.When }</time>
134+ }
135+ </dd>
136+ if p.UpdatedAt != "" {
137+ <dt>updated</dt>
138+ <dd><time datetime={ p.UpdatedAt }>{ p.Updated }</time></dd>
133139 }
134- </dd>
135- if p.UpdatedAt != "" {
136- <dt>updated</dt>
137- <dd><time datetime={ p.UpdatedAt }>{ p.Updated }</time></dd>
138- }
139- </dl>
140- </header>
140+ </dl>
141+ </header>
142+ if p.Desc != "" {
143+ <article class="mr-desc prose" aria-label="description" hx-ignore>
144+ @templ.Raw(string(p.Desc))
145+ </article>
146+ }
147+ </section>
141148 @LiveSlot("mr-notice")
142- if p.Desc != "" {
143- <article class="mr-desc prose" aria-label="description" hx-ignore>
144- @templ.Raw(string(p.Desc))
145- </article>
146- }
147149 if p.Since != nil {
148150 <nav class="since" aria-label="Diff range">
149151 <a
@@ -431,9 +433,7 @@ templ mrRail(p MRPage) {
431433 templ mrBar(p MRPage) {
432434 <div class="diff-tools mr-bar" role="group" aria-label="Diff view and merge">
433435 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
434- @viewLink(p.DiffView, "unified", "Unified", "u", p.UnifiedHref)
435- @viewLink(p.DiffView, "split", "Split", "d", p.SplitHref)
436- @collapseButton()
436+ @diffViewMenu(p.DiffView)
437437 <span class="grow"></span>
438438 @MRChecksSum(p.ChecksSum)
439439 if p.Box != nil {
@@ -562,9 +562,7 @@ templ Compare(l Layout, p ComparePage) {
562562 <div class="diff-tools" role="group" aria-label="Diff view">
563563 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
564564 <span class="grow"></span>
565- @viewLink(p.DiffView, "unified", "Unified", "u", p.UnifiedHref)
566- @viewLink(p.DiffView, "split", "Split", "d", p.SplitHref)
567- @collapseButton()
565+ @diffViewMenu(p.DiffView)
568566 </div>
569567 if p.Invisible {
570568 <p class="notice">
@@ -109,41 +109,43 @@ templ mrListRail(p MRListPage) {
109 // form, the checks, the settings, the commits and the files.109 // form, the checks, the settings, the commits and the files.
110 templ MergeRequest(l Layout, p MRPage) {110 templ MergeRequest(l Layout, p MRPage) {
111 @Shell(l, mrRail(p)) {111 @Shell(l, mrRail(p)) {
112- <header class="show mr-h">112+ <section class="mr-card">
113- <h1><span class="mute">{ p.Number }</span> { p.Title }</h1>113+ <header class="show mr-h">
114- <dl class="kv show-kv">114+ <h1><span class="mute">{ p.Number }</span> { p.Title }</h1>
115- <dt>state</dt>115+ <dl class="kv show-kv">
116- <dd class="mr-state" data-state={ p.State }>116+ <dt>state</dt>
117- { p.State }117+ <dd class="mr-state" data-state={ p.State }>
118- if p.MergedShort != "" {118+ { p.State }
119- { " · " }<a href={ templ.SafeURL(p.MergedHref) }>{ p.MergedShort }</a>{ " · " + p.MergeMode }119+ if p.MergedShort != "" {
120- }120+ { " · " }<a href={ templ.SafeURL(p.MergedHref) }>{ p.MergedShort }</a>{ " · " + p.MergeMode }
121- </dd>121+ }
122- <dt>branch</dt>122+ </dd>
123- <dd>123+ <dt>branch</dt>
124- @branchLink(p.Source, p.SourceHref)124+ <dd>
125- { " → " }125+ @branchLink(p.Source, p.SourceHref)
126- @branchLink(p.Target, p.TargetHref)126+ { " → " }
127- </dd>127+ @branchLink(p.Target, p.TargetHref)
128- <dt>opened</dt>128+ </dd>
129- <dd>129+ <dt>opened</dt>
130- { p.Opened.Name }130+ <dd>
131- if p.Opened.WhenAt != "" {131+ { p.Opened.Name }
132- { " · " }<time datetime={ p.Opened.WhenAt } title={ p.Opened.Abs }>{ p.Opened.When }</time>132+ if p.Opened.WhenAt != "" {
133+ { " · " }<time datetime={ p.Opened.WhenAt } title={ p.Opened.Abs }>{ p.Opened.When }</time>
134+ }
135+ </dd>
136+ if p.UpdatedAt != "" {
137+ <dt>updated</dt>
138+ <dd><time datetime={ p.UpdatedAt }>{ p.Updated }</time></dd>
133 }139 }
134- </dd>140+ </dl>
135- if p.UpdatedAt != "" {141+ </header>
136- <dt>updated</dt>142+ if p.Desc != "" {
137- <dd><time datetime={ p.UpdatedAt }>{ p.Updated }</time></dd>143+ <article class="mr-desc prose" aria-label="description" hx-ignore>
138- }144+ @templ.Raw(string(p.Desc))
139- </dl>145+ </article>
140- </header>146+ }
147+ </section>
141 @LiveSlot("mr-notice")148 @LiveSlot("mr-notice")
142- if p.Desc != "" {
143- <article class="mr-desc prose" aria-label="description" hx-ignore>
144- @templ.Raw(string(p.Desc))
145- </article>
146- }
147 if p.Since != nil {149 if p.Since != nil {
148 <nav class="since" aria-label="Diff range">150 <nav class="since" aria-label="Diff range">
149 <a151 <a
@@ -431,9 +433,7 @@ templ mrRail(p MRPage) {
431 templ mrBar(p MRPage) {433 templ mrBar(p MRPage) {
432 <div class="diff-tools mr-bar" role="group" aria-label="Diff view and merge">434 <div class="diff-tools mr-bar" role="group" aria-label="Diff view and merge">
433 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>435 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
434- @viewLink(p.DiffView, "unified", "Unified", "u", p.UnifiedHref)436+ @diffViewMenu(p.DiffView)
435- @viewLink(p.DiffView, "split", "Split", "d", p.SplitHref)
436- @collapseButton()
437 <span class="grow"></span>437 <span class="grow"></span>
438 @MRChecksSum(p.ChecksSum)438 @MRChecksSum(p.ChecksSum)
439 if p.Box != nil {439 if p.Box != nil {
@@ -562,9 +562,7 @@ templ Compare(l Layout, p ComparePage) {
562 <div class="diff-tools" role="group" aria-label="Diff view">562 <div class="diff-tools" role="group" aria-label="Diff view">
563 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>563 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
564 <span class="grow"></span>564 <span class="grow"></span>
565- @viewLink(p.DiffView, "unified", "Unified", "u", p.UnifiedHref)565+ @diffViewMenu(p.DiffView)
566- @viewLink(p.DiffView, "split", "Split", "d", p.SplitHref)
567- @collapseButton()
568 </div>566 </div>
569 if p.Invisible {567 if p.Invisible {
570 <p class="notice">568 <p class="notice">
M internal/adapters/web/mr_templ.go +107 -123 generated file: collapsed
M internal/adapters/web/overlays.templ +1 -0
@@ -28,6 +28,7 @@ templ OverlayHelp(mr bool) {
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>
3030 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>
31+ <div><dt><kbd>w</kbd></dt><dd>wide mode: use the whole width of the screen, or not (remembered)</dd></div>
3132 <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div>
3233 <div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div>
3334 <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>
@@ -28,6 +28,7 @@ templ OverlayHelp(mr bool) {
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 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>30 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>
31+ <div><dt><kbd>w</kbd></dt><dd>wide mode: use the whole width of the screen, or not (remembered)</dd></div>
31 <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>
32 <div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div>33 <div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div>
33 <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>34 <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>
M internal/adapters/web/overlays_templ.go +12 -12 generated file: collapsed
M internal/adapters/web/pages.templ +3 -9
@@ -2,6 +2,8 @@
22
33 package web
44
5+import "fmt"
6+
57 // Home is the repository list.
68 templ Home(l Layout, p HomePage) {
79 @Shell(l, nil) {
@@ -9,14 +11,6 @@ templ Home(l Layout, p HomePage) {
911 <div class="home" data-layout="cards">
1012 <section class="home-repos" aria-label="Repositories">
1113 <div class="home-bar">
12- if p.Inbox != nil {
13- <a class="btn inbox-btn" href={ templ.SafeURL(p.Inbox.Href) } title="inbox">
14- Inbox
15- if p.Inbox.Count >= 0 {
16- <span class="count">{ itoa(p.Inbox.Count) }</span>
17- }
18- </a>
19- }
2014 <div class="filter">
2115 <label for="home-filter" class="sr">filter</label>
2216 <input
@@ -57,7 +51,7 @@ templ Home(l Layout, p HomePage) {
5751 </button>
5852 </div>
5953 </div>
60- <ul class="repos" id="repo-rows" data-rows>
54+ <ul class="repos" id="repo-rows" data-rows data-sort={ p.Sort } data-desc={ fmt.Sprint(p.Desc) }>
6155 for _, r := range p.Rows {
6256 @HomeRowView(r)
6357 }
@@ -2,6 +2,8 @@
2 2
3 package web3 package web
4 4
5+import "fmt"
6+
5 // Home is the repository list.7 // Home is the repository list.
6 templ Home(l Layout, p HomePage) {8 templ Home(l Layout, p HomePage) {
7 @Shell(l, nil) {9 @Shell(l, nil) {
@@ -9,14 +11,6 @@ templ Home(l Layout, p HomePage) {
9 <div class="home" data-layout="cards">11 <div class="home" data-layout="cards">
10 <section class="home-repos" aria-label="Repositories">12 <section class="home-repos" aria-label="Repositories">
11 <div class="home-bar">13 <div class="home-bar">
12- if p.Inbox != nil {
13- <a class="btn inbox-btn" href={ templ.SafeURL(p.Inbox.Href) } title="inbox">
14- Inbox
15- if p.Inbox.Count >= 0 {
16- <span class="count">{ itoa(p.Inbox.Count) }</span>
17- }
18- </a>
19- }
20 <div class="filter">14 <div class="filter">
21 <label for="home-filter" class="sr">filter</label>15 <label for="home-filter" class="sr">filter</label>
22 <input16 <input
@@ -57,7 +51,7 @@ templ Home(l Layout, p HomePage) {
57 </button>51 </button>
58 </div>52 </div>
59 </div>53 </div>
60- <ul class="repos" id="repo-rows" data-rows>54+ <ul class="repos" id="repo-rows" data-rows data-sort={ p.Sort } data-desc={ fmt.Sprint(p.Desc) }>
61 for _, r := range p.Rows {55 for _, r := range p.Rows {
62 @HomeRowView(r)56 @HomeRowView(r)
63 }57 }
M internal/adapters/web/pages_templ.go +205 -223 generated file: collapsed
M internal/adapters/web/shell.templ +14 -1
@@ -25,7 +25,7 @@ templ Shell(l Layout, rail templ.Component) {
2525 <script src={ l.Assets.HTMX } defer></script>
2626 <script src={ l.Assets.Live } defer></script>
2727 <script src={ l.Assets.SSE } defer></script>
28- <script src={ l.Assets.Store } defer></script>
28+ <script src={ l.Assets.Store }></script>
2929 <script src={ l.Assets.App } defer></script>
3030 <script src={ l.Assets.Files } defer></script>
3131 <script src={ l.Assets.Passkey } defer></script>
@@ -80,6 +80,19 @@ templ Header(l Layout) {
8080 <a class="brand" href="/" data-g="h" aria-label="home" title="home (g h)">
8181 <span class="brand-mark" aria-hidden="true"></span>
8282 </a>
83+ if l.Inbox != nil {
84+ <a
85+ class="btn inbox-btn"
86+ href={ templ.SafeURL(l.Inbox.Href) }
87+ title="inbox"
88+ >
89+ <span class="ico i-mailbox" aria-hidden="true"></span>
90+ Inbox
91+ if l.Inbox.Count >= 0 {
92+ <span class="count">{ itoa(l.Inbox.Count) }</span>
93+ }
94+ </a>
95+ }
8396 if len(l.Crumbs) > 0 {
8497 <nav class="crumbs" aria-label="Path">
8598 <ol>
@@ -25,7 +25,7 @@ templ Shell(l Layout, rail templ.Component) {
25 <script src={ l.Assets.HTMX } defer></script>25 <script src={ l.Assets.HTMX } defer></script>
26 <script src={ l.Assets.Live } defer></script>26 <script src={ l.Assets.Live } defer></script>
27 <script src={ l.Assets.SSE } defer></script>27 <script src={ l.Assets.SSE } defer></script>
28- <script src={ l.Assets.Store } defer></script>28+ <script src={ l.Assets.Store }></script>
29 <script src={ l.Assets.App } defer></script>29 <script src={ l.Assets.App } defer></script>
30 <script src={ l.Assets.Files } defer></script>30 <script src={ l.Assets.Files } defer></script>
31 <script src={ l.Assets.Passkey } defer></script>31 <script src={ l.Assets.Passkey } defer></script>
@@ -80,6 +80,19 @@ templ Header(l Layout) {
80 <a class="brand" href="/" data-g="h" aria-label="home" title="home (g h)">80 <a class="brand" href="/" data-g="h" aria-label="home" title="home (g h)">
81 <span class="brand-mark" aria-hidden="true"></span>81 <span class="brand-mark" aria-hidden="true"></span>
82 </a>82 </a>
83+ if l.Inbox != nil {
84+ <a
85+ class="btn inbox-btn"
86+ href={ templ.SafeURL(l.Inbox.Href) }
87+ title="inbox"
88+ >
89+ <span class="ico i-mailbox" aria-hidden="true"></span>
90+ Inbox
91+ if l.Inbox.Count >= 0 {
92+ <span class="count">{ itoa(l.Inbox.Count) }</span>
93+ }
94+ </a>
95+ }
83 if len(l.Crumbs) > 0 {96 if len(l.Crumbs) > 0 {
84 <nav class="crumbs" aria-label="Path">97 <nav class="crumbs" aria-label="Path">
85 <ol>98 <ol>
M internal/adapters/web/shell_templ.go +158 -116
@@ -164,7 +164,7 @@ func Shell(l Layout, rail templ.Component) templ.Component {
164164 if templ_7745c5c3_Err != nil {
165165 return templ_7745c5c3_Err
166166 }
167- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" defer></script><script src=\"")
167+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\"></script><script src=\"")
168168 if templ_7745c5c3_Err != nil {
169169 return templ_7745c5c3_Err
170170 }
@@ -458,315 +458,357 @@ func Header(l Layout) templ.Component {
458458 if templ_7745c5c3_Err != nil {
459459 return templ_7745c5c3_Err
460460 }
461+ if l.Inbox != nil {
462+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<a class=\"btn inbox-btn\" href=\"")
463+ if templ_7745c5c3_Err != nil {
464+ return templ_7745c5c3_Err
465+ }
466+ var templ_7745c5c3_Var26 templ.SafeURL
467+ templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.Inbox.Href))
468+ if templ_7745c5c3_Err != nil {
469+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 86, Col: 39}
470+ }
471+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
472+ if templ_7745c5c3_Err != nil {
473+ return templ_7745c5c3_Err
474+ }
475+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" title=\"inbox\"><span class=\"ico i-mailbox\" aria-hidden=\"true\"></span> Inbox ")
476+ if templ_7745c5c3_Err != nil {
477+ return templ_7745c5c3_Err
478+ }
479+ if l.Inbox.Count >= 0 {
480+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<span class=\"count\">")
481+ if templ_7745c5c3_Err != nil {
482+ return templ_7745c5c3_Err
483+ }
484+ var templ_7745c5c3_Var27 string
485+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(l.Inbox.Count))
486+ if templ_7745c5c3_Err != nil {
487+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 92, Col: 47}
488+ }
489+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
490+ if templ_7745c5c3_Err != nil {
491+ return templ_7745c5c3_Err
492+ }
493+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</span>")
494+ if templ_7745c5c3_Err != nil {
495+ return templ_7745c5c3_Err
496+ }
497+ }
498+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</a> ")
499+ if templ_7745c5c3_Err != nil {
500+ return templ_7745c5c3_Err
501+ }
502+ }
461503 if len(l.Crumbs) > 0 {
462- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<nav class=\"crumbs\" aria-label=\"Path\"><ol>")
504+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<nav class=\"crumbs\" aria-label=\"Path\"><ol>")
463505 if templ_7745c5c3_Err != nil {
464506 return templ_7745c5c3_Err
465507 }
466508 for _, c := range l.Crumbs {
467- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<li>")
509+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<li>")
468510 if templ_7745c5c3_Err != nil {
469511 return templ_7745c5c3_Err
470512 }
471513 if c.Href != "" {
472- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<a href=\"")
514+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<a href=\"")
473515 if templ_7745c5c3_Err != nil {
474516 return templ_7745c5c3_Err
475517 }
476- var templ_7745c5c3_Var26 templ.SafeURL
477- templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
518+ var templ_7745c5c3_Var28 templ.SafeURL
519+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
478520 if templ_7745c5c3_Err != nil {
479- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 90, Col: 38}
521+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 103, Col: 38}
480522 }
481- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
523+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
482524 if templ_7745c5c3_Err != nil {
483525 return templ_7745c5c3_Err
484526 }
485- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\"")
527+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\"")
486528 if templ_7745c5c3_Err != nil {
487529 return templ_7745c5c3_Err
488530 }
489531 if c.Current {
490- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " aria-current=\"page\"")
532+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, " aria-current=\"page\"")
491533 if templ_7745c5c3_Err != nil {
492534 return templ_7745c5c3_Err
493535 }
494536 }
495- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, ">")
537+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, ">")
496538 if templ_7745c5c3_Err != nil {
497539 return templ_7745c5c3_Err
498540 }
499- var templ_7745c5c3_Var27 string
500- templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
541+ var templ_7745c5c3_Var29 string
542+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
501543 if templ_7745c5c3_Err != nil {
502- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 94, Col: 19}
544+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 107, Col: 19}
503545 }
504- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
546+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
505547 if templ_7745c5c3_Err != nil {
506548 return templ_7745c5c3_Err
507549 }
508- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</a>")
550+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</a>")
509551 if templ_7745c5c3_Err != nil {
510552 return templ_7745c5c3_Err
511553 }
512554 } else {
513- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<span")
555+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<span")
514556 if templ_7745c5c3_Err != nil {
515557 return templ_7745c5c3_Err
516558 }
517559 if c.Current {
518- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " aria-current=\"page\"")
560+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, " aria-current=\"page\"")
519561 if templ_7745c5c3_Err != nil {
520562 return templ_7745c5c3_Err
521563 }
522564 }
523- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, ">")
565+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, ">")
524566 if templ_7745c5c3_Err != nil {
525567 return templ_7745c5c3_Err
526568 }
527- var templ_7745c5c3_Var28 string
528- templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
569+ var templ_7745c5c3_Var30 string
570+ templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
529571 if templ_7745c5c3_Err != nil {
530- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 100, Col: 19}
572+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 113, Col: 19}
531573 }
532- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
574+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
533575 if templ_7745c5c3_Err != nil {
534576 return templ_7745c5c3_Err
535577 }
536- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</span>")
578+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</span>")
537579 if templ_7745c5c3_Err != nil {
538580 return templ_7745c5c3_Err
539581 }
540582 }
541- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</li>")
583+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</li>")
542584 if templ_7745c5c3_Err != nil {
543585 return templ_7745c5c3_Err
544586 }
545587 }
546- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</ol></nav>")
588+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</ol></nav>")
547589 if templ_7745c5c3_Err != nil {
548590 return templ_7745c5c3_Err
549591 }
550592 }
551- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<div class=\"hdr-acts\">")
593+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<div class=\"hdr-acts\">")
552594 if templ_7745c5c3_Err != nil {
553595 return templ_7745c5c3_Err
554596 }
555597 if l.Ref != "" {
556- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<a class=\"btn ref\" href=\"")
598+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<a class=\"btn ref\" href=\"")
557599 if templ_7745c5c3_Err != nil {
558600 return templ_7745c5c3_Err
559601 }
560- var templ_7745c5c3_Var29 templ.SafeURL
561- templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(repoHref(l.Repo, "refs")))
602+ var templ_7745c5c3_Var31 templ.SafeURL
603+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(repoHref(l.Repo, "refs")))
562604 if templ_7745c5c3_Err != nil {
563- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 111, Col: 52}
605+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 124, Col: 52}
564606 }
565- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
607+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
566608 if templ_7745c5c3_Err != nil {
567609 return templ_7745c5c3_Err
568610 }
569- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\" 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\">")
611+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\" 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\">")
570612 if templ_7745c5c3_Err != nil {
571613 return templ_7745c5c3_Err
572614 }
573- var templ_7745c5c3_Var30 string
574- templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(l.Ref)
615+ var templ_7745c5c3_Var32 string
616+ templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(l.Ref)
575617 if templ_7745c5c3_Err != nil {
576- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 117, Col: 36}
618+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 130, Col: 36}
577619 }
578- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
620+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
579621 if templ_7745c5c3_Err != nil {
580622 return templ_7745c5c3_Err
581623 }
582- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</span> <span class=\"sha\">")
624+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</span> <span class=\"sha\">")
583625 if templ_7745c5c3_Err != nil {
584626 return templ_7745c5c3_Err
585627 }
586- var templ_7745c5c3_Var31 string
587- templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(l.Short)
628+ var templ_7745c5c3_Var33 string
629+ templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(l.Short)
588630 if templ_7745c5c3_Err != nil {
589- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 118, Col: 33}
631+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 131, Col: 33}
590632 }
591- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
633+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
592634 if templ_7745c5c3_Err != nil {
593635 return templ_7745c5c3_Err
594636 }
595- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</span></a> ")
637+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</span></a> ")
596638 if templ_7745c5c3_Err != nil {
597639 return templ_7745c5c3_Err
598640 }
599641 }
600- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<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> ")
642+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<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> ")
601643 if templ_7745c5c3_Err != nil {
602644 return templ_7745c5c3_Err
603645 }
604646 if SignedIn(ctx) {
605- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<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>")
647+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<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>")
606648 if templ_7745c5c3_Err != nil {
607649 return templ_7745c5c3_Err
608650 }
609- var templ_7745c5c3_Var32 string
610- templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(l.Owner)
651+ var templ_7745c5c3_Var34 string
652+ templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(l.Owner)
611653 if templ_7745c5c3_Err != nil {
612- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 137, Col: 123}
654+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 150, Col: 123}
613655 }
614- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
656+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
615657 if templ_7745c5c3_Err != nil {
616658 return templ_7745c5c3_Err
617659 }
618- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</span> ")
660+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</span> ")
619661 if templ_7745c5c3_Err != nil {
620662 return templ_7745c5c3_Err
621663 }
622664 if l.Passkeys {
623- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<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> ")
665+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<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> ")
624666 if templ_7745c5c3_Err != nil {
625667 return templ_7745c5c3_Err
626668 }
627669 }
628- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<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>")
670+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<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>")
629671 if templ_7745c5c3_Err != nil {
630672 return templ_7745c5c3_Err
631673 }
632674 } else if l.Passkeys {
633- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<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>")
675+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<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>")
634676 if templ_7745c5c3_Err != nil {
635677 return templ_7745c5c3_Err
636678 }
637679 }
638- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</div></div>")
680+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</div></div>")
639681 if templ_7745c5c3_Err != nil {
640682 return templ_7745c5c3_Err
641683 }
642684 if len(l.Sections) > 0 {
643- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<nav class=\"tabs\" aria-label=\"Sections\"><ul>")
685+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<nav class=\"tabs\" aria-label=\"Sections\"><ul>")
644686 if templ_7745c5c3_Err != nil {
645687 return templ_7745c5c3_Err
646688 }
647689 for _, s := range l.Sections {
648- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<li><a href=\"")
690+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<li><a href=\"")
649691 if templ_7745c5c3_Err != nil {
650692 return templ_7745c5c3_Err
651693 }
652- var templ_7745c5c3_Var33 templ.SafeURL
653- templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href))
694+ var templ_7745c5c3_Var35 templ.SafeURL
695+ templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href))
654696 if templ_7745c5c3_Err != nil {
655- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 154, Col: 36}
697+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 167, Col: 36}
656698 }
657- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
699+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
658700 if templ_7745c5c3_Err != nil {
659701 return templ_7745c5c3_Err
660702 }
661- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\" data-key=\"")
703+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\" data-key=\"")
662704 if templ_7745c5c3_Err != nil {
663705 return templ_7745c5c3_Err
664706 }
665- var templ_7745c5c3_Var34 string
666- templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
707+ var templ_7745c5c3_Var36 string
708+ templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
667709 if templ_7745c5c3_Err != nil {
668- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 155, Col: 24}
710+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 168, Col: 24}
669711 }
670- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
712+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
671713 if templ_7745c5c3_Err != nil {
672714 return templ_7745c5c3_Err
673715 }
674- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\" aria-keyshortcuts=\"")
716+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\" aria-keyshortcuts=\"")
675717 if templ_7745c5c3_Err != nil {
676718 return templ_7745c5c3_Err
677719 }
678- var templ_7745c5c3_Var35 string
679- templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
720+ var templ_7745c5c3_Var37 string
721+ templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
680722 if templ_7745c5c3_Err != nil {
681- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 156, Col: 33}
723+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 169, Col: 33}
682724 }
683- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
725+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
684726 if templ_7745c5c3_Err != nil {
685727 return templ_7745c5c3_Err
686728 }
687- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\"")
729+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\"")
688730 if templ_7745c5c3_Err != nil {
689731 return templ_7745c5c3_Err
690732 }
691733 if s.Current {
692- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " aria-current=\"page\"")
734+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, " aria-current=\"page\"")
693735 if templ_7745c5c3_Err != nil {
694736 return templ_7745c5c3_Err
695737 }
696738 }
697- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, ">")
739+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, ">")
698740 if templ_7745c5c3_Err != nil {
699741 return templ_7745c5c3_Err
700742 }
701- var templ_7745c5c3_Var36 string
702- templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label)
743+ var templ_7745c5c3_Var38 string
744+ templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label)
703745 if templ_7745c5c3_Err != nil {
704- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 161, Col: 17}
746+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 174, Col: 17}
705747 }
706- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
748+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
707749 if templ_7745c5c3_Err != nil {
708750 return templ_7745c5c3_Err
709751 }
710- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, " ")
752+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, " ")
711753 if templ_7745c5c3_Err != nil {
712754 return templ_7745c5c3_Err
713755 }
714756 if s.Count != "" {
715- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<span class=\"count\">")
757+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<span class=\"count\">")
716758 if templ_7745c5c3_Err != nil {
717759 return templ_7745c5c3_Err
718760 }
719- var templ_7745c5c3_Var37 string
720- templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(s.Count)
761+ var templ_7745c5c3_Var39 string
762+ templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(s.Count)
721763 if templ_7745c5c3_Err != nil {
722- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 163, Col: 38}
764+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 176, Col: 38}
723765 }
724- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
766+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
725767 if templ_7745c5c3_Err != nil {
726768 return templ_7745c5c3_Err
727769 }
728- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<span class=\"sr\">")
770+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<span class=\"sr\">")
729771 if templ_7745c5c3_Err != nil {
730772 return templ_7745c5c3_Err
731773 }
732- var templ_7745c5c3_Var38 string
733- templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(" open")
774+ var templ_7745c5c3_Var40 string
775+ templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(" open")
734776 if templ_7745c5c3_Err != nil {
735- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 163, Col: 66}
777+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 176, Col: 66}
736778 }
737- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
779+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
738780 if templ_7745c5c3_Err != nil {
739781 return templ_7745c5c3_Err
740782 }
741- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</span></span> ")
783+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</span></span> ")
742784 if templ_7745c5c3_Err != nil {
743785 return templ_7745c5c3_Err
744786 }
745787 }
746- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<kbd aria-hidden=\"true\">")
788+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<kbd aria-hidden=\"true\">")
747789 if templ_7745c5c3_Err != nil {
748790 return templ_7745c5c3_Err
749791 }
750- var templ_7745c5c3_Var39 string
751- templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(s.Key)
792+ var templ_7745c5c3_Var41 string
793+ templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(s.Key)
752794 if templ_7745c5c3_Err != nil {
753- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 165, Col: 39}
795+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 178, Col: 39}
754796 }
755- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
797+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
756798 if templ_7745c5c3_Err != nil {
757799 return templ_7745c5c3_Err
758800 }
759- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</kbd></a></li>")
801+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</kbd></a></li>")
760802 if templ_7745c5c3_Err != nil {
761803 return templ_7745c5c3_Err
762804 }
763805 }
764- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</ul></nav>")
806+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</ul></nav>")
765807 if templ_7745c5c3_Err != nil {
766808 return templ_7745c5c3_Err
767809 }
768810 }
769- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</header>")
811+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</header>")
770812 if templ_7745c5c3_Err != nil {
771813 return templ_7745c5c3_Err
772814 }
@@ -792,38 +834,38 @@ func Footer(l Layout) templ.Component {
792834 }()
793835 }
794836 ctx = templ.InitializeContext(ctx)
795- templ_7745c5c3_Var40 := templ.GetChildren(ctx)
796- if templ_7745c5c3_Var40 == nil {
797- templ_7745c5c3_Var40 = templ.NopComponent
837+ templ_7745c5c3_Var42 := templ.GetChildren(ctx)
838+ if templ_7745c5c3_Var42 == nil {
839+ templ_7745c5c3_Var42 = templ.NopComponent
798840 }
799841 ctx = templ.ClearChildren(ctx)
800- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<footer class=\"foot\"><span>ermite ")
842+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<footer class=\"foot\"><span>ermite ")
801843 if templ_7745c5c3_Err != nil {
802844 return templ_7745c5c3_Err
803845 }
804- var templ_7745c5c3_Var41 string
805- templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version)
846+ var templ_7745c5c3_Var43 string
847+ templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version)
806848 if templ_7745c5c3_Err != nil {
807- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 179, Col: 26}
849+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 192, Col: 26}
808850 }
809- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
851+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
810852 if templ_7745c5c3_Err != nil {
811853 return templ_7745c5c3_Err
812854 }
813- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</span> <a href=\"")
855+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</span> <a href=\"")
814856 if templ_7745c5c3_Err != nil {
815857 return templ_7745c5c3_Err
816858 }
817- var templ_7745c5c3_Var42 templ.SafeURL
818- templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL))
859+ var templ_7745c5c3_Var44 templ.SafeURL
860+ templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL))
819861 if templ_7745c5c3_Err != nil {
820- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 180, Col: 38}
862+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 193, Col: 38}
821863 }
822- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
864+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
823865 if templ_7745c5c3_Err != nil {
824866 return templ_7745c5c3_Err
825867 }
826- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "\" 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>")
868+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "\" 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>")
827869 if templ_7745c5c3_Err != nil {
828870 return templ_7745c5c3_Err
829871 }
@@ -851,12 +893,12 @@ func Hint(l Layout) templ.Component {
851893 }()
852894 }
853895 ctx = templ.InitializeContext(ctx)
854- templ_7745c5c3_Var43 := templ.GetChildren(ctx)
855- if templ_7745c5c3_Var43 == nil {
856- templ_7745c5c3_Var43 = templ.NopComponent
896+ templ_7745c5c3_Var45 := templ.GetChildren(ctx)
897+ if templ_7745c5c3_Var45 == nil {
898+ templ_7745c5c3_Var45 = templ.NopComponent
857899 }
858900 ctx = templ.ClearChildren(ctx)
859- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<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>")
901+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<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>")
860902 if templ_7745c5c3_Err != nil {
861903 return templ_7745c5c3_Err
862904 }
@@ -864,7 +906,7 @@ func Hint(l Layout) templ.Component {
864906 if templ_7745c5c3_Err != nil {
865907 return templ_7745c5c3_Err
866908 }
867- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</div>")
909+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</div>")
868910 if templ_7745c5c3_Err != nil {
869911 return templ_7745c5c3_Err
870912 }
@@ -164,7 +164,7 @@ func Shell(l Layout, rail templ.Component) templ.Component {
164 if templ_7745c5c3_Err != nil {164 if templ_7745c5c3_Err != nil {
165 return templ_7745c5c3_Err165 return templ_7745c5c3_Err
166 }166 }
167- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" defer></script><script src=\"")167+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\"></script><script src=\"")
168 if templ_7745c5c3_Err != nil {168 if templ_7745c5c3_Err != nil {
169 return templ_7745c5c3_Err169 return templ_7745c5c3_Err
170 }170 }
@@ -458,315 +458,357 @@ func Header(l Layout) templ.Component {
458 if templ_7745c5c3_Err != nil {458 if templ_7745c5c3_Err != nil {
459 return templ_7745c5c3_Err459 return templ_7745c5c3_Err
460 }460 }
461+ if l.Inbox != nil {
462+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<a class=\"btn inbox-btn\" href=\"")
463+ if templ_7745c5c3_Err != nil {
464+ return templ_7745c5c3_Err
465+ }
466+ var templ_7745c5c3_Var26 templ.SafeURL
467+ templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.Inbox.Href))
468+ if templ_7745c5c3_Err != nil {
469+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 86, Col: 39}
470+ }
471+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
472+ if templ_7745c5c3_Err != nil {
473+ return templ_7745c5c3_Err
474+ }
475+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" title=\"inbox\"><span class=\"ico i-mailbox\" aria-hidden=\"true\"></span> Inbox ")
476+ if templ_7745c5c3_Err != nil {
477+ return templ_7745c5c3_Err
478+ }
479+ if l.Inbox.Count >= 0 {
480+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<span class=\"count\">")
481+ if templ_7745c5c3_Err != nil {
482+ return templ_7745c5c3_Err
483+ }
484+ var templ_7745c5c3_Var27 string
485+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(l.Inbox.Count))
486+ if templ_7745c5c3_Err != nil {
487+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 92, Col: 47}
488+ }
489+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
490+ if templ_7745c5c3_Err != nil {
491+ return templ_7745c5c3_Err
492+ }
493+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</span>")
494+ if templ_7745c5c3_Err != nil {
495+ return templ_7745c5c3_Err
496+ }
497+ }
498+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</a> ")
499+ if templ_7745c5c3_Err != nil {
500+ return templ_7745c5c3_Err
501+ }
502+ }
461 if len(l.Crumbs) > 0 {503 if len(l.Crumbs) > 0 {
462- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<nav class=\"crumbs\" aria-label=\"Path\"><ol>")504+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<nav class=\"crumbs\" aria-label=\"Path\"><ol>")
463 if templ_7745c5c3_Err != nil {505 if templ_7745c5c3_Err != nil {
464 return templ_7745c5c3_Err506 return templ_7745c5c3_Err
465 }507 }
466 for _, c := range l.Crumbs {508 for _, c := range l.Crumbs {
467- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<li>")509+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<li>")
468 if templ_7745c5c3_Err != nil {510 if templ_7745c5c3_Err != nil {
469 return templ_7745c5c3_Err511 return templ_7745c5c3_Err
470 }512 }
471 if c.Href != "" {513 if c.Href != "" {
472- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<a href=\"")514+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<a href=\"")
473 if templ_7745c5c3_Err != nil {515 if templ_7745c5c3_Err != nil {
474 return templ_7745c5c3_Err516 return templ_7745c5c3_Err
475 }517 }
476- var templ_7745c5c3_Var26 templ.SafeURL518+ var templ_7745c5c3_Var28 templ.SafeURL
477- templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))519+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
478 if templ_7745c5c3_Err != nil {520 if templ_7745c5c3_Err != nil {
479- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 90, Col: 38}521+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 103, Col: 38}
480 }522 }
481- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))523+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
482 if templ_7745c5c3_Err != nil {524 if templ_7745c5c3_Err != nil {
483 return templ_7745c5c3_Err525 return templ_7745c5c3_Err
484 }526 }
485- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\"")527+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\"")
486 if templ_7745c5c3_Err != nil {528 if templ_7745c5c3_Err != nil {
487 return templ_7745c5c3_Err529 return templ_7745c5c3_Err
488 }530 }
489 if c.Current {531 if c.Current {
490- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " aria-current=\"page\"")532+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, " aria-current=\"page\"")
491 if templ_7745c5c3_Err != nil {533 if templ_7745c5c3_Err != nil {
492 return templ_7745c5c3_Err534 return templ_7745c5c3_Err
493 }535 }
494 }536 }
495- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, ">")537+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, ">")
496 if templ_7745c5c3_Err != nil {538 if templ_7745c5c3_Err != nil {
497 return templ_7745c5c3_Err539 return templ_7745c5c3_Err
498 }540 }
499- var templ_7745c5c3_Var27 string541+ var templ_7745c5c3_Var29 string
500- templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)542+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
501 if templ_7745c5c3_Err != nil {543 if templ_7745c5c3_Err != nil {
502- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 94, Col: 19}544+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 107, Col: 19}
503 }545 }
504- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))546+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
505 if templ_7745c5c3_Err != nil {547 if templ_7745c5c3_Err != nil {
506 return templ_7745c5c3_Err548 return templ_7745c5c3_Err
507 }549 }
508- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</a>")550+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</a>")
509 if templ_7745c5c3_Err != nil {551 if templ_7745c5c3_Err != nil {
510 return templ_7745c5c3_Err552 return templ_7745c5c3_Err
511 }553 }
512 } else {554 } else {
513- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<span")555+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<span")
514 if templ_7745c5c3_Err != nil {556 if templ_7745c5c3_Err != nil {
515 return templ_7745c5c3_Err557 return templ_7745c5c3_Err
516 }558 }
517 if c.Current {559 if c.Current {
518- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " aria-current=\"page\"")560+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, " aria-current=\"page\"")
519 if templ_7745c5c3_Err != nil {561 if templ_7745c5c3_Err != nil {
520 return templ_7745c5c3_Err562 return templ_7745c5c3_Err
521 }563 }
522 }564 }
523- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, ">")565+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, ">")
524 if templ_7745c5c3_Err != nil {566 if templ_7745c5c3_Err != nil {
525 return templ_7745c5c3_Err567 return templ_7745c5c3_Err
526 }568 }
527- var templ_7745c5c3_Var28 string569+ var templ_7745c5c3_Var30 string
528- templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)570+ templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
529 if templ_7745c5c3_Err != nil {571 if templ_7745c5c3_Err != nil {
530- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 100, Col: 19}572+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 113, Col: 19}
531 }573 }
532- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))574+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
533 if templ_7745c5c3_Err != nil {575 if templ_7745c5c3_Err != nil {
534 return templ_7745c5c3_Err576 return templ_7745c5c3_Err
535 }577 }
536- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</span>")578+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</span>")
537 if templ_7745c5c3_Err != nil {579 if templ_7745c5c3_Err != nil {
538 return templ_7745c5c3_Err580 return templ_7745c5c3_Err
539 }581 }
540 }582 }
541- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</li>")583+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</li>")
542 if templ_7745c5c3_Err != nil {584 if templ_7745c5c3_Err != nil {
543 return templ_7745c5c3_Err585 return templ_7745c5c3_Err
544 }586 }
545 }587 }
546- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</ol></nav>")588+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</ol></nav>")
547 if templ_7745c5c3_Err != nil {589 if templ_7745c5c3_Err != nil {
548 return templ_7745c5c3_Err590 return templ_7745c5c3_Err
549 }591 }
550 }592 }
551- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<div class=\"hdr-acts\">")593+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<div class=\"hdr-acts\">")
552 if templ_7745c5c3_Err != nil {594 if templ_7745c5c3_Err != nil {
553 return templ_7745c5c3_Err595 return templ_7745c5c3_Err
554 }596 }
555 if l.Ref != "" {597 if l.Ref != "" {
556- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<a class=\"btn ref\" href=\"")598+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<a class=\"btn ref\" href=\"")
557 if templ_7745c5c3_Err != nil {599 if templ_7745c5c3_Err != nil {
558 return templ_7745c5c3_Err600 return templ_7745c5c3_Err
559 }601 }
560- var templ_7745c5c3_Var29 templ.SafeURL602+ var templ_7745c5c3_Var31 templ.SafeURL
561- templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(repoHref(l.Repo, "refs")))603+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(repoHref(l.Repo, "refs")))
562 if templ_7745c5c3_Err != nil {604 if templ_7745c5c3_Err != nil {
563- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 111, Col: 52}605+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 124, Col: 52}
564 }606 }
565- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))607+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
566 if templ_7745c5c3_Err != nil {608 if templ_7745c5c3_Err != nil {
567 return templ_7745c5c3_Err609 return templ_7745c5c3_Err
568 }610 }
569- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\" 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\">")611+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\" 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\">")
570 if templ_7745c5c3_Err != nil {612 if templ_7745c5c3_Err != nil {
571 return templ_7745c5c3_Err613 return templ_7745c5c3_Err
572 }614 }
573- var templ_7745c5c3_Var30 string615+ var templ_7745c5c3_Var32 string
574- templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(l.Ref)616+ templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(l.Ref)
575 if templ_7745c5c3_Err != nil {617 if templ_7745c5c3_Err != nil {
576- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 117, Col: 36}618+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 130, Col: 36}
577 }619 }
578- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))620+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
579 if templ_7745c5c3_Err != nil {621 if templ_7745c5c3_Err != nil {
580 return templ_7745c5c3_Err622 return templ_7745c5c3_Err
581 }623 }
582- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</span> <span class=\"sha\">")624+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</span> <span class=\"sha\">")
583 if templ_7745c5c3_Err != nil {625 if templ_7745c5c3_Err != nil {
584 return templ_7745c5c3_Err626 return templ_7745c5c3_Err
585 }627 }
586- var templ_7745c5c3_Var31 string628+ var templ_7745c5c3_Var33 string
587- templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(l.Short)629+ templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(l.Short)
588 if templ_7745c5c3_Err != nil {630 if templ_7745c5c3_Err != nil {
589- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 118, Col: 33}631+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 131, Col: 33}
590 }632 }
591- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))633+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
592 if templ_7745c5c3_Err != nil {634 if templ_7745c5c3_Err != nil {
593 return templ_7745c5c3_Err635 return templ_7745c5c3_Err
594 }636 }
595- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</span></a> ")637+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</span></a> ")
596 if templ_7745c5c3_Err != nil {638 if templ_7745c5c3_Err != nil {
597 return templ_7745c5c3_Err639 return templ_7745c5c3_Err
598 }640 }
599 }641 }
600- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<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> ")642+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<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> ")
601 if templ_7745c5c3_Err != nil {643 if templ_7745c5c3_Err != nil {
602 return templ_7745c5c3_Err644 return templ_7745c5c3_Err
603 }645 }
604 if SignedIn(ctx) {646 if SignedIn(ctx) {
605- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<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>")647+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<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>")
606 if templ_7745c5c3_Err != nil {648 if templ_7745c5c3_Err != nil {
607 return templ_7745c5c3_Err649 return templ_7745c5c3_Err
608 }650 }
609- var templ_7745c5c3_Var32 string651+ var templ_7745c5c3_Var34 string
610- templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(l.Owner)652+ templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(l.Owner)
611 if templ_7745c5c3_Err != nil {653 if templ_7745c5c3_Err != nil {
612- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 137, Col: 123}654+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 150, Col: 123}
613 }655 }
614- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))656+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
615 if templ_7745c5c3_Err != nil {657 if templ_7745c5c3_Err != nil {
616 return templ_7745c5c3_Err658 return templ_7745c5c3_Err
617 }659 }
618- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</span> ")660+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</span> ")
619 if templ_7745c5c3_Err != nil {661 if templ_7745c5c3_Err != nil {
620 return templ_7745c5c3_Err662 return templ_7745c5c3_Err
621 }663 }
622 if l.Passkeys {664 if l.Passkeys {
623- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<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> ")665+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<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> ")
624 if templ_7745c5c3_Err != nil {666 if templ_7745c5c3_Err != nil {
625 return templ_7745c5c3_Err667 return templ_7745c5c3_Err
626 }668 }
627 }669 }
628- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<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>")670+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<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>")
629 if templ_7745c5c3_Err != nil {671 if templ_7745c5c3_Err != nil {
630 return templ_7745c5c3_Err672 return templ_7745c5c3_Err
631 }673 }
632 } else if l.Passkeys {674 } else if l.Passkeys {
633- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<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>")675+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<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>")
634 if templ_7745c5c3_Err != nil {676 if templ_7745c5c3_Err != nil {
635 return templ_7745c5c3_Err677 return templ_7745c5c3_Err
636 }678 }
637 }679 }
638- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</div></div>")680+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</div></div>")
639 if templ_7745c5c3_Err != nil {681 if templ_7745c5c3_Err != nil {
640 return templ_7745c5c3_Err682 return templ_7745c5c3_Err
641 }683 }
642 if len(l.Sections) > 0 {684 if len(l.Sections) > 0 {
643- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<nav class=\"tabs\" aria-label=\"Sections\"><ul>")685+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<nav class=\"tabs\" aria-label=\"Sections\"><ul>")
644 if templ_7745c5c3_Err != nil {686 if templ_7745c5c3_Err != nil {
645 return templ_7745c5c3_Err687 return templ_7745c5c3_Err
646 }688 }
647 for _, s := range l.Sections {689 for _, s := range l.Sections {
648- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<li><a href=\"")690+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<li><a href=\"")
649 if templ_7745c5c3_Err != nil {691 if templ_7745c5c3_Err != nil {
650 return templ_7745c5c3_Err692 return templ_7745c5c3_Err
651 }693 }
652- var templ_7745c5c3_Var33 templ.SafeURL694+ var templ_7745c5c3_Var35 templ.SafeURL
653- templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href))695+ templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href))
654 if templ_7745c5c3_Err != nil {696 if templ_7745c5c3_Err != nil {
655- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 154, Col: 36}697+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 167, Col: 36}
656 }698 }
657- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))699+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
658 if templ_7745c5c3_Err != nil {700 if templ_7745c5c3_Err != nil {
659 return templ_7745c5c3_Err701 return templ_7745c5c3_Err
660 }702 }
661- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\" data-key=\"")703+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\" data-key=\"")
662 if templ_7745c5c3_Err != nil {704 if templ_7745c5c3_Err != nil {
663 return templ_7745c5c3_Err705 return templ_7745c5c3_Err
664 }706 }
665- var templ_7745c5c3_Var34 string707+ var templ_7745c5c3_Var36 string
666- templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)708+ templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
667 if templ_7745c5c3_Err != nil {709 if templ_7745c5c3_Err != nil {
668- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 155, Col: 24}710+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 168, Col: 24}
669 }711 }
670- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)712+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
671 if templ_7745c5c3_Err != nil {713 if templ_7745c5c3_Err != nil {
672 return templ_7745c5c3_Err714 return templ_7745c5c3_Err
673 }715 }
674- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\" aria-keyshortcuts=\"")716+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\" aria-keyshortcuts=\"")
675 if templ_7745c5c3_Err != nil {717 if templ_7745c5c3_Err != nil {
676 return templ_7745c5c3_Err718 return templ_7745c5c3_Err
677 }719 }
678- var templ_7745c5c3_Var35 string720+ var templ_7745c5c3_Var37 string
679- templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)721+ templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
680 if templ_7745c5c3_Err != nil {722 if templ_7745c5c3_Err != nil {
681- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 156, Col: 33}723+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 169, Col: 33}
682 }724 }
683- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)725+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
684 if templ_7745c5c3_Err != nil {726 if templ_7745c5c3_Err != nil {
685 return templ_7745c5c3_Err727 return templ_7745c5c3_Err
686 }728 }
687- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\"")729+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\"")
688 if templ_7745c5c3_Err != nil {730 if templ_7745c5c3_Err != nil {
689 return templ_7745c5c3_Err731 return templ_7745c5c3_Err
690 }732 }
691 if s.Current {733 if s.Current {
692- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " aria-current=\"page\"")734+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, " aria-current=\"page\"")
693 if templ_7745c5c3_Err != nil {735 if templ_7745c5c3_Err != nil {
694 return templ_7745c5c3_Err736 return templ_7745c5c3_Err
695 }737 }
696 }738 }
697- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, ">")739+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, ">")
698 if templ_7745c5c3_Err != nil {740 if templ_7745c5c3_Err != nil {
699 return templ_7745c5c3_Err741 return templ_7745c5c3_Err
700 }742 }
701- var templ_7745c5c3_Var36 string743+ var templ_7745c5c3_Var38 string
702- templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label)744+ templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label)
703 if templ_7745c5c3_Err != nil {745 if templ_7745c5c3_Err != nil {
704- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 161, Col: 17}746+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 174, Col: 17}
705 }747 }
706- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))748+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
707 if templ_7745c5c3_Err != nil {749 if templ_7745c5c3_Err != nil {
708 return templ_7745c5c3_Err750 return templ_7745c5c3_Err
709 }751 }
710- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, " ")752+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, " ")
711 if templ_7745c5c3_Err != nil {753 if templ_7745c5c3_Err != nil {
712 return templ_7745c5c3_Err754 return templ_7745c5c3_Err
713 }755 }
714 if s.Count != "" {756 if s.Count != "" {
715- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<span class=\"count\">")757+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<span class=\"count\">")
716 if templ_7745c5c3_Err != nil {758 if templ_7745c5c3_Err != nil {
717 return templ_7745c5c3_Err759 return templ_7745c5c3_Err
718 }760 }
719- var templ_7745c5c3_Var37 string761+ var templ_7745c5c3_Var39 string
720- templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(s.Count)762+ templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(s.Count)
721 if templ_7745c5c3_Err != nil {763 if templ_7745c5c3_Err != nil {
722- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 163, Col: 38}764+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 176, Col: 38}
723 }765 }
724- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))766+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
725 if templ_7745c5c3_Err != nil {767 if templ_7745c5c3_Err != nil {
726 return templ_7745c5c3_Err768 return templ_7745c5c3_Err
727 }769 }
728- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<span class=\"sr\">")770+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<span class=\"sr\">")
729 if templ_7745c5c3_Err != nil {771 if templ_7745c5c3_Err != nil {
730 return templ_7745c5c3_Err772 return templ_7745c5c3_Err
731 }773 }
732- var templ_7745c5c3_Var38 string774+ var templ_7745c5c3_Var40 string
733- templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(" open")775+ templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(" open")
734 if templ_7745c5c3_Err != nil {776 if templ_7745c5c3_Err != nil {
735- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 163, Col: 66}777+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 176, Col: 66}
736 }778 }
737- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))779+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
738 if templ_7745c5c3_Err != nil {780 if templ_7745c5c3_Err != nil {
739 return templ_7745c5c3_Err781 return templ_7745c5c3_Err
740 }782 }
741- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</span></span> ")783+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</span></span> ")
742 if templ_7745c5c3_Err != nil {784 if templ_7745c5c3_Err != nil {
743 return templ_7745c5c3_Err785 return templ_7745c5c3_Err
744 }786 }
745 }787 }
746- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<kbd aria-hidden=\"true\">")788+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<kbd aria-hidden=\"true\">")
747 if templ_7745c5c3_Err != nil {789 if templ_7745c5c3_Err != nil {
748 return templ_7745c5c3_Err790 return templ_7745c5c3_Err
749 }791 }
750- var templ_7745c5c3_Var39 string792+ var templ_7745c5c3_Var41 string
751- templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(s.Key)793+ templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(s.Key)
752 if templ_7745c5c3_Err != nil {794 if templ_7745c5c3_Err != nil {
753- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 165, Col: 39}795+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 178, Col: 39}
754 }796 }
755- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))797+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
756 if templ_7745c5c3_Err != nil {798 if templ_7745c5c3_Err != nil {
757 return templ_7745c5c3_Err799 return templ_7745c5c3_Err
758 }800 }
759- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</kbd></a></li>")801+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</kbd></a></li>")
760 if templ_7745c5c3_Err != nil {802 if templ_7745c5c3_Err != nil {
761 return templ_7745c5c3_Err803 return templ_7745c5c3_Err
762 }804 }
763 }805 }
764- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</ul></nav>")806+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</ul></nav>")
765 if templ_7745c5c3_Err != nil {807 if templ_7745c5c3_Err != nil {
766 return templ_7745c5c3_Err808 return templ_7745c5c3_Err
767 }809 }
768 }810 }
769- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</header>")811+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</header>")
770 if templ_7745c5c3_Err != nil {812 if templ_7745c5c3_Err != nil {
771 return templ_7745c5c3_Err813 return templ_7745c5c3_Err
772 }814 }
@@ -792,38 +834,38 @@ func Footer(l Layout) templ.Component {
792 }()834 }()
793 }835 }
794 ctx = templ.InitializeContext(ctx)836 ctx = templ.InitializeContext(ctx)
795- templ_7745c5c3_Var40 := templ.GetChildren(ctx)837+ templ_7745c5c3_Var42 := templ.GetChildren(ctx)
796- if templ_7745c5c3_Var40 == nil {838+ if templ_7745c5c3_Var42 == nil {
797- templ_7745c5c3_Var40 = templ.NopComponent839+ templ_7745c5c3_Var42 = templ.NopComponent
798 }840 }
799 ctx = templ.ClearChildren(ctx)841 ctx = templ.ClearChildren(ctx)
800- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<footer class=\"foot\"><span>ermite ")842+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<footer class=\"foot\"><span>ermite ")
801 if templ_7745c5c3_Err != nil {843 if templ_7745c5c3_Err != nil {
802 return templ_7745c5c3_Err844 return templ_7745c5c3_Err
803 }845 }
804- var templ_7745c5c3_Var41 string846+ var templ_7745c5c3_Var43 string
805- templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version)847+ templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version)
806 if templ_7745c5c3_Err != nil {848 if templ_7745c5c3_Err != nil {
807- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 179, Col: 26}849+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 192, Col: 26}
808 }850 }
809- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))851+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
810 if templ_7745c5c3_Err != nil {852 if templ_7745c5c3_Err != nil {
811 return templ_7745c5c3_Err853 return templ_7745c5c3_Err
812 }854 }
813- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</span> <a href=\"")855+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</span> <a href=\"")
814 if templ_7745c5c3_Err != nil {856 if templ_7745c5c3_Err != nil {
815 return templ_7745c5c3_Err857 return templ_7745c5c3_Err
816 }858 }
817- var templ_7745c5c3_Var42 templ.SafeURL859+ var templ_7745c5c3_Var44 templ.SafeURL
818- templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL))860+ templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL))
819 if templ_7745c5c3_Err != nil {861 if templ_7745c5c3_Err != nil {
820- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 180, Col: 38}862+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 193, Col: 38}
821 }863 }
822- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))864+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
823 if templ_7745c5c3_Err != nil {865 if templ_7745c5c3_Err != nil {
824 return templ_7745c5c3_Err866 return templ_7745c5c3_Err
825 }867 }
826- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "\" 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>")868+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "\" 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>")
827 if templ_7745c5c3_Err != nil {869 if templ_7745c5c3_Err != nil {
828 return templ_7745c5c3_Err870 return templ_7745c5c3_Err
829 }871 }
@@ -851,12 +893,12 @@ func Hint(l Layout) templ.Component {
851 }()893 }()
852 }894 }
853 ctx = templ.InitializeContext(ctx)895 ctx = templ.InitializeContext(ctx)
854- templ_7745c5c3_Var43 := templ.GetChildren(ctx)896+ templ_7745c5c3_Var45 := templ.GetChildren(ctx)
855- if templ_7745c5c3_Var43 == nil {897+ if templ_7745c5c3_Var45 == nil {
856- templ_7745c5c3_Var43 = templ.NopComponent898+ templ_7745c5c3_Var45 = templ.NopComponent
857 }899 }
858 ctx = templ.ClearChildren(ctx)900 ctx = templ.ClearChildren(ctx)
859- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<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>")901+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<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>")
860 if templ_7745c5c3_Err != nil {902 if templ_7745c5c3_Err != nil {
861 return templ_7745c5c3_Err903 return templ_7745c5c3_Err
862 }904 }
@@ -864,7 +906,7 @@ func Hint(l Layout) templ.Component {
864 if templ_7745c5c3_Err != nil {906 if templ_7745c5c3_Err != nil {
865 return templ_7745c5c3_Err907 return templ_7745c5c3_Err
866 }908 }
867- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</div>")909+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</div>")
868 if templ_7745c5c3_Err != nil {910 if templ_7745c5c3_Err != nil {
869 return templ_7745c5c3_Err911 return templ_7745c5c3_Err
870 }912 }
M internal/adapters/web/static/css/20-shell.css +9 -0
@@ -29,6 +29,7 @@
2929 .i-cards { --i: url(icons/cards.svg); }
3030 .i-split { --i: url(icons/split.svg); }
3131 .i-compact { --i: url(icons/compact.svg); }
32+ .i-mailbox { --i: url(icons/mailbox.svg); }
3233
3334 .pill {
3435 display: inline-flex; align-items: center; height: 20px; padding: 0 var(--s2); margin-left: var(--s1);
@@ -77,6 +78,11 @@
7778 }
7879 /* the hermit by his fire: a grey picture in its own rounded square */
7980 .brand-mark { width: 32px; height: 32px; flex: none; background: url(icons/brand.svg) center / contain no-repeat; }
81+ /* the inbox, right of the logo on the home page: its number in a darker badge */
82+ .inbox-btn {
83+ flex: none;
84+ & .count { margin-left: 0; color: var(--ink-hi); background: var(--bg-canvas); box-shadow: inset 0 0 0 1px var(--border); }
85+ }
8086 .crumbs {
8187 min-width: 0; flex: 1 1 auto; overflow: hidden;
8288 & ol { display: flex; align-items: center; min-width: 0; font-family: var(--mono); font-size: var(--fs); }
@@ -133,6 +139,9 @@
133139 align-content: start;
134140 }
135141 .work.has-rail { grid-template-columns: minmax(0, 1fr) var(--rail); }
142+ /* the wide mode (w, ermiteWide): every page takes the whole width of the
143+ screen; the rail goes to the right edge, the content takes the rest */
144+ html.wide :is(.work, .hdr-top, .tabs, .foot) { max-width: none; }
136145 .rail { display: flex; flex-direction: column; gap: var(--s4); min-width: 0; }
137146 .work-lede { grid-column: 1 / -1; margin-bottom: calc(-1 * var(--s2)); }
138147 main > * + * { margin-top: var(--s4); }
@@ -29,6 +29,7 @@
29 .i-cards { --i: url(icons/cards.svg); }29 .i-cards { --i: url(icons/cards.svg); }
30 .i-split { --i: url(icons/split.svg); }30 .i-split { --i: url(icons/split.svg); }
31 .i-compact { --i: url(icons/compact.svg); }31 .i-compact { --i: url(icons/compact.svg); }
32+ .i-mailbox { --i: url(icons/mailbox.svg); }
32 33
33 .pill {34 .pill {
34 display: inline-flex; align-items: center; height: 20px; padding: 0 var(--s2); margin-left: var(--s1);35 display: inline-flex; align-items: center; height: 20px; padding: 0 var(--s2); margin-left: var(--s1);
@@ -77,6 +78,11 @@
77 }78 }
78 /* the hermit by his fire: a grey picture in its own rounded square */79 /* the hermit by his fire: a grey picture in its own rounded square */
79 .brand-mark { width: 32px; height: 32px; flex: none; background: url(icons/brand.svg) center / contain no-repeat; }80 .brand-mark { width: 32px; height: 32px; flex: none; background: url(icons/brand.svg) center / contain no-repeat; }
81+ /* the inbox, right of the logo on the home page: its number in a darker badge */
82+ .inbox-btn {
83+ flex: none;
84+ & .count { margin-left: 0; color: var(--ink-hi); background: var(--bg-canvas); box-shadow: inset 0 0 0 1px var(--border); }
85+ }
80 .crumbs {86 .crumbs {
81 min-width: 0; flex: 1 1 auto; overflow: hidden;87 min-width: 0; flex: 1 1 auto; overflow: hidden;
82 & ol { display: flex; align-items: center; min-width: 0; font-family: var(--mono); font-size: var(--fs); }88 & ol { display: flex; align-items: center; min-width: 0; font-family: var(--mono); font-size: var(--fs); }
@@ -133,6 +139,9 @@
133 align-content: start;139 align-content: start;
134 }140 }
135 .work.has-rail { grid-template-columns: minmax(0, 1fr) var(--rail); }141 .work.has-rail { grid-template-columns: minmax(0, 1fr) var(--rail); }
142+ /* the wide mode (w, ermiteWide): every page takes the whole width of the
143+ screen; the rail goes to the right edge, the content takes the rest */
144+ html.wide :is(.work, .hdr-top, .tabs, .foot) { max-width: none; }
136 .rail { display: flex; flex-direction: column; gap: var(--s4); min-width: 0; }145 .rail { display: flex; flex-direction: column; gap: var(--s4); min-width: 0; }
137 .work-lede { grid-column: 1 / -1; margin-bottom: calc(-1 * var(--s2)); }146 .work-lede { grid-column: 1 / -1; margin-bottom: calc(-1 * var(--s2)); }
138 main > * + * { margin-top: var(--s4); }147 main > * + * { margin-top: var(--s4); }
M internal/adapters/web/static/css/50-code.css +7 -4
@@ -79,13 +79,16 @@
7979 & .ln { width: calc(var(--lnw) + 2ch); min-width: calc(var(--lnw) + 2ch); padding: 0 1ch; text-align: right; color: var(--ink-mute); user-select: none; }
8080 & .m { width: 2ch; text-align: center; user-select: none; color: var(--ink-dim); }
8181 & .tx { white-space: pre; }
82- & .hunk td { color: var(--ink-mute); background: var(--paper-alt); padding: 0 1ch; }
82+ /* the hunk headers and the hidden lines stay quiet: a faint band between
83+ the code, so that the eye keeps reading */
84+ & .hunk td, & .gap td { background: color-mix(in srgb, var(--paper-alt) 35%, var(--paper)); }
85+ & .hunk td { color: var(--ink-mute); padding: 0 1ch; }
8386 /* a run of hidden lines: the whole row loads them */
84- & .gap td { padding: 0; background: var(--paper-alt); }
87+ & .gap td { padding: 0; }
8588 & .gap button {
8689 display: block; width: 100%; padding: 2px 1ch; text-align: left; cursor: pointer;
87- font: inherit; color: var(--ink-dim); background: none; border: 0;
88- border-block: 1px dashed var(--border);
90+ font: inherit; color: var(--ink-mute); background: none; border: 0;
91+ border-block: 1px dashed color-mix(in srgb, var(--border) 60%, transparent);
8992 }
9093 & .gap button:hover, & .gap button:focus-visible { color: var(--ink-hi); background: var(--bg-strong); }
9194 & .gap button:disabled { cursor: progress; opacity: .6; }
@@ -79,13 +79,16 @@
79 & .ln { width: calc(var(--lnw) + 2ch); min-width: calc(var(--lnw) + 2ch); padding: 0 1ch; text-align: right; color: var(--ink-mute); user-select: none; }79 & .ln { width: calc(var(--lnw) + 2ch); min-width: calc(var(--lnw) + 2ch); padding: 0 1ch; text-align: right; color: var(--ink-mute); user-select: none; }
80 & .m { width: 2ch; text-align: center; user-select: none; color: var(--ink-dim); }80 & .m { width: 2ch; text-align: center; user-select: none; color: var(--ink-dim); }
81 & .tx { white-space: pre; }81 & .tx { white-space: pre; }
82- & .hunk td { color: var(--ink-mute); background: var(--paper-alt); padding: 0 1ch; }82+ /* the hunk headers and the hidden lines stay quiet: a faint band between
83+ the code, so that the eye keeps reading */
84+ & .hunk td, & .gap td { background: color-mix(in srgb, var(--paper-alt) 35%, var(--paper)); }
85+ & .hunk td { color: var(--ink-mute); padding: 0 1ch; }
83 /* a run of hidden lines: the whole row loads them */86 /* a run of hidden lines: the whole row loads them */
84- & .gap td { padding: 0; background: var(--paper-alt); }87+ & .gap td { padding: 0; }
85 & .gap button {88 & .gap button {
86 display: block; width: 100%; padding: 2px 1ch; text-align: left; cursor: pointer;89 display: block; width: 100%; padding: 2px 1ch; text-align: left; cursor: pointer;
87- font: inherit; color: var(--ink-dim); background: none; border: 0;90+ font: inherit; color: var(--ink-mute); background: none; border: 0;
88- border-block: 1px dashed var(--border);91+ border-block: 1px dashed color-mix(in srgb, var(--border) 60%, transparent);
89 }92 }
90 & .gap button:hover, & .gap button:focus-visible { color: var(--ink-hi); background: var(--bg-strong); }93 & .gap button:hover, & .gap button:focus-visible { color: var(--ink-hi); background: var(--bg-strong); }
91 & .gap button:disabled { cursor: progress; opacity: .6; }94 & .gap button:disabled { cursor: progress; opacity: .6; }
M internal/adapters/web/static/css/55-diff.css +25 -0
@@ -9,4 +9,29 @@
99
1010 @layer components {
1111 .sel { outline: 1px solid var(--ink-dim); outline-offset: -1px; }
12+
13+ /* a range of selected diff lines (shift + click) is one block: one border
14+ around the run, none between its lines. Each edge is an inset shadow of
15+ the cells; the top one only on the first line of the run, the bottom one
16+ only on the last. In split, each side is a block of its own. */
17+ .dt {
18+ --sel-edge: var(--ink-dim);
19+ & .sel { outline: none; }
20+ & tr.sel > td, & td.sel {
21+ box-shadow: var(--sel-l, 0 0 transparent), var(--sel-r, 0 0 transparent), var(--sel-t, 0 0 transparent), var(--sel-b, 0 0 transparent);
22+ --sel-t: inset 0 1px var(--sel-edge); --sel-b: inset 0 -1px var(--sel-edge);
23+ }
24+ /* unified: the row is selected */
25+ & tr.sel > td:first-child { --sel-l: inset 1px 0 var(--sel-edge); }
26+ & tr.sel > td:last-child { --sel-r: inset -1px 0 var(--sel-edge); }
27+ & tr.sel + tr.sel > td { --sel-t: 0 0 transparent; }
28+ & tr.sel:has(+ tr.sel) > td { --sel-b: 0 0 transparent; }
29+ /* split: the cells of one side (number, mark, text) are selected */
30+ & td.sel.ln { --sel-l: inset 1px 0 var(--sel-edge); }
31+ & td.sel.tx { --sel-r: inset -1px 0 var(--sel-edge); }
32+ & tr:has(> :nth-child(3).sel) + tr > :nth-child(-n + 3).sel,
33+ & tr:has(> :nth-child(6).sel) + tr > :nth-child(n + 4).sel { --sel-t: 0 0 transparent; }
34+ & tr:has(> :nth-child(3).sel):has(+ tr > :nth-child(3).sel) > :nth-child(-n + 3).sel,
35+ & tr:has(> :nth-child(6).sel):has(+ tr > :nth-child(6).sel) > :nth-child(n + 4).sel { --sel-b: 0 0 transparent; }
36+ }
1237 }
@@ -9,4 +9,29 @@
9 9
10 @layer components {10 @layer components {
11 .sel { outline: 1px solid var(--ink-dim); outline-offset: -1px; }11 .sel { outline: 1px solid var(--ink-dim); outline-offset: -1px; }
12+
13+ /* a range of selected diff lines (shift + click) is one block: one border
14+ around the run, none between its lines. Each edge is an inset shadow of
15+ the cells; the top one only on the first line of the run, the bottom one
16+ only on the last. In split, each side is a block of its own. */
17+ .dt {
18+ --sel-edge: var(--ink-dim);
19+ & .sel { outline: none; }
20+ & tr.sel > td, & td.sel {
21+ box-shadow: var(--sel-l, 0 0 transparent), var(--sel-r, 0 0 transparent), var(--sel-t, 0 0 transparent), var(--sel-b, 0 0 transparent);
22+ --sel-t: inset 0 1px var(--sel-edge); --sel-b: inset 0 -1px var(--sel-edge);
23+ }
24+ /* unified: the row is selected */
25+ & tr.sel > td:first-child { --sel-l: inset 1px 0 var(--sel-edge); }
26+ & tr.sel > td:last-child { --sel-r: inset -1px 0 var(--sel-edge); }
27+ & tr.sel + tr.sel > td { --sel-t: 0 0 transparent; }
28+ & tr.sel:has(+ tr.sel) > td { --sel-b: 0 0 transparent; }
29+ /* split: the cells of one side (number, mark, text) are selected */
30+ & td.sel.ln { --sel-l: inset 1px 0 var(--sel-edge); }
31+ & td.sel.tx { --sel-r: inset -1px 0 var(--sel-edge); }
32+ & tr:has(> :nth-child(3).sel) + tr > :nth-child(-n + 3).sel,
33+ & tr:has(> :nth-child(6).sel) + tr > :nth-child(n + 4).sel { --sel-t: 0 0 transparent; }
34+ & tr:has(> :nth-child(3).sel):has(+ tr > :nth-child(3).sel) > :nth-child(-n + 3).sel,
35+ & tr:has(> :nth-child(6).sel):has(+ tr > :nth-child(6).sel) > :nth-child(n + 4).sel { --sel-b: 0 0 transparent; }
36+ }
12 }37 }
M internal/adapters/web/static/css/60-mr.css +15 -1
@@ -1,6 +1,12 @@
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+ /* one card: the header (title, facts) a shade lighter, the description below */
5+ .mr-card {
6+ background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); overflow: hidden;
7+ & > .mr-h { background: var(--bg-raised); border: 0; border-radius: 0; padding-bottom: var(--s3); }
8+ & > .mr-desc.prose { border: 0; border-top: 1px solid var(--border); border-radius: 0; }
9+ }
410
511 .mr-state[data-state="open"]::before { content: "● "; color: var(--teal); }
612 .mr-state[data-state="merged"]::before { content: "✓ "; color: var(--teal); }
@@ -49,6 +55,15 @@
4955 }
5056 & .kv { margin: 0; }
5157 }
58+ /* the view menu of a diff: the layout and the fold, one row each */
59+ .vmenu {
60+ & > summary::after { content: ""; width: 0; height: 0; margin-left: 2px; border: 4px solid transparent; border-top-color: currentColor; transform: translateY(2px); }
61+ & .menu-pop { left: 0; right: auto; width: 220px; gap: var(--s1); padding: var(--s2); }
62+ & .menu-pop .btn { width: 100%; justify-content: flex-start; border-color: transparent; background: none; }
63+ & .menu-pop .btn:hover { background: var(--bg-raised); }
64+ & .menu-pop .btn[aria-current="true"] { background: var(--bg-raised); color: var(--ink-hi); }
65+ & .menu-pop .btn kbd { margin-left: auto; }
66+ }
5267 .mmenu {
5368 & .d-modes { display: grid; gap: var(--s1); border: 0; padding: 0; margin: 0; }
5469 & .d-modes label, & .d-check {
@@ -207,7 +222,6 @@
207222 & .inbox-counts strong { color: var(--ink-hi); font-family: var(--mono); }
208223 & .since { align-self: flex-start; max-width: 100%; }
209224 }
210- .inbox-btn { gap: var(--s2); }
211225
212226 @media (max-width: 700px) {
213227 .list.mrs .c-who { display: none; }
@@ -1,6 +1,12 @@
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+ /* one card: the header (title, facts) a shade lighter, the description below */
5+ .mr-card {
6+ background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); overflow: hidden;
7+ & > .mr-h { background: var(--bg-raised); border: 0; border-radius: 0; padding-bottom: var(--s3); }
8+ & > .mr-desc.prose { border: 0; border-top: 1px solid var(--border); border-radius: 0; }
9+ }
4 10
5 .mr-state[data-state="open"]::before { content: "● "; color: var(--teal); }11 .mr-state[data-state="open"]::before { content: "● "; color: var(--teal); }
6 .mr-state[data-state="merged"]::before { content: "✓ "; color: var(--teal); }12 .mr-state[data-state="merged"]::before { content: "✓ "; color: var(--teal); }
@@ -49,6 +55,15 @@
49 }55 }
50 & .kv { margin: 0; }56 & .kv { margin: 0; }
51 }57 }
58+ /* the view menu of a diff: the layout and the fold, one row each */
59+ .vmenu {
60+ & > summary::after { content: ""; width: 0; height: 0; margin-left: 2px; border: 4px solid transparent; border-top-color: currentColor; transform: translateY(2px); }
61+ & .menu-pop { left: 0; right: auto; width: 220px; gap: var(--s1); padding: var(--s2); }
62+ & .menu-pop .btn { width: 100%; justify-content: flex-start; border-color: transparent; background: none; }
63+ & .menu-pop .btn:hover { background: var(--bg-raised); }
64+ & .menu-pop .btn[aria-current="true"] { background: var(--bg-raised); color: var(--ink-hi); }
65+ & .menu-pop .btn kbd { margin-left: auto; }
66+ }
52 .mmenu {67 .mmenu {
53 & .d-modes { display: grid; gap: var(--s1); border: 0; padding: 0; margin: 0; }68 & .d-modes { display: grid; gap: var(--s1); border: 0; padding: 0; margin: 0; }
54 & .d-modes label, & .d-check {69 & .d-modes label, & .d-check {
@@ -207,7 +222,6 @@
207 & .inbox-counts strong { color: var(--ink-hi); font-family: var(--mono); }222 & .inbox-counts strong { color: var(--ink-hi); font-family: var(--mono); }
208 & .since { align-self: flex-start; max-width: 100%; }223 & .since { align-self: flex-start; max-width: 100%; }
209 }224 }
210- .inbox-btn { gap: var(--s2); }
211 225
212 @media (max-width: 700px) {226 @media (max-width: 700px) {
213 .list.mrs .c-who { display: none; }227 .list.mrs .c-who { display: none; }
A internal/adapters/web/static/icons/mailbox.svg +1 -0
@@ -0,0 +1,1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 17a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9.5C2 7 4 5 6.5 5H18c2.2 0 4 1.8 4 4v8Z"/><polyline points="15,9 18,9 18,11"/><path d="M6.5 5C9 5 11 7 11 9.5V17a2 2 0 0 1-2 2"/><line x1="6" x2="7" y1="10" y2="10"/></svg>
@@ -0,0 +1,1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 17a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9.5C2 7 4 5 6.5 5H18c2.2 0 4 1.8 4 4v8Z"/><polyline points="15,9 18,9 18,11"/><path d="M6.5 5C9 5 11 7 11 9.5V17a2 2 0 0 1-2 2"/><line x1="6" x2="7" y1="10" y2="10"/></svg>
M internal/adapters/web/static/js/ermite.js +70 -3
@@ -14,6 +14,7 @@
1414 // 7. The service worker (ermiteInstall).
1515 // 8. Chip lists kept on one line, with a … chip (ermiteFit).
1616 // 9. The layout of the home page, remembered in the browser (ermiteLayout).
17+// 10. The wide mode: every page takes the whole width (ermiteWide).
1718 //
1819 // Preferences are kept by ermiteStore (store.js, loaded first).
1920 (() => {
@@ -400,6 +401,25 @@
400401 // file whose lines are all loaded has no gap left.
401402 window.ermiteGaps = (() => {
402403 const pending = new Map();
404+ // selectLoaded marks the loaded rows that fall in the range of lines the
405+ // URL selects (?on=new:21-149:a.go, a shift + click), as the server marks
406+ // the rows it sent: the range stays one block once its hidden lines show.
407+ // The loaded rows are context lines, the same on both sides.
408+ function selectLoaded(file, rows) {
409+ const m = /^(new|old):(?:(\d+)-)?(\d+):(.+)$/.exec(new URLSearchParams(location.search).get('on') || '');
410+ // data-file is the path comments name (the new path of a rename), as ?on=
411+ if (!m || file.dataset.file !== m[4]) return;
412+ const end = Number(m[3]);
413+ const start = m[2] ? Number(m[2]) : end;
414+ for (const r of rows) {
415+ const cells = [...r.querySelectorAll('td')];
416+ // unified: old, new, mark, text; split: old side, then new side
417+ const num = Number((cells.length === 4 ? cells[m[1] === 'old' ? 0 : 1] : cells[m[1] === 'old' ? 0 : 3])?.textContent.trim());
418+ if (!(num >= start && num <= end)) continue;
419+ if (cells.length === 4) r.classList.add('sel');
420+ else for (const c of cells) c.classList.add('sel');
421+ }
422+ }
403423 // load fills the gap the button is in, in both tables of its file.
404424 function load(btn) {
405425 const src = btn.dataset.src;
@@ -424,6 +444,7 @@
424444 if (!from || !at) continue;
425445 const rows = [...from.children].map((r) => r.cloneNode(true));
426446 n = Math.max(n, rows.filter((r) => !r.classList.contains('gap')).length);
447+ selectLoaded(file, rows.filter((r) => !r.classList.contains('gap')));
427448 at.replaceWith(...rows);
428449 }
429450 // The focus was on the button, which is gone: on the gap that is
@@ -553,9 +574,44 @@
553574 return { get: pref.get, set, apply, values };
554575 })();
555576
577+ // The wide mode (w, on every page): the work area takes the whole width of
578+ // the screen, the rail at the right edge and the content in the rest.
579+ // The choice is kept by ermiteStore ("wide": '1' when wide) and shown
580+ // as html.wide, which the styles follow. Another tab's change applies here
581+ // at once.
582+ window.ermiteWide = (() => {
583+ const pref = window.ermiteStore.pref('wide', { values: ['1', ''], fallback: '' });
584+ function apply() {
585+ const wide = pref.get() === '1';
586+ document.documentElement.classList.toggle?.('wide', wide);
587+ }
588+ function toggle() {
589+ pref.set(pref.get() === '1' ? '' : '1');
590+ apply();
591+ }
592+ pref.watch(apply);
593+ apply();
594+ return { toggle, apply };
595+ })();
596+
597+ // The menus of the bars (<details class="menu">: the diff view, the merge
598+ // menu): closing one hands the focus back to its button when it was inside,
599+ // so that a keyboard user keeps their place.
600+ function closeMenu(d) {
601+ const had = d.contains(document.activeElement);
602+ d.open = false;
603+ if (had) $('summary', d)?.focus();
604+ }
605+ // closeMenus closes every open menu (escape); true when one was open.
606+ function closeMenus() {
607+ const open = $$('details.menu[open]');
608+ for (const d of open) closeMenu(d);
609+ return open.length > 0;
610+ }
611+
556612 // 1. Keys.
557613 const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',
558- s: 's', t: 't', c: 'c', r: 'r', m: 'm', i: 'i', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc',
614+ 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',
559615 // the merge request page (mr.js): the merge menu, mark read
560616 M: 'merge', R: 'read' };
561617 // "g t" is the key "t": the tabs have one event each.
@@ -571,7 +627,7 @@
571627 const mr = window.ermiteMR;
572628 if (e.key === 'Escape') {
573629 pending = 0;
574- if (mr?.closeMenus() || mr?.leaveFiles()) return;
630+ if (closeMenus() || mr?.leaveFiles()) return;
575631 if (typing) e.target.blur();
576632 return ermite.fire('esc');
577633 }
@@ -611,6 +667,13 @@
611667 if (!el || e.button || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
612668 e.preventDefault();
613669 ermite.fire(el.dataset.fire);
670+ // A choice in a menu closes it; the focus goes back to its button.
671+ const menu = el.closest('details.menu');
672+ if (menu) closeMenu(menu);
673+ });
674+ // A click outside an open menu closes it.
675+ addEventListener('click', (e) => {
676+ for (const d of $$('details.menu[open]')) if (!d.contains(e.target)) d.open = false;
614677 });
615678 for (const a of $$('a[data-fire="refs"]')) {
616679 a.setAttribute('aria-haspopup', 'dialog');
@@ -773,7 +836,8 @@
773836 // internal/adapters/web/events.go). A fragment is a top-level element with an
774837 // id: it replaces the element of the page that has the same id. A fragment
775838 // marked data-gone removes it; a row of a repository the home page does not
776-// list yet is added at the top of the list. A slot (data-slot: the place of a
839+// list yet is added at the top of the list. Activity updates also move existing
840+// home rows to the top when sorted by newest activity; CI updates stay put. A slot (data-slot: the place of a
777841 // banner, a persistent status region) keeps its element and only gets the
778842 // children of the fragment, so that screen readers announce the change. A gap,
779843 // which says the page may have missed events or that what it shows changed,
@@ -872,6 +936,9 @@ window.ermiteLive = (() => {
872936 }
873937 if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', '');
874938 old.replaceWith(el);
939+ const rows = document.getElementById('repo-rows');
940+ if (rows && el.parentElement === rows && rows.dataset.sort === 'activity' &&
941+ rows.dataset.desc === 'true' && el.hasAttribute('data-activity')) rows.prepend(el);
875942 window.htmx?.process(el);
876943 fitIn(el);
877944 if (had) refocus(el, had);
@@ -14,6 +14,7 @@
14 // 7. The service worker (ermiteInstall).14 // 7. The service worker (ermiteInstall).
15 // 8. Chip lists kept on one line, with a … chip (ermiteFit).15 // 8. Chip lists kept on one line, with a … chip (ermiteFit).
16 // 9. The layout of the home page, remembered in the browser (ermiteLayout).16 // 9. The layout of the home page, remembered in the browser (ermiteLayout).
17+// 10. The wide mode: every page takes the whole width (ermiteWide).
17 //18 //
18 // Preferences are kept by ermiteStore (store.js, loaded first).19 // Preferences are kept by ermiteStore (store.js, loaded first).
19 (() => {20 (() => {
@@ -400,6 +401,25 @@
400 // file whose lines are all loaded has no gap left.401 // file whose lines are all loaded has no gap left.
401 window.ermiteGaps = (() => {402 window.ermiteGaps = (() => {
402 const pending = new Map();403 const pending = new Map();
404+ // selectLoaded marks the loaded rows that fall in the range of lines the
405+ // URL selects (?on=new:21-149:a.go, a shift + click), as the server marks
406+ // the rows it sent: the range stays one block once its hidden lines show.
407+ // The loaded rows are context lines, the same on both sides.
408+ function selectLoaded(file, rows) {
409+ const m = /^(new|old):(?:(\d+)-)?(\d+):(.+)$/.exec(new URLSearchParams(location.search).get('on') || '');
410+ // data-file is the path comments name (the new path of a rename), as ?on=
411+ if (!m || file.dataset.file !== m[4]) return;
412+ const end = Number(m[3]);
413+ const start = m[2] ? Number(m[2]) : end;
414+ for (const r of rows) {
415+ const cells = [...r.querySelectorAll('td')];
416+ // unified: old, new, mark, text; split: old side, then new side
417+ const num = Number((cells.length === 4 ? cells[m[1] === 'old' ? 0 : 1] : cells[m[1] === 'old' ? 0 : 3])?.textContent.trim());
418+ if (!(num >= start && num <= end)) continue;
419+ if (cells.length === 4) r.classList.add('sel');
420+ else for (const c of cells) c.classList.add('sel');
421+ }
422+ }
403 // load fills the gap the button is in, in both tables of its file.423 // load fills the gap the button is in, in both tables of its file.
404 function load(btn) {424 function load(btn) {
405 const src = btn.dataset.src;425 const src = btn.dataset.src;
@@ -424,6 +444,7 @@
424 if (!from || !at) continue;444 if (!from || !at) continue;
425 const rows = [...from.children].map((r) => r.cloneNode(true));445 const rows = [...from.children].map((r) => r.cloneNode(true));
426 n = Math.max(n, rows.filter((r) => !r.classList.contains('gap')).length);446 n = Math.max(n, rows.filter((r) => !r.classList.contains('gap')).length);
447+ selectLoaded(file, rows.filter((r) => !r.classList.contains('gap')));
427 at.replaceWith(...rows);448 at.replaceWith(...rows);
428 }449 }
429 // The focus was on the button, which is gone: on the gap that is450 // The focus was on the button, which is gone: on the gap that is
@@ -553,9 +574,44 @@
553 return { get: pref.get, set, apply, values };574 return { get: pref.get, set, apply, values };
554 })();575 })();
555 576
577+ // The wide mode (w, on every page): the work area takes the whole width of
578+ // the screen, the rail at the right edge and the content in the rest.
579+ // The choice is kept by ermiteStore ("wide": '1' when wide) and shown
580+ // as html.wide, which the styles follow. Another tab's change applies here
581+ // at once.
582+ window.ermiteWide = (() => {
583+ const pref = window.ermiteStore.pref('wide', { values: ['1', ''], fallback: '' });
584+ function apply() {
585+ const wide = pref.get() === '1';
586+ document.documentElement.classList.toggle?.('wide', wide);
587+ }
588+ function toggle() {
589+ pref.set(pref.get() === '1' ? '' : '1');
590+ apply();
591+ }
592+ pref.watch(apply);
593+ apply();
594+ return { toggle, apply };
595+ })();
596+
597+ // The menus of the bars (<details class="menu">: the diff view, the merge
598+ // menu): closing one hands the focus back to its button when it was inside,
599+ // so that a keyboard user keeps their place.
600+ function closeMenu(d) {
601+ const had = d.contains(document.activeElement);
602+ d.open = false;
603+ if (had) $('summary', d)?.focus();
604+ }
605+ // closeMenus closes every open menu (escape); true when one was open.
606+ function closeMenus() {
607+ const open = $$('details.menu[open]');
608+ for (const d of open) closeMenu(d);
609+ return open.length > 0;
610+ }
611+
556 // 1. Keys.612 // 1. Keys.
557 const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',613 const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',
558- s: 's', t: 't', c: 'c', r: 'r', m: 'm', i: 'i', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc',614+ 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',
559 // the merge request page (mr.js): the merge menu, mark read615 // the merge request page (mr.js): the merge menu, mark read
560 M: 'merge', R: 'read' };616 M: 'merge', R: 'read' };
561 // "g t" is the key "t": the tabs have one event each.617 // "g t" is the key "t": the tabs have one event each.
@@ -571,7 +627,7 @@
571 const mr = window.ermiteMR;627 const mr = window.ermiteMR;
572 if (e.key === 'Escape') {628 if (e.key === 'Escape') {
573 pending = 0;629 pending = 0;
574- if (mr?.closeMenus() || mr?.leaveFiles()) return;630+ if (closeMenus() || mr?.leaveFiles()) return;
575 if (typing) e.target.blur();631 if (typing) e.target.blur();
576 return ermite.fire('esc');632 return ermite.fire('esc');
577 }633 }
@@ -611,6 +667,13 @@
611 if (!el || e.button || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;667 if (!el || e.button || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
612 e.preventDefault();668 e.preventDefault();
613 ermite.fire(el.dataset.fire);669 ermite.fire(el.dataset.fire);
670+ // A choice in a menu closes it; the focus goes back to its button.
671+ const menu = el.closest('details.menu');
672+ if (menu) closeMenu(menu);
673+ });
674+ // A click outside an open menu closes it.
675+ addEventListener('click', (e) => {
676+ for (const d of $$('details.menu[open]')) if (!d.contains(e.target)) d.open = false;
614 });677 });
615 for (const a of $$('a[data-fire="refs"]')) {678 for (const a of $$('a[data-fire="refs"]')) {
616 a.setAttribute('aria-haspopup', 'dialog');679 a.setAttribute('aria-haspopup', 'dialog');
@@ -773,7 +836,8 @@
773 // internal/adapters/web/events.go). A fragment is a top-level element with an836 // internal/adapters/web/events.go). A fragment is a top-level element with an
774 // id: it replaces the element of the page that has the same id. A fragment837 // id: it replaces the element of the page that has the same id. A fragment
775 // marked data-gone removes it; a row of a repository the home page does not838 // marked data-gone removes it; a row of a repository the home page does not
776-// list yet is added at the top of the list. A slot (data-slot: the place of a839+// list yet is added at the top of the list. Activity updates also move existing
840+// home rows to the top when sorted by newest activity; CI updates stay put. A slot (data-slot: the place of a
777 // banner, a persistent status region) keeps its element and only gets the841 // banner, a persistent status region) keeps its element and only gets the
778 // children of the fragment, so that screen readers announce the change. A gap,842 // children of the fragment, so that screen readers announce the change. A gap,
779 // which says the page may have missed events or that what it shows changed,843 // which says the page may have missed events or that what it shows changed,
@@ -872,6 +936,9 @@ window.ermiteLive = (() => {
872 }936 }
873 if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', '');937 if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', '');
874 old.replaceWith(el);938 old.replaceWith(el);
939+ const rows = document.getElementById('repo-rows');
940+ if (rows && el.parentElement === rows && rows.dataset.sort === 'activity' &&
941+ rows.dataset.desc === 'true' && el.hasAttribute('data-activity')) rows.prepend(el);
875 window.htmx?.process(el);942 window.htmx?.process(el);
876 fitIn(el);943 fitIn(el);
877 if (had) refocus(el, had);944 if (had) refocus(el, had);
M internal/adapters/web/static/js/mr.js +1 -11
@@ -196,12 +196,6 @@ window.ermiteMR = (() => {
196196 $('[data-default]', m)?.focus();
197197 return true;
198198 }
199- function closeMenus() {
200- const open = $$('details.menu[open]');
201- for (const d of open) d.open = false;
202- if (open.length) $('summary', open[0])?.focus();
203- return open.length > 0;
204- }
205199 // menuKey handles a key while the merge menu is open; true when it used it.
206200 function menuKey(e) {
207201 const m = menu();
@@ -311,13 +305,9 @@ window.ermiteMR = (() => {
311305 try { sessionStorage.setItem('mr-last-line', lastLine); } catch { /* storage optional */ }
312306 if (next) { e.preventDefault(); location.assign(next); }
313307 });
314- // A click outside an open menu closes it.
315- addEventListener('click', (e) => {
316- for (const d of $$('details.menu[open]')) if (!d.contains(e.target)) d.open = false;
317- });
318308 }
319309
320- return { core, start, load, reveal, openMerge, closeMenus, menuKey, markRead, focusFiles, stepFiles, leaveFiles, foldFiles, openFile };
310+ return { core, start, load, reveal, openMerge, menuKey, markRead, focusFiles, stepFiles, leaveFiles, foldFiles, openFile };
321311 })();
322312
323313 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteMR.start());
@@ -196,12 +196,6 @@ window.ermiteMR = (() => {
196 $('[data-default]', m)?.focus();196 $('[data-default]', m)?.focus();
197 return true;197 return true;
198 }198 }
199- function closeMenus() {
200- const open = $$('details.menu[open]');
201- for (const d of open) d.open = false;
202- if (open.length) $('summary', open[0])?.focus();
203- return open.length > 0;
204- }
205 // menuKey handles a key while the merge menu is open; true when it used it.199 // menuKey handles a key while the merge menu is open; true when it used it.
206 function menuKey(e) {200 function menuKey(e) {
207 const m = menu();201 const m = menu();
@@ -311,13 +305,9 @@ window.ermiteMR = (() => {
311 try { sessionStorage.setItem('mr-last-line', lastLine); } catch { /* storage optional */ }305 try { sessionStorage.setItem('mr-last-line', lastLine); } catch { /* storage optional */ }
312 if (next) { e.preventDefault(); location.assign(next); }306 if (next) { e.preventDefault(); location.assign(next); }
313 });307 });
314- // A click outside an open menu closes it.
315- addEventListener('click', (e) => {
316- for (const d of $$('details.menu[open]')) if (!d.contains(e.target)) d.open = false;
317- });
318 }308 }
319 309
320- return { core, start, load, reveal, openMerge, closeMenus, menuKey, markRead, focusFiles, stepFiles, leaveFiles, foldFiles, openFile };310+ return { core, start, load, reveal, openMerge, menuKey, markRead, focusFiles, stepFiles, leaveFiles, foldFiles, openFile };
321 })();311 })();
322 312
323 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteMR.start());313 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteMR.start());
M internal/adapters/web/static/js/store.js +5 -0
@@ -74,3 +74,8 @@ window.ermiteStore = (() => {
7474
7575 return { get, set, remove, watch, pref };
7676 })();
77+
78+// The wide mode (ermiteWide in ermite.js) is set here, before the first paint:
79+// shell.templ loads this file without defer, so a wide page never draws narrow
80+// first.
81+if (window.ermiteStore.get('wide') === '1') document?.documentElement?.classList?.add('wide');
@@ -74,3 +74,8 @@ window.ermiteStore = (() => {
74 74
75 return { get, set, remove, watch, pref };75 return { get, set, remove, watch, pref };
76 })();76 })();
77+
78+// The wide mode (ermiteWide in ermite.js) is set here, before the first paint:
79+// shell.templ loads this file without defer, so a wide page never draws narrow
80+// first.
81+if (window.ermiteStore.get('wide') === '1') document?.documentElement?.classList?.add('wide');
M internal/adapters/web/view.go +6 -4
@@ -71,6 +71,9 @@ type Layout struct {
7171 MR bool
7272 // IssueForm loads the script of the public issue form (issue-form.js).
7373 IssueForm bool
74+ // Inbox is the button to the owner's inbox, next to the logo; set on the
75+ // home page only, for the signed-in owner.
76+ Inbox *InboxButton
7477 }
7578
7679 // Link is a plain label + href pair.
@@ -93,7 +96,7 @@ type HomeRow struct {
9396 Stats *HomeStats
9497 // Private marks a private repository; only set for a signed-in visitor.
9598 Private bool
96- // Fresh marks a row that a push just updated (live updates only).
99+ // Fresh marks a row that activity just updated (live updates only).
97100 Fresh bool
98101 // Commits are the newest of the default branch. NoCommits says there is
99102 // none (never pushed, or an empty branch); a failed read says nothing.
@@ -135,11 +138,10 @@ type SortLink struct {
135138
136139 // HomePage is the repository list.
137140 type HomePage struct {
141+ Sort string
142+ Desc bool
138143 Sorts []SortLink
139144 Rows []HomeRow
140- // Inbox is the button to the owner's inbox; nil unless the owner is signed
141- // in.
142- Inbox *InboxButton
143145 }
144146
145147 // InboxButton is the link to the inbox with the number of open items; Count is
@@ -71,6 +71,9 @@ type Layout struct {
71 MR bool71 MR bool
72 // IssueForm loads the script of the public issue form (issue-form.js).72 // IssueForm loads the script of the public issue form (issue-form.js).
73 IssueForm bool73 IssueForm bool
74+ // Inbox is the button to the owner's inbox, next to the logo; set on the
75+ // home page only, for the signed-in owner.
76+ Inbox *InboxButton
74 }77 }
75 78
76 // Link is a plain label + href pair.79 // Link is a plain label + href pair.
@@ -93,7 +96,7 @@ type HomeRow struct {
93 Stats *HomeStats96 Stats *HomeStats
94 // Private marks a private repository; only set for a signed-in visitor.97 // Private marks a private repository; only set for a signed-in visitor.
95 Private bool98 Private bool
96- // Fresh marks a row that a push just updated (live updates only).99+ // Fresh marks a row that activity just updated (live updates only).
97 Fresh bool100 Fresh bool
98 // Commits are the newest of the default branch. NoCommits says there is101 // Commits are the newest of the default branch. NoCommits says there is
99 // none (never pushed, or an empty branch); a failed read says nothing.102 // none (never pushed, or an empty branch); a failed read says nothing.
@@ -135,11 +138,10 @@ type SortLink struct {
135 138
136 // HomePage is the repository list.139 // HomePage is the repository list.
137 type HomePage struct {140 type HomePage struct {
141+ Sort string
142+ Desc bool
138 Sorts []SortLink143 Sorts []SortLink
139 Rows []HomeRow144 Rows []HomeRow
140- // Inbox is the button to the owner's inbox; nil unless the owner is signed
141- // in.
142- Inbox *InboxButton
143 }145 }
144 146
145 // InboxButton is the link to the inbox with the number of open items; Count is147 // InboxButton is the link to the inbox with the number of open items; Count is
M internal/app/testdata/web/blob-binary.html +8 -1
@@ -20,7 +20,7 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23-<script src="/-/static/js/store.HASH.js" defer>
23+<script src="/-/static/js/store.HASH.js">
2424 </script>
2525 <script src="/-/static/js/ermite.HASH.js" defer>
2626 </script>
@@ -239,6 +239,12 @@
239239 </div>
240240 <div>
241241 <dt>
242+<kbd>w</kbd>
243+</dt>
244+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
245+</div>
246+<div>
247+<dt>
242248 <kbd>y</kbd>
243249 </dt>
244250 <dd>copy a permalink pinned to the commit</dd>
@@ -416,6 +422,7 @@
416422 k-split from:body -> ermite.layout('split');
417423 k-unified from:body -> ermite.layout('unified');
418424 k-collapse from:body -> ermite.collapse();
425+ k-wide from:body -> window.ermiteWide.toggle();
419426 k-expand from:body -> ermite.expandFile();
420427 k-expandAll from:body -> ermite.expandAll();
421428 k-permalink from:body -> ermite.permalink();
@@ -20,7 +20,7 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23-<script src="/-/static/js/store.HASH.js" defer>23+<script src="/-/static/js/store.HASH.js">
24 </script>24 </script>
25 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
26 </script>26 </script>
@@ -239,6 +239,12 @@
239 </div>239 </div>
240 <div>240 <div>
241 <dt>241 <dt>
242+<kbd>w</kbd>
243+</dt>
244+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
245+</div>
246+<div>
247+<dt>
242 <kbd>y</kbd>248 <kbd>y</kbd>
243 </dt>249 </dt>
244 <dd>copy a permalink pinned to the commit</dd>250 <dd>copy a permalink pinned to the commit</dd>
@@ -416,6 +422,7 @@
416 k-split from:body -> ermite.layout('split');422 k-split from:body -> ermite.layout('split');
417 k-unified from:body -> ermite.layout('unified');423 k-unified from:body -> ermite.layout('unified');
418 k-collapse from:body -> ermite.collapse();424 k-collapse from:body -> ermite.collapse();
425+ k-wide from:body -> window.ermiteWide.toggle();
419 k-expand from:body -> ermite.expandFile();426 k-expand from:body -> ermite.expandFile();
420 k-expandAll from:body -> ermite.expandAll();427 k-expandAll from:body -> ermite.expandAll();
421 k-permalink from:body -> ermite.permalink();428 k-permalink from:body -> ermite.permalink();
M internal/app/testdata/web/blob-code.html +8 -1
@@ -20,7 +20,7 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23-<script src="/-/static/js/store.HASH.js" defer>
23+<script src="/-/static/js/store.HASH.js">
2424 </script>
2525 <script src="/-/static/js/ermite.HASH.js" defer>
2626 </script>
@@ -309,6 +309,12 @@
309309 </div>
310310 <div>
311311 <dt>
312+<kbd>w</kbd>
313+</dt>
314+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
315+</div>
316+<div>
317+<dt>
312318 <kbd>y</kbd>
313319 </dt>
314320 <dd>copy a permalink pinned to the commit</dd>
@@ -486,6 +492,7 @@
486492 k-split from:body -> ermite.layout('split');
487493 k-unified from:body -> ermite.layout('unified');
488494 k-collapse from:body -> ermite.collapse();
495+ k-wide from:body -> window.ermiteWide.toggle();
489496 k-expand from:body -> ermite.expandFile();
490497 k-expandAll from:body -> ermite.expandAll();
491498 k-permalink from:body -> ermite.permalink();
@@ -20,7 +20,7 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23-<script src="/-/static/js/store.HASH.js" defer>23+<script src="/-/static/js/store.HASH.js">
24 </script>24 </script>
25 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
26 </script>26 </script>
@@ -309,6 +309,12 @@
309 </div>309 </div>
310 <div>310 <div>
311 <dt>311 <dt>
312+<kbd>w</kbd>
313+</dt>
314+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
315+</div>
316+<div>
317+<dt>
312 <kbd>y</kbd>318 <kbd>y</kbd>
313 </dt>319 </dt>
314 <dd>copy a permalink pinned to the commit</dd>320 <dd>copy a permalink pinned to the commit</dd>
@@ -486,6 +492,7 @@
486 k-split from:body -> ermite.layout('split');492 k-split from:body -> ermite.layout('split');
487 k-unified from:body -> ermite.layout('unified');493 k-unified from:body -> ermite.layout('unified');
488 k-collapse from:body -> ermite.collapse();494 k-collapse from:body -> ermite.collapse();
495+ k-wide from:body -> window.ermiteWide.toggle();
489 k-expand from:body -> ermite.expandFile();496 k-expand from:body -> ermite.expandFile();
490 k-expandAll from:body -> ermite.expandAll();497 k-expandAll from:body -> ermite.expandAll();
491 k-permalink from:body -> ermite.permalink();498 k-permalink from:body -> ermite.permalink();
M internal/app/testdata/web/blob-empty.html +8 -1
@@ -20,7 +20,7 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23-<script src="/-/static/js/store.HASH.js" defer>
23+<script src="/-/static/js/store.HASH.js">
2424 </script>
2525 <script src="/-/static/js/ermite.HASH.js" defer>
2626 </script>
@@ -239,6 +239,12 @@
239239 </div>
240240 <div>
241241 <dt>
242+<kbd>w</kbd>
243+</dt>
244+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
245+</div>
246+<div>
247+<dt>
242248 <kbd>y</kbd>
243249 </dt>
244250 <dd>copy a permalink pinned to the commit</dd>
@@ -416,6 +422,7 @@
416422 k-split from:body -> ermite.layout('split');
417423 k-unified from:body -> ermite.layout('unified');
418424 k-collapse from:body -> ermite.collapse();
425+ k-wide from:body -> window.ermiteWide.toggle();
419426 k-expand from:body -> ermite.expandFile();
420427 k-expandAll from:body -> ermite.expandAll();
421428 k-permalink from:body -> ermite.permalink();
@@ -20,7 +20,7 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23-<script src="/-/static/js/store.HASH.js" defer>23+<script src="/-/static/js/store.HASH.js">
24 </script>24 </script>
25 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
26 </script>26 </script>
@@ -239,6 +239,12 @@
239 </div>239 </div>
240 <div>240 <div>
241 <dt>241 <dt>
242+<kbd>w</kbd>
243+</dt>
244+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
245+</div>
246+<div>
247+<dt>
242 <kbd>y</kbd>248 <kbd>y</kbd>
243 </dt>249 </dt>
244 <dd>copy a permalink pinned to the commit</dd>250 <dd>copy a permalink pinned to the commit</dd>
@@ -416,6 +422,7 @@
416 k-split from:body -> ermite.layout('split');422 k-split from:body -> ermite.layout('split');
417 k-unified from:body -> ermite.layout('unified');423 k-unified from:body -> ermite.layout('unified');
418 k-collapse from:body -> ermite.collapse();424 k-collapse from:body -> ermite.collapse();
425+ k-wide from:body -> window.ermiteWide.toggle();
419 k-expand from:body -> ermite.expandFile();426 k-expand from:body -> ermite.expandFile();
420 k-expandAll from:body -> ermite.expandAll();427 k-expandAll from:body -> ermite.expandAll();
421 k-permalink from:body -> ermite.permalink();428 k-permalink from:body -> ermite.permalink();
M internal/app/testdata/web/blob-image.html +8 -1
@@ -20,7 +20,7 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23-<script src="/-/static/js/store.HASH.js" defer>
23+<script src="/-/static/js/store.HASH.js">
2424 </script>
2525 <script src="/-/static/js/ermite.HASH.js" defer>
2626 </script>
@@ -241,6 +241,12 @@
241241 </div>
242242 <div>
243243 <dt>
244+<kbd>w</kbd>
245+</dt>
246+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
247+</div>
248+<div>
249+<dt>
244250 <kbd>y</kbd>
245251 </dt>
246252 <dd>copy a permalink pinned to the commit</dd>
@@ -418,6 +424,7 @@
418424 k-split from:body -> ermite.layout('split');
419425 k-unified from:body -> ermite.layout('unified');
420426 k-collapse from:body -> ermite.collapse();
427+ k-wide from:body -> window.ermiteWide.toggle();
421428 k-expand from:body -> ermite.expandFile();
422429 k-expandAll from:body -> ermite.expandAll();
423430 k-permalink from:body -> ermite.permalink();
@@ -20,7 +20,7 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23-<script src="/-/static/js/store.HASH.js" defer>23+<script src="/-/static/js/store.HASH.js">
24 </script>24 </script>
25 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
26 </script>26 </script>
@@ -241,6 +241,12 @@
241 </div>241 </div>
242 <div>242 <div>
243 <dt>243 <dt>
244+<kbd>w</kbd>
245+</dt>
246+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
247+</div>
248+<div>
249+<dt>
244 <kbd>y</kbd>250 <kbd>y</kbd>
245 </dt>251 </dt>
246 <dd>copy a permalink pinned to the commit</dd>252 <dd>copy a permalink pinned to the commit</dd>
@@ -418,6 +424,7 @@
418 k-split from:body -> ermite.layout('split');424 k-split from:body -> ermite.layout('split');
419 k-unified from:body -> ermite.layout('unified');425 k-unified from:body -> ermite.layout('unified');
420 k-collapse from:body -> ermite.collapse();426 k-collapse from:body -> ermite.collapse();
427+ k-wide from:body -> window.ermiteWide.toggle();
421 k-expand from:body -> ermite.expandFile();428 k-expand from:body -> ermite.expandFile();
422 k-expandAll from:body -> ermite.expandAll();429 k-expandAll from:body -> ermite.expandAll();
423 k-permalink from:body -> ermite.permalink();430 k-permalink from:body -> ermite.permalink();
M internal/app/testdata/web/blob-markdown.html +8 -1
@@ -20,7 +20,7 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23-<script src="/-/static/js/store.HASH.js" defer>
23+<script src="/-/static/js/store.HASH.js">
2424 </script>
2525 <script src="/-/static/js/ermite.HASH.js" defer>
2626 </script>
@@ -306,6 +306,12 @@
306306 </div>
307307 <div>
308308 <dt>
309+<kbd>w</kbd>
310+</dt>
311+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
312+</div>
313+<div>
314+<dt>
309315 <kbd>y</kbd>
310316 </dt>
311317 <dd>copy a permalink pinned to the commit</dd>
@@ -483,6 +489,7 @@
483489 k-split from:body -> ermite.layout('split');
484490 k-unified from:body -> ermite.layout('unified');
485491 k-collapse from:body -> ermite.collapse();
492+ k-wide from:body -> window.ermiteWide.toggle();
486493 k-expand from:body -> ermite.expandFile();
487494 k-expandAll from:body -> ermite.expandAll();
488495 k-permalink from:body -> ermite.permalink();
@@ -20,7 +20,7 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23-<script src="/-/static/js/store.HASH.js" defer>23+<script src="/-/static/js/store.HASH.js">
24 </script>24 </script>
25 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
26 </script>26 </script>
@@ -306,6 +306,12 @@
306 </div>306 </div>
307 <div>307 <div>
308 <dt>308 <dt>
309+<kbd>w</kbd>
310+</dt>
311+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
312+</div>
313+<div>
314+<dt>
309 <kbd>y</kbd>315 <kbd>y</kbd>
310 </dt>316 </dt>
311 <dd>copy a permalink pinned to the commit</dd>317 <dd>copy a permalink pinned to the commit</dd>
@@ -483,6 +489,7 @@
483 k-split from:body -> ermite.layout('split');489 k-split from:body -> ermite.layout('split');
484 k-unified from:body -> ermite.layout('unified');490 k-unified from:body -> ermite.layout('unified');
485 k-collapse from:body -> ermite.collapse();491 k-collapse from:body -> ermite.collapse();
492+ k-wide from:body -> window.ermiteWide.toggle();
486 k-expand from:body -> ermite.expandFile();493 k-expand from:body -> ermite.expandFile();
487 k-expandAll from:body -> ermite.expandAll();494 k-expandAll from:body -> ermite.expandAll();
488 k-permalink from:body -> ermite.permalink();495 k-permalink from:body -> ermite.permalink();
M internal/app/testdata/web/blob-svg.html +8 -1
@@ -20,7 +20,7 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23-<script src="/-/static/js/store.HASH.js" defer>
23+<script src="/-/static/js/store.HASH.js">
2424 </script>
2525 <script src="/-/static/js/ermite.HASH.js" defer>
2626 </script>
@@ -264,6 +264,12 @@
264264 </div>
265265 <div>
266266 <dt>
267+<kbd>w</kbd>
268+</dt>
269+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
270+</div>
271+<div>
272+<dt>
267273 <kbd>y</kbd>
268274 </dt>
269275 <dd>copy a permalink pinned to the commit</dd>
@@ -441,6 +447,7 @@
441447 k-split from:body -> ermite.layout('split');
442448 k-unified from:body -> ermite.layout('unified');
443449 k-collapse from:body -> ermite.collapse();
450+ k-wide from:body -> window.ermiteWide.toggle();
444451 k-expand from:body -> ermite.expandFile();
445452 k-expandAll from:body -> ermite.expandAll();
446453 k-permalink from:body -> ermite.permalink();
@@ -20,7 +20,7 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23-<script src="/-/static/js/store.HASH.js" defer>23+<script src="/-/static/js/store.HASH.js">
24 </script>24 </script>
25 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
26 </script>26 </script>
@@ -264,6 +264,12 @@
264 </div>264 </div>
265 <div>265 <div>
266 <dt>266 <dt>
267+<kbd>w</kbd>
268+</dt>
269+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
270+</div>
271+<div>
272+<dt>
267 <kbd>y</kbd>273 <kbd>y</kbd>
268 </dt>274 </dt>
269 <dd>copy a permalink pinned to the commit</dd>275 <dd>copy a permalink pinned to the commit</dd>
@@ -441,6 +447,7 @@
441 k-split from:body -> ermite.layout('split');447 k-split from:body -> ermite.layout('split');
442 k-unified from:body -> ermite.layout('unified');448 k-unified from:body -> ermite.layout('unified');
443 k-collapse from:body -> ermite.collapse();449 k-collapse from:body -> ermite.collapse();
450+ k-wide from:body -> window.ermiteWide.toggle();
444 k-expand from:body -> ermite.expandFile();451 k-expand from:body -> ermite.expandFile();
445 k-expandAll from:body -> ermite.expandAll();452 k-expandAll from:body -> ermite.expandAll();
446 k-permalink from:body -> ermite.permalink();453 k-permalink from:body -> ermite.permalink();
M internal/app/testdata/web/blob-text.html +8 -1
@@ -20,7 +20,7 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23-<script src="/-/static/js/store.HASH.js" defer>
23+<script src="/-/static/js/store.HASH.js">
2424 </script>
2525 <script src="/-/static/js/ermite.HASH.js" defer>
2626 </script>
@@ -259,6 +259,12 @@
259259 </div>
260260 <div>
261261 <dt>
262+<kbd>w</kbd>
263+</dt>
264+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
265+</div>
266+<div>
267+<dt>
262268 <kbd>y</kbd>
263269 </dt>
264270 <dd>copy a permalink pinned to the commit</dd>
@@ -436,6 +442,7 @@
436442 k-split from:body -> ermite.layout('split');
437443 k-unified from:body -> ermite.layout('unified');
438444 k-collapse from:body -> ermite.collapse();
445+ k-wide from:body -> window.ermiteWide.toggle();
439446 k-expand from:body -> ermite.expandFile();
440447 k-expandAll from:body -> ermite.expandAll();
441448 k-permalink from:body -> ermite.permalink();
@@ -20,7 +20,7 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23-<script src="/-/static/js/store.HASH.js" defer>23+<script src="/-/static/js/store.HASH.js">
24 </script>24 </script>
25 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
26 </script>26 </script>
@@ -259,6 +259,12 @@
259 </div>259 </div>
260 <div>260 <div>
261 <dt>261 <dt>
262+<kbd>w</kbd>
263+</dt>
264+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
265+</div>
266+<div>
267+<dt>
262 <kbd>y</kbd>268 <kbd>y</kbd>
263 </dt>269 </dt>
264 <dd>copy a permalink pinned to the commit</dd>270 <dd>copy a permalink pinned to the commit</dd>
@@ -436,6 +442,7 @@
436 k-split from:body -> ermite.layout('split');442 k-split from:body -> ermite.layout('split');
437 k-unified from:body -> ermite.layout('unified');443 k-unified from:body -> ermite.layout('unified');
438 k-collapse from:body -> ermite.collapse();444 k-collapse from:body -> ermite.collapse();
445+ k-wide from:body -> window.ermiteWide.toggle();
439 k-expand from:body -> ermite.expandFile();446 k-expand from:body -> ermite.expandFile();
440 k-expandAll from:body -> ermite.expandAll();447 k-expandAll from:body -> ermite.expandAll();
441 k-permalink from:body -> ermite.permalink();448 k-permalink from:body -> ermite.permalink();
M internal/app/testdata/web/blob-too-large.html +8 -1
@@ -20,7 +20,7 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23-<script src="/-/static/js/store.HASH.js" defer>
23+<script src="/-/static/js/store.HASH.js">
2424 </script>
2525 <script src="/-/static/js/ermite.HASH.js" defer>
2626 </script>
@@ -239,6 +239,12 @@
239239 </div>
240240 <div>
241241 <dt>
242+<kbd>w</kbd>
243+</dt>
244+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
245+</div>
246+<div>
247+<dt>
242248 <kbd>y</kbd>
243249 </dt>
244250 <dd>copy a permalink pinned to the commit</dd>
@@ -416,6 +422,7 @@
416422 k-split from:body -> ermite.layout('split');
417423 k-unified from:body -> ermite.layout('unified');
418424 k-collapse from:body -> ermite.collapse();
425+ k-wide from:body -> window.ermiteWide.toggle();
419426 k-expand from:body -> ermite.expandFile();
420427 k-expandAll from:body -> ermite.expandAll();
421428 k-permalink from:body -> ermite.permalink();
@@ -20,7 +20,7 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23-<script src="/-/static/js/store.HASH.js" defer>23+<script src="/-/static/js/store.HASH.js">
24 </script>24 </script>
25 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
26 </script>26 </script>
@@ -239,6 +239,12 @@
239 </div>239 </div>
240 <div>240 <div>
241 <dt>241 <dt>
242+<kbd>w</kbd>
243+</dt>
244+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
245+</div>
246+<div>
247+<dt>
242 <kbd>y</kbd>248 <kbd>y</kbd>
243 </dt>249 </dt>
244 <dd>copy a permalink pinned to the commit</dd>250 <dd>copy a permalink pinned to the commit</dd>
@@ -416,6 +422,7 @@
416 k-split from:body -> ermite.layout('split');422 k-split from:body -> ermite.layout('split');
417 k-unified from:body -> ermite.layout('unified');423 k-unified from:body -> ermite.layout('unified');
418 k-collapse from:body -> ermite.collapse();424 k-collapse from:body -> ermite.collapse();
425+ k-wide from:body -> window.ermiteWide.toggle();
419 k-expand from:body -> ermite.expandFile();426 k-expand from:body -> ermite.expandFile();
420 k-expandAll from:body -> ermite.expandAll();427 k-expandAll from:body -> ermite.expandAll();
421 k-permalink from:body -> ermite.permalink();428 k-permalink from:body -> ermite.permalink();
M internal/app/testdata/web/blob-trojan.html +8 -1
@@ -20,7 +20,7 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23-<script src="/-/static/js/store.HASH.js" defer>
23+<script src="/-/static/js/store.HASH.js">
2424 </script>
2525 <script src="/-/static/js/ermite.HASH.js" defer>
2626 </script>
@@ -306,6 +306,12 @@
306306 </div>
307307 <div>
308308 <dt>
309+<kbd>w</kbd>
310+</dt>
311+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
312+</div>
313+<div>
314+<dt>
309315 <kbd>y</kbd>
310316 </dt>
311317 <dd>copy a permalink pinned to the commit</dd>
@@ -483,6 +489,7 @@
483489 k-split from:body -> ermite.layout('split');
484490 k-unified from:body -> ermite.layout('unified');
485491 k-collapse from:body -> ermite.collapse();
492+ k-wide from:body -> window.ermiteWide.toggle();
486493 k-expand from:body -> ermite.expandFile();
487494 k-expandAll from:body -> ermite.expandAll();
488495 k-permalink from:body -> ermite.permalink();
@@ -20,7 +20,7 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23-<script src="/-/static/js/store.HASH.js" defer>23+<script src="/-/static/js/store.HASH.js">
24 </script>24 </script>
25 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
26 </script>26 </script>
@@ -306,6 +306,12 @@
306 </div>306 </div>
307 <div>307 <div>
308 <dt>308 <dt>
309+<kbd>w</kbd>
310+</dt>
311+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
312+</div>
313+<div>
314+<dt>
309 <kbd>y</kbd>315 <kbd>y</kbd>
310 </dt>316 </dt>
311 <dd>copy a permalink pinned to the commit</dd>317 <dd>copy a permalink pinned to the commit</dd>
@@ -483,6 +489,7 @@
483 k-split from:body -> ermite.layout('split');489 k-split from:body -> ermite.layout('split');
484 k-unified from:body -> ermite.layout('unified');490 k-unified from:body -> ermite.layout('unified');
485 k-collapse from:body -> ermite.collapse();491 k-collapse from:body -> ermite.collapse();
492+ k-wide from:body -> window.ermiteWide.toggle();
486 k-expand from:body -> ermite.expandFile();493 k-expand from:body -> ermite.expandFile();
487 k-expandAll from:body -> ermite.expandAll();494 k-expandAll from:body -> ermite.expandAll();
488 k-permalink from:body -> ermite.permalink();495 k-permalink from:body -> ermite.permalink();
M internal/app/testdata/web/commit-big.html +16 -2
@@ -20,7 +20,7 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23-<script src="/-/static/js/store.HASH.js" defer>
23+<script src="/-/static/js/store.HASH.js">
2424 </script>
2525 <script src="/-/static/js/ermite.HASH.js" defer>
2626 </script>
@@ -125,6 +125,11 @@
125125 </span>
126126 <span class="grow">
127127 </span>
128+<details class="menu vmenu">
129+<summary class="btn" aria-label="Diff view">
130+<span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>
131+</summary>
132+<div class="menu-pop" role="group" aria-label="Diff view">
128133 <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
129134 </a>
130135 <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
@@ -134,9 +139,11 @@
134139 <kbd aria-hidden="true">x</kbd>
135140 </button>
136141 </div>
142+</details>
143+</div>
137144 <p class="notice">this diff is incomplete: 1 file is too large to show. the totals above count everything; clone the repository to read the rest.</p>
138145 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
139-<details class="dfile lnw-2" id="f1" data-file>
146+<details class="dfile lnw-2" id="f1" data-file="big.txt">
140147 <summary>
141148 <span class="st">A</span>
142149 <span class="p">big.txt</span>
@@ -281,6 +288,12 @@
281288 </div>
282289 <div>
283290 <dt>
291+<kbd>w</kbd>
292+</dt>
293+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
294+</div>
295+<div>
296+<dt>
284297 <kbd>y</kbd>
285298 </dt>
286299 <dd>copy a permalink pinned to the commit</dd>
@@ -458,6 +471,7 @@
458471 k-split from:body -> ermite.layout('split');
459472 k-unified from:body -> ermite.layout('unified');
460473 k-collapse from:body -> ermite.collapse();
474+ k-wide from:body -> window.ermiteWide.toggle();
461475 k-expand from:body -> ermite.expandFile();
462476 k-expandAll from:body -> ermite.expandAll();
463477 k-permalink from:body -> ermite.permalink();
@@ -20,7 +20,7 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23-<script src="/-/static/js/store.HASH.js" defer>23+<script src="/-/static/js/store.HASH.js">
24 </script>24 </script>
25 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
26 </script>26 </script>
@@ -125,6 +125,11 @@
125 </span>125 </span>
126 <span class="grow">126 <span class="grow">
127 </span>127 </span>
128+<details class="menu vmenu">
129+<summary class="btn" aria-label="Diff view">
130+<span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>
131+</summary>
132+<div class="menu-pop" role="group" aria-label="Diff view">
128 <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>133 <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
129 </a>134 </a>
130 <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>135 <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
@@ -134,9 +139,11 @@
134 <kbd aria-hidden="true">x</kbd>139 <kbd aria-hidden="true">x</kbd>
135 </button>140 </button>
136 </div>141 </div>
142+</details>
143+</div>
137 <p class="notice">this diff is incomplete: 1 file is too large to show. the totals above count everything; clone the repository to read the rest.</p>144 <p class="notice">this diff is incomplete: 1 file is too large to show. the totals above count everything; clone the repository to read the rest.</p>
138 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">145 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
139-<details class="dfile lnw-2" id="f1" data-file>146+<details class="dfile lnw-2" id="f1" data-file="big.txt">
140 <summary>147 <summary>
141 <span class="st">A</span>148 <span class="st">A</span>
142 <span class="p">big.txt</span>149 <span class="p">big.txt</span>
@@ -281,6 +288,12 @@
281 </div>288 </div>
282 <div>289 <div>
283 <dt>290 <dt>
291+<kbd>w</kbd>
292+</dt>
293+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
294+</div>
295+<div>
296+<dt>
284 <kbd>y</kbd>297 <kbd>y</kbd>
285 </dt>298 </dt>
286 <dd>copy a permalink pinned to the commit</dd>299 <dd>copy a permalink pinned to the commit</dd>
@@ -458,6 +471,7 @@
458 k-split from:body -> ermite.layout('split');471 k-split from:body -> ermite.layout('split');
459 k-unified from:body -> ermite.layout('unified');472 k-unified from:body -> ermite.layout('unified');
460 k-collapse from:body -> ermite.collapse();473 k-collapse from:body -> ermite.collapse();
474+ k-wide from:body -> window.ermiteWide.toggle();
461 k-expand from:body -> ermite.expandFile();475 k-expand from:body -> ermite.expandFile();
462 k-expandAll from:body -> ermite.expandAll();476 k-expandAll from:body -> ermite.expandAll();
463 k-permalink from:body -> ermite.permalink();477 k-permalink from:body -> ermite.permalink();
M internal/app/testdata/web/commit-binary.html +16 -2
@@ -20,7 +20,7 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23-<script src="/-/static/js/store.HASH.js" defer>
23+<script src="/-/static/js/store.HASH.js">
2424 </script>
2525 <script src="/-/static/js/ermite.HASH.js" defer>
2626 </script>
@@ -121,6 +121,11 @@
121121 </span>
122122 <span class="grow">
123123 </span>
124+<details class="menu vmenu">
125+<summary class="btn" aria-label="Diff view">
126+<span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>
127+</summary>
128+<div class="menu-pop" role="group" aria-label="Diff view">
124129 <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
125130 </a>
126131 <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
@@ -130,8 +135,10 @@
130135 <kbd aria-hidden="true">x</kbd>
131136 </button>
132137 </div>
138+</details>
139+</div>
133140 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
134-<details class="dfile lnw-2" id="f1" data-file>
141+<details class="dfile lnw-2" id="f1" data-file="logo.bin">
135142 <summary>
136143 <span class="st">M</span>
137144 <span class="p">logo.bin</span>
@@ -273,6 +280,12 @@
273280 </div>
274281 <div>
275282 <dt>
283+<kbd>w</kbd>
284+</dt>
285+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
286+</div>
287+<div>
288+<dt>
276289 <kbd>y</kbd>
277290 </dt>
278291 <dd>copy a permalink pinned to the commit</dd>
@@ -450,6 +463,7 @@
450463 k-split from:body -> ermite.layout('split');
451464 k-unified from:body -> ermite.layout('unified');
452465 k-collapse from:body -> ermite.collapse();
466+ k-wide from:body -> window.ermiteWide.toggle();
453467 k-expand from:body -> ermite.expandFile();
454468 k-expandAll from:body -> ermite.expandAll();
455469 k-permalink from:body -> ermite.permalink();
@@ -20,7 +20,7 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23-<script src="/-/static/js/store.HASH.js" defer>23+<script src="/-/static/js/store.HASH.js">
24 </script>24 </script>
25 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
26 </script>26 </script>
@@ -121,6 +121,11 @@
121 </span>121 </span>
122 <span class="grow">122 <span class="grow">
123 </span>123 </span>
124+<details class="menu vmenu">
125+<summary class="btn" aria-label="Diff view">
126+<span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>
127+</summary>
128+<div class="menu-pop" role="group" aria-label="Diff view">
124 <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>129 <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
125 </a>130 </a>
126 <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>131 <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
@@ -130,8 +135,10 @@
130 <kbd aria-hidden="true">x</kbd>135 <kbd aria-hidden="true">x</kbd>
131 </button>136 </button>
132 </div>137 </div>
138+</details>
139+</div>
133 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">140 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
134-<details class="dfile lnw-2" id="f1" data-file>141+<details class="dfile lnw-2" id="f1" data-file="logo.bin">
135 <summary>142 <summary>
136 <span class="st">M</span>143 <span class="st">M</span>
137 <span class="p">logo.bin</span>144 <span class="p">logo.bin</span>
@@ -273,6 +280,12 @@
273 </div>280 </div>
274 <div>281 <div>
275 <dt>282 <dt>
283+<kbd>w</kbd>
284+</dt>
285+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
286+</div>
287+<div>
288+<dt>
276 <kbd>y</kbd>289 <kbd>y</kbd>
277 </dt>290 </dt>
278 <dd>copy a permalink pinned to the commit</dd>291 <dd>copy a permalink pinned to the commit</dd>
@@ -450,6 +463,7 @@
450 k-split from:body -> ermite.layout('split');463 k-split from:body -> ermite.layout('split');
451 k-unified from:body -> ermite.layout('unified');464 k-unified from:body -> ermite.layout('unified');
452 k-collapse from:body -> ermite.collapse();465 k-collapse from:body -> ermite.collapse();
466+ k-wide from:body -> window.ermiteWide.toggle();
453 k-expand from:body -> ermite.expandFile();467 k-expand from:body -> ermite.expandFile();
454 k-expandAll from:body -> ermite.expandAll();468 k-expandAll from:body -> ermite.expandAll();
455 k-permalink from:body -> ermite.permalink();469 k-permalink from:body -> ermite.permalink();
M internal/app/testdata/web/commit-merge.html +16 -2
@@ -20,7 +20,7 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23-<script src="/-/static/js/store.HASH.js" defer>
23+<script src="/-/static/js/store.HASH.js">
2424 </script>
2525 <script src="/-/static/js/ermite.HASH.js" defer>
2626 </script>
@@ -120,6 +120,11 @@
120120 </span>
121121 <span class="grow">
122122 </span>
123+<details class="menu vmenu">
124+<summary class="btn" aria-label="Diff view">
125+<span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>
126+</summary>
127+<div class="menu-pop" role="group" aria-label="Diff view">
123128 <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
124129 </a>
125130 <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
@@ -129,8 +134,10 @@
129134 <kbd aria-hidden="true">x</kbd>
130135 </button>
131136 </div>
137+</details>
138+</div>
132139 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
133-<details class="dfile lnw-2" id="f1" data-file open>
140+<details class="dfile lnw-2" id="f1" data-file="side.txt" open>
134141 <summary>
135142 <span class="st">A</span>
136143 <span class="p">side.txt</span>
@@ -315,6 +322,12 @@
315322 </div>
316323 <div>
317324 <dt>
325+<kbd>w</kbd>
326+</dt>
327+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
328+</div>
329+<div>
330+<dt>
318331 <kbd>y</kbd>
319332 </dt>
320333 <dd>copy a permalink pinned to the commit</dd>
@@ -492,6 +505,7 @@
492505 k-split from:body -> ermite.layout('split');
493506 k-unified from:body -> ermite.layout('unified');
494507 k-collapse from:body -> ermite.collapse();
508+ k-wide from:body -> window.ermiteWide.toggle();
495509 k-expand from:body -> ermite.expandFile();
496510 k-expandAll from:body -> ermite.expandAll();
497511 k-permalink from:body -> ermite.permalink();
@@ -20,7 +20,7 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23-<script src="/-/static/js/store.HASH.js" defer>23+<script src="/-/static/js/store.HASH.js">
24 </script>24 </script>
25 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
26 </script>26 </script>
@@ -120,6 +120,11 @@
120 </span>120 </span>
121 <span class="grow">121 <span class="grow">
122 </span>122 </span>
123+<details class="menu vmenu">
124+<summary class="btn" aria-label="Diff view">
125+<span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>
126+</summary>
127+<div class="menu-pop" role="group" aria-label="Diff view">
123 <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>128 <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
124 </a>129 </a>
125 <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>130 <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
@@ -129,8 +134,10 @@
129 <kbd aria-hidden="true">x</kbd>134 <kbd aria-hidden="true">x</kbd>
130 </button>135 </button>
131 </div>136 </div>
137+</details>
138+</div>
132 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">139 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
133-<details class="dfile lnw-2" id="f1" data-file open>140+<details class="dfile lnw-2" id="f1" data-file="side.txt" open>
134 <summary>141 <summary>
135 <span class="st">A</span>142 <span class="st">A</span>
136 <span class="p">side.txt</span>143 <span class="p">side.txt</span>
@@ -315,6 +322,12 @@
315 </div>322 </div>
316 <div>323 <div>
317 <dt>324 <dt>
325+<kbd>w</kbd>
326+</dt>
327+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
328+</div>
329+<div>
330+<dt>
318 <kbd>y</kbd>331 <kbd>y</kbd>
319 </dt>332 </dt>
320 <dd>copy a permalink pinned to the commit</dd>333 <dd>copy a permalink pinned to the commit</dd>
@@ -492,6 +505,7 @@
492 k-split from:body -> ermite.layout('split');505 k-split from:body -> ermite.layout('split');
493 k-unified from:body -> ermite.layout('unified');506 k-unified from:body -> ermite.layout('unified');
494 k-collapse from:body -> ermite.collapse();507 k-collapse from:body -> ermite.collapse();
508+ k-wide from:body -> window.ermiteWide.toggle();
495 k-expand from:body -> ermite.expandFile();509 k-expand from:body -> ermite.expandFile();
496 k-expandAll from:body -> ermite.expandAll();510 k-expandAll from:body -> ermite.expandAll();
497 k-permalink from:body -> ermite.permalink();511 k-permalink from:body -> ermite.permalink();
M internal/app/testdata/web/commit-modify-split.html +17 -3
@@ -20,7 +20,7 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23-<script src="/-/static/js/store.HASH.js" defer>
23+<script src="/-/static/js/store.HASH.js">
2424 </script>
2525 <script src="/-/static/js/ermite.HASH.js" defer>
2626 </script>
@@ -127,6 +127,11 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
127127 </span>
128128 <span class="grow">
129129 </span>
130+<details class="menu vmenu">
131+<summary class="btn" aria-label="Diff view">
132+<span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Split</span>
133+</summary>
134+<div class="menu-pop" role="group" aria-label="Diff view">
130135 <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
131136 </a>
132137 <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" aria-current="true" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
@@ -136,8 +141,10 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
136141 <kbd aria-hidden="true">x</kbd>
137142 </button>
138143 </div>
144+</details>
145+</div>
139146 <div id="diffs" data-diff="split">
140-<details class="dfile lnw-2" id="f1" data-file open>
147+<details class="dfile lnw-2" id="f1" data-file="a.txt" open>
141148 <summary>
142149 <span class="st">M</span>
143150 <span class="p">a.txt</span>
@@ -277,7 +284,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
277284 </table>
278285 </div>
279286 </details>
280-<details class="dfile lnw-2" id="f2" data-file>
287+<details class="dfile lnw-2" id="f2" data-file="run.sh">
281288 <summary>
282289 <span class="st">M</span>
283290 <span class="p">run.sh</span>
@@ -438,6 +445,12 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
438445 </div>
439446 <div>
440447 <dt>
448+<kbd>w</kbd>
449+</dt>
450+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
451+</div>
452+<div>
453+<dt>
441454 <kbd>y</kbd>
442455 </dt>
443456 <dd>copy a permalink pinned to the commit</dd>
@@ -615,6 +628,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
615628 k-split from:body -> ermite.layout('split');
616629 k-unified from:body -> ermite.layout('unified');
617630 k-collapse from:body -> ermite.collapse();
631+ k-wide from:body -> window.ermiteWide.toggle();
618632 k-expand from:body -> ermite.expandFile();
619633 k-expandAll from:body -> ermite.expandAll();
620634 k-permalink from:body -> ermite.permalink();
@@ -20,7 +20,7 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23-<script src="/-/static/js/store.HASH.js" defer>23+<script src="/-/static/js/store.HASH.js">
24 </script>24 </script>
25 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
26 </script>26 </script>
@@ -127,6 +127,11 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
127 </span>127 </span>
128 <span class="grow">128 <span class="grow">
129 </span>129 </span>
130+<details class="menu vmenu">
131+<summary class="btn" aria-label="Diff view">
132+<span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Split</span>
133+</summary>
134+<div class="menu-pop" role="group" aria-label="Diff view">
130 <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>135 <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
131 </a>136 </a>
132 <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" aria-current="true" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>137 <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" aria-current="true" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
@@ -136,8 +141,10 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
136 <kbd aria-hidden="true">x</kbd>141 <kbd aria-hidden="true">x</kbd>
137 </button>142 </button>
138 </div>143 </div>
144+</details>
145+</div>
139 <div id="diffs" data-diff="split">146 <div id="diffs" data-diff="split">
140-<details class="dfile lnw-2" id="f1" data-file open>147+<details class="dfile lnw-2" id="f1" data-file="a.txt" open>
141 <summary>148 <summary>
142 <span class="st">M</span>149 <span class="st">M</span>
143 <span class="p">a.txt</span>150 <span class="p">a.txt</span>
@@ -277,7 +284,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
277 </table>284 </table>
278 </div>285 </div>
279 </details>286 </details>
280-<details class="dfile lnw-2" id="f2" data-file>287+<details class="dfile lnw-2" id="f2" data-file="run.sh">
281 <summary>288 <summary>
282 <span class="st">M</span>289 <span class="st">M</span>
283 <span class="p">run.sh</span>290 <span class="p">run.sh</span>
@@ -438,6 +445,12 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
438 </div>445 </div>
439 <div>446 <div>
440 <dt>447 <dt>
448+<kbd>w</kbd>
449+</dt>
450+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
451+</div>
452+<div>
453+<dt>
441 <kbd>y</kbd>454 <kbd>y</kbd>
442 </dt>455 </dt>
443 <dd>copy a permalink pinned to the commit</dd>456 <dd>copy a permalink pinned to the commit</dd>
@@ -615,6 +628,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
615 k-split from:body -> ermite.layout('split');628 k-split from:body -> ermite.layout('split');
616 k-unified from:body -> ermite.layout('unified');629 k-unified from:body -> ermite.layout('unified');
617 k-collapse from:body -> ermite.collapse();630 k-collapse from:body -> ermite.collapse();
631+ k-wide from:body -> window.ermiteWide.toggle();
618 k-expand from:body -> ermite.expandFile();632 k-expand from:body -> ermite.expandFile();
619 k-expandAll from:body -> ermite.expandAll();633 k-expandAll from:body -> ermite.expandAll();
620 k-permalink from:body -> ermite.permalink();634 k-permalink from:body -> ermite.permalink();
M internal/app/testdata/web/commit-modify.html +17 -3
@@ -20,7 +20,7 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23-<script src="/-/static/js/store.HASH.js" defer>
23+<script src="/-/static/js/store.HASH.js">
2424 </script>
2525 <script src="/-/static/js/ermite.HASH.js" defer>
2626 </script>
@@ -127,6 +127,11 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
127127 </span>
128128 <span class="grow">
129129 </span>
130+<details class="menu vmenu">
131+<summary class="btn" aria-label="Diff view">
132+<span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>
133+</summary>
134+<div class="menu-pop" role="group" aria-label="Diff view">
130135 <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
131136 </a>
132137 <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
@@ -136,8 +141,10 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
136141 <kbd aria-hidden="true">x</kbd>
137142 </button>
138143 </div>
144+</details>
145+</div>
139146 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
140-<details class="dfile lnw-2" id="f1" data-file open>
147+<details class="dfile lnw-2" id="f1" data-file="a.txt" open>
141148 <summary>
142149 <span class="st">M</span>
143150 <span class="p">a.txt</span>
@@ -277,7 +284,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
277284 </table>
278285 </div>
279286 </details>
280-<details class="dfile lnw-2" id="f2" data-file>
287+<details class="dfile lnw-2" id="f2" data-file="run.sh">
281288 <summary>
282289 <span class="st">M</span>
283290 <span class="p">run.sh</span>
@@ -438,6 +445,12 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
438445 </div>
439446 <div>
440447 <dt>
448+<kbd>w</kbd>
449+</dt>
450+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
451+</div>
452+<div>
453+<dt>
441454 <kbd>y</kbd>
442455 </dt>
443456 <dd>copy a permalink pinned to the commit</dd>
@@ -615,6 +628,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
615628 k-split from:body -> ermite.layout('split');
616629 k-unified from:body -> ermite.layout('unified');
617630 k-collapse from:body -> ermite.collapse();
631+ k-wide from:body -> window.ermiteWide.toggle();
618632 k-expand from:body -> ermite.expandFile();
619633 k-expandAll from:body -> ermite.expandAll();
620634 k-permalink from:body -> ermite.permalink();
@@ -20,7 +20,7 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23-<script src="/-/static/js/store.HASH.js" defer>23+<script src="/-/static/js/store.HASH.js">
24 </script>24 </script>
25 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
26 </script>26 </script>
@@ -127,6 +127,11 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
127 </span>127 </span>
128 <span class="grow">128 <span class="grow">
129 </span>129 </span>
130+<details class="menu vmenu">
131+<summary class="btn" aria-label="Diff view">
132+<span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>
133+</summary>
134+<div class="menu-pop" role="group" aria-label="Diff view">
130 <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>135 <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
131 </a>136 </a>
132 <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>137 <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
@@ -136,8 +141,10 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
136 <kbd aria-hidden="true">x</kbd>141 <kbd aria-hidden="true">x</kbd>
137 </button>142 </button>
138 </div>143 </div>
144+</details>
145+</div>
139 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">146 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
140-<details class="dfile lnw-2" id="f1" data-file open>147+<details class="dfile lnw-2" id="f1" data-file="a.txt" open>
141 <summary>148 <summary>
142 <span class="st">M</span>149 <span class="st">M</span>
143 <span class="p">a.txt</span>150 <span class="p">a.txt</span>
@@ -277,7 +284,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
277 </table>284 </table>
278 </div>285 </div>
279 </details>286 </details>
280-<details class="dfile lnw-2" id="f2" data-file>287+<details class="dfile lnw-2" id="f2" data-file="run.sh">
281 <summary>288 <summary>
282 <span class="st">M</span>289 <span class="st">M</span>
283 <span class="p">run.sh</span>290 <span class="p">run.sh</span>
@@ -438,6 +445,12 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
438 </div>445 </div>
439 <div>446 <div>
440 <dt>447 <dt>
448+<kbd>w</kbd>
449+</dt>
450+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
451+</div>
452+<div>
453+<dt>
441 <kbd>y</kbd>454 <kbd>y</kbd>
442 </dt>455 </dt>
443 <dd>copy a permalink pinned to the commit</dd>456 <dd>copy a permalink pinned to the commit</dd>
@@ -615,6 +628,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
615 k-split from:body -> ermite.layout('split');628 k-split from:body -> ermite.layout('split');
616 k-unified from:body -> ermite.layout('unified');629 k-unified from:body -> ermite.layout('unified');
617 k-collapse from:body -> ermite.collapse();630 k-collapse from:body -> ermite.collapse();
631+ k-wide from:body -> window.ermiteWide.toggle();
618 k-expand from:body -> ermite.expandFile();632 k-expand from:body -> ermite.expandFile();
619 k-expandAll from:body -> ermite.expandAll();633 k-expandAll from:body -> ermite.expandAll();
620 k-permalink from:body -> ermite.permalink();634 k-permalink from:body -> ermite.permalink();
M internal/app/testdata/web/commit-rename.html +17 -3
@@ -20,7 +20,7 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23-<script src="/-/static/js/store.HASH.js" defer>
23+<script src="/-/static/js/store.HASH.js">
2424 </script>
2525 <script src="/-/static/js/ermite.HASH.js" defer>
2626 </script>
@@ -121,6 +121,11 @@
121121 </span>
122122 <span class="grow">
123123 </span>
124+<details class="menu vmenu">
125+<summary class="btn" aria-label="Diff view">
126+<span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>
127+</summary>
128+<div class="menu-pop" role="group" aria-label="Diff view">
124129 <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
125130 </a>
126131 <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
@@ -130,8 +135,10 @@
130135 <kbd aria-hidden="true">x</kbd>
131136 </button>
132137 </div>
138+</details>
139+</div>
133140 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
134-<details class="dfile lnw-2" id="f1" data-file>
141+<details class="dfile lnw-2" id="f1" data-file="docs-old.txt">
135142 <summary>
136143 <span class="st">R</span>
137144 <span class="p">old.txt → docs-old.txt</span>
@@ -141,7 +148,7 @@
141148 </summary>
142149 <p class="note pad">renamed, 100% similar</p>
143150 </details>
144-<details class="dfile lnw-2" id="f2" data-file open>
151+<details class="dfile lnw-2" id="f2" data-file="gone.txt" open>
145152 <summary>
146153 <span class="st">D</span>
147154 <span class="p">gone.txt</span>
@@ -342,6 +349,12 @@
342349 </div>
343350 <div>
344351 <dt>
352+<kbd>w</kbd>
353+</dt>
354+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
355+</div>
356+<div>
357+<dt>
345358 <kbd>y</kbd>
346359 </dt>
347360 <dd>copy a permalink pinned to the commit</dd>
@@ -519,6 +532,7 @@
519532 k-split from:body -> ermite.layout('split');
520533 k-unified from:body -> ermite.layout('unified');
521534 k-collapse from:body -> ermite.collapse();
535+ k-wide from:body -> window.ermiteWide.toggle();
522536 k-expand from:body -> ermite.expandFile();
523537 k-expandAll from:body -> ermite.expandAll();
524538 k-permalink from:body -> ermite.permalink();
@@ -20,7 +20,7 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23-<script src="/-/static/js/store.HASH.js" defer>23+<script src="/-/static/js/store.HASH.js">
24 </script>24 </script>
25 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
26 </script>26 </script>
@@ -121,6 +121,11 @@
121 </span>121 </span>
122 <span class="grow">122 <span class="grow">
123 </span>123 </span>
124+<details class="menu vmenu">
125+<summary class="btn" aria-label="Diff view">
126+<span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>
127+</summary>
128+<div class="menu-pop" role="group" aria-label="Diff view">
124 <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>129 <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
125 </a>130 </a>
126 <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>131 <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
@@ -130,8 +135,10 @@
130 <kbd aria-hidden="true">x</kbd>135 <kbd aria-hidden="true">x</kbd>
131 </button>136 </button>
132 </div>137 </div>
138+</details>
139+</div>
133 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">140 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
134-<details class="dfile lnw-2" id="f1" data-file>141+<details class="dfile lnw-2" id="f1" data-file="docs-old.txt">
135 <summary>142 <summary>
136 <span class="st">R</span>143 <span class="st">R</span>
137 <span class="p">old.txt → docs-old.txt</span>144 <span class="p">old.txt → docs-old.txt</span>
@@ -141,7 +148,7 @@
141 </summary>148 </summary>
142 <p class="note pad">renamed, 100% similar</p>149 <p class="note pad">renamed, 100% similar</p>
143 </details>150 </details>
144-<details class="dfile lnw-2" id="f2" data-file open>151+<details class="dfile lnw-2" id="f2" data-file="gone.txt" open>
145 <summary>152 <summary>
146 <span class="st">D</span>153 <span class="st">D</span>
147 <span class="p">gone.txt</span>154 <span class="p">gone.txt</span>
@@ -342,6 +349,12 @@
342 </div>349 </div>
343 <div>350 <div>
344 <dt>351 <dt>
352+<kbd>w</kbd>
353+</dt>
354+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
355+</div>
356+<div>
357+<dt>
345 <kbd>y</kbd>358 <kbd>y</kbd>
346 </dt>359 </dt>
347 <dd>copy a permalink pinned to the commit</dd>360 <dd>copy a permalink pinned to the commit</dd>
@@ -519,6 +532,7 @@
519 k-split from:body -> ermite.layout('split');532 k-split from:body -> ermite.layout('split');
520 k-unified from:body -> ermite.layout('unified');533 k-unified from:body -> ermite.layout('unified');
521 k-collapse from:body -> ermite.collapse();534 k-collapse from:body -> ermite.collapse();
535+ k-wide from:body -> window.ermiteWide.toggle();
522 k-expand from:body -> ermite.expandFile();536 k-expand from:body -> ermite.expandFile();
523 k-expandAll from:body -> ermite.expandAll();537 k-expandAll from:body -> ermite.expandAll();
524 k-permalink from:body -> ermite.permalink();538 k-permalink from:body -> ermite.permalink();
M internal/app/testdata/web/commit-root.html +20 -6
@@ -20,7 +20,7 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23-<script src="/-/static/js/store.HASH.js" defer>
23+<script src="/-/static/js/store.HASH.js">
2424 </script>
2525 <script src="/-/static/js/ermite.HASH.js" defer>
2626 </script>
@@ -119,6 +119,11 @@
119119 </span>
120120 <span class="grow">
121121 </span>
122+<details class="menu vmenu">
123+<summary class="btn" aria-label="Diff view">
124+<span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>
125+</summary>
126+<div class="menu-pop" role="group" aria-label="Diff view">
122127 <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
123128 </a>
124129 <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
@@ -128,8 +133,10 @@
128133 <kbd aria-hidden="true">x</kbd>
129134 </button>
130135 </div>
136+</details>
137+</div>
131138 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
132-<details class="dfile lnw-2" id="f1" data-file open>
139+<details class="dfile lnw-2" id="f1" data-file="a.txt" open>
133140 <summary>
134141 <span class="st">A</span>
135142 <span class="p">a.txt</span>
@@ -251,7 +258,7 @@
251258 </table>
252259 </div>
253260 </details>
254-<details class="dfile lnw-2" id="f2" data-file open>
261+<details class="dfile lnw-2" id="f2" data-file="gone.txt" open>
255262 <summary>
256263 <span class="st">A</span>
257264 <span class="p">gone.txt</span>
@@ -301,7 +308,7 @@
301308 </table>
302309 </div>
303310 </details>
304-<details class="dfile lnw-2" id="f3" data-file>
311+<details class="dfile lnw-2" id="f3" data-file="logo.bin">
305312 <summary>
306313 <span class="st">A</span>
307314 <span class="p">logo.bin</span>
@@ -311,7 +318,7 @@
311318 </summary>
312319 <p class="note pad">binary file: no text diff</p>
313320 </details>
314-<details class="dfile lnw-2" id="f4" data-file open>
321+<details class="dfile lnw-2" id="f4" data-file="old.txt" open>
315322 <summary>
316323 <span class="st">A</span>
317324 <span class="p">old.txt</span>
@@ -397,7 +404,7 @@
397404 </table>
398405 </div>
399406 </details>
400-<details class="dfile lnw-2" id="f5" data-file open>
407+<details class="dfile lnw-2" id="f5" data-file="run.sh" open>
401408 <summary>
402409 <span class="st">A</span>
403410 <span class="p">run.sh</span>
@@ -661,6 +668,12 @@
661668 </div>
662669 <div>
663670 <dt>
671+<kbd>w</kbd>
672+</dt>
673+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
674+</div>
675+<div>
676+<dt>
664677 <kbd>y</kbd>
665678 </dt>
666679 <dd>copy a permalink pinned to the commit</dd>
@@ -838,6 +851,7 @@
838851 k-split from:body -> ermite.layout('split');
839852 k-unified from:body -> ermite.layout('unified');
840853 k-collapse from:body -> ermite.collapse();
854+ k-wide from:body -> window.ermiteWide.toggle();
841855 k-expand from:body -> ermite.expandFile();
842856 k-expandAll from:body -> ermite.expandAll();
843857 k-permalink from:body -> ermite.permalink();
@@ -20,7 +20,7 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23-<script src="/-/static/js/store.HASH.js" defer>23+<script src="/-/static/js/store.HASH.js">
24 </script>24 </script>
25 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
26 </script>26 </script>
@@ -119,6 +119,11 @@
119 </span>119 </span>
120 <span class="grow">120 <span class="grow">
121 </span>121 </span>
122+<details class="menu vmenu">
123+<summary class="btn" aria-label="Diff view">
124+<span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>
125+</summary>
126+<div class="menu-pop" role="group" aria-label="Diff view">
122 <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>127 <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
123 </a>128 </a>
124 <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>129 <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
@@ -128,8 +133,10 @@
128 <kbd aria-hidden="true">x</kbd>133 <kbd aria-hidden="true">x</kbd>
129 </button>134 </button>
130 </div>135 </div>
136+</details>
137+</div>
131 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">138 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
132-<details class="dfile lnw-2" id="f1" data-file open>139+<details class="dfile lnw-2" id="f1" data-file="a.txt" open>
133 <summary>140 <summary>
134 <span class="st">A</span>141 <span class="st">A</span>
135 <span class="p">a.txt</span>142 <span class="p">a.txt</span>
@@ -251,7 +258,7 @@
251 </table>258 </table>
252 </div>259 </div>
253 </details>260 </details>
254-<details class="dfile lnw-2" id="f2" data-file open>261+<details class="dfile lnw-2" id="f2" data-file="gone.txt" open>
255 <summary>262 <summary>
256 <span class="st">A</span>263 <span class="st">A</span>
257 <span class="p">gone.txt</span>264 <span class="p">gone.txt</span>
@@ -301,7 +308,7 @@
301 </table>308 </table>
302 </div>309 </div>
303 </details>310 </details>
304-<details class="dfile lnw-2" id="f3" data-file>311+<details class="dfile lnw-2" id="f3" data-file="logo.bin">
305 <summary>312 <summary>
306 <span class="st">A</span>313 <span class="st">A</span>
307 <span class="p">logo.bin</span>314 <span class="p">logo.bin</span>
@@ -311,7 +318,7 @@
311 </summary>318 </summary>
312 <p class="note pad">binary file: no text diff</p>319 <p class="note pad">binary file: no text diff</p>
313 </details>320 </details>
314-<details class="dfile lnw-2" id="f4" data-file open>321+<details class="dfile lnw-2" id="f4" data-file="old.txt" open>
315 <summary>322 <summary>
316 <span class="st">A</span>323 <span class="st">A</span>
317 <span class="p">old.txt</span>324 <span class="p">old.txt</span>
@@ -397,7 +404,7 @@
397 </table>404 </table>
398 </div>405 </div>
399 </details>406 </details>
400-<details class="dfile lnw-2" id="f5" data-file open>407+<details class="dfile lnw-2" id="f5" data-file="run.sh" open>
401 <summary>408 <summary>
402 <span class="st">A</span>409 <span class="st">A</span>
403 <span class="p">run.sh</span>410 <span class="p">run.sh</span>
@@ -661,6 +668,12 @@
661 </div>668 </div>
662 <div>669 <div>
663 <dt>670 <dt>
671+<kbd>w</kbd>
672+</dt>
673+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
674+</div>
675+<div>
676+<dt>
664 <kbd>y</kbd>677 <kbd>y</kbd>
665 </dt>678 </dt>
666 <dd>copy a permalink pinned to the commit</dd>679 <dd>copy a permalink pinned to the commit</dd>
@@ -838,6 +851,7 @@
838 k-split from:body -> ermite.layout('split');851 k-split from:body -> ermite.layout('split');
839 k-unified from:body -> ermite.layout('unified');852 k-unified from:body -> ermite.layout('unified');
840 k-collapse from:body -> ermite.collapse();853 k-collapse from:body -> ermite.collapse();
854+ k-wide from:body -> window.ermiteWide.toggle();
841 k-expand from:body -> ermite.expandFile();855 k-expand from:body -> ermite.expandFile();
842 k-expandAll from:body -> ermite.expandAll();856 k-expandAll from:body -> ermite.expandAll();
843 k-permalink from:body -> ermite.permalink();857 k-permalink from:body -> ermite.permalink();
M internal/app/testdata/web/commit-trojan.html +16 -2
@@ -20,7 +20,7 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23-<script src="/-/static/js/store.HASH.js" defer>
23+<script src="/-/static/js/store.HASH.js">
2424 </script>
2525 <script src="/-/static/js/ermite.HASH.js" defer>
2626 </script>
@@ -122,6 +122,11 @@
122122 </span>
123123 <span class="grow">
124124 </span>
125+<details class="menu vmenu">
126+<summary class="btn" aria-label="Diff view">
127+<span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>
128+</summary>
129+<div class="menu-pop" role="group" aria-label="Diff view">
125130 <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
126131 </a>
127132 <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
@@ -131,9 +136,11 @@
131136 <kbd aria-hidden="true">x</kbd>
132137 </button>
133138 </div>
139+</details>
140+</div>
134141 <p class="notice">this diff holds invisible characters (direction controls, zero-width characters). They are shown as <span class="inv">&lt;U+XXXX&gt;</span> markers, never applied.</p>
135142 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
136-<details class="dfile lnw-2" id="f1" data-file open>
143+<details class="dfile lnw-2" id="f1" data-file="evil.go" open>
137144 <summary>
138145 <span class="st">A</span>
139146 <span class="p">evil.go</span>
@@ -490,6 +497,12 @@
490497 </div>
491498 <div>
492499 <dt>
500+<kbd>w</kbd>
501+</dt>
502+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
503+</div>
504+<div>
505+<dt>
493506 <kbd>y</kbd>
494507 </dt>
495508 <dd>copy a permalink pinned to the commit</dd>
@@ -667,6 +680,7 @@
667680 k-split from:body -> ermite.layout('split');
668681 k-unified from:body -> ermite.layout('unified');
669682 k-collapse from:body -> ermite.collapse();
683+ k-wide from:body -> window.ermiteWide.toggle();
670684 k-expand from:body -> ermite.expandFile();
671685 k-expandAll from:body -> ermite.expandAll();
672686 k-permalink from:body -> ermite.permalink();
@@ -20,7 +20,7 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23-<script src="/-/static/js/store.HASH.js" defer>23+<script src="/-/static/js/store.HASH.js">
24 </script>24 </script>
25 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
26 </script>26 </script>
@@ -122,6 +122,11 @@
122 </span>122 </span>
123 <span class="grow">123 <span class="grow">
124 </span>124 </span>
125+<details class="menu vmenu">
126+<summary class="btn" aria-label="Diff view">
127+<span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span>
128+</summary>
129+<div class="menu-pop" role="group" aria-label="Diff view">
125 <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>130 <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd>
126 </a>131 </a>
127 <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>132 <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd>
@@ -131,9 +136,11 @@
131 <kbd aria-hidden="true">x</kbd>136 <kbd aria-hidden="true">x</kbd>
132 </button>137 </button>
133 </div>138 </div>
139+</details>
140+</div>
134 <p class="notice">this diff holds invisible characters (direction controls, zero-width characters). They are shown as <span class="inv">&lt;U+XXXX&gt;</span> markers, never applied.</p>141 <p class="notice">this diff holds invisible characters (direction controls, zero-width characters). They are shown as <span class="inv">&lt;U+XXXX&gt;</span> markers, never applied.</p>
135 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">142 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
136-<details class="dfile lnw-2" id="f1" data-file open>143+<details class="dfile lnw-2" id="f1" data-file="evil.go" open>
137 <summary>144 <summary>
138 <span class="st">A</span>145 <span class="st">A</span>
139 <span class="p">evil.go</span>146 <span class="p">evil.go</span>
@@ -490,6 +497,12 @@
490 </div>497 </div>
491 <div>498 <div>
492 <dt>499 <dt>
500+<kbd>w</kbd>
501+</dt>
502+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
503+</div>
504+<div>
505+<dt>
493 <kbd>y</kbd>506 <kbd>y</kbd>
494 </dt>507 </dt>
495 <dd>copy a permalink pinned to the commit</dd>508 <dd>copy a permalink pinned to the commit</dd>
@@ -667,6 +680,7 @@
667 k-split from:body -> ermite.layout('split');680 k-split from:body -> ermite.layout('split');
668 k-unified from:body -> ermite.layout('unified');681 k-unified from:body -> ermite.layout('unified');
669 k-collapse from:body -> ermite.collapse();682 k-collapse from:body -> ermite.collapse();
683+ k-wide from:body -> window.ermiteWide.toggle();
670 k-expand from:body -> ermite.expandFile();684 k-expand from:body -> ermite.expandFile();
671 k-expandAll from:body -> ermite.expandAll();685 k-expandAll from:body -> ermite.expandAll();
672 k-permalink from:body -> ermite.permalink();686 k-permalink from:body -> ermite.permalink();
M internal/app/testdata/web/commits-page2.html +8 -1
@@ -20,7 +20,7 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23-<script src="/-/static/js/store.HASH.js" defer>
23+<script src="/-/static/js/store.HASH.js">
2424 </script>
2525 <script src="/-/static/js/ermite.HASH.js" defer>
2626 </script>
@@ -266,6 +266,12 @@
266266 </div>
267267 <div>
268268 <dt>
269+<kbd>w</kbd>
270+</dt>
271+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
272+</div>
273+<div>
274+<dt>
269275 <kbd>y</kbd>
270276 </dt>
271277 <dd>copy a permalink pinned to the commit</dd>
@@ -429,6 +435,7 @@
429435 k-split from:body -> ermite.layout('split');
430436 k-unified from:body -> ermite.layout('unified');
431437 k-collapse from:body -> ermite.collapse();
438+ k-wide from:body -> window.ermiteWide.toggle();
432439 k-expand from:body -> ermite.expandFile();
433440 k-expandAll from:body -> ermite.expandAll();
434441 k-permalink from:body -> ermite.permalink();
@@ -20,7 +20,7 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23-<script src="/-/static/js/store.HASH.js" defer>23+<script src="/-/static/js/store.HASH.js">
24 </script>24 </script>
25 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
26 </script>26 </script>
@@ -266,6 +266,12 @@
266 </div>266 </div>
267 <div>267 <div>
268 <dt>268 <dt>
269+<kbd>w</kbd>
270+</dt>
271+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
272+</div>
273+<div>
274+<dt>
269 <kbd>y</kbd>275 <kbd>y</kbd>
270 </dt>276 </dt>
271 <dd>copy a permalink pinned to the commit</dd>277 <dd>copy a permalink pinned to the commit</dd>
@@ -429,6 +435,7 @@
429 k-split from:body -> ermite.layout('split');435 k-split from:body -> ermite.layout('split');
430 k-unified from:body -> ermite.layout('unified');436 k-unified from:body -> ermite.layout('unified');
431 k-collapse from:body -> ermite.collapse();437 k-collapse from:body -> ermite.collapse();
438+ k-wide from:body -> window.ermiteWide.toggle();
432 k-expand from:body -> ermite.expandFile();439 k-expand from:body -> ermite.expandFile();
433 k-expandAll from:body -> ermite.expandAll();440 k-expandAll from:body -> ermite.expandAll();
434 k-permalink from:body -> ermite.permalink();441 k-permalink from:body -> ermite.permalink();
M internal/app/testdata/web/commits-path.html +8 -1
@@ -20,7 +20,7 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23-<script src="/-/static/js/store.HASH.js" defer>
23+<script src="/-/static/js/store.HASH.js">
2424 </script>
2525 <script src="/-/static/js/ermite.HASH.js" defer>
2626 </script>
@@ -281,6 +281,12 @@
281281 </div>
282282 <div>
283283 <dt>
284+<kbd>w</kbd>
285+</dt>
286+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
287+</div>
288+<div>
289+<dt>
284290 <kbd>y</kbd>
285291 </dt>
286292 <dd>copy a permalink pinned to the commit</dd>
@@ -444,6 +450,7 @@
444450 k-split from:body -> ermite.layout('split');
445451 k-unified from:body -> ermite.layout('unified');
446452 k-collapse from:body -> ermite.collapse();
453+ k-wide from:body -> window.ermiteWide.toggle();
447454 k-expand from:body -> ermite.expandFile();
448455 k-expandAll from:body -> ermite.expandAll();
449456 k-permalink from:body -> ermite.permalink();
@@ -20,7 +20,7 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23-<script src="/-/static/js/store.HASH.js" defer>23+<script src="/-/static/js/store.HASH.js">
24 </script>24 </script>
25 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
26 </script>26 </script>
@@ -281,6 +281,12 @@
281 </div>281 </div>
282 <div>282 <div>
283 <dt>283 <dt>
284+<kbd>w</kbd>
285+</dt>
286+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
287+</div>
288+<div>
289+<dt>
284 <kbd>y</kbd>290 <kbd>y</kbd>
285 </dt>291 </dt>
286 <dd>copy a permalink pinned to the commit</dd>292 <dd>copy a permalink pinned to the commit</dd>
@@ -444,6 +450,7 @@
444 k-split from:body -> ermite.layout('split');450 k-split from:body -> ermite.layout('split');
445 k-unified from:body -> ermite.layout('unified');451 k-unified from:body -> ermite.layout('unified');
446 k-collapse from:body -> ermite.collapse();452 k-collapse from:body -> ermite.collapse();
453+ k-wide from:body -> window.ermiteWide.toggle();
447 k-expand from:body -> ermite.expandFile();454 k-expand from:body -> ermite.expandFile();
448 k-expandAll from:body -> ermite.expandAll();455 k-expandAll from:body -> ermite.expandAll();
449 k-permalink from:body -> ermite.permalink();456 k-permalink from:body -> ermite.permalink();
M internal/app/testdata/web/commits-root.html +8 -1
@@ -20,7 +20,7 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23-<script src="/-/static/js/store.HASH.js" defer>
23+<script src="/-/static/js/store.HASH.js">
2424 </script>
2525 <script src="/-/static/js/ermite.HASH.js" defer>
2626 </script>
@@ -1070,6 +1070,12 @@
10701070 </div>
10711071 <div>
10721072 <dt>
1073+<kbd>w</kbd>
1074+</dt>
1075+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
1076+</div>
1077+<div>
1078+<dt>
10731079 <kbd>y</kbd>
10741080 </dt>
10751081 <dd>copy a permalink pinned to the commit</dd>
@@ -1233,6 +1239,7 @@
12331239 k-split from:body -> ermite.layout('split');
12341240 k-unified from:body -> ermite.layout('unified');
12351241 k-collapse from:body -> ermite.collapse();
1242+ k-wide from:body -> window.ermiteWide.toggle();
12361243 k-expand from:body -> ermite.expandFile();
12371244 k-expandAll from:body -> ermite.expandAll();
12381245 k-permalink from:body -> ermite.permalink();
@@ -20,7 +20,7 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23-<script src="/-/static/js/store.HASH.js" defer>23+<script src="/-/static/js/store.HASH.js">
24 </script>24 </script>
25 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
26 </script>26 </script>
@@ -1070,6 +1070,12 @@
1070 </div>1070 </div>
1071 <div>1071 <div>
1072 <dt>1072 <dt>
1073+<kbd>w</kbd>
1074+</dt>
1075+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
1076+</div>
1077+<div>
1078+<dt>
1073 <kbd>y</kbd>1079 <kbd>y</kbd>
1074 </dt>1080 </dt>
1075 <dd>copy a permalink pinned to the commit</dd>1081 <dd>copy a permalink pinned to the commit</dd>
@@ -1233,6 +1239,7 @@
1233 k-split from:body -> ermite.layout('split');1239 k-split from:body -> ermite.layout('split');
1234 k-unified from:body -> ermite.layout('unified');1240 k-unified from:body -> ermite.layout('unified');
1235 k-collapse from:body -> ermite.collapse();1241 k-collapse from:body -> ermite.collapse();
1242+ k-wide from:body -> window.ermiteWide.toggle();
1236 k-expand from:body -> ermite.expandFile();1243 k-expand from:body -> ermite.expandFile();
1237 k-expandAll from:body -> ermite.expandAll();1244 k-expandAll from:body -> ermite.expandAll();
1238 k-permalink from:body -> ermite.permalink();1245 k-permalink from:body -> ermite.permalink();
M internal/app/testdata/web/commits-small.html +8 -1
@@ -20,7 +20,7 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23-<script src="/-/static/js/store.HASH.js" defer>
23+<script src="/-/static/js/store.HASH.js">
2424 </script>
2525 <script src="/-/static/js/ermite.HASH.js" defer>
2626 </script>
@@ -415,6 +415,12 @@
415415 </div>
416416 <div>
417417 <dt>
418+<kbd>w</kbd>
419+</dt>
420+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
421+</div>
422+<div>
423+<dt>
418424 <kbd>y</kbd>
419425 </dt>
420426 <dd>copy a permalink pinned to the commit</dd>
@@ -598,6 +604,7 @@
598604 k-split from:body -> ermite.layout('split');
599605 k-unified from:body -> ermite.layout('unified');
600606 k-collapse from:body -> ermite.collapse();
607+ k-wide from:body -> window.ermiteWide.toggle();
601608 k-expand from:body -> ermite.expandFile();
602609 k-expandAll from:body -> ermite.expandAll();
603610 k-permalink from:body -> ermite.permalink();
@@ -20,7 +20,7 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23-<script src="/-/static/js/store.HASH.js" defer>23+<script src="/-/static/js/store.HASH.js">
24 </script>24 </script>
25 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
26 </script>26 </script>
@@ -415,6 +415,12 @@
415 </div>415 </div>
416 <div>416 <div>
417 <dt>417 <dt>
418+<kbd>w</kbd>
419+</dt>
420+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
421+</div>
422+<div>
423+<dt>
418 <kbd>y</kbd>424 <kbd>y</kbd>
419 </dt>425 </dt>
420 <dd>copy a permalink pinned to the commit</dd>426 <dd>copy a permalink pinned to the commit</dd>
@@ -598,6 +604,7 @@
598 k-split from:body -> ermite.layout('split');604 k-split from:body -> ermite.layout('split');
599 k-unified from:body -> ermite.layout('unified');605 k-unified from:body -> ermite.layout('unified');
600 k-collapse from:body -> ermite.collapse();606 k-collapse from:body -> ermite.collapse();
607+ k-wide from:body -> window.ermiteWide.toggle();
601 k-expand from:body -> ermite.expandFile();608 k-expand from:body -> ermite.expandFile();
602 k-expandAll from:body -> ermite.expandAll();609 k-expandAll from:body -> ermite.expandAll();
603 k-permalink from:body -> ermite.permalink();610 k-permalink from:body -> ermite.permalink();
M internal/app/testdata/web/empty.html +8 -1
@@ -20,7 +20,7 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23-<script src="/-/static/js/store.HASH.js" defer>
23+<script src="/-/static/js/store.HASH.js">
2424 </script>
2525 <script src="/-/static/js/ermite.HASH.js" defer>
2626 </script>
@@ -188,6 +188,12 @@
188188 </div>
189189 <div>
190190 <dt>
191+<kbd>w</kbd>
192+</dt>
193+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
194+</div>
195+<div>
196+<dt>
191197 <kbd>y</kbd>
192198 </dt>
193199 <dd>copy a permalink pinned to the commit</dd>
@@ -282,6 +288,7 @@
282288 k-split from:body -> ermite.layout('split');
283289 k-unified from:body -> ermite.layout('unified');
284290 k-collapse from:body -> ermite.collapse();
291+ k-wide from:body -> window.ermiteWide.toggle();
285292 k-expand from:body -> ermite.expandFile();
286293 k-expandAll from:body -> ermite.expandAll();
287294 k-permalink from:body -> ermite.permalink();
@@ -20,7 +20,7 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23-<script src="/-/static/js/store.HASH.js" defer>23+<script src="/-/static/js/store.HASH.js">
24 </script>24 </script>
25 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
26 </script>26 </script>
@@ -188,6 +188,12 @@
188 </div>188 </div>
189 <div>189 <div>
190 <dt>190 <dt>
191+<kbd>w</kbd>
192+</dt>
193+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
194+</div>
195+<div>
196+<dt>
191 <kbd>y</kbd>197 <kbd>y</kbd>
192 </dt>198 </dt>
193 <dd>copy a permalink pinned to the commit</dd>199 <dd>copy a permalink pinned to the commit</dd>
@@ -282,6 +288,7 @@
282 k-split from:body -> ermite.layout('split');288 k-split from:body -> ermite.layout('split');
283 k-unified from:body -> ermite.layout('unified');289 k-unified from:body -> ermite.layout('unified');
284 k-collapse from:body -> ermite.collapse();290 k-collapse from:body -> ermite.collapse();
291+ k-wide from:body -> window.ermiteWide.toggle();
285 k-expand from:body -> ermite.expandFile();292 k-expand from:body -> ermite.expandFile();
286 k-expandAll from:body -> ermite.expandAll();293 k-expandAll from:body -> ermite.expandAll();
287 k-permalink from:body -> ermite.permalink();294 k-permalink from:body -> ermite.permalink();
M internal/app/testdata/web/home.html +40 -31
@@ -20,7 +20,7 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23-<script src="/-/static/js/store.HASH.js" defer>
23+<script src="/-/static/js/store.HASH.js">
2424 </script>
2525 <script src="/-/static/js/ermite.HASH.js" defer>
2626 </script>
@@ -61,7 +61,7 @@
6161 </div>
6262 <nav class="sort" aria-label="Sort repositories">
6363 <span class="mute">sort</span>
64-<a href="/?sort=push&amp;dir=asc" aria-current="true">last push <span aria-hidden="true">▼</span>
64+<a href="/?sort=activity&amp;dir=asc" aria-current="true">last activity <span aria-hidden="true">▼</span>
6565 <span class="sr">, descending, select for ascending</span>
6666 </a>
6767 <a href="/?sort=name&amp;dir=asc">name </a>
@@ -81,8 +81,31 @@
8181 </button>
8282 </div>
8383 </div>
84-<ul class="repos" id="repo-rows" data-rows>
85-<li id="repo-demo" class="repo" data-cursor>
84+<ul class="repos" id="repo-rows" data-rows data-sort="activity" data-desc="true">
85+<li id="repo-quiet" class="repo" data-cursor>
86+<div class="repo-main">
87+<h2 class="repo-name">
88+<a href="/quiet">quiet</a>
89+</h2>
90+<p class="repo-desc mute">no description</p>
91+<div class="repo-topics" aria-hidden="true">
92+</div>
93+<div class="repo-pulse" aria-hidden="true">
94+</div>
95+</div>
96+<div class="repo-head">
97+<dl class="repo-stats" aria-label="Figures of quiet">
98+<div class="rs-active">
99+<dt>last activity</dt>
100+<dd>
101+<time datetime="STAMP" title="STAMP">AGO</time>
102+</dd>
103+</div>
104+</dl>
105+</div>
106+<p class="repo-commits mute">no commits yet</p>
107+</li>
108+<li id="repo-demo" class="repo">
86109 <div class="repo-main">
87110 <h2 class="repo-name">
88111 <a href="/demo">demo</a>
@@ -148,27 +171,6 @@
148171 </li>
149172 </ul>
150173 </li>
151-<li id="repo-quiet" class="repo">
152-<div class="repo-main">
153-<h2 class="repo-name">
154-<a href="/quiet">quiet</a>
155-</h2>
156-<p class="repo-desc mute">no description</p>
157-<div class="repo-topics" aria-hidden="true">
158-</div>
159-<div class="repo-pulse" aria-hidden="true">
160-</div>
161-</div>
162-<div class="repo-head">
163-<dl class="repo-stats" aria-label="Figures of quiet">
164-<div class="rs-active">
165-<dt>last activity</dt>
166-<dd class="mute">never</dd>
167-</div>
168-</dl>
169-</div>
170-<p class="repo-commits mute">no commits yet</p>
171-</li>
172174 </ul>
173175 </section>
174176 </div>
@@ -289,6 +291,12 @@
289291 </div>
290292 <div>
291293 <dt>
294+<kbd>w</kbd>
295+</dt>
296+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
297+</div>
298+<div>
299+<dt>
292300 <kbd>y</kbd>
293301 </dt>
294302 <dd>copy a permalink pinned to the commit</dd>
@@ -337,15 +345,15 @@
337345 <span class="k">go</span>
338346 </a>
339347 </li>
340-<li id="cmd-o1" role="option" aria-selected="false" data-k="repo demo">
341-<a href="/demo" tabindex="-1">
342-<span class="lbl">demo</span>
348+<li id="cmd-o1" role="option" aria-selected="false" data-k="repo quiet">
349+<a href="/quiet" tabindex="-1">
350+<span class="lbl">quiet</span>
343351 <span class="k">repo</span>
344352 </a>
345353 </li>
346-<li id="cmd-o2" role="option" aria-selected="false" data-k="repo quiet">
347-<a href="/quiet" tabindex="-1">
348-<span class="lbl">quiet</span>
354+<li id="cmd-o2" role="option" aria-selected="false" data-k="repo demo">
355+<a href="/demo" tabindex="-1">
356+<span class="lbl">demo</span>
349357 <span class="k">repo</span>
350358 </a>
351359 </li>
@@ -389,6 +397,7 @@
389397 k-split from:body -> ermite.layout('split');
390398 k-unified from:body -> ermite.layout('unified');
391399 k-collapse from:body -> ermite.collapse();
400+ k-wide from:body -> window.ermiteWide.toggle();
392401 k-expand from:body -> ermite.expandFile();
393402 k-expandAll from:body -> ermite.expandAll();
394403 k-permalink from:body -> ermite.permalink();
@@ -20,7 +20,7 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23-<script src="/-/static/js/store.HASH.js" defer>23+<script src="/-/static/js/store.HASH.js">
24 </script>24 </script>
25 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
26 </script>26 </script>
@@ -61,7 +61,7 @@
61 </div>61 </div>
62 <nav class="sort" aria-label="Sort repositories">62 <nav class="sort" aria-label="Sort repositories">
63 <span class="mute">sort</span>63 <span class="mute">sort</span>
64-<a href="/?sort=push&amp;dir=asc" aria-current="true">last push <span aria-hidden="true">▼</span>64+<a href="/?sort=activity&amp;dir=asc" aria-current="true">last activity <span aria-hidden="true">▼</span>
65 <span class="sr">, descending, select for ascending</span>65 <span class="sr">, descending, select for ascending</span>
66 </a>66 </a>
67 <a href="/?sort=name&amp;dir=asc">name </a>67 <a href="/?sort=name&amp;dir=asc">name </a>
@@ -81,8 +81,31 @@
81 </button>81 </button>
82 </div>82 </div>
83 </div>83 </div>
84-<ul class="repos" id="repo-rows" data-rows>84+<ul class="repos" id="repo-rows" data-rows data-sort="activity" data-desc="true">
85-<li id="repo-demo" class="repo" data-cursor>85+<li id="repo-quiet" class="repo" data-cursor>
86+<div class="repo-main">
87+<h2 class="repo-name">
88+<a href="/quiet">quiet</a>
89+</h2>
90+<p class="repo-desc mute">no description</p>
91+<div class="repo-topics" aria-hidden="true">
92+</div>
93+<div class="repo-pulse" aria-hidden="true">
94+</div>
95+</div>
96+<div class="repo-head">
97+<dl class="repo-stats" aria-label="Figures of quiet">
98+<div class="rs-active">
99+<dt>last activity</dt>
100+<dd>
101+<time datetime="STAMP" title="STAMP">AGO</time>
102+</dd>
103+</div>
104+</dl>
105+</div>
106+<p class="repo-commits mute">no commits yet</p>
107+</li>
108+<li id="repo-demo" class="repo">
86 <div class="repo-main">109 <div class="repo-main">
87 <h2 class="repo-name">110 <h2 class="repo-name">
88 <a href="/demo">demo</a>111 <a href="/demo">demo</a>
@@ -148,27 +171,6 @@
148 </li>171 </li>
149 </ul>172 </ul>
150 </li>173 </li>
151-<li id="repo-quiet" class="repo">
152-<div class="repo-main">
153-<h2 class="repo-name">
154-<a href="/quiet">quiet</a>
155-</h2>
156-<p class="repo-desc mute">no description</p>
157-<div class="repo-topics" aria-hidden="true">
158-</div>
159-<div class="repo-pulse" aria-hidden="true">
160-</div>
161-</div>
162-<div class="repo-head">
163-<dl class="repo-stats" aria-label="Figures of quiet">
164-<div class="rs-active">
165-<dt>last activity</dt>
166-<dd class="mute">never</dd>
167-</div>
168-</dl>
169-</div>
170-<p class="repo-commits mute">no commits yet</p>
171-</li>
172 </ul>174 </ul>
173 </section>175 </section>
174 </div>176 </div>
@@ -289,6 +291,12 @@
289 </div>291 </div>
290 <div>292 <div>
291 <dt>293 <dt>
294+<kbd>w</kbd>
295+</dt>
296+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
297+</div>
298+<div>
299+<dt>
292 <kbd>y</kbd>300 <kbd>y</kbd>
293 </dt>301 </dt>
294 <dd>copy a permalink pinned to the commit</dd>302 <dd>copy a permalink pinned to the commit</dd>
@@ -337,15 +345,15 @@
337 <span class="k">go</span>345 <span class="k">go</span>
338 </a>346 </a>
339 </li>347 </li>
340-<li id="cmd-o1" role="option" aria-selected="false" data-k="repo demo">348+<li id="cmd-o1" role="option" aria-selected="false" data-k="repo quiet">
341-<a href="/demo" tabindex="-1">349+<a href="/quiet" tabindex="-1">
342-<span class="lbl">demo</span>350+<span class="lbl">quiet</span>
343 <span class="k">repo</span>351 <span class="k">repo</span>
344 </a>352 </a>
345 </li>353 </li>
346-<li id="cmd-o2" role="option" aria-selected="false" data-k="repo quiet">354+<li id="cmd-o2" role="option" aria-selected="false" data-k="repo demo">
347-<a href="/quiet" tabindex="-1">355+<a href="/demo" tabindex="-1">
348-<span class="lbl">quiet</span>356+<span class="lbl">demo</span>
349 <span class="k">repo</span>357 <span class="k">repo</span>
350 </a>358 </a>
351 </li>359 </li>
@@ -389,6 +397,7 @@
389 k-split from:body -> ermite.layout('split');397 k-split from:body -> ermite.layout('split');
390 k-unified from:body -> ermite.layout('unified');398 k-unified from:body -> ermite.layout('unified');
391 k-collapse from:body -> ermite.collapse();399 k-collapse from:body -> ermite.collapse();
400+ k-wide from:body -> window.ermiteWide.toggle();
392 k-expand from:body -> ermite.expandFile();401 k-expand from:body -> ermite.expandFile();
393 k-expandAll from:body -> ermite.expandAll();402 k-expandAll from:body -> ermite.expandAll();
394 k-permalink from:body -> ermite.permalink();403 k-permalink from:body -> ermite.permalink();
M internal/app/testdata/web/not-found-page.html +8 -1
@@ -20,7 +20,7 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23-<script src="/-/static/js/store.HASH.js" defer>
23+<script src="/-/static/js/store.HASH.js">
2424 </script>
2525 <script src="/-/static/js/ermite.HASH.js" defer>
2626 </script>
@@ -177,6 +177,12 @@
177177 </div>
178178 <div>
179179 <dt>
180+<kbd>w</kbd>
181+</dt>
182+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
183+</div>
184+<div>
185+<dt>
180186 <kbd>y</kbd>
181187 </dt>
182188 <dd>copy a permalink pinned to the commit</dd>
@@ -265,6 +271,7 @@
265271 k-split from:body -> ermite.layout('split');
266272 k-unified from:body -> ermite.layout('unified');
267273 k-collapse from:body -> ermite.collapse();
274+ k-wide from:body -> window.ermiteWide.toggle();
268275 k-expand from:body -> ermite.expandFile();
269276 k-expandAll from:body -> ermite.expandAll();
270277 k-permalink from:body -> ermite.permalink();
@@ -20,7 +20,7 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23-<script src="/-/static/js/store.HASH.js" defer>23+<script src="/-/static/js/store.HASH.js">
24 </script>24 </script>
25 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
26 </script>26 </script>
@@ -177,6 +177,12 @@
177 </div>177 </div>
178 <div>178 <div>
179 <dt>179 <dt>
180+<kbd>w</kbd>
181+</dt>
182+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
183+</div>
184+<div>
185+<dt>
180 <kbd>y</kbd>186 <kbd>y</kbd>
181 </dt>187 </dt>
182 <dd>copy a permalink pinned to the commit</dd>188 <dd>copy a permalink pinned to the commit</dd>
@@ -265,6 +271,7 @@
265 k-split from:body -> ermite.layout('split');271 k-split from:body -> ermite.layout('split');
266 k-unified from:body -> ermite.layout('unified');272 k-unified from:body -> ermite.layout('unified');
267 k-collapse from:body -> ermite.collapse();273 k-collapse from:body -> ermite.collapse();
274+ k-wide from:body -> window.ermiteWide.toggle();
268 k-expand from:body -> ermite.expandFile();275 k-expand from:body -> ermite.expandFile();
269 k-expandAll from:body -> ermite.expandAll();276 k-expandAll from:body -> ermite.expandAll();
270 k-permalink from:body -> ermite.permalink();277 k-permalink from:body -> ermite.permalink();
M internal/app/testdata/web/not-found.html +8 -1
@@ -20,7 +20,7 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23-<script src="/-/static/js/store.HASH.js" defer>
23+<script src="/-/static/js/store.HASH.js">
2424 </script>
2525 <script src="/-/static/js/ermite.HASH.js" defer>
2626 </script>
@@ -177,6 +177,12 @@
177177 </div>
178178 <div>
179179 <dt>
180+<kbd>w</kbd>
181+</dt>
182+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
183+</div>
184+<div>
185+<dt>
180186 <kbd>y</kbd>
181187 </dt>
182188 <dd>copy a permalink pinned to the commit</dd>
@@ -265,6 +271,7 @@
265271 k-split from:body -> ermite.layout('split');
266272 k-unified from:body -> ermite.layout('unified');
267273 k-collapse from:body -> ermite.collapse();
274+ k-wide from:body -> window.ermiteWide.toggle();
268275 k-expand from:body -> ermite.expandFile();
269276 k-expandAll from:body -> ermite.expandAll();
270277 k-permalink from:body -> ermite.permalink();
@@ -20,7 +20,7 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23-<script src="/-/static/js/store.HASH.js" defer>23+<script src="/-/static/js/store.HASH.js">
24 </script>24 </script>
25 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
26 </script>26 </script>
@@ -177,6 +177,12 @@
177 </div>177 </div>
178 <div>178 <div>
179 <dt>179 <dt>
180+<kbd>w</kbd>
181+</dt>
182+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
183+</div>
184+<div>
185+<dt>
180 <kbd>y</kbd>186 <kbd>y</kbd>
181 </dt>187 </dt>
182 <dd>copy a permalink pinned to the commit</dd>188 <dd>copy a permalink pinned to the commit</dd>
@@ -265,6 +271,7 @@
265 k-split from:body -> ermite.layout('split');271 k-split from:body -> ermite.layout('split');
266 k-unified from:body -> ermite.layout('unified');272 k-unified from:body -> ermite.layout('unified');
267 k-collapse from:body -> ermite.collapse();273 k-collapse from:body -> ermite.collapse();
274+ k-wide from:body -> window.ermiteWide.toggle();
268 k-expand from:body -> ermite.expandFile();275 k-expand from:body -> ermite.expandFile();
269 k-expandAll from:body -> ermite.expandAll();276 k-expandAll from:body -> ermite.expandAll();
270 k-permalink from:body -> ermite.permalink();277 k-permalink from:body -> ermite.permalink();
M internal/app/testdata/web/refs.html +8 -1
@@ -20,7 +20,7 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23-<script src="/-/static/js/store.HASH.js" defer>
23+<script src="/-/static/js/store.HASH.js">
2424 </script>
2525 <script src="/-/static/js/ermite.HASH.js" defer>
2626 </script>
@@ -297,6 +297,12 @@
297297 </div>
298298 <div>
299299 <dt>
300+<kbd>w</kbd>
301+</dt>
302+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
303+</div>
304+<div>
305+<dt>
300306 <kbd>y</kbd>
301307 </dt>
302308 <dd>copy a permalink pinned to the commit</dd>
@@ -490,6 +496,7 @@
490496 k-split from:body -> ermite.layout('split');
491497 k-unified from:body -> ermite.layout('unified');
492498 k-collapse from:body -> ermite.collapse();
499+ k-wide from:body -> window.ermiteWide.toggle();
493500 k-expand from:body -> ermite.expandFile();
494501 k-expandAll from:body -> ermite.expandAll();
495502 k-permalink from:body -> ermite.permalink();
@@ -20,7 +20,7 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23-<script src="/-/static/js/store.HASH.js" defer>23+<script src="/-/static/js/store.HASH.js">
24 </script>24 </script>
25 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
26 </script>26 </script>
@@ -297,6 +297,12 @@
297 </div>297 </div>
298 <div>298 <div>
299 <dt>299 <dt>
300+<kbd>w</kbd>
301+</dt>
302+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
303+</div>
304+<div>
305+<dt>
300 <kbd>y</kbd>306 <kbd>y</kbd>
301 </dt>307 </dt>
302 <dd>copy a permalink pinned to the commit</dd>308 <dd>copy a permalink pinned to the commit</dd>
@@ -490,6 +496,7 @@
490 k-split from:body -> ermite.layout('split');496 k-split from:body -> ermite.layout('split');
491 k-unified from:body -> ermite.layout('unified');497 k-unified from:body -> ermite.layout('unified');
492 k-collapse from:body -> ermite.collapse();498 k-collapse from:body -> ermite.collapse();
499+ k-wide from:body -> window.ermiteWide.toggle();
493 k-expand from:body -> ermite.expandFile();500 k-expand from:body -> ermite.expandFile();
494 k-expandAll from:body -> ermite.expandAll();501 k-expandAll from:body -> ermite.expandAll();
495 k-permalink from:body -> ermite.permalink();502 k-permalink from:body -> ermite.permalink();
M internal/app/testdata/web/summary.html +8 -1
@@ -20,7 +20,7 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23-<script src="/-/static/js/store.HASH.js" defer>
23+<script src="/-/static/js/store.HASH.js">
2424 </script>
2525 <script src="/-/static/js/ermite.HASH.js" defer>
2626 </script>
@@ -323,6 +323,12 @@
323323 </div>
324324 <div>
325325 <dt>
326+<kbd>w</kbd>
327+</dt>
328+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
329+</div>
330+<div>
331+<dt>
326332 <kbd>y</kbd>
327333 </dt>
328334 <dd>copy a permalink pinned to the commit</dd>
@@ -516,6 +522,7 @@
516522 k-split from:body -> ermite.layout('split');
517523 k-unified from:body -> ermite.layout('unified');
518524 k-collapse from:body -> ermite.collapse();
525+ k-wide from:body -> window.ermiteWide.toggle();
519526 k-expand from:body -> ermite.expandFile();
520527 k-expandAll from:body -> ermite.expandAll();
521528 k-permalink from:body -> ermite.permalink();
@@ -20,7 +20,7 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23-<script src="/-/static/js/store.HASH.js" defer>23+<script src="/-/static/js/store.HASH.js">
24 </script>24 </script>
25 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
26 </script>26 </script>
@@ -323,6 +323,12 @@
323 </div>323 </div>
324 <div>324 <div>
325 <dt>325 <dt>
326+<kbd>w</kbd>
327+</dt>
328+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
329+</div>
330+<div>
331+<dt>
326 <kbd>y</kbd>332 <kbd>y</kbd>
327 </dt>333 </dt>
328 <dd>copy a permalink pinned to the commit</dd>334 <dd>copy a permalink pinned to the commit</dd>
@@ -516,6 +522,7 @@
516 k-split from:body -> ermite.layout('split');522 k-split from:body -> ermite.layout('split');
517 k-unified from:body -> ermite.layout('unified');523 k-unified from:body -> ermite.layout('unified');
518 k-collapse from:body -> ermite.collapse();524 k-collapse from:body -> ermite.collapse();
525+ k-wide from:body -> window.ermiteWide.toggle();
519 k-expand from:body -> ermite.expandFile();526 k-expand from:body -> ermite.expandFile();
520 k-expandAll from:body -> ermite.expandAll();527 k-expandAll from:body -> ermite.expandAll();
521 k-permalink from:body -> ermite.permalink();528 k-permalink from:body -> ermite.permalink();
M internal/app/testdata/web/tree-nested.html +8 -1
@@ -20,7 +20,7 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23-<script src="/-/static/js/store.HASH.js" defer>
23+<script src="/-/static/js/store.HASH.js">
2424 </script>
2525 <script src="/-/static/js/ermite.HASH.js" defer>
2626 </script>
@@ -254,6 +254,12 @@
254254 </div>
255255 <div>
256256 <dt>
257+<kbd>w</kbd>
258+</dt>
259+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
260+</div>
261+<div>
262+<dt>
257263 <kbd>y</kbd>
258264 </dt>
259265 <dd>copy a permalink pinned to the commit</dd>
@@ -431,6 +437,7 @@
431437 k-split from:body -> ermite.layout('split');
432438 k-unified from:body -> ermite.layout('unified');
433439 k-collapse from:body -> ermite.collapse();
440+ k-wide from:body -> window.ermiteWide.toggle();
434441 k-expand from:body -> ermite.expandFile();
435442 k-expandAll from:body -> ermite.expandAll();
436443 k-permalink from:body -> ermite.permalink();
@@ -20,7 +20,7 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23-<script src="/-/static/js/store.HASH.js" defer>23+<script src="/-/static/js/store.HASH.js">
24 </script>24 </script>
25 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
26 </script>26 </script>
@@ -254,6 +254,12 @@
254 </div>254 </div>
255 <div>255 <div>
256 <dt>256 <dt>
257+<kbd>w</kbd>
258+</dt>
259+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
260+</div>
261+<div>
262+<dt>
257 <kbd>y</kbd>263 <kbd>y</kbd>
258 </dt>264 </dt>
259 <dd>copy a permalink pinned to the commit</dd>265 <dd>copy a permalink pinned to the commit</dd>
@@ -431,6 +437,7 @@
431 k-split from:body -> ermite.layout('split');437 k-split from:body -> ermite.layout('split');
432 k-unified from:body -> ermite.layout('unified');438 k-unified from:body -> ermite.layout('unified');
433 k-collapse from:body -> ermite.collapse();439 k-collapse from:body -> ermite.collapse();
440+ k-wide from:body -> window.ermiteWide.toggle();
434 k-expand from:body -> ermite.expandFile();441 k-expand from:body -> ermite.expandFile();
435 k-expandAll from:body -> ermite.expandAll();442 k-expandAll from:body -> ermite.expandAll();
436 k-permalink from:body -> ermite.permalink();443 k-permalink from:body -> ermite.permalink();
M internal/app/testdata/web/tree-root.html +8 -1
@@ -20,7 +20,7 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23-<script src="/-/static/js/store.HASH.js" defer>
23+<script src="/-/static/js/store.HASH.js">
2424 </script>
2525 <script src="/-/static/js/ermite.HASH.js" defer>
2626 </script>
@@ -475,6 +475,12 @@
475475 </div>
476476 <div>
477477 <dt>
478+<kbd>w</kbd>
479+</dt>
480+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
481+</div>
482+<div>
483+<dt>
478484 <kbd>y</kbd>
479485 </dt>
480486 <dd>copy a permalink pinned to the commit</dd>
@@ -652,6 +658,7 @@
652658 k-split from:body -> ermite.layout('split');
653659 k-unified from:body -> ermite.layout('unified');
654660 k-collapse from:body -> ermite.collapse();
661+ k-wide from:body -> window.ermiteWide.toggle();
655662 k-expand from:body -> ermite.expandFile();
656663 k-expandAll from:body -> ermite.expandAll();
657664 k-permalink from:body -> ermite.permalink();
@@ -20,7 +20,7 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23-<script src="/-/static/js/store.HASH.js" defer>23+<script src="/-/static/js/store.HASH.js">
24 </script>24 </script>
25 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
26 </script>26 </script>
@@ -475,6 +475,12 @@
475 </div>475 </div>
476 <div>476 <div>
477 <dt>477 <dt>
478+<kbd>w</kbd>
479+</dt>
480+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
481+</div>
482+<div>
483+<dt>
478 <kbd>y</kbd>484 <kbd>y</kbd>
479 </dt>485 </dt>
480 <dd>copy a permalink pinned to the commit</dd>486 <dd>copy a permalink pinned to the commit</dd>
@@ -652,6 +658,7 @@
652 k-split from:body -> ermite.layout('split');658 k-split from:body -> ermite.layout('split');
653 k-unified from:body -> ermite.layout('unified');659 k-unified from:body -> ermite.layout('unified');
654 k-collapse from:body -> ermite.collapse();660 k-collapse from:body -> ermite.collapse();
661+ k-wide from:body -> window.ermiteWide.toggle();
655 k-expand from:body -> ermite.expandFile();662 k-expand from:body -> ermite.expandFile();
656 k-expandAll from:body -> ermite.expandAll();663 k-expandAll from:body -> ermite.expandAll();
657 k-permalink from:body -> ermite.permalink();664 k-permalink from:body -> ermite.permalink();
M internal/app/testdata/web/tree-slash-ref.html +8 -1
@@ -20,7 +20,7 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23-<script src="/-/static/js/store.HASH.js" defer>
23+<script src="/-/static/js/store.HASH.js">
2424 </script>
2525 <script src="/-/static/js/ermite.HASH.js" defer>
2626 </script>
@@ -251,6 +251,12 @@
251251 </div>
252252 <div>
253253 <dt>
254+<kbd>w</kbd>
255+</dt>
256+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
257+</div>
258+<div>
259+<dt>
254260 <kbd>y</kbd>
255261 </dt>
256262 <dd>copy a permalink pinned to the commit</dd>
@@ -428,6 +434,7 @@
428434 k-split from:body -> ermite.layout('split');
429435 k-unified from:body -> ermite.layout('unified');
430436 k-collapse from:body -> ermite.collapse();
437+ k-wide from:body -> window.ermiteWide.toggle();
431438 k-expand from:body -> ermite.expandFile();
432439 k-expandAll from:body -> ermite.expandAll();
433440 k-permalink from:body -> ermite.permalink();
@@ -20,7 +20,7 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23-<script src="/-/static/js/store.HASH.js" defer>23+<script src="/-/static/js/store.HASH.js">
24 </script>24 </script>
25 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
26 </script>26 </script>
@@ -251,6 +251,12 @@
251 </div>251 </div>
252 <div>252 <div>
253 <dt>253 <dt>
254+<kbd>w</kbd>
255+</dt>
256+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
257+</div>
258+<div>
259+<dt>
254 <kbd>y</kbd>260 <kbd>y</kbd>
255 </dt>261 </dt>
256 <dd>copy a permalink pinned to the commit</dd>262 <dd>copy a permalink pinned to the commit</dd>
@@ -428,6 +434,7 @@
428 k-split from:body -> ermite.layout('split');434 k-split from:body -> ermite.layout('split');
429 k-unified from:body -> ermite.layout('unified');435 k-unified from:body -> ermite.layout('unified');
430 k-collapse from:body -> ermite.collapse();436 k-collapse from:body -> ermite.collapse();
437+ k-wide from:body -> window.ermiteWide.toggle();
431 k-expand from:body -> ermite.expandFile();438 k-expand from:body -> ermite.expandFile();
432 k-expandAll from:body -> ermite.expandAll();439 k-expandAll from:body -> ermite.expandAll();
433 k-permalink from:body -> ermite.permalink();440 k-permalink from:body -> ermite.permalink();
M internal/app/testdata/web/tree-subdir.html +8 -1
@@ -20,7 +20,7 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23-<script src="/-/static/js/store.HASH.js" defer>
23+<script src="/-/static/js/store.HASH.js">
2424 </script>
2525 <script src="/-/static/js/ermite.HASH.js" defer>
2626 </script>
@@ -267,6 +267,12 @@
267267 </div>
268268 <div>
269269 <dt>
270+<kbd>w</kbd>
271+</dt>
272+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
273+</div>
274+<div>
275+<dt>
270276 <kbd>y</kbd>
271277 </dt>
272278 <dd>copy a permalink pinned to the commit</dd>
@@ -444,6 +450,7 @@
444450 k-split from:body -> ermite.layout('split');
445451 k-unified from:body -> ermite.layout('unified');
446452 k-collapse from:body -> ermite.collapse();
453+ k-wide from:body -> window.ermiteWide.toggle();
447454 k-expand from:body -> ermite.expandFile();
448455 k-expandAll from:body -> ermite.expandAll();
449456 k-permalink from:body -> ermite.permalink();
@@ -20,7 +20,7 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23-<script src="/-/static/js/store.HASH.js" defer>23+<script src="/-/static/js/store.HASH.js">
24 </script>24 </script>
25 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
26 </script>26 </script>
@@ -267,6 +267,12 @@
267 </div>267 </div>
268 <div>268 <div>
269 <dt>269 <dt>
270+<kbd>w</kbd>
271+</dt>
272+<dd>wide mode: use the whole width of the screen, or not (remembered)</dd>
273+</div>
274+<div>
275+<dt>
270 <kbd>y</kbd>276 <kbd>y</kbd>
271 </dt>277 </dt>
272 <dd>copy a permalink pinned to the commit</dd>278 <dd>copy a permalink pinned to the commit</dd>
@@ -444,6 +450,7 @@
444 k-split from:body -> ermite.layout('split');450 k-split from:body -> ermite.layout('split');
445 k-unified from:body -> ermite.layout('unified');451 k-unified from:body -> ermite.layout('unified');
446 k-collapse from:body -> ermite.collapse();452 k-collapse from:body -> ermite.collapse();
453+ k-wide from:body -> window.ermiteWide.toggle();
447 k-expand from:body -> ermite.expandFile();454 k-expand from:body -> ermite.expandFile();
448 k-expandAll from:body -> ermite.expandAll();455 k-expandAll from:body -> ermite.expandAll();
449 k-permalink from:body -> ermite.permalink();456 k-permalink from:body -> ermite.permalink();
M internal/app/web_events_test.go +2 -2
@@ -123,7 +123,7 @@ func TestWebEventsHome(t *testing.T) {
123123 assert.Equal(t, "home-list", ev.name)
124124 assert.NotEmpty(t, ev.id)
125125 assert.Contains(t, ev.data, `<li id="repo-pub"`)
126- assert.Contains(t, ev.data, `new push`)
126+ assert.Contains(t, ev.data, `new activity`)
127127 assert.Contains(t, ev.data, `href="/pub"`)
128128 assert.Contains(t, ev.data, `<ul class="repo-commits" aria-label="Recent commits of pub">`, "the live row lists the commits too")
129129
@@ -134,7 +134,7 @@ func TestWebEventsHome(t *testing.T) {
134134 assert.Equal(t, "home-list", ev.name)
135135 assert.Contains(t, ev.data, `<li id="repo-fresh"`)
136136 assert.Contains(t, ev.data, "a &lt;b&gt;fresh&lt;/b&gt; one", "text is escaped")
137- assert.NotContains(t, ev.data, "new push")
137+ assert.NotContains(t, ev.data, "new activity")
138138
139139 // A repository that turns private is removed from the anonymous list.
140140 priv := domain.VisibilityPrivate
@@ -123,7 +123,7 @@ func TestWebEventsHome(t *testing.T) {
123 assert.Equal(t, "home-list", ev.name)123 assert.Equal(t, "home-list", ev.name)
124 assert.NotEmpty(t, ev.id)124 assert.NotEmpty(t, ev.id)
125 assert.Contains(t, ev.data, `<li id="repo-pub"`)125 assert.Contains(t, ev.data, `<li id="repo-pub"`)
126- assert.Contains(t, ev.data, `new push`)126+ assert.Contains(t, ev.data, `new activity`)
127 assert.Contains(t, ev.data, `href="/pub"`)127 assert.Contains(t, ev.data, `href="/pub"`)
128 assert.Contains(t, ev.data, `<ul class="repo-commits" aria-label="Recent commits of pub">`, "the live row lists the commits too")128 assert.Contains(t, ev.data, `<ul class="repo-commits" aria-label="Recent commits of pub">`, "the live row lists the commits too")
129 129
@@ -134,7 +134,7 @@ func TestWebEventsHome(t *testing.T) {
134 assert.Equal(t, "home-list", ev.name)134 assert.Equal(t, "home-list", ev.name)
135 assert.Contains(t, ev.data, `<li id="repo-fresh"`)135 assert.Contains(t, ev.data, `<li id="repo-fresh"`)
136 assert.Contains(t, ev.data, "a &lt;b&gt;fresh&lt;/b&gt; one", "text is escaped")136 assert.Contains(t, ev.data, "a &lt;b&gt;fresh&lt;/b&gt; one", "text is escaped")
137- assert.NotContains(t, ev.data, "new push")137+ assert.NotContains(t, ev.data, "new activity")
138 138
139 // A repository that turns private is removed from the anonymous list.139 // A repository that turns private is removed from the anonymous list.
140 priv := domain.VisibilityPrivate140 priv := domain.VisibilityPrivate
M internal/app/web_mr_test.go +2 -0
@@ -268,6 +268,8 @@ func TestWebMergeRequestReviewAndFastForward(t *testing.T) {
268268 assert.Contains(t, line.body, `name="path" value="search.go"`)
269269 assert.Contains(t, line.body, `name="line" value="3"`)
270270 assert.Contains(t, line.body, `name="side" value="new"`)
271+ assert.Contains(t, line.body, `comment on search.go:3 (new)`, "one line: the title names it")
272+ assert.Contains(t, b.get(base+"?on=new:1-3:search.go", as(token)).body, `comment on search.go:1-3 (new)`, "a range: the title names all of it")
271273 assert.Equal(t, 0, strings.Count(b.get(base+"?on=new:99:search.go", as(token)).body, `name="line" value="99"`), "a line the diff lacks has no form")
272274 assert.Equal(t, 0, strings.Count(b.get(base+"?on=new:3:search.go", nil).body, `<form`), "nor for a visitor")
273275
@@ -268,6 +268,8 @@ func TestWebMergeRequestReviewAndFastForward(t *testing.T) {
268 assert.Contains(t, line.body, `name="path" value="search.go"`)268 assert.Contains(t, line.body, `name="path" value="search.go"`)
269 assert.Contains(t, line.body, `name="line" value="3"`)269 assert.Contains(t, line.body, `name="line" value="3"`)
270 assert.Contains(t, line.body, `name="side" value="new"`)270 assert.Contains(t, line.body, `name="side" value="new"`)
271+ assert.Contains(t, line.body, `comment on search.go:3 (new)`, "one line: the title names it")
272+ assert.Contains(t, b.get(base+"?on=new:1-3:search.go", as(token)).body, `comment on search.go:1-3 (new)`, "a range: the title names all of it")
271 assert.Equal(t, 0, strings.Count(b.get(base+"?on=new:99:search.go", as(token)).body, `name="line" value="99"`), "a line the diff lacks has no form")273 assert.Equal(t, 0, strings.Count(b.get(base+"?on=new:99:search.go", as(token)).body, `name="line" value="99"`), "a line the diff lacks has no form")
272 assert.Equal(t, 0, strings.Count(b.get(base+"?on=new:3:search.go", nil).body, `<form`), "nor for a visitor")274 assert.Equal(t, 0, strings.Count(b.get(base+"?on=new:3:search.go", nil).body, `<form`), "nor for a visitor")
273 275
M internal/domain/repo.go +1 -0
@@ -42,6 +42,7 @@ type Repo struct {
4242 Visibility Visibility
4343 CreatedAt time.Time
4444 PushedAt time.Time // zero until the first push
45+ ActiveAt time.Time // latest push, merge request or issue activity
4546 // Settings are the merge settings; the stores always fill them in, with
4647 // the defaults for a repository that never stored any.
4748 Settings RepoSettings
@@ -42,6 +42,7 @@ type Repo struct {
42 Visibility Visibility42 Visibility Visibility
43 CreatedAt time.Time43 CreatedAt time.Time
44 PushedAt time.Time // zero until the first push44 PushedAt time.Time // zero until the first push
45+ ActiveAt time.Time // latest push, merge request or issue activity
45 // Settings are the merge settings; the stores always fill them in, with46 // Settings are the merge settings; the stores always fill them in, with
46 // the defaults for a repository that never stored any.47 // the defaults for a repository that never stored any.
47 Settings RepoSettings48 Settings RepoSettings
M internal/ports/dto.go +1 -1
@@ -21,7 +21,7 @@ type RepoPage struct {
2121
2222 // RepoView is a repository with the Git state that belongs in its summary.
2323 type RepoView struct {
24- domain.Repo
24+ domain.Repo // includes ActiveAt, the latest repository activity
2525 // DefaultBranch is the branch HEAD points to, even when it does not exist
2626 // yet; empty when HEAD is detached or the directory is missing.
2727 DefaultBranch string
@@ -21,7 +21,7 @@ type RepoPage struct {
21 21
22 // RepoView is a repository with the Git state that belongs in its summary.22 // RepoView is a repository with the Git state that belongs in its summary.
23 type RepoView struct {23 type RepoView struct {
24- domain.Repo24+ domain.Repo // includes ActiveAt, the latest repository activity
25 // DefaultBranch is the branch HEAD points to, even when it does not exist25 // DefaultBranch is the branch HEAD points to, even when it does not exist
26 // yet; empty when HEAD is detached or the directory is missing.26 // yet; empty when HEAD is detached or the directory is missing.
27 DefaultBranch string27 DefaultBranch string
M internal/ports/mocks/RepoMetaStore.go +36 -21
@@ -289,59 +289,74 @@ func (_c *RepoMetaStore_List_Call) RunAndReturn(run func(ctx context.Context) ([
289289 return _c
290290 }
291291
292-// Update provides a mock function for the type RepoMetaStore
293-func (_mock *RepoMetaStore) Update(ctx context.Context, repo domain.Repo) error {
294- ret := _mock.Called(ctx, repo)
292+// Mutate provides a mock function for the type RepoMetaStore
293+func (_mock *RepoMetaStore) Mutate(ctx context.Context, name domain.RepoName, fn func(*domain.Repo) (changed bool, err error)) (domain.Repo, error) {
294+ ret := _mock.Called(ctx, name, fn)
295295
296296 if len(ret) == 0 {
297- panic("no return value specified for Update")
297+ panic("no return value specified for Mutate")
298298 }
299299
300- var r0 error
301- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Repo) error); ok {
302- r0 = returnFunc(ctx, repo)
300+ var r0 domain.Repo
301+ var r1 error
302+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, func(*domain.Repo) (changed bool, err error)) (domain.Repo, error)); ok {
303+ return returnFunc(ctx, name, fn)
304+ }
305+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, func(*domain.Repo) (changed bool, err error)) domain.Repo); ok {
306+ r0 = returnFunc(ctx, name, fn)
303307 } else {
304- r0 = ret.Error(0)
308+ r0 = ret.Get(0).(domain.Repo)
305309 }
306- return r0
310+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, func(*domain.Repo) (changed bool, err error)) error); ok {
311+ r1 = returnFunc(ctx, name, fn)
312+ } else {
313+ r1 = ret.Error(1)
314+ }
315+ return r0, r1
307316 }
308317
309-// RepoMetaStore_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update'
310-type RepoMetaStore_Update_Call struct {
318+// RepoMetaStore_Mutate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Mutate'
319+type RepoMetaStore_Mutate_Call struct {
311320 *mock.Call
312321 }
313322
314-// Update is a helper method to define mock.On call
323+// Mutate is a helper method to define mock.On call
315324 // - ctx context.Context
316-// - repo domain.Repo
317-func (_e *RepoMetaStore_Expecter) Update(ctx any, repo any) *RepoMetaStore_Update_Call {
318- return &RepoMetaStore_Update_Call{Call: _e.mock.On("Update", ctx, repo)}
325+// - name domain.RepoName
326+// - fn func(*domain.Repo) (changed bool, err error)
327+func (_e *RepoMetaStore_Expecter) Mutate(ctx any, name any, fn any) *RepoMetaStore_Mutate_Call {
328+ return &RepoMetaStore_Mutate_Call{Call: _e.mock.On("Mutate", ctx, name, fn)}
319329 }
320330
321-func (_c *RepoMetaStore_Update_Call) Run(run func(ctx context.Context, repo domain.Repo)) *RepoMetaStore_Update_Call {
331+func (_c *RepoMetaStore_Mutate_Call) Run(run func(ctx context.Context, name domain.RepoName, fn func(*domain.Repo) (changed bool, err error))) *RepoMetaStore_Mutate_Call {
322332 _c.Call.Run(func(args mock.Arguments) {
323333 var arg0 context.Context
324334 if args[0] != nil {
325335 arg0 = args[0].(context.Context)
326336 }
327- var arg1 domain.Repo
337+ var arg1 domain.RepoName
328338 if args[1] != nil {
329- arg1 = args[1].(domain.Repo)
339+ arg1 = args[1].(domain.RepoName)
340+ }
341+ var arg2 func(*domain.Repo) (changed bool, err error)
342+ if args[2] != nil {
343+ arg2 = args[2].(func(*domain.Repo) (changed bool, err error))
330344 }
331345 run(
332346 arg0,
333347 arg1,
348+ arg2,
334349 )
335350 })
336351 return _c
337352 }
338353
339-func (_c *RepoMetaStore_Update_Call) Return(err error) *RepoMetaStore_Update_Call {
340- _c.Call.Return(err)
354+func (_c *RepoMetaStore_Mutate_Call) Return(repo domain.Repo, err error) *RepoMetaStore_Mutate_Call {
355+ _c.Call.Return(repo, err)
341356 return _c
342357 }
343358
344-func (_c *RepoMetaStore_Update_Call) RunAndReturn(run func(ctx context.Context, repo domain.Repo) error) *RepoMetaStore_Update_Call {
359+func (_c *RepoMetaStore_Mutate_Call) RunAndReturn(run func(ctx context.Context, name domain.RepoName, fn func(*domain.Repo) (changed bool, err error)) (domain.Repo, error)) *RepoMetaStore_Mutate_Call {
345360 _c.Call.Return(run)
346361 return _c
347362 }
@@ -289,59 +289,74 @@ func (_c *RepoMetaStore_List_Call) RunAndReturn(run func(ctx context.Context) ([
289 return _c289 return _c
290 }290 }
291 291
292-// Update provides a mock function for the type RepoMetaStore292+// Mutate provides a mock function for the type RepoMetaStore
293-func (_mock *RepoMetaStore) Update(ctx context.Context, repo domain.Repo) error {293+func (_mock *RepoMetaStore) Mutate(ctx context.Context, name domain.RepoName, fn func(*domain.Repo) (changed bool, err error)) (domain.Repo, error) {
294- ret := _mock.Called(ctx, repo)294+ ret := _mock.Called(ctx, name, fn)
295 295
296 if len(ret) == 0 {296 if len(ret) == 0 {
297- panic("no return value specified for Update")297+ panic("no return value specified for Mutate")
298 }298 }
299 299
300- var r0 error300+ var r0 domain.Repo
301- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Repo) error); ok {301+ var r1 error
302- r0 = returnFunc(ctx, repo)302+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, func(*domain.Repo) (changed bool, err error)) (domain.Repo, error)); ok {
303+ return returnFunc(ctx, name, fn)
304+ }
305+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, func(*domain.Repo) (changed bool, err error)) domain.Repo); ok {
306+ r0 = returnFunc(ctx, name, fn)
303 } else {307 } else {
304- r0 = ret.Error(0)308+ r0 = ret.Get(0).(domain.Repo)
305 }309 }
306- return r0310+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, func(*domain.Repo) (changed bool, err error)) error); ok {
311+ r1 = returnFunc(ctx, name, fn)
312+ } else {
313+ r1 = ret.Error(1)
314+ }
315+ return r0, r1
307 }316 }
308 317
309-// RepoMetaStore_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update'318+// RepoMetaStore_Mutate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Mutate'
310-type RepoMetaStore_Update_Call struct {319+type RepoMetaStore_Mutate_Call struct {
311 *mock.Call320 *mock.Call
312 }321 }
313 322
314-// Update is a helper method to define mock.On call323+// Mutate is a helper method to define mock.On call
315 // - ctx context.Context324 // - ctx context.Context
316-// - repo domain.Repo325+// - name domain.RepoName
317-func (_e *RepoMetaStore_Expecter) Update(ctx any, repo any) *RepoMetaStore_Update_Call {326+// - fn func(*domain.Repo) (changed bool, err error)
318- return &RepoMetaStore_Update_Call{Call: _e.mock.On("Update", ctx, repo)}327+func (_e *RepoMetaStore_Expecter) Mutate(ctx any, name any, fn any) *RepoMetaStore_Mutate_Call {
328+ return &RepoMetaStore_Mutate_Call{Call: _e.mock.On("Mutate", ctx, name, fn)}
319 }329 }
320 330
321-func (_c *RepoMetaStore_Update_Call) Run(run func(ctx context.Context, repo domain.Repo)) *RepoMetaStore_Update_Call {331+func (_c *RepoMetaStore_Mutate_Call) Run(run func(ctx context.Context, name domain.RepoName, fn func(*domain.Repo) (changed bool, err error))) *RepoMetaStore_Mutate_Call {
322 _c.Call.Run(func(args mock.Arguments) {332 _c.Call.Run(func(args mock.Arguments) {
323 var arg0 context.Context333 var arg0 context.Context
324 if args[0] != nil {334 if args[0] != nil {
325 arg0 = args[0].(context.Context)335 arg0 = args[0].(context.Context)
326 }336 }
327- var arg1 domain.Repo337+ var arg1 domain.RepoName
328 if args[1] != nil {338 if args[1] != nil {
329- arg1 = args[1].(domain.Repo)339+ arg1 = args[1].(domain.RepoName)
340+ }
341+ var arg2 func(*domain.Repo) (changed bool, err error)
342+ if args[2] != nil {
343+ arg2 = args[2].(func(*domain.Repo) (changed bool, err error))
330 }344 }
331 run(345 run(
332 arg0,346 arg0,
333 arg1,347 arg1,
348+ arg2,
334 )349 )
335 })350 })
336 return _c351 return _c
337 }352 }
338 353
339-func (_c *RepoMetaStore_Update_Call) Return(err error) *RepoMetaStore_Update_Call {354+func (_c *RepoMetaStore_Mutate_Call) Return(repo domain.Repo, err error) *RepoMetaStore_Mutate_Call {
340- _c.Call.Return(err)355+ _c.Call.Return(repo, err)
341 return _c356 return _c
342 }357 }
343 358
344-func (_c *RepoMetaStore_Update_Call) RunAndReturn(run func(ctx context.Context, repo domain.Repo) error) *RepoMetaStore_Update_Call {359+func (_c *RepoMetaStore_Mutate_Call) RunAndReturn(run func(ctx context.Context, name domain.RepoName, fn func(*domain.Repo) (changed bool, err error)) (domain.Repo, error)) *RepoMetaStore_Mutate_Call {
345 _c.Call.Return(run)360 _c.Call.Return(run)
346 return _c361 return _c
347 }362 }
M internal/ports/ports.go +7 -4
@@ -32,10 +32,13 @@ type RepoMetaStore interface {
3232 Get(ctx context.Context, name domain.RepoName) (domain.Repo, error)
3333 // List returns every repository ordered by name.
3434 List(ctx context.Context) ([]domain.Repo, error)
35- // Update replaces the stored repository. It returns domain.ErrNotFound if
36- // there is none and domain.ErrConflict if another write changed the
37- // stored repository since it was read.
38- Update(ctx context.Context, repo domain.Repo) error
35+ // Mutate applies fn to the stored repository and writes under the version read.
36+ // Concurrent writes retry fn up to a bounded number of attempts, then return
37+ // domain.ErrConflict. fn must be pure and safe to call several times, and
38+ // must not change Name. changed=false writes nothing. It returns the stored
39+ // repository, or domain.ErrNotFound when absent. Errors from fn are returned
40+ // unchanged with nothing written.
41+ Mutate(ctx context.Context, name domain.RepoName, fn func(*domain.Repo) (changed bool, err error)) (domain.Repo, error)
3942 // Delete removes the repository and everything keyed to it: its merge
4043 // requests, their comments and read positions, and its number sequence.
4144 // A repository created again under the same name starts empty, with its
@@ -32,10 +32,13 @@ type RepoMetaStore interface {
32 Get(ctx context.Context, name domain.RepoName) (domain.Repo, error)32 Get(ctx context.Context, name domain.RepoName) (domain.Repo, error)
33 // List returns every repository ordered by name.33 // List returns every repository ordered by name.
34 List(ctx context.Context) ([]domain.Repo, error)34 List(ctx context.Context) ([]domain.Repo, error)
35- // Update replaces the stored repository. It returns domain.ErrNotFound if35+ // Mutate applies fn to the stored repository and writes under the version read.
36- // there is none and domain.ErrConflict if another write changed the36+ // Concurrent writes retry fn up to a bounded number of attempts, then return
37- // stored repository since it was read.37+ // domain.ErrConflict. fn must be pure and safe to call several times, and
38- Update(ctx context.Context, repo domain.Repo) error38+ // must not change Name. changed=false writes nothing. It returns the stored
39+ // repository, or domain.ErrNotFound when absent. Errors from fn are returned
40+ // unchanged with nothing written.
41+ Mutate(ctx context.Context, name domain.RepoName, fn func(*domain.Repo) (changed bool, err error)) (domain.Repo, error)
39 // Delete removes the repository and everything keyed to it: its merge42 // Delete removes the repository and everything keyed to it: its merge
40 // requests, their comments and read positions, and its number sequence.43 // requests, their comments and read positions, and its number sequence.
41 // A repository created again under the same name starts empty, with its44 // A repository created again under the same name starts empty, with its
A internal/usecase/activity.go +43 -0
@@ -0,0 +1,43 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "time"
10+
11+ "github.com/bnema/zerowrap"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+)
16+
17+// recordActivity is best-effort: a metadata failure never fails the action.
18+func recordActivity(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, at time.Time) {
19+ if _, err := touchActiveAt(ctx, meta, name, at); err != nil {
20+ log := zerowrap.FromCtx(ctx)
21+ log.Error().Err(err).Str("repo", name.String()).Msg("record repository activity")
22+ }
23+}
24+
25+// touchActiveAt preserves later activity and retries concurrent metadata writes.
26+// A repository deleted meanwhile is not an error.
27+func touchActiveAt(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, now time.Time) (domain.Repo, error) {
28+ repo, err := meta.Mutate(ctx, name, func(repo *domain.Repo) (bool, error) {
29+ changed := false
30+ if now.After(repo.ActiveAt) {
31+ repo.ActiveAt = now
32+ changed = true
33+ }
34+ return changed, nil
35+ })
36+ if errors.Is(err, domain.ErrNotFound) {
37+ return domain.Repo{}, nil
38+ }
39+ if err != nil {
40+ return repo, fmt.Errorf("update repository %q: %w", name, err)
41+ }
42+ return repo, nil
43+}
@@ -0,0 +1,43 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "time"
10+
11+ "github.com/bnema/zerowrap"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+)
16+
17+// recordActivity is best-effort: a metadata failure never fails the action.
18+func recordActivity(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, at time.Time) {
19+ if _, err := touchActiveAt(ctx, meta, name, at); err != nil {
20+ log := zerowrap.FromCtx(ctx)
21+ log.Error().Err(err).Str("repo", name.String()).Msg("record repository activity")
22+ }
23+}
24+
25+// touchActiveAt preserves later activity and retries concurrent metadata writes.
26+// A repository deleted meanwhile is not an error.
27+func touchActiveAt(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, now time.Time) (domain.Repo, error) {
28+ repo, err := meta.Mutate(ctx, name, func(repo *domain.Repo) (bool, error) {
29+ changed := false
30+ if now.After(repo.ActiveAt) {
31+ repo.ActiveAt = now
32+ changed = true
33+ }
34+ return changed, nil
35+ })
36+ if errors.Is(err, domain.ErrNotFound) {
37+ return domain.Repo{}, nil
38+ }
39+ if err != nil {
40+ return repo, fmt.Errorf("update repository %q: %w", name, err)
41+ }
42+ return repo, nil
43+}
A internal/usecase/activity_store_test.go +46 -0
@@ -0,0 +1,46 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "bnema.dev/ermite/internal/adapters/kvstore"
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/usecase"
14+)
15+
16+func TestActivityTimestampsOverStore(t *testing.T) {
17+ ctx := t.Context()
18+ s, err := kvstore.Open(ctx, t.TempDir())
19+ require.NoError(t, err)
20+ t.Cleanup(func() { require.NoError(t, s.Close(ctx)) })
21+ n, err := domain.ParseRepoName("proj")
22+ require.NoError(t, err)
23+ at := time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC)
24+ require.NoError(t, s.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: at}))
25+ got, err := usecase.TouchPushedAt(ctx, s, n, at.Add(time.Hour))
26+ require.NoError(t, err)
27+ require.Equal(t, got.PushedAt, got.ActiveAt)
28+ _, err = usecase.TouchActiveAt(ctx, s, n, at.Add(3*time.Hour))
29+ require.NoError(t, err)
30+ _, err = usecase.TouchPushedAt(ctx, s, n, at.Add(2*time.Hour))
31+ require.NoError(t, err)
32+ _, err = usecase.TouchPushedAt(ctx, s, n, at)
33+ require.NoError(t, err)
34+ _, err = usecase.TouchActiveAt(ctx, s, n, at)
35+ require.NoError(t, err)
36+ got, err = s.Get(ctx, n)
37+ require.NoError(t, err)
38+ require.Equal(t, at.Add(2*time.Hour), got.PushedAt)
39+ require.Equal(t, at.Add(3*time.Hour), got.ActiveAt)
40+ require.Equal(t, domain.VisibilityPrivate, got.Visibility)
41+ require.NoError(t, s.Delete(ctx, n))
42+ _, err = usecase.TouchPushedAt(ctx, s, n, at)
43+ require.NoError(t, err)
44+ _, err = usecase.TouchActiveAt(ctx, s, n, at)
45+ require.NoError(t, err)
46+}
@@ -0,0 +1,46 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "bnema.dev/ermite/internal/adapters/kvstore"
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/usecase"
14+)
15+
16+func TestActivityTimestampsOverStore(t *testing.T) {
17+ ctx := t.Context()
18+ s, err := kvstore.Open(ctx, t.TempDir())
19+ require.NoError(t, err)
20+ t.Cleanup(func() { require.NoError(t, s.Close(ctx)) })
21+ n, err := domain.ParseRepoName("proj")
22+ require.NoError(t, err)
23+ at := time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC)
24+ require.NoError(t, s.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: at}))
25+ got, err := usecase.TouchPushedAt(ctx, s, n, at.Add(time.Hour))
26+ require.NoError(t, err)
27+ require.Equal(t, got.PushedAt, got.ActiveAt)
28+ _, err = usecase.TouchActiveAt(ctx, s, n, at.Add(3*time.Hour))
29+ require.NoError(t, err)
30+ _, err = usecase.TouchPushedAt(ctx, s, n, at.Add(2*time.Hour))
31+ require.NoError(t, err)
32+ _, err = usecase.TouchPushedAt(ctx, s, n, at)
33+ require.NoError(t, err)
34+ _, err = usecase.TouchActiveAt(ctx, s, n, at)
35+ require.NoError(t, err)
36+ got, err = s.Get(ctx, n)
37+ require.NoError(t, err)
38+ require.Equal(t, at.Add(2*time.Hour), got.PushedAt)
39+ require.Equal(t, at.Add(3*time.Hour), got.ActiveAt)
40+ require.Equal(t, domain.VisibilityPrivate, got.Visibility)
41+ require.NoError(t, s.Delete(ctx, n))
42+ _, err = usecase.TouchPushedAt(ctx, s, n, at)
43+ require.NoError(t, err)
44+ _, err = usecase.TouchActiveAt(ctx, s, n, at)
45+ require.NoError(t, err)
46+}
A internal/usecase/activity_test.go +47 -0
@@ -0,0 +1,47 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports/mocks"
16+)
17+
18+func TestTouchActiveAt(t *testing.T) {
19+ name, err := domain.ParseRepoName("proj")
20+ require.NoError(t, err)
21+ now := time.Now().UTC()
22+ t.Run("preserves later activity", func(t *testing.T) {
23+ meta := mocks.NewRepoMetaStore(t)
24+ later := domain.Repo{Name: name, ActiveAt: now.Add(time.Hour)}
25+ meta.EXPECT().Mutate(mock.Anything, name, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.RepoName, fn func(*domain.Repo) (bool, error)) (domain.Repo, error) {
26+ repo := later
27+ changed, err := fn(&repo)
28+ require.NoError(t, err)
29+ require.False(t, changed)
30+ return repo, nil
31+ })
32+ got, err := touchActiveAt(t.Context(), meta, name, now)
33+ require.NoError(t, err)
34+ require.Equal(t, later, got)
35+ })
36+ t.Run("deleted repository", func(t *testing.T) {
37+ meta := mocks.NewRepoMetaStore(t)
38+ meta.EXPECT().Mutate(mock.Anything, name, mock.Anything).Return(domain.Repo{}, domain.ErrNotFound)
39+ _, err := touchActiveAt(t.Context(), meta, name, now)
40+ require.NoError(t, err)
41+ })
42+ t.Run("best effort failure", func(t *testing.T) {
43+ meta := mocks.NewRepoMetaStore(t)
44+ meta.EXPECT().Mutate(mock.Anything, name, mock.Anything).Return(domain.Repo{}, errors.New("offline"))
45+ require.NotPanics(t, func() { recordActivity(t.Context(), meta, name, now) })
46+ })
47+}
@@ -0,0 +1,47 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports/mocks"
16+)
17+
18+func TestTouchActiveAt(t *testing.T) {
19+ name, err := domain.ParseRepoName("proj")
20+ require.NoError(t, err)
21+ now := time.Now().UTC()
22+ t.Run("preserves later activity", func(t *testing.T) {
23+ meta := mocks.NewRepoMetaStore(t)
24+ later := domain.Repo{Name: name, ActiveAt: now.Add(time.Hour)}
25+ meta.EXPECT().Mutate(mock.Anything, name, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.RepoName, fn func(*domain.Repo) (bool, error)) (domain.Repo, error) {
26+ repo := later
27+ changed, err := fn(&repo)
28+ require.NoError(t, err)
29+ require.False(t, changed)
30+ return repo, nil
31+ })
32+ got, err := touchActiveAt(t.Context(), meta, name, now)
33+ require.NoError(t, err)
34+ require.Equal(t, later, got)
35+ })
36+ t.Run("deleted repository", func(t *testing.T) {
37+ meta := mocks.NewRepoMetaStore(t)
38+ meta.EXPECT().Mutate(mock.Anything, name, mock.Anything).Return(domain.Repo{}, domain.ErrNotFound)
39+ _, err := touchActiveAt(t.Context(), meta, name, now)
40+ require.NoError(t, err)
41+ })
42+ t.Run("best effort failure", func(t *testing.T) {
43+ meta := mocks.NewRepoMetaStore(t)
44+ meta.EXPECT().Mutate(mock.Anything, name, mock.Anything).Return(domain.Repo{}, errors.New("offline"))
45+ require.NotPanics(t, func() { recordActivity(t.Context(), meta, name, now) })
46+ })
47+}
M internal/usecase/export_test.go +10 -0
@@ -19,6 +19,16 @@ import (
1919 // Sweep exposes the daily sweep to the tests.
2020 func (c *CI) Sweep(ctx context.Context) { c.sweep(ctx) }
2121
22+// TouchPushedAt exposes touchPushedAt to the tests.
23+func TouchPushedAt(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, now time.Time) (domain.Repo, error) {
24+ return touchPushedAt(ctx, meta, name, now)
25+}
26+
27+// TouchActiveAt exposes touchActiveAt to the tests.
28+func TouchActiveAt(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, now time.Time) (domain.Repo, error) {
29+ return touchActiveAt(ctx, meta, name, now)
30+}
31+
2232 // RunTitle gives the title of a run as startRun sets it: the evaluated
2333 // run-name, else commitTitle.
2434 func (c *CI) RunTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, commitTitle string) string {
@@ -19,6 +19,16 @@ import (
19 // Sweep exposes the daily sweep to the tests.19 // Sweep exposes the daily sweep to the tests.
20 func (c *CI) Sweep(ctx context.Context) { c.sweep(ctx) }20 func (c *CI) Sweep(ctx context.Context) { c.sweep(ctx) }
21 21
22+// TouchPushedAt exposes touchPushedAt to the tests.
23+func TouchPushedAt(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, now time.Time) (domain.Repo, error) {
24+ return touchPushedAt(ctx, meta, name, now)
25+}
26+
27+// TouchActiveAt exposes touchActiveAt to the tests.
28+func TouchActiveAt(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, now time.Time) (domain.Repo, error) {
29+ return touchActiveAt(ctx, meta, name, now)
30+}
31+
22 // RunTitle gives the title of a run as startRun sets it: the evaluated32 // RunTitle gives the title of a run as startRun sets it: the evaluated
23 // run-name, else commitTitle.33 // run-name, else commitTitle.
24 func (c *CI) RunTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, commitTitle string) string {34 func (c *CI) RunTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, commitTitle string) string {
M internal/usecase/issue_moderation.go +1 -0
@@ -85,6 +85,7 @@ func (m *IssueModeration) Approve(ctx context.Context, p domain.Principal, repo
8585 log := zerowrap.FromCtx(ctx)
8686 log.Info().Str("repo", repo.String()).Int64("number", issue.Number).
8787 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("pending issue approved")
88+ recordActivity(ctx, m.meta, repo, now)
8889 m.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: r.Visibility, CreatedAt: r.CreatedAt})
8990 return issue, nil
9091 }
@@ -85,6 +85,7 @@ func (m *IssueModeration) Approve(ctx context.Context, p domain.Principal, repo
85 log := zerowrap.FromCtx(ctx)85 log := zerowrap.FromCtx(ctx)
86 log.Info().Str("repo", repo.String()).Int64("number", issue.Number).86 log.Info().Str("repo", repo.String()).Int64("number", issue.Number).
87 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("pending issue approved")87 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("pending issue approved")
88+ recordActivity(ctx, m.meta, repo, now)
88 m.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: r.Visibility, CreatedAt: r.CreatedAt})89 m.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: r.Visibility, CreatedAt: r.CreatedAt})
89 return issue, nil90 return issue, nil
90 }91 }
M internal/usecase/issue_moderation_test.go +1 -0
@@ -89,6 +89,7 @@ func TestIssueModerationApprove(t *testing.T) {
8989 u := newModerationUnit(t, domain.IssuesPublic)
9090 issue := domain.Issue{Repo: u.repo.Name, Number: 7, State: domain.IssueStateOpen}
9191 u.pending.EXPECT().Approve(mock.Anything, u.repo.Name, u.id, now).Return(issue, nil).Once()
92+ expectRepoMutation(t, u.meta, u.repo, mock.MatchedBy(func(r domain.Repo) bool { return r.ActiveAt.Equal(now) })).Once()
9293 u.bus.EXPECT().Publish(mock.Anything, domain.IssueOpened{Issue: issue, At: now, Visibility: u.repo.Visibility, CreatedAt: u.repo.CreatedAt}).Once()
9394 got, err := u.mod.Approve(ctx, domain.Owner(), u.repo.Name, u.id)
9495 require.NoError(t, err)
@@ -89,6 +89,7 @@ func TestIssueModerationApprove(t *testing.T) {
89 u := newModerationUnit(t, domain.IssuesPublic)89 u := newModerationUnit(t, domain.IssuesPublic)
90 issue := domain.Issue{Repo: u.repo.Name, Number: 7, State: domain.IssueStateOpen}90 issue := domain.Issue{Repo: u.repo.Name, Number: 7, State: domain.IssueStateOpen}
91 u.pending.EXPECT().Approve(mock.Anything, u.repo.Name, u.id, now).Return(issue, nil).Once()91 u.pending.EXPECT().Approve(mock.Anything, u.repo.Name, u.id, now).Return(issue, nil).Once()
92+ expectRepoMutation(t, u.meta, u.repo, mock.MatchedBy(func(r domain.Repo) bool { return r.ActiveAt.Equal(now) })).Once()
92 u.bus.EXPECT().Publish(mock.Anything, domain.IssueOpened{Issue: issue, At: now, Visibility: u.repo.Visibility, CreatedAt: u.repo.CreatedAt}).Once()93 u.bus.EXPECT().Publish(mock.Anything, domain.IssueOpened{Issue: issue, At: now, Visibility: u.repo.Visibility, CreatedAt: u.repo.CreatedAt}).Once()
93 got, err := u.mod.Approve(ctx, domain.Owner(), u.repo.Name, u.id)94 got, err := u.mod.Approve(ctx, domain.Owner(), u.repo.Name, u.id)
94 require.NoError(t, err)95 require.NoError(t, err)
M internal/usecase/issues.go +3 -0
@@ -124,6 +124,7 @@ func (i *Issues) Create(ctx context.Context, p domain.Principal, name domain.Rep
124124 log := zerowrap.FromCtx(ctx)
125125 log.Info().Str("repo", name.String()).Int64("number", issue.Number).
126126 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue opened")
127+ recordActivity(ctx, i.meta, repo.Name, now)
127128 i.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
128129 return issue, nil
129130 }
@@ -211,6 +212,7 @@ func (i *Issues) AddComment(ctx context.Context, p domain.Principal, name domain
211212 log := zerowrap.FromCtx(ctx)
212213 log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).
213214 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue comment added")
215+ recordActivity(ctx, i.meta, name, c.CreatedAt)
214216 i.bus.Publish(ctx, domain.IssueCommented{
215217 Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
216218 })
@@ -260,6 +262,7 @@ func (i *Issues) transition(ctx context.Context, p domain.Principal, name domain
260262 log := zerowrap.FromCtx(ctx)
261263 log.Info().Str("repo", name.String()).Int64("number", number).
262264 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue " + verb + "d")
265+ recordActivity(ctx, i.meta, repo.Name, issue.UpdatedAt)
263266 if to == domain.IssueStateOpen {
264267 i.bus.Publish(ctx, domain.IssueReopened{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
265268 } else {
@@ -124,6 +124,7 @@ func (i *Issues) Create(ctx context.Context, p domain.Principal, name domain.Rep
124 log := zerowrap.FromCtx(ctx)124 log := zerowrap.FromCtx(ctx)
125 log.Info().Str("repo", name.String()).Int64("number", issue.Number).125 log.Info().Str("repo", name.String()).Int64("number", issue.Number).
126 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue opened")126 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue opened")
127+ recordActivity(ctx, i.meta, repo.Name, now)
127 i.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})128 i.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
128 return issue, nil129 return issue, nil
129 }130 }
@@ -211,6 +212,7 @@ func (i *Issues) AddComment(ctx context.Context, p domain.Principal, name domain
211 log := zerowrap.FromCtx(ctx)212 log := zerowrap.FromCtx(ctx)
212 log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).213 log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).
213 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue comment added")214 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue comment added")
215+ recordActivity(ctx, i.meta, name, c.CreatedAt)
214 i.bus.Publish(ctx, domain.IssueCommented{216 i.bus.Publish(ctx, domain.IssueCommented{
215 Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,217 Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
216 })218 })
@@ -260,6 +262,7 @@ func (i *Issues) transition(ctx context.Context, p domain.Principal, name domain
260 log := zerowrap.FromCtx(ctx)262 log := zerowrap.FromCtx(ctx)
261 log.Info().Str("repo", name.String()).Int64("number", number).263 log.Info().Str("repo", name.String()).Int64("number", number).
262 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue " + verb + "d")264 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue " + verb + "d")
265+ recordActivity(ctx, i.meta, repo.Name, issue.UpdatedAt)
263 if to == domain.IssueStateOpen {266 if to == domain.IssueStateOpen {
264 i.bus.Publish(ctx, domain.IssueReopened{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})267 i.bus.Publish(ctx, domain.IssueReopened{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
265 } else {268 } else {
M internal/usecase/issues_integration_test.go +67 -0
@@ -6,14 +6,17 @@ import (
66 "context"
77 "path/filepath"
88 "testing"
9+ "time"
910
1011 "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
1113 "github.com/stretchr/testify/require"
1214
1315 "bnema.dev/ermite/internal/adapters/eventbus"
1416 "bnema.dev/ermite/internal/adapters/kvstore"
1517 "bnema.dev/ermite/internal/domain"
1618 "bnema.dev/ermite/internal/ports"
19+ "bnema.dev/ermite/internal/ports/mocks"
1720 "bnema.dev/ermite/internal/usecase"
1821 )
1922
@@ -46,6 +49,10 @@ func TestIssuesOverTheStore(t *testing.T) {
4649 require.NoError(t, err)
4750 assert.EqualValues(t, 2, issue.Number, "the number follows the merge request")
4851 assert.Equal(t, "bot", issue.Author)
52+ repo, err := store.Get(ctx, n)
53+ require.NoError(t, err)
54+ assert.True(t, repo.ActiveAt.Equal(now))
55+ assert.True(t, repo.PushedAt.IsZero())
4956
5057 kind, err := issues.Kind(ctx, domain.Anonymous(), n, 1)
5158 require.NoError(t, err)
@@ -85,3 +92,63 @@ func TestIssuesOverTheStore(t *testing.T) {
8592 }
8693 assert.Equal(t, []string{"issue.opened", "issue.commented", "issue.closed", "issue.commented", "issue.reopened"}, kinds)
8794 }
95+
96+// Moderated submissions are private work until approval makes them public activity.
97+func TestModeratedIssueActivityOverStore(t *testing.T) {
98+ ctx := t.Context()
99+ store, err := kvstore.Open(ctx, t.TempDir())
100+ require.NoError(t, err)
101+ t.Cleanup(func() { require.NoError(t, store.Close(ctx)) })
102+ n := name(t, "proj")
103+ initial := now.Add(-time.Hour)
104+ require.NoError(t, store.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: initial, ActiveAt: initial, Settings: withIssues(domain.IssuesPublic)}))
105+ clock := mocks.NewClock(t)
106+ at := now
107+ clock.EXPECT().Now().RunAndReturn(func() time.Time { return at }).Maybe()
108+ keys, err := usecase.NewIssueKeys(ctx, store.IssueKeys())
109+ require.NoError(t, err)
110+ limiter := mocks.NewSubmissionLimiter(t)
111+ limiter.EXPECT().Recent(mock.Anything, mock.Anything).Return(0, 0).Maybe()
112+ limiter.EXPECT().Reserve(mock.Anything, mock.Anything).Return(true, time.Duration(0), func() {}).Maybe()
113+ bus := mocks.NewEventBus(t)
114+ var kinds []string
115+ bus.EXPECT().Publish(mock.Anything, mock.Anything).Run(func(_ context.Context, ev domain.Event) {
116+ repo, err := store.Get(ctx, n)
117+ require.NoError(t, err)
118+ if ev.EventKind() == "issue.opened" {
119+ require.Equal(t, at, repo.ActiveAt)
120+ } else {
121+ require.Equal(t, initial, repo.ActiveAt)
122+ }
123+ kinds = append(kinds, ev.EventKind())
124+ }).Maybe()
125+ submissions := usecase.NewIssueSubmissions(usecase.IssueSubmissionsConfig{Meta: store, Pending: store.PendingIssues(), Bans: store.IssueBans(), Limiter: limiter, Keys: keys, Bus: bus, Clock: clock, BaseDifficulty: testBits, MaxDifficulty: testBits})
126+ moderation := usecase.NewIssueModeration(usecase.IssueModerationConfig{Meta: store, Pending: store.PendingIssues(), Bans: store.IssueBans(), Bus: bus, Clock: clock})
127+ submit := func() domain.PendingIssueID {
128+ ch, err := submissions.Challenge(ctx, n, "client")
129+ require.NoError(t, err)
130+ sub := ports.IssueSubmission{Token: ch.Token, FormToken: ch.FormToken, Solution: solve(t, ch), Title: "Bug", Body: "Details"}
131+ at = at.Add(usecase.MinFillTime)
132+ require.NoError(t, submissions.Submit(ctx, n, "client", sub))
133+ repo, err := store.Get(ctx, n)
134+ require.NoError(t, err)
135+ require.Equal(t, initial, repo.ActiveAt)
136+ page, err := store.PendingIssues().List(ctx, n, ports.ListPendingParams{PageSize: 10})
137+ require.NoError(t, err)
138+ require.Len(t, page.Issues, 1)
139+ return page.Issues[0].ID
140+ }
141+ id := submit()
142+ require.NoError(t, moderation.Reject(ctx, domain.Owner(), n, id, false))
143+ repo, err := store.Get(ctx, n)
144+ require.NoError(t, err)
145+ require.Equal(t, initial, repo.ActiveAt)
146+ id = submit()
147+ _, err = moderation.Approve(ctx, domain.Owner(), n, id)
148+ require.NoError(t, err)
149+ repo, err = store.Get(ctx, n)
150+ require.NoError(t, err)
151+ require.Equal(t, at, repo.ActiveAt)
152+ require.True(t, repo.PushedAt.IsZero())
153+ require.Equal(t, []string{"issue.submitted", "issue.pending_removed", "issue.submitted", "issue.opened"}, kinds)
154+}
@@ -6,14 +6,17 @@ import (
6 "context"6 "context"
7 "path/filepath"7 "path/filepath"
8 "testing"8 "testing"
9+ "time"
9 10
10 "github.com/stretchr/testify/assert"11 "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
11 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
12 14
13 "bnema.dev/ermite/internal/adapters/eventbus"15 "bnema.dev/ermite/internal/adapters/eventbus"
14 "bnema.dev/ermite/internal/adapters/kvstore"16 "bnema.dev/ermite/internal/adapters/kvstore"
15 "bnema.dev/ermite/internal/domain"17 "bnema.dev/ermite/internal/domain"
16 "bnema.dev/ermite/internal/ports"18 "bnema.dev/ermite/internal/ports"
19+ "bnema.dev/ermite/internal/ports/mocks"
17 "bnema.dev/ermite/internal/usecase"20 "bnema.dev/ermite/internal/usecase"
18 )21 )
19 22
@@ -46,6 +49,10 @@ func TestIssuesOverTheStore(t *testing.T) {
46 require.NoError(t, err)49 require.NoError(t, err)
47 assert.EqualValues(t, 2, issue.Number, "the number follows the merge request")50 assert.EqualValues(t, 2, issue.Number, "the number follows the merge request")
48 assert.Equal(t, "bot", issue.Author)51 assert.Equal(t, "bot", issue.Author)
52+ repo, err := store.Get(ctx, n)
53+ require.NoError(t, err)
54+ assert.True(t, repo.ActiveAt.Equal(now))
55+ assert.True(t, repo.PushedAt.IsZero())
49 56
50 kind, err := issues.Kind(ctx, domain.Anonymous(), n, 1)57 kind, err := issues.Kind(ctx, domain.Anonymous(), n, 1)
51 require.NoError(t, err)58 require.NoError(t, err)
@@ -85,3 +92,63 @@ func TestIssuesOverTheStore(t *testing.T) {
85 }92 }
86 assert.Equal(t, []string{"issue.opened", "issue.commented", "issue.closed", "issue.commented", "issue.reopened"}, kinds)93 assert.Equal(t, []string{"issue.opened", "issue.commented", "issue.closed", "issue.commented", "issue.reopened"}, kinds)
87 }94 }
95+
96+// Moderated submissions are private work until approval makes them public activity.
97+func TestModeratedIssueActivityOverStore(t *testing.T) {
98+ ctx := t.Context()
99+ store, err := kvstore.Open(ctx, t.TempDir())
100+ require.NoError(t, err)
101+ t.Cleanup(func() { require.NoError(t, store.Close(ctx)) })
102+ n := name(t, "proj")
103+ initial := now.Add(-time.Hour)
104+ require.NoError(t, store.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: initial, ActiveAt: initial, Settings: withIssues(domain.IssuesPublic)}))
105+ clock := mocks.NewClock(t)
106+ at := now
107+ clock.EXPECT().Now().RunAndReturn(func() time.Time { return at }).Maybe()
108+ keys, err := usecase.NewIssueKeys(ctx, store.IssueKeys())
109+ require.NoError(t, err)
110+ limiter := mocks.NewSubmissionLimiter(t)
111+ limiter.EXPECT().Recent(mock.Anything, mock.Anything).Return(0, 0).Maybe()
112+ limiter.EXPECT().Reserve(mock.Anything, mock.Anything).Return(true, time.Duration(0), func() {}).Maybe()
113+ bus := mocks.NewEventBus(t)
114+ var kinds []string
115+ bus.EXPECT().Publish(mock.Anything, mock.Anything).Run(func(_ context.Context, ev domain.Event) {
116+ repo, err := store.Get(ctx, n)
117+ require.NoError(t, err)
118+ if ev.EventKind() == "issue.opened" {
119+ require.Equal(t, at, repo.ActiveAt)
120+ } else {
121+ require.Equal(t, initial, repo.ActiveAt)
122+ }
123+ kinds = append(kinds, ev.EventKind())
124+ }).Maybe()
125+ submissions := usecase.NewIssueSubmissions(usecase.IssueSubmissionsConfig{Meta: store, Pending: store.PendingIssues(), Bans: store.IssueBans(), Limiter: limiter, Keys: keys, Bus: bus, Clock: clock, BaseDifficulty: testBits, MaxDifficulty: testBits})
126+ moderation := usecase.NewIssueModeration(usecase.IssueModerationConfig{Meta: store, Pending: store.PendingIssues(), Bans: store.IssueBans(), Bus: bus, Clock: clock})
127+ submit := func() domain.PendingIssueID {
128+ ch, err := submissions.Challenge(ctx, n, "client")
129+ require.NoError(t, err)
130+ sub := ports.IssueSubmission{Token: ch.Token, FormToken: ch.FormToken, Solution: solve(t, ch), Title: "Bug", Body: "Details"}
131+ at = at.Add(usecase.MinFillTime)
132+ require.NoError(t, submissions.Submit(ctx, n, "client", sub))
133+ repo, err := store.Get(ctx, n)
134+ require.NoError(t, err)
135+ require.Equal(t, initial, repo.ActiveAt)
136+ page, err := store.PendingIssues().List(ctx, n, ports.ListPendingParams{PageSize: 10})
137+ require.NoError(t, err)
138+ require.Len(t, page.Issues, 1)
139+ return page.Issues[0].ID
140+ }
141+ id := submit()
142+ require.NoError(t, moderation.Reject(ctx, domain.Owner(), n, id, false))
143+ repo, err := store.Get(ctx, n)
144+ require.NoError(t, err)
145+ require.Equal(t, initial, repo.ActiveAt)
146+ id = submit()
147+ _, err = moderation.Approve(ctx, domain.Owner(), n, id)
148+ require.NoError(t, err)
149+ repo, err = store.Get(ctx, n)
150+ require.NoError(t, err)
151+ require.Equal(t, at, repo.ActiveAt)
152+ require.True(t, repo.PushedAt.IsZero())
153+ require.Equal(t, []string{"issue.submitted", "issue.pending_removed", "issue.submitted", "issue.opened"}, kinds)
154+}
M internal/usecase/issues_test.go +2 -0
@@ -39,6 +39,7 @@ func newIssueUnit(t *testing.T, vis domain.Visibility, mode domain.IssuesMode) *
3939 clock := mocks.NewClock(t)
4040 clock.EXPECT().Now().Return(now).Maybe()
4141 u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil).Maybe()
42+ expectRepoMutation(t, u.meta, u.repo, mock.MatchedBy(func(r domain.Repo) bool { return r.ActiveAt.Equal(now) && r.PushedAt.IsZero() })).Maybe()
4243 u.issues = usecase.NewIssues(usecase.IssuesConfig{Meta: u.meta, Store: u.store, MergeRequests: u.mrs, Bus: u.bus, Clock: clock})
4344 return u
4445 }
@@ -116,6 +117,7 @@ func TestIssueCreateAndTransitions(t *testing.T) {
116117 got, err := u.issues.Create(ctx, p, u.repo.Name, ports.CreateIssueParams{Title: " Crash ", Body: "why"})
117118 require.NoError(t, err)
118119 assert.EqualValues(t, 7, got.Number)
120+ u.meta.AssertCalled(t, "Mutate", mock.Anything, u.repo.Name, mock.Anything)
119121 })
120122
121123 t.Run("invalid text stores nothing", func(t *testing.T) {
@@ -39,6 +39,7 @@ func newIssueUnit(t *testing.T, vis domain.Visibility, mode domain.IssuesMode) *
39 clock := mocks.NewClock(t)39 clock := mocks.NewClock(t)
40 clock.EXPECT().Now().Return(now).Maybe()40 clock.EXPECT().Now().Return(now).Maybe()
41 u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil).Maybe()41 u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil).Maybe()
42+ expectRepoMutation(t, u.meta, u.repo, mock.MatchedBy(func(r domain.Repo) bool { return r.ActiveAt.Equal(now) && r.PushedAt.IsZero() })).Maybe()
42 u.issues = usecase.NewIssues(usecase.IssuesConfig{Meta: u.meta, Store: u.store, MergeRequests: u.mrs, Bus: u.bus, Clock: clock})43 u.issues = usecase.NewIssues(usecase.IssuesConfig{Meta: u.meta, Store: u.store, MergeRequests: u.mrs, Bus: u.bus, Clock: clock})
43 return u44 return u
44 }45 }
@@ -116,6 +117,7 @@ func TestIssueCreateAndTransitions(t *testing.T) {
116 got, err := u.issues.Create(ctx, p, u.repo.Name, ports.CreateIssueParams{Title: " Crash ", Body: "why"})117 got, err := u.issues.Create(ctx, p, u.repo.Name, ports.CreateIssueParams{Title: " Crash ", Body: "why"})
117 require.NoError(t, err)118 require.NoError(t, err)
118 assert.EqualValues(t, 7, got.Number)119 assert.EqualValues(t, 7, got.Number)
120+ u.meta.AssertCalled(t, "Mutate", mock.Anything, u.repo.Name, mock.Anything)
119 })121 })
120 122
121 t.Run("invalid text stores nothing", func(t *testing.T) {123 t.Run("invalid text stores nothing", func(t *testing.T) {
M internal/usecase/mergerequests.go +5 -0
@@ -254,6 +254,7 @@ func (m *MergeRequests) Create(ctx context.Context, p domain.Principal, name dom
254254 if err != nil {
255255 return domain.MergeRequest{}, err
256256 }
257+ recordActivity(ctx, m.meta, repo.Name, now)
257258 m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
258259 if m.pulls != nil {
259260 // The commits the opening is for are the ones of now.
@@ -377,6 +378,7 @@ func (m *MergeRequests) MarkReady(ctx context.Context, p domain.Principal, name
377378 if err != nil {
378379 return domain.MergeRequest{}, fmt.Errorf("mark !%d of %q ready: %w", number, name, err)
379380 }
381+ recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)
380382 m.bus.Publish(ctx, domain.MergeRequestReady{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
381383 return m.withHead(ctx, mr)
382384 }
@@ -656,6 +658,7 @@ func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name
656658 log := zerowrap.FromCtx(ctx)
657659 log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).
658660 Int64("reply_to", c.ReplyTo).Str("verdict", c.Verdict.String()).Bool("on_line", !general).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("comment added")
661+ recordActivity(ctx, m.meta, name, c.CreatedAt)
659662 m.bus.Publish(ctx, domain.CommentAdded{
660663 Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
661664 })
@@ -796,6 +799,7 @@ func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name doma
796799 log := zerowrap.FromCtx(ctx)
797800 log.Info().Str("repo", name.String()).Int64("number", number).
798801 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed")
802+ recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)
799803 m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
800804 if m.pulls != nil {
801805 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID}, p)
@@ -998,6 +1002,7 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
9981002 }
9991003 }
10001004 at := mr.UpdatedAt
1005+ recordActivity(ctx, m.meta, repo.Name, at)
10011006 m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
10021007 m.bus.Publish(ctx, domain.PushEvent{
10031008 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,
@@ -254,6 +254,7 @@ func (m *MergeRequests) Create(ctx context.Context, p domain.Principal, name dom
254 if err != nil {254 if err != nil {
255 return domain.MergeRequest{}, err255 return domain.MergeRequest{}, err
256 }256 }
257+ recordActivity(ctx, m.meta, repo.Name, now)
257 m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})258 m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
258 if m.pulls != nil {259 if m.pulls != nil {
259 // The commits the opening is for are the ones of now.260 // The commits the opening is for are the ones of now.
@@ -377,6 +378,7 @@ func (m *MergeRequests) MarkReady(ctx context.Context, p domain.Principal, name
377 if err != nil {378 if err != nil {
378 return domain.MergeRequest{}, fmt.Errorf("mark !%d of %q ready: %w", number, name, err)379 return domain.MergeRequest{}, fmt.Errorf("mark !%d of %q ready: %w", number, name, err)
379 }380 }
381+ recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)
380 m.bus.Publish(ctx, domain.MergeRequestReady{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})382 m.bus.Publish(ctx, domain.MergeRequestReady{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
381 return m.withHead(ctx, mr)383 return m.withHead(ctx, mr)
382 }384 }
@@ -656,6 +658,7 @@ func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name
656 log := zerowrap.FromCtx(ctx)658 log := zerowrap.FromCtx(ctx)
657 log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).659 log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID).
658 Int64("reply_to", c.ReplyTo).Str("verdict", c.Verdict.String()).Bool("on_line", !general).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("comment added")660 Int64("reply_to", c.ReplyTo).Str("verdict", c.Verdict.String()).Bool("on_line", !general).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("comment added")
661+ recordActivity(ctx, m.meta, name, c.CreatedAt)
659 m.bus.Publish(ctx, domain.CommentAdded{662 m.bus.Publish(ctx, domain.CommentAdded{
660 Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,663 Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
661 })664 })
@@ -796,6 +799,7 @@ func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name doma
796 log := zerowrap.FromCtx(ctx)799 log := zerowrap.FromCtx(ctx)
797 log.Info().Str("repo", name.String()).Int64("number", number).800 log.Info().Str("repo", name.String()).Int64("number", number).
798 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed")801 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed")
802+ recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt)
799 m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})803 m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
800 if m.pulls != nil {804 if m.pulls != nil {
801 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID}, p)805 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID}, p)
@@ -998,6 +1002,7 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
998 }1002 }
999 }1003 }
1000 at := mr.UpdatedAt1004 at := mr.UpdatedAt
1005+ recordActivity(ctx, m.meta, repo.Name, at)
1001 m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})1006 m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
1002 m.bus.Publish(ctx, domain.PushEvent{1007 m.bus.Publish(ctx, domain.PushEvent{
1003 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,1008 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,
M internal/usecase/mergerequests_integration_test.go +11 -0
@@ -1448,3 +1448,14 @@ func TestNoteThreadsRangesAndVerdicts(t *testing.T) {
14481448 _, err = r.mrs.DeleteComment(r.ctx, root, r.repo, mr.Number, changes.ID)
14491449 assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
14501450 }
1451+
1452+func TestMergeRequestRecordsRepositoryActivity(t *testing.T) {
1453+ r := newMRRig(t)
1454+ r.seed()
1455+ r.feature("activity")
1456+ _, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "Activity", Source: "activity", Target: "main"})
1457+ require.NoError(t, err)
1458+ repo, err := r.store.Get(r.ctx, r.repo)
1459+ require.NoError(t, err)
1460+ assert.True(t, repo.ActiveAt.Equal(now))
1461+}
@@ -1448,3 +1448,14 @@ func TestNoteThreadsRangesAndVerdicts(t *testing.T) {
1448 _, err = r.mrs.DeleteComment(r.ctx, root, r.repo, mr.Number, changes.ID)1448 _, err = r.mrs.DeleteComment(r.ctx, root, r.repo, mr.Number, changes.ID)
1449 assert.ErrorIs(t, err, domain.ErrPreconditionFailed)1449 assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
1450 }1450 }
1451+
1452+func TestMergeRequestRecordsRepositoryActivity(t *testing.T) {
1453+ r := newMRRig(t)
1454+ r.seed()
1455+ r.feature("activity")
1456+ _, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "Activity", Source: "activity", Target: "main"})
1457+ require.NoError(t, err)
1458+ repo, err := r.store.Get(r.ctx, r.repo)
1459+ require.NoError(t, err)
1460+ assert.True(t, repo.ActiveAt.Equal(now))
1461+}
M internal/usecase/pushes.go +21 -29
@@ -15,9 +15,6 @@ import (
1515 "bnema.dev/ermite/internal/ports"
1616 )
1717
18-// pushRetries bounds the retries of a metadata update that loses a race.
19-const pushRetries = 5
20-
2118 // Pushes are the use cases around a Git push.
2219 type Pushes struct {
2320 meta ports.RepoMetaStore
@@ -209,7 +206,7 @@ func (u *Pushes) checkWorkflows(ctx context.Context, name domain.RepoName, updat
209206 return nil
210207 }
211208
212-// Record is the post-receive step of an accepted push: it sets pushed_at,
209+// Record is the post-receive step of an accepted push: it sets pushed_at and active_at,
213210 // repairs HEAD when it points to a missing branch, tells the reader that the
214211 // repository changed, queues the workflows the push triggers, publishes a
215212 // PushEvent and schedules maintenance. Every
@@ -245,36 +242,31 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep
245242 return errors.Join(errs...)
246243 }
247244
248-// touchPushedAt stores pushed_at, retrying when another write got in between.
245+// touchPushedAt stores pushed_at and active_at, preserving later timestamps
246+// and retrying when another write got in between.
249247 // A repository deleted meanwhile is not an error. It returns the repository
250-// as read, or the zero value when it is unknown. A push (Pushes.Record) and a
251-// merge, which moves a branch too, both record through it.
248+// as stored after mutation, or the zero value when it is unknown. A push
249+// (Pushes.Record) and a merge, which moves a branch too, both record through it.
252250 func touchPushedAt(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, now time.Time) (domain.Repo, error) {
253- for range pushRetries {
254- repo, err := meta.Get(ctx, name)
255- if errors.Is(err, domain.ErrNotFound) {
256- return domain.Repo{}, nil
257- }
258- if err != nil {
259- return domain.Repo{}, fmt.Errorf("get repository %q: %w", name, err)
260- }
261- if !now.After(repo.PushedAt) {
262- return repo, nil // a later push already recorded
263- }
264- repo.PushedAt = now
265- err = meta.Update(ctx, repo)
266- if errors.Is(err, domain.ErrConflict) {
267- continue
251+ repo, err := meta.Mutate(ctx, name, func(repo *domain.Repo) (bool, error) {
252+ changed := false
253+ if now.After(repo.PushedAt) {
254+ repo.PushedAt = now
255+ changed = true
268256 }
269- if errors.Is(err, domain.ErrNotFound) {
270- return domain.Repo{}, nil
257+ if now.After(repo.ActiveAt) {
258+ repo.ActiveAt = now
259+ changed = true
271260 }
272- if err != nil {
273- return repo, fmt.Errorf("update repository %q: %w", name, err)
274- }
275- return repo, nil
261+ return changed, nil
262+ })
263+ if errors.Is(err, domain.ErrNotFound) {
264+ return domain.Repo{}, nil
265+ }
266+ if err != nil {
267+ return repo, fmt.Errorf("update repository %q: %w", name, err)
276268 }
277- return domain.Repo{}, fmt.Errorf("update pushed_at of %q: %w", name, domain.ErrConflict)
269+ return repo, nil
278270 }
279271
280272 // fixDefaultBranch points HEAD to a branch that exists when it points to a
@@ -15,9 +15,6 @@ import (
15 "bnema.dev/ermite/internal/ports"15 "bnema.dev/ermite/internal/ports"
16 )16 )
17 17
18-// pushRetries bounds the retries of a metadata update that loses a race.
19-const pushRetries = 5
20-
21 // Pushes are the use cases around a Git push.18 // Pushes are the use cases around a Git push.
22 type Pushes struct {19 type Pushes struct {
23 meta ports.RepoMetaStore20 meta ports.RepoMetaStore
@@ -209,7 +206,7 @@ func (u *Pushes) checkWorkflows(ctx context.Context, name domain.RepoName, updat
209 return nil206 return nil
210 }207 }
211 208
212-// Record is the post-receive step of an accepted push: it sets pushed_at,209+// Record is the post-receive step of an accepted push: it sets pushed_at and active_at,
213 // repairs HEAD when it points to a missing branch, tells the reader that the210 // repairs HEAD when it points to a missing branch, tells the reader that the
214 // repository changed, queues the workflows the push triggers, publishes a211 // repository changed, queues the workflows the push triggers, publishes a
215 // PushEvent and schedules maintenance. Every212 // PushEvent and schedules maintenance. Every
@@ -245,36 +242,31 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep
245 return errors.Join(errs...)242 return errors.Join(errs...)
246 }243 }
247 244
248-// touchPushedAt stores pushed_at, retrying when another write got in between.245+// touchPushedAt stores pushed_at and active_at, preserving later timestamps
246+// and retrying when another write got in between.
249 // A repository deleted meanwhile is not an error. It returns the repository247 // A repository deleted meanwhile is not an error. It returns the repository
250-// as read, or the zero value when it is unknown. A push (Pushes.Record) and a248+// as stored after mutation, or the zero value when it is unknown. A push
251-// merge, which moves a branch too, both record through it.249+// (Pushes.Record) and a merge, which moves a branch too, both record through it.
252 func touchPushedAt(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, now time.Time) (domain.Repo, error) {250 func touchPushedAt(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, now time.Time) (domain.Repo, error) {
253- for range pushRetries {251+ repo, err := meta.Mutate(ctx, name, func(repo *domain.Repo) (bool, error) {
254- repo, err := meta.Get(ctx, name)252+ changed := false
255- if errors.Is(err, domain.ErrNotFound) {253+ if now.After(repo.PushedAt) {
256- return domain.Repo{}, nil254+ repo.PushedAt = now
257- }255+ changed = true
258- if err != nil {
259- return domain.Repo{}, fmt.Errorf("get repository %q: %w", name, err)
260- }
261- if !now.After(repo.PushedAt) {
262- return repo, nil // a later push already recorded
263- }
264- repo.PushedAt = now
265- err = meta.Update(ctx, repo)
266- if errors.Is(err, domain.ErrConflict) {
267- continue
268 }256 }
269- if errors.Is(err, domain.ErrNotFound) {257+ if now.After(repo.ActiveAt) {
270- return domain.Repo{}, nil258+ repo.ActiveAt = now
259+ changed = true
271 }260 }
272- if err != nil {261+ return changed, nil
273- return repo, fmt.Errorf("update repository %q: %w", name, err)262+ })
274- }263+ if errors.Is(err, domain.ErrNotFound) {
275- return repo, nil264+ return domain.Repo{}, nil
265+ }
266+ if err != nil {
267+ return repo, fmt.Errorf("update repository %q: %w", name, err)
276 }268 }
277- return domain.Repo{}, fmt.Errorf("update pushed_at of %q: %w", name, domain.ErrConflict)269+ return repo, nil
278 }270 }
279 271
280 // fixDefaultBranch points HEAD to a branch that exists when it points to a272 // fixDefaultBranch points HEAD to a branch that exists when it points to a
M internal/usecase/pushes_test.go +11 -16
@@ -305,8 +305,7 @@ func TestPushesRecord(t *testing.T) {
305305 f := newPushFixture(t)
306306 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
307307 repo := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now.Add(-time.Hour)}
308- f.meta.EXPECT().Get(ctx, n).Return(repo, nil)
309- f.meta.EXPECT().Update(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: repo.CreatedAt, PushedAt: now}).Return(nil)
308+ expectRepoMutation(t, f.meta, repo, domain.Repo{Name: n, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, PushedAt: now, ActiveAt: now}).Once()
310309 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
311310 f.bus.EXPECT().Publish(ctx, domain.PushEvent{Repo: n, Updates: ups, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}).Return()
312311
@@ -320,22 +319,18 @@ func TestPushesRecord(t *testing.T) {
320319 fresh := mocks.NewRepoFreshener(t)
321320 fresh.EXPECT().Refresh(ctx, n).Return().Once()
322321 f.pushes.WithFreshener(fresh)
323- f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n}, nil)
324- f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil)
322+ expectPushMutation(t, f.meta, domain.Repo{Name: n}, nil)
325323 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
326324 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
327325 require.NoError(t, f.pushes.Record(ctx, p, n, ups))
328326 })
329327
330- t.Run("pushed_at retries after a conflict and never goes backwards", func(t *testing.T) {
328+ t.Run("pushed_at preserves a later push", func(t *testing.T) {
331329 t.Parallel()
332330 f := newPushFixture(t)
333331 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
334- repo := domain.Repo{Name: n}
335- f.meta.EXPECT().Get(ctx, n).Return(repo, nil).Once()
336- f.meta.EXPECT().Update(ctx, mock.Anything).Return(domain.ErrConflict).Once()
337- later := domain.Repo{Name: n, PushedAt: now.Add(time.Second)}
338- f.meta.EXPECT().Get(ctx, n).Return(later, nil).Once() // a later push recorded meanwhile
332+ later := domain.Repo{Name: n, PushedAt: now.Add(time.Second), ActiveAt: now.Add(time.Second)}
333+ expectPushMutation(t, f.meta, later, nil) // a later push recorded meanwhile
339334 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
340335 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
341336 require.NoError(t, f.pushes.Record(ctx, p, n, ups))
@@ -345,7 +340,7 @@ func TestPushesRecord(t *testing.T) {
345340 t.Parallel()
346341 f := newPushFixture(t)
347342 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
348- f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
343+ expectPushMutation(t, f.meta, domain.Repo{}, domain.ErrNotFound)
349344 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
350345 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
351346 require.NoError(t, f.pushes.Record(ctx, p, n, ups))
@@ -355,7 +350,7 @@ func TestPushesRecord(t *testing.T) {
355350 t.Parallel()
356351 f := newPushFixture(t)
357352 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
358- f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
353+ expectPushMutation(t, f.meta, domain.Repo{}, domain.ErrNotFound)
359354 f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, domain.ErrNotFound)
360355 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
361356 require.NoError(t, f.pushes.Record(ctx, p, n, ups))
@@ -365,7 +360,7 @@ func TestPushesRecord(t *testing.T) {
365360 t.Parallel()
366361 f := newPushFixture(t)
367362 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
368- f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
363+ expectPushMutation(t, f.meta, domain.Repo{}, domain.ErrNotFound)
369364 f.git.EXPECT().DefaultBranch(ctx, n).Return("trunk", false, nil)
370365 f.git.EXPECT().SetDefaultBranch(ctx, n, "main").Return(domain.ErrNotFound)
371366 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
@@ -377,7 +372,7 @@ func TestPushesRecord(t *testing.T) {
377372 f := newPushFixture(t)
378373 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
379374 boom := errors.New("boom")
380- f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, boom)
375+ expectPushMutation(t, f.meta, domain.Repo{}, boom)
381376 f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, boom)
382377 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
383378 err := f.pushes.Record(ctx, p, n, ups)
@@ -414,7 +409,7 @@ func TestPushesDefaultBranchRule(t *testing.T) {
414409 t.Parallel()
415410 f := newPushFixture(t)
416411 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
417- f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil)
412+ expectPushMutation(t, f.meta, domain.Repo{Name: n, PushedAt: now, ActiveAt: now}, nil)
418413 f.git.EXPECT().DefaultBranch(ctx, n).Return(tt.head, tt.exists, nil)
419414 if tt.want != "" {
420415 f.git.EXPECT().SetDefaultBranch(ctx, n, tt.want).Return(nil)
@@ -429,7 +424,7 @@ func TestPushesDefaultBranchRule(t *testing.T) {
429424 f := newPushFixture(t)
430425 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
431426 boom := errors.New("boom")
432- f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil)
427+ expectPushMutation(t, f.meta, domain.Repo{Name: n, PushedAt: now, ActiveAt: now}, nil)
433428 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", false, nil)
434429 f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom)
435430 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
@@ -305,8 +305,7 @@ func TestPushesRecord(t *testing.T) {
305 f := newPushFixture(t)305 f := newPushFixture(t)
306 f.sched.EXPECT().Schedule(ctx, n).Return().Once()306 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
307 repo := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now.Add(-time.Hour)}307 repo := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now.Add(-time.Hour)}
308- f.meta.EXPECT().Get(ctx, n).Return(repo, nil)308+ expectRepoMutation(t, f.meta, repo, domain.Repo{Name: n, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, PushedAt: now, ActiveAt: now}).Once()
309- f.meta.EXPECT().Update(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: repo.CreatedAt, PushedAt: now}).Return(nil)
310 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)309 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
311 f.bus.EXPECT().Publish(ctx, domain.PushEvent{Repo: n, Updates: ups, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}).Return()310 f.bus.EXPECT().Publish(ctx, domain.PushEvent{Repo: n, Updates: ups, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}).Return()
312 311
@@ -320,22 +319,18 @@ func TestPushesRecord(t *testing.T) {
320 fresh := mocks.NewRepoFreshener(t)319 fresh := mocks.NewRepoFreshener(t)
321 fresh.EXPECT().Refresh(ctx, n).Return().Once()320 fresh.EXPECT().Refresh(ctx, n).Return().Once()
322 f.pushes.WithFreshener(fresh)321 f.pushes.WithFreshener(fresh)
323- f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n}, nil)322+ expectPushMutation(t, f.meta, domain.Repo{Name: n}, nil)
324- f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil)
325 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)323 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
326 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()324 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
327 require.NoError(t, f.pushes.Record(ctx, p, n, ups))325 require.NoError(t, f.pushes.Record(ctx, p, n, ups))
328 })326 })
329 327
330- t.Run("pushed_at retries after a conflict and never goes backwards", func(t *testing.T) {328+ t.Run("pushed_at preserves a later push", func(t *testing.T) {
331 t.Parallel()329 t.Parallel()
332 f := newPushFixture(t)330 f := newPushFixture(t)
333 f.sched.EXPECT().Schedule(ctx, n).Return().Once()331 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
334- repo := domain.Repo{Name: n}332+ later := domain.Repo{Name: n, PushedAt: now.Add(time.Second), ActiveAt: now.Add(time.Second)}
335- f.meta.EXPECT().Get(ctx, n).Return(repo, nil).Once()333+ expectPushMutation(t, f.meta, later, nil) // a later push recorded meanwhile
336- f.meta.EXPECT().Update(ctx, mock.Anything).Return(domain.ErrConflict).Once()
337- later := domain.Repo{Name: n, PushedAt: now.Add(time.Second)}
338- f.meta.EXPECT().Get(ctx, n).Return(later, nil).Once() // a later push recorded meanwhile
339 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)334 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
340 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()335 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
341 require.NoError(t, f.pushes.Record(ctx, p, n, ups))336 require.NoError(t, f.pushes.Record(ctx, p, n, ups))
@@ -345,7 +340,7 @@ func TestPushesRecord(t *testing.T) {
345 t.Parallel()340 t.Parallel()
346 f := newPushFixture(t)341 f := newPushFixture(t)
347 f.sched.EXPECT().Schedule(ctx, n).Return().Once()342 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
348- f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)343+ expectPushMutation(t, f.meta, domain.Repo{}, domain.ErrNotFound)
349 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)344 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil)
350 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()345 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
351 require.NoError(t, f.pushes.Record(ctx, p, n, ups))346 require.NoError(t, f.pushes.Record(ctx, p, n, ups))
@@ -355,7 +350,7 @@ func TestPushesRecord(t *testing.T) {
355 t.Parallel()350 t.Parallel()
356 f := newPushFixture(t)351 f := newPushFixture(t)
357 f.sched.EXPECT().Schedule(ctx, n).Return().Once()352 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
358- f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)353+ expectPushMutation(t, f.meta, domain.Repo{}, domain.ErrNotFound)
359 f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, domain.ErrNotFound)354 f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, domain.ErrNotFound)
360 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()355 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
361 require.NoError(t, f.pushes.Record(ctx, p, n, ups))356 require.NoError(t, f.pushes.Record(ctx, p, n, ups))
@@ -365,7 +360,7 @@ func TestPushesRecord(t *testing.T) {
365 t.Parallel()360 t.Parallel()
366 f := newPushFixture(t)361 f := newPushFixture(t)
367 f.sched.EXPECT().Schedule(ctx, n).Return().Once()362 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
368- f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)363+ expectPushMutation(t, f.meta, domain.Repo{}, domain.ErrNotFound)
369 f.git.EXPECT().DefaultBranch(ctx, n).Return("trunk", false, nil)364 f.git.EXPECT().DefaultBranch(ctx, n).Return("trunk", false, nil)
370 f.git.EXPECT().SetDefaultBranch(ctx, n, "main").Return(domain.ErrNotFound)365 f.git.EXPECT().SetDefaultBranch(ctx, n, "main").Return(domain.ErrNotFound)
371 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()366 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
@@ -377,7 +372,7 @@ func TestPushesRecord(t *testing.T) {
377 f := newPushFixture(t)372 f := newPushFixture(t)
378 f.sched.EXPECT().Schedule(ctx, n).Return().Once()373 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
379 boom := errors.New("boom")374 boom := errors.New("boom")
380- f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, boom)375+ expectPushMutation(t, f.meta, domain.Repo{}, boom)
381 f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, boom)376 f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, boom)
382 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()377 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
383 err := f.pushes.Record(ctx, p, n, ups)378 err := f.pushes.Record(ctx, p, n, ups)
@@ -414,7 +409,7 @@ func TestPushesDefaultBranchRule(t *testing.T) {
414 t.Parallel()409 t.Parallel()
415 f := newPushFixture(t)410 f := newPushFixture(t)
416 f.sched.EXPECT().Schedule(ctx, n).Return().Once()411 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
417- f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil)412+ expectPushMutation(t, f.meta, domain.Repo{Name: n, PushedAt: now, ActiveAt: now}, nil)
418 f.git.EXPECT().DefaultBranch(ctx, n).Return(tt.head, tt.exists, nil)413 f.git.EXPECT().DefaultBranch(ctx, n).Return(tt.head, tt.exists, nil)
419 if tt.want != "" {414 if tt.want != "" {
420 f.git.EXPECT().SetDefaultBranch(ctx, n, tt.want).Return(nil)415 f.git.EXPECT().SetDefaultBranch(ctx, n, tt.want).Return(nil)
@@ -429,7 +424,7 @@ func TestPushesDefaultBranchRule(t *testing.T) {
429 f := newPushFixture(t)424 f := newPushFixture(t)
430 f.sched.EXPECT().Schedule(ctx, n).Return().Once()425 f.sched.EXPECT().Schedule(ctx, n).Return().Once()
431 boom := errors.New("boom")426 boom := errors.New("boom")
432- f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil)427+ expectPushMutation(t, f.meta, domain.Repo{Name: n, PushedAt: now, ActiveAt: now}, nil)
433 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", false, nil)428 f.git.EXPECT().DefaultBranch(ctx, n).Return("main", false, nil)
434 f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom)429 f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom)
435 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()430 f.bus.EXPECT().Publish(ctx, mock.Anything).Return()
A internal/usecase/repo_mutation_test.go +34 -0
@@ -0,0 +1,34 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports/mocks"
14+)
15+
16+func expectRepoMutation(t *testing.T, meta *mocks.RepoMetaStore, repo domain.Repo, want any) *mocks.RepoMetaStore_Mutate_Call {
17+ t.Helper()
18+ return meta.EXPECT().Mutate(mock.Anything, repo.Name, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.RepoName, fn func(*domain.Repo) (bool, error)) (domain.Repo, error) {
19+ result := repo
20+ _, err := fn(&result)
21+ require.NoError(t, err)
22+ _, differences := (mock.Arguments{want}).Diff([]any{result})
23+ require.Zero(t, differences, "unexpected mutated repository: %+v", result)
24+ return result, err
25+ })
26+}
27+
28+func expectPushMutation(t *testing.T, meta *mocks.RepoMetaStore, repo domain.Repo, err error) *mocks.RepoMetaStore_Mutate_Call {
29+ t.Helper()
30+ if err != nil {
31+ return meta.EXPECT().Mutate(mock.Anything, mock.Anything, mock.Anything).Return(repo, err)
32+ }
33+ return expectRepoMutation(t, meta, repo, mock.Anything)
34+}
@@ -0,0 +1,34 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports/mocks"
14+)
15+
16+func expectRepoMutation(t *testing.T, meta *mocks.RepoMetaStore, repo domain.Repo, want any) *mocks.RepoMetaStore_Mutate_Call {
17+ t.Helper()
18+ return meta.EXPECT().Mutate(mock.Anything, repo.Name, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.RepoName, fn func(*domain.Repo) (bool, error)) (domain.Repo, error) {
19+ result := repo
20+ _, err := fn(&result)
21+ require.NoError(t, err)
22+ _, differences := (mock.Arguments{want}).Diff([]any{result})
23+ require.Zero(t, differences, "unexpected mutated repository: %+v", result)
24+ return result, err
25+ })
26+}
27+
28+func expectPushMutation(t *testing.T, meta *mocks.RepoMetaStore, repo domain.Repo, err error) *mocks.RepoMetaStore_Mutate_Call {
29+ t.Helper()
30+ if err != nil {
31+ return meta.EXPECT().Mutate(mock.Anything, mock.Anything, mock.Anything).Return(repo, err)
32+ }
33+ return expectRepoMutation(t, meta, repo, mock.Anything)
34+}
M internal/usecase/repos_write.go +6 -14
@@ -155,11 +155,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
155155 // updateMeta applies the metadata fields, retrying when a write (a push
156156 // recording pushed_at) gets in between.
157157 func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params ports.UpdateRepoParams) (domain.Repo, error) {
158- for range pushRetries {
159- repo, err := r.meta.Get(ctx, name)
160- if err != nil {
161- return domain.Repo{}, err
162- }
158+ repo, err := r.meta.Mutate(ctx, name, func(repo *domain.Repo) (bool, error) {
163159 if params.Description != nil {
164160 repo.Description = *params.Description
165161 }
@@ -181,16 +177,12 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por
181177 if params.Issues != nil {
182178 repo.Settings.Issues = *params.Issues
183179 }
184- err = r.meta.Update(ctx, repo)
185- if errors.Is(err, domain.ErrConflict) {
186- continue
187- }
188- if err != nil {
189- return domain.Repo{}, fmt.Errorf("update repository %q: %w", name, err)
190- }
191- return repo, nil
180+ return true, nil
181+ })
182+ if err != nil {
183+ return domain.Repo{}, fmt.Errorf("update repository %q: %w", name, err)
192184 }
193- return domain.Repo{}, fmt.Errorf("update repository %q: %w", name, domain.ErrConflict)
185+ return repo, nil
194186 }
195187
196188 // Delete moves the repository directory to the trash, removes its metadata
@@ -155,11 +155,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
155 // updateMeta applies the metadata fields, retrying when a write (a push155 // updateMeta applies the metadata fields, retrying when a write (a push
156 // recording pushed_at) gets in between.156 // recording pushed_at) gets in between.
157 func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params ports.UpdateRepoParams) (domain.Repo, error) {157 func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params ports.UpdateRepoParams) (domain.Repo, error) {
158- for range pushRetries {158+ repo, err := r.meta.Mutate(ctx, name, func(repo *domain.Repo) (bool, error) {
159- repo, err := r.meta.Get(ctx, name)
160- if err != nil {
161- return domain.Repo{}, err
162- }
163 if params.Description != nil {159 if params.Description != nil {
164 repo.Description = *params.Description160 repo.Description = *params.Description
165 }161 }
@@ -181,16 +177,12 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por
181 if params.Issues != nil {177 if params.Issues != nil {
182 repo.Settings.Issues = *params.Issues178 repo.Settings.Issues = *params.Issues
183 }179 }
184- err = r.meta.Update(ctx, repo)180+ return true, nil
185- if errors.Is(err, domain.ErrConflict) {181+ })
186- continue182+ if err != nil {
187- }183+ return domain.Repo{}, fmt.Errorf("update repository %q: %w", name, err)
188- if err != nil {
189- return domain.Repo{}, fmt.Errorf("update repository %q: %w", name, err)
190- }
191- return repo, nil
192 }184 }
193- return domain.Repo{}, fmt.Errorf("update repository %q: %w", name, domain.ErrConflict)185+ return repo, nil
194 }186 }
195 187
196 // Delete moves the repository directory to the trash, removes its metadata188 // Delete moves the repository directory to the trash, removes its metadata
M internal/usecase/repos_write_test.go +20 -21
@@ -30,9 +30,9 @@ func TestUpdate(t *testing.T) {
3030 t.Run("description and visibility are stored and RepoUpdated is published", func(t *testing.T) {
3131 t.Parallel()
3232 f := newFixture(t)
33- f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()
33+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()
3434 want := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "new", Settings: domain.DefaultRepoSettings()}
35- f.meta.EXPECT().Update(ctx, want).Return(nil)
35+ expectRepoMutation(t, f.meta, priv, want)
3636 f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{
3737 Repo: n, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPrivate,
3838 }).Return().Once()
@@ -47,11 +47,11 @@ func TestUpdate(t *testing.T) {
4747 t.Run("topics are normalized and stored, and RepoUpdated is published", func(t *testing.T) {
4848 t.Parallel()
4949 f := newFixture(t)
50- f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()
50+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()
5151 want := priv
5252 want.Topics = []string{"cli", "go"}
5353 want.Settings = domain.DefaultRepoSettings()
54- f.meta.EXPECT().Update(ctx, want).Return(nil)
54+ expectRepoMutation(t, f.meta, priv, want)
5555 f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{
5656 Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPrivate,
5757 }).Return().Once()
@@ -66,10 +66,10 @@ func TestUpdate(t *testing.T) {
6666 f := newFixture(t)
6767 withTopics := priv
6868 withTopics.Topics = []string{"go"}
69- f.meta.EXPECT().Get(ctx, n).Return(withTopics, nil).Twice()
69+ f.meta.EXPECT().Get(ctx, n).Return(withTopics, nil).Once()
7070 want := priv
7171 want.Settings = domain.DefaultRepoSettings()
72- f.meta.EXPECT().Update(ctx, want).Return(nil)
72+ expectRepoMutation(t, f.meta, withTopics, want)
7373 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
7474
7575 got, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Topics: &[]string{}})
@@ -77,15 +77,12 @@ func TestUpdate(t *testing.T) {
7777 assert.Nil(t, got.Topics)
7878 })
7979
80- t.Run("a lost race on the metadata is retried", func(t *testing.T) {
81- t.Parallel()
80+ t.Run("exhausted mutation conflicts are returned", func(t *testing.T) {
8281 f := newFixture(t)
8382 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
84- f.meta.EXPECT().Update(ctx, mock.Anything).Return(domain.ErrConflict).Once()
85- f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil).Once()
86- f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
83+ f.meta.EXPECT().Mutate(ctx, n, mock.Anything).Return(domain.Repo{}, domain.ErrConflict).Once()
8784 _, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Description: ptr("x")})
88- require.NoError(t, err)
85+ require.ErrorIs(t, err, domain.ErrConflict)
8986 })
9087
9188 t.Run("the default branch is checked, then HEAD moves", func(t *testing.T) {
@@ -106,9 +103,11 @@ func TestUpdate(t *testing.T) {
106103 var order []string
107104 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
108105 f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
109- f.meta.EXPECT().Update(ctx, mock.Anything).RunAndReturn(func(context.Context, domain.Repo) error {
106+ f.meta.EXPECT().Mutate(ctx, n, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.RepoName, fn func(*domain.Repo) (bool, error)) (domain.Repo, error) {
110107 order = append(order, "meta")
111- return nil
108+ repo := priv
109+ _, err := fn(&repo)
110+ return repo, err
112111 })
113112 f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").RunAndReturn(func(context.Context, domain.RepoName, string) error {
114113 order = append(order, "head")
@@ -127,7 +126,7 @@ func TestUpdate(t *testing.T) {
127126 pub := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "old"}
128127 f.meta.EXPECT().Get(ctx, n).Return(pub, nil)
129128 f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
130- f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil)
129+ expectRepoMutation(t, f.meta, pub, mock.Anything)
131130 f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom)
132131 f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{
133132 Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPublic,
@@ -250,10 +249,10 @@ func TestUpdateIssuesMode(t *testing.T) {
250249 t.Run("public is stored and published", func(t *testing.T) {
251250 t.Parallel()
252251 f := newFixture(t)
253- f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()
254- f.meta.EXPECT().Update(ctx, mock.MatchedBy(func(r domain.Repo) bool {
252+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()
253+ expectRepoMutation(t, f.meta, priv, mock.MatchedBy(func(r domain.Repo) bool {
255254 return r.Settings.Issues == domain.IssuesPublic
256- })).Return(nil)
255+ }))
257256 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
258257 repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesPublic)})
259258 require.NoError(t, err)
@@ -271,10 +270,10 @@ func TestUpdateIssuesMode(t *testing.T) {
271270 t.Run("owner is stored and published", func(t *testing.T) {
272271 t.Parallel()
273272 f := newFixture(t)
274- f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()
275- f.meta.EXPECT().Update(ctx, mock.MatchedBy(func(r domain.Repo) bool {
273+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()
274+ expectRepoMutation(t, f.meta, priv, mock.MatchedBy(func(r domain.Repo) bool {
276275 return r.Settings.Issues == domain.IssuesOwner && r.Settings.FailingChecksBlock
277- })).Return(nil)
276+ }))
278277 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
279278 repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesOwner)})
280279 require.NoError(t, err)
@@ -30,9 +30,9 @@ func TestUpdate(t *testing.T) {
30 t.Run("description and visibility are stored and RepoUpdated is published", func(t *testing.T) {30 t.Run("description and visibility are stored and RepoUpdated is published", func(t *testing.T) {
31 t.Parallel()31 t.Parallel()
32 f := newFixture(t)32 f := newFixture(t)
33- f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()33+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()
34 want := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "new", Settings: domain.DefaultRepoSettings()}34 want := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "new", Settings: domain.DefaultRepoSettings()}
35- f.meta.EXPECT().Update(ctx, want).Return(nil)35+ expectRepoMutation(t, f.meta, priv, want)
36 f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{36 f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{
37 Repo: n, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPrivate,37 Repo: n, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPrivate,
38 }).Return().Once()38 }).Return().Once()
@@ -47,11 +47,11 @@ func TestUpdate(t *testing.T) {
47 t.Run("topics are normalized and stored, and RepoUpdated is published", func(t *testing.T) {47 t.Run("topics are normalized and stored, and RepoUpdated is published", func(t *testing.T) {
48 t.Parallel()48 t.Parallel()
49 f := newFixture(t)49 f := newFixture(t)
50- f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()50+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()
51 want := priv51 want := priv
52 want.Topics = []string{"cli", "go"}52 want.Topics = []string{"cli", "go"}
53 want.Settings = domain.DefaultRepoSettings()53 want.Settings = domain.DefaultRepoSettings()
54- f.meta.EXPECT().Update(ctx, want).Return(nil)54+ expectRepoMutation(t, f.meta, priv, want)
55 f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{55 f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{
56 Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPrivate,56 Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPrivate,
57 }).Return().Once()57 }).Return().Once()
@@ -66,10 +66,10 @@ func TestUpdate(t *testing.T) {
66 f := newFixture(t)66 f := newFixture(t)
67 withTopics := priv67 withTopics := priv
68 withTopics.Topics = []string{"go"}68 withTopics.Topics = []string{"go"}
69- f.meta.EXPECT().Get(ctx, n).Return(withTopics, nil).Twice()69+ f.meta.EXPECT().Get(ctx, n).Return(withTopics, nil).Once()
70 want := priv70 want := priv
71 want.Settings = domain.DefaultRepoSettings()71 want.Settings = domain.DefaultRepoSettings()
72- f.meta.EXPECT().Update(ctx, want).Return(nil)72+ expectRepoMutation(t, f.meta, withTopics, want)
73 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()73 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
74 74
75 got, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Topics: &[]string{}})75 got, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Topics: &[]string{}})
@@ -77,15 +77,12 @@ func TestUpdate(t *testing.T) {
77 assert.Nil(t, got.Topics)77 assert.Nil(t, got.Topics)
78 })78 })
79 79
80- t.Run("a lost race on the metadata is retried", func(t *testing.T) {80+ t.Run("exhausted mutation conflicts are returned", func(t *testing.T) {
81- t.Parallel()
82 f := newFixture(t)81 f := newFixture(t)
83 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)82 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
84- f.meta.EXPECT().Update(ctx, mock.Anything).Return(domain.ErrConflict).Once()83+ f.meta.EXPECT().Mutate(ctx, n, mock.Anything).Return(domain.Repo{}, domain.ErrConflict).Once()
85- f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil).Once()
86- f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
87 _, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Description: ptr("x")})84 _, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Description: ptr("x")})
88- require.NoError(t, err)85+ require.ErrorIs(t, err, domain.ErrConflict)
89 })86 })
90 87
91 t.Run("the default branch is checked, then HEAD moves", func(t *testing.T) {88 t.Run("the default branch is checked, then HEAD moves", func(t *testing.T) {
@@ -106,9 +103,11 @@ func TestUpdate(t *testing.T) {
106 var order []string103 var order []string
107 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)104 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
108 f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)105 f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
109- f.meta.EXPECT().Update(ctx, mock.Anything).RunAndReturn(func(context.Context, domain.Repo) error {106+ f.meta.EXPECT().Mutate(ctx, n, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.RepoName, fn func(*domain.Repo) (bool, error)) (domain.Repo, error) {
110 order = append(order, "meta")107 order = append(order, "meta")
111- return nil108+ repo := priv
109+ _, err := fn(&repo)
110+ return repo, err
112 })111 })
113 f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").RunAndReturn(func(context.Context, domain.RepoName, string) error {112 f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").RunAndReturn(func(context.Context, domain.RepoName, string) error {
114 order = append(order, "head")113 order = append(order, "head")
@@ -127,7 +126,7 @@ func TestUpdate(t *testing.T) {
127 pub := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "old"}126 pub := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "old"}
128 f.meta.EXPECT().Get(ctx, n).Return(pub, nil)127 f.meta.EXPECT().Get(ctx, n).Return(pub, nil)
129 f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)128 f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
130- f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil)129+ expectRepoMutation(t, f.meta, pub, mock.Anything)
131 f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom)130 f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom)
132 f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{131 f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{
133 Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPublic,132 Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPublic,
@@ -250,10 +249,10 @@ func TestUpdateIssuesMode(t *testing.T) {
250 t.Run("public is stored and published", func(t *testing.T) {249 t.Run("public is stored and published", func(t *testing.T) {
251 t.Parallel()250 t.Parallel()
252 f := newFixture(t)251 f := newFixture(t)
253- f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()252+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()
254- f.meta.EXPECT().Update(ctx, mock.MatchedBy(func(r domain.Repo) bool {253+ expectRepoMutation(t, f.meta, priv, mock.MatchedBy(func(r domain.Repo) bool {
255 return r.Settings.Issues == domain.IssuesPublic254 return r.Settings.Issues == domain.IssuesPublic
256- })).Return(nil)255+ }))
257 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()256 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
258 repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesPublic)})257 repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesPublic)})
259 require.NoError(t, err)258 require.NoError(t, err)
@@ -271,10 +270,10 @@ func TestUpdateIssuesMode(t *testing.T) {
271 t.Run("owner is stored and published", func(t *testing.T) {270 t.Run("owner is stored and published", func(t *testing.T) {
272 t.Parallel()271 t.Parallel()
273 f := newFixture(t)272 f := newFixture(t)
274- f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()273+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()
275- f.meta.EXPECT().Update(ctx, mock.MatchedBy(func(r domain.Repo) bool {274+ expectRepoMutation(t, f.meta, priv, mock.MatchedBy(func(r domain.Repo) bool {
276 return r.Settings.Issues == domain.IssuesOwner && r.Settings.FailingChecksBlock275 return r.Settings.Issues == domain.IssuesOwner && r.Settings.FailingChecksBlock
277- })).Return(nil)276+ }))
278 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()277 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
279 repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesOwner)})278 repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesOwner)})
280 require.NoError(t, err)279 require.NoError(t, err)
M proto/ermite/storage/repo.proto +2 -0
@@ -32,6 +32,8 @@ message Repo {
3232 RepoSettings settings = 6;
3333 // Normalized and sorted; empty on repositories stored before topics.
3434 repeated string topics = 7;
35+ // Latest push, merge request or issue activity; unset until recorded.
36+ google.protobuf.Timestamp active_at = 8;
3537 }
3638
3739 // SchemaVersion is stored under the key "meta/schema".
@@ -32,6 +32,8 @@ message Repo {
32 RepoSettings settings = 6;32 RepoSettings settings = 6;
33 // Normalized and sorted; empty on repositories stored before topics.33 // Normalized and sorted; empty on repositories stored before topics.
34 repeated string topics = 7;34 repeated string topics = 7;
35+ // Latest push, merge request or issue activity; unset until recorded.
36+ google.protobuf.Timestamp active_at = 8;
35 }37 }
36 38
37 // SchemaVersion is stored under the key "meta/schema".39 // SchemaVersion is stored under the key "meta/schema".

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
esc
close a dialog, leave a field

Branches 2

Tags 44

↑↓ moveenter openesc close