diff --git a/README.md b/README.md index 809f15d..6ab7f31 100644 --- a/README.md +++ b/README.md @@ -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 | | `gg` / `G` | first / last entry, or top / bottom of the 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 folder here | | `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 | | `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 | @@ -92,7 +92,7 @@ it; in the document pane it is that document. In the browse pane: | `h` | up one level in the tree | | `/` | full-text search in this space (`n` clears it) | | `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`) | | `:` | command line | | `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 `:w`, `:wq`, `:q!`, `:new [yyyy-mm-dd]`, `:date`, `:name`, `:share`, `:link`, `: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 list, `:delete [id]` deletes, `:copy` puts the whole document on the clipboard, `:clear` drops the search, `:reload` re-reads the folder, `:stats` counts words diff --git a/web/src/App.svelte b/web/src/App.svelte index b86416f..b81ecc0 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -354,7 +354,8 @@ return true case 'R': // 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 case 'D': cmdline = ':due ' @@ -574,6 +575,7 @@ case 'l': case 'ArrowRight': event.preventDefault() + if (key === 'Enter' && reading && hooks.fileAction()) break if (workspace.open && workspace.selected?.id === workspace.open.id) focusPane('document') else void workspace.guard(() => workspace.openSelected()) break @@ -616,11 +618,15 @@ event.preventDefault() void runCommand('board') break - case 'R': - // Renaming needs a name typed, and names are typed on the command line. + case 'R': { + // 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() - cmdline = ':name ' + const target = workspace.target + const current = target && target.id === workspace.open?.id ? workspace.draft.name : (target?.name ?? '') + cmdline = `:name ${current.trim()}` break + } case 'h': case 'ArrowLeft': // Leftwards at every depth: from the document to the list, from the @@ -640,12 +646,23 @@ void runCommand('d') break case 's': + // Sharing is a key away; taking a link back from its readers asks first. 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 case 'y': + // A vim hand presses y to yank; here it retires the link readers have, + // so it asks first. 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 case 'n': event.preventDefault() diff --git a/web/src/lib/CertView.svelte b/web/src/lib/CertView.svelte index 2320b90..1260fee 100644 --- a/web/src/lib/CertView.svelte +++ b/web/src/lib/CertView.svelte @@ -2,8 +2,11 @@ /* * 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. + * 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 { formatDay } from './util' @@ -11,25 +14,51 @@ let inspection = $state(null) let error = $state(null) + let asking = false async function load(password?: string) { try { inspection = await api.inspect(id, password) error = null } 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) } } $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() { - const password = await workspace.askSecret(`password for ${name}`) - if (password === null) return - await load(password) - if (error) workspace.say(error, 'error') + if (asking) return + asking = true + try { + 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 { @@ -49,7 +78,7 @@ {#if inspection} {#if inspection.needs_password}

- protected by a password — to read {inspection.items.length ? 'the rest' : 'it'}. + protected by a password — ↵ or to read {inspection.items.length ? 'the rest' : 'it'}.

{/if} {#each inspection.items as item, i (i)} diff --git a/web/src/lib/DocumentPane.svelte b/web/src/lib/DocumentPane.svelte index 4d72949..d9ea6b5 100644 --- a/web/src/lib/DocumentPane.svelte +++ b/web/src/lib/DocumentPane.svelte @@ -20,6 +20,24 @@ let reader = $state() let article = $state() + let title = $state() + + // `: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 @@ -138,13 +156,15 @@ {#if workspace.editing} workspace.touch()} 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() - document.querySelector('.cm-content')?.focus() + hooks.focusEditor() } }} /> diff --git a/web/src/lib/FilePane.svelte b/web/src/lib/FilePane.svelte index f1b7f63..deb644d 100644 --- a/web/src/lib/FilePane.svelte +++ b/web/src/lib/FilePane.svelte @@ -70,16 +70,26 @@ let reader = $state() - // Same arrangement as the document pane: j/k scroll whatever is shown here. + let cert = $state() + + // 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(() => { const scroll = (amount: ScrollAmount) => { if (!reader) return false scrollPage(reader, amount) return true } + const act = () => { + if (locked) return void runCommand('unlock'), true + return cert?.act() ?? false + } hooks.scrollReader = scroll + hooks.fileAction = act return () => { if (hooks.scrollReader === scroll) hooks.scrollReader = () => false + if (hooks.fileAction === act) hooks.fileAction = () => false } }) @@ -135,7 +145,7 @@ {#if locked}

- sealed in the vault — to open it. + sealed in the vault — ↵ or to open it.

{:else if kind === 'image'} {node.name} @@ -147,7 +157,7 @@ {:else if kind === 'pdf'} {:else if kind === 'cert'} - + {:else if text !== null} {#if kind === 'markdown' && !source} diff --git a/web/src/lib/Help.svelte b/web/src/lib/Help.svelte index 1cd0378..aa09395 100644 --- a/web/src/lib/Help.svelte +++ b/web/src/lib/Help.svelte @@ -12,12 +12,12 @@ ['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'], ['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'], ['o', 'new document here, straight into insert mode'], ['O', 'new folder here'], ['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', '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'], @@ -29,8 +29,8 @@ ['Esc · q', 'close the document'], ['/', 'full-text search, inside the current space'], ['n', 'clear the search'], - ['s', 'toggle sharing for the document'], - ['y', 'mint a new share link and copy it'], + ['s', 'toggle sharing for the document — asks before taking it back'], + ['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)'], [':', 'command line'], ['Ctrl-S', 'save, from anywhere'], diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts index 3f10f93..3186146 100644 --- a/web/src/lib/commands.ts +++ b/web/src/lib/commands.ts @@ -13,11 +13,15 @@ export const hooks = { /** `root`: into the workspace itself, where each directory is a space. */ pickArchive: (_root: boolean) => {}, focusEditor: () => {}, + /** Filled in while a document is being written: into its title field. */ + focusTitle: () => {}, focusList: () => {}, insertText: (_text: string) => {}, /* 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. */ 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. */ overviewColumns: (): number => 1, openOverview: () => {}, @@ -582,8 +586,9 @@ export const commands: CommandSpec[] = [ aliases: ['title', 'rename'], args: '', 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) => { + if (!arg.trim() && workspace.editing && workspace.pane === 'document') return hooks.focusTitle() if (workspace.view === 'board') { if (!arg.trim()) return workspace.say('usage: :name ', 'error') return workspace.guard(() => workspace.patchCard({ title: arg.trim() }))