search: Esc on the line gives it up, Esc in the list lets go of it

Typing after / only tries a search: Esc, or back, puts the list back as it
was before the line opened, and only Enter keeps the results. In the list,
Esc lets go of one thing at a time, so after the document it clears the
search, with the cursor kept on the row it was on; back on a phone does the
same before going up a folder. A late answer to an older search no longer
lands over a newer one, and closing the last document puts the folder back
in the address bar.
This commit is contained in:
nvrl committed 2026-10-22 22:58:27 +02:00
1 parent a27d5de9c7
commit 4542b9692e
9 files changed
+60 -16

No files matched your search

+3 -3
View File
@@ -51,9 +51,9 @@ arguments and `↑` / `↓` recall earlier lines.
| `Ctrl-P` | go to anything by its name, in every space — words before the last narrow the path, as in proj/ingest; Tab or Ctrl-N/P walk, Enter goes | | `Ctrl-P` | go to anything by its name, in every space — words before the last narrow the path, as in proj/ingest; Tab or Ctrl-N/P walk, Enter goes |
| `b` | the board, on the plan screen: this space's, or the last one shown — 1 … 9 there shows another's | | `b` | the board, on the plan screen: this space's, or the last one shown — 1 … 9 there shows another's |
| `c` | capture a card into this space's inbox, without leaving here — reading a document, a card about it; Tab on the line makes it a note, or a line in today's entry | | `c` | capture a card into this space's inbox, without leaving here — reading a document, a card about it; Tab on the line makes it a note, or a line in today's entry |
| `Esc · q` | close the document, and its tile when there are several — Esc first puts down what m picked up | | `Esc · q` | close the document, and its tile when there are several — Esc first puts down what m picked up, and in the list lets go of a search |
| `/` | full-text search, inside the current space and its text files — Tab takes it to every space, Ctrl-N/P walk the results, Enter opens one | | `/` | full-text search, inside the current space and its text files — Tab takes it to every space, Ctrl-N/P walk the results, Enter opens one, Esc gives up |
| `n / N` | next / previous search result, opened beside the results — :noh clears the search | | `n / N` | next / previous search result, opened beside the results — Esc or :noh clears the search |
| `x · dd` | delete what has focus — the row in the list, the document in its pane — into the trash (asks y/N) | | `x · dd` | delete what has focus — the row in the list, the document in its pane — into the trash (asks y/N) |
| `u` | undo the last delete: it comes back out of the trash — :trash has the rest | | `u` | undo the last delete: it comes back out of the trash — :trash has the rest |
| `gh` | the open document's earlier versions, as diffs — Enter brings one back | | `gh` | the open document's earlier versions, as diffs — Enter brings one back |
+4 -2
View File
@@ -58,8 +58,10 @@ space, and back (`:search!` does the same from the command line).
Results appear as you type, ranked by relevance, each with the space and Results appear as you type, ranked by relevance, each with the space and
folder it is in. `Ctrl-N` / `Ctrl-P` walk them without leaving the folder it is in. `Ctrl-N` / `Ctrl-P` walk them without leaving the
line, and `Enter` opens the one lit. Afterwards `n` and `N` open the next and line, and `Enter` opens the one lit and keeps the results in the list. `Esc`
previous result beside the list, and `:noh` clears the search. on the line gives the search up, and the list is as it was before `/`.
Afterwards `n` and `N` open the next and previous result beside the list, and
`Esc` in the list (or `:noh`, or back on a phone) clears the search.
Cards are searched too, by their title, description and checklist steps. They Cards are searched too, by their title, description and checklist steps. They
are listed after the documents, under `cards`, with the board and list each is are listed after the documents, under `cards`, with the board and list each is
+21
View File
@@ -121,6 +121,26 @@
void tick().then(() => list?.querySelector('.current')?.scrollIntoView({ block: 'nearest' })) void tick().then(() => list?.querySelector('.current')?.scrollIntoView({ block: 'nearest' }))
}) })
/*
* What the list showed before the search line opened. Esc, or anything else
* that closes the line without Enter, puts it back, as vim puts the cursor
* back when a `/` is abandoned: typing a search only tries it.
*/
const before = untrack(() => ({
query: workspace.tree.query,
global: workspace.tree.globalSearch,
selected: workspace.tree.selected?.id,
}))
let entered = false
$effect(() => () => {
if (entered) return
const { tree } = workspace
if (tree.query !== before.query || tree.globalSearch !== before.global) {
void workspace.guard(() => tree.search(before.query, before.global, before.selected))
}
})
// A search is live: the list filters as you type, like `incsearch`. // A search is live: the list filters as you type, like `incsearch`.
$effect(() => { $effect(() => {
if (!isSearch) return if (!isSearch) return
@@ -174,6 +194,7 @@
const line = value const line = value
// Closing takes the line's props with it, so what it was is read first. // Closing takes the line's props with it, so what it was is read first.
const [jumping, searching, place] = [jump, isSearch, places[lit]] const [jumping, searching, place] = [jump, isSearch, places[lit]]
entered = true
remember(historyKey, line) remember(historyKey, line)
close() close()
if (jumping) return go(place) if (jumping) return go(place)
+1 -1
View File
@@ -101,7 +101,7 @@
<div class="filter"> <div class="filter">
<span class="muted">/</span><span class="query">{workspace.tree.query}</span> <span class="muted">/</span><span class="query">{workspace.tree.query}</span>
{#if workspace.tree.globalSearch}<span class="muted scope">all spaces</span>{/if} {#if workspace.tree.globalSearch}<span class="muted scope">all spaces</span>{/if}
<button title="clear search (n)" onclick={() => workspace.guard(() => workspace.tree.search(''))}>esc</button> <button title="clear the search (Esc)" onclick={() => workspace.guard(() => workspace.tree.clearSearch())}>esc</button>
</div> </div>
{/if} {/if}
+5 -2
View File
@@ -431,16 +431,19 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
if (workspace.open) void runCommand('q') if (workspace.open) void runCommand('q')
else workspace.tree.unmarkAll() else workspace.tree.unmarkAll()
}, },
// Esc lets go of one thing at a time: what is carried, then the document. // Esc lets go of one thing at a time: what is carried, then what has the
// keys — the document read, or the search the list shows — then the rest.
escape: () => { escape: () => {
if (workspace.tree.marked.length) return workspace.tree.unmarkAll() if (workspace.tree.marked.length) return workspace.tree.unmarkAll()
if (workspace.open && workspace.pane === 'document') return void runCommand('q')
if (workspace.tree.query) return guard(() => workspace.tree.clearSearch())
if (workspace.open) void runCommand('q') if (workspace.open) void runCommand('q')
}, },
delete: () => void runCommand('d'), delete: () => void runCommand('d'),
// A delete goes to the trash, so it can be undone the way vim undoes. // A delete goes to the trash, so it can be undone the way vim undoes.
undo: () => guard(() => workspace.tree.undoDelete()), undo: () => guard(() => workspace.tree.undoDelete()),
// Through the results, as vim's `n` goes through the matches; the // Through the results, as vim's `n` goes through the matches; the
// search itself stays until `:noh`. // search itself stays until Esc or `:noh`.
next: () => guard(() => workspace.tree.nextResult(1)), next: () => guard(() => workspace.tree.nextResult(1)),
previous: () => guard(() => workspace.tree.nextResult(-1)), previous: () => guard(() => workspace.tree.nextResult(-1)),
search: () => line('/'), search: () => line('/'),
+3 -3
View File
@@ -117,10 +117,10 @@ export const bindings = [
{ context: 'global', keys: ['Ctrl-p'], action: 'find', label: 'Ctrl-P', help: 'go to anything by its name, in every space — words before the last narrow the path, as in proj/ingest; Tab or Ctrl-N/P walk, Enter goes', essential: true }, { context: 'global', keys: ['Ctrl-p'], action: 'find', label: 'Ctrl-P', help: 'go to anything by its name, in every space — words before the last narrow the path, as in proj/ingest; Tab or Ctrl-N/P walk, Enter goes', essential: true },
{ context: 'global', keys: ['b'], action: 'board', help: "the board, on the plan screen: this space's, or the last one shown — 1 … 9 there shows another's" }, { context: 'global', keys: ['b'], action: 'board', help: "the board, on the plan screen: this space's, or the last one shown — 1 … 9 there shows another's" },
{ context: 'tree', keys: ['c'], action: 'capture', help: "capture a card into this space's inbox, without leaving here — reading a document, a card about it; Tab on the line makes it a note, or a line in today's entry", essential: true }, { context: 'tree', keys: ['c'], action: 'capture', help: "capture a card into this space's inbox, without leaving here — reading a document, a card about it; Tab on the line makes it a note, or a line in today's entry", essential: true },
{ context: 'tree', keys: ['q'], action: 'close', label: 'Esc · q', help: 'close the document, and its tile when there are several — Esc first puts down what m picked up', essential: true }, { context: 'tree', keys: ['q'], action: 'close', label: 'Esc · q', help: 'close the document, and its tile when there are several — Esc first puts down what m picked up, and in the list lets go of a search', essential: true },
{ context: 'tree', keys: ['Escape'], action: 'escape' }, { context: 'tree', keys: ['Escape'], action: 'escape' },
{ context: 'tree', keys: ['/'], action: 'search', help: 'full-text search, inside the current space and its text files — Tab takes it to every space, Ctrl-N/P walk the results, Enter opens one', essential: true }, { context: 'tree', keys: ['/'], action: 'search', help: 'full-text search, inside the current space and its text files — Tab takes it to every space, Ctrl-N/P walk the results, Enter opens one, Esc gives up', essential: true },
{ context: 'tree', keys: ['n'], action: 'next', label: 'n / N', help: 'next / previous search result, opened beside the results — :noh clears the search' }, { context: 'tree', keys: ['n'], action: 'next', label: 'n / N', help: 'next / previous search result, opened beside the results — Esc or :noh clears the search' },
{ context: 'tree', keys: ['N'], action: 'previous' }, { context: 'tree', keys: ['N'], action: 'previous' },
{ context: 'tree', keys: ['x', 'dd'], action: 'delete', help: 'delete what has focus — the row in the list, the document in its pane — into the trash (asks y/N)', essential: true }, { context: 'tree', keys: ['x', 'dd'], action: 'delete', help: 'delete what has focus — the row in the list, the document in its pane — into the trash (asks y/N)', essential: true },
{ context: 'tree', keys: ['u'], action: 'undo', help: 'undo the last delete: it comes back out of the trash — :trash has the rest', essential: true }, { context: 'tree', keys: ['u'], action: 'undo', help: 'undo the last delete: it comes back out of the trash — :trash has the rest', essential: true },
+2 -1
View File
@@ -155,7 +155,7 @@ export function claim(event: KeyboardEvent): boolean {
/* /*
* The phone's back gesture, or its back button: one thing let go of, the way * The phone's back gesture, or its back button: one thing let go of, the way
* Esc and q let go of it on a keyboard — a question, the line, a sheet, the * Esc and q let go of it on a keyboard — a question, the line, a sheet, the
* drawer, the editor, a tile — and then up a folder. False when there is * drawer, the editor, a tile, a search — and then up a folder. False when there is
* nothing left to leave, and the app itself may go. * nothing left to leave, and the app itself may go.
*/ */
export function back(): boolean { export function back(): boolean {
@@ -177,6 +177,7 @@ function leaving(): (() => unknown) | null {
if (workspace.fullscreen) return () => (workspace.fullscreen = false) if (workspace.fullscreen) return () => (workspace.fullscreen = false)
if (workspace.focus !== 'list' && workspace.focus !== 'node') return () => runCommand('close') if (workspace.focus !== 'list' && workspace.focus !== 'node') return () => runCommand('close')
if (workspace.open && workspace.pane === 'document') return () => runCommand('q') if (workspace.open && workspace.pane === 'document') return () => runCommand('q')
if (tree.query) return () => workspace.guard(() => tree.clearSearch())
if (tree.path.length > 1) return () => workspace.guard(() => tree.up()) if (tree.path.length > 1) return () => workspace.guard(() => tree.up())
return null return null
} }
+2
View File
@@ -180,6 +180,8 @@ export class Tiles {
this.current.clear() this.current.clear()
this.prune() this.prune()
if (!this.current.empty) this.focusOn(this.focused) if (!this.current.empty) this.focusOn(this.focused)
// Nothing open: the address is the folder's again.
else this.ws.tree.syncUrl(this.ws.tree.path)
} }
/** /**
+19 -4
View File
@@ -239,6 +239,7 @@ export class Tree {
const folder = await api.getNode(id) const folder = await api.getNode(id)
// Walking into another space by a link leaves this one where it was. // Walking into another space by a link leaves this one where it was.
if (this.spaceId !== null && folder.path[0]?.id !== this.spaceId) this.#rememberSpot() if (this.spaceId !== null && folder.path[0]?.id !== this.spaceId) this.#rememberSpot()
this.#reading++
this.parentId = id this.parentId = id
this.parentSealed = folder.sealed this.parentSealed = folder.sealed
this.query = '' this.query = ''
@@ -259,19 +260,27 @@ export class Tree {
await this.goTo(parent.id, from.id) await this.goTo(parent.id, from.id)
} }
/** Which `refresh` is the latest: an older one answering late is dropped. */
#reading = 0
/** `keep` follows a node that just arrived, instead of holding the cursor. */ /** `keep` follows a node that just arrived, instead of holding the cursor. */
async refresh(keep?: number) { async refresh(keep?: number) {
const keepId = keep ?? this.selected?.id const keepId = keep ?? this.selected?.id
const reading = ++this.#reading
if (this.query) { if (this.query) {
const space = this.searchSpace const space = this.searchSpace
// A note is in no space, so every search looks through them all. // A note is in no space, so every search looks through them all.
;[this.nodes, this.foundCards, this.foundNotes] = await Promise.all([ const found = await Promise.all([
api.listNodes({ q: this.query, space }), api.listNodes({ q: this.query, space }),
api.searchCards(this.query, space), api.searchCards(this.query, space),
api.scratch(this.query), api.scratch(this.query),
]) ])
if (reading !== this.#reading) return
;[this.nodes, this.foundCards, this.foundNotes] = found
} else { } else {
this.nodes = await this.#withFiles(await api.listNodes({ parent: this.parentId ?? undefined })) const nodes = await this.#withFiles(await api.listNodes({ parent: this.parentId ?? undefined }))
if (reading !== this.#reading) return
this.nodes = nodes
this.foundCards = [] this.foundCards = []
this.foundNotes = [] this.foundNotes = []
} }
@@ -312,11 +321,17 @@ export class Tree {
return this.globalSearch ? undefined : (this.spaceId ?? undefined) return this.globalSearch ? undefined : (this.spaceId ?? undefined)
} }
async search(query: string, global = this.globalSearch) { /** `keep`: the node the cursor stays on, when the list it lands in has it. */
async search(query: string, global = this.globalSearch, keep?: number) {
this.query = query this.query = query
this.globalSearch = global && query !== '' this.globalSearch = global && query !== ''
this.cursor = 0 this.cursor = 0
await this.refresh() await this.refresh(keep)
}
/** Esc, or back: the search let go of, the folder shown again with the cursor where it was in the results. */
async clearSearch() {
await this.search('', false, this.selected?.id)
} }
/** /**