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

+36 -5
View File
@@ -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 @@
<CommandLine initial={cmdline} close={() => (cmdline = null)} />
{/if}
<Prompt />
{#if workspace.overlay !== 'none'}
<!-- 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')}
+2 -2
View File
@@ -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'],
+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: {
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 <folder|..>', '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 <id>', '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)
+34 -1
View File
@@ -65,6 +65,13 @@ class Workspace {
flash = $state<Flash>(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<MediaFile[]>([])
overview = $state<SpaceOverview[]>([])
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<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') {
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}`)
}
/**