detached 84a2d29

refactor(web): share the finder hit link and throttle the palette search

The palette searches once per frame, narrows its search with its own
previous result and drops it when the dialog closes. Option and palette
share hitLink. The truncated status gives the real count, the path list
is a single promise reset on failure, a directory is loaded when deeper
rows follow it, and Enter/arrows keep aria-selected in step with
data-cursor. The .cmd-list label rules move into the panel block.
commit
84a2d29380edc12e156db5bc0d9e1d2eab9f58ad
author
brice · · 2026-10-12 05:03 UTC
parent
28d17a7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +103 -71
M internal/adapters/web/keys.templ +1 -1
@@ -35,7 +35,7 @@ templ Keys() {
3535 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
3636 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
3737 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
38- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();
38+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load();
3939 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
4040 k-gh from:body -> document.querySelector('.brand')?.click();
4141 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
@@ -35,7 +35,7 @@ templ Keys() {
35 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());35 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
36 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();36 k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
37 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());37 k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
38- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); document.body.dataset.paths && ermiteFinder.load();38+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load();
39 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();39 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
40 k-gh from:body -> document.querySelector('.brand')?.click();40 k-gh from:body -> document.querySelector('.brand')?.click();
41 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();41 k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
M internal/adapters/web/keys_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/overlays.templ +1 -1
@@ -86,7 +86,7 @@ templ refItem(r RefItem) {
8686 // OverlayCmd is the ":" command line: one filterable list mixing sections,
8787 // refs and repos.
8888 templ OverlayCmd(items []CmdItem) {
89- <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
89+ <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
9090 <div class="panel-head">
9191 <label for="cmd-q" class="sr">go to</label>
9292 <input
@@ -86,7 +86,7 @@ templ refItem(r RefItem) {
86 // OverlayCmd is the ":" command line: one filterable list mixing sections,86 // OverlayCmd is the ":" command line: one filterable list mixing sections,
87 // refs and repos.87 // refs and repos.
88 templ OverlayCmd(items []CmdItem) {88 templ OverlayCmd(items []CmdItem) {
89- <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">89+ <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
90 <div class="panel-head">90 <div class="panel-head">
91 <label for="cmd-q" class="sr">go to</label>91 <label for="cmd-q" class="sr">go to</label>
92 <input92 <input
M internal/adapters/web/overlays_templ.go +10 -10
@@ -41,7 +41,7 @@ func OverlayHelp() templ.Component {
4141 var templ_7745c5c3_Var2 string
4242 templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs("switch")
4343 if templ_7745c5c3_Err != nil {
44- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 18, Col: 43}
44+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `overlays.templ`, Line: 18, Col: 43}
4545 }
4646 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
4747 if templ_7745c5c3_Err != nil {
@@ -85,7 +85,7 @@ func OverlayRefs(l Layout) templ.Component {
8585 var templ_7745c5c3_Var4 string
8686 templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(l.Heads)))
8787 if templ_7745c5c3_Err != nil {
88- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 47, Col: 58}
88+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `overlays.templ`, Line: 47, Col: 58}
8989 }
9090 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
9191 if templ_7745c5c3_Err != nil {
@@ -108,7 +108,7 @@ func OverlayRefs(l Layout) templ.Component {
108108 var templ_7745c5c3_Var5 string
109109 templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(l.Tags)))
110110 if templ_7745c5c3_Err != nil {
111- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 55, Col: 53}
111+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `overlays.templ`, Line: 55, Col: 53}
112112 }
113113 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
114114 if templ_7745c5c3_Err != nil {
@@ -161,7 +161,7 @@ func refItem(r RefItem) templ.Component {
161161 var templ_7745c5c3_Var7 templ.SafeURL
162162 templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
163163 if templ_7745c5c3_Err != nil {
164- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 70, Col: 31}
164+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `overlays.templ`, Line: 70, Col: 31}
165165 }
166166 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
167167 if templ_7745c5c3_Err != nil {
@@ -199,7 +199,7 @@ func refItem(r RefItem) templ.Component {
199199 var templ_7745c5c3_Var8 string
200200 templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
201201 if templ_7745c5c3_Err != nil {
202- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 81, Col: 18}
202+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `overlays.templ`, Line: 81, Col: 18}
203203 }
204204 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
205205 if templ_7745c5c3_Err != nil {
@@ -236,7 +236,7 @@ func OverlayCmd(items []CmdItem) templ.Component {
236236 templ_7745c5c3_Var9 = templ.NopComponent
237237 }
238238 ctx = templ.ClearChildren(ctx)
239- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<dialog id=\"panel-cmd\" class=\"panel modal\" aria-label=\"Go to\" hx-on:keydown=\"event.key === 'Escape' && (event.preventDefault(), this.close())\" hx-on:click=\"event.target === this && this.close()\"><div class=\"panel-head\"><label for=\"cmd-q\" class=\"sr\">go to</label> <input id=\"cmd-q\" type=\"search\" placeholder=\"Go to a section, branch, tag, file or repository…\" autocomplete=\"off\" spellcheck=\"false\" aria-controls=\"cmd-list\" hx-on:input=\"let t = this.value.toLowerCase().trim();\n q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));\n let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)\" hx-on:keydown=\"let vis = q('#cmd-list > li:not([hidden])').arr();\n let i = vis.findIndex(li => li.hasAttribute('data-cursor'));\n let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;\n if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }\n else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }\"> <button type=\"button\" class=\"panel-x\" aria-label=\"Close\" hx-on:click=\"closest('dialog').close()\"><kbd>esc</kbd></button></div><ul id=\"cmd-list\" class=\"cmd-list\" aria-label=\"Targets\">")
239+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<dialog id=\"panel-cmd\" class=\"panel modal\" aria-label=\"Go to\" hx-on:close=\"ermiteFinder.paletteClose()\" hx-on:keydown=\"event.key === 'Escape' && (event.preventDefault(), this.close())\" hx-on:click=\"event.target === this && this.close()\"><div class=\"panel-head\"><label for=\"cmd-q\" class=\"sr\">go to</label> <input id=\"cmd-q\" type=\"search\" placeholder=\"Go to a section, branch, tag, file or repository…\" autocomplete=\"off\" spellcheck=\"false\" aria-controls=\"cmd-list\" hx-on:input=\"let t = this.value.toLowerCase().trim();\n q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));\n let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)\" hx-on:keydown=\"let vis = q('#cmd-list > li:not([hidden])').arr();\n let i = vis.findIndex(li => li.hasAttribute('data-cursor'));\n let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;\n if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }\n else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }\"> <button type=\"button\" class=\"panel-x\" aria-label=\"Close\" hx-on:click=\"closest('dialog').close()\"><kbd>esc</kbd></button></div><ul id=\"cmd-list\" class=\"cmd-list\" aria-label=\"Targets\">")
240240 if templ_7745c5c3_Err != nil {
241241 return templ_7745c5c3_Err
242242 }
@@ -248,7 +248,7 @@ func OverlayCmd(items []CmdItem) templ.Component {
248248 var templ_7745c5c3_Var10 string
249249 templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(strings.ToLower(c.Kind + " " + c.Label))
250250 if templ_7745c5c3_Err != nil {
251- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 112, Col: 56}
251+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `overlays.templ`, Line: 112, Col: 56}
252252 }
253253 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
254254 if templ_7745c5c3_Err != nil {
@@ -271,7 +271,7 @@ func OverlayCmd(items []CmdItem) templ.Component {
271271 var templ_7745c5c3_Var11 templ.SafeURL
272272 templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
273273 if templ_7745c5c3_Err != nil {
274- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 112, Col: 114}
274+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `overlays.templ`, Line: 112, Col: 114}
275275 }
276276 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
277277 if templ_7745c5c3_Err != nil {
@@ -284,7 +284,7 @@ func OverlayCmd(items []CmdItem) templ.Component {
284284 var templ_7745c5c3_Var12 string
285285 templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
286286 if templ_7745c5c3_Err != nil {
287- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 112, Col: 158}
287+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `overlays.templ`, Line: 112, Col: 158}
288288 }
289289 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
290290 if templ_7745c5c3_Err != nil {
@@ -297,7 +297,7 @@ func OverlayCmd(items []CmdItem) templ.Component {
297297 var templ_7745c5c3_Var13 string
298298 templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(c.Kind)
299299 if templ_7745c5c3_Err != nil {
300- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 112, Col: 191}
300+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `overlays.templ`, Line: 112, Col: 191}
301301 }
302302 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
303303 if templ_7745c5c3_Err != nil {
@@ -41,7 +41,7 @@ func OverlayHelp() templ.Component {
41 var templ_7745c5c3_Var2 string41 var templ_7745c5c3_Var2 string
42 templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs("switch")42 templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs("switch")
43 if templ_7745c5c3_Err != nil {43 if templ_7745c5c3_Err != nil {
44- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 18, Col: 43}44+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `overlays.templ`, Line: 18, Col: 43}
45 }45 }
46 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))46 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
47 if templ_7745c5c3_Err != nil {47 if templ_7745c5c3_Err != nil {
@@ -85,7 +85,7 @@ func OverlayRefs(l Layout) templ.Component {
85 var templ_7745c5c3_Var4 string85 var templ_7745c5c3_Var4 string
86 templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(l.Heads)))86 templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(l.Heads)))
87 if templ_7745c5c3_Err != nil {87 if templ_7745c5c3_Err != nil {
88- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 47, Col: 58}88+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `overlays.templ`, Line: 47, Col: 58}
89 }89 }
90 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))90 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
91 if templ_7745c5c3_Err != nil {91 if templ_7745c5c3_Err != nil {
@@ -108,7 +108,7 @@ func OverlayRefs(l Layout) templ.Component {
108 var templ_7745c5c3_Var5 string108 var templ_7745c5c3_Var5 string
109 templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(l.Tags)))109 templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(l.Tags)))
110 if templ_7745c5c3_Err != nil {110 if templ_7745c5c3_Err != nil {
111- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 55, Col: 53}111+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `overlays.templ`, Line: 55, Col: 53}
112 }112 }
113 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))113 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
114 if templ_7745c5c3_Err != nil {114 if templ_7745c5c3_Err != nil {
@@ -161,7 +161,7 @@ func refItem(r RefItem) templ.Component {
161 var templ_7745c5c3_Var7 templ.SafeURL161 var templ_7745c5c3_Var7 templ.SafeURL
162 templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))162 templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
163 if templ_7745c5c3_Err != nil {163 if templ_7745c5c3_Err != nil {
164- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 70, Col: 31}164+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `overlays.templ`, Line: 70, Col: 31}
165 }165 }
166 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))166 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
167 if templ_7745c5c3_Err != nil {167 if templ_7745c5c3_Err != nil {
@@ -199,7 +199,7 @@ func refItem(r RefItem) templ.Component {
199 var templ_7745c5c3_Var8 string199 var templ_7745c5c3_Var8 string
200 templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)200 templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
201 if templ_7745c5c3_Err != nil {201 if templ_7745c5c3_Err != nil {
202- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 81, Col: 18}202+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `overlays.templ`, Line: 81, Col: 18}
203 }203 }
204 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))204 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
205 if templ_7745c5c3_Err != nil {205 if templ_7745c5c3_Err != nil {
@@ -236,7 +236,7 @@ func OverlayCmd(items []CmdItem) templ.Component {
236 templ_7745c5c3_Var9 = templ.NopComponent236 templ_7745c5c3_Var9 = templ.NopComponent
237 }237 }
238 ctx = templ.ClearChildren(ctx)238 ctx = templ.ClearChildren(ctx)
239- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<dialog id=\"panel-cmd\" class=\"panel modal\" aria-label=\"Go to\" hx-on:keydown=\"event.key === 'Escape' && (event.preventDefault(), this.close())\" hx-on:click=\"event.target === this && this.close()\"><div class=\"panel-head\"><label for=\"cmd-q\" class=\"sr\">go to</label> <input id=\"cmd-q\" type=\"search\" placeholder=\"Go to a section, branch, tag, file or repository…\" autocomplete=\"off\" spellcheck=\"false\" aria-controls=\"cmd-list\" hx-on:input=\"let t = this.value.toLowerCase().trim();\n q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));\n let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)\" hx-on:keydown=\"let vis = q('#cmd-list > li:not([hidden])').arr();\n let i = vis.findIndex(li => li.hasAttribute('data-cursor'));\n let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;\n if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }\n else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }\"> <button type=\"button\" class=\"panel-x\" aria-label=\"Close\" hx-on:click=\"closest('dialog').close()\"><kbd>esc</kbd></button></div><ul id=\"cmd-list\" class=\"cmd-list\" aria-label=\"Targets\">")239+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<dialog id=\"panel-cmd\" class=\"panel modal\" aria-label=\"Go to\" hx-on:close=\"ermiteFinder.paletteClose()\" hx-on:keydown=\"event.key === 'Escape' && (event.preventDefault(), this.close())\" hx-on:click=\"event.target === this && this.close()\"><div class=\"panel-head\"><label for=\"cmd-q\" class=\"sr\">go to</label> <input id=\"cmd-q\" type=\"search\" placeholder=\"Go to a section, branch, tag, file or repository…\" autocomplete=\"off\" spellcheck=\"false\" aria-controls=\"cmd-list\" hx-on:input=\"let t = this.value.toLowerCase().trim();\n q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));\n let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)\" hx-on:keydown=\"let vis = q('#cmd-list > li:not([hidden])').arr();\n let i = vis.findIndex(li => li.hasAttribute('data-cursor'));\n let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;\n if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }\n else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }\"> <button type=\"button\" class=\"panel-x\" aria-label=\"Close\" hx-on:click=\"closest('dialog').close()\"><kbd>esc</kbd></button></div><ul id=\"cmd-list\" class=\"cmd-list\" aria-label=\"Targets\">")
240 if templ_7745c5c3_Err != nil {240 if templ_7745c5c3_Err != nil {
241 return templ_7745c5c3_Err241 return templ_7745c5c3_Err
242 }242 }
@@ -248,7 +248,7 @@ func OverlayCmd(items []CmdItem) templ.Component {
248 var templ_7745c5c3_Var10 string248 var templ_7745c5c3_Var10 string
249 templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(strings.ToLower(c.Kind + " " + c.Label))249 templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(strings.ToLower(c.Kind + " " + c.Label))
250 if templ_7745c5c3_Err != nil {250 if templ_7745c5c3_Err != nil {
251- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 112, Col: 56}251+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `overlays.templ`, Line: 112, Col: 56}
252 }252 }
253 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)253 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
254 if templ_7745c5c3_Err != nil {254 if templ_7745c5c3_Err != nil {
@@ -271,7 +271,7 @@ func OverlayCmd(items []CmdItem) templ.Component {
271 var templ_7745c5c3_Var11 templ.SafeURL271 var templ_7745c5c3_Var11 templ.SafeURL
272 templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))272 templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
273 if templ_7745c5c3_Err != nil {273 if templ_7745c5c3_Err != nil {
274- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 112, Col: 114}274+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `overlays.templ`, Line: 112, Col: 114}
275 }275 }
276 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))276 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
277 if templ_7745c5c3_Err != nil {277 if templ_7745c5c3_Err != nil {
@@ -284,7 +284,7 @@ func OverlayCmd(items []CmdItem) templ.Component {
284 var templ_7745c5c3_Var12 string284 var templ_7745c5c3_Var12 string
285 templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)285 templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
286 if templ_7745c5c3_Err != nil {286 if templ_7745c5c3_Err != nil {
287- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 112, Col: 158}287+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `overlays.templ`, Line: 112, Col: 158}
288 }288 }
289 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))289 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
290 if templ_7745c5c3_Err != nil {290 if templ_7745c5c3_Err != nil {
@@ -297,7 +297,7 @@ func OverlayCmd(items []CmdItem) templ.Component {
297 var templ_7745c5c3_Var13 string297 var templ_7745c5c3_Var13 string
298 templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(c.Kind)298 templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(c.Kind)
299 if templ_7745c5c3_Err != nil {299 if templ_7745c5c3_Err != nil {
300- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/overlays.templ`, Line: 112, Col: 191}300+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `overlays.templ`, Line: 112, Col: 191}
301 }301 }
302 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))302 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
303 if templ_7745c5c3_Err != nil {303 if templ_7745c5c3_Err != nil {
M internal/adapters/web/static/css/25-panel.css +3 -0
@@ -54,6 +54,9 @@
5454 & li[data-cursor] a { background: var(--bg-strong); color: var(--ink-hi); }
5555 & .lbl { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: var(--fs-s); }
5656 & .k { flex: none; font-size: var(--fs-xs); color: var(--ink-mute); }
57+ & .dir { color: var(--ink-dim); }
58+ & strong { color: var(--ink-hi); font-weight: 700; }
59+ & mark { background: none; color: var(--teal); font-weight: 700; text-decoration: underline; }
5760 }
5861
5962 .keys {
@@ -54,6 +54,9 @@
54 & li[data-cursor] a { background: var(--bg-strong); color: var(--ink-hi); }54 & li[data-cursor] a { background: var(--bg-strong); color: var(--ink-hi); }
55 & .lbl { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: var(--fs-s); }55 & .lbl { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: var(--fs-s); }
56 & .k { flex: none; font-size: var(--fs-xs); color: var(--ink-mute); }56 & .k { flex: none; font-size: var(--fs-xs); color: var(--ink-mute); }
57+ & .dir { color: var(--ink-dim); }
58+ & strong { color: var(--ink-hi); font-weight: 700; }
59+ & mark { background: none; color: var(--teal); font-weight: 700; text-decoration: underline; }
57 }60 }
58 61
59 .keys {62 .keys {
M internal/adapters/web/static/css/30-list.css +0 -3
@@ -79,9 +79,6 @@
7979 margin-top: var(--s3);
8080 & #finder-status { margin-bottom: var(--s2); font-size: var(--fs-s); }
8181 }
82- .cmd-list .dir { color: var(--ink-dim); }
83- .cmd-list strong { color: var(--ink-hi); font-weight: 700; }
84- .cmd-list mark { background: none; color: var(--teal); font-weight: 700; text-decoration: underline; }
8582
8683 /* section headings inside a page */
8784 .rule-h {
@@ -79,9 +79,6 @@
79 margin-top: var(--s3);79 margin-top: var(--s3);
80 & #finder-status { margin-bottom: var(--s2); font-size: var(--fs-s); }80 & #finder-status { margin-bottom: var(--s2); font-size: var(--fs-s); }
81 }81 }
82- .cmd-list .dir { color: var(--ink-dim); }
83- .cmd-list strong { color: var(--ink-hi); font-weight: 700; }
84- .cmd-list mark { background: none; color: var(--teal); font-weight: 700; text-decoration: underline; }
85 82
86 /* section headings inside a page */83 /* section headings inside a page */
87 .rule-h {84 .rule-h {
M internal/adapters/web/static/js/files.js +87 -55
@@ -10,6 +10,8 @@ window.ermiteTree = (() => {
1010 const depthOf = (tr) => Number(tr.dataset.depth || 0);
1111 const linkOf = (tr) => tr.querySelector('a[data-expand]');
1212 const isOpen = (a) => a.getAttribute('aria-expanded') === 'true';
13+ // A directory is loaded when rows deeper than it follow it.
14+ const isLoaded = (tr) => { const n = tr.nextElementSibling; return !!n && depthOf(n) > depthOf(tr); };
1315
1416 function setOpen(tr, a, open) {
1517 a.setAttribute('aria-expanded', open ? 'true' : 'false');
@@ -64,7 +66,6 @@ window.ermiteTree = (() => {
6466 if (covered) r.hidden = true;
6567 }
6668 tr.after(...rows);
67- tr.dataset.loaded = '1';
6869 tr.removeAttribute('aria-busy');
6970 if (covered) setOpen(tr, a, true);
7071 else expand(tr, a);
@@ -77,7 +78,7 @@ window.ermiteTree = (() => {
7778 const a = linkOf(tr);
7879 if (!a || tr.getAttribute('aria-busy') === 'true') return;
7980 if (isOpen(a)) collapse(tr, a);
80- else if (tr.dataset.loaded) expand(tr, a);
81+ else if (isLoaded(tr)) expand(tr, a);
8182 else load(tr, a);
8283 }
8384
@@ -385,7 +386,7 @@ window.ermiteFinder = (() => {
385386 // ---- DOM glue -----------------------------------------------------------
386387
387388 const $ = (id) => document.getElementById(id);
388- const loads = new Map(); // URL -> promise of {idx, truncated}
389+ let loading = null; // the promise of {idx, truncated}; null before the first load and after a failure
389390 let rendered = null; // the result the finder list shows
390391 let frame = 0;
391392 let treeCursor = null; // the row of the tree that had the cursor when the results came
@@ -395,27 +396,22 @@ window.ermiteFinder = (() => {
395396 function load() {
396397 const url = document.body.dataset.paths;
397398 if (!url) return Promise.resolve(null);
398- let p = loads.get(url);
399- if (!p) {
400- p = fetch(url, { headers: { Accept: 'application/json' } })
399+ if (!loading) {
400+ const p = fetch(url, { headers: { Accept: 'application/json' } })
401401 .then((res) => {
402402 if (!res.ok) throw new Error('HTTP ' + res.status);
403403 return res.json();
404404 })
405405 .then((j) => ({ idx: index(j.paths || []), truncated: !!j.truncated }));
406- p.catch(() => loads.delete(url)); // a failure is retried at the next use
407- loads.set(url, p);
406+ loading = p;
407+ p.catch(() => { if (loading === p) loading = null; }); // a failure is retried at the next use
408408 }
409- return p;
409+ return loading;
410410 }
411411
412- function blobHref(path) {
413- try {
414- return document.body.dataset.blob + '/' + path.split('/').map(encodeURIComponent).join('/');
415- } catch (e) {
416- return '#';
417- }
418- }
412+ // The server's JSON never carries a lone surrogate, so encodeURIComponent
413+ // cannot throw here.
414+ const blobHref = (path) => document.body.dataset.blob + '/' + path.split('/').map(encodeURIComponent).join('/');
419415
420416 const isHigh = (c) => c >= 0xd800 && c <= 0xdbff;
421417 const isLow = (c) => c >= 0xdc00 && c <= 0xdfff;
@@ -471,22 +467,35 @@ window.ermiteFinder = (() => {
471467 return lbl;
472468 }
473469
470+ // hitLink is the link of path i of the result: its label, out of the tab order.
471+ function hitLink(res, i) {
472+ const path = res.idx.paths[i];
473+ const a = document.createElement('a');
474+ a.href = blobHref(path);
475+ a.tabIndex = -1;
476+ a.append(label(path, positions(res.idx, i, res.q)));
477+ return a;
478+ }
479+
474480 function option(res, hit, n) {
475- const path = res.idx.paths[hit.i];
476481 const li = document.createElement('li');
477482 li.setAttribute('role', 'option');
478483 li.setAttribute('aria-selected', 'false');
479484 li.id = 'finder-o' + n;
480- const a = document.createElement('a');
481- a.href = blobHref(path);
482- a.tabIndex = -1;
483- a.append(label(path, positions(res.idx, hit.i, res.q)));
484- li.append(a);
485+ li.append(hitLink(res, hit.i));
485486 return li;
486487 }
487488
488489 const status = (text) => { $('finder-status').textContent = text; };
489490
491+ // syncActive makes the option that has data-cursor the active one: j/k
492+ // (keys.templ) move the cursor while the field is blurred.
493+ function syncActive(el) {
494+ const cur = $('finder-list').querySelector(':scope > li[data-cursor]');
495+ if (cur && cur.getAttribute('aria-selected') !== 'true') setActive(el, cur);
496+ return cur;
497+ }
498+
490499 // setActive makes li the active option (null: none) of the listbox.
491500 function setActive(el, li) {
492501 $('finder-list').querySelectorAll(':scope > li[aria-selected="true"]').forEach((o) => o.setAttribute('aria-selected', 'false'));
@@ -563,44 +572,68 @@ window.ermiteFinder = (() => {
563572 setActive(el, items[0]);
564573 rendered = res;
565574 let msg = res.all.length === 0 ? 'no file matches' : res.all.length + (res.all.length === 1 ? ' file matches' : ' files match');
566- if (loaded.truncated) msg += ' · only the first 100,000 files are searched';
575+ if (loaded.truncated) msg += ' · only the first ' + loaded.idx.paths.length.toLocaleString('en') + ' files are searched';
567576 status(msg);
568577 const open = pendingEnter === q;
569578 pendingEnter = null;
570579 if (open) items[0]?.querySelector('a').click();
571580 }
572581
582+ // The palette keeps its own frame and previous result: the field of the
583+ // dialog and the finder are searched independently.
584+ let pFrame = 0;
585+ let pPrev = null;
586+ const clearHits = () => document.querySelectorAll('#cmd-list > li.finder-hit').forEach((li) => li.remove());
587+
588+ // palette lists the file hits of the ":" field under its entries, once per frame.
573589 function palette(el) {
574- if (!document.body.dataset.paths) return Promise.resolve();
575- const clear = () => document.querySelectorAll('#cmd-list > li.finder-hit').forEach((li) => li.remove());
576- clear();
590+ if (!document.body.dataset.paths) return;
591+ clearHits();
592+ if (pFrame) cancelAnimationFrame(pFrame);
593+ pFrame = 0;
594+ if (!el.value.trim()) {
595+ pPrev = null;
596+ return;
597+ }
598+ pFrame = requestAnimationFrame(() => { pFrame = 0; paletteRun(el); });
599+ }
600+
601+ // paletteClose drops the frame and the result kept for the next keystroke.
602+ function paletteClose() {
603+ if (pFrame) cancelAnimationFrame(pFrame);
604+ pFrame = 0;
605+ pPrev = null;
606+ }
607+
608+ async function paletteRun(el) {
577609 const q = el.value;
578- if (!q.trim()) return Promise.resolve();
579- return load().then((loaded) => {
580- if (el.value !== q || !loaded) return;
581- clear();
582- const res = search(loaded.idx, q, 20);
583- const list = $('cmd-list');
584- for (const h of res.hits) {
585- const path = res.idx.paths[h.i];
586- const li = document.createElement('li');
587- li.className = 'finder-hit';
588- const a = document.createElement('a');
589- a.href = blobHref(path);
590- a.tabIndex = -1;
591- const k = document.createElement('span');
592- k.className = 'k';
593- k.textContent = 'file';
594- a.append(label(path, positions(res.idx, h.i, q)), k);
595- li.append(a);
596- list.append(li);
597- }
598- const shown = Array.from(list.querySelectorAll(':scope > li:not([hidden])'));
599- if (shown.length && !shown.some((li) => li.hasAttribute('data-cursor'))) {
600- list.querySelectorAll(':scope > li[data-cursor]').forEach((li) => li.removeAttribute('data-cursor'));
601- shown[0].setAttribute('data-cursor', '');
602- }
603- }, () => {});
610+ let loaded;
611+ try {
612+ loaded = await load();
613+ } catch (e) {
614+ return;
615+ }
616+ if (el.value !== q || !loaded) return; // the field changed during the wait
617+ const res = search(loaded.idx, q, 20, pPrev);
618+ pPrev = res;
619+ clearHits();
620+ const list = $('cmd-list');
621+ for (const h of res.hits) {
622+ const li = document.createElement('li');
623+ li.className = 'finder-hit';
624+ const a = hitLink(res, h.i);
625+ const k = document.createElement('span');
626+ k.className = 'k';
627+ k.textContent = 'file';
628+ a.append(k);
629+ li.append(a);
630+ list.append(li);
631+ }
632+ const shown = Array.from(list.querySelectorAll(':scope > li:not([hidden])'));
633+ if (shown.length && !shown.some((li) => li.hasAttribute('data-cursor'))) {
634+ list.querySelectorAll(':scope > li[data-cursor]').forEach((li) => li.removeAttribute('data-cursor'));
635+ shown[0].setAttribute('data-cursor', '');
636+ }
604637 }
605638
606639 // input runs the search of the field, once per frame.
@@ -615,7 +648,7 @@ window.ermiteFinder = (() => {
615648 }
616649
617650 return {
618- index, search, positions, visible, load, palette, input,
651+ index, search, positions, visible, label, load, palette, paletteClose, input,
619652 // key moves the cursor of the results, opens one, or clears the field.
620653 key(el, e) {
621654 if (e.isComposing) return; // the Enter or Escape of an input method is its own
@@ -626,8 +659,7 @@ window.ermiteFinder = (() => {
626659 return;
627660 }
628661 if (!results) return;
629- const list = $('finder-list');
630- const cur = list.querySelector(':scope > li[data-cursor]');
662+ const cur = syncActive(el);
631663 if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
632664 e.preventDefault();
633665 const next = e.key === 'ArrowDown' ? cur?.nextElementSibling : cur?.previousElementSibling;
@@ -10,6 +10,8 @@ window.ermiteTree = (() => {
10 const depthOf = (tr) => Number(tr.dataset.depth || 0);10 const depthOf = (tr) => Number(tr.dataset.depth || 0);
11 const linkOf = (tr) => tr.querySelector('a[data-expand]');11 const linkOf = (tr) => tr.querySelector('a[data-expand]');
12 const isOpen = (a) => a.getAttribute('aria-expanded') === 'true';12 const isOpen = (a) => a.getAttribute('aria-expanded') === 'true';
13+ // A directory is loaded when rows deeper than it follow it.
14+ const isLoaded = (tr) => { const n = tr.nextElementSibling; return !!n && depthOf(n) > depthOf(tr); };
13 15
14 function setOpen(tr, a, open) {16 function setOpen(tr, a, open) {
15 a.setAttribute('aria-expanded', open ? 'true' : 'false');17 a.setAttribute('aria-expanded', open ? 'true' : 'false');
@@ -64,7 +66,6 @@ window.ermiteTree = (() => {
64 if (covered) r.hidden = true;66 if (covered) r.hidden = true;
65 }67 }
66 tr.after(...rows);68 tr.after(...rows);
67- tr.dataset.loaded = '1';
68 tr.removeAttribute('aria-busy');69 tr.removeAttribute('aria-busy');
69 if (covered) setOpen(tr, a, true);70 if (covered) setOpen(tr, a, true);
70 else expand(tr, a);71 else expand(tr, a);
@@ -77,7 +78,7 @@ window.ermiteTree = (() => {
77 const a = linkOf(tr);78 const a = linkOf(tr);
78 if (!a || tr.getAttribute('aria-busy') === 'true') return;79 if (!a || tr.getAttribute('aria-busy') === 'true') return;
79 if (isOpen(a)) collapse(tr, a);80 if (isOpen(a)) collapse(tr, a);
80- else if (tr.dataset.loaded) expand(tr, a);81+ else if (isLoaded(tr)) expand(tr, a);
81 else load(tr, a);82 else load(tr, a);
82 }83 }
83 84
@@ -385,7 +386,7 @@ window.ermiteFinder = (() => {
385 // ---- DOM glue -----------------------------------------------------------386 // ---- DOM glue -----------------------------------------------------------
386 387
387 const $ = (id) => document.getElementById(id);388 const $ = (id) => document.getElementById(id);
388- const loads = new Map(); // URL -> promise of {idx, truncated}389+ let loading = null; // the promise of {idx, truncated}; null before the first load and after a failure
389 let rendered = null; // the result the finder list shows390 let rendered = null; // the result the finder list shows
390 let frame = 0;391 let frame = 0;
391 let treeCursor = null; // the row of the tree that had the cursor when the results came392 let treeCursor = null; // the row of the tree that had the cursor when the results came
@@ -395,27 +396,22 @@ window.ermiteFinder = (() => {
395 function load() {396 function load() {
396 const url = document.body.dataset.paths;397 const url = document.body.dataset.paths;
397 if (!url) return Promise.resolve(null);398 if (!url) return Promise.resolve(null);
398- let p = loads.get(url);399+ if (!loading) {
399- if (!p) {400+ const p = fetch(url, { headers: { Accept: 'application/json' } })
400- p = fetch(url, { headers: { Accept: 'application/json' } })
401 .then((res) => {401 .then((res) => {
402 if (!res.ok) throw new Error('HTTP ' + res.status);402 if (!res.ok) throw new Error('HTTP ' + res.status);
403 return res.json();403 return res.json();
404 })404 })
405 .then((j) => ({ idx: index(j.paths || []), truncated: !!j.truncated }));405 .then((j) => ({ idx: index(j.paths || []), truncated: !!j.truncated }));
406- p.catch(() => loads.delete(url)); // a failure is retried at the next use406+ loading = p;
407- loads.set(url, p);407+ p.catch(() => { if (loading === p) loading = null; }); // a failure is retried at the next use
408 }408 }
409- return p;409+ return loading;
410 }410 }
411 411
412- function blobHref(path) {412+ // The server's JSON never carries a lone surrogate, so encodeURIComponent
413- try {413+ // cannot throw here.
414- return document.body.dataset.blob + '/' + path.split('/').map(encodeURIComponent).join('/');414+ const blobHref = (path) => document.body.dataset.blob + '/' + path.split('/').map(encodeURIComponent).join('/');
415- } catch (e) {
416- return '#';
417- }
418- }
419 415
420 const isHigh = (c) => c >= 0xd800 && c <= 0xdbff;416 const isHigh = (c) => c >= 0xd800 && c <= 0xdbff;
421 const isLow = (c) => c >= 0xdc00 && c <= 0xdfff;417 const isLow = (c) => c >= 0xdc00 && c <= 0xdfff;
@@ -471,22 +467,35 @@ window.ermiteFinder = (() => {
471 return lbl;467 return lbl;
472 }468 }
473 469
470+ // hitLink is the link of path i of the result: its label, out of the tab order.
471+ function hitLink(res, i) {
472+ const path = res.idx.paths[i];
473+ const a = document.createElement('a');
474+ a.href = blobHref(path);
475+ a.tabIndex = -1;
476+ a.append(label(path, positions(res.idx, i, res.q)));
477+ return a;
478+ }
479+
474 function option(res, hit, n) {480 function option(res, hit, n) {
475- const path = res.idx.paths[hit.i];
476 const li = document.createElement('li');481 const li = document.createElement('li');
477 li.setAttribute('role', 'option');482 li.setAttribute('role', 'option');
478 li.setAttribute('aria-selected', 'false');483 li.setAttribute('aria-selected', 'false');
479 li.id = 'finder-o' + n;484 li.id = 'finder-o' + n;
480- const a = document.createElement('a');485+ li.append(hitLink(res, hit.i));
481- a.href = blobHref(path);
482- a.tabIndex = -1;
483- a.append(label(path, positions(res.idx, hit.i, res.q)));
484- li.append(a);
485 return li;486 return li;
486 }487 }
487 488
488 const status = (text) => { $('finder-status').textContent = text; };489 const status = (text) => { $('finder-status').textContent = text; };
489 490
491+ // syncActive makes the option that has data-cursor the active one: j/k
492+ // (keys.templ) move the cursor while the field is blurred.
493+ function syncActive(el) {
494+ const cur = $('finder-list').querySelector(':scope > li[data-cursor]');
495+ if (cur && cur.getAttribute('aria-selected') !== 'true') setActive(el, cur);
496+ return cur;
497+ }
498+
490 // setActive makes li the active option (null: none) of the listbox.499 // setActive makes li the active option (null: none) of the listbox.
491 function setActive(el, li) {500 function setActive(el, li) {
492 $('finder-list').querySelectorAll(':scope > li[aria-selected="true"]').forEach((o) => o.setAttribute('aria-selected', 'false'));501 $('finder-list').querySelectorAll(':scope > li[aria-selected="true"]').forEach((o) => o.setAttribute('aria-selected', 'false'));
@@ -563,44 +572,68 @@ window.ermiteFinder = (() => {
563 setActive(el, items[0]);572 setActive(el, items[0]);
564 rendered = res;573 rendered = res;
565 let msg = res.all.length === 0 ? 'no file matches' : res.all.length + (res.all.length === 1 ? ' file matches' : ' files match');574 let msg = res.all.length === 0 ? 'no file matches' : res.all.length + (res.all.length === 1 ? ' file matches' : ' files match');
566- if (loaded.truncated) msg += ' · only the first 100,000 files are searched';575+ if (loaded.truncated) msg += ' · only the first ' + loaded.idx.paths.length.toLocaleString('en') + ' files are searched';
567 status(msg);576 status(msg);
568 const open = pendingEnter === q;577 const open = pendingEnter === q;
569 pendingEnter = null;578 pendingEnter = null;
570 if (open) items[0]?.querySelector('a').click();579 if (open) items[0]?.querySelector('a').click();
571 }580 }
572 581
582+ // The palette keeps its own frame and previous result: the field of the
583+ // dialog and the finder are searched independently.
584+ let pFrame = 0;
585+ let pPrev = null;
586+ const clearHits = () => document.querySelectorAll('#cmd-list > li.finder-hit').forEach((li) => li.remove());
587+
588+ // palette lists the file hits of the ":" field under its entries, once per frame.
573 function palette(el) {589 function palette(el) {
574- if (!document.body.dataset.paths) return Promise.resolve();590+ if (!document.body.dataset.paths) return;
575- const clear = () => document.querySelectorAll('#cmd-list > li.finder-hit').forEach((li) => li.remove());591+ clearHits();
576- clear();592+ if (pFrame) cancelAnimationFrame(pFrame);
593+ pFrame = 0;
594+ if (!el.value.trim()) {
595+ pPrev = null;
596+ return;
597+ }
598+ pFrame = requestAnimationFrame(() => { pFrame = 0; paletteRun(el); });
599+ }
600+
601+ // paletteClose drops the frame and the result kept for the next keystroke.
602+ function paletteClose() {
603+ if (pFrame) cancelAnimationFrame(pFrame);
604+ pFrame = 0;
605+ pPrev = null;
606+ }
607+
608+ async function paletteRun(el) {
577 const q = el.value;609 const q = el.value;
578- if (!q.trim()) return Promise.resolve();610+ let loaded;
579- return load().then((loaded) => {611+ try {
580- if (el.value !== q || !loaded) return;612+ loaded = await load();
581- clear();613+ } catch (e) {
582- const res = search(loaded.idx, q, 20);614+ return;
583- const list = $('cmd-list');615+ }
584- for (const h of res.hits) {616+ if (el.value !== q || !loaded) return; // the field changed during the wait
585- const path = res.idx.paths[h.i];617+ const res = search(loaded.idx, q, 20, pPrev);
586- const li = document.createElement('li');618+ pPrev = res;
587- li.className = 'finder-hit';619+ clearHits();
588- const a = document.createElement('a');620+ const list = $('cmd-list');
589- a.href = blobHref(path);621+ for (const h of res.hits) {
590- a.tabIndex = -1;622+ const li = document.createElement('li');
591- const k = document.createElement('span');623+ li.className = 'finder-hit';
592- k.className = 'k';624+ const a = hitLink(res, h.i);
593- k.textContent = 'file';625+ const k = document.createElement('span');
594- a.append(label(path, positions(res.idx, h.i, q)), k);626+ k.className = 'k';
595- li.append(a);627+ k.textContent = 'file';
596- list.append(li);628+ a.append(k);
597- }629+ li.append(a);
598- const shown = Array.from(list.querySelectorAll(':scope > li:not([hidden])'));630+ list.append(li);
599- if (shown.length && !shown.some((li) => li.hasAttribute('data-cursor'))) {631+ }
600- list.querySelectorAll(':scope > li[data-cursor]').forEach((li) => li.removeAttribute('data-cursor'));632+ const shown = Array.from(list.querySelectorAll(':scope > li:not([hidden])'));
601- shown[0].setAttribute('data-cursor', '');633+ if (shown.length && !shown.some((li) => li.hasAttribute('data-cursor'))) {
602- }634+ list.querySelectorAll(':scope > li[data-cursor]').forEach((li) => li.removeAttribute('data-cursor'));
603- }, () => {});635+ shown[0].setAttribute('data-cursor', '');
636+ }
604 }637 }
605 638
606 // input runs the search of the field, once per frame.639 // input runs the search of the field, once per frame.
@@ -615,7 +648,7 @@ window.ermiteFinder = (() => {
615 }648 }
616 649
617 return {650 return {
618- index, search, positions, visible, load, palette, input,651+ index, search, positions, visible, label, load, palette, paletteClose, input,
619 // key moves the cursor of the results, opens one, or clears the field.652 // key moves the cursor of the results, opens one, or clears the field.
620 key(el, e) {653 key(el, e) {
621 if (e.isComposing) return; // the Enter or Escape of an input method is its own654 if (e.isComposing) return; // the Enter or Escape of an input method is its own
@@ -626,8 +659,7 @@ window.ermiteFinder = (() => {
626 return;659 return;
627 }660 }
628 if (!results) return;661 if (!results) return;
629- const list = $('finder-list');662+ const cur = syncActive(el);
630- const cur = list.querySelector(':scope > li[data-cursor]');
631 if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {663 if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
632 e.preventDefault();664 e.preventDefault();
633 const next = e.key === 'ArrowDown' ? cur?.nextElementSibling : cur?.previousElementSibling;665 const next = e.key === 'ArrowDown' ? cur?.nextElementSibling : cur?.previousElementSibling;

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close