detached e08572c

feat(web): show releases as one card with prose notes and the latest release in About

commit
e08572cdbcb35f78e9849a172ea0dd69ef5105b9
author
brice · · 2026-10-22 05:47 UTC
committer
brice · · 2026-10-22 08:51 UTC
parent
5d69f2b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +518 -304
M internal/adapters/web/fragments.go +1 -1
@@ -167,7 +167,7 @@ func (l *liveStream) gone(ctx context.Context, name string) ([]fragment, error)
167167 func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
168168 switch ev := we.Event.(type) {
169169 case domain.ReleasePublished, domain.ReleaseUpdated, domain.ReleaseDeleted, domain.ReleaseUnpublished:
170- if l.view == "releases" || l.view == "release" {
170+ if l.view == "releases" || l.view == "release" || l.view == "summary" {
171171 return gap, nil // notes, assets, latest or visibility changed: reload
172172 }
173173 return nil, nil
@@ -167,7 +167,7 @@ func (l *liveStream) gone(ctx context.Context, name string) ([]fragment, error)
167 func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {167 func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
168 switch ev := we.Event.(type) {168 switch ev := we.Event.(type) {
169 case domain.ReleasePublished, domain.ReleaseUpdated, domain.ReleaseDeleted, domain.ReleaseUnpublished:169 case domain.ReleasePublished, domain.ReleaseUpdated, domain.ReleaseDeleted, domain.ReleaseUnpublished:
170- if l.view == "releases" || l.view == "release" {170+ if l.view == "releases" || l.view == "release" || l.view == "summary" {
171 return gap, nil // notes, assets, latest or visibility changed: reload171 return gap, nil // notes, assets, latest or visibility changed: reload
172 }172 }
173 return nil, nil173 return nil, nil
M internal/adapters/web/fragments_test.go +3 -3
@@ -101,8 +101,8 @@ func TestMRPushedHeadSignal(t *testing.T) {
101101 }
102102 }
103103
104-// Release lifecycle events invalidate the release pages without putting notes
105-// or asset metadata in SSE payloads. Other views need no release fragments.
104+// Release lifecycle events invalidate release pages and the summary's release
105+// fact without putting notes or asset metadata in SSE payloads.
106106 func TestReleaseLiveUpdates(t *testing.T) {
107107 for _, ev := range []domain.Event{domain.ReleasePublished{}, domain.ReleaseUpdated{}, domain.ReleaseDeleted{}, domain.ReleaseUnpublished{}} {
108108 we := ports.WatchEvent{Event: ev}
@@ -111,7 +111,7 @@ func TestReleaseLiveUpdates(t *testing.T) {
111111 st := &liveStream{repo: "proj", view: view}
112112 got, err := st.render(t.Context(), we)
113113 require.NoError(t, err)
114- if view == "release" || view == "releases" {
114+ if view == "release" || view == "releases" || view == "summary" {
115115 assert.Equal(t, gap, got)
116116 } else {
117117 assert.Empty(t, got)
@@ -101,8 +101,8 @@ func TestMRPushedHeadSignal(t *testing.T) {
101 }101 }
102 }102 }
103 103
104-// Release lifecycle events invalidate the release pages without putting notes104+// Release lifecycle events invalidate release pages and the summary's release
105-// or asset metadata in SSE payloads. Other views need no release fragments.105+// fact without putting notes or asset metadata in SSE payloads.
106 func TestReleaseLiveUpdates(t *testing.T) {106 func TestReleaseLiveUpdates(t *testing.T) {
107 for _, ev := range []domain.Event{domain.ReleasePublished{}, domain.ReleaseUpdated{}, domain.ReleaseDeleted{}, domain.ReleaseUnpublished{}} {107 for _, ev := range []domain.Event{domain.ReleasePublished{}, domain.ReleaseUpdated{}, domain.ReleaseDeleted{}, domain.ReleaseUnpublished{}} {
108 we := ports.WatchEvent{Event: ev}108 we := ports.WatchEvent{Event: ev}
@@ -111,7 +111,7 @@ func TestReleaseLiveUpdates(t *testing.T) {
111 st := &liveStream{repo: "proj", view: view}111 st := &liveStream{repo: "proj", view: view}
112 got, err := st.render(t.Context(), we)112 got, err := st.render(t.Context(), we)
113 require.NoError(t, err)113 require.NoError(t, err)
114- if view == "release" || view == "releases" {114+ if view == "release" || view == "releases" || view == "summary" {
115 assert.Equal(t, gap, got)115 assert.Equal(t, gap, got)
116 } else {116 } else {
117 assert.Empty(t, got)117 assert.Empty(t, got)
M internal/adapters/web/pages.templ +8 -0
@@ -115,6 +115,14 @@ templ summaryRail(p SummaryPage) {
115115 if p.Latest != nil {
116116 @latestTagFact(*p.Latest)
117117 }
118+ if r := p.Release; r != nil {
119+ <dt>release</dt>
120+ <dd>
121+ <a href={ templ.SafeURL(r.Href) }>{ r.Tag }</a>{ " · " }
122+ <time datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time>{ " · " }
123+ <a href={ templ.SafeURL(r.AllHref) }>all releases</a>
124+ </dd>
125+ }
118126 </dl>
119127 </section>
120128 if len(p.Recent) > 0 {
@@ -115,6 +115,14 @@ templ summaryRail(p SummaryPage) {
115 if p.Latest != nil {115 if p.Latest != nil {
116 @latestTagFact(*p.Latest)116 @latestTagFact(*p.Latest)
117 }117 }
118+ if r := p.Release; r != nil {
119+ <dt>release</dt>
120+ <dd>
121+ <a href={ templ.SafeURL(r.Href) }>{ r.Tag }</a>{ " · " }
122+ <time datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time>{ " · " }
123+ <a href={ templ.SafeURL(r.AllHref) }>all releases</a>
124+ </dd>
125+ }
118 </dl>126 </dl>
119 </section>127 </section>
120 if len(p.Recent) > 0 {128 if len(p.Recent) > 0 {
M internal/adapters/web/pages_templ.go +371 -261 generated file: collapsed
M internal/adapters/web/releases.templ +16 -9
@@ -40,15 +40,22 @@ templ Releases(l Layout, p ReleaseListPage) {
4040
4141 templ ReleaseDetail(l Layout, p ReleaseDetailPage) {
4242 @Shell(l, nil) {
43- <h1>{ p.Title }</h1>
44- <p>
45- <span class="pill">{ p.Tag }</span> @ReleaseBadges(p.ReleaseRow)
46- if p.SHA != "" {
47- <a href={ templ.SafeURL(p.CommitHref) }><code>{ p.SHA }</code></a>
48- }
49- <span class="dim">{ p.Published }</span>
50- </p>
51- <div class="md">@templ.Raw(string(p.Notes))</div>
43+ <section class="mr-card">
44+ <header class="show mr-h">
45+ <h1>{ p.Title }</h1>
46+ <dl class="kv show-kv">
47+ <dt>tag</dt>
48+ <dd><span class="pill">{ p.Tag }</span> @ReleaseBadges(p.ReleaseRow)</dd>
49+ if p.SHA != "" {
50+ <dt>commit</dt>
51+ <dd><a href={ templ.SafeURL(p.CommitHref) }><code>{ p.SHA }</code></a></dd>
52+ }
53+ <dt>published</dt>
54+ <dd>{ p.Published }</dd>
55+ </dl>
56+ </header>
57+ <article class="mr-desc prose">@templ.Raw(string(p.Notes))</article>
58+ </section>
5259 <h2>Assets</h2>
5360 if len(p.Assets) == 0 {
5461 <p class="dim">no assets.</p>
@@ -40,15 +40,22 @@ templ Releases(l Layout, p ReleaseListPage) {
40 40
41 templ ReleaseDetail(l Layout, p ReleaseDetailPage) {41 templ ReleaseDetail(l Layout, p ReleaseDetailPage) {
42 @Shell(l, nil) {42 @Shell(l, nil) {
43- <h1>{ p.Title }</h1>43+ <section class="mr-card">
44- <p>44+ <header class="show mr-h">
45- <span class="pill">{ p.Tag }</span> @ReleaseBadges(p.ReleaseRow)45+ <h1>{ p.Title }</h1>
46- if p.SHA != "" {46+ <dl class="kv show-kv">
47- <a href={ templ.SafeURL(p.CommitHref) }><code>{ p.SHA }</code></a>47+ <dt>tag</dt>
48- }48+ <dd><span class="pill">{ p.Tag }</span> @ReleaseBadges(p.ReleaseRow)</dd>
49- <span class="dim">{ p.Published }</span>49+ if p.SHA != "" {
50- </p>50+ <dt>commit</dt>
51- <div class="md">@templ.Raw(string(p.Notes))</div>51+ <dd><a href={ templ.SafeURL(p.CommitHref) }><code>{ p.SHA }</code></a></dd>
52+ }
53+ <dt>published</dt>
54+ <dd>{ p.Published }</dd>
55+ </dl>
56+ </header>
57+ <article class="mr-desc prose">@templ.Raw(string(p.Notes))</article>
58+ </section>
52 <h2>Assets</h2>59 <h2>Assets</h2>
53 if len(p.Assets) == 0 {60 if len(p.Assets) == 0 {
54 <p class="dim">no assets.</p>61 <p class="dim">no assets.</p>
M internal/adapters/web/releases_templ.go +32 -28 generated file: collapsed
M internal/adapters/web/releases_test.go +10 -1
@@ -38,7 +38,7 @@ func newReleaseFixture(t *testing.T) *releaseFixture {
3838 t.Helper()
3939 repo := repoName(t, "proj")
4040 f := &releaseFixture{svc: mocks.NewReleaseService(t), data: "0123456789"}
41- f.published = domain.Release{ID: 1, Repo: repo, Tag: "stable/v1", Title: "First release", Notes: "# Notes\n\n**Ready** [bad](javascript:alert(1)) <script>alert(1)</script>\n\n[guide](guide.md)", TargetCommitish: strings.Repeat("a", 40), PublishedAt: fixedNow, Prerelease: true}
41+ f.published = domain.Release{ID: 1, Repo: repo, Tag: "stable/v1", Title: "First release", Notes: "# Notes\n\n**Ready** [bad](javascript:alert(1)) <script>alert(1)</script>\n\n[guide](guide.md)\n\n```sh\ncurl https://example.com\n```", TargetCommitish: strings.Repeat("a", 40), PublishedAt: fixedNow, Prerelease: true}
4242 f.draft = domain.Release{ID: 2, Repo: repo, Tag: "unpublished", Title: "Secret draft", Draft: true, MakeLatest: true}
4343 f.asset = domain.ReleaseAsset{ID: 7, ReleaseID: 1, Repo: repo, Name: "build.zip", Size: 10, ContentType: "application/zip", Digest: "sha256:" + strings.Repeat("b", 64)}
4444 f.svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal, _ domain.RepoName, ref ports.ReleaseRef) (domain.Release, error) {
@@ -137,6 +137,15 @@ func TestReleasePages(t *testing.T) {
137137 for _, text := range []string{"<strong>Ready</strong>", "build.zip", humanSize(10), "application/zip", `href="/proj/releases/assets/7"`, `href="/proj/tree/stable/v1"`, `href="/proj/commit/` + strings.Repeat("a", 40) + `"`, "view=release"} {
138138 assert.Contains(t, body, text)
139139 }
140+ notes := strings.SplitN(body, `<article class="mr-desc prose">`, 2)
141+ require.Len(t, notes, 2)
142+ notesBody := strings.SplitN(notes[1], "</article>", 2)[0]
143+ assert.Contains(t, notesBody, "<pre")
144+ assert.Contains(t, notesBody, "<code")
145+ assert.Contains(t, notesBody, "curl")
146+ assert.Contains(t, notesBody, "</code></pre>")
147+ assert.Contains(t, notesBody, `<h1 id="md-notes">Notes</h1>`)
148+ assert.Contains(t, body, `<section class="mr-card">`)
140149 assert.NotContains(t, body, "<script>alert")
141150 assert.NotContains(t, body, `href="javascript:`)
142151 assert.Contains(t, body, `href="/proj/blob/stable/v1/guide.md"`)
@@ -38,7 +38,7 @@ func newReleaseFixture(t *testing.T) *releaseFixture {
38 t.Helper()38 t.Helper()
39 repo := repoName(t, "proj")39 repo := repoName(t, "proj")
40 f := &releaseFixture{svc: mocks.NewReleaseService(t), data: "0123456789"}40 f := &releaseFixture{svc: mocks.NewReleaseService(t), data: "0123456789"}
41- f.published = domain.Release{ID: 1, Repo: repo, Tag: "stable/v1", Title: "First release", Notes: "# Notes\n\n**Ready** [bad](javascript:alert(1)) <script>alert(1)</script>\n\n[guide](guide.md)", TargetCommitish: strings.Repeat("a", 40), PublishedAt: fixedNow, Prerelease: true}41+ f.published = domain.Release{ID: 1, Repo: repo, Tag: "stable/v1", Title: "First release", Notes: "# Notes\n\n**Ready** [bad](javascript:alert(1)) <script>alert(1)</script>\n\n[guide](guide.md)\n\n```sh\ncurl https://example.com\n```", TargetCommitish: strings.Repeat("a", 40), PublishedAt: fixedNow, Prerelease: true}
42 f.draft = domain.Release{ID: 2, Repo: repo, Tag: "unpublished", Title: "Secret draft", Draft: true, MakeLatest: true}42 f.draft = domain.Release{ID: 2, Repo: repo, Tag: "unpublished", Title: "Secret draft", Draft: true, MakeLatest: true}
43 f.asset = domain.ReleaseAsset{ID: 7, ReleaseID: 1, Repo: repo, Name: "build.zip", Size: 10, ContentType: "application/zip", Digest: "sha256:" + strings.Repeat("b", 64)}43 f.asset = domain.ReleaseAsset{ID: 7, ReleaseID: 1, Repo: repo, Name: "build.zip", Size: 10, ContentType: "application/zip", Digest: "sha256:" + strings.Repeat("b", 64)}
44 f.svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal, _ domain.RepoName, ref ports.ReleaseRef) (domain.Release, error) {44 f.svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal, _ domain.RepoName, ref ports.ReleaseRef) (domain.Release, error) {
@@ -137,6 +137,15 @@ func TestReleasePages(t *testing.T) {
137 for _, text := range []string{"<strong>Ready</strong>", "build.zip", humanSize(10), "application/zip", `href="/proj/releases/assets/7"`, `href="/proj/tree/stable/v1"`, `href="/proj/commit/` + strings.Repeat("a", 40) + `"`, "view=release"} {137 for _, text := range []string{"<strong>Ready</strong>", "build.zip", humanSize(10), "application/zip", `href="/proj/releases/assets/7"`, `href="/proj/tree/stable/v1"`, `href="/proj/commit/` + strings.Repeat("a", 40) + `"`, "view=release"} {
138 assert.Contains(t, body, text)138 assert.Contains(t, body, text)
139 }139 }
140+ notes := strings.SplitN(body, `<article class="mr-desc prose">`, 2)
141+ require.Len(t, notes, 2)
142+ notesBody := strings.SplitN(notes[1], "</article>", 2)[0]
143+ assert.Contains(t, notesBody, "<pre")
144+ assert.Contains(t, notesBody, "<code")
145+ assert.Contains(t, notesBody, "curl")
146+ assert.Contains(t, notesBody, "</code></pre>")
147+ assert.Contains(t, notesBody, `<h1 id="md-notes">Notes</h1>`)
148+ assert.Contains(t, body, `<section class="mr-card">`)
140 assert.NotContains(t, body, "<script>alert")149 assert.NotContains(t, body, "<script>alert")
141 assert.NotContains(t, body, `href="javascript:`)150 assert.NotContains(t, body, `href="javascript:`)
142 assert.Contains(t, body, `href="/proj/blob/stable/v1/guide.md"`)151 assert.Contains(t, body, `href="/proj/blob/stable/v1/guide.md"`)
M internal/adapters/web/summary.go +21 -0
@@ -68,6 +68,7 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component,
6868
6969 p.Facts = s.facts(d, now)
7070 p.Latest = s.latest(ctx, d)
71+ p.Release = s.summaryRelease(ctx, d.repo.Name, now)
7172 p.Actions = s.recentActions(ctx, d.repo.Name, now)
7273
7374 if rd, err := s.readme(ctx, d, ref); err != nil {
@@ -272,3 +273,23 @@ func (s *Server) listRecentActions(ctx context.Context, repo domain.RepoName, no
272273 }
273274 return a, nil
274275 }
276+
277+// summaryRelease uses the same Latest selection as the releases page. A
278+// missing service or an unavailable release must not prevent repository browsing.
279+func (s *Server) summaryRelease(ctx context.Context, repo domain.RepoName, now time.Time) *LatestRelease {
280+ if s.cfg.Releases == nil {
281+ return nil
282+ }
283+ rel, err := s.cfg.Releases.GetRelease(ctx, principal(ctx), repo, ports.ReleaseRef{Latest: true})
284+ if err != nil {
285+ if !errors.Is(err, domain.ErrNotFound) {
286+ log := zerowrap.FromCtx(ctx)
287+ log.Warn().Err(err).Str("repo", repo.String()).Msg("read the latest release")
288+ }
289+ return nil
290+ }
291+ if rel.Draft || rel.PublishedAt.IsZero() {
292+ return nil
293+ }
294+ return &LatestRelease{Tag: rel.Tag, Href: releaseHref(repo.String(), rel.Tag), When: ago(now, rel.PublishedAt), WhenAt: rfc3339(rel.PublishedAt), AllHref: releasesBase(repo.String())}
295+}
@@ -68,6 +68,7 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component,
68 68
69 p.Facts = s.facts(d, now)69 p.Facts = s.facts(d, now)
70 p.Latest = s.latest(ctx, d)70 p.Latest = s.latest(ctx, d)
71+ p.Release = s.summaryRelease(ctx, d.repo.Name, now)
71 p.Actions = s.recentActions(ctx, d.repo.Name, now)72 p.Actions = s.recentActions(ctx, d.repo.Name, now)
72 73
73 if rd, err := s.readme(ctx, d, ref); err != nil {74 if rd, err := s.readme(ctx, d, ref); err != nil {
@@ -272,3 +273,23 @@ func (s *Server) listRecentActions(ctx context.Context, repo domain.RepoName, no
272 }273 }
273 return a, nil274 return a, nil
274 }275 }
276+
277+// summaryRelease uses the same Latest selection as the releases page. A
278+// missing service or an unavailable release must not prevent repository browsing.
279+func (s *Server) summaryRelease(ctx context.Context, repo domain.RepoName, now time.Time) *LatestRelease {
280+ if s.cfg.Releases == nil {
281+ return nil
282+ }
283+ rel, err := s.cfg.Releases.GetRelease(ctx, principal(ctx), repo, ports.ReleaseRef{Latest: true})
284+ if err != nil {
285+ if !errors.Is(err, domain.ErrNotFound) {
286+ log := zerowrap.FromCtx(ctx)
287+ log.Warn().Err(err).Str("repo", repo.String()).Msg("read the latest release")
288+ }
289+ return nil
290+ }
291+ if rel.Draft || rel.PublishedAt.IsZero() {
292+ return nil
293+ }
294+ return &LatestRelease{Tag: rel.Tag, Href: releaseHref(repo.String(), rel.Tag), When: ago(now, rel.PublishedAt), WhenAt: rfc3339(rel.PublishedAt), AllHref: releasesBase(repo.String())}
295+}
A internal/adapters/web/summary_releases_test.go +49 -0
@@ -0,0 +1,49 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
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"
16+ "bnema.dev/ermite/internal/ports/mocks"
17+)
18+
19+func TestSummaryRelease(t *testing.T) {
20+ repo := repoName(t, "proj")
21+ for _, tc := range []struct {
22+ name string
23+ release domain.Release
24+ err error
25+ visible bool
26+ }{
27+ {name: "published", release: domain.Release{Tag: "v0.9.1", PublishedAt: fixedNow.Add(-6 * time.Minute)}, visible: true},
28+ {name: "none", err: domain.ErrNotFound},
29+ {name: "draft", release: domain.Release{Tag: "secret", Draft: true, PublishedAt: fixedNow}},
30+ } {
31+ t.Run(tc.name, func(t *testing.T) {
32+ svc := mocks.NewReleaseService(t)
33+ svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo, ports.ReleaseRef{Latest: true}).Return(tc.release, tc.err).Once()
34+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t), Releases: svc})
35+ rel := srv.summaryRelease(t.Context(), repo, fixedNow)
36+ out := strings.Join(strings.Fields(renderRail(t, SummaryPage{Latest: &LatestTag{Name: "v1", Count: 1}, Release: rel})), " ")
37+ if !tc.visible {
38+ assert.Nil(t, rel)
39+ assert.NotContains(t, out, "<dt>release</dt>")
40+ return
41+ }
42+ require.NotNil(t, rel)
43+ assert.Contains(t, out, `<a href="/proj/releases/tag/v0.9.1">v0.9.1</a> · <time`)
44+ assert.Contains(t, out, `datetime="`+rfc3339(tc.release.PublishedAt)+`"`)
45+ assert.Contains(t, out, `>`+ago(fixedNow, tc.release.PublishedAt)+`</time> · <a href="/proj/releases">all releases</a>`)
46+ assert.Regexp(t, `(?s)<dt>latest tag</dt>.*<dt>release</dt>`, out)
47+ })
48+ }
49+}
@@ -0,0 +1,49 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
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"
16+ "bnema.dev/ermite/internal/ports/mocks"
17+)
18+
19+func TestSummaryRelease(t *testing.T) {
20+ repo := repoName(t, "proj")
21+ for _, tc := range []struct {
22+ name string
23+ release domain.Release
24+ err error
25+ visible bool
26+ }{
27+ {name: "published", release: domain.Release{Tag: "v0.9.1", PublishedAt: fixedNow.Add(-6 * time.Minute)}, visible: true},
28+ {name: "none", err: domain.ErrNotFound},
29+ {name: "draft", release: domain.Release{Tag: "secret", Draft: true, PublishedAt: fixedNow}},
30+ } {
31+ t.Run(tc.name, func(t *testing.T) {
32+ svc := mocks.NewReleaseService(t)
33+ svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo, ports.ReleaseRef{Latest: true}).Return(tc.release, tc.err).Once()
34+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t), Releases: svc})
35+ rel := srv.summaryRelease(t.Context(), repo, fixedNow)
36+ out := strings.Join(strings.Fields(renderRail(t, SummaryPage{Latest: &LatestTag{Name: "v1", Count: 1}, Release: rel})), " ")
37+ if !tc.visible {
38+ assert.Nil(t, rel)
39+ assert.NotContains(t, out, "<dt>release</dt>")
40+ return
41+ }
42+ require.NotNil(t, rel)
43+ assert.Contains(t, out, `<a href="/proj/releases/tag/v0.9.1">v0.9.1</a> · <time`)
44+ assert.Contains(t, out, `datetime="`+rfc3339(tc.release.PublishedAt)+`"`)
45+ assert.Contains(t, out, `>`+ago(fixedNow, tc.release.PublishedAt)+`</time> · <a href="/proj/releases">all releases</a>`)
46+ assert.Regexp(t, `(?s)<dt>latest tag</dt>.*<dt>release</dt>`, out)
47+ })
48+ }
49+}
M internal/adapters/web/view.go +7 -1
@@ -211,12 +211,18 @@ type SummaryPage struct {
211211 Outline []Heading
212212 AllHref string
213213 // Latest is the newest tag, nil when the repository has none.
214- Latest *LatestTag
214+ Latest *LatestTag
215+ Release *LatestRelease
215216 // Actions is the recent runs of the rail, nil when the viewer cannot read
216217 // CI (or the server runs none).
217218 Actions *RecentActions
218219 }
219220
221+// LatestRelease is the published release selected as Latest by the release service.
222+type LatestRelease struct {
223+ Tag, Href, When, WhenAt, AllHref string
224+}
225+
220226 // RecentActions is the "Recent actions" block of the summary rail: the newest
221227 // runs. With no run the block is in the page but hidden, so that the first
222228 // run reaches it live.
@@ -211,12 +211,18 @@ type SummaryPage struct {
211 Outline []Heading211 Outline []Heading
212 AllHref string212 AllHref string
213 // Latest is the newest tag, nil when the repository has none.213 // Latest is the newest tag, nil when the repository has none.
214- Latest *LatestTag214+ Latest *LatestTag
215+ Release *LatestRelease
215 // Actions is the recent runs of the rail, nil when the viewer cannot read216 // Actions is the recent runs of the rail, nil when the viewer cannot read
216 // CI (or the server runs none).217 // CI (or the server runs none).
217 Actions *RecentActions218 Actions *RecentActions
218 }219 }
219 220
221+// LatestRelease is the published release selected as Latest by the release service.
222+type LatestRelease struct {
223+ Tag, Href, When, WhenAt, AllHref string
224+}
225+
220 // RecentActions is the "Recent actions" block of the summary rail: the newest226 // RecentActions is the "Recent actions" block of the summary rail: the newest
221 // runs. With no run the block is in the page but hidden, so that the first227 // runs. With no run the block is in the page but hidden, so that the first
222 // run reaches it live.228 // run reaches it live.

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 42

↑↓ moveenter openesc close