feat(basin): sourcehut style layout, settings tabs, display name, versioned avatars
22 files changed, +421 −234
- git.go modified +17 −0
- static/logo.svg deleted +0 −1
- static/style.css modified +102 −69
- store.go modified +11 −7
- templates/blob.html modified +13 −8
- templates/commit.html modified +8 −6
- templates/create.html modified +4 −4
- templates/index.html modified +25 −13
- templates/keys.html added +10 −0
- templates/layout.html modified +19 −11
- templates/log.html modified +3 −3
- templates/login.html modified +4 −4
- templates/refs.html modified +4 −4
- templates/register.html modified +4 −4
- templates/repo.html modified +12 −11
- templates/reposettings.html modified +8 −7
- templates/security.html added +12 −0
- templates/settings.html modified +21 −35
- templates/tokens.html added +10 −0
- templates/tree.html modified +11 −5
- templates/user.html modified +9 −12
- web.go modified +114 −30
git.go modified
| @@ -186,6 +186,23 @@ func (g Git) Tree(commit, dir string) ([]Entry, error) { | ||
| 186 | 186 | return entries, nil |
| 187 | 187 | } |
| 188 | 188 | |
| 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 | + | |
| 189 | 206 | func (g Git) LastCommits(commit, dir string, names []string) map[string]Commit { |
| 190 | 207 | want := map[string]bool{} |
| 191 | 208 | 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 @@ | ||
| 1 | 1 | @font-face{font-family:Inter;font-style:normal;font-weight:100 900;font-display:swap;src:url(/static/fonts/inter.woff2) format('woff2')} |
| 2 | 2 | @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} | |
| 4 | 4 | *{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)} | |
| 6 | 6 | body{margin:0} |
| 7 | 7 | a{color:var(--link);text-decoration:none} |
| 8 | 8 | 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} | |
| 24 | 27 | nav.site a{color:var(--fg)} |
| 28 | +nav.site .spacer{flex:1} | |
| 25 | 29 | 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} | |
| 61 | 74 | dl.clone{margin:0 0 1rem} |
| 62 | 75 | dl.clone dt{color:var(--muted);font-size:.85rem} |
| 63 | 76 | dl.clone dd{margin:0 0 .4rem;overflow-wrap:anywhere} |
| 64 | 77 | dl.meta{display:grid;grid-template-columns:6em 1fr;gap:.15rem .5rem;margin:0 0 .8rem} |
| 65 | 78 | dl.meta dt{color:var(--muted)} |
| 66 | 79 | 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} | |
| 67 | 90 | .readme img{max-width:100%} |
| 68 | 91 | .readme table{border-collapse:collapse} |
| 69 | 92 | .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} | |
| 71 | 94 | .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} | |
| 74 | 98 | .readme blockquote{border-left:3px solid var(--line);margin:0;padding:0 1rem;color:var(--muted)} |
| 75 | 99 | table{border-collapse:collapse} |
| 76 | 100 | 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} | |
| 78 | 102 | table.tree .mode{color:var(--muted)} |
| 79 | 103 | table.tree .size{color:var(--muted);text-align:right} |
| 80 | 104 | table.tree .last{max-width:30rem;overflow:hidden;text-overflow:ellipsis;width:100%} |
| 81 | 105 | table.tree .when{text-align:right} |
| 82 | 106 | 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%} | |
| 85 | 109 | table.blob td,table.hunk td,.code td{padding:0 .5rem;white-space:pre;vertical-align:top} |
| 86 | 110 | 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)} | |
| 88 | 112 | td.l{white-space:pre-wrap;overflow-wrap:anywhere;width:100%} |
| 89 | 113 | tr:target td{background:#fff8c5} |
| 90 | 114 | .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)} | |
| 94 | 118 | .code .line:target,.code .line:target .cl{background:#fff8c5} |
| 119 | +table.hunk{width:100%;border:1px solid var(--line)} | |
| 95 | 120 | .hunk .head td{color:var(--muted);background:var(--soft)} |
| 96 | 121 | .hunk tr.add td{background:var(--addbg)} |
| 97 | 122 | .hunk tr.del td{background:var(--delbg)} |
| @@ -101,15 +126,23 @@ tr:target td{background:#fff8c5} | ||
| 101 | 126 | .diff{margin-bottom:1.5rem} |
| 102 | 127 | .files{margin-bottom:1.2rem} |
| 103 | 128 | .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} | |
| 107 | 132 | 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} | |
| 109 | 137 | input:focus,textarea:focus{outline:2px solid #b6d4fe;border-color:#86b7fe} |
| 110 | 138 | 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} | |
| 114 | 146 | .cropper canvas{display:block;max-width:100%;border:1px solid var(--line);cursor:move;touch-action:none} |
| 115 | 147 | .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{ | ||
| 64 | 64 | `alter table users add column workplace text not null default ''`, |
| 65 | 65 | `alter table users add column website text not null default ''`, |
| 66 | 66 | `alter table users add column avatar integer not null default 0`, |
| 67 | + `alter table users add column display text not null default ''`, | |
| 67 | 68 | } |
| 68 | 69 | |
| 69 | 70 | type Profile struct { |
| 71 | + Display string | |
| 70 | 72 | Bio string |
| 71 | 73 | Location string |
| 72 | 74 | Workplace string |
| 73 | 75 | Website string |
| 74 | - Avatar bool | |
| 76 | + Avatar int64 | |
| 75 | 77 | } |
| 76 | 78 | |
| 77 | 79 | var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]{0,38}$`) |
| @@ -244,6 +246,10 @@ func (s *Store) DeleteSession(id string) { | ||
| 244 | 246 | s.db.Exec(`delete from sessions where id = ?`, id) |
| 245 | 247 | } |
| 246 | 248 | |
| 249 | +func (s *Store) DeleteSessions(user int64) { | |
| 250 | + s.db.Exec(`delete from sessions where user = ?`, user) | |
| 251 | +} | |
| 252 | + | |
| 247 | 253 | func (s *Store) AddKey(user int64, title, public string) error { |
| 248 | 254 | pub, comment, _, _, err := ssh.ParseAuthorizedKey([]byte(public)) |
| 249 | 255 | if err != nil { |
| @@ -429,9 +435,7 @@ func (s *Store) DeleteRepo(id int64) error { | ||
| 429 | 435 | |
| 430 | 436 | func (s *Store) Profile(user int64) Profile { |
| 431 | 437 | 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) | |
| 435 | 439 | return p |
| 436 | 440 | } |
| 437 | 441 | |
| @@ -439,12 +443,12 @@ func (s *Store) UpdateProfile(user int64, p Profile) error { | ||
| 439 | 443 | if p.Website != "" && !strings.HasPrefix(p.Website, "https://") && !strings.HasPrefix(p.Website, "http://") { |
| 440 | 444 | p.Website = "https://" + p.Website |
| 441 | 445 | } |
| 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) | |
| 443 | 447 | return err |
| 444 | 448 | } |
| 445 | 449 | |
| 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) | |
| 448 | 452 | return err |
| 449 | 453 | } |
| 450 | 454 | |
templates/blob.html modified
| @@ -1,16 +1,21 @@ | ||
| 1 | 1 | {{define "content"}} |
| 2 | 2 | {{$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> | |
| 8 | 7 | {{else if .Data.Highlighted}}<div class="code">{{.Data.Highlighted}}</div> |
| 9 | 8 | {{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> | |
| 11 | 10 | {{end}} |
| 12 | 11 | {{end}} |
| 13 | -{{define "crumbs"}} | |
| 12 | +{{define "filebar"}} | |
| 14 | 13 | {{$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> | |
| 16 | 21 | {{end}} |
templates/commit.html modified
| @@ -1,19 +1,21 @@ | ||
| 1 | 1 | {{define "content"}} |
| 2 | 2 | {{$p := .Data.Page}} |
| 3 | 3 | {{$c := .Data.Commit}} |
| 4 | -<h2>{{$c.Subject}}</h2> | |
| 4 | +<div class="panel"> | |
| 5 | +<h3>{{$c.Subject}}</h3> | |
| 5 | 6 | <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}} <{{$c.Email}}></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}} <{{$c.Email}}></dd> | |
| 10 | +<dt>date</dt><dd>{{date $c.Time}} UTC</dd> | |
| 10 | 11 | </dl> |
| 11 | 12 | {{if $c.Body}}<pre class="body">{{$c.Body}}</pre>{{end}} |
| 13 | +</div> | |
| 12 | 14 | <p class="muted">{{len .Data.Files}} files changed, <span class="add">+{{.Data.Added}}</span> <span class="del">−{{.Data.Removed}}</span></p> |
| 13 | 15 | <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> |
| 14 | 16 | {{range .Data.Files}} |
| 15 | 17 | <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> | |
| 17 | 19 | {{if .Binary}}<p class="muted">Binary file.</p>{{end}} |
| 18 | 20 | {{range .Hunks}} |
| 19 | 21 | <table class="hunk"> |
templates/create.html modified
| @@ -1,9 +1,9 @@ | ||
| 1 | 1 | {{define "content"}} |
| 2 | -<h2>New repository</h2> | |
| 2 | +<div class="panel narrow"><h3>Create a new repository</h3> | |
| 3 | 3 | <form method="post" action="/create" class="stack"> |
| 4 | 4 | <label>Name <input name="name" required autofocus pattern="[a-z0-9][a-z0-9_.-]{0,38}"></label> |
| 5 | 5 | <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> | |
| 9 | 9 | {{end}} |
templates/index.html modified
| @@ -1,21 +1,33 @@ | ||
| 1 | 1 | {{define "content"}} |
| 2 | 2 | {{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> | |
| 6 | 13 | </div> |
| 7 | -{{template "repolist" .Data.Repos}} | |
| 8 | 14 | {{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> | |
| 17 | 24 | {{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> | |
| 18 | 30 | {{end}} |
| 19 | 31 | {{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}} | |
| 21 | 33 | {{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 @@ | ||
| 4 | 4 | <meta charset="utf-8"> |
| 5 | 5 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 6 | 6 | <title>{{.Title}}</title> |
| 7 | -<link rel="icon" href="/static/logo.svg" type="image/svg+xml"> | |
| 8 | 7 | <link rel="stylesheet" href="/static/style.css"> |
| 9 | 8 | <link rel="stylesheet" href="/static/highlight.css"> |
| 10 | 9 | {{if .Math}}<link rel="stylesheet" href="/static/katex/katex.min.css"> |
| @@ -15,22 +14,31 @@ | ||
| 15 | 14 | <body> |
| 16 | 15 | <header> |
| 17 | 16 | <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> | |
| 19 | 19 | <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}} | |
| 21 | 21 | </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> | |
| 22 | 31 | {{with .Data}}{{with index . "Page"}} |
| 23 | 32 | <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> | |
| 31 | 39 | </div> |
| 40 | +{{if .Repo.Description}}<div class="descbar"><div class="inner">{{.Repo.Description}}</div></div>{{end}} | |
| 32 | 41 | {{end}}{{end}} |
| 33 | -</header> | |
| 34 | 42 | <main> |
| 35 | 43 | {{if .Error}}<p class="error">{{.Error}}</p>{{end}} |
| 36 | 44 | {{template "content" .}} |
templates/log.html modified
| @@ -1,8 +1,8 @@ | ||
| 1 | 1 | {{define "content"}} |
| 2 | 2 | {{$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}} | |
| 4 | 4 | {{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}} | |
| 7 | 7 | {{end}} |
| 8 | 8 | {{end}} |
templates/login.html modified
| @@ -1,8 +1,8 @@ | ||
| 1 | 1 | {{define "content"}} |
| 2 | -<h2>Log in</h2> | |
| 2 | +<div class="panel narrow"><h3>Log in</h3> | |
| 3 | 3 | <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> | |
| 5 | 5 | <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> | |
| 8 | 8 | {{end}} |
templates/refs.html modified
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | {{define "content"}} |
| 2 | 2 | {{$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> | |
| 7 | 7 | {{end}} |
templates/register.html modified
| @@ -1,9 +1,9 @@ | ||
| 1 | 1 | {{define "content"}} |
| 2 | -<h2>Register</h2> | |
| 2 | +<div class="panel narrow"><h3>Register</h3> | |
| 3 | 3 | <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> | |
| 5 | 5 | <label>Password <input name="password" type="password" required minlength="8" autocomplete="new-password"></label> |
| 6 | 6 | {{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> | |
| 9 | 9 | {{end}} |
templates/repo.html modified
| @@ -1,28 +1,29 @@ | ||
| 1 | 1 | {{define "content"}} |
| 2 | 2 | {{$p := .Data.Page}} |
| 3 | 3 | {{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> | |
| 5 | 5 | <pre>git remote add origin git@{{.Host}}:{{$p.Repo.Path}} |
| 6 | 6 | git push -u origin {{$p.Ref}}</pre> |
| 7 | 7 | {{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> | |
| 8 | 11 | <div class="columns"> |
| 9 | 12 | <div class="main"> |
| 10 | 13 | {{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}} | |
| 12 | 15 | {{if not .Data.ReadmeName}}<p class="muted">No README.</p>{{end}} |
| 13 | 16 | </div> |
| 14 | 17 | <aside> |
| 15 | -<h3>Clone</h3> | |
| 18 | +<h4>Clone</h4> | |
| 16 | 19 | <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}} | |
| 19 | 22 | </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}} | |
| 26 | 27 | </aside> |
| 27 | 28 | </div> |
| 28 | 29 | {{end}} |
templates/reposettings.html modified
| @@ -1,14 +1,15 @@ | ||
| 1 | 1 | {{define "content"}} |
| 2 | 2 | {{$p := .Data.Page}} |
| 3 | -<h2>Repository settings</h2> | |
| 3 | +<div class="panel"><h3>Repository settings</h3> | |
| 4 | 4 | <form method="post" action="/{{$p.Repo.Path}}/settings" class="stack"> |
| 5 | 5 | <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> | |
| 10 | 11 | <form method="post" action="/{{$p.Repo.Path}}/delete" class="stack"> |
| 11 | 12 | <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> | |
| 14 | 15 | {{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 @@ | ||
| 1 | 1 | {{define "content"}} |
| 2 | 2 | {{$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> | |
| 16 | 10 | <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> | |
| 18 | 13 | </form> |
| 19 | -{{if $p.Avatar}}<form method="post" action="/settings/avatar/delete"><button class="danger">Remove avatar</button></form>{{end}} | |
| 20 | 14 | <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> | |
| 41 | 27 | </form> |
| 42 | 28 | {{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 @@ | ||
| 1 | 1 | {{define "content"}} |
| 2 | 2 | {{$p := .Data.Page}} |
| 3 | -{{template "crumbs" .Data}} | |
| 3 | +{{template "filebar" .Data}} | |
| 4 | 4 | <table class="tree"> |
| 5 | 5 | {{range .Data.Entries}}{{$c := index $.Data.Last .Name}} |
| 6 | 6 | <tr> |
| 7 | -<td class="mode">{{.Mode}}</td> | |
| 7 | +<td class="mode">{{perm .Mode}}</td> | |
| 8 | 8 | <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> |
| 9 | 9 | <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> | |
| 11 | 11 | <td class="when">{{if $c.Hash}}{{ago $c.Time}}{{end}}</td> |
| 12 | 12 | </tr> |
| 13 | 13 | {{end}} |
| 14 | 14 | </table> |
| 15 | 15 | {{end}} |
| 16 | -{{define "crumbs"}} | |
| 16 | +{{define "filebar"}} | |
| 17 | 17 | {{$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> | |
| 19 | 25 | {{end}} |
templates/user.html modified
| @@ -1,23 +1,20 @@ | ||
| 1 | 1 | {{define "content"}} |
| 2 | 2 | {{$p := .Data.Profile}} |
| 3 | 3 | <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> | |
| 14 | 12 | </aside> |
| 15 | 13 | <div class="main"> |
| 16 | -<h2>Repositories</h2> | |
| 17 | 14 | {{template "repolist" .Data.Repos}} |
| 18 | 15 | </div> |
| 19 | 16 | </div> |
| 20 | 17 | {{end}} |
| 21 | 18 | {{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}} | |
| 23 | 20 | {{end}} |
web.go modified
| @@ -68,6 +68,7 @@ func newServer(store *Store, root, host string, open bool) *Server { | ||
| 68 | 68 | return strings.Split(s, "\n") |
| 69 | 69 | }, |
| 70 | 70 | "inc": func(i int) int { return i + 1 }, |
| 71 | + "perm": perm, | |
| 71 | 72 | "join": func(parts []string, upto int) string { return strings.Join(parts[:upto], "/") }, |
| 72 | 73 | "string": func(b []byte) string { return string(b) }, |
| 73 | 74 | } |
| @@ -142,6 +143,10 @@ func (s *Server) handler() http.Handler { | ||
| 142 | 143 | mux.HandleFunc("GET /create", s.createForm) |
| 143 | 144 | mux.HandleFunc("POST /create", s.create) |
| 144 | 145 | 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) | |
| 145 | 150 | mux.HandleFunc("POST /settings/keys", s.addKey) |
| 146 | 151 | mux.HandleFunc("POST /settings/keys/{id}/delete", s.deleteKey) |
| 147 | 152 | mux.HandleFunc("POST /settings/tokens", s.addToken) |
| @@ -157,19 +162,17 @@ func (s *Server) handler() http.Handler { | ||
| 157 | 162 | |
| 158 | 163 | func (s *Server) index(w http.ResponseWriter, r *http.Request) { |
| 159 | 164 | 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) | |
| 167 | 166 | if err != nil { |
| 168 | 167 | s.fail(w, r, 500, err.Error()) |
| 169 | 168 | return |
| 170 | 169 | } |
| 170 | + var repos []Repo | |
| 171 | + if user != nil { | |
| 172 | + repos, _ = s.store.ReposByOwner(user.ID, true) | |
| 173 | + } | |
| 171 | 174 | 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}}) | |
| 173 | 176 | } |
| 174 | 177 | |
| 175 | 178 | func (s *Server) loginForm(w http.ResponseWriter, r *http.Request) { |
| @@ -287,14 +290,48 @@ func (s *Server) repoDir(repo Repo) string { | ||
| 287 | 290 | } |
| 288 | 291 | |
| 289 | 292 | 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) { | |
| 290 | 310 | u := s.require(w, r) |
| 291 | 311 | if u == nil { |
| 292 | 312 | return |
| 293 | 313 | } |
| 294 | 314 | 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 | + } | |
| 295 | 323 | 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) | |
| 298 | 335 | } |
| 299 | 336 | |
| 300 | 337 | func (s *Server) profile(w http.ResponseWriter, r *http.Request) { |
| @@ -309,7 +346,7 @@ func (s *Server) profile(w http.ResponseWriter, r *http.Request) { | ||
| 309 | 346 | } |
| 310 | 347 | return v |
| 311 | 348 | } |
| 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)} | |
| 313 | 350 | if err := s.store.UpdateProfile(u.ID, p); err != nil { |
| 314 | 351 | http.Redirect(w, r, "/settings?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) |
| 315 | 352 | return |
| @@ -326,16 +363,16 @@ func (s *Server) avatar(w http.ResponseWriter, r *http.Request) { | ||
| 326 | 363 | if u == nil { |
| 327 | 364 | return |
| 328 | 365 | } |
| 329 | - r.Body = http.MaxBytesReader(w, r.Body, 8<<20) | |
| 366 | + r.Body = http.MaxBytesReader(w, r.Body, 16<<20) | |
| 330 | 367 | f, _, err := r.FormFile("avatar") |
| 331 | 368 | 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) | |
| 333 | 370 | return |
| 334 | 371 | } |
| 335 | 372 | defer f.Close() |
| 336 | 373 | data, err := io.ReadAll(f) |
| 337 | 374 | 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) | |
| 339 | 376 | return |
| 340 | 377 | } |
| 341 | 378 | x, _ := strconv.Atoi(r.FormValue("x")) |
| @@ -351,7 +388,7 @@ func (s *Server) avatar(w http.ResponseWriter, r *http.Request) { | ||
| 351 | 388 | http.Redirect(w, r, "/settings?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) |
| 352 | 389 | return |
| 353 | 390 | } |
| 354 | - s.store.SetAvatar(u.ID, true) | |
| 391 | + s.store.SetAvatar(u.ID, time.Now().Unix()) | |
| 355 | 392 | http.Redirect(w, r, "/~"+u.Name, http.StatusSeeOther) |
| 356 | 393 | } |
| 357 | 394 | |
| @@ -361,7 +398,7 @@ func (s *Server) deleteAvatar(w http.ResponseWriter, r *http.Request) { | ||
| 361 | 398 | return |
| 362 | 399 | } |
| 363 | 400 | os.Remove(s.avatarPath(u.ID)) |
| 364 | - s.store.SetAvatar(u.ID, false) | |
| 401 | + s.store.SetAvatar(u.ID, 0) | |
| 365 | 402 | http.Redirect(w, r, "/settings", http.StatusSeeOther) |
| 366 | 403 | } |
| 367 | 404 | |
| @@ -373,7 +410,7 @@ func (s *Server) serveAvatar(w http.ResponseWriter, r *http.Request, owner User) | ||
| 373 | 410 | } |
| 374 | 411 | w.Header().Set("Content-Type", "image/png") |
| 375 | 412 | 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") | |
| 377 | 414 | w.Write(data) |
| 378 | 415 | } |
| 379 | 416 | |
| @@ -383,10 +420,10 @@ func (s *Server) addKey(w http.ResponseWriter, r *http.Request) { | ||
| 383 | 420 | return |
| 384 | 421 | } |
| 385 | 422 | 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) | |
| 387 | 424 | return |
| 388 | 425 | } |
| 389 | - http.Redirect(w, r, "/settings", http.StatusSeeOther) | |
| 426 | + http.Redirect(w, r, "/settings/keys", http.StatusSeeOther) | |
| 390 | 427 | } |
| 391 | 428 | |
| 392 | 429 | func (s *Server) deleteKey(w http.ResponseWriter, r *http.Request) { |
| @@ -396,7 +433,7 @@ func (s *Server) deleteKey(w http.ResponseWriter, r *http.Request) { | ||
| 396 | 433 | } |
| 397 | 434 | id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) |
| 398 | 435 | s.store.DeleteKey(u.ID, id) |
| 399 | - http.Redirect(w, r, "/settings", http.StatusSeeOther) | |
| 436 | + http.Redirect(w, r, "/settings/keys", http.StatusSeeOther) | |
| 400 | 437 | } |
| 401 | 438 | |
| 402 | 439 | func (s *Server) addToken(w http.ResponseWriter, r *http.Request) { |
| @@ -406,10 +443,10 @@ func (s *Server) addToken(w http.ResponseWriter, r *http.Request) { | ||
| 406 | 443 | } |
| 407 | 444 | token, err := s.store.CreateToken(u.ID, strings.TrimSpace(r.FormValue("title"))) |
| 408 | 445 | 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) | |
| 410 | 447 | return |
| 411 | 448 | } |
| 412 | - http.Redirect(w, r, "/settings?token="+token, http.StatusSeeOther) | |
| 449 | + http.Redirect(w, r, "/settings/tokens?token="+token, http.StatusSeeOther) | |
| 413 | 450 | } |
| 414 | 451 | |
| 415 | 452 | func (s *Server) deleteToken(w http.ResponseWriter, r *http.Request) { |
| @@ -419,7 +456,7 @@ func (s *Server) deleteToken(w http.ResponseWriter, r *http.Request) { | ||
| 419 | 456 | } |
| 420 | 457 | id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) |
| 421 | 458 | s.store.DeleteToken(u.ID, id) |
| 422 | - http.Redirect(w, r, "/settings", http.StatusSeeOther) | |
| 459 | + http.Redirect(w, r, "/settings/tokens", http.StatusSeeOther) | |
| 423 | 460 | } |
| 424 | 461 | |
| 425 | 462 | func (s *Server) password(w http.ResponseWriter, r *http.Request) { |
| @@ -429,14 +466,14 @@ func (s *Server) password(w http.ResponseWriter, r *http.Request) { | ||
| 429 | 466 | } |
| 430 | 467 | if _, ok := s.store.CheckPassword(u.Name, r.FormValue("current")); !ok { |
| 431 | 468 | 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) | |
| 433 | 470 | return |
| 434 | 471 | } |
| 435 | 472 | 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) | |
| 437 | 474 | return |
| 438 | 475 | } |
| 439 | - http.Redirect(w, r, "/settings", http.StatusSeeOther) | |
| 476 | + http.Redirect(w, r, "/settings/security", http.StatusSeeOther) | |
| 440 | 477 | } |
| 441 | 478 | |
| 442 | 479 | func (s *Server) route(w http.ResponseWriter, r *http.Request) { |
| @@ -457,7 +494,12 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) { | ||
| 457 | 494 | s.fail(w, r, 500, err.Error()) |
| 458 | 495 | return |
| 459 | 496 | } |
| 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}}) | |
| 461 | 503 | return |
| 462 | 504 | } |
| 463 | 505 | name := parts[1] |
| @@ -516,6 +558,7 @@ type repoPage struct { | ||
| 516 | 558 | Commit string |
| 517 | 559 | Owner bool |
| 518 | 560 | Empty bool |
| 561 | + Tab string | |
| 519 | 562 | } |
| 520 | 563 | |
| 521 | 564 | 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) { | ||
| 545 | 588 | s.fail(w, r, 404, "No such ref") |
| 546 | 589 | return |
| 547 | 590 | } |
| 591 | + rp.Tab = "summary" | |
| 548 | 592 | data := map[string]any{"Page": rp} |
| 549 | 593 | if !rp.Empty { |
| 550 | - commits, _ := g.Log(rp.Commit, 8, "") | |
| 594 | + commits, _ := g.Log(rp.Commit, 3, "") | |
| 551 | 595 | data["Commits"] = commits |
| 552 | 596 | branches, _ := g.Refs("refs/heads") |
| 553 | 597 | tags, _ := g.Refs("refs/tags") |
| @@ -618,11 +662,18 @@ func (s *Server) tree(w http.ResponseWriter, r *http.Request, repo Repo, spec st | ||
| 618 | 662 | s.fail(w, r, 404, "No such ref") |
| 619 | 663 | return |
| 620 | 664 | } |
| 665 | + rp.Tab = "tree" | |
| 621 | 666 | crumbs := strings.Split(filePath, "/") |
| 622 | 667 | if filePath == "" { |
| 623 | 668 | crumbs = nil |
| 624 | 669 | } |
| 625 | 670 | 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 | + } | |
| 626 | 677 | switch g.Kind(rp.Commit, filePath) { |
| 627 | 678 | case "tree", "commit": |
| 628 | 679 | 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 | ||
| 703 | 754 | s.fail(w, r, 404, "No such ref") |
| 704 | 755 | return |
| 705 | 756 | } |
| 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} | |
| 707 | 764 | if !rp.Empty { |
| 708 | 765 | start := rp.Commit |
| 709 | 766 | 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 | ||
| 713 | 770 | } |
| 714 | 771 | } |
| 715 | 772 | const per = 50 |
| 716 | - commits, err := g.Log(start, per+1, "") | |
| 773 | + commits, err := g.Log(start, per+1, filePath) | |
| 717 | 774 | if err != nil { |
| 718 | 775 | s.fail(w, r, 500, err.Error()) |
| 719 | 776 | return |
| @@ -733,6 +790,7 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request, repo Repo, hash | ||
| 733 | 790 | s.fail(w, r, 404, "No such commit") |
| 734 | 791 | return |
| 735 | 792 | } |
| 793 | + rp.Tab = "log" | |
| 736 | 794 | c, err := g.Commit(rp.Commit) |
| 737 | 795 | if err != nil { |
| 738 | 796 | s.fail(w, r, 404, "No such commit") |
| @@ -757,6 +815,7 @@ func (s *Server) refs(w http.ResponseWriter, r *http.Request, repo Repo) { | ||
| 757 | 815 | s.fail(w, r, 404, "No such ref") |
| 758 | 816 | return |
| 759 | 817 | } |
| 818 | + rp.Tab = "refs" | |
| 760 | 819 | branches, _ := g.Refs("refs/heads") |
| 761 | 820 | tags, _ := g.Refs("refs/tags") |
| 762 | 821 | 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) | ||
| 768 | 827 | s.fail(w, r, 403, "Not your repository") |
| 769 | 828 | return |
| 770 | 829 | } |
| 830 | + rp.Tab = "settings" | |
| 771 | 831 | s.render(w, r, "reposettings", page{Title: repo.Path() + " settings", Data: map[string]any{"Page": rp}}) |
| 772 | 832 | } |
| 773 | 833 | |
| @@ -824,6 +884,30 @@ func ago(t time.Time) string { | ||
| 824 | 884 | } |
| 825 | 885 | } |
| 826 | 886 | |
| 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 | + | |
| 827 | 911 | func humanSize(n int64) string { |
| 828 | 912 | switch { |
| 829 | 913 | case n < 1024: |