Carbon web overhaul: rail, repo header, diff view, configurable title !65

merged merged by cmc on 2026-08-26 02:28 UTC · krz/gitbay:web-harbor into main

51 files changed, +1361 −521

Layout: unified · split

Makefile added +58
@@ -0,0 +1,58 @@
1# gitbay build and deploy.
2#
3# make test run the full suite
4# make deploy build the server, push it to HOST, rebuild the local CLI
5# make deploy-runner update the CI runner on HOST (only when cmd/gitbay-runner changed)
6#
7# Override the target host with: make deploy HOST=example.org PORT=22
8
9HOST ?= 155.138.253.199
10PORT ?= 2222
11CLI_DEST ?= /opt/homebrew/bin/gitbay
12
13CROSS := CGO_ENABLED=0 GOOS=linux GOARCH=amd64
14LDFLAGS := -s -w
15SERVER_BIN := dist/gitbayd-linux-amd64
16RUNNER_BIN := dist/gitbay-runner-linux-amd64
17
18.PHONY: help test server cli deploy deploy-runner preflight
19
20help:
21 @sed -n 's/^# //p' $(MAKEFILE_LIST)
22
23test:
24 go test ./... -count=1
25
26# Fail in seconds on an unreachable host or a wedged ssh-agent, rather
27# than hanging on a credential prompt mid-deploy.
28preflight:
29 @echo "==> checking $(HOST):$(PORT)"
30 @ssh -p $(PORT) -o BatchMode=yes -o ConnectTimeout=10 root@$(HOST) true \
31 || { echo "cannot reach root@$(HOST):$(PORT) without a prompt." >&2; \
32 echo "if ssh-add -l says 'invalid format', the agent is wedged: killall ssh-agent" >&2; \
33 exit 1; }
34
35server:
36 @echo "==> building $(SERVER_BIN)"
37 $(CROSS) go build -trimpath -ldflags='$(LDFLAGS)' -o $(SERVER_BIN) ./cmd/gitbayd
38
39cli:
40 @echo "==> installing CLI to $(CLI_DEST)"
41 go build -o $(CLI_DEST) ./cmd/gitbay
42
43deploy: preflight server
44 @echo "==> pushing to $(HOST) (~24MB, then restart)"
45 ./deploy/install.sh $(HOST) $(PORT)
46 @$(MAKE) --no-print-directory cli
47 @echo "==> deployed $$(git rev-parse --short HEAD)"
48
49deploy-runner: preflight
50 @echo "==> building $(RUNNER_BIN)"
51 $(CROSS) go build -trimpath -ldflags='$(LDFLAGS)' -o $(RUNNER_BIN) ./cmd/gitbay-runner
52 @echo "==> pushing runner to $(HOST)"
53 scp -P $(PORT) $(RUNNER_BIN) root@$(HOST):/usr/local/bin/gitbay-runner.new
54 ssh -p $(PORT) root@$(HOST) 'set -eu; \
55 chmod 755 /usr/local/bin/gitbay-runner.new; \
56 mv /usr/local/bin/gitbay-runner.new /usr/local/bin/gitbay-runner; \
57 systemctl restart gitbay-runner; \
58 systemctl --no-pager --lines=3 status gitbay-runner'
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 +26 −4
@@ -51,14 +51,27 @@ 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)
5858 }
5959 }
60 // Directories sort ahead of files, whatever git's own tree order was:
61 // docs/ and img/ precede LICENSE and README.md despite sorting after
62 // them byte-wise.
63 for _, dir := range []string{"docs", "img"} {
64 d := strings.Index(body, `/tree/main/`+dir+`">`)
65 f := strings.Index(body, `/blob/main/README.md">`)
66 if d < 0 || f < 0 {
67 t.Fatalf("listing missing %s/ or README.md", dir)
68 }
69 if d > f {
70 t.Errorf("%s/ listed after README.md; directories should come first", dir)
71 }
72 }
6073 // Branch dropdown lists branches.
61 if !strings.Contains(body, `class="refmenu"`) || !strings.Contains(body, ">all refs") {
74 if !strings.Contains(body, `class="refmenu"`) || !strings.Contains(body, ">All refs") {
6275 t.Error("branch dropdown missing")
6376 }
6477 // Per-file history: ?path= filters the log; blob pages link to it.
@@ -126,6 +139,15 @@ func TestReadmeRelativeLinks(t *testing.T) {
126139 t.Errorf("explore row missing %q", want)
127140 }
128141 }
142 // The repo header renders the same on every tab of the repo. It sits
143 // above the tab bar, so anything that appears on one tab and not
144 // another moves the navigation between clicks of that navigation.
145 for _, page := range []string{"", "/issues", "/mrs", "/releases"} {
146 _, body := inst.get(t, "/alice/site"+page)
147 if !strings.Contains(body, `href="/explore?q=web"`) {
148 t.Errorf("repo header topics missing on /alice/site%s", page)
149 }
150 }
129151}
130152
131153func TestWebInteractions(t *testing.T) {
@@ -164,14 +186,14 @@ func TestWebInteractions(t *testing.T) {
164186 t.Fatal("pin toggle failed")
165187 }
166188 _, body := browserGet(t, browser, inst.base()+"/theorg/webborn")
167 if !strings.Contains(body, "★ pinned") {
189 if !strings.Contains(body, "★ Pinned") {
168190 t.Fatal("repo header not pinned")
169191 }
170192 if _, body = browserGet(t, browser, inst.base()+"/"); !strings.Contains(body, "theorg<span") {
171193 t.Fatal("dashboard missing pinned repo")
172194 }
173195 browserPost(t, browser, inst.base()+"/theorg/webborn/pin", url.Values{})
174 if _, body = browserGet(t, browser, inst.base()+"/theorg/webborn"); !strings.Contains(body, "☆ pin") {
196 if _, body = browserGet(t, browser, inst.base()+"/theorg/webborn"); !strings.Contains(body, "☆ Pin") {
175197 t.Fatal("unpin failed")
176198 }
177199
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/mirror_test.go +2 −2
@@ -119,10 +119,10 @@ func TestMirrors(t *testing.T) {
119119 if status, _ := browserGet(t, browser, inst.base()+loginPath); status != 200 {
120120 t.Fatalf("login: %d", status)
121121 }
122 if _, body := browserGet(t, browser, inst.base()+"/alice/app"); !strings.Contains(body, "mirrors to <a href=\""+remoteURL) {
122 if _, body := browserGet(t, browser, inst.base()+"/alice/app"); !strings.Contains(body, "Mirrors to <a href=\""+remoteURL) {
123123 t.Fatalf("admin repo page missing mirror line:\n%s", body)
124124 }
125 if _, body := browserGet(t, newBrowser(t), inst.base()+"/alice/app"); strings.Contains(body, "mirrors to") {
125 if _, body := browserGet(t, newBrowser(t), inst.base()+"/alice/app"); strings.Contains(body, "Mirrors to") {
126126 t.Fatalf("anonymous repo page shows mirror line:\n%s", body)
127127 }
128128
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")
e2e/web_test.go +1 −1
@@ -88,7 +88,7 @@ func TestWebUI(t *testing.T) {
8888 if !strings.Contains(body, "<h1>hello site</h1>") || !strings.Contains(body, "<em>markdown</em>") {
8989 t.Fatalf("README not rendered:\n%s", body)
9090 }
91 for _, tab := range []string{">issues<", ">merge requests<"} {
91 for _, tab := range []string{">Issues<", ">Merge requests<"} {
9292 if !strings.Contains(body, tab) {
9393 t.Fatalf("repo header missing %s tab", tab)
9494 }
e2e/wiki_test.go +2 −2
@@ -23,7 +23,7 @@ func TestWikis(t *testing.T) {
2323 // No wiki yet: the tab is absent, the page shows the push hint, and
2424 // cloning the companion says so.
2525 _, body := inst.get(t, "/alice/app")
26 if strings.Contains(body, ">wiki<") {
26 if strings.Contains(body, ">Wiki<") {
2727 t.Fatal("wiki tab shown with no wiki")
2828 }
2929 _, body = inst.get(t, "/alice/app/wiki")
@@ -55,7 +55,7 @@ func TestWikis(t *testing.T) {
5555 // Rendering: home resolves, tab appears, links rewrite to wiki pages
5656 // and images to the wiki raw route; org pages render too.
5757 _, body = inst.get(t, "/alice/app")
58 if !strings.Contains(body, ">wiki<") {
58 if !strings.Contains(body, ">Wiki<") {
5959 t.Fatal("wiki tab missing after push")
6060 }
6161 _, body = inst.get(t, "/alice/app/wiki")
internal/config/config.go +3
@@ -64,6 +64,9 @@ type GitDaemon struct {
6464type Web struct {
6565 Mode string `toml:"mode"` // view_only | accounts
6666 PasswordAuth bool `toml:"password_auth"`
67 // Title is the instance's display name in the header and page titles.
68 // Empty falls back to the site host.
69 Title string `toml:"title"`
6770 // PrivacyNotice is operator-provided text shown on /privacy under the
6871 // fixed project-level statement. Plain text; blank paragraphs split.
6972 PrivacyNotice string `toml:"privacy_notice"`
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/accounts.go +18 −23
@@ -64,19 +64,18 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
6464 token := r.URL.Query().Get("token")
6565 if token == "" {
6666 s.render(w, "login.html", struct {
67 Site string
68 Viewer string
69 Error string
70 }{s.siteName(), "", ""})
67 basePage
68 Error string
69 }{basePage{Site: s.siteName()}, ""})
7170 return
7271 }
7372 userID, err := s.st.ConsumeLoginToken(store.HashToken(token))
7473 if err != nil {
7574 s.render(w, "login.html", struct {
76 Site string
77 Viewer string
78 Error string
79 }{s.siteName(), "", "that login link is invalid, expired, or already used — mint a new one"})
75 basePage
76 Error string
77 }{basePage{Site: s.siteName()},
78 "that login link is invalid, expired, or already used — mint a new one"})
8079 return
8180 }
8281 sessTok, sessHash, err := store.NewToken()
@@ -120,11 +119,10 @@ func (s *Server) adminOrgs(u store.User) []string {
120119
121120func (s *Server) renderNewRepo(w http.ResponseWriter, u store.User, errMsg string) {
122121 s.render(w, "new.html", struct {
123 Site string
124 Viewer string
125 Orgs []string
126 Error string
127 }{s.siteName(), u.Username, s.adminOrgs(u), errMsg})
122 basePage
123 Orgs []string
124 Error string
125 }{s.baseFor(u), s.adminOrgs(u), errMsg})
128126}
129127
130128func (s *Server) newRepoForm(w http.ResponseWriter, r *http.Request, u store.User) {
@@ -223,13 +221,12 @@ func (s *Server) signupForm(w http.ResponseWriter, r *http.Request) {
223221
224222func (s *Server) renderSignup(w http.ResponseWriter, errMsg, username string) {
225223 s.render(w, "register.html", struct {
226 Site string
227 Viewer string
224 basePage
228225 Host string
229226 Mode string // open | invite
230227 Error string
231228 Username string
232 }{s.siteName(), "", s.cfg.SiteHost(), s.cfg.Registration.Mode, errMsg, username})
229 }{basePage{Site: s.siteName()}, s.cfg.SiteHost(), s.cfg.Registration.Mode, errMsg, username})
233230}
234231
235232func (s *Server) signupSubmit(w http.ResponseWriter, r *http.Request) {
@@ -247,12 +244,11 @@ func (s *Server) signupSubmit(w http.ResponseWriter, r *http.Request) {
247244 return
248245 }
249246 s.render(w, "registered.html", struct {
250 Site string
251 Viewer string
247 basePage
252248 Username string
253249 Message string
254250 Host string
255 }{s.siteName(), "", username, msg, s.cfg.SiteHost()})
251 }{basePage{Site: s.siteName()}, username, msg, s.cfg.SiteHost()})
256252}
257253
258254// issueCreateForm renders the new-issue form, prefilled from the repo's
@@ -441,8 +437,7 @@ func (s *Server) mrCommentSubmit(w http.ResponseWriter, r *http.Request, u store
441437}
442438
443439type editPage struct {
444 Site string
445 Viewer string
440 basePage
446441 Repo store.Repo
447442 Ref string
448443 Path string
@@ -467,7 +462,7 @@ func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User)
467462 return
468463 }
469464 s.render(w, "edit.html", editPage{
470 Site: s.siteName(), Viewer: u.Username, Repo: repo,
465 basePage: s.baseFor(u), Repo: repo,
471466 Ref: ref, Path: filePath, Content: string(content),
472467 })
473468}
@@ -481,7 +476,7 @@ func (s *Server) editSubmit(w http.ResponseWriter, r *http.Request, u store.User
481476 filePath := strings.Trim(r.PathValue("path"), "/")
482477 fail := func(msg string) {
483478 s.render(w, "edit.html", editPage{
484 Site: s.siteName(), Viewer: u.Username, Repo: repo,
479 basePage: s.baseFor(u), Repo: repo,
485480 Ref: ref, Path: filePath, Content: r.FormValue("content"), Error: msg,
486481 })
487482 }
internal/httpd/page.go added +85
@@ -0,0 +1,85 @@
1package httpd
2
3import (
4 "net/http"
5
6 "gitbay.org/gitbay/internal/policy"
7 "gitbay.org/gitbay/internal/store"
8)
9
10// railRepo is one pinned repository in the rail.
11type railRepo struct {
12 Owner string
13 Name string
14}
15
16// railItem is one merge request in the rail's review queue.
17type railItem struct {
18 RepoPath string
19 Number int64
20 Title string
21}
22
23// rail is the cross-repo state in the left rail: the repositories and the
24// review work a viewer moves between. A repo's own navigation lives in its
25// header tabs, not here — the rail holds only what changes as you switch
26// context, which is what earns it the width.
27type rail struct {
28 Pinned []railRepo
29 Reviews []railItem
30}
31
32// Empty reports whether the rail has nothing to show beyond the global
33// links, so the template can skip its group headings.
34func (r rail) Empty() bool { return len(r.Pinned) == 0 && len(r.Reviews) == 0 }
35
36// basePage is what the layout needs on every page, repo or not. Page
37// structs embed it so the rail and the site name are always in scope.
38type basePage struct {
39 Site string
40 Viewer string
41 Rail rail
42}
43
44// base builds the layout-wide data for a request that has not already
45// resolved a viewer.
46func (s *Server) base(r *http.Request) basePage {
47 if s.cfg.Web.Mode != "accounts" {
48 return basePage{Site: s.siteName()}
49 }
50 return s.baseFor(s.viewer(r))
51}
52
53// baseFor is base for a handler that already holds the viewer, so the
54// session lookup is not repeated.
55func (s *Server) baseFor(viewer store.User) basePage {
56 b := basePage{Site: s.siteName()}
57 if viewer.ID == 0 {
58 return b
59 }
60 b.Viewer = viewer.Username
61 b.Rail = s.railFor(viewer)
62 return b
63}
64
65// railFor collects the viewer's pinned repositories and review queue,
66// dropping anything they may no longer read.
67func (s *Server) railFor(viewer store.User) rail {
68 var rl rail
69 pinned, _ := s.st.PinnedRepos(viewer.ID)
70 for _, rp := range pinned {
71 grant, _ := s.st.AccessRole(rp.ID, viewer.ID)
72 if policy.CanRead(viewer, rp, grant) {
73 rl.Pinned = append(rl.Pinned, railRepo{Owner: rp.OwnerName, Name: rp.Name})
74 }
75 }
76 queue, _ := s.st.ReviewQueue(viewer.ID)
77 for _, q := range queue {
78 rl.Reviews = append(rl.Reviews, railItem{
79 RepoPath: q.RepoPath,
80 Number: q.Number,
81 Title: q.Title,
82 })
83 }
84 return rl
85}
internal/httpd/web.go +102 −64
@@ -13,6 +13,7 @@ import (
1313 "net/http"
1414 "path"
1515 "regexp"
16 "sort"
1617 "strconv"
1718 "strings"
1819 "time"
@@ -46,7 +47,12 @@ func (s *Server) render(w http.ResponseWriter, page string, data any) {
4647 buf.WriteTo(w)
4748}
4849
50// siteName is the instance's display name: the operator's [web] title,
51// or the site host when they have not set one.
4952func (s *Server) siteName() string {
53 if t := strings.TrimSpace(s.cfg.Web.Title); t != "" {
54 return t
55 }
5056 h := strings.TrimPrefix(strings.TrimPrefix(s.cfg.Server.SiteURL, "https://"), "http://")
5157 return strings.TrimSuffix(h, "/")
5258}
@@ -62,8 +68,8 @@ func (s *Server) favicon(w http.ResponseWriter, r *http.Request) {
6268 w.Write(web.FaviconSVG)
6369}
6470
65// font serves the embedded IBM Plex subsets. Same-origin, so the CSP's
66// default-src 'self' covers it — no font CDN.
71// font serves the embedded Atkinson Hyperlegible subsets. Same-origin,
72// so the CSP's default-src 'self' covers it — no font CDN.
6773func (s *Server) font(w http.ResponseWriter, r *http.Request) {
6874 data, err := web.FontFS.ReadFile("static" + r.URL.Path[len("/static"):])
6975 if err != nil {
@@ -79,10 +85,7 @@ func (s *Server) font(w http.ResponseWriter, r *http.Request) {
7985// the stock plain-text response if the template fails.
8086func (s *Server) notFound(w http.ResponseWriter, r *http.Request) {
8187 var buf bytes.Buffer
82 if err := web.Render(&buf, "404.html", struct {
83 Site string
84 Viewer string
85 }{s.siteName(), s.viewerName(r)}); err != nil {
88 if err := web.Render(&buf, "404.html", s.base(r)); err != nil {
8689 http.NotFound(w, r)
8790 return
8891 }
@@ -129,12 +132,11 @@ func (s *Server) index(w http.ResponseWriter, r *http.Request) {
129132 host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(
130133 s.cfg.Server.SiteURL, "https://"), "http://"), "/")
131134 s.render(w, "landing.html", struct {
132 Site string
133 Viewer string
135 basePage
134136 Host string
135137 Accounts bool
136138 Signup bool
137 }{s.siteName(), "", host, s.cfg.Web.Mode == "accounts",
139 }{basePage{Site: s.siteName()}, host, s.cfg.Web.Mode == "accounts",
138140 s.cfg.Web.Mode == "accounts" && s.cfg.Registration.Mode != "closed"})
139141}
140142
@@ -150,12 +152,11 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.
150152 mrs, _ := s.st.DashboardMRs(viewer.ID)
151153 issues, _ := s.st.DashboardIssues(viewer.ID)
152154 s.render(w, "dashboard.html", struct {
153 Site string
154 Viewer string
155 basePage
155156 Pinned []store.Repo
156157 MRs []store.DashboardItem
157158 Issues []store.DashboardItem
158 }{s.siteName(), viewer.Username, visible, mrs, issues})
159 }{s.baseFor(viewer), visible, mrs, issues})
159160}
160161
161162func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
@@ -170,30 +171,20 @@ func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
170171 }
171172 q := strings.TrimSpace(r.URL.Query().Get("q"))
172173 s.render(w, "explore.html", struct {
173 Site string
174 Viewer string
175 Query string
176 Repos []describedRepo
177 }{s.siteName(), viewer.Username, q, s.filterRepos(q, s.describeAll(repos))})
178}
179
180// viewerName returns the logged-in username for header rendering, or "".
181func (s *Server) viewerName(r *http.Request) string {
182 if s.cfg.Web.Mode != "accounts" {
183 return ""
184 }
185 return s.viewer(r).Username
174 basePage
175 Query string
176 Repos []describedRepo
177 }{s.baseFor(viewer), q, s.filterRepos(q, s.describeAll(repos))})
186178}
187179
188180// privacy renders the privacy page: what the gitbay software does with
189181// data, plus this instance's operator-provided notes.
190182func (s *Server) privacy(w http.ResponseWriter, r *http.Request) {
191183 s.render(w, "privacy.html", struct {
192 Site string
193 Viewer string
184 basePage
194185 Host string
195186 Notice string
196 }{s.siteName(), s.viewerName(r), s.cfg.SiteHost(), s.cfg.Web.PrivacyNotice})
187 }{s.base(r), s.cfg.SiteHost(), s.cfg.Web.PrivacyNotice})
197188}
198189
199190// filterRepos keeps repos whose path, description, or topics contain the
@@ -222,8 +213,7 @@ func (s *Server) filterRepos(q string, repos []describedRepo) []describedRepo {
222213
223214// repoPage is the shared context for repo-scoped pages.
224215type repoPage struct {
225 Site string
226 Viewer string
216 basePage
227217 Desc string
228218 Repo store.Repo
229219 Ref string
@@ -235,6 +225,13 @@ type repoPage struct {
235225 HasWiki bool
236226 Host string
237227 Mirrors []mirrorLine // repo admins only
228 // OpenIssues and OpenMRs are the counts on the header tabs.
229 OpenIssues int
230 OpenMRs int
231 // RepoHome asks the layout for the full header — description, topics,
232 // website, mirrors. Every other page gets identity and tabs only, so a
233 // repo describes itself once rather than on all twelve of its pages.
234 RepoHome bool
238235}
239236
240237// mirrorLine is the admin-only mirror status shown in the repo header.
@@ -300,19 +297,21 @@ func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (re
300297 })
301298 }
302299 }
300 openIssues, openMRs := s.st.OpenCounts(repo.ID)
303301 return repoPage{
304 Mirrors: mirrors,
305 Site: s.siteName(),
306 Viewer: viewer.Username,
307 Pinned: pinned,
308 HasWiki: s.wikiDir(repo.OwnerName, repo.Name) != "",
309 Host: s.cfg.SiteHost(),
310 Desc: gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)),
311 Repo: repo,
312 Ref: ref,
313 CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git",
314 Dir: control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name),
315 Topics: topics,
302 basePage: s.baseFor(viewer),
303 Mirrors: mirrors,
304 Pinned: pinned,
305 HasWiki: s.wikiDir(repo.OwnerName, repo.Name) != "",
306 Host: s.cfg.SiteHost(),
307 Desc: gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)),
308 Repo: repo,
309 Ref: ref,
310 CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git",
311 Dir: control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name),
312 Topics: topics,
313 OpenIssues: openIssues,
314 OpenMRs: openMRs,
316315 }, true
317316}
318317
@@ -385,8 +384,7 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
385384 weeks, activityTotal := activityGrid(counts)
386385
387386 s.render(w, "owner.html", struct {
388 Site string
389 Viewer string
387 basePage
390388 Owner string
391389 Kind string
392390 Profile store.Profile
@@ -395,7 +393,7 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
395393 Orgs []store.OrgMember
396394 Activity []activityWeek
397395 ActivityTotal int
398 }{s.siteName(), viewer.Username, name, kind, profile, s.describeAll(visible), members, orgs,
396 }{s.baseFor(viewer), name, kind, profile, s.describeAll(visible), members, orgs,
399397 weeks, activityTotal})
400398}
401399
@@ -405,6 +403,7 @@ func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
405403 return
406404 }
407405 p.Tab = "files"
406 p.RepoHome = true
408407 s.renderTree(w, r, p, "")
409408}
410409
@@ -422,14 +421,16 @@ func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage,
422421 // Empty repo: render the page with no entries rather than 404.
423422 s.render(w, "tree.html", struct {
424423 repoPage
425 Crumbs []crumb
426 Prefix string
427 DirPath string
428 RefKind string
429 Entries []gitutil.TreeEntry
430 Branches []gitutil.Ref
431 ReadmeName string
432 ReadmeHTML template.HTML
424 Crumbs []crumb
425 Prefix string
426 DirPath string
427 RefKind string
428 Entries []gitutil.TreeEntry
429 Branches []gitutil.Ref
430 ReadmeName string
431 ReadmeHTML template.HTML
432 LastCommits map[string]gitutil.EntryCommit
433 Tip gitutil.EntryCommit
433434 }{repoPage: p, RefKind: "tree"})
434435 return
435436 }
@@ -438,6 +439,12 @@ func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage,
438439 s.notFound(w, r)
439440 return
440441 }
442 // Directories first. git's tree order interleaves them with files, but
443 // a listing is scanned by shape before name. Stable, so each group
444 // keeps the ordering git gave it.
445 sort.SliceStable(entries, func(i, j int) bool {
446 return entries[i].Type == "tree" && entries[j].Type != "tree"
447 })
441448 prefix := ""
442449 if dirPath != "" {
443450 prefix = dirPath + "/"
@@ -452,17 +459,26 @@ func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage,
452459 }
453460
454461 branches, _ := gitutil.Refs(p.Dir, "heads")
462 names := make([]string, 0, len(entries))
463 for _, e := range entries {
464 names = append(names, e.Name)
465 }
455466 s.render(w, "tree.html", struct {
456467 repoPage
457 Crumbs []crumb
458 Prefix string
459 DirPath string
460 RefKind string
461 Entries []gitutil.TreeEntry
462 Branches []gitutil.Ref
463 ReadmeName string
464 ReadmeHTML template.HTML
465 }{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches, readmeName, readmeHTML})
468 Crumbs []crumb
469 Prefix string
470 DirPath string
471 RefKind string
472 Entries []gitutil.TreeEntry
473 Branches []gitutil.Ref
474 ReadmeName string
475 ReadmeHTML template.HTML
476 LastCommits map[string]gitutil.EntryCommit
477 Tip gitutil.EntryCommit
478 }{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches,
479 readmeName, readmeHTML,
480 gitutil.LastCommits(p.Dir, p.Ref, dirPath, names),
481 gitutil.TipCommit(p.Dir, p.Ref)})
466482}
467483
468484func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
@@ -491,6 +507,16 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
491507 cs = cs[:len(cs)-1]
492508 }
493509 branches, _ := gitutil.Refs(p.Dir, "heads")
510 lines := 0
511 if !binary && !image && len(data) > 0 {
512 lines = bytes.Count(data, []byte("\n"))
513 if data[len(data)-1] != '\n' {
514 lines++
515 }
516 }
517 // The file listing leads with the last commit now, so the facts about
518 // the file itself are reported here instead.
519 entry, _ := gitutil.StatPath(p.Dir, p.Ref, filePath)
494520 s.render(w, "blob.html", struct {
495521 repoPage
496522 Crumbs []crumb
@@ -501,9 +527,13 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
501527 Binary bool
502528 Image bool
503529 Size int
530 Lines int
531 Exec bool
532 Symlink bool
504533 Branches []gitutil.Ref
505534 CodeHTML template.HTML
506 }{p, cs, base, filePath, filePath, "blob", binary, image, len(data), branches, codeHTML})
535 }{p, cs, base, filePath, filePath, "blob", binary, image, len(data), lines,
536 entry.Mode == "100755", entry.Mode == "120000", branches, codeHTML})
507537}
508538
509539// releases lists tag-anchored releases with notes and assets.
@@ -1447,9 +1477,17 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
14471477 commits = append(commits, cr)
14481478 }
14491479 }
1480 // The diff is the reason most people open a merge request, so it gets
1481 // its own view rather than a fold at the foot of the conversation.
1482 // A query parameter keeps this working without JavaScript.
1483 view := r.URL.Query().Get("view")
1484 if view != "commits" && view != "diff" {
1485 view = "conversation"
1486 }
14501487 s.render(w, "mr.html", struct {
14511488 repoPage
14521489 MR store.MR
1490 View string
14531491 BodyHTML template.HTML
14541492 Checks []store.CommitStatus
14551493 Combined string
@@ -1460,7 +1498,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
14601498 Commits []commitRow
14611499 CanEdit bool
14621500 DetachedThreads []diffThread
1463 }{p, m, md(m.Body), checks, store.CombinedStatus(checks), renderComments(comments, md),
1501 }{p, m, view, md(m.Body), checks, store.CombinedStatus(checks), renderComments(comments, md),
14641502 reviews, lines, stat, commits, s.canEditItem(r, p.Repo, m.Author), detachedThreads})
14651503}
14661504
internal/store/dashboard.go +31
@@ -118,3 +118,34 @@ func (s *Store) PinnedRepos(userID int64) ([]Repo, error) {
118118 }
119119 return out, rows.Err()
120120}
121
122// ReviewQueue returns open merge requests the user is involved in, has not
123// authored, and has not reviewed at the current head — what the rail shows
124// as waiting on them. Ordered most recently touched first.
125func (s *Store) ReviewQueue(userID int64) ([]DashboardItem, error) {
126 return s.dashboardQuery(`
127 SELECT COALESCE(u.username, o.name) || '/' || r.name,
128 x.number, x.title, au.username, x.state, x.updated_at
129 FROM merge_requests x
130 JOIN repos r ON r.id = x.repo_id
131 LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
132 LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id
133 JOIN users au ON au.id = x.author_id
134 WHERE x.state IN ('open', 'source_gone')
135 AND x.author_id <> ?1
136 AND NOT EXISTS (SELECT 1 FROM mr_reviews rv
137 WHERE rv.mr_id = x.id AND rv.reviewer_id = ?1
138 AND rv.head_sha = x.head_sha)
139 AND `+involvedCond+`
140 ORDER BY x.updated_at DESC LIMIT 8`, userID)
141}
142
143// OpenCounts returns the repo's open issue and open merge request counts,
144// for the repo tab badges.
145func (s *Store) OpenCounts(repoID int64) (issues, mrs int) {
146 s.DB.QueryRow("SELECT COUNT(*) FROM issues WHERE repo_id = ? AND state = 'open'",
147 repoID).Scan(&issues)
148 s.DB.QueryRow("SELECT COUNT(*) FROM merge_requests WHERE repo_id = ? AND state IN ('open', 'source_gone')",
149 repoID).Scan(&mrs)
150 return
151}
internal/web/static/favicon.svg +1 −1
@@ -1 +1 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect x="1" y="1" width="22" height="22" rx="6" fill="#0000f0"/><path d="M5 9.5c2-2.5 4-2.5 6 0s4 2.5 6 0" stroke="#ffffff" stroke-width="2.2" fill="none" stroke-linecap="round"/><path d="M5 15c2-2.5 4-2.5 6 0s4 2.5 6 0" stroke="#ffffff" stroke-width="2.2" fill="none" stroke-linecap="round"/></svg>
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect width="24" height="24" fill="#000000"/><path d="M12 2.25 18.75 12H5.25z" fill="#ff6b3d"/><rect x="3" y="13.5" width="18" height="3" fill="#ffffff"/><rect x="7.5" y="18" width="9" height="3" fill="#ffffff"/></svg>
internal/web/static/fonts/atkinson-mono-roman.woff2 added

