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.
This commit is contained in:
nvrl committed 2026-10-19 14:39:38 +02:00
1 parent 4ded89b0e7
commit d9b78170d8
6 files changed
+149 -28

No files matched your search

+4 -2
View File
@@ -49,7 +49,9 @@ works once the frontend has been built at least once.
## Keys ## 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 | | key | action |
| --- | --- | | --- | --- |
@@ -65,7 +67,7 @@ The desktop UI is modal. In the browse pane:
| `h` | up one level in the tree | | `h` | up one level in the tree |
| `/` | full-text search (`n` clears it) | | `/` | full-text search (`n` clears it) |
| `s` / `y` | toggle sharing / mint a new share link | | `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 | | `:` | command line |
| `Ctrl-S` | save from anywhere | | `Ctrl-S` | save from anywhere |
| `?` | help | | `?` | help |
+36 -5
View File
@@ -9,6 +9,7 @@
import Login from './lib/Login.svelte' import Login from './lib/Login.svelte'
import Overview from './lib/Overview.svelte' import Overview from './lib/Overview.svelte'
import MediaBrowser from './lib/MediaBrowser.svelte' import MediaBrowser from './lib/MediaBrowser.svelte'
import Prompt from './lib/Prompt.svelte'
import Rail from './lib/Rail.svelte' import Rail from './lib/Rail.svelte'
import Shared from './lib/Shared.svelte' import Shared from './lib/Shared.svelte'
import Sidebar from './lib/Sidebar.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. // Guard against losing a half-written entry to a stray refresh.
$effect(() => { $effect(() => {
if (!workspace.dirty) return if (!workspace.dirty) return
@@ -178,9 +196,9 @@
/* /*
* The board reads the same keys as the list, one dimension wider: h/l walk * 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 * the lists, j/k the cards, and the shifted pair takes the card along.
* `:` and `?` fall through to the keys below, because everything else would * Nothing falls through to the tree keys, because they would act on a tree
* act on a tree that is not on screen. * that is not on screen.
*/ */
function boardKey(key: string): boolean { function boardKey(key: string): boolean {
switch (key) { switch (key) {
@@ -331,7 +349,12 @@
workspace.cardCursor = 0 workspace.cardCursor = 0
return return
} }
if (key === 'g') { if (previous === 'd' && key === 'd') {
event.preventDefault()
void runCommand('delete')
return
}
if (key === 'g' || key === 'd') {
pending = key pending = key
return return
} }
@@ -339,6 +362,12 @@
event.preventDefault() event.preventDefault()
return return
} }
if (key === ':' || key === '?') {
event.preventDefault()
if (key === ':') cmdline = ':'
else void runCommand('help')
}
return
} }
/* /*
@@ -444,7 +473,7 @@
break break
case 's': case 's':
event.preventDefault() event.preventDefault()
void runCommand(workspace.selected?.shared ? 'unshare' : 'share') void runCommand(workspace.target?.shared ? 'unshare' : 'share')
break break
case 'y': case 'y':
event.preventDefault() event.preventDefault()
@@ -543,6 +572,8 @@
<CommandLine initial={cmdline} close={() => (cmdline = null)} /> <CommandLine initial={cmdline} close={() => (cmdline = null)} />
{/if} {/if}
<Prompt />
{#if workspace.overlay !== 'none'} {#if workspace.overlay !== 'none'}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_tabindex --> <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_tabindex -->
<div class="overlay" tabindex="-1" bind:this={overlayEl} onclick={(e) => e.target === e.currentTarget && (workspace.overlay = 'none')} <div class="overlay" tabindex="-1" bind:this={overlayEl} onclick={(e) => e.target === e.currentTarget && (workspace.overlay = 'none')}
+2 -2
View File
@@ -17,7 +17,7 @@
['o', 'new document here, straight into insert mode'], ['o', 'new document here, straight into insert mode'],
['O', 'new folder here'], ['O', 'new folder here'],
['i · a', 'edit the open document'], ['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'], ['m / p', 'pick a node up, then drop it into the folder you walk to'],
['b', "open this space's board"], ['b', "open this space's board"],
['Esc · q', 'close the document'], ['Esc · q', 'close the document'],
@@ -25,7 +25,7 @@
['n', 'clear the search'], ['n', 'clear the search'],
['s', 'toggle sharing for the document'], ['s', 'toggle sharing for the document'],
['y', 'mint a new share link and copy it'], ['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'], [':', 'command line'],
['Ctrl-S', 'save, from anywhere'], ['Ctrl-S', 'save, from anywhere'],
['?', 'this screen — in the editor too'], ['?', 'this screen — in the editor too'],
+45
View File
@@ -0,0 +1,45 @@
<script lang="ts">
/*
* The `[y/N]` question `workspace.ask` raises, drawn where the command line
* opens. The keys are read in App.svelte, ahead of everything else, so a
* stray `y` can never reach the editor or the list underneath.
*/
import { workspace } from './store.svelte'
</script>
{#if workspace.prompt}
<div class="prompt" role="alertdialog" aria-label={workspace.prompt.text}>
<span class="text">{workspace.prompt.text}</span>
<span class="choice">
<button class="yes" onclick={() => workspace.prompt?.resolve(true)}><span class="kbd">y</span> yes</button>
<button onclick={() => workspace.prompt?.resolve(false)}>no</button>
</span>
</div>
{/if}
<style>
.prompt {
position: absolute;
left: calc(var(--frame) + 12px);
right: calc(var(--rail-w) + 12px);
bottom: calc(var(--status-h) - 1px);
z-index: 40;
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: color-mix(in oklab, var(--hull) 97%, transparent);
border: 1px solid var(--alarm);
border-bottom: 0;
border-radius: var(--radius-l) var(--radius-l) 0 0;
font-size: 13px;
animation: rise var(--t-slow) var(--ease-snap) both;
}
.text { flex: 1; min-width: 0; color: var(--light); overflow-wrap: anywhere; }
.choice { flex: none; display: flex; gap: 6px; align-items: center; }
.yes:hover { background: var(--alarm); border-color: var(--alarm); color: var(--ink); }
@media (max-width: 767px) {
.prompt { left: 8px; right: 8px; flex-wrap: wrap; }
}
</style>
+28 -18
View File
@@ -234,7 +234,9 @@ export const commands: CommandSpec[] = [
bang: { bang: {
help: 'add a card linked to the open document — Enter on it opens the document', help: 'add a card linked to the open document — Enter on it opens the document',
run: async (arg) => { 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') { if (!node || node.kind !== 'document') {
return workspace.say('no document to link — open one first', 'error') 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') if (workspace.view !== 'board') return workspace.say('no board open — :board', 'error')
const list = workspace.list const list = workspace.list
if (!list) return workspace.say('no list selected', 'error') 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()) await workspace.guard(() => workspace.deleteList())
}, },
}, },
@@ -299,7 +302,7 @@ export const commands: CommandSpec[] = [
group: 'tree', group: 'tree',
help: 'move the selected node into a folder here, or up one level', help: 'move the selected node into a folder here, or up one level',
run: async (arg) => { run: async (arg) => {
const node = workspace.open ?? workspace.selected const node = workspace.target
if (!node) return workspace.say('nothing selected', 'error') if (!node) return workspace.say('nothing selected', 'error')
const target = arg.trim() const target = arg.trim()
if (!target) return workspace.say('usage: :mv <folder|..>', 'error') if (!target) return workspace.say('usage: :mv <folder|..>', 'error')
@@ -364,7 +367,11 @@ export const commands: CommandSpec[] = [
group: 'editing', group: 'editing',
help: 'edit the open document, or open a node by id', help: 'edit the open document, or open a node by id',
run: async (arg) => { 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 <id>', 'error') if (!id || Number.isNaN(id)) return workspace.say('usage: :e <id>', 'error')
await workspace.guard(() => workspace.openNode(id, true)) await workspace.guard(() => workspace.openNode(id, true))
hooks.focusEditor() hooks.focusEditor()
@@ -424,17 +431,20 @@ export const commands: CommandSpec[] = [
if (workspace.view === 'board') { if (workspace.view === 'board') {
const card = workspace.card const card = workspace.card
if (!card) return workspace.say('no card selected', 'error') 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()) 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') 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 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 = const what =
node && node.kind !== 'document' node && node.kind !== 'document'
? `delete ${node.kind} "${node.name}" and everything in it?` ? `delete the ${node.kind} ${name} and everything in it?`
: `delete #${id}?` : `delete ${name}?`
if (!confirm(`${what} this cannot be undone.`)) return if (!(await workspace.ask(`${what} this cannot be undone.`))) return
await workspace.guard(() => workspace.deleteNode(id)) 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 // 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. // 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.draft.name = arg
workspace.dirty = true workspace.dirty = true
return workspace.guard(() => workspace.save()) return workspace.guard(() => workspace.save())
} }
const node = workspace.selected
if (!node) return workspace.say('nothing selected — :name! renames the space', 'error') if (!node) return workspace.say('nothing selected — :name! renames the space', 'error')
await workspace.guard(() => workspace.renameNode(node.id, arg)) 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.spaces.find((s) => s.slug === wanted || s.name.toLowerCase() === wanted)
: workspace.space : workspace.space
if (!space) return workspace.say(`no such space: ${wanted || '(none open)'}`, 'error') 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 return
} }
await workspace.guard(() => workspace.deleteSpace(space.id)) await workspace.guard(() => workspace.deleteSpace(space.id))
@@ -510,9 +520,9 @@ export const commands: CommandSpec[] = [
group: 'sharing', group: 'sharing',
help: 'publish the document behind an unguessable link and copy it', help: 'publish the document behind an unguessable link and copy it',
run: async () => { 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') 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') if (node?.shared) return workspace.say('already shared — :link for a new link, :unshare to revoke')
await workspace.guard(() => workspace.toggleShare(id)) await workspace.guard(() => workspace.toggleShare(id))
}, },
@@ -522,9 +532,9 @@ export const commands: CommandSpec[] = [
group: 'sharing', group: 'sharing',
help: 'revoke the share link', help: 'revoke the share link',
run: async () => { 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') 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') if (!node?.shared) return workspace.say('not shared')
await workspace.guard(() => workspace.toggleShare(id)) await workspace.guard(() => workspace.toggleShare(id))
}, },
@@ -534,9 +544,9 @@ export const commands: CommandSpec[] = [
group: 'sharing', group: 'sharing',
help: 'mint a new share link for the document and copy it', help: 'mint a new share link for the document and copy it',
run: async () => { 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') 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') if (!node?.shared) return workspace.say('not shared — :share first', 'error')
await workspace.guard(async () => { await workspace.guard(async () => {
const url = await workspace.mintShareLink(id) const url = await workspace.mintShareLink(id)
+34 -1
View File
@@ -65,6 +65,13 @@ class Workspace {
flash = $state<Flash>(null) flash = $state<Flash>(null)
overlay = $state<'none' | 'help' | 'media' | 'overview'>('none') 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<MediaFile[]>([]) media = $state<MediaFile[]>([])
overview = $state<SpaceOverview[]>([]) overview = $state<SpaceOverview[]>([])
overviewCursor = $state(0) overviewCursor = $state(0)
@@ -92,6 +99,15 @@ class Workspace {
return this.nodes[this.cursor] 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 { get space(): NodeSummary | undefined {
return this.spaces.find((s) => s.id === this.spaceId) return this.spaces.find((s) => s.id === this.spaceId)
} }
@@ -109,6 +125,19 @@ class Workspace {
return this.list?.cards[this.cardCursor] return this.list?.cards[this.cardCursor]
} }
ask(text: string): Promise<boolean> {
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') { say(text: string, kind: 'info' | 'error' = 'info') {
this.flash = { text, kind } this.flash = { text, kind }
if (kind === 'info') { if (kind === 'info') {
@@ -471,14 +500,18 @@ class Workspace {
this.#autosave = null this.#autosave = null
} }
this.dropStash(id) this.dropStash(id)
const node = this.open?.id === id ? this.open : this.nodes.find((n) => n.id === id)
const { removed } = await api.deleteNode(id) const { removed } = await api.deleteNode(id)
if (this.open?.id === id) { if (this.open?.id === id) {
this.open = null this.open = null
this.editing = false this.editing = false
this.dirty = false
this.pane = 'list' this.pane = 'list'
} }
if (this.marked?.id === id) this.marked = null
await this.refresh() 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}`)
} }
/** /**