Commit bf4c721a48

bf4c721a48935ef2dfc848c093e366c6d1e4d416

parent: 4989dbf889

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 22:24 UTC

web: rail and phone More menu render New repository/Settings/Admin/Log out from one list

Closes #271

Layout: unified · split

CHANGELOG.org +6
@@ -6,6 +6,12 @@ anything beyond "replace the binary and restart" is needed.
66
77* Unreleased
88
9- The new-issue form takes labels, milestone and assignee in one step
10 for writers; the watch button names watching, muted and default; a
11 Discussion heading sits before comment threads; the build page's
12 live note says the page updates itself; and the rail and the phone
13 More menu render from one list (#271).
14
915- Empty states on the web state the fact instead of a CLI command, and
1016 drop "yet" on a finished item; the merge request list offers a New
1117 merge request link, a fork link, or a sign-in prompt depending on
internal/web/templates/layout.html +9 −10
@@ -15,33 +15,32 @@
1515
1616<nav class="rail" aria-label="Site">
1717 <a class="brand" href="/" aria-label="{{.Site}} home">{{template "mark"}}<span class="vh">{{.Site}}</span></a>
18 {{$items := railOptItems .}}
1819 {{/* A square is .railopt when the phone rail drops it into the More
19 menu below. The menu repeats those destinations, so the two lists
20 are kept in step by hand: add a square there and add it here. */}}
20 menu below. Every one of those destinations — strip, menu and
21 the foot's own Log out link — renders off $items, so the strip
22 and the menu can never drift out of step. */}}
2123 <ul class="raillist">
2224 {{if .Viewer}}<li>{{template "raillink" dict "Href" "/" "Icon" "home" "Name" "Dashboard" "Current" (eq (str . "Tab") "dashboard")}}</li>{{end}}
2325 <li>{{template "raillink" dict "Href" "/explore" "Icon" "compass" "Name" "Explore" "Current" (eq (str . "Tab") "explore")}}</li>
2426 <li>{{template "raillink" dict "Href" "/search" "Icon" "search" "Name" "Search" "Current" (eq (str . "Tab") "sitesearch")}}</li>
2527 {{if .Viewer}}<li>{{template "raillink" dict "Href" "/notifications" "Icon" "bell" "Name" "Notifications" "Current" (eq (str . "Tab") "notifications") "Count" .Rail.Unread}}</li>
26 <li class="railopt">{{template "raillink" dict "Href" "/new" "Icon" "plus" "Name" "New repository"}}</li>{{end}}
28 <li class="railopt">{{template "raillink" (index $items 0)}}</li>{{end}}
2729 </ul>
2830 <span class="railgap"></span>
2931 <ul class="raillist">
30 {{if .Viewer}}<li class="railopt">{{template "raillink" dict "Href" "/settings" "Icon" "gear" "Name" "Settings" "Current" (eq (str . "Tab") "account")}}</li>{{end}}
31 {{if .Admin}}<li class="railopt">{{template "raillink" dict "Href" "/admin" "Icon" "shield" "Name" "Admin" "Current" (eq (str . "Tab") "admin")}}</li>{{end}}
32 {{if .Viewer}}<li class="railopt">{{template "raillink" (index $items 1)}}</li>
33 {{if (index $items 2).Show}}<li class="railopt">{{template "raillink" (index $items 2)}}</li>{{end}}{{end}}
3234 </ul>
3335 <div class="railfoot">
3436 {{if .Viewer}}<details class="railmore">
3537 <summary class="railicon" aria-label="More" title="More">{{template "icon" "ellipsis"}}<span class="vh">More</span></summary>
3638 <div class="raildrop">
37 <a href="/new">{{template "icon" "plus"}} New repository</a>
38 <a href="/settings">{{template "icon" "gear"}} Settings</a>
39 {{if .Admin}}<a href="/admin">{{template "icon" "shield"}} Admin</a>{{end}}
40 <a href="/logout">{{template "icon" "signout"}} Log out</a>
39 {{range $items}}{{if .Show}}<a href="{{.Href}}">{{template "icon" .Icon}} {{.Name}}</a>{{end}}{{end}}
4140 </div>
4241 </details>
4342 <a class="railuser" href="/{{.Viewer}}" aria-label="Your profile" title="{{.Viewer}}"><span class="avatar">{{initial .Viewer}}</span></a>
44 <a class="railopt railicon" href="/logout" aria-label="Log out" title="Log out">{{template "icon" "signout"}}<span class="vh">Log out</span></a>
43 {{$logout := index $items 3}}<a class="railopt railicon" href="{{$logout.Href}}" aria-label="{{$logout.Name}}" title="{{$logout.Name}}">{{template "icon" $logout.Icon}}<span class="vh">{{$logout.Name}}</span></a>
4544 {{else}}<a class="railicon" href="/login" aria-label="Sign in" title="Sign in">{{template "icon" "person"}}<span class="vh">Sign in</span></a>{{end}}
4645 </div>
4746</nav>
internal/web/web.go +60
@@ -67,6 +67,65 @@ var fullVersion = sync.OnceValue(func() string {
6767// changes that URL and a browser holding a cached copy cannot miss it.
6868var StyleVersion string
6969
70// railItem is one destination the rail's icon strip and the phone
71// "More" menu both render — from this one list, so a destination added
72// here reaches both instead of the two being hand-kept in step (#271).
73type railItem struct {
74 Href string
75 Icon string
76 Name string
77 Current bool
78 Count int64 // unused by railOptItems; present so "raillink" can read it uniformly
79 Show bool
80}
81
82// railField and railBool read a named field off the page value the
83// layout was given — the same reflection str/field already do for the
84// repo header, duplicated narrowly here rather than exported, since
85// railOptItems is their only other caller.
86func railField(v any, name string) string {
87 rv := reflect.ValueOf(v)
88 for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
89 rv = rv.Elem()
90 }
91 if rv.Kind() != reflect.Struct {
92 return ""
93 }
94 f := rv.FieldByName(name)
95 if !f.IsValid() || f.Kind() != reflect.String {
96 return ""
97 }
98 return f.String()
99}
100
101func railBool(v any, name string) bool {
102 rv := reflect.ValueOf(v)
103 for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
104 rv = rv.Elem()
105 }
106 if rv.Kind() != reflect.Struct {
107 return false
108 }
109 f := rv.FieldByName(name)
110 return f.IsValid() && f.Kind() == reflect.Bool && f.Bool()
111}
112
113// railOptItems is the rail's "New repository", "Settings", "Admin" and
114// "Log out" destinations, in the order the rail shows them. v is the
115// page value the layout renders (any page struct that embeds
116// basePage), read by field name since the layout has no single common
117// type for every page.
118func railOptItems(v any) []railItem {
119 tab := railField(v, "Tab")
120 admin := railBool(v, "Admin")
121 return []railItem{
122 {Href: "/new", Icon: "plus", Name: "New repository", Show: true},
123 {Href: "/settings", Icon: "gear", Name: "Settings", Current: tab == "account", Show: true},
124 {Href: "/admin", Icon: "shield", Name: "Admin", Current: tab == "admin", Show: admin},
125 {Href: "/logout", Icon: "signout", Name: "Log out", Show: true},
126 }
127}
128
70129var funcs = template.FuncMap{
71130 "gitbayVersion": func() string { return version() },
72131 "gitbayCommit": func() string { return fullVersion() },
@@ -122,6 +181,7 @@ var funcs = template.FuncMap{
122181 }
123182 return "?"
124183 },
184 "railOptItems": railOptItems,
125185 // str is field, narrowed to strings: missing or non-string fields
126186 // yield "", which comparisons handle without erroring.
127187 "str": func(v any, name string) string {
internal/web/web_test.go +39
@@ -242,3 +242,42 @@ func TestMixedTextLinksAreUnderlined(t *testing.T) {
242242 }
243243 }
244244}
245
246// The main rail and the phone "More" menu render New repository,
247// Settings, Admin and Log out from one list, so adding a destination in
248// one place reaches both (#271).
249func TestRailOptItemsDriveBothRailAndMoreMenu(t *testing.T) {
250 items := railOptItems(struct {
251 Tab string
252 Admin bool
253 }{Tab: "admin", Admin: true})
254 if len(items) != 4 {
255 t.Fatalf("got %d items, want 4 (New repository, Settings, Admin, Log out)", len(items))
256 }
257 if items[2].Name != "Admin" || !items[2].Show {
258 t.Errorf("Admin item: %+v", items[2])
259 }
260 if !items[2].Current {
261 t.Error("Admin item should be Current when Tab is admin")
262 }
263
264 nonAdmin := railOptItems(struct {
265 Tab string
266 Admin bool
267 }{Tab: "account"})
268 if nonAdmin[2].Show {
269 t.Error("Admin item should not Show for a non-admin viewer")
270 }
271 if !nonAdmin[1].Current {
272 t.Error("Settings item should be Current when Tab is account")
273 }
274
275 // TestRailIconsAreLabelled's regex checks a raillink call site for a
276 // literal "Icon" and "Name" argument; a call built off railOptItems
277 // does not match that pattern, so it is checked here instead.
278 for i, it := range items {
279 if it.Icon == "" || it.Name == "" || it.Href == "" {
280 t.Errorf("item %d missing a field: %+v", i, it)
281 }
282 }
283}