detached 06a59c3

refactor(web): share ref resolution and commit rows between pages

The history page had its own copies of the ref lookup, the ref switcher
and the crumbs of the tree and blob pages. They now share target/locate,
pinRef (which also marks the current branch or tag in the switcher: the
history of a tag used to mark the default branch) and pathCrumbs, kept
in refs.go with isCommitID and isBranchHead.

One commitRow builds the row of every list of commits (history, recent
commits, tree rows, last change of a file, refs), and applies visibleText
to author names everywhere.
commit
06a59c314a56ef78472e1e12aeccff1971348957
author
brice · · 2026-10-10 14:24 UTC
parent
52dd2ef
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +232 -278
M internal/adapters/web/blob.go +3 -10
@@ -186,7 +186,7 @@ func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, in
186186
187187 l := s.repoLayout(ctx, d, "tree")
188188 pinRef(&l, t)
189- l.Crumbs = fileCrumbs(d.name(), "blob", t)
189+ l.Crumbs = pathCrumbs(d.name(), "blob", t)
190190 l.Title = visibleText(t.path) + " · " + d.name() + " · " + s.instance
191191 l.Status = Status{
192192 Mode: "blob", Context: p.Name,
@@ -205,15 +205,8 @@ func (s *Server) lastChange(ctx context.Context, repo string, t target) *CommitR
205205 if err != nil || len(res.Msg.GetCommits()) != 1 {
206206 return nil
207207 }
208- c := res.Msg.GetCommits()[0]
209- row := &CommitRow{
210- Short: short(c.GetId()), Subject: visibleText(c.GetSummary()), Author: c.GetAuthor().GetName(),
211- CHref: repoHref(repo, "commit", c.GetId()),
212- }
213- if ts := c.GetCommitter().GetTime(); ts.IsValid() {
214- row.When, row.WhenAt = ago(s.cfg.Now(), ts.AsTime()), rfc3339(ts.AsTime())
215- }
216- return row
208+ row := commitRow(repo, res.Msg.GetCommits()[0], s.cfg.Now())
209+ return &row
217210 }
218211
219212 // isMarkdown says whether a file name is a Markdown file.
@@ -186,7 +186,7 @@ func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, in
186 186
187 l := s.repoLayout(ctx, d, "tree")187 l := s.repoLayout(ctx, d, "tree")
188 pinRef(&l, t)188 pinRef(&l, t)
189- l.Crumbs = fileCrumbs(d.name(), "blob", t)189+ l.Crumbs = pathCrumbs(d.name(), "blob", t)
190 l.Title = visibleText(t.path) + " · " + d.name() + " · " + s.instance190 l.Title = visibleText(t.path) + " · " + d.name() + " · " + s.instance
191 l.Status = Status{191 l.Status = Status{
192 Mode: "blob", Context: p.Name,192 Mode: "blob", Context: p.Name,
@@ -205,15 +205,8 @@ func (s *Server) lastChange(ctx context.Context, repo string, t target) *CommitR
205 if err != nil || len(res.Msg.GetCommits()) != 1 {205 if err != nil || len(res.Msg.GetCommits()) != 1 {
206 return nil206 return nil
207 }207 }
208- c := res.Msg.GetCommits()[0]208+ row := commitRow(repo, res.Msg.GetCommits()[0], s.cfg.Now())
209- row := &CommitRow{209+ return &row
210- Short: short(c.GetId()), Subject: visibleText(c.GetSummary()), Author: c.GetAuthor().GetName(),
211- CHref: repoHref(repo, "commit", c.GetId()),
212- }
213- if ts := c.GetCommitter().GetTime(); ts.IsValid() {
214- row.When, row.WhenAt = ago(s.cfg.Now(), ts.AsTime()), rfc3339(ts.AsTime())
215- }
216- return row
217 }210 }
218 211
219 // isMarkdown says whether a file name is a Markdown file.212 // isMarkdown says whether a file name is a Markdown file.
M internal/adapters/web/commit.go +2 -1
@@ -153,8 +153,9 @@ func (p CommitPage) totals() string {
153153 // person converts a signature.
154154 func person(s *pb.Signature, now time.Time) Person {
155155 p := Person{Name: visibleText(s.GetName())}
156+ p.When, p.WhenAt = whenOf(now, s.GetTime())
156157 if t := s.GetTime(); t.IsValid() {
157- p.When, p.WhenAt, p.Abs = ago(now, t.AsTime()), rfc3339(t.AsTime()), absolute(t.AsTime())
158+ p.Abs = absolute(t.AsTime())
158159 }
159160 return p
160161 }
@@ -153,8 +153,9 @@ func (p CommitPage) totals() string {
153 // person converts a signature.153 // person converts a signature.
154 func person(s *pb.Signature, now time.Time) Person {154 func person(s *pb.Signature, now time.Time) Person {
155 p := Person{Name: visibleText(s.GetName())}155 p := Person{Name: visibleText(s.GetName())}
156+ p.When, p.WhenAt = whenOf(now, s.GetTime())
156 if t := s.GetTime(); t.IsValid() {157 if t := s.GetTime(); t.IsValid() {
157- p.When, p.WhenAt, p.Abs = ago(now, t.AsTime()), rfc3339(t.AsTime()), absolute(t.AsTime())158+ p.Abs = absolute(t.AsTime())
158 }159 }
159 return p160 return p
160 }161 }
M internal/adapters/web/commits.go +7 -92
@@ -7,7 +7,6 @@ import (
77 "net/http"
88 "net/url"
99 "path"
10- "strings"
1110
1211 "connectrpc.com/connect"
1312 "github.com/a-h/templ"
@@ -44,50 +43,6 @@ type CommitsPage struct {
4443 Live bool
4544 }
4645
47-// revSel is what the URL of a history page selects.
48-type revSel struct {
49- rev string // what the API is asked for: a commit id
50- name string // what the page shows
51- path string // the path limit, "" for none
52- // detached is true when a commit id, not a ref, was named.
53- detached bool
54-}
55-
56-// isCommitID reports whether s can be a commit id or an abbreviation of one:
57-// 7 to 40 lowercase hexadecimal digits.
58-func isCommitID(s string) bool {
59- if len(s) < 7 || len(s) > 40 {
60- return false
61- }
62- for _, c := range []byte(s) {
63- if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
64- return false
65- }
66- }
67- return true
68-}
69-
70-// selectRev reads "<ref>[/<path>]" or "<commit>[/<path>]". The ref is the
71-// longest prefix that names a branch or a tag; an empty rest selects the head.
72-func (d *repoData) selectRev(rest string) (revSel, bool) {
73- rest = strings.Trim(rest, "/")
74- if rest == "" {
75- if d.head == nil {
76- return revSel{}, false
77- }
78- return revSel{rev: d.head.GetCommitId(), name: d.head.GetShortName()}, true
79- }
80- all := append(append([]*pb.Ref(nil), d.branches...), d.tags...)
81- if ref, p, ok := resolveRef(all, rest); ok && ref.GetCommitId() != "" {
82- return revSel{rev: ref.GetCommitId(), name: ref.GetShortName(), path: p}, true
83- }
84- first, p, _ := strings.Cut(rest, "/")
85- if isCommitID(first) {
86- return revSel{rev: first, name: first, path: p, detached: true}, true
87- }
88- return revSel{}, false
89-}
90-
9146 // commits lists the history of a ref, newest first, a page at a time, with
9247 // an optional path to limit it to. The page token of the API travels in the
9348 // "page" query parameter.
@@ -96,7 +51,7 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component,
9651 if err != nil {
9752 return nil, 0, err
9853 }
99- sel, ok := d.selectRev(r.PathValue("rest"))
54+ sel, ok := d.locate(r.PathValue("rest"))
10055 if !ok {
10156 return nil, 0, errNotFound
10257 }
@@ -127,13 +82,13 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component,
12782 }
12883
12984 l := s.repoLayout(ctx, d, "commits")
130- s.setRef(&l, sel)
85+ pinRef(&l, sel)
13186 if d.isBranchHead(sel) && token == "" && sel.path == "" && len(cs) > 0 {
13287 // The first page of a branch announces the commits pushed to it.
13388 p.Live = true
13489 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=commits&ref="+url.QueryEscape(sel.name)+"&head="+sel.rev)
13590 }
136- l.Crumbs = commitsCrumbs(d, sel)
91+ l.Crumbs = pathCrumbs(d.name(), "commits", sel)
13792 if sel.path == "" {
13893 l.Title = "commits · " + d.name() + " · " + s.instance
13994 } else {
@@ -154,56 +109,16 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component,
154109 return Commits(l, p), http.StatusOK, nil
155110 }
156111
157-// setRef points the ref switcher of the layout at what the page shows.
158-func (s *Server) setRef(l *Layout, sel revSel) {
159- if sel.detached {
160- l.Ref, l.RefPath, l.Short, l.SHA = "detached", "", short(sel.rev), ""
161- if len(sel.rev) == 40 {
162- l.SHA = sel.rev
163- }
164- return
165- }
166- l.Ref, l.RefPath, l.SHA, l.Short = sel.name, sel.name, sel.rev, short(sel.rev)
167-}
168-
169-// commitsCrumbs is the path in the top bar: repo, commits, ref, then the
170-// directories of the path, each of which links to its tree.
171-func commitsCrumbs(d *repoData, sel revSel) []Crumb {
172- home := repoHref(d.name(), "commits", sel.name)
173- cs := []Crumb{
174- {Label: d.name(), Href: repoHref(d.name())},
175- {Label: "commits", Href: home},
176- {Label: sel.name, Href: home, Current: sel.path == ""},
177- }
178- if sel.path == "" {
179- return cs
180- }
181- segs := strings.Split(sel.path, "/")
182- for i, seg := range segs {
183- last := i == len(segs)-1
184- c := Crumb{Label: visibleText(seg), Current: last}
185- if !last {
186- c.Href = repoHref(d.name(), "tree", sel.name, strings.Join(segs[:i+1], "/"))
187- }
188- cs = append(cs, c)
189- }
190- return cs
191-}
192-
193112 // fillDays groups the commits of a page by day and lists the refs among them.
194113 func (s *Server) fillDays(p *CommitsPage, d *repoData, cs []*pb.Commit) {
195114 now := s.cfg.Now()
196115 branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags)
197116 for i, c := range cs {
198- row := CommitRow{
199- ID: "c-" + short(c.GetId()), Short: short(c.GetId()), Subject: visibleText(c.GetSummary()),
200- CHref: repoHref(d.name(), "commit", c.GetId()),
201- Author: visibleText(c.GetAuthor().GetName()), Merge: len(c.GetParentIds()) > 1,
202- Branches: branchesAt[c.GetId()], Tags: tagsAt[c.GetId()], Cursor: i == 0,
203- }
117+ row := commitRow(d.name(), c, now)
118+ row.ID, row.Cursor = "c-"+row.Short, i == 0
119+ row.Branches, row.Tags = branchesAt[c.GetId()], tagsAt[c.GetId()]
204120 day := "unknown date"
205121 if t := c.GetCommitter().GetTime(); t.IsValid() {
206- row.When, row.WhenAt = ago(now, t.AsTime()), rfc3339(t.AsTime())
207122 day = t.AsTime().UTC().Format("2006-01-02")
208123 }
209124 if n := len(p.Days); n == 0 || p.Days[n-1].Day != day {
@@ -223,7 +138,7 @@ func (s *Server) fillDays(p *CommitsPage, d *repoData, cs []*pb.Commit) {
223138 // pathLink finds what the path of a history is at the ref: a directory
224139 // (tree) or a file (blob). It is nil when the path is not there, a deleted
225140 // file for one, or when the listing fails: the link is a convenience.
226-func (s *Server) pathLink(ctx context.Context, d *repoData, sel revSel) *Link {
141+func (s *Server) pathLink(ctx context.Context, d *repoData, sel target) *Link {
227142 dir := path.Dir(sel.path)
228143 if dir == "." {
229144 dir = ""
@@ -7,7 +7,6 @@ import (
7 "net/http"7 "net/http"
8 "net/url"8 "net/url"
9 "path"9 "path"
10- "strings"
11 10
12 "connectrpc.com/connect"11 "connectrpc.com/connect"
13 "github.com/a-h/templ"12 "github.com/a-h/templ"
@@ -44,50 +43,6 @@ type CommitsPage struct {
44 Live bool43 Live bool
45 }44 }
46 45
47-// revSel is what the URL of a history page selects.
48-type revSel struct {
49- rev string // what the API is asked for: a commit id
50- name string // what the page shows
51- path string // the path limit, "" for none
52- // detached is true when a commit id, not a ref, was named.
53- detached bool
54-}
55-
56-// isCommitID reports whether s can be a commit id or an abbreviation of one:
57-// 7 to 40 lowercase hexadecimal digits.
58-func isCommitID(s string) bool {
59- if len(s) < 7 || len(s) > 40 {
60- return false
61- }
62- for _, c := range []byte(s) {
63- if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
64- return false
65- }
66- }
67- return true
68-}
69-
70-// selectRev reads "<ref>[/<path>]" or "<commit>[/<path>]". The ref is the
71-// longest prefix that names a branch or a tag; an empty rest selects the head.
72-func (d *repoData) selectRev(rest string) (revSel, bool) {
73- rest = strings.Trim(rest, "/")
74- if rest == "" {
75- if d.head == nil {
76- return revSel{}, false
77- }
78- return revSel{rev: d.head.GetCommitId(), name: d.head.GetShortName()}, true
79- }
80- all := append(append([]*pb.Ref(nil), d.branches...), d.tags...)
81- if ref, p, ok := resolveRef(all, rest); ok && ref.GetCommitId() != "" {
82- return revSel{rev: ref.GetCommitId(), name: ref.GetShortName(), path: p}, true
83- }
84- first, p, _ := strings.Cut(rest, "/")
85- if isCommitID(first) {
86- return revSel{rev: first, name: first, path: p, detached: true}, true
87- }
88- return revSel{}, false
89-}
90-
91 // commits lists the history of a ref, newest first, a page at a time, with46 // commits lists the history of a ref, newest first, a page at a time, with
92 // an optional path to limit it to. The page token of the API travels in the47 // an optional path to limit it to. The page token of the API travels in the
93 // "page" query parameter.48 // "page" query parameter.
@@ -96,7 +51,7 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component,
96 if err != nil {51 if err != nil {
97 return nil, 0, err52 return nil, 0, err
98 }53 }
99- sel, ok := d.selectRev(r.PathValue("rest"))54+ sel, ok := d.locate(r.PathValue("rest"))
100 if !ok {55 if !ok {
101 return nil, 0, errNotFound56 return nil, 0, errNotFound
102 }57 }
@@ -127,13 +82,13 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component,
127 }82 }
128 83
129 l := s.repoLayout(ctx, d, "commits")84 l := s.repoLayout(ctx, d, "commits")
130- s.setRef(&l, sel)85+ pinRef(&l, sel)
131 if d.isBranchHead(sel) && token == "" && sel.path == "" && len(cs) > 0 {86 if d.isBranchHead(sel) && token == "" && sel.path == "" && len(cs) > 0 {
132 // The first page of a branch announces the commits pushed to it.87 // The first page of a branch announces the commits pushed to it.
133 p.Live = true88 p.Live = true
134 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=commits&ref="+url.QueryEscape(sel.name)+"&head="+sel.rev)89 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=commits&ref="+url.QueryEscape(sel.name)+"&head="+sel.rev)
135 }90 }
136- l.Crumbs = commitsCrumbs(d, sel)91+ l.Crumbs = pathCrumbs(d.name(), "commits", sel)
137 if sel.path == "" {92 if sel.path == "" {
138 l.Title = "commits · " + d.name() + " · " + s.instance93 l.Title = "commits · " + d.name() + " · " + s.instance
139 } else {94 } else {
@@ -154,56 +109,16 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component,
154 return Commits(l, p), http.StatusOK, nil109 return Commits(l, p), http.StatusOK, nil
155 }110 }
156 111
157-// setRef points the ref switcher of the layout at what the page shows.
158-func (s *Server) setRef(l *Layout, sel revSel) {
159- if sel.detached {
160- l.Ref, l.RefPath, l.Short, l.SHA = "detached", "", short(sel.rev), ""
161- if len(sel.rev) == 40 {
162- l.SHA = sel.rev
163- }
164- return
165- }
166- l.Ref, l.RefPath, l.SHA, l.Short = sel.name, sel.name, sel.rev, short(sel.rev)
167-}
168-
169-// commitsCrumbs is the path in the top bar: repo, commits, ref, then the
170-// directories of the path, each of which links to its tree.
171-func commitsCrumbs(d *repoData, sel revSel) []Crumb {
172- home := repoHref(d.name(), "commits", sel.name)
173- cs := []Crumb{
174- {Label: d.name(), Href: repoHref(d.name())},
175- {Label: "commits", Href: home},
176- {Label: sel.name, Href: home, Current: sel.path == ""},
177- }
178- if sel.path == "" {
179- return cs
180- }
181- segs := strings.Split(sel.path, "/")
182- for i, seg := range segs {
183- last := i == len(segs)-1
184- c := Crumb{Label: visibleText(seg), Current: last}
185- if !last {
186- c.Href = repoHref(d.name(), "tree", sel.name, strings.Join(segs[:i+1], "/"))
187- }
188- cs = append(cs, c)
189- }
190- return cs
191-}
192-
193 // fillDays groups the commits of a page by day and lists the refs among them.112 // fillDays groups the commits of a page by day and lists the refs among them.
194 func (s *Server) fillDays(p *CommitsPage, d *repoData, cs []*pb.Commit) {113 func (s *Server) fillDays(p *CommitsPage, d *repoData, cs []*pb.Commit) {
195 now := s.cfg.Now()114 now := s.cfg.Now()
196 branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags)115 branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags)
197 for i, c := range cs {116 for i, c := range cs {
198- row := CommitRow{117+ row := commitRow(d.name(), c, now)
199- ID: "c-" + short(c.GetId()), Short: short(c.GetId()), Subject: visibleText(c.GetSummary()),118+ row.ID, row.Cursor = "c-"+row.Short, i == 0
200- CHref: repoHref(d.name(), "commit", c.GetId()),119+ row.Branches, row.Tags = branchesAt[c.GetId()], tagsAt[c.GetId()]
201- Author: visibleText(c.GetAuthor().GetName()), Merge: len(c.GetParentIds()) > 1,
202- Branches: branchesAt[c.GetId()], Tags: tagsAt[c.GetId()], Cursor: i == 0,
203- }
204 day := "unknown date"120 day := "unknown date"
205 if t := c.GetCommitter().GetTime(); t.IsValid() {121 if t := c.GetCommitter().GetTime(); t.IsValid() {
206- row.When, row.WhenAt = ago(now, t.AsTime()), rfc3339(t.AsTime())
207 day = t.AsTime().UTC().Format("2006-01-02")122 day = t.AsTime().UTC().Format("2006-01-02")
208 }123 }
209 if n := len(p.Days); n == 0 || p.Days[n-1].Day != day {124 if n := len(p.Days); n == 0 || p.Days[n-1].Day != day {
@@ -223,7 +138,7 @@ func (s *Server) fillDays(p *CommitsPage, d *repoData, cs []*pb.Commit) {
223 // pathLink finds what the path of a history is at the ref: a directory138 // pathLink finds what the path of a history is at the ref: a directory
224 // (tree) or a file (blob). It is nil when the path is not there, a deleted139 // (tree) or a file (blob). It is nil when the path is not there, a deleted
225 // file for one, or when the listing fails: the link is a convenience.140 // file for one, or when the listing fails: the link is a convenience.
226-func (s *Server) pathLink(ctx context.Context, d *repoData, sel revSel) *Link {141+func (s *Server) pathLink(ctx context.Context, d *repoData, sel target) *Link {
227 dir := path.Dir(sel.path)142 dir := path.Dir(sel.path)
228 if dir == "." {143 if dir == "." {
229 dir = ""144 dir = ""
M internal/adapters/web/commits_test.go +42 -24
@@ -3,11 +3,15 @@
33 package web
44
55 import (
6+ "errors"
7+ "net/http"
68 "testing"
79
810 "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
912
1013 pb "bnema.dev/ermite-api/gen/ermite"
14+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
1115 )
1216
1317 func TestIsCommitID(t *testing.T) {
@@ -19,32 +23,46 @@ func TestIsCommitID(t *testing.T) {
1923 }
2024 }
2125
22-func TestSelectRev(t *testing.T) {
26+// The ref switcher marks the ref the page shows, whatever the page: a tag, a
27+// branch other than the default one, or none for a commit.
28+func TestPinRefMarksCurrent(t *testing.T) {
2329 b, tag := pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG
24- d := &repoData{branches: []*pb.Ref{ref(b, "main"), ref(b, "feature/x")}, tags: []*pb.Ref{ref(tag, "v1")}}
25- d.head = d.branches[0]
26- tests := []struct {
27- in string
28- want revSel
29- wantOK bool
30- describe string
31- }{
32- {"", revSel{rev: "c-main", name: "main"}, true, "head"},
33- {"main", revSel{rev: "c-main", name: "main"}, true, "branch"},
34- {"main/docs/a.md", revSel{rev: "c-main", name: "main", path: "docs/a.md"}, true, "path"},
35- {"feature/x/src", revSel{rev: "c-feature/x", name: "feature/x", path: "src"}, true, "ref with a slash"},
36- {"v1", revSel{rev: "c-v1", name: "v1"}, true, "tag"},
37- {"abcdef0123/x.go", revSel{rev: "abcdef0123", name: "abcdef0123", path: "x.go", detached: true}, true, "commit id"},
38- {"nope", revSel{}, false, "unknown"},
39- {"feature", revSel{}, false, "a prefix of a ref is not a ref"},
30+ d := &repoData{
31+ repo: &pb.Repo{Name: "demo", DefaultBranch: "main"},
32+ branches: []*pb.Ref{ref(b, "main"), ref(b, "side")},
33+ tags: []*pb.Ref{ref(tag, "v1")},
4034 }
41- for _, tt := range tests {
42- got, ok := d.selectRev(tt.in)
43- assert.Equal(t, tt.wantOK, ok, tt.describe)
44- if ok {
45- assert.Equal(t, tt.want, got, tt.describe)
35+ d.head = d.branches[0]
36+ s, err := New(Config{
37+ Repos: failing{err: errors.New("no list")}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"),
38+ PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1",
39+ })
40+ require.NoError(t, err)
41+ current := func(items []RefItem) (names []string) {
42+ for _, it := range items {
43+ if it.Current {
44+ names = append(names, it.Name)
45+ }
4646 }
47+ return names
48+ }
49+ for _, tt := range []struct {
50+ rest string
51+ heads, tags []string
52+ ref, short string
53+ }{
54+ {"", []string{"main"}, nil, "main", "c-main"},
55+ {"v1", nil, []string{"v1"}, "v1", "c-v1"},
56+ {"side", []string{"side"}, nil, "side", "c-side"},
57+ {"abcdef0123", nil, nil, "detached", "abcdef0"},
58+ } {
59+ tg, ok := d.locate(tt.rest)
60+ require.True(t, ok, tt.rest)
61+ l := s.repoLayout(t.Context(), d, "commits")
62+ pinRef(&l, tg)
63+ assert.Equal(t, tt.heads, current(l.Heads), tt.rest)
64+ assert.Equal(t, tt.tags, current(l.Tags), tt.rest)
65+ assert.Equal(t, tt.ref, l.Ref, tt.rest)
66+ assert.Equal(t, tt.short, l.Short, tt.rest)
4767 }
48- _, ok := (&repoData{}).selectRev("")
49- assert.False(t, ok, "an empty repository has no history")
5068 }
@@ -3,11 +3,15 @@
3 package web3 package web
4 4
5 import (5 import (
6+ "errors"
7+ "net/http"
6 "testing"8 "testing"
7 9
8 "github.com/stretchr/testify/assert"10 "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
9 12
10 pb "bnema.dev/ermite-api/gen/ermite"13 pb "bnema.dev/ermite-api/gen/ermite"
14+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
11 )15 )
12 16
13 func TestIsCommitID(t *testing.T) {17 func TestIsCommitID(t *testing.T) {
@@ -19,32 +23,46 @@ func TestIsCommitID(t *testing.T) {
19 }23 }
20 }24 }
21 25
22-func TestSelectRev(t *testing.T) {26+// The ref switcher marks the ref the page shows, whatever the page: a tag, a
27+// branch other than the default one, or none for a commit.
28+func TestPinRefMarksCurrent(t *testing.T) {
23 b, tag := pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG29 b, tag := pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG
24- d := &repoData{branches: []*pb.Ref{ref(b, "main"), ref(b, "feature/x")}, tags: []*pb.Ref{ref(tag, "v1")}}30+ d := &repoData{
25- d.head = d.branches[0]31+ repo: &pb.Repo{Name: "demo", DefaultBranch: "main"},
26- tests := []struct {32+ branches: []*pb.Ref{ref(b, "main"), ref(b, "side")},
27- in string33+ tags: []*pb.Ref{ref(tag, "v1")},
28- want revSel
29- wantOK bool
30- describe string
31- }{
32- {"", revSel{rev: "c-main", name: "main"}, true, "head"},
33- {"main", revSel{rev: "c-main", name: "main"}, true, "branch"},
34- {"main/docs/a.md", revSel{rev: "c-main", name: "main", path: "docs/a.md"}, true, "path"},
35- {"feature/x/src", revSel{rev: "c-feature/x", name: "feature/x", path: "src"}, true, "ref with a slash"},
36- {"v1", revSel{rev: "c-v1", name: "v1"}, true, "tag"},
37- {"abcdef0123/x.go", revSel{rev: "abcdef0123", name: "abcdef0123", path: "x.go", detached: true}, true, "commit id"},
38- {"nope", revSel{}, false, "unknown"},
39- {"feature", revSel{}, false, "a prefix of a ref is not a ref"},
40 }34 }
41- for _, tt := range tests {35+ d.head = d.branches[0]
42- got, ok := d.selectRev(tt.in)36+ s, err := New(Config{
43- assert.Equal(t, tt.wantOK, ok, tt.describe)37+ Repos: failing{err: errors.New("no list")}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"),
44- if ok {38+ PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1",
45- assert.Equal(t, tt.want, got, tt.describe)39+ })
40+ require.NoError(t, err)
41+ current := func(items []RefItem) (names []string) {
42+ for _, it := range items {
43+ if it.Current {
44+ names = append(names, it.Name)
45+ }
46 }46 }
47+ return names
48+ }
49+ for _, tt := range []struct {
50+ rest string
51+ heads, tags []string
52+ ref, short string
53+ }{
54+ {"", []string{"main"}, nil, "main", "c-main"},
55+ {"v1", nil, []string{"v1"}, "v1", "c-v1"},
56+ {"side", []string{"side"}, nil, "side", "c-side"},
57+ {"abcdef0123", nil, nil, "detached", "abcdef0"},
58+ } {
59+ tg, ok := d.locate(tt.rest)
60+ require.True(t, ok, tt.rest)
61+ l := s.repoLayout(t.Context(), d, "commits")
62+ pinRef(&l, tg)
63+ assert.Equal(t, tt.heads, current(l.Heads), tt.rest)
64+ assert.Equal(t, tt.tags, current(l.Tags), tt.rest)
65+ assert.Equal(t, tt.ref, l.Ref, tt.rest)
66+ assert.Equal(t, tt.short, l.Short, tt.rest)
47 }67 }
48- _, ok := (&repoData{}).selectRev("")
49- assert.False(t, ok, "an empty repository has no history")
50 }68 }
M internal/adapters/web/events.go +0 -14
@@ -318,17 +318,3 @@ func liveURL(topic, query string) string {
318318 }
319319 return u
320320 }
321-
322-// isBranchHead reports whether sel is a branch at its tip, as opposed to a
323-// tag or a commit id.
324-func (d *repoData) isBranchHead(sel revSel) bool {
325- if sel.detached || !isFullID(sel.rev) {
326- return false
327- }
328- for _, b := range d.branches {
329- if b.GetShortName() == sel.name && b.GetCommitId() == sel.rev {
330- return true
331- }
332- }
333- return false
334-}
@@ -318,17 +318,3 @@ func liveURL(topic, query string) string {
318 }318 }
319 return u319 return u
320 }320 }
321-
322-// isBranchHead reports whether sel is a branch at its tip, as opposed to a
323-// tag or a commit id.
324-func (d *repoData) isBranchHead(sel revSel) bool {
325- if sel.detached || !isFullID(sel.rev) {
326- return false
327- }
328- for _, b := range d.branches {
329- if b.GetShortName() == sel.name && b.GetCommitId() == sel.rev {
330- return true
331- }
332- }
333- return false
334-}
M internal/adapters/web/highlight_test.go +6 -6
@@ -163,20 +163,20 @@ func TestLocate(t *testing.T) {
163163 empty := &repoData{}
164164 _, ok := empty.locate("")
165165 assert.False(t, ok, "an empty repository has no head")
166- require.True(t, looksLikeCommit(strings.Repeat("a", 40)))
167- assert.False(t, looksLikeCommit(strings.Repeat("a", 41)))
168- assert.False(t, looksLikeCommit("abcdef"))
166+ require.True(t, isCommitID(strings.Repeat("a", 40)))
167+ assert.False(t, isCommitID(strings.Repeat("a", 41)))
168+ assert.False(t, isCommitID("abcdef"))
169169 }
170170
171171 func TestFileCrumbs(t *testing.T) {
172- cs := fileCrumbs("demo", "blob", target{name: "feature/x", path: "src/a.go"})
172+ cs := pathCrumbs("demo", "blob", target{name: "feature/x", path: "src/a.go"})
173173 require.Len(t, cs, 5)
174174 assert.Equal(t, "/demo/tree/feature/x/src", cs[3].Href)
175175 assert.Equal(t, "/demo/blob/feature/x/src/a.go", cs[4].Href)
176176 assert.True(t, cs[4].Current)
177- cs = fileCrumbs("demo", "tree", target{name: "main", path: "src"})
177+ cs = pathCrumbs("demo", "tree", target{name: "main", path: "src"})
178178 assert.Equal(t, "/demo/tree/main/src", cs[3].Href)
179- assert.Equal(t, "a<U+202E>b", fileCrumbs("demo", "tree", target{name: "main", path: "a\u202eb"})[3].Label)
179+ assert.Equal(t, "a<U+202E>b", pathCrumbs("demo", "tree", target{name: "main", path: "a\u202eb"})[3].Label)
180180 }
181181
182182 func stripTags(s string) string {
@@ -163,20 +163,20 @@ func TestLocate(t *testing.T) {
163 empty := &repoData{}163 empty := &repoData{}
164 _, ok := empty.locate("")164 _, ok := empty.locate("")
165 assert.False(t, ok, "an empty repository has no head")165 assert.False(t, ok, "an empty repository has no head")
166- require.True(t, looksLikeCommit(strings.Repeat("a", 40)))166+ require.True(t, isCommitID(strings.Repeat("a", 40)))
167- assert.False(t, looksLikeCommit(strings.Repeat("a", 41)))167+ assert.False(t, isCommitID(strings.Repeat("a", 41)))
168- assert.False(t, looksLikeCommit("abcdef"))168+ assert.False(t, isCommitID("abcdef"))
169 }169 }
170 170
171 func TestFileCrumbs(t *testing.T) {171 func TestFileCrumbs(t *testing.T) {
172- cs := fileCrumbs("demo", "blob", target{name: "feature/x", path: "src/a.go"})172+ cs := pathCrumbs("demo", "blob", target{name: "feature/x", path: "src/a.go"})
173 require.Len(t, cs, 5)173 require.Len(t, cs, 5)
174 assert.Equal(t, "/demo/tree/feature/x/src", cs[3].Href)174 assert.Equal(t, "/demo/tree/feature/x/src", cs[3].Href)
175 assert.Equal(t, "/demo/blob/feature/x/src/a.go", cs[4].Href)175 assert.Equal(t, "/demo/blob/feature/x/src/a.go", cs[4].Href)
176 assert.True(t, cs[4].Current)176 assert.True(t, cs[4].Current)
177- cs = fileCrumbs("demo", "tree", target{name: "main", path: "src"})177+ cs = pathCrumbs("demo", "tree", target{name: "main", path: "src"})
178 assert.Equal(t, "/demo/tree/main/src", cs[3].Href)178 assert.Equal(t, "/demo/tree/main/src", cs[3].Href)
179- assert.Equal(t, "a<U+202E>b", fileCrumbs("demo", "tree", target{name: "main", path: "a\u202eb"})[3].Label)179+ assert.Equal(t, "a<U+202E>b", pathCrumbs("demo", "tree", target{name: "main", path: "a\u202eb"})[3].Label)
180 }180 }
181 181
182 func stripTags(s string) string {182 func stripTags(s string) string {
M internal/adapters/web/refs.go +132 -0
@@ -64,3 +64,135 @@ func repoHref(repo string, parts ...string) string {
6464 }
6565 return b.String()
6666 }
67+
68+// target is what the URL of a tree, blob, raw or commits page designates: a
69+// commit, the name it was reached by, and a path in its tree.
70+type target struct {
71+ // rev is what the API is asked for: the id of the commit of a ref, or
72+ // the commit id of the URL itself.
73+ rev string
74+ // name is how the URL spells the ref: a branch or a tag, or the commit id
75+ // when detached.
76+ name string
77+ path string
78+ // detached is true when the URL names a commit and not a ref.
79+ detached bool
80+}
81+
82+// locate reads "<ref>[/<path>]" or "<commit>[/<path>]". The ref is the
83+// longest prefix of the path that is the short name of a branch or a tag
84+// (names may contain "/"); a branch wins over a tag of the same name. An
85+// empty rest designates the head of the repository. Whether the commit or
86+// the path exists is for the API to say.
87+func (d *repoData) locate(rest string) (target, bool) {
88+ rest = strings.Trim(rest, "/")
89+ if rest == "" {
90+ if d.head == nil {
91+ return target{}, false
92+ }
93+ return target{rev: d.head.GetCommitId(), name: d.head.GetShortName()}, true
94+ }
95+ if ref, p, ok := resolveRef(d.allRefs(), rest); ok && ref.GetCommitId() != "" {
96+ return target{rev: ref.GetCommitId(), name: ref.GetShortName(), path: p}, true
97+ }
98+ first, p, _ := strings.Cut(rest, "/")
99+ if isCommitID(first) {
100+ return target{rev: first, name: first, path: p, detached: true}, true
101+ }
102+ return target{}, false
103+}
104+
105+// isCommitID reports whether s can be a commit id or an abbreviation of one
106+// (7 to 40 lowercase hexadecimal digits).
107+func isCommitID(s string) bool {
108+ if len(s) < 7 || len(s) > 40 {
109+ return false
110+ }
111+ for _, c := range []byte(s) {
112+ if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
113+ return false
114+ }
115+ }
116+ return true
117+}
118+
119+// isBranchHead reports whether t is a branch at its tip, as opposed to a tag
120+// or a commit id.
121+func (d *repoData) isBranchHead(t target) bool {
122+ if t.detached || !isFullID(t.rev) {
123+ return false
124+ }
125+ for _, b := range d.branches {
126+ if b.GetShortName() == t.name && b.GetCommitId() == t.rev {
127+ return true
128+ }
129+ }
130+ return false
131+}
132+
133+// pinRef points the ref switcher of the layout at what a page shows: a
134+// branch or a tag, which is then the current one of the lists, or a commit,
135+// which makes none current.
136+func pinRef(l *Layout, t target) {
137+ if t.detached {
138+ l.Ref, l.RefPath, l.Short, l.SHA = "detached", "", short(t.rev), ""
139+ if len(t.rev) == 40 {
140+ l.SHA = t.rev
141+ }
142+ for i := range l.Heads {
143+ l.Heads[i].Current = false
144+ }
145+ for i := range l.Tags {
146+ l.Tags[i].Current = false
147+ }
148+ return
149+ }
150+ l.Ref, l.RefPath, l.SHA, l.Short = t.name, t.name, t.rev, short(t.rev)
151+ for i := range l.Heads {
152+ l.Heads[i].Current = l.Heads[i].Name == t.name
153+ }
154+ for i := range l.Tags {
155+ l.Tags[i].Current = l.Tags[i].Name == t.name && !hasName(l.Heads, t.name)
156+ }
157+}
158+
159+func hasName(items []RefItem, name string) bool {
160+ for _, it := range items {
161+ if it.Name == name {
162+ return true
163+ }
164+ }
165+ return false
166+}
167+
168+// pathCrumbs is the path in the top bar: repo, kind ("tree", "blob" or
169+// "commits"), ref, then the segments of the path. The directories link to
170+// their tree; the last segment links to the file for a blob, and to nothing
171+// for a history.
172+func pathCrumbs(repo, kind string, t target) []Crumb {
173+ home := repoHref(repo, "tree", t.name)
174+ if kind == "commits" {
175+ home = repoHref(repo, "commits", t.name)
176+ }
177+ cs := []Crumb{
178+ {Label: repo, Href: repoHref(repo)},
179+ {Label: kind, Href: home},
180+ {Label: t.name, Href: home, Current: t.path == ""},
181+ }
182+ if t.path == "" {
183+ return cs
184+ }
185+ segs := strings.Split(t.path, "/")
186+ for i, seg := range segs {
187+ last := i == len(segs)-1
188+ c := Crumb{Label: visibleText(seg), Current: last}
189+ switch {
190+ case !last || kind == "tree":
191+ c.Href = repoHref(repo, "tree", t.name, strings.Join(segs[:i+1], "/"))
192+ case kind == "blob":
193+ c.Href = repoHref(repo, "blob", t.name, t.path)
194+ }
195+ cs = append(cs, c)
196+ }
197+ return cs
198+}
@@ -64,3 +64,135 @@ func repoHref(repo string, parts ...string) string {
64 }64 }
65 return b.String()65 return b.String()
66 }66 }
67+
68+// target is what the URL of a tree, blob, raw or commits page designates: a
69+// commit, the name it was reached by, and a path in its tree.
70+type target struct {
71+ // rev is what the API is asked for: the id of the commit of a ref, or
72+ // the commit id of the URL itself.
73+ rev string
74+ // name is how the URL spells the ref: a branch or a tag, or the commit id
75+ // when detached.
76+ name string
77+ path string
78+ // detached is true when the URL names a commit and not a ref.
79+ detached bool
80+}
81+
82+// locate reads "<ref>[/<path>]" or "<commit>[/<path>]". The ref is the
83+// longest prefix of the path that is the short name of a branch or a tag
84+// (names may contain "/"); a branch wins over a tag of the same name. An
85+// empty rest designates the head of the repository. Whether the commit or
86+// the path exists is for the API to say.
87+func (d *repoData) locate(rest string) (target, bool) {
88+ rest = strings.Trim(rest, "/")
89+ if rest == "" {
90+ if d.head == nil {
91+ return target{}, false
92+ }
93+ return target{rev: d.head.GetCommitId(), name: d.head.GetShortName()}, true
94+ }
95+ if ref, p, ok := resolveRef(d.allRefs(), rest); ok && ref.GetCommitId() != "" {
96+ return target{rev: ref.GetCommitId(), name: ref.GetShortName(), path: p}, true
97+ }
98+ first, p, _ := strings.Cut(rest, "/")
99+ if isCommitID(first) {
100+ return target{rev: first, name: first, path: p, detached: true}, true
101+ }
102+ return target{}, false
103+}
104+
105+// isCommitID reports whether s can be a commit id or an abbreviation of one
106+// (7 to 40 lowercase hexadecimal digits).
107+func isCommitID(s string) bool {
108+ if len(s) < 7 || len(s) > 40 {
109+ return false
110+ }
111+ for _, c := range []byte(s) {
112+ if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
113+ return false
114+ }
115+ }
116+ return true
117+}
118+
119+// isBranchHead reports whether t is a branch at its tip, as opposed to a tag
120+// or a commit id.
121+func (d *repoData) isBranchHead(t target) bool {
122+ if t.detached || !isFullID(t.rev) {
123+ return false
124+ }
125+ for _, b := range d.branches {
126+ if b.GetShortName() == t.name && b.GetCommitId() == t.rev {
127+ return true
128+ }
129+ }
130+ return false
131+}
132+
133+// pinRef points the ref switcher of the layout at what a page shows: a
134+// branch or a tag, which is then the current one of the lists, or a commit,
135+// which makes none current.
136+func pinRef(l *Layout, t target) {
137+ if t.detached {
138+ l.Ref, l.RefPath, l.Short, l.SHA = "detached", "", short(t.rev), ""
139+ if len(t.rev) == 40 {
140+ l.SHA = t.rev
141+ }
142+ for i := range l.Heads {
143+ l.Heads[i].Current = false
144+ }
145+ for i := range l.Tags {
146+ l.Tags[i].Current = false
147+ }
148+ return
149+ }
150+ l.Ref, l.RefPath, l.SHA, l.Short = t.name, t.name, t.rev, short(t.rev)
151+ for i := range l.Heads {
152+ l.Heads[i].Current = l.Heads[i].Name == t.name
153+ }
154+ for i := range l.Tags {
155+ l.Tags[i].Current = l.Tags[i].Name == t.name && !hasName(l.Heads, t.name)
156+ }
157+}
158+
159+func hasName(items []RefItem, name string) bool {
160+ for _, it := range items {
161+ if it.Name == name {
162+ return true
163+ }
164+ }
165+ return false
166+}
167+
168+// pathCrumbs is the path in the top bar: repo, kind ("tree", "blob" or
169+// "commits"), ref, then the segments of the path. The directories link to
170+// their tree; the last segment links to the file for a blob, and to nothing
171+// for a history.
172+func pathCrumbs(repo, kind string, t target) []Crumb {
173+ home := repoHref(repo, "tree", t.name)
174+ if kind == "commits" {
175+ home = repoHref(repo, "commits", t.name)
176+ }
177+ cs := []Crumb{
178+ {Label: repo, Href: repoHref(repo)},
179+ {Label: kind, Href: home},
180+ {Label: t.name, Href: home, Current: t.path == ""},
181+ }
182+ if t.path == "" {
183+ return cs
184+ }
185+ segs := strings.Split(t.path, "/")
186+ for i, seg := range segs {
187+ last := i == len(segs)-1
188+ c := Crumb{Label: visibleText(seg), Current: last}
189+ switch {
190+ case !last || kind == "tree":
191+ c.Href = repoHref(repo, "tree", t.name, strings.Join(segs[:i+1], "/"))
192+ case kind == "blob":
193+ c.Href = repoHref(repo, "blob", t.name, t.path)
194+ }
195+ cs = append(cs, c)
196+ }
197+ return cs
198+}
M internal/adapters/web/refspage.go +1 -2
@@ -37,8 +37,7 @@ func (s *Server) refs(ctx context.Context, r *http.Request) (templ.Component, in
3737 // refsPage is the content of the refs page, which the live updates of the
3838 // page render again.
3939 func (s *Server) refsPage(ctx context.Context, d *repoData) RefsPage {
40- all := append(append([]*pb.Ref(nil), d.branches...), d.tags...)
41- commits := s.commitsOf(ctx, d.name(), all)
40+ commits := s.commitsOf(ctx, d.name(), d.allRefs())
4241
4342 def := d.repo.GetDefaultBranch()
4443 p := RefsPage{Default: def}
@@ -37,8 +37,7 @@ func (s *Server) refs(ctx context.Context, r *http.Request) (templ.Component, in
37 // refsPage is the content of the refs page, which the live updates of the37 // refsPage is the content of the refs page, which the live updates of the
38 // page render again.38 // page render again.
39 func (s *Server) refsPage(ctx context.Context, d *repoData) RefsPage {39 func (s *Server) refsPage(ctx context.Context, d *repoData) RefsPage {
40- all := append(append([]*pb.Ref(nil), d.branches...), d.tags...)40+ commits := s.commitsOf(ctx, d.name(), d.allRefs())
41- commits := s.commitsOf(ctx, d.name(), all)
42 41
43 def := d.repo.GetDefaultBranch()42 def := d.repo.GetDefaultBranch()
44 p := RefsPage{Default: def}43 p := RefsPage{Default: def}
M internal/adapters/web/repo.go +32 -6
@@ -7,8 +7,10 @@ import (
77 "slices"
88 "sort"
99 "sync"
10+ "time"
1011
1112 "connectrpc.com/connect"
13+ "google.golang.org/protobuf/types/known/timestamppb"
1214
1315 pb "bnema.dev/ermite-api/gen/ermite"
1416 )
@@ -76,6 +78,11 @@ func (s *Server) loadRepo(ctx context.Context, name string) (*repoData, error) {
7678 return d, nil
7779 }
7880
81+// allRefs is the branches then the tags.
82+func (d *repoData) allRefs() []*pb.Ref {
83+ return append(append([]*pb.Ref(nil), d.branches...), d.tags...)
84+}
85+
7986 // name is the repository name.
8087 func (d *repoData) name() string { return d.repo.GetName() }
8188
@@ -170,12 +177,9 @@ func (s *Server) commitsOf(ctx context.Context, repo string, refs []*pb.Ref) map
170177 res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{Repo: repo, Revision: id, PageSize: 1}))
171178 var rc refCommit
172179 if err == nil && len(res.Msg.GetCommits()) == 1 {
173- c := res.Msg.GetCommits()[0]
174- rc.short, rc.subject = short(c.GetId()), c.GetSummary()
175- if t := c.GetCommitter().GetTime(); t.IsValid() {
176- rc.at = t.GetSeconds()
177- rc.when, rc.whenAt = ago(now, t.AsTime()), rfc3339(t.AsTime())
178- }
180+ row := commitRow(repo, res.Msg.GetCommits()[0], now)
181+ rc.short, rc.subject, rc.when, rc.whenAt = row.Short, row.Subject, row.When, row.WhenAt
182+ rc.at = res.Msg.GetCommits()[0].GetCommitter().GetTime().GetSeconds()
179183 }
180184 mu.Lock()
181185 out[id] = rc
@@ -186,6 +190,28 @@ func (s *Server) commitsOf(ctx context.Context, repo string, refs []*pb.Ref) map
186190 return out
187191 }
188192
193+// whenOf is the relative and the machine form of a time; both are "" when
194+// the time is unknown.
195+func whenOf(now time.Time, ts *timestamppb.Timestamp) (when, at string) {
196+ if !ts.IsValid() {
197+ return "", ""
198+ }
199+ return ago(now, ts.AsTime()), rfc3339(ts.AsTime())
200+}
201+
202+// commitRow is the row every list of commits starts from: the short id, the
203+// subject, the author and the date of the committer, all fit to display. The
204+// caller adds what is specific to its list.
205+func commitRow(repo string, c *pb.Commit, now time.Time) CommitRow {
206+ row := CommitRow{
207+ Short: short(c.GetId()), Subject: visibleText(c.GetSummary()),
208+ CHref: repoHref(repo, "commit", c.GetId()),
209+ Author: visibleText(c.GetAuthor().GetName()), Merge: len(c.GetParentIds()) > 1,
210+ }
211+ row.When, row.WhenAt = whenOf(now, c.GetCommitter().GetTime())
212+ return row
213+}
214+
189215 // namesAt maps commit ids to the names of the refs that designate them.
190216 func namesAt(refs []*pb.Ref) map[string][]string {
191217 m := map[string][]string{}
@@ -7,8 +7,10 @@ import (
7 "slices"7 "slices"
8 "sort"8 "sort"
9 "sync"9 "sync"
10+ "time"
10 11
11 "connectrpc.com/connect"12 "connectrpc.com/connect"
13+ "google.golang.org/protobuf/types/known/timestamppb"
12 14
13 pb "bnema.dev/ermite-api/gen/ermite"15 pb "bnema.dev/ermite-api/gen/ermite"
14 )16 )
@@ -76,6 +78,11 @@ func (s *Server) loadRepo(ctx context.Context, name string) (*repoData, error) {
76 return d, nil78 return d, nil
77 }79 }
78 80
81+// allRefs is the branches then the tags.
82+func (d *repoData) allRefs() []*pb.Ref {
83+ return append(append([]*pb.Ref(nil), d.branches...), d.tags...)
84+}
85+
79 // name is the repository name.86 // name is the repository name.
80 func (d *repoData) name() string { return d.repo.GetName() }87 func (d *repoData) name() string { return d.repo.GetName() }
81 88
@@ -170,12 +177,9 @@ func (s *Server) commitsOf(ctx context.Context, repo string, refs []*pb.Ref) map
170 res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{Repo: repo, Revision: id, PageSize: 1}))177 res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{Repo: repo, Revision: id, PageSize: 1}))
171 var rc refCommit178 var rc refCommit
172 if err == nil && len(res.Msg.GetCommits()) == 1 {179 if err == nil && len(res.Msg.GetCommits()) == 1 {
173- c := res.Msg.GetCommits()[0]180+ row := commitRow(repo, res.Msg.GetCommits()[0], now)
174- rc.short, rc.subject = short(c.GetId()), c.GetSummary()181+ rc.short, rc.subject, rc.when, rc.whenAt = row.Short, row.Subject, row.When, row.WhenAt
175- if t := c.GetCommitter().GetTime(); t.IsValid() {182+ rc.at = res.Msg.GetCommits()[0].GetCommitter().GetTime().GetSeconds()
176- rc.at = t.GetSeconds()
177- rc.when, rc.whenAt = ago(now, t.AsTime()), rfc3339(t.AsTime())
178- }
179 }183 }
180 mu.Lock()184 mu.Lock()
181 out[id] = rc185 out[id] = rc
@@ -186,6 +190,28 @@ func (s *Server) commitsOf(ctx context.Context, repo string, refs []*pb.Ref) map
186 return out190 return out
187 }191 }
188 192
193+// whenOf is the relative and the machine form of a time; both are "" when
194+// the time is unknown.
195+func whenOf(now time.Time, ts *timestamppb.Timestamp) (when, at string) {
196+ if !ts.IsValid() {
197+ return "", ""
198+ }
199+ return ago(now, ts.AsTime()), rfc3339(ts.AsTime())
200+}
201+
202+// commitRow is the row every list of commits starts from: the short id, the
203+// subject, the author and the date of the committer, all fit to display. The
204+// caller adds what is specific to its list.
205+func commitRow(repo string, c *pb.Commit, now time.Time) CommitRow {
206+ row := CommitRow{
207+ Short: short(c.GetId()), Subject: visibleText(c.GetSummary()),
208+ CHref: repoHref(repo, "commit", c.GetId()),
209+ Author: visibleText(c.GetAuthor().GetName()), Merge: len(c.GetParentIds()) > 1,
210+ }
211+ row.When, row.WhenAt = whenOf(now, c.GetCommitter().GetTime())
212+ return row
213+}
214+
189 // namesAt maps commit ids to the names of the refs that designate them.215 // namesAt maps commit ids to the names of the refs that designate them.
190 func namesAt(refs []*pb.Ref) map[string][]string {216 func namesAt(refs []*pb.Ref) map[string][]string {
191 m := map[string][]string{}217 m := map[string][]string{}
M internal/adapters/web/summary.go +2 -8
@@ -57,14 +57,8 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component,
5757 }
5858 branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags)
5959 for i, c := range commits.Msg.GetCommits() {
60- row := CommitRow{
61- Short: short(c.GetId()), Subject: c.GetSummary(), CHref: repoHref(d.name(), "commit", c.GetId()),
62- Author: c.GetAuthor().GetName(), Merge: len(c.GetParentIds()) > 1,
63- Branches: branchesAt[c.GetId()], Tags: tagsAt[c.GetId()], Cursor: i == 0,
64- }
65- if t := c.GetCommitter().GetTime(); t.IsValid() {
66- row.When, row.WhenAt = ago(now, t.AsTime()), rfc3339(t.AsTime())
67- }
60+ row := commitRow(d.name(), c, now)
61+ row.Branches, row.Tags, row.Cursor = branchesAt[c.GetId()], tagsAt[c.GetId()], i == 0
6862 p.Recent = append(p.Recent, row)
6963 }
7064
@@ -57,14 +57,8 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component,
57 }57 }
58 branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags)58 branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags)
59 for i, c := range commits.Msg.GetCommits() {59 for i, c := range commits.Msg.GetCommits() {
60- row := CommitRow{60+ row := commitRow(d.name(), c, now)
61- Short: short(c.GetId()), Subject: c.GetSummary(), CHref: repoHref(d.name(), "commit", c.GetId()),61+ row.Branches, row.Tags, row.Cursor = branchesAt[c.GetId()], tagsAt[c.GetId()], i == 0
62- Author: c.GetAuthor().GetName(), Merge: len(c.GetParentIds()) > 1,
63- Branches: branchesAt[c.GetId()], Tags: tagsAt[c.GetId()], Cursor: i == 0,
64- }
65- if t := c.GetCommitter().GetTime(); t.IsValid() {
66- row.When, row.WhenAt = ago(now, t.AsTime()), rfc3339(t.AsTime())
67- }
68 p.Recent = append(p.Recent, row)62 p.Recent = append(p.Recent, row)
69 }63 }
70 64
M internal/adapters/web/tree.go +3 -112
@@ -5,7 +5,6 @@ package web
55 import (
66 "context"
77 "net/http"
8- "strings"
98
109 "connectrpc.com/connect"
1110 "github.com/a-h/templ"
@@ -13,111 +12,6 @@ import (
1312 pb "bnema.dev/ermite-api/gen/ermite"
1413 )
1514
16-// target is what the URL of a tree, blob or raw page designates: a commit,
17-// the name it was reached by, and a path in its tree.
18-type target struct {
19- // rev is what the API is asked for: the id of the commit of a ref, or
20- // the commit id of the URL itself.
21- rev string
22- // name is how the URL spells the ref: a branch or a tag, or the commit id
23- // when detached.
24- name string
25- path string
26- // detached is true when the URL names a commit and not a ref.
27- detached bool
28-}
29-
30-// locate reads "<ref>[/<path>]" or "<commit>[/<path>]". The ref is the
31-// longest prefix of the path that is the short name of a branch or a tag
32-// (names may contain "/"); a branch wins over a tag of the same name. An
33-// empty rest designates the head of the repository. Whether the commit or
34-// the path exists is for the API to say.
35-func (d *repoData) locate(rest string) (target, bool) {
36- rest = strings.Trim(rest, "/")
37- if rest == "" {
38- if d.head == nil {
39- return target{}, false
40- }
41- return target{rev: d.head.GetCommitId(), name: d.head.GetShortName()}, true
42- }
43- all := append(append([]*pb.Ref(nil), d.branches...), d.tags...)
44- if ref, p, ok := resolveRef(all, rest); ok && ref.GetCommitId() != "" {
45- return target{rev: ref.GetCommitId(), name: ref.GetShortName(), path: p}, true
46- }
47- first, p, _ := strings.Cut(rest, "/")
48- if looksLikeCommit(first) {
49- return target{rev: first, name: first, path: p, detached: true}, true
50- }
51- return target{}, false
52-}
53-
54-// looksLikeCommit reports whether s can be a commit id or an abbreviation of
55-// one (7 to 40 lowercase hexadecimal digits).
56-func looksLikeCommit(s string) bool {
57- if len(s) < 7 || len(s) > 40 {
58- return false
59- }
60- for _, c := range s {
61- if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
62- return false
63- }
64- }
65- return true
66-}
67-
68-// pinRef points the ref switcher of the layout at the ref a page shows.
69-func pinRef(l *Layout, t target) {
70- if t.detached {
71- l.Ref, l.RefPath, l.Short, l.SHA = "detached", "", short(t.rev), ""
72- if len(t.rev) == 40 {
73- l.SHA = t.rev
74- }
75- return
76- }
77- l.Ref, l.RefPath, l.SHA, l.Short = t.name, t.name, t.rev, short(t.rev)
78- for i := range l.Heads {
79- l.Heads[i].Current = l.Heads[i].Name == t.name
80- }
81- for i := range l.Tags {
82- l.Tags[i].Current = l.Tags[i].Name == t.name && !hasName(l.Heads, t.name)
83- }
84-}
85-
86-func hasName(items []RefItem, name string) bool {
87- for _, it := range items {
88- if it.Name == name {
89- return true
90- }
91- }
92- return false
93-}
94-
95-// fileCrumbs is the path in the top bar: repo, kind ("tree" or "blob"), ref,
96-// then the segments of the path. The directories link to their tree.
97-func fileCrumbs(repo, kind string, t target) []Crumb {
98- root := repoHref(repo, "tree", t.name)
99- cs := []Crumb{
100- {Label: repo, Href: repoHref(repo)},
101- {Label: kind, Href: root},
102- {Label: t.name, Href: root, Current: t.path == ""},
103- }
104- if t.path == "" {
105- return cs
106- }
107- segs := strings.Split(t.path, "/")
108- for i, seg := range segs {
109- last := i == len(segs)-1
110- c := Crumb{Label: visibleText(seg), Current: last}
111- if !last || kind == "tree" {
112- c.Href = repoHref(repo, "tree", t.name, strings.Join(segs[:i+1], "/"))
113- } else {
114- c.Href = repoHref(repo, "blob", t.name, t.path)
115- }
116- cs = append(cs, c)
117- }
118- return cs
119-}
120-
12115 // maxFileTargets bounds the files of a directory offered in the ":" list.
12216 const maxFileTargets = 200
12317
@@ -183,11 +77,8 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in
18377 }
18478 }
18579 if c := e.GetLastCommit(); c != nil {
186- row.Subject, row.Author = visibleText(c.GetSummary()), c.GetAuthor().GetName()
187- row.CHref = repoHref(d.name(), "commit", c.GetId())
188- if ts := c.GetCommitter().GetTime(); ts.IsValid() {
189- row.When, row.WhenAt = ago(now, ts.AsTime()), rfc3339(ts.AsTime())
190- }
80+ last := commitRow(d.name(), c, now)
81+ row.Subject, row.Author, row.CHref, row.When, row.WhenAt = last.Subject, last.Author, last.CHref, last.When, last.WhenAt
19182 }
19283 p.Rows = append(p.Rows, row)
19384 }
@@ -206,7 +97,7 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in
20697 }
20798
20899 pinRef(&l, t)
209- l.Crumbs = fileCrumbs(d.name(), "tree", t)
100+ l.Crumbs = pathCrumbs(d.name(), "tree", t)
210101 if t.path == "" {
211102 l.Title = "tree · " + d.name() + " · " + s.instance
212103 } else {
@@ -5,7 +5,6 @@ package web
5 import (5 import (
6 "context"6 "context"
7 "net/http"7 "net/http"
8- "strings"
9 8
10 "connectrpc.com/connect"9 "connectrpc.com/connect"
11 "github.com/a-h/templ"10 "github.com/a-h/templ"
@@ -13,111 +12,6 @@ import (
13 pb "bnema.dev/ermite-api/gen/ermite"12 pb "bnema.dev/ermite-api/gen/ermite"
14 )13 )
15 14
16-// target is what the URL of a tree, blob or raw page designates: a commit,
17-// the name it was reached by, and a path in its tree.
18-type target struct {
19- // rev is what the API is asked for: the id of the commit of a ref, or
20- // the commit id of the URL itself.
21- rev string
22- // name is how the URL spells the ref: a branch or a tag, or the commit id
23- // when detached.
24- name string
25- path string
26- // detached is true when the URL names a commit and not a ref.
27- detached bool
28-}
29-
30-// locate reads "<ref>[/<path>]" or "<commit>[/<path>]". The ref is the
31-// longest prefix of the path that is the short name of a branch or a tag
32-// (names may contain "/"); a branch wins over a tag of the same name. An
33-// empty rest designates the head of the repository. Whether the commit or
34-// the path exists is for the API to say.
35-func (d *repoData) locate(rest string) (target, bool) {
36- rest = strings.Trim(rest, "/")
37- if rest == "" {
38- if d.head == nil {
39- return target{}, false
40- }
41- return target{rev: d.head.GetCommitId(), name: d.head.GetShortName()}, true
42- }
43- all := append(append([]*pb.Ref(nil), d.branches...), d.tags...)
44- if ref, p, ok := resolveRef(all, rest); ok && ref.GetCommitId() != "" {
45- return target{rev: ref.GetCommitId(), name: ref.GetShortName(), path: p}, true
46- }
47- first, p, _ := strings.Cut(rest, "/")
48- if looksLikeCommit(first) {
49- return target{rev: first, name: first, path: p, detached: true}, true
50- }
51- return target{}, false
52-}
53-
54-// looksLikeCommit reports whether s can be a commit id or an abbreviation of
55-// one (7 to 40 lowercase hexadecimal digits).
56-func looksLikeCommit(s string) bool {
57- if len(s) < 7 || len(s) > 40 {
58- return false
59- }
60- for _, c := range s {
61- if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
62- return false
63- }
64- }
65- return true
66-}
67-
68-// pinRef points the ref switcher of the layout at the ref a page shows.
69-func pinRef(l *Layout, t target) {
70- if t.detached {
71- l.Ref, l.RefPath, l.Short, l.SHA = "detached", "", short(t.rev), ""
72- if len(t.rev) == 40 {
73- l.SHA = t.rev
74- }
75- return
76- }
77- l.Ref, l.RefPath, l.SHA, l.Short = t.name, t.name, t.rev, short(t.rev)
78- for i := range l.Heads {
79- l.Heads[i].Current = l.Heads[i].Name == t.name
80- }
81- for i := range l.Tags {
82- l.Tags[i].Current = l.Tags[i].Name == t.name && !hasName(l.Heads, t.name)
83- }
84-}
85-
86-func hasName(items []RefItem, name string) bool {
87- for _, it := range items {
88- if it.Name == name {
89- return true
90- }
91- }
92- return false
93-}
94-
95-// fileCrumbs is the path in the top bar: repo, kind ("tree" or "blob"), ref,
96-// then the segments of the path. The directories link to their tree.
97-func fileCrumbs(repo, kind string, t target) []Crumb {
98- root := repoHref(repo, "tree", t.name)
99- cs := []Crumb{
100- {Label: repo, Href: repoHref(repo)},
101- {Label: kind, Href: root},
102- {Label: t.name, Href: root, Current: t.path == ""},
103- }
104- if t.path == "" {
105- return cs
106- }
107- segs := strings.Split(t.path, "/")
108- for i, seg := range segs {
109- last := i == len(segs)-1
110- c := Crumb{Label: visibleText(seg), Current: last}
111- if !last || kind == "tree" {
112- c.Href = repoHref(repo, "tree", t.name, strings.Join(segs[:i+1], "/"))
113- } else {
114- c.Href = repoHref(repo, "blob", t.name, t.path)
115- }
116- cs = append(cs, c)
117- }
118- return cs
119-}
120-
121 // maxFileTargets bounds the files of a directory offered in the ":" list.15 // maxFileTargets bounds the files of a directory offered in the ":" list.
122 const maxFileTargets = 20016 const maxFileTargets = 200
123 17
@@ -183,11 +77,8 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in
183 }77 }
184 }78 }
185 if c := e.GetLastCommit(); c != nil {79 if c := e.GetLastCommit(); c != nil {
186- row.Subject, row.Author = visibleText(c.GetSummary()), c.GetAuthor().GetName()80+ last := commitRow(d.name(), c, now)
187- row.CHref = repoHref(d.name(), "commit", c.GetId())81+ row.Subject, row.Author, row.CHref, row.When, row.WhenAt = last.Subject, last.Author, last.CHref, last.When, last.WhenAt
188- if ts := c.GetCommitter().GetTime(); ts.IsValid() {
189- row.When, row.WhenAt = ago(now, ts.AsTime()), rfc3339(ts.AsTime())
190- }
191 }82 }
192 p.Rows = append(p.Rows, row)83 p.Rows = append(p.Rows, row)
193 }84 }
@@ -206,7 +97,7 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in
206 }97 }
207 98
208 pinRef(&l, t)99 pinRef(&l, t)
209- l.Crumbs = fileCrumbs(d.name(), "tree", t)100+ l.Crumbs = pathCrumbs(d.name(), "tree", t)
210 if t.path == "" {101 if t.path == "" {
211 l.Title = "tree · " + d.name() + " · " + s.instance102 l.Title = "tree · " + d.name() + " · " + s.instance
212 } else {103 } else {
M internal/adapters/web/web.go +2 -3
@@ -94,9 +94,8 @@ func New(cfg Config) (*Server, error) {
9494 return s, nil
9595 }
9696
97-// routes is the router of the web. The pages of the next phases (tree, blob,
98-// raw, commits, commit, events) are added here; until then they, and any
99-// other path, answer with the 404 page.
97+// routes is the router of the web. Any path that is not a page is the 404
98+// page.
10099 func (s *Server) routes() *http.ServeMux {
101100 mux := http.NewServeMux()
102101 mux.HandleFunc("GET "+StaticPrefix, s.assets.serve)
@@ -94,9 +94,8 @@ func New(cfg Config) (*Server, error) {
94 return s, nil94 return s, nil
95 }95 }
96 96
97-// routes is the router of the web. The pages of the next phases (tree, blob,97+// routes is the router of the web. Any path that is not a page is the 404
98-// raw, commits, commit, events) are added here; until then they, and any98+// page.
99-// other path, answer with the 404 page.
100 func (s *Server) routes() *http.ServeMux {99 func (s *Server) routes() *http.ServeMux {
101 mux := http.NewServeMux()100 mux := http.NewServeMux()
102 mux.HandleFunc("GET "+StaticPrefix, s.assets.serve)101 mux.HandleFunc("GET "+StaticPrefix, s.assets.serve)

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