Commit 235ad49736

235ad4973643a1d36743915dea455c6695399e50

parent: 027899f05d

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-26 01:22 UTC

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.

Layout: unified · split

e2e/dashboard_test.go +13 −6
@@ -93,7 +93,7 @@ func TestDashboard(t *testing.T) {
93 t.Fatalf("dashboard: %d", status) 93 t.Fatalf("dashboard: %d", status)
94 } 94 }
95 for _, want := range []string{ 95 for _, want := range []string{
96 ">pinned</h2>", ">app<", // pinned card 96 ">Pinned</h2>", ">app<", // pinned card
97 "alice/app!1 from bob", "alice/app#1 todo one", 97 "alice/app!1 from bob", "alice/app#1 todo one",
98 `href="/alice/app/mrs/1"`, `href="/alice/app/issues/1"`, 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 _, body = inst.get(t, "/alice/app/mrs/1") 108 _, body = inst.get(t, "/alice/app/mrs/1")
107 if !strings.Contains(body, `<details class="difffold">`) || 109 if !strings.Contains(body, `href="/alice/app/mrs/1?view=diff"`) {
108 strings.Contains(body, `<details class="difffold" open`) || 110 t.Fatal("merge request missing the files-changed view")
109 !strings.Contains(body, "1 file changed") { 111 }
110 t.Fatal("diff not collapsed with stats") 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 "<table>", "<td>verbose</td>", // GFM table renders 51 "<table>", "<td>verbose</td>", // GFM table renders
52 `<span class="kd">func</span>`, // fenced code highlighted via classes 52 `<span class="kd">func</span>`, // fenced code highlighted via classes
53 `href="/alice/site/blob/main/README.md">README.md</a>`, // clickable card header 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 if !strings.Contains(body, want) { 56 if !strings.Contains(body, want) {
57 t.Errorf("missing %q", want) 57 t.Errorf("missing %q", want)
e2e/diffcomment_test.go +4 −3
@@ -92,12 +92,13 @@ func TestDiffComments(t *testing.T) {
92 if !strings.Contains(out, `"unresolved_threads":1`) { 92 if !strings.Contains(out, `"unresolved_threads":1`) {
93 t.Fatalf("mr show count: %s", out) 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 if status != 200 || !strings.Contains(body, "use log instead of fmt") || 96 if status != 200 || !strings.Contains(body, "use log instead of fmt") ||
97 !strings.Contains(body, `class="thread`) { 97 !strings.Contains(body, `class="thread`) {
98 t.Fatalf("web thread: %d", status) 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 t.Fatal("fresh thread rendered as detached") 102 t.Fatal("fresh thread rendered as detached")
102 } 103 }
103 104
@@ -126,7 +127,7 @@ func TestDiffComments(t *testing.T) {
126 t.Fatalf("thread not stale after force-push: %s", out) 127 t.Fatalf("thread not stale after force-push: %s", out)
127 } 128 }
128 _, body = inst.get(t, "/alice/lib/mrs/1") 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 t.Fatal("stale thread not moved to detached section") 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 if status != 200 || !strings.Contains(body, "stale") || !strings.Contains(body, "merged") { 264 if status != 200 || !strings.Contains(body, "stale") || !strings.Contains(body, "merged") {
265 t.Fatalf("mr detail: %d\n%s", status, body) 265 t.Fatalf("mr detail: %d\n%s", status, body)
266 } 266 }
267 if !strings.Contains(body, "feature.txt") { 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", body) 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. 270 // The MR lists the commits it carries, linked to commit pages.
271 if !strings.Contains(body, ">commits <") || !strings.Contains(body, "/alice/lib/commit/") { 271 _, commits := inst.get(t, "/alice/lib/mrs/1?view=commits")
272 t.Fatalf("mr commits section missing:\n%s", body) 272 if !strings.Contains(commits, "/alice/lib/commit/") {
273 t.Fatalf("mr commits view missing:\n%s", commits)
273 } 274 }
274 // So does mr show, human and JSON. 275 // So does mr show, human and JSON.
275 showOut, _, code := inst.ssh(t, aliceKey, "", "mr", "show", "alice/lib", "1") 276 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) {
79// the stock plain-text response if the template fails. 79// the stock plain-text response if the template fails.
80func (s *Server) notFound(w http.ResponseWriter, r *http.Request) { 80func (s *Server) notFound(w http.ResponseWriter, r *http.Request) {
81 var buf bytes.Buffer 81 var buf bytes.Buffer
82 if err := web.Render(&buf, "404.html", basePage{ 82 if err := web.Render(&buf, "404.html", s.base(r)); err != nil {
83 Site: s.siteName(), Viewer: s.viewerName(r),
84 }); err != nil {
85 http.NotFound(w, r) 83 http.NotFound(w, r)
86 return 84 return
87 } 85 }
@@ -173,14 +171,6 @@ func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
173 }{s.baseFor(viewer), q, s.filterRepos(q, s.describeAll(repos))}) 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 "".
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
184// privacy renders the privacy page: what the gitbay software does with 174// privacy renders the privacy page: what the gitbay software does with
185// data, plus this instance's operator-provided notes. 175// data, plus this instance's operator-provided notes.
186func (s *Server) privacy(w http.ResponseWriter, r *http.Request) { 176func (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 // Empty repo: render the page with no entries rather than 404. 415 // Empty repo: render the page with no entries rather than 404.
426 s.render(w, "tree.html", struct { 416 s.render(w, "tree.html", struct {
427 repoPage 417 repoPage
428 Crumbs []crumb 418 Crumbs []crumb
429 Prefix string 419 Prefix string
430 DirPath string 420 DirPath string
431 RefKind string 421 RefKind string
432 Entries []gitutil.TreeEntry 422 Entries []gitutil.TreeEntry
433 Branches []gitutil.Ref 423 Branches []gitutil.Ref
434 ReadmeName string 424 ReadmeName string
435 ReadmeHTML template.HTML 425 ReadmeHTML template.HTML
426 LastCommits map[string]gitutil.EntryCommit
427 Tip gitutil.EntryCommit
436 }{repoPage: p, RefKind: "tree"}) 428 }{repoPage: p, RefKind: "tree"})
437 return 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 branches, _ := gitutil.Refs(p.Dir, "heads") 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 s.render(w, "tree.html", struct { 454 s.render(w, "tree.html", struct {
459 repoPage 455 repoPage
460 Crumbs []crumb 456 Crumbs []crumb
461 Prefix string 457 Prefix string
462 DirPath string 458 DirPath string
463 RefKind string 459 RefKind string
464 Entries []gitutil.TreeEntry 460 Entries []gitutil.TreeEntry
465 Branches []gitutil.Ref 461 Branches []gitutil.Ref
466 ReadmeName string 462 ReadmeName string
467 ReadmeHTML template.HTML 463 ReadmeHTML template.HTML
468 }{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches, readmeName, readmeHTML}) 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
471func (s *Server) blob(w http.ResponseWriter, r *http.Request) { 472func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
@@ -494,6 +495,16 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
494 cs = cs[:len(cs)-1] 495 cs = cs[:len(cs)-1]
495 } 496 }
496 branches, _ := gitutil.Refs(p.Dir, "heads") 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 s.render(w, "blob.html", struct { 508 s.render(w, "blob.html", struct {
498 repoPage 509 repoPage
499 Crumbs []crumb 510 Crumbs []crumb
@@ -504,9 +515,13 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
504 Binary bool 515 Binary bool
505 Image bool 516 Image bool
506 Size int 517 Size int
518 Lines int
519 Exec bool
520 Symlink bool
507 Branches []gitutil.Ref 521 Branches []gitutil.Ref
508 CodeHTML template.HTML 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// releases lists tag-anchored releases with notes and assets. 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 commits = append(commits, cr) 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 s.render(w, "mr.html", struct { 1475 s.render(w, "mr.html", struct {
1454 repoPage 1476 repoPage
1455 MR store.MR 1477 MR store.MR
1478 View string
1456 BodyHTML template.HTML 1479 BodyHTML template.HTML
1457 Checks []store.CommitStatus 1480 Checks []store.CommitStatus
1458 Combined string 1481 Combined string
@@ -1463,7 +1486,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1463 Commits []commitRow 1486 Commits []commitRow
1464 CanEdit bool 1487 CanEdit bool
1465 DetachedThreads []diffThread 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 reviews, lines, stat, commits, s.canEditItem(r, p.Repo, m.Author), detachedThreads}) 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 --sp-5: 1.5rem; 86 --sp-5: 1.5rem;
87 --sp-6: 2.5rem; 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 /* radii: 2px everywhere — chart plates, not pills */ 91 /* radii: 2px everywhere — chart plates, not pills */
90 --r-sm: 2px; 92 --r-sm: 2px;
91 --r-md: 2px; 93 --r-md: 2px;
@@ -111,6 +113,7 @@
111 --bad: #ff6b6b; 113 --bad: #ff6b6b;
112 --done: #c084fc; 114 --done: #c084fc;
113 --neutral: #a1a1a1; 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.thread .rendered p { margin: var(--sp-1) 0; } 939.thread .rendered p { margin: var(--sp-1) 0; }
937.thread .threadstate { color: var(--muted); font-size: var(--fs-1); margin: var(--sp-1) 0 0; } 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 */
940input, textarea, button, select { font: inherit; color: inherit; } 944input, 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 {
942 background: var(--bg); 947 background: var(--bg);
948 color: var(--fg);
943 border: 1px solid var(--line); 949 border: 1px solid var(--line);
944 border-radius: var(--r-md); 950 border-radius: var(--r-md);
945 padding: var(--sp-2) var(--sp-3); 951 padding: var(--sp-2) var(--sp-3);
952 line-height: 1.4;
946} 953}
947textarea { width: 100%; resize: vertical; } 954textarea { width: 100%; resize: vertical; }
948textarea[name="content"] { font-family: var(--mono); font-size: var(--fs-1); } 955textarea[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); }
950button { 973button {
951 background: var(--accent); 974 background: var(--fill);
952 color: var(--accent-fg); 975 color: #ffffff;
953 border: none; 976 border: none;
954 border-radius: var(--r-md); 977 border-radius: var(--r-md);
955 padding: var(--sp-1) var(--sp-3); 978 padding: var(--sp-2) var(--sp-4);
956 font-weight: 500; 979 font-weight: 600;
957 cursor: pointer; 980 cursor: pointer;
958} 981}
959button:hover { filter: brightness(1.08); } 982button:hover { filter: brightness(1.15); }
960 983
961/* 404 */ 984/* 404 */
962.notfound { text-align: center; padding: var(--sp-6) 0; } 985.notfound { text-align: center; padding: var(--sp-6) 0; }
@@ -1087,3 +1110,100 @@ pre.matchline mark {
1087 padding: var(--sp-2) var(--sp-3); 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); }
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 @@
2{{define "content"}} 2{{define "content"}}
3<div class="notfound"> 3<div class="notfound">
4 <p class="bigcode">404</p> 4 <p class="bigcode">404</p>
5 <h1>page not found</h1> 5 <h1>Page not found</h1>
6 <p class="desc">nothing lives at this address — the path may be wrong, 6 <p class="desc">nothing lives at this address — the path may be wrong,
7 or the repository may be private.</p> 7 or the repository may be private.</p>
8 <p><a href="/">← back to {{.Site}}</a></p> 8 <p><a href="/">← back to {{.Site}}</a></p>
internal/web/templates/blob.html +1
@@ -7,6 +7,7 @@
7 <span class="spacer"></span> 7 <span class="spacer"></span>
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> 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</div> 9</div>
10<p class="filefacts">{{if .Lines}}{{.Lines}} lines · {{end}}{{.Size}} bytes{{if .Exec}} · executable{{end}}{{if .Symlink}} · symlink{{end}}</p>
10{{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{{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{{else if .Binary}}<p class="empty-note">binary file, {{.Size}} bytes — <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">download</a></p> 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{{else}}<div class="code">{{.CodeHTML}}</div>{{end}} 13{{else}}<div class="code">{{.CodeHTML}}</div>{{end}}
internal/web/templates/dashboard.html +4 −4
@@ -1,16 +1,16 @@
1{{define "title"}}dashboard · {{.Site}}{{end}} 1{{define "title"}}dashboard · {{.Site}}{{end}}
2{{define "content"}} 2{{define "content"}}
3<div class="headrow"> 3<div class="headrow">
4<h1>dashboard</h1> 4<h1>Dashboard</h1>
5<span class="spacer"></span> 5<span class="spacer"></span>
6<p class="toolbar">logged in as <a href="/{{.Viewer}}">{{.Viewer}}</a></p> 6<p class="toolbar">logged in as <a href="/{{.Viewer}}">{{.Viewer}}</a></p>
7</div> 7</div>
8{{if .Pinned}}<h2>pinned</h2> 8{{if .Pinned}}<h2>Pinned</h2>
9<ul class="pinlist"> 9<ul class="pinlist">
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> 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{{end}} 11{{end}}
12</ul>{{end}} 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<ul class="issuelist"> 14<ul class="issuelist">
15{{range .MRs}}<li> 15{{range .MRs}}<li>
16 <div class="issuemain"> 16 <div class="issuemain">
@@ -20,7 +20,7 @@
20</li> 20</li>
21{{else}}<li class="empty">no open merge requests</li>{{end}} 21{{else}}<li class="empty">no open merge requests</li>{{end}}
22</ul> 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<ul class="issuelist"> 24<ul class="issuelist">
25{{range .Issues}}<li> 25{{range .Issues}}<li>
26 <div class="issuemain"> 26 <div class="issuemain">
internal/web/templates/edit.html +3 −3
@@ -3,9 +3,9 @@
3<h1>edit {{.Repo.OwnerName}}/{{.Repo.Name}} : {{.Path}} @ {{.Ref}}</h1> 3<h1>edit {{.Repo.OwnerName}}/{{.Repo.Name}} : {{.Path}} @ {{.Ref}}</h1>
4{{if .Error}}<p class="error">{{.Error}}</p>{{end}} 4{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
5<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/edit/{{.Ref}}/{{.Path}}"> 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> 6<p><textarea name="content" aria-label="File contents" rows="24" style="width:100%" spellcheck="false">{{.Content}}</textarea></p>
7<p><input name="message" placeholder="commit message" style="width:60%"> 7<p><input name="message" aria-label="Commit message" placeholder="commit message" style="width:60%">
8<button type="submit">commit to {{.Ref}}</button></p> 8<button type="submit">Commit to {{.Ref}}</button></p>
9<p class="crumbs">this commit will be unsigned and authored as {{.Viewer}}</p> 9<p class="crumbs">this commit will be unsigned and authored as {{.Viewer}}</p>
10</form> 10</form>
11{{end}} 11{{end}}
internal/web/templates/explore.html +2 −2
@@ -1,9 +1,9 @@
1{{define "title"}}explore · {{.Site}}{{end}} 1{{define "title"}}explore · {{.Site}}{{end}}
2{{define "content"}} 2{{define "content"}}
3<div class="headrow"> 3<div class="headrow">
4<h1>explore</h1> 4<h1>Explore</h1>
5<form method="get" action="/explore" class="searchform compact"> 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</form> 7</form>
8<span class="spacer"></span> 8<span class="spacer"></span>
9</div> 9</div>
internal/web/templates/issue.html +4 −4
@@ -8,9 +8,9 @@
8{{if .Issue.Milestone}} · milestone <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">{{.Issue.Milestone}}</a>{{end}}</p> 8{{if .Issue.Milestone}} · milestone <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">{{.Issue.Milestone}}</a>{{end}}</p>
9{{if .CanEdit}}<details class="editbox"><summary>edit</summary> 9{{if .CanEdit}}<details class="editbox"><summary>edit</summary>
10<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/edit" class="commentform"> 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> 11<p><input type="text" name="title" aria-label="Title" value="{{.Issue.Title}}" required></p>
12<p><textarea name="body" rows="8">{{.Issue.Body}}</textarea></p> 12<p><textarea name="body" aria-label="Description" 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> 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<p><button type="submit">save</button></p> 14<p><button type="submit">save</button></p>
15</form></details>{{end}} 15</form></details>{{end}}
16{{if .BodyHTML}}<article class="comment"> 16{{if .BodyHTML}}<article class="comment">
@@ -26,7 +26,7 @@
26{{end}} 26{{end}}
27{{if .Viewer}} 27{{if .Viewer}}
28<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/comment" class="commentform"> 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<p><button type="submit">comment</button></p> 30<p><button type="submit">comment</button></p>
31</form> 31</form>
32{{end}} 32{{end}}
internal/web/templates/issuenew.html +4 −4
@@ -4,9 +4,9 @@
4{{if gt (len .Templates) 1}}<p class="meta">template: 4{{if gt (len .Templates) 1}}<p class="meta">template:
5{{range .Templates}}{{if eq .Name $.Template}}<strong>{{.Name}}</strong>{{else}}<a href="?template={{.Name}}">{{.Name}}</a>{{end}} {{end}}</p>{{end}} 5{{range .Templates}}{{if eq .Name $.Template}}<strong>{{.Name}}</strong>{{else}}<a href="?template={{.Name}}">{{.Name}}</a>{{end}} {{end}}</p>{{end}}
6<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/new" class="commentform"> 6<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/new" class="commentform">
7<p><input type="text" name="title" placeholder="title" required></p> 7<p><input type="text" name="title" aria-label="Title" placeholder="title" required></p>
8<p><textarea name="body" rows="12">{{.Body}}</textarea></p> 8<p><textarea name="body" aria-label="Description" rows="12">{{.Body}}</textarea></p>
9<p><input type="text" name="labels" placeholder="labels, space-separated (write access)"></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> 10<p><button type="submit">Open issue</button></p>
11</form> 11</form>
12{{end}} 12{{end}}
internal/web/templates/layout.html +1 −1
@@ -107,4 +107,4 @@
107 107
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}} 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{{define "title"}}login · {{.Site}}{{end}} 1{{define "title"}}login · {{.Site}}{{end}}
2{{define "content"}} 2{{define "content"}}
3<h1>log in</h1> 3<h1>Log in</h1>
4{{if .Error}}<p class="error">{{.Error}}</p>{{end}} 4{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
5<p>Browser sessions are minted over SSH — there is no password. From a machine 5<p>Browser sessions are minted over SSH — there is no password. From a machine
6with your registered key:</p> 6with your registered key:</p>
internal/web/templates/mr.html +58 −23
@@ -1,24 +1,30 @@
1{{define "title"}}!{{.MR.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}!{{.MR.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{$base := printf "/%s/%s/mrs/%d" .Repo.OwnerName .Repo.Name .MR.Number}}
3<h1 class="issuetitle">{{.MR.Title}} <span class="issuenumber">!{{.MR.Number}}</span></h1> 4<h1 class="issuetitle">{{.MR.Title}} <span class="issuenumber">!{{.MR.Number}}</span></h1>
4<p class="issuemeta"><span class="chip chip-{{.MR.State}}">{{.MR.State}}</span> 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<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>
6 at <code>{{short .MR.HeadSHA}}</code>{{if .MR.Milestone}} · milestone <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">{{.MR.Milestone}}</a>{{end}}</p> 7
7{{if .CanEdit}}<details class="editbox"><summary>edit</summary> 8<div class="withaside">
8<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/{{.MR.Number}}/edit" class="commentform"> 9<div class="mainside">
9<p><input type="text" name="title" value="{{.MR.Title}}" required></p> 10
10<p><textarea name="body" rows="8">{{.MR.Body}}</textarea></p> 11<nav class="subtabs" aria-label="Merge request">
11<p><button type="submit">save</button></p> 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</form></details>{{end}} 23</form></details>{{end}}
13{{if .BodyHTML}}<article class="comment"> 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 <div class="rendered">{{.BodyHTML}}</div> 26 <div class="rendered">{{.BodyHTML}}</div>
16</article>{{end}} 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{{range .Comments}} 28{{range .Comments}}
23{{if eq .Kind "system"}}<div class="syscomment">{{.BodyHTML}} <span class="when">{{when .CreatedAt}}</span></div> 29{{if eq .Kind "system"}}<div class="syscomment">{{.BodyHTML}} <span class="when">{{when .CreatedAt}}</span></div>
24{{else}}<article class="comment"> 30{{else}}<article class="comment">
@@ -26,13 +32,16 @@
26 <div class="rendered">{{.BodyHTML}}</div> 32 <div class="rendered">{{.BodyHTML}}</div>
27</article>{{end}} 33</article>{{end}}
28{{end}} 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{{if .Viewer}} 37{{if .Viewer}}
30<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/{{.MR.Number}}/comment" class="commentform"> 38<form method="post" action="{{$base}}/comment" class="commentform">
31<p><textarea name="body" rows="4" placeholder="comment as {{.Viewer}}"></textarea></p> 39<p><textarea name="body" aria-label="Comment" rows="4" placeholder="Comment as {{.Viewer}}"></textarea></p>
32<p><button type="submit">comment</button></p> 40<p><button type="submit">Comment</button></p>
33</form> 41</form>
34{{end}} 42{{end}}
35{{if .Commits}}<h3>commits <span class="count">{{len .Commits}}</span></h3> 43
44{{else if eq .View "commits"}}
36<ul class="loglist"> 45<ul class="loglist">
37{{range .Commits}}<li> 46{{range .Commits}}<li>
38 <div class="commitmain"> 47 <div class="commitmain">
@@ -44,13 +53,39 @@
44 <code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.ShortSHA}}</a></code> 53 <code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.ShortSHA}}</a></code>
45 </div> 54 </div>
46</li> 55</li>
56{{else}}<li class="empty">no commits on this merge request</li>
47{{end}} 57{{end}}
48</ul>{{end}} 58</ul>
49<details class="difffold"> 59
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> 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<pre class="diff">{{range .DiffLines}}<span class="{{.Class}}">{{.Text}}</span> 62<pre class="diff">{{range .DiffLines}}<span class="{{.Class}}">{{.Text}}</span>
52{{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> 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> 64{{end}}
54{{if .DetachedThreads}}<h3>threads on earlier revisions</h3> 65
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}} 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{{end}} 91{{end}}
internal/web/templates/new.html +6 −6
@@ -1,15 +1,15 @@
1{{define "title"}}new repository · {{.Site}}{{end}} 1{{define "title"}}new repository · {{.Site}}{{end}}
2{{define "content"}} 2{{define "content"}}
3<h1>new repository</h1> 3<h1>New repository</h1>
4{{if .Error}}<p class="error">{{.Error}}</p>{{end}} 4{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
5<form method="post" action="/new"> 5<form method="post" action="/new">
6<p><label>owner <select name="owner"> 6<p><label>Owner <select name="owner">
7 <option value="{{.Viewer}}">{{.Viewer}}</option> 7 <option value="{{.Viewer}}">{{.Viewer}}</option>
8 {{range .Orgs}}<option value="{{.}}">{{.}}</option>{{end}} 8 {{range .Orgs}}<option value="{{.}}">{{.}}</option>{{end}}
9</select></label> 9</select></label>
10<label>/ name <input name="name" required pattern="[a-z0-9][a-z0-9._-]*"></label></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> 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> 12 <label><input type="radio" name="visibility" value="private"> Private</label></p>
13<p><button type="submit">create</button></p> 13<p><button type="submit">Create repository</button></p>
14</form> 14</form>
15{{end}} 15{{end}}
internal/web/templates/privacy.html +3 −3
@@ -1,9 +1,9 @@
1{{define "title"}}privacy · {{.Site}}{{end}} 1{{define "title"}}privacy · {{.Site}}{{end}}
2{{define "content"}} 2{{define "content"}}
3<div class="landing"> 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<p>This site runs <a href="https://gitbay.org/krz/gitbay">gitbay</a>, a 7<p>This site runs <a href="https://gitbay.org/krz/gitbay">gitbay</a>, a
8self-hosted, CLI-first git forge. The software makes no external 8self-hosted, CLI-first git forge. The software makes no external
9requests from your browser: no analytics, no CDNs, no webfonts, no 9requests from your browser: no analytics, no CDNs, no webfonts, no
@@ -20,7 +20,7 @@ they are indistinguishable from nonexistent.</p>
20downloads your account bundle, git data is yours by clone, and 20downloads your account bundle, git data is yours by clone, and
21<code>gitbay migrate</code> moves everything to another instance.</p> 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{{if .Notice}}{{range paragraphs .Notice}}<p>{{.}}</p>{{end}} 24{{if .Notice}}{{range paragraphs .Notice}}<p>{{.}}</p>{{end}}
25{{else}}<p class="desc">The operator of this instance has not added 25{{else}}<p class="desc">The operator of this instance has not added
26instance-specific notes. Questions about backups, retention, or 26instance-specific notes. Questions about backups, retention, or
internal/web/templates/register.html +3 −3
@@ -1,7 +1,7 @@
1{{define "title"}}register · {{.Site}}{{end}} 1{{define "title"}}register · {{.Site}}{{end}}
2{{define "content"}} 2{{define "content"}}
3<div class="landing"> 3<div class="landing">
4<h1>create an account</h1> 4<h1>Create an account</h1>
5{{if eq .Mode "invite"}}<p class="lede">This instance is invite-only: you need an invite code from an admin.</p> 5{{if eq .Mode "invite"}}<p class="lede">This instance is invite-only: you need an invite code from an admin.</p>
6{{else}}<p class="lede">Open registration — your account activates once you verify your email.</p>{{end}} 6{{else}}<p class="lede">Open registration — your account activates once you verify your email.</p>{{end}}
7{{if .Error}}<p class="error">{{.Error}}</p>{{end}} 7{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
@@ -10,8 +10,8 @@
10{{if eq .Mode "invite"}}<p><label>invite code<br><input type="text" name="invite" required></label></p> 10{{if eq .Mode "invite"}}<p><label>invite code<br><input type="text" name="invite" required></label></p>
11{{else}}<p><label>email<br><input type="text" name="email" required></label></p>{{end}} 11{{else}}<p><label>email<br><input type="text" name="email" required></label></p>{{end}}
12<p><label>SSH public key — your key is your identity; paste e.g. <code>~/.ssh/id_ed25519.pub</code><br> 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> 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> 14<p><button type="submit">Create account</button></p>
15</form> 15</form>
16<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> 16<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>
17</div> 17</div>
internal/web/templates/registered.html +1 −1
@@ -1,7 +1,7 @@
1{{define "title"}}welcome · {{.Site}}{{end}} 1{{define "title"}}welcome · {{.Site}}{{end}}
2{{define "content"}} 2{{define "content"}}
3<div class="landing"> 3<div class="landing">
4<h1>welcome, {{.Username}}</h1> 4<h1>Welcome, {{.Username}}</h1>
5<pre class="quickstart">{{.Message}}</pre> 5<pre class="quickstart">{{.Message}}</pre>
6<p>Everything runs over SSH with the key you registered:</p> 6<p>Everything runs over SSH with the key you registered:</p>
7<pre class="quickstart">ssh git@{{.Host}} whoami 7<pre class="quickstart">ssh git@{{.Host}} whoami
internal/web/templates/search.html +1 −1
@@ -2,7 +2,7 @@
2{{define "content"}} 2{{define "content"}}
3<h1>Search</h1> 3<h1>Search</h1>
4<form method="get" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/search" class="searchform"> 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 <button type="submit">search</button> 6 <button type="submit">search</button>
7</form> 7</form>
8{{if .QueryErr}}<p class="error">{{.QueryErr}}</p> 8{{if .QueryErr}}<p class="error">{{.QueryErr}}</p>
internal/web/templates/tree.html +17 −6
@@ -5,14 +5,25 @@
5 {{template "refmenu" .}} 5 {{template "refmenu" .}}
6 <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}</span> 6 <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}</span>
7 <span class="spacer"></span> 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</div> 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<table class="tree"> 20<table class="tree">
11<tr class="cols"><th>name</th><th class="size">size</th><th class="mode">mode</th></tr> 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>
12{{range .Entries}}<tr> 22{{range .Entries}}{{$c := index $.LastCommits .Name}}<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> 23 {{if eq .Type "tree"}}<td class="name dir"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/tree/{{$.Ref}}/{{$.Prefix}}{{.Name}}">{{.Name}}/</a></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}} 24 {{else}}<td class="name"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/blob/{{$.Ref}}/{{$.Prefix}}{{.Name}}">{{.Name}}</a></td>{{end}}
15 <td class="mode">{{.Mode}}</td> 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</tr> 27</tr>
17{{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}} 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</table> 29</table>
internal/web/web.go +53
@@ -8,6 +8,7 @@ import (
8 "io" 8 "io"
9 "reflect" 9 "reflect"
10 "runtime/debug" 10 "runtime/debug"
11 "strconv"
11 "strings" 12 "strings"
12 "sync" 13 "sync"
13 "time" 14 "time"
@@ -131,6 +132,58 @@ var funcs = template.FuncMap{
131 } 132 }
132 return f.Interface() 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 // when formats a stored RFC3339 timestamp for display; unparseable 187 // when formats a stored RFC3339 timestamp for display; unparseable
135 // values pass through unchanged. 188 // values pass through unchanged.
136 "when": func(s string) string { 189 "when": func(s string) string {