Frame the file views and unify the file-actions menu
The blob view was a bare table under a path crumb that duplicated the
repo title and overflowed on mobile, and the readme box carried only a
'source' link, with no room for raw.
Fold the path into the repo title (dropping the duplicate crumb line),
and give blob, blame, and the overview readme box a framed header with a
☰ menu: the readme box lists its documents then source/raw/blame; blob
and blame carry their own file actions. One <details> dropdown at every
width replaces the mobile-only checkbox switcher.
Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
Joachim Wiberg · 59 days agocommit 213b268 · parent 71cf00d · child ff62e89 · patch
diff --git a/internal/web/pages.go b/internal/web/pages.goindex c2d2b39..d2d84bb 100644--- a/internal/web/pages.go+++ b/internal/web/pages.go@@ -45,10 +45,11 @@ type repoHead struct { Website string View string // active tab: overview, tree, log, refs Refsel refsel- CloneURL string // https, when features.clone and server.name- SSHURL string // when server.ssh is configured- Searchable bool // show the log search box in the title row- Query string // active log search+ CloneURL string // https, when features.clone and server.name+ SSHURL string // when server.ssh is configured+ Searchable bool // show the log search box in the title row+ Query string // active log search+ Crumbs []crumb // path breadcrumb, appended to the title on file views } // refsel is the branch selector; a sub-struct so embedding repoHead@@ -74,6 +75,21 @@ func (s *Server) head(repo *git.Repo, ref, view, path string) repoHead { } meta := repo.Meta() h := repoHead{Name: repo.Name, Ref: ref, Upstream: meta["upstream"], Website: meta["website"], View: view}+ // File views show the filename in the frame header, so the title+ // breadcrumb only needs the containing directory.+ crumbPath := path+ fileView := view == "blob" || view == "blame" || view == "doc"+ if fileView {+ if crumbPath = pathpkg.Dir(path); crumbPath == "." {+ crumbPath = ""+ }+ }+ h.Crumbs = crumbs(repo.Name, ref, crumbPath)+ if fileView && len(h.Crumbs) > 0 {+ // The file (shown in the frame header) is the current location,+ // so its parent directory stays a clickable crumb.+ h.Crumbs[len(h.Crumbs)-1].URL = repoURL(repo.Name, "tree", ref, crumbPath)+ } if s.cfg.Features.Clone && s.cfg.Server.Name != "" { h.CloneURL = "https://" + s.cfg.Server.Name + "/" + repo.Name }@@ -144,7 +160,6 @@ type treeEntry struct { type treeData struct { repoHead- Crumbs []crumb Entries []treeEntry ReadmeName string ReadmeBlob string@@ -168,7 +183,6 @@ func (s *Server) tree(w http.ResponseWriter, r *http.Request, repo *git.Repo, re } d := treeData{ repoHead: s.head(repo, ref, "tree", path),- Crumbs: crumbs(repo.Name, ref, path), } for _, e := range ents { var u string@@ -204,7 +218,7 @@ const maxBlobRender = 1 << 20 // 1 MiB type blobData struct { repoHead- Crumbs []crumb+ FileName string RawURL string BlameURL string Size int64@@ -226,7 +240,7 @@ func (s *Server) blob(w http.ResponseWriter, repo *git.Repo, rest string) error } d := blobData{ repoHead: s.head(repo, ref, "blob", path),- Crumbs: crumbs(repo.Name, ref, path),+ FileName: pathpkg.Base(path), RawURL: repoURL(repo.Name, "raw", ref, path), BlameURL: repoURL(repo.Name, "blame", ref, path), Size: int64(len(data)),@@ -281,7 +295,7 @@ func (s *Server) doc(w http.ResponseWriter, r *http.Request, repo *git.Repo, res } s.render(w, http.StatusOK, "doc", repo.Name+"/"+path, docData{ repoHead: s.head(repo, ref, "doc", path),- FileName: path,+ FileName: pathpkg.Base(path), BlobURL: repoURL(repo.Name, "blob", ref, path), HTML: s.renderFile(repo, ref, path, data), })@@ -302,7 +316,7 @@ type blameGroup struct { type blameData struct { repoHead- Crumbs []crumb+ FileName string BlobURL string RawURL string Lang string@@ -320,7 +334,7 @@ func (s *Server) blame(w http.ResponseWriter, repo *git.Repo, rest string) error } d := blameData{ repoHead: s.head(repo, ref, "blame", path),- Crumbs: crumbs(repo.Name, ref, path),+ FileName: pathpkg.Base(path), BlobURL: repoURL(repo.Name, "blob", ref, path), RawURL: repoURL(repo.Name, "raw", ref, path), Lang: langFor(path),diff --git a/internal/web/server.go b/internal/web/server.goindex 2260ca6..5c683d5 100644--- a/internal/web/server.go+++ b/internal/web/server.go@@ -512,10 +512,12 @@ type summaryData struct { // friends, switched in place GitHub-style. URL is the no-JS // fallback to the stand-alone doc view. type docTab struct {- Label string- URL string- BlobURL string- HTML template.HTML+ Label string+ URL string+ BlobURL string+ RawURL string+ BlameURL string+ HTML template.HTML } // quickFiles are well-known files shown as shortcuts in the readme@@ -590,10 +592,12 @@ func (s *Server) summary(w http.ResponseWriter, repo *git.Repo, ref string) erro label = licenseLabel(data) } d.Docs = append(d.Docs, docTab{- Label: label,- URL: repoURL(repo.Name, "doc", ref, path),- BlobURL: repoURL(repo.Name, "blob", ref, path),- HTML: s.renderFile(repo, ref, path, data),+ Label: label,+ URL: repoURL(repo.Name, "doc", ref, path),+ BlobURL: repoURL(repo.Name, "blob", ref, path),+ RawURL: repoURL(repo.Name, "raw", ref, path),+ BlameURL: repoURL(repo.Name, "blame", ref, path),+ HTML: s.renderFile(repo, ref, path, data), }) } for _, n := range s.cfg.Repo.Readme {diff --git a/static/clone.js b/static/clone.jsindex 6e1cbce..e74500a 100644--- a/static/clone.js+++ b/static/clone.js@@ -18,7 +18,7 @@ done(); return; }- var opens = document.querySelectorAll("details.refmenu[open], details.clone-menu[open]");+ var opens = document.querySelectorAll("details.refmenu[open], details.clone-menu[open], details.filemenu[open]"); for (var i = 0; i < opens.length; i++) if (!opens[i].contains(e.target)) opens[i].removeAttribute("open");diff --git a/static/doctabs.js b/static/doctabs.jsindex 90d2d01..9137bea 100644--- a/static/doctabs.js+++ b/static/doctabs.js@@ -1,14 +1,17 @@ /* Overview file box: switch between readme, license, and friends in- * place. The tab hrefs point at the stand-alone doc view, so- * everything still works without JavaScript. */+ * place, and repoint the source/raw/blame actions at the active doc.+ * The tabs are real links to the stand-alone doc view, so it all works+ * without JavaScript too. */ (function () { "use strict";- var tabs = document.querySelectorAll(".filehead a.doctab");+ var tabs = document.querySelectorAll(".filemenu a.doctab"); var bodies = document.querySelectorAll(".readme .filebody");- var src = document.getElementById("docsrc"); var title = document.getElementById("doctitle");- var toggle = document.getElementById("doctoggle");+ var src = document.getElementById("docsrc");+ var raw = document.getElementById("docraw");+ var blame = document.getElementById("docblame");+ var menu = document.querySelector(".readme .filemenu"); if (tabs.length < 2) return;@@ -18,12 +21,17 @@ if (bodies[i]) bodies[i].hidden = i !== n; }- if (src)- src.href = tabs[n].getAttribute("data-blob");+ var t = tabs[n]; if (title)- title.textContent = tabs[n].textContent;- if (toggle)- toggle.checked = false; /* close the mobile dropdown */+ title.textContent = t.textContent;+ if (src)+ src.href = t.getAttribute("data-blob");+ if (raw)+ raw.href = t.getAttribute("data-raw");+ if (blame)+ blame.href = t.getAttribute("data-blame");+ if (menu)+ menu.removeAttribute("open"); /* close after picking */ } for (var i = 0; i < tabs.length; i++)diff --git a/static/style.css b/static/style.cssindex da0a908..fcc6566 100644--- a/static/style.css+++ b/static/style.css@@ -213,7 +213,7 @@ details.clone-menu > summary::after { color: var(--bg); } /* Floating-panel chrome, shared by the desktop ref/clone dropdowns and the mobile ☰ panels. The :checked selectors are inert on desktop — the toggles are display:none there, so they never match. */-.ref-panel, .clone-panel, .more-toggle:checked ~ .more-panel, .doc-toggle:checked ~ .doctabs {+.ref-panel, .clone-panel, .more-toggle:checked ~ .more-panel, .menu-panel { position: absolute; right: 0; top: calc(100% + 4px);@@ -343,35 +343,56 @@ table.log tr.cur td { background: var(--code-bg); } table.log tr.cur td:first-child { box-shadow: inset 2px 0 0 var(--accent); } table.log tr[hidden] { display: none; }-.readme {+.readme, .fileframe { border: 1px solid var(--border); border-radius: 6px;- margin-top: 1.5rem; overflow-wrap: break-word; }-.readme .filehead {+.readme { margin-top: 1.5rem; }+.fileframe { margin-top: 1rem; }+.filehead {+ display: flex;+ align-items: center;+ gap: 0.75rem;+ position: relative; /* anchor for the file menu dropdown */ background: var(--code-bg); border-bottom: 1px solid var(--border); border-radius: 6px 6px 0 0;- padding: 0.5rem 1rem;+ padding: 0.4rem 1rem; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.875rem; }-.readme .filehead {+.filehead a { color: var(--muted); }+.filehead a:hover { color: var(--fg); text-decoration: none; }+.filehead a.active { color: var(--fg); font-weight: 600; }+.doc-title { color: var(--fg); font-weight: 600; }+.filesize { color: var(--muted); }+/* File-actions menu (README box + blob/blame frames): one ☰ dropdown of+ doc switches and source/raw/blame actions, at every width. */+.filemenu { position: relative; margin-left: auto; }+.filemenu > summary {+ list-style: none;+ cursor: pointer;+ color: var(--muted);+ padding: 0 0.25rem;+ font-size: 1.1rem;+}+.filemenu > summary::-webkit-details-marker { display: none; }+.filemenu > summary:hover, .filemenu[open] > summary { color: var(--fg); }+.menu-panel { display: flex;- gap: 1.25rem;- align-items: baseline;- flex-wrap: wrap;- position: relative; /* anchor for the mobile doc dropdown */-}-.readme .filehead a { color: var(--muted); }-.readme .filehead a:hover { color: var(--fg); text-decoration: none; }-.readme .filehead a.active { color: var(--fg); font-weight: 600; }-.readme .filehead a.src { margin-left: auto; }-/* Desktop: the wrapper is transparent so the tabs + source lay out- as direct flex children. Mobile turns it into a dropdown. */-.doctabs { display: contents; }-.doc-title, .doc-toggle, .doc-summary, .doc-backdrop { display: none; }+ flex-direction: column;+ min-width: 10rem;+ max-width: calc(100vw - 1.5rem);+ padding: 0.3rem 0;+}+.menu-panel a { padding: 0.4rem 0.9rem; color: var(--fg); }+.menu-panel a:hover { color: var(--accent); text-decoration: none; }+.menu-panel a.active { color: var(--accent); font-weight: 600; }+.menu-panel hr { border: 0; border-top: 1px solid var(--border); margin: 0.3rem 0; }+/* Blob/blame frame: the code/blame table joins the header seamlessly. */+.fileframe .scrollx { border: none; border-radius: 0 0 6px 6px; }+.fileframe .filebody { padding: 0.75rem 1rem; } .readme .filebody { padding: 0.5rem 2rem 1rem; } .readme img { max-width: 100%; } .readme pre {@@ -417,7 +438,6 @@ blockquote.alert::before { } h2 { font-size: 1.1rem; }-.crumbs { color: var(--muted); } .pager { display: flex; margin-top: 1rem; } .pager a.older { margin-left: auto; }@@ -561,36 +581,31 @@ pre.diff { padding-right: 0.8rem; } .spacer { display: none; }- /* Shared ☰ mechanics — the nav "more" menu and the content-frame doc- switcher are the same disclosure: a visually-hidden but focusable- checkbox, a label acting as the ☰ button, an opened column panel,- and a full-viewport backdrop to tap-close (no JS). */- .more-toggle, .doc-toggle {+ /* Nav ☰ mechanics: a visually-hidden but focusable checkbox, a label+ acting as the ☰ button, an opened column panel, and a full-viewport+ backdrop to tap-close (no JS). */+ .more-toggle { position: absolute; /* off-screen but focusable for keyboard */ width: 1px; height: 1px; opacity: 0; }- .more-summary, .doc-summary {+ .more-summary { display: inline-flex; align-items: center; cursor: pointer; font-size: 1.1rem; color: var(--muted);+ padding: 0.35rem 0.5rem; }- .more-summary { padding: 0.35rem 0.5rem; }- .doc-summary { margin-left: auto; padding: 0 0.25rem; }- .more-toggle:focus-visible + .more-summary,- .doc-toggle:focus-visible + .doc-summary {+ .more-toggle:focus-visible + .more-summary { outline: 2px solid var(--accent); outline-offset: 2px; }- .more-toggle:checked + .more-summary,- .doc-toggle:checked + .doc-summary { color: var(--fg); }+ .more-toggle:checked + .more-summary { color: var(--fg); } /* Opened panel: a uniform column that fits its contents (chrome comes from the shared floating-panel rule). */- .more-toggle:checked ~ .more-panel,- .doc-toggle:checked ~ .doctabs {+ .more-toggle:checked ~ .more-panel { display: flex; flex-direction: column; align-items: stretch;@@ -598,8 +613,7 @@ pre.diff { max-width: calc(100vw - 1.5rem); padding: 0.25rem 0; }- .more-toggle:checked ~ .more-backdrop,- .doc-toggle:checked ~ .doc-backdrop {+ .more-toggle:checked ~ .more-backdrop { display: block; position: fixed; inset: 0;@@ -642,13 +656,6 @@ pre.diff { .more-panel .clone-panel a.dl { display: block; margin-top: 0.4rem; } /* let the URL field shrink so the copy button stays on-screen */ .more-panel .copyrow input { min-width: 0; }- /* Content-frame doc switcher: keep the active doc name as the title- and fold the rest (+ source) behind a ☰, mirroring the nav. */- .filehead.foldable { align-items: center; }- .doc-title { display: block; color: var(--fg); font-weight: 600; }- .foldable .doctabs { display: none; } /* open rule out-specifies this */- .doc-toggle:checked ~ .doctabs a { padding: 0.5rem 0.9rem; }- .doc-toggle:checked ~ .doctabs a.src { margin-left: 0; } .logsearch { margin-left: 0; width: 100%; } .logsearch input { width: 100%; } .keyhelp > div { min-width: 0; width: calc(100% - 2rem); }diff --git a/templates/blame.html b/templates/blame.htmlindex a483224..cff9eed 100644--- a/templates/blame.html+++ b/templates/blame.html@@ -1,30 +1,41 @@ {{define "content"}} {{with .Data}} {{template "repohead" .}}-<p class="crumbs">{{template "crumbs" .Crumbs}}- <span class="muted">(<a href="{{.BlobURL}}">blob</a>, <a href="{{.RawURL}}">raw</a>)</span></p>-{{if .TooLarge}}-<p class="muted">file too large to blame; <a href="{{.RawURL}}">view raw</a></p>-{{else}}-<div class="scrollx">-<table class="blame"{{with .Lang}} data-lang="{{.}}"{{end}}>- <caption class="sr-only">Blame</caption>- {{range .Groups}}{{$g := .}}- {{range $i, $l := .Lines}}- <tr{{if eq $i 0}} class="grp"{{end}}>- {{if eq $i 0}}- <td class="who" rowspan="{{len $g.Lines}}"><a class="sha" href="{{url $.Data.Name "commit" $g.Hash}}">{{shortsha $g.Hash}}</a> <span class="muted">{{$g.Author}}, {{ago $g.Date}}</span></td>+<div class="fileframe">+ <div class="filehead">+ <span class="doc-title">{{.FileName}}</span>+ <span class="filesize">blame</span>+ <details class="filemenu">+ <summary aria-label="File actions">☰</summary>+ <div class="menu-panel">+ <a href="{{.BlobURL}}">blob</a>+ <a href="{{.RawURL}}">raw</a>+ </div>+ </details>+ </div>+ {{if .TooLarge}}+ <div class="filebody"><p class="muted">file too large to blame; <a href="{{.RawURL}}">view raw</a></p></div>+ {{else}}+ <div class="scrollx">+ <table class="blame"{{with .Lang}} data-lang="{{.}}"{{end}}>+ <caption class="sr-only">Blame</caption>+ {{range .Groups}}{{$g := .}}+ {{range $i, $l := .Lines}}+ <tr{{if eq $i 0}} class="grp"{{end}}>+ {{if eq $i 0}}+ <td class="who" rowspan="{{len $g.Lines}}"><a class="sha" href="{{url $.Data.Name "commit" $g.Hash}}">{{shortsha $g.Hash}}</a> <span class="muted">{{$g.Author}}, {{ago $g.Date}}</span></td>+ {{end}}+ <td class="nums"><a id="L{{$l.No}}" href="#L{{$l.No}}">{{$l.No}}</a></td>+ <td class="line">{{$l.Text}}</td>+ </tr> {{end}}- <td class="nums"><a id="L{{$l.No}}" href="#L{{$l.No}}">{{$l.No}}</a></td>- <td class="line">{{$l.Text}}</td>- </tr>- {{end}}+ {{end}}+ </table>+ </div> {{end}}-</table> </div> {{end}} {{end}}-{{end}} {{define "scripts"}} {{if not .Data.TooLarge}}<script src="{{asset "lines.js"}}" defer></script>{{end}}diff --git a/templates/blob.html b/templates/blob.htmlindex 6b94312..f20d688 100644--- a/templates/blob.html+++ b/templates/blob.html@@ -1,25 +1,36 @@ {{define "content"}} {{with .Data}} {{template "repohead" .}}-<p class="crumbs">{{template "crumbs" .Crumbs}}- <span class="muted">({{prettysize .Size}}, <a href="{{.RawURL}}">raw</a>{{if and (not .Binary) (feature "blame")}}, <a href="{{.BlameURL}}">blame</a>{{end}})</span></p>-{{if .TooLarge}}-<p class="muted">file too large to display; <a href="{{.RawURL}}">view raw</a></p>-{{else if .Binary}}-<p class="muted">binary file; <a href="{{.RawURL}}">download</a></p>-{{else}}-<div class="scrollx">-<table class="blob">- <tr>- <td class="nums"><pre>{{range .LineNums}}<a id="L{{.}}" href="#L{{.}}">{{.}}</a>+<div class="fileframe">+ <div class="filehead">+ <span class="doc-title">{{.FileName}}</span>+ <span class="filesize">{{prettysize .Size}}</span>+ <details class="filemenu">+ <summary aria-label="File actions">☰</summary>+ <div class="menu-panel">+ <a href="{{.RawURL}}">raw</a>+ {{if and (not .Binary) (feature "blame")}}<a href="{{.BlameURL}}">blame</a>{{end}}+ </div>+ </details>+ </div>+ {{if .TooLarge}}+ <div class="filebody"><p class="muted">file too large to display; <a href="{{.RawURL}}">view raw</a></p></div>+ {{else if .Binary}}+ <div class="filebody"><p class="muted">binary file; <a href="{{.RawURL}}">download</a></p></div>+ {{else}}+ <div class="scrollx">+ <table class="blob">+ <tr>+ <td class="nums"><pre>{{range .LineNums}}<a id="L{{.}}" href="#L{{.}}">{{.}}</a> {{end}}</pre></td>- <td class="code"><pre><code{{with .Lang}} class="language-{{.}}"{{end}}>{{.Code}}</code></pre></td>- </tr>-</table>+ <td class="code"><pre><code{{with .Lang}} class="language-{{.}}"{{end}}>{{.Code}}</code></pre></td>+ </tr>+ </table>+ </div>+ {{end}} </div> {{end}} {{end}}-{{end}} {{define "scripts"}} {{if .Data.Code}}<script src="{{asset "lines.js"}}" defer></script>{{end}}diff --git a/templates/layout.html b/templates/layout.htmlindex 3e4e1c5..cb94899 100644--- a/templates/layout.html+++ b/templates/layout.html@@ -33,7 +33,7 @@ {{define "repohead"}} <div class="repo-title">- <h1 class="repo-name">{{with dirname .Name}}<a class="org" href="{{url .}}/">{{.}}</a> <span class="muted">/</span> {{end}}<a href="{{url .Name}}/">{{basename .Name}}</a></h1>+ <h1 class="repo-name">{{with dirname .Name}}<a class="org" href="{{url .}}/">{{.}}</a> <span class="muted">/</span> {{end}}<a href="{{url .Name}}/">{{basename .Name}}</a>{{range $i, $c := .Crumbs}}{{if $i}} <span class="muted">/</span> {{if $c.URL}}<a href="{{$c.URL}}">{{$c.Name}}</a>{{else}}{{$c.Name}}{{end}}{{end}}{{end}}</h1> {{with .Description}}<p class="desc">{{.}}</p>{{end}} {{if .Searchable}} <form class="logsearch" method="get" action="{{url .Name "log" .Ref}}">diff --git a/templates/repo.html b/templates/repo.htmlindex ff1d77c..f9ab6b3 100644--- a/templates/repo.html+++ b/templates/repo.html@@ -10,17 +10,17 @@ {{if .Docs}} <article class="readme"> {{$multi := gt (len .Docs) 1}}- <div class="filehead{{if $multi}} foldable{{end}}">- {{if $multi}}+ <div class="filehead"> <span class="doc-title" id="doctitle">{{(index .Docs 0).Label}}</span>- <input type="checkbox" id="doctoggle" class="doc-toggle">- <label for="doctoggle" class="doc-summary" aria-label="Documents">☰</label>- {{end}}- <div class="doctabs">- {{range $i, $t := .Docs}}<a class="doctab{{if not $i}} active{{end}}" href="{{$t.URL}}" data-blob="{{$t.BlobURL}}">{{$t.Label}}</a>{{end}}- <a class="src" id="docsrc" href="{{(index .Docs 0).BlobURL}}">source</a>- </div>- {{if $multi}}<label for="doctoggle" class="doc-backdrop" aria-hidden="true"></label>{{end}}+ <details class="filemenu">+ <summary aria-label="File menu">☰</summary>+ <div class="menu-panel">+ {{if $multi}}{{range $i, $t := .Docs}}<a class="doctab{{if not $i}} active{{end}}" href="{{$t.URL}}" data-blob="{{$t.BlobURL}}" data-raw="{{$t.RawURL}}" data-blame="{{$t.BlameURL}}">{{$t.Label}}</a>{{end}}<hr>{{end}}+ <a id="docsrc" href="{{(index .Docs 0).BlobURL}}">source</a>+ <a id="docraw" href="{{(index .Docs 0).RawURL}}">raw</a>+ {{if feature "blame"}}<a id="docblame" href="{{(index .Docs 0).BlameURL}}">blame</a>{{end}}+ </div>+ </details> </div> {{range $i, $t := .Docs}} <div class="filebody"{{if $i}} hidden{{end}}>{{$t.HTML}}</div>diff --git a/templates/tree.html b/templates/tree.htmlindex 43d85e5..31442ca 100644--- a/templates/tree.html+++ b/templates/tree.html@@ -1,7 +1,6 @@ {{define "content"}} {{with .Data}} {{template "repohead" .}}-<p class="crumbs">{{template "crumbs" .Crumbs}}</p> <table class="tree"> <caption class="sr-only">Files</caption> {{range .Entries}}