Carbon web overhaul: rail, repo header, diff view, configurable title !65
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 | |||
| 9 | HOST ?= 155.138.253.199 | ||
| 10 | PORT ?= 2222 | ||
| 11 | CLI_DEST ?= /opt/homebrew/bin/gitbay | ||
| 12 | |||
| 13 | CROSS := CGO_ENABLED=0 GOOS=linux GOARCH=amd64 | ||
| 14 | LDFLAGS := -s -w | ||
| 15 | SERVER_BIN := dist/gitbayd-linux-amd64 | ||
| 16 | RUNNER_BIN := dist/gitbay-runner-linux-amd64 | ||
| 17 | |||
| 18 | .PHONY: help test server cli deploy deploy-runner preflight | ||
| 19 | |||
| 20 | help: | ||
| 21 | @sed -n 's/^# //p' $(MAKEFILE_LIST) | ||
| 22 | |||
| 23 | test: | ||
| 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. | ||
| 28 | preflight: | ||
| 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 | |||
| 35 | server: | ||
| 36 | @echo "==> building $(SERVER_BIN)" | ||
| 37 | $(CROSS) go build -trimpath -ldflags='$(LDFLAGS)' -o $(SERVER_BIN) ./cmd/gitbayd | ||
| 38 | |||
| 39 | cli: | ||
| 40 | @echo "==> installing CLI to $(CLI_DEST)" | ||
| 41 | go build -o $(CLI_DEST) ./cmd/gitbay | ||
| 42 | |||
| 43 | deploy: 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 | |||
| 49 | deploy-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 | ||
| 131 | func TestWebInteractions(t *testing.T) { | 153 | func 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 { | |||
| 64 | type Web struct { | 64 | type 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 @@ | |||
| 1 | package gitutil | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bufio" | ||
| 5 | "os/exec" | ||
| 6 | "strconv" | ||
| 7 | "strings" | ||
| 8 | "time" | ||
| 9 | ) | ||
| 10 | |||
| 11 | // EntryCommit is the newest commit touching one entry of a tree listing. | ||
| 12 | type EntryCommit struct { | ||
| 13 | SHA string | ||
| 14 | Subject string | ||
| 15 | Author string | ||
| 16 | When time.Time | ||
| 17 | } | ||
| 18 | |||
| 19 | // lastCommitScan bounds the history walk. A directory whose entries are | ||
| 20 | // all recently touched resolves in a handful of commits; this only caps | ||
| 21 | // the pathological case, where the remaining entries are reported absent | ||
| 22 | // rather than costing an unbounded scan on every page view. | ||
| 23 | const lastCommitScan = 2000 | ||
| 24 | |||
| 25 | // LastCommits resolves the newest commit touching each of names directly | ||
| 26 | // under path, for the tree at ref. | ||
| 27 | // | ||
| 28 | // One git log process serves the whole listing rather than one per entry: | ||
| 29 | // the walk streams newest-first and is killed as soon as every name is | ||
| 30 | // accounted for, so an active directory reads only the few commits it | ||
| 31 | // needs no matter how deep the history goes. Names still unresolved when | ||
| 32 | // the walk ends are absent from the map, and callers render them blank. | ||
| 33 | func LastCommits(dir, ref, path string, names []string) map[string]EntryCommit { | ||
| 34 | if len(names) == 0 { | ||
| 35 | return nil | ||
| 36 | } | ||
| 37 | want := make(map[string]bool, len(names)) | ||
| 38 | for _, n := range names { | ||
| 39 | want[n] = true | ||
| 40 | } | ||
| 41 | prefix := "" | ||
| 42 | if path != "" { | ||
| 43 | prefix = strings.TrimSuffix(path, "/") + "/" | ||
| 44 | } | ||
| 45 | |||
| 46 | args := []string{"-C", dir, "log", "--first-parent", "--name-only", | ||
| 47 | "--format=%x1e%H%x1f%ct%x1f%an%x1f%s", "-n", strconv.Itoa(lastCommitScan), ref} | ||
| 48 | if prefix != "" { | ||
| 49 | args = append(args, "--", strings.TrimSuffix(prefix, "/")) | ||
| 50 | } | ||
| 51 | cmd := exec.Command("git", args...) | ||
| 52 | stdout, err := cmd.StdoutPipe() | ||
| 53 | if err != nil { | ||
| 54 | return nil | ||
| 55 | } | ||
| 56 | if err := cmd.Start(); err != nil { | ||
| 57 | return nil | ||
| 58 | } | ||
| 59 | defer func() { | ||
| 60 | // The walk usually ends early; stop git rather than let it finish | ||
| 61 | // reading history nobody is going to look at. | ||
| 62 | cmd.Process.Kill() | ||
| 63 | cmd.Wait() | ||
| 64 | }() | ||
| 65 | |||
| 66 | out := make(map[string]EntryCommit, len(names)) | ||
| 67 | var cur EntryCommit | ||
| 68 | sc := bufio.NewScanner(stdout) | ||
| 69 | sc.Buffer(make([]byte, 0, 64*1024), 1<<20) | ||
| 70 | for sc.Scan() { | ||
| 71 | line := sc.Text() | ||
| 72 | if strings.HasPrefix(line, "\x1e") { | ||
| 73 | cur = parseCommitHeader(line[1:]) | ||
| 74 | continue | ||
| 75 | } | ||
| 76 | if line == "" || cur.SHA == "" { | ||
| 77 | continue | ||
| 78 | } | ||
| 79 | name, ok := entryName(line, prefix) | ||
| 80 | if !ok || !want[name] { | ||
| 81 | continue | ||
| 82 | } | ||
| 83 | out[name] = cur | ||
| 84 | delete(want, name) | ||
| 85 | if len(want) == 0 { | ||
| 86 | break | ||
| 87 | } | ||
| 88 | } | ||
| 89 | return out | ||
| 90 | } | ||
| 91 | |||
| 92 | // parseCommitHeader reads sha, commit time, author, and subject, unit | ||
| 93 | // separated so a subject containing spaces stays intact. | ||
| 94 | func parseCommitHeader(s string) EntryCommit { | ||
| 95 | f := strings.SplitN(s, "\x1f", 4) | ||
| 96 | if len(f) != 4 { | ||
| 97 | return EntryCommit{} | ||
| 98 | } | ||
| 99 | c := EntryCommit{SHA: f[0], Author: f[2], Subject: f[3]} | ||
| 100 | if n, err := strconv.ParseInt(f[1], 10, 64); err == nil { | ||
| 101 | c.When = time.Unix(n, 0).UTC() | ||
| 102 | } | ||
| 103 | return c | ||
| 104 | } | ||
| 105 | |||
| 106 | // TipCommit is the commit at ref, for the bar above a tree listing that | ||
| 107 | // answers "who touched this repository last". | ||
| 108 | func TipCommit(dir, ref string) EntryCommit { | ||
| 109 | out, err := exec.Command("git", "-C", dir, "log", "-1", | ||
| 110 | "--format=%H%x1f%ct%x1f%an%x1f%s", ref).Output() | ||
| 111 | if err != nil { | ||
| 112 | return EntryCommit{} | ||
| 113 | } | ||
| 114 | return parseCommitHeader(strings.TrimRight(string(out), "\n")) | ||
| 115 | } | ||
| 116 | |||
| 117 | // entryName maps a changed path to the listing entry that contains it: | ||
| 118 | // "internal/web/web.go" under prefix "internal/" is the entry "web". | ||
| 119 | func entryName(changed, prefix string) (string, bool) { | ||
| 120 | if prefix != "" { | ||
| 121 | if !strings.HasPrefix(changed, prefix) { | ||
| 122 | return "", false | ||
| 123 | } | ||
| 124 | changed = changed[len(prefix):] | ||
| 125 | } | ||
| 126 | if changed == "" { | ||
| 127 | return "", false | ||
| 128 | } | ||
| 129 | if i := strings.IndexByte(changed, '/'); i >= 0 { | ||
| 130 | return changed[:i], true | ||
| 131 | } | ||
| 132 | return changed, true | ||
| 133 | } | ||
| 134 | |||
| 135 | // StatPath returns the tree entry for a single path at ref, so a blob | ||
| 136 | // page can report the facts the file listing no longer carries: its size, | ||
| 137 | // and whether it is executable or a symlink. | ||
| 138 | func StatPath(dir, ref, path string) (TreeEntry, bool) { | ||
| 139 | out, err := exec.Command("git", "-C", dir, "ls-tree", "-l", ref, "--", path).Output() | ||
| 140 | if err != nil { | ||
| 141 | return TreeEntry{}, false | ||
| 142 | } | ||
| 143 | line := strings.TrimRight(string(out), "\n") | ||
| 144 | meta, name, ok := strings.Cut(line, "\t") | ||
| 145 | if !ok { | ||
| 146 | return TreeEntry{}, false | ||
| 147 | } | ||
| 148 | f := strings.Fields(meta) | ||
| 149 | if len(f) != 4 { | ||
| 150 | return TreeEntry{}, false | ||
| 151 | } | ||
| 152 | size := int64(-1) | ||
| 153 | if f[3] != "-" { | ||
| 154 | size, _ = strconv.ParseInt(f[3], 10, 64) | ||
| 155 | } | ||
| 156 | return TreeEntry{Mode: f[0], Type: f[1], SHA: f[2], Size: size, Name: name}, true | ||
| 157 | } | ||
internal/gitutil/lastcommit_test.go added +91
| @@ -0,0 +1,91 @@ | |||
| 1 | package gitutil | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "os" | ||
| 5 | "os/exec" | ||
| 6 | "path/filepath" | ||
| 7 | "testing" | ||
| 8 | ) | ||
| 9 | |||
| 10 | func git(t *testing.T, dir string, args ...string) { | ||
| 11 | t.Helper() | ||
| 12 | cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) | ||
| 13 | cmd.Env = append(os.Environ(), | ||
| 14 | "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@e", | ||
| 15 | "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@e") | ||
| 16 | if out, err := cmd.CombinedOutput(); err != nil { | ||
| 17 | t.Fatalf("git %v: %v\n%s", args, err, out) | ||
| 18 | } | ||
| 19 | } | ||
| 20 | |||
| 21 | func write(t *testing.T, dir, rel, body string) { | ||
| 22 | t.Helper() | ||
| 23 | p := filepath.Join(dir, rel) | ||
| 24 | if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { | ||
| 25 | t.Fatal(err) | ||
| 26 | } | ||
| 27 | if err := os.WriteFile(p, []byte(body), 0o644); err != nil { | ||
| 28 | t.Fatal(err) | ||
| 29 | } | ||
| 30 | } | ||
| 31 | |||
| 32 | func TestLastCommits(t *testing.T) { | ||
| 33 | dir := t.TempDir() | ||
| 34 | git(t, dir, "init", "-q", "-b", "main") | ||
| 35 | |||
| 36 | write(t, dir, "README.md", "one\n") | ||
| 37 | write(t, dir, "src/main.go", "one\n") | ||
| 38 | write(t, dir, "docs/guide.md", "one\n") | ||
| 39 | git(t, dir, "add", ".") | ||
| 40 | git(t, dir, "commit", "-qm", "first") | ||
| 41 | |||
| 42 | // Touching a file deep inside src/ must move the src/ entry without | ||
| 43 | // disturbing README.md or docs/. | ||
| 44 | write(t, dir, "src/lib/helper.go", "two\n") | ||
| 45 | git(t, dir, "add", ".") | ||
| 46 | git(t, dir, "commit", "-qm", "add helper") | ||
| 47 | |||
| 48 | write(t, dir, "README.md", "three\n") | ||
| 49 | git(t, dir, "commit", "-qam", "update readme") | ||
| 50 | |||
| 51 | got := LastCommits(dir, "main", "", []string{"README.md", "src", "docs"}) | ||
| 52 | for name, wantSubject := range map[string]string{ | ||
| 53 | "README.md": "update readme", | ||
| 54 | "src": "add helper", | ||
| 55 | "docs": "first", | ||
| 56 | } { | ||
| 57 | c, ok := got[name] | ||
| 58 | if !ok { | ||
| 59 | t.Errorf("%s unresolved", name) | ||
| 60 | continue | ||
| 61 | } | ||
| 62 | if c.Subject != wantSubject { | ||
| 63 | t.Errorf("%s = %q, want %q", name, c.Subject, wantSubject) | ||
| 64 | } | ||
| 65 | if c.SHA == "" || c.When.IsZero() { | ||
| 66 | t.Errorf("%s missing sha or time: %+v", name, c) | ||
| 67 | } | ||
| 68 | } | ||
| 69 | |||
| 70 | // Inside a subdirectory the prefix is stripped, so entries resolve | ||
| 71 | // against their own names rather than full paths. | ||
| 72 | sub := LastCommits(dir, "main", "src", []string{"main.go", "lib"}) | ||
| 73 | if c := sub["main.go"]; c.Subject != "first" { | ||
| 74 | t.Errorf("src/main.go = %q, want %q", c.Subject, "first") | ||
| 75 | } | ||
| 76 | if c := sub["lib"]; c.Subject != "add helper" { | ||
| 77 | t.Errorf("src/lib = %q, want %q", c.Subject, "add helper") | ||
| 78 | } | ||
| 79 | |||
| 80 | // A name nobody asked about never appears, and an unknown one is | ||
| 81 | // simply absent rather than an error. | ||
| 82 | if _, ok := sub["README.md"]; ok { | ||
| 83 | t.Error("src listing leaked a root entry") | ||
| 84 | } | ||
| 85 | if _, ok := LastCommits(dir, "main", "", []string{"nope"})["nope"]; ok { | ||
| 86 | t.Error("resolved a path that does not exist") | ||
| 87 | } | ||
| 88 | if LastCommits(dir, "main", "", nil) != nil { | ||
| 89 | t.Error("empty name list should not run git") | ||
| 90 | } | ||
| 91 | } | ||
internal/httpd/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 | ||
| 121 | func (s *Server) renderNewRepo(w http.ResponseWriter, u store.User, errMsg string) { | 120 | func (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 | ||
| 130 | func (s *Server) newRepoForm(w http.ResponseWriter, r *http.Request, u store.User) { | 128 | func (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 | ||
| 224 | func (s *Server) renderSignup(w http.ResponseWriter, errMsg, username string) { | 222 | func (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 | ||
| 235 | func (s *Server) signupSubmit(w http.ResponseWriter, r *http.Request) { | 232 | func (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 | ||
| 443 | type editPage struct { | 439 | type 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 @@ | |||
| 1 | package httpd | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 11 | type railRepo struct { | ||
| 12 | Owner string | ||
| 13 | Name string | ||
| 14 | } | ||
| 15 | |||
| 16 | // railItem is one merge request in the rail's review queue. | ||
| 17 | type 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. | ||
| 27 | type 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. | ||
| 34 | func (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. | ||
| 38 | type 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. | ||
| 46 | func (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. | ||
| 55 | func (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. | ||
| 67 | func (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. | ||
| 49 | func (s *Server) siteName() string { | 52 | func (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. |
| 67 | func (s *Server) font(w http.ResponseWriter, r *http.Request) { | 73 | func (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. |
| 80 | func (s *Server) notFound(w http.ResponseWriter, r *http.Request) { | 86 | func (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 | ||
| 161 | func (s *Server) explore(w http.ResponseWriter, r *http.Request) { | 162 | func (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 "". | ||
| 181 | func (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. |
| 190 | func (s *Server) privacy(w http.ResponseWriter, r *http.Request) { | 182 | func (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. |
| 224 | type repoPage struct { | 215 | type 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 | ||
| 468 | func (s *Server) blob(w http.ResponseWriter, r *http.Request) { | 484 | func (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. | ||
| 125 | func (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. | ||
| 145 | func (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 | } |
| 114 | main { flex: 1; } | ||
| 115 | 129 | ||
| 116 | a { color: var(--accent); text-decoration: none; } | 130 | a { color: var(--accent); text-decoration: none; } |
| 117 | a:hover { text-decoration: underline; } | 131 | a:hover { text-decoration: underline; } |
| 118 | 132 | ||
| 119 | h1 { 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. */ |
| 121 | h2 { 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 */ | ||
| 153 | a.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 | } | ||
| 162 | a.skip:focus { top: var(--sp-3); text-decoration: none; } | ||
| 163 | |||
| 164 | h1 { 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; } | ||
| 166 | h2 { 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; } | ||
| 123 | h3 { font-size: var(--fs-3); font-weight: 600; margin: var(--sp-5) 0 var(--sp-2); } | 168 | h3 { font-size: var(--fs-3); font-weight: 600; margin: var(--sp-5) 0 var(--sp-2); } |
| 124 | 169 | ||
| 125 | code, pre, .code, td.mode, td.size { | 170 | code, 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. */ | ||
| 138 | body > 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 | } |
| 146 | a.site { | 190 | a.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 | } |
| 155 | a.site:hover { text-decoration: none; color: #ffffff; } | 201 | a.brand:hover { text-decoration: none; color: #ffffff; } |
| 156 | a.site svg.mark { display: block; } | 202 | a.brand svg.mark { display: block; flex: none; } |
| 157 | a.site svg.mark rect { fill: #0f62fe; } | 203 | a.brand span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| 158 | a.site svg.mark path { stroke: #ffffff; } | 204 | |
| 159 | nav.topnav { | 205 | .railbody { flex: 1; overflow-y: auto; padding: var(--sp-3) 0; } |
| 160 | display: flex; | 206 | ul.raillist { list-style: none; margin: 0; padding: 0; } |
| 161 | align-items: center; | 207 | ul.raillist a { |
| 162 | gap: 0; | 208 | display: block; |
| 163 | height: 3rem; | 209 | padding: 0.28rem var(--sp-4); |
| 164 | } | 210 | color: var(--shell-fg); |
| 165 | nav.topnav .spacer { flex: 1; } | ||
| 166 | /* the avatar square is the only nav item in the shell; everything else | ||
| 167 | lives in the rail */ | ||
| 168 | a.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 | } | ||
| 215 | ul.raillist a:hover { background: var(--shell-hover); text-decoration: none; } | ||
| 216 | /* orange marks position — the rail's only accent */ | ||
| 217 | ul.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 | } |
| 181 | a.avatar:hover { text-decoration: none; filter: brightness(1.12); } | 222 | ul.raillist .owner { color: var(--shell-muted); } |
| 182 | a.avatar.guest { background: #393939; color: #f4f4f4; } | 223 | ul.raillist.wide a { white-space: normal; padding-top: var(--sp-1); padding-bottom: var(--sp-1); } |
| 224 | ul.raillist.wide b { display: block; font-weight: 500; font-size: var(--fs-1); } | ||
| 225 | ul.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); | 260 | a.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 | |||
| 309 | button.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 { | 320 | button.btn:hover { border-color: var(--accent); color: var(--accent); } |
| 228 | display: block; | 321 | button.btn[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); } |
| 229 | font-size: var(--fs-0); | 322 | form.inline { display: inline; } |
| 323 | |||
| 324 | nav.tabs { | ||
| 325 | display: flex; | ||
| 326 | gap: var(--sp-1); | ||
| 327 | margin-top: var(--sp-4); | ||
| 328 | overflow-x: auto; | ||
| 329 | scrollbar-width: none; | ||
| 330 | } | ||
| 331 | nav.tabs::-webkit-scrollbar { display: none; } | ||
| 332 | nav.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) { | 343 | nav.tabs a:hover { color: var(--fg); text-decoration: none; } |
| 344 | /* orange marks position here too */ | ||
| 345 | nav.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); } | 350 | nav.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; |
| 252 | main.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 { | |||
| 301 | td:last-child { padding-right: 0; } | 394 | td:last-child { padding-right: 0; } |
| 302 | td.size, td.mode { color: var(--muted); white-space: nowrap; } | 395 | td.size, td.mode { color: var(--muted); white-space: nowrap; } |
| 303 | 396 | ||
| 304 | /* repo header */ | ||
| 305 | .repohead { margin-bottom: var(--sp-4); } | ||
| 306 | h1.repotitle { margin: var(--sp-4) 0 var(--sp-1); font-weight: 400; } | ||
| 307 | h1.repotitle a { color: var(--fg); } | ||
| 308 | h1.repotitle a:last-of-type { font-weight: 650; } | ||
| 309 | h1.repotitle a:hover { color: var(--accent); text-decoration: none; } | ||
| 310 | h1.repotitle .sep { color: var(--muted); margin: 0 0.15em; } | ||
| 311 | form.pinform { display: inline; margin-left: var(--sp-2); vertical-align: middle; } | ||
| 312 | button.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 | } | ||
| 321 | button.pinbtn:hover { border-color: var(--accent); color: var(--accent); filter: none; } | ||
| 322 | button.pinbtn.pinned { color: var(--accent); border-color: var(--accent); } | ||
| 323 | a.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 */ | ||
| 327 | nav.tabs { display: flex; flex-direction: column; margin-top: var(--sp-1); } | ||
| 328 | nav.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 | } | ||
| 334 | nav.tabs .glabel:first-child { margin-top: 0; } | ||
| 335 | nav.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 | } | ||
| 341 | nav.tabs a:hover { background: var(--hover); text-decoration: none; } | ||
| 342 | nav.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); } |
| 504 | svg.icon { vertical-align: -0.125em; } | 544 | svg.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 */ | ||
| 912 | input, textarea, button, select { font: inherit; color: inherit; } | 953 | input, textarea, button, select { font: inherit; color: inherit; } |
| 913 | input[type="text"], input:not([type]), textarea { | 954 | input[type="text"], input[type="search"], input[type="email"], |
| 955 | input[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 | } |
| 919 | textarea { width: 100%; resize: vertical; } | 963 | textarea { width: 100%; resize: vertical; } |
| 920 | textarea[name="content"] { font-family: var(--mono); font-size: var(--fs-1); } | 964 | textarea[name="content"] { font-family: var(--mono); font-size: var(--fs-1); } |
| 921 | input: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 */ | ||
| 967 | select { | ||
| 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 | } | ||
| 976 | input:hover, textarea:hover, select:hover { border-color: var(--muted); } | ||
| 977 | /* the global :focus-visible ring covers these; only the border moves */ | ||
| 978 | input:focus, textarea:focus, select:focus { border-color: var(--accent); } | ||
| 979 | input[type="radio"], input[type="checkbox"] { accent-color: var(--fill); } | ||
| 980 | label:has(> input[type="radio"]), label:has(> input[type="checkbox"]), | ||
| 981 | label: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. */ | ||
| 988 | input[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 */ | ||
| 992 | fieldset.segmented { border: 0; margin: 0 0 var(--sp-4); padding: 0; } | ||
| 993 | fieldset.segmented legend { | ||
| 994 | padding: 0; | ||
| 995 | margin-bottom: var(--sp-2); | ||
| 996 | color: var(--muted); | ||
| 997 | font-size: var(--fs-2); | ||
| 998 | } | ||
| 999 | fieldset.segmented .options { display: inline-flex; } | ||
| 1000 | fieldset.segmented label { gap: 0; } | ||
| 1001 | fieldset.segmented input { | ||
| 1002 | position: absolute; | ||
| 1003 | width: 1px; | ||
| 1004 | height: 1px; | ||
| 1005 | opacity: 0; | ||
| 1006 | pointer-events: none; | ||
| 1007 | } | ||
| 1008 | fieldset.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 | } | ||
| 1015 | fieldset.segmented label + label span { border-left: 0; } | ||
| 1016 | fieldset.segmented label:first-child span { border-radius: var(--r-sm) 0 0 var(--r-sm); } | ||
| 1017 | fieldset.segmented label:last-child span { border-radius: 0 var(--r-sm) var(--r-sm) 0; } | ||
| 1018 | fieldset.segmented span:hover { border-color: var(--muted); } | ||
| 1019 | fieldset.segmented input:checked + span { | ||
| 1020 | background: var(--fill); | ||
| 1021 | border-color: var(--fill); | ||
| 1022 | color: #ffffff; | ||
| 1023 | font-weight: 600; | ||
| 1024 | } | ||
| 1025 | fieldset.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 */ | ||
| 1036 | input: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); } | ||
| 922 | button { | 1040 | button { |
| 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 | } |
| 931 | button:hover { filter: brightness(1.08); } | 1049 | button: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 */ |
| 946 | footer { | 1064 | footer { |
| 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); } | ||
| 1202 | table.tree th.lastcommit, table.tree td.lastcommit { width: 55%; } | ||
| 1203 | table.tree td.lastcommit a { color: var(--muted); display: block; overflow: hidden; | ||
| 1204 | text-overflow: ellipsis; white-space: nowrap; } | ||
| 1205 | table.tree td.lastcommit a:hover { color: var(--accent); } | ||
| 1206 | table.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 | } | ||
| 1212 | table.tree td.name { width: 25%; white-space: nowrap; } | ||
| 1213 | table.tree td.name.dir a { color: var(--accent); } | ||
| 1214 | p.clone { margin: 0 0 var(--sp-3); } | ||
| 1215 | p.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 */ | ||
| 1250 | nav.subtabs { | ||
| 1251 | display: flex; | ||
| 1252 | gap: var(--sp-4); | ||
| 1253 | border-bottom: 1px solid var(--line); | ||
| 1254 | margin: 0 0 var(--sp-4); | ||
| 1255 | } | ||
| 1256 | nav.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 | } | ||
| 1266 | nav.subtabs a:hover { color: var(--fg); text-decoration: none; } | ||
| 1267 | nav.subtabs a[aria-current] { color: var(--fg); font-weight: 700; border-bottom-color: var(--mark); } | ||
| 1268 | nav.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 | } | ||
| 1276 | p.diffstat { color: var(--muted); font-size: var(--fs-2); margin: 0 0 var(--sp-3); } | ||
| 1277 | p.diffstat .add { color: var(--ok); } | ||
| 1278 | p.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 |
| 6 | with your registered key:</p> | 6 | with 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 |
| 8 | self-hosted, CLI-first git forge. The software makes no external | 8 | self-hosted, CLI-first git forge. The software makes no external |
| 9 | requests from your browser: no analytics, no CDNs, no webfonts, no | 9 | requests from your browser: no analytics, no CDNs, no webfonts, no |
| @@ -20,7 +20,7 @@ they are indistinguishable from nonexistent.</p> | |||
| 20 | downloads your account bundle, git data is yours by clone, and | 20 | downloads 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 |
| 26 | instance-specific notes. Questions about backups, retention, or | 26 | instance-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 <code>{{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 <code>{{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 { |