detached e95eebd

feat(web): the home page lists repositories as cards (#56)

commit
e95eebda317302f13d3d13c1fc33a5477e4bfa1b
author
brice · · 2026-10-18 06:17 UTC
committer
bnema · · 2026-10-18 06:17 UTC
parent
8e01339
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
14 files · +770 -672
M internal/adapters/web/components.templ +24 -15
@@ -32,26 +32,35 @@ templ CloneBlock(c Clone) {
3232 </div>
3333 }
3434
35-// HomeRow is one row of the home list. Its id is the target of SSE swaps.
35+// HomeRowView is the card of a repository in the home list. Its id is the
36+// target of SSE swaps; the name link comes first, `enter` follows it.
3637 templ HomeRowView(r HomeRow) {
37- <tr id={ r.ID } data-cursor?={ r.Cursor }>
38- <td class="cur" aria-hidden="true"></td>
39- <td class="c-name">
38+ <li id={ r.ID } class="repo" data-cursor?={ r.Cursor }>
39+ <h2 class="repo-name">
4040 <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>
41+ if r.Private {
42+ <span class="pill">private</span>
43+ }
4144 if r.Fresh {
4245 <span class="pill new">new push</span>
4346 }
44- </td>
45- <td class="c-desc dim">{ r.Desc }</td>
46- <td class="c-when nowrap">
47- <time
48- if r.PushedAt != "" {
49- datetime={ r.PushedAt }
50- }
51- >{ r.Pushed }</time>
52- </td>
53- <td class="c-ref">{ r.Branch }</td>
54- </tr>
47+ </h2>
48+ if r.Desc != "" {
49+ <p class="repo-desc">{ r.Desc }</p>
50+ } else {
51+ <p class="repo-desc mute">no description</p>
52+ }
53+ <p class="repo-meta">
54+ if r.Branch != "" {
55+ <span class="repo-branch"><span class="ico i-branch" aria-hidden="true"></span>{ r.Branch }</span>
56+ }
57+ if r.PushedAt != "" {
58+ <span>pushed <time datetime={ r.PushedAt } title={ r.PushedAt }>{ r.Pushed }</time></span>
59+ } else {
60+ <span>never pushed</span>
61+ }
62+ </p>
63+ </li>
5564 }
5665
5766 // Outline is the README outline; levels indent by 2ch, the h1 is bold.
@@ -32,26 +32,35 @@ templ CloneBlock(c Clone) {
32 </div>32 </div>
33 }33 }
34 34
35-// HomeRow is one row of the home list. Its id is the target of SSE swaps.35+// HomeRowView is the card of a repository in the home list. Its id is the
36+// target of SSE swaps; the name link comes first, `enter` follows it.
36 templ HomeRowView(r HomeRow) {37 templ HomeRowView(r HomeRow) {
37- <tr id={ r.ID } data-cursor?={ r.Cursor }>38+ <li id={ r.ID } class="repo" data-cursor?={ r.Cursor }>
38- <td class="cur" aria-hidden="true"></td>39+ <h2 class="repo-name">
39- <td class="c-name">
40 <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>40 <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>
41+ if r.Private {
42+ <span class="pill">private</span>
43+ }
41 if r.Fresh {44 if r.Fresh {
42 <span class="pill new">new push</span>45 <span class="pill new">new push</span>
43 }46 }
44- </td>47+ </h2>
45- <td class="c-desc dim">{ r.Desc }</td>48+ if r.Desc != "" {
46- <td class="c-when nowrap">49+ <p class="repo-desc">{ r.Desc }</p>
47- <time50+ } else {
48- if r.PushedAt != "" {51+ <p class="repo-desc mute">no description</p>
49- datetime={ r.PushedAt }52+ }
50- }53+ <p class="repo-meta">
51- >{ r.Pushed }</time>54+ if r.Branch != "" {
52- </td>55+ <span class="repo-branch"><span class="ico i-branch" aria-hidden="true"></span>{ r.Branch }</span>
53- <td class="c-ref">{ r.Branch }</td>56+ }
54- </tr>57+ if r.PushedAt != "" {
58+ <span>pushed <time datetime={ r.PushedAt } title={ r.PushedAt }>{ r.Pushed }</time></span>
59+ } else {
60+ <span>never pushed</span>
61+ }
62+ </p>
63+ </li>
55 }64 }
56 65
57 // Outline is the README outline; levels indent by 2ch, the h1 is bold.66 // Outline is the README outline; levels indent by 2ch, the h1 is bold.
M internal/adapters/web/components_templ.go +206 -160 generated file: collapsed
M internal/adapters/web/events.go +1 -1
@@ -45,7 +45,7 @@ import (
4545 //
4646 // Events, each with the id of the Watch event it comes from:
4747 //
48-// home-list data: <tr id="repo-<name>">…</tr>, or <tr id="repo-<name>" data-gone></tr>
48+// home-list data: <li id="repo-<name>">…</li>, or <li id="repo-<name>" data-gone></li>
4949 // for a repository that is deleted or no longer visible
5050 // repo-last-push data: <dd id="last-push-<name>">…</dd>
5151 // refs-list data: <tbody id="ref-rows">…</tbody>, and again with
@@ -45,7 +45,7 @@ import (
45 //45 //
46 // Events, each with the id of the Watch event it comes from:46 // Events, each with the id of the Watch event it comes from:
47 //47 //
48-// home-list data: <tr id="repo-<name>">…</tr>, or <tr id="repo-<name>" data-gone></tr>48+// home-list data: <li id="repo-<name>">…</li>, or <li id="repo-<name>" data-gone></li>
49 // for a repository that is deleted or no longer visible49 // for a repository that is deleted or no longer visible
50 // repo-last-push data: <dd id="last-push-<name>">…</dd>50 // repo-last-push data: <dd id="last-push-<name>">…</dd>
51 // refs-list data: <tbody id="ref-rows">…</tbody>, and again with51 // refs-list data: <tbody id="ref-rows">…</tbody>, and again with
M internal/adapters/web/fragments.go +1 -1
@@ -116,7 +116,7 @@ func (l *liveStream) home(ctx context.Context, we ports.WatchEvent) ([]fragment,
116116 case err != nil:
117117 return nil, err
118118 }
119- row := homeRow(view, l.s.cfg.Now(), false)
119+ row := homeRow(view, l.s.cfg.Now(), false, SignedIn(ctx))
120120 row.Fresh = fresh
121121 f, err := renderFragment(ctx, "home-list", HomeRowView(row))
122122 return []fragment{f}, err
@@ -116,7 +116,7 @@ func (l *liveStream) home(ctx context.Context, we ports.WatchEvent) ([]fragment,
116 case err != nil:116 case err != nil:
117 return nil, err117 return nil, err
118 }118 }
119- row := homeRow(view, l.s.cfg.Now(), false)119+ row := homeRow(view, l.s.cfg.Now(), false, SignedIn(ctx))
120 row.Fresh = fresh120 row.Fresh = fresh
121 f, err := renderFragment(ctx, "home-list", HomeRowView(row))121 f, err := renderFragment(ctx, "home-list", HomeRowView(row))
122 return []fragment{f}, err122 return []fragment{f}, err
M internal/adapters/web/fragments.templ +2 -2
@@ -21,10 +21,10 @@ templ FactValue(f Fact) {
2121 </dd>
2222 }
2323
24-// RepoGone replaces the row of a repository that is deleted or not visible
24+// RepoGone replaces the card of a repository that is deleted or not visible
2525 // any more; the page removes the element.
2626 templ RepoGone(id string) {
27- <tr id={ id } data-gone hidden></tr>
27+ <li id={ id } data-gone hidden></li>
2828 }
2929
3030 // RefsBody is the rows of the refs table.
@@ -21,10 +21,10 @@ templ FactValue(f Fact) {
21 </dd>21 </dd>
22 }22 }
23 23
24-// RepoGone replaces the row of a repository that is deleted or not visible24+// RepoGone replaces the card of a repository that is deleted or not visible
25 // any more; the page removes the element.25 // any more; the page removes the element.
26 templ RepoGone(id string) {26 templ RepoGone(id string) {
27- <tr id={ id } data-gone hidden></tr>27+ <li id={ id } data-gone hidden></li>
28 }28 }
29 29
30 // RefsBody is the rows of the refs table.30 // RefsBody is the rows of the refs table.
M internal/adapters/web/fragments_templ.go +3 -3 generated file: collapsed
M internal/adapters/web/home.go +29 -21
@@ -11,6 +11,7 @@ import (
1111
1212 "github.com/a-h/templ"
1313
14+ "bnema.dev/ermite/internal/domain"
1415 "bnema.dev/ermite/internal/ports"
1516 )
1617
@@ -48,14 +49,16 @@ func (s *Server) listRepos(ctx context.Context) ([]ports.RepoView, error) {
4849 return repos, nil
4950 }
5051
51-// homeCols are the sortable columns of the home list, in order.
52-var homeCols = []struct{ key, label, class string }{
53- {"name", "name", "c-name"},
54- {"desc", "description", "c-desc"},
55- {"push", "last push", "c-when"},
56- {"branch", "branch", "c-ref"},
52+// homeSorts are the orders the home list offers, in order. The query also
53+// accepts "desc" and "branch", which the page does not link to.
54+var homeSorts = []struct{ key, label string }{
55+ {"push", "last push"},
56+ {"name", "name"},
5757 }
5858
59+// homeKeys are the keys of ?sort= that sortRepos knows.
60+var homeKeys = []string{"push", "name", "desc", "branch"}
61+
5962 // sortRepos orders repos by the column key; ties keep the order by name.
6063 func sortRepos(repos []ports.RepoView, key string, desc bool) {
6164 less := func(a, b ports.RepoView) bool {
@@ -85,9 +88,9 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in
8588 return nil, 0, err
8689 }
8790 key, desc := "push", true
88- for _, c := range homeCols {
89- if r.URL.Query().Get("sort") == c.key {
90- key, desc = c.key, c.key == "push"
91+ for _, k := range homeKeys {
92+ if r.URL.Query().Get("sort") == k {
93+ key, desc = k, k == "push"
9194 }
9295 }
9396 if d := r.URL.Query().Get("dir"); d == "asc" || d == "desc" {
@@ -96,23 +99,26 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in
9699 sortRepos(repos, key, desc)
97100
98101 now := s.cfg.Now()
102+ signed := SignedIn(ctx)
99103 p := HomePage{}
100- for _, c := range homeCols {
101- sc := SortCol{Label: c.label, Class: c.class, Dir: "none"}
104+ for _, c := range homeSorts {
105+ sl := SortLink{Label: c.label}
102106 next := "asc"
107+ if c.key == "push" {
108+ next = "desc"
109+ }
103110 if c.key == key {
104- sc.Dir, sc.Mark = "ascending", "▲"
111+ sl.Mark, sl.State = "▲", "ascending, select for descending"
112+ next = "desc"
105113 if desc {
106- sc.Dir, sc.Mark = "descending", "▼"
107- } else {
108- next = "desc"
114+ sl.Mark, sl.State, next = "▼", "descending, select for ascending", "asc"
109115 }
110116 }
111- sc.Href = "/?sort=" + c.key + "&dir=" + next
112- p.Cols = append(p.Cols, sc)
117+ sl.Href = "/?sort=" + c.key + "&dir=" + next
118+ p.Sorts = append(p.Sorts, sl)
113119 }
114120 for i, repo := range repos {
115- p.Rows = append(p.Rows, homeRow(repo, now, i == 0))
121+ p.Rows = append(p.Rows, homeRow(repo, now, i == 0, signed))
116122 }
117123
118124 l := s.base()
@@ -122,12 +128,14 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in
122128 return Home(l, p), http.StatusOK, nil
123129 }
124130
125-// homeRow is the row of a repository in the home list.
126-func homeRow(r ports.RepoView, now time.Time, cursor bool) HomeRow {
131+// homeRow is the card of a repository in the home list. The private badge is
132+// for a signed-in visitor: an anonymous one never gets a private repository.
133+func homeRow(r ports.RepoView, now time.Time, cursor, signed bool) HomeRow {
127134 name := r.Name.String()
128135 row := HomeRow{
129136 ID: "repo-" + name, Name: name, Href: repoHref(name),
130- Desc: r.Description, Pushed: "never", Branch: r.DefaultBranch, Cursor: cursor,
137+ Desc: r.Description, Branch: r.DefaultBranch, Cursor: cursor,
138+ Private: signed && r.Visibility == domain.VisibilityPrivate,
131139 }
132140 if !r.PushedAt.IsZero() {
133141 row.Pushed, row.PushedAt = ago(now, r.PushedAt), rfc3339(r.PushedAt)
@@ -11,6 +11,7 @@ import (
11 11
12 "github.com/a-h/templ"12 "github.com/a-h/templ"
13 13
14+ "bnema.dev/ermite/internal/domain"
14 "bnema.dev/ermite/internal/ports"15 "bnema.dev/ermite/internal/ports"
15 )16 )
16 17
@@ -48,14 +49,16 @@ func (s *Server) listRepos(ctx context.Context) ([]ports.RepoView, error) {
48 return repos, nil49 return repos, nil
49 }50 }
50 51
51-// homeCols are the sortable columns of the home list, in order.52+// homeSorts are the orders the home list offers, in order. The query also
52-var homeCols = []struct{ key, label, class string }{53+// accepts "desc" and "branch", which the page does not link to.
53- {"name", "name", "c-name"},54+var homeSorts = []struct{ key, label string }{
54- {"desc", "description", "c-desc"},55+ {"push", "last push"},
55- {"push", "last push", "c-when"},56+ {"name", "name"},
56- {"branch", "branch", "c-ref"},
57 }57 }
58 58
59+// homeKeys are the keys of ?sort= that sortRepos knows.
60+var homeKeys = []string{"push", "name", "desc", "branch"}
61+
59 // sortRepos orders repos by the column key; ties keep the order by name.62 // sortRepos orders repos by the column key; ties keep the order by name.
60 func sortRepos(repos []ports.RepoView, key string, desc bool) {63 func sortRepos(repos []ports.RepoView, key string, desc bool) {
61 less := func(a, b ports.RepoView) bool {64 less := func(a, b ports.RepoView) bool {
@@ -85,9 +88,9 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in
85 return nil, 0, err88 return nil, 0, err
86 }89 }
87 key, desc := "push", true90 key, desc := "push", true
88- for _, c := range homeCols {91+ for _, k := range homeKeys {
89- if r.URL.Query().Get("sort") == c.key {92+ if r.URL.Query().Get("sort") == k {
90- key, desc = c.key, c.key == "push"93+ key, desc = k, k == "push"
91 }94 }
92 }95 }
93 if d := r.URL.Query().Get("dir"); d == "asc" || d == "desc" {96 if d := r.URL.Query().Get("dir"); d == "asc" || d == "desc" {
@@ -96,23 +99,26 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in
96 sortRepos(repos, key, desc)99 sortRepos(repos, key, desc)
97 100
98 now := s.cfg.Now()101 now := s.cfg.Now()
102+ signed := SignedIn(ctx)
99 p := HomePage{}103 p := HomePage{}
100- for _, c := range homeCols {104+ for _, c := range homeSorts {
101- sc := SortCol{Label: c.label, Class: c.class, Dir: "none"}105+ sl := SortLink{Label: c.label}
102 next := "asc"106 next := "asc"
107+ if c.key == "push" {
108+ next = "desc"
109+ }
103 if c.key == key {110 if c.key == key {
104- sc.Dir, sc.Mark = "ascending", "▲"111+ sl.Mark, sl.State = "▲", "ascending, select for descending"
112+ next = "desc"
105 if desc {113 if desc {
106- sc.Dir, sc.Mark = "descending", "▼"114+ sl.Mark, sl.State, next = "▼", "descending, select for ascending", "asc"
107- } else {
108- next = "desc"
109 }115 }
110 }116 }
111- sc.Href = "/?sort=" + c.key + "&dir=" + next117+ sl.Href = "/?sort=" + c.key + "&dir=" + next
112- p.Cols = append(p.Cols, sc)118+ p.Sorts = append(p.Sorts, sl)
113 }119 }
114 for i, repo := range repos {120 for i, repo := range repos {
115- p.Rows = append(p.Rows, homeRow(repo, now, i == 0))121+ p.Rows = append(p.Rows, homeRow(repo, now, i == 0, signed))
116 }122 }
117 123
118 l := s.base()124 l := s.base()
@@ -122,12 +128,14 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in
122 return Home(l, p), http.StatusOK, nil128 return Home(l, p), http.StatusOK, nil
123 }129 }
124 130
125-// homeRow is the row of a repository in the home list.131+// homeRow is the card of a repository in the home list. The private badge is
126-func homeRow(r ports.RepoView, now time.Time, cursor bool) HomeRow {132+// for a signed-in visitor: an anonymous one never gets a private repository.
133+func homeRow(r ports.RepoView, now time.Time, cursor, signed bool) HomeRow {
127 name := r.Name.String()134 name := r.Name.String()
128 row := HomeRow{135 row := HomeRow{
129 ID: "repo-" + name, Name: name, Href: repoHref(name),136 ID: "repo-" + name, Name: name, Href: repoHref(name),
130- Desc: r.Description, Pushed: "never", Branch: r.DefaultBranch, Cursor: cursor,137+ Desc: r.Description, Branch: r.DefaultBranch, Cursor: cursor,
138+ Private: signed && r.Visibility == domain.VisibilityPrivate,
131 }139 }
132 if !r.PushedAt.IsZero() {140 if !r.PushedAt.IsZero() {
133 row.Pushed, row.PushedAt = ago(now, r.PushedAt), rfc3339(r.PushedAt)141 row.Pushed, row.PushedAt = ago(now, r.PushedAt), rfc3339(r.PushedAt)
M internal/adapters/web/pages.templ +39 -32
@@ -5,42 +5,49 @@ package web
55 // Home is the repository list.
66 templ Home(l Layout, p HomePage) {
77 @Shell(l, nil) {
8- <div class="filter">
9- <label for="home-filter" class="sr">filter</label>
10- <input
11- id="home-filter"
12- type="search"
13- data-filter="#repo-rows > tr"
14- placeholder="Filter repositories…"
15- autocomplete="off"
16- hx-on:input="ermite.filter(this)"
17- />
18- </div>
19- <table class="list home" aria-label="Repositories">
20- <thead>
21- <tr>
22- <th class="cur"></th>
23- for _, c := range p.Cols {
24- <th class={ c.Class } aria-sort={ c.Dir }>
25- <a href={ templ.SafeURL(c.Href) }>
26- { c.Label }
27- if c.Mark != "" {
28- <span aria-hidden="true">{ c.Mark }</span>
8+ <h1 class="sr">Repositories</h1>
9+ <div class="home">
10+ <section class="home-repos" aria-label="Repositories">
11+ <div class="home-bar">
12+ <div class="filter">
13+ <label for="home-filter" class="sr">filter</label>
14+ <input
15+ id="home-filter"
16+ type="search"
17+ data-filter="#repo-rows > li"
18+ placeholder="Filter repositories…"
19+ autocomplete="off"
20+ hx-on:input="ermite.filter(this)"
21+ />
22+ </div>
23+ <nav class="sort" aria-label="Sort repositories">
24+ <span class="mute">sort</span>
25+ for _, s := range p.Sorts {
26+ <a
27+ href={ templ.SafeURL(s.Href) }
28+ if s.Mark != "" {
29+ aria-current="true"
30+ }
31+ >
32+ { s.Label }
33+ if s.Mark != "" {
34+ <span aria-hidden="true">{ s.Mark }</span>
35+ <span class="sr">{ ", " + s.State }</span>
2936 }
3037 </a>
31- </th>
38+ }
39+ </nav>
40+ </div>
41+ <ul class="repos" id="repo-rows" data-rows>
42+ for _, r := range p.Rows {
43+ @HomeRowView(r)
3244 }
33- </tr>
34- </thead>
35- <tbody id="repo-rows" data-rows>
36- for _, r := range p.Rows {
37- @HomeRowView(r)
45+ </ul>
46+ if len(p.Rows) == 0 {
47+ <p class="lede dim">no repositories yet.</p>
3848 }
39- </tbody>
40- </table>
41- if len(p.Rows) == 0 {
42- <p class="lede dim">no repositories yet.</p>
43- }
49+ </section>
50+ </div>
4451 }
4552 }
4653
@@ -5,42 +5,49 @@ package web
5 // Home is the repository list.5 // Home is the repository list.
6 templ Home(l Layout, p HomePage) {6 templ Home(l Layout, p HomePage) {
7 @Shell(l, nil) {7 @Shell(l, nil) {
8- <div class="filter">8+ <h1 class="sr">Repositories</h1>
9- <label for="home-filter" class="sr">filter</label>9+ <div class="home">
10- <input10+ <section class="home-repos" aria-label="Repositories">
11- id="home-filter"11+ <div class="home-bar">
12- type="search"12+ <div class="filter">
13- data-filter="#repo-rows > tr"13+ <label for="home-filter" class="sr">filter</label>
14- placeholder="Filter repositories…"14+ <input
15- autocomplete="off"15+ id="home-filter"
16- hx-on:input="ermite.filter(this)"16+ type="search"
17- />17+ data-filter="#repo-rows > li"
18- </div>18+ placeholder="Filter repositories…"
19- <table class="list home" aria-label="Repositories">19+ autocomplete="off"
20- <thead>20+ hx-on:input="ermite.filter(this)"
21- <tr>21+ />
22- <th class="cur"></th>22+ </div>
23- for _, c := range p.Cols {23+ <nav class="sort" aria-label="Sort repositories">
24- <th class={ c.Class } aria-sort={ c.Dir }>24+ <span class="mute">sort</span>
25- <a href={ templ.SafeURL(c.Href) }>25+ for _, s := range p.Sorts {
26- { c.Label }26+ <a
27- if c.Mark != "" {27+ href={ templ.SafeURL(s.Href) }
28- <span aria-hidden="true">{ c.Mark }</span>28+ if s.Mark != "" {
29+ aria-current="true"
30+ }
31+ >
32+ { s.Label }
33+ if s.Mark != "" {
34+ <span aria-hidden="true">{ s.Mark }</span>
35+ <span class="sr">{ ", " + s.State }</span>
29 }36 }
30 </a>37 </a>
31- </th>38+ }
39+ </nav>
40+ </div>
41+ <ul class="repos" id="repo-rows" data-rows>
42+ for _, r := range p.Rows {
43+ @HomeRowView(r)
32 }44 }
33- </tr>45+ </ul>
34- </thead>46+ if len(p.Rows) == 0 {
35- <tbody id="repo-rows" data-rows>47+ <p class="lede dim">no repositories yet.</p>
36- for _, r := range p.Rows {
37- @HomeRowView(r)
38 }48 }
39- </tbody>49+ </section>
40- </table>50+ </div>
41- if len(p.Rows) == 0 {
42- <p class="lede dim">no repositories yet.</p>
43- }
44 }51 }
45 }52 }
46 53
M internal/adapters/web/pages_templ.go +369 -373 generated file: collapsed
M internal/adapters/web/static/css/30-list.css +40 -6
@@ -28,13 +28,47 @@
2828 & .c-sub { color: var(--ink-dim); }
2929 }
3030
31+ /* home: the repositories as cards in a grid (one column on a phone); the
32+ page is a stack of sections, so others can sit beside or below */
3133 .home {
32- & .c-name { width: 26%; font-family: var(--mono); font-weight: 700; }
33- & th.c-name { font-family: var(--font); }
34- & .c-desc { width: auto; }
35- & .c-when { width: 130px; }
36- & .c-ref { width: 120px; }
37- & .c-lang { width: 120px; }
34+ display: flex; flex-direction: column; gap: var(--s5);
35+ & .home-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--s3) var(--s4); margin-bottom: var(--s4); }
36+ & .home-bar .filter { flex: 1 1 260px; }
37+ & .sort { display: flex; align-items: center; gap: var(--s1); font-size: var(--fs-s); }
38+ & .sort > .mute { margin-right: var(--s1); }
39+ & .sort a {
40+ display: inline-flex; align-items: center; gap: 4px; height: 28px; padding: 0 var(--s2);
41+ color: var(--ink-dim); text-decoration: none; border: 1px solid transparent; border-radius: var(--r-s);
42+ }
43+ & .sort a:hover { color: var(--ink-hi); background: var(--bg-raised); }
44+ & .sort a[aria-current="true"] { color: var(--ink-hi); background: var(--bg-raised); border-color: var(--border-strong); }
45+ }
46+ .repos {
47+ display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 380px), 1fr)); gap: var(--s3);
48+ margin: 0; padding: 0; list-style: none;
49+ & .repo {
50+ position: relative; display: flex; flex-direction: column; gap: var(--s2); min-width: 0;
51+ padding: var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r);
52+ }
53+ & .repo:hover { background: var(--bg-raised); border-color: var(--border-strong); }
54+ & .repo[data-cursor] { background: var(--bg-raised); border-color: var(--ink-mute); box-shadow: inset 3px 0 0 var(--ink-hi); }
55+ & .repo[hidden] { display: none; }
56+ & .repo-name { display: flex; align-items: center; flex-wrap: wrap; gap: var(--s1) var(--s2); font-size: 17px; line-height: 26px; overflow-wrap: anywhere; }
57+ & .repo-name a { color: var(--ink-hi); text-decoration: none; }
58+ /* the whole card is the target of the name link */
59+ & .repo-name a::after { content: ""; position: absolute; inset: 0; border-radius: var(--r); }
60+ & .repo-name a:focus-visible { outline: none; }
61+ & .repo-name a:focus-visible::after { outline: 2px solid var(--focus); outline-offset: 1px; }
62+ & .repo:hover .repo-name a, & .repo[data-cursor] .repo-name a { text-decoration: underline; }
63+ & .repo-name .pill { margin-left: 0; }
64+ & .repo-desc {
65+ flex: 1; color: var(--ink-dim);
66+ display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden;
67+ }
68+ & .repo-desc.mute { color: var(--ink-mute); font-style: italic; }
69+ & .repo-meta { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s1) var(--s4); font-size: var(--fs-s); color: var(--ink-mute); }
70+ & .repo-meta time { color: var(--ink-dim); }
71+ & .repo-branch { display: inline-flex; align-items: center; gap: 6px; font-family: var(--mono); color: var(--ink-dim); }
3872 }
3973 .list.tree {
4074 & .c-kind { width: 36px; color: var(--ink-mute); padding-right: 0; text-align: center; }
@@ -28,13 +28,47 @@
28 & .c-sub { color: var(--ink-dim); }28 & .c-sub { color: var(--ink-dim); }
29 }29 }
30 30
31+ /* home: the repositories as cards in a grid (one column on a phone); the
32+ page is a stack of sections, so others can sit beside or below */
31 .home {33 .home {
32- & .c-name { width: 26%; font-family: var(--mono); font-weight: 700; }34+ display: flex; flex-direction: column; gap: var(--s5);
33- & th.c-name { font-family: var(--font); }35+ & .home-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--s3) var(--s4); margin-bottom: var(--s4); }
34- & .c-desc { width: auto; }36+ & .home-bar .filter { flex: 1 1 260px; }
35- & .c-when { width: 130px; }37+ & .sort { display: flex; align-items: center; gap: var(--s1); font-size: var(--fs-s); }
36- & .c-ref { width: 120px; }38+ & .sort > .mute { margin-right: var(--s1); }
37- & .c-lang { width: 120px; }39+ & .sort a {
40+ display: inline-flex; align-items: center; gap: 4px; height: 28px; padding: 0 var(--s2);
41+ color: var(--ink-dim); text-decoration: none; border: 1px solid transparent; border-radius: var(--r-s);
42+ }
43+ & .sort a:hover { color: var(--ink-hi); background: var(--bg-raised); }
44+ & .sort a[aria-current="true"] { color: var(--ink-hi); background: var(--bg-raised); border-color: var(--border-strong); }
45+ }
46+ .repos {
47+ display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 380px), 1fr)); gap: var(--s3);
48+ margin: 0; padding: 0; list-style: none;
49+ & .repo {
50+ position: relative; display: flex; flex-direction: column; gap: var(--s2); min-width: 0;
51+ padding: var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r);
52+ }
53+ & .repo:hover { background: var(--bg-raised); border-color: var(--border-strong); }
54+ & .repo[data-cursor] { background: var(--bg-raised); border-color: var(--ink-mute); box-shadow: inset 3px 0 0 var(--ink-hi); }
55+ & .repo[hidden] { display: none; }
56+ & .repo-name { display: flex; align-items: center; flex-wrap: wrap; gap: var(--s1) var(--s2); font-size: 17px; line-height: 26px; overflow-wrap: anywhere; }
57+ & .repo-name a { color: var(--ink-hi); text-decoration: none; }
58+ /* the whole card is the target of the name link */
59+ & .repo-name a::after { content: ""; position: absolute; inset: 0; border-radius: var(--r); }
60+ & .repo-name a:focus-visible { outline: none; }
61+ & .repo-name a:focus-visible::after { outline: 2px solid var(--focus); outline-offset: 1px; }
62+ & .repo:hover .repo-name a, & .repo[data-cursor] .repo-name a { text-decoration: underline; }
63+ & .repo-name .pill { margin-left: 0; }
64+ & .repo-desc {
65+ flex: 1; color: var(--ink-dim);
66+ display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden;
67+ }
68+ & .repo-desc.mute { color: var(--ink-mute); font-style: italic; }
69+ & .repo-meta { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s1) var(--s4); font-size: var(--fs-s); color: var(--ink-mute); }
70+ & .repo-meta time { color: var(--ink-dim); }
71+ & .repo-branch { display: inline-flex; align-items: center; gap: 6px; font-family: var(--mono); color: var(--ink-dim); }
38 }72 }
39 .list.tree {73 .list.tree {
40 & .c-kind { width: 36px; color: var(--ink-mute); padding-right: 0; text-align: center; }74 & .c-kind { width: 36px; color: var(--ink-mute); padding-right: 0; text-align: center; }
M internal/adapters/web/view.go +10 -8
@@ -83,25 +83,27 @@ type HomeRow struct {
8383 Pushed string
8484 PushedAt string // RFC 3339, "" when never pushed
8585 Branch string
86- Tag string
8786 Cursor bool
87+ // Private marks a private repository; only set for a signed-in visitor.
88+ Private bool
8889 // Fresh marks a row that a push just updated (live updates only).
8990 Fresh bool
9091 }
9192
92-// SortCol is a column header of the home list; it links to its sort order.
93-type SortCol struct {
93+// SortLink is one way to order the home list; it links to that order.
94+type SortLink struct {
9495 Label string
9596 Href string
96- Dir string // "ascending" | "descending" | "none"
97- Mark string
98- Class string
97+ Mark string // "▲" or "▼" on the current order, "" otherwise
98+ // State says the current direction and what the link does, for screen
99+ // readers (the mark is hidden from them); "" off the current order.
100+ State string
99101 }
100102
101103 // HomePage is the repository list.
102104 type HomePage struct {
103- Cols []SortCol
104- Rows []HomeRow
105+ Sorts []SortLink
106+ Rows []HomeRow
105107 }
106108
107109 // Fact is one key/value row of the facts block.
@@ -83,25 +83,27 @@ type HomeRow struct {
83 Pushed string83 Pushed string
84 PushedAt string // RFC 3339, "" when never pushed84 PushedAt string // RFC 3339, "" when never pushed
85 Branch string85 Branch string
86- Tag string
87 Cursor bool86 Cursor bool
87+ // Private marks a private repository; only set for a signed-in visitor.
88+ Private bool
88 // Fresh marks a row that a push just updated (live updates only).89 // Fresh marks a row that a push just updated (live updates only).
89 Fresh bool90 Fresh bool
90 }91 }
91 92
92-// SortCol is a column header of the home list; it links to its sort order.93+// SortLink is one way to order the home list; it links to that order.
93-type SortCol struct {94+type SortLink struct {
94 Label string95 Label string
95 Href string96 Href string
96- Dir string // "ascending" | "descending" | "none"97+ Mark string // "▲" or "▼" on the current order, "" otherwise
97- Mark string98+ // State says the current direction and what the link does, for screen
98- Class string99+ // readers (the mark is hidden from them); "" off the current order.
100+ State string
99 }101 }
100 102
101 // HomePage is the repository list.103 // HomePage is the repository list.
102 type HomePage struct {104 type HomePage struct {
103- Cols []SortCol105+ Sorts []SortLink
104- Rows []HomeRow106+ Rows []HomeRow
105 }107 }
106 108
107 // Fact is one key/value row of the facts block.109 // Fact is one key/value row of the facts block.
M internal/app/testdata/web/home.html +39 -46
@@ -49,58 +49,51 @@
4949 </header>
5050 <div class="work">
5151 <main id="main" tabindex="-1">
52+<h1 class="sr">Repositories</h1>
53+<div class="home">
54+<section class="home-repos" aria-label="Repositories">
55+<div class="home-bar">
5256 <div class="filter">
5357 <label for="home-filter" class="sr">filter</label>
54-<input id="home-filter" type="search" data-filter="#repo-rows > tr" placeholder="Filter repositories…" autocomplete="off" hx-on:input="ermite.filter(this)">
58+<input id="home-filter" type="search" data-filter="#repo-rows > li" placeholder="Filter repositories…" autocomplete="off" hx-on:input="ermite.filter(this)">
5559 </div>
56-<table class="list home" aria-label="Repositories">
57-<thead>
58-<tr>
59-<th class="cur">
60-</th>
61-<th class="c-name" aria-sort="none">
62-<a href="/?sort=name&amp;dir=asc">name </a>
63-</th>
64-<th class="c-desc" aria-sort="none">
65-<a href="/?sort=desc&amp;dir=asc">description </a>
66-</th>
67-<th class="c-when" aria-sort="descending">
68-<a href="/?sort=push&amp;dir=asc">last push <span aria-hidden="true">▼</span>
60+<nav class="sort" aria-label="Sort repositories">
61+<span class="mute">sort</span>
62+<a href="/?sort=push&amp;dir=asc" aria-current="true">last push <span aria-hidden="true">▼</span>
63+<span class="sr">, descending, select for ascending</span>
6964 </a>
70-</th>
71-<th class="c-ref" aria-sort="none">
72-<a href="/?sort=branch&amp;dir=asc">branch </a>
73-</th>
74-</tr>
75-</thead>
76-<tbody id="repo-rows" data-rows>
77-<tr id="repo-demo" data-cursor>
78-<td class="cur" aria-hidden="true">
79-</td>
80-<td class="c-name">
65+<a href="/?sort=name&amp;dir=asc">name </a>
66+</nav>
67+</div>
68+<ul class="repos" id="repo-rows" data-rows>
69+<li id="repo-demo" class="repo" data-cursor>
70+<h2 class="repo-name">
8171 <a href="/demo">demo</a>
82-</td>
83-<td class="c-desc dim">A demo repository</td>
84-<td class="c-when nowrap">
85-<time datetime="STAMP">AGO</time>
86-</td>
87-<td class="c-ref">main</td>
88-</tr>
89-<tr id="repo-quiet">
90-<td class="cur" aria-hidden="true">
91-</td>
92-<td class="c-name">
72+</h2>
73+<p class="repo-desc">A demo repository</p>
74+<p class="repo-meta">
75+<span class="repo-branch">
76+<span class="ico i-branch" aria-hidden="true">
77+</span>main</span>
78+<span>pushed <time datetime="STAMP" title="STAMP">AGO</time>
79+</span>
80+</p>
81+</li>
82+<li id="repo-quiet" class="repo">
83+<h2 class="repo-name">
9384 <a href="/quiet">quiet</a>
94-</td>
95-<td class="c-desc dim">
96-</td>
97-<td class="c-when nowrap">
98-<time>never</time>
99-</td>
100-<td class="c-ref">main</td>
101-</tr>
102-</tbody>
103-</table>
85+</h2>
86+<p class="repo-desc mute">no description</p>
87+<p class="repo-meta">
88+<span class="repo-branch">
89+<span class="ico i-branch" aria-hidden="true">
90+</span>main</span>
91+<span>never pushed</span>
92+</p>
93+</li>
94+</ul>
95+</section>
96+</div>
10497 </main>
10598 </div>
10699 <footer class="foot">
@@ -49,58 +49,51 @@
49 </header>49 </header>
50 <div class="work">50 <div class="work">
51 <main id="main" tabindex="-1">51 <main id="main" tabindex="-1">
52+<h1 class="sr">Repositories</h1>
53+<div class="home">
54+<section class="home-repos" aria-label="Repositories">
55+<div class="home-bar">
52 <div class="filter">56 <div class="filter">
53 <label for="home-filter" class="sr">filter</label>57 <label for="home-filter" class="sr">filter</label>
54-<input id="home-filter" type="search" data-filter="#repo-rows > tr" placeholder="Filter repositories…" autocomplete="off" hx-on:input="ermite.filter(this)">58+<input id="home-filter" type="search" data-filter="#repo-rows > li" placeholder="Filter repositories…" autocomplete="off" hx-on:input="ermite.filter(this)">
55 </div>59 </div>
56-<table class="list home" aria-label="Repositories">60+<nav class="sort" aria-label="Sort repositories">
57-<thead>61+<span class="mute">sort</span>
58-<tr>62+<a href="/?sort=push&amp;dir=asc" aria-current="true">last push <span aria-hidden="true">▼</span>
59-<th class="cur">63+<span class="sr">, descending, select for ascending</span>
60-</th>
61-<th class="c-name" aria-sort="none">
62-<a href="/?sort=name&amp;dir=asc">name </a>
63-</th>
64-<th class="c-desc" aria-sort="none">
65-<a href="/?sort=desc&amp;dir=asc">description </a>
66-</th>
67-<th class="c-when" aria-sort="descending">
68-<a href="/?sort=push&amp;dir=asc">last push <span aria-hidden="true">▼</span>
69 </a>64 </a>
70-</th>65+<a href="/?sort=name&amp;dir=asc">name </a>
71-<th class="c-ref" aria-sort="none">66+</nav>
72-<a href="/?sort=branch&amp;dir=asc">branch </a>67+</div>
73-</th>68+<ul class="repos" id="repo-rows" data-rows>
74-</tr>69+<li id="repo-demo" class="repo" data-cursor>
75-</thead>70+<h2 class="repo-name">
76-<tbody id="repo-rows" data-rows>
77-<tr id="repo-demo" data-cursor>
78-<td class="cur" aria-hidden="true">
79-</td>
80-<td class="c-name">
81 <a href="/demo">demo</a>71 <a href="/demo">demo</a>
82-</td>72+</h2>
83-<td class="c-desc dim">A demo repository</td>73+<p class="repo-desc">A demo repository</p>
84-<td class="c-when nowrap">74+<p class="repo-meta">
85-<time datetime="STAMP">AGO</time>75+<span class="repo-branch">
86-</td>76+<span class="ico i-branch" aria-hidden="true">
87-<td class="c-ref">main</td>77+</span>main</span>
88-</tr>78+<span>pushed <time datetime="STAMP" title="STAMP">AGO</time>
89-<tr id="repo-quiet">79+</span>
90-<td class="cur" aria-hidden="true">80+</p>
91-</td>81+</li>
92-<td class="c-name">82+<li id="repo-quiet" class="repo">
83+<h2 class="repo-name">
93 <a href="/quiet">quiet</a>84 <a href="/quiet">quiet</a>
94-</td>85+</h2>
95-<td class="c-desc dim">86+<p class="repo-desc mute">no description</p>
96-</td>87+<p class="repo-meta">
97-<td class="c-when nowrap">88+<span class="repo-branch">
98-<time>never</time>89+<span class="ico i-branch" aria-hidden="true">
99-</td>90+</span>main</span>
100-<td class="c-ref">main</td>91+<span>never pushed</span>
101-</tr>92+</p>
102-</tbody>93+</li>
103-</table>94+</ul>
95+</section>
96+</div>
104 </main>97 </main>
105 </div>98 </div>
106 <footer class="foot">99 <footer class="foot">
M internal/app/web_events_test.go +4 -4
@@ -122,7 +122,7 @@ func TestWebEventsHome(t *testing.T) {
122122 assert.Less(t, time.Since(start), 2*time.Second)
123123 assert.Equal(t, "home-list", ev.name)
124124 assert.NotEmpty(t, ev.id)
125- assert.Contains(t, ev.data, `<tr id="repo-pub"`)
125+ assert.Contains(t, ev.data, `<li id="repo-pub"`)
126126 assert.Contains(t, ev.data, `new push`)
127127 assert.Contains(t, ev.data, `href="/pub"`)
128128
@@ -131,7 +131,7 @@ func TestWebEventsHome(t *testing.T) {
131131 require.NoError(t, err)
132132 ev = s.next(t, watchWait)
133133 assert.Equal(t, "home-list", ev.name)
134- assert.Contains(t, ev.data, `<tr id="repo-fresh"`)
134+ assert.Contains(t, ev.data, `<li id="repo-fresh"`)
135135 assert.Contains(t, ev.data, "a &lt;b&gt;fresh&lt;/b&gt; one", "text is escaped")
136136 assert.NotContains(t, ev.data, "new push")
137137
@@ -141,13 +141,13 @@ func TestWebEventsHome(t *testing.T) {
141141 require.NoError(t, err)
142142 ev = s.next(t, watchWait)
143143 assert.Equal(t, "home-list", ev.name)
144- assert.Contains(t, ev.data, `<tr id="repo-fresh" data-gone`)
144+ assert.Contains(t, ev.data, `<li id="repo-fresh" data-gone`)
145145 assert.NotContains(t, ev.data, "fresh one")
146146
147147 // And a deleted one.
148148 require.NoError(t, h.forge.Repos.Delete(h.ctx, domain.Root(), h.name("pub")))
149149 ev = s.next(t, watchWait)
150- assert.Contains(t, ev.data, `<tr id="repo-pub" data-gone`)
150+ assert.Contains(t, ev.data, `<li id="repo-pub" data-gone`)
151151 }
152152
153153 // AC5: the stream of a repository carries its last push; the refs page its
@@ -122,7 +122,7 @@ func TestWebEventsHome(t *testing.T) {
122 assert.Less(t, time.Since(start), 2*time.Second)122 assert.Less(t, time.Since(start), 2*time.Second)
123 assert.Equal(t, "home-list", ev.name)123 assert.Equal(t, "home-list", ev.name)
124 assert.NotEmpty(t, ev.id)124 assert.NotEmpty(t, ev.id)
125- assert.Contains(t, ev.data, `<tr id="repo-pub"`)125+ assert.Contains(t, ev.data, `<li id="repo-pub"`)
126 assert.Contains(t, ev.data, `new push`)126 assert.Contains(t, ev.data, `new push`)
127 assert.Contains(t, ev.data, `href="/pub"`)127 assert.Contains(t, ev.data, `href="/pub"`)
128 128
@@ -131,7 +131,7 @@ func TestWebEventsHome(t *testing.T) {
131 require.NoError(t, err)131 require.NoError(t, err)
132 ev = s.next(t, watchWait)132 ev = s.next(t, watchWait)
133 assert.Equal(t, "home-list", ev.name)133 assert.Equal(t, "home-list", ev.name)
134- assert.Contains(t, ev.data, `<tr id="repo-fresh"`)134+ assert.Contains(t, ev.data, `<li id="repo-fresh"`)
135 assert.Contains(t, ev.data, "a &lt;b&gt;fresh&lt;/b&gt; one", "text is escaped")135 assert.Contains(t, ev.data, "a &lt;b&gt;fresh&lt;/b&gt; one", "text is escaped")
136 assert.NotContains(t, ev.data, "new push")136 assert.NotContains(t, ev.data, "new push")
137 137
@@ -141,13 +141,13 @@ func TestWebEventsHome(t *testing.T) {
141 require.NoError(t, err)141 require.NoError(t, err)
142 ev = s.next(t, watchWait)142 ev = s.next(t, watchWait)
143 assert.Equal(t, "home-list", ev.name)143 assert.Equal(t, "home-list", ev.name)
144- assert.Contains(t, ev.data, `<tr id="repo-fresh" data-gone`)144+ assert.Contains(t, ev.data, `<li id="repo-fresh" data-gone`)
145 assert.NotContains(t, ev.data, "fresh one")145 assert.NotContains(t, ev.data, "fresh one")
146 146
147 // And a deleted one.147 // And a deleted one.
148 require.NoError(t, h.forge.Repos.Delete(h.ctx, domain.Root(), h.name("pub")))148 require.NoError(t, h.forge.Repos.Delete(h.ctx, domain.Root(), h.name("pub")))
149 ev = s.next(t, watchWait)149 ev = s.next(t, watchWait)
150- assert.Contains(t, ev.data, `<tr id="repo-pub" data-gone`)150+ assert.Contains(t, ev.data, `<li id="repo-pub" data-gone`)
151 }151 }
152 152
153 // AC5: the stream of a repository carries its last push; the refs page its153 // AC5: the stream of a repository carries its last push; the refs page its
M internal/app/web_session_test.go +3 -0
@@ -118,6 +118,7 @@ func TestWebLoginEndToEnd(t *testing.T) {
118118 assert.Equal(t, "no-cache", home.hdr.Get("Cache-Control"))
119119 assert.Equal(t, []string{"Accept-Encoding"}, home.hdr.Values("Vary"), "no Cookie in the Vary of a public page")
120120 assert.NotContains(t, home.body, `action="/-/logout"`)
121+ assert.NotContains(t, home.body, `>private</span>`, "an anonymous visitor sees no private badge")
121122 assert.Equal(t, 404, b.get("/secret", nil).status)
122123
123124 // The CLI asks for a link and prints it; stderr says when it ends.
@@ -199,6 +200,8 @@ func TestWebLoginEndToEnd(t *testing.T) {
199200 }
200201 home = b.get("/", as(token))
201202 assert.Contains(t, home.body, "secret")
203+ assert.Equal(t, 1, strings.Count(home.body, `<span class="pill">private</span>`), "only the private repository has the badge")
204+ assert.Regexp(t, `(?s)<li id="repo-secret".*?<span class="pill">private</span>.*?</li>\s*<li id="repo-pub"|<li id="repo-pub".*?</li>\s*<li id="repo-secret".*?<span class="pill">private</span>`, home.body)
202205 assert.Contains(t, home.body, `<form class="owner" method="post" action="/-/logout">`)
203206 assert.Contains(t, b.get("/secret/blob/main/notes.txt", as(token)).body, "notes.txt")
204207 raw := b.get("/secret/raw/main/notes.txt", as(token))
@@ -118,6 +118,7 @@ func TestWebLoginEndToEnd(t *testing.T) {
118 assert.Equal(t, "no-cache", home.hdr.Get("Cache-Control"))118 assert.Equal(t, "no-cache", home.hdr.Get("Cache-Control"))
119 assert.Equal(t, []string{"Accept-Encoding"}, home.hdr.Values("Vary"), "no Cookie in the Vary of a public page")119 assert.Equal(t, []string{"Accept-Encoding"}, home.hdr.Values("Vary"), "no Cookie in the Vary of a public page")
120 assert.NotContains(t, home.body, `action="/-/logout"`)120 assert.NotContains(t, home.body, `action="/-/logout"`)
121+ assert.NotContains(t, home.body, `>private</span>`, "an anonymous visitor sees no private badge")
121 assert.Equal(t, 404, b.get("/secret", nil).status)122 assert.Equal(t, 404, b.get("/secret", nil).status)
122 123
123 // The CLI asks for a link and prints it; stderr says when it ends.124 // The CLI asks for a link and prints it; stderr says when it ends.
@@ -199,6 +200,8 @@ func TestWebLoginEndToEnd(t *testing.T) {
199 }200 }
200 home = b.get("/", as(token))201 home = b.get("/", as(token))
201 assert.Contains(t, home.body, "secret")202 assert.Contains(t, home.body, "secret")
203+ assert.Equal(t, 1, strings.Count(home.body, `<span class="pill">private</span>`), "only the private repository has the badge")
204+ assert.Regexp(t, `(?s)<li id="repo-secret".*?<span class="pill">private</span>.*?</li>\s*<li id="repo-pub"|<li id="repo-pub".*?</li>\s*<li id="repo-secret".*?<span class="pill">private</span>`, home.body)
202 assert.Contains(t, home.body, `<form class="owner" method="post" action="/-/logout">`)205 assert.Contains(t, home.body, `<form class="owner" method="post" action="/-/logout">`)
203 assert.Contains(t, b.get("/secret/blob/main/notes.txt", as(token)).body, "notes.txt")206 assert.Contains(t, b.get("/secret/blob/main/notes.txt", as(token)).body, "notes.txt")
204 raw := b.get("/secret/raw/main/notes.txt", as(token))207 raw := b.get("/secret/raw/main/notes.txt", as(token))

Keyboard shortcuts

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

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

Branches 2

Tags 44

↑↓ moveenter openesc close