A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 235ad49736

235ad4973643a1d36743915dea455c6695399e50

parent: 027899f05d

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-26T01:22:36Z

web: last-commit file listing, diff view, labelled controls

Steps three through five of the redesign.

File listing: name, last commit, updated. The two data columns were raw
byte counts and octal modes, neither of which anyone scans a file table
for. One git log walk serves the whole listing rather than one process
per row — it streams newest-first and is killed as soon as every entry is
accounted for, so a directory resolves in the few commits it needs
regardless of history depth, bounded at 2000 for the pathological case.
A tip-commit bar above the table answers who touched the repository last.
The facts the listing dropped move to the blob page, which now reports
line count, byte size, and the executable or symlink bit.

Merge requests: the diff was a collapsed <details> at the foot of the
conversation, which inverted the reason most people open the page. It now
has its own view alongside conversation and commits, selected by a query
parameter so nothing needs JavaScript. Reviews and checks were plain text
lines; they move to a metadata column beside the content.

Signature states render as words. signed_unknown_key is the CLI's
vocabulary, and the badge class keeps it for styling, but a reader needs
to know the key is unregistered.

Forms: select controls inherited only the font, so they rendered as OS
widgets beside styled inputs. Every control now shares one shell, with a
drawn chevron since the native arrow cannot be restyled. Placeholders
were standing in as labels on search, comment, title and body fields;
each control is named explicitly now.

