~gitpocket/gitpocket

feat(basin): sourcehut style layout, settings tabs, display name, versioned avatars

commit
224ff777622d9e61ff4507c5a50fb2bc8f3635ef browse tree
parent
b9073efe11e630baa39bc91afee48452eb70a686
author
james <j@cpl0.zip>
signature
none
date
2026-11-10 05:38 UTC

22 files changed, +421 −234

git.go modified

@@ -186,6 +186,23 @@ func (g Git) Tree(commit, dir string) ([]Entry, error) {
186186 return entries, nil
187187 }
188188
189+func (g Git) Entry(commit, filePath string) *Entry {
190+ if filePath == "" {
191+ return nil
192+ }
193+ dir, name := path.Split(filePath)
194+ entries, err := g.Tree(commit, strings.TrimSuffix(dir, "/"))
195+ if err != nil {
196+ return nil
197+ }
198+ for i := range entries {
199+ if entries[i].Name == name {
200+ return &entries[i]
201+ }
202+ }
203+ return nil
204+}
205+
189206 func (g Git) LastCommits(commit, dir string, names []string) map[string]Commit {
190207 want := map[string]bool{}
191208 for _, n := range names {

static/logo.svg deleted

@@ -1 +0,0 @@
1-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32"><rect width="32" height="32" rx="7" fill="#212529"/><g fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M11 9v14"/><path d="M11 16c0-3.5 2.5-5.5 7-5.5h2"/></g><g fill="#fff"><circle cx="11" cy="8" r="2.6"/><circle cx="11" cy="24" r="2.6"/><circle cx="21" cy="10.5" r="2.6"/></g></svg>

static/style.css modified

@@ -1,97 +1,122 @@
11 @font-face{font-family:Inter;font-style:normal;font-weight:100 900;font-display:swap;src:url(/static/fonts/inter.woff2) format('woff2')}
22 @font-face{font-family:Mono;font-style:normal;font-weight:400;font-display:swap;src:url(/static/fonts/mono.woff2) format('woff2')}
3-:root{--bg:#fff;--fg:#212529;--muted:#6c757d;--line:#dee2e6;--soft:#f8f9fa;--link:#0b5ab0;--add:#1a7f37;--del:#c0392b;--addbg:#e6ffec;--delbg:#ffebe9;--code:#f6f8fa;--accent:#212529}
3+:root{--fg:#212529;--muted:#6c757d;--line:#dee2e6;--soft:#f8f9fa;--bar:#ddd;--link:#0b5ab0;--primary:#0049d1;--add:#1a7f37;--del:#c0392b;--addbg:#e6ffec;--delbg:#ffebe9;--code:#f8f9fa}
44 *{box-sizing:border-box}
5-html{font:15px/1.5 Inter,system-ui,sans-serif;font-feature-settings:"cv05","ss03";background:var(--bg);color:var(--fg)}
5+html{font:15px/1.5 Inter,system-ui,sans-serif;font-feature-settings:"cv05","ss03";background:#fff;color:var(--fg)}
66 body{margin:0}
77 a{color:var(--link);text-decoration:none}
88 a:hover{text-decoration:underline}
9-code,pre,.hash,.tree .mode,.blob,.hunk,.code,textarea{font-family:Mono,ui-monospace,Menlo,Consolas,monospace;font-size:13px}
10-pre{background:var(--code);padding:.6rem .8rem;overflow-x:auto;border:1px solid var(--line);border-radius:3px}
11-h1{font-size:1.35rem;margin:0 0 .4rem;font-weight:600}
12-h2{font-size:1.1rem;margin:1.4rem 0 .6rem;font-weight:600}
13-h3{font-size:.95rem;margin:1rem 0 .4rem;font-weight:600}
14-h2:first-child,h3:first-child{margin-top:0}
15-.muted,.when,.who{color:var(--muted)}
16-.tag{font-size:.75rem;border:1px solid var(--line);border-radius:3px;padding:0 .3rem;color:var(--muted);vertical-align:middle}
17-.error{color:var(--del);border:1px solid var(--del);border-radius:3px;padding:.4rem .6rem}
18-.notice{border:1px solid var(--add);border-radius:3px;padding:.4rem .6rem;background:var(--addbg)}
19-header{background:var(--soft);border-bottom:1px solid var(--line)}
20-nav.site{max-width:1100px;margin:0 auto;padding:.55rem 1rem;display:flex;gap:1.2rem;align-items:center;flex-wrap:wrap}
21-nav.site .brand{font-weight:700;color:var(--fg);display:inline-flex;align-items:center;gap:.5rem}
22-nav.site .brand svg{display:block}
23-nav.site .spacer{flex:1}
9+code,pre,.hash,.mode,.blob,.hunk,.code,textarea{font-family:Mono,ui-monospace,Menlo,Consolas,monospace;font-size:13px}
10+pre{background:var(--code);padding:.6rem .8rem;overflow-x:auto;border:1px solid var(--line)}
11+pre.plain{background:none;border:0;padding:0}
12+h1{font-size:1.6rem;font-weight:500;margin:0 0 .2rem}
13+h2{font-size:1.4rem;font-weight:500;margin:0}
14+h3{font-size:1.15rem;font-weight:500;margin:0 0 .6rem;padding-bottom:.3rem;border-bottom:1px solid var(--line)}
15+h3.rule{margin-top:.5rem}
16+h4{font-size:1rem;font-weight:500;margin:1rem 0 .4rem}
17+h4 a{color:var(--fg)}
18+.muted,.when{color:var(--muted)}
19+.small{font-size:.85rem}
20+.tag{font-size:.75rem;border:1px solid var(--line);padding:0 .3rem;color:var(--muted);vertical-align:middle}
21+.error{color:var(--del);border:1px solid var(--del);padding:.4rem .6rem;background:#fff}
22+.notice{border:1px solid var(--add);padding:.4rem .6rem;background:var(--addbg)}
23+.inner,nav.site,nav.subtabs,main{max-width:1140px;margin:0 auto;padding-left:1rem;padding-right:1rem}
24+header{border-bottom:1px solid var(--bar);margin-bottom:1rem}
25+nav.site{display:flex;gap:1rem;align-items:center;padding-top:.5rem;padding-bottom:.5rem;flex-wrap:wrap}
26+nav.site .brand{font-size:1.25rem;color:var(--fg);margin-right:.5rem}
2427 nav.site a{color:var(--fg)}
28+nav.site .spacer{flex:1}
2529 nav.site form{display:inline}
26-nav.site button{background:none;border:0;color:var(--fg);cursor:pointer;font:inherit;padding:0}
27-.repohead{max-width:1100px;margin:0 auto;padding:1rem 1rem 0}
28-.repohead h1{margin:0;font-size:1.3rem}
29-.repohead h1 a{color:var(--fg)}
30-.repohead .desc{margin:.2rem 0 .6rem;color:var(--muted)}
31-nav.tabs{display:flex;gap:0;margin-top:.5rem}
32-nav.tabs a{color:var(--fg);padding:.4rem .9rem;border:1px solid transparent;border-bottom:0;margin-bottom:-1px}
33-nav.tabs a:hover{text-decoration:none;background:#fff;border-color:var(--line)}
34-main{max-width:1100px;margin:0 auto;padding:1.2rem 1rem 3rem}
35-.hero{padding:1rem 0 1.5rem;border-bottom:1px solid var(--line);margin-bottom:1rem;max-width:44rem}
36-.hero h1{font-size:1.8rem;display:flex;align-items:center;gap:.6rem}
37-.hero h1 img{width:40px;height:40px}
38-.hero p{margin:.4rem 0}
39-.row{display:flex;justify-content:space-between;align-items:baseline}
40-.row h2{margin-top:0}
41-.button{display:inline-block;background:var(--accent);color:#fff;padding:.4rem .9rem;border-radius:3px;font-size:.9rem}
42-.button:hover{text-decoration:none;opacity:.9}
43-.button.secondary{background:#fff;color:var(--fg);border:1px solid var(--line)}
44-ul.repos,ul.commits,ul.side,ul.files,ul.facts{list-style:none;padding:0;margin:0}
45-ul.repos li{display:grid;grid-template-columns:minmax(10rem,1fr) 2fr auto;gap:1rem;padding:.5rem 0;border-bottom:1px solid var(--line);align-items:baseline}
46-ul.repos .muted{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
47-ul.repos .when{white-space:nowrap}
48-ul.commits li{display:grid;grid-template-columns:7em 1fr auto auto;gap:1rem;padding:.35rem 0;border-bottom:1px solid var(--line)}
49-ul.commits .subject{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
50-ul.side li{padding:.3rem 0;border-bottom:1px solid var(--line)}
51-ul.side li:last-child{border-bottom:0}
52-ul.side .muted{font-size:.85rem}
53-.columns{display:grid;grid-template-columns:minmax(0,1fr) 20rem;gap:2.5rem;align-items:start}
54-.columns.user{grid-template-columns:16rem minmax(0,1fr)}
55-aside h3 a{color:var(--fg)}
56-.profile .avatar{display:block;width:16rem;height:16rem;margin-bottom:.8rem}
57-.avatar{width:128px;height:128px;object-fit:cover;border:1px solid var(--line);border-radius:3px;background:var(--soft)}
58-.bio{white-space:pre-wrap;margin:.3rem 0 .6rem}
59-ul.facts li{padding:.15rem 0}
60-@media(max-width:800px){.columns,.columns.user{grid-template-columns:1fr}ul.commits li{grid-template-columns:7em 1fr}ul.commits .who,ul.commits .when{display:none}ul.repos li{grid-template-columns:1fr auto}ul.repos .muted{grid-column:1/-1}.profile .avatar{width:128px;height:128px}}
30+nav.site button{background:none;border:0;color:var(--link);cursor:pointer;font:inherit;padding:0;text-decoration:underline}
31+nav.subtabs{display:flex;gap:0;padding-top:.2rem}
32+nav.subtabs a,nav.tabs a{color:var(--muted);padding:.3rem .9rem}
33+nav.subtabs a.active{background:#e9ecef;color:var(--fg)}
34+nav.subtabs a:hover,nav.tabs a:hover{text-decoration:none;color:var(--fg)}
35+.repohead{margin-top:-1rem}
36+.repohead h2{padding:.4rem 0 .3rem}
37+.tabbar{background:var(--bar)}
38+nav.tabs{display:flex}
39+nav.tabs a{padding:.25rem .9rem}
40+nav.tabs a.active{background:#fff;color:var(--fg)}
41+.descbar{background:#e9ecef;margin-bottom:1rem}
42+.descbar+main,.repohead+main{margin-top:1rem}
43+.descbar .inner{padding:.3rem 1rem}
44+.repohead+main{margin-top:0}
45+main{padding-bottom:3rem}
46+.welcome{max-width:50rem;margin:0 auto 1.5rem;padding:0 1rem}
47+.welcome p{margin:.3rem 0 0}
48+.cards{display:grid;grid-template-columns:repeat(3,1fr);gap:1.5rem;margin-bottom:1.5rem}
49+.cards.commits{grid-template-columns:repeat(3,1fr);gap:1rem}
50+.cards.commits .panel{margin:0}
51+.panel{background:var(--soft);padding:.8rem 1rem;margin-bottom:1.5rem}
52+.panel.narrow{max-width:34rem;margin:1rem auto}
53+.panel .btn{margin-top:.6rem}
54+.panel p{margin:.4rem 0 .6rem}
55+.btn{display:inline-block;font:inherit;font-size:.9rem;background:#e9ecef;color:var(--fg);border:1px solid #ccc;padding:.25rem .7rem;cursor:pointer;line-height:1.4}
56+.btn:hover{text-decoration:none;background:#dde1e5}
57+.btn.primary{background:var(--primary);color:#fff;border-color:var(--primary)}
58+.btn.primary:hover{background:#003bb0}
59+.btn.danger{background:#fff;color:var(--del);border-color:var(--del)}
60+.repos{display:flex;flex-direction:column;gap:.6rem}
61+.repo{border:1px solid var(--line);padding:.4rem .6rem;background:#fff}
62+.repo>a{font-size:1.05rem}
63+.columns{display:grid;grid-template-columns:minmax(0,1fr) 18rem;gap:2.5rem;align-items:start}
64+.columns.user{grid-template-columns:19rem minmax(0,1fr)}
65+.profile{border-left:3px solid var(--line);padding-left:.8rem}
66+.profile .avatar{display:block;width:160px;height:160px;margin-bottom:.6rem}
67+.profile .handle{margin:0 0 .3rem;color:var(--muted)}
68+.profile .links{margin:.2rem 0 .6rem}
69+.avatar{object-fit:cover;border:1px solid var(--line);background:var(--soft)}
70+.bio{margin-bottom:.6rem}
71+.bio p{margin:.3rem 0}
72+ul.side,ul.files{list-style:none;padding:0;margin:0}
73+ul.side li{padding:.15rem 0}
6174 dl.clone{margin:0 0 1rem}
6275 dl.clone dt{color:var(--muted);font-size:.85rem}
6376 dl.clone dd{margin:0 0 .4rem;overflow-wrap:anywhere}
6477 dl.meta{display:grid;grid-template-columns:6em 1fr;gap:.15rem .5rem;margin:0 0 .8rem}
6578 dl.meta dt{color:var(--muted)}
6679 dl.meta dd{margin:0;overflow-wrap:anywhere}
80+.events{display:flex;flex-direction:column}
81+.event{padding:.5rem 0;border-bottom:1px solid var(--line)}
82+.event .subject{color:var(--fg)}
83+.event pre.body{margin:.4rem 0 0}
84+.filebar{display:flex;gap:1.2rem;align-items:baseline;border-bottom:1px solid var(--line);padding:0 0 .3rem;margin-bottom:.6rem;flex-wrap:wrap}
85+.filebar .spacer{flex:1}
86+.filebar a.active{color:var(--fg);border-bottom:2px solid var(--fg);padding-bottom:.2rem}
87+.filebar a{color:var(--muted)}
88+.filebar .crumbs a{color:var(--link)}
89+.filebar .lastcommit{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:40rem}
6790 .readme img{max-width:100%}
6891 .readme table{border-collapse:collapse}
6992 .readme td,.readme th{border:1px solid var(--line);padding:.2rem .5rem}
70-.readme code{background:var(--code);padding:.05rem .25rem;border-radius:3px}
93+.readme code{background:var(--code);padding:.05rem .25rem}
7194 .readme pre code{padding:0;background:none}
72-.readme h1{font-size:1.5rem;margin:.6rem 0 .6rem}
73-.readme h2{font-size:1.2rem;border-bottom:1px solid var(--line);padding-bottom:.2rem}
95+.readme h1{font-size:1.5rem;margin:.6rem 0}
96+.readme h2{font-size:1.25rem;margin:1rem 0 .5rem;border-bottom:1px solid var(--line);padding-bottom:.2rem}
97+.readme h3{border:0;font-size:1.1rem;margin:.8rem 0 .3rem}
7498 .readme blockquote{border-left:3px solid var(--line);margin:0;padding:0 1rem;color:var(--muted)}
7599 table{border-collapse:collapse}
76100 table.tree{width:100%}
77-table.tree td{padding:.3rem .8rem .3rem 0;border-bottom:1px solid var(--line);white-space:nowrap}
101+table.tree td{padding:.25rem .8rem .25rem 0;white-space:nowrap}
78102 table.tree .mode{color:var(--muted)}
79103 table.tree .size{color:var(--muted);text-align:right}
80104 table.tree .last{max-width:30rem;overflow:hidden;text-overflow:ellipsis;width:100%}
81105 table.tree .when{text-align:right}
82106 table.refs td,table.keys td{padding:.3rem 1rem .3rem 0;border-bottom:1px solid var(--line)}
83-table.blob,.code table{width:100%;background:var(--code);border:1px solid var(--line);border-radius:3px}
84-table.hunk{width:100%;background:#fff;border:1px solid var(--line);border-radius:3px}
107+.code{border-left:1px solid var(--line)}
108+table.blob,.code table{width:100%}
85109 table.blob td,table.hunk td,.code td{padding:0 .5rem;white-space:pre;vertical-align:top}
86110 td.n{text-align:right;color:var(--muted);user-select:none;width:1%}
87-td.n a{color:var(--muted)}
111+td.n a{color:var(--link)}
88112 td.l{white-space:pre-wrap;overflow-wrap:anywhere;width:100%}
89113 tr:target td{background:#fff8c5}
90114 .code pre{background:none;border:0;padding:0;margin:0}
91-.code .lntd:first-child{width:1%;user-select:none}
92-.code .lnt,.code .ln{color:var(--muted);margin-right:0;padding:0 .4rem 0 .2rem}
93-.code .lntd a{color:var(--muted)}
115+.code .lntd:first-child{width:1%;user-select:none;border-right:1px solid var(--line)}
116+.code .lnt,.code .ln{color:var(--link);margin-right:0;padding:0 .5rem 0 .3rem}
117+.code .lntd a{color:var(--link)}
94118 .code .line:target,.code .line:target .cl{background:#fff8c5}
119+table.hunk{width:100%;border:1px solid var(--line)}
95120 .hunk .head td{color:var(--muted);background:var(--soft)}
96121 .hunk tr.add td{background:var(--addbg)}
97122 .hunk tr.del td{background:var(--delbg)}
@@ -101,15 +126,23 @@ tr:target td{background:#fff8c5}
101126 .diff{margin-bottom:1.5rem}
102127 .files{margin-bottom:1.2rem}
103128 .files li{padding:.1rem 0}
104-.crumbs{margin:0 0 .8rem}
105-form.stack{display:flex;flex-direction:column;gap:.7rem;max-width:32rem;margin-bottom:1rem}
106-form.stack label{display:flex;flex-direction:column;gap:.25rem;color:var(--muted);font-size:.9rem}
129+form.stack{display:flex;flex-direction:column;gap:.7rem;max-width:34rem}
130+form.stack.wide{max-width:52rem}
131+form.stack label{display:flex;flex-direction:column;gap:.25rem}
107132 form.stack label.check{flex-direction:row;align-items:center;gap:.5rem}
108-input,textarea{font:inherit;color:var(--fg);background:#fff;border:1px solid var(--line);border-radius:3px;padding:.4rem .55rem}
133+form.stack small{color:var(--muted)}
134+.pair{display:grid;grid-template-columns:1fr 1fr;gap:1rem}
135+input,textarea{font:inherit;color:var(--fg);background:#fff;border:1px solid #ced4da;padding:.35rem .55rem}
136+input:disabled{background:#e9ecef}
109137 input:focus,textarea:focus{outline:2px solid #b6d4fe;border-color:#86b7fe}
110138 input[type=file]{border:0;padding:0}
111-button{font:inherit;background:var(--accent);color:#fff;border:0;border-radius:3px;padding:.4rem .9rem;cursor:pointer;align-self:flex-start}
112-button.danger{background:#fff;color:var(--del);border:1px solid var(--del);padding:.15rem .5rem}
113-pre.body{white-space:pre-wrap}
139+.actions{display:flex;justify-content:flex-end;gap:.5rem}
140+.actions.left{justify-content:flex-start}
141+.avatarform{display:grid;grid-template-columns:160px 1fr;gap:1.2rem;margin-bottom:1.2rem;align-items:start}
142+.avatarform .avatarbox{width:160px;height:160px;border:1px solid var(--line);background:#fff}
143+.avatarform .avatar{width:158px;height:158px;border:0}
144+.avatarform label{display:block;margin-bottom:.3rem}
145+.avatarform small{display:block;color:var(--muted);margin-bottom:.5rem}
114146 .cropper canvas{display:block;max-width:100%;border:1px solid var(--line);cursor:move;touch-action:none}
115147 .cropper input[type=range]{width:100%;padding:0}
148+@media(max-width:800px){.cards,.cards.commits,.columns,.columns.user,.pair,.avatarform{grid-template-columns:1fr}.profile .avatar{width:120px;height:120px}}

store.go modified

@@ -64,14 +64,16 @@ var migrations = []string{
6464 `alter table users add column workplace text not null default ''`,
6565 `alter table users add column website text not null default ''`,
6666 `alter table users add column avatar integer not null default 0`,
67+ `alter table users add column display text not null default ''`,
6768 }
6869
6970 type Profile struct {
71+ Display string
7072 Bio string
7173 Location string
7274 Workplace string
7375 Website string
74- Avatar bool
76+ Avatar int64
7577 }
7678
7779 var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]{0,38}$`)
@@ -244,6 +246,10 @@ func (s *Store) DeleteSession(id string) {
244246 s.db.Exec(`delete from sessions where id = ?`, id)
245247 }
246248
249+func (s *Store) DeleteSessions(user int64) {
250+ s.db.Exec(`delete from sessions where user = ?`, user)
251+}
252+
247253 func (s *Store) AddKey(user int64, title, public string) error {
248254 pub, comment, _, _, err := ssh.ParseAuthorizedKey([]byte(public))
249255 if err != nil {
@@ -429,9 +435,7 @@ func (s *Store) DeleteRepo(id int64) error {
429435
430436 func (s *Store) Profile(user int64) Profile {
431437 var p Profile
432- var avatar int
433- s.db.QueryRow(`select bio, location, workplace, website, avatar from users where id = ?`, user).Scan(&p.Bio, &p.Location, &p.Workplace, &p.Website, &avatar)
434- p.Avatar = avatar != 0
438+ s.db.QueryRow(`select display, bio, location, workplace, website, avatar from users where id = ?`, user).Scan(&p.Display, &p.Bio, &p.Location, &p.Workplace, &p.Website, &p.Avatar)
435439 return p
436440 }
437441
@@ -439,12 +443,12 @@ func (s *Store) UpdateProfile(user int64, p Profile) error {
439443 if p.Website != "" && !strings.HasPrefix(p.Website, "https://") && !strings.HasPrefix(p.Website, "http://") {
440444 p.Website = "https://" + p.Website
441445 }
442- _, err := s.db.Exec(`update users set bio = ?, location = ?, workplace = ?, website = ? where id = ?`, p.Bio, p.Location, p.Workplace, p.Website, user)
446+ _, err := s.db.Exec(`update users set display = ?, bio = ?, location = ?, workplace = ?, website = ? where id = ?`, p.Display, p.Bio, p.Location, p.Workplace, p.Website, user)
443447 return err
444448 }
445449
446-func (s *Store) SetAvatar(user int64, has bool) error {
447- _, err := s.db.Exec(`update users set avatar = ? where id = ?`, has, user)
450+func (s *Store) SetAvatar(user int64, version int64) error {
451+ _, err := s.db.Exec(`update users set avatar = ? where id = ?`, version, user)
448452 return err
449453 }
450454

templates/blob.html modified

@@ -1,16 +1,21 @@
11 {{define "content"}}
22 {{$p := .Data.Page}}
3-{{template "crumbs" .Data}}
4-<p class="muted">{{size .Data.Size}} · <a href="/{{$p.Repo.Path}}/raw/{{$p.Ref}}/{{.Data.Path}}">Raw</a>{{if .Data.Markdown}} · <a href="?source=1">Source</a>{{end}} · <a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}?path={{.Data.Path}}">History</a></p>
5-{{if .Data.Binary}}<p class="muted">Binary file, see raw.</p>
6-{{else if .Data.Large}}<p class="muted">File too large to display, see raw.</p>
7-{{else if .Data.Markdown}}<article class="readme">{{.Data.Markdown}}</article>
3+{{template "filebar" .Data}}
4+{{if .Data.Binary}}<p class="muted">Binary file, <a href="/{{$p.Repo.Path}}/raw/{{$p.Ref}}/{{.Data.Path}}">view raw</a>.</p>
5+{{else if .Data.Large}}<p class="muted">File too large to display, <a href="/{{$p.Repo.Path}}/raw/{{$p.Ref}}/{{.Data.Path}}">view raw</a>.</p>
6+{{else if .Data.Markdown}}<p class="muted small"><a href="?source=1">View source</a></p><article class="readme">{{.Data.Markdown}}</article>
87 {{else if .Data.Highlighted}}<div class="code">{{.Data.Highlighted}}</div>
98 {{else}}
10-<table class="blob">{{range $i, $l := lines .Data.Content}}<tr><td class="n" id="L{{inc $i}}"><a href="#L{{inc $i}}">{{inc $i}}</a></td><td class="l">{{$l}}</td></tr>{{end}}</table>
9+<div class="code"><table class="blob">{{range $i, $l := lines .Data.Content}}<tr><td class="n" id="L{{inc $i}}"><a href="#L{{inc $i}}">{{inc $i}}</a></td><td class="l">{{$l}}</td></tr>{{end}}</table></div>
1110 {{end}}
1211 {{end}}
13-{{define "crumbs"}}
12+{{define "filebar"}}
1413 {{$p := .Page}}
15-<p class="crumbs"><a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/">{{$p.Repo.Name}}</a>{{range $i, $c := .Crumbs}}/<a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{join $.Crumbs (inc $i)}}{{if lt (inc $i) (len $.Crumbs)}}/{{end}}">{{$c}}</a>{{end}} <span class="muted">at <a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}">{{$p.Ref}}</a></span></p>
14+<div class="filebar">
15+<span class="crumbs"><a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/">{{$p.Repo.Name}}</a>{{range $i, $c := .Crumbs}}/<a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{join $.Crumbs (inc $i)}}{{if lt (inc $i) (len $.Crumbs)}}/{{end}}">{{$c}}</a>{{end}}</span>
16+{{with .Entry}}<span class="mode">{{perm .Mode}}</span>{{if eq .Type "blob"}}<span class="muted">{{size .Size}}</span>{{end}}{{end}}
17+<a class="active" href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{.Path}}">View</a><a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}?path={{.Path}}">Log</a><a href="/{{$p.Repo.Path}}/raw/{{$p.Ref}}/{{.Path}}">View raw</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/{{.Path}}">Permalink</a>
18+<span class="spacer"></span>
19+{{with .LastCommit}}<span class="lastcommit"><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a> — {{.Subject}} <span class="muted">{{ago .Time}}</span></span>{{end}}
20+</div>
1621 {{end}}

templates/commit.html modified

@@ -1,19 +1,21 @@
11 {{define "content"}}
22 {{$p := .Data.Page}}
33 {{$c := .Data.Commit}}
4-<h2>{{$c.Subject}}</h2>
4+<div class="panel">
5+<h3>{{$c.Subject}}</h3>
56 <dl class="meta">
6-<dt>Commit</dt><dd><code>{{$c.Hash}}</code> <a href="/{{$p.Repo.Path}}/tree/{{$c.Hash}}/">browse tree</a></dd>
7-{{range $c.Parents}}<dt>Parent</dt><dd><a href="/{{$p.Repo.Path}}/commit/{{.}}"><code>{{.}}</code></a></dd>{{end}}
8-<dt>Author</dt><dd>{{$c.Author}} &lt;{{$c.Email}}&gt;</dd>
9-<dt>Date</dt><dd>{{date $c.Time}} UTC</dd>
7+<dt>commit</dt><dd><code>{{$c.Hash}}</code> <a href="/{{$p.Repo.Path}}/tree/{{$c.Hash}}/">browse tree</a></dd>
8+{{range $c.Parents}}<dt>parent</dt><dd><a href="/{{$p.Repo.Path}}/commit/{{.}}"><code>{{.}}</code></a></dd>{{end}}
9+<dt>author</dt><dd>{{$c.Author}} &lt;{{$c.Email}}&gt;</dd>
10+<dt>date</dt><dd>{{date $c.Time}} UTC</dd>
1011 </dl>
1112 {{if $c.Body}}<pre class="body">{{$c.Body}}</pre>{{end}}
13+</div>
1214 <p class="muted">{{len .Data.Files}} files changed, <span class="add">+{{.Data.Added}}</span> <span class="del">−{{.Data.Removed}}</span></p>
1315 <ul class="files">{{range .Data.Files}}<li><a href="#{{.New}}">{{if eq .Status "renamed"}}{{.Old}} → {{end}}{{.New}}</a> <span class="muted">{{.Status}}</span> <span class="add">+{{.Added}}</span> <span class="del">−{{.Removed}}</span></li>{{end}}</ul>
1416 {{range .Data.Files}}
1517 <section class="diff" id="{{.New}}">
16-<h3><a href="/{{$p.Repo.Path}}/tree/{{$c.Hash}}/{{.New}}">{{if eq .Status "renamed"}}{{.Old}} → {{end}}{{.New}}</a> <span class="muted">{{.Status}}</span></h3>
18+<h4><a href="/{{$p.Repo.Path}}/tree/{{$c.Hash}}/{{.New}}">{{if eq .Status "renamed"}}{{.Old}} → {{end}}{{.New}}</a> <span class="muted">{{.Status}}</span></h4>
1719 {{if .Binary}}<p class="muted">Binary file.</p>{{end}}
1820 {{range .Hunks}}
1921 <table class="hunk">

templates/create.html modified

@@ -1,9 +1,9 @@
11 {{define "content"}}
2-<h2>New repository</h2>
2+<div class="panel narrow"><h3>Create a new repository</h3>
33 <form method="post" action="/create" class="stack">
44 <label>Name <input name="name" required autofocus pattern="[a-z0-9][a-z0-9_.-]{0,38}"></label>
55 <label>Description <input name="description" maxlength="200"></label>
6-<label class="check"><input type="checkbox" name="private"> Private</label>
7-<button>Create</button>
8-</form>
6+<label class="check"><input type="checkbox" name="private"> This repository is private</label>
7+<div class="actions"><button class="btn primary">Create ▸</button></div>
8+</form></div>
99 {{end}}

templates/index.html modified

@@ -1,21 +1,33 @@
11 {{define "content"}}
22 {{if .User}}
3-<div class="row">
4-<h2>Your repositories</h2>
5-<a class="button" href="/create">New repository</a>
3+<div class="welcome">
4+<strong>Welcome back, ~{{.User.Name}}.</strong>
5+<p>Push with <code>git@{{.Host}}:~{{.User.Name}}/name</code> after adding an SSH key, or over HTTPS with an access token.</p>
6+</div>
7+<div class="cards">
8+<section class="panel"><h3>Your repositories</h3>
9+{{if .Data.Repos}}{{template "repolist" .Data.Repos}}{{else}}<p>You have no repositories yet.</p>{{end}}
10+<a class="btn primary" href="/create">Create a repository ▸</a></section>
11+<section class="panel"><h3>Your profile</h3><p>Set a bio in Markdown, a location, a workplace and an avatar. Your page is <a href="/~{{.User.Name}}">~{{.User.Name}}</a>.</p><a class="btn" href="/settings">Edit your profile ▸</a></section>
12+<section class="panel"><h3>Keys and tokens</h3><p>Register SSH public keys for pushing and create access tokens for HTTPS pushes or scripts.</p><a class="btn" href="/settings/keys">Manage keys ▸</a></section>
613 </div>
7-{{template "repolist" .Data.Repos}}
814 {{else}}
9-<section class="hero">
10-<h1><img src="/static/logo.svg" alt="">{{.Host}}</h1>
11-<p>A small git host. Push over SSH or HTTPS, browse trees, logs and diffs, and read READMEs with Markdown and LaTeX rendering. No JavaScript is needed to use it.</p>
12-<p class="muted">{{.Data.Public}} public repositories from {{.Data.Users}} users.</p>
13-<p><a class="button" href="/login">Log in</a> <a class="button secondary" href="/register">Register</a></p>
14-</section>
15-<h2>Recent repositories</h2>
16-{{template "repolist" .Data.Repos}}
15+<div class="welcome">
16+<strong>Welcome to {{.Host}}!</strong>
17+<p>{{.Host}} is a small, self hosted git forge. It serves repositories over SSH and HTTPS and renders trees, logs, diffs and READMEs as plain pages, with Markdown and LaTeX where it helps and no scripts where it doesn't.</p>
18+</div>
19+<div class="cards">
20+<section class="panel"><h3>Host a repository</h3><p>Registration is by invitation. Once you have an account, push any repository and it gets a page with its history, refs and README.</p><a class="btn primary" href="/register">Register ▸</a></section>
21+<section class="panel"><h3>Browse the code</h3><p>Every public repository can be cloned anonymously over HTTPS and read in the browser with syntax highlighting.</p><a class="btn" href="#recent">Recent repositories ▸</a></section>
22+<section class="panel"><h3>Already a member?</h3><p>Log in to see your repositories, manage SSH keys and tokens, and edit your profile.</p><a class="btn" href="/login">Log in ▸</a></section>
23+</div>
1724 {{end}}
25+<section class="panel wide" id="recent">
26+<h3>Recent repositories</h3>
27+<p>{{.Data.Public}} public repositories from {{.Data.Users}} users.</p>
28+{{template "repolist" .Data.Recent}}
29+</section>
1830 {{end}}
1931 {{define "repolist"}}
20-{{if .}}<ul class="repos">{{range .}}<li><div><a href="/{{.Path}}">{{.Path}}</a>{{if .Private}} <span class="tag">private</span>{{end}}</div>{{if .Description}}<div class="muted">{{.Description}}</div>{{end}}<div class="when">{{ago .Created}}</div></li>{{end}}</ul>{{else}}<p class="muted">Nothing here yet.</p>{{end}}
32+{{if .}}<div class="repos">{{range .}}<div class="repo"><a href="/{{.Path}}">{{.Path}}</a>{{if .Private}} <span class="tag">private</span>{{end}}{{if .Description}}<div>{{.Description}}</div>{{end}}<div class="muted small">{{ago .Created}}</div></div>{{end}}</div>{{else}}<p class="muted">Nothing here yet.</p>{{end}}
2133 {{end}}

templates/keys.html added

@@ -0,0 +1,10 @@
1+{{define "content"}}
2+<div class="panel"><h3>SSH keys</h3>
3+{{if .Data.Keys}}<table class="keys">{{range .Data.Keys}}<tr><td>{{.Title}}</td><td><code>{{.Fingerprint}}</code></td><td class="when">{{ago .Created}}</td><td><form method="post" action="/settings/keys/{{.ID}}/delete"><button class="btn danger">Remove</button></form></td></tr>{{end}}</table>{{end}}
4+<form method="post" action="/settings/keys" class="stack">
5+<label>SSH public key <textarea name="key" rows="3" required placeholder="ssh-ed25519 AAAA..."></textarea></label>
6+<label>Title <input name="title" placeholder="Defaults to the key comment"></label>
7+<small>Push with <code>git@{{.Host}}:~{{.User.Name}}/repo</code> once the key is registered.</small>
8+<div class="actions"><button class="btn primary">Add key ▸</button></div>
9+</form></div>
10+{{end}}

templates/layout.html modified

@@ -4,7 +4,6 @@
44 <meta charset="utf-8">
55 <meta name="viewport" content="width=device-width, initial-scale=1">
66 <title>{{.Title}}</title>
7-<link rel="icon" href="/static/logo.svg" type="image/svg+xml">
87 <link rel="stylesheet" href="/static/style.css">
98 <link rel="stylesheet" href="/static/highlight.css">
109 {{if .Math}}<link rel="stylesheet" href="/static/katex/katex.min.css">
@@ -15,22 +14,31 @@
1514 <body>
1615 <header>
1716 <nav class="site">
18-<a class="brand" href="/"><svg viewBox="0 0 32 32" width="22" height="22" aria-hidden="true"><rect width="32" height="32" rx="7" fill="currentColor"/><g fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M11 9v14"/><path d="M11 16c0-3.5 2.5-5.5 7-5.5h2"/></g><g fill="#fff"><circle cx="11" cy="8" r="2.6"/><circle cx="11" cy="24" r="2.6"/><circle cx="21" cy="10.5" r="2.6"/></g></svg>{{.Host}}</a>
17+<a class="brand" href="/">{{.Host}}</a>
18+<a href="/">git</a>
1919 <span class="spacer"></span>
20-{{if .User}}<a href="/~{{.User.Name}}">~{{.User.Name}}</a><a href="/create">New repository</a><a href="/settings">Settings</a><form method="post" action="/logout"><button>Log out</button></form>{{else}}<a href="/login">Log in</a><a href="/register">Register</a>{{end}}
20+{{if .User}}<span>Logged in as <a href="/~{{.User.Name}}">~{{.User.Name}}</a> — <form method="post" action="/logout"><button>Log out</button></form></span>{{else}}<a href="/login">Log in</a> — <a href="/register">Register</a>{{end}}
2121 </nav>
22+{{with .Data}}{{with index . "Settings"}}
23+<nav class="subtabs">
24+<a href="/settings"{{if eq . "profile"}} class="active"{{end}}>profile</a>
25+<a href="/settings/security"{{if eq . "security"}} class="active"{{end}}>security</a>
26+<a href="/settings/keys"{{if eq . "keys"}} class="active"{{end}}>keys</a>
27+<a href="/settings/tokens"{{if eq . "tokens"}} class="active"{{end}}>tokens</a>
28+</nav>
29+{{end}}{{end}}
30+</header>
2231 {{with .Data}}{{with index . "Page"}}
2332 <div class="repohead">
24-<h1><a href="/~{{.Repo.Owner.Name}}">~{{.Repo.Owner.Name}}</a>/<a href="/{{.Repo.Path}}">{{.Repo.Name}}</a>{{if .Repo.Private}} <span class="tag">private</span>{{end}}</h1>
25-{{if .Repo.Description}}<p class="desc">{{.Repo.Description}}</p>{{end}}
26-<nav class="tabs">
27-<a href="/{{.Repo.Path}}">Summary</a>
28-{{if not .Empty}}<a href="/{{.Repo.Path}}/tree/{{.Ref}}/">Tree</a><a href="/{{.Repo.Path}}/log/{{.Ref}}">Log</a><a href="/{{.Repo.Path}}/refs">Refs</a>{{end}}
29-{{if .Owner}}<a href="/{{.Repo.Path}}/settings">Settings</a>{{end}}
30-</nav>
33+<div class="inner"><h2><a href="/~{{.Repo.Owner.Name}}">~{{.Repo.Owner.Name}}</a>/{{.Repo.Name}}{{if .Repo.Private}} <span class="tag">private</span>{{end}}</h2></div>
34+<div class="tabbar"><nav class="tabs inner">
35+<a href="/{{.Repo.Path}}"{{if eq .Tab "summary"}} class="active"{{end}}>summary</a>
36+{{if not .Empty}}<a href="/{{.Repo.Path}}/tree/{{.Ref}}/"{{if eq .Tab "tree"}} class="active"{{end}}>tree</a><a href="/{{.Repo.Path}}/log/{{.Ref}}"{{if eq .Tab "log"}} class="active"{{end}}>log</a><a href="/{{.Repo.Path}}/refs"{{if eq .Tab "refs"}} class="active"{{end}}>refs</a>{{end}}
37+{{if .Owner}}<a href="/{{.Repo.Path}}/settings"{{if eq .Tab "settings"}} class="active"{{end}}>settings</a>{{end}}
38+</nav></div>
3139 </div>
40+{{if .Repo.Description}}<div class="descbar"><div class="inner">{{.Repo.Description}}</div></div>{{end}}
3241 {{end}}{{end}}
33-</header>
3442 <main>
3543 {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
3644 {{template "content" .}}

templates/log.html modified

@@ -1,8 +1,8 @@
11 {{define "content"}}
22 {{$p := .Data.Page}}
3-<h2>Log <span class="muted">{{$p.Ref}}</span></h2>
3+{{if .Data.Path}}<p class="muted">History of <a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{.Data.Path}}">{{.Data.Path}}</a> on {{$p.Ref}}</p>{{end}}
44 {{if $p.Empty}}<p class="muted">No commits.</p>{{else}}
5-<ul class="commits">{{range .Data.Commits}}<li><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a><span class="subject"><a href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{.Subject}}</a></span><span class="who">{{.Author}}</span><span class="when" title="{{date .Time}}">{{ago .Time}}</span></li>{{end}}</ul>
6-{{with .Data.Next}}<p><a href="?from={{.}}">Older →</a></p>{{end}}
5+<div class="events">{{range .Data.Commits}}<div class="event"><div><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a> <a class="subject" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{.Subject}}</a></div><div class="muted small">{{.Author}} · <span title="{{date .Time}}">{{ago .Time}}</span></div>{{if .Body}}<pre class="body">{{.Body}}</pre>{{end}}</div>{{end}}</div>
6+{{with .Data.Next}}<p><a class="btn" href="?from={{.}}{{if $.Data.Path}}&path={{$.Data.Path}}{{end}}">Older ▸</a></p>{{end}}
77 {{end}}
88 {{end}}

templates/login.html modified

@@ -1,8 +1,8 @@
11 {{define "content"}}
2-<h2>Log in</h2>
2+<div class="panel narrow"><h3>Log in</h3>
33 <form method="post" action="/login" class="stack">
4-<label>Name <input name="name" required autofocus autocomplete="username"></label>
4+<label>Username <input name="name" required autofocus autocomplete="username"></label>
55 <label>Password <input name="password" type="password" required autocomplete="current-password"></label>
6-<button>Log in</button>
7-</form>
6+<div class="actions"><button class="btn primary">Log in ▸</button></div>
7+</form></div>
88 {{end}}

templates/refs.html modified

@@ -1,7 +1,7 @@
11 {{define "content"}}
22 {{$p := .Data.Page}}
3-<h2>Branches</h2>
4-<table class="refs">{{range .Data.Branches}}<tr><td><a href="/{{$p.Repo.Path}}/tree/{{.Name}}/">{{.Name}}</a></td><td><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a></td><td class="when">{{ago .Time}}</td></tr>{{end}}</table>
5-<h2>Tags</h2>
6-{{if .Data.Tags}}<table class="refs">{{range .Data.Tags}}<tr><td><a href="/{{$p.Repo.Path}}/tree/{{.Name}}/">{{.Name}}</a></td><td><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a></td><td class="when">{{ago .Time}}</td></tr>{{end}}</table>{{else}}<p class="muted">No tags.</p>{{end}}
3+<div class="panel"><h3>Branches</h3>
4+<table class="refs">{{range .Data.Branches}}<tr><td><a href="/{{$p.Repo.Path}}/tree/{{.Name}}/">{{.Name}}</a></td><td><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a></td><td class="when">{{ago .Time}}</td></tr>{{end}}</table></div>
5+<div class="panel"><h3>Tags</h3>
6+{{if .Data.Tags}}<table class="refs">{{range .Data.Tags}}<tr><td><a href="/{{$p.Repo.Path}}/tree/{{.Name}}/">{{.Name}}</a></td><td><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a></td><td class="when">{{ago .Time}}</td></tr>{{end}}</table>{{else}}<p class="muted">No tags.</p>{{end}}</div>
77 {{end}}

templates/register.html modified

@@ -1,9 +1,9 @@
11 {{define "content"}}
2-<h2>Register</h2>
2+<div class="panel narrow"><h3>Register</h3>
33 <form method="post" action="/register" class="stack">
4-<label>Name <input name="name" required autofocus autocomplete="username" pattern="[a-z0-9][a-z0-9_.-]{0,38}"></label>
4+<label>Username <input name="name" required autofocus autocomplete="username" pattern="[a-z0-9][a-z0-9_.-]{0,38}"><small>Lowercase letters, digits, dot, dash or underscore. Your page will be ~username.</small></label>
55 <label>Password <input name="password" type="password" required minlength="8" autocomplete="new-password"></label>
66 {{if not .Data.Open}}<label>Invite code <input name="invite" required value="{{.Data.Invite}}"></label>{{end}}
7-<button>Create account</button>
8-</form>
7+<div class="actions"><button class="btn primary">Create account ▸</button></div>
8+</form></div>
99 {{end}}

templates/repo.html modified

@@ -1,28 +1,29 @@
11 {{define "content"}}
22 {{$p := .Data.Page}}
33 {{if $p.Empty}}
4-<p class="muted">This repository is empty. Push something to it:</p>
4+<p>This repository is empty. Push something to it:</p>
55 <pre>git remote add origin git@{{.Host}}:{{$p.Repo.Path}}
66 git push -u origin {{$p.Ref}}</pre>
77 {{else}}
8+<div class="cards commits">
9+{{range .Data.Commits}}<div class="panel"><div><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a> <span class="subject">{{.Subject}}</span></div><div>{{.Author}} pushed to <a href="/{{$p.Repo.Path}}">{{$p.Repo.Path}}</a></div><div class="muted small">{{ago .Time}}</div></div>{{end}}
10+</div>
811 <div class="columns">
912 <div class="main">
1013 {{with .Data.Readme}}<article class="readme">{{.}}</article>{{end}}
11-{{with .Data.ReadmeText}}<article class="readme"><pre>{{.}}</pre></article>{{end}}
14+{{with .Data.ReadmeText}}<pre class="plain">{{.}}</pre>{{end}}
1215 {{if not .Data.ReadmeName}}<p class="muted">No README.</p>{{end}}
1316 </div>
1417 <aside>
15-<h3>Clone</h3>
18+<h4>Clone</h4>
1619 <dl class="clone">
17-<dt>HTTPS</dt><dd><code>https://{{.Host}}/{{$p.Repo.Path}}</code></dd>
18-{{if $p.Owner}}<dt>SSH</dt><dd><code>git@{{.Host}}:{{$p.Repo.Path}}</code></dd>{{end}}
20+<dt>read-only</dt><dd><code>https://{{.Host}}/{{$p.Repo.Path}}</code></dd>
21+{{if $p.Owner}}<dt>read/write</dt><dd><code>git@{{.Host}}:{{$p.Repo.Path}}</code></dd>{{end}}
1922 </dl>
20-<h3><a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}">Latest commits</a></h3>
21-<ul class="side">{{range .Data.Commits}}<li><a href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{.Subject}}</a><div class="muted"><code>{{short .Hash}}</code> · {{.Author}} · {{ago .Time}}</div></li>{{end}}</ul>
22-<h3><a href="/{{$p.Repo.Path}}/refs">Branches</a></h3>
23-<ul class="side">{{range $i, $b := .Data.Branches}}{{if lt $i 5}}<li><a href="/{{$p.Repo.Path}}/tree/{{$b.Name}}/">{{$b.Name}}</a> <span class="muted">{{ago $b.Time}}</span></li>{{end}}{{end}}</ul>
24-{{if .Data.Tags}}<h3><a href="/{{$p.Repo.Path}}/refs">Tags</a></h3>
25-<ul class="side">{{range $i, $t := .Data.Tags}}{{if lt $i 5}}<li><a href="/{{$p.Repo.Path}}/tree/{{$t.Name}}/">{{$t.Name}}</a> <span class="muted">{{ago $t.Time}}</span></li>{{end}}{{end}}</ul>{{end}}
23+<h4><a href="/{{$p.Repo.Path}}/refs">Branches</a></h4>
24+<ul class="side">{{range $i, $b := .Data.Branches}}{{if lt $i 5}}<li><a href="/{{$p.Repo.Path}}/tree/{{$b.Name}}/">{{$b.Name}}</a> <span class="muted small">{{ago $b.Time}}</span></li>{{end}}{{end}}</ul>
25+{{if .Data.Tags}}<h4><a href="/{{$p.Repo.Path}}/refs">Tags</a></h4>
26+<ul class="side">{{range $i, $t := .Data.Tags}}{{if lt $i 5}}<li><a href="/{{$p.Repo.Path}}/tree/{{$t.Name}}/">{{$t.Name}}</a> <span class="muted small">{{ago $t.Time}}</span></li>{{end}}{{end}}</ul>{{end}}
2627 </aside>
2728 </div>
2829 {{end}}

templates/reposettings.html modified

@@ -1,14 +1,15 @@
11 {{define "content"}}
22 {{$p := .Data.Page}}
3-<h2>Repository settings</h2>
3+<div class="panel"><h3>Repository settings</h3>
44 <form method="post" action="/{{$p.Repo.Path}}/settings" class="stack">
55 <label>Description <input name="description" value="{{$p.Repo.Description}}" maxlength="200"></label>
6-<label class="check"><input type="checkbox" name="private"{{if $p.Repo.Private}} checked{{end}}> Private</label>
7-<button>Save</button>
8-</form>
9-<h2>Delete repository</h2>
6+<label class="check"><input type="checkbox" name="private"{{if $p.Repo.Private}} checked{{end}}> This repository is private</label>
7+<div class="actions left"><button class="btn primary">Save ▸</button></div>
8+</form></div>
9+<div class="panel"><h3>Delete repository</h3>
10+<p>This permanently removes the repository and its history from the server.</p>
1011 <form method="post" action="/{{$p.Repo.Path}}/delete" class="stack">
1112 <label>Type <code>{{$p.Repo.Name}}</code> to confirm <input name="confirm" required></label>
12-<button class="danger">Delete repository</button>
13-</form>
13+<div class="actions left"><button class="btn danger">Delete repository ▸</button></div>
14+</form></div>
1415 {{end}}

templates/security.html added

@@ -0,0 +1,12 @@
1+{{define "content"}}
2+<div class="panel"><h3>Change your password</h3>
3+<form method="post" action="/settings/password" class="stack">
4+<label>Current password <input name="current" type="password" required autocomplete="current-password"></label>
5+<label>New password <input name="password" type="password" required minlength="8" autocomplete="new-password"></label>
6+<div class="actions left"><button class="btn primary">Change password ▸</button></div>
7+</form></div>
8+<div class="panel"><h3>Sessions</h3>
9+<p>Sessions last 90 days. Logging out ends only the current one.</p>
10+<form method="post" action="/settings/sessions"><button class="btn">Log out everywhere ▸</button></form>
11+</div>
12+{{end}}

templates/settings.html modified

@@ -1,42 +1,28 @@
11 {{define "content"}}
22 {{$p := .Data.Profile}}
3-<h2>Profile</h2>
4-<form method="post" action="/settings/profile" class="stack">
5-<label>Bio <textarea name="bio" rows="3" maxlength="1000">{{$p.Bio}}</textarea></label>
6-<label>Location <input name="location" value="{{$p.Location}}" maxlength="100"></label>
7-<label>Workplace <input name="workplace" value="{{$p.Workplace}}" maxlength="100"></label>
8-<label>Website <input name="website" value="{{$p.Website}}" maxlength="200" placeholder="https://"></label>
9-<button>Save profile</button>
10-</form>
11-<h2>Avatar</h2>
12-<form method="post" action="/settings/avatar" enctype="multipart/form-data" class="stack" id="avatarform">
13-{{if $p.Avatar}}<img class="avatar" src="/~{{.User.Name}}/avatar" alt="">{{end}}
14-<label>Image <input type="file" name="avatar" accept="image/png,image/jpeg,image/gif,image/webp" required></label>
15-<div class="cropper" hidden><canvas></canvas><p class="muted">Drag to move the square, scroll or use the slider to resize.</p><input type="range" min="10" max="100" value="100"></div>
3+<h3 class="rule">Edit your profile</h3>
4+<form method="post" action="/settings/avatar" enctype="multipart/form-data" class="avatarform" id="avatarform">
5+<div class="avatarbox">{{if $p.Avatar}}<img class="avatar" src="/~{{.User.Name}}/avatar?v={{$p.Avatar}}" alt="">{{end}}</div>
6+<div>
7+<label>Choose an avatar <input type="file" name="avatar" accept="image/png,image/jpeg,image/gif,image/webp" required></label>
8+<small>If the image is not square you can pick the crop below. It is resized to 256×256 and stored as PNG.</small>
9+<div class="cropper" hidden><canvas></canvas><input type="range" min="10" max="100" value="100"><small>Drag to move the square, scroll or slide to resize.</small></div>
1610 <input type="hidden" name="x"><input type="hidden" name="y"><input type="hidden" name="size">
17-<button>Upload avatar</button>
11+<div class="actions left"><button class="btn">Upload avatar ▸</button>{{if $p.Avatar}}<button class="btn danger" formaction="/settings/avatar/delete" formnovalidate>Remove</button>{{end}}</div>
12+</div>
1813 </form>
19-{{if $p.Avatar}}<form method="post" action="/settings/avatar/delete"><button class="danger">Remove avatar</button></form>{{end}}
2014 <script src="/static/crop.js" defer></script>
21-<h2>SSH keys</h2>
22-{{if .Data.Keys}}<table class="keys">{{range .Data.Keys}}<tr><td>{{.Title}}</td><td><code>{{.Fingerprint}}</code></td><td class="when">{{ago .Created}}</td><td><form method="post" action="/settings/keys/{{.ID}}/delete"><button class="danger">Remove</button></form></td></tr>{{end}}</table>{{end}}
23-<form method="post" action="/settings/keys" class="stack">
24-<label>Title <input name="title" placeholder="laptop"></label>
25-<label>Public key <textarea name="key" rows="3" required placeholder="ssh-ed25519 AAAA..."></textarea></label>
26-<button>Add key</button>
27-</form>
28-<h2>Access tokens</h2>
29-<p class="muted">Use a token as the password when pushing over HTTPS.</p>
30-{{with .Data.NewToken}}<p class="notice">New token, shown once: <code>{{.}}</code></p>{{end}}
31-{{if .Data.Tokens}}<table class="keys">{{range .Data.Tokens}}<tr><td>{{.Title}}</td><td class="when">{{ago .Created}}</td><td><form method="post" action="/settings/tokens/{{.ID}}/delete"><button class="danger">Revoke</button></form></td></tr>{{end}}</table>{{end}}
32-<form method="post" action="/settings/tokens" class="stack">
33-<label>Title <input name="title" placeholder="ci"></label>
34-<button>Create token</button>
35-</form>
36-<h2>Password</h2>
37-<form method="post" action="/settings/password" class="stack">
38-<label>Current password <input name="current" type="password" required autocomplete="current-password"></label>
39-<label>New password <input name="password" type="password" required minlength="8" autocomplete="new-password"></label>
40-<button>Change password</button>
15+<form method="post" action="/settings/profile" class="stack wide">
16+<div class="pair">
17+<label>Username <span class="muted">(you can't edit this)</span><input value="{{.User.Name}}" disabled></label>
18+<label>Name <input name="display" value="{{$p.Display}}" maxlength="100" placeholder="Shown on your profile"></label>
19+</div>
20+<div class="pair">
21+<label>Location <input name="location" value="{{$p.Location}}" maxlength="100"></label>
22+<label>Workplace <input name="workplace" value="{{$p.Workplace}}" maxlength="100"></label>
23+</div>
24+<label>URL <input name="website" value="{{$p.Website}}" maxlength="200" placeholder="https://"></label>
25+<label>Bio <textarea name="bio" rows="6" maxlength="4000" placeholder="Markdown supported">{{$p.Bio}}</textarea></label>
26+<div class="actions"><button class="btn primary">Save changes ▸</button></div>
4127 </form>
4228 {{end}}

templates/tokens.html added

@@ -0,0 +1,10 @@
1+{{define "content"}}
2+<div class="panel"><h3>Personal access tokens</h3>
3+<p>Use a token as the password when pushing over HTTPS, for example from CI. Tokens are shown once and stored hashed.</p>
4+{{with .Data.NewToken}}<p class="notice">New token, shown once: <code>{{.}}</code></p>{{end}}
5+{{if .Data.Tokens}}<table class="keys">{{range .Data.Tokens}}<tr><td>{{.Title}}</td><td class="when">{{ago .Created}}</td><td><form method="post" action="/settings/tokens/{{.ID}}/delete"><button class="btn danger">Revoke</button></form></td></tr>{{end}}</table>{{else}}<p>You have not created any personal access tokens.</p>{{end}}
6+<form method="post" action="/settings/tokens" class="stack">
7+<label>Title <input name="title" placeholder="ci"></label>
8+<div class="actions left"><button class="btn primary">Generate new token ▸</button></div>
9+</form></div>
10+{{end}}

templates/tree.html modified

@@ -1,19 +1,25 @@
11 {{define "content"}}
22 {{$p := .Data.Page}}
3-{{template "crumbs" .Data}}
3+{{template "filebar" .Data}}
44 <table class="tree">
55 {{range .Data.Entries}}{{$c := index $.Data.Last .Name}}
66 <tr>
7-<td class="mode">{{.Mode}}</td>
7+<td class="mode">{{perm .Mode}}</td>
88 <td class="name">{{if eq .Type "tree"}}<a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{if $.Data.Path}}{{$.Data.Path}}/{{end}}{{.Name}}/">{{.Name}}/</a>{{else if eq .Type "blob"}}<a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{if $.Data.Path}}{{$.Data.Path}}/{{end}}{{.Name}}">{{.Name}}</a>{{else}}{{.Name}} <span class="muted">({{.Type}})</span>{{end}}</td>
99 <td class="size">{{if eq .Type "blob"}}{{size .Size}}{{end}}</td>
10-<td class="last">{{if $c.Hash}}<a href="/{{$p.Repo.Path}}/commit/{{$c.Hash}}">{{$c.Subject}}</a>{{end}}</td>
10+<td class="last">{{if $c.Hash}}<a class="hash" href="/{{$p.Repo.Path}}/commit/{{$c.Hash}}">{{short $c.Hash}}</a> — {{$c.Subject}}{{end}}</td>
1111 <td class="when">{{if $c.Hash}}{{ago $c.Time}}{{end}}</td>
1212 </tr>
1313 {{end}}
1414 </table>
1515 {{end}}
16-{{define "crumbs"}}
16+{{define "filebar"}}
1717 {{$p := .Page}}
18-<p class="crumbs"><a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/">{{$p.Repo.Name}}</a>{{range $i, $c := .Crumbs}}/<a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{join $.Crumbs (inc $i)}}{{if lt (inc $i) (len $.Crumbs)}}/{{end}}">{{$c}}</a>{{end}} <span class="muted">at <a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}">{{$p.Ref}}</a></span></p>
18+<div class="filebar">
19+<span class="crumbs"><a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/">{{$p.Repo.Name}}</a>{{range $i, $c := .Crumbs}}/<a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{join $.Crumbs (inc $i)}}{{if lt (inc $i) (len $.Crumbs)}}/{{end}}">{{$c}}</a>{{end}}</span>
20+{{with .Entry}}<span class="mode">{{perm .Mode}}</span>{{if eq .Type "blob"}}<span class="muted">{{size .Size}}</span>{{end}}{{end}}
21+{{if .Entry}}{{if eq .Entry.Type "blob"}}<a class="active" href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{.Path}}">View</a><a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}?path={{.Path}}">Log</a><a href="/{{$p.Repo.Path}}/raw/{{$p.Ref}}/{{.Path}}">View raw</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/{{.Path}}">Permalink</a>{{else}}<a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}?path={{.Path}}">Log</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/{{.Path}}/">Permalink</a>{{end}}{{else}}<a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}">Log</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/">Permalink</a>{{end}}
22+<span class="spacer"></span>
23+{{with .LastCommit}}<span class="lastcommit"><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a> — {{.Subject}} <span class="muted">{{ago .Time}}</span></span>{{end}}
24+</div>
1925 {{end}}

templates/user.html modified

@@ -1,23 +1,20 @@
11 {{define "content"}}
22 {{$p := .Data.Profile}}
33 <div class="columns user">
4-<aside class="profile">
5-{{if $p.Avatar}}<img class="avatar" src="/~{{.Data.Owner.Name}}/avatar" alt="">{{end}}
6-<h1>~{{.Data.Owner.Name}}</h1>
7-{{if $p.Bio}}<p class="bio">{{$p.Bio}}</p>{{end}}
8-<ul class="facts">
9-{{if $p.Location}}<li>{{$p.Location}}</li>{{end}}
10-{{if $p.Workplace}}<li>{{$p.Workplace}}</li>{{end}}
11-{{if $p.Website}}<li><a href="{{$p.Website}}" rel="nofollow me">{{$p.Website}}</a></li>{{end}}
12-<li class="muted">Joined {{ago .Data.Owner.Created}}</li>
13-</ul>
4+<aside>
5+<div class="profile">
6+{{if $p.Avatar}}<img class="avatar" src="/~{{.Data.Owner.Name}}/avatar?v={{$p.Avatar}}" alt="">{{end}}
7+{{if $p.Display}}<h1>{{$p.Display}}</h1><p class="handle">~{{.Data.Owner.Name}}</p>{{else}}<h1>~{{.Data.Owner.Name}}</h1>{{end}}
8+<p class="links">{{if $p.Website}}<a href="{{$p.Website}}" rel="nofollow me">{{$p.Website}}</a>{{end}}{{if $p.Location}}{{if $p.Website}} | {{end}}{{$p.Location}}{{end}}{{if $p.Workplace}}{{if or $p.Website $p.Location}} | {{end}}{{$p.Workplace}}{{end}}</p>
9+{{with .Data.Bio}}<div class="bio readme">{{.}}</div>{{end}}
10+<p class="muted small">Joined {{ago .Data.Owner.Created}}</p>
11+</div>
1412 </aside>
1513 <div class="main">
16-<h2>Repositories</h2>
1714 {{template "repolist" .Data.Repos}}
1815 </div>
1916 </div>
2017 {{end}}
2118 {{define "repolist"}}
22-{{if .}}<ul class="repos">{{range .}}<li><div><a href="/{{.Path}}">{{.Name}}</a>{{if .Private}} <span class="tag">private</span>{{end}}</div>{{if .Description}}<div class="muted">{{.Description}}</div>{{end}}<div class="when">{{ago .Created}}</div></li>{{end}}</ul>{{else}}<p class="muted">No repositories.</p>{{end}}
19+{{if .}}<div class="repos">{{range .}}<div class="repo"><a href="/{{.Path}}">{{.Name}}</a>{{if .Private}} <span class="tag">private</span>{{end}}{{if .Description}}<div>{{.Description}}</div>{{end}}</div>{{end}}</div>{{else}}<p class="muted">No repositories.</p>{{end}}
2320 {{end}}

web.go modified

@@ -68,6 +68,7 @@ func newServer(store *Store, root, host string, open bool) *Server {
6868 return strings.Split(s, "\n")
6969 },
7070 "inc": func(i int) int { return i + 1 },
71+ "perm": perm,
7172 "join": func(parts []string, upto int) string { return strings.Join(parts[:upto], "/") },
7273 "string": func(b []byte) string { return string(b) },
7374 }
@@ -142,6 +143,10 @@ func (s *Server) handler() http.Handler {
142143 mux.HandleFunc("GET /create", s.createForm)
143144 mux.HandleFunc("POST /create", s.create)
144145 mux.HandleFunc("GET /settings", s.settings)
146+ mux.HandleFunc("GET /settings/security", s.security)
147+ mux.HandleFunc("GET /settings/keys", s.keysPage)
148+ mux.HandleFunc("GET /settings/tokens", s.tokensPage)
149+ mux.HandleFunc("POST /settings/sessions", s.endSessions)
145150 mux.HandleFunc("POST /settings/keys", s.addKey)
146151 mux.HandleFunc("POST /settings/keys/{id}/delete", s.deleteKey)
147152 mux.HandleFunc("POST /settings/tokens", s.addToken)
@@ -157,19 +162,17 @@ func (s *Server) handler() http.Handler {
157162
158163 func (s *Server) index(w http.ResponseWriter, r *http.Request) {
159164 user := s.current(r)
160- var repos []Repo
161- var err error
162- if user != nil {
163- repos, err = s.store.ReposByOwner(user.ID, true)
164- } else {
165- repos, err = s.store.RecentRepos(30)
166- }
165+ recent, err := s.store.RecentRepos(20)
167166 if err != nil {
168167 s.fail(w, r, 500, err.Error())
169168 return
170169 }
170+ var repos []Repo
171+ if user != nil {
172+ repos, _ = s.store.ReposByOwner(user.ID, true)
173+ }
171174 users, public := s.store.Stats()
172- s.render(w, r, "index", page{Title: s.host, User: user, Data: map[string]any{"Repos": repos, "Users": users, "Public": public}})
175+ s.render(w, r, "index", page{Title: s.host, User: user, Data: map[string]any{"Repos": repos, "Recent": recent, "Users": users, "Public": public}})
173176 }
174177
175178 func (s *Server) loginForm(w http.ResponseWriter, r *http.Request) {
@@ -287,14 +290,48 @@ func (s *Server) repoDir(repo Repo) string {
287290 }
288291
289292 func (s *Server) settings(w http.ResponseWriter, r *http.Request) {
293+ u := s.require(w, r)
294+ if u == nil {
295+ return
296+ }
297+ s.render(w, r, "settings", page{Title: "Settings", User: u, Error: r.URL.Query().Get("error"),
298+ Data: map[string]any{"Settings": "profile", "Profile": s.store.Profile(u.ID)}})
299+}
300+
301+func (s *Server) security(w http.ResponseWriter, r *http.Request) {
302+ u := s.require(w, r)
303+ if u == nil {
304+ return
305+ }
306+ s.render(w, r, "security", page{Title: "Security", User: u, Error: r.URL.Query().Get("error"), Data: map[string]any{"Settings": "security"}})
307+}
308+
309+func (s *Server) keysPage(w http.ResponseWriter, r *http.Request) {
290310 u := s.require(w, r)
291311 if u == nil {
292312 return
293313 }
294314 keys, _ := s.store.Keys(u.ID)
315+ s.render(w, r, "keys", page{Title: "Keys", User: u, Error: r.URL.Query().Get("error"), Data: map[string]any{"Settings": "keys", "Keys": keys}})
316+}
317+
318+func (s *Server) tokensPage(w http.ResponseWriter, r *http.Request) {
319+ u := s.require(w, r)
320+ if u == nil {
321+ return
322+ }
295323 tokens, _ := s.store.Tokens(u.ID)
296- s.render(w, r, "settings", page{Title: "Settings", User: u, Error: r.URL.Query().Get("error"),
297- Data: map[string]any{"Keys": keys, "Tokens": tokens, "NewToken": r.URL.Query().Get("token"), "Profile": s.store.Profile(u.ID)}})
324+ s.render(w, r, "tokens", page{Title: "Tokens", User: u, Error: r.URL.Query().Get("error"), Data: map[string]any{"Settings": "tokens", "Tokens": tokens, "NewToken": r.URL.Query().Get("token")}})
325+}
326+
327+func (s *Server) endSessions(w http.ResponseWriter, r *http.Request) {
328+ u := s.require(w, r)
329+ if u == nil {
330+ return
331+ }
332+ s.store.DeleteSessions(u.ID)
333+ http.SetCookie(w, &http.Cookie{Name: "session", Value: "", Path: "/", MaxAge: -1})
334+ http.Redirect(w, r, "/login", http.StatusSeeOther)
298335 }
299336
300337 func (s *Server) profile(w http.ResponseWriter, r *http.Request) {
@@ -309,7 +346,7 @@ func (s *Server) profile(w http.ResponseWriter, r *http.Request) {
309346 }
310347 return v
311348 }
312- p := Profile{Bio: field("bio", 1000), Location: field("location", 100), Workplace: field("workplace", 100), Website: field("website", 200)}
349+ p := Profile{Display: field("display", 100), Bio: field("bio", 4000), Location: field("location", 100), Workplace: field("workplace", 100), Website: field("website", 200)}
313350 if err := s.store.UpdateProfile(u.ID, p); err != nil {
314351 http.Redirect(w, r, "/settings?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
315352 return
@@ -326,16 +363,16 @@ func (s *Server) avatar(w http.ResponseWriter, r *http.Request) {
326363 if u == nil {
327364 return
328365 }
329- r.Body = http.MaxBytesReader(w, r.Body, 8<<20)
366+ r.Body = http.MaxBytesReader(w, r.Body, 16<<20)
330367 f, _, err := r.FormFile("avatar")
331368 if err != nil {
332- http.Redirect(w, r, "/settings?error=Choose+an+image+under+8+MiB", http.StatusSeeOther)
369+ http.Redirect(w, r, "/settings?error=Choose+an+image+under+16+MiB", http.StatusSeeOther)
333370 return
334371 }
335372 defer f.Close()
336373 data, err := io.ReadAll(f)
337374 if err != nil {
338- http.Redirect(w, r, "/settings?error=Choose+an+image+under+8+MiB", http.StatusSeeOther)
375+ http.Redirect(w, r, "/settings?error=Choose+an+image+under+16+MiB", http.StatusSeeOther)
339376 return
340377 }
341378 x, _ := strconv.Atoi(r.FormValue("x"))
@@ -351,7 +388,7 @@ func (s *Server) avatar(w http.ResponseWriter, r *http.Request) {
351388 http.Redirect(w, r, "/settings?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
352389 return
353390 }
354- s.store.SetAvatar(u.ID, true)
391+ s.store.SetAvatar(u.ID, time.Now().Unix())
355392 http.Redirect(w, r, "/~"+u.Name, http.StatusSeeOther)
356393 }
357394
@@ -361,7 +398,7 @@ func (s *Server) deleteAvatar(w http.ResponseWriter, r *http.Request) {
361398 return
362399 }
363400 os.Remove(s.avatarPath(u.ID))
364- s.store.SetAvatar(u.ID, false)
401+ s.store.SetAvatar(u.ID, 0)
365402 http.Redirect(w, r, "/settings", http.StatusSeeOther)
366403 }
367404
@@ -373,7 +410,7 @@ func (s *Server) serveAvatar(w http.ResponseWriter, r *http.Request, owner User)
373410 }
374411 w.Header().Set("Content-Type", "image/png")
375412 w.Header().Set("X-Content-Type-Options", "nosniff")
376- w.Header().Set("Cache-Control", "public, max-age=300")
413+ w.Header().Set("Cache-Control", "public, max-age=86400")
377414 w.Write(data)
378415 }
379416
@@ -383,10 +420,10 @@ func (s *Server) addKey(w http.ResponseWriter, r *http.Request) {
383420 return
384421 }
385422 if err := s.store.AddKey(u.ID, strings.TrimSpace(r.FormValue("title")), r.FormValue("key")); err != nil {
386- http.Redirect(w, r, "/settings?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
423+ http.Redirect(w, r, "/settings/keys?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
387424 return
388425 }
389- http.Redirect(w, r, "/settings", http.StatusSeeOther)
426+ http.Redirect(w, r, "/settings/keys", http.StatusSeeOther)
390427 }
391428
392429 func (s *Server) deleteKey(w http.ResponseWriter, r *http.Request) {
@@ -396,7 +433,7 @@ func (s *Server) deleteKey(w http.ResponseWriter, r *http.Request) {
396433 }
397434 id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
398435 s.store.DeleteKey(u.ID, id)
399- http.Redirect(w, r, "/settings", http.StatusSeeOther)
436+ http.Redirect(w, r, "/settings/keys", http.StatusSeeOther)
400437 }
401438
402439 func (s *Server) addToken(w http.ResponseWriter, r *http.Request) {
@@ -406,10 +443,10 @@ func (s *Server) addToken(w http.ResponseWriter, r *http.Request) {
406443 }
407444 token, err := s.store.CreateToken(u.ID, strings.TrimSpace(r.FormValue("title")))
408445 if err != nil {
409- http.Redirect(w, r, "/settings?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
446+ http.Redirect(w, r, "/settings/tokens?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
410447 return
411448 }
412- http.Redirect(w, r, "/settings?token="+token, http.StatusSeeOther)
449+ http.Redirect(w, r, "/settings/tokens?token="+token, http.StatusSeeOther)
413450 }
414451
415452 func (s *Server) deleteToken(w http.ResponseWriter, r *http.Request) {
@@ -419,7 +456,7 @@ func (s *Server) deleteToken(w http.ResponseWriter, r *http.Request) {
419456 }
420457 id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
421458 s.store.DeleteToken(u.ID, id)
422- http.Redirect(w, r, "/settings", http.StatusSeeOther)
459+ http.Redirect(w, r, "/settings/tokens", http.StatusSeeOther)
423460 }
424461
425462 func (s *Server) password(w http.ResponseWriter, r *http.Request) {
@@ -429,14 +466,14 @@ func (s *Server) password(w http.ResponseWriter, r *http.Request) {
429466 }
430467 if _, ok := s.store.CheckPassword(u.Name, r.FormValue("current")); !ok {
431468 s.limiter.fail(clientIP(r))
432- http.Redirect(w, r, "/settings?error=Current+password+is+wrong", http.StatusSeeOther)
469+ http.Redirect(w, r, "/settings/security?error=Current+password+is+wrong", http.StatusSeeOther)
433470 return
434471 }
435472 if err := s.store.SetPassword(u.ID, r.FormValue("password")); err != nil {
436- http.Redirect(w, r, "/settings?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
473+ http.Redirect(w, r, "/settings/security?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
437474 return
438475 }
439- http.Redirect(w, r, "/settings", http.StatusSeeOther)
476+ http.Redirect(w, r, "/settings/security", http.StatusSeeOther)
440477 }
441478
442479 func (s *Server) route(w http.ResponseWriter, r *http.Request) {
@@ -457,7 +494,12 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) {
457494 s.fail(w, r, 500, err.Error())
458495 return
459496 }
460- s.render(w, r, "user", page{Title: "~" + owner.Name, User: user, Data: map[string]any{"Owner": owner, "Repos": repos, "Profile": s.store.Profile(owner.ID)}})
497+ profile := s.store.Profile(owner.ID)
498+ var bio template.HTML
499+ if profile.Bio != "" {
500+ bio = s.renderMarkdown([]byte(profile.Bio))
501+ }
502+ s.render(w, r, "user", page{Title: "~" + owner.Name, User: user, Math: hasMath(bio), Data: map[string]any{"Owner": owner, "Repos": repos, "Profile": profile, "Bio": bio}})
461503 return
462504 }
463505 name := parts[1]
@@ -516,6 +558,7 @@ type repoPage struct {
516558 Commit string
517559 Owner bool
518560 Empty bool
561+ Tab string
519562 }
520563
521564 func (s *Server) repoPage(r *http.Request, repo Repo, ref string) (repoPage, Git, error) {
@@ -545,9 +588,10 @@ func (s *Server) summary(w http.ResponseWriter, r *http.Request, repo Repo) {
545588 s.fail(w, r, 404, "No such ref")
546589 return
547590 }
591+ rp.Tab = "summary"
548592 data := map[string]any{"Page": rp}
549593 if !rp.Empty {
550- commits, _ := g.Log(rp.Commit, 8, "")
594+ commits, _ := g.Log(rp.Commit, 3, "")
551595 data["Commits"] = commits
552596 branches, _ := g.Refs("refs/heads")
553597 tags, _ := g.Refs("refs/tags")
@@ -618,11 +662,18 @@ func (s *Server) tree(w http.ResponseWriter, r *http.Request, repo Repo, spec st
618662 s.fail(w, r, 404, "No such ref")
619663 return
620664 }
665+ rp.Tab = "tree"
621666 crumbs := strings.Split(filePath, "/")
622667 if filePath == "" {
623668 crumbs = nil
624669 }
625670 data := map[string]any{"Page": rp, "Path": filePath, "Crumbs": crumbs}
671+ if last, _ := g.Log(rp.Commit, 1, filePath); len(last) > 0 {
672+ data["LastCommit"] = last[0]
673+ }
674+ if entry := g.Entry(rp.Commit, filePath); entry != nil {
675+ data["Entry"] = entry
676+ }
626677 switch g.Kind(rp.Commit, filePath) {
627678 case "tree", "commit":
628679 entries, err := g.Tree(rp.Commit, filePath)
@@ -703,7 +754,13 @@ func (s *Server) log(w http.ResponseWriter, r *http.Request, repo Repo, ref stri
703754 s.fail(w, r, 404, "No such ref")
704755 return
705756 }
706- data := map[string]any{"Page": rp}
757+ rp.Tab = "log"
758+ filePath, ok := cleanPath(r.URL.Query().Get("path"))
759+ if !ok {
760+ s.fail(w, r, 404, "No such path")
761+ return
762+ }
763+ data := map[string]any{"Page": rp, "Path": filePath}
707764 if !rp.Empty {
708765 start := rp.Commit
709766 if from := r.URL.Query().Get("from"); from != "" {
@@ -713,7 +770,7 @@ func (s *Server) log(w http.ResponseWriter, r *http.Request, repo Repo, ref stri
713770 }
714771 }
715772 const per = 50
716- commits, err := g.Log(start, per+1, "")
773+ commits, err := g.Log(start, per+1, filePath)
717774 if err != nil {
718775 s.fail(w, r, 500, err.Error())
719776 return
@@ -733,6 +790,7 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request, repo Repo, hash
733790 s.fail(w, r, 404, "No such commit")
734791 return
735792 }
793+ rp.Tab = "log"
736794 c, err := g.Commit(rp.Commit)
737795 if err != nil {
738796 s.fail(w, r, 404, "No such commit")
@@ -757,6 +815,7 @@ func (s *Server) refs(w http.ResponseWriter, r *http.Request, repo Repo) {
757815 s.fail(w, r, 404, "No such ref")
758816 return
759817 }
818+ rp.Tab = "refs"
760819 branches, _ := g.Refs("refs/heads")
761820 tags, _ := g.Refs("refs/tags")
762821 s.render(w, r, "refs", page{Title: repo.Path() + " refs", Data: map[string]any{"Page": rp, "Branches": branches, "Tags": tags}})
@@ -768,6 +827,7 @@ func (s *Server) repoSettings(w http.ResponseWriter, r *http.Request, repo Repo)
768827 s.fail(w, r, 403, "Not your repository")
769828 return
770829 }
830+ rp.Tab = "settings"
771831 s.render(w, r, "reposettings", page{Title: repo.Path() + " settings", Data: map[string]any{"Page": rp}})
772832 }
773833
@@ -824,6 +884,30 @@ func ago(t time.Time) string {
824884 }
825885 }
826886
887+func perm(mode string) string {
888+ bits := func(o string) string {
889+ n, _ := strconv.ParseInt(o, 8, 16)
890+ out := []byte("---------")
891+ for i := 0; i < 9; i++ {
892+ if n&(1<<(8-i)) != 0 {
893+ out[i] = "rwx"[i%3]
894+ }
895+ }
896+ return string(out)
897+ }
898+ switch {
899+ case strings.HasPrefix(mode, "040"):
900+ return "drwxr-xr-x"
901+ case strings.HasPrefix(mode, "120"):
902+ return "lrwxrwxrwx"
903+ case strings.HasPrefix(mode, "160"):
904+ return "m---------"
905+ case len(mode) >= 3:
906+ return "-" + bits(mode[len(mode)-3:])
907+ }
908+ return mode
909+}
910+
827911 func humanSize(n int64) string {
828912 switch {
829913 case n < 1024: