Merge 0a9e349cf0e4bd5155b69315570ebba15a7200da into d8e7d5b03a01f5f16f8d49f12a7e132355b40f06
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 | |
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 | |
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 | } | |
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 | + | } | |
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) | |
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 != "" { | |
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 | } | |
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 | |
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 | |
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: | |
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 | + | } | |
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 | + | } | |
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, "") | |
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 | + | }); | |
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(); | |
M internal/adapters/web/keys_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/mr.go +6 -1
| @@ -910,10 +910,15 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai | |||
| 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 | } | |
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"> | |
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> | |
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 | } | |
M internal/adapters/web/pages_templ.go +205 -223
| @@ -10,6 +10,8 @@ package web | |||
| 10 | 10 | import "github.com/a-h/templ" | |
| 11 | 11 | import templruntime "github.com/a-h/templ/runtime" | |
| 12 | 12 | ||
| 13 | + | import "fmt" | |
| 14 | + | ||
| 13 | 15 | // Home is the repository list. | |
| 14 | 16 | func Home(l Layout, p HomePage) templ.Component { | |
| 15 | 17 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| @@ -44,135 +46,115 @@ func Home(l Layout, p HomePage) templ.Component { | |||
| 44 | 46 | }() | |
| 45 | 47 | } | |
| 46 | 48 | ctx = templ.InitializeContext(ctx) | |
| 47 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<h1 class=\"sr\">Repositories</h1><div class=\"home\" data-layout=\"cards\"><section class=\"home-repos\" aria-label=\"Repositories\"><div class=\"home-bar\">") | |
| 49 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<h1 class=\"sr\">Repositories</h1><div class=\"home\" data-layout=\"cards\"><section class=\"home-repos\" aria-label=\"Repositories\"><div class=\"home-bar\"><div class=\"filter\"><label for=\"home-filter\" class=\"sr\">filter</label> <input id=\"home-filter\" type=\"search\" data-filter=\"#repo-rows > li\" placeholder=\"Filter repositories…\" autocomplete=\"off\" hx-on:input=\"ermite.filter(this)\"></div><nav class=\"sort\" aria-label=\"Sort repositories\"><span class=\"mute\">sort</span> ") | |
| 48 | 50 | if templ_7745c5c3_Err != nil { | |
| 49 | 51 | return templ_7745c5c3_Err | |
| 50 | 52 | } | |
| 51 | - | if p.Inbox != nil { | |
| 52 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<a class=\"btn inbox-btn\" href=\"") | |
| 53 | + | for _, s := range p.Sorts { | |
| 54 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<a href=\"") | |
| 53 | 55 | if templ_7745c5c3_Err != nil { | |
| 54 | 56 | return templ_7745c5c3_Err | |
| 55 | 57 | } | |
| 56 | 58 | var templ_7745c5c3_Var3 templ.SafeURL | |
| 57 | - | templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Inbox.Href)) | |
| 59 | + | templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href)) | |
| 58 | 60 | if templ_7745c5c3_Err != nil { | |
| 59 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 13, Col: 65} | |
| 61 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 29, Col: 36} | |
| 60 | 62 | } | |
| 61 | 63 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) | |
| 62 | 64 | if templ_7745c5c3_Err != nil { | |
| 63 | 65 | return templ_7745c5c3_Err | |
| 64 | 66 | } | |
| 65 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" title=\"inbox\">Inbox ") | |
| 66 | - | if templ_7745c5c3_Err != nil { | |
| 67 | - | return templ_7745c5c3_Err | |
| 68 | - | } | |
| 69 | - | if p.Inbox.Count >= 0 { | |
| 70 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<span class=\"count\">") | |
| 71 | - | if templ_7745c5c3_Err != nil { | |
| 72 | - | return templ_7745c5c3_Err | |
| 73 | - | } | |
| 74 | - | var templ_7745c5c3_Var4 string | |
| 75 | - | templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Inbox.Count)) | |
| 76 | - | if templ_7745c5c3_Err != nil { | |
| 77 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 16, Col: 49} | |
| 78 | - | } | |
| 79 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) | |
| 80 | - | if templ_7745c5c3_Err != nil { | |
| 81 | - | return templ_7745c5c3_Err | |
| 82 | - | } | |
| 83 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span>") | |
| 84 | - | if templ_7745c5c3_Err != nil { | |
| 85 | - | return templ_7745c5c3_Err | |
| 86 | - | } | |
| 87 | - | } | |
| 88 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</a>") | |
| 89 | - | if templ_7745c5c3_Err != nil { | |
| 90 | - | return templ_7745c5c3_Err | |
| 91 | - | } | |
| 92 | - | } | |
| 93 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"filter\"><label for=\"home-filter\" class=\"sr\">filter</label> <input id=\"home-filter\" type=\"search\" data-filter=\"#repo-rows > li\" placeholder=\"Filter repositories…\" autocomplete=\"off\" hx-on:input=\"ermite.filter(this)\"></div><nav class=\"sort\" aria-label=\"Sort repositories\"><span class=\"mute\">sort</span> ") | |
| 94 | - | if templ_7745c5c3_Err != nil { | |
| 95 | - | return templ_7745c5c3_Err | |
| 96 | - | } | |
| 97 | - | for _, s := range p.Sorts { | |
| 98 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<a href=\"") | |
| 99 | - | if templ_7745c5c3_Err != nil { | |
| 100 | - | return templ_7745c5c3_Err | |
| 101 | - | } | |
| 102 | - | var templ_7745c5c3_Var5 templ.SafeURL | |
| 103 | - | templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href)) | |
| 104 | - | if templ_7745c5c3_Err != nil { | |
| 105 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 35, Col: 36} | |
| 106 | - | } | |
| 107 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) | |
| 108 | - | if templ_7745c5c3_Err != nil { | |
| 109 | - | return templ_7745c5c3_Err | |
| 110 | - | } | |
| 111 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"") | |
| 67 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"") | |
| 112 | 68 | if templ_7745c5c3_Err != nil { | |
| 113 | 69 | return templ_7745c5c3_Err | |
| 114 | 70 | } | |
| 115 | 71 | if s.Mark != "" { | |
| 116 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " aria-current=\"true\"") | |
| 72 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " aria-current=\"true\"") | |
| 117 | 73 | if templ_7745c5c3_Err != nil { | |
| 118 | 74 | return templ_7745c5c3_Err | |
| 119 | 75 | } | |
| 120 | 76 | } | |
| 121 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, ">") | |
| 77 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, ">") | |
| 122 | 78 | if templ_7745c5c3_Err != nil { | |
| 123 | 79 | return templ_7745c5c3_Err | |
| 124 | 80 | } | |
| 125 | - | var templ_7745c5c3_Var6 string | |
| 126 | - | templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label) | |
| 81 | + | var templ_7745c5c3_Var4 string | |
| 82 | + | templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label) | |
| 127 | 83 | if templ_7745c5c3_Err != nil { | |
| 128 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 40, Col: 17} | |
| 84 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 34, Col: 17} | |
| 129 | 85 | } | |
| 130 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) | |
| 86 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) | |
| 131 | 87 | if templ_7745c5c3_Err != nil { | |
| 132 | 88 | return templ_7745c5c3_Err | |
| 133 | 89 | } | |
| 134 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " ") | |
| 90 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " ") | |
| 135 | 91 | if templ_7745c5c3_Err != nil { | |
| 136 | 92 | return templ_7745c5c3_Err | |
| 137 | 93 | } | |
| 138 | 94 | if s.Mark != "" { | |
| 139 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<span aria-hidden=\"true\">") | |
| 95 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<span aria-hidden=\"true\">") | |
| 140 | 96 | if templ_7745c5c3_Err != nil { | |
| 141 | 97 | return templ_7745c5c3_Err | |
| 142 | 98 | } | |
| 143 | - | var templ_7745c5c3_Var7 string | |
| 144 | - | templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(s.Mark) | |
| 99 | + | var templ_7745c5c3_Var5 string | |
| 100 | + | templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(s.Mark) | |
| 145 | 101 | if templ_7745c5c3_Err != nil { | |
| 146 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 42, Col: 42} | |
| 102 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 36, Col: 42} | |
| 147 | 103 | } | |
| 148 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) | |
| 104 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) | |
| 149 | 105 | if templ_7745c5c3_Err != nil { | |
| 150 | 106 | return templ_7745c5c3_Err | |
| 151 | 107 | } | |
| 152 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</span> <span class=\"sr\">") | |
| 108 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span> <span class=\"sr\">") | |
| 153 | 109 | if templ_7745c5c3_Err != nil { | |
| 154 | 110 | return templ_7745c5c3_Err | |
| 155 | 111 | } | |
| 156 | - | var templ_7745c5c3_Var8 string | |
| 157 | - | templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(", " + s.State) | |
| 112 | + | var templ_7745c5c3_Var6 string | |
| 113 | + | templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(", " + s.State) | |
| 158 | 114 | if templ_7745c5c3_Err != nil { | |
| 159 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 43, Col: 42} | |
| 115 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 37, Col: 42} | |
| 160 | 116 | } | |
| 161 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) | |
| 117 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) | |
| 162 | 118 | if templ_7745c5c3_Err != nil { | |
| 163 | 119 | return templ_7745c5c3_Err | |
| 164 | 120 | } | |
| 165 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</span>") | |
| 121 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</span>") | |
| 166 | 122 | if templ_7745c5c3_Err != nil { | |
| 167 | 123 | return templ_7745c5c3_Err | |
| 168 | 124 | } | |
| 169 | 125 | } | |
| 170 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</a>") | |
| 126 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</a>") | |
| 171 | 127 | if templ_7745c5c3_Err != nil { | |
| 172 | 128 | return templ_7745c5c3_Err | |
| 173 | 129 | } | |
| 174 | 130 | } | |
| 175 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</nav><div class=\"layout-switch\" role=\"group\" aria-label=\"Layout\" data-js><button type=\"button\" class=\"btn icon\" data-set-layout=\"cards\" aria-pressed=\"true\" title=\"Cards\" aria-label=\"Cards\"><span class=\"ico i-cards\" aria-hidden=\"true\"></span></button> <button type=\"button\" class=\"btn icon\" data-set-layout=\"split\" aria-pressed=\"false\" title=\"Two columns\" aria-label=\"Two columns\"><span class=\"ico i-split\" aria-hidden=\"true\"></span></button> <button type=\"button\" class=\"btn icon\" data-set-layout=\"compact\" aria-pressed=\"false\" title=\"Compact list\" aria-label=\"Compact list\"><span class=\"ico i-compact\" aria-hidden=\"true\"></span></button></div></div><ul class=\"repos\" id=\"repo-rows\" data-rows>") | |
| 131 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</nav><div class=\"layout-switch\" role=\"group\" aria-label=\"Layout\" data-js><button type=\"button\" class=\"btn icon\" data-set-layout=\"cards\" aria-pressed=\"true\" title=\"Cards\" aria-label=\"Cards\"><span class=\"ico i-cards\" aria-hidden=\"true\"></span></button> <button type=\"button\" class=\"btn icon\" data-set-layout=\"split\" aria-pressed=\"false\" title=\"Two columns\" aria-label=\"Two columns\"><span class=\"ico i-split\" aria-hidden=\"true\"></span></button> <button type=\"button\" class=\"btn icon\" data-set-layout=\"compact\" aria-pressed=\"false\" title=\"Compact list\" aria-label=\"Compact list\"><span class=\"ico i-compact\" aria-hidden=\"true\"></span></button></div></div><ul class=\"repos\" id=\"repo-rows\" data-rows data-sort=\"") | |
| 132 | + | if templ_7745c5c3_Err != nil { | |
| 133 | + | return templ_7745c5c3_Err | |
| 134 | + | } | |
| 135 | + | var templ_7745c5c3_Var7 string | |
| 136 | + | templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Sort) | |
| 137 | + | if templ_7745c5c3_Err != nil { | |
| 138 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 54, Col: 65} | |
| 139 | + | } | |
| 140 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7) | |
| 141 | + | if templ_7745c5c3_Err != nil { | |
| 142 | + | return templ_7745c5c3_Err | |
| 143 | + | } | |
| 144 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" data-desc=\"") | |
| 145 | + | if templ_7745c5c3_Err != nil { | |
| 146 | + | return templ_7745c5c3_Err | |
| 147 | + | } | |
| 148 | + | var templ_7745c5c3_Var8 string | |
| 149 | + | templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprint(p.Desc)) | |
| 150 | + | if templ_7745c5c3_Err != nil { | |
| 151 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 54, Col: 98} | |
| 152 | + | } | |
| 153 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8) | |
| 154 | + | if templ_7745c5c3_Err != nil { | |
| 155 | + | return templ_7745c5c3_Err | |
| 156 | + | } | |
| 157 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\">") | |
| 176 | 158 | if templ_7745c5c3_Err != nil { | |
| 177 | 159 | return templ_7745c5c3_Err | |
| 178 | 160 | } | |
| @@ -182,17 +164,17 @@ func Home(l Layout, p HomePage) templ.Component { | |||
| 182 | 164 | return templ_7745c5c3_Err | |
| 183 | 165 | } | |
| 184 | 166 | } | |
| 185 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</ul>") | |
| 167 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</ul>") | |
| 186 | 168 | if templ_7745c5c3_Err != nil { | |
| 187 | 169 | return templ_7745c5c3_Err | |
| 188 | 170 | } | |
| 189 | 171 | if len(p.Rows) == 0 { | |
| 190 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<p class=\"lede dim\">no repositories yet.</p>") | |
| 172 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<p class=\"lede dim\">no repositories yet.</p>") | |
| 191 | 173 | if templ_7745c5c3_Err != nil { | |
| 192 | 174 | return templ_7745c5c3_Err | |
| 193 | 175 | } | |
| 194 | 176 | } | |
| 195 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</section></div>") | |
| 177 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</section></div>") | |
| 196 | 178 | if templ_7745c5c3_Err != nil { | |
| 197 | 179 | return templ_7745c5c3_Err | |
| 198 | 180 | } | |
| @@ -281,51 +263,51 @@ func latestTagFact(t LatestTag) templ.Component { | |||
| 281 | 263 | templ_7745c5c3_Var11 = templ.NopComponent | |
| 282 | 264 | } | |
| 283 | 265 | ctx = templ.ClearChildren(ctx) | |
| 284 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<dt>latest tag</dt><dd class=\"latest-tag\">") | |
| 266 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<dt>latest tag</dt><dd class=\"latest-tag\">") | |
| 285 | 267 | if templ_7745c5c3_Err != nil { | |
| 286 | 268 | return templ_7745c5c3_Err | |
| 287 | 269 | } | |
| 288 | 270 | if t.Name != "" { | |
| 289 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<a class=\"tag-name\" href=\"") | |
| 271 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<a class=\"tag-name\" href=\"") | |
| 290 | 272 | if templ_7745c5c3_Err != nil { | |
| 291 | 273 | return templ_7745c5c3_Err | |
| 292 | 274 | } | |
| 293 | 275 | var templ_7745c5c3_Var12 templ.SafeURL | |
| 294 | 276 | templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(t.Href)) | |
| 295 | 277 | if templ_7745c5c3_Err != nil { | |
| 296 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 90, Col: 51} | |
| 278 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 84, Col: 51} | |
| 297 | 279 | } | |
| 298 | 280 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) | |
| 299 | 281 | if templ_7745c5c3_Err != nil { | |
| 300 | 282 | return templ_7745c5c3_Err | |
| 301 | 283 | } | |
| 302 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" title=\"") | |
| 284 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" title=\"") | |
| 303 | 285 | if templ_7745c5c3_Err != nil { | |
| 304 | 286 | return templ_7745c5c3_Err | |
| 305 | 287 | } | |
| 306 | 288 | var templ_7745c5c3_Var13 string | |
| 307 | 289 | templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.Subject) | |
| 308 | 290 | if templ_7745c5c3_Err != nil { | |
| 309 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 90, Col: 71} | |
| 291 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 84, Col: 71} | |
| 310 | 292 | } | |
| 311 | 293 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13) | |
| 312 | 294 | if templ_7745c5c3_Err != nil { | |
| 313 | 295 | return templ_7745c5c3_Err | |
| 314 | 296 | } | |
| 315 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\">") | |
| 297 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\">") | |
| 316 | 298 | if templ_7745c5c3_Err != nil { | |
| 317 | 299 | return templ_7745c5c3_Err | |
| 318 | 300 | } | |
| 319 | 301 | var templ_7745c5c3_Var14 string | |
| 320 | 302 | templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(t.Name) | |
| 321 | 303 | if templ_7745c5c3_Err != nil { | |
| 322 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 90, Col: 82} | |
| 304 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 84, Col: 82} | |
| 323 | 305 | } | |
| 324 | 306 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) | |
| 325 | 307 | if templ_7745c5c3_Err != nil { | |
| 326 | 308 | return templ_7745c5c3_Err | |
| 327 | 309 | } | |
| 328 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</a> ") | |
| 310 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</a> ") | |
| 329 | 311 | if templ_7745c5c3_Err != nil { | |
| 330 | 312 | return templ_7745c5c3_Err | |
| 331 | 313 | } | |
| @@ -333,101 +315,101 @@ func latestTagFact(t LatestTag) templ.Component { | |||
| 333 | 315 | var templ_7745c5c3_Var15 string | |
| 334 | 316 | templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(" · ") | |
| 335 | 317 | if templ_7745c5c3_Err != nil { | |
| 336 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 92, Col: 12} | |
| 318 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 86, Col: 12} | |
| 337 | 319 | } | |
| 338 | 320 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) | |
| 339 | 321 | if templ_7745c5c3_Err != nil { | |
| 340 | 322 | return templ_7745c5c3_Err | |
| 341 | 323 | } | |
| 342 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<time datetime=\"") | |
| 324 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<time datetime=\"") | |
| 343 | 325 | if templ_7745c5c3_Err != nil { | |
| 344 | 326 | return templ_7745c5c3_Err | |
| 345 | 327 | } | |
| 346 | 328 | var templ_7745c5c3_Var16 string | |
| 347 | 329 | templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.WhenAt) | |
| 348 | 330 | if templ_7745c5c3_Err != nil { | |
| 349 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 92, Col: 39} | |
| 331 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 86, Col: 39} | |
| 350 | 332 | } | |
| 351 | 333 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16) | |
| 352 | 334 | if templ_7745c5c3_Err != nil { | |
| 353 | 335 | return templ_7745c5c3_Err | |
| 354 | 336 | } | |
| 355 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" title=\"") | |
| 337 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" title=\"") | |
| 356 | 338 | if templ_7745c5c3_Err != nil { | |
| 357 | 339 | return templ_7745c5c3_Err | |
| 358 | 340 | } | |
| 359 | 341 | var templ_7745c5c3_Var17 string | |
| 360 | 342 | templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.WhenAt) | |
| 361 | 343 | if templ_7745c5c3_Err != nil { | |
| 362 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 92, Col: 58} | |
| 344 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 86, Col: 58} | |
| 363 | 345 | } | |
| 364 | 346 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17) | |
| 365 | 347 | if templ_7745c5c3_Err != nil { | |
| 366 | 348 | return templ_7745c5c3_Err | |
| 367 | 349 | } | |
| 368 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\">") | |
| 350 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\">") | |
| 369 | 351 | if templ_7745c5c3_Err != nil { | |
| 370 | 352 | return templ_7745c5c3_Err | |
| 371 | 353 | } | |
| 372 | 354 | var templ_7745c5c3_Var18 string | |
| 373 | 355 | templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(t.When) | |
| 374 | 356 | if templ_7745c5c3_Err != nil { | |
| 375 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 92, Col: 69} | |
| 357 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 86, Col: 69} | |
| 376 | 358 | } | |
| 377 | 359 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) | |
| 378 | 360 | if templ_7745c5c3_Err != nil { | |
| 379 | 361 | return templ_7745c5c3_Err | |
| 380 | 362 | } | |
| 381 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</time>") | |
| 363 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</time>") | |
| 382 | 364 | if templ_7745c5c3_Err != nil { | |
| 383 | 365 | return templ_7745c5c3_Err | |
| 384 | 366 | } | |
| 385 | 367 | } | |
| 386 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " ") | |
| 368 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " ") | |
| 387 | 369 | if templ_7745c5c3_Err != nil { | |
| 388 | 370 | return templ_7745c5c3_Err | |
| 389 | 371 | } | |
| 390 | 372 | var templ_7745c5c3_Var19 string | |
| 391 | 373 | templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(" · ") | |
| 392 | 374 | if templ_7745c5c3_Err != nil { | |
| 393 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 94, Col: 11} | |
| 375 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 88, Col: 11} | |
| 394 | 376 | } | |
| 395 | 377 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) | |
| 396 | 378 | if templ_7745c5c3_Err != nil { | |
| 397 | 379 | return templ_7745c5c3_Err | |
| 398 | 380 | } | |
| 399 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " ") | |
| 381 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, " ") | |
| 400 | 382 | if templ_7745c5c3_Err != nil { | |
| 401 | 383 | return templ_7745c5c3_Err | |
| 402 | 384 | } | |
| 403 | 385 | } | |
| 404 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<a class=\"all-tags\" href=\"") | |
| 386 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<a class=\"all-tags\" href=\"") | |
| 405 | 387 | if templ_7745c5c3_Err != nil { | |
| 406 | 388 | return templ_7745c5c3_Err | |
| 407 | 389 | } | |
| 408 | 390 | var templ_7745c5c3_Var20 templ.SafeURL | |
| 409 | 391 | templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(t.RefsHref)) | |
| 410 | 392 | if templ_7745c5c3_Err != nil { | |
| 411 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 96, Col: 54} | |
| 393 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 90, Col: 54} | |
| 412 | 394 | } | |
| 413 | 395 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) | |
| 414 | 396 | if templ_7745c5c3_Err != nil { | |
| 415 | 397 | return templ_7745c5c3_Err | |
| 416 | 398 | } | |
| 417 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\">") | |
| 399 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\">") | |
| 418 | 400 | if templ_7745c5c3_Err != nil { | |
| 419 | 401 | return templ_7745c5c3_Err | |
| 420 | 402 | } | |
| 421 | 403 | var templ_7745c5c3_Var21 string | |
| 422 | 404 | templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(plural(t.Count, "tag", "tags")) | |
| 423 | 405 | if templ_7745c5c3_Err != nil { | |
| 424 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 96, Col: 89} | |
| 406 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 90, Col: 89} | |
| 425 | 407 | } | |
| 426 | 408 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) | |
| 427 | 409 | if templ_7745c5c3_Err != nil { | |
| 428 | 410 | return templ_7745c5c3_Err | |
| 429 | 411 | } | |
| 430 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</a></dd>") | |
| 412 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</a></dd>") | |
| 431 | 413 | if templ_7745c5c3_Err != nil { | |
| 432 | 414 | return templ_7745c5c3_Err | |
| 433 | 415 | } | |
| @@ -456,7 +438,7 @@ func summaryRail(p SummaryPage) templ.Component { | |||
| 456 | 438 | templ_7745c5c3_Var22 = templ.NopComponent | |
| 457 | 439 | } | |
| 458 | 440 | ctx = templ.ClearChildren(ctx) | |
| 459 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<section aria-labelledby=\"r-clone\"><h2 id=\"r-clone\">Clone</h2>") | |
| 441 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<section aria-labelledby=\"r-clone\"><h2 id=\"r-clone\">Clone</h2>") | |
| 460 | 442 | if templ_7745c5c3_Err != nil { | |
| 461 | 443 | return templ_7745c5c3_Err | |
| 462 | 444 | } | |
| @@ -464,25 +446,25 @@ func summaryRail(p SummaryPage) templ.Component { | |||
| 464 | 446 | if templ_7745c5c3_Err != nil { | |
| 465 | 447 | return templ_7745c5c3_Err | |
| 466 | 448 | } | |
| 467 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</section><section aria-labelledby=\"r-facts\"><h2 id=\"r-facts\">About</h2><dl class=\"kv\">") | |
| 449 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</section><section aria-labelledby=\"r-facts\"><h2 id=\"r-facts\">About</h2><dl class=\"kv\">") | |
| 468 | 450 | if templ_7745c5c3_Err != nil { | |
| 469 | 451 | return templ_7745c5c3_Err | |
| 470 | 452 | } | |
| 471 | 453 | for _, f := range p.Facts { | |
| 472 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<dt>") | |
| 454 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<dt>") | |
| 473 | 455 | if templ_7745c5c3_Err != nil { | |
| 474 | 456 | return templ_7745c5c3_Err | |
| 475 | 457 | } | |
| 476 | 458 | var templ_7745c5c3_Var23 string | |
| 477 | 459 | templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(f.Key) | |
| 478 | 460 | if templ_7745c5c3_Err != nil { | |
| 479 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 107, Col: 15} | |
| 461 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 101, Col: 15} | |
| 480 | 462 | } | |
| 481 | 463 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) | |
| 482 | 464 | if templ_7745c5c3_Err != nil { | |
| 483 | 465 | return templ_7745c5c3_Err | |
| 484 | 466 | } | |
| 485 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</dt>") | |
| 467 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</dt>") | |
| 486 | 468 | if templ_7745c5c3_Err != nil { | |
| 487 | 469 | return templ_7745c5c3_Err | |
| 488 | 470 | } | |
| @@ -497,25 +479,25 @@ func summaryRail(p SummaryPage) templ.Component { | |||
| 497 | 479 | return templ_7745c5c3_Err | |
| 498 | 480 | } | |
| 499 | 481 | } | |
| 500 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</dl></section>") | |
| 482 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</dl></section>") | |
| 501 | 483 | if templ_7745c5c3_Err != nil { | |
| 502 | 484 | return templ_7745c5c3_Err | |
| 503 | 485 | } | |
| 504 | 486 | if len(p.Recent) > 0 { | |
| 505 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<section aria-labelledby=\"r-recent\"><h2 id=\"r-recent\">Recent commits <a href=\"") | |
| 487 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<section aria-labelledby=\"r-recent\"><h2 id=\"r-recent\">Recent commits <a href=\"") | |
| 506 | 488 | if templ_7745c5c3_Err != nil { | |
| 507 | 489 | return templ_7745c5c3_Err | |
| 508 | 490 | } | |
| 509 | 491 | var templ_7745c5c3_Var24 templ.SafeURL | |
| 510 | 492 | templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.AllHref)) | |
| 511 | 493 | if templ_7745c5c3_Err != nil { | |
| 512 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 116, Col: 105} | |
| 494 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 110, Col: 105} | |
| 513 | 495 | } | |
| 514 | 496 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) | |
| 515 | 497 | if templ_7745c5c3_Err != nil { | |
| 516 | 498 | return templ_7745c5c3_Err | |
| 517 | 499 | } | |
| 518 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\">View all</a></h2>") | |
| 500 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\">View all</a></h2>") | |
| 519 | 501 | if templ_7745c5c3_Err != nil { | |
| 520 | 502 | return templ_7745c5c3_Err | |
| 521 | 503 | } | |
| @@ -523,7 +505,7 @@ func summaryRail(p SummaryPage) templ.Component { | |||
| 523 | 505 | if templ_7745c5c3_Err != nil { | |
| 524 | 506 | return templ_7745c5c3_Err | |
| 525 | 507 | } | |
| 526 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</section>") | |
| 508 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</section>") | |
| 527 | 509 | if templ_7745c5c3_Err != nil { | |
| 528 | 510 | return templ_7745c5c3_Err | |
| 529 | 511 | } | |
| @@ -535,7 +517,7 @@ func summaryRail(p SummaryPage) templ.Component { | |||
| 535 | 517 | } | |
| 536 | 518 | } | |
| 537 | 519 | if len(p.Outline) > 0 && p.Readme != nil { | |
| 538 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<section aria-labelledby=\"r-outline\"><h2 id=\"r-outline\">On this page</h2>") | |
| 520 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<section aria-labelledby=\"r-outline\"><h2 id=\"r-outline\">On this page</h2>") | |
| 539 | 521 | if templ_7745c5c3_Err != nil { | |
| 540 | 522 | return templ_7745c5c3_Err | |
| 541 | 523 | } | |
| @@ -543,7 +525,7 @@ func summaryRail(p SummaryPage) templ.Component { | |||
| 543 | 525 | if templ_7745c5c3_Err != nil { | |
| 544 | 526 | return templ_7745c5c3_Err | |
| 545 | 527 | } | |
| 546 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</section>") | |
| 528 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</section>") | |
| 547 | 529 | if templ_7745c5c3_Err != nil { | |
| 548 | 530 | return templ_7745c5c3_Err | |
| 549 | 531 | } | |
| @@ -586,7 +568,7 @@ func Refs(l Layout, p RefsPage) templ.Component { | |||
| 586 | 568 | }() | |
| 587 | 569 | } | |
| 588 | 570 | ctx = templ.InitializeContext(ctx) | |
| 589 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<div class=\"filter\"><label for=\"ref-filter\" class=\"sr\">filter</label> <input id=\"ref-filter\" type=\"search\" data-filter=\"#ref-rows > tr\" placeholder=\"Filter branches and tags…\" autocomplete=\"off\" hx-on:input=\"ermite.filter(this)\"></div><table class=\"list refs\" aria-label=\"Branches and tags\"><thead><tr><th class=\"c-kind\">kind</th><th class=\"c-name\">name</th><th class=\"c-sha\">commit</th><th class=\"c-sub\">subject</th><th class=\"c-when\">date</th></tr></thead>") | |
| 571 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<div class=\"filter\"><label for=\"ref-filter\" class=\"sr\">filter</label> <input id=\"ref-filter\" type=\"search\" data-filter=\"#ref-rows > tr\" placeholder=\"Filter branches and tags…\" autocomplete=\"off\" hx-on:input=\"ermite.filter(this)\"></div><table class=\"list refs\" aria-label=\"Branches and tags\"><thead><tr><th class=\"c-kind\">kind</th><th class=\"c-name\">name</th><th class=\"c-sha\">commit</th><th class=\"c-sub\">subject</th><th class=\"c-when\">date</th></tr></thead>") | |
| 590 | 572 | if templ_7745c5c3_Err != nil { | |
| 591 | 573 | return templ_7745c5c3_Err | |
| 592 | 574 | } | |
| @@ -594,25 +576,25 @@ func Refs(l Layout, p RefsPage) templ.Component { | |||
| 594 | 576 | if templ_7745c5c3_Err != nil { | |
| 595 | 577 | return templ_7745c5c3_Err | |
| 596 | 578 | } | |
| 597 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</table>") | |
| 579 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</table>") | |
| 598 | 580 | if templ_7745c5c3_Err != nil { | |
| 599 | 581 | return templ_7745c5c3_Err | |
| 600 | 582 | } | |
| 601 | 583 | if p.Note != "" { | |
| 602 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<p class=\"note\">") | |
| 584 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<p class=\"note\">") | |
| 603 | 585 | if templ_7745c5c3_Err != nil { | |
| 604 | 586 | return templ_7745c5c3_Err | |
| 605 | 587 | } | |
| 606 | 588 | var templ_7745c5c3_Var27 string | |
| 607 | 589 | templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note) | |
| 608 | 590 | if templ_7745c5c3_Err != nil { | |
| 609 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 149, Col: 27} | |
| 591 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 143, Col: 27} | |
| 610 | 592 | } | |
| 611 | 593 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) | |
| 612 | 594 | if templ_7745c5c3_Err != nil { | |
| 613 | 595 | return templ_7745c5c3_Err | |
| 614 | 596 | } | |
| 615 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</p>") | |
| 597 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</p>") | |
| 616 | 598 | if templ_7745c5c3_Err != nil { | |
| 617 | 599 | return templ_7745c5c3_Err | |
| 618 | 600 | } | |
| @@ -648,97 +630,97 @@ func refRow(kind string, r RefRow) templ.Component { | |||
| 648 | 630 | templ_7745c5c3_Var28 = templ.NopComponent | |
| 649 | 631 | } | |
| 650 | 632 | ctx = templ.ClearChildren(ctx) | |
| 651 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<tr") | |
| 633 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<tr") | |
| 652 | 634 | if templ_7745c5c3_Err != nil { | |
| 653 | 635 | return templ_7745c5c3_Err | |
| 654 | 636 | } | |
| 655 | 637 | if r.Cursor { | |
| 656 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " data-cursor") | |
| 638 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " data-cursor") | |
| 657 | 639 | if templ_7745c5c3_Err != nil { | |
| 658 | 640 | return templ_7745c5c3_Err | |
| 659 | 641 | } | |
| 660 | 642 | } | |
| 661 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "><td class=\"c-kind mute\">") | |
| 643 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "><td class=\"c-kind mute\">") | |
| 662 | 644 | if templ_7745c5c3_Err != nil { | |
| 663 | 645 | return templ_7745c5c3_Err | |
| 664 | 646 | } | |
| 665 | 647 | var templ_7745c5c3_Var29 string | |
| 666 | 648 | templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(kind) | |
| 667 | 649 | if templ_7745c5c3_Err != nil { | |
| 668 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 156, Col: 32} | |
| 650 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 150, Col: 32} | |
| 669 | 651 | } | |
| 670 | 652 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) | |
| 671 | 653 | if templ_7745c5c3_Err != nil { | |
| 672 | 654 | return templ_7745c5c3_Err | |
| 673 | 655 | } | |
| 674 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</td><td class=\"c-name\"><a href=\"") | |
| 656 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</td><td class=\"c-name\"><a href=\"") | |
| 675 | 657 | if templ_7745c5c3_Err != nil { | |
| 676 | 658 | return templ_7745c5c3_Err | |
| 677 | 659 | } | |
| 678 | 660 | var templ_7745c5c3_Var30 templ.SafeURL | |
| 679 | 661 | templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href)) | |
| 680 | 662 | if templ_7745c5c3_Err != nil { | |
| 681 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 158, Col: 34} | |
| 663 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 152, Col: 34} | |
| 682 | 664 | } | |
| 683 | 665 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) | |
| 684 | 666 | if templ_7745c5c3_Err != nil { | |
| 685 | 667 | return templ_7745c5c3_Err | |
| 686 | 668 | } | |
| 687 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\">") | |
| 669 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\">") | |
| 688 | 670 | if templ_7745c5c3_Err != nil { | |
| 689 | 671 | return templ_7745c5c3_Err | |
| 690 | 672 | } | |
| 691 | 673 | var templ_7745c5c3_Var31 string | |
| 692 | 674 | templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name) | |
| 693 | 675 | if templ_7745c5c3_Err != nil { | |
| 694 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 158, Col: 45} | |
| 676 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 152, Col: 45} | |
| 695 | 677 | } | |
| 696 | 678 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) | |
| 697 | 679 | if templ_7745c5c3_Err != nil { | |
| 698 | 680 | return templ_7745c5c3_Err | |
| 699 | 681 | } | |
| 700 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</a> ") | |
| 682 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</a> ") | |
| 701 | 683 | if templ_7745c5c3_Err != nil { | |
| 702 | 684 | return templ_7745c5c3_Err | |
| 703 | 685 | } | |
| 704 | 686 | if r.Default { | |
| 705 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<span class=\"pill\">default</span>") | |
| 687 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<span class=\"pill\">default</span>") | |
| 706 | 688 | if templ_7745c5c3_Err != nil { | |
| 707 | 689 | return templ_7745c5c3_Err | |
| 708 | 690 | } | |
| 709 | 691 | } | |
| 710 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</td><td class=\"c-sha\">") | |
| 692 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</td><td class=\"c-sha\">") | |
| 711 | 693 | if templ_7745c5c3_Err != nil { | |
| 712 | 694 | return templ_7745c5c3_Err | |
| 713 | 695 | } | |
| 714 | 696 | if r.CHref != "" { | |
| 715 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<a href=\"") | |
| 697 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<a href=\"") | |
| 716 | 698 | if templ_7745c5c3_Err != nil { | |
| 717 | 699 | return templ_7745c5c3_Err | |
| 718 | 700 | } | |
| 719 | 701 | var templ_7745c5c3_Var32 templ.SafeURL | |
| 720 | 702 | templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CHref)) | |
| 721 | 703 | if templ_7745c5c3_Err != nil { | |
| 722 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 165, Col: 36} | |
| 704 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 159, Col: 36} | |
| 723 | 705 | } | |
| 724 | 706 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) | |
| 725 | 707 | if templ_7745c5c3_Err != nil { | |
| 726 | 708 | return templ_7745c5c3_Err | |
| 727 | 709 | } | |
| 728 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\">") | |
| 710 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\">") | |
| 729 | 711 | if templ_7745c5c3_Err != nil { | |
| 730 | 712 | return templ_7745c5c3_Err | |
| 731 | 713 | } | |
| 732 | 714 | var templ_7745c5c3_Var33 string | |
| 733 | 715 | templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short) | |
| 734 | 716 | if templ_7745c5c3_Err != nil { | |
| 735 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 165, Col: 48} | |
| 717 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 159, Col: 48} | |
| 736 | 718 | } | |
| 737 | 719 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) | |
| 738 | 720 | if templ_7745c5c3_Err != nil { | |
| 739 | 721 | return templ_7745c5c3_Err | |
| 740 | 722 | } | |
| 741 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</a>") | |
| 723 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</a>") | |
| 742 | 724 | if templ_7745c5c3_Err != nil { | |
| 743 | 725 | return templ_7745c5c3_Err | |
| 744 | 726 | } | |
| @@ -746,7 +728,7 @@ func refRow(kind string, r RefRow) templ.Component { | |||
| 746 | 728 | var templ_7745c5c3_Var34 string | |
| 747 | 729 | templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short) | |
| 748 | 730 | if templ_7745c5c3_Err != nil { | |
| 749 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 167, Col: 13} | |
| 731 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 161, Col: 13} | |
| 750 | 732 | } | |
| 751 | 733 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) | |
| 752 | 734 | if templ_7745c5c3_Err != nil { | |
| @@ -757,69 +739,69 @@ func refRow(kind string, r RefRow) templ.Component { | |||
| 757 | 739 | if templ_7745c5c3_Err != nil { | |
| 758 | 740 | return templ_7745c5c3_Err | |
| 759 | 741 | } | |
| 760 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</td><td class=\"c-sub\">") | |
| 742 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</td><td class=\"c-sub\">") | |
| 761 | 743 | if templ_7745c5c3_Err != nil { | |
| 762 | 744 | return templ_7745c5c3_Err | |
| 763 | 745 | } | |
| 764 | 746 | var templ_7745c5c3_Var35 string | |
| 765 | 747 | templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(r.Subject) | |
| 766 | 748 | if templ_7745c5c3_Err != nil { | |
| 767 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 171, Col: 31} | |
| 749 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 165, Col: 31} | |
| 768 | 750 | } | |
| 769 | 751 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) | |
| 770 | 752 | if templ_7745c5c3_Err != nil { | |
| 771 | 753 | return templ_7745c5c3_Err | |
| 772 | 754 | } | |
| 773 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</td><td class=\"c-when dim\">") | |
| 755 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</td><td class=\"c-when dim\">") | |
| 774 | 756 | if templ_7745c5c3_Err != nil { | |
| 775 | 757 | return templ_7745c5c3_Err | |
| 776 | 758 | } | |
| 777 | 759 | if r.WhenAt != "" { | |
| 778 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<time datetime=\"") | |
| 760 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<time datetime=\"") | |
| 779 | 761 | if templ_7745c5c3_Err != nil { | |
| 780 | 762 | return templ_7745c5c3_Err | |
| 781 | 763 | } | |
| 782 | 764 | var templ_7745c5c3_Var36 string | |
| 783 | 765 | templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt) | |
| 784 | 766 | if templ_7745c5c3_Err != nil { | |
| 785 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 174, Col: 29} | |
| 767 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 168, Col: 29} | |
| 786 | 768 | } | |
| 787 | 769 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36) | |
| 788 | 770 | if templ_7745c5c3_Err != nil { | |
| 789 | 771 | return templ_7745c5c3_Err | |
| 790 | 772 | } | |
| 791 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" title=\"") | |
| 773 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\" title=\"") | |
| 792 | 774 | if templ_7745c5c3_Err != nil { | |
| 793 | 775 | return templ_7745c5c3_Err | |
| 794 | 776 | } | |
| 795 | 777 | var templ_7745c5c3_Var37 string | |
| 796 | 778 | templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt) | |
| 797 | 779 | if templ_7745c5c3_Err != nil { | |
| 798 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 174, Col: 48} | |
| 780 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 168, Col: 48} | |
| 799 | 781 | } | |
| 800 | 782 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37) | |
| 801 | 783 | if templ_7745c5c3_Err != nil { | |
| 802 | 784 | return templ_7745c5c3_Err | |
| 803 | 785 | } | |
| 804 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\">") | |
| 786 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\">") | |
| 805 | 787 | if templ_7745c5c3_Err != nil { | |
| 806 | 788 | return templ_7745c5c3_Err | |
| 807 | 789 | } | |
| 808 | 790 | var templ_7745c5c3_Var38 string | |
| 809 | 791 | templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(r.When) | |
| 810 | 792 | if templ_7745c5c3_Err != nil { | |
| 811 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 174, Col: 59} | |
| 793 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 168, Col: 59} | |
| 812 | 794 | } | |
| 813 | 795 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) | |
| 814 | 796 | if templ_7745c5c3_Err != nil { | |
| 815 | 797 | return templ_7745c5c3_Err | |
| 816 | 798 | } | |
| 817 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</time>") | |
| 799 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</time>") | |
| 818 | 800 | if templ_7745c5c3_Err != nil { | |
| 819 | 801 | return templ_7745c5c3_Err | |
| 820 | 802 | } | |
| 821 | 803 | } | |
| 822 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</td></tr>") | |
| 804 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</td></tr>") | |
| 823 | 805 | if templ_7745c5c3_Err != nil { | |
| 824 | 806 | return templ_7745c5c3_Err | |
| 825 | 807 | } | |
| @@ -848,7 +830,7 @@ func refsRail(p RefsPage) templ.Component { | |||
| 848 | 830 | templ_7745c5c3_Var39 = templ.NopComponent | |
| 849 | 831 | } | |
| 850 | 832 | ctx = templ.ClearChildren(ctx) | |
| 851 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<section aria-labelledby=\"r-refs\"><h2 id=\"r-refs\">Overview</h2>") | |
| 833 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<section aria-labelledby=\"r-refs\"><h2 id=\"r-refs\">Overview</h2>") | |
| 852 | 834 | if templ_7745c5c3_Err != nil { | |
| 853 | 835 | return templ_7745c5c3_Err | |
| 854 | 836 | } | |
| @@ -856,7 +838,7 @@ func refsRail(p RefsPage) templ.Component { | |||
| 856 | 838 | if templ_7745c5c3_Err != nil { | |
| 857 | 839 | return templ_7745c5c3_Err | |
| 858 | 840 | } | |
| 859 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</section>") | |
| 841 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</section>") | |
| 860 | 842 | if templ_7745c5c3_Err != nil { | |
| 861 | 843 | return templ_7745c5c3_Err | |
| 862 | 844 | } | |
| @@ -898,51 +880,51 @@ func State(l Layout, p StatePage) templ.Component { | |||
| 898 | 880 | }() | |
| 899 | 881 | } | |
| 900 | 882 | ctx = templ.InitializeContext(ctx) | |
| 901 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<section class=\"state\" aria-labelledby=\"state-title\"><p class=\"eyebrow\">") | |
| 883 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<section class=\"state\" aria-labelledby=\"state-title\"><p class=\"eyebrow\">") | |
| 902 | 884 | if templ_7745c5c3_Err != nil { | |
| 903 | 885 | return templ_7745c5c3_Err | |
| 904 | 886 | } | |
| 905 | 887 | var templ_7745c5c3_Var42 string | |
| 906 | 888 | templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(p.Code) | |
| 907 | 889 | if templ_7745c5c3_Err != nil { | |
| 908 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 191, Col: 30} | |
| 890 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 185, Col: 30} | |
| 909 | 891 | } | |
| 910 | 892 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) | |
| 911 | 893 | if templ_7745c5c3_Err != nil { | |
| 912 | 894 | return templ_7745c5c3_Err | |
| 913 | 895 | } | |
| 914 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</p><h1 id=\"state-title\">") | |
| 896 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</p><h1 id=\"state-title\">") | |
| 915 | 897 | if templ_7745c5c3_Err != nil { | |
| 916 | 898 | return templ_7745c5c3_Err | |
| 917 | 899 | } | |
| 918 | 900 | var templ_7745c5c3_Var43 string | |
| 919 | 901 | templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title) | |
| 920 | 902 | if templ_7745c5c3_Err != nil { | |
| 921 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 192, Col: 33} | |
| 903 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 186, Col: 33} | |
| 922 | 904 | } | |
| 923 | 905 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) | |
| 924 | 906 | if templ_7745c5c3_Err != nil { | |
| 925 | 907 | return templ_7745c5c3_Err | |
| 926 | 908 | } | |
| 927 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</h1>") | |
| 909 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</h1>") | |
| 928 | 910 | if templ_7745c5c3_Err != nil { | |
| 929 | 911 | return templ_7745c5c3_Err | |
| 930 | 912 | } | |
| 931 | 913 | for _, line := range p.Lines { | |
| 932 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<p class=\"dim\">") | |
| 914 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<p class=\"dim\">") | |
| 933 | 915 | if templ_7745c5c3_Err != nil { | |
| 934 | 916 | return templ_7745c5c3_Err | |
| 935 | 917 | } | |
| 936 | 918 | var templ_7745c5c3_Var44 string | |
| 937 | 919 | templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(line) | |
| 938 | 920 | if templ_7745c5c3_Err != nil { | |
| 939 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 194, Col: 25} | |
| 921 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 188, Col: 25} | |
| 940 | 922 | } | |
| 941 | 923 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) | |
| 942 | 924 | if templ_7745c5c3_Err != nil { | |
| 943 | 925 | return templ_7745c5c3_Err | |
| 944 | 926 | } | |
| 945 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</p>") | |
| 927 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</p>") | |
| 946 | 928 | if templ_7745c5c3_Err != nil { | |
| 947 | 929 | return templ_7745c5c3_Err | |
| 948 | 930 | } | |
| @@ -953,43 +935,43 @@ func State(l Layout, p StatePage) templ.Component { | |||
| 953 | 935 | return templ_7745c5c3_Err | |
| 954 | 936 | } | |
| 955 | 937 | } | |
| 956 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<p class=\"state-back\">") | |
| 938 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<p class=\"state-back\">") | |
| 957 | 939 | if templ_7745c5c3_Err != nil { | |
| 958 | 940 | return templ_7745c5c3_Err | |
| 959 | 941 | } | |
| 960 | 942 | for _, b := range p.Back { | |
| 961 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "<a href=\"") | |
| 943 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<a href=\"") | |
| 962 | 944 | if templ_7745c5c3_Err != nil { | |
| 963 | 945 | return templ_7745c5c3_Err | |
| 964 | 946 | } | |
| 965 | 947 | var templ_7745c5c3_Var45 templ.SafeURL | |
| 966 | 948 | templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(b.Href)) | |
| 967 | 949 | if templ_7745c5c3_Err != nil { | |
| 968 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 201, Col: 36} | |
| 950 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 195, Col: 36} | |
| 969 | 951 | } | |
| 970 | 952 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) | |
| 971 | 953 | if templ_7745c5c3_Err != nil { | |
| 972 | 954 | return templ_7745c5c3_Err | |
| 973 | 955 | } | |
| 974 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "\">") | |
| 956 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\">") | |
| 975 | 957 | if templ_7745c5c3_Err != nil { | |
| 976 | 958 | return templ_7745c5c3_Err | |
| 977 | 959 | } | |
| 978 | 960 | var templ_7745c5c3_Var46 string | |
| 979 | 961 | templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(b.Label) | |
| 980 | 962 | if templ_7745c5c3_Err != nil { | |
| 981 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 201, Col: 48} | |
| 963 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 195, Col: 48} | |
| 982 | 964 | } | |
| 983 | 965 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) | |
| 984 | 966 | if templ_7745c5c3_Err != nil { | |
| 985 | 967 | return templ_7745c5c3_Err | |
| 986 | 968 | } | |
| 987 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</a>") | |
| 969 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</a>") | |
| 988 | 970 | if templ_7745c5c3_Err != nil { | |
| 989 | 971 | return templ_7745c5c3_Err | |
| 990 | 972 | } | |
| 991 | 973 | } | |
| 992 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</p></section>") | |
| 974 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</p></section>") | |
| 993 | 975 | if templ_7745c5c3_Err != nil { | |
| 994 | 976 | return templ_7745c5c3_Err | |
| 995 | 977 | } | |
| @@ -1041,69 +1023,69 @@ func Login(l Layout, p LoginPage) templ.Component { | |||
| 1041 | 1023 | }() | |
| 1042 | 1024 | } | |
| 1043 | 1025 | ctx = templ.InitializeContext(ctx) | |
| 1044 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<section class=\"state\" aria-labelledby=\"state-title\"><h1 id=\"state-title\">") | |
| 1026 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "<section class=\"state\" aria-labelledby=\"state-title\"><h1 id=\"state-title\">") | |
| 1045 | 1027 | if templ_7745c5c3_Err != nil { | |
| 1046 | 1028 | return templ_7745c5c3_Err | |
| 1047 | 1029 | } | |
| 1048 | 1030 | var templ_7745c5c3_Var49 string | |
| 1049 | 1031 | templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title) | |
| 1050 | 1032 | if templ_7745c5c3_Err != nil { | |
| 1051 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 216, Col: 33} | |
| 1033 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 210, Col: 33} | |
| 1052 | 1034 | } | |
| 1053 | 1035 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49)) | |
| 1054 | 1036 | if templ_7745c5c3_Err != nil { | |
| 1055 | 1037 | return templ_7745c5c3_Err | |
| 1056 | 1038 | } | |
| 1057 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</h1>") | |
| 1039 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</h1>") | |
| 1058 | 1040 | if templ_7745c5c3_Err != nil { | |
| 1059 | 1041 | return templ_7745c5c3_Err | |
| 1060 | 1042 | } | |
| 1061 | 1043 | for _, line := range p.Lines { | |
| 1062 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<p class=\"dim\">") | |
| 1044 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<p class=\"dim\">") | |
| 1063 | 1045 | if templ_7745c5c3_Err != nil { | |
| 1064 | 1046 | return templ_7745c5c3_Err | |
| 1065 | 1047 | } | |
| 1066 | 1048 | var templ_7745c5c3_Var50 string | |
| 1067 | 1049 | templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(line) | |
| 1068 | 1050 | if templ_7745c5c3_Err != nil { | |
| 1069 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 218, Col: 25} | |
| 1051 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 212, Col: 25} | |
| 1070 | 1052 | } | |
| 1071 | 1053 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50)) | |
| 1072 | 1054 | if templ_7745c5c3_Err != nil { | |
| 1073 | 1055 | return templ_7745c5c3_Err | |
| 1074 | 1056 | } | |
| 1075 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</p>") | |
| 1057 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</p>") | |
| 1076 | 1058 | if templ_7745c5c3_Err != nil { | |
| 1077 | 1059 | return templ_7745c5c3_Err | |
| 1078 | 1060 | } | |
| 1079 | 1061 | } | |
| 1080 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<form class=\"state-act\" method=\"post\" action=\"") | |
| 1062 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<form class=\"state-act\" method=\"post\" action=\"") | |
| 1081 | 1063 | if templ_7745c5c3_Err != nil { | |
| 1082 | 1064 | return templ_7745c5c3_Err | |
| 1083 | 1065 | } | |
| 1084 | 1066 | var templ_7745c5c3_Var51 templ.SafeURL | |
| 1085 | 1067 | templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Action)) | |
| 1086 | 1068 | if templ_7745c5c3_Err != nil { | |
| 1087 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 220, Col: 73} | |
| 1069 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 214, Col: 73} | |
| 1088 | 1070 | } | |
| 1089 | 1071 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51)) | |
| 1090 | 1072 | if templ_7745c5c3_Err != nil { | |
| 1091 | 1073 | return templ_7745c5c3_Err | |
| 1092 | 1074 | } | |
| 1093 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\" data-autosubmit><button type=\"submit\" class=\"btn primary\">") | |
| 1075 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "\" data-autosubmit><button type=\"submit\" class=\"btn primary\">") | |
| 1094 | 1076 | if templ_7745c5c3_Err != nil { | |
| 1095 | 1077 | return templ_7745c5c3_Err | |
| 1096 | 1078 | } | |
| 1097 | 1079 | var templ_7745c5c3_Var52 string | |
| 1098 | 1080 | templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button) | |
| 1099 | 1081 | if templ_7745c5c3_Err != nil { | |
| 1100 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 221, Col: 56} | |
| 1082 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 215, Col: 56} | |
| 1101 | 1083 | } | |
| 1102 | 1084 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52)) | |
| 1103 | 1085 | if templ_7745c5c3_Err != nil { | |
| 1104 | 1086 | return templ_7745c5c3_Err | |
| 1105 | 1087 | } | |
| 1106 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</button></form><p class=\"state-back\"><a href=\"/\">Back to home</a></p></section>") | |
| 1088 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</button></form><p class=\"state-back\"><a href=\"/\">Back to home</a></p></section>") | |
| 1107 | 1089 | if templ_7745c5c3_Err != nil { | |
| 1108 | 1090 | return templ_7745c5c3_Err | |
| 1109 | 1091 | } | |
| @@ -1152,82 +1134,82 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component { | |||
| 1152 | 1134 | }() | |
| 1153 | 1135 | } | |
| 1154 | 1136 | ctx = templ.InitializeContext(ctx) | |
| 1155 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<section class=\"state\" aria-labelledby=\"state-title\"><h1 id=\"state-title\">") | |
| 1137 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "<section class=\"state\" aria-labelledby=\"state-title\"><h1 id=\"state-title\">") | |
| 1156 | 1138 | if templ_7745c5c3_Err != nil { | |
| 1157 | 1139 | return templ_7745c5c3_Err | |
| 1158 | 1140 | } | |
| 1159 | 1141 | var templ_7745c5c3_Var55 string | |
| 1160 | 1142 | templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title) | |
| 1161 | 1143 | if templ_7745c5c3_Err != nil { | |
| 1162 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 233, Col: 33} | |
| 1144 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 227, Col: 33} | |
| 1163 | 1145 | } | |
| 1164 | 1146 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55)) | |
| 1165 | 1147 | if templ_7745c5c3_Err != nil { | |
| 1166 | 1148 | return templ_7745c5c3_Err | |
| 1167 | 1149 | } | |
| 1168 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</h1><form class=\"state-act\" data-js data-passkey-form><button type=\"submit\" class=\"btn primary\" data-passkey=\"login\" data-begin=\"") | |
| 1150 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</h1><form class=\"state-act\" data-js data-passkey-form><button type=\"submit\" class=\"btn primary\" data-passkey=\"login\" data-begin=\"") | |
| 1169 | 1151 | if templ_7745c5c3_Err != nil { | |
| 1170 | 1152 | return templ_7745c5c3_Err | |
| 1171 | 1153 | } | |
| 1172 | 1154 | var templ_7745c5c3_Var56 string | |
| 1173 | 1155 | templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin) | |
| 1174 | 1156 | if templ_7745c5c3_Err != nil { | |
| 1175 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 235, Col: 87} | |
| 1157 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 229, Col: 87} | |
| 1176 | 1158 | } | |
| 1177 | 1159 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var56) | |
| 1178 | 1160 | if templ_7745c5c3_Err != nil { | |
| 1179 | 1161 | return templ_7745c5c3_Err | |
| 1180 | 1162 | } | |
| 1181 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "\" data-finish=\"") | |
| 1163 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\" data-finish=\"") | |
| 1182 | 1164 | if templ_7745c5c3_Err != nil { | |
| 1183 | 1165 | return templ_7745c5c3_Err | |
| 1184 | 1166 | } | |
| 1185 | 1167 | var templ_7745c5c3_Var57 string | |
| 1186 | 1168 | templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish) | |
| 1187 | 1169 | if templ_7745c5c3_Err != nil { | |
| 1188 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 235, Col: 112} | |
| 1170 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 229, Col: 112} | |
| 1189 | 1171 | } | |
| 1190 | 1172 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var57) | |
| 1191 | 1173 | if templ_7745c5c3_Err != nil { | |
| 1192 | 1174 | return templ_7745c5c3_Err | |
| 1193 | 1175 | } | |
| 1194 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\">") | |
| 1176 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "\">") | |
| 1195 | 1177 | if templ_7745c5c3_Err != nil { | |
| 1196 | 1178 | return templ_7745c5c3_Err | |
| 1197 | 1179 | } | |
| 1198 | 1180 | var templ_7745c5c3_Var58 string | |
| 1199 | 1181 | templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button) | |
| 1200 | 1182 | if templ_7745c5c3_Err != nil { | |
| 1201 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 235, Col: 125} | |
| 1183 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 229, Col: 125} | |
| 1202 | 1184 | } | |
| 1203 | 1185 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58)) | |
| 1204 | 1186 | if templ_7745c5c3_Err != nil { | |
| 1205 | 1187 | return templ_7745c5c3_Err | |
| 1206 | 1188 | } | |
| 1207 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "</button></form><p class=\"dim\" role=\"status\" aria-live=\"polite\" data-passkey-status></p>") | |
| 1189 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</button></form><p class=\"dim\" role=\"status\" aria-live=\"polite\" data-passkey-status></p>") | |
| 1208 | 1190 | if templ_7745c5c3_Err != nil { | |
| 1209 | 1191 | return templ_7745c5c3_Err | |
| 1210 | 1192 | } | |
| 1211 | 1193 | for _, line := range p.Lines { | |
| 1212 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "<p class=\"dim\">") | |
| 1194 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "<p class=\"dim\">") | |
| 1213 | 1195 | if templ_7745c5c3_Err != nil { | |
| 1214 | 1196 | return templ_7745c5c3_Err | |
| 1215 | 1197 | } | |
| 1216 | 1198 | var templ_7745c5c3_Var59 string | |
| 1217 | 1199 | templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(line) | |
| 1218 | 1200 | if templ_7745c5c3_Err != nil { | |
| 1219 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 239, Col: 25} | |
| 1201 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 233, Col: 25} | |
| 1220 | 1202 | } | |
| 1221 | 1203 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59)) | |
| 1222 | 1204 | if templ_7745c5c3_Err != nil { | |
| 1223 | 1205 | return templ_7745c5c3_Err | |
| 1224 | 1206 | } | |
| 1225 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</p>") | |
| 1207 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</p>") | |
| 1226 | 1208 | if templ_7745c5c3_Err != nil { | |
| 1227 | 1209 | return templ_7745c5c3_Err | |
| 1228 | 1210 | } | |
| 1229 | 1211 | } | |
| 1230 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "<p class=\"state-back\"><a href=\"/\">Back to home</a></p></section>") | |
| 1212 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "<p class=\"state-back\"><a href=\"/\">Back to home</a></p></section>") | |
| 1231 | 1213 | if templ_7745c5c3_Err != nil { | |
| 1232 | 1214 | return templ_7745c5c3_Err | |
| 1233 | 1215 | } | |
| @@ -1275,225 +1257,225 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component { | |||
| 1275 | 1257 | }() | |
| 1276 | 1258 | } | |
| 1277 | 1259 | ctx = templ.InitializeContext(ctx) | |
| 1278 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "<h2 class=\"rule-h\"><span>Passkeys</span><span class=\"count\">") | |
| 1260 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<h2 class=\"rule-h\"><span>Passkeys</span><span class=\"count\">") | |
| 1279 | 1261 | if templ_7745c5c3_Err != nil { | |
| 1280 | 1262 | return templ_7745c5c3_Err | |
| 1281 | 1263 | } | |
| 1282 | 1264 | var templ_7745c5c3_Var62 string | |
| 1283 | 1265 | templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Rows))) | |
| 1284 | 1266 | if templ_7745c5c3_Err != nil { | |
| 1285 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 249, Col: 81} | |
| 1267 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 243, Col: 81} | |
| 1286 | 1268 | } | |
| 1287 | 1269 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62)) | |
| 1288 | 1270 | if templ_7745c5c3_Err != nil { | |
| 1289 | 1271 | return templ_7745c5c3_Err | |
| 1290 | 1272 | } | |
| 1291 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "</span></h2><p class=\"dim\">A passkey signs you in from this device or your phone, without a terminal. Removing one signs every browser out.</p>") | |
| 1273 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "</span></h2><p class=\"dim\">A passkey signs you in from this device or your phone, without a terminal. Removing one signs every browser out.</p>") | |
| 1292 | 1274 | if templ_7745c5c3_Err != nil { | |
| 1293 | 1275 | return templ_7745c5c3_Err | |
| 1294 | 1276 | } | |
| 1295 | 1277 | if len(p.Rows) > 0 { | |
| 1296 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "<table class=\"list passkeys\" aria-label=\"Passkeys\"><thead><tr><th class=\"c-name\">Name</th><th class=\"c-when\">Created</th><th class=\"c-when\">Last used</th><th class=\"c-sync\">Synced</th><th class=\"c-act\"><span class=\"sr\">Remove</span></th></tr></thead> <tbody>") | |
| 1278 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "<table class=\"list passkeys\" aria-label=\"Passkeys\"><thead><tr><th class=\"c-name\">Name</th><th class=\"c-when\">Created</th><th class=\"c-when\">Last used</th><th class=\"c-sync\">Synced</th><th class=\"c-act\"><span class=\"sr\">Remove</span></th></tr></thead> <tbody>") | |
| 1297 | 1279 | if templ_7745c5c3_Err != nil { | |
| 1298 | 1280 | return templ_7745c5c3_Err | |
| 1299 | 1281 | } | |
| 1300 | 1282 | for _, r := range p.Rows { | |
| 1301 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "<tr><td class=\"c-name\">") | |
| 1283 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "<tr><td class=\"c-name\">") | |
| 1302 | 1284 | if templ_7745c5c3_Err != nil { | |
| 1303 | 1285 | return templ_7745c5c3_Err | |
| 1304 | 1286 | } | |
| 1305 | 1287 | var templ_7745c5c3_Var63 string | |
| 1306 | 1288 | templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name) | |
| 1307 | 1289 | if templ_7745c5c3_Err != nil { | |
| 1308 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 265, Col: 34} | |
| 1290 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 259, Col: 34} | |
| 1309 | 1291 | } | |
| 1310 | 1292 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63)) | |
| 1311 | 1293 | if templ_7745c5c3_Err != nil { | |
| 1312 | 1294 | return templ_7745c5c3_Err | |
| 1313 | 1295 | } | |
| 1314 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "</td><td class=\"c-when nowrap\"><time") | |
| 1296 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "</td><td class=\"c-when nowrap\"><time") | |
| 1315 | 1297 | if templ_7745c5c3_Err != nil { | |
| 1316 | 1298 | return templ_7745c5c3_Err | |
| 1317 | 1299 | } | |
| 1318 | 1300 | if r.CreatedAt != "" { | |
| 1319 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, " datetime=\"") | |
| 1301 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, " datetime=\"") | |
| 1320 | 1302 | if templ_7745c5c3_Err != nil { | |
| 1321 | 1303 | return templ_7745c5c3_Err | |
| 1322 | 1304 | } | |
| 1323 | 1305 | var templ_7745c5c3_Var64 string | |
| 1324 | 1306 | templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.CreatedAt) | |
| 1325 | 1307 | if templ_7745c5c3_Err != nil { | |
| 1326 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 269, Col: 32} | |
| 1308 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 263, Col: 32} | |
| 1327 | 1309 | } | |
| 1328 | 1310 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64) | |
| 1329 | 1311 | if templ_7745c5c3_Err != nil { | |
| 1330 | 1312 | return templ_7745c5c3_Err | |
| 1331 | 1313 | } | |
| 1332 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "\"") | |
| 1314 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "\"") | |
| 1333 | 1315 | if templ_7745c5c3_Err != nil { | |
| 1334 | 1316 | return templ_7745c5c3_Err | |
| 1335 | 1317 | } | |
| 1336 | 1318 | } | |
| 1337 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, ">") | |
| 1319 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, ">") | |
| 1338 | 1320 | if templ_7745c5c3_Err != nil { | |
| 1339 | 1321 | return templ_7745c5c3_Err | |
| 1340 | 1322 | } | |
| 1341 | 1323 | var templ_7745c5c3_Var65 string | |
| 1342 | 1324 | templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(r.Created) | |
| 1343 | 1325 | if templ_7745c5c3_Err != nil { | |
| 1344 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 271, Col: 20} | |
| 1326 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 265, Col: 20} | |
| 1345 | 1327 | } | |
| 1346 | 1328 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65)) | |
| 1347 | 1329 | if templ_7745c5c3_Err != nil { | |
| 1348 | 1330 | return templ_7745c5c3_Err | |
| 1349 | 1331 | } | |
| 1350 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</time></td><td class=\"c-when nowrap\"><time") | |
| 1332 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "</time></td><td class=\"c-when nowrap\"><time") | |
| 1351 | 1333 | if templ_7745c5c3_Err != nil { | |
| 1352 | 1334 | return templ_7745c5c3_Err | |
| 1353 | 1335 | } | |
| 1354 | 1336 | if r.LastUsedAt != "" { | |
| 1355 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, " datetime=\"") | |
| 1337 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, " datetime=\"") | |
| 1356 | 1338 | if templ_7745c5c3_Err != nil { | |
| 1357 | 1339 | return templ_7745c5c3_Err | |
| 1358 | 1340 | } | |
| 1359 | 1341 | var templ_7745c5c3_Var66 string | |
| 1360 | 1342 | templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.LastUsedAt) | |
| 1361 | 1343 | if templ_7745c5c3_Err != nil { | |
| 1362 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 276, Col: 33} | |
| 1344 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 270, Col: 33} | |
| 1363 | 1345 | } | |
| 1364 | 1346 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var66) | |
| 1365 | 1347 | if templ_7745c5c3_Err != nil { | |
| 1366 | 1348 | return templ_7745c5c3_Err | |
| 1367 | 1349 | } | |
| 1368 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "\"") | |
| 1350 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "\"") | |
| 1369 | 1351 | if templ_7745c5c3_Err != nil { | |
| 1370 | 1352 | return templ_7745c5c3_Err | |
| 1371 | 1353 | } | |
| 1372 | 1354 | } | |
| 1373 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, ">") | |
| 1355 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, ">") | |
| 1374 | 1356 | if templ_7745c5c3_Err != nil { | |
| 1375 | 1357 | return templ_7745c5c3_Err | |
| 1376 | 1358 | } | |
| 1377 | 1359 | var templ_7745c5c3_Var67 string | |
| 1378 | 1360 | templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinStringErrs(r.LastUsed) | |
| 1379 | 1361 | if templ_7745c5c3_Err != nil { | |
| 1380 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 278, Col: 21} | |
| 1362 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 272, Col: 21} | |
| 1381 | 1363 | } | |
| 1382 | 1364 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67)) | |
| 1383 | 1365 | if templ_7745c5c3_Err != nil { | |
| 1384 | 1366 | return templ_7745c5c3_Err | |
| 1385 | 1367 | } | |
| 1386 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "</time></td><td class=\"c-sync\">") | |
| 1368 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</time></td><td class=\"c-sync\">") | |
| 1387 | 1369 | if templ_7745c5c3_Err != nil { | |
| 1388 | 1370 | return templ_7745c5c3_Err | |
| 1389 | 1371 | } | |
| 1390 | 1372 | if r.Synced { | |
| 1391 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "yes") | |
| 1373 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "yes") | |
| 1392 | 1374 | if templ_7745c5c3_Err != nil { | |
| 1393 | 1375 | return templ_7745c5c3_Err | |
| 1394 | 1376 | } | |
| 1395 | 1377 | } else { | |
| 1396 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "no") | |
| 1378 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "no") | |
| 1397 | 1379 | if templ_7745c5c3_Err != nil { | |
| 1398 | 1380 | return templ_7745c5c3_Err | |
| 1399 | 1381 | } | |
| 1400 | 1382 | } | |
| 1401 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "</td><td class=\"c-act\"><form method=\"post\" action=\"") | |
| 1383 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "</td><td class=\"c-act\"><form method=\"post\" action=\"") | |
| 1402 | 1384 | if templ_7745c5c3_Err != nil { | |
| 1403 | 1385 | return templ_7745c5c3_Err | |
| 1404 | 1386 | } | |
| 1405 | 1387 | var templ_7745c5c3_Var68 templ.SafeURL | |
| 1406 | 1388 | templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Remove)) | |
| 1407 | 1389 | if templ_7745c5c3_Err != nil { | |
| 1408 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 288, Col: 60} | |
| 1390 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 282, Col: 60} | |
| 1409 | 1391 | } | |
| 1410 | 1392 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68)) | |
| 1411 | 1393 | if templ_7745c5c3_Err != nil { | |
| 1412 | 1394 | return templ_7745c5c3_Err | |
| 1413 | 1395 | } | |
| 1414 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "\"><button type=\"submit\" class=\"btn danger\" aria-label=\"") | |
| 1396 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "\"><button type=\"submit\" class=\"btn danger\" aria-label=\"") | |
| 1415 | 1397 | if templ_7745c5c3_Err != nil { | |
| 1416 | 1398 | return templ_7745c5c3_Err | |
| 1417 | 1399 | } | |
| 1418 | 1400 | var templ_7745c5c3_Var69 string | |
| 1419 | 1401 | templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue("Remove passkey " + r.Name) | |
| 1420 | 1402 | if templ_7745c5c3_Err != nil { | |
| 1421 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 289, Col: 89} | |
| 1403 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 283, Col: 89} | |
| 1422 | 1404 | } | |
| 1423 | 1405 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69) | |
| 1424 | 1406 | if templ_7745c5c3_Err != nil { | |
| 1425 | 1407 | return templ_7745c5c3_Err | |
| 1426 | 1408 | } | |
| 1427 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "\">Remove</button></form></td></tr>") | |
| 1409 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "\">Remove</button></form></td></tr>") | |
| 1428 | 1410 | if templ_7745c5c3_Err != nil { | |
| 1429 | 1411 | return templ_7745c5c3_Err | |
| 1430 | 1412 | } | |
| 1431 | 1413 | } | |
| 1432 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "</tbody></table>") | |
| 1414 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "</tbody></table>") | |
| 1433 | 1415 | if templ_7745c5c3_Err != nil { | |
| 1434 | 1416 | return templ_7745c5c3_Err | |
| 1435 | 1417 | } | |
| 1436 | 1418 | } else { | |
| 1437 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "<p class=\"lede dim\">no passkeys yet.</p>") | |
| 1419 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "<p class=\"lede dim\">no passkeys yet.</p>") | |
| 1438 | 1420 | if templ_7745c5c3_Err != nil { | |
| 1439 | 1421 | return templ_7745c5c3_Err | |
| 1440 | 1422 | } | |
| 1441 | 1423 | } | |
| 1442 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, " ") | |
| 1424 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, " ") | |
| 1443 | 1425 | if templ_7745c5c3_Err != nil { | |
| 1444 | 1426 | return templ_7745c5c3_Err | |
| 1445 | 1427 | } | |
| 1446 | 1428 | if p.Full { | |
| 1447 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "<p class=\"dim\">") | |
| 1429 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "<p class=\"dim\">") | |
| 1448 | 1430 | if templ_7745c5c3_Err != nil { | |
| 1449 | 1431 | return templ_7745c5c3_Err | |
| 1450 | 1432 | } | |
| 1451 | 1433 | var templ_7745c5c3_Var70 string | |
| 1452 | 1434 | templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Max)) | |
| 1453 | 1435 | if templ_7745c5c3_Err != nil { | |
| 1454 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 300, Col: 31} | |
| 1436 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 294, Col: 31} | |
| 1455 | 1437 | } | |
| 1456 | 1438 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70)) | |
| 1457 | 1439 | if templ_7745c5c3_Err != nil { | |
| 1458 | 1440 | return templ_7745c5c3_Err | |
| 1459 | 1441 | } | |
| 1460 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, " passkeys are registered, the most there can be. Remove one to add another.</p>") | |
| 1442 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, " passkeys are registered, the most there can be. Remove one to add another.</p>") | |
| 1461 | 1443 | if templ_7745c5c3_Err != nil { | |
| 1462 | 1444 | return templ_7745c5c3_Err | |
| 1463 | 1445 | } | |
| 1464 | 1446 | } else { | |
| 1465 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "<form class=\"passkey-add\" data-js data-passkey-form><label for=\"passkey-name\" class=\"sr\">Name of the new passkey</label> <input id=\"passkey-name\" type=\"text\" placeholder=\"Name, for example Pixel 9\" autocomplete=\"off\" data-passkey-name> <button type=\"submit\" class=\"btn primary\" data-passkey=\"register\" data-begin=\"") | |
| 1447 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "<form class=\"passkey-add\" data-js data-passkey-form><label for=\"passkey-name\" class=\"sr\">Name of the new passkey</label> <input id=\"passkey-name\" type=\"text\" placeholder=\"Name, for example Pixel 9\" autocomplete=\"off\" data-passkey-name> <button type=\"submit\" class=\"btn primary\" data-passkey=\"register\" data-begin=\"") | |
| 1466 | 1448 | if templ_7745c5c3_Err != nil { | |
| 1467 | 1449 | return templ_7745c5c3_Err | |
| 1468 | 1450 | } | |
| 1469 | 1451 | var templ_7745c5c3_Var71 string | |
| 1470 | 1452 | templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin) | |
| 1471 | 1453 | if templ_7745c5c3_Err != nil { | |
| 1472 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 305, Col: 90} | |
| 1454 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 299, Col: 90} | |
| 1473 | 1455 | } | |
| 1474 | 1456 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var71) | |
| 1475 | 1457 | if templ_7745c5c3_Err != nil { | |
| 1476 | 1458 | return templ_7745c5c3_Err | |
| 1477 | 1459 | } | |
| 1478 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "\" data-finish=\"") | |
| 1460 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "\" data-finish=\"") | |
| 1479 | 1461 | if templ_7745c5c3_Err != nil { | |
| 1480 | 1462 | return templ_7745c5c3_Err | |
| 1481 | 1463 | } | |
| 1482 | 1464 | var templ_7745c5c3_Var72 string | |
| 1483 | 1465 | templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish) | |
| 1484 | 1466 | if templ_7745c5c3_Err != nil { | |
| 1485 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 305, Col: 115} | |
| 1467 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 299, Col: 115} | |
| 1486 | 1468 | } | |
| 1487 | 1469 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var72) | |
| 1488 | 1470 | if templ_7745c5c3_Err != nil { | |
| 1489 | 1471 | return templ_7745c5c3_Err | |
| 1490 | 1472 | } | |
| 1491 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "\">Add a passkey</button></form>") | |
| 1473 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "\">Add a passkey</button></form>") | |
| 1492 | 1474 | if templ_7745c5c3_Err != nil { | |
| 1493 | 1475 | return templ_7745c5c3_Err | |
| 1494 | 1476 | } | |
| 1495 | 1477 | } | |
| 1496 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, " <p class=\"dim\" role=\"status\" aria-live=\"polite\" data-passkey-status></p>") | |
| 1478 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, " <p class=\"dim\" role=\"status\" aria-live=\"polite\" data-passkey-status></p>") | |
| 1497 | 1479 | if templ_7745c5c3_Err != nil { | |
| 1498 | 1480 | return templ_7745c5c3_Err | |
| 1499 | 1481 | } | |
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> | |
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); } | |
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; } | |
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 | } | |
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; } | |
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> | |
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); | |
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()); | |
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'); | |
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 | |
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(); | |
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(); | |
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(); | |
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(); | |
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(); | |
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(); | |
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(); | |
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(); | |
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(); | |
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(); | |
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(); | |
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(); | |
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(); | |
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(); | |
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(); | |
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(); | |
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(); | |
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(); | |
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(); | |
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(); | |
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(); | |
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(); | |
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(); | |
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(); | |
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(); | |
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(); | |
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(); | |
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(); | |
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(); | |
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(); | |
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(); | |
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 | |
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 | ||
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 | |
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 | |
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 | } | |
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 | |
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 | + | } | |
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 | + | } | |
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 | + | } | |
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 { | |
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 | } | |
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) | |
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 { | |
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 | + | } | |
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) { | |
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, | |
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 | + | } | |
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 | |
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() | |
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 | + | } | |
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 | |
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) | |
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". | |