Commit 235ad49736
Verified · cmc
e2e/dashboard_test.go +13 −6
| @@ -93,7 +93,7 @@ func TestDashboard(t *testing.T) { | ||
| 93 | 93 | t.Fatalf("dashboard: %d", status) |
| 94 | 94 | } |
| 95 | 95 | for _, want := range []string{ |
| 96 | ">pinned</h2>", ">app<", // pinned card | |
| 96 | ">Pinned</h2>", ">app<", // pinned card | |
| 97 | 97 | "alice/app!1 from bob", "alice/app#1 todo one", |
| 98 | 98 | `href="/alice/app/mrs/1"`, `href="/alice/app/issues/1"`, |
| 99 | 99 | } { |
| @@ -102,11 +102,18 @@ func TestDashboard(t *testing.T) { | ||
| 102 | 102 | } |
| 103 | 103 | } |
| 104 | 104 | |
| 105 | // The MR diff is collapsed by default in a <details> with stats. | |
| 105 | // The diff has its own view rather than a fold at the foot of the | |
| 106 | // conversation: the default view offers it, and asking for it renders | |
| 107 | // the stat line and the patch. | |
| 106 | 108 | _, body = inst.get(t, "/alice/app/mrs/1") |
| 107 | if !strings.Contains(body, `<details class="difffold">`) || | |
| 108 | strings.Contains(body, `<details class="difffold" open`) || | |
| 109 | !strings.Contains(body, "1 file changed") { | |
| 110 | t.Fatal("diff not collapsed with stats") | |
| 109 | if !strings.Contains(body, `href="/alice/app/mrs/1?view=diff"`) { | |
| 110 | t.Fatal("merge request missing the files-changed view") | |
| 111 | } | |
| 112 | if strings.Contains(body, `class="diff"`) { | |
| 113 | t.Fatal("diff rendered on the conversation view") | |
| 114 | } | |
| 115 | _, body = inst.get(t, "/alice/app/mrs/1?view=diff") | |
| 116 | if !strings.Contains(body, "1 file changed") || !strings.Contains(body, `class="diff"`) { | |
| 117 | t.Fatalf("diff view missing stat or patch:\n%s", body) | |
| 111 | 118 | } |
| 112 | 119 | } |
e2e/design_test.go +1 −1
| @@ -51,7 +51,7 @@ func TestReadmeRelativeLinks(t *testing.T) { | ||
| 51 | 51 | "<table>", "<td>verbose</td>", // GFM table renders |
| 52 | 52 | `<span class="kd">func</span>`, // fenced code highlighted via classes |
| 53 | 53 | `href="/alice/site/blob/main/README.md">README.md</a>`, // clickable card header |
| 54 | `<th>name</th>`, // file table column headers | |
| 54 | `<th scope="col">name</th>`, // file table column headers | |
| 55 | 55 | } { |
| 56 | 56 | if !strings.Contains(body, want) { |
| 57 | 57 | t.Errorf("missing %q", want) |
e2e/diffcomment_test.go +4 −3
| @@ -92,12 +92,13 @@ func TestDiffComments(t *testing.T) { | ||
| 92 | 92 | if !strings.Contains(out, `"unresolved_threads":1`) { |
| 93 | 93 | t.Fatalf("mr show count: %s", out) |
| 94 | 94 | } |
| 95 | status, body := inst.get(t, "/alice/lib/mrs/1") | |
| 95 | status, body := inst.get(t, "/alice/lib/mrs/1?view=diff") | |
| 96 | 96 | if status != 200 || !strings.Contains(body, "use log instead of fmt") || |
| 97 | 97 | !strings.Contains(body, `class="thread`) { |
| 98 | 98 | t.Fatalf("web thread: %d", status) |
| 99 | 99 | } |
| 100 | if strings.Contains(body, "threads on earlier revisions") { | |
| 100 | // Detached threads belong to the conversation, not the current diff. | |
| 101 | if _, conv := inst.get(t, "/alice/lib/mrs/1"); strings.Contains(conv, "Threads on earlier revisions") { | |
| 101 | 102 | t.Fatal("fresh thread rendered as detached") |
| 102 | 103 | } |
| 103 | 104 | |
| @@ -126,7 +127,7 @@ func TestDiffComments(t *testing.T) { | ||
| 126 | 127 | t.Fatalf("thread not stale after force-push: %s", out) |
| 127 | 128 | } |
| 128 | 129 | _, body = inst.get(t, "/alice/lib/mrs/1") |
| 129 | if !strings.Contains(body, "threads on earlier revisions") { | |
| 130 | if !strings.Contains(body, "Threads on earlier revisions") { | |
| 130 | 131 | t.Fatal("stale thread not moved to detached section") |
| 131 | 132 | } |
| 132 | 133 | } |
e2e/mr_test.go +6 −5
| @@ -264,12 +264,13 @@ func TestMergeRequests(t *testing.T) { | ||
| 264 | 264 | if status != 200 || !strings.Contains(body, "stale") || !strings.Contains(body, "merged") { |
| 265 | 265 | t.Fatalf("mr detail: %d\n%s", status, body) |
| 266 | 266 | } |
| 267 | if !strings.Contains(body, "feature.txt") { | |
| 268 | t.Fatalf("merged MR web diff empty:\n%s", body) | |
| 267 | if _, diff := inst.get(t, "/alice/lib/mrs/1?view=diff"); !strings.Contains(diff, "feature.txt") { | |
| 268 | t.Fatalf("merged MR web diff empty:\n%s", diff) | |
| 269 | 269 | } |
| 270 | // The MR page lists the commits it carries, linked to commit pages. | |
| 271 | if !strings.Contains(body, ">commits <") || !strings.Contains(body, "/alice/lib/commit/") { | |
| 272 | t.Fatalf("mr commits section missing:\n%s", body) | |
| 270 | // The MR lists the commits it carries, linked to commit pages. | |
| 271 | _, commits := inst.get(t, "/alice/lib/mrs/1?view=commits") | |
| 272 | if !strings.Contains(commits, "/alice/lib/commit/") { | |
| 273 | t.Fatalf("mr commits view missing:\n%s", commits) | |
| 273 | 274 | } |
| 274 | 275 | // So does mr show, human and JSON. |
| 275 | 276 | showOut, _, code := inst.ssh(t, aliceKey, "", "mr", "show", "alice/lib", "1") |
internal/gitutil/lastcommit.go added +157
| @@ -0,0 +1,157 @@ | ||
| 1 | package gitutil | |
| 2 | ||
| 3 | import ( | |
| 4 | "bufio" | |
| 5 | "os/exec" | |
| 6 | "strconv" | |
| 7 | "strings" | |
| 8 | "time" | |
| 9 | ) | |
| 10 | ||
| 11 | // EntryCommit is the newest commit touching one entry of a tree listing. | |
| 12 | type EntryCommit struct { | |
| 13 | SHA string | |
| 14 | Subject string | |
| 15 | Author string | |
| 16 | When time.Time | |
| 17 | } | |
| 18 | ||
| 19 | // lastCommitScan bounds the history walk. A directory whose entries are | |
| 20 | // all recently touched resolves in a handful of commits; this only caps | |
| 21 | // the pathological case, where the remaining entries are reported absent | |
| 22 | // rather than costing an unbounded scan on every page view. | |
| 23 | const lastCommitScan = 2000 | |
| 24 | ||
| 25 | // LastCommits resolves the newest commit touching each of names directly | |
| 26 | // under path, for the tree at ref. | |
| 27 | // | |
| 28 | // One git log process serves the whole listing rather than one per entry: | |
| 29 | // the walk streams newest-first and is killed as soon as every name is | |
| 30 | // accounted for, so an active directory reads only the few commits it | |
| 31 | // needs no matter how deep the history goes. Names still unresolved when | |
| 32 | // the walk ends are absent from the map, and callers render them blank. | |
| 33 | func LastCommits(dir, ref, path string, names []string) map[string]EntryCommit { | |
| 34 | if len(names) == 0 { | |
| 35 | return nil | |
| 36 | } | |
| 37 | want := make(map[string]bool, len(names)) | |
| 38 | for _, n := range names { | |
| 39 | want[n] = true | |
| 40 | } | |
| 41 | prefix := "" | |
| 42 | if path != "" { | |
| 43 | prefix = strings.TrimSuffix(path, "/") + "/" | |
| 44 | } | |
| 45 | ||
| 46 | args := []string{"-C", dir, "log", "--first-parent", "--name-only", | |
| 47 | "--format=%x1e%H%x1f%ct%x1f%an%x1f%s", "-n", strconv.Itoa(lastCommitScan), ref} | |
| 48 | if prefix != "" { | |
| 49 | args = append(args, "--", strings.TrimSuffix(prefix, "/")) | |
| 50 | } | |
| 51 | cmd := exec.Command("git", args...) | |
| 52 | stdout, err := cmd.StdoutPipe() | |
| 53 | if err != nil { | |
| 54 | return nil | |
| 55 | } | |
| 56 | if err := cmd.Start(); err != nil { | |
| 57 | return nil | |
| 58 | } | |
| 59 | defer func() { | |
| 60 | // The walk usually ends early; stop git rather than let it finish | |
| 61 | // reading history nobody is going to look at. | |
| 62 | cmd.Process.Kill() | |
| 63 | cmd.Wait() | |
| 64 | }() | |
| 65 | ||
| 66 | out := make(map[string]EntryCommit, len(names)) | |
| 67 | var cur EntryCommit | |
| 68 | sc := bufio.NewScanner(stdout) | |
| 69 | sc.Buffer(make([]byte, 0, 64*1024), 1<<20) | |
| 70 | for sc.Scan() { | |
| 71 | line := sc.Text() | |
| 72 | if strings.HasPrefix(line, "\x1e") { | |
| 73 | cur = parseCommitHeader(line[1:]) | |
| 74 | continue | |
| 75 | } | |
| 76 | if line == "" || cur.SHA == "" { | |
| 77 | continue | |
| 78 | } | |
| 79 | name, ok := entryName(line, prefix) | |
| 80 | if !ok || !want[name] { | |
| 81 | continue | |
| 82 | } | |
| 83 | out[name] = cur | |
| 84 | delete(want, name) | |
| 85 | if len(want) == 0 { | |
| 86 | break | |
| 87 | } | |
| 88 | } | |
| 89 | return out | |
| 90 | } | |
| 91 | ||
| 92 | // parseCommitHeader reads sha, commit time, author, and subject, unit | |
| 93 | // separated so a subject containing spaces stays intact. | |
| 94 | func parseCommitHeader(s string) EntryCommit { | |
| 95 | f := strings.SplitN(s, "\x1f", 4) | |
| 96 | if len(f) != 4 { | |
| 97 | return EntryCommit{} | |
| 98 | } | |
| 99 | c := EntryCommit{SHA: f[0], Author: f[2], Subject: f[3]} | |
| 100 | if n, err := strconv.ParseInt(f[1], 10, 64); err == nil { | |
| 101 | c.When = time.Unix(n, 0).UTC() | |
| 102 | } | |
| 103 | return c | |
| 104 | } | |
| 105 | ||
| 106 | // TipCommit is the commit at ref, for the bar above a tree listing that | |
| 107 | // answers "who touched this repository last". | |
| 108 | func TipCommit(dir, ref string) EntryCommit { | |
| 109 | out, err := exec.Command("git", "-C", dir, "log", "-1", | |
| 110 | "--format=%H%x1f%ct%x1f%an%x1f%s", ref).Output() | |
| 111 | if err != nil { | |
| 112 | return EntryCommit{} | |
| 113 | } | |
| 114 | return parseCommitHeader(strings.TrimRight(string(out), "\n")) | |
| 115 | } | |
| 116 | ||
| 117 | // entryName maps a changed path to the listing entry that contains it: | |
| 118 | // "internal/web/web.go" under prefix "internal/" is the entry "web". | |
| 119 | func entryName(changed, prefix string) (string, bool) { | |
| 120 | if prefix != "" { | |
| 121 | if !strings.HasPrefix(changed, prefix) { | |
| 122 | return "", false | |
| 123 | } | |
| 124 | changed = changed[len(prefix):] | |
| 125 | } | |
| 126 | if changed == "" { | |
| 127 | return "", false | |
| 128 | } | |
| 129 | if i := strings.IndexByte(changed, '/'); i >= 0 { | |
| 130 | return changed[:i], true | |
| 131 | } | |
| 132 | return changed, true | |
| 133 | } | |
| 134 | ||
| 135 | // StatPath returns the tree entry for a single path at ref, so a blob | |
| 136 | // page can report the facts the file listing no longer carries: its size, | |
| 137 | // and whether it is executable or a symlink. | |
| 138 | func StatPath(dir, ref, path string) (TreeEntry, bool) { | |
| 139 | out, err := exec.Command("git", "-C", dir, "ls-tree", "-l", ref, "--", path).Output() | |
| 140 | if err != nil { | |
| 141 | return TreeEntry{}, false | |
| 142 | } | |
| 143 | line := strings.TrimRight(string(out), "\n") | |
| 144 | meta, name, ok := strings.Cut(line, "\t") | |
| 145 | if !ok { | |
| 146 | return TreeEntry{}, false | |
| 147 | } | |
| 148 | f := strings.Fields(meta) | |
| 149 | if len(f) != 4 { | |
| 150 | return TreeEntry{}, false | |
| 151 | } | |
| 152 | size := int64(-1) | |
| 153 | if f[3] != "-" { | |
| 154 | size, _ = strconv.ParseInt(f[3], 10, 64) | |
| 155 | } | |
| 156 | return TreeEntry{Mode: f[0], Type: f[1], SHA: f[2], Size: size, Name: name}, true | |
| 157 | } | |
internal/gitutil/lastcommit_test.go added +91
| @@ -0,0 +1,91 @@ | ||
| 1 | package gitutil | |
| 2 | ||
| 3 | import ( | |
| 4 | "os" | |
| 5 | "os/exec" | |
| 6 | "path/filepath" | |
| 7 | "testing" | |
| 8 | ) | |
| 9 | ||
| 10 | func git(t *testing.T, dir string, args ...string) { | |
| 11 | t.Helper() | |
| 12 | cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) | |
| 13 | cmd.Env = append(os.Environ(), | |
| 14 | "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@e", | |
| 15 | "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@e") | |
| 16 | if out, err := cmd.CombinedOutput(); err != nil { | |
| 17 | t.Fatalf("git %v: %v\n%s", args, err, out) | |
| 18 | } | |
| 19 | } | |
| 20 | ||
| 21 | func write(t *testing.T, dir, rel, body string) { | |
| 22 | t.Helper() | |
| 23 | p := filepath.Join(dir, rel) | |
| 24 | if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { | |
| 25 | t.Fatal(err) | |
| 26 | } | |
| 27 | if err := os.WriteFile(p, []byte(body), 0o644); err != nil { | |
| 28 | t.Fatal(err) | |
| 29 | } | |
| 30 | } | |
| 31 | ||
| 32 | func TestLastCommits(t *testing.T) { | |
| 33 | dir := t.TempDir() | |
| 34 | git(t, dir, "init", "-q", "-b", "main") | |
| 35 | ||
| 36 | write(t, dir, "README.md", "one\n") | |
| 37 | write(t, dir, "src/main.go", "one\n") | |
| 38 | write(t, dir, "docs/guide.md", "one\n") | |
| 39 | git(t, dir, "add", ".") | |
| 40 | git(t, dir, "commit", "-qm", "first") | |
| 41 | ||
| 42 | // Touching a file deep inside src/ must move the src/ entry without | |
| 43 | // disturbing README.md or docs/. | |
| 44 | write(t, dir, "src/lib/helper.go", "two\n") | |
| 45 | git(t, dir, "add", ".") | |
| 46 | git(t, dir, "commit", "-qm", "add helper") | |
| 47 | ||
| 48 | write(t, dir, "README.md", "three\n") | |
| 49 | git(t, dir, "commit", "-qam", "update readme") | |
| 50 | ||
| 51 | got := LastCommits(dir, "main", "", []string{"README.md", "src", "docs"}) | |
| 52 | for name, wantSubject := range map[string]string{ | |
| 53 | "README.md": "update readme", | |
| 54 | "src": "add helper", | |
| 55 | "docs": "first", | |
| 56 | } { | |
| 57 | c, ok := got[name] | |
| 58 | if !ok { | |
| 59 | t.Errorf("%s unresolved", name) | |
| 60 | continue | |
| 61 | } | |
| 62 | if c.Subject != wantSubject { | |
| 63 | t.Errorf("%s = %q, want %q", name, c.Subject, wantSubject) | |
| 64 | } | |
| 65 | if c.SHA == "" || c.When.IsZero() { | |
| 66 | t.Errorf("%s missing sha or time: %+v", name, c) | |
| 67 | } | |
| 68 | } | |
| 69 | ||
| 70 | // Inside a subdirectory the prefix is stripped, so entries resolve | |
| 71 | // against their own names rather than full paths. | |
| 72 | sub := LastCommits(dir, "main", "src", []string{"main.go", "lib"}) | |
| 73 | if c := sub["main.go"]; c.Subject != "first" { | |
| 74 | t.Errorf("src/main.go = %q, want %q", c.Subject, "first") | |
| 75 | } | |
| 76 | if c := sub["lib"]; c.Subject != "add helper" { | |
| 77 | t.Errorf("src/lib = %q, want %q", c.Subject, "add helper") | |
| 78 | } | |
| 79 | ||
| 80 | // A name nobody asked about never appears, and an unknown one is | |
| 81 | // simply absent rather than an error. | |
| 82 | if _, ok := sub["README.md"]; ok { | |
| 83 | t.Error("src listing leaked a root entry") | |
| 84 | } | |
| 85 | if _, ok := LastCommits(dir, "main", "", []string{"nope"})["nope"]; ok { | |
| 86 | t.Error("resolved a path that does not exist") | |
| 87 | } | |
| 88 | if LastCommits(dir, "main", "", nil) != nil { | |
| 89 | t.Error("empty name list should not run git") | |
| 90 | } | |
| 91 | } | |
internal/httpd/web.go +53 −30
| @@ -79,9 +79,7 @@ func (s *Server) font(w http.ResponseWriter, r *http.Request) { | ||
| 79 | 79 | // the stock plain-text response if the template fails. |
| 80 | 80 | func (s *Server) notFound(w http.ResponseWriter, r *http.Request) { |
| 81 | 81 | var buf bytes.Buffer |
| 82 | if err := web.Render(&buf, "404.html", basePage{ | |
| 83 | Site: s.siteName(), Viewer: s.viewerName(r), | |
| 84 | }); err != nil { | |
| 82 | if err := web.Render(&buf, "404.html", s.base(r)); err != nil { | |
| 85 | 83 | http.NotFound(w, r) |
| 86 | 84 | return |
| 87 | 85 | } |
| @@ -173,14 +171,6 @@ func (s *Server) explore(w http.ResponseWriter, r *http.Request) { | ||
| 173 | 171 | }{s.baseFor(viewer), q, s.filterRepos(q, s.describeAll(repos))}) |
| 174 | 172 | } |
| 175 | 173 | |
| 176 | // viewerName returns the logged-in username for header rendering, or "". | |
| 177 | func (s *Server) viewerName(r *http.Request) string { | |
| 178 | if s.cfg.Web.Mode != "accounts" { | |
| 179 | return "" | |
| 180 | } | |
| 181 | return s.viewer(r).Username | |
| 182 | } | |
| 183 | ||
| 184 | 174 | // privacy renders the privacy page: what the gitbay software does with |
| 185 | 175 | // data, plus this instance's operator-provided notes. |
| 186 | 176 | func (s *Server) privacy(w http.ResponseWriter, r *http.Request) { |
| @@ -425,14 +415,16 @@ func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, | ||
| 425 | 415 | // Empty repo: render the page with no entries rather than 404. |
| 426 | 416 | s.render(w, "tree.html", struct { |
| 427 | 417 | repoPage |
| 428 | Crumbs []crumb | |
| 429 | Prefix string | |
| 430 | DirPath string | |
| 431 | RefKind string | |
| 432 | Entries []gitutil.TreeEntry | |
| 433 | Branches []gitutil.Ref | |
| 434 | ReadmeName string | |
| 435 | ReadmeHTML template.HTML | |
| 418 | Crumbs []crumb | |
| 419 | Prefix string | |
| 420 | DirPath string | |
| 421 | RefKind string | |
| 422 | Entries []gitutil.TreeEntry | |
| 423 | Branches []gitutil.Ref | |
| 424 | ReadmeName string | |
| 425 | ReadmeHTML template.HTML | |
| 426 | LastCommits map[string]gitutil.EntryCommit | |
| 427 | Tip gitutil.EntryCommit | |
| 436 | 428 | }{repoPage: p, RefKind: "tree"}) |
| 437 | 429 | return |
| 438 | 430 | } |
| @@ -455,17 +447,26 @@ func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, | ||
| 455 | 447 | } |
| 456 | 448 | |
| 457 | 449 | branches, _ := gitutil.Refs(p.Dir, "heads") |
| 450 | names := make([]string, 0, len(entries)) | |
| 451 | for _, e := range entries { | |
| 452 | names = append(names, e.Name) | |
| 453 | } | |
| 458 | 454 | s.render(w, "tree.html", struct { |
| 459 | 455 | repoPage |
| 460 | Crumbs []crumb | |
| 461 | Prefix string | |
| 462 | DirPath string | |
| 463 | RefKind string | |
| 464 | Entries []gitutil.TreeEntry | |
| 465 | Branches []gitutil.Ref | |
| 466 | ReadmeName string | |
| 467 | ReadmeHTML template.HTML | |
| 468 | }{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches, readmeName, readmeHTML}) | |
| 456 | Crumbs []crumb | |
| 457 | Prefix string | |
| 458 | DirPath string | |
| 459 | RefKind string | |
| 460 | Entries []gitutil.TreeEntry | |
| 461 | Branches []gitutil.Ref | |
| 462 | ReadmeName string | |
| 463 | ReadmeHTML template.HTML | |
| 464 | LastCommits map[string]gitutil.EntryCommit | |
| 465 | Tip gitutil.EntryCommit | |
| 466 | }{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches, | |
| 467 | readmeName, readmeHTML, | |
| 468 | gitutil.LastCommits(p.Dir, p.Ref, dirPath, names), | |
| 469 | gitutil.TipCommit(p.Dir, p.Ref)}) | |
| 469 | 470 | } |
| 470 | 471 | |
| 471 | 472 | func (s *Server) blob(w http.ResponseWriter, r *http.Request) { |
| @@ -494,6 +495,16 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) { | ||
| 494 | 495 | cs = cs[:len(cs)-1] |
| 495 | 496 | } |
| 496 | 497 | branches, _ := gitutil.Refs(p.Dir, "heads") |
| 498 | lines := 0 | |
| 499 | if !binary && !image && len(data) > 0 { | |
| 500 | lines = bytes.Count(data, []byte("\n")) | |
| 501 | if data[len(data)-1] != '\n' { | |
| 502 | lines++ | |
| 503 | } | |
| 504 | } | |
| 505 | // The file listing leads with the last commit now, so the facts about | |
| 506 | // the file itself are reported here instead. | |
| 507 | entry, _ := gitutil.StatPath(p.Dir, p.Ref, filePath) | |
| 497 | 508 | s.render(w, "blob.html", struct { |
| 498 | 509 | repoPage |
| 499 | 510 | Crumbs []crumb |
| @@ -504,9 +515,13 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) { | ||
| 504 | 515 | Binary bool |
| 505 | 516 | Image bool |
| 506 | 517 | Size int |
| 518 | Lines int | |
| 519 | Exec bool | |
| 520 | Symlink bool | |
| 507 | 521 | Branches []gitutil.Ref |
| 508 | 522 | CodeHTML template.HTML |
| 509 | }{p, cs, base, filePath, filePath, "blob", binary, image, len(data), branches, codeHTML}) | |
| 523 | }{p, cs, base, filePath, filePath, "blob", binary, image, len(data), lines, | |
| 524 | entry.Mode == "100755", entry.Mode == "120000", branches, codeHTML}) | |
| 510 | 525 | } |
| 511 | 526 | |
| 512 | 527 | // releases lists tag-anchored releases with notes and assets. |
| @@ -1450,9 +1465,17 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) { | ||
| 1450 | 1465 | commits = append(commits, cr) |
| 1451 | 1466 | } |
| 1452 | 1467 | } |
| 1468 | // The diff is the reason most people open a merge request, so it gets | |
| 1469 | // its own view rather than a fold at the foot of the conversation. | |
| 1470 | // A query parameter keeps this working without JavaScript. | |
| 1471 | view := r.URL.Query().Get("view") | |
| 1472 | if view != "commits" && view != "diff" { | |
| 1473 | view = "conversation" | |
| 1474 | } | |
| 1453 | 1475 | s.render(w, "mr.html", struct { |
| 1454 | 1476 | repoPage |
| 1455 | 1477 | MR store.MR |
| 1478 | View string | |
| 1456 | 1479 | BodyHTML template.HTML |
| 1457 | 1480 | Checks []store.CommitStatus |
| 1458 | 1481 | Combined string |
| @@ -1463,7 +1486,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) { | ||
| 1463 | 1486 | Commits []commitRow |
| 1464 | 1487 | CanEdit bool |
| 1465 | 1488 | DetachedThreads []diffThread |
| 1466 | }{p, m, md(m.Body), checks, store.CombinedStatus(checks), renderComments(comments, md), | |
| 1489 | }{p, m, view, md(m.Body), checks, store.CombinedStatus(checks), renderComments(comments, md), | |
| 1467 | 1490 | reviews, lines, stat, commits, s.canEditItem(r, p.Repo, m.Author), detachedThreads}) |
| 1468 | 1491 | } |
| 1469 | 1492 | |
internal/web/static/style.css +128 −8
| @@ -86,6 +86,8 @@ | ||
| 86 | 86 | --sp-5: 1.5rem; |
| 87 | 87 | --sp-6: 2.5rem; |
| 88 | 88 | |
| 89 | --chev: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23595959' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); | |
| 90 | ||
| 89 | 91 | /* radii: 2px everywhere — chart plates, not pills */ |
| 90 | 92 | --r-sm: 2px; |
| 91 | 93 | --r-md: 2px; |
| @@ -111,6 +113,7 @@ | ||
| 111 | 113 | --bad: #ff6b6b; |
| 112 | 114 | --done: #c084fc; |
| 113 | 115 | --neutral: #a1a1a1; |
| 116 | --chev: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23a1a1a1' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); | |
| 114 | 117 | } |
| 115 | 118 | } |
| 116 | 119 | |
| @@ -936,27 +939,47 @@ details.editbox input[type="text"] { width: 100%; } | ||
| 936 | 939 | .thread .rendered p { margin: var(--sp-1) 0; } |
| 937 | 940 | .thread .threadstate { color: var(--muted); font-size: var(--fs-1); margin: var(--sp-1) 0 0; } |
| 938 | 941 | |
| 939 | /* forms */ | |
| 942 | /* forms: every control shares one shell, so a select reads as a sibling | |
| 943 | of the text inputs beside it rather than as an OS control dropped in */ | |
| 940 | 944 | input, textarea, button, select { font: inherit; color: inherit; } |
| 941 | input[type="text"], input:not([type]), textarea { | |
| 945 | input[type="text"], input[type="search"], input[type="email"], | |
| 946 | input[type="password"], input:not([type]), textarea, select { | |
| 942 | 947 | background: var(--bg); |
| 948 | color: var(--fg); | |
| 943 | 949 | border: 1px solid var(--line); |
| 944 | 950 | border-radius: var(--r-md); |
| 945 | 951 | padding: var(--sp-2) var(--sp-3); |
| 952 | line-height: 1.4; | |
| 946 | 953 | } |
| 947 | 954 | textarea { width: 100%; resize: vertical; } |
| 948 | 955 | textarea[name="content"] { font-family: var(--mono); font-size: var(--fs-1); } |
| 949 | input:focus, textarea:focus { outline: 2px solid color-mix(in srgb, var(--accent) 45%, transparent); border-color: var(--accent); } | |
| 956 | /* the native arrow cannot be restyled, so draw our own and match the | |
| 957 | inputs exactly; --chev carries the stroke colour per scheme */ | |
| 958 | select { | |
| 959 | appearance: none; | |
| 960 | padding-right: 1.9rem; | |
| 961 | background-image: var(--chev); | |
| 962 | background-repeat: no-repeat; | |
| 963 | background-position: right 0.6rem center; | |
| 964 | background-size: 0.62rem; | |
| 965 | cursor: pointer; | |
| 966 | } | |
| 967 | input:hover, textarea:hover, select:hover { border-color: var(--muted); } | |
| 968 | /* the global :focus-visible ring covers these; only the border moves */ | |
| 969 | input:focus, textarea:focus, select:focus { border-color: var(--accent); } | |
| 970 | input[type="radio"], input[type="checkbox"] { accent-color: var(--fill); } | |
| 971 | label:has(> input[type="radio"]), label:has(> input[type="checkbox"]), | |
| 972 | label:has(> select) { display: inline-flex; align-items: center; gap: var(--sp-2); } | |
| 950 | 973 | button { |
| 951 | background: var(--accent); | |
| 952 | color: var(--accent-fg); | |
| 974 | background: var(--fill); | |
| 975 | color: #ffffff; | |
| 953 | 976 | border: none; |
| 954 | 977 | border-radius: var(--r-md); |
| 955 | padding: var(--sp-1) var(--sp-3); | |
| 956 | font-weight: 500; | |
| 978 | padding: var(--sp-2) var(--sp-4); | |
| 979 | font-weight: 600; | |
| 957 | 980 | cursor: pointer; |
| 958 | 981 | } |
| 959 | button:hover { filter: brightness(1.08); } | |
| 982 | button:hover { filter: brightness(1.15); } | |
| 960 | 983 | |
| 961 | 984 | /* 404 */ |
| 962 | 985 | .notfound { text-align: center; padding: var(--sp-6) 0; } |
| @@ -1087,3 +1110,100 @@ pre.matchline mark { | ||
| 1087 | 1110 | padding: var(--sp-2) var(--sp-3); |
| 1088 | 1111 | } |
| 1089 | 1112 | } |
| 1113 | ||
| 1114 | /* tree listing: the last commit is what a file table is scanned for, so | |
| 1115 | it leads. The file's own facts moved to the blob page. */ | |
| 1116 | .tipbar { | |
| 1117 | display: flex; | |
| 1118 | align-items: center; | |
| 1119 | gap: var(--sp-3); | |
| 1120 | padding: var(--sp-2) var(--sp-4); | |
| 1121 | background: var(--hover); | |
| 1122 | border: 1px solid var(--line); | |
| 1123 | border-bottom: 0; | |
| 1124 | border-radius: var(--r-lg) var(--r-lg) 0 0; | |
| 1125 | font-size: var(--fs-2); | |
| 1126 | } | |
| 1127 | .tipbar .who { font-weight: 600; } | |
| 1128 | .tipbar .subject { color: var(--fg); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 1129 | .tipbar .subject:hover { color: var(--accent); } | |
| 1130 | .tipbar .spacer { flex: 1; } | |
| 1131 | .tipbar .age { color: var(--muted); white-space: nowrap; } | |
| 1132 | .tipbar + table.tree { border-radius: 0 0 var(--r-lg) var(--r-lg); } | |
| 1133 | table.tree th.lastcommit, table.tree td.lastcommit { width: 55%; } | |
| 1134 | table.tree td.lastcommit a { color: var(--muted); display: block; overflow: hidden; | |
| 1135 | text-overflow: ellipsis; white-space: nowrap; } | |
| 1136 | table.tree td.lastcommit a:hover { color: var(--accent); } | |
| 1137 | table.tree th.age, table.tree td.age { | |
| 1138 | text-align: right; | |
| 1139 | white-space: nowrap; | |
| 1140 | color: var(--muted); | |
| 1141 | font-variant-numeric: tabular-nums; | |
| 1142 | } | |
| 1143 | table.tree td.name { width: 25%; white-space: nowrap; } | |
| 1144 | table.tree td.name.dir a { color: var(--accent); } | |
| 1145 | p.clone { margin: 0 0 var(--sp-3); } | |
| 1146 | p.filefacts { color: var(--muted); font-size: var(--fs-1); margin: 0 0 var(--sp-3); } | |
| 1147 | .pathbar .act { font-size: var(--fs-1); } | |
| 1148 | ||
| 1149 | /* merge request: a two-column split, so state has somewhere to live that | |
| 1150 | is not a run-on sentence under the title */ | |
| 1151 | .withaside { | |
| 1152 | display: grid; | |
| 1153 | grid-template-columns: minmax(0, 1fr) 15rem; | |
| 1154 | gap: var(--sp-5); | |
| 1155 | align-items: start; | |
| 1156 | } | |
| 1157 | .withaside .mainside { min-width: 0; } | |
| 1158 | @media (max-width: 62rem) { .withaside { grid-template-columns: 1fr; } } | |
| 1159 | ||
| 1160 | .aside { font-size: var(--fs-2); } | |
| 1161 | .aside .grp { border-top: 1px solid var(--line); padding: var(--sp-3) 0; } | |
| 1162 | .aside .grp:first-child { border-top: 0; padding-top: 0; } | |
| 1163 | .aside h2 { | |
| 1164 | margin: 0 0 var(--sp-2); | |
| 1165 | font-family: var(--mono); | |
| 1166 | font-size: var(--fs-0); | |
| 1167 | font-weight: 500; | |
| 1168 | letter-spacing: 0.1em; | |
| 1169 | text-transform: uppercase; | |
| 1170 | color: var(--muted); | |
| 1171 | } | |
| 1172 | .aside .row { display: flex; align-items: center; gap: var(--sp-2); margin: 0 0 var(--sp-1); flex-wrap: wrap; } | |
| 1173 | .aside .row:last-child { margin-bottom: 0; } | |
| 1174 | .aside .none { color: var(--muted); } | |
| 1175 | .aside .dot { width: 0.45rem; height: 0.45rem; flex: none; border-radius: 50%; background: var(--muted); } | |
| 1176 | .aside .dot.ok { background: var(--ok); } | |
| 1177 | .aside .dot.bad { background: var(--bad); } | |
| 1178 | .aside .dot.pend { background: var(--mark); } | |
| 1179 | ||
| 1180 | /* sub-navigation within an item; orange marks position as it does above */ | |
| 1181 | nav.subtabs { | |
| 1182 | display: flex; | |
| 1183 | gap: var(--sp-4); | |
| 1184 | border-bottom: 1px solid var(--line); | |
| 1185 | margin: 0 0 var(--sp-4); | |
| 1186 | } | |
| 1187 | nav.subtabs a { | |
| 1188 | display: inline-flex; | |
| 1189 | align-items: center; | |
| 1190 | gap: var(--sp-2); | |
| 1191 | padding: 0 0 var(--sp-2); | |
| 1192 | color: var(--muted); | |
| 1193 | font-size: var(--fs-2); | |
| 1194 | border-bottom: 2px solid transparent; | |
| 1195 | margin-bottom: -1px; | |
| 1196 | } | |
| 1197 | nav.subtabs a:hover { color: var(--fg); text-decoration: none; } | |
| 1198 | nav.subtabs a[aria-current] { color: var(--fg); font-weight: 700; border-bottom-color: var(--mark); } | |
| 1199 | nav.subtabs a i { | |
| 1200 | font-style: normal; | |
| 1201 | font-family: var(--mono); | |
| 1202 | font-size: var(--fs-0); | |
| 1203 | background: var(--hover); | |
| 1204 | border-radius: var(--r-sm); | |
| 1205 | padding: 0 0.35rem; | |
| 1206 | } | |
| 1207 | p.diffstat { color: var(--muted); font-size: var(--fs-2); margin: 0 0 var(--sp-3); } | |
| 1208 | p.diffstat .add { color: var(--ok); } | |
| 1209 | p.diffstat .del { color: var(--bad); } | |
internal/web/templates/404.html +1 −1
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | {{define "content"}} |
| 3 | 3 | <div class="notfound"> |
| 4 | 4 | <p class="bigcode">404</p> |
| 5 | <h1>page not found</h1> | |
| 5 | <h1>Page not found</h1> | |
| 6 | 6 | <p class="desc">nothing lives at this address — the path may be wrong, |
| 7 | 7 | or the repository may be private.</p> |
| 8 | 8 | <p><a href="/">← back to {{.Site}}</a></p> |
internal/web/templates/blob.html +1
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | <span class="spacer"></span> |
| 8 | 8 | <span class="actions"><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> |
| 9 | 9 | </div> |
| 10 | <p class="filefacts">{{if .Lines}}{{.Lines}} lines · {{end}}{{.Size}} bytes{{if .Exec}} · executable{{end}}{{if .Symlink}} · symlink{{end}}</p> | |
| 10 | 11 | {{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> |
| 11 | 12 | {{else if .Binary}}<p class="empty-note">binary file, {{.Size}} bytes — <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">download</a></p> |
| 12 | 13 | {{else}}<div class="code">{{.CodeHTML}}</div>{{end}} |
internal/web/templates/dashboard.html +4 −4
| @@ -1,16 +1,16 @@ | ||
| 1 | 1 | {{define "title"}}dashboard · {{.Site}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | 3 | <div class="headrow"> |
| 4 | <h1>dashboard</h1> | |
| 4 | <h1>Dashboard</h1> | |
| 5 | 5 | <span class="spacer"></span> |
| 6 | 6 | <p class="toolbar">logged in as <a href="/{{.Viewer}}">{{.Viewer}}</a></p> |
| 7 | 7 | </div> |
| 8 | {{if .Pinned}}<h2>pinned</h2> | |
| 8 | {{if .Pinned}}<h2>Pinned</h2> | |
| 9 | 9 | <ul class="pinlist"> |
| 10 | 10 | {{range .Pinned}}<li><a href="/{{.OwnerName}}/{{.Name}}">{{.OwnerName}}<span class="sep">/</span><strong>{{.Name}}</strong></a>{{if eq .Visibility "private"}} <span class="chip chip-neutral">private</span>{{end}}</li> |
| 11 | 11 | {{end}} |
| 12 | 12 | </ul>{{end}} |
| 13 | <h2>open merge requests <span class="count">{{len .MRs}}</span></h2> | |
| 13 | <h2>Open merge requests <span class="count">{{len .MRs}}</span></h2> | |
| 14 | 14 | <ul class="issuelist"> |
| 15 | 15 | {{range .MRs}}<li> |
| 16 | 16 | <div class="issuemain"> |
| @@ -20,7 +20,7 @@ | ||
| 20 | 20 | </li> |
| 21 | 21 | {{else}}<li class="empty">no open merge requests</li>{{end}} |
| 22 | 22 | </ul> |
| 23 | <h2>open issues <span class="count">{{len .Issues}}</span></h2> | |
| 23 | <h2>Open issues <span class="count">{{len .Issues}}</span></h2> | |
| 24 | 24 | <ul class="issuelist"> |
| 25 | 25 | {{range .Issues}}<li> |
| 26 | 26 | <div class="issuemain"> |
internal/web/templates/edit.html +3 −3
| @@ -3,9 +3,9 @@ | ||
| 3 | 3 | <h1>edit {{.Repo.OwnerName}}/{{.Repo.Name}} : {{.Path}} @ {{.Ref}}</h1> |
| 4 | 4 | {{if .Error}}<p class="error">{{.Error}}</p>{{end}} |
| 5 | 5 | <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/edit/{{.Ref}}/{{.Path}}"> |
| 6 | <p><textarea name="content" rows="24" style="width:100%" spellcheck="false">{{.Content}}</textarea></p> | |
| 7 | <p><input name="message" placeholder="commit message" style="width:60%"> | |
| 8 | <button type="submit">commit to {{.Ref}}</button></p> | |
| 6 | <p><textarea name="content" aria-label="File contents" rows="24" style="width:100%" spellcheck="false">{{.Content}}</textarea></p> | |
| 7 | <p><input name="message" aria-label="Commit message" placeholder="commit message" style="width:60%"> | |
| 8 | <button type="submit">Commit to {{.Ref}}</button></p> | |
| 9 | 9 | <p class="crumbs">this commit will be unsigned and authored as {{.Viewer}}</p> |
| 10 | 10 | </form> |
| 11 | 11 | {{end}} |
internal/web/templates/explore.html +2 −2
| @@ -1,9 +1,9 @@ | ||
| 1 | 1 | {{define "title"}}explore · {{.Site}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | 3 | <div class="headrow"> |
| 4 | <h1>explore</h1> | |
| 4 | <h1>Explore</h1> | |
| 5 | 5 | <form method="get" action="/explore" class="searchform compact"> |
| 6 | <input type="search" name="q" value="{{.Query}}" placeholder="filter by name, description, topic"> | |
| 6 | <input type="search" name="q" aria-label="Filter repositories" value="{{.Query}}" placeholder="filter by name, description, topic"> | |
| 7 | 7 | </form> |
| 8 | 8 | <span class="spacer"></span> |
| 9 | 9 | </div> |
internal/web/templates/issue.html +4 −4
| @@ -8,9 +8,9 @@ | ||
| 8 | 8 | {{if .Issue.Milestone}} · milestone <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">{{.Issue.Milestone}}</a>{{end}}</p> |
| 9 | 9 | {{if .CanEdit}}<details class="editbox"><summary>edit</summary> |
| 10 | 10 | <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/edit" class="commentform"> |
| 11 | <p><input type="text" name="title" value="{{.Issue.Title}}" required></p> | |
| 12 | <p><textarea name="body" rows="8">{{.Issue.Body}}</textarea></p> | |
| 13 | <p><input type="text" name="labels" value="{{range $i, $l := .Issue.Labels}}{{if $i}} {{end}}{{$l}}{{end}}" placeholder="labels, space-separated (write access)"></p> | |
| 11 | <p><input type="text" name="title" aria-label="Title" value="{{.Issue.Title}}" required></p> | |
| 12 | <p><textarea name="body" aria-label="Description" rows="8">{{.Issue.Body}}</textarea></p> | |
| 13 | <p><input type="text" name="labels" aria-label="Labels" value="{{range $i, $l := .Issue.Labels}}{{if $i}} {{end}}{{$l}}{{end}}" placeholder="labels, space-separated (write access)"></p> | |
| 14 | 14 | <p><button type="submit">save</button></p> |
| 15 | 15 | </form></details>{{end}} |
| 16 | 16 | {{if .BodyHTML}}<article class="comment"> |
| @@ -26,7 +26,7 @@ | ||
| 26 | 26 | {{end}} |
| 27 | 27 | {{if .Viewer}} |
| 28 | 28 | <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/comment" class="commentform"> |
| 29 | <p><textarea name="body" rows="4" placeholder="comment as {{.Viewer}}"></textarea></p> | |
| 29 | <p><textarea name="body" aria-label="Comment" rows="4" placeholder="Comment as {{.Viewer}}"></textarea></p> | |
| 30 | 30 | <p><button type="submit">comment</button></p> |
| 31 | 31 | </form> |
| 32 | 32 | {{end}} |
internal/web/templates/issuenew.html +4 −4
| @@ -4,9 +4,9 @@ | ||
| 4 | 4 | {{if gt (len .Templates) 1}}<p class="meta">template: |
| 5 | 5 | {{range .Templates}}{{if eq .Name $.Template}}<strong>{{.Name}}</strong>{{else}}<a href="?template={{.Name}}">{{.Name}}</a>{{end}} {{end}}</p>{{end}} |
| 6 | 6 | <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/new" class="commentform"> |
| 7 | <p><input type="text" name="title" placeholder="title" required></p> | |
| 8 | <p><textarea name="body" rows="12">{{.Body}}</textarea></p> | |
| 9 | <p><input type="text" name="labels" placeholder="labels, space-separated (write access)"></p> | |
| 10 | <p><button type="submit">open issue</button></p> | |
| 7 | <p><input type="text" name="title" aria-label="Title" placeholder="title" required></p> | |
| 8 | <p><textarea name="body" aria-label="Description" rows="12">{{.Body}}</textarea></p> | |
| 9 | <p><input type="text" name="labels" aria-label="Labels" placeholder="labels, space-separated (write access)"></p> | |
| 10 | <p><button type="submit">Open issue</button></p> | |
| 11 | 11 | </form> |
| 12 | 12 | {{end}} |
internal/web/templates/layout.html +1 −1
| @@ -107,4 +107,4 @@ | ||
| 107 | 107 | |
| 108 | 108 | {{define "branchicon"}}<svg class="icon" width="12" height="12" viewBox="0 0 16 16" aria-hidden="true" fill="currentColor"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628a2.25 2.25 0 0 1-1.5-2.122ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM3.5 3.25a.75.75 0 1 1 1.5 0 .75.75 0 0 1-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"/></svg>{{end}} |
| 109 | 109 | |
| 110 | {{define "sigbadge"}}<span class="badge badge-{{.State}}" title="{{.Fingerprint}}">{{.State}}{{if .Signer}} · {{.Signer}}{{end}}</span>{{end}} | |
| 110 | {{define "sigbadge"}}<span class="badge badge-{{.State}}" title="{{.Fingerprint}}">{{sigLabel .State}}{{if .Signer}} · {{.Signer}}{{end}}</span>{{end}} | |
internal/web/templates/login.html +1 −1
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | {{define "title"}}login · {{.Site}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | <h1>log in</h1> | |
| 3 | <h1>Log in</h1> | |
| 4 | 4 | {{if .Error}}<p class="error">{{.Error}}</p>{{end}} |
| 5 | 5 | <p>Browser sessions are minted over SSH — there is no password. From a machine |
| 6 | 6 | with your registered key:</p> |
internal/web/templates/mr.html +58 −23
| @@ -1,24 +1,30 @@ | ||
| 1 | 1 | {{define "title"}}!{{.MR.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | {{$base := printf "/%s/%s/mrs/%d" .Repo.OwnerName .Repo.Name .MR.Number}} | |
| 3 | 4 | <h1 class="issuetitle">{{.MR.Title}} <span class="issuenumber">!{{.MR.Number}}</span></h1> |
| 4 | 5 | <p class="issuemeta"><span class="chip chip-{{.MR.State}}">{{.MR.State}}</span> |
| 5 | <a href="/{{.MR.Author}}">{{.MR.Author}}</a> wants to merge {{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}} into {{.MR.TargetRef}} | |
| 6 | at <code>{{short .MR.HeadSHA}}</code>{{if .MR.Milestone}} · milestone <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">{{.MR.Milestone}}</a>{{end}}</p> | |
| 7 | {{if .CanEdit}}<details class="editbox"><summary>edit</summary> | |
| 8 | <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/{{.MR.Number}}/edit" class="commentform"> | |
| 9 | <p><input type="text" name="title" value="{{.MR.Title}}" required></p> | |
| 10 | <p><textarea name="body" rows="8">{{.MR.Body}}</textarea></p> | |
| 11 | <p><button type="submit">save</button></p> | |
| 6 | <a href="/{{.MR.Author}}">{{.MR.Author}}</a> wants to merge <code>{{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}}</code> into <code>{{.MR.TargetRef}}</code></p> | |
| 7 | ||
| 8 | <div class="withaside"> | |
| 9 | <div class="mainside"> | |
| 10 | ||
| 11 | <nav class="subtabs" aria-label="Merge request"> | |
| 12 | <a {{if eq .View "conversation"}}aria-current="page" {{end}}href="{{$base}}">Conversation{{if .Comments}} <i>{{len .Comments}}</i>{{end}}</a> | |
| 13 | <a {{if eq .View "commits"}}aria-current="page" {{end}}href="{{$base}}?view=commits">Commits{{if .Commits}} <i>{{len .Commits}}</i>{{end}}</a> | |
| 14 | <a {{if eq .View "diff"}}aria-current="page" {{end}}href="{{$base}}?view=diff">Files changed{{if .Stat.Files}} <i>{{.Stat.Files}}</i>{{end}}</a> | |
| 15 | </nav> | |
| 16 | ||
| 17 | {{if eq .View "conversation"}} | |
| 18 | {{if .CanEdit}}<details class="editbox"><summary>Edit</summary> | |
| 19 | <form method="post" action="{{$base}}/edit" class="commentform"> | |
| 20 | <p><input type="text" name="title" aria-label="Title" value="{{.MR.Title}}" required></p> | |
| 21 | <p><textarea name="body" aria-label="Description" rows="8">{{.MR.Body}}</textarea></p> | |
| 22 | <p><button type="submit">Save</button></p> | |
| 12 | 23 | </form></details>{{end}} |
| 13 | 24 | {{if .BodyHTML}}<article class="comment"> |
| 14 | <header class="commenthead"><strong>{{.MR.Author}}</strong></header> | |
| 25 | <header class="commenthead"><strong><a href="/{{.MR.Author}}">{{.MR.Author}}</a></strong></header> | |
| 15 | 26 | <div class="rendered">{{.BodyHTML}}</div> |
| 16 | 27 | </article>{{end}} |
| 17 | {{if .Checks}}<div class="checks"> | |
| 18 | <p>checks: <span class="badge check-{{.Combined}}">{{.Combined}}</span> | |
| 19 | {{range .Checks}} · {{if .TargetURL}}<a href="{{.TargetURL}}" rel="nofollow">{{.Context}}</a>{{else}}{{.Context}}{{end}} <span class="check-{{.State}}">{{.State}}</span>{{end}}</p> | |
| 20 | </div>{{end}} | |
| 21 | {{range .Reviews}}<p class="review">review: <strong>{{.Reviewer}}</strong> — {{.Verdict}}{{if .Stale}} <span class="chip chip-stale">stale</span>{{end}}</p>{{end}} | |
| 22 | 28 | {{range .Comments}} |
| 23 | 29 | {{if eq .Kind "system"}}<div class="syscomment">{{.BodyHTML}} <span class="when">{{when .CreatedAt}}</span></div> |
| 24 | 30 | {{else}}<article class="comment"> |
| @@ -26,13 +32,16 @@ | ||
| 26 | 32 | <div class="rendered">{{.BodyHTML}}</div> |
| 27 | 33 | </article>{{end}} |
| 28 | 34 | {{end}} |
| 35 | {{if .DetachedThreads}}<h2>Threads on earlier revisions</h2> | |
| 36 | {{range .DetachedThreads}}<div class="thread stale"><p class="threadstate">{{if .Stale}}stale{{end}}{{if .Resolved}}{{if .Stale}} · {{end}}resolved by {{.Resolved}}{{end}}</p>{{range .Comments}}<p class="commenthead"><strong>{{.Author}}</strong> <span class="when">{{when .CreatedAt}}</span></p><div class="rendered">{{.BodyHTML}}</div>{{end}}</div>{{end}}{{end}} | |
| 29 | 37 | {{if .Viewer}} |
| 30 | <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/{{.MR.Number}}/comment" class="commentform"> | |
| 31 | <p><textarea name="body" rows="4" placeholder="comment as {{.Viewer}}"></textarea></p> | |
| 32 | <p><button type="submit">comment</button></p> | |
| 38 | <form method="post" action="{{$base}}/comment" class="commentform"> | |
| 39 | <p><textarea name="body" aria-label="Comment" rows="4" placeholder="Comment as {{.Viewer}}"></textarea></p> | |
| 40 | <p><button type="submit">Comment</button></p> | |
| 33 | 41 | </form> |
| 34 | 42 | {{end}} |
| 35 | {{if .Commits}}<h3>commits <span class="count">{{len .Commits}}</span></h3> | |
| 43 | ||
| 44 | {{else if eq .View "commits"}} | |
| 36 | 45 | <ul class="loglist"> |
| 37 | 46 | {{range .Commits}}<li> |
| 38 | 47 | <div class="commitmain"> |
| @@ -44,13 +53,39 @@ | ||
| 44 | 53 | <code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.ShortSHA}}</a></code> |
| 45 | 54 | </div> |
| 46 | 55 | </li> |
| 56 | {{else}}<li class="empty">no commits on this merge request</li> | |
| 47 | 57 | {{end}} |
| 48 | </ul>{{end}} | |
| 49 | <details class="difffold"> | |
| 50 | <summary><h3>diff</h3><span class="meta">{{.Stat.Files}} file{{if ne .Stat.Files 1}}s{{end}} changed, <span class="add">+{{.Stat.Adds}}</span> <span class="del">−{{.Stat.Dels}}</span></span></summary> | |
| 58 | </ul> | |
| 59 | ||
| 60 | {{else}} | |
| 61 | <p class="diffstat">{{.Stat.Files}} file{{if ne .Stat.Files 1}}s{{end}} changed, <span class="add">+{{.Stat.Adds}}</span> <span class="del">−{{.Stat.Dels}}</span></p> | |
| 51 | 62 | <pre class="diff">{{range .DiffLines}}<span class="{{.Class}}">{{.Text}}</span> |
| 52 | 63 | {{range .Threads}}</pre><div class="thread{{if .Resolved}} resolved{{end}}">{{if .Resolved}}<p class="threadstate">resolved by {{.Resolved}}</p>{{end}}{{range .Comments}}<p class="commenthead"><strong>{{.Author}}</strong> <span class="when">{{when .CreatedAt}}</span></p><div class="rendered">{{.BodyHTML}}</div>{{end}}</div><pre class="diff">{{end}}{{end}}</pre> |
| 53 | </details> | |
| 54 | {{if .DetachedThreads}}<h3>threads on earlier revisions</h3> | |
| 55 | {{range .DetachedThreads}}<div class="thread stale"><p class="threadstate">{{if .Stale}}stale{{end}}{{if .Resolved}}{{if .Stale}} · {{end}}resolved by {{.Resolved}}{{end}}</p>{{range .Comments}}<p class="commenthead"><strong>{{.Author}}</strong> <span class="when">{{when .CreatedAt}}</span></p><div class="rendered">{{.BodyHTML}}</div>{{end}}</div>{{end}}{{end}} | |
| 64 | {{end}} | |
| 65 | ||
| 66 | </div> | |
| 67 | ||
| 68 | <aside class="aside"> | |
| 69 | <div class="grp"> | |
| 70 | <h2>Reviews</h2> | |
| 71 | {{range .Reviews}}<p class="row"><span class="dot {{if eq .Verdict "approve"}}ok{{else}}pend{{end}}"></span><a href="/{{.Reviewer}}">{{.Reviewer}}</a> {{.Verdict}}{{if .Stale}} <span class="chip chip-stale">stale</span>{{end}}</p> | |
| 72 | {{else}}<p class="none">No reviews yet</p>{{end}} | |
| 73 | </div> | |
| 74 | <div class="grp"> | |
| 75 | <h2>Checks</h2> | |
| 76 | {{if .Checks}}<p class="row"><span class="badge check-{{.Combined}}">{{.Combined}}</span></p> | |
| 77 | {{range .Checks}}<p class="row"><span class="dot {{if eq .State "success"}}ok{{else if eq .State "failure"}}bad{{else}}pend{{end}}"></span>{{if .TargetURL}}<a href="{{.TargetURL}}" rel="nofollow">{{.Context}}</a>{{else}}{{.Context}}{{end}} <span class="none">{{.State}}</span></p>{{end}} | |
| 78 | {{else}}<p class="none">No checks reported</p>{{end}} | |
| 79 | </div> | |
| 80 | <div class="grp"> | |
| 81 | <h2>Source</h2> | |
| 82 | <p class="row"><code>{{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}}</code></p> | |
| 83 | <p class="row none">into <code>{{.MR.TargetRef}}</code> at <code>{{short .MR.HeadSHA}}</code></p> | |
| 84 | </div> | |
| 85 | {{if .MR.Milestone}}<div class="grp"> | |
| 86 | <h2>Milestone</h2> | |
| 87 | <p class="row"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">{{.MR.Milestone}}</a></p> | |
| 88 | </div>{{end}} | |
| 89 | </aside> | |
| 90 | </div> | |
| 56 | 91 | {{end}} |
internal/web/templates/new.html +6 −6
| @@ -1,15 +1,15 @@ | ||
| 1 | 1 | {{define "title"}}new repository · {{.Site}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | <h1>new repository</h1> | |
| 3 | <h1>New repository</h1> | |
| 4 | 4 | {{if .Error}}<p class="error">{{.Error}}</p>{{end}} |
| 5 | 5 | <form method="post" action="/new"> |
| 6 | <p><label>owner <select name="owner"> | |
| 6 | <p><label>Owner <select name="owner"> | |
| 7 | 7 | <option value="{{.Viewer}}">{{.Viewer}}</option> |
| 8 | 8 | {{range .Orgs}}<option value="{{.}}">{{.}}</option>{{end}} |
| 9 | 9 | </select></label> |
| 10 | <label>/ name <input name="name" required pattern="[a-z0-9][a-z0-9._-]*"></label></p> | |
| 11 | <p><label><input type="radio" name="visibility" value="public" checked> public</label> | |
| 12 | <label><input type="radio" name="visibility" value="private"> private</label></p> | |
| 13 | <p><button type="submit">create</button></p> | |
| 10 | <label>/ Name <input name="name" required pattern="[a-z0-9][a-z0-9._-]*"></label></p> | |
| 11 | <p><label><input type="radio" name="visibility" value="public" checked> Public</label> | |
| 12 | <label><input type="radio" name="visibility" value="private"> Private</label></p> | |
| 13 | <p><button type="submit">Create repository</button></p> | |
| 14 | 14 | </form> |
| 15 | 15 | {{end}} |
internal/web/templates/privacy.html +3 −3
| @@ -1,9 +1,9 @@ | ||
| 1 | 1 | {{define "title"}}privacy · {{.Site}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | 3 | <div class="landing"> |
| 4 | <h1>privacy</h1> | |
| 4 | <h1>Privacy</h1> | |
| 5 | 5 | |
| 6 | <h2>the software</h2> | |
| 6 | <h2>The software</h2> | |
| 7 | 7 | <p>This site runs <a href="https://gitbay.org/krz/gitbay">gitbay</a>, a |
| 8 | 8 | self-hosted, CLI-first git forge. The software makes no external |
| 9 | 9 | requests from your browser: no analytics, no CDNs, no webfonts, no |
| @@ -20,7 +20,7 @@ they are indistinguishable from nonexistent.</p> | ||
| 20 | 20 | downloads your account bundle, git data is yours by clone, and |
| 21 | 21 | <code>gitbay migrate</code> moves everything to another instance.</p> |
| 22 | 22 | |
| 23 | <h2>this instance ({{.Host}})</h2> | |
| 23 | <h2>This instance ({{.Host}})</h2> | |
| 24 | 24 | {{if .Notice}}{{range paragraphs .Notice}}<p>{{.}}</p>{{end}} |
| 25 | 25 | {{else}}<p class="desc">The operator of this instance has not added |
| 26 | 26 | instance-specific notes. Questions about backups, retention, or |
internal/web/templates/register.html +3 −3
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | {{define "title"}}register · {{.Site}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | 3 | <div class="landing"> |
| 4 | <h1>create an account</h1> | |
| 4 | <h1>Create an account</h1> | |
| 5 | 5 | {{if eq .Mode "invite"}}<p class="lede">This instance is invite-only: you need an invite code from an admin.</p> |
| 6 | 6 | {{else}}<p class="lede">Open registration — your account activates once you verify your email.</p>{{end}} |
| 7 | 7 | {{if .Error}}<p class="error">{{.Error}}</p>{{end}} |
| @@ -10,8 +10,8 @@ | ||
| 10 | 10 | {{if eq .Mode "invite"}}<p><label>invite code<br><input type="text" name="invite" required></label></p> |
| 11 | 11 | {{else}}<p><label>email<br><input type="text" name="email" required></label></p>{{end}} |
| 12 | 12 | <p><label>SSH public key — your key is your identity; paste e.g. <code>~/.ssh/id_ed25519.pub</code><br> |
| 13 | <textarea name="key" rows="3" required placeholder="ssh-ed25519 AAAA... you@host"></textarea></label></p> | |
| 14 | <p><button type="submit">create account</button></p> | |
| 13 | <textarea name="key" aria-label="SSH public key" rows="3" required placeholder="ssh-ed25519 AAAA... you@host"></textarea></label></p> | |
| 14 | <p><button type="submit">Create account</button></p> | |
| 15 | 15 | </form> |
| 16 | 16 | <p class="meta">Prefer the terminal? <code>ssh git@{{.Host}} register --username you {{if eq .Mode "invite"}}--invite <code>{{else}}--email you@example.org{{end}}</code></p> |
| 17 | 17 | </div> |
internal/web/templates/registered.html +1 −1
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | {{define "title"}}welcome · {{.Site}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | 3 | <div class="landing"> |
| 4 | <h1>welcome, {{.Username}}</h1> | |
| 4 | <h1>Welcome, {{.Username}}</h1> | |
| 5 | 5 | <pre class="quickstart">{{.Message}}</pre> |
| 6 | 6 | <p>Everything runs over SSH with the key you registered:</p> |
| 7 | 7 | <pre class="quickstart">ssh git@{{.Host}} whoami |
internal/web/templates/search.html +1 −1
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | {{define "content"}} |
| 3 | 3 | <h1>Search</h1> |
| 4 | 4 | <form method="get" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/search" class="searchform"> |
| 5 | <input type="search" name="q" value="{{.Query}}" placeholder="search file contents on {{.Ref}}" autofocus> | |
| 5 | <input type="search" name="q" aria-label="Search file contents" value="{{.Query}}" placeholder="search file contents on {{.Ref}}" autofocus> | |
| 6 | 6 | <button type="submit">search</button> |
| 7 | 7 | </form> |
| 8 | 8 | {{if .QueryErr}}<p class="error">{{.QueryErr}}</p> |
internal/web/templates/tree.html +17 −6
| @@ -5,14 +5,25 @@ | ||
| 5 | 5 | {{template "refmenu" .}} |
| 6 | 6 | <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}</span> |
| 7 | 7 | <span class="spacer"></span> |
| 8 | <span class="clone">clone: <code>git clone {{.CloneURL}}</code></span> | |
| 8 | <a class="act" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/search">Find file</a> | |
| 9 | <a class="act" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log/{{.Ref}}">History</a> | |
| 10 | <a class="act" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/archive/{{.Ref}}.tar.gz">Download</a> | |
| 9 | 11 | </div> |
| 12 | {{if .Entries}}<p class="clone">Clone: <code>git clone {{.CloneURL}}</code></p>{{end}} | |
| 13 | {{with .Tip}}{{if .SHA}}<div class="tipbar"> | |
| 14 | <span class="who">{{.Author}}</span> | |
| 15 | <a class="subject" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Subject}}</a> | |
| 16 | <span class="spacer"></span> | |
| 17 | <a class="sha" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}"><code>{{short .SHA}}</code></a> | |
| 18 | <span class="age">{{ago .When}}</span> | |
| 19 | </div>{{end}}{{end}} | |
| 10 | 20 | <table class="tree"> |
| 11 | <tr class="cols"><th>name</th><th class="size">size</th><th class="mode">mode</th></tr> | |
| 12 | {{range .Entries}}<tr> | |
| 13 | {{if eq .Type "tree"}}<td class="name"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/tree/{{$.Ref}}/{{$.Prefix}}{{.Name}}">{{.Name}}/</a></td><td class="size"></td> | |
| 14 | {{else}}<td class="name"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/blob/{{$.Ref}}/{{$.Prefix}}{{.Name}}">{{.Name}}</a></td><td class="size">{{.Size}}</td>{{end}} | |
| 15 | <td class="mode">{{.Mode}}</td> | |
| 21 | <tr class="cols"><th scope="col">name</th><th scope="col" class="lastcommit">last commit</th><th scope="col" class="age">updated</th></tr> | |
| 22 | {{range .Entries}}{{$c := index $.LastCommits .Name}}<tr> | |
| 23 | {{if eq .Type "tree"}}<td class="name dir"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/tree/{{$.Ref}}/{{$.Prefix}}{{.Name}}">{{.Name}}/</a></td> | |
| 24 | {{else}}<td class="name"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/blob/{{$.Ref}}/{{$.Prefix}}{{.Name}}">{{.Name}}</a></td>{{end}} | |
| 25 | <td class="lastcommit">{{with $c.Subject}}<a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{$c.SHA}}" title="{{.}}">{{.}}</a>{{end}}</td> | |
| 26 | <td class="age">{{ago $c.When}}</td> | |
| 16 | 27 | </tr> |
| 17 | 28 | {{else}}<tr><td class="name empty" colspan="3">this repository is empty — push something:<br><code>git remote add origin {{.CloneURL}}</code></td></tr>{{end}} |
| 18 | 29 | </table> |
internal/web/web.go +53
| @@ -8,6 +8,7 @@ import ( | ||
| 8 | 8 | "io" |
| 9 | 9 | "reflect" |
| 10 | 10 | "runtime/debug" |
| 11 | "strconv" | |
| 11 | 12 | "strings" |
| 12 | 13 | "sync" |
| 13 | 14 | "time" |
| @@ -131,6 +132,58 @@ var funcs = template.FuncMap{ | ||
| 131 | 132 | } |
| 132 | 133 | return f.Interface() |
| 133 | 134 | }, |
| 135 | // sigLabel turns a stored signature state into words. The state names | |
| 136 | // are the CLI's vocabulary and belong in its output; a reader of the | |
| 137 | // web UI needs to know what is wrong, not what the column is called. | |
| 138 | "sigLabel": func(state string) string { | |
| 139 | switch state { | |
| 140 | case "verified": | |
| 141 | return "Verified" | |
| 142 | case "signed_unknown_key": | |
| 143 | return "Unregistered key" | |
| 144 | case "signed_email_mismatch": | |
| 145 | return "Email mismatch" | |
| 146 | case "signed_key_expired": | |
| 147 | return "Key expired" | |
| 148 | case "signed_key_revoked": | |
| 149 | return "Key revoked" | |
| 150 | case "bad_signature": | |
| 151 | return "Bad signature" | |
| 152 | case "unsigned": | |
| 153 | return "Unsigned" | |
| 154 | } | |
| 155 | return state | |
| 156 | }, | |
| 157 | // ago renders a time as a coarse relative age, which is what a | |
| 158 | // listing column is actually read for. The zero time yields "". | |
| 159 | "ago": func(t time.Time) string { | |
| 160 | if t.IsZero() { | |
| 161 | return "" | |
| 162 | } | |
| 163 | d := time.Since(t) | |
| 164 | if d < 0 { | |
| 165 | d = 0 | |
| 166 | } | |
| 167 | plural := func(n int, unit string) string { | |
| 168 | if n == 1 { | |
| 169 | return "1 " + unit + " ago" | |
| 170 | } | |
| 171 | return strconv.Itoa(n) + " " + unit + "s ago" | |
| 172 | } | |
| 173 | switch { | |
| 174 | case d < time.Minute: | |
| 175 | return "just now" | |
| 176 | case d < time.Hour: | |
| 177 | return plural(int(d/time.Minute), "minute") | |
| 178 | case d < 24*time.Hour: | |
| 179 | return plural(int(d/time.Hour), "hour") | |
| 180 | case d < 30*24*time.Hour: | |
| 181 | return plural(int(d/(24*time.Hour)), "day") | |
| 182 | case d < 365*24*time.Hour: | |
| 183 | return plural(int(d/(30*24*time.Hour)), "month") | |
| 184 | } | |
| 185 | return plural(int(d/(365*24*time.Hour)), "year") | |
| 186 | }, | |
| 134 | 187 | // when formats a stored RFC3339 timestamp for display; unparseable |
| 135 | 188 | // values pass through unchanged. |
| 136 | 189 | "when": func(s string) string { |