mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
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:
1 parent
4bf8634991
commit
007fb1d3e3
7 files changed
+108
-26
No files matched your search
@@ -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
|
||||
|
||||
+23
-6
@@ -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()
|
||||
|
||||
@@ -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<Inspection | null>(null)
|
||||
let error = $state<string | null>(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 (asking) return
|
||||
asking = true
|
||||
try {
|
||||
let question = `password for ${name}`
|
||||
for (;;) {
|
||||
const password = await workspace.askSecret(question)
|
||||
if (password === null) return
|
||||
await load(password)
|
||||
if (error) workspace.say(error, 'error')
|
||||
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}
|
||||
<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>
|
||||
{/if}
|
||||
{#each inspection.items as item, i (i)}
|
||||
|
||||
@@ -20,6 +20,24 @@
|
||||
|
||||
let reader = $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
|
||||
@@ -138,13 +156,15 @@
|
||||
{#if workspace.editing}
|
||||
<input
|
||||
class="title"
|
||||
bind:this={title}
|
||||
placeholder="Untitled"
|
||||
bind:value={workspace.draft.name}
|
||||
oninput={() => 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<HTMLElement>('.cm-content')?.focus()
|
||||
hooks.focusEditor()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -70,16 +70,26 @@
|
||||
|
||||
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(() => {
|
||||
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
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -135,7 +145,7 @@
|
||||
{#if locked}
|
||||
<p class="sealed">
|
||||
<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>
|
||||
{:else if kind === 'image'}
|
||||
<img src={file.url} alt={node.name} />
|
||||
@@ -147,7 +157,7 @@
|
||||
{:else if kind === 'pdf'}
|
||||
<iframe class="pdf" src={file.url} title={node.name}></iframe>
|
||||
{: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}
|
||||
{#if kind === 'markdown' && !source}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in renderMarkdown -->
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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: '<text>',
|
||||
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 <text>', 'error')
|
||||
return workspace.guard(() => workspace.patchCard({ title: arg.trim() }))
|
||||
|
||||
Reference in new issue
Block a user