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
+109
-27
No files matched your search
+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 (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}
|
||||
<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