detached b06e385

fix(web): point the finder of an abbreviated-id page at the full commit

setFinder uses the repository of the layout. The tree page passes the
full id it got from GetTree through the target, and the blob page resolves
an abbreviated id once, so data-paths names the full commit for both and
pinRef is the only place that sets the finder.
commit
b06e3856ed4ccbc91580aca097b7758e5304a196
author
brice · · 2026-10-12 05:10 UTC
parent
c75c5f3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +43 -11
M internal/adapters/web/blob.go +3 -0
@@ -185,6 +185,9 @@ func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, in
185185 }
186186
187187 l := s.repoLayout(ctx, d, "tree")
188+ // A blob stream does not name its commit: an abbreviated id is resolved
189+ // for the finder.
190+ s.resolveCommit(ctx, d.name(), &t)
188191 pinRef(&l, t)
189192 l.Crumbs = pathCrumbs(d.name(), "blob", t)
190193 l.Title = visibleText(t.path) + " · " + d.name() + " · " + s.instance
@@ -185,6 +185,9 @@ func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, in
185 }185 }
186 186
187 l := s.repoLayout(ctx, d, "tree")187 l := s.repoLayout(ctx, d, "tree")
188+ // A blob stream does not name its commit: an abbreviated id is resolved
189+ // for the finder.
190+ s.resolveCommit(ctx, d.name(), &t)
188 pinRef(&l, t)191 pinRef(&l, t)
189 l.Crumbs = pathCrumbs(d.name(), "blob", t)192 l.Crumbs = pathCrumbs(d.name(), "blob", t)
190 l.Title = visibleText(t.path) + " · " + d.name() + " · " + s.instance193 l.Title = visibleText(t.path) + " · " + d.name() + " · " + s.instance
M internal/adapters/web/refs.go +36 -7
@@ -3,9 +3,12 @@
33 package web
44
55 import (
6+ "context"
67 "net/url"
78 "strings"
89
10+ "connectrpc.com/connect"
11+
912 pb "bnema.dev/ermite-api/gen/ermite"
1013 )
1114
@@ -77,6 +80,32 @@ type target struct {
7780 path string
7881 // detached is true when the URL names a commit and not a ref.
7982 detached bool
83+ // commit is the full id of the commit when the page has learnt it from
84+ // the API and rev is an abbreviation; "" otherwise.
85+ commit string
86+}
87+
88+// commitID is the full id of the commit of the target when it is known, and
89+// rev otherwise.
90+func (t target) commitID() string {
91+ if t.commit != "" {
92+ return t.commit
93+ }
94+ return t.rev
95+}
96+
97+// resolveCommit sets the full id of a target named by an abbreviated commit id,
98+// with one ResolveRevision. The page renders without it (it only lacks the file
99+// finder), so a failure is not one.
100+func (s *Server) resolveCommit(ctx context.Context, repo string, t *target) {
101+ if !t.detached || isFullID(t.rev) {
102+ return
103+ }
104+ res, err := s.cfg.Git.ResolveRevision(ctx, connect.NewRequest(&pb.ResolveRevisionRequest{Repo: repo, Revision: t.rev}))
105+ if err != nil {
106+ return
107+ }
108+ t.commit = res.Msg.GetCommitId()
80109 }
81110
82111 // locate reads "<ref>[/<path>]" or "<commit>[/<path>]". The ref is the
@@ -132,14 +161,14 @@ func (d *repoData) isBranchHead(t target) bool {
132161
133162 // pinRef points the ref switcher of the layout at what a page shows: a
134163 // branch or a tag, which is then the current one of the lists, or a commit,
135-// which makes none current.
164+// which makes none current. It points the file finder at the commit too.
136165 func pinRef(l *Layout, t target) {
137166 if t.detached {
138167 l.Ref, l.RefPath, l.Short, l.SHA = "detached", "", short(t.rev), ""
139168 if len(t.rev) == 40 {
140169 l.SHA = t.rev
141170 }
142- l.setFinder(l.Repo, l.SHA, t.rev)
171+ l.setFinder(t.commitID(), t.rev)
143172 for i := range l.Heads {
144173 l.Heads[i].Current = false
145174 }
@@ -149,7 +178,7 @@ func pinRef(l *Layout, t target) {
149178 return
150179 }
151180 l.Ref, l.RefPath, l.SHA, l.Short = t.name, t.name, t.rev, short(t.rev)
152- l.setFinder(l.Repo, t.rev, t.name)
181+ l.setFinder(t.rev, t.name)
153182 for i := range l.Heads {
154183 l.Heads[i].Current = l.Heads[i].Name == t.name
155184 }
@@ -162,13 +191,13 @@ func pinRef(l *Layout, t target) {
162191 // is a full commit id, and the blob pages of ref (a ref name or a commit id)
163192 // its results open. Without a full id there is nothing to search and the
164193 // page has no finder data.
165-func (l *Layout) setFinder(repo, sha, ref string) {
194+func (l *Layout) setFinder(sha, ref string) {
166195 l.PathsURL, l.BlobBase = "", ""
167- if repo == "" || ref == "" || !isFullID(sha) {
196+ if l.Repo == "" || ref == "" || !isFullID(sha) {
168197 return
169198 }
170- l.PathsURL = repoHref(repo, "paths", sha)
171- l.BlobBase = repoHref(repo, "blob", ref)
199+ l.PathsURL = repoHref(l.Repo, "paths", sha)
200+ l.BlobBase = repoHref(l.Repo, "blob", ref)
172201 }
173202
174203 func hasName(items []RefItem, name string) bool {
@@ -3,9 +3,12 @@
3 package web3 package web
4 4
5 import (5 import (
6+ "context"
6 "net/url"7 "net/url"
7 "strings"8 "strings"
8 9
10+ "connectrpc.com/connect"
11+
9 pb "bnema.dev/ermite-api/gen/ermite"12 pb "bnema.dev/ermite-api/gen/ermite"
10 )13 )
11 14
@@ -77,6 +80,32 @@ type target struct {
77 path string80 path string
78 // detached is true when the URL names a commit and not a ref.81 // detached is true when the URL names a commit and not a ref.
79 detached bool82 detached bool
83+ // commit is the full id of the commit when the page has learnt it from
84+ // the API and rev is an abbreviation; "" otherwise.
85+ commit string
86+}
87+
88+// commitID is the full id of the commit of the target when it is known, and
89+// rev otherwise.
90+func (t target) commitID() string {
91+ if t.commit != "" {
92+ return t.commit
93+ }
94+ return t.rev
95+}
96+
97+// resolveCommit sets the full id of a target named by an abbreviated commit id,
98+// with one ResolveRevision. The page renders without it (it only lacks the file
99+// finder), so a failure is not one.
100+func (s *Server) resolveCommit(ctx context.Context, repo string, t *target) {
101+ if !t.detached || isFullID(t.rev) {
102+ return
103+ }
104+ res, err := s.cfg.Git.ResolveRevision(ctx, connect.NewRequest(&pb.ResolveRevisionRequest{Repo: repo, Revision: t.rev}))
105+ if err != nil {
106+ return
107+ }
108+ t.commit = res.Msg.GetCommitId()
80 }109 }
81 110
82 // locate reads "<ref>[/<path>]" or "<commit>[/<path>]". The ref is the111 // locate reads "<ref>[/<path>]" or "<commit>[/<path>]". The ref is the
@@ -132,14 +161,14 @@ func (d *repoData) isBranchHead(t target) bool {
132 161
133 // pinRef points the ref switcher of the layout at what a page shows: a162 // 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,163 // branch or a tag, which is then the current one of the lists, or a commit,
135-// which makes none current.164+// which makes none current. It points the file finder at the commit too.
136 func pinRef(l *Layout, t target) {165 func pinRef(l *Layout, t target) {
137 if t.detached {166 if t.detached {
138 l.Ref, l.RefPath, l.Short, l.SHA = "detached", "", short(t.rev), ""167 l.Ref, l.RefPath, l.Short, l.SHA = "detached", "", short(t.rev), ""
139 if len(t.rev) == 40 {168 if len(t.rev) == 40 {
140 l.SHA = t.rev169 l.SHA = t.rev
141 }170 }
142- l.setFinder(l.Repo, l.SHA, t.rev)171+ l.setFinder(t.commitID(), t.rev)
143 for i := range l.Heads {172 for i := range l.Heads {
144 l.Heads[i].Current = false173 l.Heads[i].Current = false
145 }174 }
@@ -149,7 +178,7 @@ func pinRef(l *Layout, t target) {
149 return178 return
150 }179 }
151 l.Ref, l.RefPath, l.SHA, l.Short = t.name, t.name, t.rev, short(t.rev)180 l.Ref, l.RefPath, l.SHA, l.Short = t.name, t.name, t.rev, short(t.rev)
152- l.setFinder(l.Repo, t.rev, t.name)181+ l.setFinder(t.rev, t.name)
153 for i := range l.Heads {182 for i := range l.Heads {
154 l.Heads[i].Current = l.Heads[i].Name == t.name183 l.Heads[i].Current = l.Heads[i].Name == t.name
155 }184 }
@@ -162,13 +191,13 @@ func pinRef(l *Layout, t target) {
162 // is a full commit id, and the blob pages of ref (a ref name or a commit id)191 // is a full commit id, and the blob pages of ref (a ref name or a commit id)
163 // its results open. Without a full id there is nothing to search and the192 // its results open. Without a full id there is nothing to search and the
164 // page has no finder data.193 // page has no finder data.
165-func (l *Layout) setFinder(repo, sha, ref string) {194+func (l *Layout) setFinder(sha, ref string) {
166 l.PathsURL, l.BlobBase = "", ""195 l.PathsURL, l.BlobBase = "", ""
167- if repo == "" || ref == "" || !isFullID(sha) {196+ if l.Repo == "" || ref == "" || !isFullID(sha) {
168 return197 return
169 }198 }
170- l.PathsURL = repoHref(repo, "paths", sha)199+ l.PathsURL = repoHref(l.Repo, "paths", sha)
171- l.BlobBase = repoHref(repo, "blob", ref)200+ l.BlobBase = repoHref(l.Repo, "blob", ref)
172 }201 }
173 202
174 func hasName(items []RefItem, name string) bool {203 func hasName(items []RefItem, name string) bool {
M internal/adapters/web/repo.go +1 -1
@@ -98,7 +98,7 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
9898 def = d.head.GetShortName()
9999 l.Ref, l.RefPath = def, def
100100 l.SHA, l.Short = d.head.GetCommitId(), short(d.head.GetCommitId())
101- l.setFinder(d.name(), l.SHA, def)
101+ l.setFinder(l.SHA, def)
102102 }
103103 mk := func(key, label, href, id string) Section {
104104 return Section{Key: key, Label: label, Href: href, Current: section == id}
@@ -98,7 +98,7 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
98 def = d.head.GetShortName()98 def = d.head.GetShortName()
99 l.Ref, l.RefPath = def, def99 l.Ref, l.RefPath = def, def
100 l.SHA, l.Short = d.head.GetCommitId(), short(d.head.GetCommitId())100 l.SHA, l.Short = d.head.GetCommitId(), short(d.head.GetCommitId())
101- l.setFinder(d.name(), l.SHA, def)101+ l.setFinder(l.SHA, def)
102 }102 }
103 mk := func(key, label, href, id string) Section {103 mk := func(key, label, href, id string) Section {
104 return Section{Key: key, Label: label, Href: href, Current: section == id}104 return Section{Key: key, Label: label, Href: href, Current: section == id}
M internal/adapters/web/tree.go +3 -3
@@ -105,10 +105,10 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in
105105 }
106106
107107 l := s.repoLayout(ctx, d, "tree")
108- pinRef(&l, t)
109108 // The finder searches the commit this page lists, whichever way the URL
110- // spelled it (an abbreviated id included).
111- l.setFinder(d.name(), res.Msg.GetCommitId(), t.name)
109+ // spelled it (an abbreviated id included): the listing knows it.
110+ t.commit = res.Msg.GetCommitId()
111+ pinRef(&l, t)
112112 l.Crumbs = pathCrumbs(d.name(), "tree", t)
113113 if t.path == "" {
114114 l.Title = "tree · " + d.name() + " · " + s.instance
@@ -105,10 +105,10 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in
105 }105 }
106 106
107 l := s.repoLayout(ctx, d, "tree")107 l := s.repoLayout(ctx, d, "tree")
108- pinRef(&l, t)
109 // The finder searches the commit this page lists, whichever way the URL108 // The finder searches the commit this page lists, whichever way the URL
110- // spelled it (an abbreviated id included).109+ // spelled it (an abbreviated id included): the listing knows it.
111- l.setFinder(d.name(), res.Msg.GetCommitId(), t.name)110+ t.commit = res.Msg.GetCommitId()
111+ pinRef(&l, t)
112 l.Crumbs = pathCrumbs(d.name(), "tree", t)112 l.Crumbs = pathCrumbs(d.name(), "tree", t)
113 if t.path == "" {113 if t.path == "" {
114 l.Title = "tree · " + d.name() + " · " + s.instance114 l.Title = "tree · " + d.name() + " · " + s.instance

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 45

↑↓ moveenter openesc close