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) {
93 t.Fatalf("dashboard: %d", status) 93 t.Fatalf("dashboard: %d", status)
94 } 94 }
95 for _, want := range []string{ 95 for _, want := range []string{
96 ">pinned</h2>", ">app<", // pinned card 96 ">Pinned</h2>", ">app<", // pinned card
97 "alice/app!1 from bob", "alice/app#1 todo one", 97 "alice/app!1 from bob", "alice/app#1 todo one",
98 `href="/alice/app/mrs/1"`, `href="/alice/app/issues/1"`, 98 `href="/alice/app/mrs/1"`, `href="/alice/app/issues/1"`,
99 } { 99 } {
@@ -102,11 +102,18 @@ func TestDashboard(t *testing.T) {
102 } 102 }
103 } 103 }
104 104
105 // The MR diff is collapsed by default in a <details> with stats. 105 // The diff has its own view rather than a fold at the foot of the
106 // conversation: the default view offers it, and asking for it renders
107 // the stat line and the patch.
106 _, body = inst.get(t, "/alice/app/mrs/1") 108 _, body = inst.get(t, "/alice/app/mrs/1")
107 if !strings.Contains(body, `<details class="difffold">`) || 109 if !strings.Contains(body, `href="/alice/app/mrs/1?view=diff"`) {
108 strings.Contains(body, `<details class="difffold" open`) || 110 t.Fatal("merge request missing the files-changed view")
109 !strings.Contains(body, "1 file changed") { 111 }
110 t.Fatal("diff not collapsed with stats") 112 if strings.Contains(body, `class="diff"`) {
113 t.Fatal("diff rendered on the conversation view")
114 }
115 _, body = inst.get(t, "/alice/app/mrs/1?view=diff")
116 if !strings.Contains(body, "1 file changed") || !strings.Contains(body, `class="diff"`) {
117 t.Fatalf("diff view missing stat or patch:\n%s", body)
111 } 118 }
112} 119}
e2e/design_test.go +26 −4
@@ -51,14 +51,27 @@ func TestReadmeRelativeLinks(t *testing.T) {
51 "<table>", "<td>verbose</td>", // GFM table renders 51 "<table>", "<td>verbose</td>", // GFM table renders
52 `<span class="kd">func</span>`, // fenced code highlighted via classes 52 `<span class="kd">func</span>`, // fenced code highlighted via classes
53 `href="/alice/site/blob/main/README.md">README.md</a>`, // clickable card header 53 `href="/alice/site/blob/main/README.md">README.md</a>`, // clickable card header
54 `<th>name</th>`, // file table column headers 54 `<th scope="col">name</th>`, // file table column headers
55 } { 55 } {
56 if !strings.Contains(body, want) { 56 if !strings.Contains(body, want) {
57 t.Errorf("missing %q", want) 57 t.Errorf("missing %q", want)
58 } 58 }
59 } 59 }
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 }
60 // Branch dropdown lists branches. 73 // 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") {
62 t.Error("branch dropdown missing") 75 t.Error("branch dropdown missing")
63 } 76 }
64 // Per-file history: ?path= filters the log; blob pages link to it. 77 // Per-file history: ?path= filters the log; blob pages link to it.
@@ -126,6 +139,15 @@ func TestReadmeRelativeLinks(t *testing.T) {
126 t.Errorf("explore row missing %q", want) 139 t.Errorf("explore row missing %q", want)
127 } 140 }
128 } 141 }
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 }
129} 151}
130 152
131func TestWebInteractions(t *testing.T) { 153func TestWebInteractions(t *testing.T) {
@@ -164,14 +186,14 @@ func TestWebInteractions(t *testing.T) {
164 t.Fatal("pin toggle failed") 186 t.Fatal("pin toggle failed")
165 } 187 }
166 _, body := browserGet(t, browser, inst.base()+"/theorg/webborn") 188 _, body := browserGet(t, browser, inst.base()+"/theorg/webborn")
167 if !strings.Contains(body, "★ pinned") { 189 if !strings.Contains(body, "★ Pinned") {
168 t.Fatal("repo header not pinned") 190 t.Fatal("repo header not pinned")
169 } 191 }
170 if _, body = browserGet(t, browser, inst.base()+"/"); !strings.Contains(body, "theorg<span") { 192 if _, body = browserGet(t, browser, inst.base()+"/"); !strings.Contains(body, "theorg<span") {
171 t.Fatal("dashboard missing pinned repo") 193 t.Fatal("dashboard missing pinned repo")
172 } 194 }
173 browserPost(t, browser, inst.base()+"/theorg/webborn/pin", url.Values{}) 195 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") {
175 t.Fatal("unpin failed") 197 t.Fatal("unpin failed")
176 } 198 }
177 199
e2e/diffcomment_test.go +4 −3
@@ -92,12 +92,13 @@ func TestDiffComments(t *testing.T) {
92 if !strings.Contains(out, `"unresolved_threads":1`) { 92 if !strings.Contains(out, `"unresolved_threads":1`) {
93 t.Fatalf("mr show count: %s", out) 93 t.Fatalf("mr show count: %s", out)
94 } 94 }
95 status, body := inst.get(t, "/alice/lib/mrs/1") 95 status, body := inst.get(t, "/alice/lib/mrs/1?view=diff")
96 if status != 200 || !strings.Contains(body, "use log instead of fmt") || 96 if status != 200 || !strings.Contains(body, "use log instead of fmt") ||
97 !strings.Contains(body, `class="thread`) { 97 !strings.Contains(body, `class="thread`) {
98 t.Fatalf("web thread: %d", status) 98 t.Fatalf("web thread: %d", status)
99 } 99 }
100 if strings.Contains(body, "threads on earlier revisions") { 100 // Detached threads belong to the conversation, not the current diff.
101 if _, conv := inst.get(t, "/alice/lib/mrs/1"); strings.Contains(conv, "Threads on earlier revisions") {
101 t.Fatal("fresh thread rendered as detached") 102 t.Fatal("fresh thread rendered as detached")
102 } 103 }
103 104
@@ -126,7 +127,7 @@ func TestDiffComments(t *testing.T) {
126 t.Fatalf("thread not stale after force-push: %s", out) 127 t.Fatalf("thread not stale after force-push: %s", out)
127 } 128 }
128 _, body = inst.get(t, "/alice/lib/mrs/1") 129 _, body = inst.get(t, "/alice/lib/mrs/1")
129 if !strings.Contains(body, "threads on earlier revisions") { 130 if !strings.Contains(body, "Threads on earlier revisions") {
130 t.Fatal("stale thread not moved to detached section") 131 t.Fatal("stale thread not moved to detached section")
131 } 132 }
132} 133}
e2e/mirror_test.go +2 −2
@@ -119,10 +119,10 @@ func TestMirrors(t *testing.T) {
119 if status, _ := browserGet(t, browser, inst.base()+loginPath); status != 200 { 119 if status, _ := browserGet(t, browser, inst.base()+loginPath); status != 200 {
120 t.Fatalf("login: %d", status) 120 t.Fatalf("login: %d", status)
121 } 121 }
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) {
123 t.Fatalf("admin repo page missing mirror line:\n%s", body) 123 t.Fatalf("admin repo page missing mirror line:\n%s", body)
124 } 124 }
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") {
126 t.Fatalf("anonymous repo page shows mirror line:\n%s", body) 126 t.Fatalf("anonymous repo page shows mirror line:\n%s", body)
127 } 127 }
128 128
e2e/mr_test.go +6 −5
@@ -264,12 +264,13 @@ func TestMergeRequests(t *testing.T) {
264 if status != 200 || !strings.Contains(body, "stale") || !strings.Contains(body, "merged") { 264 if status != 200 || !strings.Contains(body, "stale") || !strings.Contains(body, "merged") {
265 t.Fatalf("mr detail: %d\n%s", status, body) 265 t.Fatalf("mr detail: %d\n%s", status, body)
266 } 266 }
267 if !strings.Contains(body, "feature.txt") { 267 if _, diff := inst.get(t, "/alice/lib/mrs/1?view=diff"); !strings.Contains(diff, "feature.txt") {
268 t.Fatalf("merged MR web diff empty:\n%s", body) 268 t.Fatalf("merged MR web diff empty:\n%s", diff)
269 } 269 }
270 // The MR page lists the commits it carries, linked to commit pages. 270 // The MR lists the commits it carries, linked to commit pages.
271 if !strings.Contains(body, ">commits <") || !strings.Contains(body, "/alice/lib/commit/") { 271 _, commits := inst.get(t, "/alice/lib/mrs/1?view=commits")
272 t.Fatalf("mr commits section missing:\n%s", body) 272 if !strings.Contains(commits, "/alice/lib/commit/") {
273 t.Fatalf("mr commits view missing:\n%s", commits)
273 } 274 }
274 // So does mr show, human and JSON. 275 // So does mr show, human and JSON.
275 showOut, _, code := inst.ssh(t, aliceKey, "", "mr", "show", "alice/lib", "1") 276 showOut, _, code := inst.ssh(t, aliceKey, "", "mr", "show", "alice/lib", "1")
e2e/web_test.go +1 −1
@@ -88,7 +88,7 @@ func TestWebUI(t *testing.T) {
88 if !strings.Contains(body, "<h1>hello site</h1>") || !strings.Contains(body, "<em>markdown</em>") { 88 if !strings.Contains(body, "<h1>hello site</h1>") || !strings.Contains(body, "<em>markdown</em>") {
89 t.Fatalf("README not rendered:\n%s", body) 89 t.Fatalf("README not rendered:\n%s", body)
90 } 90 }
91 for _, tab := range []string{">issues<", ">merge requests<"} { 91 for _, tab := range []string{">Issues<", ">Merge requests<"} {
92 if !strings.Contains(body, tab) { 92 if !strings.Contains(body, tab) {
93 t.Fatalf("repo header missing %s tab", tab) 93 t.Fatalf("repo header missing %s tab", tab)
94 } 94 }
e2e/wiki_test.go +2 −2
@@ -23,7 +23,7 @@ func TestWikis(t *testing.T) {
23 // No wiki yet: the tab is absent, the page shows the push hint, and 23 // No wiki yet: the tab is absent, the page shows the push hint, and
24 // cloning the companion says so. 24 // cloning the companion says so.
25 _, body := inst.get(t, "/alice/app") 25 _, body := inst.get(t, "/alice/app")
26 if strings.Contains(body, ">wiki<") { 26 if strings.Contains(body, ">Wiki<") {
27 t.Fatal("wiki tab shown with no wiki") 27 t.Fatal("wiki tab shown with no wiki")
28 } 28 }
29 _, body = inst.get(t, "/alice/app/wiki") 29 _, body = inst.get(t, "/alice/app/wiki")
@@ -55,7 +55,7 @@ func TestWikis(t *testing.T) {
55 // Rendering: home resolves, tab appears, links rewrite to wiki pages 55 // Rendering: home resolves, tab appears, links rewrite to wiki pages
56 // and images to the wiki raw route; org pages render too. 56 // and images to the wiki raw route; org pages render too.
57 _, body = inst.get(t, "/alice/app") 57 _, body = inst.get(t, "/alice/app")
58 if !strings.Contains(body, ">wiki<") { 58 if !strings.Contains(body, ">Wiki<") {
59 t.Fatal("wiki tab missing after push") 59 t.Fatal("wiki tab missing after push")
60 } 60 }
61 _, body = inst.get(t, "/alice/app/wiki") 61 _, body = inst.get(t, "/alice/app/wiki")
internal/config/config.go +3
@@ -64,6 +64,9 @@ type GitDaemon struct {
64type Web struct { 64type Web struct {
65 Mode string `toml:"mode"` // view_only | accounts 65 Mode string `toml:"mode"` // view_only | accounts
66 PasswordAuth bool `toml:"password_auth"` 66 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"`
67 // PrivacyNotice is operator-provided text shown on /privacy under the 70 // PrivacyNotice is operator-provided text shown on /privacy under the
68 // fixed project-level statement. Plain text; blank paragraphs split. 71 // fixed project-level statement. Plain text; blank paragraphs split.
69 PrivacyNotice string `toml:"privacy_notice"` 72 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) {
64 token := r.URL.Query().Get("token") 64 token := r.URL.Query().Get("token")
65 if token == "" { 65 if token == "" {
66 s.render(w, "login.html", struct { 66 s.render(w, "login.html", struct {
67 Site string 67 basePage
68 Viewer string 68 Error string
69 Error string 69 }{basePage{Site: s.siteName()}, ""})
70 }{s.siteName(), "", ""})
71 return 70 return
72 } 71 }
73 userID, err := s.st.ConsumeLoginToken(store.HashToken(token)) 72 userID, err := s.st.ConsumeLoginToken(store.HashToken(token))
74 if err != nil { 73 if err != nil {
75 s.render(w, "login.html", struct { 74 s.render(w, "login.html", struct {
76 Site string 75 basePage
77 Viewer string 76 Error string
78 Error string 77 }{basePage{Site: s.siteName()},
79 }{s.siteName(), "", "that login link is invalid, expired, or already used — mint a new one"}) 78 "that login link is invalid, expired, or already used — mint a new one"})
80 return 79 return
81 } 80 }
82 sessTok, sessHash, err := store.NewToken() 81 sessTok, sessHash, err := store.NewToken()
@@ -120,11 +119,10 @@ func (s *Server) adminOrgs(u store.User) []string {
120 119
121func (s *Server) renderNewRepo(w http.ResponseWriter, u store.User, errMsg string) { 120func (s *Server) renderNewRepo(w http.ResponseWriter, u store.User, errMsg string) {
122 s.render(w, "new.html", struct { 121 s.render(w, "new.html", struct {
123 Site string 122 basePage
124 Viewer string 123 Orgs []string
125 Orgs []string 124 Error string
126 Error string 125 }{s.baseFor(u), s.adminOrgs(u), errMsg})
127 }{s.siteName(), u.Username, s.adminOrgs(u), errMsg})
128} 126}
129 127
130func (s *Server) newRepoForm(w http.ResponseWriter, r *http.Request, u store.User) { 128func (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) {
223 221
224func (s *Server) renderSignup(w http.ResponseWriter, errMsg, username string) { 222func (s *Server) renderSignup(w http.ResponseWriter, errMsg, username string) {
225 s.render(w, "register.html", struct { 223 s.render(w, "register.html", struct {
226 Site string 224 basePage
227 Viewer string
228 Host string 225 Host string
229 Mode string // open | invite 226 Mode string // open | invite
230 Error string 227 Error string
231 Username string 228 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})
233} 230}
234 231
235func (s *Server) signupSubmit(w http.ResponseWriter, r *http.Request) { 232func (s *Server) signupSubmit(w http.ResponseWriter, r *http.Request) {
@@ -247,12 +244,11 @@ func (s *Server) signupSubmit(w http.ResponseWriter, r *http.Request) {
247 return 244 return
248 } 245 }
249 s.render(w, "registered.html", struct { 246 s.render(w, "registered.html", struct {
250 Site string 247 basePage
251 Viewer string
252 Username string 248 Username string
253 Message string 249 Message string
254 Host string 250 Host string
255 }{s.siteName(), "", username, msg, s.cfg.SiteHost()}) 251 }{basePage{Site: s.siteName()}, username, msg, s.cfg.SiteHost()})
256} 252}
257 253
258// issueCreateForm renders the new-issue form, prefilled from the repo's 254// 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
441} 437}
442 438
443type editPage struct { 439type editPage struct {
444 Site string 440 basePage
445 Viewer string
446 Repo store.Repo 441 Repo store.Repo
447 Ref string 442 Ref string
448 Path string 443 Path string
@@ -467,7 +462,7 @@ func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User)
467 return 462 return
468 } 463 }
469 s.render(w, "edit.html", editPage{ 464 s.render(w, "edit.html", editPage{
470 Site: s.siteName(), Viewer: u.Username, Repo: repo, 465 basePage: s.baseFor(u), Repo: repo,
471 Ref: ref, Path: filePath, Content: string(content), 466 Ref: ref, Path: filePath, Content: string(content),
472 }) 467 })
473} 468}
@@ -481,7 +476,7 @@ func (s *Server) editSubmit(w http.ResponseWriter, r *http.Request, u store.User
481 filePath := strings.Trim(r.PathValue("path"), "/") 476 filePath := strings.Trim(r.PathValue("path"), "/")
482 fail := func(msg string) { 477 fail := func(msg string) {
483 s.render(w, "edit.html", editPage{ 478 s.render(w, "edit.html", editPage{
484 Site: s.siteName(), Viewer: u.Username, Repo: repo, 479 basePage: s.baseFor(u), Repo: repo,
485 Ref: ref, Path: filePath, Content: r.FormValue("content"), Error: msg, 480 Ref: ref, Path: filePath, Content: r.FormValue("content"), Error: msg,
486 }) 481 })
487 } 482 }
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 (
13 "net/http" 13 "net/http"
14 "path" 14 "path"
15 "regexp" 15 "regexp"
16 "sort"
16 "strconv" 17 "strconv"
17 "strings" 18 "strings"
18 "time" 19 "time"
@@ -46,7 +47,12 @@ func (s *Server) render(w http.ResponseWriter, page string, data any) {
46 buf.WriteTo(w) 47 buf.WriteTo(w)
47} 48}
48 49
50// siteName is the instance's display name: the operator's [web] title,
51// or the site host when they have not set one.
49func (s *Server) siteName() string { 52func (s *Server) siteName() string {
53 if t := strings.TrimSpace(s.cfg.Web.Title); t != "" {
54 return t
55 }
50 h := strings.TrimPrefix(strings.TrimPrefix(s.cfg.Server.SiteURL, "https://"), "http://") 56 h := strings.TrimPrefix(strings.TrimPrefix(s.cfg.Server.SiteURL, "https://"), "http://")
51 return strings.TrimSuffix(h, "/") 57 return strings.TrimSuffix(h, "/")
52} 58}
@@ -62,8 +68,8 @@ func (s *Server) favicon(w http.ResponseWriter, r *http.Request) {
62 w.Write(web.FaviconSVG) 68 w.Write(web.FaviconSVG)
63} 69}
64 70
65// font serves the embedded IBM Plex subsets. Same-origin, so the CSP's 71// font serves the embedded Atkinson Hyperlegible subsets. Same-origin,
66// default-src 'self' covers it — no font CDN. 72// so the CSP's default-src 'self' covers it — no font CDN.
67func (s *Server) font(w http.ResponseWriter, r *http.Request) { 73func (s *Server) font(w http.ResponseWriter, r *http.Request) {
68 data, err := web.FontFS.ReadFile("static" + r.URL.Path[len("/static"):]) 74 data, err := web.FontFS.ReadFile("static" + r.URL.Path[len("/static"):])
69 if err != nil { 75 if err != nil {
@@ -79,10 +85,7 @@ func (s *Server) font(w http.ResponseWriter, r *http.Request) {
79// the stock plain-text response if the template fails. 85// the stock plain-text response if the template fails.
80func (s *Server) notFound(w http.ResponseWriter, r *http.Request) { 86func (s *Server) notFound(w http.ResponseWriter, r *http.Request) {
81 var buf bytes.Buffer 87 var buf bytes.Buffer
82 if err := web.Render(&buf, "404.html", struct { 88 if err := web.Render(&buf, "404.html", s.base(r)); err != nil {
83 Site string
84 Viewer string
85 }{s.siteName(), s.viewerName(r)}); err != nil {
86 http.NotFound(w, r) 89 http.NotFound(w, r)
87 return 90 return
88 } 91 }
@@ -129,12 +132,11 @@ func (s *Server) index(w http.ResponseWriter, r *http.Request) {
129 host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix( 132 host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(
130 s.cfg.Server.SiteURL, "https://"), "http://"), "/") 133 s.cfg.Server.SiteURL, "https://"), "http://"), "/")
131 s.render(w, "landing.html", struct { 134 s.render(w, "landing.html", struct {
132 Site string 135 basePage
133 Viewer string
134 Host string 136 Host string
135 Accounts bool 137 Accounts bool
136 Signup bool 138 Signup bool
137 }{s.siteName(), "", host, s.cfg.Web.Mode == "accounts", 139 }{basePage{Site: s.siteName()}, host, s.cfg.Web.Mode == "accounts",
138 s.cfg.Web.Mode == "accounts" && s.cfg.Registration.Mode != "closed"}) 140 s.cfg.Web.Mode == "accounts" && s.cfg.Registration.Mode != "closed"})
139} 141}
140 142
@@ -150,12 +152,11 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.
150 mrs, _ := s.st.DashboardMRs(viewer.ID) 152 mrs, _ := s.st.DashboardMRs(viewer.ID)
151 issues, _ := s.st.DashboardIssues(viewer.ID) 153 issues, _ := s.st.DashboardIssues(viewer.ID)
152 s.render(w, "dashboard.html", struct { 154 s.render(w, "dashboard.html", struct {
153 Site string 155 basePage
154 Viewer string
155 Pinned []store.Repo 156 Pinned []store.Repo
156 MRs []store.DashboardItem 157 MRs []store.DashboardItem
157 Issues []store.DashboardItem 158 Issues []store.DashboardItem
158 }{s.siteName(), viewer.Username, visible, mrs, issues}) 159 }{s.baseFor(viewer), visible, mrs, issues})
159} 160}
160 161
161func (s *Server) explore(w http.ResponseWriter, r *http.Request) { 162func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
@@ -170,30 +171,20 @@ func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
170 } 171 }
171 q := strings.TrimSpace(r.URL.Query().Get("q")) 172 q := strings.TrimSpace(r.URL.Query().Get("q"))
172 s.render(w, "explore.html", struct { 173 s.render(w, "explore.html", struct {
173 Site string 174 basePage
174 Viewer string 175 Query string
175 Query string 176 Repos []describedRepo
176 Repos []describedRepo 177 }{s.baseFor(viewer), q, s.filterRepos(q, s.describeAll(repos))})
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
186} 178}
187 179
188// privacy renders the privacy page: what the gitbay software does with 180// privacy renders the privacy page: what the gitbay software does with
189// data, plus this instance's operator-provided notes. 181// data, plus this instance's operator-provided notes.
190func (s *Server) privacy(w http.ResponseWriter, r *http.Request) { 182func (s *Server) privacy(w http.ResponseWriter, r *http.Request) {
191 s.render(w, "privacy.html", struct { 183 s.render(w, "privacy.html", struct {
192 Site string 184 basePage
193 Viewer string
194 Host string 185 Host string
195 Notice string 186 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})
197} 188}
198 189
199// filterRepos keeps repos whose path, description, or topics contain the 190// filterRepos keeps repos whose path, description, or topics contain the
@@ -222,8 +213,7 @@ func (s *Server) filterRepos(q string, repos []describedRepo) []describedRepo {
222 213
223// repoPage is the shared context for repo-scoped pages. 214// repoPage is the shared context for repo-scoped pages.
224type repoPage struct { 215type repoPage struct {
225 Site string 216 basePage
226 Viewer string
227 Desc string 217 Desc string
228 Repo store.Repo 218 Repo store.Repo
229 Ref string 219 Ref string
@@ -235,6 +225,13 @@ type repoPage struct {
235 HasWiki bool 225 HasWiki bool
236 Host string 226 Host string
237 Mirrors []mirrorLine // repo admins only 227 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
238} 235}
239 236
240// mirrorLine is the admin-only mirror status shown in the repo header. 237// 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
300 }) 297 })
301 } 298 }
302 } 299 }
300 openIssues, openMRs := s.st.OpenCounts(repo.ID)
303 return repoPage{ 301 return repoPage{
304 Mirrors: mirrors, 302 basePage: s.baseFor(viewer),
305 Site: s.siteName(), 303 Mirrors: mirrors,
306 Viewer: viewer.Username, 304 Pinned: pinned,
307 Pinned: pinned, 305 HasWiki: s.wikiDir(repo.OwnerName, repo.Name) != "",
308 HasWiki: s.wikiDir(repo.OwnerName, repo.Name) != "", 306 Host: s.cfg.SiteHost(),
309 Host: s.cfg.SiteHost(), 307 Desc: gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)),
310 Desc: gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)), 308 Repo: repo,
311 Repo: repo, 309 Ref: ref,
312 Ref: ref, 310 CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git",
313 CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git", 311 Dir: control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name),
314 Dir: control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name), 312 Topics: topics,
315 Topics: topics, 313 OpenIssues: openIssues,
314 OpenMRs: openMRs,
316 }, true 315 }, true
317} 316}
318 317
@@ -385,8 +384,7 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
385 weeks, activityTotal := activityGrid(counts) 384 weeks, activityTotal := activityGrid(counts)
386 385
387 s.render(w, "owner.html", struct { 386 s.render(w, "owner.html", struct {
388 Site string 387 basePage
389 Viewer string
390 Owner string 388 Owner string
391 Kind string 389 Kind string
392 Profile store.Profile 390 Profile store.Profile
@@ -395,7 +393,7 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
395 Orgs []store.OrgMember 393 Orgs []store.OrgMember
396 Activity []activityWeek 394 Activity []activityWeek
397 ActivityTotal int 395 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,
399 weeks, activityTotal}) 397 weeks, activityTotal})
400} 398}
401 399
@@ -405,6 +403,7 @@ func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
405 return 403 return
406 } 404 }
407 p.Tab = "files" 405 p.Tab = "files"
406 p.RepoHome = true
408 s.renderTree(w, r, p, "") 407 s.renderTree(w, r, p, "")
409} 408}
410 409
@@ -422,14 +421,16 @@ func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage,
422 // Empty repo: render the page with no entries rather than 404. 421 // Empty repo: render the page with no entries rather than 404.
423 s.render(w, "tree.html", struct { 422 s.render(w, "tree.html", struct {
424 repoPage 423 repoPage
425 Crumbs []crumb 424 Crumbs []crumb
426 Prefix string 425 Prefix string
427 DirPath string 426 DirPath string
428 RefKind string 427 RefKind string
429 Entries []gitutil.TreeEntry 428 Entries []gitutil.TreeEntry
430 Branches []gitutil.Ref 429 Branches []gitutil.Ref
431 ReadmeName string 430 ReadmeName string
432 ReadmeHTML template.HTML 431 ReadmeHTML template.HTML
432 LastCommits map[string]gitutil.EntryCommit
433 Tip gitutil.EntryCommit
433 }{repoPage: p, RefKind: "tree"}) 434 }{repoPage: p, RefKind: "tree"})
434 return 435 return
435 } 436 }
@@ -438,6 +439,12 @@ func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage,
438 s.notFound(w, r) 439 s.notFound(w, r)
439 return 440 return
440 } 441 }
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 })
441 prefix := "" 448 prefix := ""
442 if dirPath != "" { 449 if dirPath != "" {
443 prefix = dirPath + "/" 450 prefix = dirPath + "/"
@@ -452,17 +459,26 @@ func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage,
452 } 459 }
453 460
454 branches, _ := gitutil.Refs(p.Dir, "heads") 461 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 }
455 s.render(w, "tree.html", struct { 466 s.render(w, "tree.html", struct {
456 repoPage 467 repoPage
457 Crumbs []crumb 468 Crumbs []crumb
458 Prefix string 469 Prefix string
459 DirPath string 470 DirPath string
460 RefKind string 471 RefKind string
461 Entries []gitutil.TreeEntry 472 Entries []gitutil.TreeEntry
462 Branches []gitutil.Ref 473 Branches []gitutil.Ref
463 ReadmeName string 474 ReadmeName string
464 ReadmeHTML template.HTML 475 ReadmeHTML template.HTML
465 }{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches, readmeName, readmeHTML}) 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)})
466} 482}
467 483
468func (s *Server) blob(w http.ResponseWriter, r *http.Request) { 484func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
@@ -491,6 +507,16 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
491 cs = cs[:len(cs)-1] 507 cs = cs[:len(cs)-1]
492 } 508 }
493 branches, _ := gitutil.Refs(p.Dir, "heads") 509 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)
494 s.render(w, "blob.html", struct { 520 s.render(w, "blob.html", struct {
495 repoPage 521 repoPage
496 Crumbs []crumb 522 Crumbs []crumb
@@ -501,9 +527,13 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
501 Binary bool 527 Binary bool
502 Image bool 528 Image bool
503 Size int 529 Size int
530 Lines int
531 Exec bool
532 Symlink bool
504 Branches []gitutil.Ref 533 Branches []gitutil.Ref
505 CodeHTML template.HTML 534 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})
507} 537}
508 538
509// releases lists tag-anchored releases with notes and assets. 539// releases lists tag-anchored releases with notes and assets.
@@ -1447,9 +1477,17 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1447 commits = append(commits, cr) 1477 commits = append(commits, cr)
1448 } 1478 }
1449 } 1479 }
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 }
1450 s.render(w, "mr.html", struct { 1487 s.render(w, "mr.html", struct {
1451 repoPage 1488 repoPage
1452 MR store.MR 1489 MR store.MR
1490 View string
1453 BodyHTML template.HTML 1491 BodyHTML template.HTML
1454 Checks []store.CommitStatus 1492 Checks []store.CommitStatus
1455 Combined string 1493 Combined string
@@ -1460,7 +1498,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1460 Commits []commitRow 1498 Commits []commitRow
1461 CanEdit bool 1499 CanEdit bool
1462 DetachedThreads []diffThread 1500 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),
1464 reviews, lines, stat, commits, s.canEditItem(r, p.Repo, m.Author), detachedThreads}) 1502 reviews, lines, stat, commits, s.canEditItem(r, p.Repo, m.Author), detachedThreads})
1465} 1503}
1466 1504
internal/store/dashboard.go +31
@@ -118,3 +118,34 @@ func (s *Store) PinnedRepos(userID int64) ([]Repo, error) {
118 } 118 }
119 return out, rows.Err() 119 return out, rows.Err()
120} 120}
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. 1/* gitbay stylesheet — Harbor: black and white ground, two accents with
2 One file, light and dark via prefers-color-scheme. Tokens first; 2 separate jobs. Blue is what you can do (links, buttons, focus); orange
3 components use tokens only. No external requests: IBM Plex is served 3 is where you are and what wants you (active nav, counts, pending state,
4 from this origin. */ 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. */
5 8
6@font-face { 9@font-face {
7 font-family: "IBM Plex Sans"; 10 font-family: "Atkinson Hyperlegible Next";
8 font-style: normal; 11 font-style: normal;
9 font-weight: 400 600; 12 font-weight: 400 700;
10 font-display: swap; 13 font-display: swap;
11 src: url(/static/fonts/plex-sans.woff2) format("woff2"); 14 src: url(/static/fonts/atkinson-next-roman.woff2) format("woff2");
12} 15}
13@font-face { 16@font-face {
14 font-family: "IBM Plex Mono"; 17 font-family: "Atkinson Hyperlegible Next";
15 font-style: normal; 18 font-style: italic;
16 font-weight: 400; 19 font-weight: 400 700;
17 font-display: swap; 20 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");
19} 22}
20@font-face { 23@font-face {
21 font-family: "IBM Plex Mono"; 24 font-family: "Atkinson Hyperlegible Mono";
22 font-style: normal; 25 font-style: normal;
23 font-weight: 500; 26 font-weight: 400 600;
24 font-display: swap; 27 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");
26} 29}
27 30
28:root { 31:root {
@@ -30,39 +33,50 @@
30 scrollbars per scheme, and opt out of browser auto-darkening */ 33 scrollbars per scheme, and opt out of browser auto-darkening */
31 color-scheme: light dark; 34 color-scheme: light dark;
32 35
33 /* palette (Carbon g10) */ 36 /* ground and ink */
34 --bg: #f4f4f4; 37 --bg: #ffffff;
35 --fg: #161616; 38 --fg: #0d0d0d;
36 --muted: #525252; 39 --muted: #595959; /* 7.00:1 on white */
37 --faint: #ececec; /* hairlines, row separators */ 40 --faint: #eaeaea; /* hairlines, row separators */
38 --line: #e0e0e0; /* control borders, tile edges */ 41 --line: #d4d4d4; /* control borders, table edges */
39 --surface: #ffffff; /* tiles, cards */ 42 --surface: #ffffff;
40 --hover: #f0f2f8; /* row hover on tiles */ 43 --hover: #f5f5f5;
41 --accent: #0f62fe; /* blue-60: links, active nav, primary */ 44 --code-bg: #f5f5f5;
42 --accent-fg: #ffffff; /* text on accent */ 45
43 --ok: #198038; 46 /* blue: what you can do */
44 --warn: #8e6a00; 47 --accent: #0000f0; /* links, focus — 9.30:1 on white */
45 --bad: #da1e28; 48 --accent-fg: #ffffff;
46 --done: #8a3ffc; /* merged */ 49 --fill: #0000f0; /* button grounds; white on it is 9.30:1 */
47 --neutral: #525252; 50
48 --code-bg: #f4f4f4; 51 /* orange: where you are, what wants you */
49 /* the shell is ink in both schemes */ 52 --mark: #e4572e; /* bars and underlines — non-text, 3.0 bar */
50 --shell-bg: #161616; 53 --warn: #c2410c; /* the same orange at text size — 5.18:1 */
51 --shell-fg: #f4f4f4; 54
52 --shell-muted: #c6c6c6; 55 /* semantic */
53 --shell-line: #161616; 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 */
54 68
55 /* type */ 69 /* type */
56 --sans: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 70 --sans: "Atkinson Hyperlegible Next", -apple-system, BlinkMacSystemFont,
57 Roboto, "Helvetica Neue", Arial, sans-serif; 71 "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
58 --mono: "IBM Plex Mono", ui-monospace, "SF Mono", SFMono-Regular, Menlo, 72 --mono: "Atkinson Hyperlegible Mono", ui-monospace, "SF Mono",
59 Consolas, "Liberation Mono", monospace; 73 SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
60 --fs-0: 0.8rem; /* 12.8px: chips, fine print */ 74 --fs-0: 0.78rem;
61 --fs-1: 0.866rem; /* ~13px: metadata, mono in tables */ 75 --fs-1: 0.85rem;
62 --fs-2: 0.933rem; /* 14px: dense table text */ 76 --fs-2: 0.92rem;
63 --fs-3: 1rem; /* 15px: body */ 77 --fs-3: 1rem;
64 --fs-4: 1.135rem; /* ~17px: h2 */ 78 --fs-4: 1.2rem;
65 --fs-5: 1.335rem; /* ~20px: h1 */ 79 --fs-5: 1.6rem;
66 80
67 /* spacing */ 81 /* spacing */
68 --sp-1: 0.25rem; 82 --sp-1: 0.25rem;
@@ -72,31 +86,34 @@
72 --sp-5: 1.5rem; 86 --sp-5: 1.5rem;
73 --sp-6: 2.5rem; 87 --sp-6: 2.5rem;
74 88
75 /* radii: sharp everywhere; only tags stay pills */ 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");
76 --r-sm: 0; 90
77 --r-md: 0; 91 /* radii: 2px everywhere — chart plates, not pills */
78 --r-lg: 0; 92 --r-sm: 2px;
79 --r-pill: 999px; 93 --r-md: 2px;
94 --r-lg: 2px;
95 --r-pill: 2px;
80} 96}
81@media (prefers-color-scheme: dark) { 97@media (prefers-color-scheme: dark) {
82 :root { 98 :root {
83 /* Carbon g100 */ 99 --bg: #0a0a0a;
84 --bg: #161616; 100 --fg: #f2f2f2;
85 --fg: #f4f4f4; 101 --muted: #a1a1a1; /* 7.66:1 on #0a0a0a */
86 --muted: #a8a8a8; 102 --faint: #1c1c1c;
87 --faint: #2a2a2a; 103 --line: #2e2e2e;
88 --line: #393939; 104 --surface: #0f0f0f;
89 --surface: #262626; 105 --hover: #161616;
90 --hover: #2e3440; 106 --code-bg: #050505;
91 --accent: #78a9ff; /* blue-40 reads on dark grounds */ 107 --accent: #5b6bff; /* 4.71:1 — pure #0000f0 is 2.13:1 here */
92 --accent-fg: #161616; 108 --accent-fg: #ffffff;
93 --ok: #42be65; 109 --fill: #0000f0; /* fills stay pure; white on it clears 9:1 */
94 --warn: #d2a106; 110 --mark: #ff6b3d;
95 --bad: #fa4d56; 111 --warn: #ff6b3d;
96 --done: #a56eff; 112 --ok: #3fce7a;
97 --neutral: #a8a8a8; 113 --bad: #ff6b6b;
98 --code-bg: #131313; 114 --done: #c084fc;
99 --shell-line: #393939; 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");
100 } 117 }
101} 118}
102 119
@@ -108,18 +125,46 @@ body {
108 color: var(--fg); 125 color: var(--fg);
109 font: var(--fs-3)/1.6 var(--sans); 126 font: var(--fs-3)/1.6 var(--sans);
110 min-height: 100vh; 127 min-height: 100vh;
111 display: flex;
112 flex-direction: column;
113} 128}
114main { flex: 1; }
115 129
116a { color: var(--accent); text-decoration: none; } 130a { color: var(--accent); text-decoration: none; }
117a:hover { text-decoration: underline; } 131a:hover { text-decoration: underline; }
118 132
119h1 { font-size: var(--fs-5); font-weight: 600; line-height: 1.3; 133/* one authored focus ring, so keyboard position is never the browser's
120 margin: var(--sp-5) 0 var(--sp-3); letter-spacing: -0.01em; } 134 guess. Blue, because focus is an interactive state. */
121h2 { font-size: var(--fs-4); font-weight: 600; line-height: 1.35; 135:focus-visible {
122 margin: var(--sp-5) 0 var(--sp-3); } 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; }
123h3 { font-size: var(--fs-3); font-weight: 600; margin: var(--sp-5) 0 var(--sp-2); } 168h3 { font-size: var(--fs-3); font-weight: 600; margin: var(--sp-5) 0 var(--sp-2); }
124 169
125code, pre, .code, td.mode, td.size { 170code, pre, .code, td.mode, td.size {
@@ -127,167 +172,215 @@ code, pre, .code, td.mode, td.size {
127 font-size: var(--fs-1); 172 font-size: var(--fs-1);
128} 173}
129 174
130/* layout: one shared container aligns header, content, and footer */ 175/* ---- shell: black rail, content pane ---- */
131.container { 176.shell { display: flex; align-items: stretch; min-height: 100vh; }
132 max-width: 64rem; 177
133 margin: 0 auto; 178.rail {
134 padding: 0 var(--sp-5); 179 flex: none;
135} 180 width: 13rem;
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 {
139 background: var(--shell-bg); 181 background: var(--shell-bg);
140 border-bottom: 1px solid var(--shell-line); 182 color: var(--shell-fg);
141 padding: 0; 183 border-right: 1px solid var(--shell-line);
142 position: sticky; 184 position: sticky;
143 top: 0; 185 top: 0;
144 z-index: 30; 186 height: 100vh;
187 display: flex;
188 flex-direction: column;
145} 189}
146a.site { 190a.brand {
147 font-weight: 600; 191 display: flex;
148 color: var(--shell-fg);
149 letter-spacing: -0.01em;
150 display: inline-flex;
151 align-items: center; 192 align-items: center;
152 gap: var(--sp-2); 193 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);
154} 200}
155a.site:hover { text-decoration: none; color: #ffffff; } 201a.brand:hover { text-decoration: none; color: #ffffff; }
156a.site svg.mark { display: block; } 202a.brand svg.mark { display: block; flex: none; }
157a.site svg.mark rect { fill: #0f62fe; } 203a.brand span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
158a.site svg.mark path { stroke: #ffffff; } 204
159nav.topnav { 205.railbody { flex: 1; overflow-y: auto; padding: var(--sp-3) 0; }
160 display: flex; 206ul.raillist { list-style: none; margin: 0; padding: 0; }
161 align-items: center; 207ul.raillist a {
162 gap: 0; 208 display: block;
163 height: 3rem; 209 padding: 0.28rem var(--sp-4);
164} 210 color: var(--shell-fg);
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;
177 font-size: var(--fs-2); 211 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);
178 font-weight: 600; 220 font-weight: 600;
179 flex: none;
180} 221}
181a.avatar:hover { text-decoration: none; filter: brightness(1.12); } 222ul.raillist .owner { color: var(--shell-muted); }
182a.avatar.guest { background: #393939; color: #f4f4f4; } 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); }
183 226
184/* the rail: persistent navigation, global links first, repo sections 227.raillabel {
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);
195 display: flex; 228 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);
197} 249}
198.railmain { flex: 1; overflow-y: auto; padding: var(--sp-3) 0; }
199.railfoot { 250.railfoot {
200 flex: none; 251 flex: none;
201 border-top: 1px solid var(--line); 252 border-top: 1px solid var(--shell-line);
202 padding: var(--sp-2) 0; 253 padding: var(--sp-2) var(--sp-4);
203 background: var(--surface);
204}
205.siderail a, .siderail button.linklike {
206 display: flex; 254 display: flex;
207 align-items: center; 255 align-items: center;
208 gap: var(--sp-2); 256 gap: var(--sp-3);
209 width: 100%;
210 text-align: left;
211 color: var(--fg);
212 font-size: var(--fs-2); 257 font-size: var(--fs-2);
213 padding: 0.4rem var(--sp-4);
214 border-left: 3px solid transparent;
215 line-height: 1.35;
216} 258}
217.siderail a:hover, .siderail button.linklike:hover { 259.railfoot a { color: var(--shell-fg); }
218 background: var(--hover); 260a.railuser { display: inline-flex; align-items: center; gap: var(--sp-2); }
219 text-decoration: none; 261.railfoot .linklike {
220 color: var(--fg); 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);
221} 274}
222.siderail a.active { 275
223 border-left-color: var(--accent); 276.pane { flex: 1; min-width: 0; display: flex; flex-direction: column; }
224 background: var(--hover); 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);
225 font-weight: 600; 312 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;
226} 319}
227.siderail .glabel { 320button.btn:hover { border-color: var(--accent); color: var(--accent); }
228 display: block; 321button.btn[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); }
229 font-size: var(--fs-0); 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;
230 color: var(--muted); 337 color: var(--muted);
231 letter-spacing: 0.04em; 338 font-size: var(--fs-2);
232 padding: 0 var(--sp-4); 339 white-space: nowrap;
233 margin: var(--sp-4) 0 var(--sp-1); 340 border-bottom: 2px solid transparent;
341 margin-bottom: -1px;
234} 342}
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] {
236 color: var(--fg); 346 color: var(--fg);
237 font-weight: 600; 347 font-weight: 700;
238 border-top: 1px solid var(--line); 348 border-bottom-color: var(--mark);
239 padding-top: var(--sp-3);
240 overflow-wrap: anywhere;
241} 349}
242.siderail .glabel.sub { margin-top: var(--sp-2); } 350nav.tabs a i {
243.siderail .railuser { font-weight: 600; } 351 font-style: normal;
244.siderail .avatar.sm { 352 font-family: var(--mono);
245 width: 1.25rem;
246 height: 1.25rem;
247 margin: 0;
248 font-size: var(--fs-0); 353 font-size: var(--fs-0);
249 background: var(--accent); 354 background: var(--hover);
250 color: #ffffff; 355 border-radius: var(--r-sm);
251} 356 padding: 0 0.35rem;
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);
260} 357}
358
359/* ---- mobile: the rail becomes a single strip, not a wall of links ---- */
261@media (max-width: 52rem) { 360@media (max-width: 52rem) {
262 /* the rail folds into a wrapping strip above the content */
263 .shell { display: block; } 361 .shell { display: block; }
264 .siderail { 362 .rail {
265 width: auto; 363 width: auto;
266 height: auto; 364 height: auto;
267 position: static; 365 position: static;
268 flex-direction: row; 366 flex-direction: row;
269 flex-wrap: wrap;
270 align-items: center; 367 align-items: center;
271 border-right: none; 368 gap: var(--sp-2);
272 border-bottom: 1px solid var(--line); 369 border-right: 0;
273 } 370 border-bottom: 1px solid var(--shell-line);
274 .railmain, .railfoot { 371 overflow-x: auto;
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);
288 } 372 }
289 .siderail a.active { border-left: none; border-bottom-color: var(--accent); background: none; } 373 a.brand { border-bottom: 0; padding-right: var(--sp-2); }
290 .siderail .glabel { display: none; } 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; }
291} 384}
292 385
293/* tables */ 386/* tables */
@@ -301,66 +394,6 @@ td {
301td:last-child { padding-right: 0; } 394td:last-child { padding-right: 0; }
302td.size, td.mode { color: var(--muted); white-space: nowrap; } 395td.size, td.mode { color: var(--muted); white-space: nowrap; }
303 396
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
364/* path bar on tree and blob pages */ 397/* path bar on tree and blob pages */
365.pathbar { 398.pathbar {
366 display: flex; 399 display: flex;
@@ -499,7 +532,14 @@ code.fullsha { color: var(--muted); overflow-wrap: anywhere; }
499.empty-note { color: var(--muted); } 532.empty-note { color: var(--muted); }
500.crumbs { color: var(--muted); font-size: var(--fs-2); } 533.crumbs { color: var(--muted); font-size: var(--fs-2); }
501.desc, td.desc { color: var(--muted); } 534.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}
503.meta { color: var(--muted); font-size: var(--fs-1); } 543.meta { color: var(--muted); font-size: var(--fs-1); }
504svg.icon { vertical-align: -0.125em; } 544svg.icon { vertical-align: -0.125em; }
505.lede { font-size: var(--fs-3); margin: var(--sp-2) 0; } 545.lede { font-size: var(--fs-3); margin: var(--sp-2) 0; }
@@ -908,27 +948,105 @@ details.editbox input[type="text"] { width: 100%; }
908.thread .rendered p { margin: var(--sp-1) 0; } 948.thread .rendered p { margin: var(--sp-1) 0; }
909.thread .threadstate { color: var(--muted); font-size: var(--fs-1); margin: var(--sp-1) 0 0; } 949.thread .threadstate { color: var(--muted); font-size: var(--fs-1); margin: var(--sp-1) 0 0; }
910 950
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 */
912input, textarea, button, select { font: inherit; color: inherit; } 953input, 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 {
914 background: var(--bg); 956 background: var(--bg);
957 color: var(--fg);
915 border: 1px solid var(--line); 958 border: 1px solid var(--line);
916 border-radius: var(--r-md); 959 border-radius: var(--r-md);
917 padding: var(--sp-2) var(--sp-3); 960 padding: var(--sp-2) var(--sp-3);
961 line-height: 1.4;
918} 962}
919textarea { width: 100%; resize: vertical; } 963textarea { width: 100%; resize: vertical; }
920textarea[name="content"] { font-family: var(--mono); font-size: var(--fs-1); } 964textarea[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); }
922button { 1040button {
923 background: var(--accent); 1041 background: var(--fill);
924 color: var(--accent-fg); 1042 color: #ffffff;
925 border: none; 1043 border: none;
926 border-radius: var(--r-md); 1044 border-radius: var(--r-md);
927 padding: var(--sp-1) var(--sp-3); 1045 padding: var(--sp-2) var(--sp-4);
928 font-weight: 500; 1046 font-weight: 600;
929 cursor: pointer; 1047 cursor: pointer;
930} 1048}
931button:hover { filter: brightness(1.08); } 1049button:hover { filter: brightness(1.15); }
932 1050
933/* 404 */ 1051/* 404 */
934.notfound { text-align: center; padding: var(--sp-6) 0; } 1052.notfound { text-align: center; padding: var(--sp-6) 0; }
@@ -945,7 +1063,9 @@ button:hover { filter: brightness(1.08); }
945/* footer */ 1063/* footer */
946footer { 1064footer {
947 border-top: 1px solid var(--faint); 1065 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%;
949 color: var(--muted); 1069 color: var(--muted);
950 font-size: var(--fs-1); 1070 font-size: var(--fs-1);
951} 1071}
@@ -1059,3 +1179,100 @@ pre.matchline mark {
1059 padding: var(--sp-2) var(--sp-3); 1179 padding: var(--sp-2) var(--sp-3);
1060 } 1180 }
1061} 1181}
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 @@
2{{define "content"}} 2{{define "content"}}
3<div class="notfound"> 3<div class="notfound">
4 <p class="bigcode">404</p> 4 <p class="bigcode">404</p>
5 <h1>page not found</h1> 5 <h1>Page not found</h1>
6 <p class="desc">nothing lives at this address — the path may be wrong, 6 <p class="desc">nothing lives at this address — the path may be wrong,
7 or the repository may be private.</p> 7 or the repository may be private.</p>
8 <p><a href="/">← back to {{.Site}}</a></p> 8 <p><a href="/">← back to {{.Site}}</a></p>
internal/web/templates/blame.html +1 −1
@@ -1,6 +1,6 @@
1{{define "title"}}blame: {{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}blame: {{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}} 3<h1 class="vh">Blame: {{.Path}}</h1>
4<div class="pathbar"> 4<div class="pathbar">
5 <span class="refchip">{{template "branchicon"}} {{.Ref}}</span> 5 <span class="refchip">{{template "branchicon"}} {{.Ref}}</span>
6 <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}<strong>{{.Base}}</strong></span> 6 <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 @@
1{{define "title"}}{{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}{{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}} 3<h1 class="vh">{{.Path}}</h1>
4<div class="pathbar"> 4<div class="pathbar">
5 {{template "refmenu" .}} 5 {{template "refmenu" .}}
6 <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}<strong>{{.Base}}</strong></span> 6 <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}<strong>{{.Base}}</strong></span>
7 <span class="spacer"></span> 7 <span class="spacer"></span>
8 <span class="actions"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log/{{.Ref}}?path={{.Path}}">history</a> · {{if not .Binary}}<a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/blame/{{.Ref}}/{{.Path}}">blame</a> · {{end}}<a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">raw</a>{{if .Viewer}} · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/edit/{{.Ref}}/{{.Path}}">edit</a>{{end}}</span> 8 <span class="actions"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log/{{.Ref}}?path={{.Path}}">history</a> · {{if not .Binary}}<a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/blame/{{.Ref}}/{{.Path}}">blame</a> · {{end}}<a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">raw</a>{{if .Viewer}} · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/edit/{{.Ref}}/{{.Path}}">edit</a>{{end}}</span>
9</div> 9</div>
10<p class="filefacts">{{if .Lines}}{{.Lines}} lines · {{end}}{{.Size}} bytes{{if .Exec}} · executable{{end}}{{if .Symlink}} · symlink{{end}}</p>
10{{if .Image}}<div class="blobimage"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}"><img src="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}" alt="{{.Base}}"></a><p class="meta">{{.Size}} bytes</p></div> 11{{if .Image}}<div class="blobimage"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}"><img src="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}" alt="{{.Base}}"></a><p class="meta">{{.Size}} bytes</p></div>
11{{else if .Binary}}<p class="empty-note">binary file, {{.Size}} bytes — <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">download</a></p> 12{{else if .Binary}}<p class="empty-note">binary file, {{.Size}} bytes — <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">download</a></p>
12{{else}}<div class="code">{{.CodeHTML}}</div>{{end}} 13{{else}}<div class="code">{{.CodeHTML}}</div>{{end}}
internal/web/templates/build.html +1 −2
@@ -1,8 +1,7 @@
1{{define "title"}}build {{.Build.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}build {{.Build.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}}
4<div class="headrow"> 3<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>
6</div> 5</div>
7<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> 6<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>
8{{if .Log}}<pre class="code buildlog">{{.Log}}</pre>{{else}}<p class="empty-note">no log yet</p>{{end}} 7{{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 @@
1{{define "title"}}builds · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}builds · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}} 3<h1>Builds</h1>
4<ul class="loglist"> 4<ul class="loglist">
5{{range .Builds}}<li> 5{{range .Builds}}<li>
6 <div class="commitmain"> 6 <div class="commitmain">
internal/web/templates/commit.html +1 −2
@@ -1,8 +1,7 @@
1{{define "title"}}{{.ShortSHA}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}{{.ShortSHA}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}}
4<div class="commithead"> 3<div class="commithead">
5 <h2>commit <code>{{.ShortSHA}}</code></h2> 4 <h1>Commit <code>{{.ShortSHA}}</code></h1>
6 <p class="meta"><code class="fullsha">{{.SHA}}</code></p> 5 <p class="meta"><code class="fullsha">{{.SHA}}</code></p>
7 {{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}} 6 {{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}}
8 <p>{{template "sigbadge" .Sig}}{{range .Checks}} <span class="badge check-{{.State}}">{{.Context}}: {{.State}}</span>{{end}}</p> 7 <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 @@
1{{define "title"}}dashboard · {{.Site}}{{end}} 1{{define "title"}}dashboard · {{.Site}}{{end}}
2{{define "content"}} 2{{define "content"}}
3<div class="headrow"> 3<div class="headrow">
4<h1>dashboard</h1> 4<h1>Dashboard</h1>
5<span class="spacer"></span> 5<span class="spacer"></span>
6<p class="toolbar">logged in as <a href="/{{.Viewer}}">{{.Viewer}}</a></p> 6<p class="toolbar">logged in as <a href="/{{.Viewer}}">{{.Viewer}}</a></p>
7</div> 7</div>
8{{if .Pinned}}<h2>pinned</h2> 8{{if .Pinned}}<h2>Pinned</h2>
9<ul class="pinlist"> 9<ul class="pinlist">
10{{range .Pinned}}<li><a href="/{{.OwnerName}}/{{.Name}}">{{.OwnerName}}<span class="sep">/</span><strong>{{.Name}}</strong></a>{{if eq .Visibility "private"}} <span class="chip chip-neutral">private</span>{{end}}</li> 10{{range .Pinned}}<li><a href="/{{.OwnerName}}/{{.Name}}">{{.OwnerName}}<span class="sep">/</span><strong>{{.Name}}</strong></a>{{if eq .Visibility "private"}} <span class="chip chip-neutral">private</span>{{end}}</li>
11{{end}} 11{{end}}
12</ul>{{end}} 12</ul>{{end}}
13<h2>open merge requests <span class="count">{{len .MRs}}</span></h2> 13<h2>Open merge requests <span class="count">{{len .MRs}}</span></h2>
14<ul class="issuelist"> 14<ul class="issuelist">
15{{range .MRs}}<li> 15{{range .MRs}}<li>
16 <div class="issuemain"> 16 <div class="issuemain">
@@ -20,7 +20,7 @@
20</li> 20</li>
21{{else}}<li class="empty">no open merge requests</li>{{end}} 21{{else}}<li class="empty">no open merge requests</li>{{end}}
22</ul> 22</ul>
23<h2>open issues <span class="count">{{len .Issues}}</span></h2> 23<h2>Open issues <span class="count">{{len .Issues}}</span></h2>
24<ul class="issuelist"> 24<ul class="issuelist">
25{{range .Issues}}<li> 25{{range .Issues}}<li>
26 <div class="issuemain"> 26 <div class="issuemain">
internal/web/templates/edit.html +4 −4
@@ -1,11 +1,11 @@
1{{define "title"}}edit {{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}edit {{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3<h1>edit {{.Repo.OwnerName}}/{{.Repo.Name}} : {{.Path}} @ {{.Ref}}</h1> 3<h1>edit {{.Repo.OwnerName}}/{{.Repo.Name}} : {{.Path}} @ {{.Ref}}</h1>
4{{if .Error}}<p class="error">{{.Error}}</p>{{end}} 4{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
5<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/edit/{{.Ref}}/{{.Path}}"> 5<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/edit/{{.Ref}}/{{.Path}}">
6<p><textarea name="content" rows="24" style="width:100%" spellcheck="false">{{.Content}}</textarea></p> 6<p><textarea name="content" aria-label="File contents" rows="24" style="width:100%" spellcheck="false">{{.Content}}</textarea></p>
7<p><input name="message" placeholder="commit message" style="width:60%"> 7<p><input name="message" aria-label="Commit message" placeholder="commit message" style="width:60%">
8<button type="submit">commit to {{.Ref}}</button></p> 8<button type="submit">Commit to {{.Ref}}</button></p>
9<p class="crumbs">this commit will be unsigned and authored as {{.Viewer}}</p> 9<p class="crumbs">this commit will be unsigned and authored as {{.Viewer}}</p>
10</form> 10</form>
11{{end}} 11{{end}}
internal/web/templates/explore.html +2 −2
@@ -1,9 +1,9 @@
1{{define "title"}}explore · {{.Site}}{{end}} 1{{define "title"}}explore · {{.Site}}{{end}}
2{{define "content"}} 2{{define "content"}}
3<div class="headrow"> 3<div class="headrow">
4<h1>explore</h1> 4<h1>Explore</h1>
5<form method="get" action="/explore" class="searchform compact"> 5<form method="get" action="/explore" class="searchform compact">
6 <input type="search" name="q" value="{{.Query}}" placeholder="filter by name, description, topic"> 6 <input type="search" name="q" aria-label="Filter repositories" value="{{.Query}}" placeholder="filter by name, description, topic">
7</form> 7</form>
8<span class="spacer"></span> 8<span class="spacer"></span>
9</div> 9</div>
internal/web/templates/issue.html +5 −6
@@ -1,7 +1,6 @@
1{{define "title"}}#{{.Issue.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}#{{.Issue.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}} 3<h1 class="issuetitle">{{.Issue.Title}} <span class="issuenumber">#{{.Issue.Number}}</span></h1>
4<h2 class="issuetitle">{{.Issue.Title}} <span class="issuenumber">#{{.Issue.Number}}</span></h2>
5<p class="issuemeta"><span class="chip {{if eq .Issue.State "open"}}chip-open{{else}}chip-done{{end}}">{{.Issue.State}}</span> 4<p class="issuemeta"><span class="chip {{if eq .Issue.State "open"}}chip-open{{else}}chip-done{{end}}">{{.Issue.State}}</span>
6<a href="/{{.Issue.Author}}">{{.Issue.Author}}</a> opened this on {{when .Issue.CreatedAt}} 5<a href="/{{.Issue.Author}}">{{.Issue.Author}}</a> opened this on {{when .Issue.CreatedAt}}
7{{if .Issue.Labels}} · {{range .Issue.Labels}}<a class="chip label" style="{{index $.LabelColors .}}" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues?label={{.}}">{{.}}</a> {{end}}{{end}} 6{{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 @@
9{{if .Issue.Milestone}} · milestone <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">{{.Issue.Milestone}}</a>{{end}}</p> 8{{if .Issue.Milestone}} · milestone <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">{{.Issue.Milestone}}</a>{{end}}</p>
10{{if .CanEdit}}<details class="editbox"><summary>edit</summary> 9{{if .CanEdit}}<details class="editbox"><summary>edit</summary>
11<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/edit" class="commentform"> 10<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/edit" class="commentform">
12<p><input type="text" name="title" value="{{.Issue.Title}}" required></p> 11<p><input type="text" name="title" aria-label="Title" value="{{.Issue.Title}}" required></p>
13<p><textarea name="body" rows="8">{{.Issue.Body}}</textarea></p> 12<p><textarea name="body" aria-label="Description" 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> 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>
15<p><button type="submit">save</button></p> 14<p><button type="submit">save</button></p>
16</form></details>{{end}} 15</form></details>{{end}}
17{{if .BodyHTML}}<article class="comment"> 16{{if .BodyHTML}}<article class="comment">
@@ -27,7 +26,7 @@
27{{end}} 26{{end}}
28{{if .Viewer}} 27{{if .Viewer}}
29<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/comment" class="commentform"> 28<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/comment" class="commentform">
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>
31<p><button type="submit">comment</button></p> 30<p><button type="submit">comment</button></p>
32</form> 31</form>
33{{end}} 32{{end}}
internal/web/templates/issuenew.html +5 −6
@@ -1,13 +1,12 @@
1{{define "title"}}new issue · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}new issue · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}} 3<h1>New issue</h1>
4<h2>new issue</h2>
5{{if gt (len .Templates) 1}}<p class="meta">template: 4{{if gt (len .Templates) 1}}<p class="meta">template:
6{{range .Templates}}{{if eq .Name $.Template}}<strong>{{.Name}}</strong>{{else}}<a href="?template={{.Name}}">{{.Name}}</a>{{end}} {{end}}</p>{{end}} 5{{range .Templates}}{{if eq .Name $.Template}}<strong>{{.Name}}</strong>{{else}}<a href="?template={{.Name}}">{{.Name}}</a>{{end}} {{end}}</p>{{end}}
7<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/new" class="commentform"> 6<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/new" class="commentform">
8<p><input type="text" name="title" placeholder="title" required></p> 7<p><input type="text" name="title" aria-label="Title" placeholder="title" required></p>
9<p><textarea name="body" rows="12">{{.Body}}</textarea></p> 8<p><textarea name="body" aria-label="Description" rows="12">{{.Body}}</textarea></p>
10<p><input type="text" name="labels" placeholder="labels, space-separated (write access)"></p> 9<p><input type="text" name="labels" aria-label="Labels" placeholder="labels, space-separated (write access)"></p>
11<p><button type="submit">open issue</button></p> 10<p><button type="submit">Open issue</button></p>
12</form> 11</form>
13{{end}} 12{{end}}
internal/web/templates/issues.html +1 −2
@@ -1,8 +1,7 @@
1{{define "title"}}issues · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}issues · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}}
4<div class="listhead"> 3<div class="listhead">
5 <h2>issues</h2> 4 <h1>Issues</h1>
6 <nav class="filters"> 5 <nav class="filters">
7 <a {{if eq .State "open"}}class="active" {{end}}href="?state=open">open</a> 6 <a {{if eq .State "open"}}class="active" {{end}}href="?state=open">open</a>
8 <a {{if eq .State "closed"}}class="active" {{end}}href="?state=closed">closed</a> 7 <a {{if eq .State "closed"}}class="active" {{end}}href="?state=closed">closed</a>
internal/web/templates/layout.html +69 −46
@@ -8,65 +8,88 @@
8<link rel="icon" href="/favicon.svg" type="image/svg+xml"> 8<link rel="icon" href="/favicon.svg" type="image/svg+xml">
9</head> 9</head>
10<body> 10<body>
11<header> 11<a class="skip" href="#content">Skip to content</a>
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>
19<div class="shell"> 12<div class="shell">
20<nav class="siderail" aria-label="site"> 13
21 <div class="railmain"> 14{{$here := ""}}{{with field . "Repo"}}{{$here = printf "%s/%s" .OwnerName .Name}}{{end}}
22 <a {{if eq (str . "Tab") "explore"}}class="active" {{end}}href="/explore">explore</a> 15<nav class="rail" aria-label="Site">
23 {{if .Viewer}}<a href="/new">new repository</a>{{end}} 16 <a class="brand" href="/">{{template "mark"}}<span>{{.Site}}</span></a>
24 {{with field . "Repo"}} 17 <div class="railbody">
25 <span class="glabel">{{.OwnerName}}/{{.Name}}</span> 18 <ul class="raillist">
26 <span class="glabel sub">code</span> 19 {{if .Viewer}}<li><a href="/">Dashboard</a></li>{{end}}
27 <a {{if eq (str $ "Tab") "files"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}">files</a> 20 <li><a {{if eq (str . "Tab") "explore"}}aria-current="page" {{end}}href="/explore">Explore</a></li>
28 <a {{if eq (str $ "Tab") "log"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/log">log</a> 21 {{if .Viewer}}<li><a href="/new">New repository</a></li>{{end}}
29 <a {{if eq (str $ "Tab") "refs"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/refs">refs</a> 22 </ul>
30 <span class="glabel sub">work</span> 23 {{with .Rail.Pinned}}
31 <a {{if eq (str $ "Tab") "issues"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/issues">issues</a> 24 <div class="railgroup">
32 <a {{if eq (str $ "Tab") "merge requests"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/mrs">merge requests</a> 25 <p class="raillabel" id="rail-pinned">Pinned</p>
33 <a {{if eq (str $ "Tab") "builds"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/builds">builds</a> 26 <ul class="raillist" aria-labelledby="rail-pinned">
34 <span class="glabel sub">publish</span> 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>
35 <a {{if eq (str $ "Tab") "releases"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/releases">releases</a> 28 {{end}}
36 {{if field $ "HasWiki"}}<a {{if eq (str $ "Tab") "wiki"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/wiki">wiki</a>{{end}} 29 </ul>
37 <span class="glabel sub">tools</span> 30 </div>
38 <a {{if eq (str $ "Tab") "search"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/search">search</a> 31 {{end}}
39 <a href="/{{.OwnerName}}/{{.Name}}/archive/{{str $ "Ref"}}.tar.gz">archive</a> 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>
40 {{end}} 40 {{end}}
41 </div> 41 </div>
42 <div class="railfoot"> 42 <div class="railfoot">
43 {{if .Viewer}}<a class="railuser" href="/{{.Viewer}}"><span class="avatar sm">{{initial .Viewer}}</span>{{.Viewer}}</a> 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> 44 <form method="post" action="/logout"><button type="submit" class="linklike">Log out</button></form>
45 {{else}}<a href="/login">sign in</a>{{end}} 45 {{else}}<a href="/login">Sign in</a>{{end}}
46 </div> 46 </div>
47</nav> 47</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">
49{{template "content" .}} 78{{template "content" .}}
50</main> 79</main>
51</div> 80
52<footer> 81<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>
54</footer> 83</footer>
84</div>
85</div>
55</body> 86</body>
56</html>{{end}} 87</html>{{end}}
57 88
58{{define "repoheader"}} 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}}
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}}
67 90
68{{define "reporow"}}<li> 91{{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>
70 {{if .Desc}}<p class="desc">{{.Desc}}</p>{{end}} 93 {{if .Desc}}<p class="desc">{{.Desc}}</p>{{end}}
71 {{if .Topics}}<p class="topics">{{range .Topics}}<a class="chip topic" href="/explore?q={{.}}">{{.}}</a> {{end}}</p>{{end}} 94 {{if .Topics}}<p class="topics">{{range .Topics}}<a class="chip topic" href="/explore?q={{.}}">{{.}}</a> {{end}}</p>{{end}}
72 <p class="meta">{{template "branchicon"}} {{.DefaultBranch}}{{if .License}} · {{.License}}{{end}}{{if .Updated}} · updated {{.Updated}}{{end}}</p> 95 <p class="meta">{{template "branchicon"}} {{.DefaultBranch}}{{if .License}} · {{.License}}{{end}}{{if .Updated}} · updated {{.Updated}}{{end}}</p>
@@ -76,10 +99,10 @@
76 <summary class="refchip">{{template "branchicon"}} {{.Ref}}</summary> 99 <summary class="refchip">{{template "branchicon"}} {{.Ref}}</summary>
77 <div class="refdrop"> 100 <div class="refdrop">
78 {{range .Branches}}<a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/{{$.RefKind}}/{{.Name}}/{{$.DirPath}}">{{.Name}}</a> 101 {{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>
80 </div> 103 </div>
81</details>{{else}}<span class="refchip">{{template "branchicon"}} {{.Ref}}</span>{{end}}{{end}} 104</details>{{else}}<span class="refchip">{{template "branchicon"}} {{.Ref}}</span>{{end}}{{end}}
82 105
83{{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}} 106{{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}}
84 107
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 @@
1{{define "title"}}log · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}log · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}}
4{{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}} 3{{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>
5<ul class="loglist"> 5<ul class="loglist">
6{{range .Commits}}<li> 6{{range .Commits}}<li>
7 <div class="commitmain"> 7 <div class="commitmain">
internal/web/templates/login.html +2 −2
@@ -1,7 +1,7 @@
1{{define "title"}}login · {{.Site}}{{end}} 1{{define "title"}}login · {{.Site}}{{end}}
2{{define "content"}} 2{{define "content"}}
3<h1>log in</h1> 3<h1>Log in</h1>
4{{if .Error}}<p class="error">{{.Error}}</p>{{end}} 4{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
5<p>Browser sessions are minted over SSH — there is no password. From a machine 5<p>Browser sessions are minted over SSH — there is no password. From a machine
6with your registered key:</p> 6with your registered key:</p>
7<pre class="message">ssh git@{{.Site}} web login</pre> 7<pre class="message">ssh git@{{.Site}} web login</pre>
internal/web/templates/milestones.html +1 −2
@@ -1,8 +1,7 @@
1{{define "title"}}milestones · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}milestones · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}}
4<div class="listhead"> 3<div class="listhead">
5 <h2>milestones</h2> 4 <h1>Milestones</h1>
6 <nav class="filters"> 5 <nav class="filters">
7 <a {{if eq .State "open"}}class="active" {{end}}href="?state=open">open</a> 6 <a {{if eq .State "open"}}class="active" {{end}}href="?state=open">open</a>
8 <a {{if eq .State "closed"}}class="active" {{end}}href="?state=closed">closed</a> 7 <a {{if eq .State "closed"}}class="active" {{end}}href="?state=closed">closed</a>
internal/web/templates/mr.html +59 −25
@@ -1,25 +1,30 @@
1{{define "title"}}!{{.MR.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}!{{.MR.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}} 3{{$base := printf "/%s/%s/mrs/%d" .Repo.OwnerName .Repo.Name .MR.Number}}
4<h2 class="issuetitle">{{.MR.Title}} <span class="issuenumber">!{{.MR.Number}}</span></h2> 4<h1 class="issuetitle">{{.MR.Title}} <span class="issuenumber">!{{.MR.Number}}</span></h1>
5<p class="issuemeta"><span class="chip chip-{{.MR.State}}">{{.MR.State}}</span> 5<p class="issuemeta"><span class="chip chip-{{.MR.State}}">{{.MR.State}}</span>
6<a href="/{{.MR.Author}}">{{.MR.Author}}</a> wants to merge {{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}} into {{.MR.TargetRef}} 6<a href="/{{.MR.Author}}">{{.MR.Author}}</a> wants to merge <code>{{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}}</code> into <code>{{.MR.TargetRef}}</code></p>
7 at <code>{{short .MR.HeadSHA}}</code>{{if .MR.Milestone}} · milestone <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">{{.MR.Milestone}}</a>{{end}}</p> 7
8{{if .CanEdit}}<details class="editbox"><summary>edit</summary> 8<div class="withaside">
9<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/{{.MR.Number}}/edit" class="commentform"> 9<div class="mainside">
10<p><input type="text" name="title" value="{{.MR.Title}}" required></p> 10
11<p><textarea name="body" rows="8">{{.MR.Body}}</textarea></p> 11<nav class="subtabs" aria-label="Merge request">
12<p><button type="submit">save</button></p> 12 <a {{if eq .View "conversation"}}aria-current="page" {{end}}href="{{$base}}">Conversation{{if .Comments}} <i>{{len .Comments}}</i>{{end}}</a>
13 <a {{if eq .View "commits"}}aria-current="page" {{end}}href="{{$base}}?view=commits">Commits{{if .Commits}} <i>{{len .Commits}}</i>{{end}}</a>
14 <a {{if eq .View "diff"}}aria-current="page" {{end}}href="{{$base}}?view=diff">Files changed{{if .Stat.Files}} <i>{{.Stat.Files}}</i>{{end}}</a>
15</nav>
16
17{{if eq .View "conversation"}}
18{{if .CanEdit}}<details class="editbox"><summary>Edit</summary>
19<form method="post" action="{{$base}}/edit" class="commentform">
20<p><input type="text" name="title" aria-label="Title" value="{{.MR.Title}}" required></p>
21<p><textarea name="body" aria-label="Description" rows="8">{{.MR.Body}}</textarea></p>
22<p><button type="submit">Save</button></p>
13</form></details>{{end}} 23</form></details>{{end}}
14{{if .BodyHTML}}<article class="comment"> 24{{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>
16 <div class="rendered">{{.BodyHTML}}</div> 26 <div class="rendered">{{.BodyHTML}}</div>
17</article>{{end}} 27</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}}
23{{range .Comments}} 28{{range .Comments}}
24{{if eq .Kind "system"}}<div class="syscomment">{{.BodyHTML}} <span class="when">{{when .CreatedAt}}</span></div> 29{{if eq .Kind "system"}}<div class="syscomment">{{.BodyHTML}} <span class="when">{{when .CreatedAt}}</span></div>
25{{else}}<article class="comment"> 30{{else}}<article class="comment">
@@ -27,13 +32,16 @@
27 <div class="rendered">{{.BodyHTML}}</div> 32 <div class="rendered">{{.BodyHTML}}</div>
28</article>{{end}} 33</article>{{end}}
29{{end}} 34{{end}}
35{{if .DetachedThreads}}<h2>Threads on earlier revisions</h2>
36{{range .DetachedThreads}}<div class="thread stale"><p class="threadstate">{{if .Stale}}stale{{end}}{{if .Resolved}}{{if .Stale}} · {{end}}resolved by {{.Resolved}}{{end}}</p>{{range .Comments}}<p class="commenthead"><strong>{{.Author}}</strong> <span class="when">{{when .CreatedAt}}</span></p><div class="rendered">{{.BodyHTML}}</div>{{end}}</div>{{end}}{{end}}
30{{if .Viewer}} 37{{if .Viewer}}
31<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/{{.MR.Number}}/comment" class="commentform"> 38<form method="post" action="{{$base}}/comment" class="commentform">
32<p><textarea name="body" rows="4" placeholder="comment as {{.Viewer}}"></textarea></p> 39<p><textarea name="body" aria-label="Comment" rows="4" placeholder="Comment as {{.Viewer}}"></textarea></p>
33<p><button type="submit">comment</button></p> 40<p><button type="submit">Comment</button></p>
34</form> 41</form>
35{{end}} 42{{end}}
36{{if .Commits}}<h3>commits <span class="count">{{len .Commits}}</span></h3> 43
44{{else if eq .View "commits"}}
37<ul class="loglist"> 45<ul class="loglist">
38{{range .Commits}}<li> 46{{range .Commits}}<li>
39 <div class="commitmain"> 47 <div class="commitmain">
@@ -45,13 +53,39 @@
45 <code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.ShortSHA}}</a></code> 53 <code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.ShortSHA}}</a></code>
46 </div> 54 </div>
47</li> 55</li>
56{{else}}<li class="empty">no commits on this merge request</li>
48{{end}} 57{{end}}
49</ul>{{end}} 58</ul>
50<details class="difffold"> 59
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> 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>
52<pre class="diff">{{range .DiffLines}}<span class="{{.Class}}">{{.Text}}</span> 62<pre class="diff">{{range .DiffLines}}<span class="{{.Class}}">{{.Text}}</span>
53{{range .Threads}}</pre><div class="thread{{if .Resolved}} resolved{{end}}">{{if .Resolved}}<p class="threadstate">resolved by {{.Resolved}}</p>{{end}}{{range .Comments}}<p class="commenthead"><strong>{{.Author}}</strong> <span class="when">{{when .CreatedAt}}</span></p><div class="rendered">{{.BodyHTML}}</div>{{end}}</div><pre class="diff">{{end}}{{end}}</pre> 63{{range .Threads}}</pre><div class="thread{{if .Resolved}} resolved{{end}}">{{if .Resolved}}<p class="threadstate">resolved by {{.Resolved}}</p>{{end}}{{range .Comments}}<p class="commenthead"><strong>{{.Author}}</strong> <span class="when">{{when .CreatedAt}}</span></p><div class="rendered">{{.BodyHTML}}</div>{{end}}</div><pre class="diff">{{end}}{{end}}</pre>
54</details> 64{{end}}
55{{if .DetachedThreads}}<h3>threads on earlier revisions</h3> 65
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}} 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>
57{{end}} 91{{end}}
internal/web/templates/mrs.html +1 −2
@@ -1,8 +1,7 @@
1{{define "title"}}merge requests · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}merge requests · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}}
4<div class="listhead"> 3<div class="listhead">
5 <h2>merge requests</h2> 4 <h1>Merge requests</h1>
6 <nav class="filters"> 5 <nav class="filters">
7 <a {{if eq .State "open"}}class="active" {{end}}href="?state=open">open</a> 6 <a {{if eq .State "open"}}class="active" {{end}}href="?state=open">open</a>
8 <a {{if eq .State "merged"}}class="active" {{end}}href="?state=merged">merged</a> 7 <a {{if eq .State "merged"}}class="active" {{end}}href="?state=merged">merged</a>
internal/web/templates/new.html +13 −7
@@ -1,15 +1,21 @@
1{{define "title"}}new repository · {{.Site}}{{end}} 1{{define "title"}}new repository · {{.Site}}{{end}}
2{{define "content"}} 2{{define "content"}}
3<h1>new repository</h1> 3<h1>New repository</h1>
4{{if .Error}}<p class="error">{{.Error}}</p>{{end}} 4{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
5<form method="post" action="/new"> 5<form method="post" action="/new">
6<p><label>owner <select name="owner"> 6<p><label>Owner <select name="owner">
7 <option value="{{.Viewer}}">{{.Viewer}}</option> 7 <option value="{{.Viewer}}">{{.Viewer}}</option>
8 {{range .Orgs}}<option value="{{.}}">{{.}}</option>{{end}} 8 {{range .Orgs}}<option value="{{.}}">{{.}}</option>{{end}}
9</select></label> 9</select></label>
10<label>/ name <input name="name" required pattern="[a-z0-9][a-z0-9._-]*"></label></p> 10<label>/ Name <input name="name" required maxlength="63" pattern="[a-z0-9][a-z0-9._\-]{0,62}" aria-describedby="namehint"></label>
11<p><label><input type="radio" name="visibility" value="public" checked> public</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 <label><input type="radio" name="visibility" value="private"> private</label></p> 12<fieldset class="segmented">
13<p><button type="submit">create</button></p> 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>
14</form> 20</form>
15{{end}} 21{{end}}
internal/web/templates/privacy.html +3 −3
@@ -1,9 +1,9 @@
1{{define "title"}}privacy · {{.Site}}{{end}} 1{{define "title"}}privacy · {{.Site}}{{end}}
2{{define "content"}} 2{{define "content"}}
3<div class="landing"> 3<div class="landing">
4<h1>privacy</h1> 4<h1>Privacy</h1>
5 5
6<h2>the software</h2> 6<h2>The software</h2>
7<p>This site runs <a href="https://gitbay.org/krz/gitbay">gitbay</a>, a 7<p>This site runs <a href="https://gitbay.org/krz/gitbay">gitbay</a>, a
8self-hosted, CLI-first git forge. The software makes no external 8self-hosted, CLI-first git forge. The software makes no external
9requests from your browser: no analytics, no CDNs, no webfonts, no 9requests from your browser: no analytics, no CDNs, no webfonts, no
@@ -20,7 +20,7 @@ they are indistinguishable from nonexistent.</p>
20downloads your account bundle, git data is yours by clone, and 20downloads your account bundle, git data is yours by clone, and
21<code>gitbay migrate</code> moves everything to another instance.</p> 21<code>gitbay migrate</code> moves everything to another instance.</p>
22 22
23<h2>this instance ({{.Host}})</h2> 23<h2>This instance ({{.Host}})</h2>
24{{if .Notice}}{{range paragraphs .Notice}}<p>{{.}}</p>{{end}} 24{{if .Notice}}{{range paragraphs .Notice}}<p>{{.}}</p>{{end}}
25{{else}}<p class="desc">The operator of this instance has not added 25{{else}}<p class="desc">The operator of this instance has not added
26instance-specific notes. Questions about backups, retention, or 26instance-specific notes. Questions about backups, retention, or
internal/web/templates/refs.html +3 −3
@@ -1,8 +1,8 @@
1{{define "title"}}refs · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}refs · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}} 3<h1>Refs</h1>
4<h2>branches</h2> 4<h2>Branches</h2>
5<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> 5<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>
7<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> 7<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>
8{{end}} 8{{end}}
internal/web/templates/register.html +4 −4
@@ -1,17 +1,17 @@
1{{define "title"}}register · {{.Site}}{{end}} 1{{define "title"}}register · {{.Site}}{{end}}
2{{define "content"}} 2{{define "content"}}
3<div class="landing"> 3<div class="landing">
4<h1>create an account</h1> 4<h1>Create an account</h1>
5{{if eq .Mode "invite"}}<p class="lede">This instance is invite-only: you need an invite code from an admin.</p> 5{{if eq .Mode "invite"}}<p class="lede">This instance is invite-only: you need an invite code from an admin.</p>
6{{else}}<p class="lede">Open registration — your account activates once you verify your email.</p>{{end}} 6{{else}}<p class="lede">Open registration — your account activates once you verify your email.</p>{{end}}
7{{if .Error}}<p class="error">{{.Error}}</p>{{end}} 7{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
8<form method="post" action="/register" class="signupform"> 8<form method="post" action="/register" class="signupform">
9<p><label>username<br><input type="text" name="username" value="{{.Username}}" required autofocus></label></p> 9<p><label>username<br><input type="text" name="username" value="{{.Username}}" required autofocus></label></p>
10{{if eq .Mode "invite"}}<p><label>invite code<br><input type="text" name="invite" required></label></p> 10{{if eq .Mode "invite"}}<p><label>invite code<br><input type="text" name="invite" required></label></p>
11{{else}}<p><label>email<br><input type="text" name="email" required></label></p>{{end}} 11{{else}}<p><label>email<br><input type="text" name="email" required></label></p>{{end}}
12<p><label>SSH public key — your key is your identity; paste e.g. <code>~/.ssh/id_ed25519.pub</code><br> 12<p><label>SSH public key — your key is your identity; paste e.g. <code>~/.ssh/id_ed25519.pub</code><br>
13<textarea name="key" rows="3" required placeholder="ssh-ed25519 AAAA... you@host"></textarea></label></p> 13<textarea name="key" aria-label="SSH public key" rows="3" required placeholder="ssh-ed25519 AAAA... you@host"></textarea></label></p>
14<p><button type="submit">create account</button></p> 14<p><button type="submit">Create account</button></p>
15</form> 15</form>
16<p class="meta">Prefer the terminal? <code>ssh git@{{.Host}} register --username you {{if eq .Mode "invite"}}--invite &lt;code&gt;{{else}}--email you@example.org{{end}}</code></p> 16<p class="meta">Prefer the terminal? <code>ssh git@{{.Host}} register --username you {{if eq .Mode "invite"}}--invite &lt;code&gt;{{else}}--email you@example.org{{end}}</code></p>
17</div> 17</div>
internal/web/templates/registered.html +1 −1
@@ -1,7 +1,7 @@
1{{define "title"}}welcome · {{.Site}}{{end}} 1{{define "title"}}welcome · {{.Site}}{{end}}
2{{define "content"}} 2{{define "content"}}
3<div class="landing"> 3<div class="landing">
4<h1>welcome, {{.Username}}</h1> 4<h1>Welcome, {{.Username}}</h1>
5<pre class="quickstart">{{.Message}}</pre> 5<pre class="quickstart">{{.Message}}</pre>
6<p>Everything runs over SSH with the key you registered:</p> 6<p>Everything runs over SSH with the key you registered:</p>
7<pre class="quickstart">ssh git@{{.Host}} whoami 7<pre class="quickstart">ssh git@{{.Host}} whoami
internal/web/templates/releases.html +1 −2
@@ -1,7 +1,6 @@
1{{define "title"}}releases · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}releases · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}} 3<h1>Releases</h1>
4<h2>releases</h2>
5{{range $rel := .Releases}} 4{{range $rel := .Releases}}
6<article class="release"> 5<article class="release">
7 <header class="releasehead"> 6 <header class="releasehead">
internal/web/templates/search.html +3 −3
@@ -1,11 +1,11 @@
1{{define "title"}}search · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}search · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}} 3<h1>Search</h1>
4<form method="get" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/search" class="searchform"> 4<form method="get" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/search" class="searchform">
5 <input type="search" name="q" value="{{.Query}}" placeholder="search file contents on {{.Ref}}" autofocus> 5 <input type="search" name="q" aria-label="Search file contents" value="{{.Query}}" placeholder="search file contents on {{.Ref}}" autofocus>
6 <button type="submit">search</button> 6 <button type="submit">search</button>
7</form> 7</form>
8{{if .QueryErr}}<p class="error">{{.QueryErr}}</p> 8{{if .QueryErr}}<p class="error" role="alert">{{.QueryErr}}</p>
9{{else if .Query}} 9{{else if .Query}}
10{{if .Matches}} 10{{if .Matches}}
11<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> 11<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 @@
1{{define "title"}}{{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}{{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}} 3{{if .DirPath}}<h1 class="vh">{{.DirPath}}</h1>{{end}}
4<div class="pathbar"> 4<div class="pathbar">
5 {{template "refmenu" .}} 5 {{template "refmenu" .}}
6 <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}</span> 6 <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}</span>
7 <span class="spacer"></span> 7 <span class="spacer"></span>
8 <span class="clone">clone: <code>git clone {{.CloneURL}}</code></span> 8 <a class="act" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/search">Find file</a>
9 <a class="act" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log/{{.Ref}}">History</a>
10 <a class="act" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/archive/{{.Ref}}.tar.gz">Download</a>
9</div> 11</div>
12{{if .Entries}}<p class="clone">Clone: <code>git clone {{.CloneURL}}</code></p>{{end}}
13{{with .Tip}}{{if .SHA}}<div class="tipbar">
14 <span class="who">{{.Author}}</span>
15 <a class="subject" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Subject}}</a>
16 <span class="spacer"></span>
17 <a class="sha" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}"><code>{{short .SHA}}</code></a>
18 <span class="age">{{ago .When}}</span>
19</div>{{end}}{{end}}
10<table class="tree"> 20<table class="tree">
11<tr class="cols"><th>name</th><th class="size">size</th><th class="mode">mode</th></tr> 21<tr class="cols"><th scope="col">name</th><th scope="col" class="lastcommit">last commit</th><th scope="col" class="age">updated</th></tr>
12{{range .Entries}}<tr> 22{{range .Entries}}{{$c := index $.LastCommits .Name}}<tr>
13 {{if eq .Type "tree"}}<td class="name"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/tree/{{$.Ref}}/{{$.Prefix}}{{.Name}}">{{.Name}}/</a></td><td class="size"></td> 23 {{if eq .Type "tree"}}<td class="name dir"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/tree/{{$.Ref}}/{{$.Prefix}}{{.Name}}">{{.Name}}/</a></td>
14 {{else}}<td class="name"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/blob/{{$.Ref}}/{{$.Prefix}}{{.Name}}">{{.Name}}</a></td><td class="size">{{.Size}}</td>{{end}} 24 {{else}}<td class="name"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/blob/{{$.Ref}}/{{$.Prefix}}{{.Name}}">{{.Name}}</a></td>{{end}}
15 <td class="mode">{{.Mode}}</td> 25 <td class="lastcommit">{{with $c.Subject}}<a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{$c.SHA}}" title="{{.}}">{{.}}</a>{{end}}</td>
26 <td class="age">{{ago $c.When}}</td>
16</tr> 27</tr>
17{{else}}<tr><td class="name empty" colspan="3">this repository is empty — push something:<br><code>git remote add origin {{.CloneURL}}</code></td></tr>{{end}} 28{{else}}<tr><td class="name empty" colspan="3">this repository is empty — push something:<br><code>git remote add origin {{.CloneURL}}</code></td></tr>{{end}}
18</table> 29</table>
internal/web/templates/wiki.html +1 −1
@@ -1,6 +1,6 @@
1{{define "title"}}wiki{{if .Page}}: {{.Page}}{{end}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}wiki{{if .Page}}: {{.Page}}{{end}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}} 3<h1 class="vh">Wiki{{if .Page}}: {{.Page}}{{end}}</h1>
4{{if .Missing}}<p class="empty-note">no wiki yet — create one by pushing pages:<br> 4{{if .Missing}}<p class="empty-note">no wiki yet — create one by pushing pages:<br>
5<code>git clone ssh://git@{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}.wiki.git</code> then add <code>Home.md</code> (or .org) and push.</p> 5<code>git clone ssh://git@{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}.wiki.git</code> then add <code>Home.md</code> (or .org) and push.</p>
6{{else}} 6{{else}}
internal/web/web.go +67
@@ -8,6 +8,7 @@ import (
8 "io" 8 "io"
9 "reflect" 9 "reflect"
10 "runtime/debug" 10 "runtime/debug"
11 "strconv"
11 "strings" 12 "strings"
12 "sync" 13 "sync"
13 "time" 14 "time"
@@ -77,6 +78,20 @@ var funcs = template.FuncMap{
77 }, 78 },
78 "add": func(a, b int) int { return a + b }, 79 "add": func(a, b int) int { return a + b },
79 "sub": func(a, b int) int { return a - b }, 80 "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 },
80 // initial is the avatar letter for a username. 95 // initial is the avatar letter for a username.
81 "initial": func(s string) string { 96 "initial": func(s string) string {
82 for _, r := range s { 97 for _, r := range s {
@@ -117,6 +132,58 @@ var funcs = template.FuncMap{
117 } 132 }
118 return f.Interface() 133 return f.Interface()
119 }, 134 },
135 // sigLabel turns a stored signature state into words. The state names
136 // are the CLI's vocabulary and belong in its output; a reader of the
137 // web UI needs to know what is wrong, not what the column is called.
138 "sigLabel": func(state string) string {
139 switch state {
140 case "verified":
141 return "Verified"
142 case "signed_unknown_key":
143 return "Unregistered key"
144 case "signed_email_mismatch":
145 return "Email mismatch"
146 case "signed_key_expired":
147 return "Key expired"
148 case "signed_key_revoked":
149 return "Key revoked"
150 case "bad_signature":
151 return "Bad signature"
152 case "unsigned":
153 return "Unsigned"
154 }
155 return state
156 },
157 // ago renders a time as a coarse relative age, which is what a
158 // listing column is actually read for. The zero time yields "".
159 "ago": func(t time.Time) string {
160 if t.IsZero() {
161 return ""
162 }
163 d := time.Since(t)
164 if d < 0 {
165 d = 0
166 }
167 plural := func(n int, unit string) string {
168 if n == 1 {
169 return "1 " + unit + " ago"
170 }
171 return strconv.Itoa(n) + " " + unit + "s ago"
172 }
173 switch {
174 case d < time.Minute:
175 return "just now"
176 case d < time.Hour:
177 return plural(int(d/time.Minute), "minute")
178 case d < 24*time.Hour:
179 return plural(int(d/time.Hour), "hour")
180 case d < 30*24*time.Hour:
181 return plural(int(d/(24*time.Hour)), "day")
182 case d < 365*24*time.Hour:
183 return plural(int(d/(30*24*time.Hour)), "month")
184 }
185 return plural(int(d/(365*24*time.Hour)), "year")
186 },
120 // when formats a stored RFC3339 timestamp for display; unparseable 187 // when formats a stored RFC3339 timestamp for display; unparseable
121 // values pass through unchanged. 188 // values pass through unchanged.
122 "when": func(s string) string { 189 "when": func(s string) string {