feat(web): review page polish and home page ordered by last activity (!6)
- feat(web): one merge request card, a diff view menu, a wide diff mode and quieter hunk rows - feat(web): make the wide mode a global w shortcut, set before the first paint, and tighten the merge request header - refactor(web): drop the Wide button of the diff bar, the w shortcut covers it - fix(web): a selected line range is one block, its title names the whole range and its hidden lines stay selected once loaded - test(app): refresh the web golden pages for the view menu, the w shortcut and the store script - feat(web): move the inbox button of the home page next to the logo, with a mailbox icon and a darker count badge - feat(web): order the home page by last activity and move repositories up live - fix(web): match loaded lines to a selection by file key, keep the focus on a closing menu, close menus with escape on every page and restore the hunk header contrast - fix(repos): mutate metadata atomically and record approved issue activity - test: run the activity store test outside the usecase package and its home row subtests in parallel
92 files · +2426 -1392
M gen/ermite/storage/repo.pb.go +22 -11 generated file: collapsed
M internal/adapters/kvstore/issues_test.go +4 -2
| @@ -258,13 +258,15 @@ func TestRepoSettingsKeepTheIssuesMode(t *testing.T) { | |||
| 258 | 258 | assert.Equal(t, domain.IssuesOff, got.Settings.Issues) | |
| 259 | 259 | for _, m := range []domain.IssuesMode{domain.IssuesOwner, domain.IssuesPublic, domain.IssuesOff} { | |
| 260 | 260 | 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 | 263 | again, err := s.Get(ctx, n) | |
| 263 | 264 | require.NoError(t, err) | |
| 264 | 265 | assert.Equal(t, m, again.Settings.Issues) | |
| 265 | 266 | } | |
| 266 | 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 | 272 | // 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 = m | 260 | 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 every | 272 | // TestSchema4UpgradesTheTokensWithAllScopes: the tokens that held every | ||
M internal/adapters/kvstore/mergerequests_test.go +4 -2
| @@ -442,13 +442,15 @@ func TestRepoSettings(t *testing.T) { | |||
| 442 | 442 | assert.Equal(t, domain.DefaultRepoSettings(), got.Settings) | |
| 443 | 443 | ||
| 444 | 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 | 447 | again, err := s.Get(ctx, n) | |
| 447 | 448 | require.NoError(t, err) | |
| 448 | 449 | assert.Equal(t, got.Settings, again.Settings) | |
| 449 | 450 | ||
| 450 | 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 | 456 | // 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 before | 456 | // 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) { | |||
| 64 | 64 | return repos, nil | |
| 65 | 65 | } | |
| 66 | 66 | ||
| 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. | |
| 70 | 70 | // | |
| 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 | |
| 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 nil | |
| 111 | + | return domain.Repo{}, domain.ErrConflict | |
| 92 | 112 | } | |
| 93 | 113 | ||
| 94 | 114 | // guardRepoKey makes the batch depend on the repository: it requires the | |
| @@ -208,6 +228,7 @@ func encodeRepo(r domain.Repo) ([]byte, error) { | |||
| 208 | 228 | Visibility: vis, | |
| 209 | 229 | CreatedAt: encodeTime(r.CreatedAt), | |
| 210 | 230 | PushedAt: encodeTime(r.PushedAt), | |
| 231 | + | ActiveAt: encodeTime(r.ActiveAt), | |
| 211 | 232 | Settings: pbSettings, | |
| 212 | 233 | } | |
| 213 | 234 | return mustMarshal(pb), nil | |
| @@ -229,6 +250,7 @@ func decodeRepo(val []byte) (domain.Repo, error) { | |||
| 229 | 250 | Visibility: decodeVisibility(pb.GetVisibility()), | |
| 230 | 251 | CreatedAt: decodeTime(pb.GetCreatedAt()), | |
| 231 | 252 | PushedAt: decodeTime(pb.GetPushedAt()), | |
| 253 | + | ActiveAt: decodeTime(pb.GetActiveAt()), | |
| 232 | 254 | Settings: decodeSettings(pb.GetSettings()), | |
| 233 | 255 | }, nil | |
| 234 | 256 | } | |
| @@ -64,31 +64,51 @@ func (s *Store) List(ctx context.Context) ([]domain.Repo, error) { | |||||
| 64 | return repos, nil | 64 | return repos, nil | ||
| 65 | } | 65 | } | ||
| 66 | 66 | ||||
| 67 | - | // Update replaces the stored repository. It returns domain.ErrNotFound if | 67 | + | const repoMutationTries = 8 |
| 68 | - | // there is none and domain.ErrConflict if another write changed it since it | 68 | + | |
| 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 only | 71 | + | //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 err | 75 | + | 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.ErrNotFound | 80 | + | 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 nil | 111 | + | return domain.Repo{}, domain.ErrConflict |
| 92 | } | 112 | } | ||
| 93 | 113 | ||||
| 94 | // guardRepoKey makes the batch depend on the repository: it requires the | 114 | // 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), nil | 234 | 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 | }, nil | 255 | }, 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) { | |||
| 43 | 43 | s := openStore(t, t.TempDir()) | |
| 44 | 44 | ||
| 45 | 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 | 47 | require.NoError(t, s.Create(ctx, b)) | |
| 48 | 48 | require.NoError(t, s.Create(ctx, a)) | |
| 49 | 49 | ||
| @@ -54,6 +54,7 @@ func TestCreateGetListUpdateDelete(t *testing.T) { | |||
| 54 | 54 | require.NoError(t, err) | |
| 55 | 55 | assert.Equal(t, a, got) | |
| 56 | 56 | assert.True(t, got.PushedAt.Equal(a.PushedAt)) | |
| 57 | + | assert.True(t, got.ActiveAt.Equal(a.ActiveAt)) | |
| 57 | 58 | ||
| 58 | 59 | list, err := s.List(ctx) | |
| 59 | 60 | require.NoError(t, err) | |
| @@ -65,13 +66,15 @@ func TestCreateGetListUpdateDelete(t *testing.T) { | |||
| 65 | 66 | b.Description = "changed" | |
| 66 | 67 | b.Topics = []string{"forge"} | |
| 67 | 68 | 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 | 71 | got, err = s.Get(ctx, b.Name) | |
| 70 | 72 | require.NoError(t, err) | |
| 71 | 73 | assert.Equal(t, b, got) | |
| 72 | 74 | ||
| 73 | 75 | 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 | 78 | got, err = s.Get(ctx, b.Name) | |
| 76 | 79 | require.NoError(t, err) | |
| 77 | 80 | assert.Nil(t, got.Topics, "cleared topics read back as nil") | |
| @@ -80,7 +83,8 @@ func TestCreateGetListUpdateDelete(t *testing.T) { | |||
| 80 | 83 | _, err = s.Get(ctx, b.Name) | |
| 81 | 84 | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 82 | 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 | 89 | _, err = s.Get(ctx, name(t, "never")) | |
| 86 | 90 | 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.VisibilityPrivate | 68 | 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 = nil | 75 | 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) { | |||
| 37 | 37 | // right its figures and CI state over its newest commits. Its id is the | |
| 38 | 38 | // target of SSE swaps; the name link comes first, `enter` follows it. | |
| 39 | 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 | 41 | <div class="repo-main"> | |
| 42 | 42 | <h2 class="repo-name"> | |
| 43 | 43 | <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a> | |
| @@ -45,7 +45,7 @@ templ HomeRowView(r HomeRow) { | |||
| 45 | 45 | <span class="pill">private</span> | |
| 46 | 46 | } | |
| 47 | 47 | if r.Fresh { | |
| 48 | - | <span class="pill new">new push</span> | |
| 48 | + | <span class="pill new">new activity</span> | |
| 49 | 49 | } | |
| 50 | 50 | </h2> | |
| 51 | 51 | 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 the | 37 | // 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) { | |||
| 72 | 72 | <div class="diff-tools" role="group" aria-label="Diff view"> | |
| 73 | 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 | 74 | <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) | |
| 78 | 76 | </div> | |
| 79 | 77 | if p.Invisible { | |
| 80 | 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 | 133 | // viewLink is a button of the diff tools. It is a link, so that the split | |
| 116 | 134 | // layout works without JavaScript; with it, and when both layouts are in the | |
| 117 | 135 | // page, a click only flips the layout. | |
| @@ -203,7 +221,7 @@ templ DiffFileView(f DiffFile, p DiffView) { | |||
| 203 | 221 | <details | |
| 204 | 222 | class={ "dfile", "lnw-" + itoa(f.Digits) } | |
| 205 | 223 | id={ f.ID } | |
| 206 | - | data-file | |
| 224 | + | data-file={ f.Key } | |
| 207 | 225 | if f.Open { | |
| 208 | 226 | open | |
| 209 | 227 | } | |
| @@ -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 split | 133 | // 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 the | 134 | // 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 | <details | 221 | <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-file | 224 | + | data-file={ f.Key } |
| 207 | if f.Open { | 225 | if f.Open { | ||
| 208 | open | 226 | 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 ( | |||
| 50 | 50 | // | |
| 51 | 51 | // home-list data: <li id="repo-<name>">…</li>, or <li id="repo-<name>" data-gone></li> | |
| 52 | 52 | // 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 | 55 | // repo-last-push data: <dd id="last-push-<name>">…</dd> | |
| 54 | 56 | // refs-list data: <tbody id="ref-rows">…</tbody>, and again with | |
| 55 | 57 | // <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 visible | 52 | // 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 with | 56 | // refs-list data: <tbody id="ref-rows">…</tbody>, and again with | ||
| 55 | // <dl id="refs-kv">…</dl>, the facts of the rail | 57 | // <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 { | |||
| 61 | 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 | 72 | func plural(n int, one, many string) string { | |
| 65 | 73 | if n == 1 { | |
| 66 | 74 | 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 " + one | 74 | 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, | |||
| 112 | 112 | name = ev.Repo.String() | |
| 113 | 113 | case domain.PushEvent: | |
| 114 | 114 | 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 | 133 | case domain.RunUpdated: | |
| 116 | 134 | name = ev.Run.Repo.String() // the CI state of the row | |
| 117 | 135 | 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(), true | 114 | 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 row | 134 | 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) { | |||
| 56 | 56 | // homeSorts are the orders the home list offers, in order. The query also | |
| 57 | 57 | // accepts "desc" and "branch", which the page does not link to. | |
| 58 | 58 | var homeSorts = []struct{ key, label string }{ | |
| 59 | - | {"push", "last push"}, | |
| 59 | + | {"activity", "last activity"}, | |
| 60 | 60 | {"name", "name"}, | |
| 61 | 61 | } | |
| 62 | 62 | ||
| 63 | 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 | 66 | // sortRepos orders repos by the column key; ties keep the order by name. | |
| 67 | 67 | func sortRepos(repos []ports.RepoView, key string, desc bool) { | |
| @@ -71,8 +71,8 @@ func sortRepos(repos []ports.RepoView, key string, desc bool) { | |||
| 71 | 71 | return strings.ToLower(a.Description) < strings.ToLower(b.Description) | |
| 72 | 72 | case "branch": | |
| 73 | 73 | 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)) | |
| 76 | 76 | } | |
| 77 | 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 first | |
| 87 | + | // home lists the repositories: /?sort=<col>&dir=asc|desc, newest activity first | |
| 88 | 88 | // by default. | |
| 89 | 89 | func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |
| 90 | 90 | repos, err := s.listRepos(ctx) | |
| 91 | 91 | if err != nil { | |
| 92 | 92 | return nil, 0, err | |
| 93 | 93 | } | |
| 94 | - | key, desc := "push", true | |
| 94 | + | key, desc := "activity", true | |
| 95 | 95 | for _, k := range homeKeys { | |
| 96 | 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 | 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 | 103 | sortRepos(repos, key, desc) | |
| 104 | 104 | ||
| 105 | 105 | now := s.cfg.Now() | |
| 106 | - | p := HomePage{} | |
| 106 | + | p := HomePage{Sort: key, Desc: desc} | |
| 107 | 107 | for _, c := range homeSorts { | |
| 108 | 108 | sl := SortLink{Label: c.label} | |
| 109 | 109 | next := "asc" | |
| 110 | - | if c.key == "push" { | |
| 110 | + | if c.key == "activity" { | |
| 111 | 111 | next = "desc" | |
| 112 | 112 | } | |
| 113 | 113 | if c.key == key { | |
| @@ -121,11 +121,10 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in | |||
| 121 | 121 | p.Sorts = append(p.Sorts, sl) | |
| 122 | 122 | } | |
| 123 | 123 | p.Rows = s.homeRows(ctx, repos, now) | |
| 124 | + | l := s.base() | |
| 124 | 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 | 128 | l.Title = s.instance | |
| 130 | 129 | l.Live, l.LiveURL = true, liveURL("home", "") | |
| 131 | 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 | 267 | Desc: r.Description, Topics: r.Topics, Cursor: cursor, | |
| 269 | 268 | Private: signed && r.Visibility == domain.VisibilityPrivate, | |
| 270 | 269 | } | |
| 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) | |
| 273 | 272 | } | |
| 274 | 273 | return row | |
| 275 | 274 | } | |
| @@ -285,3 +284,14 @@ func repoCmds(repos []ports.RepoView, except string) []CmdItem { | |||
| 285 | 284 | } | |
| 286 | 285 | 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 | + | } | |
| @@ -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 also | 56 | // 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.DefaultBranch | 73 | 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 first | 87 | + | // 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, err | 92 | return nil, 0, err | ||
| 93 | } | 93 | } | ||
| 94 | - | key, desc := "push", true | 94 | + | 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.instance | 128 | 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 row | 273 | return row | ||
| 275 | } | 274 | } | ||
| @@ -285,3 +284,14 @@ func repoCmds(repos []ports.RepoView, except string) []CmdItem { | |||||
| 285 | } | 284 | } | ||
| 286 | return out | 285 | 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) { | |||
| 252 | 252 | assert.Equal(t, "home-list", got[0].event) | |
| 253 | 253 | assert.Contains(t, got[0].html, `id="repo-proj"`) | |
| 254 | 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 | 257 | // A job that ended refreshes the row too; one in progress does not. | |
| 258 | 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 | 344 | require.NotEqual(t, -1, store) | |
| 345 | 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 | + | } | |
| @@ -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() { | |||
| 136 | 136 | // owner, the form to comment and the button that closes or reopens it. | |
| 137 | 137 | templ IssueShow(l Layout, p IssuePage) { | |
| 138 | 138 | @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> | |
| 149 | 155 | } | |
| 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> | |
| 157 | 164 | if p.Live { | |
| 158 | 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 | 167 | <h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(len(p.Comments)) }</span></h2> | |
| 166 | 168 | for _, c := range p.Comments { | |
| 167 | 169 | @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'; | |||
| 8 | 8 | import { dom, El, el, flushFrames, installDom, settle } from './dom.mjs'; | |
| 9 | 9 | ||
| 10 | 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 | 12 | El.prototype.replaceWith = function (n) { | |
| 12 | 13 | const p = this.parent; | |
| 13 | 14 | const i = p.nodes.indexOf(this); | |
| @@ -333,3 +334,27 @@ test('the preference is read from storage when the page loads', async () => { | |||
| 333 | 334 | assert.deepEqual(dom.body.events, []); | |
| 334 | 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 | + | }); | |
| @@ -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() { | |||
| 32 | 32 | k-split from:body -> ermite.layout('split'); | |
| 33 | 33 | k-unified from:body -> ermite.layout('unified'); | |
| 34 | 34 | k-collapse from:body -> ermite.collapse(); | |
| 35 | + | k-wide from:body -> window.ermiteWide.toggle(); | |
| 35 | 36 | k-expand from:body -> ermite.expandFile(); | |
| 36 | 37 | k-expandAll from:body -> ermite.expandAll(); | |
| 37 | 38 | 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
| @@ -34,7 +34,7 @@ func Keys() templ.Component { | |||
| 34 | 34 | templ_7745c5c3_Var1 = templ.NopComponent | |
| 35 | 35 | } | |
| 36 | 36 | ctx = templ.ClearChildren(ctx) | |
| 37 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"keys\" hidden hx-on=\"k-j from:body -> ermite.move(1);\n k-k from:body -> ermite.move(-1);\n k-top from:body -> ermite.edge(false);\n k-bottom from:body -> ermite.edge(true);\n k-open from:body -> ermite.open();\n k-right from:body -> ermite.rail(true);\n k-left from:body -> ermite.rail(false);\n k-esc from:body -> ermite.closeDialogs();\n k-help from:body -> ermite.dialog('panel-help');\n k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');\n k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();\n k-filter from:body -> ermite.focusFilter();\n k-gh from:body -> ermite.go('.brand');\n k-s from:body -> ermite.tab('s');\n k-t from:body -> ermite.tab('t');\n k-c from:body -> ermite.tab('c');\n k-r from:body -> ermite.tab('r');\n k-m from:body -> ermite.tab('m');\n k-i from:body -> ermite.tab('i');\n k-next from:body -> ermite.nextNote();\n k-split from:body -> ermite.layout('split');\n k-unified from:body -> ermite.layout('unified');\n k-collapse from:body -> ermite.collapse();\n k-expand from:body -> ermite.expandFile();\n k-expandAll from:body -> ermite.expandAll();\n k-permalink from:body -> ermite.permalink();\n k-merge from:body -> window.ermiteMR?.openMerge();\n k-read from:body -> window.ermiteMR?.markRead();\n k-gf from:body -> window.ermiteMR?.focusFiles();\n k-keys from:body -> ermite.toggleKeys()\"></div>") | |
| 37 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"keys\" hidden hx-on=\"k-j from:body -> ermite.move(1);\n k-k from:body -> ermite.move(-1);\n k-top from:body -> ermite.edge(false);\n k-bottom from:body -> ermite.edge(true);\n k-open from:body -> ermite.open();\n k-right from:body -> ermite.rail(true);\n k-left from:body -> ermite.rail(false);\n k-esc from:body -> ermite.closeDialogs();\n k-help from:body -> ermite.dialog('panel-help');\n k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');\n k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();\n k-filter from:body -> ermite.focusFilter();\n k-gh from:body -> ermite.go('.brand');\n k-s from:body -> ermite.tab('s');\n k-t from:body -> ermite.tab('t');\n k-c from:body -> ermite.tab('c');\n k-r from:body -> ermite.tab('r');\n k-m from:body -> ermite.tab('m');\n k-i from:body -> ermite.tab('i');\n k-next from:body -> ermite.nextNote();\n k-split from:body -> ermite.layout('split');\n k-unified from:body -> ermite.layout('unified');\n k-collapse from:body -> ermite.collapse();\n k-wide from:body -> window.ermiteWide.toggle();\n k-expand from:body -> ermite.expandFile();\n k-expandAll from:body -> ermite.expandAll();\n k-permalink from:body -> ermite.permalink();\n k-merge from:body -> window.ermiteMR?.openMerge();\n k-read from:body -> window.ermiteMR?.markRead();\n k-gf from:body -> window.ermiteMR?.focusFiles();\n k-keys from:body -> ermite.toggleKeys()\"></div>") | |
| 38 | 38 | if templ_7745c5c3_Err != nil { | |
| 39 | 39 | return templ_7745c5c3_Err | |
| 40 | 40 | } | |
| @@ -34,7 +34,7 @@ func Keys() templ.Component { | |||||
| 34 | templ_7745c5c3_Var1 = templ.NopComponent | 34 | templ_7745c5c3_Var1 = templ.NopComponent | ||
| 35 | } | 35 | } | ||
| 36 | ctx = templ.ClearChildren(ctx) | 36 | ctx = templ.ClearChildren(ctx) | ||
| 37 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"keys\" hidden hx-on=\"k-j from:body -> ermite.move(1);\n k-k from:body -> ermite.move(-1);\n k-top from:body -> ermite.edge(false);\n k-bottom from:body -> ermite.edge(true);\n k-open from:body -> ermite.open();\n k-right from:body -> ermite.rail(true);\n k-left from:body -> ermite.rail(false);\n k-esc from:body -> ermite.closeDialogs();\n k-help from:body -> ermite.dialog('panel-help');\n k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');\n k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();\n k-filter from:body -> ermite.focusFilter();\n k-gh from:body -> ermite.go('.brand');\n k-s from:body -> ermite.tab('s');\n k-t from:body -> ermite.tab('t');\n k-c from:body -> ermite.tab('c');\n k-r from:body -> ermite.tab('r');\n k-m from:body -> ermite.tab('m');\n k-i from:body -> ermite.tab('i');\n k-next from:body -> ermite.nextNote();\n k-split from:body -> ermite.layout('split');\n k-unified from:body -> ermite.layout('unified');\n k-collapse from:body -> ermite.collapse();\n k-expand from:body -> ermite.expandFile();\n k-expandAll from:body -> ermite.expandAll();\n k-permalink from:body -> ermite.permalink();\n k-merge from:body -> window.ermiteMR?.openMerge();\n k-read from:body -> window.ermiteMR?.markRead();\n k-gf from:body -> window.ermiteMR?.focusFiles();\n k-keys from:body -> ermite.toggleKeys()\"></div>") | 37 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"keys\" hidden hx-on=\"k-j from:body -> ermite.move(1);\n k-k from:body -> ermite.move(-1);\n k-top from:body -> ermite.edge(false);\n k-bottom from:body -> ermite.edge(true);\n k-open from:body -> ermite.open();\n k-right from:body -> ermite.rail(true);\n k-left from:body -> ermite.rail(false);\n k-esc from:body -> ermite.closeDialogs();\n k-help from:body -> ermite.dialog('panel-help');\n k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');\n k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();\n k-filter from:body -> ermite.focusFilter();\n k-gh from:body -> ermite.go('.brand');\n k-s from:body -> ermite.tab('s');\n k-t from:body -> ermite.tab('t');\n k-c from:body -> ermite.tab('c');\n k-r from:body -> ermite.tab('r');\n k-m from:body -> ermite.tab('m');\n k-i from:body -> ermite.tab('i');\n k-next from:body -> ermite.nextNote();\n k-split from:body -> ermite.layout('split');\n k-unified from:body -> ermite.layout('unified');\n k-collapse from:body -> ermite.collapse();\n k-wide from:body -> window.ermiteWide.toggle();\n k-expand from:body -> ermite.expandFile();\n k-expandAll from:body -> ermite.expandAll();\n k-permalink from:body -> ermite.permalink();\n k-merge from:body -> window.ermiteMR?.openMerge();\n k-read from:body -> window.ermiteMR?.markRead();\n k-gf from:body -> window.ermiteMR?.focusFiles();\n k-keys from:body -> ermite.toggleKeys()\"></div>") |
| 38 | if templ_7745c5c3_Err != nil { | 38 | if templ_7745c5c3_Err != nil { | ||
| 39 | return templ_7745c5c3_Err | 39 | return templ_7745c5c3_Err | ||
| 40 | } | 40 | } | ||
M internal/adapters/web/mr.go +6 -1
| @@ -910,10 +910,15 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai | |||
| 910 | 910 | 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 | 918 | if ln := find(on.path, side, on.line); ln != nil { | |
| 914 | 919 | ln.Form = &CommentForm{ | |
| 915 | 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 | 922 | Cancel: withFrom(self, windowStart(fileAt[on.path])) + "#" + fileIDs[on.path], Focus: true, | |
| 918 | 923 | From: windowStart(fileAt[on.path]), | |
| 919 | 924 | } | |
| @@ -910,10 +910,15 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai | |||||
| 910 | ln.Selected = true | 910 | 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) { | |||
| 109 | 109 | // form, the checks, the settings, the commits and the files. | |
| 110 | 110 | templ MergeRequest(l Layout, p MRPage) { | |
| 111 | 111 | @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> | |
| 133 | 139 | } | |
| 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> | |
| 141 | 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 | 149 | if p.Since != nil { | |
| 148 | 150 | <nav class="since" aria-label="Diff range"> | |
| 149 | 151 | <a | |
| @@ -431,9 +433,7 @@ templ mrRail(p MRPage) { | |||
| 431 | 433 | templ mrBar(p MRPage) { | |
| 432 | 434 | <div class="diff-tools mr-bar" role="group" aria-label="Diff view and merge"> | |
| 433 | 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) | |
| 435 | - | @viewLink(p.DiffView, "split", "Split", "d", p.SplitHref) | |
| 436 | - | @collapseButton() | |
| 436 | + | @diffViewMenu(p.DiffView) | |
| 437 | 437 | <span class="grow"></span> | |
| 438 | 438 | @MRChecksSum(p.ChecksSum) | |
| 439 | 439 | if p.Box != nil { | |
| @@ -562,9 +562,7 @@ templ Compare(l Layout, p ComparePage) { | |||
| 562 | 562 | <div class="diff-tools" role="group" aria-label="Diff view"> | |
| 563 | 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 | 564 | <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) | |
| 568 | 566 | </div> | |
| 569 | 567 | if p.Invisible { | |
| 570 | 568 | <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 | <a | 151 | <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) { | |||
| 28 | 28 | <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div> | |
| 29 | 29 | <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd> <kbd>i</kbd></dt><dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd></div> | |
| 30 | 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 | 32 | <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div> | |
| 32 | 33 | <div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div> | |
| 33 | 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> | |
| @@ -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 & 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 & tags, merge requests, issues</dd></div> | ||
| 30 | <div><dt><kbd>g h</kbd></dt><dd>home</dd></div> | 30 | <div><dt><kbd>g h</kbd></dt><dd>home</dd></div> | ||
| 31 | + | <div><dt><kbd>w</kbd></dt><dd>wide mode: use the whole width of the screen, or not (remembered)</dd></div> | |||
| 31 | <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div> | 32 | <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div> | ||
| 32 | <div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div> | 33 | <div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div> | ||
| 33 | <div><dt><kbd>e</kbd> <kbd>E</kbd></dt><dd>diff: the whole file in view / every file, with the lines the hunks hide</dd></div> | 34 | <div><dt><kbd>e</kbd> <kbd>E</kbd></dt><dd>diff: the whole file in view / every file, with the lines the hunks hide</dd></div> | ||
M internal/adapters/web/overlays_templ.go +12 -12 generated file: collapsed
M internal/adapters/web/pages.templ +3 -9
| @@ -2,6 +2,8 @@ | |||
| 2 | 2 | ||
| 3 | 3 | package web | |
| 4 | 4 | ||
| 5 | + | import "fmt" | |
| 6 | + | ||
| 5 | 7 | // Home is the repository list. | |
| 6 | 8 | templ Home(l Layout, p HomePage) { | |
| 7 | 9 | @Shell(l, nil) { | |
| @@ -9,14 +11,6 @@ templ Home(l Layout, p HomePage) { | |||
| 9 | 11 | <div class="home" data-layout="cards"> | |
| 10 | 12 | <section class="home-repos" aria-label="Repositories"> | |
| 11 | 13 | <div class="home-bar"> | |
| 12 | - | if p.Inbox != nil { | |
| 13 | - | <a class="btn inbox-btn" href={ templ.SafeURL(p.Inbox.Href) } title="inbox"> | |
| 14 | - | Inbox | |
| 15 | - | if p.Inbox.Count >= 0 { | |
| 16 | - | <span class="count">{ itoa(p.Inbox.Count) }</span> | |
| 17 | - | } | |
| 18 | - | </a> | |
| 19 | - | } | |
| 20 | 14 | <div class="filter"> | |
| 21 | 15 | <label for="home-filter" class="sr">filter</label> | |
| 22 | 16 | <input | |
| @@ -57,7 +51,7 @@ templ Home(l Layout, p HomePage) { | |||
| 57 | 51 | </button> | |
| 58 | 52 | </div> | |
| 59 | 53 | </div> | |
| 60 | - | <ul class="repos" id="repo-rows" data-rows> | |
| 54 | + | <ul class="repos" id="repo-rows" data-rows data-sort={ p.Sort } data-desc={ fmt.Sprint(p.Desc) }> | |
| 61 | 55 | for _, r := range p.Rows { | |
| 62 | 56 | @HomeRowView(r) | |
| 63 | 57 | } | |
| @@ -2,6 +2,8 @@ | |||||
| 2 | 2 | ||||
| 3 | package web | 3 | package web | ||
| 4 | 4 | ||||
| 5 | + | import "fmt" | |||
| 6 | + | ||||
| 5 | // Home is the repository list. | 7 | // Home is the repository list. | ||
| 6 | templ Home(l Layout, p HomePage) { | 8 | templ Home(l Layout, p HomePage) { | ||
| 7 | @Shell(l, nil) { | 9 | @Shell(l, nil) { | ||
| @@ -9,14 +11,6 @@ templ Home(l Layout, p HomePage) { | |||||
| 9 | <div class="home" data-layout="cards"> | 11 | <div class="home" data-layout="cards"> | ||
| 10 | <section class="home-repos" aria-label="Repositories"> | 12 | <section class="home-repos" aria-label="Repositories"> | ||
| 11 | <div class="home-bar"> | 13 | <div class="home-bar"> | ||
| 12 | - | if p.Inbox != nil { | |||
| 13 | - | <a class="btn inbox-btn" href={ templ.SafeURL(p.Inbox.Href) } title="inbox"> | |||
| 14 | - | Inbox | |||
| 15 | - | if p.Inbox.Count >= 0 { | |||
| 16 | - | <span class="count">{ itoa(p.Inbox.Count) }</span> | |||
| 17 | - | } | |||
| 18 | - | </a> | |||
| 19 | - | } | |||
| 20 | <div class="filter"> | 14 | <div class="filter"> | ||
| 21 | <label for="home-filter" class="sr">filter</label> | 15 | <label for="home-filter" class="sr">filter</label> | ||
| 22 | <input | 16 | <input | ||
| @@ -57,7 +51,7 @@ templ Home(l Layout, p HomePage) { | |||||
| 57 | </button> | 51 | </button> | ||
| 58 | </div> | 52 | </div> | ||
| 59 | </div> | 53 | </div> | ||
| 60 | - | <ul class="repos" id="repo-rows" data-rows> | 54 | + | <ul class="repos" id="repo-rows" data-rows data-sort={ p.Sort } data-desc={ fmt.Sprint(p.Desc) }> |
| 61 | for _, r := range p.Rows { | 55 | for _, r := range p.Rows { | ||
| 62 | @HomeRowView(r) | 56 | @HomeRowView(r) | ||
| 63 | } | 57 | } | ||
M internal/adapters/web/pages_templ.go +205 -223 generated file: collapsed
M internal/adapters/web/shell.templ +14 -1
| @@ -25,7 +25,7 @@ templ Shell(l Layout, rail templ.Component) { | |||
| 25 | 25 | <script src={ l.Assets.HTMX } defer></script> | |
| 26 | 26 | <script src={ l.Assets.Live } defer></script> | |
| 27 | 27 | <script src={ l.Assets.SSE } defer></script> | |
| 28 | - | <script src={ l.Assets.Store } defer></script> | |
| 28 | + | <script src={ l.Assets.Store }></script> | |
| 29 | 29 | <script src={ l.Assets.App } defer></script> | |
| 30 | 30 | <script src={ l.Assets.Files } defer></script> | |
| 31 | 31 | <script src={ l.Assets.Passkey } defer></script> | |
| @@ -80,6 +80,19 @@ templ Header(l Layout) { | |||
| 80 | 80 | <a class="brand" href="/" data-g="h" aria-label="home" title="home (g h)"> | |
| 81 | 81 | <span class="brand-mark" aria-hidden="true"></span> | |
| 82 | 82 | </a> | |
| 83 | + | if l.Inbox != nil { | |
| 84 | + | <a | |
| 85 | + | class="btn inbox-btn" | |
| 86 | + | href={ templ.SafeURL(l.Inbox.Href) } | |
| 87 | + | title="inbox" | |
| 88 | + | > | |
| 89 | + | <span class="ico i-mailbox" aria-hidden="true"></span> | |
| 90 | + | Inbox | |
| 91 | + | if l.Inbox.Count >= 0 { | |
| 92 | + | <span class="count">{ itoa(l.Inbox.Count) }</span> | |
| 93 | + | } | |
| 94 | + | </a> | |
| 95 | + | } | |
| 83 | 96 | if len(l.Crumbs) > 0 { | |
| 84 | 97 | <nav class="crumbs" aria-label="Path"> | |
| 85 | 98 | <ol> | |
| @@ -25,7 +25,7 @@ templ Shell(l Layout, rail templ.Component) { | |||||
| 25 | <script src={ l.Assets.HTMX } defer></script> | 25 | <script src={ l.Assets.HTMX } defer></script> | ||
| 26 | <script src={ l.Assets.Live } defer></script> | 26 | <script src={ l.Assets.Live } defer></script> | ||
| 27 | <script src={ l.Assets.SSE } defer></script> | 27 | <script src={ l.Assets.SSE } defer></script> | ||
| 28 | - | <script src={ l.Assets.Store } defer></script> | 28 | + | <script src={ l.Assets.Store }></script> |
| 29 | <script src={ l.Assets.App } defer></script> | 29 | <script src={ l.Assets.App } defer></script> | ||
| 30 | <script src={ l.Assets.Files } defer></script> | 30 | <script src={ l.Assets.Files } defer></script> | ||
| 31 | <script src={ l.Assets.Passkey } defer></script> | 31 | <script src={ l.Assets.Passkey } defer></script> | ||
| @@ -80,6 +80,19 @@ templ Header(l Layout) { | |||||
| 80 | <a class="brand" href="/" data-g="h" aria-label="home" title="home (g h)"> | 80 | <a class="brand" href="/" data-g="h" aria-label="home" title="home (g h)"> | ||
| 81 | <span class="brand-mark" aria-hidden="true"></span> | 81 | <span class="brand-mark" aria-hidden="true"></span> | ||
| 82 | </a> | 82 | </a> | ||
| 83 | + | if l.Inbox != nil { | |||
| 84 | + | <a | |||
| 85 | + | class="btn inbox-btn" | |||
| 86 | + | href={ templ.SafeURL(l.Inbox.Href) } | |||
| 87 | + | title="inbox" | |||
| 88 | + | > | |||
| 89 | + | <span class="ico i-mailbox" aria-hidden="true"></span> | |||
| 90 | + | Inbox | |||
| 91 | + | if l.Inbox.Count >= 0 { | |||
| 92 | + | <span class="count">{ itoa(l.Inbox.Count) }</span> | |||
| 93 | + | } | |||
| 94 | + | </a> | |||
| 95 | + | } | |||
| 83 | if len(l.Crumbs) > 0 { | 96 | if len(l.Crumbs) > 0 { | ||
| 84 | <nav class="crumbs" aria-label="Path"> | 97 | <nav class="crumbs" aria-label="Path"> | ||
| 85 | <ol> | 98 | <ol> | ||
M internal/adapters/web/shell_templ.go +158 -116 generated file: collapsed
M internal/adapters/web/static/css/20-shell.css +9 -0
| @@ -29,6 +29,7 @@ | |||
| 29 | 29 | .i-cards { --i: url(icons/cards.svg); } | |
| 30 | 30 | .i-split { --i: url(icons/split.svg); } | |
| 31 | 31 | .i-compact { --i: url(icons/compact.svg); } | |
| 32 | + | .i-mailbox { --i: url(icons/mailbox.svg); } | |
| 32 | 33 | ||
| 33 | 34 | .pill { | |
| 34 | 35 | display: inline-flex; align-items: center; height: 20px; padding: 0 var(--s2); margin-left: var(--s1); | |
| @@ -77,6 +78,11 @@ | |||
| 77 | 78 | } | |
| 78 | 79 | /* the hermit by his fire: a grey picture in its own rounded square */ | |
| 79 | 80 | .brand-mark { width: 32px; height: 32px; flex: none; background: url(icons/brand.svg) center / contain no-repeat; } | |
| 81 | + | /* the inbox, right of the logo on the home page: its number in a darker badge */ | |
| 82 | + | .inbox-btn { | |
| 83 | + | flex: none; | |
| 84 | + | & .count { margin-left: 0; color: var(--ink-hi); background: var(--bg-canvas); box-shadow: inset 0 0 0 1px var(--border); } | |
| 85 | + | } | |
| 80 | 86 | .crumbs { | |
| 81 | 87 | min-width: 0; flex: 1 1 auto; overflow: hidden; | |
| 82 | 88 | & ol { display: flex; align-items: center; min-width: 0; font-family: var(--mono); font-size: var(--fs); } | |
| @@ -133,6 +139,9 @@ | |||
| 133 | 139 | align-content: start; | |
| 134 | 140 | } | |
| 135 | 141 | .work.has-rail { grid-template-columns: minmax(0, 1fr) var(--rail); } | |
| 142 | + | /* the wide mode (w, ermiteWide): every page takes the whole width of the | |
| 143 | + | screen; the rail goes to the right edge, the content takes the rest */ | |
| 144 | + | html.wide :is(.work, .hdr-top, .tabs, .foot) { max-width: none; } | |
| 136 | 145 | .rail { display: flex; flex-direction: column; gap: var(--s4); min-width: 0; } | |
| 137 | 146 | .work-lede { grid-column: 1 / -1; margin-bottom: calc(-1 * var(--s2)); } | |
| 138 | 147 | main > * + * { margin-top: var(--s4); } | |
| @@ -29,6 +29,7 @@ | |||||
| 29 | .i-cards { --i: url(icons/cards.svg); } | 29 | .i-cards { --i: url(icons/cards.svg); } | ||
| 30 | .i-split { --i: url(icons/split.svg); } | 30 | .i-split { --i: url(icons/split.svg); } | ||
| 31 | .i-compact { --i: url(icons/compact.svg); } | 31 | .i-compact { --i: url(icons/compact.svg); } | ||
| 32 | + | .i-mailbox { --i: url(icons/mailbox.svg); } | |||
| 32 | 33 | ||||
| 33 | .pill { | 34 | .pill { | ||
| 34 | display: inline-flex; align-items: center; height: 20px; padding: 0 var(--s2); margin-left: var(--s1); | 35 | display: inline-flex; align-items: center; height: 20px; padding: 0 var(--s2); margin-left: var(--s1); | ||
| @@ -77,6 +78,11 @@ | |||||
| 77 | } | 78 | } | ||
| 78 | /* the hermit by his fire: a grey picture in its own rounded square */ | 79 | /* the hermit by his fire: a grey picture in its own rounded square */ | ||
| 79 | .brand-mark { width: 32px; height: 32px; flex: none; background: url(icons/brand.svg) center / contain no-repeat; } | 80 | .brand-mark { width: 32px; height: 32px; flex: none; background: url(icons/brand.svg) center / contain no-repeat; } | ||
| 81 | + | /* the inbox, right of the logo on the home page: its number in a darker badge */ | |||
| 82 | + | .inbox-btn { | |||
| 83 | + | flex: none; | |||
| 84 | + | & .count { margin-left: 0; color: var(--ink-hi); background: var(--bg-canvas); box-shadow: inset 0 0 0 1px var(--border); } | |||
| 85 | + | } | |||
| 80 | .crumbs { | 86 | .crumbs { | ||
| 81 | min-width: 0; flex: 1 1 auto; overflow: hidden; | 87 | min-width: 0; flex: 1 1 auto; overflow: hidden; | ||
| 82 | & ol { display: flex; align-items: center; min-width: 0; font-family: var(--mono); font-size: var(--fs); } | 88 | & ol { display: flex; align-items: center; min-width: 0; font-family: var(--mono); font-size: var(--fs); } | ||
| @@ -133,6 +139,9 @@ | |||||
| 133 | align-content: start; | 139 | align-content: start; | ||
| 134 | } | 140 | } | ||
| 135 | .work.has-rail { grid-template-columns: minmax(0, 1fr) var(--rail); } | 141 | .work.has-rail { grid-template-columns: minmax(0, 1fr) var(--rail); } | ||
| 142 | + | /* the wide mode (w, ermiteWide): every page takes the whole width of the | |||
| 143 | + | screen; the rail goes to the right edge, the content takes the rest */ | |||
| 144 | + | html.wide :is(.work, .hdr-top, .tabs, .foot) { max-width: none; } | |||
| 136 | .rail { display: flex; flex-direction: column; gap: var(--s4); min-width: 0; } | 145 | .rail { display: flex; flex-direction: column; gap: var(--s4); min-width: 0; } | ||
| 137 | .work-lede { grid-column: 1 / -1; margin-bottom: calc(-1 * var(--s2)); } | 146 | .work-lede { grid-column: 1 / -1; margin-bottom: calc(-1 * var(--s2)); } | ||
| 138 | main > * + * { margin-top: var(--s4); } | 147 | main > * + * { margin-top: var(--s4); } | ||
M internal/adapters/web/static/css/50-code.css +7 -4
| @@ -79,13 +79,16 @@ | |||
| 79 | 79 | & .ln { width: calc(var(--lnw) + 2ch); min-width: calc(var(--lnw) + 2ch); padding: 0 1ch; text-align: right; color: var(--ink-mute); user-select: none; } | |
| 80 | 80 | & .m { width: 2ch; text-align: center; user-select: none; color: var(--ink-dim); } | |
| 81 | 81 | & .tx { white-space: pre; } | |
| 82 | - | & .hunk td { color: var(--ink-mute); background: var(--paper-alt); padding: 0 1ch; } | |
| 82 | + | /* the hunk headers and the hidden lines stay quiet: a faint band between | |
| 83 | + | the code, so that the eye keeps reading */ | |
| 84 | + | & .hunk td, & .gap td { background: color-mix(in srgb, var(--paper-alt) 35%, var(--paper)); } | |
| 85 | + | & .hunk td { color: var(--ink-mute); padding: 0 1ch; } | |
| 83 | 86 | /* a run of hidden lines: the whole row loads them */ | |
| 84 | - | & .gap td { padding: 0; background: var(--paper-alt); } | |
| 87 | + | & .gap td { padding: 0; } | |
| 85 | 88 | & .gap button { | |
| 86 | 89 | display: block; width: 100%; padding: 2px 1ch; text-align: left; cursor: pointer; | |
| 87 | - | font: inherit; color: var(--ink-dim); background: none; border: 0; | |
| 88 | - | border-block: 1px dashed var(--border); | |
| 90 | + | font: inherit; color: var(--ink-mute); background: none; border: 0; | |
| 91 | + | border-block: 1px dashed color-mix(in srgb, var(--border) 60%, transparent); | |
| 89 | 92 | } | |
| 90 | 93 | & .gap button:hover, & .gap button:focus-visible { color: var(--ink-hi); background: var(--bg-strong); } | |
| 91 | 94 | & .gap button:disabled { cursor: progress; opacity: .6; } | |
| @@ -79,13 +79,16 @@ | |||||
| 79 | & .ln { width: calc(var(--lnw) + 2ch); min-width: calc(var(--lnw) + 2ch); padding: 0 1ch; text-align: right; color: var(--ink-mute); user-select: none; } | 79 | & .ln { width: calc(var(--lnw) + 2ch); min-width: calc(var(--lnw) + 2ch); padding: 0 1ch; text-align: right; color: var(--ink-mute); user-select: none; } | ||
| 80 | & .m { width: 2ch; text-align: center; user-select: none; color: var(--ink-dim); } | 80 | & .m { width: 2ch; text-align: center; user-select: none; color: var(--ink-dim); } | ||
| 81 | & .tx { white-space: pre; } | 81 | & .tx { white-space: pre; } | ||
| 82 | - | & .hunk td { color: var(--ink-mute); background: var(--paper-alt); padding: 0 1ch; } | 82 | + | /* the hunk headers and the hidden lines stay quiet: a faint band between |
| 83 | + | the code, so that the eye keeps reading */ | |||
| 84 | + | & .hunk td, & .gap td { background: color-mix(in srgb, var(--paper-alt) 35%, var(--paper)); } | |||
| 85 | + | & .hunk td { color: var(--ink-mute); padding: 0 1ch; } | |||
| 83 | /* a run of hidden lines: the whole row loads them */ | 86 | /* a run of hidden lines: the whole row loads them */ | ||
| 84 | - | & .gap td { padding: 0; background: var(--paper-alt); } | 87 | + | & .gap td { padding: 0; } |
| 85 | & .gap button { | 88 | & .gap button { | ||
| 86 | display: block; width: 100%; padding: 2px 1ch; text-align: left; cursor: pointer; | 89 | display: block; width: 100%; padding: 2px 1ch; text-align: left; cursor: pointer; | ||
| 87 | - | font: inherit; color: var(--ink-dim); background: none; border: 0; | 90 | + | font: inherit; color: var(--ink-mute); background: none; border: 0; |
| 88 | - | border-block: 1px dashed var(--border); | 91 | + | border-block: 1px dashed color-mix(in srgb, var(--border) 60%, transparent); |
| 89 | } | 92 | } | ||
| 90 | & .gap button:hover, & .gap button:focus-visible { color: var(--ink-hi); background: var(--bg-strong); } | 93 | & .gap button:hover, & .gap button:focus-visible { color: var(--ink-hi); background: var(--bg-strong); } | ||
| 91 | & .gap button:disabled { cursor: progress; opacity: .6; } | 94 | & .gap button:disabled { cursor: progress; opacity: .6; } | ||
M internal/adapters/web/static/css/55-diff.css +25 -0
| @@ -9,4 +9,29 @@ | |||
| 9 | 9 | ||
| 10 | 10 | @layer components { | |
| 11 | 11 | .sel { outline: 1px solid var(--ink-dim); outline-offset: -1px; } | |
| 12 | + | ||
| 13 | + | /* a range of selected diff lines (shift + click) is one block: one border | |
| 14 | + | around the run, none between its lines. Each edge is an inset shadow of | |
| 15 | + | the cells; the top one only on the first line of the run, the bottom one | |
| 16 | + | only on the last. In split, each side is a block of its own. */ | |
| 17 | + | .dt { | |
| 18 | + | --sel-edge: var(--ink-dim); | |
| 19 | + | & .sel { outline: none; } | |
| 20 | + | & tr.sel > td, & td.sel { | |
| 21 | + | box-shadow: var(--sel-l, 0 0 transparent), var(--sel-r, 0 0 transparent), var(--sel-t, 0 0 transparent), var(--sel-b, 0 0 transparent); | |
| 22 | + | --sel-t: inset 0 1px var(--sel-edge); --sel-b: inset 0 -1px var(--sel-edge); | |
| 23 | + | } | |
| 24 | + | /* unified: the row is selected */ | |
| 25 | + | & tr.sel > td:first-child { --sel-l: inset 1px 0 var(--sel-edge); } | |
| 26 | + | & tr.sel > td:last-child { --sel-r: inset -1px 0 var(--sel-edge); } | |
| 27 | + | & tr.sel + tr.sel > td { --sel-t: 0 0 transparent; } | |
| 28 | + | & tr.sel:has(+ tr.sel) > td { --sel-b: 0 0 transparent; } | |
| 29 | + | /* split: the cells of one side (number, mark, text) are selected */ | |
| 30 | + | & td.sel.ln { --sel-l: inset 1px 0 var(--sel-edge); } | |
| 31 | + | & td.sel.tx { --sel-r: inset -1px 0 var(--sel-edge); } | |
| 32 | + | & tr:has(> :nth-child(3).sel) + tr > :nth-child(-n + 3).sel, | |
| 33 | + | & tr:has(> :nth-child(6).sel) + tr > :nth-child(n + 4).sel { --sel-t: 0 0 transparent; } | |
| 34 | + | & tr:has(> :nth-child(3).sel):has(+ tr > :nth-child(3).sel) > :nth-child(-n + 3).sel, | |
| 35 | + | & tr:has(> :nth-child(6).sel):has(+ tr > :nth-child(6).sel) > :nth-child(n + 4).sel { --sel-b: 0 0 transparent; } | |
| 36 | + | } | |
| 12 | 37 | } | |
| @@ -9,4 +9,29 @@ | |||||
| 9 | 9 | ||||
| 10 | @layer components { | 10 | @layer components { | ||
| 11 | .sel { outline: 1px solid var(--ink-dim); outline-offset: -1px; } | 11 | .sel { outline: 1px solid var(--ink-dim); outline-offset: -1px; } | ||
| 12 | + | ||||
| 13 | + | /* a range of selected diff lines (shift + click) is one block: one border | |||
| 14 | + | around the run, none between its lines. Each edge is an inset shadow of | |||
| 15 | + | the cells; the top one only on the first line of the run, the bottom one | |||
| 16 | + | only on the last. In split, each side is a block of its own. */ | |||
| 17 | + | .dt { | |||
| 18 | + | --sel-edge: var(--ink-dim); | |||
| 19 | + | & .sel { outline: none; } | |||
| 20 | + | & tr.sel > td, & td.sel { | |||
| 21 | + | box-shadow: var(--sel-l, 0 0 transparent), var(--sel-r, 0 0 transparent), var(--sel-t, 0 0 transparent), var(--sel-b, 0 0 transparent); | |||
| 22 | + | --sel-t: inset 0 1px var(--sel-edge); --sel-b: inset 0 -1px var(--sel-edge); | |||
| 23 | + | } | |||
| 24 | + | /* unified: the row is selected */ | |||
| 25 | + | & tr.sel > td:first-child { --sel-l: inset 1px 0 var(--sel-edge); } | |||
| 26 | + | & tr.sel > td:last-child { --sel-r: inset -1px 0 var(--sel-edge); } | |||
| 27 | + | & tr.sel + tr.sel > td { --sel-t: 0 0 transparent; } | |||
| 28 | + | & tr.sel:has(+ tr.sel) > td { --sel-b: 0 0 transparent; } | |||
| 29 | + | /* split: the cells of one side (number, mark, text) are selected */ | |||
| 30 | + | & td.sel.ln { --sel-l: inset 1px 0 var(--sel-edge); } | |||
| 31 | + | & td.sel.tx { --sel-r: inset -1px 0 var(--sel-edge); } | |||
| 32 | + | & tr:has(> :nth-child(3).sel) + tr > :nth-child(-n + 3).sel, | |||
| 33 | + | & tr:has(> :nth-child(6).sel) + tr > :nth-child(n + 4).sel { --sel-t: 0 0 transparent; } | |||
| 34 | + | & tr:has(> :nth-child(3).sel):has(+ tr > :nth-child(3).sel) > :nth-child(-n + 3).sel, | |||
| 35 | + | & tr:has(> :nth-child(6).sel):has(+ tr > :nth-child(6).sel) > :nth-child(n + 4).sel { --sel-b: 0 0 transparent; } | |||
| 36 | + | } | |||
| 12 | } | 37 | } | ||
M internal/adapters/web/static/css/60-mr.css +15 -1
| @@ -1,6 +1,12 @@ | |||
| 1 | 1 | @layer components { | |
| 2 | 2 | /* merge requests: the review page. Hue only where it carries a verdict. */ | |
| 3 | 3 | .mr-h .show-kv { margin-top: 0; } | |
| 4 | + | /* one card: the header (title, facts) a shade lighter, the description below */ | |
| 5 | + | .mr-card { | |
| 6 | + | background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; | |
| 7 | + | & > .mr-h { background: var(--bg-raised); border: 0; border-radius: 0; padding-bottom: var(--s3); } | |
| 8 | + | & > .mr-desc.prose { border: 0; border-top: 1px solid var(--border); border-radius: 0; } | |
| 9 | + | } | |
| 4 | 10 | ||
| 5 | 11 | .mr-state[data-state="open"]::before { content: "● "; color: var(--teal); } | |
| 6 | 12 | .mr-state[data-state="merged"]::before { content: "✓ "; color: var(--teal); } | |
| @@ -49,6 +55,15 @@ | |||
| 49 | 55 | } | |
| 50 | 56 | & .kv { margin: 0; } | |
| 51 | 57 | } | |
| 58 | + | /* the view menu of a diff: the layout and the fold, one row each */ | |
| 59 | + | .vmenu { | |
| 60 | + | & > summary::after { content: ""; width: 0; height: 0; margin-left: 2px; border: 4px solid transparent; border-top-color: currentColor; transform: translateY(2px); } | |
| 61 | + | & .menu-pop { left: 0; right: auto; width: 220px; gap: var(--s1); padding: var(--s2); } | |
| 62 | + | & .menu-pop .btn { width: 100%; justify-content: flex-start; border-color: transparent; background: none; } | |
| 63 | + | & .menu-pop .btn:hover { background: var(--bg-raised); } | |
| 64 | + | & .menu-pop .btn[aria-current="true"] { background: var(--bg-raised); color: var(--ink-hi); } | |
| 65 | + | & .menu-pop .btn kbd { margin-left: auto; } | |
| 66 | + | } | |
| 52 | 67 | .mmenu { | |
| 53 | 68 | & .d-modes { display: grid; gap: var(--s1); border: 0; padding: 0; margin: 0; } | |
| 54 | 69 | & .d-modes label, & .d-check { | |
| @@ -207,7 +222,6 @@ | |||
| 207 | 222 | & .inbox-counts strong { color: var(--ink-hi); font-family: var(--mono); } | |
| 208 | 223 | & .since { align-self: flex-start; max-width: 100%; } | |
| 209 | 224 | } | |
| 210 | - | .inbox-btn { gap: var(--s2); } | |
| 211 | 225 | ||
| 212 | 226 | @media (max-width: 700px) { | |
| 213 | 227 | .list.mrs .c-who { display: none; } | |
| @@ -1,6 +1,12 @@ | |||||
| 1 | @layer components { | 1 | @layer components { | ||
| 2 | /* merge requests: the review page. Hue only where it carries a verdict. */ | 2 | /* merge requests: the review page. Hue only where it carries a verdict. */ | ||
| 3 | .mr-h .show-kv { margin-top: 0; } | 3 | .mr-h .show-kv { margin-top: 0; } | ||
| 4 | + | /* one card: the header (title, facts) a shade lighter, the description below */ | |||
| 5 | + | .mr-card { | |||
| 6 | + | background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; | |||
| 7 | + | & > .mr-h { background: var(--bg-raised); border: 0; border-radius: 0; padding-bottom: var(--s3); } | |||
| 8 | + | & > .mr-desc.prose { border: 0; border-top: 1px solid var(--border); border-radius: 0; } | |||
| 9 | + | } | |||
| 4 | 10 | ||||
| 5 | .mr-state[data-state="open"]::before { content: "● "; color: var(--teal); } | 11 | .mr-state[data-state="open"]::before { content: "● "; color: var(--teal); } | ||
| 6 | .mr-state[data-state="merged"]::before { content: "✓ "; color: var(--teal); } | 12 | .mr-state[data-state="merged"]::before { content: "✓ "; color: var(--teal); } | ||
| @@ -49,6 +55,15 @@ | |||||
| 49 | } | 55 | } | ||
| 50 | & .kv { margin: 0; } | 56 | & .kv { margin: 0; } | ||
| 51 | } | 57 | } | ||
| 58 | + | /* the view menu of a diff: the layout and the fold, one row each */ | |||
| 59 | + | .vmenu { | |||
| 60 | + | & > summary::after { content: ""; width: 0; height: 0; margin-left: 2px; border: 4px solid transparent; border-top-color: currentColor; transform: translateY(2px); } | |||
| 61 | + | & .menu-pop { left: 0; right: auto; width: 220px; gap: var(--s1); padding: var(--s2); } | |||
| 62 | + | & .menu-pop .btn { width: 100%; justify-content: flex-start; border-color: transparent; background: none; } | |||
| 63 | + | & .menu-pop .btn:hover { background: var(--bg-raised); } | |||
| 64 | + | & .menu-pop .btn[aria-current="true"] { background: var(--bg-raised); color: var(--ink-hi); } | |||
| 65 | + | & .menu-pop .btn kbd { margin-left: auto; } | |||
| 66 | + | } | |||
| 52 | .mmenu { | 67 | .mmenu { | ||
| 53 | & .d-modes { display: grid; gap: var(--s1); border: 0; padding: 0; margin: 0; } | 68 | & .d-modes { display: grid; gap: var(--s1); border: 0; padding: 0; margin: 0; } | ||
| 54 | & .d-modes label, & .d-check { | 69 | & .d-modes label, & .d-check { | ||
| @@ -207,7 +222,6 @@ | |||||
| 207 | & .inbox-counts strong { color: var(--ink-hi); font-family: var(--mono); } | 222 | & .inbox-counts strong { color: var(--ink-hi); font-family: var(--mono); } | ||
| 208 | & .since { align-self: flex-start; max-width: 100%; } | 223 | & .since { align-self: flex-start; max-width: 100%; } | ||
| 209 | } | 224 | } | ||
| 210 | - | .inbox-btn { gap: var(--s2); } | |||
| 211 | 225 | ||||
| 212 | @media (max-width: 700px) { | 226 | @media (max-width: 700px) { | ||
| 213 | .list.mrs .c-who { display: none; } | 227 | .list.mrs .c-who { display: none; } | ||
A internal/adapters/web/static/icons/mailbox.svg +1 -0
| @@ -0,0 +1,1 @@ | |||
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 17a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9.5C2 7 4 5 6.5 5H18c2.2 0 4 1.8 4 4v8Z"/><polyline points="15,9 18,9 18,11"/><path d="M6.5 5C9 5 11 7 11 9.5V17a2 2 0 0 1-2 2"/><line x1="6" x2="7" y1="10" y2="10"/></svg> | |
| @@ -0,0 +1,1 @@ | |||||
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 17a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9.5C2 7 4 5 6.5 5H18c2.2 0 4 1.8 4 4v8Z"/><polyline points="15,9 18,9 18,11"/><path d="M6.5 5C9 5 11 7 11 9.5V17a2 2 0 0 1-2 2"/><line x1="6" x2="7" y1="10" y2="10"/></svg> | |||
M internal/adapters/web/static/js/ermite.js +70 -3
| @@ -14,6 +14,7 @@ | |||
| 14 | 14 | // 7. The service worker (ermiteInstall). | |
| 15 | 15 | // 8. Chip lists kept on one line, with a … chip (ermiteFit). | |
| 16 | 16 | // 9. The layout of the home page, remembered in the browser (ermiteLayout). | |
| 17 | + | // 10. The wide mode: every page takes the whole width (ermiteWide). | |
| 17 | 18 | // | |
| 18 | 19 | // Preferences are kept by ermiteStore (store.js, loaded first). | |
| 19 | 20 | (() => { | |
| @@ -400,6 +401,25 @@ | |||
| 400 | 401 | // file whose lines are all loaded has no gap left. | |
| 401 | 402 | window.ermiteGaps = (() => { | |
| 402 | 403 | const pending = new Map(); | |
| 404 | + | // selectLoaded marks the loaded rows that fall in the range of lines the | |
| 405 | + | // URL selects (?on=new:21-149:a.go, a shift + click), as the server marks | |
| 406 | + | // the rows it sent: the range stays one block once its hidden lines show. | |
| 407 | + | // The loaded rows are context lines, the same on both sides. | |
| 408 | + | function selectLoaded(file, rows) { | |
| 409 | + | const m = /^(new|old):(?:(\d+)-)?(\d+):(.+)$/.exec(new URLSearchParams(location.search).get('on') || ''); | |
| 410 | + | // data-file is the path comments name (the new path of a rename), as ?on= | |
| 411 | + | if (!m || file.dataset.file !== m[4]) return; | |
| 412 | + | const end = Number(m[3]); | |
| 413 | + | const start = m[2] ? Number(m[2]) : end; | |
| 414 | + | for (const r of rows) { | |
| 415 | + | const cells = [...r.querySelectorAll('td')]; | |
| 416 | + | // unified: old, new, mark, text; split: old side, then new side | |
| 417 | + | const num = Number((cells.length === 4 ? cells[m[1] === 'old' ? 0 : 1] : cells[m[1] === 'old' ? 0 : 3])?.textContent.trim()); | |
| 418 | + | if (!(num >= start && num <= end)) continue; | |
| 419 | + | if (cells.length === 4) r.classList.add('sel'); | |
| 420 | + | else for (const c of cells) c.classList.add('sel'); | |
| 421 | + | } | |
| 422 | + | } | |
| 403 | 423 | // load fills the gap the button is in, in both tables of its file. | |
| 404 | 424 | function load(btn) { | |
| 405 | 425 | const src = btn.dataset.src; | |
| @@ -424,6 +444,7 @@ | |||
| 424 | 444 | if (!from || !at) continue; | |
| 425 | 445 | const rows = [...from.children].map((r) => r.cloneNode(true)); | |
| 426 | 446 | n = Math.max(n, rows.filter((r) => !r.classList.contains('gap')).length); | |
| 447 | + | selectLoaded(file, rows.filter((r) => !r.classList.contains('gap'))); | |
| 427 | 448 | at.replaceWith(...rows); | |
| 428 | 449 | } | |
| 429 | 450 | // The focus was on the button, which is gone: on the gap that is | |
| @@ -553,9 +574,44 @@ | |||
| 553 | 574 | return { get: pref.get, set, apply, values }; | |
| 554 | 575 | })(); | |
| 555 | 576 | ||
| 577 | + | // The wide mode (w, on every page): the work area takes the whole width of | |
| 578 | + | // the screen, the rail at the right edge and the content in the rest. | |
| 579 | + | // The choice is kept by ermiteStore ("wide": '1' when wide) and shown | |
| 580 | + | // as html.wide, which the styles follow. Another tab's change applies here | |
| 581 | + | // at once. | |
| 582 | + | window.ermiteWide = (() => { | |
| 583 | + | const pref = window.ermiteStore.pref('wide', { values: ['1', ''], fallback: '' }); | |
| 584 | + | function apply() { | |
| 585 | + | const wide = pref.get() === '1'; | |
| 586 | + | document.documentElement.classList.toggle?.('wide', wide); | |
| 587 | + | } | |
| 588 | + | function toggle() { | |
| 589 | + | pref.set(pref.get() === '1' ? '' : '1'); | |
| 590 | + | apply(); | |
| 591 | + | } | |
| 592 | + | pref.watch(apply); | |
| 593 | + | apply(); | |
| 594 | + | return { toggle, apply }; | |
| 595 | + | })(); | |
| 596 | + | ||
| 597 | + | // The menus of the bars (<details class="menu">: the diff view, the merge | |
| 598 | + | // menu): closing one hands the focus back to its button when it was inside, | |
| 599 | + | // so that a keyboard user keeps their place. | |
| 600 | + | function closeMenu(d) { | |
| 601 | + | const had = d.contains(document.activeElement); | |
| 602 | + | d.open = false; | |
| 603 | + | if (had) $('summary', d)?.focus(); | |
| 604 | + | } | |
| 605 | + | // closeMenus closes every open menu (escape); true when one was open. | |
| 606 | + | function closeMenus() { | |
| 607 | + | const open = $$('details.menu[open]'); | |
| 608 | + | for (const d of open) closeMenu(d); | |
| 609 | + | return open.length > 0; | |
| 610 | + | } | |
| 611 | + | ||
| 556 | 612 | // 1. Keys. | |
| 557 | 613 | const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink', | |
| 558 | - | s: 's', t: 't', c: 'c', r: 'r', m: 'm', i: 'i', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc', | |
| 614 | + | s: 's', t: 't', c: 'c', r: 'r', m: 'm', i: 'i', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', w: 'wide', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc', | |
| 559 | 615 | // the merge request page (mr.js): the merge menu, mark read | |
| 560 | 616 | M: 'merge', R: 'read' }; | |
| 561 | 617 | // "g t" is the key "t": the tabs have one event each. | |
| @@ -571,7 +627,7 @@ | |||
| 571 | 627 | const mr = window.ermiteMR; | |
| 572 | 628 | if (e.key === 'Escape') { | |
| 573 | 629 | pending = 0; | |
| 574 | - | if (mr?.closeMenus() || mr?.leaveFiles()) return; | |
| 630 | + | if (closeMenus() || mr?.leaveFiles()) return; | |
| 575 | 631 | if (typing) e.target.blur(); | |
| 576 | 632 | return ermite.fire('esc'); | |
| 577 | 633 | } | |
| @@ -611,6 +667,13 @@ | |||
| 611 | 667 | if (!el || e.button || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return; | |
| 612 | 668 | e.preventDefault(); | |
| 613 | 669 | ermite.fire(el.dataset.fire); | |
| 670 | + | // A choice in a menu closes it; the focus goes back to its button. | |
| 671 | + | const menu = el.closest('details.menu'); | |
| 672 | + | if (menu) closeMenu(menu); | |
| 673 | + | }); | |
| 674 | + | // A click outside an open menu closes it. | |
| 675 | + | addEventListener('click', (e) => { | |
| 676 | + | for (const d of $$('details.menu[open]')) if (!d.contains(e.target)) d.open = false; | |
| 614 | 677 | }); | |
| 615 | 678 | for (const a of $$('a[data-fire="refs"]')) { | |
| 616 | 679 | a.setAttribute('aria-haspopup', 'dialog'); | |
| @@ -773,7 +836,8 @@ | |||
| 773 | 836 | // internal/adapters/web/events.go). A fragment is a top-level element with an | |
| 774 | 837 | // id: it replaces the element of the page that has the same id. A fragment | |
| 775 | 838 | // marked data-gone removes it; a row of a repository the home page does not | |
| 776 | - | // list yet is added at the top of the list. A slot (data-slot: the place of a | |
| 839 | + | // list yet is added at the top of the list. Activity updates also move existing | |
| 840 | + | // home rows to the top when sorted by newest activity; CI updates stay put. A slot (data-slot: the place of a | |
| 777 | 841 | // banner, a persistent status region) keeps its element and only gets the | |
| 778 | 842 | // children of the fragment, so that screen readers announce the change. A gap, | |
| 779 | 843 | // which says the page may have missed events or that what it shows changed, | |
| @@ -872,6 +936,9 @@ window.ermiteLive = (() => { | |||
| 872 | 936 | } | |
| 873 | 937 | if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', ''); | |
| 874 | 938 | old.replaceWith(el); | |
| 939 | + | const rows = document.getElementById('repo-rows'); | |
| 940 | + | if (rows && el.parentElement === rows && rows.dataset.sort === 'activity' && | |
| 941 | + | rows.dataset.desc === 'true' && el.hasAttribute('data-activity')) rows.prepend(el); | |
| 875 | 942 | window.htmx?.process(el); | |
| 876 | 943 | fitIn(el); | |
| 877 | 944 | if (had) refocus(el, had); | |
| @@ -14,6 +14,7 @@ | |||||
| 14 | // 7. The service worker (ermiteInstall). | 14 | // 7. The service worker (ermiteInstall). | ||
| 15 | // 8. Chip lists kept on one line, with a … chip (ermiteFit). | 15 | // 8. Chip lists kept on one line, with a … chip (ermiteFit). | ||
| 16 | // 9. The layout of the home page, remembered in the browser (ermiteLayout). | 16 | // 9. The layout of the home page, remembered in the browser (ermiteLayout). | ||
| 17 | + | // 10. The wide mode: every page takes the whole width (ermiteWide). | |||
| 17 | // | 18 | // | ||
| 18 | // Preferences are kept by ermiteStore (store.js, loaded first). | 19 | // Preferences are kept by ermiteStore (store.js, loaded first). | ||
| 19 | (() => { | 20 | (() => { | ||
| @@ -400,6 +401,25 @@ | |||||
| 400 | // file whose lines are all loaded has no gap left. | 401 | // file whose lines are all loaded has no gap left. | ||
| 401 | window.ermiteGaps = (() => { | 402 | window.ermiteGaps = (() => { | ||
| 402 | const pending = new Map(); | 403 | const pending = new Map(); | ||
| 404 | + | // selectLoaded marks the loaded rows that fall in the range of lines the | |||
| 405 | + | // URL selects (?on=new:21-149:a.go, a shift + click), as the server marks | |||
| 406 | + | // the rows it sent: the range stays one block once its hidden lines show. | |||
| 407 | + | // The loaded rows are context lines, the same on both sides. | |||
| 408 | + | function selectLoaded(file, rows) { | |||
| 409 | + | const m = /^(new|old):(?:(\d+)-)?(\d+):(.+)$/.exec(new URLSearchParams(location.search).get('on') || ''); | |||
| 410 | + | // data-file is the path comments name (the new path of a rename), as ?on= | |||
| 411 | + | if (!m || file.dataset.file !== m[4]) return; | |||
| 412 | + | const end = Number(m[3]); | |||
| 413 | + | const start = m[2] ? Number(m[2]) : end; | |||
| 414 | + | for (const r of rows) { | |||
| 415 | + | const cells = [...r.querySelectorAll('td')]; | |||
| 416 | + | // unified: old, new, mark, text; split: old side, then new side | |||
| 417 | + | const num = Number((cells.length === 4 ? cells[m[1] === 'old' ? 0 : 1] : cells[m[1] === 'old' ? 0 : 3])?.textContent.trim()); | |||
| 418 | + | if (!(num >= start && num <= end)) continue; | |||
| 419 | + | if (cells.length === 4) r.classList.add('sel'); | |||
| 420 | + | else for (const c of cells) c.classList.add('sel'); | |||
| 421 | + | } | |||
| 422 | + | } | |||
| 403 | // load fills the gap the button is in, in both tables of its file. | 423 | // load fills the gap the button is in, in both tables of its file. | ||
| 404 | function load(btn) { | 424 | function load(btn) { | ||
| 405 | const src = btn.dataset.src; | 425 | const src = btn.dataset.src; | ||
| @@ -424,6 +444,7 @@ | |||||
| 424 | if (!from || !at) continue; | 444 | if (!from || !at) continue; | ||
| 425 | const rows = [...from.children].map((r) => r.cloneNode(true)); | 445 | const rows = [...from.children].map((r) => r.cloneNode(true)); | ||
| 426 | n = Math.max(n, rows.filter((r) => !r.classList.contains('gap')).length); | 446 | n = Math.max(n, rows.filter((r) => !r.classList.contains('gap')).length); | ||
| 447 | + | selectLoaded(file, rows.filter((r) => !r.classList.contains('gap'))); | |||
| 427 | at.replaceWith(...rows); | 448 | at.replaceWith(...rows); | ||
| 428 | } | 449 | } | ||
| 429 | // The focus was on the button, which is gone: on the gap that is | 450 | // The focus was on the button, which is gone: on the gap that is | ||
| @@ -553,9 +574,44 @@ | |||||
| 553 | return { get: pref.get, set, apply, values }; | 574 | return { get: pref.get, set, apply, values }; | ||
| 554 | })(); | 575 | })(); | ||
| 555 | 576 | ||||
| 577 | + | // The wide mode (w, on every page): the work area takes the whole width of | |||
| 578 | + | // the screen, the rail at the right edge and the content in the rest. | |||
| 579 | + | // The choice is kept by ermiteStore ("wide": '1' when wide) and shown | |||
| 580 | + | // as html.wide, which the styles follow. Another tab's change applies here | |||
| 581 | + | // at once. | |||
| 582 | + | window.ermiteWide = (() => { | |||
| 583 | + | const pref = window.ermiteStore.pref('wide', { values: ['1', ''], fallback: '' }); | |||
| 584 | + | function apply() { | |||
| 585 | + | const wide = pref.get() === '1'; | |||
| 586 | + | document.documentElement.classList.toggle?.('wide', wide); | |||
| 587 | + | } | |||
| 588 | + | function toggle() { | |||
| 589 | + | pref.set(pref.get() === '1' ? '' : '1'); | |||
| 590 | + | apply(); | |||
| 591 | + | } | |||
| 592 | + | pref.watch(apply); | |||
| 593 | + | apply(); | |||
| 594 | + | return { toggle, apply }; | |||
| 595 | + | })(); | |||
| 596 | + | ||||
| 597 | + | // The menus of the bars (<details class="menu">: the diff view, the merge | |||
| 598 | + | // menu): closing one hands the focus back to its button when it was inside, | |||
| 599 | + | // so that a keyboard user keeps their place. | |||
| 600 | + | function closeMenu(d) { | |||
| 601 | + | const had = d.contains(document.activeElement); | |||
| 602 | + | d.open = false; | |||
| 603 | + | if (had) $('summary', d)?.focus(); | |||
| 604 | + | } | |||
| 605 | + | // closeMenus closes every open menu (escape); true when one was open. | |||
| 606 | + | function closeMenus() { | |||
| 607 | + | const open = $$('details.menu[open]'); | |||
| 608 | + | for (const d of open) closeMenu(d); | |||
| 609 | + | return open.length > 0; | |||
| 610 | + | } | |||
| 611 | + | ||||
| 556 | // 1. Keys. | 612 | // 1. Keys. | ||
| 557 | const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink', | 613 | const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink', | ||
| 558 | - | s: 's', t: 't', c: 'c', r: 'r', m: 'm', i: 'i', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc', | 614 | + | s: 's', t: 't', c: 'c', r: 'r', m: 'm', i: 'i', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', w: 'wide', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc', |
| 559 | // the merge request page (mr.js): the merge menu, mark read | 615 | // the merge request page (mr.js): the merge menu, mark read | ||
| 560 | M: 'merge', R: 'read' }; | 616 | M: 'merge', R: 'read' }; | ||
| 561 | // "g t" is the key "t": the tabs have one event each. | 617 | // "g t" is the key "t": the tabs have one event each. | ||
| @@ -571,7 +627,7 @@ | |||||
| 571 | const mr = window.ermiteMR; | 627 | const mr = window.ermiteMR; | ||
| 572 | if (e.key === 'Escape') { | 628 | if (e.key === 'Escape') { | ||
| 573 | pending = 0; | 629 | pending = 0; | ||
| 574 | - | if (mr?.closeMenus() || mr?.leaveFiles()) return; | 630 | + | if (closeMenus() || mr?.leaveFiles()) return; |
| 575 | if (typing) e.target.blur(); | 631 | if (typing) e.target.blur(); | ||
| 576 | return ermite.fire('esc'); | 632 | return ermite.fire('esc'); | ||
| 577 | } | 633 | } | ||
| @@ -611,6 +667,13 @@ | |||||
| 611 | if (!el || e.button || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return; | 667 | if (!el || e.button || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return; | ||
| 612 | e.preventDefault(); | 668 | e.preventDefault(); | ||
| 613 | ermite.fire(el.dataset.fire); | 669 | ermite.fire(el.dataset.fire); | ||
| 670 | + | // A choice in a menu closes it; the focus goes back to its button. | |||
| 671 | + | const menu = el.closest('details.menu'); | |||
| 672 | + | if (menu) closeMenu(menu); | |||
| 673 | + | }); | |||
| 674 | + | // A click outside an open menu closes it. | |||
| 675 | + | addEventListener('click', (e) => { | |||
| 676 | + | for (const d of $$('details.menu[open]')) if (!d.contains(e.target)) d.open = false; | |||
| 614 | }); | 677 | }); | ||
| 615 | for (const a of $$('a[data-fire="refs"]')) { | 678 | for (const a of $$('a[data-fire="refs"]')) { | ||
| 616 | a.setAttribute('aria-haspopup', 'dialog'); | 679 | a.setAttribute('aria-haspopup', 'dialog'); | ||
| @@ -773,7 +836,8 @@ | |||||
| 773 | // internal/adapters/web/events.go). A fragment is a top-level element with an | 836 | // internal/adapters/web/events.go). A fragment is a top-level element with an | ||
| 774 | // id: it replaces the element of the page that has the same id. A fragment | 837 | // id: it replaces the element of the page that has the same id. A fragment | ||
| 775 | // marked data-gone removes it; a row of a repository the home page does not | 838 | // marked data-gone removes it; a row of a repository the home page does not | ||
| 776 | - | // list yet is added at the top of the list. A slot (data-slot: the place of a | 839 | + | // list yet is added at the top of the list. Activity updates also move existing |
| 840 | + | // home rows to the top when sorted by newest activity; CI updates stay put. A slot (data-slot: the place of a | |||
| 777 | // banner, a persistent status region) keeps its element and only gets the | 841 | // banner, a persistent status region) keeps its element and only gets the | ||
| 778 | // children of the fragment, so that screen readers announce the change. A gap, | 842 | // children of the fragment, so that screen readers announce the change. A gap, | ||
| 779 | // which says the page may have missed events or that what it shows changed, | 843 | // which says the page may have missed events or that what it shows changed, | ||
| @@ -872,6 +936,9 @@ window.ermiteLive = (() => { | |||||
| 872 | } | 936 | } | ||
| 873 | if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', ''); | 937 | if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', ''); | ||
| 874 | old.replaceWith(el); | 938 | old.replaceWith(el); | ||
| 939 | + | const rows = document.getElementById('repo-rows'); | |||
| 940 | + | if (rows && el.parentElement === rows && rows.dataset.sort === 'activity' && | |||
| 941 | + | rows.dataset.desc === 'true' && el.hasAttribute('data-activity')) rows.prepend(el); | |||
| 875 | window.htmx?.process(el); | 942 | window.htmx?.process(el); | ||
| 876 | fitIn(el); | 943 | fitIn(el); | ||
| 877 | if (had) refocus(el, had); | 944 | if (had) refocus(el, had); | ||
M internal/adapters/web/static/js/mr.js +1 -11
| @@ -196,12 +196,6 @@ window.ermiteMR = (() => { | |||
| 196 | 196 | $('[data-default]', m)?.focus(); | |
| 197 | 197 | return true; | |
| 198 | 198 | } | |
| 199 | - | function closeMenus() { | |
| 200 | - | const open = $$('details.menu[open]'); | |
| 201 | - | for (const d of open) d.open = false; | |
| 202 | - | if (open.length) $('summary', open[0])?.focus(); | |
| 203 | - | return open.length > 0; | |
| 204 | - | } | |
| 205 | 199 | // menuKey handles a key while the merge menu is open; true when it used it. | |
| 206 | 200 | function menuKey(e) { | |
| 207 | 201 | const m = menu(); | |
| @@ -311,13 +305,9 @@ window.ermiteMR = (() => { | |||
| 311 | 305 | try { sessionStorage.setItem('mr-last-line', lastLine); } catch { /* storage optional */ } | |
| 312 | 306 | if (next) { e.preventDefault(); location.assign(next); } | |
| 313 | 307 | }); | |
| 314 | - | // A click outside an open menu closes it. | |
| 315 | - | addEventListener('click', (e) => { | |
| 316 | - | for (const d of $$('details.menu[open]')) if (!d.contains(e.target)) d.open = false; | |
| 317 | - | }); | |
| 318 | 308 | } | |
| 319 | 309 | ||
| 320 | - | return { core, start, load, reveal, openMerge, closeMenus, menuKey, markRead, focusFiles, stepFiles, leaveFiles, foldFiles, openFile }; | |
| 310 | + | return { core, start, load, reveal, openMerge, menuKey, markRead, focusFiles, stepFiles, leaveFiles, foldFiles, openFile }; | |
| 321 | 311 | })(); | |
| 322 | 312 | ||
| 323 | 313 | if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteMR.start()); | |
| @@ -196,12 +196,6 @@ window.ermiteMR = (() => { | |||||
| 196 | $('[data-default]', m)?.focus(); | 196 | $('[data-default]', m)?.focus(); | ||
| 197 | return true; | 197 | return true; | ||
| 198 | } | 198 | } | ||
| 199 | - | function closeMenus() { | |||
| 200 | - | const open = $$('details.menu[open]'); | |||
| 201 | - | for (const d of open) d.open = false; | |||
| 202 | - | if (open.length) $('summary', open[0])?.focus(); | |||
| 203 | - | return open.length > 0; | |||
| 204 | - | } | |||
| 205 | // menuKey handles a key while the merge menu is open; true when it used it. | 199 | // menuKey handles a key while the merge menu is open; true when it used it. | ||
| 206 | function menuKey(e) { | 200 | function menuKey(e) { | ||
| 207 | const m = menu(); | 201 | const m = menu(); | ||
| @@ -311,13 +305,9 @@ window.ermiteMR = (() => { | |||||
| 311 | try { sessionStorage.setItem('mr-last-line', lastLine); } catch { /* storage optional */ } | 305 | try { sessionStorage.setItem('mr-last-line', lastLine); } catch { /* storage optional */ } | ||
| 312 | if (next) { e.preventDefault(); location.assign(next); } | 306 | if (next) { e.preventDefault(); location.assign(next); } | ||
| 313 | }); | 307 | }); | ||
| 314 | - | // A click outside an open menu closes it. | |||
| 315 | - | addEventListener('click', (e) => { | |||
| 316 | - | for (const d of $$('details.menu[open]')) if (!d.contains(e.target)) d.open = false; | |||
| 317 | - | }); | |||
| 318 | } | 308 | } | ||
| 319 | 309 | ||||
| 320 | - | return { core, start, load, reveal, openMerge, closeMenus, menuKey, markRead, focusFiles, stepFiles, leaveFiles, foldFiles, openFile }; | 310 | + | return { core, start, load, reveal, openMerge, menuKey, markRead, focusFiles, stepFiles, leaveFiles, foldFiles, openFile }; |
| 321 | })(); | 311 | })(); | ||
| 322 | 312 | ||||
| 323 | if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteMR.start()); | 313 | if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteMR.start()); | ||
M internal/adapters/web/static/js/store.js +5 -0
| @@ -74,3 +74,8 @@ window.ermiteStore = (() => { | |||
| 74 | 74 | ||
| 75 | 75 | return { get, set, remove, watch, pref }; | |
| 76 | 76 | })(); | |
| 77 | + | ||
| 78 | + | // The wide mode (ermiteWide in ermite.js) is set here, before the first paint: | |
| 79 | + | // shell.templ loads this file without defer, so a wide page never draws narrow | |
| 80 | + | // first. | |
| 81 | + | if (window.ermiteStore.get('wide') === '1') document?.documentElement?.classList?.add('wide'); | |
| @@ -74,3 +74,8 @@ window.ermiteStore = (() => { | |||||
| 74 | 74 | ||||
| 75 | return { get, set, remove, watch, pref }; | 75 | return { get, set, remove, watch, pref }; | ||
| 76 | })(); | 76 | })(); | ||
| 77 | + | ||||
| 78 | + | // The wide mode (ermiteWide in ermite.js) is set here, before the first paint: | |||
| 79 | + | // shell.templ loads this file without defer, so a wide page never draws narrow | |||
| 80 | + | // first. | |||
| 81 | + | if (window.ermiteStore.get('wide') === '1') document?.documentElement?.classList?.add('wide'); | |||
M internal/adapters/web/view.go +6 -4
| @@ -71,6 +71,9 @@ type Layout struct { | |||
| 71 | 71 | MR bool | |
| 72 | 72 | // IssueForm loads the script of the public issue form (issue-form.js). | |
| 73 | 73 | IssueForm bool | |
| 74 | + | // Inbox is the button to the owner's inbox, next to the logo; set on the | |
| 75 | + | // home page only, for the signed-in owner. | |
| 76 | + | Inbox *InboxButton | |
| 74 | 77 | } | |
| 75 | 78 | ||
| 76 | 79 | // Link is a plain label + href pair. | |
| @@ -93,7 +96,7 @@ type HomeRow struct { | |||
| 93 | 96 | Stats *HomeStats | |
| 94 | 97 | // Private marks a private repository; only set for a signed-in visitor. | |
| 95 | 98 | Private bool | |
| 96 | - | // Fresh marks a row that a push just updated (live updates only). | |
| 99 | + | // Fresh marks a row that activity just updated (live updates only). | |
| 97 | 100 | Fresh bool | |
| 98 | 101 | // Commits are the newest of the default branch. NoCommits says there is | |
| 99 | 102 | // none (never pushed, or an empty branch); a failed read says nothing. | |
| @@ -135,11 +138,10 @@ type SortLink struct { | |||
| 135 | 138 | ||
| 136 | 139 | // HomePage is the repository list. | |
| 137 | 140 | type HomePage struct { | |
| 141 | + | Sort string | |
| 142 | + | Desc bool | |
| 138 | 143 | Sorts []SortLink | |
| 139 | 144 | Rows []HomeRow | |
| 140 | - | // Inbox is the button to the owner's inbox; nil unless the owner is signed | |
| 141 | - | // in. | |
| 142 | - | Inbox *InboxButton | |
| 143 | 145 | } | |
| 144 | 146 | ||
| 145 | 147 | // InboxButton is the link to the inbox with the number of open items; Count is | |
| @@ -71,6 +71,9 @@ type Layout struct { | |||||
| 71 | MR bool | 71 | MR bool | ||
| 72 | // IssueForm loads the script of the public issue form (issue-form.js). | 72 | // IssueForm loads the script of the public issue form (issue-form.js). | ||
| 73 | IssueForm bool | 73 | IssueForm bool | ||
| 74 | + | // Inbox is the button to the owner's inbox, next to the logo; set on the | |||
| 75 | + | // home page only, for the signed-in owner. | |||
| 76 | + | Inbox *InboxButton | |||
| 74 | } | 77 | } | ||
| 75 | 78 | ||||
| 76 | // Link is a plain label + href pair. | 79 | // Link is a plain label + href pair. | ||
| @@ -93,7 +96,7 @@ type HomeRow struct { | |||||
| 93 | Stats *HomeStats | 96 | Stats *HomeStats | ||
| 94 | // Private marks a private repository; only set for a signed-in visitor. | 97 | // Private marks a private repository; only set for a signed-in visitor. | ||
| 95 | Private bool | 98 | Private bool | ||
| 96 | - | // Fresh marks a row that a push just updated (live updates only). | 99 | + | // Fresh marks a row that activity just updated (live updates only). |
| 97 | Fresh bool | 100 | Fresh bool | ||
| 98 | // Commits are the newest of the default branch. NoCommits says there is | 101 | // Commits are the newest of the default branch. NoCommits says there is | ||
| 99 | // none (never pushed, or an empty branch); a failed read says nothing. | 102 | // none (never pushed, or an empty branch); a failed read says nothing. | ||
| @@ -135,11 +138,10 @@ type SortLink struct { | |||||
| 135 | 138 | ||||
| 136 | // HomePage is the repository list. | 139 | // HomePage is the repository list. | ||
| 137 | type HomePage struct { | 140 | type HomePage struct { | ||
| 141 | + | Sort string | |||
| 142 | + | Desc bool | |||
| 138 | Sorts []SortLink | 143 | Sorts []SortLink | ||
| 139 | Rows []HomeRow | 144 | Rows []HomeRow | ||
| 140 | - | // Inbox is the button to the owner's inbox; nil unless the owner is signed | |||
| 141 | - | // in. | |||
| 142 | - | Inbox *InboxButton | |||
| 143 | } | 145 | } | ||
| 144 | 146 | ||||
| 145 | // InboxButton is the link to the inbox with the number of open items; Count is | 147 | // InboxButton is the link to the inbox with the number of open items; Count is | ||
M internal/app/testdata/web/blob-binary.html +8 -1
| @@ -20,7 +20,7 @@ | |||
| 20 | 20 | </script> | |
| 21 | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 22 | 22 | </script> | |
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | |
| 23 | + | <script src="/-/static/js/store.HASH.js"> | |
| 24 | 24 | </script> | |
| 25 | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 26 | 26 | </script> | |
| @@ -239,6 +239,12 @@ | |||
| 239 | 239 | </div> | |
| 240 | 240 | <div> | |
| 241 | 241 | <dt> | |
| 242 | + | <kbd>w</kbd> | |
| 243 | + | </dt> | |
| 244 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |
| 245 | + | </div> | |
| 246 | + | <div> | |
| 247 | + | <dt> | |
| 242 | 248 | <kbd>y</kbd> | |
| 243 | 249 | </dt> | |
| 244 | 250 | <dd>copy a permalink pinned to the commit</dd> | |
| @@ -416,6 +422,7 @@ | |||
| 416 | 422 | k-split from:body -> ermite.layout('split'); | |
| 417 | 423 | k-unified from:body -> ermite.layout('unified'); | |
| 418 | 424 | k-collapse from:body -> ermite.collapse(); | |
| 425 | + | k-wide from:body -> window.ermiteWide.toggle(); | |
| 419 | 426 | k-expand from:body -> ermite.expandFile(); | |
| 420 | 427 | k-expandAll from:body -> ermite.expandAll(); | |
| 421 | 428 | k-permalink from:body -> ermite.permalink(); | |
| @@ -20,7 +20,7 @@ | |||||
| 20 | </script> | 20 | </script> | ||
| 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | ||
| 22 | </script> | 22 | </script> | ||
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | 23 | + | <script src="/-/static/js/store.HASH.js"> |
| 24 | </script> | 24 | </script> | ||
| 25 | <script src="/-/static/js/ermite.HASH.js" defer> | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 26 | </script> | 26 | </script> | ||
| @@ -239,6 +239,12 @@ | |||||
| 239 | </div> | 239 | </div> | ||
| 240 | <div> | 240 | <div> | ||
| 241 | <dt> | 241 | <dt> | ||
| 242 | + | <kbd>w</kbd> | |||
| 243 | + | </dt> | |||
| 244 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |||
| 245 | + | </div> | |||
| 246 | + | <div> | |||
| 247 | + | <dt> | |||
| 242 | <kbd>y</kbd> | 248 | <kbd>y</kbd> | ||
| 243 | </dt> | 249 | </dt> | ||
| 244 | <dd>copy a permalink pinned to the commit</dd> | 250 | <dd>copy a permalink pinned to the commit</dd> | ||
| @@ -416,6 +422,7 @@ | |||||
| 416 | k-split from:body -> ermite.layout('split'); | 422 | k-split from:body -> ermite.layout('split'); | ||
| 417 | k-unified from:body -> ermite.layout('unified'); | 423 | k-unified from:body -> ermite.layout('unified'); | ||
| 418 | k-collapse from:body -> ermite.collapse(); | 424 | k-collapse from:body -> ermite.collapse(); | ||
| 425 | + | k-wide from:body -> window.ermiteWide.toggle(); | |||
| 419 | k-expand from:body -> ermite.expandFile(); | 426 | k-expand from:body -> ermite.expandFile(); | ||
| 420 | k-expandAll from:body -> ermite.expandAll(); | 427 | k-expandAll from:body -> ermite.expandAll(); | ||
| 421 | k-permalink from:body -> ermite.permalink(); | 428 | k-permalink from:body -> ermite.permalink(); | ||
M internal/app/testdata/web/blob-code.html +8 -1
| @@ -20,7 +20,7 @@ | |||
| 20 | 20 | </script> | |
| 21 | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 22 | 22 | </script> | |
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | |
| 23 | + | <script src="/-/static/js/store.HASH.js"> | |
| 24 | 24 | </script> | |
| 25 | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 26 | 26 | </script> | |
| @@ -309,6 +309,12 @@ | |||
| 309 | 309 | </div> | |
| 310 | 310 | <div> | |
| 311 | 311 | <dt> | |
| 312 | + | <kbd>w</kbd> | |
| 313 | + | </dt> | |
| 314 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |
| 315 | + | </div> | |
| 316 | + | <div> | |
| 317 | + | <dt> | |
| 312 | 318 | <kbd>y</kbd> | |
| 313 | 319 | </dt> | |
| 314 | 320 | <dd>copy a permalink pinned to the commit</dd> | |
| @@ -486,6 +492,7 @@ | |||
| 486 | 492 | k-split from:body -> ermite.layout('split'); | |
| 487 | 493 | k-unified from:body -> ermite.layout('unified'); | |
| 488 | 494 | k-collapse from:body -> ermite.collapse(); | |
| 495 | + | k-wide from:body -> window.ermiteWide.toggle(); | |
| 489 | 496 | k-expand from:body -> ermite.expandFile(); | |
| 490 | 497 | k-expandAll from:body -> ermite.expandAll(); | |
| 491 | 498 | k-permalink from:body -> ermite.permalink(); | |
| @@ -20,7 +20,7 @@ | |||||
| 20 | </script> | 20 | </script> | ||
| 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | ||
| 22 | </script> | 22 | </script> | ||
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | 23 | + | <script src="/-/static/js/store.HASH.js"> |
| 24 | </script> | 24 | </script> | ||
| 25 | <script src="/-/static/js/ermite.HASH.js" defer> | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 26 | </script> | 26 | </script> | ||
| @@ -309,6 +309,12 @@ | |||||
| 309 | </div> | 309 | </div> | ||
| 310 | <div> | 310 | <div> | ||
| 311 | <dt> | 311 | <dt> | ||
| 312 | + | <kbd>w</kbd> | |||
| 313 | + | </dt> | |||
| 314 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |||
| 315 | + | </div> | |||
| 316 | + | <div> | |||
| 317 | + | <dt> | |||
| 312 | <kbd>y</kbd> | 318 | <kbd>y</kbd> | ||
| 313 | </dt> | 319 | </dt> | ||
| 314 | <dd>copy a permalink pinned to the commit</dd> | 320 | <dd>copy a permalink pinned to the commit</dd> | ||
| @@ -486,6 +492,7 @@ | |||||
| 486 | k-split from:body -> ermite.layout('split'); | 492 | k-split from:body -> ermite.layout('split'); | ||
| 487 | k-unified from:body -> ermite.layout('unified'); | 493 | k-unified from:body -> ermite.layout('unified'); | ||
| 488 | k-collapse from:body -> ermite.collapse(); | 494 | k-collapse from:body -> ermite.collapse(); | ||
| 495 | + | k-wide from:body -> window.ermiteWide.toggle(); | |||
| 489 | k-expand from:body -> ermite.expandFile(); | 496 | k-expand from:body -> ermite.expandFile(); | ||
| 490 | k-expandAll from:body -> ermite.expandAll(); | 497 | k-expandAll from:body -> ermite.expandAll(); | ||
| 491 | k-permalink from:body -> ermite.permalink(); | 498 | k-permalink from:body -> ermite.permalink(); | ||
M internal/app/testdata/web/blob-empty.html +8 -1
| @@ -20,7 +20,7 @@ | |||
| 20 | 20 | </script> | |
| 21 | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 22 | 22 | </script> | |
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | |
| 23 | + | <script src="/-/static/js/store.HASH.js"> | |
| 24 | 24 | </script> | |
| 25 | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 26 | 26 | </script> | |
| @@ -239,6 +239,12 @@ | |||
| 239 | 239 | </div> | |
| 240 | 240 | <div> | |
| 241 | 241 | <dt> | |
| 242 | + | <kbd>w</kbd> | |
| 243 | + | </dt> | |
| 244 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |
| 245 | + | </div> | |
| 246 | + | <div> | |
| 247 | + | <dt> | |
| 242 | 248 | <kbd>y</kbd> | |
| 243 | 249 | </dt> | |
| 244 | 250 | <dd>copy a permalink pinned to the commit</dd> | |
| @@ -416,6 +422,7 @@ | |||
| 416 | 422 | k-split from:body -> ermite.layout('split'); | |
| 417 | 423 | k-unified from:body -> ermite.layout('unified'); | |
| 418 | 424 | k-collapse from:body -> ermite.collapse(); | |
| 425 | + | k-wide from:body -> window.ermiteWide.toggle(); | |
| 419 | 426 | k-expand from:body -> ermite.expandFile(); | |
| 420 | 427 | k-expandAll from:body -> ermite.expandAll(); | |
| 421 | 428 | k-permalink from:body -> ermite.permalink(); | |
| @@ -20,7 +20,7 @@ | |||||
| 20 | </script> | 20 | </script> | ||
| 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | ||
| 22 | </script> | 22 | </script> | ||
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | 23 | + | <script src="/-/static/js/store.HASH.js"> |
| 24 | </script> | 24 | </script> | ||
| 25 | <script src="/-/static/js/ermite.HASH.js" defer> | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 26 | </script> | 26 | </script> | ||
| @@ -239,6 +239,12 @@ | |||||
| 239 | </div> | 239 | </div> | ||
| 240 | <div> | 240 | <div> | ||
| 241 | <dt> | 241 | <dt> | ||
| 242 | + | <kbd>w</kbd> | |||
| 243 | + | </dt> | |||
| 244 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |||
| 245 | + | </div> | |||
| 246 | + | <div> | |||
| 247 | + | <dt> | |||
| 242 | <kbd>y</kbd> | 248 | <kbd>y</kbd> | ||
| 243 | </dt> | 249 | </dt> | ||
| 244 | <dd>copy a permalink pinned to the commit</dd> | 250 | <dd>copy a permalink pinned to the commit</dd> | ||
| @@ -416,6 +422,7 @@ | |||||
| 416 | k-split from:body -> ermite.layout('split'); | 422 | k-split from:body -> ermite.layout('split'); | ||
| 417 | k-unified from:body -> ermite.layout('unified'); | 423 | k-unified from:body -> ermite.layout('unified'); | ||
| 418 | k-collapse from:body -> ermite.collapse(); | 424 | k-collapse from:body -> ermite.collapse(); | ||
| 425 | + | k-wide from:body -> window.ermiteWide.toggle(); | |||
| 419 | k-expand from:body -> ermite.expandFile(); | 426 | k-expand from:body -> ermite.expandFile(); | ||
| 420 | k-expandAll from:body -> ermite.expandAll(); | 427 | k-expandAll from:body -> ermite.expandAll(); | ||
| 421 | k-permalink from:body -> ermite.permalink(); | 428 | k-permalink from:body -> ermite.permalink(); | ||
M internal/app/testdata/web/blob-image.html +8 -1
| @@ -20,7 +20,7 @@ | |||
| 20 | 20 | </script> | |
| 21 | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 22 | 22 | </script> | |
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | |
| 23 | + | <script src="/-/static/js/store.HASH.js"> | |
| 24 | 24 | </script> | |
| 25 | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 26 | 26 | </script> | |
| @@ -241,6 +241,12 @@ | |||
| 241 | 241 | </div> | |
| 242 | 242 | <div> | |
| 243 | 243 | <dt> | |
| 244 | + | <kbd>w</kbd> | |
| 245 | + | </dt> | |
| 246 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |
| 247 | + | </div> | |
| 248 | + | <div> | |
| 249 | + | <dt> | |
| 244 | 250 | <kbd>y</kbd> | |
| 245 | 251 | </dt> | |
| 246 | 252 | <dd>copy a permalink pinned to the commit</dd> | |
| @@ -418,6 +424,7 @@ | |||
| 418 | 424 | k-split from:body -> ermite.layout('split'); | |
| 419 | 425 | k-unified from:body -> ermite.layout('unified'); | |
| 420 | 426 | k-collapse from:body -> ermite.collapse(); | |
| 427 | + | k-wide from:body -> window.ermiteWide.toggle(); | |
| 421 | 428 | k-expand from:body -> ermite.expandFile(); | |
| 422 | 429 | k-expandAll from:body -> ermite.expandAll(); | |
| 423 | 430 | k-permalink from:body -> ermite.permalink(); | |
| @@ -20,7 +20,7 @@ | |||||
| 20 | </script> | 20 | </script> | ||
| 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | ||
| 22 | </script> | 22 | </script> | ||
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | 23 | + | <script src="/-/static/js/store.HASH.js"> |
| 24 | </script> | 24 | </script> | ||
| 25 | <script src="/-/static/js/ermite.HASH.js" defer> | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 26 | </script> | 26 | </script> | ||
| @@ -241,6 +241,12 @@ | |||||
| 241 | </div> | 241 | </div> | ||
| 242 | <div> | 242 | <div> | ||
| 243 | <dt> | 243 | <dt> | ||
| 244 | + | <kbd>w</kbd> | |||
| 245 | + | </dt> | |||
| 246 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |||
| 247 | + | </div> | |||
| 248 | + | <div> | |||
| 249 | + | <dt> | |||
| 244 | <kbd>y</kbd> | 250 | <kbd>y</kbd> | ||
| 245 | </dt> | 251 | </dt> | ||
| 246 | <dd>copy a permalink pinned to the commit</dd> | 252 | <dd>copy a permalink pinned to the commit</dd> | ||
| @@ -418,6 +424,7 @@ | |||||
| 418 | k-split from:body -> ermite.layout('split'); | 424 | k-split from:body -> ermite.layout('split'); | ||
| 419 | k-unified from:body -> ermite.layout('unified'); | 425 | k-unified from:body -> ermite.layout('unified'); | ||
| 420 | k-collapse from:body -> ermite.collapse(); | 426 | k-collapse from:body -> ermite.collapse(); | ||
| 427 | + | k-wide from:body -> window.ermiteWide.toggle(); | |||
| 421 | k-expand from:body -> ermite.expandFile(); | 428 | k-expand from:body -> ermite.expandFile(); | ||
| 422 | k-expandAll from:body -> ermite.expandAll(); | 429 | k-expandAll from:body -> ermite.expandAll(); | ||
| 423 | k-permalink from:body -> ermite.permalink(); | 430 | k-permalink from:body -> ermite.permalink(); | ||
M internal/app/testdata/web/blob-markdown.html +8 -1
| @@ -20,7 +20,7 @@ | |||
| 20 | 20 | </script> | |
| 21 | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 22 | 22 | </script> | |
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | |
| 23 | + | <script src="/-/static/js/store.HASH.js"> | |
| 24 | 24 | </script> | |
| 25 | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 26 | 26 | </script> | |
| @@ -306,6 +306,12 @@ | |||
| 306 | 306 | </div> | |
| 307 | 307 | <div> | |
| 308 | 308 | <dt> | |
| 309 | + | <kbd>w</kbd> | |
| 310 | + | </dt> | |
| 311 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |
| 312 | + | </div> | |
| 313 | + | <div> | |
| 314 | + | <dt> | |
| 309 | 315 | <kbd>y</kbd> | |
| 310 | 316 | </dt> | |
| 311 | 317 | <dd>copy a permalink pinned to the commit</dd> | |
| @@ -483,6 +489,7 @@ | |||
| 483 | 489 | k-split from:body -> ermite.layout('split'); | |
| 484 | 490 | k-unified from:body -> ermite.layout('unified'); | |
| 485 | 491 | k-collapse from:body -> ermite.collapse(); | |
| 492 | + | k-wide from:body -> window.ermiteWide.toggle(); | |
| 486 | 493 | k-expand from:body -> ermite.expandFile(); | |
| 487 | 494 | k-expandAll from:body -> ermite.expandAll(); | |
| 488 | 495 | k-permalink from:body -> ermite.permalink(); | |
| @@ -20,7 +20,7 @@ | |||||
| 20 | </script> | 20 | </script> | ||
| 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | ||
| 22 | </script> | 22 | </script> | ||
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | 23 | + | <script src="/-/static/js/store.HASH.js"> |
| 24 | </script> | 24 | </script> | ||
| 25 | <script src="/-/static/js/ermite.HASH.js" defer> | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 26 | </script> | 26 | </script> | ||
| @@ -306,6 +306,12 @@ | |||||
| 306 | </div> | 306 | </div> | ||
| 307 | <div> | 307 | <div> | ||
| 308 | <dt> | 308 | <dt> | ||
| 309 | + | <kbd>w</kbd> | |||
| 310 | + | </dt> | |||
| 311 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |||
| 312 | + | </div> | |||
| 313 | + | <div> | |||
| 314 | + | <dt> | |||
| 309 | <kbd>y</kbd> | 315 | <kbd>y</kbd> | ||
| 310 | </dt> | 316 | </dt> | ||
| 311 | <dd>copy a permalink pinned to the commit</dd> | 317 | <dd>copy a permalink pinned to the commit</dd> | ||
| @@ -483,6 +489,7 @@ | |||||
| 483 | k-split from:body -> ermite.layout('split'); | 489 | k-split from:body -> ermite.layout('split'); | ||
| 484 | k-unified from:body -> ermite.layout('unified'); | 490 | k-unified from:body -> ermite.layout('unified'); | ||
| 485 | k-collapse from:body -> ermite.collapse(); | 491 | k-collapse from:body -> ermite.collapse(); | ||
| 492 | + | k-wide from:body -> window.ermiteWide.toggle(); | |||
| 486 | k-expand from:body -> ermite.expandFile(); | 493 | k-expand from:body -> ermite.expandFile(); | ||
| 487 | k-expandAll from:body -> ermite.expandAll(); | 494 | k-expandAll from:body -> ermite.expandAll(); | ||
| 488 | k-permalink from:body -> ermite.permalink(); | 495 | k-permalink from:body -> ermite.permalink(); | ||
M internal/app/testdata/web/blob-svg.html +8 -1
| @@ -20,7 +20,7 @@ | |||
| 20 | 20 | </script> | |
| 21 | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 22 | 22 | </script> | |
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | |
| 23 | + | <script src="/-/static/js/store.HASH.js"> | |
| 24 | 24 | </script> | |
| 25 | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 26 | 26 | </script> | |
| @@ -264,6 +264,12 @@ | |||
| 264 | 264 | </div> | |
| 265 | 265 | <div> | |
| 266 | 266 | <dt> | |
| 267 | + | <kbd>w</kbd> | |
| 268 | + | </dt> | |
| 269 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |
| 270 | + | </div> | |
| 271 | + | <div> | |
| 272 | + | <dt> | |
| 267 | 273 | <kbd>y</kbd> | |
| 268 | 274 | </dt> | |
| 269 | 275 | <dd>copy a permalink pinned to the commit</dd> | |
| @@ -441,6 +447,7 @@ | |||
| 441 | 447 | k-split from:body -> ermite.layout('split'); | |
| 442 | 448 | k-unified from:body -> ermite.layout('unified'); | |
| 443 | 449 | k-collapse from:body -> ermite.collapse(); | |
| 450 | + | k-wide from:body -> window.ermiteWide.toggle(); | |
| 444 | 451 | k-expand from:body -> ermite.expandFile(); | |
| 445 | 452 | k-expandAll from:body -> ermite.expandAll(); | |
| 446 | 453 | k-permalink from:body -> ermite.permalink(); | |
| @@ -20,7 +20,7 @@ | |||||
| 20 | </script> | 20 | </script> | ||
| 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | ||
| 22 | </script> | 22 | </script> | ||
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | 23 | + | <script src="/-/static/js/store.HASH.js"> |
| 24 | </script> | 24 | </script> | ||
| 25 | <script src="/-/static/js/ermite.HASH.js" defer> | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 26 | </script> | 26 | </script> | ||
| @@ -264,6 +264,12 @@ | |||||
| 264 | </div> | 264 | </div> | ||
| 265 | <div> | 265 | <div> | ||
| 266 | <dt> | 266 | <dt> | ||
| 267 | + | <kbd>w</kbd> | |||
| 268 | + | </dt> | |||
| 269 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |||
| 270 | + | </div> | |||
| 271 | + | <div> | |||
| 272 | + | <dt> | |||
| 267 | <kbd>y</kbd> | 273 | <kbd>y</kbd> | ||
| 268 | </dt> | 274 | </dt> | ||
| 269 | <dd>copy a permalink pinned to the commit</dd> | 275 | <dd>copy a permalink pinned to the commit</dd> | ||
| @@ -441,6 +447,7 @@ | |||||
| 441 | k-split from:body -> ermite.layout('split'); | 447 | k-split from:body -> ermite.layout('split'); | ||
| 442 | k-unified from:body -> ermite.layout('unified'); | 448 | k-unified from:body -> ermite.layout('unified'); | ||
| 443 | k-collapse from:body -> ermite.collapse(); | 449 | k-collapse from:body -> ermite.collapse(); | ||
| 450 | + | k-wide from:body -> window.ermiteWide.toggle(); | |||
| 444 | k-expand from:body -> ermite.expandFile(); | 451 | k-expand from:body -> ermite.expandFile(); | ||
| 445 | k-expandAll from:body -> ermite.expandAll(); | 452 | k-expandAll from:body -> ermite.expandAll(); | ||
| 446 | k-permalink from:body -> ermite.permalink(); | 453 | k-permalink from:body -> ermite.permalink(); | ||
M internal/app/testdata/web/blob-text.html +8 -1
| @@ -20,7 +20,7 @@ | |||
| 20 | 20 | </script> | |
| 21 | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 22 | 22 | </script> | |
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | |
| 23 | + | <script src="/-/static/js/store.HASH.js"> | |
| 24 | 24 | </script> | |
| 25 | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 26 | 26 | </script> | |
| @@ -259,6 +259,12 @@ | |||
| 259 | 259 | </div> | |
| 260 | 260 | <div> | |
| 261 | 261 | <dt> | |
| 262 | + | <kbd>w</kbd> | |
| 263 | + | </dt> | |
| 264 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |
| 265 | + | </div> | |
| 266 | + | <div> | |
| 267 | + | <dt> | |
| 262 | 268 | <kbd>y</kbd> | |
| 263 | 269 | </dt> | |
| 264 | 270 | <dd>copy a permalink pinned to the commit</dd> | |
| @@ -436,6 +442,7 @@ | |||
| 436 | 442 | k-split from:body -> ermite.layout('split'); | |
| 437 | 443 | k-unified from:body -> ermite.layout('unified'); | |
| 438 | 444 | k-collapse from:body -> ermite.collapse(); | |
| 445 | + | k-wide from:body -> window.ermiteWide.toggle(); | |
| 439 | 446 | k-expand from:body -> ermite.expandFile(); | |
| 440 | 447 | k-expandAll from:body -> ermite.expandAll(); | |
| 441 | 448 | k-permalink from:body -> ermite.permalink(); | |
| @@ -20,7 +20,7 @@ | |||||
| 20 | </script> | 20 | </script> | ||
| 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | ||
| 22 | </script> | 22 | </script> | ||
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | 23 | + | <script src="/-/static/js/store.HASH.js"> |
| 24 | </script> | 24 | </script> | ||
| 25 | <script src="/-/static/js/ermite.HASH.js" defer> | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 26 | </script> | 26 | </script> | ||
| @@ -259,6 +259,12 @@ | |||||
| 259 | </div> | 259 | </div> | ||
| 260 | <div> | 260 | <div> | ||
| 261 | <dt> | 261 | <dt> | ||
| 262 | + | <kbd>w</kbd> | |||
| 263 | + | </dt> | |||
| 264 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |||
| 265 | + | </div> | |||
| 266 | + | <div> | |||
| 267 | + | <dt> | |||
| 262 | <kbd>y</kbd> | 268 | <kbd>y</kbd> | ||
| 263 | </dt> | 269 | </dt> | ||
| 264 | <dd>copy a permalink pinned to the commit</dd> | 270 | <dd>copy a permalink pinned to the commit</dd> | ||
| @@ -436,6 +442,7 @@ | |||||
| 436 | k-split from:body -> ermite.layout('split'); | 442 | k-split from:body -> ermite.layout('split'); | ||
| 437 | k-unified from:body -> ermite.layout('unified'); | 443 | k-unified from:body -> ermite.layout('unified'); | ||
| 438 | k-collapse from:body -> ermite.collapse(); | 444 | k-collapse from:body -> ermite.collapse(); | ||
| 445 | + | k-wide from:body -> window.ermiteWide.toggle(); | |||
| 439 | k-expand from:body -> ermite.expandFile(); | 446 | k-expand from:body -> ermite.expandFile(); | ||
| 440 | k-expandAll from:body -> ermite.expandAll(); | 447 | k-expandAll from:body -> ermite.expandAll(); | ||
| 441 | k-permalink from:body -> ermite.permalink(); | 448 | k-permalink from:body -> ermite.permalink(); | ||
M internal/app/testdata/web/blob-too-large.html +8 -1
| @@ -20,7 +20,7 @@ | |||
| 20 | 20 | </script> | |
| 21 | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 22 | 22 | </script> | |
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | |
| 23 | + | <script src="/-/static/js/store.HASH.js"> | |
| 24 | 24 | </script> | |
| 25 | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 26 | 26 | </script> | |
| @@ -239,6 +239,12 @@ | |||
| 239 | 239 | </div> | |
| 240 | 240 | <div> | |
| 241 | 241 | <dt> | |
| 242 | + | <kbd>w</kbd> | |
| 243 | + | </dt> | |
| 244 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |
| 245 | + | </div> | |
| 246 | + | <div> | |
| 247 | + | <dt> | |
| 242 | 248 | <kbd>y</kbd> | |
| 243 | 249 | </dt> | |
| 244 | 250 | <dd>copy a permalink pinned to the commit</dd> | |
| @@ -416,6 +422,7 @@ | |||
| 416 | 422 | k-split from:body -> ermite.layout('split'); | |
| 417 | 423 | k-unified from:body -> ermite.layout('unified'); | |
| 418 | 424 | k-collapse from:body -> ermite.collapse(); | |
| 425 | + | k-wide from:body -> window.ermiteWide.toggle(); | |
| 419 | 426 | k-expand from:body -> ermite.expandFile(); | |
| 420 | 427 | k-expandAll from:body -> ermite.expandAll(); | |
| 421 | 428 | k-permalink from:body -> ermite.permalink(); | |
| @@ -20,7 +20,7 @@ | |||||
| 20 | </script> | 20 | </script> | ||
| 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | ||
| 22 | </script> | 22 | </script> | ||
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | 23 | + | <script src="/-/static/js/store.HASH.js"> |
| 24 | </script> | 24 | </script> | ||
| 25 | <script src="/-/static/js/ermite.HASH.js" defer> | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 26 | </script> | 26 | </script> | ||
| @@ -239,6 +239,12 @@ | |||||
| 239 | </div> | 239 | </div> | ||
| 240 | <div> | 240 | <div> | ||
| 241 | <dt> | 241 | <dt> | ||
| 242 | + | <kbd>w</kbd> | |||
| 243 | + | </dt> | |||
| 244 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |||
| 245 | + | </div> | |||
| 246 | + | <div> | |||
| 247 | + | <dt> | |||
| 242 | <kbd>y</kbd> | 248 | <kbd>y</kbd> | ||
| 243 | </dt> | 249 | </dt> | ||
| 244 | <dd>copy a permalink pinned to the commit</dd> | 250 | <dd>copy a permalink pinned to the commit</dd> | ||
| @@ -416,6 +422,7 @@ | |||||
| 416 | k-split from:body -> ermite.layout('split'); | 422 | k-split from:body -> ermite.layout('split'); | ||
| 417 | k-unified from:body -> ermite.layout('unified'); | 423 | k-unified from:body -> ermite.layout('unified'); | ||
| 418 | k-collapse from:body -> ermite.collapse(); | 424 | k-collapse from:body -> ermite.collapse(); | ||
| 425 | + | k-wide from:body -> window.ermiteWide.toggle(); | |||
| 419 | k-expand from:body -> ermite.expandFile(); | 426 | k-expand from:body -> ermite.expandFile(); | ||
| 420 | k-expandAll from:body -> ermite.expandAll(); | 427 | k-expandAll from:body -> ermite.expandAll(); | ||
| 421 | k-permalink from:body -> ermite.permalink(); | 428 | k-permalink from:body -> ermite.permalink(); | ||
M internal/app/testdata/web/blob-trojan.html +8 -1
| @@ -20,7 +20,7 @@ | |||
| 20 | 20 | </script> | |
| 21 | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 22 | 22 | </script> | |
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | |
| 23 | + | <script src="/-/static/js/store.HASH.js"> | |
| 24 | 24 | </script> | |
| 25 | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 26 | 26 | </script> | |
| @@ -306,6 +306,12 @@ | |||
| 306 | 306 | </div> | |
| 307 | 307 | <div> | |
| 308 | 308 | <dt> | |
| 309 | + | <kbd>w</kbd> | |
| 310 | + | </dt> | |
| 311 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |
| 312 | + | </div> | |
| 313 | + | <div> | |
| 314 | + | <dt> | |
| 309 | 315 | <kbd>y</kbd> | |
| 310 | 316 | </dt> | |
| 311 | 317 | <dd>copy a permalink pinned to the commit</dd> | |
| @@ -483,6 +489,7 @@ | |||
| 483 | 489 | k-split from:body -> ermite.layout('split'); | |
| 484 | 490 | k-unified from:body -> ermite.layout('unified'); | |
| 485 | 491 | k-collapse from:body -> ermite.collapse(); | |
| 492 | + | k-wide from:body -> window.ermiteWide.toggle(); | |
| 486 | 493 | k-expand from:body -> ermite.expandFile(); | |
| 487 | 494 | k-expandAll from:body -> ermite.expandAll(); | |
| 488 | 495 | k-permalink from:body -> ermite.permalink(); | |
| @@ -20,7 +20,7 @@ | |||||
| 20 | </script> | 20 | </script> | ||
| 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | ||
| 22 | </script> | 22 | </script> | ||
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | 23 | + | <script src="/-/static/js/store.HASH.js"> |
| 24 | </script> | 24 | </script> | ||
| 25 | <script src="/-/static/js/ermite.HASH.js" defer> | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 26 | </script> | 26 | </script> | ||
| @@ -306,6 +306,12 @@ | |||||
| 306 | </div> | 306 | </div> | ||
| 307 | <div> | 307 | <div> | ||
| 308 | <dt> | 308 | <dt> | ||
| 309 | + | <kbd>w</kbd> | |||
| 310 | + | </dt> | |||
| 311 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |||
| 312 | + | </div> | |||
| 313 | + | <div> | |||
| 314 | + | <dt> | |||
| 309 | <kbd>y</kbd> | 315 | <kbd>y</kbd> | ||
| 310 | </dt> | 316 | </dt> | ||
| 311 | <dd>copy a permalink pinned to the commit</dd> | 317 | <dd>copy a permalink pinned to the commit</dd> | ||
| @@ -483,6 +489,7 @@ | |||||
| 483 | k-split from:body -> ermite.layout('split'); | 489 | k-split from:body -> ermite.layout('split'); | ||
| 484 | k-unified from:body -> ermite.layout('unified'); | 490 | k-unified from:body -> ermite.layout('unified'); | ||
| 485 | k-collapse from:body -> ermite.collapse(); | 491 | k-collapse from:body -> ermite.collapse(); | ||
| 492 | + | k-wide from:body -> window.ermiteWide.toggle(); | |||
| 486 | k-expand from:body -> ermite.expandFile(); | 493 | k-expand from:body -> ermite.expandFile(); | ||
| 487 | k-expandAll from:body -> ermite.expandAll(); | 494 | k-expandAll from:body -> ermite.expandAll(); | ||
| 488 | k-permalink from:body -> ermite.permalink(); | 495 | k-permalink from:body -> ermite.permalink(); | ||
M internal/app/testdata/web/commit-big.html +16 -2
| @@ -20,7 +20,7 @@ | |||
| 20 | 20 | </script> | |
| 21 | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 22 | 22 | </script> | |
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | |
| 23 | + | <script src="/-/static/js/store.HASH.js"> | |
| 24 | 24 | </script> | |
| 25 | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 26 | 26 | </script> | |
| @@ -125,6 +125,11 @@ | |||
| 125 | 125 | </span> | |
| 126 | 126 | <span class="grow"> | |
| 127 | 127 | </span> | |
| 128 | + | <details class="menu vmenu"> | |
| 129 | + | <summary class="btn" aria-label="Diff view"> | |
| 130 | + | <span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> | |
| 131 | + | </summary> | |
| 132 | + | <div class="menu-pop" role="group" aria-label="Diff view"> | |
| 128 | 133 | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 129 | 134 | </a> | |
| 130 | 135 | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| @@ -134,9 +139,11 @@ | |||
| 134 | 139 | <kbd aria-hidden="true">x</kbd> | |
| 135 | 140 | </button> | |
| 136 | 141 | </div> | |
| 142 | + | </details> | |
| 143 | + | </div> | |
| 137 | 144 | <p class="notice">this diff is incomplete: 1 file is too large to show. the totals above count everything; clone the repository to read the rest.</p> | |
| 138 | 145 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | |
| 139 | - | <details class="dfile lnw-2" id="f1" data-file> | |
| 146 | + | <details class="dfile lnw-2" id="f1" data-file="big.txt"> | |
| 140 | 147 | <summary> | |
| 141 | 148 | <span class="st">A</span> | |
| 142 | 149 | <span class="p">big.txt</span> | |
| @@ -281,6 +288,12 @@ | |||
| 281 | 288 | </div> | |
| 282 | 289 | <div> | |
| 283 | 290 | <dt> | |
| 291 | + | <kbd>w</kbd> | |
| 292 | + | </dt> | |
| 293 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |
| 294 | + | </div> | |
| 295 | + | <div> | |
| 296 | + | <dt> | |
| 284 | 297 | <kbd>y</kbd> | |
| 285 | 298 | </dt> | |
| 286 | 299 | <dd>copy a permalink pinned to the commit</dd> | |
| @@ -458,6 +471,7 @@ | |||
| 458 | 471 | k-split from:body -> ermite.layout('split'); | |
| 459 | 472 | k-unified from:body -> ermite.layout('unified'); | |
| 460 | 473 | k-collapse from:body -> ermite.collapse(); | |
| 474 | + | k-wide from:body -> window.ermiteWide.toggle(); | |
| 461 | 475 | k-expand from:body -> ermite.expandFile(); | |
| 462 | 476 | k-expandAll from:body -> ermite.expandAll(); | |
| 463 | 477 | k-permalink from:body -> ermite.permalink(); | |
| @@ -20,7 +20,7 @@ | |||||
| 20 | </script> | 20 | </script> | ||
| 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | ||
| 22 | </script> | 22 | </script> | ||
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | 23 | + | <script src="/-/static/js/store.HASH.js"> |
| 24 | </script> | 24 | </script> | ||
| 25 | <script src="/-/static/js/ermite.HASH.js" defer> | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 26 | </script> | 26 | </script> | ||
| @@ -125,6 +125,11 @@ | |||||
| 125 | </span> | 125 | </span> | ||
| 126 | <span class="grow"> | 126 | <span class="grow"> | ||
| 127 | </span> | 127 | </span> | ||
| 128 | + | <details class="menu vmenu"> | |||
| 129 | + | <summary class="btn" aria-label="Diff view"> | |||
| 130 | + | <span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> | |||
| 131 | + | </summary> | |||
| 132 | + | <div class="menu-pop" role="group" aria-label="Diff view"> | |||
| 128 | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | 133 | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | ||
| 129 | </a> | 134 | </a> | ||
| 130 | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | 135 | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | ||
| @@ -134,9 +139,11 @@ | |||||
| 134 | <kbd aria-hidden="true">x</kbd> | 139 | <kbd aria-hidden="true">x</kbd> | ||
| 135 | </button> | 140 | </button> | ||
| 136 | </div> | 141 | </div> | ||
| 142 | + | </details> | |||
| 143 | + | </div> | |||
| 137 | <p class="notice">this diff is incomplete: 1 file is too large to show. the totals above count everything; clone the repository to read the rest.</p> | 144 | <p class="notice">this diff is incomplete: 1 file is too large to show. the totals above count everything; clone the repository to read the rest.</p> | ||
| 138 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | 145 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | ||
| 139 | - | <details class="dfile lnw-2" id="f1" data-file> | 146 | + | <details class="dfile lnw-2" id="f1" data-file="big.txt"> |
| 140 | <summary> | 147 | <summary> | ||
| 141 | <span class="st">A</span> | 148 | <span class="st">A</span> | ||
| 142 | <span class="p">big.txt</span> | 149 | <span class="p">big.txt</span> | ||
| @@ -281,6 +288,12 @@ | |||||
| 281 | </div> | 288 | </div> | ||
| 282 | <div> | 289 | <div> | ||
| 283 | <dt> | 290 | <dt> | ||
| 291 | + | <kbd>w</kbd> | |||
| 292 | + | </dt> | |||
| 293 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |||
| 294 | + | </div> | |||
| 295 | + | <div> | |||
| 296 | + | <dt> | |||
| 284 | <kbd>y</kbd> | 297 | <kbd>y</kbd> | ||
| 285 | </dt> | 298 | </dt> | ||
| 286 | <dd>copy a permalink pinned to the commit</dd> | 299 | <dd>copy a permalink pinned to the commit</dd> | ||
| @@ -458,6 +471,7 @@ | |||||
| 458 | k-split from:body -> ermite.layout('split'); | 471 | k-split from:body -> ermite.layout('split'); | ||
| 459 | k-unified from:body -> ermite.layout('unified'); | 472 | k-unified from:body -> ermite.layout('unified'); | ||
| 460 | k-collapse from:body -> ermite.collapse(); | 473 | k-collapse from:body -> ermite.collapse(); | ||
| 474 | + | k-wide from:body -> window.ermiteWide.toggle(); | |||
| 461 | k-expand from:body -> ermite.expandFile(); | 475 | k-expand from:body -> ermite.expandFile(); | ||
| 462 | k-expandAll from:body -> ermite.expandAll(); | 476 | k-expandAll from:body -> ermite.expandAll(); | ||
| 463 | k-permalink from:body -> ermite.permalink(); | 477 | k-permalink from:body -> ermite.permalink(); | ||
M internal/app/testdata/web/commit-binary.html +16 -2
| @@ -20,7 +20,7 @@ | |||
| 20 | 20 | </script> | |
| 21 | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 22 | 22 | </script> | |
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | |
| 23 | + | <script src="/-/static/js/store.HASH.js"> | |
| 24 | 24 | </script> | |
| 25 | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 26 | 26 | </script> | |
| @@ -121,6 +121,11 @@ | |||
| 121 | 121 | </span> | |
| 122 | 122 | <span class="grow"> | |
| 123 | 123 | </span> | |
| 124 | + | <details class="menu vmenu"> | |
| 125 | + | <summary class="btn" aria-label="Diff view"> | |
| 126 | + | <span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> | |
| 127 | + | </summary> | |
| 128 | + | <div class="menu-pop" role="group" aria-label="Diff view"> | |
| 124 | 129 | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 125 | 130 | </a> | |
| 126 | 131 | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| @@ -130,8 +135,10 @@ | |||
| 130 | 135 | <kbd aria-hidden="true">x</kbd> | |
| 131 | 136 | </button> | |
| 132 | 137 | </div> | |
| 138 | + | </details> | |
| 139 | + | </div> | |
| 133 | 140 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | |
| 134 | - | <details class="dfile lnw-2" id="f1" data-file> | |
| 141 | + | <details class="dfile lnw-2" id="f1" data-file="logo.bin"> | |
| 135 | 142 | <summary> | |
| 136 | 143 | <span class="st">M</span> | |
| 137 | 144 | <span class="p">logo.bin</span> | |
| @@ -273,6 +280,12 @@ | |||
| 273 | 280 | </div> | |
| 274 | 281 | <div> | |
| 275 | 282 | <dt> | |
| 283 | + | <kbd>w</kbd> | |
| 284 | + | </dt> | |
| 285 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |
| 286 | + | </div> | |
| 287 | + | <div> | |
| 288 | + | <dt> | |
| 276 | 289 | <kbd>y</kbd> | |
| 277 | 290 | </dt> | |
| 278 | 291 | <dd>copy a permalink pinned to the commit</dd> | |
| @@ -450,6 +463,7 @@ | |||
| 450 | 463 | k-split from:body -> ermite.layout('split'); | |
| 451 | 464 | k-unified from:body -> ermite.layout('unified'); | |
| 452 | 465 | k-collapse from:body -> ermite.collapse(); | |
| 466 | + | k-wide from:body -> window.ermiteWide.toggle(); | |
| 453 | 467 | k-expand from:body -> ermite.expandFile(); | |
| 454 | 468 | k-expandAll from:body -> ermite.expandAll(); | |
| 455 | 469 | k-permalink from:body -> ermite.permalink(); | |
| @@ -20,7 +20,7 @@ | |||||
| 20 | </script> | 20 | </script> | ||
| 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | ||
| 22 | </script> | 22 | </script> | ||
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | 23 | + | <script src="/-/static/js/store.HASH.js"> |
| 24 | </script> | 24 | </script> | ||
| 25 | <script src="/-/static/js/ermite.HASH.js" defer> | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 26 | </script> | 26 | </script> | ||
| @@ -121,6 +121,11 @@ | |||||
| 121 | </span> | 121 | </span> | ||
| 122 | <span class="grow"> | 122 | <span class="grow"> | ||
| 123 | </span> | 123 | </span> | ||
| 124 | + | <details class="menu vmenu"> | |||
| 125 | + | <summary class="btn" aria-label="Diff view"> | |||
| 126 | + | <span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> | |||
| 127 | + | </summary> | |||
| 128 | + | <div class="menu-pop" role="group" aria-label="Diff view"> | |||
| 124 | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | 129 | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | ||
| 125 | </a> | 130 | </a> | ||
| 126 | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | 131 | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | ||
| @@ -130,8 +135,10 @@ | |||||
| 130 | <kbd aria-hidden="true">x</kbd> | 135 | <kbd aria-hidden="true">x</kbd> | ||
| 131 | </button> | 136 | </button> | ||
| 132 | </div> | 137 | </div> | ||
| 138 | + | </details> | |||
| 139 | + | </div> | |||
| 133 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | 140 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | ||
| 134 | - | <details class="dfile lnw-2" id="f1" data-file> | 141 | + | <details class="dfile lnw-2" id="f1" data-file="logo.bin"> |
| 135 | <summary> | 142 | <summary> | ||
| 136 | <span class="st">M</span> | 143 | <span class="st">M</span> | ||
| 137 | <span class="p">logo.bin</span> | 144 | <span class="p">logo.bin</span> | ||
| @@ -273,6 +280,12 @@ | |||||
| 273 | </div> | 280 | </div> | ||
| 274 | <div> | 281 | <div> | ||
| 275 | <dt> | 282 | <dt> | ||
| 283 | + | <kbd>w</kbd> | |||
| 284 | + | </dt> | |||
| 285 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |||
| 286 | + | </div> | |||
| 287 | + | <div> | |||
| 288 | + | <dt> | |||
| 276 | <kbd>y</kbd> | 289 | <kbd>y</kbd> | ||
| 277 | </dt> | 290 | </dt> | ||
| 278 | <dd>copy a permalink pinned to the commit</dd> | 291 | <dd>copy a permalink pinned to the commit</dd> | ||
| @@ -450,6 +463,7 @@ | |||||
| 450 | k-split from:body -> ermite.layout('split'); | 463 | k-split from:body -> ermite.layout('split'); | ||
| 451 | k-unified from:body -> ermite.layout('unified'); | 464 | k-unified from:body -> ermite.layout('unified'); | ||
| 452 | k-collapse from:body -> ermite.collapse(); | 465 | k-collapse from:body -> ermite.collapse(); | ||
| 466 | + | k-wide from:body -> window.ermiteWide.toggle(); | |||
| 453 | k-expand from:body -> ermite.expandFile(); | 467 | k-expand from:body -> ermite.expandFile(); | ||
| 454 | k-expandAll from:body -> ermite.expandAll(); | 468 | k-expandAll from:body -> ermite.expandAll(); | ||
| 455 | k-permalink from:body -> ermite.permalink(); | 469 | k-permalink from:body -> ermite.permalink(); | ||
M internal/app/testdata/web/commit-merge.html +16 -2
| @@ -20,7 +20,7 @@ | |||
| 20 | 20 | </script> | |
| 21 | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 22 | 22 | </script> | |
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | |
| 23 | + | <script src="/-/static/js/store.HASH.js"> | |
| 24 | 24 | </script> | |
| 25 | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 26 | 26 | </script> | |
| @@ -120,6 +120,11 @@ | |||
| 120 | 120 | </span> | |
| 121 | 121 | <span class="grow"> | |
| 122 | 122 | </span> | |
| 123 | + | <details class="menu vmenu"> | |
| 124 | + | <summary class="btn" aria-label="Diff view"> | |
| 125 | + | <span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> | |
| 126 | + | </summary> | |
| 127 | + | <div class="menu-pop" role="group" aria-label="Diff view"> | |
| 123 | 128 | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 124 | 129 | </a> | |
| 125 | 130 | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| @@ -129,8 +134,10 @@ | |||
| 129 | 134 | <kbd aria-hidden="true">x</kbd> | |
| 130 | 135 | </button> | |
| 131 | 136 | </div> | |
| 137 | + | </details> | |
| 138 | + | </div> | |
| 132 | 139 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | |
| 133 | - | <details class="dfile lnw-2" id="f1" data-file open> | |
| 140 | + | <details class="dfile lnw-2" id="f1" data-file="side.txt" open> | |
| 134 | 141 | <summary> | |
| 135 | 142 | <span class="st">A</span> | |
| 136 | 143 | <span class="p">side.txt</span> | |
| @@ -315,6 +322,12 @@ | |||
| 315 | 322 | </div> | |
| 316 | 323 | <div> | |
| 317 | 324 | <dt> | |
| 325 | + | <kbd>w</kbd> | |
| 326 | + | </dt> | |
| 327 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |
| 328 | + | </div> | |
| 329 | + | <div> | |
| 330 | + | <dt> | |
| 318 | 331 | <kbd>y</kbd> | |
| 319 | 332 | </dt> | |
| 320 | 333 | <dd>copy a permalink pinned to the commit</dd> | |
| @@ -492,6 +505,7 @@ | |||
| 492 | 505 | k-split from:body -> ermite.layout('split'); | |
| 493 | 506 | k-unified from:body -> ermite.layout('unified'); | |
| 494 | 507 | k-collapse from:body -> ermite.collapse(); | |
| 508 | + | k-wide from:body -> window.ermiteWide.toggle(); | |
| 495 | 509 | k-expand from:body -> ermite.expandFile(); | |
| 496 | 510 | k-expandAll from:body -> ermite.expandAll(); | |
| 497 | 511 | k-permalink from:body -> ermite.permalink(); | |
| @@ -20,7 +20,7 @@ | |||||
| 20 | </script> | 20 | </script> | ||
| 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | ||
| 22 | </script> | 22 | </script> | ||
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | 23 | + | <script src="/-/static/js/store.HASH.js"> |
| 24 | </script> | 24 | </script> | ||
| 25 | <script src="/-/static/js/ermite.HASH.js" defer> | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 26 | </script> | 26 | </script> | ||
| @@ -120,6 +120,11 @@ | |||||
| 120 | </span> | 120 | </span> | ||
| 121 | <span class="grow"> | 121 | <span class="grow"> | ||
| 122 | </span> | 122 | </span> | ||
| 123 | + | <details class="menu vmenu"> | |||
| 124 | + | <summary class="btn" aria-label="Diff view"> | |||
| 125 | + | <span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> | |||
| 126 | + | </summary> | |||
| 127 | + | <div class="menu-pop" role="group" aria-label="Diff view"> | |||
| 123 | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | 128 | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | ||
| 124 | </a> | 129 | </a> | ||
| 125 | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | 130 | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | ||
| @@ -129,8 +134,10 @@ | |||||
| 129 | <kbd aria-hidden="true">x</kbd> | 134 | <kbd aria-hidden="true">x</kbd> | ||
| 130 | </button> | 135 | </button> | ||
| 131 | </div> | 136 | </div> | ||
| 137 | + | </details> | |||
| 138 | + | </div> | |||
| 132 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | 139 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | ||
| 133 | - | <details class="dfile lnw-2" id="f1" data-file open> | 140 | + | <details class="dfile lnw-2" id="f1" data-file="side.txt" open> |
| 134 | <summary> | 141 | <summary> | ||
| 135 | <span class="st">A</span> | 142 | <span class="st">A</span> | ||
| 136 | <span class="p">side.txt</span> | 143 | <span class="p">side.txt</span> | ||
| @@ -315,6 +322,12 @@ | |||||
| 315 | </div> | 322 | </div> | ||
| 316 | <div> | 323 | <div> | ||
| 317 | <dt> | 324 | <dt> | ||
| 325 | + | <kbd>w</kbd> | |||
| 326 | + | </dt> | |||
| 327 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |||
| 328 | + | </div> | |||
| 329 | + | <div> | |||
| 330 | + | <dt> | |||
| 318 | <kbd>y</kbd> | 331 | <kbd>y</kbd> | ||
| 319 | </dt> | 332 | </dt> | ||
| 320 | <dd>copy a permalink pinned to the commit</dd> | 333 | <dd>copy a permalink pinned to the commit</dd> | ||
| @@ -492,6 +505,7 @@ | |||||
| 492 | k-split from:body -> ermite.layout('split'); | 505 | k-split from:body -> ermite.layout('split'); | ||
| 493 | k-unified from:body -> ermite.layout('unified'); | 506 | k-unified from:body -> ermite.layout('unified'); | ||
| 494 | k-collapse from:body -> ermite.collapse(); | 507 | k-collapse from:body -> ermite.collapse(); | ||
| 508 | + | k-wide from:body -> window.ermiteWide.toggle(); | |||
| 495 | k-expand from:body -> ermite.expandFile(); | 509 | k-expand from:body -> ermite.expandFile(); | ||
| 496 | k-expandAll from:body -> ermite.expandAll(); | 510 | k-expandAll from:body -> ermite.expandAll(); | ||
| 497 | k-permalink from:body -> ermite.permalink(); | 511 | k-permalink from:body -> ermite.permalink(); | ||
M internal/app/testdata/web/commit-modify-split.html +17 -3
| @@ -20,7 +20,7 @@ | |||
| 20 | 20 | </script> | |
| 21 | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 22 | 22 | </script> | |
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | |
| 23 | + | <script src="/-/static/js/store.HASH.js"> | |
| 24 | 24 | </script> | |
| 25 | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 26 | 26 | </script> | |
| @@ -127,6 +127,11 @@ And <b>html</b> & "quotes".</pre> | |||
| 127 | 127 | </span> | |
| 128 | 128 | <span class="grow"> | |
| 129 | 129 | </span> | |
| 130 | + | <details class="menu vmenu"> | |
| 131 | + | <summary class="btn" aria-label="Diff view"> | |
| 132 | + | <span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Split</span> | |
| 133 | + | </summary> | |
| 134 | + | <div class="menu-pop" role="group" aria-label="Diff view"> | |
| 130 | 135 | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 131 | 136 | </a> | |
| 132 | 137 | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" aria-current="true" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| @@ -136,8 +141,10 @@ And <b>html</b> & "quotes".</pre> | |||
| 136 | 141 | <kbd aria-hidden="true">x</kbd> | |
| 137 | 142 | </button> | |
| 138 | 143 | </div> | |
| 144 | + | </details> | |
| 145 | + | </div> | |
| 139 | 146 | <div id="diffs" data-diff="split"> | |
| 140 | - | <details class="dfile lnw-2" id="f1" data-file open> | |
| 147 | + | <details class="dfile lnw-2" id="f1" data-file="a.txt" open> | |
| 141 | 148 | <summary> | |
| 142 | 149 | <span class="st">M</span> | |
| 143 | 150 | <span class="p">a.txt</span> | |
| @@ -277,7 +284,7 @@ And <b>html</b> & "quotes".</pre> | |||
| 277 | 284 | </table> | |
| 278 | 285 | </div> | |
| 279 | 286 | </details> | |
| 280 | - | <details class="dfile lnw-2" id="f2" data-file> | |
| 287 | + | <details class="dfile lnw-2" id="f2" data-file="run.sh"> | |
| 281 | 288 | <summary> | |
| 282 | 289 | <span class="st">M</span> | |
| 283 | 290 | <span class="p">run.sh</span> | |
| @@ -438,6 +445,12 @@ And <b>html</b> & "quotes".</pre> | |||
| 438 | 445 | </div> | |
| 439 | 446 | <div> | |
| 440 | 447 | <dt> | |
| 448 | + | <kbd>w</kbd> | |
| 449 | + | </dt> | |
| 450 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |
| 451 | + | </div> | |
| 452 | + | <div> | |
| 453 | + | <dt> | |
| 441 | 454 | <kbd>y</kbd> | |
| 442 | 455 | </dt> | |
| 443 | 456 | <dd>copy a permalink pinned to the commit</dd> | |
| @@ -615,6 +628,7 @@ And <b>html</b> & "quotes".</pre> | |||
| 615 | 628 | k-split from:body -> ermite.layout('split'); | |
| 616 | 629 | k-unified from:body -> ermite.layout('unified'); | |
| 617 | 630 | k-collapse from:body -> ermite.collapse(); | |
| 631 | + | k-wide from:body -> window.ermiteWide.toggle(); | |
| 618 | 632 | k-expand from:body -> ermite.expandFile(); | |
| 619 | 633 | k-expandAll from:body -> ermite.expandAll(); | |
| 620 | 634 | k-permalink from:body -> ermite.permalink(); | |
| @@ -20,7 +20,7 @@ | |||||
| 20 | </script> | 20 | </script> | ||
| 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | ||
| 22 | </script> | 22 | </script> | ||
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | 23 | + | <script src="/-/static/js/store.HASH.js"> |
| 24 | </script> | 24 | </script> | ||
| 25 | <script src="/-/static/js/ermite.HASH.js" defer> | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 26 | </script> | 26 | </script> | ||
| @@ -127,6 +127,11 @@ And <b>html</b> & "quotes".</pre> | |||||
| 127 | </span> | 127 | </span> | ||
| 128 | <span class="grow"> | 128 | <span class="grow"> | ||
| 129 | </span> | 129 | </span> | ||
| 130 | + | <details class="menu vmenu"> | |||
| 131 | + | <summary class="btn" aria-label="Diff view"> | |||
| 132 | + | <span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Split</span> | |||
| 133 | + | </summary> | |||
| 134 | + | <div class="menu-pop" role="group" aria-label="Diff view"> | |||
| 130 | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | 135 | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | ||
| 131 | </a> | 136 | </a> | ||
| 132 | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" aria-current="true" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | 137 | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" aria-current="true" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | ||
| @@ -136,8 +141,10 @@ And <b>html</b> & "quotes".</pre> | |||||
| 136 | <kbd aria-hidden="true">x</kbd> | 141 | <kbd aria-hidden="true">x</kbd> | ||
| 137 | </button> | 142 | </button> | ||
| 138 | </div> | 143 | </div> | ||
| 144 | + | </details> | |||
| 145 | + | </div> | |||
| 139 | <div id="diffs" data-diff="split"> | 146 | <div id="diffs" data-diff="split"> | ||
| 140 | - | <details class="dfile lnw-2" id="f1" data-file open> | 147 | + | <details class="dfile lnw-2" id="f1" data-file="a.txt" open> |
| 141 | <summary> | 148 | <summary> | ||
| 142 | <span class="st">M</span> | 149 | <span class="st">M</span> | ||
| 143 | <span class="p">a.txt</span> | 150 | <span class="p">a.txt</span> | ||
| @@ -277,7 +284,7 @@ And <b>html</b> & "quotes".</pre> | |||||
| 277 | </table> | 284 | </table> | ||
| 278 | </div> | 285 | </div> | ||
| 279 | </details> | 286 | </details> | ||
| 280 | - | <details class="dfile lnw-2" id="f2" data-file> | 287 | + | <details class="dfile lnw-2" id="f2" data-file="run.sh"> |
| 281 | <summary> | 288 | <summary> | ||
| 282 | <span class="st">M</span> | 289 | <span class="st">M</span> | ||
| 283 | <span class="p">run.sh</span> | 290 | <span class="p">run.sh</span> | ||
| @@ -438,6 +445,12 @@ And <b>html</b> & "quotes".</pre> | |||||
| 438 | </div> | 445 | </div> | ||
| 439 | <div> | 446 | <div> | ||
| 440 | <dt> | 447 | <dt> | ||
| 448 | + | <kbd>w</kbd> | |||
| 449 | + | </dt> | |||
| 450 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |||
| 451 | + | </div> | |||
| 452 | + | <div> | |||
| 453 | + | <dt> | |||
| 441 | <kbd>y</kbd> | 454 | <kbd>y</kbd> | ||
| 442 | </dt> | 455 | </dt> | ||
| 443 | <dd>copy a permalink pinned to the commit</dd> | 456 | <dd>copy a permalink pinned to the commit</dd> | ||
| @@ -615,6 +628,7 @@ And <b>html</b> & "quotes".</pre> | |||||
| 615 | k-split from:body -> ermite.layout('split'); | 628 | k-split from:body -> ermite.layout('split'); | ||
| 616 | k-unified from:body -> ermite.layout('unified'); | 629 | k-unified from:body -> ermite.layout('unified'); | ||
| 617 | k-collapse from:body -> ermite.collapse(); | 630 | k-collapse from:body -> ermite.collapse(); | ||
| 631 | + | k-wide from:body -> window.ermiteWide.toggle(); | |||
| 618 | k-expand from:body -> ermite.expandFile(); | 632 | k-expand from:body -> ermite.expandFile(); | ||
| 619 | k-expandAll from:body -> ermite.expandAll(); | 633 | k-expandAll from:body -> ermite.expandAll(); | ||
| 620 | k-permalink from:body -> ermite.permalink(); | 634 | k-permalink from:body -> ermite.permalink(); | ||
M internal/app/testdata/web/commit-modify.html +17 -3
| @@ -20,7 +20,7 @@ | |||
| 20 | 20 | </script> | |
| 21 | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 22 | 22 | </script> | |
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | |
| 23 | + | <script src="/-/static/js/store.HASH.js"> | |
| 24 | 24 | </script> | |
| 25 | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 26 | 26 | </script> | |
| @@ -127,6 +127,11 @@ And <b>html</b> & "quotes".</pre> | |||
| 127 | 127 | </span> | |
| 128 | 128 | <span class="grow"> | |
| 129 | 129 | </span> | |
| 130 | + | <details class="menu vmenu"> | |
| 131 | + | <summary class="btn" aria-label="Diff view"> | |
| 132 | + | <span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> | |
| 133 | + | </summary> | |
| 134 | + | <div class="menu-pop" role="group" aria-label="Diff view"> | |
| 130 | 135 | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 131 | 136 | </a> | |
| 132 | 137 | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| @@ -136,8 +141,10 @@ And <b>html</b> & "quotes".</pre> | |||
| 136 | 141 | <kbd aria-hidden="true">x</kbd> | |
| 137 | 142 | </button> | |
| 138 | 143 | </div> | |
| 144 | + | </details> | |
| 145 | + | </div> | |
| 139 | 146 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | |
| 140 | - | <details class="dfile lnw-2" id="f1" data-file open> | |
| 147 | + | <details class="dfile lnw-2" id="f1" data-file="a.txt" open> | |
| 141 | 148 | <summary> | |
| 142 | 149 | <span class="st">M</span> | |
| 143 | 150 | <span class="p">a.txt</span> | |
| @@ -277,7 +284,7 @@ And <b>html</b> & "quotes".</pre> | |||
| 277 | 284 | </table> | |
| 278 | 285 | </div> | |
| 279 | 286 | </details> | |
| 280 | - | <details class="dfile lnw-2" id="f2" data-file> | |
| 287 | + | <details class="dfile lnw-2" id="f2" data-file="run.sh"> | |
| 281 | 288 | <summary> | |
| 282 | 289 | <span class="st">M</span> | |
| 283 | 290 | <span class="p">run.sh</span> | |
| @@ -438,6 +445,12 @@ And <b>html</b> & "quotes".</pre> | |||
| 438 | 445 | </div> | |
| 439 | 446 | <div> | |
| 440 | 447 | <dt> | |
| 448 | + | <kbd>w</kbd> | |
| 449 | + | </dt> | |
| 450 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |
| 451 | + | </div> | |
| 452 | + | <div> | |
| 453 | + | <dt> | |
| 441 | 454 | <kbd>y</kbd> | |
| 442 | 455 | </dt> | |
| 443 | 456 | <dd>copy a permalink pinned to the commit</dd> | |
| @@ -615,6 +628,7 @@ And <b>html</b> & "quotes".</pre> | |||
| 615 | 628 | k-split from:body -> ermite.layout('split'); | |
| 616 | 629 | k-unified from:body -> ermite.layout('unified'); | |
| 617 | 630 | k-collapse from:body -> ermite.collapse(); | |
| 631 | + | k-wide from:body -> window.ermiteWide.toggle(); | |
| 618 | 632 | k-expand from:body -> ermite.expandFile(); | |
| 619 | 633 | k-expandAll from:body -> ermite.expandAll(); | |
| 620 | 634 | k-permalink from:body -> ermite.permalink(); | |
| @@ -20,7 +20,7 @@ | |||||
| 20 | </script> | 20 | </script> | ||
| 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | ||
| 22 | </script> | 22 | </script> | ||
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | 23 | + | <script src="/-/static/js/store.HASH.js"> |
| 24 | </script> | 24 | </script> | ||
| 25 | <script src="/-/static/js/ermite.HASH.js" defer> | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 26 | </script> | 26 | </script> | ||
| @@ -127,6 +127,11 @@ And <b>html</b> & "quotes".</pre> | |||||
| 127 | </span> | 127 | </span> | ||
| 128 | <span class="grow"> | 128 | <span class="grow"> | ||
| 129 | </span> | 129 | </span> | ||
| 130 | + | <details class="menu vmenu"> | |||
| 131 | + | <summary class="btn" aria-label="Diff view"> | |||
| 132 | + | <span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> | |||
| 133 | + | </summary> | |||
| 134 | + | <div class="menu-pop" role="group" aria-label="Diff view"> | |||
| 130 | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | 135 | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | ||
| 131 | </a> | 136 | </a> | ||
| 132 | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | 137 | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | ||
| @@ -136,8 +141,10 @@ And <b>html</b> & "quotes".</pre> | |||||
| 136 | <kbd aria-hidden="true">x</kbd> | 141 | <kbd aria-hidden="true">x</kbd> | ||
| 137 | </button> | 142 | </button> | ||
| 138 | </div> | 143 | </div> | ||
| 144 | + | </details> | |||
| 145 | + | </div> | |||
| 139 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | 146 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | ||
| 140 | - | <details class="dfile lnw-2" id="f1" data-file open> | 147 | + | <details class="dfile lnw-2" id="f1" data-file="a.txt" open> |
| 141 | <summary> | 148 | <summary> | ||
| 142 | <span class="st">M</span> | 149 | <span class="st">M</span> | ||
| 143 | <span class="p">a.txt</span> | 150 | <span class="p">a.txt</span> | ||
| @@ -277,7 +284,7 @@ And <b>html</b> & "quotes".</pre> | |||||
| 277 | </table> | 284 | </table> | ||
| 278 | </div> | 285 | </div> | ||
| 279 | </details> | 286 | </details> | ||
| 280 | - | <details class="dfile lnw-2" id="f2" data-file> | 287 | + | <details class="dfile lnw-2" id="f2" data-file="run.sh"> |
| 281 | <summary> | 288 | <summary> | ||
| 282 | <span class="st">M</span> | 289 | <span class="st">M</span> | ||
| 283 | <span class="p">run.sh</span> | 290 | <span class="p">run.sh</span> | ||
| @@ -438,6 +445,12 @@ And <b>html</b> & "quotes".</pre> | |||||
| 438 | </div> | 445 | </div> | ||
| 439 | <div> | 446 | <div> | ||
| 440 | <dt> | 447 | <dt> | ||
| 448 | + | <kbd>w</kbd> | |||
| 449 | + | </dt> | |||
| 450 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |||
| 451 | + | </div> | |||
| 452 | + | <div> | |||
| 453 | + | <dt> | |||
| 441 | <kbd>y</kbd> | 454 | <kbd>y</kbd> | ||
| 442 | </dt> | 455 | </dt> | ||
| 443 | <dd>copy a permalink pinned to the commit</dd> | 456 | <dd>copy a permalink pinned to the commit</dd> | ||
| @@ -615,6 +628,7 @@ And <b>html</b> & "quotes".</pre> | |||||
| 615 | k-split from:body -> ermite.layout('split'); | 628 | k-split from:body -> ermite.layout('split'); | ||
| 616 | k-unified from:body -> ermite.layout('unified'); | 629 | k-unified from:body -> ermite.layout('unified'); | ||
| 617 | k-collapse from:body -> ermite.collapse(); | 630 | k-collapse from:body -> ermite.collapse(); | ||
| 631 | + | k-wide from:body -> window.ermiteWide.toggle(); | |||
| 618 | k-expand from:body -> ermite.expandFile(); | 632 | k-expand from:body -> ermite.expandFile(); | ||
| 619 | k-expandAll from:body -> ermite.expandAll(); | 633 | k-expandAll from:body -> ermite.expandAll(); | ||
| 620 | k-permalink from:body -> ermite.permalink(); | 634 | k-permalink from:body -> ermite.permalink(); | ||
M internal/app/testdata/web/commit-rename.html +17 -3
| @@ -20,7 +20,7 @@ | |||
| 20 | 20 | </script> | |
| 21 | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 22 | 22 | </script> | |
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | |
| 23 | + | <script src="/-/static/js/store.HASH.js"> | |
| 24 | 24 | </script> | |
| 25 | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 26 | 26 | </script> | |
| @@ -121,6 +121,11 @@ | |||
| 121 | 121 | </span> | |
| 122 | 122 | <span class="grow"> | |
| 123 | 123 | </span> | |
| 124 | + | <details class="menu vmenu"> | |
| 125 | + | <summary class="btn" aria-label="Diff view"> | |
| 126 | + | <span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> | |
| 127 | + | </summary> | |
| 128 | + | <div class="menu-pop" role="group" aria-label="Diff view"> | |
| 124 | 129 | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 125 | 130 | </a> | |
| 126 | 131 | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| @@ -130,8 +135,10 @@ | |||
| 130 | 135 | <kbd aria-hidden="true">x</kbd> | |
| 131 | 136 | </button> | |
| 132 | 137 | </div> | |
| 138 | + | </details> | |
| 139 | + | </div> | |
| 133 | 140 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | |
| 134 | - | <details class="dfile lnw-2" id="f1" data-file> | |
| 141 | + | <details class="dfile lnw-2" id="f1" data-file="docs-old.txt"> | |
| 135 | 142 | <summary> | |
| 136 | 143 | <span class="st">R</span> | |
| 137 | 144 | <span class="p">old.txt → docs-old.txt</span> | |
| @@ -141,7 +148,7 @@ | |||
| 141 | 148 | </summary> | |
| 142 | 149 | <p class="note pad">renamed, 100% similar</p> | |
| 143 | 150 | </details> | |
| 144 | - | <details class="dfile lnw-2" id="f2" data-file open> | |
| 151 | + | <details class="dfile lnw-2" id="f2" data-file="gone.txt" open> | |
| 145 | 152 | <summary> | |
| 146 | 153 | <span class="st">D</span> | |
| 147 | 154 | <span class="p">gone.txt</span> | |
| @@ -342,6 +349,12 @@ | |||
| 342 | 349 | </div> | |
| 343 | 350 | <div> | |
| 344 | 351 | <dt> | |
| 352 | + | <kbd>w</kbd> | |
| 353 | + | </dt> | |
| 354 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |
| 355 | + | </div> | |
| 356 | + | <div> | |
| 357 | + | <dt> | |
| 345 | 358 | <kbd>y</kbd> | |
| 346 | 359 | </dt> | |
| 347 | 360 | <dd>copy a permalink pinned to the commit</dd> | |
| @@ -519,6 +532,7 @@ | |||
| 519 | 532 | k-split from:body -> ermite.layout('split'); | |
| 520 | 533 | k-unified from:body -> ermite.layout('unified'); | |
| 521 | 534 | k-collapse from:body -> ermite.collapse(); | |
| 535 | + | k-wide from:body -> window.ermiteWide.toggle(); | |
| 522 | 536 | k-expand from:body -> ermite.expandFile(); | |
| 523 | 537 | k-expandAll from:body -> ermite.expandAll(); | |
| 524 | 538 | k-permalink from:body -> ermite.permalink(); | |
| @@ -20,7 +20,7 @@ | |||||
| 20 | </script> | 20 | </script> | ||
| 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | ||
| 22 | </script> | 22 | </script> | ||
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | 23 | + | <script src="/-/static/js/store.HASH.js"> |
| 24 | </script> | 24 | </script> | ||
| 25 | <script src="/-/static/js/ermite.HASH.js" defer> | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 26 | </script> | 26 | </script> | ||
| @@ -121,6 +121,11 @@ | |||||
| 121 | </span> | 121 | </span> | ||
| 122 | <span class="grow"> | 122 | <span class="grow"> | ||
| 123 | </span> | 123 | </span> | ||
| 124 | + | <details class="menu vmenu"> | |||
| 125 | + | <summary class="btn" aria-label="Diff view"> | |||
| 126 | + | <span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> | |||
| 127 | + | </summary> | |||
| 128 | + | <div class="menu-pop" role="group" aria-label="Diff view"> | |||
| 124 | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | 129 | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | ||
| 125 | </a> | 130 | </a> | ||
| 126 | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | 131 | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | ||
| @@ -130,8 +135,10 @@ | |||||
| 130 | <kbd aria-hidden="true">x</kbd> | 135 | <kbd aria-hidden="true">x</kbd> | ||
| 131 | </button> | 136 | </button> | ||
| 132 | </div> | 137 | </div> | ||
| 138 | + | </details> | |||
| 139 | + | </div> | |||
| 133 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | 140 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | ||
| 134 | - | <details class="dfile lnw-2" id="f1" data-file> | 141 | + | <details class="dfile lnw-2" id="f1" data-file="docs-old.txt"> |
| 135 | <summary> | 142 | <summary> | ||
| 136 | <span class="st">R</span> | 143 | <span class="st">R</span> | ||
| 137 | <span class="p">old.txt → docs-old.txt</span> | 144 | <span class="p">old.txt → docs-old.txt</span> | ||
| @@ -141,7 +148,7 @@ | |||||
| 141 | </summary> | 148 | </summary> | ||
| 142 | <p class="note pad">renamed, 100% similar</p> | 149 | <p class="note pad">renamed, 100% similar</p> | ||
| 143 | </details> | 150 | </details> | ||
| 144 | - | <details class="dfile lnw-2" id="f2" data-file open> | 151 | + | <details class="dfile lnw-2" id="f2" data-file="gone.txt" open> |
| 145 | <summary> | 152 | <summary> | ||
| 146 | <span class="st">D</span> | 153 | <span class="st">D</span> | ||
| 147 | <span class="p">gone.txt</span> | 154 | <span class="p">gone.txt</span> | ||
| @@ -342,6 +349,12 @@ | |||||
| 342 | </div> | 349 | </div> | ||
| 343 | <div> | 350 | <div> | ||
| 344 | <dt> | 351 | <dt> | ||
| 352 | + | <kbd>w</kbd> | |||
| 353 | + | </dt> | |||
| 354 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |||
| 355 | + | </div> | |||
| 356 | + | <div> | |||
| 357 | + | <dt> | |||
| 345 | <kbd>y</kbd> | 358 | <kbd>y</kbd> | ||
| 346 | </dt> | 359 | </dt> | ||
| 347 | <dd>copy a permalink pinned to the commit</dd> | 360 | <dd>copy a permalink pinned to the commit</dd> | ||
| @@ -519,6 +532,7 @@ | |||||
| 519 | k-split from:body -> ermite.layout('split'); | 532 | k-split from:body -> ermite.layout('split'); | ||
| 520 | k-unified from:body -> ermite.layout('unified'); | 533 | k-unified from:body -> ermite.layout('unified'); | ||
| 521 | k-collapse from:body -> ermite.collapse(); | 534 | k-collapse from:body -> ermite.collapse(); | ||
| 535 | + | k-wide from:body -> window.ermiteWide.toggle(); | |||
| 522 | k-expand from:body -> ermite.expandFile(); | 536 | k-expand from:body -> ermite.expandFile(); | ||
| 523 | k-expandAll from:body -> ermite.expandAll(); | 537 | k-expandAll from:body -> ermite.expandAll(); | ||
| 524 | k-permalink from:body -> ermite.permalink(); | 538 | k-permalink from:body -> ermite.permalink(); | ||
M internal/app/testdata/web/commit-root.html +20 -6
| @@ -20,7 +20,7 @@ | |||
| 20 | 20 | </script> | |
| 21 | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 22 | 22 | </script> | |
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | |
| 23 | + | <script src="/-/static/js/store.HASH.js"> | |
| 24 | 24 | </script> | |
| 25 | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 26 | 26 | </script> | |
| @@ -119,6 +119,11 @@ | |||
| 119 | 119 | </span> | |
| 120 | 120 | <span class="grow"> | |
| 121 | 121 | </span> | |
| 122 | + | <details class="menu vmenu"> | |
| 123 | + | <summary class="btn" aria-label="Diff view"> | |
| 124 | + | <span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> | |
| 125 | + | </summary> | |
| 126 | + | <div class="menu-pop" role="group" aria-label="Diff view"> | |
| 122 | 127 | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 123 | 128 | </a> | |
| 124 | 129 | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| @@ -128,8 +133,10 @@ | |||
| 128 | 133 | <kbd aria-hidden="true">x</kbd> | |
| 129 | 134 | </button> | |
| 130 | 135 | </div> | |
| 136 | + | </details> | |
| 137 | + | </div> | |
| 131 | 138 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | |
| 132 | - | <details class="dfile lnw-2" id="f1" data-file open> | |
| 139 | + | <details class="dfile lnw-2" id="f1" data-file="a.txt" open> | |
| 133 | 140 | <summary> | |
| 134 | 141 | <span class="st">A</span> | |
| 135 | 142 | <span class="p">a.txt</span> | |
| @@ -251,7 +258,7 @@ | |||
| 251 | 258 | </table> | |
| 252 | 259 | </div> | |
| 253 | 260 | </details> | |
| 254 | - | <details class="dfile lnw-2" id="f2" data-file open> | |
| 261 | + | <details class="dfile lnw-2" id="f2" data-file="gone.txt" open> | |
| 255 | 262 | <summary> | |
| 256 | 263 | <span class="st">A</span> | |
| 257 | 264 | <span class="p">gone.txt</span> | |
| @@ -301,7 +308,7 @@ | |||
| 301 | 308 | </table> | |
| 302 | 309 | </div> | |
| 303 | 310 | </details> | |
| 304 | - | <details class="dfile lnw-2" id="f3" data-file> | |
| 311 | + | <details class="dfile lnw-2" id="f3" data-file="logo.bin"> | |
| 305 | 312 | <summary> | |
| 306 | 313 | <span class="st">A</span> | |
| 307 | 314 | <span class="p">logo.bin</span> | |
| @@ -311,7 +318,7 @@ | |||
| 311 | 318 | </summary> | |
| 312 | 319 | <p class="note pad">binary file: no text diff</p> | |
| 313 | 320 | </details> | |
| 314 | - | <details class="dfile lnw-2" id="f4" data-file open> | |
| 321 | + | <details class="dfile lnw-2" id="f4" data-file="old.txt" open> | |
| 315 | 322 | <summary> | |
| 316 | 323 | <span class="st">A</span> | |
| 317 | 324 | <span class="p">old.txt</span> | |
| @@ -397,7 +404,7 @@ | |||
| 397 | 404 | </table> | |
| 398 | 405 | </div> | |
| 399 | 406 | </details> | |
| 400 | - | <details class="dfile lnw-2" id="f5" data-file open> | |
| 407 | + | <details class="dfile lnw-2" id="f5" data-file="run.sh" open> | |
| 401 | 408 | <summary> | |
| 402 | 409 | <span class="st">A</span> | |
| 403 | 410 | <span class="p">run.sh</span> | |
| @@ -661,6 +668,12 @@ | |||
| 661 | 668 | </div> | |
| 662 | 669 | <div> | |
| 663 | 670 | <dt> | |
| 671 | + | <kbd>w</kbd> | |
| 672 | + | </dt> | |
| 673 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |
| 674 | + | </div> | |
| 675 | + | <div> | |
| 676 | + | <dt> | |
| 664 | 677 | <kbd>y</kbd> | |
| 665 | 678 | </dt> | |
| 666 | 679 | <dd>copy a permalink pinned to the commit</dd> | |
| @@ -838,6 +851,7 @@ | |||
| 838 | 851 | k-split from:body -> ermite.layout('split'); | |
| 839 | 852 | k-unified from:body -> ermite.layout('unified'); | |
| 840 | 853 | k-collapse from:body -> ermite.collapse(); | |
| 854 | + | k-wide from:body -> window.ermiteWide.toggle(); | |
| 841 | 855 | k-expand from:body -> ermite.expandFile(); | |
| 842 | 856 | k-expandAll from:body -> ermite.expandAll(); | |
| 843 | 857 | k-permalink from:body -> ermite.permalink(); | |
| @@ -20,7 +20,7 @@ | |||||
| 20 | </script> | 20 | </script> | ||
| 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | ||
| 22 | </script> | 22 | </script> | ||
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | 23 | + | <script src="/-/static/js/store.HASH.js"> |
| 24 | </script> | 24 | </script> | ||
| 25 | <script src="/-/static/js/ermite.HASH.js" defer> | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 26 | </script> | 26 | </script> | ||
| @@ -119,6 +119,11 @@ | |||||
| 119 | </span> | 119 | </span> | ||
| 120 | <span class="grow"> | 120 | <span class="grow"> | ||
| 121 | </span> | 121 | </span> | ||
| 122 | + | <details class="menu vmenu"> | |||
| 123 | + | <summary class="btn" aria-label="Diff view"> | |||
| 124 | + | <span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> | |||
| 125 | + | </summary> | |||
| 126 | + | <div class="menu-pop" role="group" aria-label="Diff view"> | |||
| 122 | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | 127 | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | ||
| 123 | </a> | 128 | </a> | ||
| 124 | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | 129 | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | ||
| @@ -128,8 +133,10 @@ | |||||
| 128 | <kbd aria-hidden="true">x</kbd> | 133 | <kbd aria-hidden="true">x</kbd> | ||
| 129 | </button> | 134 | </button> | ||
| 130 | </div> | 135 | </div> | ||
| 136 | + | </details> | |||
| 137 | + | </div> | |||
| 131 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | 138 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | ||
| 132 | - | <details class="dfile lnw-2" id="f1" data-file open> | 139 | + | <details class="dfile lnw-2" id="f1" data-file="a.txt" open> |
| 133 | <summary> | 140 | <summary> | ||
| 134 | <span class="st">A</span> | 141 | <span class="st">A</span> | ||
| 135 | <span class="p">a.txt</span> | 142 | <span class="p">a.txt</span> | ||
| @@ -251,7 +258,7 @@ | |||||
| 251 | </table> | 258 | </table> | ||
| 252 | </div> | 259 | </div> | ||
| 253 | </details> | 260 | </details> | ||
| 254 | - | <details class="dfile lnw-2" id="f2" data-file open> | 261 | + | <details class="dfile lnw-2" id="f2" data-file="gone.txt" open> |
| 255 | <summary> | 262 | <summary> | ||
| 256 | <span class="st">A</span> | 263 | <span class="st">A</span> | ||
| 257 | <span class="p">gone.txt</span> | 264 | <span class="p">gone.txt</span> | ||
| @@ -301,7 +308,7 @@ | |||||
| 301 | </table> | 308 | </table> | ||
| 302 | </div> | 309 | </div> | ||
| 303 | </details> | 310 | </details> | ||
| 304 | - | <details class="dfile lnw-2" id="f3" data-file> | 311 | + | <details class="dfile lnw-2" id="f3" data-file="logo.bin"> |
| 305 | <summary> | 312 | <summary> | ||
| 306 | <span class="st">A</span> | 313 | <span class="st">A</span> | ||
| 307 | <span class="p">logo.bin</span> | 314 | <span class="p">logo.bin</span> | ||
| @@ -311,7 +318,7 @@ | |||||
| 311 | </summary> | 318 | </summary> | ||
| 312 | <p class="note pad">binary file: no text diff</p> | 319 | <p class="note pad">binary file: no text diff</p> | ||
| 313 | </details> | 320 | </details> | ||
| 314 | - | <details class="dfile lnw-2" id="f4" data-file open> | 321 | + | <details class="dfile lnw-2" id="f4" data-file="old.txt" open> |
| 315 | <summary> | 322 | <summary> | ||
| 316 | <span class="st">A</span> | 323 | <span class="st">A</span> | ||
| 317 | <span class="p">old.txt</span> | 324 | <span class="p">old.txt</span> | ||
| @@ -397,7 +404,7 @@ | |||||
| 397 | </table> | 404 | </table> | ||
| 398 | </div> | 405 | </div> | ||
| 399 | </details> | 406 | </details> | ||
| 400 | - | <details class="dfile lnw-2" id="f5" data-file open> | 407 | + | <details class="dfile lnw-2" id="f5" data-file="run.sh" open> |
| 401 | <summary> | 408 | <summary> | ||
| 402 | <span class="st">A</span> | 409 | <span class="st">A</span> | ||
| 403 | <span class="p">run.sh</span> | 410 | <span class="p">run.sh</span> | ||
| @@ -661,6 +668,12 @@ | |||||
| 661 | </div> | 668 | </div> | ||
| 662 | <div> | 669 | <div> | ||
| 663 | <dt> | 670 | <dt> | ||
| 671 | + | <kbd>w</kbd> | |||
| 672 | + | </dt> | |||
| 673 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |||
| 674 | + | </div> | |||
| 675 | + | <div> | |||
| 676 | + | <dt> | |||
| 664 | <kbd>y</kbd> | 677 | <kbd>y</kbd> | ||
| 665 | </dt> | 678 | </dt> | ||
| 666 | <dd>copy a permalink pinned to the commit</dd> | 679 | <dd>copy a permalink pinned to the commit</dd> | ||
| @@ -838,6 +851,7 @@ | |||||
| 838 | k-split from:body -> ermite.layout('split'); | 851 | k-split from:body -> ermite.layout('split'); | ||
| 839 | k-unified from:body -> ermite.layout('unified'); | 852 | k-unified from:body -> ermite.layout('unified'); | ||
| 840 | k-collapse from:body -> ermite.collapse(); | 853 | k-collapse from:body -> ermite.collapse(); | ||
| 854 | + | k-wide from:body -> window.ermiteWide.toggle(); | |||
| 841 | k-expand from:body -> ermite.expandFile(); | 855 | k-expand from:body -> ermite.expandFile(); | ||
| 842 | k-expandAll from:body -> ermite.expandAll(); | 856 | k-expandAll from:body -> ermite.expandAll(); | ||
| 843 | k-permalink from:body -> ermite.permalink(); | 857 | k-permalink from:body -> ermite.permalink(); | ||
M internal/app/testdata/web/commit-trojan.html +16 -2
| @@ -20,7 +20,7 @@ | |||
| 20 | 20 | </script> | |
| 21 | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 22 | 22 | </script> | |
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | |
| 23 | + | <script src="/-/static/js/store.HASH.js"> | |
| 24 | 24 | </script> | |
| 25 | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 26 | 26 | </script> | |
| @@ -122,6 +122,11 @@ | |||
| 122 | 122 | </span> | |
| 123 | 123 | <span class="grow"> | |
| 124 | 124 | </span> | |
| 125 | + | <details class="menu vmenu"> | |
| 126 | + | <summary class="btn" aria-label="Diff view"> | |
| 127 | + | <span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> | |
| 128 | + | </summary> | |
| 129 | + | <div class="menu-pop" role="group" aria-label="Diff view"> | |
| 125 | 130 | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 126 | 131 | </a> | |
| 127 | 132 | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| @@ -131,9 +136,11 @@ | |||
| 131 | 136 | <kbd aria-hidden="true">x</kbd> | |
| 132 | 137 | </button> | |
| 133 | 138 | </div> | |
| 139 | + | </details> | |
| 140 | + | </div> | |
| 134 | 141 | <p class="notice">this diff holds invisible characters (direction controls, zero-width characters). They are shown as <span class="inv"><U+XXXX></span> markers, never applied.</p> | |
| 135 | 142 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | |
| 136 | - | <details class="dfile lnw-2" id="f1" data-file open> | |
| 143 | + | <details class="dfile lnw-2" id="f1" data-file="evil.go" open> | |
| 137 | 144 | <summary> | |
| 138 | 145 | <span class="st">A</span> | |
| 139 | 146 | <span class="p">evil.go</span> | |
| @@ -490,6 +497,12 @@ | |||
| 490 | 497 | </div> | |
| 491 | 498 | <div> | |
| 492 | 499 | <dt> | |
| 500 | + | <kbd>w</kbd> | |
| 501 | + | </dt> | |
| 502 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |
| 503 | + | </div> | |
| 504 | + | <div> | |
| 505 | + | <dt> | |
| 493 | 506 | <kbd>y</kbd> | |
| 494 | 507 | </dt> | |
| 495 | 508 | <dd>copy a permalink pinned to the commit</dd> | |
| @@ -667,6 +680,7 @@ | |||
| 667 | 680 | k-split from:body -> ermite.layout('split'); | |
| 668 | 681 | k-unified from:body -> ermite.layout('unified'); | |
| 669 | 682 | k-collapse from:body -> ermite.collapse(); | |
| 683 | + | k-wide from:body -> window.ermiteWide.toggle(); | |
| 670 | 684 | k-expand from:body -> ermite.expandFile(); | |
| 671 | 685 | k-expandAll from:body -> ermite.expandAll(); | |
| 672 | 686 | k-permalink from:body -> ermite.permalink(); | |
| @@ -20,7 +20,7 @@ | |||||
| 20 | </script> | 20 | </script> | ||
| 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | ||
| 22 | </script> | 22 | </script> | ||
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | 23 | + | <script src="/-/static/js/store.HASH.js"> |
| 24 | </script> | 24 | </script> | ||
| 25 | <script src="/-/static/js/ermite.HASH.js" defer> | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 26 | </script> | 26 | </script> | ||
| @@ -122,6 +122,11 @@ | |||||
| 122 | </span> | 122 | </span> | ||
| 123 | <span class="grow"> | 123 | <span class="grow"> | ||
| 124 | </span> | 124 | </span> | ||
| 125 | + | <details class="menu vmenu"> | |||
| 126 | + | <summary class="btn" aria-label="Diff view"> | |||
| 127 | + | <span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> | |||
| 128 | + | </summary> | |||
| 129 | + | <div class="menu-pop" role="group" aria-label="Diff view"> | |||
| 125 | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | 130 | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | ||
| 126 | </a> | 131 | </a> | ||
| 127 | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | 132 | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | ||
| @@ -131,9 +136,11 @@ | |||||
| 131 | <kbd aria-hidden="true">x</kbd> | 136 | <kbd aria-hidden="true">x</kbd> | ||
| 132 | </button> | 137 | </button> | ||
| 133 | </div> | 138 | </div> | ||
| 139 | + | </details> | |||
| 140 | + | </div> | |||
| 134 | <p class="notice">this diff holds invisible characters (direction controls, zero-width characters). They are shown as <span class="inv"><U+XXXX></span> markers, never applied.</p> | 141 | <p class="notice">this diff holds invisible characters (direction controls, zero-width characters). They are shown as <span class="inv"><U+XXXX></span> markers, never applied.</p> | ||
| 135 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | 142 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | ||
| 136 | - | <details class="dfile lnw-2" id="f1" data-file open> | 143 | + | <details class="dfile lnw-2" id="f1" data-file="evil.go" open> |
| 137 | <summary> | 144 | <summary> | ||
| 138 | <span class="st">A</span> | 145 | <span class="st">A</span> | ||
| 139 | <span class="p">evil.go</span> | 146 | <span class="p">evil.go</span> | ||
| @@ -490,6 +497,12 @@ | |||||
| 490 | </div> | 497 | </div> | ||
| 491 | <div> | 498 | <div> | ||
| 492 | <dt> | 499 | <dt> | ||
| 500 | + | <kbd>w</kbd> | |||
| 501 | + | </dt> | |||
| 502 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |||
| 503 | + | </div> | |||
| 504 | + | <div> | |||
| 505 | + | <dt> | |||
| 493 | <kbd>y</kbd> | 506 | <kbd>y</kbd> | ||
| 494 | </dt> | 507 | </dt> | ||
| 495 | <dd>copy a permalink pinned to the commit</dd> | 508 | <dd>copy a permalink pinned to the commit</dd> | ||
| @@ -667,6 +680,7 @@ | |||||
| 667 | k-split from:body -> ermite.layout('split'); | 680 | k-split from:body -> ermite.layout('split'); | ||
| 668 | k-unified from:body -> ermite.layout('unified'); | 681 | k-unified from:body -> ermite.layout('unified'); | ||
| 669 | k-collapse from:body -> ermite.collapse(); | 682 | k-collapse from:body -> ermite.collapse(); | ||
| 683 | + | k-wide from:body -> window.ermiteWide.toggle(); | |||
| 670 | k-expand from:body -> ermite.expandFile(); | 684 | k-expand from:body -> ermite.expandFile(); | ||
| 671 | k-expandAll from:body -> ermite.expandAll(); | 685 | k-expandAll from:body -> ermite.expandAll(); | ||
| 672 | k-permalink from:body -> ermite.permalink(); | 686 | k-permalink from:body -> ermite.permalink(); | ||
M internal/app/testdata/web/commits-page2.html +8 -1
| @@ -20,7 +20,7 @@ | |||
| 20 | 20 | </script> | |
| 21 | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 22 | 22 | </script> | |
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | |
| 23 | + | <script src="/-/static/js/store.HASH.js"> | |
| 24 | 24 | </script> | |
| 25 | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 26 | 26 | </script> | |
| @@ -266,6 +266,12 @@ | |||
| 266 | 266 | </div> | |
| 267 | 267 | <div> | |
| 268 | 268 | <dt> | |
| 269 | + | <kbd>w</kbd> | |
| 270 | + | </dt> | |
| 271 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |
| 272 | + | </div> | |
| 273 | + | <div> | |
| 274 | + | <dt> | |
| 269 | 275 | <kbd>y</kbd> | |
| 270 | 276 | </dt> | |
| 271 | 277 | <dd>copy a permalink pinned to the commit</dd> | |
| @@ -429,6 +435,7 @@ | |||
| 429 | 435 | k-split from:body -> ermite.layout('split'); | |
| 430 | 436 | k-unified from:body -> ermite.layout('unified'); | |
| 431 | 437 | k-collapse from:body -> ermite.collapse(); | |
| 438 | + | k-wide from:body -> window.ermiteWide.toggle(); | |
| 432 | 439 | k-expand from:body -> ermite.expandFile(); | |
| 433 | 440 | k-expandAll from:body -> ermite.expandAll(); | |
| 434 | 441 | k-permalink from:body -> ermite.permalink(); | |
| @@ -20,7 +20,7 @@ | |||||
| 20 | </script> | 20 | </script> | ||
| 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | ||
| 22 | </script> | 22 | </script> | ||
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | 23 | + | <script src="/-/static/js/store.HASH.js"> |
| 24 | </script> | 24 | </script> | ||
| 25 | <script src="/-/static/js/ermite.HASH.js" defer> | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 26 | </script> | 26 | </script> | ||
| @@ -266,6 +266,12 @@ | |||||
| 266 | </div> | 266 | </div> | ||
| 267 | <div> | 267 | <div> | ||
| 268 | <dt> | 268 | <dt> | ||
| 269 | + | <kbd>w</kbd> | |||
| 270 | + | </dt> | |||
| 271 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |||
| 272 | + | </div> | |||
| 273 | + | <div> | |||
| 274 | + | <dt> | |||
| 269 | <kbd>y</kbd> | 275 | <kbd>y</kbd> | ||
| 270 | </dt> | 276 | </dt> | ||
| 271 | <dd>copy a permalink pinned to the commit</dd> | 277 | <dd>copy a permalink pinned to the commit</dd> | ||
| @@ -429,6 +435,7 @@ | |||||
| 429 | k-split from:body -> ermite.layout('split'); | 435 | k-split from:body -> ermite.layout('split'); | ||
| 430 | k-unified from:body -> ermite.layout('unified'); | 436 | k-unified from:body -> ermite.layout('unified'); | ||
| 431 | k-collapse from:body -> ermite.collapse(); | 437 | k-collapse from:body -> ermite.collapse(); | ||
| 438 | + | k-wide from:body -> window.ermiteWide.toggle(); | |||
| 432 | k-expand from:body -> ermite.expandFile(); | 439 | k-expand from:body -> ermite.expandFile(); | ||
| 433 | k-expandAll from:body -> ermite.expandAll(); | 440 | k-expandAll from:body -> ermite.expandAll(); | ||
| 434 | k-permalink from:body -> ermite.permalink(); | 441 | k-permalink from:body -> ermite.permalink(); | ||
M internal/app/testdata/web/commits-path.html +8 -1
| @@ -20,7 +20,7 @@ | |||
| 20 | 20 | </script> | |
| 21 | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 22 | 22 | </script> | |
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | |
| 23 | + | <script src="/-/static/js/store.HASH.js"> | |
| 24 | 24 | </script> | |
| 25 | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 26 | 26 | </script> | |
| @@ -281,6 +281,12 @@ | |||
| 281 | 281 | </div> | |
| 282 | 282 | <div> | |
| 283 | 283 | <dt> | |
| 284 | + | <kbd>w</kbd> | |
| 285 | + | </dt> | |
| 286 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |
| 287 | + | </div> | |
| 288 | + | <div> | |
| 289 | + | <dt> | |
| 284 | 290 | <kbd>y</kbd> | |
| 285 | 291 | </dt> | |
| 286 | 292 | <dd>copy a permalink pinned to the commit</dd> | |
| @@ -444,6 +450,7 @@ | |||
| 444 | 450 | k-split from:body -> ermite.layout('split'); | |
| 445 | 451 | k-unified from:body -> ermite.layout('unified'); | |
| 446 | 452 | k-collapse from:body -> ermite.collapse(); | |
| 453 | + | k-wide from:body -> window.ermiteWide.toggle(); | |
| 447 | 454 | k-expand from:body -> ermite.expandFile(); | |
| 448 | 455 | k-expandAll from:body -> ermite.expandAll(); | |
| 449 | 456 | k-permalink from:body -> ermite.permalink(); | |
| @@ -20,7 +20,7 @@ | |||||
| 20 | </script> | 20 | </script> | ||
| 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | ||
| 22 | </script> | 22 | </script> | ||
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | 23 | + | <script src="/-/static/js/store.HASH.js"> |
| 24 | </script> | 24 | </script> | ||
| 25 | <script src="/-/static/js/ermite.HASH.js" defer> | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 26 | </script> | 26 | </script> | ||
| @@ -281,6 +281,12 @@ | |||||
| 281 | </div> | 281 | </div> | ||
| 282 | <div> | 282 | <div> | ||
| 283 | <dt> | 283 | <dt> | ||
| 284 | + | <kbd>w</kbd> | |||
| 285 | + | </dt> | |||
| 286 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |||
| 287 | + | </div> | |||
| 288 | + | <div> | |||
| 289 | + | <dt> | |||
| 284 | <kbd>y</kbd> | 290 | <kbd>y</kbd> | ||
| 285 | </dt> | 291 | </dt> | ||
| 286 | <dd>copy a permalink pinned to the commit</dd> | 292 | <dd>copy a permalink pinned to the commit</dd> | ||
| @@ -444,6 +450,7 @@ | |||||
| 444 | k-split from:body -> ermite.layout('split'); | 450 | k-split from:body -> ermite.layout('split'); | ||
| 445 | k-unified from:body -> ermite.layout('unified'); | 451 | k-unified from:body -> ermite.layout('unified'); | ||
| 446 | k-collapse from:body -> ermite.collapse(); | 452 | k-collapse from:body -> ermite.collapse(); | ||
| 453 | + | k-wide from:body -> window.ermiteWide.toggle(); | |||
| 447 | k-expand from:body -> ermite.expandFile(); | 454 | k-expand from:body -> ermite.expandFile(); | ||
| 448 | k-expandAll from:body -> ermite.expandAll(); | 455 | k-expandAll from:body -> ermite.expandAll(); | ||
| 449 | k-permalink from:body -> ermite.permalink(); | 456 | k-permalink from:body -> ermite.permalink(); | ||
M internal/app/testdata/web/commits-root.html +8 -1
| @@ -20,7 +20,7 @@ | |||
| 20 | 20 | </script> | |
| 21 | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 22 | 22 | </script> | |
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | |
| 23 | + | <script src="/-/static/js/store.HASH.js"> | |
| 24 | 24 | </script> | |
| 25 | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 26 | 26 | </script> | |
| @@ -1070,6 +1070,12 @@ | |||
| 1070 | 1070 | </div> | |
| 1071 | 1071 | <div> | |
| 1072 | 1072 | <dt> | |
| 1073 | + | <kbd>w</kbd> | |
| 1074 | + | </dt> | |
| 1075 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |
| 1076 | + | </div> | |
| 1077 | + | <div> | |
| 1078 | + | <dt> | |
| 1073 | 1079 | <kbd>y</kbd> | |
| 1074 | 1080 | </dt> | |
| 1075 | 1081 | <dd>copy a permalink pinned to the commit</dd> | |
| @@ -1233,6 +1239,7 @@ | |||
| 1233 | 1239 | k-split from:body -> ermite.layout('split'); | |
| 1234 | 1240 | k-unified from:body -> ermite.layout('unified'); | |
| 1235 | 1241 | k-collapse from:body -> ermite.collapse(); | |
| 1242 | + | k-wide from:body -> window.ermiteWide.toggle(); | |
| 1236 | 1243 | k-expand from:body -> ermite.expandFile(); | |
| 1237 | 1244 | k-expandAll from:body -> ermite.expandAll(); | |
| 1238 | 1245 | k-permalink from:body -> ermite.permalink(); | |
| @@ -20,7 +20,7 @@ | |||||
| 20 | </script> | 20 | </script> | ||
| 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | ||
| 22 | </script> | 22 | </script> | ||
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | 23 | + | <script src="/-/static/js/store.HASH.js"> |
| 24 | </script> | 24 | </script> | ||
| 25 | <script src="/-/static/js/ermite.HASH.js" defer> | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 26 | </script> | 26 | </script> | ||
| @@ -1070,6 +1070,12 @@ | |||||
| 1070 | </div> | 1070 | </div> | ||
| 1071 | <div> | 1071 | <div> | ||
| 1072 | <dt> | 1072 | <dt> | ||
| 1073 | + | <kbd>w</kbd> | |||
| 1074 | + | </dt> | |||
| 1075 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |||
| 1076 | + | </div> | |||
| 1077 | + | <div> | |||
| 1078 | + | <dt> | |||
| 1073 | <kbd>y</kbd> | 1079 | <kbd>y</kbd> | ||
| 1074 | </dt> | 1080 | </dt> | ||
| 1075 | <dd>copy a permalink pinned to the commit</dd> | 1081 | <dd>copy a permalink pinned to the commit</dd> | ||
| @@ -1233,6 +1239,7 @@ | |||||
| 1233 | k-split from:body -> ermite.layout('split'); | 1239 | k-split from:body -> ermite.layout('split'); | ||
| 1234 | k-unified from:body -> ermite.layout('unified'); | 1240 | k-unified from:body -> ermite.layout('unified'); | ||
| 1235 | k-collapse from:body -> ermite.collapse(); | 1241 | k-collapse from:body -> ermite.collapse(); | ||
| 1242 | + | k-wide from:body -> window.ermiteWide.toggle(); | |||
| 1236 | k-expand from:body -> ermite.expandFile(); | 1243 | k-expand from:body -> ermite.expandFile(); | ||
| 1237 | k-expandAll from:body -> ermite.expandAll(); | 1244 | k-expandAll from:body -> ermite.expandAll(); | ||
| 1238 | k-permalink from:body -> ermite.permalink(); | 1245 | k-permalink from:body -> ermite.permalink(); | ||
M internal/app/testdata/web/commits-small.html +8 -1
| @@ -20,7 +20,7 @@ | |||
| 20 | 20 | </script> | |
| 21 | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 22 | 22 | </script> | |
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | |
| 23 | + | <script src="/-/static/js/store.HASH.js"> | |
| 24 | 24 | </script> | |
| 25 | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 26 | 26 | </script> | |
| @@ -415,6 +415,12 @@ | |||
| 415 | 415 | </div> | |
| 416 | 416 | <div> | |
| 417 | 417 | <dt> | |
| 418 | + | <kbd>w</kbd> | |
| 419 | + | </dt> | |
| 420 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |
| 421 | + | </div> | |
| 422 | + | <div> | |
| 423 | + | <dt> | |
| 418 | 424 | <kbd>y</kbd> | |
| 419 | 425 | </dt> | |
| 420 | 426 | <dd>copy a permalink pinned to the commit</dd> | |
| @@ -598,6 +604,7 @@ | |||
| 598 | 604 | k-split from:body -> ermite.layout('split'); | |
| 599 | 605 | k-unified from:body -> ermite.layout('unified'); | |
| 600 | 606 | k-collapse from:body -> ermite.collapse(); | |
| 607 | + | k-wide from:body -> window.ermiteWide.toggle(); | |
| 601 | 608 | k-expand from:body -> ermite.expandFile(); | |
| 602 | 609 | k-expandAll from:body -> ermite.expandAll(); | |
| 603 | 610 | k-permalink from:body -> ermite.permalink(); | |
| @@ -20,7 +20,7 @@ | |||||
| 20 | </script> | 20 | </script> | ||
| 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | ||
| 22 | </script> | 22 | </script> | ||
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | 23 | + | <script src="/-/static/js/store.HASH.js"> |
| 24 | </script> | 24 | </script> | ||
| 25 | <script src="/-/static/js/ermite.HASH.js" defer> | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 26 | </script> | 26 | </script> | ||
| @@ -415,6 +415,12 @@ | |||||
| 415 | </div> | 415 | </div> | ||
| 416 | <div> | 416 | <div> | ||
| 417 | <dt> | 417 | <dt> | ||
| 418 | + | <kbd>w</kbd> | |||
| 419 | + | </dt> | |||
| 420 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |||
| 421 | + | </div> | |||
| 422 | + | <div> | |||
| 423 | + | <dt> | |||
| 418 | <kbd>y</kbd> | 424 | <kbd>y</kbd> | ||
| 419 | </dt> | 425 | </dt> | ||
| 420 | <dd>copy a permalink pinned to the commit</dd> | 426 | <dd>copy a permalink pinned to the commit</dd> | ||
| @@ -598,6 +604,7 @@ | |||||
| 598 | k-split from:body -> ermite.layout('split'); | 604 | k-split from:body -> ermite.layout('split'); | ||
| 599 | k-unified from:body -> ermite.layout('unified'); | 605 | k-unified from:body -> ermite.layout('unified'); | ||
| 600 | k-collapse from:body -> ermite.collapse(); | 606 | k-collapse from:body -> ermite.collapse(); | ||
| 607 | + | k-wide from:body -> window.ermiteWide.toggle(); | |||
| 601 | k-expand from:body -> ermite.expandFile(); | 608 | k-expand from:body -> ermite.expandFile(); | ||
| 602 | k-expandAll from:body -> ermite.expandAll(); | 609 | k-expandAll from:body -> ermite.expandAll(); | ||
| 603 | k-permalink from:body -> ermite.permalink(); | 610 | k-permalink from:body -> ermite.permalink(); | ||
M internal/app/testdata/web/empty.html +8 -1
| @@ -20,7 +20,7 @@ | |||
| 20 | 20 | </script> | |
| 21 | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 22 | 22 | </script> | |
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | |
| 23 | + | <script src="/-/static/js/store.HASH.js"> | |
| 24 | 24 | </script> | |
| 25 | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 26 | 26 | </script> | |
| @@ -188,6 +188,12 @@ | |||
| 188 | 188 | </div> | |
| 189 | 189 | <div> | |
| 190 | 190 | <dt> | |
| 191 | + | <kbd>w</kbd> | |
| 192 | + | </dt> | |
| 193 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |
| 194 | + | </div> | |
| 195 | + | <div> | |
| 196 | + | <dt> | |
| 191 | 197 | <kbd>y</kbd> | |
| 192 | 198 | </dt> | |
| 193 | 199 | <dd>copy a permalink pinned to the commit</dd> | |
| @@ -282,6 +288,7 @@ | |||
| 282 | 288 | k-split from:body -> ermite.layout('split'); | |
| 283 | 289 | k-unified from:body -> ermite.layout('unified'); | |
| 284 | 290 | k-collapse from:body -> ermite.collapse(); | |
| 291 | + | k-wide from:body -> window.ermiteWide.toggle(); | |
| 285 | 292 | k-expand from:body -> ermite.expandFile(); | |
| 286 | 293 | k-expandAll from:body -> ermite.expandAll(); | |
| 287 | 294 | k-permalink from:body -> ermite.permalink(); | |
| @@ -20,7 +20,7 @@ | |||||
| 20 | </script> | 20 | </script> | ||
| 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | ||
| 22 | </script> | 22 | </script> | ||
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | 23 | + | <script src="/-/static/js/store.HASH.js"> |
| 24 | </script> | 24 | </script> | ||
| 25 | <script src="/-/static/js/ermite.HASH.js" defer> | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 26 | </script> | 26 | </script> | ||
| @@ -188,6 +188,12 @@ | |||||
| 188 | </div> | 188 | </div> | ||
| 189 | <div> | 189 | <div> | ||
| 190 | <dt> | 190 | <dt> | ||
| 191 | + | <kbd>w</kbd> | |||
| 192 | + | </dt> | |||
| 193 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |||
| 194 | + | </div> | |||
| 195 | + | <div> | |||
| 196 | + | <dt> | |||
| 191 | <kbd>y</kbd> | 197 | <kbd>y</kbd> | ||
| 192 | </dt> | 198 | </dt> | ||
| 193 | <dd>copy a permalink pinned to the commit</dd> | 199 | <dd>copy a permalink pinned to the commit</dd> | ||
| @@ -282,6 +288,7 @@ | |||||
| 282 | k-split from:body -> ermite.layout('split'); | 288 | k-split from:body -> ermite.layout('split'); | ||
| 283 | k-unified from:body -> ermite.layout('unified'); | 289 | k-unified from:body -> ermite.layout('unified'); | ||
| 284 | k-collapse from:body -> ermite.collapse(); | 290 | k-collapse from:body -> ermite.collapse(); | ||
| 291 | + | k-wide from:body -> window.ermiteWide.toggle(); | |||
| 285 | k-expand from:body -> ermite.expandFile(); | 292 | k-expand from:body -> ermite.expandFile(); | ||
| 286 | k-expandAll from:body -> ermite.expandAll(); | 293 | k-expandAll from:body -> ermite.expandAll(); | ||
| 287 | k-permalink from:body -> ermite.permalink(); | 294 | k-permalink from:body -> ermite.permalink(); | ||
M internal/app/testdata/web/home.html +40 -31
| @@ -20,7 +20,7 @@ | |||
| 20 | 20 | </script> | |
| 21 | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 22 | 22 | </script> | |
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | |
| 23 | + | <script src="/-/static/js/store.HASH.js"> | |
| 24 | 24 | </script> | |
| 25 | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 26 | 26 | </script> | |
| @@ -61,7 +61,7 @@ | |||
| 61 | 61 | </div> | |
| 62 | 62 | <nav class="sort" aria-label="Sort repositories"> | |
| 63 | 63 | <span class="mute">sort</span> | |
| 64 | - | <a href="/?sort=push&dir=asc" aria-current="true">last push <span aria-hidden="true">▼</span> | |
| 64 | + | <a href="/?sort=activity&dir=asc" aria-current="true">last activity <span aria-hidden="true">▼</span> | |
| 65 | 65 | <span class="sr">, descending, select for ascending</span> | |
| 66 | 66 | </a> | |
| 67 | 67 | <a href="/?sort=name&dir=asc">name </a> | |
| @@ -81,8 +81,31 @@ | |||
| 81 | 81 | </button> | |
| 82 | 82 | </div> | |
| 83 | 83 | </div> | |
| 84 | - | <ul class="repos" id="repo-rows" data-rows> | |
| 85 | - | <li id="repo-demo" class="repo" data-cursor> | |
| 84 | + | <ul class="repos" id="repo-rows" data-rows data-sort="activity" data-desc="true"> | |
| 85 | + | <li id="repo-quiet" class="repo" data-cursor> | |
| 86 | + | <div class="repo-main"> | |
| 87 | + | <h2 class="repo-name"> | |
| 88 | + | <a href="/quiet">quiet</a> | |
| 89 | + | </h2> | |
| 90 | + | <p class="repo-desc mute">no description</p> | |
| 91 | + | <div class="repo-topics" aria-hidden="true"> | |
| 92 | + | </div> | |
| 93 | + | <div class="repo-pulse" aria-hidden="true"> | |
| 94 | + | </div> | |
| 95 | + | </div> | |
| 96 | + | <div class="repo-head"> | |
| 97 | + | <dl class="repo-stats" aria-label="Figures of quiet"> | |
| 98 | + | <div class="rs-active"> | |
| 99 | + | <dt>last activity</dt> | |
| 100 | + | <dd> | |
| 101 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 102 | + | </dd> | |
| 103 | + | </div> | |
| 104 | + | </dl> | |
| 105 | + | </div> | |
| 106 | + | <p class="repo-commits mute">no commits yet</p> | |
| 107 | + | </li> | |
| 108 | + | <li id="repo-demo" class="repo"> | |
| 86 | 109 | <div class="repo-main"> | |
| 87 | 110 | <h2 class="repo-name"> | |
| 88 | 111 | <a href="/demo">demo</a> | |
| @@ -148,27 +171,6 @@ | |||
| 148 | 171 | </li> | |
| 149 | 172 | </ul> | |
| 150 | 173 | </li> | |
| 151 | - | <li id="repo-quiet" class="repo"> | |
| 152 | - | <div class="repo-main"> | |
| 153 | - | <h2 class="repo-name"> | |
| 154 | - | <a href="/quiet">quiet</a> | |
| 155 | - | </h2> | |
| 156 | - | <p class="repo-desc mute">no description</p> | |
| 157 | - | <div class="repo-topics" aria-hidden="true"> | |
| 158 | - | </div> | |
| 159 | - | <div class="repo-pulse" aria-hidden="true"> | |
| 160 | - | </div> | |
| 161 | - | </div> | |
| 162 | - | <div class="repo-head"> | |
| 163 | - | <dl class="repo-stats" aria-label="Figures of quiet"> | |
| 164 | - | <div class="rs-active"> | |
| 165 | - | <dt>last activity</dt> | |
| 166 | - | <dd class="mute">never</dd> | |
| 167 | - | </div> | |
| 168 | - | </dl> | |
| 169 | - | </div> | |
| 170 | - | <p class="repo-commits mute">no commits yet</p> | |
| 171 | - | </li> | |
| 172 | 174 | </ul> | |
| 173 | 175 | </section> | |
| 174 | 176 | </div> | |
| @@ -289,6 +291,12 @@ | |||
| 289 | 291 | </div> | |
| 290 | 292 | <div> | |
| 291 | 293 | <dt> | |
| 294 | + | <kbd>w</kbd> | |
| 295 | + | </dt> | |
| 296 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |
| 297 | + | </div> | |
| 298 | + | <div> | |
| 299 | + | <dt> | |
| 292 | 300 | <kbd>y</kbd> | |
| 293 | 301 | </dt> | |
| 294 | 302 | <dd>copy a permalink pinned to the commit</dd> | |
| @@ -337,15 +345,15 @@ | |||
| 337 | 345 | <span class="k">go</span> | |
| 338 | 346 | </a> | |
| 339 | 347 | </li> | |
| 340 | - | <li id="cmd-o1" role="option" aria-selected="false" data-k="repo demo"> | |
| 341 | - | <a href="/demo" tabindex="-1"> | |
| 342 | - | <span class="lbl">demo</span> | |
| 348 | + | <li id="cmd-o1" role="option" aria-selected="false" data-k="repo quiet"> | |
| 349 | + | <a href="/quiet" tabindex="-1"> | |
| 350 | + | <span class="lbl">quiet</span> | |
| 343 | 351 | <span class="k">repo</span> | |
| 344 | 352 | </a> | |
| 345 | 353 | </li> | |
| 346 | - | <li id="cmd-o2" role="option" aria-selected="false" data-k="repo quiet"> | |
| 347 | - | <a href="/quiet" tabindex="-1"> | |
| 348 | - | <span class="lbl">quiet</span> | |
| 354 | + | <li id="cmd-o2" role="option" aria-selected="false" data-k="repo demo"> | |
| 355 | + | <a href="/demo" tabindex="-1"> | |
| 356 | + | <span class="lbl">demo</span> | |
| 349 | 357 | <span class="k">repo</span> | |
| 350 | 358 | </a> | |
| 351 | 359 | </li> | |
| @@ -389,6 +397,7 @@ | |||
| 389 | 397 | k-split from:body -> ermite.layout('split'); | |
| 390 | 398 | k-unified from:body -> ermite.layout('unified'); | |
| 391 | 399 | k-collapse from:body -> ermite.collapse(); | |
| 400 | + | k-wide from:body -> window.ermiteWide.toggle(); | |
| 392 | 401 | k-expand from:body -> ermite.expandFile(); | |
| 393 | 402 | k-expandAll from:body -> ermite.expandAll(); | |
| 394 | 403 | k-permalink from:body -> ermite.permalink(); | |
| @@ -20,7 +20,7 @@ | |||||
| 20 | </script> | 20 | </script> | ||
| 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | ||
| 22 | </script> | 22 | </script> | ||
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | 23 | + | <script src="/-/static/js/store.HASH.js"> |
| 24 | </script> | 24 | </script> | ||
| 25 | <script src="/-/static/js/ermite.HASH.js" defer> | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 26 | </script> | 26 | </script> | ||
| @@ -61,7 +61,7 @@ | |||||
| 61 | </div> | 61 | </div> | ||
| 62 | <nav class="sort" aria-label="Sort repositories"> | 62 | <nav class="sort" aria-label="Sort repositories"> | ||
| 63 | <span class="mute">sort</span> | 63 | <span class="mute">sort</span> | ||
| 64 | - | <a href="/?sort=push&dir=asc" aria-current="true">last push <span aria-hidden="true">▼</span> | 64 | + | <a href="/?sort=activity&dir=asc" aria-current="true">last activity <span aria-hidden="true">▼</span> |
| 65 | <span class="sr">, descending, select for ascending</span> | 65 | <span class="sr">, descending, select for ascending</span> | ||
| 66 | </a> | 66 | </a> | ||
| 67 | <a href="/?sort=name&dir=asc">name </a> | 67 | <a href="/?sort=name&dir=asc">name </a> | ||
| @@ -81,8 +81,31 @@ | |||||
| 81 | </button> | 81 | </button> | ||
| 82 | </div> | 82 | </div> | ||
| 83 | </div> | 83 | </div> | ||
| 84 | - | <ul class="repos" id="repo-rows" data-rows> | 84 | + | <ul class="repos" id="repo-rows" data-rows data-sort="activity" data-desc="true"> |
| 85 | - | <li id="repo-demo" class="repo" data-cursor> | 85 | + | <li id="repo-quiet" class="repo" data-cursor> |
| 86 | + | <div class="repo-main"> | |||
| 87 | + | <h2 class="repo-name"> | |||
| 88 | + | <a href="/quiet">quiet</a> | |||
| 89 | + | </h2> | |||
| 90 | + | <p class="repo-desc mute">no description</p> | |||
| 91 | + | <div class="repo-topics" aria-hidden="true"> | |||
| 92 | + | </div> | |||
| 93 | + | <div class="repo-pulse" aria-hidden="true"> | |||
| 94 | + | </div> | |||
| 95 | + | </div> | |||
| 96 | + | <div class="repo-head"> | |||
| 97 | + | <dl class="repo-stats" aria-label="Figures of quiet"> | |||
| 98 | + | <div class="rs-active"> | |||
| 99 | + | <dt>last activity</dt> | |||
| 100 | + | <dd> | |||
| 101 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 102 | + | </dd> | |||
| 103 | + | </div> | |||
| 104 | + | </dl> | |||
| 105 | + | </div> | |||
| 106 | + | <p class="repo-commits mute">no commits yet</p> | |||
| 107 | + | </li> | |||
| 108 | + | <li id="repo-demo" class="repo"> | |||
| 86 | <div class="repo-main"> | 109 | <div class="repo-main"> | ||
| 87 | <h2 class="repo-name"> | 110 | <h2 class="repo-name"> | ||
| 88 | <a href="/demo">demo</a> | 111 | <a href="/demo">demo</a> | ||
| @@ -148,27 +171,6 @@ | |||||
| 148 | </li> | 171 | </li> | ||
| 149 | </ul> | 172 | </ul> | ||
| 150 | </li> | 173 | </li> | ||
| 151 | - | <li id="repo-quiet" class="repo"> | |||
| 152 | - | <div class="repo-main"> | |||
| 153 | - | <h2 class="repo-name"> | |||
| 154 | - | <a href="/quiet">quiet</a> | |||
| 155 | - | </h2> | |||
| 156 | - | <p class="repo-desc mute">no description</p> | |||
| 157 | - | <div class="repo-topics" aria-hidden="true"> | |||
| 158 | - | </div> | |||
| 159 | - | <div class="repo-pulse" aria-hidden="true"> | |||
| 160 | - | </div> | |||
| 161 | - | </div> | |||
| 162 | - | <div class="repo-head"> | |||
| 163 | - | <dl class="repo-stats" aria-label="Figures of quiet"> | |||
| 164 | - | <div class="rs-active"> | |||
| 165 | - | <dt>last activity</dt> | |||
| 166 | - | <dd class="mute">never</dd> | |||
| 167 | - | </div> | |||
| 168 | - | </dl> | |||
| 169 | - | </div> | |||
| 170 | - | <p class="repo-commits mute">no commits yet</p> | |||
| 171 | - | </li> | |||
| 172 | </ul> | 174 | </ul> | ||
| 173 | </section> | 175 | </section> | ||
| 174 | </div> | 176 | </div> | ||
| @@ -289,6 +291,12 @@ | |||||
| 289 | </div> | 291 | </div> | ||
| 290 | <div> | 292 | <div> | ||
| 291 | <dt> | 293 | <dt> | ||
| 294 | + | <kbd>w</kbd> | |||
| 295 | + | </dt> | |||
| 296 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |||
| 297 | + | </div> | |||
| 298 | + | <div> | |||
| 299 | + | <dt> | |||
| 292 | <kbd>y</kbd> | 300 | <kbd>y</kbd> | ||
| 293 | </dt> | 301 | </dt> | ||
| 294 | <dd>copy a permalink pinned to the commit</dd> | 302 | <dd>copy a permalink pinned to the commit</dd> | ||
| @@ -337,15 +345,15 @@ | |||||
| 337 | <span class="k">go</span> | 345 | <span class="k">go</span> | ||
| 338 | </a> | 346 | </a> | ||
| 339 | </li> | 347 | </li> | ||
| 340 | - | <li id="cmd-o1" role="option" aria-selected="false" data-k="repo demo"> | 348 | + | <li id="cmd-o1" role="option" aria-selected="false" data-k="repo quiet"> |
| 341 | - | <a href="/demo" tabindex="-1"> | 349 | + | <a href="/quiet" tabindex="-1"> |
| 342 | - | <span class="lbl">demo</span> | 350 | + | <span class="lbl">quiet</span> |
| 343 | <span class="k">repo</span> | 351 | <span class="k">repo</span> | ||
| 344 | </a> | 352 | </a> | ||
| 345 | </li> | 353 | </li> | ||
| 346 | - | <li id="cmd-o2" role="option" aria-selected="false" data-k="repo quiet"> | 354 | + | <li id="cmd-o2" role="option" aria-selected="false" data-k="repo demo"> |
| 347 | - | <a href="/quiet" tabindex="-1"> | 355 | + | <a href="/demo" tabindex="-1"> |
| 348 | - | <span class="lbl">quiet</span> | 356 | + | <span class="lbl">demo</span> |
| 349 | <span class="k">repo</span> | 357 | <span class="k">repo</span> | ||
| 350 | </a> | 358 | </a> | ||
| 351 | </li> | 359 | </li> | ||
| @@ -389,6 +397,7 @@ | |||||
| 389 | k-split from:body -> ermite.layout('split'); | 397 | k-split from:body -> ermite.layout('split'); | ||
| 390 | k-unified from:body -> ermite.layout('unified'); | 398 | k-unified from:body -> ermite.layout('unified'); | ||
| 391 | k-collapse from:body -> ermite.collapse(); | 399 | k-collapse from:body -> ermite.collapse(); | ||
| 400 | + | k-wide from:body -> window.ermiteWide.toggle(); | |||
| 392 | k-expand from:body -> ermite.expandFile(); | 401 | k-expand from:body -> ermite.expandFile(); | ||
| 393 | k-expandAll from:body -> ermite.expandAll(); | 402 | k-expandAll from:body -> ermite.expandAll(); | ||
| 394 | k-permalink from:body -> ermite.permalink(); | 403 | k-permalink from:body -> ermite.permalink(); | ||
M internal/app/testdata/web/not-found-page.html +8 -1
| @@ -20,7 +20,7 @@ | |||
| 20 | 20 | </script> | |
| 21 | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 22 | 22 | </script> | |
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | |
| 23 | + | <script src="/-/static/js/store.HASH.js"> | |
| 24 | 24 | </script> | |
| 25 | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 26 | 26 | </script> | |
| @@ -177,6 +177,12 @@ | |||
| 177 | 177 | </div> | |
| 178 | 178 | <div> | |
| 179 | 179 | <dt> | |
| 180 | + | <kbd>w</kbd> | |
| 181 | + | </dt> | |
| 182 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |
| 183 | + | </div> | |
| 184 | + | <div> | |
| 185 | + | <dt> | |
| 180 | 186 | <kbd>y</kbd> | |
| 181 | 187 | </dt> | |
| 182 | 188 | <dd>copy a permalink pinned to the commit</dd> | |
| @@ -265,6 +271,7 @@ | |||
| 265 | 271 | k-split from:body -> ermite.layout('split'); | |
| 266 | 272 | k-unified from:body -> ermite.layout('unified'); | |
| 267 | 273 | k-collapse from:body -> ermite.collapse(); | |
| 274 | + | k-wide from:body -> window.ermiteWide.toggle(); | |
| 268 | 275 | k-expand from:body -> ermite.expandFile(); | |
| 269 | 276 | k-expandAll from:body -> ermite.expandAll(); | |
| 270 | 277 | k-permalink from:body -> ermite.permalink(); | |
| @@ -20,7 +20,7 @@ | |||||
| 20 | </script> | 20 | </script> | ||
| 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | ||
| 22 | </script> | 22 | </script> | ||
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | 23 | + | <script src="/-/static/js/store.HASH.js"> |
| 24 | </script> | 24 | </script> | ||
| 25 | <script src="/-/static/js/ermite.HASH.js" defer> | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 26 | </script> | 26 | </script> | ||
| @@ -177,6 +177,12 @@ | |||||
| 177 | </div> | 177 | </div> | ||
| 178 | <div> | 178 | <div> | ||
| 179 | <dt> | 179 | <dt> | ||
| 180 | + | <kbd>w</kbd> | |||
| 181 | + | </dt> | |||
| 182 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |||
| 183 | + | </div> | |||
| 184 | + | <div> | |||
| 185 | + | <dt> | |||
| 180 | <kbd>y</kbd> | 186 | <kbd>y</kbd> | ||
| 181 | </dt> | 187 | </dt> | ||
| 182 | <dd>copy a permalink pinned to the commit</dd> | 188 | <dd>copy a permalink pinned to the commit</dd> | ||
| @@ -265,6 +271,7 @@ | |||||
| 265 | k-split from:body -> ermite.layout('split'); | 271 | k-split from:body -> ermite.layout('split'); | ||
| 266 | k-unified from:body -> ermite.layout('unified'); | 272 | k-unified from:body -> ermite.layout('unified'); | ||
| 267 | k-collapse from:body -> ermite.collapse(); | 273 | k-collapse from:body -> ermite.collapse(); | ||
| 274 | + | k-wide from:body -> window.ermiteWide.toggle(); | |||
| 268 | k-expand from:body -> ermite.expandFile(); | 275 | k-expand from:body -> ermite.expandFile(); | ||
| 269 | k-expandAll from:body -> ermite.expandAll(); | 276 | k-expandAll from:body -> ermite.expandAll(); | ||
| 270 | k-permalink from:body -> ermite.permalink(); | 277 | k-permalink from:body -> ermite.permalink(); | ||
M internal/app/testdata/web/not-found.html +8 -1
| @@ -20,7 +20,7 @@ | |||
| 20 | 20 | </script> | |
| 21 | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 22 | 22 | </script> | |
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | |
| 23 | + | <script src="/-/static/js/store.HASH.js"> | |
| 24 | 24 | </script> | |
| 25 | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 26 | 26 | </script> | |
| @@ -177,6 +177,12 @@ | |||
| 177 | 177 | </div> | |
| 178 | 178 | <div> | |
| 179 | 179 | <dt> | |
| 180 | + | <kbd>w</kbd> | |
| 181 | + | </dt> | |
| 182 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |
| 183 | + | </div> | |
| 184 | + | <div> | |
| 185 | + | <dt> | |
| 180 | 186 | <kbd>y</kbd> | |
| 181 | 187 | </dt> | |
| 182 | 188 | <dd>copy a permalink pinned to the commit</dd> | |
| @@ -265,6 +271,7 @@ | |||
| 265 | 271 | k-split from:body -> ermite.layout('split'); | |
| 266 | 272 | k-unified from:body -> ermite.layout('unified'); | |
| 267 | 273 | k-collapse from:body -> ermite.collapse(); | |
| 274 | + | k-wide from:body -> window.ermiteWide.toggle(); | |
| 268 | 275 | k-expand from:body -> ermite.expandFile(); | |
| 269 | 276 | k-expandAll from:body -> ermite.expandAll(); | |
| 270 | 277 | k-permalink from:body -> ermite.permalink(); | |
| @@ -20,7 +20,7 @@ | |||||
| 20 | </script> | 20 | </script> | ||
| 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | ||
| 22 | </script> | 22 | </script> | ||
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | 23 | + | <script src="/-/static/js/store.HASH.js"> |
| 24 | </script> | 24 | </script> | ||
| 25 | <script src="/-/static/js/ermite.HASH.js" defer> | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 26 | </script> | 26 | </script> | ||
| @@ -177,6 +177,12 @@ | |||||
| 177 | </div> | 177 | </div> | ||
| 178 | <div> | 178 | <div> | ||
| 179 | <dt> | 179 | <dt> | ||
| 180 | + | <kbd>w</kbd> | |||
| 181 | + | </dt> | |||
| 182 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |||
| 183 | + | </div> | |||
| 184 | + | <div> | |||
| 185 | + | <dt> | |||
| 180 | <kbd>y</kbd> | 186 | <kbd>y</kbd> | ||
| 181 | </dt> | 187 | </dt> | ||
| 182 | <dd>copy a permalink pinned to the commit</dd> | 188 | <dd>copy a permalink pinned to the commit</dd> | ||
| @@ -265,6 +271,7 @@ | |||||
| 265 | k-split from:body -> ermite.layout('split'); | 271 | k-split from:body -> ermite.layout('split'); | ||
| 266 | k-unified from:body -> ermite.layout('unified'); | 272 | k-unified from:body -> ermite.layout('unified'); | ||
| 267 | k-collapse from:body -> ermite.collapse(); | 273 | k-collapse from:body -> ermite.collapse(); | ||
| 274 | + | k-wide from:body -> window.ermiteWide.toggle(); | |||
| 268 | k-expand from:body -> ermite.expandFile(); | 275 | k-expand from:body -> ermite.expandFile(); | ||
| 269 | k-expandAll from:body -> ermite.expandAll(); | 276 | k-expandAll from:body -> ermite.expandAll(); | ||
| 270 | k-permalink from:body -> ermite.permalink(); | 277 | k-permalink from:body -> ermite.permalink(); | ||
M internal/app/testdata/web/refs.html +8 -1
| @@ -20,7 +20,7 @@ | |||
| 20 | 20 | </script> | |
| 21 | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 22 | 22 | </script> | |
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | |
| 23 | + | <script src="/-/static/js/store.HASH.js"> | |
| 24 | 24 | </script> | |
| 25 | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 26 | 26 | </script> | |
| @@ -297,6 +297,12 @@ | |||
| 297 | 297 | </div> | |
| 298 | 298 | <div> | |
| 299 | 299 | <dt> | |
| 300 | + | <kbd>w</kbd> | |
| 301 | + | </dt> | |
| 302 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |
| 303 | + | </div> | |
| 304 | + | <div> | |
| 305 | + | <dt> | |
| 300 | 306 | <kbd>y</kbd> | |
| 301 | 307 | </dt> | |
| 302 | 308 | <dd>copy a permalink pinned to the commit</dd> | |
| @@ -490,6 +496,7 @@ | |||
| 490 | 496 | k-split from:body -> ermite.layout('split'); | |
| 491 | 497 | k-unified from:body -> ermite.layout('unified'); | |
| 492 | 498 | k-collapse from:body -> ermite.collapse(); | |
| 499 | + | k-wide from:body -> window.ermiteWide.toggle(); | |
| 493 | 500 | k-expand from:body -> ermite.expandFile(); | |
| 494 | 501 | k-expandAll from:body -> ermite.expandAll(); | |
| 495 | 502 | k-permalink from:body -> ermite.permalink(); | |
| @@ -20,7 +20,7 @@ | |||||
| 20 | </script> | 20 | </script> | ||
| 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | ||
| 22 | </script> | 22 | </script> | ||
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | 23 | + | <script src="/-/static/js/store.HASH.js"> |
| 24 | </script> | 24 | </script> | ||
| 25 | <script src="/-/static/js/ermite.HASH.js" defer> | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 26 | </script> | 26 | </script> | ||
| @@ -297,6 +297,12 @@ | |||||
| 297 | </div> | 297 | </div> | ||
| 298 | <div> | 298 | <div> | ||
| 299 | <dt> | 299 | <dt> | ||
| 300 | + | <kbd>w</kbd> | |||
| 301 | + | </dt> | |||
| 302 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |||
| 303 | + | </div> | |||
| 304 | + | <div> | |||
| 305 | + | <dt> | |||
| 300 | <kbd>y</kbd> | 306 | <kbd>y</kbd> | ||
| 301 | </dt> | 307 | </dt> | ||
| 302 | <dd>copy a permalink pinned to the commit</dd> | 308 | <dd>copy a permalink pinned to the commit</dd> | ||
| @@ -490,6 +496,7 @@ | |||||
| 490 | k-split from:body -> ermite.layout('split'); | 496 | k-split from:body -> ermite.layout('split'); | ||
| 491 | k-unified from:body -> ermite.layout('unified'); | 497 | k-unified from:body -> ermite.layout('unified'); | ||
| 492 | k-collapse from:body -> ermite.collapse(); | 498 | k-collapse from:body -> ermite.collapse(); | ||
| 499 | + | k-wide from:body -> window.ermiteWide.toggle(); | |||
| 493 | k-expand from:body -> ermite.expandFile(); | 500 | k-expand from:body -> ermite.expandFile(); | ||
| 494 | k-expandAll from:body -> ermite.expandAll(); | 501 | k-expandAll from:body -> ermite.expandAll(); | ||
| 495 | k-permalink from:body -> ermite.permalink(); | 502 | k-permalink from:body -> ermite.permalink(); | ||
M internal/app/testdata/web/summary.html +8 -1
| @@ -20,7 +20,7 @@ | |||
| 20 | 20 | </script> | |
| 21 | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 22 | 22 | </script> | |
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | |
| 23 | + | <script src="/-/static/js/store.HASH.js"> | |
| 24 | 24 | </script> | |
| 25 | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 26 | 26 | </script> | |
| @@ -323,6 +323,12 @@ | |||
| 323 | 323 | </div> | |
| 324 | 324 | <div> | |
| 325 | 325 | <dt> | |
| 326 | + | <kbd>w</kbd> | |
| 327 | + | </dt> | |
| 328 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |
| 329 | + | </div> | |
| 330 | + | <div> | |
| 331 | + | <dt> | |
| 326 | 332 | <kbd>y</kbd> | |
| 327 | 333 | </dt> | |
| 328 | 334 | <dd>copy a permalink pinned to the commit</dd> | |
| @@ -516,6 +522,7 @@ | |||
| 516 | 522 | k-split from:body -> ermite.layout('split'); | |
| 517 | 523 | k-unified from:body -> ermite.layout('unified'); | |
| 518 | 524 | k-collapse from:body -> ermite.collapse(); | |
| 525 | + | k-wide from:body -> window.ermiteWide.toggle(); | |
| 519 | 526 | k-expand from:body -> ermite.expandFile(); | |
| 520 | 527 | k-expandAll from:body -> ermite.expandAll(); | |
| 521 | 528 | k-permalink from:body -> ermite.permalink(); | |
| @@ -20,7 +20,7 @@ | |||||
| 20 | </script> | 20 | </script> | ||
| 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | ||
| 22 | </script> | 22 | </script> | ||
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | 23 | + | <script src="/-/static/js/store.HASH.js"> |
| 24 | </script> | 24 | </script> | ||
| 25 | <script src="/-/static/js/ermite.HASH.js" defer> | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 26 | </script> | 26 | </script> | ||
| @@ -323,6 +323,12 @@ | |||||
| 323 | </div> | 323 | </div> | ||
| 324 | <div> | 324 | <div> | ||
| 325 | <dt> | 325 | <dt> | ||
| 326 | + | <kbd>w</kbd> | |||
| 327 | + | </dt> | |||
| 328 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |||
| 329 | + | </div> | |||
| 330 | + | <div> | |||
| 331 | + | <dt> | |||
| 326 | <kbd>y</kbd> | 332 | <kbd>y</kbd> | ||
| 327 | </dt> | 333 | </dt> | ||
| 328 | <dd>copy a permalink pinned to the commit</dd> | 334 | <dd>copy a permalink pinned to the commit</dd> | ||
| @@ -516,6 +522,7 @@ | |||||
| 516 | k-split from:body -> ermite.layout('split'); | 522 | k-split from:body -> ermite.layout('split'); | ||
| 517 | k-unified from:body -> ermite.layout('unified'); | 523 | k-unified from:body -> ermite.layout('unified'); | ||
| 518 | k-collapse from:body -> ermite.collapse(); | 524 | k-collapse from:body -> ermite.collapse(); | ||
| 525 | + | k-wide from:body -> window.ermiteWide.toggle(); | |||
| 519 | k-expand from:body -> ermite.expandFile(); | 526 | k-expand from:body -> ermite.expandFile(); | ||
| 520 | k-expandAll from:body -> ermite.expandAll(); | 527 | k-expandAll from:body -> ermite.expandAll(); | ||
| 521 | k-permalink from:body -> ermite.permalink(); | 528 | k-permalink from:body -> ermite.permalink(); | ||
M internal/app/testdata/web/tree-nested.html +8 -1
| @@ -20,7 +20,7 @@ | |||
| 20 | 20 | </script> | |
| 21 | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 22 | 22 | </script> | |
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | |
| 23 | + | <script src="/-/static/js/store.HASH.js"> | |
| 24 | 24 | </script> | |
| 25 | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 26 | 26 | </script> | |
| @@ -254,6 +254,12 @@ | |||
| 254 | 254 | </div> | |
| 255 | 255 | <div> | |
| 256 | 256 | <dt> | |
| 257 | + | <kbd>w</kbd> | |
| 258 | + | </dt> | |
| 259 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |
| 260 | + | </div> | |
| 261 | + | <div> | |
| 262 | + | <dt> | |
| 257 | 263 | <kbd>y</kbd> | |
| 258 | 264 | </dt> | |
| 259 | 265 | <dd>copy a permalink pinned to the commit</dd> | |
| @@ -431,6 +437,7 @@ | |||
| 431 | 437 | k-split from:body -> ermite.layout('split'); | |
| 432 | 438 | k-unified from:body -> ermite.layout('unified'); | |
| 433 | 439 | k-collapse from:body -> ermite.collapse(); | |
| 440 | + | k-wide from:body -> window.ermiteWide.toggle(); | |
| 434 | 441 | k-expand from:body -> ermite.expandFile(); | |
| 435 | 442 | k-expandAll from:body -> ermite.expandAll(); | |
| 436 | 443 | k-permalink from:body -> ermite.permalink(); | |
| @@ -20,7 +20,7 @@ | |||||
| 20 | </script> | 20 | </script> | ||
| 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | ||
| 22 | </script> | 22 | </script> | ||
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | 23 | + | <script src="/-/static/js/store.HASH.js"> |
| 24 | </script> | 24 | </script> | ||
| 25 | <script src="/-/static/js/ermite.HASH.js" defer> | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 26 | </script> | 26 | </script> | ||
| @@ -254,6 +254,12 @@ | |||||
| 254 | </div> | 254 | </div> | ||
| 255 | <div> | 255 | <div> | ||
| 256 | <dt> | 256 | <dt> | ||
| 257 | + | <kbd>w</kbd> | |||
| 258 | + | </dt> | |||
| 259 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |||
| 260 | + | </div> | |||
| 261 | + | <div> | |||
| 262 | + | <dt> | |||
| 257 | <kbd>y</kbd> | 263 | <kbd>y</kbd> | ||
| 258 | </dt> | 264 | </dt> | ||
| 259 | <dd>copy a permalink pinned to the commit</dd> | 265 | <dd>copy a permalink pinned to the commit</dd> | ||
| @@ -431,6 +437,7 @@ | |||||
| 431 | k-split from:body -> ermite.layout('split'); | 437 | k-split from:body -> ermite.layout('split'); | ||
| 432 | k-unified from:body -> ermite.layout('unified'); | 438 | k-unified from:body -> ermite.layout('unified'); | ||
| 433 | k-collapse from:body -> ermite.collapse(); | 439 | k-collapse from:body -> ermite.collapse(); | ||
| 440 | + | k-wide from:body -> window.ermiteWide.toggle(); | |||
| 434 | k-expand from:body -> ermite.expandFile(); | 441 | k-expand from:body -> ermite.expandFile(); | ||
| 435 | k-expandAll from:body -> ermite.expandAll(); | 442 | k-expandAll from:body -> ermite.expandAll(); | ||
| 436 | k-permalink from:body -> ermite.permalink(); | 443 | k-permalink from:body -> ermite.permalink(); | ||
M internal/app/testdata/web/tree-root.html +8 -1
| @@ -20,7 +20,7 @@ | |||
| 20 | 20 | </script> | |
| 21 | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 22 | 22 | </script> | |
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | |
| 23 | + | <script src="/-/static/js/store.HASH.js"> | |
| 24 | 24 | </script> | |
| 25 | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 26 | 26 | </script> | |
| @@ -475,6 +475,12 @@ | |||
| 475 | 475 | </div> | |
| 476 | 476 | <div> | |
| 477 | 477 | <dt> | |
| 478 | + | <kbd>w</kbd> | |
| 479 | + | </dt> | |
| 480 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |
| 481 | + | </div> | |
| 482 | + | <div> | |
| 483 | + | <dt> | |
| 478 | 484 | <kbd>y</kbd> | |
| 479 | 485 | </dt> | |
| 480 | 486 | <dd>copy a permalink pinned to the commit</dd> | |
| @@ -652,6 +658,7 @@ | |||
| 652 | 658 | k-split from:body -> ermite.layout('split'); | |
| 653 | 659 | k-unified from:body -> ermite.layout('unified'); | |
| 654 | 660 | k-collapse from:body -> ermite.collapse(); | |
| 661 | + | k-wide from:body -> window.ermiteWide.toggle(); | |
| 655 | 662 | k-expand from:body -> ermite.expandFile(); | |
| 656 | 663 | k-expandAll from:body -> ermite.expandAll(); | |
| 657 | 664 | k-permalink from:body -> ermite.permalink(); | |
| @@ -20,7 +20,7 @@ | |||||
| 20 | </script> | 20 | </script> | ||
| 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | ||
| 22 | </script> | 22 | </script> | ||
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | 23 | + | <script src="/-/static/js/store.HASH.js"> |
| 24 | </script> | 24 | </script> | ||
| 25 | <script src="/-/static/js/ermite.HASH.js" defer> | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 26 | </script> | 26 | </script> | ||
| @@ -475,6 +475,12 @@ | |||||
| 475 | </div> | 475 | </div> | ||
| 476 | <div> | 476 | <div> | ||
| 477 | <dt> | 477 | <dt> | ||
| 478 | + | <kbd>w</kbd> | |||
| 479 | + | </dt> | |||
| 480 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |||
| 481 | + | </div> | |||
| 482 | + | <div> | |||
| 483 | + | <dt> | |||
| 478 | <kbd>y</kbd> | 484 | <kbd>y</kbd> | ||
| 479 | </dt> | 485 | </dt> | ||
| 480 | <dd>copy a permalink pinned to the commit</dd> | 486 | <dd>copy a permalink pinned to the commit</dd> | ||
| @@ -652,6 +658,7 @@ | |||||
| 652 | k-split from:body -> ermite.layout('split'); | 658 | k-split from:body -> ermite.layout('split'); | ||
| 653 | k-unified from:body -> ermite.layout('unified'); | 659 | k-unified from:body -> ermite.layout('unified'); | ||
| 654 | k-collapse from:body -> ermite.collapse(); | 660 | k-collapse from:body -> ermite.collapse(); | ||
| 661 | + | k-wide from:body -> window.ermiteWide.toggle(); | |||
| 655 | k-expand from:body -> ermite.expandFile(); | 662 | k-expand from:body -> ermite.expandFile(); | ||
| 656 | k-expandAll from:body -> ermite.expandAll(); | 663 | k-expandAll from:body -> ermite.expandAll(); | ||
| 657 | k-permalink from:body -> ermite.permalink(); | 664 | k-permalink from:body -> ermite.permalink(); | ||
M internal/app/testdata/web/tree-slash-ref.html +8 -1
| @@ -20,7 +20,7 @@ | |||
| 20 | 20 | </script> | |
| 21 | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 22 | 22 | </script> | |
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | |
| 23 | + | <script src="/-/static/js/store.HASH.js"> | |
| 24 | 24 | </script> | |
| 25 | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 26 | 26 | </script> | |
| @@ -251,6 +251,12 @@ | |||
| 251 | 251 | </div> | |
| 252 | 252 | <div> | |
| 253 | 253 | <dt> | |
| 254 | + | <kbd>w</kbd> | |
| 255 | + | </dt> | |
| 256 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |
| 257 | + | </div> | |
| 258 | + | <div> | |
| 259 | + | <dt> | |
| 254 | 260 | <kbd>y</kbd> | |
| 255 | 261 | </dt> | |
| 256 | 262 | <dd>copy a permalink pinned to the commit</dd> | |
| @@ -428,6 +434,7 @@ | |||
| 428 | 434 | k-split from:body -> ermite.layout('split'); | |
| 429 | 435 | k-unified from:body -> ermite.layout('unified'); | |
| 430 | 436 | k-collapse from:body -> ermite.collapse(); | |
| 437 | + | k-wide from:body -> window.ermiteWide.toggle(); | |
| 431 | 438 | k-expand from:body -> ermite.expandFile(); | |
| 432 | 439 | k-expandAll from:body -> ermite.expandAll(); | |
| 433 | 440 | k-permalink from:body -> ermite.permalink(); | |
| @@ -20,7 +20,7 @@ | |||||
| 20 | </script> | 20 | </script> | ||
| 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | ||
| 22 | </script> | 22 | </script> | ||
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | 23 | + | <script src="/-/static/js/store.HASH.js"> |
| 24 | </script> | 24 | </script> | ||
| 25 | <script src="/-/static/js/ermite.HASH.js" defer> | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 26 | </script> | 26 | </script> | ||
| @@ -251,6 +251,12 @@ | |||||
| 251 | </div> | 251 | </div> | ||
| 252 | <div> | 252 | <div> | ||
| 253 | <dt> | 253 | <dt> | ||
| 254 | + | <kbd>w</kbd> | |||
| 255 | + | </dt> | |||
| 256 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |||
| 257 | + | </div> | |||
| 258 | + | <div> | |||
| 259 | + | <dt> | |||
| 254 | <kbd>y</kbd> | 260 | <kbd>y</kbd> | ||
| 255 | </dt> | 261 | </dt> | ||
| 256 | <dd>copy a permalink pinned to the commit</dd> | 262 | <dd>copy a permalink pinned to the commit</dd> | ||
| @@ -428,6 +434,7 @@ | |||||
| 428 | k-split from:body -> ermite.layout('split'); | 434 | k-split from:body -> ermite.layout('split'); | ||
| 429 | k-unified from:body -> ermite.layout('unified'); | 435 | k-unified from:body -> ermite.layout('unified'); | ||
| 430 | k-collapse from:body -> ermite.collapse(); | 436 | k-collapse from:body -> ermite.collapse(); | ||
| 437 | + | k-wide from:body -> window.ermiteWide.toggle(); | |||
| 431 | k-expand from:body -> ermite.expandFile(); | 438 | k-expand from:body -> ermite.expandFile(); | ||
| 432 | k-expandAll from:body -> ermite.expandAll(); | 439 | k-expandAll from:body -> ermite.expandAll(); | ||
| 433 | k-permalink from:body -> ermite.permalink(); | 440 | k-permalink from:body -> ermite.permalink(); | ||
M internal/app/testdata/web/tree-subdir.html +8 -1
| @@ -20,7 +20,7 @@ | |||
| 20 | 20 | </script> | |
| 21 | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 22 | 22 | </script> | |
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | |
| 23 | + | <script src="/-/static/js/store.HASH.js"> | |
| 24 | 24 | </script> | |
| 25 | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 26 | 26 | </script> | |
| @@ -267,6 +267,12 @@ | |||
| 267 | 267 | </div> | |
| 268 | 268 | <div> | |
| 269 | 269 | <dt> | |
| 270 | + | <kbd>w</kbd> | |
| 271 | + | </dt> | |
| 272 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |
| 273 | + | </div> | |
| 274 | + | <div> | |
| 275 | + | <dt> | |
| 270 | 276 | <kbd>y</kbd> | |
| 271 | 277 | </dt> | |
| 272 | 278 | <dd>copy a permalink pinned to the commit</dd> | |
| @@ -444,6 +450,7 @@ | |||
| 444 | 450 | k-split from:body -> ermite.layout('split'); | |
| 445 | 451 | k-unified from:body -> ermite.layout('unified'); | |
| 446 | 452 | k-collapse from:body -> ermite.collapse(); | |
| 453 | + | k-wide from:body -> window.ermiteWide.toggle(); | |
| 447 | 454 | k-expand from:body -> ermite.expandFile(); | |
| 448 | 455 | k-expandAll from:body -> ermite.expandAll(); | |
| 449 | 456 | k-permalink from:body -> ermite.permalink(); | |
| @@ -20,7 +20,7 @@ | |||||
| 20 | </script> | 20 | </script> | ||
| 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | 21 | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | ||
| 22 | </script> | 22 | </script> | ||
| 23 | - | <script src="/-/static/js/store.HASH.js" defer> | 23 | + | <script src="/-/static/js/store.HASH.js"> |
| 24 | </script> | 24 | </script> | ||
| 25 | <script src="/-/static/js/ermite.HASH.js" defer> | 25 | <script src="/-/static/js/ermite.HASH.js" defer> | ||
| 26 | </script> | 26 | </script> | ||
| @@ -267,6 +267,12 @@ | |||||
| 267 | </div> | 267 | </div> | ||
| 268 | <div> | 268 | <div> | ||
| 269 | <dt> | 269 | <dt> | ||
| 270 | + | <kbd>w</kbd> | |||
| 271 | + | </dt> | |||
| 272 | + | <dd>wide mode: use the whole width of the screen, or not (remembered)</dd> | |||
| 273 | + | </div> | |||
| 274 | + | <div> | |||
| 275 | + | <dt> | |||
| 270 | <kbd>y</kbd> | 276 | <kbd>y</kbd> | ||
| 271 | </dt> | 277 | </dt> | ||
| 272 | <dd>copy a permalink pinned to the commit</dd> | 278 | <dd>copy a permalink pinned to the commit</dd> | ||
| @@ -444,6 +450,7 @@ | |||||
| 444 | k-split from:body -> ermite.layout('split'); | 450 | k-split from:body -> ermite.layout('split'); | ||
| 445 | k-unified from:body -> ermite.layout('unified'); | 451 | k-unified from:body -> ermite.layout('unified'); | ||
| 446 | k-collapse from:body -> ermite.collapse(); | 452 | k-collapse from:body -> ermite.collapse(); | ||
| 453 | + | k-wide from:body -> window.ermiteWide.toggle(); | |||
| 447 | k-expand from:body -> ermite.expandFile(); | 454 | k-expand from:body -> ermite.expandFile(); | ||
| 448 | k-expandAll from:body -> ermite.expandAll(); | 455 | k-expandAll from:body -> ermite.expandAll(); | ||
| 449 | k-permalink from:body -> ermite.permalink(); | 456 | k-permalink from:body -> ermite.permalink(); | ||
M internal/app/web_events_test.go +2 -2
| @@ -123,7 +123,7 @@ func TestWebEventsHome(t *testing.T) { | |||
| 123 | 123 | assert.Equal(t, "home-list", ev.name) | |
| 124 | 124 | assert.NotEmpty(t, ev.id) | |
| 125 | 125 | assert.Contains(t, ev.data, `<li id="repo-pub"`) | |
| 126 | - | assert.Contains(t, ev.data, `new push`) | |
| 126 | + | assert.Contains(t, ev.data, `new activity`) | |
| 127 | 127 | assert.Contains(t, ev.data, `href="/pub"`) | |
| 128 | 128 | assert.Contains(t, ev.data, `<ul class="repo-commits" aria-label="Recent commits of pub">`, "the live row lists the commits too") | |
| 129 | 129 | ||
| @@ -134,7 +134,7 @@ func TestWebEventsHome(t *testing.T) { | |||
| 134 | 134 | assert.Equal(t, "home-list", ev.name) | |
| 135 | 135 | assert.Contains(t, ev.data, `<li id="repo-fresh"`) | |
| 136 | 136 | assert.Contains(t, ev.data, "a <b>fresh</b> one", "text is escaped") | |
| 137 | - | assert.NotContains(t, ev.data, "new push") | |
| 137 | + | assert.NotContains(t, ev.data, "new activity") | |
| 138 | 138 | ||
| 139 | 139 | // A repository that turns private is removed from the anonymous list. | |
| 140 | 140 | priv := domain.VisibilityPrivate | |
| @@ -123,7 +123,7 @@ func TestWebEventsHome(t *testing.T) { | |||||
| 123 | assert.Equal(t, "home-list", ev.name) | 123 | assert.Equal(t, "home-list", ev.name) | ||
| 124 | assert.NotEmpty(t, ev.id) | 124 | assert.NotEmpty(t, ev.id) | ||
| 125 | assert.Contains(t, ev.data, `<li id="repo-pub"`) | 125 | assert.Contains(t, ev.data, `<li id="repo-pub"`) | ||
| 126 | - | assert.Contains(t, ev.data, `new push`) | 126 | + | assert.Contains(t, ev.data, `new activity`) |
| 127 | assert.Contains(t, ev.data, `href="/pub"`) | 127 | assert.Contains(t, ev.data, `href="/pub"`) | ||
| 128 | assert.Contains(t, ev.data, `<ul class="repo-commits" aria-label="Recent commits of pub">`, "the live row lists the commits too") | 128 | assert.Contains(t, ev.data, `<ul class="repo-commits" aria-label="Recent commits of pub">`, "the live row lists the commits too") | ||
| 129 | 129 | ||||
| @@ -134,7 +134,7 @@ func TestWebEventsHome(t *testing.T) { | |||||
| 134 | assert.Equal(t, "home-list", ev.name) | 134 | assert.Equal(t, "home-list", ev.name) | ||
| 135 | assert.Contains(t, ev.data, `<li id="repo-fresh"`) | 135 | assert.Contains(t, ev.data, `<li id="repo-fresh"`) | ||
| 136 | assert.Contains(t, ev.data, "a <b>fresh</b> one", "text is escaped") | 136 | assert.Contains(t, ev.data, "a <b>fresh</b> one", "text is escaped") | ||
| 137 | - | assert.NotContains(t, ev.data, "new push") | 137 | + | assert.NotContains(t, ev.data, "new activity") |
| 138 | 138 | ||||
| 139 | // A repository that turns private is removed from the anonymous list. | 139 | // A repository that turns private is removed from the anonymous list. | ||
| 140 | priv := domain.VisibilityPrivate | 140 | priv := domain.VisibilityPrivate | ||
M internal/app/web_mr_test.go +2 -0
| @@ -268,6 +268,8 @@ func TestWebMergeRequestReviewAndFastForward(t *testing.T) { | |||
| 268 | 268 | assert.Contains(t, line.body, `name="path" value="search.go"`) | |
| 269 | 269 | assert.Contains(t, line.body, `name="line" value="3"`) | |
| 270 | 270 | assert.Contains(t, line.body, `name="side" value="new"`) | |
| 271 | + | assert.Contains(t, line.body, `comment on search.go:3 (new)`, "one line: the title names it") | |
| 272 | + | assert.Contains(t, b.get(base+"?on=new:1-3:search.go", as(token)).body, `comment on search.go:1-3 (new)`, "a range: the title names all of it") | |
| 271 | 273 | assert.Equal(t, 0, strings.Count(b.get(base+"?on=new:99:search.go", as(token)).body, `name="line" value="99"`), "a line the diff lacks has no form") | |
| 272 | 274 | assert.Equal(t, 0, strings.Count(b.get(base+"?on=new:3:search.go", nil).body, `<form`), "nor for a visitor") | |
| 273 | 275 | ||
| @@ -268,6 +268,8 @@ func TestWebMergeRequestReviewAndFastForward(t *testing.T) { | |||||
| 268 | assert.Contains(t, line.body, `name="path" value="search.go"`) | 268 | assert.Contains(t, line.body, `name="path" value="search.go"`) | ||
| 269 | assert.Contains(t, line.body, `name="line" value="3"`) | 269 | assert.Contains(t, line.body, `name="line" value="3"`) | ||
| 270 | assert.Contains(t, line.body, `name="side" value="new"`) | 270 | assert.Contains(t, line.body, `name="side" value="new"`) | ||
| 271 | + | assert.Contains(t, line.body, `comment on search.go:3 (new)`, "one line: the title names it") | |||
| 272 | + | assert.Contains(t, b.get(base+"?on=new:1-3:search.go", as(token)).body, `comment on search.go:1-3 (new)`, "a range: the title names all of it") | |||
| 271 | assert.Equal(t, 0, strings.Count(b.get(base+"?on=new:99:search.go", as(token)).body, `name="line" value="99"`), "a line the diff lacks has no form") | 273 | assert.Equal(t, 0, strings.Count(b.get(base+"?on=new:99:search.go", as(token)).body, `name="line" value="99"`), "a line the diff lacks has no form") | ||
| 272 | assert.Equal(t, 0, strings.Count(b.get(base+"?on=new:3:search.go", nil).body, `<form`), "nor for a visitor") | 274 | assert.Equal(t, 0, strings.Count(b.get(base+"?on=new:3:search.go", nil).body, `<form`), "nor for a visitor") | ||
| 273 | 275 | ||||
M internal/domain/repo.go +1 -0
| @@ -42,6 +42,7 @@ type Repo struct { | |||
| 42 | 42 | Visibility Visibility | |
| 43 | 43 | CreatedAt time.Time | |
| 44 | 44 | PushedAt time.Time // zero until the first push | |
| 45 | + | ActiveAt time.Time // latest push, merge request or issue activity | |
| 45 | 46 | // Settings are the merge settings; the stores always fill them in, with | |
| 46 | 47 | // the defaults for a repository that never stored any. | |
| 47 | 48 | Settings RepoSettings | |
| @@ -42,6 +42,7 @@ type Repo struct { | |||||
| 42 | Visibility Visibility | 42 | Visibility Visibility | ||
| 43 | CreatedAt time.Time | 43 | CreatedAt time.Time | ||
| 44 | PushedAt time.Time // zero until the first push | 44 | PushedAt time.Time // zero until the first push | ||
| 45 | + | ActiveAt time.Time // latest push, merge request or issue activity | |||
| 45 | // Settings are the merge settings; the stores always fill them in, with | 46 | // Settings are the merge settings; the stores always fill them in, with | ||
| 46 | // the defaults for a repository that never stored any. | 47 | // the defaults for a repository that never stored any. | ||
| 47 | Settings RepoSettings | 48 | Settings RepoSettings | ||
M internal/ports/dto.go +1 -1
| @@ -21,7 +21,7 @@ type RepoPage struct { | |||
| 21 | 21 | ||
| 22 | 22 | // RepoView is a repository with the Git state that belongs in its summary. | |
| 23 | 23 | type RepoView struct { | |
| 24 | - | domain.Repo | |
| 24 | + | domain.Repo // includes ActiveAt, the latest repository activity | |
| 25 | 25 | // DefaultBranch is the branch HEAD points to, even when it does not exist | |
| 26 | 26 | // yet; empty when HEAD is detached or the directory is missing. | |
| 27 | 27 | DefaultBranch string | |
| @@ -21,7 +21,7 @@ type RepoPage struct { | |||||
| 21 | 21 | ||||
| 22 | // RepoView is a repository with the Git state that belongs in its summary. | 22 | // RepoView is a repository with the Git state that belongs in its summary. | ||
| 23 | type RepoView struct { | 23 | type RepoView struct { | ||
| 24 | - | domain.Repo | 24 | + | domain.Repo // includes ActiveAt, the latest repository activity |
| 25 | // DefaultBranch is the branch HEAD points to, even when it does not exist | 25 | // DefaultBranch is the branch HEAD points to, even when it does not exist | ||
| 26 | // yet; empty when HEAD is detached or the directory is missing. | 26 | // yet; empty when HEAD is detached or the directory is missing. | ||
| 27 | DefaultBranch string | 27 | DefaultBranch string | ||
M internal/ports/mocks/RepoMetaStore.go +36 -21
| @@ -289,59 +289,74 @@ func (_c *RepoMetaStore_List_Call) RunAndReturn(run func(ctx context.Context) ([ | |||
| 289 | 289 | return _c | |
| 290 | 290 | } | |
| 291 | 291 | ||
| 292 | - | // Update provides a mock function for the type RepoMetaStore | |
| 293 | - | func (_mock *RepoMetaStore) Update(ctx context.Context, repo domain.Repo) error { | |
| 294 | - | ret := _mock.Called(ctx, repo) | |
| 292 | + | // Mutate provides a mock function for the type RepoMetaStore | |
| 293 | + | func (_mock *RepoMetaStore) Mutate(ctx context.Context, name domain.RepoName, fn func(*domain.Repo) (changed bool, err error)) (domain.Repo, error) { | |
| 294 | + | ret := _mock.Called(ctx, name, fn) | |
| 295 | 295 | ||
| 296 | 296 | if len(ret) == 0 { | |
| 297 | - | panic("no return value specified for Update") | |
| 297 | + | panic("no return value specified for Mutate") | |
| 298 | 298 | } | |
| 299 | 299 | ||
| 300 | - | var r0 error | |
| 301 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Repo) error); ok { | |
| 302 | - | r0 = returnFunc(ctx, repo) | |
| 300 | + | var r0 domain.Repo | |
| 301 | + | var r1 error | |
| 302 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, func(*domain.Repo) (changed bool, err error)) (domain.Repo, error)); ok { | |
| 303 | + | return returnFunc(ctx, name, fn) | |
| 304 | + | } | |
| 305 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, func(*domain.Repo) (changed bool, err error)) domain.Repo); ok { | |
| 306 | + | r0 = returnFunc(ctx, name, fn) | |
| 303 | 307 | } else { | |
| 304 | - | r0 = ret.Error(0) | |
| 308 | + | r0 = ret.Get(0).(domain.Repo) | |
| 305 | 309 | } | |
| 306 | - | return r0 | |
| 310 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, func(*domain.Repo) (changed bool, err error)) error); ok { | |
| 311 | + | r1 = returnFunc(ctx, name, fn) | |
| 312 | + | } else { | |
| 313 | + | r1 = ret.Error(1) | |
| 314 | + | } | |
| 315 | + | return r0, r1 | |
| 307 | 316 | } | |
| 308 | 317 | ||
| 309 | - | // RepoMetaStore_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update' | |
| 310 | - | type RepoMetaStore_Update_Call struct { | |
| 318 | + | // RepoMetaStore_Mutate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Mutate' | |
| 319 | + | type RepoMetaStore_Mutate_Call struct { | |
| 311 | 320 | *mock.Call | |
| 312 | 321 | } | |
| 313 | 322 | ||
| 314 | - | // Update is a helper method to define mock.On call | |
| 323 | + | // Mutate is a helper method to define mock.On call | |
| 315 | 324 | // - ctx context.Context | |
| 316 | - | // - repo domain.Repo | |
| 317 | - | func (_e *RepoMetaStore_Expecter) Update(ctx any, repo any) *RepoMetaStore_Update_Call { | |
| 318 | - | return &RepoMetaStore_Update_Call{Call: _e.mock.On("Update", ctx, repo)} | |
| 325 | + | // - name domain.RepoName | |
| 326 | + | // - fn func(*domain.Repo) (changed bool, err error) | |
| 327 | + | func (_e *RepoMetaStore_Expecter) Mutate(ctx any, name any, fn any) *RepoMetaStore_Mutate_Call { | |
| 328 | + | return &RepoMetaStore_Mutate_Call{Call: _e.mock.On("Mutate", ctx, name, fn)} | |
| 319 | 329 | } | |
| 320 | 330 | ||
| 321 | - | func (_c *RepoMetaStore_Update_Call) Run(run func(ctx context.Context, repo domain.Repo)) *RepoMetaStore_Update_Call { | |
| 331 | + | func (_c *RepoMetaStore_Mutate_Call) Run(run func(ctx context.Context, name domain.RepoName, fn func(*domain.Repo) (changed bool, err error))) *RepoMetaStore_Mutate_Call { | |
| 322 | 332 | _c.Call.Run(func(args mock.Arguments) { | |
| 323 | 333 | var arg0 context.Context | |
| 324 | 334 | if args[0] != nil { | |
| 325 | 335 | arg0 = args[0].(context.Context) | |
| 326 | 336 | } | |
| 327 | - | var arg1 domain.Repo | |
| 337 | + | var arg1 domain.RepoName | |
| 328 | 338 | if args[1] != nil { | |
| 329 | - | arg1 = args[1].(domain.Repo) | |
| 339 | + | arg1 = args[1].(domain.RepoName) | |
| 340 | + | } | |
| 341 | + | var arg2 func(*domain.Repo) (changed bool, err error) | |
| 342 | + | if args[2] != nil { | |
| 343 | + | arg2 = args[2].(func(*domain.Repo) (changed bool, err error)) | |
| 330 | 344 | } | |
| 331 | 345 | run( | |
| 332 | 346 | arg0, | |
| 333 | 347 | arg1, | |
| 348 | + | arg2, | |
| 334 | 349 | ) | |
| 335 | 350 | }) | |
| 336 | 351 | return _c | |
| 337 | 352 | } | |
| 338 | 353 | ||
| 339 | - | func (_c *RepoMetaStore_Update_Call) Return(err error) *RepoMetaStore_Update_Call { | |
| 340 | - | _c.Call.Return(err) | |
| 354 | + | func (_c *RepoMetaStore_Mutate_Call) Return(repo domain.Repo, err error) *RepoMetaStore_Mutate_Call { | |
| 355 | + | _c.Call.Return(repo, err) | |
| 341 | 356 | return _c | |
| 342 | 357 | } | |
| 343 | 358 | ||
| 344 | - | func (_c *RepoMetaStore_Update_Call) RunAndReturn(run func(ctx context.Context, repo domain.Repo) error) *RepoMetaStore_Update_Call { | |
| 359 | + | func (_c *RepoMetaStore_Mutate_Call) RunAndReturn(run func(ctx context.Context, name domain.RepoName, fn func(*domain.Repo) (changed bool, err error)) (domain.Repo, error)) *RepoMetaStore_Mutate_Call { | |
| 345 | 360 | _c.Call.Return(run) | |
| 346 | 361 | return _c | |
| 347 | 362 | } | |
| @@ -289,59 +289,74 @@ func (_c *RepoMetaStore_List_Call) RunAndReturn(run func(ctx context.Context) ([ | |||||
| 289 | return _c | 289 | return _c | ||
| 290 | } | 290 | } | ||
| 291 | 291 | ||||
| 292 | - | // Update provides a mock function for the type RepoMetaStore | 292 | + | // Mutate provides a mock function for the type RepoMetaStore |
| 293 | - | func (_mock *RepoMetaStore) Update(ctx context.Context, repo domain.Repo) error { | 293 | + | func (_mock *RepoMetaStore) Mutate(ctx context.Context, name domain.RepoName, fn func(*domain.Repo) (changed bool, err error)) (domain.Repo, error) { |
| 294 | - | ret := _mock.Called(ctx, repo) | 294 | + | ret := _mock.Called(ctx, name, fn) |
| 295 | 295 | ||||
| 296 | if len(ret) == 0 { | 296 | if len(ret) == 0 { | ||
| 297 | - | panic("no return value specified for Update") | 297 | + | panic("no return value specified for Mutate") |
| 298 | } | 298 | } | ||
| 299 | 299 | ||||
| 300 | - | var r0 error | 300 | + | var r0 domain.Repo |
| 301 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Repo) error); ok { | 301 | + | var r1 error |
| 302 | - | r0 = returnFunc(ctx, repo) | 302 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, func(*domain.Repo) (changed bool, err error)) (domain.Repo, error)); ok { |
| 303 | + | return returnFunc(ctx, name, fn) | |||
| 304 | + | } | |||
| 305 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, func(*domain.Repo) (changed bool, err error)) domain.Repo); ok { | |||
| 306 | + | r0 = returnFunc(ctx, name, fn) | |||
| 303 | } else { | 307 | } else { | ||
| 304 | - | r0 = ret.Error(0) | 308 | + | r0 = ret.Get(0).(domain.Repo) |
| 305 | } | 309 | } | ||
| 306 | - | return r0 | 310 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, func(*domain.Repo) (changed bool, err error)) error); ok { |
| 311 | + | r1 = returnFunc(ctx, name, fn) | |||
| 312 | + | } else { | |||
| 313 | + | r1 = ret.Error(1) | |||
| 314 | + | } | |||
| 315 | + | return r0, r1 | |||
| 307 | } | 316 | } | ||
| 308 | 317 | ||||
| 309 | - | // RepoMetaStore_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update' | 318 | + | // RepoMetaStore_Mutate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Mutate' |
| 310 | - | type RepoMetaStore_Update_Call struct { | 319 | + | type RepoMetaStore_Mutate_Call struct { |
| 311 | *mock.Call | 320 | *mock.Call | ||
| 312 | } | 321 | } | ||
| 313 | 322 | ||||
| 314 | - | // Update is a helper method to define mock.On call | 323 | + | // Mutate is a helper method to define mock.On call |
| 315 | // - ctx context.Context | 324 | // - ctx context.Context | ||
| 316 | - | // - repo domain.Repo | 325 | + | // - name domain.RepoName |
| 317 | - | func (_e *RepoMetaStore_Expecter) Update(ctx any, repo any) *RepoMetaStore_Update_Call { | 326 | + | // - fn func(*domain.Repo) (changed bool, err error) |
| 318 | - | return &RepoMetaStore_Update_Call{Call: _e.mock.On("Update", ctx, repo)} | 327 | + | func (_e *RepoMetaStore_Expecter) Mutate(ctx any, name any, fn any) *RepoMetaStore_Mutate_Call { |
| 328 | + | return &RepoMetaStore_Mutate_Call{Call: _e.mock.On("Mutate", ctx, name, fn)} | |||
| 319 | } | 329 | } | ||
| 320 | 330 | ||||
| 321 | - | func (_c *RepoMetaStore_Update_Call) Run(run func(ctx context.Context, repo domain.Repo)) *RepoMetaStore_Update_Call { | 331 | + | func (_c *RepoMetaStore_Mutate_Call) Run(run func(ctx context.Context, name domain.RepoName, fn func(*domain.Repo) (changed bool, err error))) *RepoMetaStore_Mutate_Call { |
| 322 | _c.Call.Run(func(args mock.Arguments) { | 332 | _c.Call.Run(func(args mock.Arguments) { | ||
| 323 | var arg0 context.Context | 333 | var arg0 context.Context | ||
| 324 | if args[0] != nil { | 334 | if args[0] != nil { | ||
| 325 | arg0 = args[0].(context.Context) | 335 | arg0 = args[0].(context.Context) | ||
| 326 | } | 336 | } | ||
| 327 | - | var arg1 domain.Repo | 337 | + | var arg1 domain.RepoName |
| 328 | if args[1] != nil { | 338 | if args[1] != nil { | ||
| 329 | - | arg1 = args[1].(domain.Repo) | 339 | + | arg1 = args[1].(domain.RepoName) |
| 340 | + | } | |||
| 341 | + | var arg2 func(*domain.Repo) (changed bool, err error) | |||
| 342 | + | if args[2] != nil { | |||
| 343 | + | arg2 = args[2].(func(*domain.Repo) (changed bool, err error)) | |||
| 330 | } | 344 | } | ||
| 331 | run( | 345 | run( | ||
| 332 | arg0, | 346 | arg0, | ||
| 333 | arg1, | 347 | arg1, | ||
| 348 | + | arg2, | |||
| 334 | ) | 349 | ) | ||
| 335 | }) | 350 | }) | ||
| 336 | return _c | 351 | return _c | ||
| 337 | } | 352 | } | ||
| 338 | 353 | ||||
| 339 | - | func (_c *RepoMetaStore_Update_Call) Return(err error) *RepoMetaStore_Update_Call { | 354 | + | func (_c *RepoMetaStore_Mutate_Call) Return(repo domain.Repo, err error) *RepoMetaStore_Mutate_Call { |
| 340 | - | _c.Call.Return(err) | 355 | + | _c.Call.Return(repo, err) |
| 341 | return _c | 356 | return _c | ||
| 342 | } | 357 | } | ||
| 343 | 358 | ||||
| 344 | - | func (_c *RepoMetaStore_Update_Call) RunAndReturn(run func(ctx context.Context, repo domain.Repo) error) *RepoMetaStore_Update_Call { | 359 | + | func (_c *RepoMetaStore_Mutate_Call) RunAndReturn(run func(ctx context.Context, name domain.RepoName, fn func(*domain.Repo) (changed bool, err error)) (domain.Repo, error)) *RepoMetaStore_Mutate_Call { |
| 345 | _c.Call.Return(run) | 360 | _c.Call.Return(run) | ||
| 346 | return _c | 361 | return _c | ||
| 347 | } | 362 | } | ||
M internal/ports/ports.go +7 -4
| @@ -32,10 +32,13 @@ type RepoMetaStore interface { | |||
| 32 | 32 | Get(ctx context.Context, name domain.RepoName) (domain.Repo, error) | |
| 33 | 33 | // List returns every repository ordered by name. | |
| 34 | 34 | List(ctx context.Context) ([]domain.Repo, error) | |
| 35 | - | // Update replaces the stored repository. It returns domain.ErrNotFound if | |
| 36 | - | // there is none and domain.ErrConflict if another write changed the | |
| 37 | - | // stored repository since it was read. | |
| 38 | - | Update(ctx context.Context, repo domain.Repo) error | |
| 35 | + | // Mutate applies fn to the stored repository and writes under the version read. | |
| 36 | + | // Concurrent writes retry fn up to a bounded number of attempts, then return | |
| 37 | + | // domain.ErrConflict. fn must be pure and safe to call several times, and | |
| 38 | + | // must not change Name. changed=false writes nothing. It returns the stored | |
| 39 | + | // repository, or domain.ErrNotFound when absent. Errors from fn are returned | |
| 40 | + | // unchanged with nothing written. | |
| 41 | + | Mutate(ctx context.Context, name domain.RepoName, fn func(*domain.Repo) (changed bool, err error)) (domain.Repo, error) | |
| 39 | 42 | // Delete removes the repository and everything keyed to it: its merge | |
| 40 | 43 | // requests, their comments and read positions, and its number sequence. | |
| 41 | 44 | // A repository created again under the same name starts empty, with its | |
| @@ -32,10 +32,13 @@ type RepoMetaStore interface { | |||||
| 32 | Get(ctx context.Context, name domain.RepoName) (domain.Repo, error) | 32 | Get(ctx context.Context, name domain.RepoName) (domain.Repo, error) | ||
| 33 | // List returns every repository ordered by name. | 33 | // List returns every repository ordered by name. | ||
| 34 | List(ctx context.Context) ([]domain.Repo, error) | 34 | List(ctx context.Context) ([]domain.Repo, error) | ||
| 35 | - | // Update replaces the stored repository. It returns domain.ErrNotFound if | 35 | + | // Mutate applies fn to the stored repository and writes under the version read. |
| 36 | - | // there is none and domain.ErrConflict if another write changed the | 36 | + | // Concurrent writes retry fn up to a bounded number of attempts, then return |
| 37 | - | // stored repository since it was read. | 37 | + | // domain.ErrConflict. fn must be pure and safe to call several times, and |
| 38 | - | Update(ctx context.Context, repo domain.Repo) error | 38 | + | // must not change Name. changed=false writes nothing. It returns the stored |
| 39 | + | // repository, or domain.ErrNotFound when absent. Errors from fn are returned | |||
| 40 | + | // unchanged with nothing written. | |||
| 41 | + | Mutate(ctx context.Context, name domain.RepoName, fn func(*domain.Repo) (changed bool, err error)) (domain.Repo, error) | |||
| 39 | // Delete removes the repository and everything keyed to it: its merge | 42 | // Delete removes the repository and everything keyed to it: its merge | ||
| 40 | // requests, their comments and read positions, and its number sequence. | 43 | // requests, their comments and read positions, and its number sequence. | ||
| 41 | // A repository created again under the same name starts empty, with its | 44 | // A repository created again under the same name starts empty, with its | ||
A internal/usecase/activity.go +43 -0
| @@ -0,0 +1,43 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/zerowrap" | |
| 12 | + | ||
| 13 | + | "bnema.dev/ermite/internal/domain" | |
| 14 | + | "bnema.dev/ermite/internal/ports" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // recordActivity is best-effort: a metadata failure never fails the action. | |
| 18 | + | func recordActivity(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, at time.Time) { | |
| 19 | + | if _, err := touchActiveAt(ctx, meta, name, at); err != nil { | |
| 20 | + | log := zerowrap.FromCtx(ctx) | |
| 21 | + | log.Error().Err(err).Str("repo", name.String()).Msg("record repository activity") | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // touchActiveAt preserves later activity and retries concurrent metadata writes. | |
| 26 | + | // A repository deleted meanwhile is not an error. | |
| 27 | + | func touchActiveAt(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, now time.Time) (domain.Repo, error) { | |
| 28 | + | repo, err := meta.Mutate(ctx, name, func(repo *domain.Repo) (bool, error) { | |
| 29 | + | changed := false | |
| 30 | + | if now.After(repo.ActiveAt) { | |
| 31 | + | repo.ActiveAt = now | |
| 32 | + | changed = true | |
| 33 | + | } | |
| 34 | + | return changed, nil | |
| 35 | + | }) | |
| 36 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 37 | + | return domain.Repo{}, nil | |
| 38 | + | } | |
| 39 | + | if err != nil { | |
| 40 | + | return repo, fmt.Errorf("update repository %q: %w", name, err) | |
| 41 | + | } | |
| 42 | + | return repo, nil | |
| 43 | + | } | |
| @@ -0,0 +1,43 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package usecase | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "fmt" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/zerowrap" | |||
| 12 | + | ||||
| 13 | + | "bnema.dev/ermite/internal/domain" | |||
| 14 | + | "bnema.dev/ermite/internal/ports" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // recordActivity is best-effort: a metadata failure never fails the action. | |||
| 18 | + | func recordActivity(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, at time.Time) { | |||
| 19 | + | if _, err := touchActiveAt(ctx, meta, name, at); err != nil { | |||
| 20 | + | log := zerowrap.FromCtx(ctx) | |||
| 21 | + | log.Error().Err(err).Str("repo", name.String()).Msg("record repository activity") | |||
| 22 | + | } | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // touchActiveAt preserves later activity and retries concurrent metadata writes. | |||
| 26 | + | // A repository deleted meanwhile is not an error. | |||
| 27 | + | func touchActiveAt(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, now time.Time) (domain.Repo, error) { | |||
| 28 | + | repo, err := meta.Mutate(ctx, name, func(repo *domain.Repo) (bool, error) { | |||
| 29 | + | changed := false | |||
| 30 | + | if now.After(repo.ActiveAt) { | |||
| 31 | + | repo.ActiveAt = now | |||
| 32 | + | changed = true | |||
| 33 | + | } | |||
| 34 | + | return changed, nil | |||
| 35 | + | }) | |||
| 36 | + | if errors.Is(err, domain.ErrNotFound) { | |||
| 37 | + | return domain.Repo{}, nil | |||
| 38 | + | } | |||
| 39 | + | if err != nil { | |||
| 40 | + | return repo, fmt.Errorf("update repository %q: %w", name, err) | |||
| 41 | + | } | |||
| 42 | + | return repo, nil | |||
| 43 | + | } | |||
A internal/usecase/activity_store_test.go +46 -0
| @@ -0,0 +1,46 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | "bnema.dev/ermite/internal/adapters/kvstore" | |
| 12 | + | "bnema.dev/ermite/internal/domain" | |
| 13 | + | "bnema.dev/ermite/internal/usecase" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestActivityTimestampsOverStore(t *testing.T) { | |
| 17 | + | ctx := t.Context() | |
| 18 | + | s, err := kvstore.Open(ctx, t.TempDir()) | |
| 19 | + | require.NoError(t, err) | |
| 20 | + | t.Cleanup(func() { require.NoError(t, s.Close(ctx)) }) | |
| 21 | + | n, err := domain.ParseRepoName("proj") | |
| 22 | + | require.NoError(t, err) | |
| 23 | + | at := time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC) | |
| 24 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: at})) | |
| 25 | + | got, err := usecase.TouchPushedAt(ctx, s, n, at.Add(time.Hour)) | |
| 26 | + | require.NoError(t, err) | |
| 27 | + | require.Equal(t, got.PushedAt, got.ActiveAt) | |
| 28 | + | _, err = usecase.TouchActiveAt(ctx, s, n, at.Add(3*time.Hour)) | |
| 29 | + | require.NoError(t, err) | |
| 30 | + | _, err = usecase.TouchPushedAt(ctx, s, n, at.Add(2*time.Hour)) | |
| 31 | + | require.NoError(t, err) | |
| 32 | + | _, err = usecase.TouchPushedAt(ctx, s, n, at) | |
| 33 | + | require.NoError(t, err) | |
| 34 | + | _, err = usecase.TouchActiveAt(ctx, s, n, at) | |
| 35 | + | require.NoError(t, err) | |
| 36 | + | got, err = s.Get(ctx, n) | |
| 37 | + | require.NoError(t, err) | |
| 38 | + | require.Equal(t, at.Add(2*time.Hour), got.PushedAt) | |
| 39 | + | require.Equal(t, at.Add(3*time.Hour), got.ActiveAt) | |
| 40 | + | require.Equal(t, domain.VisibilityPrivate, got.Visibility) | |
| 41 | + | require.NoError(t, s.Delete(ctx, n)) | |
| 42 | + | _, err = usecase.TouchPushedAt(ctx, s, n, at) | |
| 43 | + | require.NoError(t, err) | |
| 44 | + | _, err = usecase.TouchActiveAt(ctx, s, n, at) | |
| 45 | + | require.NoError(t, err) | |
| 46 | + | } | |
| @@ -0,0 +1,46 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package usecase_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ||||
| 11 | + | "bnema.dev/ermite/internal/adapters/kvstore" | |||
| 12 | + | "bnema.dev/ermite/internal/domain" | |||
| 13 | + | "bnema.dev/ermite/internal/usecase" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func TestActivityTimestampsOverStore(t *testing.T) { | |||
| 17 | + | ctx := t.Context() | |||
| 18 | + | s, err := kvstore.Open(ctx, t.TempDir()) | |||
| 19 | + | require.NoError(t, err) | |||
| 20 | + | t.Cleanup(func() { require.NoError(t, s.Close(ctx)) }) | |||
| 21 | + | n, err := domain.ParseRepoName("proj") | |||
| 22 | + | require.NoError(t, err) | |||
| 23 | + | at := time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC) | |||
| 24 | + | require.NoError(t, s.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: at})) | |||
| 25 | + | got, err := usecase.TouchPushedAt(ctx, s, n, at.Add(time.Hour)) | |||
| 26 | + | require.NoError(t, err) | |||
| 27 | + | require.Equal(t, got.PushedAt, got.ActiveAt) | |||
| 28 | + | _, err = usecase.TouchActiveAt(ctx, s, n, at.Add(3*time.Hour)) | |||
| 29 | + | require.NoError(t, err) | |||
| 30 | + | _, err = usecase.TouchPushedAt(ctx, s, n, at.Add(2*time.Hour)) | |||
| 31 | + | require.NoError(t, err) | |||
| 32 | + | _, err = usecase.TouchPushedAt(ctx, s, n, at) | |||
| 33 | + | require.NoError(t, err) | |||
| 34 | + | _, err = usecase.TouchActiveAt(ctx, s, n, at) | |||
| 35 | + | require.NoError(t, err) | |||
| 36 | + | got, err = s.Get(ctx, n) | |||
| 37 | + | require.NoError(t, err) | |||
| 38 | + | require.Equal(t, at.Add(2*time.Hour), got.PushedAt) | |||
| 39 | + | require.Equal(t, at.Add(3*time.Hour), got.ActiveAt) | |||
| 40 | + | require.Equal(t, domain.VisibilityPrivate, got.Visibility) | |||
| 41 | + | require.NoError(t, s.Delete(ctx, n)) | |||
| 42 | + | _, err = usecase.TouchPushedAt(ctx, s, n, at) | |||
| 43 | + | require.NoError(t, err) | |||
| 44 | + | _, err = usecase.TouchActiveAt(ctx, s, n, at) | |||
| 45 | + | require.NoError(t, err) | |||
| 46 | + | } | |||
A internal/usecase/activity_test.go +47 -0
| @@ -0,0 +1,47 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/stretchr/testify/mock" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ||
| 14 | + | "bnema.dev/ermite/internal/domain" | |
| 15 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | func TestTouchActiveAt(t *testing.T) { | |
| 19 | + | name, err := domain.ParseRepoName("proj") | |
| 20 | + | require.NoError(t, err) | |
| 21 | + | now := time.Now().UTC() | |
| 22 | + | t.Run("preserves later activity", func(t *testing.T) { | |
| 23 | + | meta := mocks.NewRepoMetaStore(t) | |
| 24 | + | later := domain.Repo{Name: name, ActiveAt: now.Add(time.Hour)} | |
| 25 | + | meta.EXPECT().Mutate(mock.Anything, name, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.RepoName, fn func(*domain.Repo) (bool, error)) (domain.Repo, error) { | |
| 26 | + | repo := later | |
| 27 | + | changed, err := fn(&repo) | |
| 28 | + | require.NoError(t, err) | |
| 29 | + | require.False(t, changed) | |
| 30 | + | return repo, nil | |
| 31 | + | }) | |
| 32 | + | got, err := touchActiveAt(t.Context(), meta, name, now) | |
| 33 | + | require.NoError(t, err) | |
| 34 | + | require.Equal(t, later, got) | |
| 35 | + | }) | |
| 36 | + | t.Run("deleted repository", func(t *testing.T) { | |
| 37 | + | meta := mocks.NewRepoMetaStore(t) | |
| 38 | + | meta.EXPECT().Mutate(mock.Anything, name, mock.Anything).Return(domain.Repo{}, domain.ErrNotFound) | |
| 39 | + | _, err := touchActiveAt(t.Context(), meta, name, now) | |
| 40 | + | require.NoError(t, err) | |
| 41 | + | }) | |
| 42 | + | t.Run("best effort failure", func(t *testing.T) { | |
| 43 | + | meta := mocks.NewRepoMetaStore(t) | |
| 44 | + | meta.EXPECT().Mutate(mock.Anything, name, mock.Anything).Return(domain.Repo{}, errors.New("offline")) | |
| 45 | + | require.NotPanics(t, func() { recordActivity(t.Context(), meta, name, now) }) | |
| 46 | + | }) | |
| 47 | + | } | |
| @@ -0,0 +1,47 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package usecase | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/stretchr/testify/mock" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ||||
| 14 | + | "bnema.dev/ermite/internal/domain" | |||
| 15 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | func TestTouchActiveAt(t *testing.T) { | |||
| 19 | + | name, err := domain.ParseRepoName("proj") | |||
| 20 | + | require.NoError(t, err) | |||
| 21 | + | now := time.Now().UTC() | |||
| 22 | + | t.Run("preserves later activity", func(t *testing.T) { | |||
| 23 | + | meta := mocks.NewRepoMetaStore(t) | |||
| 24 | + | later := domain.Repo{Name: name, ActiveAt: now.Add(time.Hour)} | |||
| 25 | + | meta.EXPECT().Mutate(mock.Anything, name, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.RepoName, fn func(*domain.Repo) (bool, error)) (domain.Repo, error) { | |||
| 26 | + | repo := later | |||
| 27 | + | changed, err := fn(&repo) | |||
| 28 | + | require.NoError(t, err) | |||
| 29 | + | require.False(t, changed) | |||
| 30 | + | return repo, nil | |||
| 31 | + | }) | |||
| 32 | + | got, err := touchActiveAt(t.Context(), meta, name, now) | |||
| 33 | + | require.NoError(t, err) | |||
| 34 | + | require.Equal(t, later, got) | |||
| 35 | + | }) | |||
| 36 | + | t.Run("deleted repository", func(t *testing.T) { | |||
| 37 | + | meta := mocks.NewRepoMetaStore(t) | |||
| 38 | + | meta.EXPECT().Mutate(mock.Anything, name, mock.Anything).Return(domain.Repo{}, domain.ErrNotFound) | |||
| 39 | + | _, err := touchActiveAt(t.Context(), meta, name, now) | |||
| 40 | + | require.NoError(t, err) | |||
| 41 | + | }) | |||
| 42 | + | t.Run("best effort failure", func(t *testing.T) { | |||
| 43 | + | meta := mocks.NewRepoMetaStore(t) | |||
| 44 | + | meta.EXPECT().Mutate(mock.Anything, name, mock.Anything).Return(domain.Repo{}, errors.New("offline")) | |||
| 45 | + | require.NotPanics(t, func() { recordActivity(t.Context(), meta, name, now) }) | |||
| 46 | + | }) | |||
| 47 | + | } | |||
M internal/usecase/export_test.go +10 -0
| @@ -19,6 +19,16 @@ import ( | |||
| 19 | 19 | // Sweep exposes the daily sweep to the tests. | |
| 20 | 20 | func (c *CI) Sweep(ctx context.Context) { c.sweep(ctx) } | |
| 21 | 21 | ||
| 22 | + | // TouchPushedAt exposes touchPushedAt to the tests. | |
| 23 | + | func TouchPushedAt(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, now time.Time) (domain.Repo, error) { | |
| 24 | + | return touchPushedAt(ctx, meta, name, now) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // TouchActiveAt exposes touchActiveAt to the tests. | |
| 28 | + | func TouchActiveAt(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, now time.Time) (domain.Repo, error) { | |
| 29 | + | return touchActiveAt(ctx, meta, name, now) | |
| 30 | + | } | |
| 31 | + | ||
| 22 | 32 | // RunTitle gives the title of a run as startRun sets it: the evaluated | |
| 23 | 33 | // run-name, else commitTitle. | |
| 24 | 34 | func (c *CI) RunTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, commitTitle string) string { | |
| @@ -19,6 +19,16 @@ import ( | |||||
| 19 | // Sweep exposes the daily sweep to the tests. | 19 | // Sweep exposes the daily sweep to the tests. | ||
| 20 | func (c *CI) Sweep(ctx context.Context) { c.sweep(ctx) } | 20 | func (c *CI) Sweep(ctx context.Context) { c.sweep(ctx) } | ||
| 21 | 21 | ||||
| 22 | + | // TouchPushedAt exposes touchPushedAt to the tests. | |||
| 23 | + | func TouchPushedAt(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, now time.Time) (domain.Repo, error) { | |||
| 24 | + | return touchPushedAt(ctx, meta, name, now) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // TouchActiveAt exposes touchActiveAt to the tests. | |||
| 28 | + | func TouchActiveAt(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, now time.Time) (domain.Repo, error) { | |||
| 29 | + | return touchActiveAt(ctx, meta, name, now) | |||
| 30 | + | } | |||
| 31 | + | ||||
| 22 | // RunTitle gives the title of a run as startRun sets it: the evaluated | 32 | // RunTitle gives the title of a run as startRun sets it: the evaluated | ||
| 23 | // run-name, else commitTitle. | 33 | // run-name, else commitTitle. | ||
| 24 | func (c *CI) RunTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, commitTitle string) string { | 34 | func (c *CI) RunTitle(ctx context.Context, wf *workflow.Workflow, githubJSON, commitTitle string) string { | ||
M internal/usecase/issue_moderation.go +1 -0
| @@ -85,6 +85,7 @@ func (m *IssueModeration) Approve(ctx context.Context, p domain.Principal, repo | |||
| 85 | 85 | log := zerowrap.FromCtx(ctx) | |
| 86 | 86 | log.Info().Str("repo", repo.String()).Int64("number", issue.Number). | |
| 87 | 87 | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("pending issue approved") | |
| 88 | + | recordActivity(ctx, m.meta, repo, now) | |
| 88 | 89 | m.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: r.Visibility, CreatedAt: r.CreatedAt}) | |
| 89 | 90 | return issue, nil | |
| 90 | 91 | } | |
| @@ -85,6 +85,7 @@ func (m *IssueModeration) Approve(ctx context.Context, p domain.Principal, repo | |||||
| 85 | log := zerowrap.FromCtx(ctx) | 85 | log := zerowrap.FromCtx(ctx) | ||
| 86 | log.Info().Str("repo", repo.String()).Int64("number", issue.Number). | 86 | log.Info().Str("repo", repo.String()).Int64("number", issue.Number). | ||
| 87 | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("pending issue approved") | 87 | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("pending issue approved") | ||
| 88 | + | recordActivity(ctx, m.meta, repo, now) | |||
| 88 | m.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: r.Visibility, CreatedAt: r.CreatedAt}) | 89 | m.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: r.Visibility, CreatedAt: r.CreatedAt}) | ||
| 89 | return issue, nil | 90 | return issue, nil | ||
| 90 | } | 91 | } | ||
M internal/usecase/issue_moderation_test.go +1 -0
| @@ -89,6 +89,7 @@ func TestIssueModerationApprove(t *testing.T) { | |||
| 89 | 89 | u := newModerationUnit(t, domain.IssuesPublic) | |
| 90 | 90 | issue := domain.Issue{Repo: u.repo.Name, Number: 7, State: domain.IssueStateOpen} | |
| 91 | 91 | u.pending.EXPECT().Approve(mock.Anything, u.repo.Name, u.id, now).Return(issue, nil).Once() | |
| 92 | + | expectRepoMutation(t, u.meta, u.repo, mock.MatchedBy(func(r domain.Repo) bool { return r.ActiveAt.Equal(now) })).Once() | |
| 92 | 93 | u.bus.EXPECT().Publish(mock.Anything, domain.IssueOpened{Issue: issue, At: now, Visibility: u.repo.Visibility, CreatedAt: u.repo.CreatedAt}).Once() | |
| 93 | 94 | got, err := u.mod.Approve(ctx, domain.Owner(), u.repo.Name, u.id) | |
| 94 | 95 | require.NoError(t, err) | |
| @@ -89,6 +89,7 @@ func TestIssueModerationApprove(t *testing.T) { | |||||
| 89 | u := newModerationUnit(t, domain.IssuesPublic) | 89 | u := newModerationUnit(t, domain.IssuesPublic) | ||
| 90 | issue := domain.Issue{Repo: u.repo.Name, Number: 7, State: domain.IssueStateOpen} | 90 | issue := domain.Issue{Repo: u.repo.Name, Number: 7, State: domain.IssueStateOpen} | ||
| 91 | u.pending.EXPECT().Approve(mock.Anything, u.repo.Name, u.id, now).Return(issue, nil).Once() | 91 | u.pending.EXPECT().Approve(mock.Anything, u.repo.Name, u.id, now).Return(issue, nil).Once() | ||
| 92 | + | expectRepoMutation(t, u.meta, u.repo, mock.MatchedBy(func(r domain.Repo) bool { return r.ActiveAt.Equal(now) })).Once() | |||
| 92 | u.bus.EXPECT().Publish(mock.Anything, domain.IssueOpened{Issue: issue, At: now, Visibility: u.repo.Visibility, CreatedAt: u.repo.CreatedAt}).Once() | 93 | u.bus.EXPECT().Publish(mock.Anything, domain.IssueOpened{Issue: issue, At: now, Visibility: u.repo.Visibility, CreatedAt: u.repo.CreatedAt}).Once() | ||
| 93 | got, err := u.mod.Approve(ctx, domain.Owner(), u.repo.Name, u.id) | 94 | got, err := u.mod.Approve(ctx, domain.Owner(), u.repo.Name, u.id) | ||
| 94 | require.NoError(t, err) | 95 | require.NoError(t, err) | ||
M internal/usecase/issues.go +3 -0
| @@ -124,6 +124,7 @@ func (i *Issues) Create(ctx context.Context, p domain.Principal, name domain.Rep | |||
| 124 | 124 | log := zerowrap.FromCtx(ctx) | |
| 125 | 125 | log.Info().Str("repo", name.String()).Int64("number", issue.Number). | |
| 126 | 126 | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue opened") | |
| 127 | + | recordActivity(ctx, i.meta, repo.Name, now) | |
| 127 | 128 | i.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 128 | 129 | return issue, nil | |
| 129 | 130 | } | |
| @@ -211,6 +212,7 @@ func (i *Issues) AddComment(ctx context.Context, p domain.Principal, name domain | |||
| 211 | 212 | log := zerowrap.FromCtx(ctx) | |
| 212 | 213 | log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID). | |
| 213 | 214 | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue comment added") | |
| 215 | + | recordActivity(ctx, i.meta, name, c.CreatedAt) | |
| 214 | 216 | i.bus.Publish(ctx, domain.IssueCommented{ | |
| 215 | 217 | Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, | |
| 216 | 218 | }) | |
| @@ -260,6 +262,7 @@ func (i *Issues) transition(ctx context.Context, p domain.Principal, name domain | |||
| 260 | 262 | log := zerowrap.FromCtx(ctx) | |
| 261 | 263 | log.Info().Str("repo", name.String()).Int64("number", number). | |
| 262 | 264 | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue " + verb + "d") | |
| 265 | + | recordActivity(ctx, i.meta, repo.Name, issue.UpdatedAt) | |
| 263 | 266 | if to == domain.IssueStateOpen { | |
| 264 | 267 | i.bus.Publish(ctx, domain.IssueReopened{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 265 | 268 | } else { | |
| @@ -124,6 +124,7 @@ func (i *Issues) Create(ctx context.Context, p domain.Principal, name domain.Rep | |||||
| 124 | log := zerowrap.FromCtx(ctx) | 124 | log := zerowrap.FromCtx(ctx) | ||
| 125 | log.Info().Str("repo", name.String()).Int64("number", issue.Number). | 125 | log.Info().Str("repo", name.String()).Int64("number", issue.Number). | ||
| 126 | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue opened") | 126 | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue opened") | ||
| 127 | + | recordActivity(ctx, i.meta, repo.Name, now) | |||
| 127 | i.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | 128 | i.bus.Publish(ctx, domain.IssueOpened{Issue: issue, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | ||
| 128 | return issue, nil | 129 | return issue, nil | ||
| 129 | } | 130 | } | ||
| @@ -211,6 +212,7 @@ func (i *Issues) AddComment(ctx context.Context, p domain.Principal, name domain | |||||
| 211 | log := zerowrap.FromCtx(ctx) | 212 | log := zerowrap.FromCtx(ctx) | ||
| 212 | log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID). | 213 | log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID). | ||
| 213 | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue comment added") | 214 | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue comment added") | ||
| 215 | + | recordActivity(ctx, i.meta, name, c.CreatedAt) | |||
| 214 | i.bus.Publish(ctx, domain.IssueCommented{ | 216 | i.bus.Publish(ctx, domain.IssueCommented{ | ||
| 215 | Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, | 217 | Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, | ||
| 216 | }) | 218 | }) | ||
| @@ -260,6 +262,7 @@ func (i *Issues) transition(ctx context.Context, p domain.Principal, name domain | |||||
| 260 | log := zerowrap.FromCtx(ctx) | 262 | log := zerowrap.FromCtx(ctx) | ||
| 261 | log.Info().Str("repo", name.String()).Int64("number", number). | 263 | log.Info().Str("repo", name.String()).Int64("number", number). | ||
| 262 | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue " + verb + "d") | 264 | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("issue " + verb + "d") | ||
| 265 | + | recordActivity(ctx, i.meta, repo.Name, issue.UpdatedAt) | |||
| 263 | if to == domain.IssueStateOpen { | 266 | if to == domain.IssueStateOpen { | ||
| 264 | i.bus.Publish(ctx, domain.IssueReopened{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | 267 | i.bus.Publish(ctx, domain.IssueReopened{Issue: issue, At: issue.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | ||
| 265 | } else { | 268 | } else { | ||
M internal/usecase/issues_integration_test.go +67 -0
| @@ -6,14 +6,17 @@ import ( | |||
| 6 | 6 | "context" | |
| 7 | 7 | "path/filepath" | |
| 8 | 8 | "testing" | |
| 9 | + | "time" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/mock" | |
| 11 | 13 | "github.com/stretchr/testify/require" | |
| 12 | 14 | ||
| 13 | 15 | "bnema.dev/ermite/internal/adapters/eventbus" | |
| 14 | 16 | "bnema.dev/ermite/internal/adapters/kvstore" | |
| 15 | 17 | "bnema.dev/ermite/internal/domain" | |
| 16 | 18 | "bnema.dev/ermite/internal/ports" | |
| 19 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 17 | 20 | "bnema.dev/ermite/internal/usecase" | |
| 18 | 21 | ) | |
| 19 | 22 | ||
| @@ -46,6 +49,10 @@ func TestIssuesOverTheStore(t *testing.T) { | |||
| 46 | 49 | require.NoError(t, err) | |
| 47 | 50 | assert.EqualValues(t, 2, issue.Number, "the number follows the merge request") | |
| 48 | 51 | assert.Equal(t, "bot", issue.Author) | |
| 52 | + | repo, err := store.Get(ctx, n) | |
| 53 | + | require.NoError(t, err) | |
| 54 | + | assert.True(t, repo.ActiveAt.Equal(now)) | |
| 55 | + | assert.True(t, repo.PushedAt.IsZero()) | |
| 49 | 56 | ||
| 50 | 57 | kind, err := issues.Kind(ctx, domain.Anonymous(), n, 1) | |
| 51 | 58 | require.NoError(t, err) | |
| @@ -85,3 +92,63 @@ func TestIssuesOverTheStore(t *testing.T) { | |||
| 85 | 92 | } | |
| 86 | 93 | assert.Equal(t, []string{"issue.opened", "issue.commented", "issue.closed", "issue.commented", "issue.reopened"}, kinds) | |
| 87 | 94 | } | |
| 95 | + | ||
| 96 | + | // Moderated submissions are private work until approval makes them public activity. | |
| 97 | + | func TestModeratedIssueActivityOverStore(t *testing.T) { | |
| 98 | + | ctx := t.Context() | |
| 99 | + | store, err := kvstore.Open(ctx, t.TempDir()) | |
| 100 | + | require.NoError(t, err) | |
| 101 | + | t.Cleanup(func() { require.NoError(t, store.Close(ctx)) }) | |
| 102 | + | n := name(t, "proj") | |
| 103 | + | initial := now.Add(-time.Hour) | |
| 104 | + | require.NoError(t, store.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: initial, ActiveAt: initial, Settings: withIssues(domain.IssuesPublic)})) | |
| 105 | + | clock := mocks.NewClock(t) | |
| 106 | + | at := now | |
| 107 | + | clock.EXPECT().Now().RunAndReturn(func() time.Time { return at }).Maybe() | |
| 108 | + | keys, err := usecase.NewIssueKeys(ctx, store.IssueKeys()) | |
| 109 | + | require.NoError(t, err) | |
| 110 | + | limiter := mocks.NewSubmissionLimiter(t) | |
| 111 | + | limiter.EXPECT().Recent(mock.Anything, mock.Anything).Return(0, 0).Maybe() | |
| 112 | + | limiter.EXPECT().Reserve(mock.Anything, mock.Anything).Return(true, time.Duration(0), func() {}).Maybe() | |
| 113 | + | bus := mocks.NewEventBus(t) | |
| 114 | + | var kinds []string | |
| 115 | + | bus.EXPECT().Publish(mock.Anything, mock.Anything).Run(func(_ context.Context, ev domain.Event) { | |
| 116 | + | repo, err := store.Get(ctx, n) | |
| 117 | + | require.NoError(t, err) | |
| 118 | + | if ev.EventKind() == "issue.opened" { | |
| 119 | + | require.Equal(t, at, repo.ActiveAt) | |
| 120 | + | } else { | |
| 121 | + | require.Equal(t, initial, repo.ActiveAt) | |
| 122 | + | } | |
| 123 | + | kinds = append(kinds, ev.EventKind()) | |
| 124 | + | }).Maybe() | |
| 125 | + | submissions := usecase.NewIssueSubmissions(usecase.IssueSubmissionsConfig{Meta: store, Pending: store.PendingIssues(), Bans: store.IssueBans(), Limiter: limiter, Keys: keys, Bus: bus, Clock: clock, BaseDifficulty: testBits, MaxDifficulty: testBits}) | |
| 126 | + | moderation := usecase.NewIssueModeration(usecase.IssueModerationConfig{Meta: store, Pending: store.PendingIssues(), Bans: store.IssueBans(), Bus: bus, Clock: clock}) | |
| 127 | + | submit := func() domain.PendingIssueID { | |
| 128 | + | ch, err := submissions.Challenge(ctx, n, "client") | |
| 129 | + | require.NoError(t, err) | |
| 130 | + | sub := ports.IssueSubmission{Token: ch.Token, FormToken: ch.FormToken, Solution: solve(t, ch), Title: "Bug", Body: "Details"} | |
| 131 | + | at = at.Add(usecase.MinFillTime) | |
| 132 | + | require.NoError(t, submissions.Submit(ctx, n, "client", sub)) | |
| 133 | + | repo, err := store.Get(ctx, n) | |
| 134 | + | require.NoError(t, err) | |
| 135 | + | require.Equal(t, initial, repo.ActiveAt) | |
| 136 | + | page, err := store.PendingIssues().List(ctx, n, ports.ListPendingParams{PageSize: 10}) | |
| 137 | + | require.NoError(t, err) | |
| 138 | + | require.Len(t, page.Issues, 1) | |
| 139 | + | return page.Issues[0].ID | |
| 140 | + | } | |
| 141 | + | id := submit() | |
| 142 | + | require.NoError(t, moderation.Reject(ctx, domain.Owner(), n, id, false)) | |
| 143 | + | repo, err := store.Get(ctx, n) | |
| 144 | + | require.NoError(t, err) | |
| 145 | + | require.Equal(t, initial, repo.ActiveAt) | |
| 146 | + | id = submit() | |
| 147 | + | _, err = moderation.Approve(ctx, domain.Owner(), n, id) | |
| 148 | + | require.NoError(t, err) | |
| 149 | + | repo, err = store.Get(ctx, n) | |
| 150 | + | require.NoError(t, err) | |
| 151 | + | require.Equal(t, at, repo.ActiveAt) | |
| 152 | + | require.True(t, repo.PushedAt.IsZero()) | |
| 153 | + | require.Equal(t, []string{"issue.submitted", "issue.pending_removed", "issue.submitted", "issue.opened"}, kinds) | |
| 154 | + | } | |
| @@ -6,14 +6,17 @@ import ( | |||||
| 6 | "context" | 6 | "context" | ||
| 7 | "path/filepath" | 7 | "path/filepath" | ||
| 8 | "testing" | 8 | "testing" | ||
| 9 | + | "time" | |||
| 9 | 10 | ||||
| 10 | "github.com/stretchr/testify/assert" | 11 | "github.com/stretchr/testify/assert" | ||
| 12 | + | "github.com/stretchr/testify/mock" | |||
| 11 | "github.com/stretchr/testify/require" | 13 | "github.com/stretchr/testify/require" | ||
| 12 | 14 | ||||
| 13 | "bnema.dev/ermite/internal/adapters/eventbus" | 15 | "bnema.dev/ermite/internal/adapters/eventbus" | ||
| 14 | "bnema.dev/ermite/internal/adapters/kvstore" | 16 | "bnema.dev/ermite/internal/adapters/kvstore" | ||
| 15 | "bnema.dev/ermite/internal/domain" | 17 | "bnema.dev/ermite/internal/domain" | ||
| 16 | "bnema.dev/ermite/internal/ports" | 18 | "bnema.dev/ermite/internal/ports" | ||
| 19 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 17 | "bnema.dev/ermite/internal/usecase" | 20 | "bnema.dev/ermite/internal/usecase" | ||
| 18 | ) | 21 | ) | ||
| 19 | 22 | ||||
| @@ -46,6 +49,10 @@ func TestIssuesOverTheStore(t *testing.T) { | |||||
| 46 | require.NoError(t, err) | 49 | require.NoError(t, err) | ||
| 47 | assert.EqualValues(t, 2, issue.Number, "the number follows the merge request") | 50 | assert.EqualValues(t, 2, issue.Number, "the number follows the merge request") | ||
| 48 | assert.Equal(t, "bot", issue.Author) | 51 | assert.Equal(t, "bot", issue.Author) | ||
| 52 | + | repo, err := store.Get(ctx, n) | |||
| 53 | + | require.NoError(t, err) | |||
| 54 | + | assert.True(t, repo.ActiveAt.Equal(now)) | |||
| 55 | + | assert.True(t, repo.PushedAt.IsZero()) | |||
| 49 | 56 | ||||
| 50 | kind, err := issues.Kind(ctx, domain.Anonymous(), n, 1) | 57 | kind, err := issues.Kind(ctx, domain.Anonymous(), n, 1) | ||
| 51 | require.NoError(t, err) | 58 | require.NoError(t, err) | ||
| @@ -85,3 +92,63 @@ func TestIssuesOverTheStore(t *testing.T) { | |||||
| 85 | } | 92 | } | ||
| 86 | assert.Equal(t, []string{"issue.opened", "issue.commented", "issue.closed", "issue.commented", "issue.reopened"}, kinds) | 93 | assert.Equal(t, []string{"issue.opened", "issue.commented", "issue.closed", "issue.commented", "issue.reopened"}, kinds) | ||
| 87 | } | 94 | } | ||
| 95 | + | ||||
| 96 | + | // Moderated submissions are private work until approval makes them public activity. | |||
| 97 | + | func TestModeratedIssueActivityOverStore(t *testing.T) { | |||
| 98 | + | ctx := t.Context() | |||
| 99 | + | store, err := kvstore.Open(ctx, t.TempDir()) | |||
| 100 | + | require.NoError(t, err) | |||
| 101 | + | t.Cleanup(func() { require.NoError(t, store.Close(ctx)) }) | |||
| 102 | + | n := name(t, "proj") | |||
| 103 | + | initial := now.Add(-time.Hour) | |||
| 104 | + | require.NoError(t, store.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: initial, ActiveAt: initial, Settings: withIssues(domain.IssuesPublic)})) | |||
| 105 | + | clock := mocks.NewClock(t) | |||
| 106 | + | at := now | |||
| 107 | + | clock.EXPECT().Now().RunAndReturn(func() time.Time { return at }).Maybe() | |||
| 108 | + | keys, err := usecase.NewIssueKeys(ctx, store.IssueKeys()) | |||
| 109 | + | require.NoError(t, err) | |||
| 110 | + | limiter := mocks.NewSubmissionLimiter(t) | |||
| 111 | + | limiter.EXPECT().Recent(mock.Anything, mock.Anything).Return(0, 0).Maybe() | |||
| 112 | + | limiter.EXPECT().Reserve(mock.Anything, mock.Anything).Return(true, time.Duration(0), func() {}).Maybe() | |||
| 113 | + | bus := mocks.NewEventBus(t) | |||
| 114 | + | var kinds []string | |||
| 115 | + | bus.EXPECT().Publish(mock.Anything, mock.Anything).Run(func(_ context.Context, ev domain.Event) { | |||
| 116 | + | repo, err := store.Get(ctx, n) | |||
| 117 | + | require.NoError(t, err) | |||
| 118 | + | if ev.EventKind() == "issue.opened" { | |||
| 119 | + | require.Equal(t, at, repo.ActiveAt) | |||
| 120 | + | } else { | |||
| 121 | + | require.Equal(t, initial, repo.ActiveAt) | |||
| 122 | + | } | |||
| 123 | + | kinds = append(kinds, ev.EventKind()) | |||
| 124 | + | }).Maybe() | |||
| 125 | + | submissions := usecase.NewIssueSubmissions(usecase.IssueSubmissionsConfig{Meta: store, Pending: store.PendingIssues(), Bans: store.IssueBans(), Limiter: limiter, Keys: keys, Bus: bus, Clock: clock, BaseDifficulty: testBits, MaxDifficulty: testBits}) | |||
| 126 | + | moderation := usecase.NewIssueModeration(usecase.IssueModerationConfig{Meta: store, Pending: store.PendingIssues(), Bans: store.IssueBans(), Bus: bus, Clock: clock}) | |||
| 127 | + | submit := func() domain.PendingIssueID { | |||
| 128 | + | ch, err := submissions.Challenge(ctx, n, "client") | |||
| 129 | + | require.NoError(t, err) | |||
| 130 | + | sub := ports.IssueSubmission{Token: ch.Token, FormToken: ch.FormToken, Solution: solve(t, ch), Title: "Bug", Body: "Details"} | |||
| 131 | + | at = at.Add(usecase.MinFillTime) | |||
| 132 | + | require.NoError(t, submissions.Submit(ctx, n, "client", sub)) | |||
| 133 | + | repo, err := store.Get(ctx, n) | |||
| 134 | + | require.NoError(t, err) | |||
| 135 | + | require.Equal(t, initial, repo.ActiveAt) | |||
| 136 | + | page, err := store.PendingIssues().List(ctx, n, ports.ListPendingParams{PageSize: 10}) | |||
| 137 | + | require.NoError(t, err) | |||
| 138 | + | require.Len(t, page.Issues, 1) | |||
| 139 | + | return page.Issues[0].ID | |||
| 140 | + | } | |||
| 141 | + | id := submit() | |||
| 142 | + | require.NoError(t, moderation.Reject(ctx, domain.Owner(), n, id, false)) | |||
| 143 | + | repo, err := store.Get(ctx, n) | |||
| 144 | + | require.NoError(t, err) | |||
| 145 | + | require.Equal(t, initial, repo.ActiveAt) | |||
| 146 | + | id = submit() | |||
| 147 | + | _, err = moderation.Approve(ctx, domain.Owner(), n, id) | |||
| 148 | + | require.NoError(t, err) | |||
| 149 | + | repo, err = store.Get(ctx, n) | |||
| 150 | + | require.NoError(t, err) | |||
| 151 | + | require.Equal(t, at, repo.ActiveAt) | |||
| 152 | + | require.True(t, repo.PushedAt.IsZero()) | |||
| 153 | + | require.Equal(t, []string{"issue.submitted", "issue.pending_removed", "issue.submitted", "issue.opened"}, kinds) | |||
| 154 | + | } | |||
M internal/usecase/issues_test.go +2 -0
| @@ -39,6 +39,7 @@ func newIssueUnit(t *testing.T, vis domain.Visibility, mode domain.IssuesMode) * | |||
| 39 | 39 | clock := mocks.NewClock(t) | |
| 40 | 40 | clock.EXPECT().Now().Return(now).Maybe() | |
| 41 | 41 | u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil).Maybe() | |
| 42 | + | expectRepoMutation(t, u.meta, u.repo, mock.MatchedBy(func(r domain.Repo) bool { return r.ActiveAt.Equal(now) && r.PushedAt.IsZero() })).Maybe() | |
| 42 | 43 | u.issues = usecase.NewIssues(usecase.IssuesConfig{Meta: u.meta, Store: u.store, MergeRequests: u.mrs, Bus: u.bus, Clock: clock}) | |
| 43 | 44 | return u | |
| 44 | 45 | } | |
| @@ -116,6 +117,7 @@ func TestIssueCreateAndTransitions(t *testing.T) { | |||
| 116 | 117 | got, err := u.issues.Create(ctx, p, u.repo.Name, ports.CreateIssueParams{Title: " Crash ", Body: "why"}) | |
| 117 | 118 | require.NoError(t, err) | |
| 118 | 119 | assert.EqualValues(t, 7, got.Number) | |
| 120 | + | u.meta.AssertCalled(t, "Mutate", mock.Anything, u.repo.Name, mock.Anything) | |
| 119 | 121 | }) | |
| 120 | 122 | ||
| 121 | 123 | t.Run("invalid text stores nothing", func(t *testing.T) { | |
| @@ -39,6 +39,7 @@ func newIssueUnit(t *testing.T, vis domain.Visibility, mode domain.IssuesMode) * | |||||
| 39 | clock := mocks.NewClock(t) | 39 | clock := mocks.NewClock(t) | ||
| 40 | clock.EXPECT().Now().Return(now).Maybe() | 40 | clock.EXPECT().Now().Return(now).Maybe() | ||
| 41 | u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil).Maybe() | 41 | u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil).Maybe() | ||
| 42 | + | expectRepoMutation(t, u.meta, u.repo, mock.MatchedBy(func(r domain.Repo) bool { return r.ActiveAt.Equal(now) && r.PushedAt.IsZero() })).Maybe() | |||
| 42 | u.issues = usecase.NewIssues(usecase.IssuesConfig{Meta: u.meta, Store: u.store, MergeRequests: u.mrs, Bus: u.bus, Clock: clock}) | 43 | u.issues = usecase.NewIssues(usecase.IssuesConfig{Meta: u.meta, Store: u.store, MergeRequests: u.mrs, Bus: u.bus, Clock: clock}) | ||
| 43 | return u | 44 | return u | ||
| 44 | } | 45 | } | ||
| @@ -116,6 +117,7 @@ func TestIssueCreateAndTransitions(t *testing.T) { | |||||
| 116 | got, err := u.issues.Create(ctx, p, u.repo.Name, ports.CreateIssueParams{Title: " Crash ", Body: "why"}) | 117 | got, err := u.issues.Create(ctx, p, u.repo.Name, ports.CreateIssueParams{Title: " Crash ", Body: "why"}) | ||
| 117 | require.NoError(t, err) | 118 | require.NoError(t, err) | ||
| 118 | assert.EqualValues(t, 7, got.Number) | 119 | assert.EqualValues(t, 7, got.Number) | ||
| 120 | + | u.meta.AssertCalled(t, "Mutate", mock.Anything, u.repo.Name, mock.Anything) | |||
| 119 | }) | 121 | }) | ||
| 120 | 122 | ||||
| 121 | t.Run("invalid text stores nothing", func(t *testing.T) { | 123 | t.Run("invalid text stores nothing", func(t *testing.T) { | ||
M internal/usecase/mergerequests.go +5 -0
| @@ -254,6 +254,7 @@ func (m *MergeRequests) Create(ctx context.Context, p domain.Principal, name dom | |||
| 254 | 254 | if err != nil { | |
| 255 | 255 | return domain.MergeRequest{}, err | |
| 256 | 256 | } | |
| 257 | + | recordActivity(ctx, m.meta, repo.Name, now) | |
| 257 | 258 | m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 258 | 259 | if m.pulls != nil { | |
| 259 | 260 | // The commits the opening is for are the ones of now. | |
| @@ -377,6 +378,7 @@ func (m *MergeRequests) MarkReady(ctx context.Context, p domain.Principal, name | |||
| 377 | 378 | if err != nil { | |
| 378 | 379 | return domain.MergeRequest{}, fmt.Errorf("mark !%d of %q ready: %w", number, name, err) | |
| 379 | 380 | } | |
| 381 | + | recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt) | |
| 380 | 382 | m.bus.Publish(ctx, domain.MergeRequestReady{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 381 | 383 | return m.withHead(ctx, mr) | |
| 382 | 384 | } | |
| @@ -656,6 +658,7 @@ func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name | |||
| 656 | 658 | log := zerowrap.FromCtx(ctx) | |
| 657 | 659 | log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID). | |
| 658 | 660 | Int64("reply_to", c.ReplyTo).Str("verdict", c.Verdict.String()).Bool("on_line", !general).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("comment added") | |
| 661 | + | recordActivity(ctx, m.meta, name, c.CreatedAt) | |
| 659 | 662 | m.bus.Publish(ctx, domain.CommentAdded{ | |
| 660 | 663 | Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, | |
| 661 | 664 | }) | |
| @@ -796,6 +799,7 @@ func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name doma | |||
| 796 | 799 | log := zerowrap.FromCtx(ctx) | |
| 797 | 800 | log.Info().Str("repo", name.String()).Int64("number", number). | |
| 798 | 801 | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed") | |
| 802 | + | recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt) | |
| 799 | 803 | m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 800 | 804 | if m.pulls != nil { | |
| 801 | 805 | m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID}, p) | |
| @@ -998,6 +1002,7 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma | |||
| 998 | 1002 | } | |
| 999 | 1003 | } | |
| 1000 | 1004 | at := mr.UpdatedAt | |
| 1005 | + | recordActivity(ctx, m.meta, repo.Name, at) | |
| 1001 | 1006 | m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | |
| 1002 | 1007 | m.bus.Publish(ctx, domain.PushEvent{ | |
| 1003 | 1008 | Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates, | |
| @@ -254,6 +254,7 @@ func (m *MergeRequests) Create(ctx context.Context, p domain.Principal, name dom | |||||
| 254 | if err != nil { | 254 | if err != nil { | ||
| 255 | return domain.MergeRequest{}, err | 255 | return domain.MergeRequest{}, err | ||
| 256 | } | 256 | } | ||
| 257 | + | recordActivity(ctx, m.meta, repo.Name, now) | |||
| 257 | m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | 258 | m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | ||
| 258 | if m.pulls != nil { | 259 | if m.pulls != nil { | ||
| 259 | // The commits the opening is for are the ones of now. | 260 | // The commits the opening is for are the ones of now. | ||
| @@ -377,6 +378,7 @@ func (m *MergeRequests) MarkReady(ctx context.Context, p domain.Principal, name | |||||
| 377 | if err != nil { | 378 | if err != nil { | ||
| 378 | return domain.MergeRequest{}, fmt.Errorf("mark !%d of %q ready: %w", number, name, err) | 379 | return domain.MergeRequest{}, fmt.Errorf("mark !%d of %q ready: %w", number, name, err) | ||
| 379 | } | 380 | } | ||
| 381 | + | recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt) | |||
| 380 | m.bus.Publish(ctx, domain.MergeRequestReady{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | 382 | m.bus.Publish(ctx, domain.MergeRequestReady{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | ||
| 381 | return m.withHead(ctx, mr) | 383 | return m.withHead(ctx, mr) | ||
| 382 | } | 384 | } | ||
| @@ -656,6 +658,7 @@ func (m *MergeRequests) AddComment(ctx context.Context, p domain.Principal, name | |||||
| 656 | log := zerowrap.FromCtx(ctx) | 658 | log := zerowrap.FromCtx(ctx) | ||
| 657 | log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID). | 659 | log.Info().Str("repo", name.String()).Int64("number", number).Int64("comment", c.ID). | ||
| 658 | Int64("reply_to", c.ReplyTo).Str("verdict", c.Verdict.String()).Bool("on_line", !general).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("comment added") | 660 | Int64("reply_to", c.ReplyTo).Str("verdict", c.Verdict.String()).Bool("on_line", !general).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("comment added") | ||
| 661 | + | recordActivity(ctx, m.meta, name, c.CreatedAt) | |||
| 659 | m.bus.Publish(ctx, domain.CommentAdded{ | 662 | m.bus.Publish(ctx, domain.CommentAdded{ | ||
| 660 | Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, | 663 | Repo: name, Number: number, Comment: c, At: c.CreatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, | ||
| 661 | }) | 664 | }) | ||
| @@ -796,6 +799,7 @@ func (m *MergeRequests) Close(ctx context.Context, p domain.Principal, name doma | |||||
| 796 | log := zerowrap.FromCtx(ctx) | 799 | log := zerowrap.FromCtx(ctx) | ||
| 797 | log.Info().Str("repo", name.String()).Int64("number", number). | 800 | log.Info().Str("repo", name.String()).Int64("number", number). | ||
| 798 | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed") | 801 | Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request closed") | ||
| 802 | + | recordActivity(ctx, m.meta, repo.Name, mr.UpdatedAt) | |||
| 799 | m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | 803 | m.bus.Publish(ctx, domain.MergeRequestClosed{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | ||
| 800 | if m.pulls != nil { | 804 | if m.pulls != nil { | ||
| 801 | m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID}, p) | 805 | m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID}, p) | ||
| @@ -998,6 +1002,7 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma | |||||
| 998 | } | 1002 | } | ||
| 999 | } | 1003 | } | ||
| 1000 | at := mr.UpdatedAt | 1004 | at := mr.UpdatedAt | ||
| 1005 | + | recordActivity(ctx, m.meta, repo.Name, at) | |||
| 1001 | m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | 1006 | m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}) | ||
| 1002 | m.bus.Publish(ctx, domain.PushEvent{ | 1007 | m.bus.Publish(ctx, domain.PushEvent{ | ||
| 1003 | Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates, | 1008 | Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates, | ||
M internal/usecase/mergerequests_integration_test.go +11 -0
| @@ -1448,3 +1448,14 @@ func TestNoteThreadsRangesAndVerdicts(t *testing.T) { | |||
| 1448 | 1448 | _, err = r.mrs.DeleteComment(r.ctx, root, r.repo, mr.Number, changes.ID) | |
| 1449 | 1449 | assert.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 1450 | 1450 | } | |
| 1451 | + | ||
| 1452 | + | func TestMergeRequestRecordsRepositoryActivity(t *testing.T) { | |
| 1453 | + | r := newMRRig(t) | |
| 1454 | + | r.seed() | |
| 1455 | + | r.feature("activity") | |
| 1456 | + | _, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "Activity", Source: "activity", Target: "main"}) | |
| 1457 | + | require.NoError(t, err) | |
| 1458 | + | repo, err := r.store.Get(r.ctx, r.repo) | |
| 1459 | + | require.NoError(t, err) | |
| 1460 | + | assert.True(t, repo.ActiveAt.Equal(now)) | |
| 1461 | + | } | |
| @@ -1448,3 +1448,14 @@ func TestNoteThreadsRangesAndVerdicts(t *testing.T) { | |||||
| 1448 | _, err = r.mrs.DeleteComment(r.ctx, root, r.repo, mr.Number, changes.ID) | 1448 | _, err = r.mrs.DeleteComment(r.ctx, root, r.repo, mr.Number, changes.ID) | ||
| 1449 | assert.ErrorIs(t, err, domain.ErrPreconditionFailed) | 1449 | assert.ErrorIs(t, err, domain.ErrPreconditionFailed) | ||
| 1450 | } | 1450 | } | ||
| 1451 | + | ||||
| 1452 | + | func TestMergeRequestRecordsRepositoryActivity(t *testing.T) { | |||
| 1453 | + | r := newMRRig(t) | |||
| 1454 | + | r.seed() | |||
| 1455 | + | r.feature("activity") | |||
| 1456 | + | _, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "Activity", Source: "activity", Target: "main"}) | |||
| 1457 | + | require.NoError(t, err) | |||
| 1458 | + | repo, err := r.store.Get(r.ctx, r.repo) | |||
| 1459 | + | require.NoError(t, err) | |||
| 1460 | + | assert.True(t, repo.ActiveAt.Equal(now)) | |||
| 1461 | + | } | |||
M internal/usecase/pushes.go +21 -29
| @@ -15,9 +15,6 @@ import ( | |||
| 15 | 15 | "bnema.dev/ermite/internal/ports" | |
| 16 | 16 | ) | |
| 17 | 17 | ||
| 18 | - | // pushRetries bounds the retries of a metadata update that loses a race. | |
| 19 | - | const pushRetries = 5 | |
| 20 | - | ||
| 21 | 18 | // Pushes are the use cases around a Git push. | |
| 22 | 19 | type Pushes struct { | |
| 23 | 20 | meta ports.RepoMetaStore | |
| @@ -209,7 +206,7 @@ func (u *Pushes) checkWorkflows(ctx context.Context, name domain.RepoName, updat | |||
| 209 | 206 | return nil | |
| 210 | 207 | } | |
| 211 | 208 | ||
| 212 | - | // Record is the post-receive step of an accepted push: it sets pushed_at, | |
| 209 | + | // Record is the post-receive step of an accepted push: it sets pushed_at and active_at, | |
| 213 | 210 | // repairs HEAD when it points to a missing branch, tells the reader that the | |
| 214 | 211 | // repository changed, queues the workflows the push triggers, publishes a | |
| 215 | 212 | // PushEvent and schedules maintenance. Every | |
| @@ -245,36 +242,31 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep | |||
| 245 | 242 | return errors.Join(errs...) | |
| 246 | 243 | } | |
| 247 | 244 | ||
| 248 | - | // touchPushedAt stores pushed_at, retrying when another write got in between. | |
| 245 | + | // touchPushedAt stores pushed_at and active_at, preserving later timestamps | |
| 246 | + | // and retrying when another write got in between. | |
| 249 | 247 | // A repository deleted meanwhile is not an error. It returns the repository | |
| 250 | - | // as read, or the zero value when it is unknown. A push (Pushes.Record) and a | |
| 251 | - | // merge, which moves a branch too, both record through it. | |
| 248 | + | // as stored after mutation, or the zero value when it is unknown. A push | |
| 249 | + | // (Pushes.Record) and a merge, which moves a branch too, both record through it. | |
| 252 | 250 | func touchPushedAt(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, now time.Time) (domain.Repo, error) { | |
| 253 | - | for range pushRetries { | |
| 254 | - | repo, err := meta.Get(ctx, name) | |
| 255 | - | if errors.Is(err, domain.ErrNotFound) { | |
| 256 | - | return domain.Repo{}, nil | |
| 257 | - | } | |
| 258 | - | if err != nil { | |
| 259 | - | return domain.Repo{}, fmt.Errorf("get repository %q: %w", name, err) | |
| 260 | - | } | |
| 261 | - | if !now.After(repo.PushedAt) { | |
| 262 | - | return repo, nil // a later push already recorded | |
| 263 | - | } | |
| 264 | - | repo.PushedAt = now | |
| 265 | - | err = meta.Update(ctx, repo) | |
| 266 | - | if errors.Is(err, domain.ErrConflict) { | |
| 267 | - | continue | |
| 251 | + | repo, err := meta.Mutate(ctx, name, func(repo *domain.Repo) (bool, error) { | |
| 252 | + | changed := false | |
| 253 | + | if now.After(repo.PushedAt) { | |
| 254 | + | repo.PushedAt = now | |
| 255 | + | changed = true | |
| 268 | 256 | } | |
| 269 | - | if errors.Is(err, domain.ErrNotFound) { | |
| 270 | - | return domain.Repo{}, nil | |
| 257 | + | if now.After(repo.ActiveAt) { | |
| 258 | + | repo.ActiveAt = now | |
| 259 | + | changed = true | |
| 271 | 260 | } | |
| 272 | - | if err != nil { | |
| 273 | - | return repo, fmt.Errorf("update repository %q: %w", name, err) | |
| 274 | - | } | |
| 275 | - | return repo, nil | |
| 261 | + | return changed, nil | |
| 262 | + | }) | |
| 263 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 264 | + | return domain.Repo{}, nil | |
| 265 | + | } | |
| 266 | + | if err != nil { | |
| 267 | + | return repo, fmt.Errorf("update repository %q: %w", name, err) | |
| 276 | 268 | } | |
| 277 | - | return domain.Repo{}, fmt.Errorf("update pushed_at of %q: %w", name, domain.ErrConflict) | |
| 269 | + | return repo, nil | |
| 278 | 270 | } | |
| 279 | 271 | ||
| 280 | 272 | // fixDefaultBranch points HEAD to a branch that exists when it points to a | |
| @@ -15,9 +15,6 @@ import ( | |||||
| 15 | "bnema.dev/ermite/internal/ports" | 15 | "bnema.dev/ermite/internal/ports" | ||
| 16 | ) | 16 | ) | ||
| 17 | 17 | ||||
| 18 | - | // pushRetries bounds the retries of a metadata update that loses a race. | |||
| 19 | - | const pushRetries = 5 | |||
| 20 | - | ||||
| 21 | // Pushes are the use cases around a Git push. | 18 | // Pushes are the use cases around a Git push. | ||
| 22 | type Pushes struct { | 19 | type Pushes struct { | ||
| 23 | meta ports.RepoMetaStore | 20 | meta ports.RepoMetaStore | ||
| @@ -209,7 +206,7 @@ func (u *Pushes) checkWorkflows(ctx context.Context, name domain.RepoName, updat | |||||
| 209 | return nil | 206 | return nil | ||
| 210 | } | 207 | } | ||
| 211 | 208 | ||||
| 212 | - | // Record is the post-receive step of an accepted push: it sets pushed_at, | 209 | + | // Record is the post-receive step of an accepted push: it sets pushed_at and active_at, |
| 213 | // repairs HEAD when it points to a missing branch, tells the reader that the | 210 | // repairs HEAD when it points to a missing branch, tells the reader that the | ||
| 214 | // repository changed, queues the workflows the push triggers, publishes a | 211 | // repository changed, queues the workflows the push triggers, publishes a | ||
| 215 | // PushEvent and schedules maintenance. Every | 212 | // PushEvent and schedules maintenance. Every | ||
| @@ -245,36 +242,31 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep | |||||
| 245 | return errors.Join(errs...) | 242 | return errors.Join(errs...) | ||
| 246 | } | 243 | } | ||
| 247 | 244 | ||||
| 248 | - | // touchPushedAt stores pushed_at, retrying when another write got in between. | 245 | + | // touchPushedAt stores pushed_at and active_at, preserving later timestamps |
| 246 | + | // and retrying when another write got in between. | |||
| 249 | // A repository deleted meanwhile is not an error. It returns the repository | 247 | // A repository deleted meanwhile is not an error. It returns the repository | ||
| 250 | - | // as read, or the zero value when it is unknown. A push (Pushes.Record) and a | 248 | + | // as stored after mutation, or the zero value when it is unknown. A push |
| 251 | - | // merge, which moves a branch too, both record through it. | 249 | + | // (Pushes.Record) and a merge, which moves a branch too, both record through it. |
| 252 | func touchPushedAt(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, now time.Time) (domain.Repo, error) { | 250 | func touchPushedAt(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, now time.Time) (domain.Repo, error) { | ||
| 253 | - | for range pushRetries { | 251 | + | repo, err := meta.Mutate(ctx, name, func(repo *domain.Repo) (bool, error) { |
| 254 | - | repo, err := meta.Get(ctx, name) | 252 | + | changed := false |
| 255 | - | if errors.Is(err, domain.ErrNotFound) { | 253 | + | if now.After(repo.PushedAt) { |
| 256 | - | return domain.Repo{}, nil | 254 | + | repo.PushedAt = now |
| 257 | - | } | 255 | + | changed = true |
| 258 | - | if err != nil { | |||
| 259 | - | return domain.Repo{}, fmt.Errorf("get repository %q: %w", name, err) | |||
| 260 | - | } | |||
| 261 | - | if !now.After(repo.PushedAt) { | |||
| 262 | - | return repo, nil // a later push already recorded | |||
| 263 | - | } | |||
| 264 | - | repo.PushedAt = now | |||
| 265 | - | err = meta.Update(ctx, repo) | |||
| 266 | - | if errors.Is(err, domain.ErrConflict) { | |||
| 267 | - | continue | |||
| 268 | } | 256 | } | ||
| 269 | - | if errors.Is(err, domain.ErrNotFound) { | 257 | + | if now.After(repo.ActiveAt) { |
| 270 | - | return domain.Repo{}, nil | 258 | + | repo.ActiveAt = now |
| 259 | + | changed = true | |||
| 271 | } | 260 | } | ||
| 272 | - | if err != nil { | 261 | + | return changed, nil |
| 273 | - | return repo, fmt.Errorf("update repository %q: %w", name, err) | 262 | + | }) |
| 274 | - | } | 263 | + | if errors.Is(err, domain.ErrNotFound) { |
| 275 | - | return repo, nil | 264 | + | return domain.Repo{}, nil |
| 265 | + | } | |||
| 266 | + | if err != nil { | |||
| 267 | + | return repo, fmt.Errorf("update repository %q: %w", name, err) | |||
| 276 | } | 268 | } | ||
| 277 | - | return domain.Repo{}, fmt.Errorf("update pushed_at of %q: %w", name, domain.ErrConflict) | 269 | + | return repo, nil |
| 278 | } | 270 | } | ||
| 279 | 271 | ||||
| 280 | // fixDefaultBranch points HEAD to a branch that exists when it points to a | 272 | // fixDefaultBranch points HEAD to a branch that exists when it points to a | ||
M internal/usecase/pushes_test.go +11 -16
| @@ -305,8 +305,7 @@ func TestPushesRecord(t *testing.T) { | |||
| 305 | 305 | f := newPushFixture(t) | |
| 306 | 306 | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | |
| 307 | 307 | repo := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now.Add(-time.Hour)} | |
| 308 | - | f.meta.EXPECT().Get(ctx, n).Return(repo, nil) | |
| 309 | - | f.meta.EXPECT().Update(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: repo.CreatedAt, PushedAt: now}).Return(nil) | |
| 308 | + | expectRepoMutation(t, f.meta, repo, domain.Repo{Name: n, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, PushedAt: now, ActiveAt: now}).Once() | |
| 310 | 309 | f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil) | |
| 311 | 310 | f.bus.EXPECT().Publish(ctx, domain.PushEvent{Repo: n, Updates: ups, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}).Return() | |
| 312 | 311 | ||
| @@ -320,22 +319,18 @@ func TestPushesRecord(t *testing.T) { | |||
| 320 | 319 | fresh := mocks.NewRepoFreshener(t) | |
| 321 | 320 | fresh.EXPECT().Refresh(ctx, n).Return().Once() | |
| 322 | 321 | f.pushes.WithFreshener(fresh) | |
| 323 | - | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n}, nil) | |
| 324 | - | f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil) | |
| 322 | + | expectPushMutation(t, f.meta, domain.Repo{Name: n}, nil) | |
| 325 | 323 | f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil) | |
| 326 | 324 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | |
| 327 | 325 | require.NoError(t, f.pushes.Record(ctx, p, n, ups)) | |
| 328 | 326 | }) | |
| 329 | 327 | ||
| 330 | - | t.Run("pushed_at retries after a conflict and never goes backwards", func(t *testing.T) { | |
| 328 | + | t.Run("pushed_at preserves a later push", func(t *testing.T) { | |
| 331 | 329 | t.Parallel() | |
| 332 | 330 | f := newPushFixture(t) | |
| 333 | 331 | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | |
| 334 | - | repo := domain.Repo{Name: n} | |
| 335 | - | f.meta.EXPECT().Get(ctx, n).Return(repo, nil).Once() | |
| 336 | - | f.meta.EXPECT().Update(ctx, mock.Anything).Return(domain.ErrConflict).Once() | |
| 337 | - | later := domain.Repo{Name: n, PushedAt: now.Add(time.Second)} | |
| 338 | - | f.meta.EXPECT().Get(ctx, n).Return(later, nil).Once() // a later push recorded meanwhile | |
| 332 | + | later := domain.Repo{Name: n, PushedAt: now.Add(time.Second), ActiveAt: now.Add(time.Second)} | |
| 333 | + | expectPushMutation(t, f.meta, later, nil) // a later push recorded meanwhile | |
| 339 | 334 | f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil) | |
| 340 | 335 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | |
| 341 | 336 | require.NoError(t, f.pushes.Record(ctx, p, n, ups)) | |
| @@ -345,7 +340,7 @@ func TestPushesRecord(t *testing.T) { | |||
| 345 | 340 | t.Parallel() | |
| 346 | 341 | f := newPushFixture(t) | |
| 347 | 342 | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | |
| 348 | - | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound) | |
| 343 | + | expectPushMutation(t, f.meta, domain.Repo{}, domain.ErrNotFound) | |
| 349 | 344 | f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil) | |
| 350 | 345 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | |
| 351 | 346 | require.NoError(t, f.pushes.Record(ctx, p, n, ups)) | |
| @@ -355,7 +350,7 @@ func TestPushesRecord(t *testing.T) { | |||
| 355 | 350 | t.Parallel() | |
| 356 | 351 | f := newPushFixture(t) | |
| 357 | 352 | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | |
| 358 | - | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound) | |
| 353 | + | expectPushMutation(t, f.meta, domain.Repo{}, domain.ErrNotFound) | |
| 359 | 354 | f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, domain.ErrNotFound) | |
| 360 | 355 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | |
| 361 | 356 | require.NoError(t, f.pushes.Record(ctx, p, n, ups)) | |
| @@ -365,7 +360,7 @@ func TestPushesRecord(t *testing.T) { | |||
| 365 | 360 | t.Parallel() | |
| 366 | 361 | f := newPushFixture(t) | |
| 367 | 362 | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | |
| 368 | - | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound) | |
| 363 | + | expectPushMutation(t, f.meta, domain.Repo{}, domain.ErrNotFound) | |
| 369 | 364 | f.git.EXPECT().DefaultBranch(ctx, n).Return("trunk", false, nil) | |
| 370 | 365 | f.git.EXPECT().SetDefaultBranch(ctx, n, "main").Return(domain.ErrNotFound) | |
| 371 | 366 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | |
| @@ -377,7 +372,7 @@ func TestPushesRecord(t *testing.T) { | |||
| 377 | 372 | f := newPushFixture(t) | |
| 378 | 373 | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | |
| 379 | 374 | boom := errors.New("boom") | |
| 380 | - | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, boom) | |
| 375 | + | expectPushMutation(t, f.meta, domain.Repo{}, boom) | |
| 381 | 376 | f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, boom) | |
| 382 | 377 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | |
| 383 | 378 | err := f.pushes.Record(ctx, p, n, ups) | |
| @@ -414,7 +409,7 @@ func TestPushesDefaultBranchRule(t *testing.T) { | |||
| 414 | 409 | t.Parallel() | |
| 415 | 410 | f := newPushFixture(t) | |
| 416 | 411 | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | |
| 417 | - | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil) | |
| 412 | + | expectPushMutation(t, f.meta, domain.Repo{Name: n, PushedAt: now, ActiveAt: now}, nil) | |
| 418 | 413 | f.git.EXPECT().DefaultBranch(ctx, n).Return(tt.head, tt.exists, nil) | |
| 419 | 414 | if tt.want != "" { | |
| 420 | 415 | f.git.EXPECT().SetDefaultBranch(ctx, n, tt.want).Return(nil) | |
| @@ -429,7 +424,7 @@ func TestPushesDefaultBranchRule(t *testing.T) { | |||
| 429 | 424 | f := newPushFixture(t) | |
| 430 | 425 | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | |
| 431 | 426 | boom := errors.New("boom") | |
| 432 | - | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil) | |
| 427 | + | expectPushMutation(t, f.meta, domain.Repo{Name: n, PushedAt: now, ActiveAt: now}, nil) | |
| 433 | 428 | f.git.EXPECT().DefaultBranch(ctx, n).Return("main", false, nil) | |
| 434 | 429 | f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom) | |
| 435 | 430 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | |
| @@ -305,8 +305,7 @@ func TestPushesRecord(t *testing.T) { | |||||
| 305 | f := newPushFixture(t) | 305 | f := newPushFixture(t) | ||
| 306 | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | 306 | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | ||
| 307 | repo := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now.Add(-time.Hour)} | 307 | repo := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: now.Add(-time.Hour)} | ||
| 308 | - | f.meta.EXPECT().Get(ctx, n).Return(repo, nil) | 308 | + | expectRepoMutation(t, f.meta, repo, domain.Repo{Name: n, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, PushedAt: now, ActiveAt: now}).Once() |
| 309 | - | f.meta.EXPECT().Update(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: repo.CreatedAt, PushedAt: now}).Return(nil) | |||
| 310 | f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil) | 309 | f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil) | ||
| 311 | f.bus.EXPECT().Publish(ctx, domain.PushEvent{Repo: n, Updates: ups, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}).Return() | 310 | f.bus.EXPECT().Publish(ctx, domain.PushEvent{Repo: n, Updates: ups, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt}).Return() | ||
| 312 | 311 | ||||
| @@ -320,22 +319,18 @@ func TestPushesRecord(t *testing.T) { | |||||
| 320 | fresh := mocks.NewRepoFreshener(t) | 319 | fresh := mocks.NewRepoFreshener(t) | ||
| 321 | fresh.EXPECT().Refresh(ctx, n).Return().Once() | 320 | fresh.EXPECT().Refresh(ctx, n).Return().Once() | ||
| 322 | f.pushes.WithFreshener(fresh) | 321 | f.pushes.WithFreshener(fresh) | ||
| 323 | - | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n}, nil) | 322 | + | expectPushMutation(t, f.meta, domain.Repo{Name: n}, nil) |
| 324 | - | f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil) | |||
| 325 | f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil) | 323 | f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil) | ||
| 326 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | 324 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | ||
| 327 | require.NoError(t, f.pushes.Record(ctx, p, n, ups)) | 325 | require.NoError(t, f.pushes.Record(ctx, p, n, ups)) | ||
| 328 | }) | 326 | }) | ||
| 329 | 327 | ||||
| 330 | - | t.Run("pushed_at retries after a conflict and never goes backwards", func(t *testing.T) { | 328 | + | t.Run("pushed_at preserves a later push", func(t *testing.T) { |
| 331 | t.Parallel() | 329 | t.Parallel() | ||
| 332 | f := newPushFixture(t) | 330 | f := newPushFixture(t) | ||
| 333 | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | 331 | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | ||
| 334 | - | repo := domain.Repo{Name: n} | 332 | + | later := domain.Repo{Name: n, PushedAt: now.Add(time.Second), ActiveAt: now.Add(time.Second)} |
| 335 | - | f.meta.EXPECT().Get(ctx, n).Return(repo, nil).Once() | 333 | + | expectPushMutation(t, f.meta, later, nil) // a later push recorded meanwhile |
| 336 | - | f.meta.EXPECT().Update(ctx, mock.Anything).Return(domain.ErrConflict).Once() | |||
| 337 | - | later := domain.Repo{Name: n, PushedAt: now.Add(time.Second)} | |||
| 338 | - | f.meta.EXPECT().Get(ctx, n).Return(later, nil).Once() // a later push recorded meanwhile | |||
| 339 | f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil) | 334 | f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil) | ||
| 340 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | 335 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | ||
| 341 | require.NoError(t, f.pushes.Record(ctx, p, n, ups)) | 336 | require.NoError(t, f.pushes.Record(ctx, p, n, ups)) | ||
| @@ -345,7 +340,7 @@ func TestPushesRecord(t *testing.T) { | |||||
| 345 | t.Parallel() | 340 | t.Parallel() | ||
| 346 | f := newPushFixture(t) | 341 | f := newPushFixture(t) | ||
| 347 | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | 342 | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | ||
| 348 | - | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound) | 343 | + | expectPushMutation(t, f.meta, domain.Repo{}, domain.ErrNotFound) |
| 349 | f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil) | 344 | f.git.EXPECT().DefaultBranch(ctx, n).Return("main", true, nil) | ||
| 350 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | 345 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | ||
| 351 | require.NoError(t, f.pushes.Record(ctx, p, n, ups)) | 346 | require.NoError(t, f.pushes.Record(ctx, p, n, ups)) | ||
| @@ -355,7 +350,7 @@ func TestPushesRecord(t *testing.T) { | |||||
| 355 | t.Parallel() | 350 | t.Parallel() | ||
| 356 | f := newPushFixture(t) | 351 | f := newPushFixture(t) | ||
| 357 | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | 352 | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | ||
| 358 | - | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound) | 353 | + | expectPushMutation(t, f.meta, domain.Repo{}, domain.ErrNotFound) |
| 359 | f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, domain.ErrNotFound) | 354 | f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, domain.ErrNotFound) | ||
| 360 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | 355 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | ||
| 361 | require.NoError(t, f.pushes.Record(ctx, p, n, ups)) | 356 | require.NoError(t, f.pushes.Record(ctx, p, n, ups)) | ||
| @@ -365,7 +360,7 @@ func TestPushesRecord(t *testing.T) { | |||||
| 365 | t.Parallel() | 360 | t.Parallel() | ||
| 366 | f := newPushFixture(t) | 361 | f := newPushFixture(t) | ||
| 367 | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | 362 | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | ||
| 368 | - | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound) | 363 | + | expectPushMutation(t, f.meta, domain.Repo{}, domain.ErrNotFound) |
| 369 | f.git.EXPECT().DefaultBranch(ctx, n).Return("trunk", false, nil) | 364 | f.git.EXPECT().DefaultBranch(ctx, n).Return("trunk", false, nil) | ||
| 370 | f.git.EXPECT().SetDefaultBranch(ctx, n, "main").Return(domain.ErrNotFound) | 365 | f.git.EXPECT().SetDefaultBranch(ctx, n, "main").Return(domain.ErrNotFound) | ||
| 371 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | 366 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | ||
| @@ -377,7 +372,7 @@ func TestPushesRecord(t *testing.T) { | |||||
| 377 | f := newPushFixture(t) | 372 | f := newPushFixture(t) | ||
| 378 | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | 373 | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | ||
| 379 | boom := errors.New("boom") | 374 | boom := errors.New("boom") | ||
| 380 | - | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, boom) | 375 | + | expectPushMutation(t, f.meta, domain.Repo{}, boom) |
| 381 | f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, boom) | 376 | f.git.EXPECT().DefaultBranch(ctx, n).Return("", false, boom) | ||
| 382 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | 377 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | ||
| 383 | err := f.pushes.Record(ctx, p, n, ups) | 378 | err := f.pushes.Record(ctx, p, n, ups) | ||
| @@ -414,7 +409,7 @@ func TestPushesDefaultBranchRule(t *testing.T) { | |||||
| 414 | t.Parallel() | 409 | t.Parallel() | ||
| 415 | f := newPushFixture(t) | 410 | f := newPushFixture(t) | ||
| 416 | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | 411 | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | ||
| 417 | - | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil) | 412 | + | expectPushMutation(t, f.meta, domain.Repo{Name: n, PushedAt: now, ActiveAt: now}, nil) |
| 418 | f.git.EXPECT().DefaultBranch(ctx, n).Return(tt.head, tt.exists, nil) | 413 | f.git.EXPECT().DefaultBranch(ctx, n).Return(tt.head, tt.exists, nil) | ||
| 419 | if tt.want != "" { | 414 | if tt.want != "" { | ||
| 420 | f.git.EXPECT().SetDefaultBranch(ctx, n, tt.want).Return(nil) | 415 | f.git.EXPECT().SetDefaultBranch(ctx, n, tt.want).Return(nil) | ||
| @@ -429,7 +424,7 @@ func TestPushesDefaultBranchRule(t *testing.T) { | |||||
| 429 | f := newPushFixture(t) | 424 | f := newPushFixture(t) | ||
| 430 | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | 425 | f.sched.EXPECT().Schedule(ctx, n).Return().Once() | ||
| 431 | boom := errors.New("boom") | 426 | boom := errors.New("boom") | ||
| 432 | - | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{Name: n, PushedAt: now}, nil) | 427 | + | expectPushMutation(t, f.meta, domain.Repo{Name: n, PushedAt: now, ActiveAt: now}, nil) |
| 433 | f.git.EXPECT().DefaultBranch(ctx, n).Return("main", false, nil) | 428 | f.git.EXPECT().DefaultBranch(ctx, n).Return("main", false, nil) | ||
| 434 | f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom) | 429 | f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom) | ||
| 435 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | 430 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return() | ||
A internal/usecase/repo_mutation_test.go +34 -0
| @@ -0,0 +1,34 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/mock" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite/internal/domain" | |
| 13 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func expectRepoMutation(t *testing.T, meta *mocks.RepoMetaStore, repo domain.Repo, want any) *mocks.RepoMetaStore_Mutate_Call { | |
| 17 | + | t.Helper() | |
| 18 | + | return meta.EXPECT().Mutate(mock.Anything, repo.Name, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.RepoName, fn func(*domain.Repo) (bool, error)) (domain.Repo, error) { | |
| 19 | + | result := repo | |
| 20 | + | _, err := fn(&result) | |
| 21 | + | require.NoError(t, err) | |
| 22 | + | _, differences := (mock.Arguments{want}).Diff([]any{result}) | |
| 23 | + | require.Zero(t, differences, "unexpected mutated repository: %+v", result) | |
| 24 | + | return result, err | |
| 25 | + | }) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func expectPushMutation(t *testing.T, meta *mocks.RepoMetaStore, repo domain.Repo, err error) *mocks.RepoMetaStore_Mutate_Call { | |
| 29 | + | t.Helper() | |
| 30 | + | if err != nil { | |
| 31 | + | return meta.EXPECT().Mutate(mock.Anything, mock.Anything, mock.Anything).Return(repo, err) | |
| 32 | + | } | |
| 33 | + | return expectRepoMutation(t, meta, repo, mock.Anything) | |
| 34 | + | } | |
| @@ -0,0 +1,34 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package usecase_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/mock" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | "bnema.dev/ermite/internal/domain" | |||
| 13 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func expectRepoMutation(t *testing.T, meta *mocks.RepoMetaStore, repo domain.Repo, want any) *mocks.RepoMetaStore_Mutate_Call { | |||
| 17 | + | t.Helper() | |||
| 18 | + | return meta.EXPECT().Mutate(mock.Anything, repo.Name, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.RepoName, fn func(*domain.Repo) (bool, error)) (domain.Repo, error) { | |||
| 19 | + | result := repo | |||
| 20 | + | _, err := fn(&result) | |||
| 21 | + | require.NoError(t, err) | |||
| 22 | + | _, differences := (mock.Arguments{want}).Diff([]any{result}) | |||
| 23 | + | require.Zero(t, differences, "unexpected mutated repository: %+v", result) | |||
| 24 | + | return result, err | |||
| 25 | + | }) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func expectPushMutation(t *testing.T, meta *mocks.RepoMetaStore, repo domain.Repo, err error) *mocks.RepoMetaStore_Mutate_Call { | |||
| 29 | + | t.Helper() | |||
| 30 | + | if err != nil { | |||
| 31 | + | return meta.EXPECT().Mutate(mock.Anything, mock.Anything, mock.Anything).Return(repo, err) | |||
| 32 | + | } | |||
| 33 | + | return expectRepoMutation(t, meta, repo, mock.Anything) | |||
| 34 | + | } | |||
M internal/usecase/repos_write.go +6 -14
| @@ -155,11 +155,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo | |||
| 155 | 155 | // updateMeta applies the metadata fields, retrying when a write (a push | |
| 156 | 156 | // recording pushed_at) gets in between. | |
| 157 | 157 | func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params ports.UpdateRepoParams) (domain.Repo, error) { | |
| 158 | - | for range pushRetries { | |
| 159 | - | repo, err := r.meta.Get(ctx, name) | |
| 160 | - | if err != nil { | |
| 161 | - | return domain.Repo{}, err | |
| 162 | - | } | |
| 158 | + | repo, err := r.meta.Mutate(ctx, name, func(repo *domain.Repo) (bool, error) { | |
| 163 | 159 | if params.Description != nil { | |
| 164 | 160 | repo.Description = *params.Description | |
| 165 | 161 | } | |
| @@ -181,16 +177,12 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por | |||
| 181 | 177 | if params.Issues != nil { | |
| 182 | 178 | repo.Settings.Issues = *params.Issues | |
| 183 | 179 | } | |
| 184 | - | err = r.meta.Update(ctx, repo) | |
| 185 | - | if errors.Is(err, domain.ErrConflict) { | |
| 186 | - | continue | |
| 187 | - | } | |
| 188 | - | if err != nil { | |
| 189 | - | return domain.Repo{}, fmt.Errorf("update repository %q: %w", name, err) | |
| 190 | - | } | |
| 191 | - | return repo, nil | |
| 180 | + | return true, nil | |
| 181 | + | }) | |
| 182 | + | if err != nil { | |
| 183 | + | return domain.Repo{}, fmt.Errorf("update repository %q: %w", name, err) | |
| 192 | 184 | } | |
| 193 | - | return domain.Repo{}, fmt.Errorf("update repository %q: %w", name, domain.ErrConflict) | |
| 185 | + | return repo, nil | |
| 194 | 186 | } | |
| 195 | 187 | ||
| 196 | 188 | // Delete moves the repository directory to the trash, removes its metadata | |
| @@ -155,11 +155,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo | |||||
| 155 | // updateMeta applies the metadata fields, retrying when a write (a push | 155 | // updateMeta applies the metadata fields, retrying when a write (a push | ||
| 156 | // recording pushed_at) gets in between. | 156 | // recording pushed_at) gets in between. | ||
| 157 | func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params ports.UpdateRepoParams) (domain.Repo, error) { | 157 | func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params ports.UpdateRepoParams) (domain.Repo, error) { | ||
| 158 | - | for range pushRetries { | 158 | + | repo, err := r.meta.Mutate(ctx, name, func(repo *domain.Repo) (bool, error) { |
| 159 | - | repo, err := r.meta.Get(ctx, name) | |||
| 160 | - | if err != nil { | |||
| 161 | - | return domain.Repo{}, err | |||
| 162 | - | } | |||
| 163 | if params.Description != nil { | 159 | if params.Description != nil { | ||
| 164 | repo.Description = *params.Description | 160 | repo.Description = *params.Description | ||
| 165 | } | 161 | } | ||
| @@ -181,16 +177,12 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por | |||||
| 181 | if params.Issues != nil { | 177 | if params.Issues != nil { | ||
| 182 | repo.Settings.Issues = *params.Issues | 178 | repo.Settings.Issues = *params.Issues | ||
| 183 | } | 179 | } | ||
| 184 | - | err = r.meta.Update(ctx, repo) | 180 | + | return true, nil |
| 185 | - | if errors.Is(err, domain.ErrConflict) { | 181 | + | }) |
| 186 | - | continue | 182 | + | if err != nil { |
| 187 | - | } | 183 | + | return domain.Repo{}, fmt.Errorf("update repository %q: %w", name, err) |
| 188 | - | if err != nil { | |||
| 189 | - | return domain.Repo{}, fmt.Errorf("update repository %q: %w", name, err) | |||
| 190 | - | } | |||
| 191 | - | return repo, nil | |||
| 192 | } | 184 | } | ||
| 193 | - | return domain.Repo{}, fmt.Errorf("update repository %q: %w", name, domain.ErrConflict) | 185 | + | return repo, nil |
| 194 | } | 186 | } | ||
| 195 | 187 | ||||
| 196 | // Delete moves the repository directory to the trash, removes its metadata | 188 | // Delete moves the repository directory to the trash, removes its metadata | ||
M internal/usecase/repos_write_test.go +20 -21
| @@ -30,9 +30,9 @@ func TestUpdate(t *testing.T) { | |||
| 30 | 30 | t.Run("description and visibility are stored and RepoUpdated is published", func(t *testing.T) { | |
| 31 | 31 | t.Parallel() | |
| 32 | 32 | f := newFixture(t) | |
| 33 | - | f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice() | |
| 33 | + | f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once() | |
| 34 | 34 | want := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "new", Settings: domain.DefaultRepoSettings()} | |
| 35 | - | f.meta.EXPECT().Update(ctx, want).Return(nil) | |
| 35 | + | expectRepoMutation(t, f.meta, priv, want) | |
| 36 | 36 | f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{ | |
| 37 | 37 | Repo: n, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPrivate, | |
| 38 | 38 | }).Return().Once() | |
| @@ -47,11 +47,11 @@ func TestUpdate(t *testing.T) { | |||
| 47 | 47 | t.Run("topics are normalized and stored, and RepoUpdated is published", func(t *testing.T) { | |
| 48 | 48 | t.Parallel() | |
| 49 | 49 | f := newFixture(t) | |
| 50 | - | f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice() | |
| 50 | + | f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once() | |
| 51 | 51 | want := priv | |
| 52 | 52 | want.Topics = []string{"cli", "go"} | |
| 53 | 53 | want.Settings = domain.DefaultRepoSettings() | |
| 54 | - | f.meta.EXPECT().Update(ctx, want).Return(nil) | |
| 54 | + | expectRepoMutation(t, f.meta, priv, want) | |
| 55 | 55 | f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{ | |
| 56 | 56 | Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPrivate, | |
| 57 | 57 | }).Return().Once() | |
| @@ -66,10 +66,10 @@ func TestUpdate(t *testing.T) { | |||
| 66 | 66 | f := newFixture(t) | |
| 67 | 67 | withTopics := priv | |
| 68 | 68 | withTopics.Topics = []string{"go"} | |
| 69 | - | f.meta.EXPECT().Get(ctx, n).Return(withTopics, nil).Twice() | |
| 69 | + | f.meta.EXPECT().Get(ctx, n).Return(withTopics, nil).Once() | |
| 70 | 70 | want := priv | |
| 71 | 71 | want.Settings = domain.DefaultRepoSettings() | |
| 72 | - | f.meta.EXPECT().Update(ctx, want).Return(nil) | |
| 72 | + | expectRepoMutation(t, f.meta, withTopics, want) | |
| 73 | 73 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once() | |
| 74 | 74 | ||
| 75 | 75 | got, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Topics: &[]string{}}) | |
| @@ -77,15 +77,12 @@ func TestUpdate(t *testing.T) { | |||
| 77 | 77 | assert.Nil(t, got.Topics) | |
| 78 | 78 | }) | |
| 79 | 79 | ||
| 80 | - | t.Run("a lost race on the metadata is retried", func(t *testing.T) { | |
| 81 | - | t.Parallel() | |
| 80 | + | t.Run("exhausted mutation conflicts are returned", func(t *testing.T) { | |
| 82 | 81 | f := newFixture(t) | |
| 83 | 82 | f.meta.EXPECT().Get(ctx, n).Return(priv, nil) | |
| 84 | - | f.meta.EXPECT().Update(ctx, mock.Anything).Return(domain.ErrConflict).Once() | |
| 85 | - | f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil).Once() | |
| 86 | - | f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once() | |
| 83 | + | f.meta.EXPECT().Mutate(ctx, n, mock.Anything).Return(domain.Repo{}, domain.ErrConflict).Once() | |
| 87 | 84 | _, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Description: ptr("x")}) | |
| 88 | - | require.NoError(t, err) | |
| 85 | + | require.ErrorIs(t, err, domain.ErrConflict) | |
| 89 | 86 | }) | |
| 90 | 87 | ||
| 91 | 88 | t.Run("the default branch is checked, then HEAD moves", func(t *testing.T) { | |
| @@ -106,9 +103,11 @@ func TestUpdate(t *testing.T) { | |||
| 106 | 103 | var order []string | |
| 107 | 104 | f.meta.EXPECT().Get(ctx, n).Return(priv, nil) | |
| 108 | 105 | f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil) | |
| 109 | - | f.meta.EXPECT().Update(ctx, mock.Anything).RunAndReturn(func(context.Context, domain.Repo) error { | |
| 106 | + | f.meta.EXPECT().Mutate(ctx, n, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.RepoName, fn func(*domain.Repo) (bool, error)) (domain.Repo, error) { | |
| 110 | 107 | order = append(order, "meta") | |
| 111 | - | return nil | |
| 108 | + | repo := priv | |
| 109 | + | _, err := fn(&repo) | |
| 110 | + | return repo, err | |
| 112 | 111 | }) | |
| 113 | 112 | f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").RunAndReturn(func(context.Context, domain.RepoName, string) error { | |
| 114 | 113 | order = append(order, "head") | |
| @@ -127,7 +126,7 @@ func TestUpdate(t *testing.T) { | |||
| 127 | 126 | pub := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "old"} | |
| 128 | 127 | f.meta.EXPECT().Get(ctx, n).Return(pub, nil) | |
| 129 | 128 | f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil) | |
| 130 | - | f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil) | |
| 129 | + | expectRepoMutation(t, f.meta, pub, mock.Anything) | |
| 131 | 130 | f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom) | |
| 132 | 131 | f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{ | |
| 133 | 132 | Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPublic, | |
| @@ -250,10 +249,10 @@ func TestUpdateIssuesMode(t *testing.T) { | |||
| 250 | 249 | t.Run("public is stored and published", func(t *testing.T) { | |
| 251 | 250 | t.Parallel() | |
| 252 | 251 | f := newFixture(t) | |
| 253 | - | f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice() | |
| 254 | - | f.meta.EXPECT().Update(ctx, mock.MatchedBy(func(r domain.Repo) bool { | |
| 252 | + | f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once() | |
| 253 | + | expectRepoMutation(t, f.meta, priv, mock.MatchedBy(func(r domain.Repo) bool { | |
| 255 | 254 | return r.Settings.Issues == domain.IssuesPublic | |
| 256 | - | })).Return(nil) | |
| 255 | + | })) | |
| 257 | 256 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once() | |
| 258 | 257 | repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesPublic)}) | |
| 259 | 258 | require.NoError(t, err) | |
| @@ -271,10 +270,10 @@ func TestUpdateIssuesMode(t *testing.T) { | |||
| 271 | 270 | t.Run("owner is stored and published", func(t *testing.T) { | |
| 272 | 271 | t.Parallel() | |
| 273 | 272 | f := newFixture(t) | |
| 274 | - | f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice() | |
| 275 | - | f.meta.EXPECT().Update(ctx, mock.MatchedBy(func(r domain.Repo) bool { | |
| 273 | + | f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once() | |
| 274 | + | expectRepoMutation(t, f.meta, priv, mock.MatchedBy(func(r domain.Repo) bool { | |
| 276 | 275 | return r.Settings.Issues == domain.IssuesOwner && r.Settings.FailingChecksBlock | |
| 277 | - | })).Return(nil) | |
| 276 | + | })) | |
| 278 | 277 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once() | |
| 279 | 278 | repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesOwner)}) | |
| 280 | 279 | require.NoError(t, err) | |
| @@ -30,9 +30,9 @@ func TestUpdate(t *testing.T) { | |||||
| 30 | t.Run("description and visibility are stored and RepoUpdated is published", func(t *testing.T) { | 30 | t.Run("description and visibility are stored and RepoUpdated is published", func(t *testing.T) { | ||
| 31 | t.Parallel() | 31 | t.Parallel() | ||
| 32 | f := newFixture(t) | 32 | f := newFixture(t) | ||
| 33 | - | f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice() | 33 | + | f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once() |
| 34 | want := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "new", Settings: domain.DefaultRepoSettings()} | 34 | want := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "new", Settings: domain.DefaultRepoSettings()} | ||
| 35 | - | f.meta.EXPECT().Update(ctx, want).Return(nil) | 35 | + | expectRepoMutation(t, f.meta, priv, want) |
| 36 | f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{ | 36 | f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{ | ||
| 37 | Repo: n, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPrivate, | 37 | Repo: n, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPrivate, | ||
| 38 | }).Return().Once() | 38 | }).Return().Once() | ||
| @@ -47,11 +47,11 @@ func TestUpdate(t *testing.T) { | |||||
| 47 | t.Run("topics are normalized and stored, and RepoUpdated is published", func(t *testing.T) { | 47 | t.Run("topics are normalized and stored, and RepoUpdated is published", func(t *testing.T) { | ||
| 48 | t.Parallel() | 48 | t.Parallel() | ||
| 49 | f := newFixture(t) | 49 | f := newFixture(t) | ||
| 50 | - | f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice() | 50 | + | f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once() |
| 51 | want := priv | 51 | want := priv | ||
| 52 | want.Topics = []string{"cli", "go"} | 52 | want.Topics = []string{"cli", "go"} | ||
| 53 | want.Settings = domain.DefaultRepoSettings() | 53 | want.Settings = domain.DefaultRepoSettings() | ||
| 54 | - | f.meta.EXPECT().Update(ctx, want).Return(nil) | 54 | + | expectRepoMutation(t, f.meta, priv, want) |
| 55 | f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{ | 55 | f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{ | ||
| 56 | Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPrivate, | 56 | Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPrivate, | ||
| 57 | }).Return().Once() | 57 | }).Return().Once() | ||
| @@ -66,10 +66,10 @@ func TestUpdate(t *testing.T) { | |||||
| 66 | f := newFixture(t) | 66 | f := newFixture(t) | ||
| 67 | withTopics := priv | 67 | withTopics := priv | ||
| 68 | withTopics.Topics = []string{"go"} | 68 | withTopics.Topics = []string{"go"} | ||
| 69 | - | f.meta.EXPECT().Get(ctx, n).Return(withTopics, nil).Twice() | 69 | + | f.meta.EXPECT().Get(ctx, n).Return(withTopics, nil).Once() |
| 70 | want := priv | 70 | want := priv | ||
| 71 | want.Settings = domain.DefaultRepoSettings() | 71 | want.Settings = domain.DefaultRepoSettings() | ||
| 72 | - | f.meta.EXPECT().Update(ctx, want).Return(nil) | 72 | + | expectRepoMutation(t, f.meta, withTopics, want) |
| 73 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once() | 73 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once() | ||
| 74 | 74 | ||||
| 75 | got, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Topics: &[]string{}}) | 75 | got, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Topics: &[]string{}}) | ||
| @@ -77,15 +77,12 @@ func TestUpdate(t *testing.T) { | |||||
| 77 | assert.Nil(t, got.Topics) | 77 | assert.Nil(t, got.Topics) | ||
| 78 | }) | 78 | }) | ||
| 79 | 79 | ||||
| 80 | - | t.Run("a lost race on the metadata is retried", func(t *testing.T) { | 80 | + | t.Run("exhausted mutation conflicts are returned", func(t *testing.T) { |
| 81 | - | t.Parallel() | |||
| 82 | f := newFixture(t) | 81 | f := newFixture(t) | ||
| 83 | f.meta.EXPECT().Get(ctx, n).Return(priv, nil) | 82 | f.meta.EXPECT().Get(ctx, n).Return(priv, nil) | ||
| 84 | - | f.meta.EXPECT().Update(ctx, mock.Anything).Return(domain.ErrConflict).Once() | 83 | + | f.meta.EXPECT().Mutate(ctx, n, mock.Anything).Return(domain.Repo{}, domain.ErrConflict).Once() |
| 85 | - | f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil).Once() | |||
| 86 | - | f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once() | |||
| 87 | _, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Description: ptr("x")}) | 84 | _, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Description: ptr("x")}) | ||
| 88 | - | require.NoError(t, err) | 85 | + | require.ErrorIs(t, err, domain.ErrConflict) |
| 89 | }) | 86 | }) | ||
| 90 | 87 | ||||
| 91 | t.Run("the default branch is checked, then HEAD moves", func(t *testing.T) { | 88 | t.Run("the default branch is checked, then HEAD moves", func(t *testing.T) { | ||
| @@ -106,9 +103,11 @@ func TestUpdate(t *testing.T) { | |||||
| 106 | var order []string | 103 | var order []string | ||
| 107 | f.meta.EXPECT().Get(ctx, n).Return(priv, nil) | 104 | f.meta.EXPECT().Get(ctx, n).Return(priv, nil) | ||
| 108 | f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil) | 105 | f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil) | ||
| 109 | - | f.meta.EXPECT().Update(ctx, mock.Anything).RunAndReturn(func(context.Context, domain.Repo) error { | 106 | + | f.meta.EXPECT().Mutate(ctx, n, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.RepoName, fn func(*domain.Repo) (bool, error)) (domain.Repo, error) { |
| 110 | order = append(order, "meta") | 107 | order = append(order, "meta") | ||
| 111 | - | return nil | 108 | + | repo := priv |
| 109 | + | _, err := fn(&repo) | |||
| 110 | + | return repo, err | |||
| 112 | }) | 111 | }) | ||
| 113 | f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").RunAndReturn(func(context.Context, domain.RepoName, string) error { | 112 | f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").RunAndReturn(func(context.Context, domain.RepoName, string) error { | ||
| 114 | order = append(order, "head") | 113 | order = append(order, "head") | ||
| @@ -127,7 +126,7 @@ func TestUpdate(t *testing.T) { | |||||
| 127 | pub := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "old"} | 126 | pub := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "old"} | ||
| 128 | f.meta.EXPECT().Get(ctx, n).Return(pub, nil) | 127 | f.meta.EXPECT().Get(ctx, n).Return(pub, nil) | ||
| 129 | f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil) | 128 | f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil) | ||
| 130 | - | f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil) | 129 | + | expectRepoMutation(t, f.meta, pub, mock.Anything) |
| 131 | f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom) | 130 | f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom) | ||
| 132 | f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{ | 131 | f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{ | ||
| 133 | Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPublic, | 132 | Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPublic, | ||
| @@ -250,10 +249,10 @@ func TestUpdateIssuesMode(t *testing.T) { | |||||
| 250 | t.Run("public is stored and published", func(t *testing.T) { | 249 | t.Run("public is stored and published", func(t *testing.T) { | ||
| 251 | t.Parallel() | 250 | t.Parallel() | ||
| 252 | f := newFixture(t) | 251 | f := newFixture(t) | ||
| 253 | - | f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice() | 252 | + | f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once() |
| 254 | - | f.meta.EXPECT().Update(ctx, mock.MatchedBy(func(r domain.Repo) bool { | 253 | + | expectRepoMutation(t, f.meta, priv, mock.MatchedBy(func(r domain.Repo) bool { |
| 255 | return r.Settings.Issues == domain.IssuesPublic | 254 | return r.Settings.Issues == domain.IssuesPublic | ||
| 256 | - | })).Return(nil) | 255 | + | })) |
| 257 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once() | 256 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once() | ||
| 258 | repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesPublic)}) | 257 | repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesPublic)}) | ||
| 259 | require.NoError(t, err) | 258 | require.NoError(t, err) | ||
| @@ -271,10 +270,10 @@ func TestUpdateIssuesMode(t *testing.T) { | |||||
| 271 | t.Run("owner is stored and published", func(t *testing.T) { | 270 | t.Run("owner is stored and published", func(t *testing.T) { | ||
| 272 | t.Parallel() | 271 | t.Parallel() | ||
| 273 | f := newFixture(t) | 272 | f := newFixture(t) | ||
| 274 | - | f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice() | 273 | + | f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once() |
| 275 | - | f.meta.EXPECT().Update(ctx, mock.MatchedBy(func(r domain.Repo) bool { | 274 | + | expectRepoMutation(t, f.meta, priv, mock.MatchedBy(func(r domain.Repo) bool { |
| 276 | return r.Settings.Issues == domain.IssuesOwner && r.Settings.FailingChecksBlock | 275 | return r.Settings.Issues == domain.IssuesOwner && r.Settings.FailingChecksBlock | ||
| 277 | - | })).Return(nil) | 276 | + | })) |
| 278 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once() | 277 | f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once() | ||
| 279 | repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesOwner)}) | 278 | repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesOwner)}) | ||
| 280 | require.NoError(t, err) | 279 | require.NoError(t, err) | ||
M proto/ermite/storage/repo.proto +2 -0
| @@ -32,6 +32,8 @@ message Repo { | |||
| 32 | 32 | RepoSettings settings = 6; | |
| 33 | 33 | // Normalized and sorted; empty on repositories stored before topics. | |
| 34 | 34 | repeated string topics = 7; | |
| 35 | + | // Latest push, merge request or issue activity; unset until recorded. | |
| 36 | + | google.protobuf.Timestamp active_at = 8; | |
| 35 | 37 | } | |
| 36 | 38 | ||
| 37 | 39 | // SchemaVersion is stored under the key "meta/schema". | |
| @@ -32,6 +32,8 @@ message Repo { | |||||
| 32 | RepoSettings settings = 6; | 32 | RepoSettings settings = 6; | ||
| 33 | // Normalized and sorted; empty on repositories stored before topics. | 33 | // Normalized and sorted; empty on repositories stored before topics. | ||
| 34 | repeated string topics = 7; | 34 | repeated string topics = 7; | ||
| 35 | + | // Latest push, merge request or issue activity; unset until recorded. | |||
| 36 | + | google.protobuf.Timestamp active_at = 8; | |||
| 35 | } | 37 | } | ||
| 36 | 38 | ||||
| 37 | // SchemaVersion is stored under the key "meta/schema". | 39 | // SchemaVersion is stored under the key "meta/schema". | ||