github.com/troglobit / digit

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 ago commit 213b268 · parent 71cf00d · child ff62e89 · patch
diff --git a/internal/web/pages.go b/internal/web/pages.go
index 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.go
index 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.js
index 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.js
index 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.css
index 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.html
index 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">&#9776;</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.html
index 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">&#9776;</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.html
index 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.html
index 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">&#9776;</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">&#9776;</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.html
index 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}}