Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Build a landing page and simplify the forge chrome

074ae1605e177e99533af02d8c69ccd1a6a947bb · Keenan Nemetz · 14d ago · parent 62576fa8

Build a landing page and simplify the forge chrome

The instance root is now a landing page rather than the full activity feed:
forge identity, project cards with top language, last commit, issue/change
counts and latest release, a short activity rail, and a first-run empty state.
The full feed moves to /activity with type filters and pagination.

Feeds now cover issues, changes, comments, and releases only; commits and CI
runs already have their own pages. Configuration gains instance_name and
instance_tagline.

Navigation: the crowded global bar becomes Runners, Activity, search, and an
account menu holding Admin, Status, and Log out. Repository pages show the name
and description above the tabs at the home-page header scale, while tab-page
titles stay small. Top-level pages keep the larger header. Drop the workflow
run chip, assigned-issues strip, repository size stat, and footer Status link.

server/home_test.go +210

@@ -0,0 +1,210 @@
1 package server_test
2
3 // The landing page is the instance's front door: identity, projects, and a short
4 // activity rail. The full feed has its own page so the landing page can stay
5 // about the work rather than the log.
6
7 import (
8 "path/filepath"
9 "strings"
10 "testing"
11
12 "github.com/rodent-software/vole/forge/config"
13 )
14
15 func TestHomeIsALandingPage(t *testing.T) {
16 t.Parallel()
17 f := newFixtureWith(t, func(c *config.Config) {
18 c.InstanceName = "Acme Forge"
19 c.InstanceTagline = "Code for the Acme team."
20 })
21 f.publish(f.repo)
22
23 code, body := f.get("/")
24 if code != 200 {
25 t.Fatalf("home = %d", code)
26 }
27 for _, want := range []string{
28 `class="hero"`, "Acme Forge", "Code for the Acme team.",
29 "Projects", `class="repo-card"`, `href="/r/demo"`,
30 "Latest", `href="/activity"`,
31 } {
32 if !strings.Contains(body, want) {
33 t.Fatalf("home is missing %q:\n%s", want, body)
34 }
35 }
36 if strings.Contains(body, "Recent activity") {
37 t.Fatalf("home still leads with the feed:\n%s", body)
38 }
39 if !strings.Contains(body, "0 repositories") && !strings.Contains(body, "1 repository") {
40 t.Fatalf("home has no vitals line:\n%s", body)
41 }
42 }
43
44 func TestHomeHasTheCleanTopBar(t *testing.T) {
45 t.Parallel()
46 f := newFixture(t)
47 f.publish(f.repo)
48
49 _, body := f.get("/")
50 for _, want := range []string{
51 `class="topbar"`, `class="topnav"`, `href="/runners"`, `href="/activity"`,
52 `class="topsearch"`, `name="q"`, `href="/login"`,
53 } {
54 if !strings.Contains(body, want) {
55 t.Fatalf("home is missing the top-bar marker %q:\n%s", want, body)
56 }
57 }
58 for _, unwanted := range []string{`class="rail"`, `src="/static/nav.js"`, `>Repositories</a>`} {
59 if strings.Contains(body, unwanted) {
60 t.Fatalf("home still has %q:\n%s", unwanted, body)
61 }
62 }
63
64 _, cookie := f.browserSession(t, f.ownerID)
65 _, body = f.browserGet(t, "/", cookie)
66 for _, want := range []string{`class="account-menu"`, `class="account-pop"`, `href="/account"`, `href="/status"`, `action="/logout"`} {
67 if !strings.Contains(body, want) {
68 t.Fatalf("signed-in top bar is missing %q:\n%s", want, body)
69 }
70 }
71 }
72
73 func TestHomeSearchShowsProjectCards(t *testing.T) {
74 t.Parallel()
75 f := newFixture(t)
76 f.publish(f.repo)
77
78 _, body := f.get("/?q=demo")
79 for _, want := range []string{"Results", `class="repo-card"`, `href="/r/demo"`, "clear search"} {
80 if !strings.Contains(body, want) {
81 t.Fatalf("search results are missing %q:\n%s", want, body)
82 }
83 }
84 if strings.Contains(body, `class="home-rail"`) {
85 t.Fatalf("search results kept the landing-page activity rail:\n%s", body)
86 }
87
88 _, body = f.get("/?q=nomatch")
89 if !strings.Contains(body, "No repositories match") {
90 t.Fatalf("empty search state missing:\n%s", body)
91 }
92 }
93
94 func TestInstanceActivityListsOnlyVisibleRepositories(t *testing.T) {
95 t.Parallel()
96 f := newFixture(t)
97 f.publish(f.repo)
98
99 // The public repository has a commit (not an event), an issue, and a release.
100 work := workRepo(t, map[string]string{"readme.md": "public\n"})
101 if _, errs, err := runGit(t, work, "commit", "--allow-empty", "-qm", "public-commit"); err != nil {
102 t.Fatalf("public commit: %s", errs)
103 }
104 if _, errs, err := runGit(t, work, "tag", "-a", "v1.0.0", "-m", "public release"); err != nil {
105 t.Fatalf("public tag: %s", errs)
106 }
107 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main", "refs/tags/v1.0.0"); err != nil {
108 t.Fatalf("public push: %s", errs)
109 }
110 clone := filepath.Join(t.TempDir(), "clone")
111 if _, errs, err := f.git("", "clone", "-q", f.sshURL(f.repo), clone); err != nil {
112 t.Fatalf("public clone: %s", errs)
113 }
114 if _, errs, err := f.cli(clone, "issue", "new", "--title", "public-issue-marker", "-m", "body"); err != nil {
115 t.Fatalf("public issue: %v\n%s", err, errs)
116 }
117
118 secret, err := f.srv.Repos.Create("secret", f.ownerID, "private")
119 if err != nil {
120 t.Fatal(err)
121 }
122 if err := f.srv.Index.Upsert(secret); err != nil {
123 t.Fatal(err)
124 }
125 privateWork := workRepo(t, map[string]string{"hidden.md": "private\n"})
126 if _, errs, err := runGit(t, privateWork, "commit", "--allow-empty", "-qm", "private-commit"); err != nil {
127 t.Fatalf("private commit: %s", errs)
128 }
129 if _, errs, err := f.git(privateWork, "push", f.sshURL("secret"), "main:refs/heads/main"); err != nil {
130 t.Fatalf("private push: %s", errs)
131 }
132 privateClone := filepath.Join(t.TempDir(), "private-clone")
133 if _, errs, err := f.git("", "clone", "-q", f.sshURL("secret"), privateClone); err != nil {
134 t.Fatalf("private clone: %s", errs)
135 }
136 if _, errs, err := f.cli(privateClone, "issue", "new", "--title", "private-issue-marker", "-m", "body"); err != nil {
137 t.Fatalf("private issue: %v\n%s", err, errs)
138 }
139
140 code, body := f.get("/activity")
141 if code != 200 {
142 t.Fatalf("activity = %d", code)
143 }
144 for _, want := range []string{`href="/r/demo"`, "public-issue-marker", "v1.0.0", "released"} {
145 if !strings.Contains(body, want) {
146 t.Fatalf("activity feed is missing %q:\n%s", want, body)
147 }
148 }
149 for _, unwanted := range []string{"private-issue-marker", `href="/r/secret"`, "public-commit", "private-commit", "committed"} {
150 if strings.Contains(body, unwanted) {
151 t.Fatalf("activity feed contains %q:\n%s", unwanted, body)
152 }
153 }
154
155 _, body = f.get("/activity?kind=issues")
156 if !strings.Contains(body, "public-issue-marker") || strings.Contains(body, "v1.0.0") {
157 t.Fatalf("issue filter is wrong:\n%s", body)
158 }
159 _, body = f.get("/activity?kind=releases")
160 if !strings.Contains(body, "v1.0.0") || strings.Contains(body, "public-issue-marker") {
161 t.Fatalf("release filter is wrong:\n%s", body)
162 }
163 }
164
165 func TestFirstRunOnboardingIsAnInstanceAdminPage(t *testing.T) {
166 t.Parallel()
167 f := newFixture(t)
168
169 // The fixture's one repository is owned by the account we sign in as, so
170 // remove it to reach the state a fresh instance is in: an admin and no
171 // repositories.
172 m, err := f.srv.Repos.Open(f.repo)
173 if err != nil {
174 t.Fatal(err)
175 }
176 if err := f.srv.Repos.Delete(f.repo); err != nil {
177 t.Fatal(err)
178 }
179 if err := f.srv.Index.Remove(m.ID); err != nil {
180 t.Fatal(err)
181 }
182 if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil {
183 t.Fatal(err)
184 }
185 _, cookie := f.browserSession(t, f.ownerID)
186 resp, body := f.browserGet(t, "/", cookie)
187 if resp.StatusCode != 200 {
188 t.Fatalf("home = %d", resp.StatusCode)
189 }
190 for _, want := range []string{"No repositories yet", "New repository", "Or from the instance directory", "--owner keenan", `href="/admin/repos/new"`} {
191 if !strings.Contains(body, want) {
192 t.Fatalf("first-run page is missing %q:\n%s", want, body)
193 }
194 }
195 }
196
197 func TestHomeCardShowsTopLanguage(t *testing.T) {
198 t.Parallel()
199 f := newFixture(t)
200 f.publish(f.repo)
201 work := workRepo(t, map[string]string{"main.go": "package main\n", "README.md": "readme\n"})
202 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
203 t.Fatalf("push: %s", errs)
204 }
205
206 _, body := f.get("/")
207 if !strings.Contains(body, `class="lang-dot lang-go"`) || !strings.Contains(body, `class="repo-lang"`) {
208 t.Fatalf("home card has no top-language marker:\n%s", body)
209 }
210 }