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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 186 | 186 | ||
| 187 | 187 | l := s.repoLayout(ctx, d, "tree") | |
| 188 | 188 | pinRef(&l, t) | |
| 189 | - | l.Crumbs = fileCrumbs(d.name(), "blob", t) | |
| 189 | + | l.Crumbs = pathCrumbs(d.name(), "blob", t) | |
| 190 | 190 | l.Title = visibleText(t.path) + " · " + d.name() + " · " + s.instance | |
| 191 | 191 | l.Status = Status{ | |
| 192 | 192 | Mode: "blob", Context: p.Name, | |
| @@ -205,15 +205,8 @@ func (s *Server) lastChange(ctx context.Context, repo string, t target) *CommitR | |||
| 205 | 205 | if err != nil || len(res.Msg.GetCommits()) != 1 { | |
| 206 | 206 | return nil | |
| 207 | 207 | } | |
| 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 | |
| 217 | 210 | } | |
| 218 | 211 | ||
| 219 | 212 | // 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.instance | 190 | 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 nil | 206 | 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 { | |||
| 153 | 153 | // person converts a signature. | |
| 154 | 154 | func person(s *pb.Signature, now time.Time) Person { | |
| 155 | 155 | p := Person{Name: visibleText(s.GetName())} | |
| 156 | + | p.When, p.WhenAt = whenOf(now, s.GetTime()) | |
| 156 | 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 | 160 | return p | |
| 160 | 161 | } | |
| @@ -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 p | 160 | return p | ||
| 160 | } | 161 | } | ||
M internal/adapters/web/commits.go +7 -92
| @@ -7,7 +7,6 @@ import ( | |||
| 7 | 7 | "net/http" | |
| 8 | 8 | "net/url" | |
| 9 | 9 | "path" | |
| 10 | - | "strings" | |
| 11 | 10 | ||
| 12 | 11 | "connectrpc.com/connect" | |
| 13 | 12 | "github.com/a-h/templ" | |
| @@ -44,50 +43,6 @@ type CommitsPage struct { | |||
| 44 | 43 | 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 | 46 | // commits lists the history of a ref, newest first, a page at a time, with | |
| 92 | 47 | // an optional path to limit it to. The page token of the API travels in the | |
| 93 | 48 | // "page" query parameter. | |
| @@ -96,7 +51,7 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component, | |||
| 96 | 51 | if err != nil { | |
| 97 | 52 | return nil, 0, err | |
| 98 | 53 | } | |
| 99 | - | sel, ok := d.selectRev(r.PathValue("rest")) | |
| 54 | + | sel, ok := d.locate(r.PathValue("rest")) | |
| 100 | 55 | if !ok { | |
| 101 | 56 | 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 | 84 | l := s.repoLayout(ctx, d, "commits") | |
| 130 | - | s.setRef(&l, sel) | |
| 85 | + | pinRef(&l, sel) | |
| 131 | 86 | if d.isBranchHead(sel) && token == "" && sel.path == "" && len(cs) > 0 { | |
| 132 | 87 | // The first page of a branch announces the commits pushed to it. | |
| 133 | 88 | p.Live = true | |
| 134 | 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 | 92 | if sel.path == "" { | |
| 138 | 93 | l.Title = "commits · " + d.name() + " · " + s.instance | |
| 139 | 94 | } else { | |
| @@ -154,56 +109,16 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component, | |||
| 154 | 109 | 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 | 112 | // fillDays groups the commits of a page by day and lists the refs among them. | |
| 194 | 113 | func (s *Server) fillDays(p *CommitsPage, d *repoData, cs []*pb.Commit) { | |
| 195 | 114 | now := s.cfg.Now() | |
| 196 | 115 | branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags) | |
| 197 | 116 | 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()] | |
| 204 | 120 | day := "unknown date" | |
| 205 | 121 | if t := c.GetCommitter().GetTime(); t.IsValid() { | |
| 206 | - | row.When, row.WhenAt = ago(now, t.AsTime()), rfc3339(t.AsTime()) | |
| 207 | 122 | day = t.AsTime().UTC().Format("2006-01-02") | |
| 208 | 123 | } | |
| 209 | 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 | 138 | // pathLink finds what the path of a history is at the ref: a directory | |
| 224 | 139 | // (tree) or a file (blob). It is nil when the path is not there, a deleted | |
| 225 | 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 | 142 | dir := path.Dir(sel.path) | |
| 228 | 143 | if dir == "." { | |
| 229 | 144 | 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 bool | 43 | 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, with | 46 | // 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 the | 47 | // 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, err | 52 | 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, errNotFound | 56 | 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 = true | 88 | 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.instance | 93 | 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, nil | 109 | 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 directory | 138 | // 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 deleted | 139 | // (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 @@ | |||
| 3 | 3 | package web | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "errors" | |
| 7 | + | "net/http" | |
| 6 | 8 | "testing" | |
| 7 | 9 | ||
| 8 | 10 | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 9 | 12 | ||
| 10 | 13 | pb "bnema.dev/ermite-api/gen/ermite" | |
| 14 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| 11 | 15 | ) | |
| 12 | 16 | ||
| 13 | 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 | 29 | 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")}, | |
| 40 | 34 | } | |
| 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 | + | } | |
| 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 | } | |
| @@ -3,11 +3,15 @@ | |||||
| 3 | package web | 3 | 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_TAG | 29 | 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 string | 33 | + | 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 { | |||
| 318 | 318 | } | |
| 319 | 319 | 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 | - | } | |
| @@ -318,17 +318,3 @@ func liveURL(topic, query string) string { | |||||
| 318 | } | 318 | } | ||
| 319 | return u | 319 | 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) { | |||
| 163 | 163 | empty := &repoData{} | |
| 164 | 164 | _, ok := empty.locate("") | |
| 165 | 165 | 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")) | |
| 169 | 169 | } | |
| 170 | 170 | ||
| 171 | 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 | 173 | require.Len(t, cs, 5) | |
| 174 | 174 | assert.Equal(t, "/demo/tree/feature/x/src", cs[3].Href) | |
| 175 | 175 | assert.Equal(t, "/demo/blob/feature/x/src/a.go", cs[4].Href) | |
| 176 | 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 | 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 | 182 | 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 { | |||
| 64 | 64 | } | |
| 65 | 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 | + | } | |
| @@ -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 | |||
| 37 | 37 | // refsPage is the content of the refs page, which the live updates of the | |
| 38 | 38 | // page render again. | |
| 39 | 39 | 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()) | |
| 42 | 41 | ||
| 43 | 42 | def := d.repo.GetDefaultBranch() | |
| 44 | 43 | 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 the | 37 | // 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 ( | |||
| 7 | 7 | "slices" | |
| 8 | 8 | "sort" | |
| 9 | 9 | "sync" | |
| 10 | + | "time" | |
| 10 | 11 | ||
| 11 | 12 | "connectrpc.com/connect" | |
| 13 | + | "google.golang.org/protobuf/types/known/timestamppb" | |
| 12 | 14 | ||
| 13 | 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 | 78 | 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 | 86 | // name is the repository name. | |
| 80 | 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 | 177 | res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{Repo: repo, Revision: id, PageSize: 1})) | |
| 171 | 178 | var rc refCommit | |
| 172 | 179 | 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() | |
| 179 | 183 | } | |
| 180 | 184 | mu.Lock() | |
| 181 | 185 | out[id] = rc | |
| @@ -186,6 +190,28 @@ func (s *Server) commitsOf(ctx context.Context, repo string, refs []*pb.Ref) map | |||
| 186 | 190 | 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 | 215 | // namesAt maps commit ids to the names of the refs that designate them. | |
| 190 | 216 | func namesAt(refs []*pb.Ref) map[string][]string { | |
| 191 | 217 | 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, nil | 78 | 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 refCommit | 178 | 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] = rc | 185 | out[id] = rc | ||
| @@ -186,6 +190,28 @@ func (s *Server) commitsOf(ctx context.Context, repo string, refs []*pb.Ref) map | |||||
| 186 | return out | 190 | 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, | |||
| 57 | 57 | } | |
| 58 | 58 | branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags) | |
| 59 | 59 | 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 | |
| 68 | 62 | p.Recent = append(p.Recent, row) | |
| 69 | 63 | } | |
| 70 | 64 | ||
| @@ -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 | |||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | 7 | "net/http" | |
| 8 | - | "strings" | |
| 9 | 8 | ||
| 10 | 9 | "connectrpc.com/connect" | |
| 11 | 10 | "github.com/a-h/templ" | |
| @@ -13,111 +12,6 @@ import ( | |||
| 13 | 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 | 15 | // maxFileTargets bounds the files of a directory offered in the ":" list. | |
| 122 | 16 | 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 | 79 | 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 | |
| 191 | 82 | } | |
| 192 | 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 | 99 | pinRef(&l, t) | |
| 209 | - | l.Crumbs = fileCrumbs(d.name(), "tree", t) | |
| 100 | + | l.Crumbs = pathCrumbs(d.name(), "tree", t) | |
| 210 | 101 | if t.path == "" { | |
| 211 | 102 | l.Title = "tree · " + d.name() + " · " + s.instance | |
| 212 | 103 | } 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 = 200 | 16 | 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.instance | 102 | 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) { | |||
| 94 | 94 | return s, nil | |
| 95 | 95 | } | |
| 96 | 96 | ||
| 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. | |
| 100 | 99 | func (s *Server) routes() *http.ServeMux { | |
| 101 | 100 | mux := http.NewServeMux() | |
| 102 | 101 | mux.HandleFunc("GET "+StaticPrefix, s.assets.serve) | |
| @@ -94,9 +94,8 @@ func New(cfg Config) (*Server, error) { | |||||
| 94 | return s, nil | 94 | 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 any | 98 | + | // 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) | ||