Binary file not shown.

internal/web/static/fonts/atkinson-next-italic.woff2 added

Binary file not shown.

internal/web/static/fonts/atkinson-next-roman.woff2 added

Binary file not shown.

internal/web/static/fonts/plex-mono-400.woff2 deleted

Binary file not shown.

internal/web/static/fonts/plex-mono-500.woff2 deleted

Binary file not shown.

internal/web/static/fonts/plex-sans.woff2 deleted

Binary file not shown.

internal/web/static/style.css +486 −269
@@ -1,28 +1,31 @@
1/* gitbay stylesheet, Carbon-school: black shell, sharp lines, one blue.
2 One file, light and dark via prefers-color-scheme. Tokens first;
3 components use tokens only. No external requests: IBM Plex is served
4 from this origin. */
1/* gitbay stylesheet — Harbor: black and white ground, two accents with
2 separate jobs. Blue is what you can do (links, buttons, focus); orange
3 is where you are and what wants you (active nav, counts, pending state,
4 unverified signatures). Orange is never a button, so the two never
5 compete. One file, light and dark via prefers-color-scheme. Tokens
6 first; components use tokens only. No external requests: Atkinson
7 Hyperlegible is served from this origin. */
58
69@font-face {
7 font-family: "IBM Plex Sans";
10 font-family: "Atkinson Hyperlegible Next";
811 font-style: normal;
9 font-weight: 400 600;
12 font-weight: 400 700;
1013 font-display: swap;
11 src: url(/static/fonts/plex-sans.woff2) format("woff2");
14 src: url(/static/fonts/atkinson-next-roman.woff2) format("woff2");
1215}
1316@font-face {
14 font-family: "IBM Plex Mono";
15 font-style: normal;
16 font-weight: 400;
17 font-family: "Atkinson Hyperlegible Next";
18 font-style: italic;
19 font-weight: 400 700;
1720 font-display: swap;
18 src: url(/static/fonts/plex-mono-400.woff2) format("woff2");
21 src: url(/static/fonts/atkinson-next-italic.woff2) format("woff2");
1922}
2023@font-face {
21 font-family: "IBM Plex Mono";
24 font-family: "Atkinson Hyperlegible Mono";
2225 font-style: normal;
23 font-weight: 500;
26 font-weight: 400 600;
2427 font-display: swap;
25 src: url(/static/fonts/plex-mono-500.woff2) format("woff2");
28 src: url(/static/fonts/atkinson-mono-roman.woff2) format("woff2");
2629}
2730
2831:root {
@@ -30,39 +33,50 @@
3033 scrollbars per scheme, and opt out of browser auto-darkening */
3134 color-scheme: light dark;
3235
33 /* palette (Carbon g10) */
34 --bg: #f4f4f4;
35 --fg: #161616;
36 --muted: #525252;
37 --faint: #ececec; /* hairlines, row separators */
38 --line: #e0e0e0; /* control borders, tile edges */
39 --surface: #ffffff; /* tiles, cards */
40 --hover: #f0f2f8; /* row hover on tiles */
41 --accent: #0f62fe; /* blue-60: links, active nav, primary */
42 --accent-fg: #ffffff; /* text on accent */
43 --ok: #198038;
44 --warn: #8e6a00;
45 --bad: #da1e28;
46 --done: #8a3ffc; /* merged */
47 --neutral: #525252;
48 --code-bg: #f4f4f4;
49 /* the shell is ink in both schemes */
50 --shell-bg: #161616;
51 --shell-fg: #f4f4f4;
52 --shell-muted: #c6c6c6;
53 --shell-line: #161616;
36 /* ground and ink */
37 --bg: #ffffff;
38 --fg: #0d0d0d;
39 --muted: #595959; /* 7.00:1 on white */
40 --faint: #eaeaea; /* hairlines, row separators */
41 --line: #d4d4d4; /* control borders, table edges */
42 --surface: #ffffff;
43 --hover: #f5f5f5;
44 --code-bg: #f5f5f5;
45
46 /* blue: what you can do */
47 --accent: #0000f0; /* links, focus — 9.30:1 on white */
48 --accent-fg: #ffffff;
49 --fill: #0000f0; /* button grounds; white on it is 9.30:1 */
50
51 /* orange: where you are, what wants you */
52 --mark: #e4572e; /* bars and underlines — non-text, 3.0 bar */
53 --warn: #c2410c; /* the same orange at text size — 5.18:1 */
54
55 /* semantic */
56 --ok: #0a7d3c;
57 --bad: #c62828;
58 --done: #6b21a8; /* merged */
59 --neutral: #595959;
60
61 /* the rail is black in both schemes: the one brand constant */
62 --shell-bg: #000000;
63 --shell-fg: #f2f2f2;
64 --shell-muted: #8f8f8f;
65 --shell-line: #1c1c1c;
66 --shell-hover: #141414;
67 --shell-mark: #ff6b3d; /* 7.42:1 on black, both schemes */
5468
5569 /* type */
56 --sans: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI",
57 Roboto, "Helvetica Neue", Arial, sans-serif;
58 --mono: "IBM Plex Mono", ui-monospace, "SF Mono", SFMono-Regular, Menlo,
59 Consolas, "Liberation Mono", monospace;
60 --fs-0: 0.8rem; /* 12.8px: chips, fine print */
61 --fs-1: 0.866rem; /* ~13px: metadata, mono in tables */
62 --fs-2: 0.933rem; /* 14px: dense table text */
63 --fs-3: 1rem; /* 15px: body */
64 --fs-4: 1.135rem; /* ~17px: h2 */
65 --fs-5: 1.335rem; /* ~20px: h1 */
70 --sans: "Atkinson Hyperlegible Next", -apple-system, BlinkMacSystemFont,
71 "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
72 --mono: "Atkinson Hyperlegible Mono", ui-monospace, "SF Mono",
73 SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
74 --fs-0: 0.78rem;
75 --fs-1: 0.85rem;
76 --fs-2: 0.92rem;
77 --fs-3: 1rem;
78 --fs-4: 1.2rem;
79 --fs-5: 1.6rem;
6680
6781 /* spacing */
6882 --sp-1: 0.25rem;
@@ -72,31 +86,34 @@
7286 --sp-5: 1.5rem;
7387 --sp-6: 2.5rem;
7488
75 /* radii: sharp everywhere; only tags stay pills */
76 --r-sm: 0;
77 --r-md: 0;
78 --r-lg: 0;
79 --r-pill: 999px;
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
91 /* radii: 2px everywhere — chart plates, not pills */
92 --r-sm: 2px;
93 --r-md: 2px;
94 --r-lg: 2px;
95 --r-pill: 2px;
8096}
8197@media (prefers-color-scheme: dark) {
8298 :root {
83 /* Carbon g100 */
84 --bg: #161616;
85 --fg: #f4f4f4;
86 --muted: #a8a8a8;
87 --faint: #2a2a2a;
88 --line: #393939;
89 --surface: #262626;
90 --hover: #2e3440;
91 --accent: #78a9ff; /* blue-40 reads on dark grounds */
92 --accent-fg: #161616;
93 --ok: #42be65;
94 --warn: #d2a106;
95 --bad: #fa4d56;
96 --done: #a56eff;
97 --neutral: #a8a8a8;
98 --code-bg: #131313;
99 --shell-line: #393939;
99 --bg: #0a0a0a;
100 --fg: #f2f2f2;
101 --muted: #a1a1a1; /* 7.66:1 on #0a0a0a */
102 --faint: #1c1c1c;
103 --line: #2e2e2e;
104 --surface: #0f0f0f;
105 --hover: #161616;
106 --code-bg: #050505;
107 --accent: #5b6bff; /* 4.71:1 — pure #0000f0 is 2.13:1 here */
108 --accent-fg: #ffffff;
109 --fill: #0000f0; /* fills stay pure; white on it clears 9:1 */
110 --mark: #ff6b3d;
111 --warn: #ff6b3d;
112 --ok: #3fce7a;
113 --bad: #ff6b6b;
114 --done: #c084fc;
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");
100117 }
101118}
102119
@@ -108,18 +125,46 @@ body {
108125 color: var(--fg);
109126 font: var(--fs-3)/1.6 var(--sans);
110127 min-height: 100vh;
111 display: flex;
112 flex-direction: column;
113128}
114main { flex: 1; }
115129
116130a { color: var(--accent); text-decoration: none; }
117131a:hover { text-decoration: underline; }
118132
119h1 { font-size: var(--fs-5); font-weight: 600; line-height: 1.3;
120 margin: var(--sp-5) 0 var(--sp-3); letter-spacing: -0.01em; }
121h2 { font-size: var(--fs-4); font-weight: 600; line-height: 1.35;
122 margin: var(--sp-5) 0 var(--sp-3); }
133/* one authored focus ring, so keyboard position is never the browser's
134 guess. Blue, because focus is an interactive state. */
135:focus-visible {
136 outline: 2px solid var(--accent);
137 outline-offset: 2px;
138 border-radius: 1px;
139}
140
141/* visually hidden, still read aloud: for headings a sighted reader gets
142 from the layout but a screen reader would otherwise miss */
143.vh {
144 position: absolute;
145 width: 1px; height: 1px;
146 margin: -1px; padding: 0; border: 0;
147 clip-path: inset(50%);
148 overflow: hidden;
149 white-space: nowrap;
150}
151/* the rail runs ahead of the content in source order, so give keyboard
152 and screen-reader users a way past it */
153a.skip {
154 position: absolute;
155 left: var(--sp-3); top: -3rem;
156 z-index: 50;
157 background: var(--fill);
158 color: #ffffff;
159 padding: var(--sp-2) var(--sp-4);
160 font-weight: 600;
161}
162a.skip:focus { top: var(--sp-3); text-decoration: none; }
163
164h1 { font-size: var(--fs-5); font-weight: 700; line-height: 1.18;
165 margin: 0 0 var(--sp-3); letter-spacing: -0.025em; text-wrap: balance; }
166h2 { font-size: var(--fs-4); font-weight: 700; line-height: 1.3;
167 margin: var(--sp-5) 0 var(--sp-3); letter-spacing: -0.015em; }
123168h3 { font-size: var(--fs-3); font-weight: 600; margin: var(--sp-5) 0 var(--sp-2); }
124169
125170code, pre, .code, td.mode, td.size {
@@ -127,167 +172,215 @@ code, pre, .code, td.mode, td.size {
127172 font-size: var(--fs-1);
128173}
129174
130/* layout: one shared container aligns header, content, and footer */
131.container {
132 max-width: 64rem;
133 margin: 0 auto;
134 padding: 0 var(--sp-5);
135}
136/* the shell: an ink bar in both schemes, Carbon UI-shell style. Scoped
137 to the page header so cards with their own <header> stay untouched. */
138body > header {
175/* ---- shell: black rail, content pane ---- */
176.shell { display: flex; align-items: stretch; min-height: 100vh; }
177
178.rail {
179 flex: none;
180 width: 13rem;
139181 background: var(--shell-bg);
140 border-bottom: 1px solid var(--shell-line);
141 padding: 0;
182 color: var(--shell-fg);
183 border-right: 1px solid var(--shell-line);
142184 position: sticky;
143185 top: 0;
144 z-index: 30;
186 height: 100vh;
187 display: flex;
188 flex-direction: column;
145189}
146a.site {
147 font-weight: 600;
148 color: var(--shell-fg);
149 letter-spacing: -0.01em;
150 display: inline-flex;
190a.brand {
191 display: flex;
151192 align-items: center;
152193 gap: var(--sp-2);
153 padding: 0 var(--sp-4);
194 padding: var(--sp-3) var(--sp-4);
195 color: var(--shell-fg);
196 font-weight: 700;
197 font-size: var(--fs-3);
198 letter-spacing: -0.02em;
199 border-bottom: 1px solid var(--shell-line);
154200}
155a.site:hover { text-decoration: none; color: #ffffff; }
156a.site svg.mark { display: block; }
157a.site svg.mark rect { fill: #0f62fe; }
158a.site svg.mark path { stroke: #ffffff; }
159nav.topnav {
160 display: flex;
161 align-items: center;
162 gap: 0;
163 height: 3rem;
164}
165nav.topnav .spacer { flex: 1; }
166/* the avatar square is the only nav item in the shell; everything else
167 lives in the rail */
168a.avatar {
169 width: 2rem;
170 height: 2rem;
171 margin-right: var(--sp-3);
172 display: inline-flex;
173 align-items: center;
174 justify-content: center;
175 background: var(--accent);
176 color: #ffffff;
201a.brand:hover { text-decoration: none; color: #ffffff; }
202a.brand svg.mark { display: block; flex: none; }
203a.brand span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
204
205.railbody { flex: 1; overflow-y: auto; padding: var(--sp-3) 0; }
206ul.raillist { list-style: none; margin: 0; padding: 0; }
207ul.raillist a {
208 display: block;
209 padding: 0.28rem var(--sp-4);
210 color: var(--shell-fg);
177211 font-size: var(--fs-2);
212 border-left: 3px solid transparent;
213 overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
214}
215ul.raillist a:hover { background: var(--shell-hover); text-decoration: none; }
216/* orange marks position — the rail's only accent */
217ul.raillist a[aria-current] {
218 border-left-color: var(--shell-mark);
219 background: var(--shell-hover);
178220 font-weight: 600;
179 flex: none;
180221}
181a.avatar:hover { text-decoration: none; filter: brightness(1.12); }
182a.avatar.guest { background: #393939; color: #f4f4f4; }
222ul.raillist .owner { color: var(--shell-muted); }
223ul.raillist.wide a { white-space: normal; padding-top: var(--sp-1); padding-bottom: var(--sp-1); }
224ul.raillist.wide b { display: block; font-weight: 500; font-size: var(--fs-1); }
225ul.raillist.wide .owner { font-size: var(--fs-0); }
183226
184/* the rail: persistent navigation, global links first, repo sections
185 below them, the signed-in user pinned to the bottom */
186.shell { display: flex; flex: 1; align-items: stretch; }
187.siderail {
188 flex: none;
189 width: 13.5rem;
190 background: var(--surface);
191 border-right: 1px solid var(--line);
192 position: sticky;
193 top: 3rem;
194 height: calc(100vh - 3rem);
227.raillabel {
195228 display: flex;
196 flex-direction: column;
229 align-items: center;
230 gap: var(--sp-2);
231 margin: var(--sp-4) 0 var(--sp-1);
232 padding: 0 var(--sp-4);
233 font-family: var(--mono);
234 font-size: var(--fs-0);
235 font-weight: 500;
236 letter-spacing: 0.1em;
237 text-transform: uppercase;
238 color: var(--shell-muted);
239}
240.raillabel .count {
241 font-family: var(--sans);
242 font-size: var(--fs-0);
243 font-weight: 700;
244 letter-spacing: 0;
245 background: var(--shell-mark);
246 color: #000000;
247 padding: 0 0.35rem;
248 border-radius: var(--r-sm);
197249}
198.railmain { flex: 1; overflow-y: auto; padding: var(--sp-3) 0; }
199250.railfoot {
200251 flex: none;
201 border-top: 1px solid var(--line);
202 padding: var(--sp-2) 0;
203 background: var(--surface);
204}
205.siderail a, .siderail button.linklike {
252 border-top: 1px solid var(--shell-line);
253 padding: var(--sp-2) var(--sp-4);
206254 display: flex;
207255 align-items: center;
208 gap: var(--sp-2);
209 width: 100%;
210 text-align: left;
211 color: var(--fg);
256 gap: var(--sp-3);
212257 font-size: var(--fs-2);
213 padding: 0.4rem var(--sp-4);
214 border-left: 3px solid transparent;
215 line-height: 1.35;
216258}
217.siderail a:hover, .siderail button.linklike:hover {
218 background: var(--hover);
219 text-decoration: none;
220 color: var(--fg);
259.railfoot a { color: var(--shell-fg); }
260a.railuser { display: inline-flex; align-items: center; gap: var(--sp-2); }
261.railfoot .linklike {
262 background: none; border: 0; padding: 0; cursor: pointer;
263 color: var(--shell-muted); font: inherit; text-decoration: underline;
264}
265.railfoot .linklike:hover { color: var(--shell-fg); }
266.avatar {
267 width: 1.4rem; height: 1.4rem; flex: none;
268 display: inline-grid; place-items: center;
269 background: var(--fill);
270 color: #ffffff;
271 font-size: var(--fs-0);
272 font-weight: 700;
273 border-radius: var(--r-sm);
221274}
222.siderail a.active {
223 border-left-color: var(--accent);
224 background: var(--hover);
275
276.pane { flex: 1; min-width: 0; display: flex; flex-direction: column; }
277.content {
278 flex: 1;
279 padding: var(--sp-5) var(--sp-5) var(--sp-6);
280 max-width: 78rem;
281 width: 100%;
282}
283
284/* ---- repo header: identity and tabs on every page, the rest once ---- */
285.repohead {
286 padding: var(--sp-4) var(--sp-5) 0;
287 border-bottom: 1px solid var(--line);
288}
289.repohead .identity { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
290.repohead .identity .grow { flex: 1; }
291/* h1 on the repo home, p elsewhere so each page keeps its own h1 — but
292 identical in size, because the tabs sit below this and navigation must
293 not move when you use it */
294.repotitle {
295 margin: 0;
296 font-size: var(--fs-4);
297 font-weight: 700;
298 letter-spacing: -0.02em;
299 line-height: 1.3;
300}
301.repotitle a { color: var(--fg); }
302.repotitle a.owner { color: var(--muted); font-weight: 400; }
303.repotitle a:hover { color: var(--accent); text-decoration: none; }
304.repotitle .sep { color: var(--muted); margin: 0 0.12em; font-weight: 400; }
305.repodesc { margin: var(--sp-2) 0 0; color: var(--muted); font-size: var(--fs-2); max-width: 62ch; }
306.repometa { margin: var(--sp-1) 0 0; color: var(--muted); font-size: var(--fs-1); }
307.repohead .topics { margin: var(--sp-2) 0 0; }
308
309button.btn {
310 font: inherit;
311 font-size: var(--fs-1);
225312 font-weight: 600;
313 padding: 0.15rem 0.7rem;
314 border: 1px solid var(--line);
315 border-radius: var(--r-sm);
316 background: var(--bg);
317 color: var(--fg);
318 cursor: pointer;
226319}
227.siderail .glabel {
228 display: block;
229 font-size: var(--fs-0);
320button.btn:hover { border-color: var(--accent); color: var(--accent); }
321button.btn[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); }
322form.inline { display: inline; }
323
324nav.tabs {
325 display: flex;
326 gap: var(--sp-1);
327 margin-top: var(--sp-4);
328 overflow-x: auto;
329 scrollbar-width: none;
330}
331nav.tabs::-webkit-scrollbar { display: none; }
332nav.tabs a {
333 display: inline-flex;
334 align-items: center;
335 gap: var(--sp-2);
336 padding: var(--sp-2) var(--sp-3) 0.6rem;
230337 color: var(--muted);
231 letter-spacing: 0.04em;
232 padding: 0 var(--sp-4);
233 margin: var(--sp-4) 0 var(--sp-1);
338 font-size: var(--fs-2);
339 white-space: nowrap;
340 border-bottom: 2px solid transparent;
341 margin-bottom: -1px;
234342}
235.siderail .glabel:not(.sub) {
343nav.tabs a:hover { color: var(--fg); text-decoration: none; }
344/* orange marks position here too */
345nav.tabs a[aria-current] {
236346 color: var(--fg);
237 font-weight: 600;
238 border-top: 1px solid var(--line);
239 padding-top: var(--sp-3);
240 overflow-wrap: anywhere;
347 font-weight: 700;
348 border-bottom-color: var(--mark);
241349}
242.siderail .glabel.sub { margin-top: var(--sp-2); }
243.siderail .railuser { font-weight: 600; }
244.siderail .avatar.sm {
245 width: 1.25rem;
246 height: 1.25rem;
247 margin: 0;
350nav.tabs a i {
351 font-style: normal;
352 font-family: var(--mono);
248353 font-size: var(--fs-0);
249 background: var(--accent);
250 color: #ffffff;
251}
252main.container {
253 width: 100%;
254 min-width: 0;
255 flex: 1;
256 margin: 0;
257 max-width: 66rem;
258 padding-top: var(--sp-4);
259 padding-bottom: var(--sp-6);
354 background: var(--hover);
355 border-radius: var(--r-sm);
356 padding: 0 0.35rem;
260357}
358
359/* ---- mobile: the rail becomes a single strip, not a wall of links ---- */
261360@media (max-width: 52rem) {
262 /* the rail folds into a wrapping strip above the content */
263361 .shell { display: block; }
264 .siderail {
362 .rail {
265363 width: auto;
266364 height: auto;
267365 position: static;
268366 flex-direction: row;
269 flex-wrap: wrap;
270367 align-items: center;
271 border-right: none;
272 border-bottom: 1px solid var(--line);
273 }
274 .railmain, .railfoot {
275 display: flex;
276 flex-wrap: wrap;
277 align-items: center;
278 overflow: visible;
279 padding: var(--sp-1) var(--sp-2);
280 border-top: none;
281 }
282 .railmain { flex: 1 1 auto; }
283 .siderail a, .siderail button.linklike {
284 width: auto;
285 border-left: none;
286 border-bottom: 3px solid transparent;
287 padding: var(--sp-1) var(--sp-2);
368 gap: var(--sp-2);
369 border-right: 0;
370 border-bottom: 1px solid var(--shell-line);
371 overflow-x: auto;
288372 }
289 .siderail a.active { border-left: none; border-bottom-color: var(--accent); background: none; }
290 .siderail .glabel { display: none; }
373 a.brand { border-bottom: 0; padding-right: var(--sp-2); }
374 .railbody { flex: 1; overflow: visible; padding: 0; display: flex; align-items: center; }
375 /* pinned repos and the review queue both live on the dashboard, so the
376 strip keeps only what has nowhere else to go */
377 .railgroup { display: none; }
378 ul.raillist { display: flex; }
379 ul.raillist a { border-left: 0; border-bottom: 2px solid transparent; padding: var(--sp-2) var(--sp-3); }
380 ul.raillist a[aria-current] { border-left: 0; border-bottom-color: var(--shell-mark); }
381 .railfoot { border-top: 0; padding-left: var(--sp-2); }
382 .content { padding: var(--sp-4) var(--sp-4) var(--sp-5); }
383 .repohead { padding: var(--sp-3) var(--sp-4) 0; }
291384}
292385
293386/* tables */
@@ -301,66 +394,6 @@ td {
301394td:last-child { padding-right: 0; }
302395td.size, td.mode { color: var(--muted); white-space: nowrap; }
303396
304/* repo header */
305.repohead { margin-bottom: var(--sp-4); }
306h1.repotitle { margin: var(--sp-4) 0 var(--sp-1); font-weight: 400; }
307h1.repotitle a { color: var(--fg); }
308h1.repotitle a:last-of-type { font-weight: 650; }
309h1.repotitle a:hover { color: var(--accent); text-decoration: none; }
310h1.repotitle .sep { color: var(--muted); margin: 0 0.15em; }
311form.pinform { display: inline; margin-left: var(--sp-2); vertical-align: middle; }
312button.pinbtn {
313 background: none;
314 border: 1px solid var(--line);
315 border-radius: var(--r-pill);
316 color: var(--muted);
317 font-size: var(--fs-0);
318 padding: 0.05rem 0.55rem;
319 cursor: pointer;
320}
321button.pinbtn:hover { border-color: var(--accent); color: var(--accent); filter: none; }
322button.pinbtn.pinned { color: var(--accent); border-color: var(--accent); }
323a.chip.label:hover, a.chip.topic:hover { text-decoration: none; filter: brightness(1.15); }
324.repohead .desc { margin: 0 0 var(--sp-2); }
325
326/* repo rail: grouped vertical navigation on the left */
327nav.tabs { display: flex; flex-direction: column; margin-top: var(--sp-1); }
328nav.tabs .glabel {
329 font-size: var(--fs-0);
330 color: var(--muted);
331 letter-spacing: 0.04em;
332 margin: var(--sp-4) 0 var(--sp-1);
333}
334nav.tabs .glabel:first-child { margin-top: 0; }
335nav.tabs a {
336 color: var(--fg);
337 padding: 0.32rem var(--sp-3);
338 font-size: var(--fs-2);
339 border-left: 3px solid transparent;
340}
341nav.tabs a:hover { background: var(--hover); text-decoration: none; }
342nav.tabs a.active {
343 border-left-color: var(--accent);
344 background: var(--surface);
345 font-weight: 600;
346}
347@media (max-width: 52rem) {
348 /* the rail folds back into a wrapping strip on narrow screens */
349 main.container:has(> nav.tabs) { display: block; }
350 nav.tabs { flex-direction: row; flex-wrap: wrap; gap: 0 var(--sp-1); border-bottom: 1px solid var(--line); padding-bottom: var(--sp-2); position: static; }
351 nav.tabs .glabel { display: none; }
352 nav.tabs a { border-left: none; border-bottom: 3px solid transparent; padding: var(--sp-1) var(--sp-2); }
353 nav.tabs a.active { border-bottom-color: var(--accent); background: none; }
354}
355
356.clone { color: var(--muted); font-size: var(--fs-2); }
357.clone code {
358 background: var(--code-bg);
359 border: 1px solid var(--line);
360 padding: 0.15rem 0.5rem;
361 border-radius: var(--r-sm);
362}
363
364397/* path bar on tree and blob pages */
365398.pathbar {
366399 display: flex;
@@ -499,7 +532,14 @@ code.fullsha { color: var(--muted); overflow-wrap: anywhere; }
499532.empty-note { color: var(--muted); }
500533.crumbs { color: var(--muted); font-size: var(--fs-2); }
501534.desc, td.desc { color: var(--muted); }
502.error { color: var(--bad); }
535.error {
536 color: var(--fg);
537 border: 1px solid var(--bad);
538 border-left-width: 3px;
539 border-radius: var(--r-sm);
540 padding: var(--sp-2) var(--sp-3);
541 margin-bottom: var(--sp-4);
542}
503543.meta { color: var(--muted); font-size: var(--fs-1); }
504544svg.icon { vertical-align: -0.125em; }
505545.lede { font-size: var(--fs-3); margin: var(--sp-2) 0; }
@@ -908,27 +948,105 @@ details.editbox input[type="text"] { width: 100%; }
908948.thread .rendered p { margin: var(--sp-1) 0; }
909949.thread .threadstate { color: var(--muted); font-size: var(--fs-1); margin: var(--sp-1) 0 0; }
910950
911/* forms */
951/* forms: every control shares one shell, so a select reads as a sibling
952 of the text inputs beside it rather than as an OS control dropped in */
912953input, textarea, button, select { font: inherit; color: inherit; }
913input[type="text"], input:not([type]), textarea {
954input[type="text"], input[type="search"], input[type="email"],
955input[type="password"], input:not([type]), textarea, select {
914956 background: var(--bg);
957 color: var(--fg);
915958 border: 1px solid var(--line);
916959 border-radius: var(--r-md);
917960 padding: var(--sp-2) var(--sp-3);
961 line-height: 1.4;
918962}
919963textarea { width: 100%; resize: vertical; }
920964textarea[name="content"] { font-family: var(--mono); font-size: var(--fs-1); }
921input:focus, textarea:focus { outline: 2px solid color-mix(in srgb, var(--accent) 45%, transparent); border-color: var(--accent); }
965/* the native arrow cannot be restyled, so draw our own and match the
966 inputs exactly; --chev carries the stroke colour per scheme */
967select {
968 appearance: none;
969 padding-right: 1.9rem;
970 background-image: var(--chev);
971 background-repeat: no-repeat;
972 background-position: right 0.6rem center;
973 background-size: 0.62rem;
974 cursor: pointer;
975}
976input:hover, textarea:hover, select:hover { border-color: var(--muted); }
977/* the global :focus-visible ring covers these; only the border moves */
978input:focus, textarea:focus, select:focus { border-color: var(--accent); }
979input[type="radio"], input[type="checkbox"] { accent-color: var(--fill); }
980label:has(> input[type="radio"]), label:has(> input[type="checkbox"]),
981label:has(> select) { display: inline-flex; align-items: center; gap: var(--sp-2); }
982
983/* Shape carries the meaning on these two and nothing else does: a circle
984 takes one answer, a square takes any number. It survives colour being
985 removed — greyscale, high contrast, colour blindness — so the native
986 shapes stay and only the colour joins the system. Drawing them by hand
987 would align the corners at the cost of the one cue that always works. */
988input[type="radio"], input[type="checkbox"] { accent-color: var(--fill); }
989
990/* a segmented control for a small, mutually exclusive set: real radios
991 underneath, so arrow keys and screen readers behave as they always did */
992fieldset.segmented { border: 0; margin: 0 0 var(--sp-4); padding: 0; }
993fieldset.segmented legend {
994 padding: 0;
995 margin-bottom: var(--sp-2);
996 color: var(--muted);
997 font-size: var(--fs-2);
998}
999fieldset.segmented .options { display: inline-flex; }
1000fieldset.segmented label { gap: 0; }
1001fieldset.segmented input {
1002 position: absolute;
1003 width: 1px;
1004 height: 1px;
1005 opacity: 0;
1006 pointer-events: none;
1007}
1008fieldset.segmented span {
1009 display: block;
1010 padding: var(--sp-2) var(--sp-4);
1011 border: 1px solid var(--line);
1012 font-size: var(--fs-2);
1013 cursor: pointer;
1014}
1015fieldset.segmented label + label span { border-left: 0; }
1016fieldset.segmented label:first-child span { border-radius: var(--r-sm) 0 0 var(--r-sm); }
1017fieldset.segmented label:last-child span { border-radius: 0 var(--r-sm) var(--r-sm) 0; }
1018fieldset.segmented span:hover { border-color: var(--muted); }
1019fieldset.segmented input:checked + span {
1020 background: var(--fill);
1021 border-color: var(--fill);
1022 color: #ffffff;
1023 font-weight: 600;
1024}
1025fieldset.segmented input:focus-visible + span {
1026 outline: 2px solid var(--accent);
1027 outline-offset: 2px;
1028}
1029
1030/* remaining input states */
1031::placeholder { color: var(--muted); opacity: 1; }
1032::selection { background: var(--fill); color: #ffffff; }
1033/* :user-invalid, not :invalid — a required field is invalid the moment
1034 the page loads, and colouring it red before anyone has typed is a
1035 scolding, not a hint */
1036input:user-invalid, textarea:user-invalid {
1037 border-color: var(--bad);
1038}
1039.hint { display: block; color: var(--muted); font-size: var(--fs-1); margin-top: var(--sp-1); }
9221040button {
923 background: var(--accent);
924 color: var(--accent-fg);
1041 background: var(--fill);
1042 color: #ffffff;
9251043 border: none;
9261044 border-radius: var(--r-md);
927 padding: var(--sp-1) var(--sp-3);
928 font-weight: 500;
1045 padding: var(--sp-2) var(--sp-4);
1046 font-weight: 600;
9291047 cursor: pointer;
9301048}
931button:hover { filter: brightness(1.08); }
1049button:hover { filter: brightness(1.15); }
9321050
9331051/* 404 */
9341052.notfound { text-align: center; padding: var(--sp-6) 0; }
@@ -945,7 +1063,9 @@ button:hover { filter: brightness(1.08); }
9451063/* footer */
9461064footer {
9471065 border-top: 1px solid var(--faint);
948 padding: var(--sp-4) 0;
1066 padding: var(--sp-4) var(--sp-5) var(--sp-5);
1067 max-width: 78rem;
1068 width: 100%;
9491069 color: var(--muted);
9501070 font-size: var(--fs-1);
9511071}
@@ -1059,3 +1179,100 @@ pre.matchline mark {
10591179 padding: var(--sp-2) var(--sp-3);
10601180 }
10611181}
1182
1183/* tree listing: the last commit is what a file table is scanned for, so
1184 it leads. The file's own facts moved to the blob page. */
1185.tipbar {
1186 display: flex;
1187 align-items: center;
1188 gap: var(--sp-3);
1189 padding: var(--sp-2) var(--sp-4);
1190 background: var(--hover);
1191 border: 1px solid var(--line);
1192 border-bottom: 0;
1193 border-radius: var(--r-lg) var(--r-lg) 0 0;
1194 font-size: var(--fs-2);
1195}
1196.tipbar .who { font-weight: 600; }
1197.tipbar .subject { color: var(--fg); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1198.tipbar .subject:hover { color: var(--accent); }
1199.tipbar .spacer { flex: 1; }
1200.tipbar .age { color: var(--muted); white-space: nowrap; }
1201.tipbar + table.tree { border-radius: 0 0 var(--r-lg) var(--r-lg); }
1202table.tree th.lastcommit, table.tree td.lastcommit { width: 55%; }
1203table.tree td.lastcommit a { color: var(--muted); display: block; overflow: hidden;
1204 text-overflow: ellipsis; white-space: nowrap; }
1205table.tree td.lastcommit a:hover { color: var(--accent); }
1206table.tree th.age, table.tree td.age {
1207 text-align: right;
1208 white-space: nowrap;
1209 color: var(--muted);
1210 font-variant-numeric: tabular-nums;
1211}
1212table.tree td.name { width: 25%; white-space: nowrap; }
1213table.tree td.name.dir a { color: var(--accent); }
1214p.clone { margin: 0 0 var(--sp-3); }
1215p.filefacts { color: var(--muted); font-size: var(--fs-1); margin: 0 0 var(--sp-3); }
1216.pathbar .act { font-size: var(--fs-1); }
1217
1218/* merge request: a two-column split, so state has somewhere to live that
1219 is not a run-on sentence under the title */
1220.withaside {
1221 display: grid;
1222 grid-template-columns: minmax(0, 1fr) 15rem;
1223 gap: var(--sp-5);
1224 align-items: start;
1225}
1226.withaside .mainside { min-width: 0; }
1227@media (max-width: 62rem) { .withaside { grid-template-columns: 1fr; } }
1228
1229.aside { font-size: var(--fs-2); }
1230.aside .grp { border-top: 1px solid var(--line); padding: var(--sp-3) 0; }
1231.aside .grp:first-child { border-top: 0; padding-top: 0; }
1232.aside h2 {
1233 margin: 0 0 var(--sp-2);
1234 font-family: var(--mono);
1235 font-size: var(--fs-0);
1236 font-weight: 500;
1237 letter-spacing: 0.1em;
1238 text-transform: uppercase;
1239 color: var(--muted);
1240}
1241.aside .row { display: flex; align-items: center; gap: var(--sp-2); margin: 0 0 var(--sp-1); flex-wrap: wrap; }
1242.aside .row:last-child { margin-bottom: 0; }
1243.aside .none { color: var(--muted); }
1244.aside .dot { width: 0.45rem; height: 0.45rem; flex: none; border-radius: 50%; background: var(--muted); }
1245.aside .dot.ok { background: var(--ok); }
1246.aside .dot.bad { background: var(--bad); }
1247.aside .dot.pend { background: var(--mark); }
1248
1249/* sub-navigation within an item; orange marks position as it does above */
1250nav.subtabs {
1251 display: flex;
1252 gap: var(--sp-4);
1253 border-bottom: 1px solid var(--line);
1254 margin: 0 0 var(--sp-4);
1255}
1256nav.subtabs a {
1257 display: inline-flex;
1258 align-items: center;
1259 gap: var(--sp-2);
1260 padding: 0 0 var(--sp-2);
1261 color: var(--muted);
1262 font-size: var(--fs-2);
1263 border-bottom: 2px solid transparent;
1264 margin-bottom: -1px;
1265}
1266nav.subtabs a:hover { color: var(--fg); text-decoration: none; }
1267nav.subtabs a[aria-current] { color: var(--fg); font-weight: 700; border-bottom-color: var(--mark); }
1268nav.subtabs a i {
1269 font-style: normal;
1270 font-family: var(--mono);
1271 font-size: var(--fs-0);
1272 background: var(--hover);
1273 border-radius: var(--r-sm);
1274 padding: 0 0.35rem;
1275}
1276p.diffstat { color: var(--muted); font-size: var(--fs-2); margin: 0 0 var(--sp-3); }
1277p.diffstat .add { color: var(--ok); }
1278p.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/blame.html +1 −1
@@ -1,6 +1,6 @@
11{{define "title"}}blame: {{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
22{{define "content"}}
3{{template "repoheader" .}}
3<h1 class="vh">Blame: {{.Path}}</h1>
44<div class="pathbar">
55 <span class="refchip">{{template "branchicon"}} {{.Ref}}</span>
66 <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}<strong>{{.Base}}</strong></span>
internal/web/templates/blob.html +2 −1
@@ -1,12 +1,13 @@
11{{define "title"}}{{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
22{{define "content"}}
3{{template "repoheader" .}}
3<h1 class="vh">{{.Path}}</h1>
44<div class="pathbar">
55 {{template "refmenu" .}}
66 <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}<strong>{{.Base}}</strong></span>
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/build.html +1 −2
@@ -1,8 +1,7 @@
11{{define "title"}}build {{.Build.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
22{{define "content"}}
3{{template "repoheader" .}}
43<div class="headrow">
5<h1>build {{.Build.Number}} <span class="chip check-{{.Build.Status}}">{{.Build.Status}}</span></h1>
4<h1>Build {{.Build.Number}} <span class="chip check-{{.Build.Status}}">{{.Build.Status}}</span></h1>
65</div>
76<p class="meta">{{.Build.Job}} on {{.Build.Ref}} · <code><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/commit/{{.Build.SHA}}">{{printf "%.10s" .Build.SHA}}</a></code> · queued {{when .Build.CreatedAt}}{{if .Build.FinishedAt}} · finished {{when .Build.FinishedAt}}{{end}}</p>
87{{if .Log}}<pre class="code buildlog">{{.Log}}</pre>{{else}}<p class="empty-note">no log yet</p>{{end}}
internal/web/templates/builds.html +1 −1
@@ -1,6 +1,6 @@
11{{define "title"}}builds · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
22{{define "content"}}
3{{template "repoheader" .}}
3<h1>Builds</h1>
44<ul class="loglist">
55{{range .Builds}}<li>
66 <div class="commitmain">
internal/web/templates/commit.html +1 −2
@@ -1,8 +1,7 @@
11{{define "title"}}{{.ShortSHA}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
22{{define "content"}}
3{{template "repoheader" .}}
43<div class="commithead">
5 <h2>commit <code>{{.ShortSHA}}</code></h2>
4 <h1>Commit <code>{{.ShortSHA}}</code></h1>
65 <p class="meta"><code class="fullsha">{{.SHA}}</code></p>
76 {{if .Parents}}<p class="meta">parent{{if gt (len .Parents) 1}}s{{end}}:{{range .Parents}} <code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.}}">{{short .}}</a></code>{{end}}</p>{{end}}
87 <p>{{template "sigbadge" .Sig}}{{range .Checks}} <span class="badge check-{{.State}}">{{.Context}}: {{.State}}</span>{{end}}</p>
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 +4 −4
@@ -1,11 +1,11 @@
11{{define "title"}}edit {{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
22{{define "content"}}
33<h1>edit {{.Repo.OwnerName}}/{{.Repo.Name}} : {{.Path}} @ {{.Ref}}</h1>
4{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
4{{if .Error}}<p class="error" role="alert">{{.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 +5 −6
@@ -1,7 +1,6 @@
11{{define "title"}}#{{.Issue.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
22{{define "content"}}
3{{template "repoheader" .}}
4<h2 class="issuetitle">{{.Issue.Title}} <span class="issuenumber">#{{.Issue.Number}}</span></h2>
3<h1 class="issuetitle">{{.Issue.Title}} <span class="issuenumber">#{{.Issue.Number}}</span></h1>
54<p class="issuemeta"><span class="chip {{if eq .Issue.State "open"}}chip-open{{else}}chip-done{{end}}">{{.Issue.State}}</span>
65<a href="/{{.Issue.Author}}">{{.Issue.Author}}</a> opened this on {{when .Issue.CreatedAt}}
76{{if .Issue.Labels}} · {{range .Issue.Labels}}<a class="chip label" style="{{index $.LabelColors .}}" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues?label={{.}}">{{.}}</a> {{end}}{{end}}
@@ -9,9 +8,9 @@
98{{if .Issue.Milestone}} · milestone <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">{{.Issue.Milestone}}</a>{{end}}</p>
109{{if .CanEdit}}<details class="editbox"><summary>edit</summary>
1110<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/edit" class="commentform">
12<p><input type="text" name="title" value="{{.Issue.Title}}" required></p>
13<p><textarea name="body" rows="8">{{.Issue.Body}}</textarea></p>
14<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>
1514<p><button type="submit">save</button></p>
1615</form></details>{{end}}
1716{{if .BodyHTML}}<article class="comment">
@@ -27,7 +26,7 @@
2726{{end}}
2827{{if .Viewer}}
2928<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/comment" class="commentform">
30<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>
3130<p><button type="submit">comment</button></p>
3231</form>
3332{{end}}
internal/web/templates/issuenew.html +5 −6
@@ -1,13 +1,12 @@
11{{define "title"}}new issue · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
22{{define "content"}}
3{{template "repoheader" .}}
4<h2>new issue</h2>
3<h1>New issue</h1>
54{{if gt (len .Templates) 1}}<p class="meta">template:
65{{range .Templates}}{{if eq .Name $.Template}}<strong>{{.Name}}</strong>{{else}}<a href="?template={{.Name}}">{{.Name}}</a>{{end}} {{end}}</p>{{end}}
76<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/new" class="commentform">
8<p><input type="text" name="title" placeholder="title" required></p>
9<p><textarea name="body" rows="12">{{.Body}}</textarea></p>
10<p><input type="text" name="labels" placeholder="labels, space-separated (write access)"></p>
11<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>
1211</form>
1312{{end}}
internal/web/templates/issues.html +1 −2
@@ -1,8 +1,7 @@
11{{define "title"}}issues · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
22{{define "content"}}
3{{template "repoheader" .}}
43<div class="listhead">
5 <h2>issues</h2>
4 <h1>Issues</h1>
65 <nav class="filters">
76 <a {{if eq .State "open"}}class="active" {{end}}href="?state=open">open</a>
87 <a {{if eq .State "closed"}}class="active" {{end}}href="?state=closed">closed</a>
internal/web/templates/layout.html +69 −46
@@ -8,65 +8,88 @@
88<link rel="icon" href="/favicon.svg" type="image/svg+xml">
99</head>
1010<body>
11<header>
12 <nav class="topnav">
13 <a class="site" href="/"><svg class="mark" width="20" height="20" viewBox="0 0 24 24" aria-hidden="true"><rect x="1" y="1" width="22" height="22" rx="6" fill="var(--accent)"/><path d="M5 9.5c2-2.5 4-2.5 6 0s4 2.5 6 0" stroke="var(--accent-fg)" stroke-width="2.2" fill="none" stroke-linecap="round"/><path d="M5 15c2-2.5 4-2.5 6 0s4 2.5 6 0" stroke="var(--accent-fg)" stroke-width="2.2" fill="none" stroke-linecap="round"/></svg>{{.Site}}</a>
14 <span class="spacer"></span>
15 {{if .Viewer}}<a class="avatar" href="/{{.Viewer}}" title="{{.Viewer}}" aria-label="{{.Viewer}}">{{initial .Viewer}}</a>
16 {{else}}<a class="avatar guest" href="/login" title="sign in" aria-label="sign in">→</a>{{end}}
17 </nav>
18</header>
11<a class="skip" href="#content">Skip to content</a>
1912<div class="shell">
20<nav class="siderail" aria-label="site">
21 <div class="railmain">
22 <a {{if eq (str . "Tab") "explore"}}class="active" {{end}}href="/explore">explore</a>
23 {{if .Viewer}}<a href="/new">new repository</a>{{end}}
24 {{with field . "Repo"}}
25 <span class="glabel">{{.OwnerName}}/{{.Name}}</span>
26 <span class="glabel sub">code</span>
27 <a {{if eq (str $ "Tab") "files"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}">files</a>
28 <a {{if eq (str $ "Tab") "log"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/log">log</a>
29 <a {{if eq (str $ "Tab") "refs"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/refs">refs</a>
30 <span class="glabel sub">work</span>
31 <a {{if eq (str $ "Tab") "issues"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/issues">issues</a>
32 <a {{if eq (str $ "Tab") "merge requests"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/mrs">merge requests</a>
33 <a {{if eq (str $ "Tab") "builds"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/builds">builds</a>
34 <span class="glabel sub">publish</span>
35 <a {{if eq (str $ "Tab") "releases"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/releases">releases</a>
36 {{if field $ "HasWiki"}}<a {{if eq (str $ "Tab") "wiki"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/wiki">wiki</a>{{end}}
37 <span class="glabel sub">tools</span>
38 <a {{if eq (str $ "Tab") "search"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/search">search</a>
39 <a href="/{{.OwnerName}}/{{.Name}}/archive/{{str $ "Ref"}}.tar.gz">archive</a>
13
14{{$here := ""}}{{with field . "Repo"}}{{$here = printf "%s/%s" .OwnerName .Name}}{{end}}
15<nav class="rail" aria-label="Site">
16 <a class="brand" href="/">{{template "mark"}}<span>{{.Site}}</span></a>
17 <div class="railbody">
18 <ul class="raillist">
19 {{if .Viewer}}<li><a href="/">Dashboard</a></li>{{end}}
20 <li><a {{if eq (str . "Tab") "explore"}}aria-current="page" {{end}}href="/explore">Explore</a></li>
21 {{if .Viewer}}<li><a href="/new">New repository</a></li>{{end}}
22 </ul>
23 {{with .Rail.Pinned}}
24 <div class="railgroup">
25 <p class="raillabel" id="rail-pinned">Pinned</p>
26 <ul class="raillist" aria-labelledby="rail-pinned">
27 {{range .}}<li><a {{if eq $here (printf "%s/%s" .Owner .Name)}}aria-current="page" {{end}}href="/{{.Owner}}/{{.Name}}"><span class="owner">{{.Owner}}/</span>{{.Name}}</a></li>
28 {{end}}
29 </ul>
30 </div>
31 {{end}}
32 {{with .Rail.Reviews}}
33 <div class="railgroup">
34 <p class="raillabel" id="rail-reviews">Waiting on you <b class="count">{{len .}}</b></p>
35 <ul class="raillist wide" aria-labelledby="rail-reviews">
36 {{range .}}<li><a href="/{{.RepoPath}}/mrs/{{.Number}}"><b>!{{.Number}} {{.Title}}</b><span class="owner">{{.RepoPath}}</span></a></li>
37 {{end}}
38 </ul>
39 </div>
4040 {{end}}
4141 </div>
4242 <div class="railfoot">
43 {{if .Viewer}}<a class="railuser" href="/{{.Viewer}}"><span class="avatar sm">{{initial .Viewer}}</span>{{.Viewer}}</a>
44 <form method="post" action="/logout"><button type="submit" class="linklike">log out</button></form>
45 {{else}}<a href="/login">sign in</a>{{end}}
43 {{if .Viewer}}<a class="railuser" href="/{{.Viewer}}"><span class="avatar">{{initial .Viewer}}</span>{{.Viewer}}</a>
44 <form method="post" action="/logout"><button type="submit" class="linklike">Log out</button></form>
45 {{else}}<a href="/login">Sign in</a>{{end}}
4646 </div>
4747</nav>
48<main class="container">
48
49<div class="pane">
50{{with field . "Repo"}}
51<header class="repohead">
52 <div class="identity">
53 {{if field $ "RepoHome"}}<h1 class="repotitle"><a class="owner" href="/{{.OwnerName}}">{{.OwnerName}}</a><span class="sep">/</span>{{.Name}}</h1>
54 {{else}}<p class="repotitle"><a class="owner" href="/{{.OwnerName}}">{{.OwnerName}}</a><span class="sep">/</span><a href="/{{.OwnerName}}/{{.Name}}">{{.Name}}</a></p>{{end}}
55 {{if eq .Visibility "private"}}<span class="chip">Private</span>{{end}}
56 {{if .Settings.Archived}}<span class="chip">Archived</span>{{end}}
57 <span class="grow"></span>
58 {{if $.Viewer}}<form method="post" action="/{{.OwnerName}}/{{.Name}}/pin" class="inline"><button type="submit" class="btn" aria-pressed="{{if field $ "Pinned"}}true{{else}}false{{end}}">{{if field $ "Pinned"}}★ Pinned{{else}}☆ Pin{{end}}</button></form>{{end}}
59 </div>
60 {{with field $ "Desc"}}<p class="repodesc">{{.}}</p>{{end}}
61 {{with field $ "Topics"}}<p class="topics">{{range .}}<a class="chip topic" href="/explore?q={{.}}">{{.}}</a> {{end}}</p>{{end}}
62 {{with $.Repo.Settings.Website}}<p class="repometa"><a href="{{.}}" rel="nofollow">{{.}}</a></p>{{end}}
63 {{range field $ "Mirrors"}}<p class="repometa">{{if eq .Direction "push"}}Mirrors to{{else}}Mirrors from{{end}} <a href="{{.URL}}" rel="nofollow">{{.Target}}</a>{{if .Error}} · <span class="bad">sync error: {{.Error}}</span>{{else if .Synced}} · synced {{.Synced}}{{end}}</p>
64 {{end}}
65 <nav class="tabs" aria-label="Repository">
66 {{$top := topTab (str $ "Tab")}}
67 <a {{if eq $top "code"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}">Code</a>
68 <a {{if eq $top "issues"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}/issues">Issues{{with field $ "OpenIssues"}}{{if .}} <i>{{.}}</i>{{end}}{{end}}</a>
69 <a {{if eq $top "mrs"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}/mrs">Merge requests{{with field $ "OpenMRs"}}{{if .}} <i>{{.}}</i>{{end}}{{end}}</a>
70 <a {{if eq $top "builds"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}/builds">Builds</a>
71 <a {{if eq $top "releases"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}/releases">Releases</a>
72 {{if field $ "HasWiki"}}<a {{if eq $top "wiki"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}/wiki">Wiki</a>{{end}}
73 </nav>
74</header>
75{{end}}
76
77<main id="content" class="content">
4978{{template "content" .}}
5079</main>
51</div>
80
5281<footer>
53 <div class="container"><p>powered by <a href="https://gitbay.org/krz/gitbay">gitbay</a>{{with gitbayVersion}} · <code><a href="https://gitbay.org/krz/gitbay/commit/{{gitbayCommit}}">{{.}}</a></code>{{end}} · <a href="/privacy">privacy</a></p></div>
82 <p>Powered by <a href="https://gitbay.org/krz/gitbay">gitbay</a>{{with gitbayVersion}} · <code><a href="https://gitbay.org/krz/gitbay/commit/{{gitbayCommit}}">{{.}}</a></code>{{end}} · <a href="/privacy">Privacy</a></p>
5483</footer>
84</div>
85</div>
5586</body>
5687</html>{{end}}
5788
58{{define "repoheader"}}
59<div class="repohead">
60<h1 class="repotitle"><a class="owner" href="/{{.Repo.OwnerName}}">{{.Repo.OwnerName}}</a><span class="sep">/</span><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>{{if eq .Repo.Visibility "private"}} <span class="chip chip-neutral">private</span>{{end}}{{if .Repo.Settings.Archived}} <span class="chip chip-stale">archived</span>{{end}}{{if .Viewer}}<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/pin" class="inline pinform"><button type="submit" class="pinbtn{{if .Pinned}} pinned{{end}}" title="{{if .Pinned}}unpin from dashboard{{else}}pin to dashboard{{end}}">{{if .Pinned}}★ pinned{{else}}☆ pin{{end}}</button></form>{{end}}</h1>
61{{if .Desc}}<p class="desc">{{.Desc}}</p>{{end}}
62{{if .Repo.Settings.Website}}<p class="meta repowebsite"><a href="{{.Repo.Settings.Website}}" rel="nofollow">{{.Repo.Settings.Website}}</a></p>{{end}}
63{{if .Topics}}<p class="topics">{{range .Topics}}<span class="chip topic">{{.}}</span> {{end}}</p>{{end}}
64{{range .Mirrors}}<p class="meta mirrorline">{{if eq .Direction "push"}}mirrors to{{else}}mirrors from{{end}} <a href="{{.URL}}" rel="nofollow">{{.Target}}</a>{{if .Error}} · <span class="mirrorerr">sync error: {{.Error}}</span>{{else if .Synced}} · synced {{.Synced}}{{end}}</p>
65{{end}}</div>
66{{end}}
89{{define "mark"}}<svg class="mark" width="19" height="19" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2.25 18.75 12H5.25z" fill="#ff6b3d"/><rect x="3" y="13.5" width="18" height="3" fill="currentColor"/><rect x="7.5" y="18" width="9" height="3" fill="currentColor"/></svg>{{end}}
6790
6891{{define "reporow"}}<li>
69 <p class="reponame"><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}}{{if .Settings.Archived}} <span class="chip chip-stale">archived</span>{{end}}</p>
92 <p class="reponame"><a href="/{{.OwnerName}}/{{.Name}}">{{.OwnerName}}<span class="sep">/</span><strong>{{.Name}}</strong></a>{{if eq .Visibility "private"}} <span class="chip">Private</span>{{end}}{{if .Settings.Archived}} <span class="chip">Archived</span>{{end}}</p>
7093 {{if .Desc}}<p class="desc">{{.Desc}}</p>{{end}}
7194 {{if .Topics}}<p class="topics">{{range .Topics}}<a class="chip topic" href="/explore?q={{.}}">{{.}}</a> {{end}}</p>{{end}}
7295 <p class="meta">{{template "branchicon"}} {{.DefaultBranch}}{{if .License}} · {{.License}}{{end}}{{if .Updated}} · updated {{.Updated}}{{end}}</p>
@@ -76,10 +99,10 @@
7699 <summary class="refchip">{{template "branchicon"}} {{.Ref}}</summary>
77100 <div class="refdrop">
78101 {{range .Branches}}<a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/{{$.RefKind}}/{{.Name}}/{{$.DirPath}}">{{.Name}}</a>
79 {{end}}<a class="allrefs" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/refs">all refs →</a>
102 {{end}}<a class="allrefs" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/refs">All refs →</a>
80103 </div>
81104</details>{{else}}<span class="refchip">{{template "branchicon"}} {{.Ref}}</span>{{end}}{{end}}
82105
83106{{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}}
84107
85{{define "sigbadge"}}<span class="badge badge-{{.State}}" title="{{.Fingerprint}}">{{.State}}{{if .Signer}} · {{.Signer}}{{end}}</span>{{end}}
108{{define "sigbadge"}}<span class="badge badge-{{.State}}" title="{{.Fingerprint}}">{{sigLabel .State}}{{if .Signer}} · {{.Signer}}{{end}}</span>{{end}}
internal/web/templates/log.html +1 −1
@@ -1,7 +1,7 @@
11{{define "title"}}log · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
22{{define "content"}}
3{{template "repoheader" .}}
43{{if .FilePath}}<p class="meta">history of <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/blob/{{.Ref}}/{{.FilePath}}"><code>{{.FilePath}}</code></a> · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log">full log</a></p>{{end}}
4<h1 class="vh">Commits</h1>
55<ul class="loglist">
66{{range .Commits}}<li>
77 <div class="commitmain">
internal/web/templates/login.html +2 −2
@@ -1,7 +1,7 @@
11{{define "title"}}login · {{.Site}}{{end}}
22{{define "content"}}
3<h1>log in</h1>
4{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
3<h1>Log in</h1>
4{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
55<p>Browser sessions are minted over SSH — there is no password. From a machine
66with your registered key:</p>
77<pre class="message">ssh git@{{.Site}} web login</pre>
internal/web/templates/milestones.html +1 −2
@@ -1,8 +1,7 @@
11{{define "title"}}milestones · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
22{{define "content"}}
3{{template "repoheader" .}}
43<div class="listhead">
5 <h2>milestones</h2>
4 <h1>Milestones</h1>
65 <nav class="filters">
76 <a {{if eq .State "open"}}class="active" {{end}}href="?state=open">open</a>
87 <a {{if eq .State "closed"}}class="active" {{end}}href="?state=closed">closed</a>
internal/web/templates/mr.html +59 −25
@@ -1,25 +1,30 @@
11{{define "title"}}!{{.MR.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
22{{define "content"}}
3{{template "repoheader" .}}
4<h2 class="issuetitle">{{.MR.Title}} <span class="issuenumber">!{{.MR.Number}}</span></h2>
3{{$base := printf "/%s/%s/mrs/%d" .Repo.OwnerName .Repo.Name .MR.Number}}
4<h1 class="issuetitle">{{.MR.Title}} <span class="issuenumber">!{{.MR.Number}}</span></h1>
55<p class="issuemeta"><span class="chip chip-{{.MR.State}}">{{.MR.State}}</span>
6<a href="/{{.MR.Author}}">{{.MR.Author}}</a> wants to merge {{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}} into {{.MR.TargetRef}}
7 at <code>{{short .MR.HeadSHA}}</code>{{if .MR.Milestone}} · milestone <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">{{.MR.Milestone}}</a>{{end}}</p>
8{{if .CanEdit}}<details class="editbox"><summary>edit</summary>
9<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/{{.MR.Number}}/edit" class="commentform">
10<p><input type="text" name="title" value="{{.MR.Title}}" required></p>
11<p><textarea name="body" rows="8">{{.MR.Body}}</textarea></p>
12<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>
1323</form></details>{{end}}
1424{{if .BodyHTML}}<article class="comment">
15 <header class="commenthead"><strong>{{.MR.Author}}</strong></header>
25 <header class="commenthead"><strong><a href="/{{.MR.Author}}">{{.MR.Author}}</a></strong></header>
1626 <div class="rendered">{{.BodyHTML}}</div>
1727</article>{{end}}
18{{if .Checks}}<div class="checks">
19<p>checks: <span class="badge check-{{.Combined}}">{{.Combined}}</span>
20{{range .Checks}} · {{if .TargetURL}}<a href="{{.TargetURL}}" rel="nofollow">{{.Context}}</a>{{else}}{{.Context}}{{end}} <span class="check-{{.State}}">{{.State}}</span>{{end}}</p>
21</div>{{end}}
22{{range .Reviews}}<p class="review">review: <strong>{{.Reviewer}}</strong> — {{.Verdict}}{{if .Stale}} <span class="chip chip-stale">stale</span>{{end}}</p>{{end}}
2328{{range .Comments}}
2429{{if eq .Kind "system"}}<div class="syscomment">{{.BodyHTML}} <span class="when">{{when .CreatedAt}}</span></div>
2530{{else}}<article class="comment">
@@ -27,13 +32,16 @@
2732 <div class="rendered">{{.BodyHTML}}</div>
2833</article>{{end}}
2934{{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}}
3037{{if .Viewer}}
31<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/{{.MR.Number}}/comment" class="commentform">
32<p><textarea name="body" rows="4" placeholder="comment as {{.Viewer}}"></textarea></p>
33<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>
3441</form>
3542{{end}}
36{{if .Commits}}<h3>commits <span class="count">{{len .Commits}}</span></h3>
43
44{{else if eq .View "commits"}}
3745<ul class="loglist">
3846{{range .Commits}}<li>
3947 <div class="commitmain">
@@ -45,13 +53,39 @@
4553 <code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.ShortSHA}}</a></code>
4654 </div>
4755</li>
56{{else}}<li class="empty">no commits on this merge request</li>
4857{{end}}
49</ul>{{end}}
50<details class="difffold">
51<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>
5262<pre class="diff">{{range .DiffLines}}<span class="{{.Class}}">{{.Text}}</span>
5363{{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>
54</details>
55{{if .DetachedThreads}}<h3>threads on earlier revisions</h3>
56{{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>
5791{{end}}
internal/web/templates/mrs.html +1 −2
@@ -1,8 +1,7 @@
11{{define "title"}}merge requests · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
22{{define "content"}}
3{{template "repoheader" .}}
43<div class="listhead">
5 <h2>merge requests</h2>
4 <h1>Merge requests</h1>
65 <nav class="filters">
76 <a {{if eq .State "open"}}class="active" {{end}}href="?state=open">open</a>
87 <a {{if eq .State "merged"}}class="active" {{end}}href="?state=merged">merged</a>
internal/web/templates/new.html +13 −7
@@ -1,15 +1,21 @@
11{{define "title"}}new repository · {{.Site}}{{end}}
22{{define "content"}}
3<h1>new repository</h1>
4{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
3<h1>New repository</h1>
4{{if .Error}}<p class="error" role="alert">{{.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 maxlength="63" pattern="[a-z0-9][a-z0-9._\-]{0,62}" aria-describedby="namehint"></label>
11<span class="hint" id="namehint">Lowercase letters, digits, dot, dash and underscore; must start with a letter or digit; up to 63 characters.</span></p>
12<fieldset class="segmented">
13 <legend>Visibility</legend>
14 <div class="options">
15 <label><input type="radio" name="visibility" value="public" checked><span>Public</span></label>
16 <label><input type="radio" name="visibility" value="private"><span>Private</span></label>
17 </div>
18</fieldset>
19<p><button type="submit">Create repository</button></p>
1420</form>
1521{{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
88self-hosted, CLI-first git forge. The software makes no external
99requests from your browser: no analytics, no CDNs, no webfonts, no
@@ -20,7 +20,7 @@ they are indistinguishable from nonexistent.</p>
2020downloads 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
2626instance-specific notes. Questions about backups, retention, or
internal/web/templates/refs.html +3 −3
@@ -1,8 +1,8 @@
11{{define "title"}}refs · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
22{{define "content"}}
3{{template "repoheader" .}}
4<h2>branches</h2>
3<h1>Refs</h1>
4<h2>Branches</h2>
55<table class="refs">{{range .Branches}}<tr><td class="name">{{template "branchicon"}} <a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/tree/{{.Name}}/">{{.Name}}</a></td><td class="sha"><code>{{short .SHA}}</code></td></tr>{{else}}<tr><td class="empty">none</td></tr>{{end}}</table>
6<h2>tags</h2>
6<h2>Tags</h2>
77<table class="refs">{{range .Tags}}<tr><td class="name"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/tree/{{.Name}}/">{{.Name}}</a></td><td class="sha"><code>{{short .SHA}}</code></td></tr>{{else}}<tr><td class="empty">none</td></tr>{{end}}</table>
88{{end}}
internal/web/templates/register.html +4 −4
@@ -1,17 +1,17 @@
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}}
7{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
7{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
88<form method="post" action="/register" class="signupform">
99<p><label>username<br><input type="text" name="username" value="{{.Username}}" required autofocus></label></p>
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/releases.html +1 −2
@@ -1,7 +1,6 @@
11{{define "title"}}releases · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
22{{define "content"}}
3{{template "repoheader" .}}
4<h2>releases</h2>
3<h1>Releases</h1>
54{{range $rel := .Releases}}
65<article class="release">
76 <header class="releasehead">
internal/web/templates/search.html +3 −3
@@ -1,11 +1,11 @@
11{{define "title"}}search · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
22{{define "content"}}
3{{template "repoheader" .}}
3<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>
8{{if .QueryErr}}<p class="error">{{.QueryErr}}</p>
8{{if .QueryErr}}<p class="error" role="alert">{{.QueryErr}}</p>
99{{else if .Query}}
1010{{if .Matches}}
1111<p class="meta">{{len .Matches}} match{{if ne (len .Matches) 1}}es{{end}}{{if .Capped}} (capped — refine the query){{end}} on <code>{{.Ref}}</code></p>
internal/web/templates/tree.html +18 −7
@@ -1,18 +1,29 @@
11{{define "title"}}{{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
22{{define "content"}}
3{{template "repoheader" .}}
3{{if .DirPath}}<h1 class="vh">{{.DirPath}}</h1>{{end}}
44<div class="pathbar">
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/templates/wiki.html +1 −1
@@ -1,6 +1,6 @@
11{{define "title"}}wiki{{if .Page}}: {{.Page}}{{end}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
22{{define "content"}}
3{{template "repoheader" .}}
3<h1 class="vh">Wiki{{if .Page}}: {{.Page}}{{end}}</h1>
44{{if .Missing}}<p class="empty-note">no wiki yet — create one by pushing pages:<br>
55<code>git clone ssh://git@{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}.wiki.git</code> then add <code>Home.md</code> (or .org) and push.</p>
66{{else}}
internal/web/web.go +67
@@ -8,6 +8,7 @@ import (
88 "io"
99 "reflect"
1010 "runtime/debug"
11 "strconv"
1112 "strings"
1213 "sync"
1314 "time"
@@ -77,6 +78,20 @@ var funcs = template.FuncMap{
7778 },
7879 "add": func(a, b int) int { return a + b },
7980 "sub": func(a, b int) int { return a - b },
81 // topTab maps a page's Tab to the repo header tab that should read as
82 // current. Everything that browses the tree or its history sits under
83 // code, whose own toolbar carries the log, search, and refs links.
84 "topTab": func(tab string) string {
85 switch tab {
86 case "files", "log", "refs", "search":
87 return "code"
88 case "issues":
89 return "issues"
90 case "merge requests":
91 return "mrs"
92 }
93 return tab
94 },
8095 // initial is the avatar letter for a username.
8196 "initial": func(s string) string {
8297 for _, r := range s {
@@ -117,6 +132,58 @@ var funcs = template.FuncMap{
117132 }
118133 return f.Interface()
119134 },
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 },
120187 // when formats a stored RFC3339 timestamp for display; unparseable
121188 // values pass through unchanged.
122189 "when": func(s string) string {