detached 0a9e349

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

state
merged · 4ce8534 · squash
branch
feat/mr-header-card → main
opened
bnema ·
updated

Review page and home page polish, plus "last activity" ordering of the repositories.

Merge request page

  • Title, facts and description in one card; the header a shade lighter than the description. Issues share it.
  • Unified / Split / Collapse all in one view menu (the existing details.menu component); u, d and x still work, escape closes menus on every page and the focus goes back to the menu button.
  • Wide mode on every page with w (listed in ?), kept in localStorage; store.js loads without defer and sets it before the first paint.
  • Hunk headers and hidden-line rows are quieter (background and border), text keeps its contrast.
  • A shift + click range is one block (one border, none between its lines, per side in split); the form title names the whole range (a.go:21-149); lines loaded later from a gap inside the range are selected too.

Home page

  • Inbox button next to the logo, home page only, with a mailbox icon (Lucide) and a darker count badge.
  • Default order is the last activity: push (incl. merge), merge request opened / merged / closed / ready, comment on a merge request, issue opened / approved / closed / reopened / commented. Moderation (submissions, rejections) stays owner-only and does not count.
  • The row of a repository with activity moves to the top live (activity order only; CI updates do not move it).

Storage

  • Repo.ActiveAt (storage proto field 8; falls back to the later of pushed and created).
  • RepoMetaStore.Update is replaced by Mutate: a compare-and-swap on the version read, retried up to 8 times. The previous Update checked the version read at write time, so a read-modify-write could overwrite a concurrent change (e.g. a visibility change).

No change to ermite-api.

Checks: go vet ./..., go test ./..., go test -race on usecase and kvstore, JS tests (160), golden pages refreshed, manual checks on the dev server. Reviewed by Go and JS reviewers; findings fixed.

