Commit 7a781d1ccb

7a781d1ccb8bc55f191bb839fe3d1a360bae0266

parent: 2d315865c1

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-29 05:46 UTC

web: link names to their definitions

The blob view of the indexed tree links each name the index holds: to
its line when defined once, to /{owner}/{repo}/symbols otherwise, with
one lookup for the page's distinct names. The page lists the file's own
symbols. Users, Parity and CHANGELOG describe the index.

Ref #293

Layout: unified · split

.gitbay/wiki/Parity.org +10 −1
@@ -188,6 +188,8 @@ rather than the one the web page shows.
188| one commit with its patch | yes | yes | yes | 188| one commit with its patch | yes | yes | yes |
189| blame | yes | yes | yes | 189| blame | yes | yes | yes |
190| search file contents | yes | yes | yes | 190| search file contents | yes | yes | yes |
191| find a definition (symbols) | yes | yes | no |
192| jump to definition from a file | n/a | yes | no |
191| compare two refs | yes | yes | yes | 193| compare two refs | yes | yes | yes |
192| branches and tags | yes | yes | yes | 194| branches and tags | yes | yes | yes |
193| wiki (read) | yes | yes | yes | 195| wiki (read) | yes | yes | yes |
@@ -323,6 +325,11 @@ now render the way the reference does. go-org is not yet on the corpus.
323| activity feed | yes | yes | yes | 325| activity feed | yes | yes | yes |
324| command reference | yes | no | n/a | 326| command reference | yes | no | n/a |
325 327
328=repo symbols= and =/{owner}/{repo}/symbols= answer from the same
329store query and ranking. Jumping to a definition is a link on a name in
330the blob view, which a terminal has no place for; =repo symbols= with
331the name is its CLI form.
332
326=explore= is the listing without a query; =repo search= is the one with. 333=explore= is the listing without a query; =repo search= is the one with.
327=search= is both plus the title and body of every issue and merge 334=search= is both plus the title and body of every issue and merge
328request the caller can read, over FTS5; the web serves it at =/search= 335request the caller can read, over FTS5; the web serves it at =/search=
@@ -464,6 +471,7 @@ when there is none.
464| worker queues | yes | yes | no | 471| worker queues | yes | yes | no |
465| runners | yes | no | no | 472| runners | yes | no | no |
466| repository list, archive, visibility | yes | no | no | 473| repository list, archive, visibility | yes | no | no |
474| rebuild a symbol index | yes | no | no |
467| audit log | yes | no | no | 475| audit log | yes | no | no |
468| instance statistics | yes | no | no | 476| instance statistics | yes | no | no |
469 477
@@ -489,7 +497,8 @@ key, the other cannot be undone.
489 497
490* Pagination 498* Pagination
491 499
492=issue list=, =mr list=, =repo list=, =feed= and =build list= take 500=issue list=, =mr list=, =repo list=, =feed=, =build list= and =repo
501symbols= take
493=--limit <n>= and =--cursor <c>=. Cursors are opaque; each page carries 502=--limit <n>= and =--cursor <c>=. Cursors are opaque; each page carries
494the next one. Without the flags a list stays as it was, so existing 503the next one. Without the flags a list stays as it was, so existing
495scripts are unchanged — for =build list= that means the newest fifty 504scripts are unchanged — for =build list= that means the newest fifty
.gitbay/wiki/Users.org +42
@@ -242,12 +242,54 @@ gitbay repo settings git-daemon you/project on # expose over git://
242gitbay repo topics add you/project cli forge # free-form tags, shown on the web 242gitbay repo topics add you/project cli forge # free-form tags, shown on the web
243gitbay repo search forge # find repos by name/description/topic 243gitbay repo search forge # find repos by name/description/topic
244gitbay repo grep you/project "some string" # literal git grep over the default branch 244gitbay repo grep you/project "some string" # literal git grep over the default branch
245gitbay repo symbols you/project Dispatch # where a name is defined, from the symbol index
245gitbay repo pin you/project # pin to your web dashboard 246gitbay repo pin you/project # pin to your web dashboard
246gitbay repo unpin you/project 247gitbay repo unpin you/project
247gitbay repo archive you/project # read-only: pushes and issue/MR 248gitbay repo archive you/project # read-only: pushes and issue/MR
248gitbay repo unarchive you/project # writes refused, browsing intact 249gitbay repo unarchive you/project # writes refused, browsing intact
249#+end_src 250#+end_src
250 251
252A push to the default branch queues a symbol index of its tree, built in
253the background after the push completes; a tree that is already indexed
254is not indexed again. =repo symbols <owner/name> [--kind k] <query>=
255lists definitions whose name starts with the query, exact matches
256first, case-sensitive before case-insensitive, as name, kind, path and
257line; =--limit= and =--cursor= page it, and without them it stops at
258200 rows. A Go method is listed as =Type.Method= and also matches on
259=Method=. Only the default branch is indexed: =--ref= accepts another
260ref only when its tree is the indexed one. The index follows read
261access: a repository you cannot read has no symbols either.
262
263What is indexed:
264
265| language | kinds |
266|--------------------------------+-----------------------------------------------------------|
267| Go (parsed) | function, method, type, const, var |
268| Swift | function, class, struct, enum, interface (protocol), type |
269| Rust | function, struct, enum, interface (trait), type, module, const, macro |
270| Python | function, method, class |
271| JavaScript, TypeScript | function, class, interface, type, enum, const |
272| C and C++ headers (=.h=, =.hpp=) | function (prototype), struct, enum, class, type, macro |
273| shell (=.sh=, =.bash=, =.zsh=) | function |
274| org, Markdown | section (a heading) |
275
276Languages other than Go are matched a line at a time, so unusual
277definition shapes are missed. Files over 1 MiB, anything under
278=vendor/= or =node_modules/=, =*_gen.go=, =*.pb.go=, Go files marked
279=Code generated ... DO NOT EDIT.=, and =*.min.js= are skipped. One run
280stops at 200,000 symbols or two minutes and records the index as
281partial; an index that cannot be built is recorded as failed. Either
282record stands for its tree: a push that changes the tree builds again,
283one that does not is left alone. A repository that has not been pushed
284to since the index existed has none until its next push to the default
285branch. =admin symbols reindex <owner/name>= rebuilds one regardless.
286
287On the web, a file viewed at the indexed tree (the default branch's
288head, or any commit with the same tree) links each name the index
289holds: to its definition when there is one, to the results page at
290=/{owner}/{repo}/symbols?q=<name>= when there are several. The file's
291own definitions are listed above its source.
292
251Import from another forge — git data first, then optionally the issue 293Import from another forge — git data first, then optionally the issue
252and PR history from GitHub or any Forgejo instance such as Codeberg 294and PR history from GitHub or any Forgejo instance such as Codeberg
253(issues keep state/labels/comments; PRs land as closed or merged MRs 295(issues keep state/labels/comments; PRs land as closed or merged MRs
CHANGELOG.org +10
@@ -54,6 +54,16 @@ anything beyond "replace the binary and restart" is needed.
54 attributes it emits; MathML written as raw HTML is still stripped. 54 attributes it emits; MathML written as raw HTML is still stripped.
55 Anything outside the subset, and math past 1000 expressions or 55 Anything outside the subset, and math past 1000 expressions or
56 256 KiB of TeX in one document, shows as source (#294). 56 256 KiB of TeX in one document, shows as source (#294).
57- Code navigation. A push or merge to the default branch queues a symbol
58 index of its tree, built by a background worker and keyed by the tree
59 id, so an unchanged tree is not indexed again. =repo symbols
60 <owner/name> <query>= finds definitions by name or prefix, and the
61 blob view links names to their definitions, with a results page at
62 =/{owner}/{repo}/symbols= and a per-file symbol list. Go is parsed;
63 Swift, Rust, Python, JavaScript/TypeScript, C headers, shell, org and
64 Markdown are matched by line. =admin symbols reindex= forces a
65 rebuild. Migration: =symbol_indexes=, =symbols= and
66 =symbol_requests=. (#293)
57 67
58* v1.38.0 — 2026-09-29 68* v1.38.0 — 2026-09-29
59 69
internal/httpd/routes.go +1
@@ -65,6 +65,7 @@ func (s *Server) Routes() []Route {
65 Route{Method: "GET", Pattern: "/{owner}/{repo}/blob/{ref}/{path...}", Handler: s.blob}, 65 Route{Method: "GET", Pattern: "/{owner}/{repo}/blob/{ref}/{path...}", Handler: s.blob},
66 Route{Method: "GET", Pattern: "/{owner}/{repo}/blame/{ref}/{path...}", Handler: s.blame}, 66 Route{Method: "GET", Pattern: "/{owner}/{repo}/blame/{ref}/{path...}", Handler: s.blame},
67 Route{Method: "GET", Pattern: "/{owner}/{repo}/search", Handler: s.search}, 67 Route{Method: "GET", Pattern: "/{owner}/{repo}/search", Handler: s.search},
68 Route{Method: "GET", Pattern: "/{owner}/{repo}/symbols", Handler: s.symbolsPage},
68 Route{Method: "GET", Pattern: "/{owner}/{repo}/milestones", Handler: s.milestones}, 69 Route{Method: "GET", Pattern: "/{owner}/{repo}/milestones", Handler: s.milestones},
69 Route{Method: "GET", Pattern: "/{owner}/{repo}/labels", Handler: s.labels}, 70 Route{Method: "GET", Pattern: "/{owner}/{repo}/labels", Handler: s.labels},
70 Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki", Handler: s.wiki}, 71 Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki", Handler: s.wiki},
internal/httpd/symbols.go added +144
@@ -0,0 +1,144 @@
1package httpd
2
3import (
4 "errors"
5 "html/template"
6 "net/http"
7 "net/url"
8 "regexp"
9 "strconv"
10 "strings"
11
12 "gitbay.org/gitbay/internal/control"
13 "gitbay.org/gitbay/internal/store"
14 "gitbay.org/gitbay/internal/symbols"
15)
16
17// nameSpan is a name token as chroma writes it with classes: n, nx, nf,
18// nc and the rest of the Name family. Keywords, literals and punctuation
19// carry other classes and are never linked.
20var nameSpan = regexp.MustCompile(`<span class="n[a-z]?">([\p{L}_$][\p{L}\p{N}_$]*)</span>`)
21
22// escapePath escapes each segment of a slash-separated path for a URL.
23func escapePath(p string) string {
24 parts := strings.Split(p, "/")
25 for i, s := range parts {
26 parts[i] = url.PathEscape(s)
27 }
28 return strings.Join(parts, "/")
29}
30
31// blobSymbols finds the viewed file's symbols and links the names in its
32// highlighted source to their definitions, when the ref being viewed has
33// the indexed tree. A name defined once links to that line at the same
34// ref; one defined more than once links to the results page. Names the
35// index does not hold stay plain. The whole page costs one lookup of the
36// file's distinct names, however many times each appears.
37func (s *Server) blobSymbols(p repoPage, filePath string, code template.HTML) (template.HTML, []store.SymbolRow) {
38 idx, err := s.st.SymbolIndexFor(p.Repo.ID)
39 if err != nil || idx.State == "failed" || !control.IndexedTree(p.Dir, p.Ref, idx) {
40 return code, nil
41 }
42 list, _ := s.st.SymbolsInFile(idx.ID, filePath)
43 if code == "" {
44 return code, list
45 }
46 seen := map[string]bool{}
47 var keys []string
48 for _, m := range nameSpan.FindAllStringSubmatch(string(code), -1) {
49 if !seen[m[1]] {
50 seen[m[1]] = true
51 keys = append(keys, m[1])
52 }
53 }
54 if len(keys) == 0 {
55 return code, list
56 }
57 targets, err := s.st.SymbolTargets(idx.ID, keys)
58 if err != nil || len(targets) == 0 {
59 return code, list
60 }
61 repoPath := "/" + p.Repo.Path()
62 ref := escapePath(p.Ref)
63 linked := nameSpan.ReplaceAllStringFunc(string(code), func(span string) string {
64 name := nameSpan.FindStringSubmatch(span)[1]
65 t, ok := targets[name]
66 if !ok {
67 return span
68 }
69 href := repoPath + "/symbols?q=" + url.QueryEscape(name)
70 if t.Count == 1 {
71 href = repoPath + "/blob/" + ref + "/" + escapePath(t.Path) + "#L" + strconv.Itoa(t.Line)
72 }
73 return `<a class="sym" href="` + template.HTMLEscapeString(href) + `">` + span + `</a>`
74 })
75 return template.HTML(linked), list
76}
77
78// symbolsPageSize is the results page's page length.
79const symbolsPageSize = 100
80
81// symbolsPage lists the definitions matching a query, ranked as `repo
82// symbols` ranks them, from the same store query.
83func (s *Server) symbolsPage(w http.ResponseWriter, r *http.Request) {
84 p, ok := s.repoFor(w, r, "")
85 if !ok {
86 return
87 }
88 p.Tab = "search"
89 q := strings.TrimSpace(r.URL.Query().Get("q"))
90 kind := r.URL.Query().Get("kind")
91 if !symbols.ValidKind(kind) {
92 kind = ""
93 }
94 var (
95 rows []store.SymbolRow
96 next string
97 problem string
98 note string
99 )
100 idx, err := s.st.SymbolIndexFor(p.Repo.ID)
101 switch {
102 case errors.Is(err, store.ErrNotFound):
103 problem = "no symbol index yet; one is built after a push to " + p.Repo.DefaultBranch
104 case err != nil:
105 http.Error(w, "internal error", http.StatusInternalServerError)
106 return
107 case idx.State == "failed":
108 problem = "the symbol index failed: " + idx.Note
109 case len(q) > 200:
110 problem = "query must be 1 to 200 characters"
111 case q != "":
112 if idx.State == "partial" {
113 note = "the index is partial: " + idx.Note
114 }
115 var after int64
116 if a, err := strconv.ParseInt(r.URL.Query().Get("after"), 10, 64); err == nil && a > 0 {
117 after = a
118 }
119 rows, err = s.st.SearchSymbols(idx.ID, q, kind, symbolsPageSize+1, after)
120 if err != nil {
121 http.Error(w, "internal error", http.StatusInternalServerError)
122 return
123 }
124 if len(rows) > symbolsPageSize {
125 rows = rows[:symbolsPageSize]
126 v := url.Values{"q": {q}, "after": {strconv.FormatInt(rows[len(rows)-1].ID, 10)}}
127 if kind != "" {
128 v.Set("kind", kind)
129 }
130 next = "/" + p.Repo.Path() + "/symbols?" + v.Encode()
131 }
132 }
133 s.render(w, "symbols.html", struct {
134 repoPage
135 Query string
136 Kind string
137 Kinds []string
138 Problem string
139 Note string
140 Rows []store.SymbolRow
141 Next string
142 Commit string
143 }{p, q, kind, symbols.Kinds, problem, note, rows, next, idx.Commit})
144}
internal/httpd/symbols_test.go added +132
@@ -0,0 +1,132 @@
1package httpd
2
3import (
4 "context"
5 "os"
6 "os/exec"
7 "path/filepath"
8 "strings"
9 "testing"
10
11 "gitbay.org/gitbay/internal/config"
12 "gitbay.org/gitbay/internal/control"
13 "gitbay.org/gitbay/internal/store"
14 "gitbay.org/gitbay/internal/symbols"
15)
16
17func symbolGit(t *testing.T, dir string, args ...string) {
18 t.Helper()
19 cmd := exec.Command("git", args...)
20 cmd.Dir = dir
21 cmd.Env = append(os.Environ(),
22 "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null",
23 "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test",
24 "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test")
25 if out, err := cmd.CombinedOutput(); err != nil {
26 t.Fatalf("git %v: %v\n%s", args, err, out)
27 }
28}
29
30// symbolServer serves alice/app (public) and alice/secret (private), each
31// with main indexed and a side branch whose tree differs.
32func symbolServer(t *testing.T) *Server {
33 t.Helper()
34 st, err := store.Open(":memory:")
35 if err != nil {
36 t.Fatal(err)
37 }
38 t.Cleanup(func() { st.Close() })
39 if err := st.MigrateUp(); err != nil {
40 t.Fatal(err)
41 }
42 uid, err := st.CreateUser("alice", false)
43 if err != nil {
44 t.Fatal(err)
45 }
46 root := t.TempDir()
47 src := filepath.Join(root, "src")
48 symbolGit(t, root, "init", "-q", "-b", "main", "src")
49 files := map[string]string{
50 "main.go": "package main\n\nfunc main() { helper(); dup(); unknown() }\n\nfunc helper() {}\n",
51 "a/dup.go": "package a\n\nfunc dup() {}\n",
52 "b/dup.go": "package b\n\nfunc dup() {}\n",
53 }
54 for name, body := range files {
55 os.MkdirAll(filepath.Join(src, filepath.Dir(name)), 0o755)
56 os.WriteFile(filepath.Join(src, name), []byte(body), 0o644)
57 }
58 symbolGit(t, src, "add", ".")
59 symbolGit(t, src, "commit", "-q", "-m", "base")
60 symbolGit(t, src, "checkout", "-q", "-b", "side")
61 os.WriteFile(filepath.Join(src, "side.go"), []byte("package main\n"), 0o644)
62 symbolGit(t, src, "add", ".")
63 symbolGit(t, src, "commit", "-q", "-m", "side")
64
65 w := &symbols.Worker{St: st, RepoDir: func(owner, name string) string { return control.RepoDir(root, owner, name) },
66 MaxSymbols: symbols.DefaultMaxSymbols, MaxTime: symbols.DefaultMaxTime}
67 for _, r := range []struct{ name, vis string }{{"app", "public"}, {"secret", "private"}} {
68 id, err := st.CreateRepo("user", uid, r.name, r.vis)
69 if err != nil {
70 t.Fatal(err)
71 }
72 dir := control.RepoDir(root, "alice", r.name)
73 os.MkdirAll(filepath.Dir(dir), 0o755)
74 symbolGit(t, root, "init", "-q", "--bare", dir)
75 symbolGit(t, src, "push", "-q", dir, "main", "side")
76 if err := w.Index(context.Background(), id, false); err != nil {
77 t.Fatal(err)
78 }
79 }
80 cfg := config.Default()
81 cfg.Server.Root = root
82 cfg.Server.SiteURL = "https://forge.test/"
83 return New(cfg, st, nil)
84}
85
86func TestBlobLinksIndexedNames(t *testing.T) {
87 h := symbolServer(t).Handler()
88 w := get(t, h, "/alice/app/blob/main/main.go", nil)
89 if w.Code != 200 {
90 t.Fatalf("blob: %d", w.Code)
91 }
92 body := w.Body.String()
93 for _, want := range []string{
94 // defined once: straight to the line, at the ref being viewed
95 `<a class="sym" href="/alice/app/blob/main/main.go#L5"><span class="nf">helper</span></a>`,
96 // defined twice: to the results page
97 `<a class="sym" href="/alice/app/symbols?q=dup"><span class="nf">dup</span></a>`,
98 // the per-file list
99 `2 symbols in this file`,
100 } {
101 if !strings.Contains(body, want) {
102 t.Errorf("blob page lacks %s", want)
103 }
104 }
105 if strings.Contains(body, `>unknown</span></a>`) {
106 t.Error("a name the index does not hold is linked")
107 }
108
109 // A branch at another tree is not what the index describes.
110 w = get(t, h, "/alice/app/blob/side/main.go", nil)
111 if w.Code != 200 || strings.Contains(w.Body.String(), `class="sym"`) || strings.Contains(w.Body.String(), "in this file") {
112 t.Errorf("side branch: %d, links or list present", w.Code)
113 }
114}
115
116func TestSymbolsPage(t *testing.T) {
117 h := symbolServer(t).Handler()
118 w := get(t, h, "/alice/app/symbols?q=dup", nil)
119 body := w.Body.String()
120 if w.Code != 200 || !strings.Contains(body, "a/dup.go:3") || !strings.Contains(body, "b/dup.go:3") {
121 t.Fatalf("results page: %d\n%s", w.Code, body)
122 }
123 w = get(t, h, "/alice/app/symbols?q=dup&kind=type", nil)
124 if strings.Contains(w.Body.String(), "a/dup.go:3") {
125 t.Error("kind filter ignored")
126 }
127 for _, p := range []string{"/alice/secret/symbols?q=dup", "/alice/secret/blob/main/main.go"} {
128 if w := get(t, h, p, nil); w.Code != 404 {
129 t.Errorf("%s anonymously: %d, want 404", p, w.Code)
130 }
131 }
132}
internal/httpd/web.go +6 −1
@@ -706,6 +706,10 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
706 if !binary && !image { 706 if !binary && !image {
707 codeHTML = highlight(filePath, data) 707 codeHTML = highlight(filePath, data)
708 } 708 }
709 var fileSymbols []store.SymbolRow
710 if !binary && !image {
711 codeHTML, fileSymbols = s.blobSymbols(p, filePath, codeHTML)
712 }
709 // Markdown and org render like a README, with the source one click 713 // Markdown and org render like a README, with the source one click
710 // away; ?view=source shows the text instead. 714 // away; ?view=source shows the text instead.
711 renderable := markupFile(filePath) && !binary 715 renderable := markupFile(filePath) && !binary
@@ -752,8 +756,9 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
752 Rendered bool // this response shows the rendering 756 Rendered bool // this response shows the rendering
753 RenderedHTML template.HTML 757 RenderedHTML template.HTML
754 Nav fileNav 758 Nav fileNav
759 Symbols []store.SymbolRow
755 }{p, cs, base, filePath, filePath, "blob", binary, image, len(data), lines, 760 }{p, cs, base, filePath, filePath, "blob", binary, image, len(data), lines,
756 entry.Mode == "100755", entry.Mode == "120000", branches, codeHTML, renderable, rendered, renderedHTML, nav}) 761 entry.Mode == "100755", entry.Mode == "120000", branches, codeHTML, renderable, rendered, renderedHTML, nav, fileSymbols})
757} 762}
758 763
759// releases lists tag-anchored releases with notes and assets. 764// releases lists tag-anchored releases with notes and assets.
internal/web/static/style.css +8
@@ -1101,6 +1101,14 @@ article.release { padding: var(--sp-4); margin: var(--sp-4) 0; }
1101ul.matchlist { list-style: none; margin: var(--sp-3) 0; padding: 0; } 1101ul.matchlist { list-style: none; margin: var(--sp-3) 0; padding: 0; }
1102ul.matchlist li { margin-bottom: var(--sp-3); } 1102ul.matchlist li { margin-bottom: var(--sp-3); }
1103.matchpath { font-family: var(--mono); font-size: var(--fs-1); } 1103.matchpath { font-family: var(--mono); font-size: var(--fs-1); }
1104/* a name in highlighted source that links to its definition keeps its
1105 token colour; the dotted underline is what marks it as a link */
1106.chroma a.sym { color: inherit; text-decoration: underline dotted; text-underline-offset: 2px; }
1107.chroma a.sym:hover { text-decoration-style: solid; }
1108details.symlist { margin: 0 0 var(--sp-3); font-size: var(--fs-1); }
1109details.symlist summary { cursor: pointer; color: var(--muted); }
1110details.symlist ul { list-style: none; margin: var(--sp-2) 0 0; padding: 0; columns: 16rem; }
1111details.symlist li { break-inside: avoid; padding: 2px 0; }
1104pre.matchline { 1112pre.matchline {
1105 background: var(--inset); 1113 background: var(--inset);
1106 border: 1px solid var(--line); 1114 border: 1px solid var(--line);
internal/web/templates/blob.html +4
@@ -12,6 +12,10 @@
12 <span class="actions">{{if .Renderable}}{{if .Rendered}}<strong>rendered</strong> · <a href="?view=source">source</a>{{else}}<a href="?">rendered</a> · <strong>source</strong>{{end}} · {{end}}<a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log/{{.Ref}}?path={{.Path}}">history</a> · {{if not .Binary}}<a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/blame/{{.Ref}}/{{.Path}}">blame</a> · {{end}}<a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">raw</a>{{if .Viewer}} · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/edit/{{.Ref}}/{{.Path}}">edit</a>{{end}}</span> 12 <span class="actions">{{if .Renderable}}{{if .Rendered}}<strong>rendered</strong> · <a href="?view=source">source</a>{{else}}<a href="?">rendered</a> · <strong>source</strong>{{end}} · {{end}}<a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log/{{.Ref}}?path={{.Path}}">history</a> · {{if not .Binary}}<a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/blame/{{.Ref}}/{{.Path}}">blame</a> · {{end}}<a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">raw</a>{{if .Viewer}} · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/edit/{{.Ref}}/{{.Path}}">edit</a>{{end}}</span>
13</div> 13</div>
14<p class="filefacts">{{if .Lines}}{{.Lines}} lines · {{end}}{{.Size}} bytes{{if .Exec}} · executable{{end}}{{if .Symlink}} · symlink{{end}}</p> 14<p class="filefacts">{{if .Lines}}{{.Lines}} lines · {{end}}{{.Size}} bytes{{if .Exec}} · executable{{end}}{{if .Symlink}} · symlink{{end}}</p>
15{{if .Symbols}}<details class="symlist"><summary>{{len .Symbols}} symbol{{if ne (len .Symbols) 1}}s{{end}} in this file</summary>
16<ul>{{range .Symbols}}<li><a href="{{if $.Rendered}}?view=source{{end}}#L{{.Line}}"><code>{{.Name}}</code></a> <span class="meta">{{.Kind}}, line {{.Line}}</span></li>
17{{end}}</ul>
18</details>{{end}}
15{{if .Image}}<div class="blobimage"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}"><img src="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}" alt="{{.Base}}"></a><p class="meta">{{.Size}} bytes</p></div> 19{{if .Image}}<div class="blobimage"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}"><img src="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}" alt="{{.Base}}"></a><p class="meta">{{.Size}} bytes</p></div>
16{{else if .Binary}}<p class="empty-note">binary file, {{.Size}} bytes — <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">download</a></p> 20{{else if .Binary}}<p class="empty-note">binary file, {{.Size}} bytes — <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">download</a></p>
17{{else if .Rendered}}<section class="readme"><div class="rendered">{{.RenderedHTML}}</div></section> 21{{else if .Rendered}}<section class="readme"><div class="rendered">{{.RenderedHTML}}</div></section>
internal/web/templates/symbols.html added +32
@@ -0,0 +1,32 @@
1{{define "width"}}wide{{end}}
2{{define "title"}}symbols · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
3{{define "content"}}
4<h1>Symbols</h1>
5<form method="get" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/symbols" class="searchform">
6 <input type="search" name="q" aria-label="Find a definition" value="{{.Query}}" placeholder="a name or its start, on {{.Repo.DefaultBranch}}">
7 <select name="kind" aria-label="Kind">
8 <option value="">any kind</option>
9 {{range .Kinds}}<option value="{{.}}"{{if eq . $.Kind}} selected{{end}}>{{.}}</option>
10 {{end}}
11 </select>
12 <button type="submit" class="btn">Find</button>
13</form>
14{{if .Problem}}<p class="empty-note">{{.Problem}}</p>
15{{else if .Query}}
16{{if .Note}}<p class="meta">{{.Note}}</p>{{end}}
17{{if .Rows}}
18<div class="tablewrap"><table class="refs">
19<thead><tr><th scope="col">Name</th><th scope="col">Kind</th><th scope="col">Defined at</th></tr></thead>
20<tbody>
21{{range .Rows}}<tr>
22 <td><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/blob/{{$.Commit}}/{{.Path}}#L{{.Line}}"><code>{{.Name}}</code></a></td>
23 <td>{{.Kind}}</td>
24 <td class="sha">{{.Path}}:{{.Line}}</td>
25</tr>
26{{end}}
27</tbody>
28</table></div>
29{{if .Next}}<p><a href="{{.Next}}">next page</a></p>{{end}}
30{{else}}<p class="empty-note">no definitions matching “{{.Query}}”</p>{{end}}
31{{end}}
32{{end}}
internal/web/web_test.go +1 −1
@@ -89,7 +89,7 @@ func TestWhenNamesTheZone(t *testing.T) {
89// none. The merge request page picks wide for its diff view, so it gets 89// none. The merge request page picks wide for its diff view, so it gets
90// a per-view define instead of a fixed one. 90// a per-view define instead of a fixed one.
91func TestMainWidthClass(t *testing.T) { 91func TestMainWidthClass(t *testing.T) {
92 wide := map[string]bool{"tree.html": true, "blob.html": true, "blame.html": true, "log.html": true, "commit.html": true, "compare.html": true, "builds.html": true, "build.html": true, "search.html": true, "globalsearch.html": true, "edit.html": true, "dashboard.html": true, "issues.html": true, "mrs.html": true, "mrrangediff.html": true, "explore.html": true, "notifications.html": true, "settings.html": true, "account.html": true, "admin.html": true, "adminusers.html": true, "queries.html": true} 92 wide := map[string]bool{"tree.html": true, "blob.html": true, "blame.html": true, "log.html": true, "commit.html": true, "compare.html": true, "builds.html": true, "build.html": true, "search.html": true, "symbols.html": true, "globalsearch.html": true, "edit.html": true, "dashboard.html": true, "issues.html": true, "mrs.html": true, "mrrangediff.html": true, "explore.html": true, "notifications.html": true, "settings.html": true, "account.html": true, "admin.html": true, "adminusers.html": true, "queries.html": true}
93 bounded := map[string]bool{"landing.html": true, "fork.html": true, "login.html": true, "logout.html": true, "register.html": true, "registered.html": true, "new.html": true, "issuenew.html": true, "mrnew.html": true, "snippetnew.html": true, "privacy.html": true, "404.html": true} 93 bounded := map[string]bool{"landing.html": true, "fork.html": true, "login.html": true, "logout.html": true, "register.html": true, "registered.html": true, "new.html": true, "issuenew.html": true, "mrnew.html": true, "snippetnew.html": true, "privacy.html": true, "404.html": true}
94 perView := map[string]string{"mr.html": `{{define "width"}}{{if eq .View "diff"}}wide{{else}}reading{{end}}{{end}}`} 94 perView := map[string]string{"mr.html": `{{define "width"}}{{if eq .View "diff"}}wide{{else}}reading{{end}}{{end}}`}
95 for _, name := range Pages() { 95 for _, name := range Pages() {