@ 261101.7

261029.4 · 00011b6688dc6372821c09b0a62727dbb8effcb3

A search's recent pages come only from the page's own tree

Recent pages crossed trees, so on the web address a search in one repo listed pages from repos read before. Each page's search box now names its tree, and each stored entry keeps the tree it was opened in. A version counts as its tree. Entries stored before this have no tree and are dropped.

reading declarations

assets/layout.html

@@ -13,7 +13,7 @@ <nav>{{range $i, $c := .Crumbs}}{{if $i}}&nbsp;/ {{end}}{{if $c.Href}}<a href="{{$c.Href}}"{{with $c.Title}} title="{{.}}"{{end}}>{{$c.Label}}</a>{{else if $c.Title}}<span title="{{$c.Title}}">{{$c.Label}}</span>{{else}}<span>{{$c.Label}}</span>{{end}}{{end}}</nav> {{if not .Stopped}}<span>{{with .Status}}<span class="age">{{.}}</span>{{end}}{{if .Raw}}<a href="?raw">raw</a>{{end}}{{if not (or .Shared .NoStop)}}<form method="post" action="{{ownPath}}stop">{{if .Tree}}<input type="hidden" name="tree" value="{{.Tree}}"><button>Delete lock</button>{{else}}<button class="kill">Kill server</button>{{end}}</form>{{end}}</span>{{end}} </header>-{{if not .Stopped}}{{with .Search}}<div class="search"><input type="search" class="search" data-search="{{.}}" placeholder="search" aria-label="Search this directory" autocomplete="off"><span class="tools"><button class="copy" title="copy the page's text" aria-label="copy the page's text">⧉</button>{{if $.Forge}}<button class="share" data-forge="{{$.Forge}}" data-lines="{{$.ForgeLines}}" title="copy the forge's link to what is selected, or to the page" aria-label="copy the forge's link to what is selected, or to the page">⤴</button>{{else}}<button class="share" title="copy a link to what is selected, or to the page" aria-label="copy a link to what is selected, or to the page">⤴</button>{{end}}</span></div>+{{if not .Stopped}}{{with .Search}}<div class="search"><input type="search" class="search" data-search="{{.}}" data-recents="{{$.Recents}}" placeholder="search" aria-label="Search this directory" autocomplete="off"><span class="tools"><button class="copy" title="copy the page's text" aria-label="copy the page's text">⧉</button>{{if $.Forge}}<button class="share" data-forge="{{$.Forge}}" data-lines="{{$.ForgeLines}}" title="copy the forge's link to what is selected, or to the page" aria-label="copy the forge's link to what is selected, or to the page">⤴</button>{{else}}<button class="share" title="copy a link to what is selected, or to the page" aria-label="copy a link to what is selected, or to the page">⤴</button>{{end}}</span></div> {{end}}{{end}}<main> {{.Body}} </main>

assets/live.js

@@ -578,12 +578,19 @@ 	// origin, never on the server, so each viewer of a share sees only 	// what that viewer opened. Each entry counts its visits and keeps the 	// last; the score weighs both, as zoxide does. A reload is no visit.+	// Each entry names its tree, and a search lists only the page's own+	// tree, so one repo's reading never shows in another's. An entry+	// without a tree predates that and is dropped. 	const recentKey = "recent"; 	const maxRecent = 300;+	const recentTree = () => {+		const b = box();+		return b ? b.dataset.recents : undefined;+	}; 	const readRecent = () => { 		try { 			const list = JSON.parse(localStorage.getItem(recentKey));-			return Array.isArray(list) ? list : [];+			return Array.isArray(list) ? list.filter((e) => typeof e.r === "string") : []; 		} catch { 			return []; 		}@@ -594,7 +601,9 @@ 	}; 	const recentPaths = () => { 		const now = Date.now();+		const tree = recentTree(); 		return readRecent()+			.filter((e) => e.r === tree) 			.sort((a, b) => score(b, now) - score(a, now)) 			.map((e) => e.p); 	};@@ -617,13 +626,16 @@ 		if (!["doc", "code", "dir"].includes(document.body.className)) return; 		const nav = performance.getEntriesByType("navigation")[0]; 		if (nav && nav.type === "reload") return;+		const tree = recentTree();+		if (tree === undefined) return; 		const now = Date.now(); 		const list = readRecent(); 		const e = list.find((e) => e.p === location.pathname); 		if (e) { 			e.n++; 			e.t = now;-		} else list.push({ p: location.pathname, n: 1, t: now });+			e.r = tree;+		} else list.push({ p: location.pathname, r: tree, n: 1, t: now }); 		list.sort((a, b) => score(b, now) - score(a, now)); 		try { 			localStorage.setItem(recentKey, JSON.stringify(list.slice(0, maxRecent)));

