Commit 7a781d1ccb
Verified · cmc
Layout: unified · split
.gitbay/wiki/Parity.org +10 −1
| @@ -188,6 +188,8 @@ rather than the one the web page shows. | ||
| 188 | 188 | | one commit with its patch | yes | yes | yes | |
| 189 | 189 | | blame | yes | yes | yes | |
| 190 | 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 | 193 | | compare two refs | yes | yes | yes | |
| 192 | 194 | | branches and tags | yes | yes | yes | |
| 193 | 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 | 325 | | activity feed | yes | yes | yes | |
| 324 | 326 | | command reference | yes | no | n/a | |
| 325 | 327 | |
| 328 | =repo symbols= and =/{owner}/{repo}/symbols= answer from the same | |
| 329 | store query and ranking. Jumping to a definition is a link on a name in | |
| 330 | the blob view, which a terminal has no place for; =repo symbols= with | |
| 331 | the name is its CLI form. | |
| 332 | ||
| 326 | 333 | =explore= is the listing without a query; =repo search= is the one with. |
| 327 | 334 | =search= is both plus the title and body of every issue and merge |
| 328 | 335 | request the caller can read, over FTS5; the web serves it at =/search= |
| @@ -464,6 +471,7 @@ when there is none. | ||
| 464 | 471 | | worker queues | yes | yes | no | |
| 465 | 472 | | runners | yes | no | no | |
| 466 | 473 | | repository list, archive, visibility | yes | no | no | |
| 474 | | rebuild a symbol index | yes | no | no | | |
| 467 | 475 | | audit log | yes | no | no | |
| 468 | 476 | | instance statistics | yes | no | no | |
| 469 | 477 | |
| @@ -489,7 +497,8 @@ key, the other cannot be undone. | ||
| 489 | 497 | |
| 490 | 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 | |
| 501 | symbols= take | |
| 493 | 502 | =--limit <n>= and =--cursor <c>=. Cursors are opaque; each page carries |
| 494 | 503 | the next one. Without the flags a list stays as it was, so existing |
| 495 | 504 | scripts 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:// | ||
| 242 | 242 | gitbay repo topics add you/project cli forge # free-form tags, shown on the web |
| 243 | 243 | gitbay repo search forge # find repos by name/description/topic |
| 244 | 244 | gitbay repo grep you/project "some string" # literal git grep over the default branch |
| 245 | gitbay repo symbols you/project Dispatch # where a name is defined, from the symbol index | |
| 245 | 246 | gitbay repo pin you/project # pin to your web dashboard |
| 246 | 247 | gitbay repo unpin you/project |
| 247 | 248 | gitbay repo archive you/project # read-only: pushes and issue/MR |
| 248 | 249 | gitbay repo unarchive you/project # writes refused, browsing intact |
| 249 | 250 | #+end_src |
| 250 | 251 | |
| 252 | A push to the default branch queues a symbol index of its tree, built in | |
| 253 | the background after the push completes; a tree that is already indexed | |
| 254 | is not indexed again. =repo symbols <owner/name> [--kind k] <query>= | |
| 255 | lists definitions whose name starts with the query, exact matches | |
| 256 | first, case-sensitive before case-insensitive, as name, kind, path and | |
| 257 | line; =--limit= and =--cursor= page it, and without them it stops at | |
| 258 | 200 rows. A Go method is listed as =Type.Method= and also matches on | |
| 259 | =Method=. Only the default branch is indexed: =--ref= accepts another | |
| 260 | ref only when its tree is the indexed one. The index follows read | |
| 261 | access: a repository you cannot read has no symbols either. | |
| 262 | ||
| 263 | What 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 | ||
| 276 | Languages other than Go are matched a line at a time, so unusual | |
| 277 | definition 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 | |
| 280 | stops at 200,000 symbols or two minutes and records the index as | |
| 281 | partial; an index that cannot be built is recorded as failed. Either | |
| 282 | record stands for its tree: a push that changes the tree builds again, | |
| 283 | one that does not is left alone. A repository that has not been pushed | |
| 284 | to since the index existed has none until its next push to the default | |
| 285 | branch. =admin symbols reindex <owner/name>= rebuilds one regardless. | |
| 286 | ||
| 287 | On the web, a file viewed at the indexed tree (the default branch's | |
| 288 | head, or any commit with the same tree) links each name the index | |
| 289 | holds: 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 | |
| 291 | own definitions are listed above its source. | |
| 292 | ||
| 251 | 293 | Import from another forge — git data first, then optionally the issue |
| 252 | 294 | and PR history from GitHub or any Forgejo instance such as Codeberg |
| 253 | 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 | 54 | attributes it emits; MathML written as raw HTML is still stripped. |
| 55 | 55 | Anything outside the subset, and math past 1000 expressions or |
| 56 | 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 | 68 | * v1.38.0 — 2026-09-29 |
| 59 | 69 | |
internal/httpd/routes.go +1
| @@ -65,6 +65,7 @@ func (s *Server) Routes() []Route { | ||
| 65 | 65 | Route{Method: "GET", Pattern: "/{owner}/{repo}/blob/{ref}/{path...}", Handler: s.blob}, |
| 66 | 66 | Route{Method: "GET", Pattern: "/{owner}/{repo}/blame/{ref}/{path...}", Handler: s.blame}, |
| 67 | 67 | Route{Method: "GET", Pattern: "/{owner}/{repo}/search", Handler: s.search}, |
| 68 | Route{Method: "GET", Pattern: "/{owner}/{repo}/symbols", Handler: s.symbolsPage}, | |
| 68 | 69 | Route{Method: "GET", Pattern: "/{owner}/{repo}/milestones", Handler: s.milestones}, |
| 69 | 70 | Route{Method: "GET", Pattern: "/{owner}/{repo}/labels", Handler: s.labels}, |
| 70 | 71 | Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki", Handler: s.wiki}, |
internal/httpd/symbols.go added +144
| @@ -0,0 +1,144 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 20 | var 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. | |
| 23 | func 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. | |
| 37 | func (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. | |
| 79 | const symbolsPageSize = 100 | |
| 80 | ||
| 81 | // symbolsPage lists the definitions matching a query, ranked as `repo | |
| 82 | // symbols` ranks them, from the same store query. | |
| 83 | func (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 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 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 | ||
| 17 | func 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. | |
| 32 | func 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 | ||
| 86 | func 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 | ||
| 116 | func 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 | 706 | if !binary && !image { |
| 707 | 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 | 713 | // Markdown and org render like a README, with the source one click |
| 710 | 714 | // away; ?view=source shows the text instead. |
| 711 | 715 | renderable := markupFile(filePath) && !binary |
| @@ -752,8 +756,9 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) { | ||
| 752 | 756 | Rendered bool // this response shows the rendering |
| 753 | 757 | RenderedHTML template.HTML |
| 754 | 758 | Nav fileNav |
| 759 | Symbols []store.SymbolRow | |
| 755 | 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 | 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; } | ||
| 1101 | 1101 | ul.matchlist { list-style: none; margin: var(--sp-3) 0; padding: 0; } |
| 1102 | 1102 | ul.matchlist li { margin-bottom: var(--sp-3); } |
| 1103 | 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; } | |
| 1108 | details.symlist { margin: 0 0 var(--sp-3); font-size: var(--fs-1); } | |
| 1109 | details.symlist summary { cursor: pointer; color: var(--muted); } | |
| 1110 | details.symlist ul { list-style: none; margin: var(--sp-2) 0 0; padding: 0; columns: 16rem; } | |
| 1111 | details.symlist li { break-inside: avoid; padding: 2px 0; } | |
| 1104 | 1112 | pre.matchline { |
| 1105 | 1113 | background: var(--inset); |
| 1106 | 1114 | border: 1px solid var(--line); |
internal/web/templates/blob.html +4
| @@ -12,6 +12,10 @@ | ||
| 12 | 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 | 13 | </div> |
| 14 | 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 | 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 | 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 | 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 | 89 | // none. The merge request page picks wide for its diff view, so it gets |
| 90 | 90 | // a per-view define instead of a fixed one. |
| 91 | 91 | func 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 | 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 | 94 | perView := map[string]string{"mr.html": `{{define "width"}}{{if eq .View "diff"}}wide{{else}}reading{{end}}{{end}}`} |
| 95 | 95 | for _, name := range Pages() { |