CommitSubjectAuthorDateSignatureStatus
0a9e349test: run the activity store test outside the usecase package and its home row subtests in parallelbriceGPG Verified
9ca7c68fix(repos): mutate metadata atomically and record approved issue activitybriceGPG Verified
32e9fe7fix(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 contrastbriceGPG Verified
4277264feat(web): order the home page by last activity and move repositories up livebriceGPG Verified
4fc291efeat(web): move the inbox button of the home page next to the logo, with a mailbox icon and a darker count badgebriceGPG Verified
5f6d2c6test(app): refresh the web golden pages for the view menu, the w shortcut and the store scriptbriceGPG Verified
a772c1bfix(web): a selected line range is one block, its title names the whole range and its hidden lines stay selected once loadedbriceGPG Verified
502935erefactor(web): drop the Wide button of the diff bar, the w shortcut covers itbriceGPG Verified
a1f8e85feat(web): make the wide mode a global w shortcut, set before the first paint, and tighten the merge request headerbriceGPG Verified
fcc7703feat(web): one merge request card, a diff view menu, a wide diff mode and quieter hunk rowsbriceGPG Verified

Checks 4

92 files · +2426 -1392
4/4
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
@@ -51,7 +51,7 @@ func OverlayHelp(mr bool) templ.Component {
5151 if templ_7745c5c3_Err != nil {
5252 return templ_7745c5c3_Err
5353 }
54- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " branch or tag</dd></div><div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd> <kbd>i</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd></div><div><dt><kbd>g h</kbd></dt><dd>home</dd></div><div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div><div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div><div><dt><kbd>e</kbd> <kbd>E</kbd></dt><dd>diff: the whole file in view / every file, with the lines the hunks hide</dd></div><div><dt><kbd>n</kbd></dt><dd>merge request: next note in the diff</dd></div>")
54+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " branch or tag</dd></div><div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd> <kbd>i</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd></div><div><dt><kbd>g h</kbd></dt><dd>home</dd></div><div><dt><kbd>w</kbd></dt><dd>wide mode: use the whole width of the screen, or not (remembered)</dd></div><div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div><div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div><div><dt><kbd>e</kbd> <kbd>E</kbd></dt><dd>diff: the whole file in view / every file, with the lines the hunks hide</dd></div><div><dt><kbd>n</kbd></dt><dd>merge request: next note in the diff</dd></div>")
5555 if templ_7745c5c3_Err != nil {
5656 return templ_7745c5c3_Err
5757 }
@@ -99,7 +99,7 @@ func OverlayRefs(l Layout) templ.Component {
9999 var templ_7745c5c3_Var4 string
100100 templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(l.Heads)))
101101 if templ_7745c5c3_Err != nil {
102- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 63, Col: 58}
102+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 64, Col: 58}
103103 }
104104 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
105105 if templ_7745c5c3_Err != nil {
@@ -122,7 +122,7 @@ func OverlayRefs(l Layout) templ.Component {
122122 var templ_7745c5c3_Var5 string
123123 templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(l.Tags)))
124124 if templ_7745c5c3_Err != nil {
125- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 71, Col: 53}
125+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 72, Col: 53}
126126 }
127127 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
128128 if templ_7745c5c3_Err != nil {
@@ -175,7 +175,7 @@ func refItem(r RefItem) templ.Component {
175175 var templ_7745c5c3_Var7 templ.SafeURL
176176 templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
177177 if templ_7745c5c3_Err != nil {
178- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 86, Col: 31}
178+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 87, Col: 31}
179179 }
180180 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
181181 if templ_7745c5c3_Err != nil {
@@ -213,7 +213,7 @@ func refItem(r RefItem) templ.Component {
213213 var templ_7745c5c3_Var8 string
214214 templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
215215 if templ_7745c5c3_Err != nil {
216- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 97, Col: 18}
216+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 98, Col: 18}
217217 }
218218 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
219219 if templ_7745c5c3_Err != nil {
@@ -262,7 +262,7 @@ func OverlayCmd(items []CmdItem) templ.Component {
262262 var templ_7745c5c3_Var10 string
263263 templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("cmd-o" + itoa(i))
264264 if templ_7745c5c3_Err != nil {
265- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 126, Col: 30}
265+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 127, Col: 30}
266266 }
267267 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
268268 if templ_7745c5c3_Err != nil {
@@ -275,7 +275,7 @@ func OverlayCmd(items []CmdItem) templ.Component {
275275 var templ_7745c5c3_Var11 string
276276 templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(strconv.FormatBool(i == 0))
277277 if templ_7745c5c3_Err != nil {
278- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 126, Col: 89}
278+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 127, Col: 89}
279279 }
280280 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
281281 if templ_7745c5c3_Err != nil {
@@ -288,7 +288,7 @@ func OverlayCmd(items []CmdItem) templ.Component {
288288 var templ_7745c5c3_Var12 string
289289 templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(strings.ToLower(c.Kind + " " + c.Label))
290290 if templ_7745c5c3_Err != nil {
291- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 126, Col: 140}
291+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 127, Col: 140}
292292 }
293293 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
294294 if templ_7745c5c3_Err != nil {
@@ -311,7 +311,7 @@ func OverlayCmd(items []CmdItem) templ.Component {
311311 var templ_7745c5c3_Var13 templ.SafeURL
312312 templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
313313 if templ_7745c5c3_Err != nil {
314- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 126, Col: 198}
314+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 127, Col: 198}
315315 }
316316 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
317317 if templ_7745c5c3_Err != nil {
@@ -324,7 +324,7 @@ func OverlayCmd(items []CmdItem) templ.Component {
324324 var templ_7745c5c3_Var14 string
325325 templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
326326 if templ_7745c5c3_Err != nil {
327- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 126, Col: 242}
327+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 127, Col: 242}
328328 }
329329 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
330330 if templ_7745c5c3_Err != nil {
@@ -337,7 +337,7 @@ func OverlayCmd(items []CmdItem) templ.Component {
337337 var templ_7745c5c3_Var15 string
338338 templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(c.Kind)
339339 if templ_7745c5c3_Err != nil {
340- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 126, Col: 275}
340+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 127, Col: 275}
341341 }
342342 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
343343 if templ_7745c5c3_Err != nil {
@@ -355,7 +355,7 @@ func OverlayCmd(items []CmdItem) templ.Component {
355355 var templ_7745c5c3_Var16 string
356356 templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("cmd-o" + itoa(len(items)))
357357 if templ_7745c5c3_Err != nil {
358- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 128, Col: 38}
358+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 129, Col: 38}
359359 }
360360 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
361361 if templ_7745c5c3_Err != nil {
@@ -51,7 +51,7 @@ func OverlayHelp(mr bool) templ.Component {
51 if templ_7745c5c3_Err != nil {51 if templ_7745c5c3_Err != nil {
52 return templ_7745c5c3_Err52 return templ_7745c5c3_Err
53 }53 }
54- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " branch or tag</dd></div><div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd> <kbd>i</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd></div><div><dt><kbd>g h</kbd></dt><dd>home</dd></div><div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div><div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div><div><dt><kbd>e</kbd> <kbd>E</kbd></dt><dd>diff: the whole file in view / every file, with the lines the hunks hide</dd></div><div><dt><kbd>n</kbd></dt><dd>merge request: next note in the diff</dd></div>")54+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " branch or tag</dd></div><div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd> <kbd>i</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd></div><div><dt><kbd>g h</kbd></dt><dd>home</dd></div><div><dt><kbd>w</kbd></dt><dd>wide mode: use the whole width of the screen, or not (remembered)</dd></div><div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div><div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div><div><dt><kbd>e</kbd> <kbd>E</kbd></dt><dd>diff: the whole file in view / every file, with the lines the hunks hide</dd></div><div><dt><kbd>n</kbd></dt><dd>merge request: next note in the diff</dd></div>")
55 if templ_7745c5c3_Err != nil {55 if templ_7745c5c3_Err != nil {
56 return templ_7745c5c3_Err56 return templ_7745c5c3_Err
57 }57 }
@@ -99,7 +99,7 @@ func OverlayRefs(l Layout) templ.Component {
99 var templ_7745c5c3_Var4 string99 var templ_7745c5c3_Var4 string
100 templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(l.Heads)))100 templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(l.Heads)))
101 if templ_7745c5c3_Err != nil {101 if templ_7745c5c3_Err != nil {
102- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 63, Col: 58}102+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 64, Col: 58}
103 }103 }
104 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))104 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
105 if templ_7745c5c3_Err != nil {105 if templ_7745c5c3_Err != nil {
@@ -122,7 +122,7 @@ func OverlayRefs(l Layout) templ.Component {
122 var templ_7745c5c3_Var5 string122 var templ_7745c5c3_Var5 string
123 templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(l.Tags)))123 templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(l.Tags)))
124 if templ_7745c5c3_Err != nil {124 if templ_7745c5c3_Err != nil {
125- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 71, Col: 53}125+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 72, Col: 53}
126 }126 }
127 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))127 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
128 if templ_7745c5c3_Err != nil {128 if templ_7745c5c3_Err != nil {
@@ -175,7 +175,7 @@ func refItem(r RefItem) templ.Component {
175 var templ_7745c5c3_Var7 templ.SafeURL175 var templ_7745c5c3_Var7 templ.SafeURL
176 templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))176 templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
177 if templ_7745c5c3_Err != nil {177 if templ_7745c5c3_Err != nil {
178- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 86, Col: 31}178+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 87, Col: 31}
179 }179 }
180 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))180 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
181 if templ_7745c5c3_Err != nil {181 if templ_7745c5c3_Err != nil {
@@ -213,7 +213,7 @@ func refItem(r RefItem) templ.Component {
213 var templ_7745c5c3_Var8 string213 var templ_7745c5c3_Var8 string
214 templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)214 templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
215 if templ_7745c5c3_Err != nil {215 if templ_7745c5c3_Err != nil {
216- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 97, Col: 18}216+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 98, Col: 18}
217 }217 }
218 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))218 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
219 if templ_7745c5c3_Err != nil {219 if templ_7745c5c3_Err != nil {
@@ -262,7 +262,7 @@ func OverlayCmd(items []CmdItem) templ.Component {
262 var templ_7745c5c3_Var10 string262 var templ_7745c5c3_Var10 string
263 templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("cmd-o" + itoa(i))263 templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("cmd-o" + itoa(i))
264 if templ_7745c5c3_Err != nil {264 if templ_7745c5c3_Err != nil {
265- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 126, Col: 30}265+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 127, Col: 30}
266 }266 }
267 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)267 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
268 if templ_7745c5c3_Err != nil {268 if templ_7745c5c3_Err != nil {
@@ -275,7 +275,7 @@ func OverlayCmd(items []CmdItem) templ.Component {
275 var templ_7745c5c3_Var11 string275 var templ_7745c5c3_Var11 string
276 templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(strconv.FormatBool(i == 0))276 templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(strconv.FormatBool(i == 0))
277 if templ_7745c5c3_Err != nil {277 if templ_7745c5c3_Err != nil {
278- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 126, Col: 89}278+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 127, Col: 89}
279 }279 }
280 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)280 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
281 if templ_7745c5c3_Err != nil {281 if templ_7745c5c3_Err != nil {
@@ -288,7 +288,7 @@ func OverlayCmd(items []CmdItem) templ.Component {
288 var templ_7745c5c3_Var12 string288 var templ_7745c5c3_Var12 string
289 templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(strings.ToLower(c.Kind + " " + c.Label))289 templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(strings.ToLower(c.Kind + " " + c.Label))
290 if templ_7745c5c3_Err != nil {290 if templ_7745c5c3_Err != nil {
291- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 126, Col: 140}291+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 127, Col: 140}
292 }292 }
293 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)293 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
294 if templ_7745c5c3_Err != nil {294 if templ_7745c5c3_Err != nil {
@@ -311,7 +311,7 @@ func OverlayCmd(items []CmdItem) templ.Component {
311 var templ_7745c5c3_Var13 templ.SafeURL311 var templ_7745c5c3_Var13 templ.SafeURL
312 templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))312 templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
313 if templ_7745c5c3_Err != nil {313 if templ_7745c5c3_Err != nil {
314- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 126, Col: 198}314+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 127, Col: 198}
315 }315 }
316 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))316 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
317 if templ_7745c5c3_Err != nil {317 if templ_7745c5c3_Err != nil {
@@ -324,7 +324,7 @@ func OverlayCmd(items []CmdItem) templ.Component {
324 var templ_7745c5c3_Var14 string324 var templ_7745c5c3_Var14 string
325 templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)325 templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
326 if templ_7745c5c3_Err != nil {326 if templ_7745c5c3_Err != nil {
327- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 126, Col: 242}327+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 127, Col: 242}
328 }328 }
329 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))329 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
330 if templ_7745c5c3_Err != nil {330 if templ_7745c5c3_Err != nil {
@@ -337,7 +337,7 @@ func OverlayCmd(items []CmdItem) templ.Component {
337 var templ_7745c5c3_Var15 string337 var templ_7745c5c3_Var15 string
338 templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(c.Kind)338 templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(c.Kind)
339 if templ_7745c5c3_Err != nil {339 if templ_7745c5c3_Err != nil {
340- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 126, Col: 275}340+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 127, Col: 275}
341 }341 }
342 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))342 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
343 if templ_7745c5c3_Err != nil {343 if templ_7745c5c3_Err != nil {
@@ -355,7 +355,7 @@ func OverlayCmd(items []CmdItem) templ.Component {
355 var templ_7745c5c3_Var16 string355 var templ_7745c5c3_Var16 string
356 templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("cmd-o" + itoa(len(items)))356 templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("cmd-o" + itoa(len(items)))
357 if templ_7745c5c3_Err != nil {357 if templ_7745c5c3_Err != nil {
358- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 128, Col: 38}358+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 129, Col: 38}
359 }359 }
360 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)360 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
361 if templ_7745c5c3_Err != nil {361 if templ_7745c5c3_Err != nil {
M internal/adapters/web/pages.templ +3 -9
M internal/adapters/web/pages_templ.go +205 -223
M internal/adapters/web/shell.templ +14 -1
M internal/adapters/web/shell_templ.go +158 -116
M internal/adapters/web/static/css/20-shell.css +9 -0
M internal/adapters/web/static/css/50-code.css +7 -4
M internal/adapters/web/static/css/55-diff.css +25 -0
M internal/adapters/web/static/css/60-mr.css +15 -1
A internal/adapters/web/static/icons/mailbox.svg +1 -0
M internal/adapters/web/static/js/ermite.js +70 -3
M internal/adapters/web/static/js/mr.js +1 -11
M internal/adapters/web/static/js/store.js +5 -0
M internal/adapters/web/view.go +6 -4
M internal/app/testdata/web/blob-binary.html +8 -1
M internal/app/testdata/web/blob-code.html +8 -1
M internal/app/testdata/web/blob-empty.html +8 -1
M internal/app/testdata/web/blob-image.html +8 -1
M internal/app/testdata/web/blob-markdown.html +8 -1
M internal/app/testdata/web/blob-svg.html +8 -1
M internal/app/testdata/web/blob-text.html +8 -1
M internal/app/testdata/web/blob-too-large.html +8 -1
M internal/app/testdata/web/blob-trojan.html +8 -1
M internal/app/testdata/web/commit-big.html +16 -2
M internal/app/testdata/web/commit-binary.html +16 -2
M internal/app/testdata/web/commit-merge.html +16 -2
M internal/app/testdata/web/commit-modify-split.html +17 -3
M internal/app/testdata/web/commit-modify.html +17 -3
M internal/app/testdata/web/commit-rename.html +17 -3
M internal/app/testdata/web/commit-root.html +20 -6
M internal/app/testdata/web/commit-trojan.html +16 -2
M internal/app/testdata/web/commits-page2.html +8 -1
M internal/app/testdata/web/commits-path.html +8 -1
M internal/app/testdata/web/commits-root.html +8 -1
M internal/app/testdata/web/commits-small.html +8 -1
M internal/app/testdata/web/empty.html +8 -1
M internal/app/testdata/web/home.html +40 -31
M internal/app/testdata/web/not-found-page.html +8 -1
M internal/app/testdata/web/not-found.html +8 -1
M internal/app/testdata/web/refs.html +8 -1
M internal/app/testdata/web/summary.html +8 -1
M internal/app/testdata/web/tree-nested.html +8 -1
M internal/app/testdata/web/tree-root.html +8 -1
M internal/app/testdata/web/tree-slash-ref.html +8 -1
M internal/app/testdata/web/tree-subdir.html +8 -1
M internal/app/web_events_test.go +2 -2
M internal/app/web_mr_test.go +2 -0
M internal/domain/repo.go +1 -0
M internal/ports/dto.go +1 -1
M internal/ports/mocks/RepoMetaStore.go +36 -21
M internal/ports/ports.go +7 -4
A internal/usecase/activity.go +43 -0
A internal/usecase/activity_store_test.go +46 -0
A internal/usecase/activity_test.go +47 -0
M internal/usecase/export_test.go +10 -0
M internal/usecase/issue_moderation.go +1 -0
M internal/usecase/issue_moderation_test.go +1 -0
M internal/usecase/issues.go +3 -0
M internal/usecase/issues_integration_test.go +67 -0
M internal/usecase/issues_test.go +2 -0
M internal/usecase/mergerequests.go +5 -0
M internal/usecase/mergerequests_integration_test.go +11 -0
M internal/usecase/pushes.go +21 -29
M internal/usecase/pushes_test.go +11 -16
A internal/usecase/repo_mutation_test.go +34 -0
M internal/usecase/repos_write.go +6 -14
M internal/usecase/repos_write_test.go +20 -21
M proto/ermite/storage/repo.proto +2 -0

Comments0 open / 0

no comments yet.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
l
merge request: the list of files (j k move, enter opens, esc back)
M
merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges
R
merge request: mark the head read
esc
close a dialog, leave a field

Branches 2

Tags 42

↑↓ moveenter openesc close