doc/pages.md

@@ -40,7 +40,7 @@ The list arrives after the page, since the server walks the tree for it: a few m  ## Recent pages -The browser remembers the pages you open; the server keeps no such list. A search shows the best five that match its words first, under recent, from any tree on the address.+The browser remembers the pages you open; the server keeps no such list. A search shows the best five that match its words first, under recent. They come from the tree you are in, so one repo's reading never shows in another's. A version counts as its tree.  Each visit counts and recent visits weigh more, as in zoxide. A reload is no visit. 

search_test.go

@@ -1,6 +1,7 @@ package main  import (+	"context" 	"encoding/json" 	"net/url" 	"os"@@ -59,6 +60,31 @@ func TestSearchBoxScope(t *testing.T) { 	} } +// Recent pages stay within their tree: each page names its tree for the+// script, a version names the tree it is a version of, and a tree at /+// names itself, not the trees whose paths lie below it.+func TestRecentsStayInTheirTree(t *testing.T) {+	s, dir := repoTree(t)+	tr := s.list()[0]+	tr.prefix, tr.label = "/notes/", "~/notes"+	other := t.TempDir()+	os.WriteFile(filepath.Join(other, "x.md"), []byte("# x\n"), 0o644)+	addTree(t, s, other, "/", "~")+	h := s.handler()+	star := stardatesIn(context.Background(), dir).byHash[head(t, dir, "HEAD~1")]+	for _, c := range []struct{ url, want string }{+		{"/notes/", "/notes/"},+		{"/notes/a/b.go", "/notes/"},+		{"/notes@" + star + "/a/b.go", "/notes/"},+		{"/x.md", "/"},+	} {+		w := get(h, c.url)+		if !strings.Contains(w.Body.String(), `data-recents="`+c.want+`"`) {+			t.Errorf("%s: want data-recents=%q, status %d", c.url, c.want, w.Code)+		}+	}+}+ // A directory's own entries come first and are marked here; deeper ones // follow unmarked. func TestSearchHereFirst(t *testing.T) {

server.go

@@ -1251,6 +1251,7 @@ type page struct { 	Raw     bool 	Tree    string        // prefix the stop button withdraws, or "" for every tree 	Search  string        // the tree's URL, which answers ?search=, or "" off a tree+	Recents string        // the tree whose pages a search lists as recent; a version's is its tree's 	Status  template.HTML // the server's age, on the index, or a repo's commit on the web address 	Style   template.CSS  // the stylesheet inline, for a page the server may not outlive 	Stopped bool@@ -1346,8 +1347,14 @@ func render(w http.ResponseWriter, r *http.Request, status int, p page) { // shared, and a search box for the directory the page is in, with a button // that copies the page's path on disk. A failed page searches the whole // tree, since its directory may not exist, and has no path to copy.+// Recent pages stay within the tree, so one repo's reading never shows in+// another's search. func (t *tree) render(w http.ResponseWriter, r *http.Request, status int, p page) { 	p.Tree, p.Shared, p.Search = t.prefix, t.shared, t.prefixURL()+	p.Recents = t.prefixURL()+	if t.base != nil {+		p.Recents = t.base.prefixURL()+	} 	if t.web != nil { 		p.NoStop, p.Status = true, t.web.status(t.prefix, r.URL.Path) 	}