Tests updated where they assert on display copy or on the diff's old
position in the conversation.
e2e/dashboard_test.go +13 −6
@@ -93,7 +93,7 @@ func TestDashboard(t *testing.T) {
9393 t.Fatalf("dashboard: %d", status)
9494 }
9595 for _, want := range []string{
96 ">pinned</h2>", ">app<", // pinned card
96 ">Pinned</h2>", ">app<", // pinned card
9797 "alice/app!1 from bob", "alice/app#1 todo one",
9898 `href="/alice/app/mrs/1"`, `href="/alice/app/issues/1"`,
9999 } {
@@ -102,11 +102,18 @@ func TestDashboard(t *testing.T) {
102102 }
103103 }
104104
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.
106108 _, 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)
111118 }
112119 }
e2e/design_test.go +1 −1
@@ -51,7 +51,7 @@ func TestReadmeRelativeLinks(t *testing.T) {
5151 "<table>", "<td>verbose</td>", // GFM table renders
5252 `<span class="kd">func</span>`, // fenced code highlighted via classes
5353 `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
5555 } {
5656 if !strings.Contains(body, want) {
5757 t.Errorf("missing %q", want)
e2e/diffcomment_test.go +4 −3
@@ -92,12 +92,13 @@ func TestDiffComments(t *testing.T) {
9292 if !strings.Contains(out, `"unresolved_threads":1`) {
9393 t.Fatalf("mr show count: %s", out)
9494 }
95 status, body := inst.get(t, "/alice/lib/mrs/1")
95 status, body := inst.get(t, "/alice/lib/mrs/1?view=diff")
9696 if status != 200 || !strings.Contains(body, "use log instead of fmt") ||
9797 !strings.Contains(body, `class="thread`) {
9898 t.Fatalf("web thread: %d", status)
9999 }
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") {
101102 t.Fatal("fresh thread rendered as detached")
102103 }
103104
@@ -126,7 +127,7 @@ func TestDiffComments(t *testing.T) {
126127 t.Fatalf("thread not stale after force-push: %s", out)
127128 }
128129 _, 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") {
130131 t.Fatal("stale thread not moved to detached section")
131132 }
132133 }
e2e/mr_test.go +6 −5
@@ -264,12 +264,13 @@ func TestMergeRequests(t *testing.T) {
264264 if status != 200 || !strings.Contains(body, "stale") || !strings.Contains(body, "merged") {
265265 t.Fatalf("mr detail: %d\n%s", status, body)
266266 }
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)
269269 }
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)
273274 }
274275 // So does mr show, human and JSON.
275276 showOut, _, code := inst.ssh(t, aliceKey, "", "mr", "show", "alice/lib", "1")
internal/gitutil/lastcommit.go added +157
@@ -0,0 +1,157 @@
1package gitutil
2
3import (
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.
12type 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.
23const 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.
33func 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.
94func 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".
108func 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".
119func 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.
138func 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 @@
1package gitutil
2
3import (
4 "os"
5 "os/exec"
6 "path/filepath"
7 "testing"
8)
9
10func 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
21func 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
32func 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) {
7979 // the stock plain-text response if the template fails.
8080 func (s *Server) notFound(w http.ResponseWriter, r *http.Request) {
8181 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 {
8583 http.NotFound(w, r)
8684 return
8785 }
@@ -173,14 +171,6 @@ func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
173171 }{s.baseFor(viewer), q, s.filterRepos(q, s.describeAll(repos))})
174172 }
175173
176// viewerName returns the logged-in username for header rendering, or "".
177func (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
184174 // privacy renders the privacy page: what the gitbay software does with
185175 // data, plus this instance's operator-provided notes.
186176 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,
425415 // Empty repo: render the page with no entries rather than 404.
426416 s.render(w, "tree.html", struct {
427417 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
436428 }{repoPage: p, RefKind: "tree"})
437429 return
438430 }
@@ -455,17 +447,26 @@ func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage,
455447 }
456448
457449 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 }
458454 s.render(w, "tree.html", struct {
459455 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)})
469470 }
470471
471472 func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
@@ -494,6 +495,16 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
494495 cs = cs[:len(cs)-1]
495496 }
496497 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)
497508 s.render(w, "blob.html", struct {
498509 repoPage
499510 Crumbs []crumb
@@ -504,9 +515,13 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
504515 Binary bool
505516 Image bool
506517 Size int
518 Lines int
519 Exec bool
520 Symlink bool
507521 Branches []gitutil.Ref
508522 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})
510525 }
511526
512527 // releases lists tag-anchored releases with notes and assets.
@@ -1450,9 +1465,17 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
14501465 commits = append(commits, cr)
14511466 }
14521467 }
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 }
14531475 s.render(w, "mr.html", struct {
14541476 repoPage
14551477 MR store.MR
1478 View string
14561479 BodyHTML template.HTML
14571480 Checks []store.CommitStatus
14581481 Combined string
@@ -1463,7 +1486,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
14631486 Commits []commitRow
14641487 CanEdit bool
14651488 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),
14671490 reviews, lines, stat, commits, s.canEditItem(r, p.Repo, m.Author), detachedThreads})
14681491 }
14691492
internal/web/static/style.css +128 −8
@@ -86,6 +86,8 @@
8686 --sp-5: 1.5rem;
8787 --sp-6: 2.5rem;
8888
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
8991 /* radii: 2px everywhere — chart plates, not pills */
9092 --r-sm: 2px;
9193 --r-md: 2px;
@@ -111,6 +113,7 @@
111113 --bad: #ff6b6b;
112114 --done: #c084fc;
113115 --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");
114117 }
115118 }
116119
@@ -936,27 +939,47 @@ details.editbox input[type="text"] { width: 100%; }
936939 .thread .rendered p { margin: var(--sp-1) 0; }
937940 .thread .threadstate { color: var(--muted); font-size: var(--fs-1); margin: var(--sp-1) 0 0; }
938941
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 */
940944 input, textarea, button, select { font: inherit; color: inherit; }
941input[type="text"], input:not([type]), textarea {
945input[type="text"], input[type="search"], input[type="email"],
946input[type="password"], input:not([type]), textarea, select {
942947 background: var(--bg);
948 color: var(--fg);
943949 border: 1px solid var(--line);
944950 border-radius: var(--r-md);
945951 padding: var(--sp-2) var(--sp-3);
952 line-height: 1.4;
946953 }
947954 textarea { width: 100%; resize: vertical; }
948955 textarea[name="content"] { font-family: var(--mono); font-size: var(--fs-1); }
949input: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 */
958select {
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}
967input:hover, textarea:hover, select:hover { border-color: var(--muted); }
968/* the global :focus-visible ring covers these; only the border moves */
969input:focus, textarea:focus, select:focus { border-color: var(--accent); }
970input[type="radio"], input[type="checkbox"] { accent-color: var(--fill); }
971label:has(> input[type="radio"]), label:has(> input[type="checkbox"]),
972label:has(> select) { display: inline-flex; align-items: center; gap: var(--sp-2); }
950973 button {
951 background: var(--accent);
952 color: var(--accent-fg);
974 background: var(--fill);
975 color: #ffffff;
953976 border: none;
954977 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;
957980 cursor: pointer;
958981 }
959button:hover { filter: brightness(1.08); }
982button:hover { filter: brightness(1.15); }
960983
961984 /* 404 */
962985 .notfound { text-align: center; padding: var(--sp-6) 0; }
@@ -1087,3 +1110,100 @@ pre.matchline mark {
10871110 padding: var(--sp-2) var(--sp-3);
10881111 }
10891112 }
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); }
1133table.tree th.lastcommit, table.tree td.lastcommit { width: 55%; }
1134table.tree td.lastcommit a { color: var(--muted); display: block; overflow: hidden;
1135 text-overflow: ellipsis; white-space: nowrap; }
1136table.tree td.lastcommit a:hover { color: var(--accent); }
1137table.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}
1143table.tree td.name { width: 25%; white-space: nowrap; }
1144table.tree td.name.dir a { color: var(--accent); }
1145p.clone { margin: 0 0 var(--sp-3); }
1146p.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 */
1181nav.subtabs {
1182 display: flex;
1183 gap: var(--sp-4);
1184 border-bottom: 1px solid var(--line);
1185 margin: 0 0 var(--sp-4);
1186}
1187nav.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}
1197nav.subtabs a:hover { color: var(--fg); text-decoration: none; }
1198nav.subtabs a[aria-current] { color: var(--fg); font-weight: 700; border-bottom-color: var(--mark); }
1199nav.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}
1207p.diffstat { color: var(--muted); font-size: var(--fs-2); margin: 0 0 var(--sp-3); }
1208p.diffstat .add { color: var(--ok); }
1209p.diffstat .del { color: var(--bad); }
internal/web/templates/404.html +1 −1
@@ -2,7 +2,7 @@
22 {{define "content"}}
33 <div class="notfound">
44 <p class="bigcode">404</p>
5 <h1>page not found</h1>
5 <h1>Page not found</h1>
66 <p class="desc">nothing lives at this address — the path may be wrong,
77 or the repository may be private.</p>
88 <p><a href="/">← back to {{.Site}}</a></p>
internal/web/templates/blob.html +1
@@ -7,6 +7,7 @@
77 <span class="spacer"></span>
88 <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>
99 </div>
10<p class="filefacts">{{if .Lines}}{{.Lines}} lines · {{end}}{{.Size}} bytes{{if .Exec}} · executable{{end}}{{if .Symlink}} · symlink{{end}}</p>
1011 {{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>
1112 {{else if .Binary}}<p class="empty-note">binary file, {{.Size}} bytes — <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">download</a></p>
1213 {{else}}<div class="code">{{.CodeHTML}}</div>{{end}}
internal/web/templates/dashboard.html +4 −4
@@ -1,16 +1,16 @@
11 {{define "title"}}dashboard · {{.Site}}{{end}}
22 {{define "content"}}
33 <div class="headrow">
4<h1>dashboard</h1>
4<h1>Dashboard</h1>
55 <span class="spacer"></span>
66 <p class="toolbar">logged in as <a href="/{{.Viewer}}">{{.Viewer}}</a></p>
77 </div>
8{{if .Pinned}}<h2>pinned</h2>
8{{if .Pinned}}<h2>Pinned</h2>
99 <ul class="pinlist">
1010 {{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>
1111 {{end}}
1212 </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>
1414 <ul class="issuelist">
1515 {{range .MRs}}<li>
1616 <div class="issuemain">
@@ -20,7 +20,7 @@
2020 </li>
2121 {{else}}<li class="empty">no open merge requests</li>{{end}}
2222 </ul>
23<h2>open issues <span class="count">{{len .Issues}}</span></h2>
23<h2>Open issues <span class="count">{{len .Issues}}</span></h2>
2424 <ul class="issuelist">
2525 {{range .Issues}}<li>
2626 <div class="issuemain">
internal/web/templates/edit.html +3 −3
@@ -3,9 +3,9 @@
33 <h1>edit {{.Repo.OwnerName}}/{{.Repo.Name}} : {{.Path}} @ {{.Ref}}</h1>
44 {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
55 <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>
99 <p class="crumbs">this commit will be unsigned and authored as {{.Viewer}}</p>
1010 </form>
1111 {{end}}
internal/web/templates/explore.html +2 −2
@@ -1,9 +1,9 @@
11 {{define "title"}}explore · {{.Site}}{{end}}
22 {{define "content"}}
33 <div class="headrow">
4<h1>explore</h1>
4<h1>Explore</h1>
55 <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">
77 </form>
88 <span class="spacer"></span>
99 </div>
internal/web/templates/issue.html +4 −4
@@ -8,9 +8,9 @@
88 {{if .Issue.Milestone}} · milestone <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">{{.Issue.Milestone}}</a>{{end}}</p>
99 {{if .CanEdit}}<details class="editbox"><summary>edit</summary>
1010 <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>
1414 <p><button type="submit">save</button></p>
1515 </form></details>{{end}}
1616 {{if .BodyHTML}}<article class="comment">
@@ -26,7 +26,7 @@
2626 {{end}}
2727 {{if .Viewer}}
2828 <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>
3030 <p><button type="submit">comment</button></p>
3131 </form>
3232 {{end}}
internal/web/templates/issuenew.html +4 −4
@@ -4,9 +4,9 @@
44 {{if gt (len .Templates) 1}}<p class="meta">template:
55 {{range .Templates}}{{if eq .Name $.Template}}<strong>{{.Name}}</strong>{{else}}<a href="?template={{.Name}}">{{.Name}}</a>{{end}} {{end}}</p>{{end}}
66 <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>
1111 </form>
1212 {{end}}
internal/web/templates/layout.html +1 −1
@@ -107,4 +107,4 @@
107107
108108 {{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}}
109109
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 @@
11 {{define "title"}}login · {{.Site}}{{end}}
22 {{define "content"}}
3<h1>log in</h1>
3<h1>Log in</h1>
44 {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
55 <p>Browser sessions are minted over SSH — there is no password. From a machine
66 with your registered key:</p>
internal/web/templates/mr.html +58 −23
@@ -1,24 +1,30 @@
11 {{define "title"}}!{{.MR.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
22 {{define "content"}}
3{{$base := printf "/%s/%s/mrs/%d" .Repo.OwnerName .Repo.Name .MR.Number}}
34 <h1 class="issuetitle">{{.MR.Title}} <span class="issuenumber">!{{.MR.Number}}</span></h1>
45 <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>
1223 </form></details>{{end}}
1324 {{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>
1526 <div class="rendered">{{.BodyHTML}}</div>
1627 </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}}
2228 {{range .Comments}}
2329 {{if eq .Kind "system"}}<div class="syscomment">{{.BodyHTML}} <span class="when">{{when .CreatedAt}}</span></div>
2430 {{else}}<article class="comment">
@@ -26,13 +32,16 @@
2632 <div class="rendered">{{.BodyHTML}}</div>
2733 </article>{{end}}
2834 {{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}}
2937 {{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>
3341 </form>
3442 {{end}}
35{{if .Commits}}<h3>commits <span class="count">{{len .Commits}}</span></h3>
43
44{{else if eq .View "commits"}}
3645 <ul class="loglist">
3746 {{range .Commits}}<li>
3847 <div class="commitmain">
@@ -44,13 +53,39 @@
4453 <code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.ShortSHA}}</a></code>
4554 </div>
4655 </li>
56{{else}}<li class="empty">no commits on this merge request</li>
4757 {{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>
5162 <pre class="diff">{{range .DiffLines}}<span class="{{.Class}}">{{.Text}}</span>
5263 {{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>
5691 {{end}}
internal/web/templates/new.html +6 −6
@@ -1,15 +1,15 @@
11 {{define "title"}}new repository · {{.Site}}{{end}}
22 {{define "content"}}
3<h1>new repository</h1>
3<h1>New repository</h1>
44 {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
55 <form method="post" action="/new">
6<p><label>owner <select name="owner">
6<p><label>Owner <select name="owner">
77 <option value="{{.Viewer}}">{{.Viewer}}</option>
88 {{range .Orgs}}<option value="{{.}}">{{.}}</option>{{end}}
99 </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>
1414 </form>
1515 {{end}}
internal/web/templates/privacy.html +3 −3
@@ -1,9 +1,9 @@
11 {{define "title"}}privacy · {{.Site}}{{end}}
22 {{define "content"}}
33 <div class="landing">
4<h1>privacy</h1>
4<h1>Privacy</h1>
55
6<h2>the software</h2>
6<h2>The software</h2>
77 <p>This site runs <a href="https://gitbay.org/krz/gitbay">gitbay</a>, a
88 self-hosted, CLI-first git forge. The software makes no external
99 requests from your browser: no analytics, no CDNs, no webfonts, no
@@ -20,7 +20,7 @@ they are indistinguishable from nonexistent.</p>
2020 downloads your account bundle, git data is yours by clone, and
2121 <code>gitbay migrate</code> moves everything to another instance.</p>
2222
23<h2>this instance ({{.Host}})</h2>
23<h2>This instance ({{.Host}})</h2>
2424 {{if .Notice}}{{range paragraphs .Notice}}<p>{{.}}</p>{{end}}
2525 {{else}}<p class="desc">The operator of this instance has not added
2626 instance-specific notes. Questions about backups, retention, or
internal/web/templates/register.html +3 −3
@@ -1,7 +1,7 @@
11 {{define "title"}}register · {{.Site}}{{end}}
22 {{define "content"}}
33 <div class="landing">
4<h1>create an account</h1>
4<h1>Create an account</h1>
55 {{if eq .Mode "invite"}}<p class="lede">This instance is invite-only: you need an invite code from an admin.</p>
66 {{else}}<p class="lede">Open registration — your account activates once you verify your email.</p>{{end}}
77 {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
@@ -10,8 +10,8 @@
1010 {{if eq .Mode "invite"}}<p><label>invite code<br><input type="text" name="invite" required></label></p>
1111 {{else}}<p><label>email<br><input type="text" name="email" required></label></p>{{end}}
1212 <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>
1515 </form>
1616 <p class="meta">Prefer the terminal? <code>ssh git@{{.Host}} register --username you {{if eq .Mode "invite"}}--invite &lt;code&gt;{{else}}--email you@example.org{{end}}</code></p>
1717 </div>
internal/web/templates/registered.html +1 −1
@@ -1,7 +1,7 @@
11 {{define "title"}}welcome · {{.Site}}{{end}}
22 {{define "content"}}
33 <div class="landing">
4<h1>welcome, {{.Username}}</h1>
4<h1>Welcome, {{.Username}}</h1>
55 <pre class="quickstart">{{.Message}}</pre>
66 <p>Everything runs over SSH with the key you registered:</p>
77 <pre class="quickstart">ssh git@{{.Host}} whoami
internal/web/templates/search.html +1 −1
@@ -2,7 +2,7 @@
22 {{define "content"}}
33 <h1>Search</h1>
44 <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>
66 <button type="submit">search</button>
77 </form>
88 {{if .QueryErr}}<p class="error">{{.QueryErr}}</p>
internal/web/templates/tree.html +17 −6
@@ -5,14 +5,25 @@
55 {{template "refmenu" .}}
66 <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}</span>
77 <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>
911 </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}}
1020 <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>
1627 </tr>
1728 {{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}}
1829 </table>
internal/web/web.go +53
@@ -8,6 +8,7 @@ import (
88 "io"
99 "reflect"
1010 "runtime/debug"
11 "strconv"
1112 "strings"
1213 "sync"
1314 "time"
@@ -131,6 +132,58 @@ var funcs = template.FuncMap{
131132 }
132133 return f.Interface()
133134 },
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 },
134187 // when formats a stored RFC3339 timestamp for display; unparseable
135188 // values pass through unchanged.
136189 "when": func(s string) string {