keys: passwords, titles and names without the pointer

A keystore or encrypted key asks for its password as soon as it is
opened, asks again after a wrong one, and Enter on the file pane asks
once more after Escape — the button was the only way in before. Enter on
a sealed file opens the vault the same way.

`:name` on its own, while writing, moves up into the title, and Enter,
Escape, Down or Tab there goes back into the text; the title could not be
reached from the editor at all. `R` starts the command line from the
current name — of the row, the open document, the card or the list —
instead of an empty one.

`s` on a shared document and `y` both ask y/N before they cut readers
off: y is what a vim hand presses to yank, and it retired the live link
without a word.
This commit is contained in:
nvrl committed 2026-10-20 13:44:37 +02:00
1 parent 4bf8634991
commit 007fb1d3e3
7 files changed
+109 -27

No files matched your search

+5 -4
View File
@@ -77,11 +77,11 @@ it; in the document pane it is that document. In the browse pane:
| `j` / `k` | move down / up, or scroll the document when it has focus | | `j` / `k` | move down / up, or scroll the document when it has focus |
| `gg` / `G` | first / last entry, or top / bottom of the document | | `gg` / `G` | first / last entry, or top / bottom of the document |
| `Ctrl-D` / `Ctrl-U` | half a page down / up the open document | | `Ctrl-D` / `Ctrl-U` | half a page down / up the open document |
| `Enter`, `l` | open the highlighted entry | | `Enter`, `l` | open the highlighted entry; on an open keystore or sealed file, `Enter` asks for its password |
| `o` | a new document here, straight into insert mode | | `o` | a new document here, straight into insert mode |
| `O` | a new folder here | | `O` | a new folder here |
| `i`, `a` | edit the open entry | | `i`, `a` | edit the open entry |
| `R` | rename what is highlighted — a document, a folder or a file (`:name!` renames the space) | | `R` | rename what is highlighted — a document, a folder or a file — starting from its name (`:name!` renames the space) |
| `m` / `p` | pick nodes up — `m` again puts one back, `M` takes the whole folder — then drop them all into the folder you walk to | | `m` / `p` | pick nodes up — `m` again puts one back, `M` takes the whole folder — then drop them all into the folder you walk to |
| `Ctrl`/`Shift`-click | pick a row up / every row from the cursor to it; drag picked-up rows onto a folder, a crumb or a space | | `Ctrl`/`Shift`-click | pick a row up / every row from the cursor to it; drag picked-up rows onto a folder, a crumb or a space |
| `b` | the board of this space | | `b` | the board of this space |
@@ -92,7 +92,7 @@ it; in the document pane it is that document. In the browse pane:
| `h` | up one level in the tree | | `h` | up one level in the tree |
| `/` | full-text search in this space (`n` clears it) | | `/` | full-text search in this space (`n` clears it) |
| `Ctrl-P` | full-text search across every space; `Enter` goes to the match | | `Ctrl-P` | full-text search across every space; `Enter` goes to the match |
| `s` / `y` | toggle sharing / mint a new share link | | `s` / `y` | toggle sharing / mint a new share link — either asks first when it would cut readers off |
| `x`, `dd` | delete the entry under the cursor (asks `y`/`N`) | | `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 |
@@ -102,7 +102,8 @@ Inside the editor the full vim keymap is live — `w`/`b`, `dd`, `ciw`, visual
mode, macros, `:w` to write. `:help` lists every ex command; the useful ones are mode, macros, `:w` to write. `:help` lists every ex command; the useful ones are
`:w`, `:wq`, `:q!`, `:new [yyyy-mm-dd]`, `:date`, `:name`, `:share`, `:link`, `:w`, `:wq`, `:q!`, `:new [yyyy-mm-dd]`, `:date`, `:name`, `:share`, `:link`,
`:upload`, `:media`, `:search`, `:backup` and `:upload`, `:media`, `:search`, `:backup` and
`:set novim`. The rest: `:today` opens today's entry, `:edit [id]` (or `:12`) `:set novim`. `:name` on its own moves up into the title; `Enter`, `Esc` or `↓`
there goes back down into the text. The rest: `:today` opens today's entry, `:edit [id]` (or `:12`)
opens a node by id, `:next`, `:prev`, `:first`, `:last` and `:random` walk the opens a node by id, `:next`, `:prev`, `:first`, `:last` and `:random` walk the
list, `:delete [id]` deletes, `:copy` puts the whole document on the clipboard, list, `:delete [id]` deletes, `:copy` puts the whole document on the clipboard,
`:clear` drops the search, `:reload` re-reads the folder, `:stats` counts words `:clear` drops the search, `:reload` re-reads the folder, `:stats` counts words
+23 -6
View File
@@ -354,7 +354,8 @@
return true return true
case 'R': case 'R':
// On an empty list there is no card to rename, so it is the list. // On an empty list there is no card to rename, so it is the list.
cmdline = workspace.card ? ':name ' : ':name! ' // The name is there to be edited, not typed again from nothing.
cmdline = workspace.card ? `:name ${workspace.card.title}` : `:name! ${workspace.list?.name ?? ''}`
return true return true
case 'D': case 'D':
cmdline = ':due ' cmdline = ':due '
@@ -574,6 +575,7 @@
case 'l': case 'l':
case 'ArrowRight': case 'ArrowRight':
event.preventDefault() event.preventDefault()
if (key === 'Enter' && reading && hooks.fileAction()) break
if (workspace.open && workspace.selected?.id === workspace.open.id) focusPane('document') if (workspace.open && workspace.selected?.id === workspace.open.id) focusPane('document')
else void workspace.guard(() => workspace.openSelected()) else void workspace.guard(() => workspace.openSelected())
break break
@@ -616,11 +618,15 @@
event.preventDefault() event.preventDefault()
void runCommand('board') void runCommand('board')
break break
case 'R': case 'R': {
// Renaming needs a name typed, and names are typed on the command line. // Renaming needs a name typed, and names are typed on the command
// line — starting from the one it has, so a typo is a fix, not a retype.
event.preventDefault() event.preventDefault()
cmdline = ':name ' const target = workspace.target
const current = target && target.id === workspace.open?.id ? workspace.draft.name : (target?.name ?? '')
cmdline = `:name ${current.trim()}`
break break
}
case 'h': case 'h':
case 'ArrowLeft': case 'ArrowLeft':
// Leftwards at every depth: from the document to the list, from the // Leftwards at every depth: from the document to the list, from the
@@ -640,12 +646,23 @@
void runCommand('d') void runCommand('d')
break break
case 's': case 's':
// Sharing is a key away; taking a link back from its readers asks first.
event.preventDefault() event.preventDefault()
void runCommand(workspace.target?.shared ? 'unshare' : 'share') void (async () => {
const target = workspace.target
if (target?.shared && !(await workspace.ask(`stop sharing "${target.name.trim() || target.slug}"? the link stops working`))) return
void runCommand(target?.shared ? 'unshare' : 'share')
})()
break break
case 'y': case 'y':
// A vim hand presses y to yank; here it retires the link readers have,
// so it asks first.
event.preventDefault() event.preventDefault()
void runCommand('link') void (async () => {
const target = workspace.target
if (!target?.shared) return void runCommand('link')
if (await workspace.ask(`new share link for "${target.name.trim() || target.slug}"? the current one stops working`)) void runCommand('link')
})()
break break
case 'n': case 'n':
event.preventDefault() event.preventDefault()
+36 -7
View File
@@ -2,8 +2,11 @@
/* /*
* A certificate, key or keystore, as the server reads it. A password is * A certificate, key or keystore, as the server reads it. A password is
* asked for only when the file needs one, and goes with that one request. * asked for only when the file needs one, and goes with that one request.
* It is asked for as soon as the file is opened, and again after a wrong
* one, so reading a keystore never needs the pointer; Enter asks again
* after the question was waved away.
*/ */
import { api, type CertItem, type Inspection } from './api' import { api, ApiError, type CertItem, type Inspection } from './api'
import { workspace } from './store.svelte' import { workspace } from './store.svelte'
import { formatDay } from './util' import { formatDay } from './util'
@@ -11,25 +14,51 @@
let inspection = $state<Inspection | null>(null) let inspection = $state<Inspection | null>(null)
let error = $state<string | null>(null) let error = $state<string | null>(null)
let asking = false
async function load(password?: string) { async function load(password?: string) {
try { try {
inspection = await api.inspect(id, password) inspection = await api.inspect(id, password)
error = null error = null
} catch (e) { } catch (e) {
// A wrong password leaves what was read without one on screen.
if (password !== undefined && e instanceof ApiError && e.status === 400) throw e
error = e instanceof Error ? e.message : String(e) error = e instanceof Error ? e.message : String(e)
} }
} }
$effect(() => { $effect(() => {
void load() void load().then(() => {
// Only for the file being looked at: a space switch that reopens it
// behind the list must not raise a question about it.
if (inspection?.needs_password && workspace.pane === 'document') void unlock()
})
}) })
async function unlock() { async function unlock() {
const password = await workspace.askSecret(`password for ${name}`) if (asking) return
if (password === null) return asking = true
await load(password) try {
if (error) workspace.say(error, 'error') let question = `password for ${name}`
for (;;) {
const password = await workspace.askSecret(question)
if (password === null) return
try {
return await load(password)
} catch (e) {
question = `${e instanceof Error ? e.message : 'refused'} — password for ${name}`
}
}
} finally {
asking = false
}
}
/** What Enter does on the file pane: ask for the password, if one is wanted. */
export function act(): boolean {
if (!inspection?.needs_password) return false
void unlock()
return true
} }
function expiry(seconds: number): string { function expiry(seconds: number): string {
@@ -49,7 +78,7 @@
{#if inspection} {#if inspection}
{#if inspection.needs_password} {#if inspection.needs_password}
<p class="locked"> <p class="locked">
protected by a password — <button onclick={unlock}>enter it</button> to read {inspection.items.length ? 'the rest' : 'it'}. protected by a password — <span class="kbd">↵</span> or <button onclick={unlock}>enter it</button> to read {inspection.items.length ? 'the rest' : 'it'}.
</p> </p>
{/if} {/if}
{#each inspection.items as item, i (i)} {#each inspection.items as item, i (i)}
+22 -2
View File
@@ -20,6 +20,24 @@
let reader = $state<HTMLElement>() let reader = $state<HTMLElement>()
let article = $state<HTMLElement>() let article = $state<HTMLElement>()
let title = $state<HTMLInputElement>()
// `:name` with nothing after it, while writing: up into the title.
$effect(() => {
const field = title
if (!field) return
// A turn later: vim gives the focus back to the text once its `:` line
// has run, and that would land after this.
const focus = () =>
setTimeout(() => {
field.focus()
field.select()
})
hooks.focusTitle = focus
return () => {
if (hooks.focusTitle === focus) hooks.focusTitle = () => {}
}
})
/* /*
* j/k reach this pane through the window handler in App.svelte, which has no * j/k reach this pane through the window handler in App.svelte, which has no
@@ -138,13 +156,15 @@
{#if workspace.editing} {#if workspace.editing}
<input <input
class="title" class="title"
bind:this={title}
placeholder="Untitled" placeholder="Untitled"
bind:value={workspace.draft.name} bind:value={workspace.draft.name}
oninput={() => workspace.touch()} oninput={() => workspace.touch()}
onkeydown={(event) => { onkeydown={(event) => {
if (event.key === 'Enter') { // The title is the line above the text: any of these goes back down.
if (['Enter', 'Escape', 'ArrowDown', 'Tab'].includes(event.key) && !event.shiftKey) {
event.preventDefault() event.preventDefault()
document.querySelector<HTMLElement>('.cm-content')?.focus() hooks.focusEditor()
} }
}} }}
/> />
+13 -3
View File
@@ -70,16 +70,26 @@
let reader = $state<HTMLElement>() let reader = $state<HTMLElement>()
// Same arrangement as the document pane: j/k scroll whatever is shown here. let cert = $state<CertView>()
// Same arrangement as the document pane: j/k scroll whatever is shown here,
// and Enter does the one thing the file is waiting for — the vault's
// password while it is sealed, a keystore's when it has one.
$effect(() => { $effect(() => {
const scroll = (amount: ScrollAmount) => { const scroll = (amount: ScrollAmount) => {
if (!reader) return false if (!reader) return false
scrollPage(reader, amount) scrollPage(reader, amount)
return true return true
} }
const act = () => {
if (locked) return void runCommand('unlock'), true
return cert?.act() ?? false
}
hooks.scrollReader = scroll hooks.scrollReader = scroll
hooks.fileAction = act
return () => { return () => {
if (hooks.scrollReader === scroll) hooks.scrollReader = () => false if (hooks.scrollReader === scroll) hooks.scrollReader = () => false
if (hooks.fileAction === act) hooks.fileAction = () => false
} }
}) })
</script> </script>
@@ -135,7 +145,7 @@
{#if locked} {#if locked}
<p class="sealed"> <p class="sealed">
<Icon name="lock" size={16} /> <Icon name="lock" size={16} />
sealed in the vault — <button onclick={() => runCommand('unlock')}>unlock</button> to open it. sealed in the vault — <span class="kbd">↵</span> or <button onclick={() => runCommand('unlock')}>unlock</button> to open it.
</p> </p>
{:else if kind === 'image'} {:else if kind === 'image'}
<img src={file.url} alt={node.name} /> <img src={file.url} alt={node.name} />
@@ -147,7 +157,7 @@
{:else if kind === 'pdf'} {:else if kind === 'pdf'}
<iframe class="pdf" src={file.url} title={node.name}></iframe> <iframe class="pdf" src={file.url} title={node.name}></iframe>
{:else if kind === 'cert'} {:else if kind === 'cert'}
<CertView id={node.id} name={node.name} /> <CertView bind:this={cert} id={node.id} name={node.name} />
{:else if text !== null} {:else if text !== null}
{#if kind === 'markdown' && !source} {#if kind === 'markdown' && !source}
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in renderMarkdown --> <!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in renderMarkdown -->
+4 -4
View File
@@ -12,12 +12,12 @@
['j / k · ↓ ↑', 'move down / up the list — scroll the document when it has focus'], ['j / k · ↓ ↑', 'move down / up the list — scroll the document when it has focus'],
['gg / G', 'first / last row, or top / bottom of the document'], ['gg / G', 'first / last row, or top / bottom of the document'],
['Ctrl-D / Ctrl-U', 'half a page down / up the open document'], ['Ctrl-D / Ctrl-U', 'half a page down / up the open document'],
['Enter · l', 'open a document, or enter a folder'], ['Enter · l', 'open a document, or enter a folder — on an open keystore or sealed file, Enter asks for its password'],
['h', 'from the document to the list, from the list up a level'], ['h', 'from the document to the list, from the list up a level'],
['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 under the cursor — document, folder or card'], ['R', 'rename what is under the cursor — document, folder or card — starting from its name'],
['m / p', 'pick nodes up, then drop them all into the folder you walk to'], ['m / p', 'pick nodes up, then drop them all into the folder you walk to'],
['M', 'pick up the whole folder — again puts it all back'], ['M', 'pick up the whole folder — again puts it all back'],
['Ctrl / Shift-click', 'pick a row up · every row up to it — drag them onto a folder'], ['Ctrl / Shift-click', 'pick a row up · every row up to it — drag them onto a folder'],
@@ -29,8 +29,8 @@
['Esc · q', 'close the document'], ['Esc · q', 'close the document'],
['/', 'full-text search, inside the current space'], ['/', 'full-text search, inside the current space'],
['n', 'clear the search'], ['n', 'clear the search'],
['s', 'toggle sharing for the document'], ['s', 'toggle sharing for the document — asks before taking it back'],
['y', 'mint a new share link and copy it'], ['y', 'mint a new share link and copy it — asks, the old one stops working'],
['x · dd', 'delete what has focus — the row in the list, the document in its pane (asks y/N)'], ['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'],
+6 -1
View File
@@ -13,11 +13,15 @@ export const hooks = {
/** `root`: into the workspace itself, where each directory is a space. */ /** `root`: into the workspace itself, where each directory is a space. */
pickArchive: (_root: boolean) => {}, pickArchive: (_root: boolean) => {},
focusEditor: () => {}, focusEditor: () => {},
/** Filled in while a document is being written: into its title field. */
focusTitle: () => {},
focusList: () => {}, focusList: () => {},
insertText: (_text: string) => {}, insertText: (_text: string) => {},
/* Filled in while a document is on screen and not being edited: the reading /* Filled in while a document is on screen and not being edited: the reading
pane owns the scrollback, so the list keys have somewhere to send it. */ pane owns the scrollback, so the list keys have somewhere to send it. */
scrollReader: (_amount: ScrollAmount): boolean => false, scrollReader: (_amount: ScrollAmount): boolean => false,
/* Filled in by the file pane: what Enter does there, false when nothing. */
fileAction: (): boolean => false,
/** How many space cards the overview fits in a row, for j/k to step by. */ /** How many space cards the overview fits in a row, for j/k to step by. */
overviewColumns: (): number => 1, overviewColumns: (): number => 1,
openOverview: () => {}, openOverview: () => {},
@@ -582,8 +586,9 @@ export const commands: CommandSpec[] = [
aliases: ['title', 'rename'], aliases: ['title', 'rename'],
args: '<text>', args: '<text>',
group: 'editing', group: 'editing',
help: 'rename what is under the cursor — document, folder, or card on a board', help: 'rename what is under the cursor — document, folder, or card on a board; bare, while writing, into the title',
run: async (arg) => { run: async (arg) => {
if (!arg.trim() && workspace.editing && workspace.pane === 'document') return hooks.focusTitle()
if (workspace.view === 'board') { if (workspace.view === 'board') {
if (!arg.trim()) return workspace.say('usage: :name <text>', 'error') if (!arg.trim()) return workspace.say('usage: :name <text>', 'error')
return workspace.guard(() => workspace.patchCard({ title: arg.trim() })) return workspace.guard(() => workspace.patchCard({ title: arg.trim() }))