Commit b4a2206ec2

b4a2206ec2e71d04b592ef40e5dd59444c522bf0

parent: e917696f47

Verified · cmc ci/build: success ci/sonar: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-06 18:41 UTC

store, control, web, wiki: bookmark a repository

Bookmarks are public and counted, pins are private quick access; they
answer different questions, so they are separate tables rather than a
flag on one. repo bookmark/unbookmark/bookmarks, a control on the
repository header, the count in the facts bar, and /bookmarks.

Closes #146

Layout: unified · split

.gitbay/wiki/Parity.org +10
@@ -139,6 +139,7 @@ column and are always markdown.
139139| create | yes | yes | yes |
140140| fork | yes | yes | no |
141141| pin | yes | yes | yes |
142| bookmark, bookmark list | yes | yes | no |
142143| watch, mute | yes | yes | no |
143144| settings, protection | yes | yes | yes |
144145| topics, website | yes | yes | yes |
@@ -168,6 +169,15 @@ within a couple of seconds. Cancelling a duplicate of a commit that
168169already passed the job puts that result back on the commit. The build
169170page carries the button while a build is still cancellable.
170171
172A pin and a bookmark are different things and are stored separately. A
173pin is private quick access to what you are working on, and drives the
174rail; a bookmark is public, says a repository is worth coming back to,
175and its count is the only popularity signal on the instance. Bookmarking
176needs read access only — it is something you do to someone else's
177repository — and a repository bookmarked while public and since made
178private drops out of the listing rather than leaking that it exists
179(krz/gitbay#146).
180
171181Dependency checks are off until a repository's admin turns them on: the
172182check tells a public registry what the repository depends on. =repo deps
173183status= lists what is behind; the repository's settings page renders the
.gitbay/wiki/Users.org +7
@@ -89,6 +89,13 @@ key, author line claims someone else), =signed_key_expired= /
8989(web edits, merge commits) are always =unsigned= — the server holds no
9090signing key on principle.
9191
92=repo bookmark <owner/name>= saves a repository to come back to and
93=repo unbookmark= drops it; =repo bookmarks= lists yours, each with how
94many people have bookmarked it. The web has the control on the
95repository header, the count in the facts bar, and the list at
96=/bookmarks=. Bookmarks are public and counted; pins are private and
97drive the rail. Read access is all a bookmark needs.
98
9299* Profiles
93100
94101A profile is what =/{owner}= shows: a one-line description, a website, a
cmd/gitbay/main.go +3
@@ -414,6 +414,9 @@ func repoCmd() *cobra.Command {
414414 passOpts{server: []string{"repo", "download"}, needsRepo: true}),
415415 pass("pin", "pin a repository to your dashboard", passOpts{server: []string{"repo", "pin"}, needsRepo: true}),
416416 pass("unpin", "unpin a repository", passOpts{server: []string{"repo", "unpin"}, needsRepo: true}),
417 pass("bookmark", "bookmark a repository to come back to", passOpts{server: []string{"repo", "bookmark"}, needsRepo: true}),
418 pass("unbookmark", "remove a bookmark", passOpts{server: []string{"repo", "unbookmark"}, needsRepo: true}),
419 pass("bookmarks", "list the repositories you have bookmarked", passOpts{server: []string{"repo", "bookmarks"}}),
417420 pass("watch", "hear about all activity on a repository", passOpts{server: []string{"repo", "watch"}, needsRepo: true}),
418421 pass("unwatch", "mute a repository", passOpts{server: []string{"repo", "unwatch"}, needsRepo: true}),
419422 pass("archive", "archive a repository (read-only)", passOpts{server: []string{"repo", "archive"}, needsRepo: true}),
e2e/bookmarks_test.go added +107
@@ -0,0 +1,107 @@
1package e2e
2
3import (
4 "net/url"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9)
10
11// Bookmarks are the public "saved for later", separate from pins: they
12// are something you do to someone else's repository, and the count is a
13// signal of what people found worth returning to (#146).
14func TestBookmarks(t *testing.T) {
15 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
16 aliceKey := inst.newKey(t, "alice")
17 bobKey := inst.newKey(t, "bob")
18 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
19 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
20 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
21 t.Fatalf("repo create: %s", errOut)
22 }
23 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/secret", "--private"); code != 0 {
24 t.Fatalf("repo create: %s", errOut)
25 }
26 // The facts bar, where the count shows, needs a repository with
27 // commits: an empty one has nothing to size up.
28 env := inst.gitEnv(aliceKey)
29 work := t.TempDir()
30 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
31 dir := filepath.Join(work, "w")
32 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
33 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
34 mustGit(t, dir, env, "add", ".")
35 mustGit(t, dir, env, "commit", "-q", "-m", "base")
36 mustGit(t, dir, env, "push", "-q", "origin", "main")
37
38 // Bob bookmarks someone else's repository; read access is enough.
39 if out, errOut, code := inst.ssh(t, bobKey, "", "repo", "bookmark", "alice/app"); code != 0 {
40 t.Fatalf("bookmark: %s%s", out, errOut)
41 }
42 if out, _, _ := inst.ssh(t, bobKey, "", "repo", "bookmarks", "--json"); !strings.Contains(out, `"path":"alice/app"`) ||
43 !strings.Contains(out, `"bookmarks":1`) {
44 t.Fatalf("bookmark not listed with its count:\n%s", out)
45 }
46 // Bookmarking twice is not an error and does not double the count.
47 inst.ssh(t, bobKey, "", "repo", "bookmark", "alice/app")
48 if out, _, _ := inst.ssh(t, bobKey, "", "repo", "bookmarks", "--json"); !strings.Contains(out, `"bookmarks":1`) {
49 t.Fatalf("a second bookmark changed the count:\n%s", out)
50 }
51
52 // A private repository a stranger cannot read is not found, the same
53 // as everywhere.
54 if _, _, code := inst.ssh(t, bobKey, "", "repo", "bookmark", "alice/secret"); code != 3 {
55 t.Errorf("bookmarking an invisible repository exited %d, want 3", code)
56 }
57
58 // Pins stay separate: bookmarking does not pin.
59 if out, _, _ := inst.ssh(t, bobKey, "", "dashboard", "--json"); strings.Contains(out, "alice/app") {
60 t.Errorf("a bookmark showed up as a pin:\n%s", out)
61 }
62
63 // The count is public and shows on the repository page.
64 if _, body := inst.get(t, "/alice/app"); !strings.Contains(body, "1</strong> bookmark") {
65 t.Errorf("count not on the repo page:\n%s", body)
66 }
67
68 // The web toggles it, through the same command.
69 bob := inst.login(t, bobKey)
70 if status, _ := browserPost(t, bob, inst.base()+"/alice/app/bookmark", url.Values{}); status != 200 {
71 t.Fatal("web unbookmark failed")
72 }
73 if out, _, _ := inst.ssh(t, bobKey, "", "repo", "bookmarks", "--json"); strings.Contains(out, "alice/app") {
74 t.Fatalf("still bookmarked after the toggle:\n%s", out)
75 }
76 if status, _ := browserPost(t, bob, inst.base()+"/alice/app/bookmark", url.Values{}); status != 200 {
77 t.Fatal("web bookmark failed")
78 }
79 if _, page := browserGet(t, bob, inst.base()+"/bookmarks"); !strings.Contains(page, "alice/app") {
80 t.Fatalf("bookmarks page does not list it:\n%s", page)
81 }
82
83 // Unbookmarking something that is not bookmarked says so.
84 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "unbookmark", "alice/app"); code != 3 {
85 t.Errorf("unbookmarking what was never bookmarked exited %d, want 3", code)
86 }
87}
88
89// A repository bookmarked while public and since made private drops out
90// of the listing rather than leaking its existence.
91func TestBookmarkOfRepoGonePrivate(t *testing.T) {
92 inst := startInstance(t)
93 aliceKey := inst.newKey(t, "alice")
94 bobKey := inst.newKey(t, "bob")
95 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
96 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
97 inst.ssh(t, aliceKey, "", "repo", "create", "alice/app")
98 if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "bookmark", "alice/app"); code != 0 {
99 t.Fatalf("bookmark: %s", errOut)
100 }
101 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "settings", "visibility", "alice/app", "private"); code != 0 {
102 t.Fatalf("visibility: %s", errOut)
103 }
104 if out, _, _ := inst.ssh(t, bobKey, "", "repo", "bookmarks", "--json"); strings.Contains(out, "alice/app") {
105 t.Fatalf("a repository gone private is still listed:\n%s", out)
106 }
107}
e2e/readonly_test.go +1
@@ -143,6 +143,7 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) {
143143 "release show": {"alice/app", "v1"},
144144 "release asset get": {"alice/app", "v1", "a.txt"},
145145 "notifications list": nil,
146 "repo bookmarks": nil,
146147 "search": {"app"},
147148 "mr revisions": {"alice/app", "1"},
148149 "mr range-diff": {"alice/app", "1"},
internal/control/output_test.go +2 −2
@@ -16,7 +16,7 @@ func TestNamedPayloadsRoundTrip(t *testing.T) {
1616 &Created{}, &MRCreated{}, &IssueShow{}, &MRShow{},
1717 &BuildOut{}, &JobOut{}, &ProfileOut{}, &ProfileRepo{}, &ProfileMember{},
1818 &DashboardOut{}, &DashboardItem{}, &DashboardBuild{}, &PinnedOut{},
19 &FeedOut{}, &ActivityDay{}, &DepsOut{}, &DepBehind{}, &ForkOut{},
19 &FeedOut{}, &ActivityDay{}, &DepsOut{}, &DepBehind{}, &ForkOut{}, &BookmarkOut{},
2020 &SearchResult{}, &ReviewOut{}, &CheckOut{}, &CommitOut{},
2121 }
2222 for _, p := range payloads {
@@ -40,7 +40,7 @@ func TestPayloadFieldsAreTagged(t *testing.T) {
4040 types := []any{
4141 Created{}, MRCreated{}, BuildOut{}, JobOut{}, ProfileOut{}, ProfileRepo{},
4242 ProfileMember{}, DashboardOut{}, DashboardItem{}, DashboardBuild{},
43 PinnedOut{}, FeedOut{}, ActivityDay{}, DepsOut{}, DepBehind{}, ForkOut{},
43 PinnedOut{}, FeedOut{}, ActivityDay{}, DepsOut{}, DepBehind{}, ForkOut{}, BookmarkOut{},
4444 SearchResult{}, ReviewOut{},
4545 CheckOut{}, CommitOut{}, ServerOut{},
4646 }
internal/control/repo.go +86
@@ -99,6 +99,15 @@ func init() {
9999 register(Command{Path: []string{"repo", "unpin"},
100100 Summary: "unpin a repository",
101101 Usage: "repo unpin <owner/name>", Run: runRepoUnpin})
102 register(Command{Path: []string{"repo", "bookmark"},
103 Summary: "bookmark a repository to come back to",
104 Usage: "repo bookmark <owner/name>", Run: runRepoBookmark})
105 register(Command{Path: []string{"repo", "unbookmark"},
106 Summary: "remove a bookmark",
107 Usage: "repo unbookmark <owner/name>", Run: runRepoUnbookmark})
108 register(Command{Path: []string{"repo", "bookmarks"},
109 Summary: "list the repositories you have bookmarked",
110 Usage: "repo bookmarks", ReadOnly: true, Run: runRepoBookmarks})
102111}
103112
104113const (
@@ -881,6 +890,83 @@ func setPinned(c *Ctx, args []string, pin bool) int {
881890 })
882891}
883892
893func runRepoBookmark(c *Ctx, args []string) int { return setBookmarked(c, args, true) }
894func runRepoUnbookmark(c *Ctx, args []string) int { return setBookmarked(c, args, false) }
895
896// setBookmarked mirrors setPinned. A bookmark needs only read access —
897// bookmarking is something you do to someone else's repository, which is
898// the whole point of it — and a private repository you cannot read is
899// not found, as everywhere.
900func setBookmarked(c *Ctx, args []string, on bool) int {
901 verb := "bookmark"
902 if !on {
903 verb = "unbookmark"
904 }
905 if len(args) != 1 {
906 return c.fail(protocol.ExitUsage, "usage: repo %s <owner/name>", verb)
907 }
908 repo, code := resolveRepo(c, args[0], policy.CanRead)
909 if code >= 0 {
910 return code
911 }
912 if on {
913 if err := c.Store.BookmarkRepo(c.User.ID, repo.ID); err != nil {
914 return c.fail(protocol.ExitFailure, "%v", err)
915 }
916 } else if err := c.Store.UnbookmarkRepo(c.User.ID, repo.ID); err != nil {
917 if errors.Is(err, store.ErrNotFound) {
918 return c.fail(protocol.ExitNotFound, "%s is not bookmarked", repo.Path())
919 }
920 return c.fail(protocol.ExitFailure, "%v", err)
921 }
922 return c.emit(map[string]string{verb + "ed": repo.Path()}, func(w io.Writer) {
923 fmt.Fprintf(w, "%sed %s\n", verb, repo.Path())
924 })
925}
926
927// BookmarkOut is one row of `repo bookmarks`: the repository and how many
928// people have bookmarked it.
929type BookmarkOut struct {
930 Path string `json:"path"`
931 Description string `json:"description,omitempty"`
932 Visibility string `json:"visibility"`
933 Bookmarks int `json:"bookmarks"`
934}
935
936func runRepoBookmarks(c *Ctx, args []string) int {
937 if len(args) != 0 {
938 return c.fail(protocol.ExitUsage, "usage: repo bookmarks")
939 }
940 repos, err := c.Store.ListBookmarks(c.User.ID)
941 if err != nil {
942 return c.fail(protocol.ExitFailure, "%v", err)
943 }
944 out := []BookmarkOut{}
945 for _, r := range repos {
946 // A repository bookmarked while public and since made private
947 // stays in the table and drops out of the listing, the same way
948 // it disappears from every other surface.
949 grant, err := c.Store.AccessRole(r.ID, c.User.ID)
950 if err != nil {
951 return c.fail(protocol.ExitFailure, "%v", err)
952 }
953 if !policy.CanRead(c.User, r, grant) {
954 continue
955 }
956 out = append(out, BookmarkOut{
957 Path: r.Path(),
958 Description: gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name)),
959 Visibility: r.Visibility,
960 Bookmarks: c.Store.BookmarkCount(r.ID),
961 })
962 }
963 return c.emit(out, func(w io.Writer) {
964 for _, b := range out {
965 fmt.Fprintf(w, "%s\t%d\t%s\n", b.Path, b.Bookmarks, b.Description)
966 }
967 })
968}
969
884970func runProtect(c *Ctx, args []string) int { return setProtect(c, args, true) }
885971func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) }
886972
internal/httpd/accounts.go +29
@@ -221,6 +221,35 @@ func (s *Server) pinToggle(w http.ResponseWriter, r *http.Request, u store.User)
221221 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
222222}
223223
224// bookmarkToggle saves or unsaves a repository for the viewer. Read
225// access is all a bookmark needs — it is something you do to someone
226// else's repository — and repoForUser 404s a private one either way.
227func (s *Server) bookmarkToggle(w http.ResponseWriter, r *http.Request, u store.User) {
228 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
229 if !ok {
230 return
231 }
232 verb := "bookmark"
233 if s.st.IsBookmarked(u.ID, repo.ID) {
234 verb = "unbookmark"
235 }
236 if _, msg, ok := s.runControl(u, []string{"repo", verb, repo.Path()}); !ok {
237 s.setFlash(w, msg)
238 }
239 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
240}
241
242// bookmarksPage lists what the viewer has saved.
243func (s *Server) bookmarksPage(w http.ResponseWriter, r *http.Request, u store.User) {
244 var rows []control.BookmarkOut
245 s.runControlInto(u, []string{"repo", "bookmarks"}, &rows)
246 s.render(w, "bookmarks.html", struct {
247 basePage
248 Tab string
249 Bookmarks []control.BookmarkOut
250 }{s.baseFor(u), "bookmarks", rows})
251}
252
224253// forkSubmit forks the repository under the viewer's account and sends
225254// them to it. The command decides everything that matters — read access,
226255// quota, name collisions — so a refusal comes back as its own message on
internal/httpd/facts.go +2
@@ -19,6 +19,7 @@ type repoFacts struct {
1919 License string
2020 Release string // newest release tag, if any
2121 Build string // latest build status on the default branch
22 Bookmarks int // how many people saved it, the popularity signal (#146)
2223}
2324
2425type factContributor struct {
@@ -88,5 +89,6 @@ func (s *Server) factsFor(p repoPage) repoFacts {
8889 if builds, err := s.st.ListBuilds(p.Repo.ID, 1); err == nil && len(builds) > 0 {
8990 f.Build = builds[0].Status
9091 }
92 f.Bookmarks = s.st.BookmarkCount(p.Repo.ID)
9193 return f
9294}
internal/httpd/routes.go +3
@@ -152,6 +152,9 @@ func (s *Server) Routes() []Route {
152152 Handler: s.checkOrigin(s.requireUser(s.releaseSubmit))},
153153 Route{Method: "POST", Pattern: "/{owner}/{repo}/labels", Mutating: true,
154154 Handler: s.checkOrigin(s.requireUser(s.labelSubmit))},
155 Route{Method: "GET", Pattern: "/bookmarks", Handler: s.requireUser(s.bookmarksPage)},
156 Route{Method: "POST", Pattern: "/{owner}/{repo}/bookmark", Mutating: true,
157 Handler: s.checkOrigin(s.requireUser(s.bookmarkToggle))},
155158 Route{Method: "POST", Pattern: "/{owner}/{repo}/fork", Mutating: true,
156159 Handler: s.checkOrigin(s.requireUser(s.forkSubmit))},
157160 Route{Method: "POST", Pattern: "/{owner}/{repo}/builds", Mutating: true,
internal/httpd/web.go +4 −1
@@ -247,6 +247,7 @@ type repoPage struct {
247247 Tab string // active tab in the repo header
248248 Topics []string
249249 Pinned bool // by the viewer
250 Marked bool // bookmarked by the viewer
250251 Watch string // the viewer's watch state: watching, muted, or ""
251252 HasWiki bool
252253 Host string
@@ -307,9 +308,10 @@ func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (re
307308 ref = repo.DefaultBranch
308309 }
309310 topics, _ := s.st.ListTopics(repo.ID)
310 pinned, watch := false, ""
311 pinned, marked, watch := false, false, ""
311312 if viewer.ID != 0 {
312313 pinned = s.st.IsPinned(viewer.ID, repo.ID)
314 marked = s.st.IsBookmarked(viewer.ID, repo.ID)
313315 watch = s.st.RepoWatchState(repo.ID, viewer.ID)
314316 }
315317 canAdmin := viewer.ID != 0 && policy.CanAdmin(viewer, repo, grant)
@@ -332,6 +334,7 @@ func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (re
332334 CanAdmin: canAdmin,
333335 Mirrors: mirrors,
334336 Pinned: pinned,
337 Marked: marked,
335338 Watch: watch,
336339 HasWiki: s.hasWiki(repo),
337340 Host: s.cfg.SiteHost(),
internal/policy/names.go +1
@@ -14,6 +14,7 @@ var reservedNames = map[string]bool{
1414 "admin": true,
1515 "api": true,
1616 "archive": true,
17 "bookmarks": true,
1718 "explore": true,
1819 "favicon.svg": true,
1920 "gitbay": true, // vanity go-import path on gitbay.org
internal/store/bookmarks.go added +62
@@ -0,0 +1,62 @@
1package store
2
3// Bookmarks are public and counted; pins are private quick access. The
4// two are deliberately separate tables rather than a flag on one, because
5// they answer different questions: "what am I working on" and "what is
6// worth coming back to" (#146).
7
8func (s *Store) BookmarkRepo(userID, repoID int64) error {
9 _, err := s.DB.Exec(
10 "INSERT INTO repo_bookmarks (user_id, repo_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
11 userID, repoID)
12 return err
13}
14
15func (s *Store) UnbookmarkRepo(userID, repoID int64) error {
16 res, err := s.DB.Exec(
17 "DELETE FROM repo_bookmarks WHERE user_id = ? AND repo_id = ?", userID, repoID)
18 if err != nil {
19 return err
20 }
21 if n, _ := res.RowsAffected(); n == 0 {
22 return ErrNotFound
23 }
24 return nil
25}
26
27func (s *Store) IsBookmarked(userID, repoID int64) bool {
28 var n int
29 s.DB.QueryRow("SELECT COUNT(*) FROM repo_bookmarks WHERE user_id = ? AND repo_id = ?",
30 userID, repoID).Scan(&n)
31 return n > 0
32}
33
34// BookmarkCount is how many people have bookmarked a repository. It is
35// public: the count is the point, and it names nobody.
36func (s *Store) BookmarkCount(repoID int64) int {
37 var n int
38 s.DB.QueryRow("SELECT COUNT(*) FROM repo_bookmarks WHERE repo_id = ?", repoID).Scan(&n)
39 return n
40}
41
42// ListBookmarks returns one user's bookmarked repositories, newest first.
43// The caller filters by what the viewer may see: a bookmarked repository
44// can since have gone private.
45func (s *Store) ListBookmarks(userID int64) ([]Repo, error) {
46 rows, err := s.DB.Query(repoSelect+`
47 JOIN repo_bookmarks b ON b.repo_id = r.id
48 WHERE b.user_id = ? ORDER BY b.bookmarked_at DESC`, userID)
49 if err != nil {
50 return nil, err
51 }
52 defer rows.Close()
53 var out []Repo
54 for rows.Next() {
55 r, err := scanRepo(rows)
56 if err != nil {
57 return nil, err
58 }
59 out = append(out, r)
60 }
61 return out, rows.Err()
62}
internal/store/migrations/0043_bookmarks.down.sql added +1
@@ -0,0 +1 @@
1DROP TABLE repo_bookmarks;
internal/store/migrations/0043_bookmarks.up.sql added +10
@@ -0,0 +1,10 @@
1-- Bookmarks are a public "saved for later", separate from pins: a pin is
2-- private quick access to your own work, a bookmark says a repository is
3-- worth coming back to and its count is a signal of that.
4CREATE TABLE repo_bookmarks (
5 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
6 repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
7 bookmarked_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
8 PRIMARY KEY (user_id, repo_id)
9);
10CREATE INDEX repo_bookmarks_by_repo ON repo_bookmarks (repo_id);
internal/web/templates/bookmarks.html added +16
@@ -0,0 +1,16 @@
1{{define "title"}}bookmarks · {{.Site}}{{end}}
2{{define "content"}}
3<h1>Bookmarks</h1>
4<p class="meta">Repositories you saved to come back to. A pin is private quick
5access to your own work; a bookmark says something is worth returning to, and
6its count is public.</p>
7<ul class="repolist">
8{{range .Bookmarks}}<li>
9 <p class="title"><a href="/{{.Path}}">{{.Path}}</a>
10 {{if eq .Visibility "private"}}<span class="chip">Private</span>{{end}}
11 <span class="count">{{.Bookmarks}}</span></p>
12 {{if .Description}}<p class="desc">{{.Description}}</p>{{end}}
13</li>
14{{else}}<li class="empty">nothing bookmarked yet — open a repository and press Bookmark</li>{{end}}
15</ul>
16{{end}}
internal/web/templates/layout.html +2
@@ -22,6 +22,7 @@
2222 {{if .Viewer}}<li><a href="/">Dashboard</a></li>{{end}}
2323 <li><a {{if eq (str . "Tab") "explore"}}aria-current="page" {{end}}href="/explore">Explore</a></li>
2424 {{if .Viewer}}<li><a {{if eq (str . "Tab") "notifications"}}aria-current="page" {{end}}href="/notifications">Notifications{{with .Rail.Unread}} <b class="count">{{.}}</b>{{end}}</a></li>
25 <li><a {{if eq (str . "Tab") "bookmarks"}}aria-current="page" {{end}}href="/bookmarks">Bookmarks</a></li>
2526 <li><a href="/new">New repository</a></li>{{end}}
2627 </ul>
2728 {{with .Rail.Pinned}}
@@ -65,6 +66,7 @@
6566 <span class="grow"></span>
6667 {{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}}"><span aria-hidden="true">{{if field $ "Pinned"}}★{{else}}☆{{end}}</span> {{if field $ "Pinned"}}Pinned{{else}}Pin{{end}}</button></form>
6768 <form method="post" action="/{{.OwnerName}}/{{.Name}}/watch" class="inline"><button type="submit" class="btn" aria-pressed="{{if eq (str $ "Watch") "watching"}}true{{else}}false{{end}}">{{if eq (str $ "Watch") "watching"}}Watching{{else}}Watch{{end}}</button></form>
69 <form method="post" action="/{{.OwnerName}}/{{.Name}}/bookmark" class="inline"><button type="submit" class="btn" aria-pressed="{{if field $ "Marked"}}true{{else}}false{{end}}">{{if field $ "Marked"}}Bookmarked{{else}}Bookmark{{end}}</button></form>
6870 <form method="post" action="/{{.OwnerName}}/{{.Name}}/fork" class="inline"><button type="submit" class="btn">Fork</button></form>{{end}}
6971 </div>
7072 {{$top := topTab (str $ "Tab")}}
internal/web/templates/tree.html +1
@@ -16,6 +16,7 @@
1616 <a href="{{$r}}/log/{{.Ref}}"><strong>{{.Facts.Commits}}</strong> commit{{if ne .Facts.Commits 1}}s{{end}}</a>
1717 <a href="{{$r}}/refs"><strong>{{.Facts.Branches}}</strong> branch{{if ne .Facts.Branches 1}}es{{end}}</a>
1818 <a href="{{$r}}/refs"><strong>{{.Facts.Tags}}</strong> tag{{if ne .Facts.Tags 1}}s{{end}}</a>
19 {{if .Facts.Bookmarks}}<span><strong>{{.Facts.Bookmarks}}</strong> bookmark{{if ne .Facts.Bookmarks 1}}s{{end}}</span>{{end}}
1920 {{with .Facts.License}}<span class="fact">{{.}}</span>{{end}}
2021 {{with .Facts.Release}}<a href="{{$r}}/releases">latest <strong>{{.}}</strong></a>{{end}}
2122 {{with .Facts.Build}}<a href="{{$r}}/builds">build <span class="badge badge-{{.}}">{{.}}</span></a>{{end}}