From 4542b9692e41a40e2af0802a55e3a500baf0bd7e Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Thu, 22 Oct 2026 22:58:27 +0200 Subject: [PATCH] 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. --- docs/wiki/Commands.md | 6 +++--- docs/wiki/Files-and-Search.md | 6 ++++-- web/src/lib/CommandLine.svelte | 21 +++++++++++++++++++++ web/src/lib/Sidebar.svelte | 2 +- web/src/lib/actions.ts | 7 +++++-- web/src/lib/keymap.ts | 6 +++--- web/src/lib/modes.ts | 3 ++- web/src/lib/state/tiles.svelte.ts | 2 ++ web/src/lib/state/tree.svelte.ts | 23 +++++++++++++++++++---- 9 files changed, 60 insertions(+), 16 deletions(-) diff --git a/docs/wiki/Commands.md b/docs/wiki/Commands.md index fc5f005..0ac34b7 100644 --- a/docs/wiki/Commands.md +++ b/docs/wiki/Commands.md @@ -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 | diff --git a/docs/wiki/Files-and-Search.md b/docs/wiki/Files-and-Search.md index d948c05..20812c1 100644 --- a/docs/wiki/Files-and-Search.md +++ b/docs/wiki/Files-and-Search.md @@ -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 diff --git a/web/src/lib/CommandLine.svelte b/web/src/lib/CommandLine.svelte index f3b941a..9156127 100644 --- a/web/src/lib/CommandLine.svelte +++ b/web/src/lib/CommandLine.svelte @@ -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) diff --git a/web/src/lib/Sidebar.svelte b/web/src/lib/Sidebar.svelte index d2e2099..27013d7 100644 --- a/web/src/lib/Sidebar.svelte +++ b/web/src/lib/Sidebar.svelte @@ -101,7 +101,7 @@
/{workspace.tree.query} {#if workspace.tree.globalSearch}all spaces{/if} - +
{/if} diff --git a/web/src/lib/actions.ts b/web/src/lib/actions.ts index 3ecb96c..a8541fd 100644 --- a/web/src/lib/actions.ts +++ b/web/src/lib/actions.ts @@ -431,16 +431,19 @@ export const actions: { [C in Context]: Record, 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('/'), diff --git a/web/src/lib/keymap.ts b/web/src/lib/keymap.ts index d6cd112..cb0d29d 100644 --- a/web/src/lib/keymap.ts +++ b/web/src/lib/keymap.ts @@ -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 }, diff --git a/web/src/lib/modes.ts b/web/src/lib/modes.ts index b4a68db..3993a8c 100644 --- a/web/src/lib/modes.ts +++ b/web/src/lib/modes.ts @@ -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 } diff --git a/web/src/lib/state/tiles.svelte.ts b/web/src/lib/state/tiles.svelte.ts index ab3a658..69f8c4b 100644 --- a/web/src/lib/state/tiles.svelte.ts +++ b/web/src/lib/state/tiles.svelte.ts @@ -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) } /** diff --git a/web/src/lib/state/tree.svelte.ts b/web/src/lib/state/tree.svelte.ts index 46a6145..973fb36 100644 --- a/web/src/lib/state/tree.svelte.ts +++ b/web/src/lib/state/tree.svelte.ts @@ -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) } /**