keys: n walks the search results, and a half-typed key shows

`n` / `N` move to the next / previous search result and open it beside the
results, so the next `n` goes on from there; a folder is only highlighted,
as going into it would end the search. Past the last result it starts
again at the first and says so, as vim's `wrapscan` does. Clearing the
search is left to `:noh` / `:clear`, as in vim, rather than to `n`.

After `g`, `d`, `]` or `[` the status line shows the key waiting for its
second half, like vim's `showcmd`, and forgets it after a second.
This commit is contained in:
nvrl committed 2026-10-20 15:17:31 +02:00
1 parent b3a60aa361
commit c72eccf86b
5 files changed
+42 -7

No files matched your search

+13 -2
View File
@@ -44,6 +44,7 @@
let importRoot = false
let shell = $state<HTMLDivElement>()
let overlayEl = $state<HTMLDivElement>()
/** The first half of `gg`, `dd`, `]]` …, shown until the second comes. */
let pending = $state('')
/** `F`: the pane whose links and buttons are wearing hints right now. */
let hintRoot = $state<HTMLElement | null>(null)
@@ -135,6 +136,13 @@
return () => clearTimeout(timer)
})
// A first half left alone is forgotten, as vim's `timeoutlen` does.
$effect(() => {
if (!pending) return
const timer = setTimeout(() => (pending = ''), 1000)
return () => clearTimeout(timer)
})
// Guard against losing a half-written entry to a stray refresh.
$effect(() => {
if (!workspace.dirty) return
@@ -775,8 +783,11 @@
})()
break
case 'n':
case 'N':
// Through the results, as vim's `n` goes through the matches; the
// search itself stays until `:noh`.
event.preventDefault()
if (workspace.query) void workspace.guard(() => workspace.search(''))
void workspace.guard(() => workspace.nextResult(key === 'n' ? 1 : -1))
break
case '/':
event.preventDefault()
@@ -864,7 +875,7 @@
{/key}
</main>
<StatusBar />
<StatusBar {pending} />
{#if cmdline !== null}
<CommandLine initial={cmdline} global={cmdGlobal} close={() => ((cmdline = null), (cmdGlobal = false))} />
+2 -2
View File
@@ -10,7 +10,7 @@
['f', 'fullscreen the focused pane; again or Esc to leave'],
['Alt-h / Alt-l', 'focus the list / the document, from the editor too'],
['j / k · ↓ ↑', 'move down / up the list — scroll the document when it has focus'],
['gg / G', 'first / last row, or top / bottom of the document'],
['gg / G', 'first / last row, or top / bottom of the document — a first g, d, ] or [ waits a second in the status line'],
['Ctrl-D / Ctrl-U', 'half a page down / up the open document'],
[']] / [[', 'next / previous heading of the document — in the editor too'],
['gO', 'the headings as a list: j/k, Enter jumps there'],
@@ -33,7 +33,7 @@
['c', "capture a card into this space's inbox, without leaving here"],
['Esc · q', 'close the document'],
['/', 'full-text search, inside the current space — Tab or Ctrl-N/P walk the results, Enter opens one'],
['n', 'clear the search'],
['n / N', 'next / previous search result, opened beside the results — :noh clears the search'],
['s', 'toggle sharing for the document — asks before taking it back'],
['y', 'mint a new share link and copy it — asks, the old one stops working'],
['x · dd', 'delete what has focus — the row in the list, the document in its pane (asks y/N)'],
+6 -1
View File
@@ -3,6 +3,9 @@
import { workspace } from './store.svelte'
import { isTouchDevice } from './util'
/** A key waiting for its second half, like vim's `showcmd`. */
const { pending = '' }: { pending?: string } = $props()
const mode = $derived(
workspace.editing ? (workspace.vimEnabled ? workspace.vimMode.toUpperCase() : 'EDIT') : 'BROWSE',
)
@@ -28,6 +31,7 @@
<span class="msg" class:alarm={workspace.flash?.kind === 'error'}>{workspace.flash?.text ?? ''}</span>
{#if pending}<span class="pending" aria-live="polite">{pending}</span>{/if}
{#if workspace.fullscreen}<span class="flag">full</span>{/if}
{#if workspace.vault?.unlocked}<span class="flag vault" title="the vault is open — :lock closes it">vault open</span>{/if}
{#if lines !== null}<span class="readout">{lines}<span class="unit">L</span></span>{/if}
@@ -46,6 +50,7 @@
.dirty { color: var(--light); }
.msg { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--fg); }
.msg.alarm { color: var(--alarm); }
.pending { flex: none; color: var(--light); font-family: var(--font-mono); }
.flag { color: var(--light); letter-spacing: 0.18em; text-transform: uppercase; }
.tools { display: none; }
@@ -57,7 +62,7 @@
*/
@media (max-width: 767px) {
.status { padding: 0 6px; gap: 6px; overflow: visible; }
.hint, .file, .readout, .flag { display: none; }
.hint, .file, .readout, .flag, .pending { display: none; }
.mode:not(.editing) { display: none; }
.tools { display: flex; flex: 1; height: 100%; }
+18
View File
@@ -652,6 +652,24 @@ class Workspace {
await this.refresh()
}
/**
* `n` / `N`: the next or previous search result after the cursor, opened
* beside the results so the next `n` goes on from there. A folder is only
* highlighted: going into it would end the search. Past the last result it
* starts again at the first, as vim's `wrapscan` does.
*/
async nextResult(delta: 1 | -1) {
if (!this.query) return this.say('no search — / starts one', 'error')
const count = this.nodes.length
if (count === 0) return this.say(`no results for "${this.query}"`, 'error')
const next = this.cursor + delta
if (next >= count) this.say('search hit BOTTOM, continuing at TOP')
else if (next < 0) this.say('search hit TOP, continuing at BOTTOM')
this.cursor = (next + count) % count
const node = this.nodes[this.cursor]
if (!isContainer(node)) await this.openNode(node.id)
}
move(delta: number) {
if (this.nodes.length === 0) return
this.cursor = Math.min(Math.max(this.cursor + delta, 0), this.nodes.length - 1)