From d9b78170d859046a17fb6f80b2ac2e3a02251902 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Mon, 19 Oct 2026 14:39:38 +0200 Subject: [PATCH] keys act on the focused pane, deletes ask y/N in place dd, x, i, R, s, y and :mv used the open document whenever one was open, so walking the list and pressing dd deleted the document beside it. They now act on the row under the cursor while the list has focus. Deletes ask an in-app y/N naming the node instead of confirm(), and the board no longer lets tree keys through to the hidden list. --- README.md | 6 +++-- web/src/App.svelte | 41 +++++++++++++++++++++++++++++---- web/src/lib/Help.svelte | 4 ++-- web/src/lib/Prompt.svelte | 45 ++++++++++++++++++++++++++++++++++++ web/src/lib/commands.ts | 46 ++++++++++++++++++++++--------------- web/src/lib/store.svelte.ts | 35 +++++++++++++++++++++++++++- 6 files changed, 149 insertions(+), 28 deletions(-) create mode 100644 web/src/lib/Prompt.svelte diff --git a/README.md b/README.md index b1758d9..882648c 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,9 @@ works once the frontend has been built at least once. ## Keys -The desktop UI is modal. In the browse pane: +The desktop UI is modal, and a key acts on whatever has focus: in the list +that is the row under the cursor, even while another document is open beside +it; in the document pane it is that document. In the browse pane: | key | action | | --- | --- | @@ -65,7 +67,7 @@ The desktop UI is modal. In the browse pane: | `h` | up one level in the tree | | `/` | full-text search (`n` clears it) | | `s` / `y` | toggle sharing / mint a new share link | -| `x`, `dd` | delete the entry | +| `x`, `dd` | delete the entry under the cursor (asks `y`/`N`) | | `:` | command line | | `Ctrl-S` | save from anywhere | | `?` | help | diff --git a/web/src/App.svelte b/web/src/App.svelte index 0c5be2f..7a54edb 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -9,6 +9,7 @@ import Login from './lib/Login.svelte' import Overview from './lib/Overview.svelte' import MediaBrowser from './lib/MediaBrowser.svelte' + import Prompt from './lib/Prompt.svelte' import Rail from './lib/Rail.svelte' import Shared from './lib/Shared.svelte' import Sidebar from './lib/Sidebar.svelte' @@ -58,6 +59,23 @@ } }) + /* + * A pending `[y/N]` takes the next key before anything else sees it — the + * editor included, where `y` would otherwise start a yank. Only `y` agrees; + * every other key, Escape among them, declines. + */ + $effect(() => { + const answer = (event: KeyboardEvent) => { + const prompt = workspace.prompt + if (!prompt || ['Shift', 'Control', 'Alt', 'Meta', 'CapsLock'].includes(event.key)) return + event.preventDefault() + event.stopPropagation() + prompt.resolve(event.key === 'y' || event.key === 'Y') + } + window.addEventListener('keydown', answer, true) + return () => window.removeEventListener('keydown', answer, true) + }) + // Guard against losing a half-written entry to a stray refresh. $effect(() => { if (!workspace.dirty) return @@ -178,9 +196,9 @@ /* * The board reads the same keys as the list, one dimension wider: h/l walk - * the lists, j/k the cards, and the shifted pair takes the card along. Only - * `:` and `?` fall through to the keys below, because everything else would - * act on a tree that is not on screen. + * the lists, j/k the cards, and the shifted pair takes the card along. + * Nothing falls through to the tree keys, because they would act on a tree + * that is not on screen. */ function boardKey(key: string): boolean { switch (key) { @@ -331,7 +349,12 @@ workspace.cardCursor = 0 return } - if (key === 'g') { + if (previous === 'd' && key === 'd') { + event.preventDefault() + void runCommand('delete') + return + } + if (key === 'g' || key === 'd') { pending = key return } @@ -339,6 +362,12 @@ event.preventDefault() return } + if (key === ':' || key === '?') { + event.preventDefault() + if (key === ':') cmdline = ':' + else void runCommand('help') + } + return } /* @@ -444,7 +473,7 @@ break case 's': event.preventDefault() - void runCommand(workspace.selected?.shared ? 'unshare' : 'share') + void runCommand(workspace.target?.shared ? 'unshare' : 'share') break case 'y': event.preventDefault() @@ -543,6 +572,8 @@ (cmdline = null)} /> {/if} + + {#if workspace.overlay !== 'none'}
e.target === e.currentTarget && (workspace.overlay = 'none')} diff --git a/web/src/lib/Help.svelte b/web/src/lib/Help.svelte index 8f7f5fa..91f1bd7 100644 --- a/web/src/lib/Help.svelte +++ b/web/src/lib/Help.svelte @@ -17,7 +17,7 @@ ['o', 'new document here, straight into insert mode'], ['O', 'new folder here'], ['i · a', 'edit the open document'], - ['R', 'rename what is marked — document, folder or card'], + ['R', 'rename what is under the cursor — document, folder or card'], ['m / p', 'pick a node up, then drop it into the folder you walk to'], ['b', "open this space's board"], ['Esc · q', 'close the document'], @@ -25,7 +25,7 @@ ['n', 'clear the search'], ['s', 'toggle sharing for the document'], ['y', 'mint a new share link and copy it'], - ['x · dd', 'delete what is selected (asks first)'], + ['x · dd', 'delete what has focus — the row in the list, the document in its pane (asks y/N)'], [':', 'command line'], ['Ctrl-S', 'save, from anywhere'], ['?', 'this screen — in the editor too'], diff --git a/web/src/lib/Prompt.svelte b/web/src/lib/Prompt.svelte new file mode 100644 index 0000000..8a25dda --- /dev/null +++ b/web/src/lib/Prompt.svelte @@ -0,0 +1,45 @@ + + +{#if workspace.prompt} +
+ {workspace.prompt.text} + + + + +
+{/if} + + diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts index d2d78ba..4c9af27 100644 --- a/web/src/lib/commands.ts +++ b/web/src/lib/commands.ts @@ -234,7 +234,9 @@ export const commands: CommandSpec[] = [ bang: { help: 'add a card linked to the open document — Enter on it opens the document', run: async (arg) => { - const node = workspace.open ?? workspace.selected + // On the board the tree is not on screen, so only the open document + // can be meant; elsewhere it is whatever has focus. + const node = workspace.view === 'board' ? workspace.open : workspace.target if (!node || node.kind !== 'document') { return workspace.say('no document to link — open one first', 'error') } @@ -287,7 +289,8 @@ export const commands: CommandSpec[] = [ if (workspace.view !== 'board') return workspace.say('no board open — :board', 'error') const list = workspace.list if (!list) return workspace.say('no list selected', 'error') - if (!confirm(`delete the list "${list.name}" and its ${list.cards.length} cards?`)) return + const cards = list.cards.length + if (!(await workspace.ask(`delete the list "${list.name}" and its ${cards} card${cards === 1 ? '' : 's'}?`))) return await workspace.guard(() => workspace.deleteList()) }, }, @@ -299,7 +302,7 @@ export const commands: CommandSpec[] = [ group: 'tree', help: 'move the selected node into a folder here, or up one level', run: async (arg) => { - const node = workspace.open ?? workspace.selected + const node = workspace.target if (!node) return workspace.say('nothing selected', 'error') const target = arg.trim() if (!target) return workspace.say('usage: :mv ', 'error') @@ -364,7 +367,11 @@ export const commands: CommandSpec[] = [ group: 'editing', help: 'edit the open document, or open a node by id', run: async (arg) => { - const id = arg ? Number(arg) : workspace.open?.id ?? workspace.selected?.id + const node = arg ? undefined : workspace.target + if (node && node.kind !== 'document') { + return workspace.say(`${node.slug} is a ${node.kind} — l opens it`, 'error') + } + const id = arg ? Number(arg) : node?.id if (!id || Number.isNaN(id)) return workspace.say('usage: :e ', 'error') await workspace.guard(() => workspace.openNode(id, true)) hooks.focusEditor() @@ -424,17 +431,20 @@ export const commands: CommandSpec[] = [ if (workspace.view === 'board') { const card = workspace.card if (!card) return workspace.say('no card selected', 'error') - if (!confirm(`delete the card "${card.title}"?`)) return + if (!(await workspace.ask(`delete the card "${card.title}"?`))) return return workspace.guard(() => workspace.deleteCard()) } - const id = arg ? Number(arg) : workspace.open?.id ?? workspace.selected?.id + // Whatever has focus: the row under the cursor in the list, the open + // document only while the document pane is the one being looked at. + const id = arg ? Number(arg) : workspace.target?.id if (!id || Number.isNaN(id)) return workspace.say('nothing to delete', 'error') const node = workspace.open?.id === id ? workspace.open : workspace.nodes.find((n) => n.id === id) + const name = node ? `"${node.name.trim() || node.slug}"` : `#${id}` const what = node && node.kind !== 'document' - ? `delete ${node.kind} "${node.name}" and everything in it?` - : `delete #${id}?` - if (!confirm(`${what} this cannot be undone.`)) return + ? `delete the ${node.kind} ${name} and everything in it?` + : `delete ${name}?` + if (!(await workspace.ask(`${what} this cannot be undone.`))) return await workspace.guard(() => workspace.deleteNode(id)) }, }, @@ -453,13 +463,13 @@ export const commands: CommandSpec[] = [ // The open document is renamed through the draft, so an unsaved body is // written with the new name instead of being left behind by it. - if (workspace.open) { + const node = workspace.target + if (node && node.id === workspace.open?.id) { workspace.draft.name = arg workspace.dirty = true return workspace.guard(() => workspace.save()) } - const node = workspace.selected if (!node) return workspace.say('nothing selected — :name! renames the space', 'error') await workspace.guard(() => workspace.renameNode(node.id, arg)) }, @@ -485,7 +495,7 @@ export const commands: CommandSpec[] = [ ? workspace.spaces.find((s) => s.slug === wanted || s.name.toLowerCase() === wanted) : workspace.space if (!space) return workspace.say(`no such space: ${wanted || '(none open)'}`, 'error') - if (!confirm(`delete the space "${space.name}" and everything in it? this cannot be undone.`)) { + if (!(await workspace.ask(`delete the space "${space.name}" and everything in it? this cannot be undone.`))) { return } await workspace.guard(() => workspace.deleteSpace(space.id)) @@ -510,9 +520,9 @@ export const commands: CommandSpec[] = [ group: 'sharing', help: 'publish the document behind an unguessable link and copy it', run: async () => { - const id = workspace.open?.id ?? workspace.selected?.id + const node = workspace.target + const id = node?.id if (!id) return workspace.say('nothing selected', 'error') - const node = workspace.open?.id === id ? workspace.open : workspace.selected if (node?.shared) return workspace.say('already shared — :link for a new link, :unshare to revoke') await workspace.guard(() => workspace.toggleShare(id)) }, @@ -522,9 +532,9 @@ export const commands: CommandSpec[] = [ group: 'sharing', help: 'revoke the share link', run: async () => { - const id = workspace.open?.id ?? workspace.selected?.id + const node = workspace.target + const id = node?.id if (!id) return workspace.say('nothing selected', 'error') - const node = workspace.open?.id === id ? workspace.open : workspace.selected if (!node?.shared) return workspace.say('not shared') await workspace.guard(() => workspace.toggleShare(id)) }, @@ -534,9 +544,9 @@ export const commands: CommandSpec[] = [ group: 'sharing', help: 'mint a new share link for the document and copy it', run: async () => { - const id = workspace.open?.id ?? workspace.selected?.id + const node = workspace.target + const id = node?.id if (!id) return workspace.say('nothing selected', 'error') - const node = workspace.open?.id === id ? workspace.open : workspace.selected if (!node?.shared) return workspace.say('not shared — :share first', 'error') await workspace.guard(async () => { const url = await workspace.mintShareLink(id) diff --git a/web/src/lib/store.svelte.ts b/web/src/lib/store.svelte.ts index bbb6de3..ae7c009 100644 --- a/web/src/lib/store.svelte.ts +++ b/web/src/lib/store.svelte.ts @@ -65,6 +65,13 @@ class Workspace { flash = $state(null) overlay = $state<'none' | 'help' | 'media' | 'overview'>('none') + + /* + * A `[y/N]` question in the command line's place, instead of the browser's + * confirm(): it names the thing about to go, and it is answered from the + * keyboard without a modal the rest of the page cannot see past. + */ + prompt = $state<{ text: string; resolve: (yes: boolean) => void } | null>(null) media = $state([]) overview = $state([]) overviewCursor = $state(0) @@ -92,6 +99,15 @@ class Workspace { return this.nodes[this.cursor] } + /** + * What a verb acts on: the open document while the document pane has focus, + * the row under the cursor while the list does. Walking the list with a + * document still open must not make `dd` delete the document. + */ + get target(): NodeSummary | undefined { + return this.open && this.pane === 'document' ? this.open : this.selected + } + get space(): NodeSummary | undefined { return this.spaces.find((s) => s.id === this.spaceId) } @@ -109,6 +125,19 @@ class Workspace { return this.list?.cards[this.cardCursor] } + ask(text: string): Promise { + this.prompt?.resolve(false) + return new Promise((resolve) => { + this.prompt = { + text, + resolve: (yes) => { + this.prompt = null + resolve(yes) + }, + } + }) + } + say(text: string, kind: 'info' | 'error' = 'info') { this.flash = { text, kind } if (kind === 'info') { @@ -471,14 +500,18 @@ class Workspace { this.#autosave = null } this.dropStash(id) + const node = this.open?.id === id ? this.open : this.nodes.find((n) => n.id === id) const { removed } = await api.deleteNode(id) if (this.open?.id === id) { this.open = null this.editing = false + this.dirty = false this.pane = 'list' } + if (this.marked?.id === id) this.marked = null await this.refresh() - this.say(removed > 1 ? `deleted ${removed} nodes` : `deleted #${id}`) + const name = node ? `"${node.name.trim() || node.slug}"` : `#${id}` + this.say(removed > 1 ? `deleted ${name} and ${removed - 1} inside it` : `deleted ${name}`) } /**