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 |
|
| `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
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
asking = true
|
||||||
|
try {
|
||||||
|
let question = `password for ${name}`
|
||||||
|
for (;;) {
|
||||||
|
const password = await workspace.askSecret(question)
|
||||||
if (password === null) return
|
if (password === null) return
|
||||||
await load(password)
|
try {
|
||||||
if (error) workspace.say(error, 'error')
|
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)}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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() }))
|
||||||
|
|||||||
Reference in new issue
Block a user