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 |
| `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 |
| `Esc · q` | close the document, and its tile when there are several — Esc first puts down what m picked up |
| `/` | 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 |
| `n / N` | next / previous search result, opened beside the results — :noh clears the search |
| `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, Esc gives up |
| `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) |
| `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 |
+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
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
previous result beside the list, and `:noh` clears the search.
line, and `Enter` opens the one lit and keeps the results in the list. `Esc`
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
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' }))
})
/*
* 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`.
$effect(() => {
if (!isSearch) return
@@ -174,6 +194,7 @@
const line = value
// Closing takes the line's props with it, so what it was is read first.
const [jumping, searching, place] = [jump, isSearch, places[lit]]
entered = true
remember(historyKey, line)
close()
if (jumping) return go(place)
+1 -1
View File
@@ -101,7 +101,7 @@
<div class="filter">
<span class="muted">/</span><span class="query">{workspace.tree.query}</span>
{#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>
{/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')
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: () => {
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')
},
delete: () => void runCommand('d'),
// A delete goes to the trash, so it can be undone the way vim undoes.
undo: () => guard(() => workspace.tree.undoDelete()),
// 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)),
previous: () => guard(() => workspace.tree.nextResult(-1)),
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: ['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: ['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: ['/'], 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: ['n'], action: 'next', label: 'n / N', help: 'next / previous search result, opened beside the results — :noh clears the search' },
{ 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 — Esc or :noh clears the search' },
{ 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: ['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
* 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.
*/
export function back(): boolean {
@@ -177,6 +177,7 @@ function leaving(): (() => unknown) | null {
if (workspace.fullscreen) return () => (workspace.fullscreen = false)
if (workspace.focus !== 'list' && workspace.focus !== 'node') return () => runCommand('close')
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())
return null
}
+2
View File
@@ -180,6 +180,8 @@ export class Tiles {
this.current.clear()
this.prune()
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)
// 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()
this.#reading++
this.parentId = id
this.parentSealed = folder.sealed
this.query = ''
@@ -259,19 +260,27 @@ export class Tree {
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. */
async refresh(keep?: number) {
const keepId = keep ?? this.selected?.id
const reading = ++this.#reading
if (this.query) {
const space = this.searchSpace
// 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.searchCards(this.query, space),
api.scratch(this.query),
])
if (reading !== this.#reading) return
;[this.nodes, this.foundCards, this.foundNotes] = found
} 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.foundNotes = []
}
@@ -312,11 +321,17 @@ export class Tree {
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.globalSearch = global && query !== ''
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)
}
/**