commands: one grammar for !, merged commands, a settings sheet, two-level help

A `!` now means force or everything and nothing else (`:w!`, `:search!`,
`:export!`, `:logout!`). A command that did a second thing behind its bang
takes a word for it instead: `:space new|name|rm`, `:list name|rm`,
`:task [add]|edit`, `:upload seal`, `:import zip [/]`. `:card` while reading
a document makes a card about it, so `:card!` is gone.

Merged: `:export` downloads what has focus, a file as it is or a document as
markdown (`:download` folded in); `:share [off|new]` replaces `:unshare` and
`:link`, and the list's `s` and `y` give way to it and the info sheet;
`:noh` replaces `:clear`; `:vault [unlock|lock|passwd]` replaces `:unlock`,
`:lock` and `:passwd`; Esc in the list puts down what `m` picked up before it
closes the document, replacing `:unmark`.

The old spellings run as their new lines for one release through a table in
commands.ts; help and Tab leave them out, and the wiki lists them.

A settings sheet (`,` or `:settings`) holds the options, the vault, the
backup, the signed-in browsers, ssh keys and capture tokens. Each row runs
the command a line would. Bare `:set`, `:vault`, `:backup`, `:sessions`,
`:sshkey` and `:token` open it at their part.

`?` shows the keys and commands to start with, flagged in the keymap and the
command table; a second `?` (or `:help all`) shows all of them, and a filter
always searches all of them. Help rows are keyed by key and description, as
two contexts can share a label.
This commit is contained in:
nvrl committed 2026-10-22 15:35:36 +02:00
1 parent d8e72c688d
commit 7f8d625a8d
34 files changed
+799 -457

No files matched your search

+4 -2
View File
@@ -18,6 +18,7 @@
import Verify from './lib/Verify.svelte'
import Login from './lib/Login.svelte'
import Overview from './lib/Overview.svelte'
import Settings from './lib/Settings.svelte'
import Trash from './lib/Trash.svelte'
import CardTile from './lib/CardTile.svelte'
import Scratch from './lib/Scratch.svelte'
@@ -44,11 +45,11 @@
let cmdGlobal = $state(false)
let fileInput = $state<HTMLInputElement>()
let treeInput = $state<HTMLInputElement>()
/** Whether the files being picked go into the vault — `:upload!`. */
/** Whether the files being picked go into the vault — `:upload seal`. */
let sealUpload = false
let folderInput = $state<HTMLInputElement>()
let importInput = $state<HTMLInputElement>()
// Whether the next archive picked is poured in at the root, `:import! /`.
// Whether the next archive picked is poured in at the root, `:import zip /`.
let importRoot = false
let shell = $state<HTMLDivElement>()
let overlayEl = $state<HTMLDivElement>()
@@ -61,6 +62,7 @@
verify: Verify,
info: Info,
trash: Trash,
settings: Settings,
}
let boardEl = $state<HTMLDivElement>()
/** The first half of `gg`, `dd`, `]]` …, shown until the second comes. */
+2 -2
View File
@@ -35,10 +35,10 @@
if (node.kind === 'document') {
list.push(
node.shared
? { label: 'stop sharing', run: () => runCommand('unshare') }
? { label: 'stop sharing', run: () => runCommand('share off') }
: { label: 'share — copies the link', run: () => runCommand('share') },
)
if (node.shared) list.push({ label: 'new share link — the old one stops working', run: () => runCommand('link') })
if (node.shared) list.push({ label: 'new share link — the old one stops working', run: () => runCommand('share new') })
}
// Inside a sealed folder only that folder opens, as a whole.
if (holdsFiles && node.kind !== 'space' && !(sealed && workspace.parentSealed)) {
+1 -1
View File
@@ -32,7 +32,7 @@
{#if index < 10}<span class="n">{(index + 1) % 10}</span>{/if}<span class="slug">{space.slug}</span>
</button>
{/each}
<button class="pill add" title="new space (:space! <name>)" onclick={() => hooks.openCommandLine(':space! ')}>
<button class="pill add" title="new space (:space new <name>)" onclick={() => hooks.openCommandLine(':space new ')}>
+
</button>
</nav>
+7 -8
View File
@@ -28,7 +28,7 @@
<script lang="ts">
import { tick, untrack } from 'svelte'
import { argCompletions, completions, runCommand, hooks } from './commands'
import { argCompletions, completions, readingDocument, runCommand, hooks } from './commands'
import { workspace } from './store.svelte'
interface Props {
@@ -56,17 +56,16 @@
/*
* A capture line — `c` opens one — writes a thought down somewhere without
* going there: a card, a note, a line in today's entry. Tab walks the three
* with the text kept. A card stays linked to the document being read when
* the line began as one that was.
* with the text kept. Reading a document, `:card` makes one about it.
*/
const card = untrack(() => (initial.startsWith(':card! ') ? 'card!' : 'card'))
const targets = [card, 'scratch', 'today']
const targetNames: Record<string, string> = { card: 'card', 'card!': 'card about this', scratch: 'note', today: 'today' }
const capture = $derived(/^:(card!?|scratch|today)\s/.exec(value)?.[1])
const about = untrack(readingDocument)
const targets = ['card', 'scratch', 'today']
const targetNames: Record<string, string> = { card: about ? 'card about this' : 'card', scratch: 'note', today: 'today' }
const capture = $derived(/^:(card|scratch|today)\s/.exec(value)?.[1])
function retarget(delta: 1 | -1): boolean {
if (!capture) return false
const at = targets.indexOf(capture === 'card!' || capture === 'card' ? card : capture)
const at = targets.indexOf(capture)
const next = targets[(at + delta + targets.length) % targets.length]
value = value.replace(/^:\S+/, `:${next}`)
return true
+39 -9
View File
@@ -2,13 +2,30 @@
/*
* The help is a sheet the size of the porthole, laid out in newspaper
* columns that run on to the right, so all of it is in view at once on a
* wide screen and a key away on a narrow one. Its keys live in keymap.ts
* with the others; these are what they move.
* wide screen and a key away on a narrow one. It comes in two depths: the
* keys and commands to start with, then, on a second `?`, every one of
* them. Its keys live in keymap.ts with the others; these are what they
* move.
*/
import { workspace } from './store.svelte'
let pages: HTMLElement | undefined = $state()
let field: HTMLInputElement | undefined = $state()
/** `/` in the help: only the rows whose key or words hold this. */
export const helpFilter = $state({ text: '' })
/** Every key and command, rather than the ones to start with. */
export const helpDepth = $state({ all: false })
export function openHelp(all = false) {
helpDepth.all = all
workspace.openSheet('help')
}
/** `?` in the help: everything, and the help away once it is all on show. */
export function deeperHelp() {
if (helpDepth.all) workspace.closeSheet()
else helpDepth.all = true
}
/** A column on or back, a screen on or back, or to either end. */
export function scrollHelp(by: 1 | -1 | 'pagedown' | 'pageup' | 'start' | 'end') {
@@ -40,7 +57,7 @@
<script lang="ts">
import { commands, groups, type CommandSpec, type Group } from './commands'
import Sheet from './Sheet.svelte'
import { rows, sections as keySections } from './keymap'
import { essentials, rows, sections as keySections } from './keymap'
const titles: Record<Group, string> = {
tree: 'spaces & folders',
@@ -50,7 +67,7 @@
editing: 'writing',
sharing: 'sharing & export',
vault: 'vault',
view: 'session',
view: 'settings & session',
}
type Row = { key: string; args?: string; description: string }
@@ -59,10 +76,10 @@
const signature = (spec: CommandSpec) =>
`:${spec.name}${spec.aliases?.length ? `(${spec.aliases.join(' ')})` : ''}`
const fromSpecs = (specs: CommandSpec[]): Row[] =>
const fromSpecs = (specs: CommandSpec[], bangs = true): Row[] =>
specs.flatMap((spec) => [
{ key: signature(spec), args: spec.args, description: spec.help },
...(spec.bang ? [{ key: `:${spec.name}!`, description: spec.bang.help }] : []),
...(spec.bang && bangs ? [{ key: `:${spec.name}!`, description: spec.bang.help }] : []),
])
/* The keys come out of the keymap that dispatches them, so the help
@@ -72,9 +89,16 @@
...groups.map((group: Group) => [titles[group], fromSpecs(commands.filter((spec) => spec.group === group))] as [string, Row[]]),
]
/* The first page: what a day in the workspace needs, and where the rest is. */
const first: [string, Row[]][] = [
['to start with', essentials()],
['commands to start with', fromSpecs(commands.filter((spec) => spec.essential), false)],
]
const shown = $derived.by(() => {
const words = helpFilter.text.trim().toLowerCase().split(/\s+/).filter(Boolean)
if (words.length === 0) return sections
// A filter looks through all of it: what is looked for is rarely on the first page.
if (words.length === 0) return helpDepth.all ? sections : first
const hit = (row: Row) => {
const text = `${row.key} ${row.args ?? ''} ${row.description}`.toLowerCase()
return words.every((word) => text.includes(word))
@@ -105,7 +129,13 @@
}
</script>
<Sheet title="help" detail="{count} keys and commands" width={0} fill keys={['help', 'sheet']}>
<Sheet
title="help"
detail={helpDepth.all || filtering ? `${count} keys and commands` : `the ${count} to start with — ? again for all of them`}
width={0}
fill
keys={['help', 'sheet']}
>
{#snippet actions()}
<label class="filter" class:on={helpFilter.text !== ''}>
<span class="slash">/</span>
@@ -125,7 +155,7 @@
<section style="--key: {keyWidth(rows)}ch">
<h2>{title}</h2>
<dl>
{#each rows as row (row.key)}
{#each rows as row (row.key + row.description)}
{@const short = filtering ? row.description : brief(row.description)}
<div class="row">
<dt>{row.key}{#if row.args}<span class="args">{row.args}</span>{/if}</dt>
+1
View File
@@ -42,6 +42,7 @@
repeat: ['M17 3l3 3-3 3', 'M4 11V9a3 3 0 0 1 3-3h13', 'M7 21l-3-3 3-3', 'M20 13v2a3 3 0 0 1-3 3H4'],
trash: ['M4 7h16', 'M9 7V4h6v3', 'M6 7l1 13h10l1-13'],
more: ['M5 12h.01', 'M12 12h.01', 'M19 12h.01'],
settings: ['M4 6h9', 'M17 6h3', 'M15 4v4', 'M4 12h3', 'M11 12h9', 'M9 10v4', 'M4 18h11', 'M19 18h1', 'M17 16v4'],
} satisfies Record<string, string[]>
export type IconName = keyof typeof PATHS
+1 -5
View File
@@ -81,7 +81,7 @@
// A new link retires the one readers have, so it asks first.
export async function renewLink(node: NodeSummary | null | undefined) {
if (!node) return workspace.say('nothing selected', 'error')
if (!node.shared) return workspace.say('not shared — s shares it', 'error')
if (!node.shared) return workspace.say('not shared — :share shares it', 'error')
if (!(await workspace.ask(`new share link for "${node.name.trim() || node.slug}"? the current one stops working`))) return
await workspace.guard(() => workspace.renewShareLink(node.id))
}
@@ -301,10 +301,6 @@
</Sheet>
<style>
.tabs { display: flex; flex-wrap: wrap; gap: 4px; flex: none; }
.tabs button { text-transform: none; letter-spacing: 0; }
.tabs .on { background: var(--light); border-color: var(--light); color: var(--ink); }
.split { display: flex; gap: 18px; min-height: 0; flex: 1; }
.versions { width: 34ch; flex: none; }
.when { color: var(--fg); font-size: 13px; }
+6 -5
View File
@@ -30,6 +30,7 @@
const groups = $derived.by((): Door[][] => {
const cmd: Door = { icon: 'prompt', label: 'Command line', keys: keysOf('global', 'line'), run: () => hooks.openCommandLine(':') }
const help: Door = { icon: 'help', label: 'Help', keys: keysOf('global', 'help'), run: () => runCommand('help') }
const settings: Door = { icon: 'settings', label: 'Settings', keys: keysOf('global', 'settings'), run: () => runCommand('settings') }
const focus = workspace.focus
const board: Door = workspace.space?.has_board || focus === 'board'
@@ -66,16 +67,16 @@
icon: 'rename',
label: card ? 'Rename the card' : 'Rename the list',
keys: keysOf('board', 'rename'),
run: () => hooks.openCommandLine(card ? ':name ' : ':name! '),
run: () => hooks.openCommandLine(card ? ':name ' : ':list name '),
},
{
icon: 'trash',
label: card ? 'Delete the card' : 'Delete the list',
keys: card ? keysOf('board', 'delete') : [':list!'],
run: () => runCommand(card ? 'delete' : 'list!'),
keys: card ? keysOf('board', 'delete') : [':list rm'],
run: () => runCommand(card ? 'delete' : 'list rm'),
},
],
[cmd, help],
[cmd, settings, help],
]
}
@@ -112,7 +113,7 @@
{ icon: 'search', label: 'Search this space', keys: keysOf('tree', 'search'), run: () => hooks.openCommandLine('/') },
{ icon: 'search-all', label: 'Search every space', keys: keysOf('global', 'find'), run: () => hooks.openGlobalSearch() },
],
[cmd, help],
[cmd, settings, help],
]
})
+217
View File
@@ -0,0 +1,217 @@
<script lang="ts" module>
import { api, type ApiToken, type BackupStatus, type SignedIn, type SshKey } from './api'
import { browserOf, describeBackup, describeVault, hooks, runCommand } from './commands'
import { workspace } from './store.svelte'
import { relative } from './util'
/*
* Everything about the workspace that is not in it: how the editor
* behaves, the vault, the backup, who is signed in, and what may reach in
* from outside. Each row runs the command a line would, so the sheet and
* the line cannot drift apart, and the line still does all of it.
*/
export type SettingsSection = 'options' | 'vault' | 'backup' | 'sessions' | 'keys' | 'tokens'
export const settingsSections: SettingsSection[] = ['options', 'vault', 'backup', 'sessions', 'keys', 'tokens']
/** What the sheet shows, as last read; the part on show stays for the next time. */
export const settings = $state({
section: 'options' as SettingsSection,
cursor: 0,
sessions: [] as SignedIn[],
keys: [] as SshKey[],
tokens: [] as ApiToken[],
backup: null as BackupStatus | null,
})
export async function loadSettings() {
const [sessions, keys, tokens, backup] = await Promise.all([
api.sessions(),
api.listSshKeys(),
api.listTokens(),
api.backupStatus(),
workspace.loadVault(),
])
Object.assign(settings, { sessions, keys, tokens, backup })
settings.cursor = Math.min(settings.cursor, Math.max(settingRows().length - 1, 0))
}
/** The sheet, at `section` or where it was last left, read afresh. */
export function openSettings(section?: SettingsSection) {
return workspace.guard(async () => {
if (section && section !== settings.section) {
settings.section = section
settings.cursor = 0
}
await loadSettings()
workspace.openSheet('settings')
})
}
export interface Setting {
name: string
detail: string
/** Enter, or a click on the row. */
act?: () => unknown
/** `x`, after a question. */
remove?: () => unknown
}
const run = (line: string) => () => runCommand(line)
const type = (line: string) => () => hooks.openCommandLine(line)
const confirm = (question: string, line: string) => async () => {
if (await workspace.ask(question)) await runCommand(line)
}
const used = (at: number | null) => (at === null ? 'never used' : `used ${relative(at)}`)
/** The rows of a part of the sheet, from what was last read. */
export function settingRows(section = settings.section): Setting[] {
switch (section) {
case 'options': {
const space = workspace.space
return [
{
name: 'vim keys',
detail: workspace.vimEnabled ? 'on — the editor reads vim' : 'off — a plain editor',
act: run(workspace.vimEnabled ? 'set novim' : 'set vim'),
},
{
name: 'clipboard',
detail: workspace.clipboard ? 'yanks go to the system clipboard' : 'yanks stay in vim registers',
act: run(workspace.clipboard ? 'set noclipboard' : 'set clipboard'),
},
...(space
? [
{
name: 'board',
detail: space.has_board ? `${space.slug} has a board` : `${space.slug} has none`,
act: run(space.has_board ? 'set noboard' : 'set board'),
},
]
: []),
]
}
case 'vault': {
const vault = workspace.vault
if (!vault?.configured) {
return [{ name: 'set a password', detail: 'files sealed under it open only with it — it cannot be recovered', act: run('vault') }]
}
return [
{
name: vault.unlocked ? 'lock' : 'unlock',
detail: `${describeVault()} · ${vault.sealed} sealed`,
act: run(vault.unlocked ? 'vault lock' : 'vault unlock'),
},
{ name: 'change the password', detail: 'every sealed file is re-sealed under a new key', act: run('vault passwd') },
]
}
case 'backup': {
const status = settings.backup
if (!status?.configured) {
return [{ name: 'log in to proton drive', detail: 'mirror the workspace there once it falls quiet', act: type(':backup login ') }]
}
return [
{ name: 'back up now', detail: describeBackup(status), act: run('backup!') },
{ name: 'log out', detail: 'forget the session — what is mirrored stays on drive', act: run('backup logout') },
]
}
case 'sessions':
return settings.sessions.map((session, index) => ({
name: session.current ? 'this browser' : browserOf(session.user_agent),
detail: `seen ${relative(session.last_seen)} · signed in ${relative(session.created_at)}`,
remove: session.current
? undefined
: confirm(`sign out ${browserOf(session.user_agent)}? the vault locks too`, `sessions rm ${index + 1}`),
}))
case 'keys':
return [
...settings.keys.map((key) => ({
name: key.name,
detail: `${key.fingerprint} · ${key.vault ? 'reads sealed files' : 'no sealed files'} · ${used(key.last_used_at)}`,
act: run(`sshkey vault ${key.name} ${key.vault ? 'off' : 'on'}`),
remove: confirm(`remove the key ${key.name}? it mounts nothing from now on`, `sshkey rm ${key.name}`),
})),
{ name: 'add a key', detail: ':sshkey add <name> <public key>', act: type(':sshkey add ') },
]
case 'tokens':
return [
...settings.tokens.map((token) => ({
name: token.name,
detail: `made ${relative(token.created_at)} · ${used(token.last_used_at)}`,
remove: confirm(`retire the token ${token.name}? it captures nothing from now on`, `token rm ${token.name}`),
})),
{ name: 'add a token', detail: 'shown once and copied — the server keeps only its hash', act: type(':token add ') },
]
}
}
/** Tab and Shift-Tab: the next part along, round the ends. */
export function turnSettings(by: number) {
const at = settingsSections.indexOf(settings.section)
settings.section = settingsSections[(at + by + settingsSections.length) % settingsSections.length]
settings.cursor = 0
}
export function moveSettings(by: number) {
settings.cursor = Math.min(Math.max(settings.cursor + by, 0), Math.max(settingRows().length - 1, 0))
}
export const actSetting = (index = settings.cursor) => void settingRows()[index]?.act?.()
export const removeSetting = (index = settings.cursor) => void settingRows()[index]?.remove?.()
</script>
<script lang="ts">
import Sheet from './Sheet.svelte'
const rows = $derived(settingRows())
const tabs = $derived<[SettingsSection, string][]>([
['options', 'options'],
['vault', workspace.vault?.configured ? `vault ${workspace.vault.unlocked ? 'open' : 'locked'}` : 'vault'],
['backup', settings.backup?.configured ? `backup${settings.backup.last_error ? ' failing' : ''}` : 'backup off'],
['sessions', `sessions ${settings.sessions.length}`],
['keys', `ssh keys ${settings.keys.length}`],
['tokens', `tokens ${settings.tokens.length}`],
])
const turn = (section: SettingsSection) => {
settings.section = section
settings.cursor = 0
}
</script>
<Sheet title="settings" width={100} cursor={settings.cursor} keys={['settings', 'sheet']} hide={rows[settings.cursor]?.remove ? [] : ['remove']}>
<nav class="tabs">
{#each tabs as [name, text] (name)}
<button class:on={name === settings.section} onclick={() => turn(name)}>{text}</button>
{/each}
</nav>
<ul class="items">
{#each rows as row, index (index)}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<li
class="item"
class:at={index === settings.cursor}
data-index={index}
onpointerenter={() => (settings.cursor = index)}
onclick={() => actSetting(index)}
>
<span class="text">
<span class="name">{row.name}</span>
<span class="muted">{row.detail}</span>
</span>
{#if row.remove}
<button class="danger" onclick={(event) => (event.stopPropagation(), removeSetting(index))}>remove</button>
{/if}
</li>
{:else}
<li class="muted">nothing here.</li>
{/each}
</ul>
</Sheet>
<style>
/* A row with a button beside its two lines. */
.items .item { flex-direction: row; align-items: center; gap: 12px; }
.text { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
</style>
+5
View File
@@ -120,6 +120,11 @@
.sheet :global(.item .muted) { font-size: 11px; }
.sheet :global(.danger:hover) { background: var(--alarm); border-color: var(--alarm); color: var(--ink); }
/* The parts of a sheet with several, one button each — info, settings. */
.sheet :global(.tabs) { display: flex; flex-wrap: wrap; gap: 4px; flex: none; }
.sheet :global(.tabs button) { text-transform: none; letter-spacing: 0; }
.sheet :global(.tabs .on) { background: var(--light); border-color: var(--light); color: var(--ink); }
@media (max-width: 767px) {
.sheet { padding: 14px; gap: 14px; }
}
+1 -1
View File
@@ -34,7 +34,7 @@
{#if pending}<span class="pending" aria-live="polite">{pending}</span>{/if}
{#if workspace.tiles.length > 1}<span class="flag" title=":ls lists the tiles">tile {workspace.focused + 1}/{workspace.tiles.length}</span>{/if}
{#if workspace.fullscreen}<span class="flag">full</span>{/if}
{#if workspace.vault?.unlocked}<span class="flag vault" title="the vault is open — :lock closes it">vault open</span>{/if}
{#if workspace.vault?.unlocked}<span class="flag vault" title="the vault is open — :vault lock closes it">vault open</span>{/if}
{#if lines !== null}<span class="readout">{lines}<span class="unit">L</span></span>{/if}
<span class="hint">
{isTouchDevice ? 'tap : for commands' : '? help · : command · / search'}
+28 -11
View File
@@ -10,7 +10,8 @@
*/
import { cycleTile, focusTile, followLink, hooks, openInfo, runCommand } from './commands'
import { bindings, type Context } from './keymap'
import { focusHelpFilter, scrollHelp } from './Help.svelte'
import { deeperHelp, focusHelpFilter, scrollHelp } from './Help.svelte'
import { actSetting, moveSettings, removeSetting, turnSettings, openSettings } from './Settings.svelte'
import { purgeTrashed, restoreTrashed } from './Trash.svelte'
import { copyEmbed, embedFile, goHeading, moveHistory, openBacklink, openFile, renewLink, restoreShown, toggleSharing, turnInfo } from './Info.svelte'
import { workspace } from './store.svelte'
@@ -68,7 +69,7 @@ function openInTab() {
if (!file) return workspace.say('gx opens a file — put the cursor on one', 'error')
// A tab opened after the vault's question would be stopped as a popup, so
// a sealed file asks for the vault to be opened first instead.
if (file.encrypted && !workspace.vault?.unlocked) return workspace.say('sealed — :unlock first, then gx', 'error')
if (file.encrypted && !workspace.vault?.unlocked) return workspace.say('sealed — :vault unlock first, then gx', 'error')
followLink(file.url, true)
}
@@ -134,6 +135,7 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
start: () => scrollHelp('start'),
end: () => scrollHelp('end'),
filter: () => focusHelpFilter(),
deeper: () => deeperHelp(),
},
/** The overview: hjkl across the grid, a digit or Enter to go. */
@@ -227,7 +229,7 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
due: () => {
if (workspace.focusedCard) line(':due ')
},
new: () => line(':card! '),
new: () => line(':card '),
label: () => {
if (workspace.focusedCard) line(':label ')
},
@@ -248,6 +250,17 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
link: () => void renewLink(workspace.open),
},
/** The settings sheet: Tab from part to part, j/k down it, Enter changes, x removes. */
settings: {
next: () => turnSettings(1),
previous: () => turnSettings(-1),
down: () => moveSettings(1),
up: () => moveSettings(-1),
bottom: () => moveSettings(Infinity),
act: () => actSetting(),
remove: () => removeSetting(),
},
/*
* A card's back reads the list keys over its checklist, and the board's
* card keys for the card itself — `D`, `H`/`L`, `R` act on it as they do
@@ -260,10 +273,11 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
top: () => void workspace.moveTask(-Infinity),
bottom: () => void workspace.moveTask(Infinity),
tick: () => guard(() => workspace.toggleTask()),
add: () => line(':task '),
// `add` spelled out, so a step that starts with "edit" is added too.
add: () => line(':task add '),
reword: () => {
const task = workspace.card?.checklist[workspace.taskCursor]
if (task) line(`:task! ${task.text}`)
if (task) line(`:task edit ${task.text}`)
},
nudgeDown: () => guard(() => workspace.nudgeTask(1)),
nudgeUp: () => guard(() => workspace.nudgeTask(-1)),
@@ -389,8 +403,8 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
drop: () => guard(() => workspace.drop()),
new: () => void runCommand('new'),
// Capture: a thought goes on this space's inbox without leaving here.
// Reading a document, the card is about it.
capture: () => line(workspace.focus === 'node' && workspace.open?.kind === 'document' ? ':card! ' : ':card '),
// Reading a document, `:card` makes it about that document.
capture: () => line(':card '),
// The name has to come from somewhere, and the command line is where
// names are typed — this only saves typing `:mkdir `.
mkdir: () => line(':mkdir '),
@@ -405,12 +419,14 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
if (workspace.open) void runCommand('q')
else workspace.unmarkAll()
},
// Esc lets go of one thing at a time: what is carried, then the document.
escape: () => {
if (workspace.marked.length) return workspace.unmarkAll()
if (workspace.open) void runCommand('q')
},
delete: () => void runCommand('d'),
// A delete goes to the trash, so it can be undone the way vim undoes.
undo: () => guard(() => workspace.undoDelete()),
share: () => void toggleSharing(workspace.target),
// A vim hand presses y to yank; here it retires the link readers have.
link: () => void renewLink(workspace.target),
// Through the results, as vim's `n` goes through the matches; the
// search itself stays until `:noh`.
next: () => guard(() => workspace.nextResult(1)),
@@ -447,7 +463,7 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
due: () => line(':due '),
// On an empty list there is no card to rename, so it is the list. The
// name is there to be edited, not typed again from nothing.
rename: () => line(workspace.card ? `:name ${workspace.card.title}` : `:name! ${workspace.list?.name ?? ''}`),
rename: () => line(workspace.card ? `:name ${workspace.card.title}` : `:list name ${workspace.list?.name ?? ''}`),
old: () => void (workspace.showOldDone = !workspace.showOldDone),
// A letter over every card in view: typing it puts the cursor there.
hints: () => {
@@ -539,6 +555,7 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
plan: () => void runCommand('plan'),
line: () => line(':'),
help: () => void runCommand('help'),
settings: () => void openSettings(),
},
editor: {},
+286 -277
View File
@@ -1,6 +1,8 @@
import { tick } from 'svelte'
import { api, ApiError, isContainer, type BackupLogin, type BackupStatus } from './api'
import { infoSections } from './Info.svelte'
import { api, ApiError, isContainer, type BackupLogin, type BackupStatus, type NodeSummary } from './api'
import { openHelp } from './Help.svelte'
import { infoSections, renewLink, toggleSharing } from './Info.svelte'
import { loadSettings, openSettings, settingsSections, type SettingsSection } from './Settings.svelte'
import { workspace, type InfoSection } from './store.svelte'
import { emptyTrash } from './Trash.svelte'
import { formatDay, parseDay, parseDue, relative, titleHref, type Heading, type ScrollAmount, type SidewaysAmount } from './util'
@@ -65,8 +67,25 @@ export interface CommandSpec {
run: (arg: string) => unknown
/** What the argument can be, for Tab on the command line. */
complete?: (arg: string) => string[]
/** The forcing variant, `:name!` — vim hands the bang over as an argument. */
/**
* The forcing or everything variant, `:w!`, `:search!` — vim hands the
* bang over as an argument. A `!` means nothing else: a command that does
* a second thing takes a word for it, `:space new`.
*/
bang?: { help: string; run: (arg: string) => unknown }
/** Among the few the first page of the help lists. */
essential?: true
}
/**
* The first word of `arg` when it is one of `verbs`, and what follows it:
* `rm work` is `['rm', 'work']`. Anything else is `['', arg]`, so a name
* that is not a verb is the plain command's argument.
*/
function verb(arg: string, verbs: readonly string[]): [string, string] {
const match = /^\s*(\S+)(?:\s+([\s\S]*))?$/.exec(arg)
if (match && verbs.includes(match[1])) return [match[1], (match[2] ?? '').trim()]
return ['', arg.trim()]
}
/** Close the focused tile; the index of the one that has the focus after. */
@@ -100,7 +119,7 @@ export async function openInfo(section?: InfoSection) {
}
/** A document is open and in focus — being written or read — rather than the list. */
const readingDocument = () =>
export const readingDocument = () =>
workspace.editing || (workspace.focus === 'node' && workspace.open?.kind === 'document')
/** The board's cursor, or the card turned over beside it, has the keys. */
@@ -114,6 +133,10 @@ const requireOpen = () => {
return workspace.open
}
/** A card about `node`, titled after it unless named. */
const cardAbout = (node: NodeSummary, title: string) =>
workspace.guard(() => workspace.createCard(title.trim() || node.name.trim() || node.slug, node.id))
const TITLE_HREF = titleHref('')
/**
@@ -166,6 +189,15 @@ const describeTile = (index: number) => {
return `${index + 1}${index === workspace.focused ? '%' : ''} ${name}${tile.dirty ? ' [+]' : ''}`
}
/** `:space rm`: a space, the one you are in unless named, into the trash. */
async function removeSpace(name: string) {
const wanted = name.toLowerCase()
const space = wanted ? workspace.spaces.find((s) => s.slug === wanted || s.name.toLowerCase() === wanted) : workspace.space
if (!space) return workspace.say(`no such space: ${wanted || '(none open)'}`, 'error')
if (!(await workspace.ask(`delete the space "${space.name}" and everything in it? it goes to the trash.`))) return
await workspace.guard(() => workspace.deleteSpace(space.id))
}
/** Move the cursor and follow it, so `:next` reads as well as `j` then `Enter`. */
const jumpTo = async (index: number) => {
if (workspace.rows === 0) return workspace.say('nothing here', 'error')
@@ -277,7 +309,7 @@ const ago = (at: number | null) => {
}
/** "firefox on linux", out of a user-agent string, as far as it says. */
function browserOf(agent: string): string {
export function browserOf(agent: string): string {
const browser = /Edg\//.test(agent)
? 'edge'
: /Firefox\//.test(agent)
@@ -303,16 +335,66 @@ function browserOf(agent: string): string {
return system ? `${browser} on ${system}` : browser
}
const describeBackup = (status: BackupStatus) => {
if (!status.configured) {
return workspace.say('proton drive backups are off — :backup login <proton-username> sets them up', 'error')
}
if (status.last_error) {
return workspace.say(`backup failed: ${status.last_error}`, 'error')
}
/** The mirror in a line. */
export function describeBackup(status: BackupStatus): string {
if (!status.configured) return 'proton drive backups are off — :backup login <proton-username> sets them up'
if (status.last_error) return `backup failed: ${status.last_error}`
const where = status.device ? `${status.device} · ` : ''
const state = status.running ? 'running now' : status.pending ? 'changes waiting' : 'up to date'
workspace.say(`${where}${state} · last ${ago(status.last_success_at)}`)
return `${where}${state} · last ${ago(status.last_success_at)}`
}
/** The vault in a line: open or locked, and what it is busy with. */
export function describeVault(): string {
const vault = workspace.vault
if (!vault?.configured) return 'no vault yet'
const left = vault.locks_in ? ` · locks in ${Math.ceil(vault.locks_in / 60)}m unless used` : ''
const rekey = vault.rekey
? vault.unlocked
? ` · re-sealing after the password change, ${vault.rekey.total ? `${vault.rekey.done}/${vault.rekey.total}` : 'counting'}`
: ' · re-sealing after the password change waits for :vault unlock'
: ''
return vault.unlocked ? `open${left}${rekey}` : `locked${rekey}`
}
/** The first time: a password for the vault, asked twice. */
async function setupVault() {
const password = await workspace.askSecret('new vault password, 10+ characters — it cannot be recovered')
if (password === null) return
const again = await workspace.askSecret('the same again')
if (again === null) return
if (again !== password) return workspace.say('the two passwords differ — nothing was set', 'error')
workspace.vault = await api.setupVault(password)
workspace.say('vault ready and open — :encrypt seals what is under the cursor, :upload seal seals new files')
}
async function changeVaultPassword() {
const current = await workspace.askSecret('current vault password')
if (current === null) return
const password = await workspace.askSecret('new vault password, 10+ characters')
if (password === null) return
const again = await workspace.askSecret('the same again')
if (again === null) return
if (again !== password) return workspace.say('the two passwords differ — nothing was changed', 'error')
workspace.vault = await api.changeVaultPassword(current, password)
workspace.say(
workspace.vault.rekey
? `vault password changed — re-sealing ${workspace.vault.sealed} file(s) under the new key; leave the vault open, :vault shows how far`
: 'vault password changed',
)
}
/** The open document, or the one under the cursor, as a `.md` file in the downloads. */
async function exportDocument(node: NodeSummary) {
const { name, body, created_at } = node.id === workspace.open?.id ? workspace.draft : await api.getNode(node.id)
const text = name.trim() ? `# ${name.trim()}\n\n${body}` : body
const url = URL.createObjectURL(new Blob([text], { type: 'text/markdown' }))
const anchor = document.createElement('a')
anchor.href = url
anchor.download = exportName(name.trim(), created_at)
anchor.click()
URL.revokeObjectURL(url)
workspace.say(`wrote ${anchor.download}`)
}
/**
@@ -377,6 +459,7 @@ export const commands: CommandSpec[] = [
name: 'write',
aliases: ['w'],
group: 'editing',
essential: true,
help: "save what is being written — the open document, a card's description, a note — rather than in a moment",
run: () => workspace.guard(() => (workspace.writing ? workspace.saveWriting() : workspace.save())),
bang: {
@@ -401,6 +484,7 @@ export const commands: CommandSpec[] = [
name: 'quit',
aliases: ['q'],
group: 'editing',
essential: true,
help: 'leave the editor with what is waiting written first, or close the document when already reading',
run: async () => {
if (workspace.writing || workspace.editing) return leaveEditor()
@@ -432,28 +516,33 @@ export const commands: CommandSpec[] = [
{
name: 'space',
aliases: ['sp'],
args: '[name]',
args: '[name] | new <name> | name <text> | rm [name]',
group: 'tree',
help: 'switch to a space, or list them',
complete: spaceNames,
help: 'switch to a space, or list them — new makes one, name renames the one you are in, rm puts one in the trash with everything in it (asks first)',
complete: (arg) => (arg.startsWith('rm ') ? spaceNames().map((name) => `rm ${name}`) : ['new ', 'name ', 'rm ', ...spaceNames()]),
run: async (arg) => {
const wanted = arg.trim().toLowerCase()
const [what, rest] = verb(arg, ['new', 'name', 'rm'])
if (what === 'new') {
if (!rest) return workspace.say('usage: :space new <name>', 'error')
return workspace.guard(() => workspace.createSpace(rest))
}
if (what === 'name') {
const space = workspace.space
if (!space) return workspace.say('no space open', 'error')
if (!rest) return workspace.say('usage: :space name <text>', 'error')
return workspace.guard(() => workspace.renameNode(space.id, rest))
}
if (what === 'rm') return removeSpace(rest)
const wanted = rest.toLowerCase()
if (!wanted) {
return workspace.say(workspace.spaces.map((space) => space.slug).join(' · ') || 'no spaces')
}
const space = workspace.spaces.find(
(candidate) => candidate.slug === wanted || candidate.name.toLowerCase() === wanted,
)
if (!space) return workspace.say(`no such space: ${wanted} — :space! ${wanted} makes it`, 'error')
if (!space) return workspace.say(`no such space: ${wanted} — :space new ${wanted} makes it`, 'error')
await workspace.guard(() => workspace.enterSpace(space.id))
},
bang: {
help: 'create a space with that name and switch to it',
run: async (arg) => {
if (!arg.trim()) return workspace.say('usage: :space! <name>', 'error')
await workspace.guard(() => workspace.createSpace(arg.trim()))
},
},
},
{
name: 'mkdir',
@@ -488,12 +577,15 @@ export const commands: CommandSpec[] = [
{
name: 'import',
aliases: ['imp'],
args: '[zip [/]]',
group: 'tree',
help: 'upload a folder into this one — markdown becomes documents, links follow',
run: () => hooks.pickFolder(),
bang: {
help: 'import loose files, or a .zip of a folder; :import! / pours an :export! back in, spaces and boards and all',
run: (arg) => hooks.pickArchive(arg.trim() === '/'),
help: 'upload a folder into this one — markdown becomes documents, links follow; zip takes loose files or a .zip instead, and zip / pours an :export! back in, spaces and boards and all',
complete: () => ['zip', 'zip /'],
run: (arg) => {
const [what, rest] = verb(arg, ['zip'])
if (what === 'zip' && (rest === '' || rest === '/')) return hooks.pickArchive(rest === '/')
if (arg.trim()) return workspace.say('usage: :import [zip [/]]', 'error')
hooks.pickFolder()
},
},
{
@@ -512,24 +604,15 @@ export const commands: CommandSpec[] = [
{
name: 'card',
aliases: ['c'],
args: '<title>',
args: '[title]',
group: 'board',
help: "add a card — on the board to the list under the cursor, elsewhere to this space's inbox",
help: "add a card — reading a document, one about it, titled after it unless named, and gf on it comes back; on the board to the list under the cursor, elsewhere to this space's inbox",
run: async (arg) => {
const open = workspace.open
if (readingDocument() && open) return cardAbout(open, arg)
if (!arg.trim()) return workspace.say('usage: :card <title>', 'error')
await workspace.guard(() => workspace.createCard(arg.trim()))
},
bang: {
help: 'add a card linked to the open document — gf on it opens the document',
run: async (arg) => {
const node = workspace.target
if (!node || node.kind !== 'document') {
return workspace.say('no document to link — open one first', 'error')
}
const title = arg.trim() || node.name.trim() || node.slug
await workspace.guard(() => workspace.createCard(title, node.id))
},
},
},
{
name: 'due',
@@ -606,21 +689,14 @@ export const commands: CommandSpec[] = [
{
name: 'task',
aliases: ['step'],
args: '<text>',
args: '[add] <text> | edit <text>',
group: 'board',
help: "add a step to the open card's checklist, below the cursor",
help: "add a step to the open card's checklist, below the cursor — edit rewords the step under it; add is only needed for a step that starts with edit",
run: async (arg) => {
if (workspace.focus !== 'card') return workspace.say('no card turned over — Enter on the board turns one', 'error')
if (!arg.trim()) return workspace.say('usage: :task <text>', 'error')
await workspace.guard(() => workspace.addTask(arg.trim()))
},
bang: {
help: 'reword the step under the cursor',
run: async (arg) => {
if (workspace.focus !== 'card') return workspace.say('no card turned over — Enter on the board turns one', 'error')
if (!arg.trim()) return workspace.say('usage: :task! <text>', 'error')
await workspace.guard(() => workspace.renameTask(arg.trim()))
},
const [what, text] = verb(arg, ['add', 'edit'])
if (!text) return workspace.say(`usage: :task ${what === 'edit' ? 'edit' : '[add]'} <text>`, 'error')
await workspace.guard(() => (what === 'edit' ? workspace.renameTask(text) : workspace.addTask(text)))
},
},
{
@@ -637,24 +713,27 @@ export const commands: CommandSpec[] = [
},
{
name: 'list',
args: '<name>',
args: '<name> | name <text> | rm',
group: 'board',
help: 'add a column to the board',
help: 'add a column to the board — name renames the one under the cursor, rm deletes it and every card on it (asks first)',
complete: () => ['name ', 'rm'],
run: async (arg) => {
if (!workspace.view('board')) return workspace.say('no board open — :board', 'error')
if (!arg.trim()) return workspace.say('usage: :list <name>', 'error')
await workspace.guard(() => workspace.createList(arg.trim()))
},
bang: {
help: 'delete the column under the cursor and every card on it',
run: async () => {
if (!workspace.view('board')) return workspace.say('no board open — :board', 'error')
const list = workspace.list
const [what, rest] = verb(arg, ['name', 'rm'])
const list = workspace.list
if (what === 'name') {
if (!list) return workspace.say('no list selected', 'error')
if (!rest) return workspace.say('usage: :list name <text>', 'error')
return workspace.guard(() => workspace.renameList(rest))
}
if (what === 'rm') {
if (!list) return workspace.say('no list selected', 'error')
const cards = list.cards.length
if (!(await workspace.ask(`delete the list "${list.name}" and its ${cards} card${cards === 1 ? '' : 's'}?`))) return
await workspace.guard(() => workspace.deleteList())
},
return workspace.guard(() => workspace.deleteList())
}
if (!rest) return workspace.say('usage: :list <name>', 'error')
await workspace.guard(() => workspace.createList(rest))
},
},
{
@@ -662,6 +741,7 @@ export const commands: CommandSpec[] = [
aliases: ['mv'],
args: '<folder|..|/>',
group: 'tree',
essential: true,
help: 'move the marked nodes, or else the selected one, into a folder here, up one level, or to the root of the space',
complete: folderNames,
run: async (arg) => {
@@ -685,17 +765,12 @@ export const commands: CommandSpec[] = [
await workspace.guard(() => workspace.moveNode(node.id, destination))
},
},
{
name: 'unmark',
group: 'tree',
help: 'put down everything picked up with m',
run: () => workspace.unmarkAll(),
},
{
name: 'new',
aliases: ['n', 'o'],
args: '[template] [yyyy-mm-dd] [name]',
group: 'documents',
essential: true,
help: "start a new document here — from one of the space's templates, dated, named; in the diary from templates/day",
complete: (arg) => (arg.includes(' ') ? [] : templateNames()),
run: async (arg) => {
@@ -910,10 +985,11 @@ 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; bare, while writing, into the title',
help: 'rename what has focus — document, folder, file, or card on a board; bare, while writing, into the title — :space name and :list name rename those',
run: async (arg) => {
if (!arg.trim() && workspace.editing && workspace.pane === 'document') return hooks.focusTitle()
if (onBoard()) {
if (!workspace.card) return workspace.say('no card here — :list name renames the list', 'error')
if (!arg.trim()) return workspace.say('usage: :name <text>', 'error')
return workspace.guard(() => workspace.patchCard({ title: arg.trim() }))
}
@@ -928,41 +1004,9 @@ export const commands: CommandSpec[] = [
return workspace.guard(() => workspace.save())
}
if (!node) return workspace.say('nothing selected — :name! renames the space', 'error')
if (!node) return workspace.say('nothing selected — :space name renames the space', 'error')
await workspace.guard(() => workspace.renameNode(node.id, arg))
},
bang: {
help: 'rename the space you are in — on the board, the list under the cursor',
run: async (arg) => {
if (onBoard()) {
if (!arg.trim()) return workspace.say('usage: :name! <list name>', 'error')
return workspace.guard(() => workspace.renameList(arg.trim()))
}
const space = workspace.space
if (!space) return workspace.say('no space open', 'error')
if (!arg.trim()) return workspace.say('usage: :name! <text>', 'error')
await workspace.guard(() => workspace.renameNode(space.id, arg))
},
},
},
{
name: 'rmspace',
aliases: ['rms'],
args: '[name]',
group: 'tree',
help: 'delete a space and everything in it, into the trash (asks first)',
complete: spaceNames,
run: async (arg) => {
const wanted = arg.trim().toLowerCase()
const space = wanted
? workspace.spaces.find((s) => s.slug === wanted || s.name.toLowerCase() === wanted)
: workspace.space
if (!space) return workspace.say(`no such space: ${wanted || '(none open)'}`, 'error')
if (!(await workspace.ask(`delete the space "${space.name}" and everything in it? it goes to the trash.`))) {
return
}
await workspace.guard(() => workspace.deleteSpace(space.id))
},
},
{
name: 'history',
@@ -992,6 +1036,7 @@ export const commands: CommandSpec[] = [
name: 'trash',
args: '[purge]',
group: 'tree',
essential: true,
help: 'what was deleted — j/k, Enter brings it back, x deletes it for good; :trash purge empties it',
complete: () => ['purge'],
run: async (arg) => {
@@ -1020,38 +1065,20 @@ export const commands: CommandSpec[] = [
},
{
name: 'share',
args: '[off|new]',
group: 'sharing',
help: 'publish the document behind an unguessable link and copy it',
run: async () => {
essential: true,
help: 'publish the document behind an unguessable link and copy it — off takes it back, new mints a fresh link and the old one stops working; both ask first, and :info share shows where it stands',
complete: () => ['off', 'new'],
run: async (arg) => {
const node = workspace.target
const id = node?.id
if (!id) return workspace.say('nothing selected', 'error')
if (node?.shared) return workspace.say('already shared — :link for a new link, :unshare to revoke')
await workspace.guard(() => workspace.toggleShare(id))
},
},
{
name: 'unshare',
group: 'sharing',
help: 'revoke the share link',
run: async () => {
const node = workspace.target
const id = node?.id
if (!id) return workspace.say('nothing selected', 'error')
if (!node?.shared) return workspace.say('not shared')
await workspace.guard(() => workspace.toggleShare(id))
},
},
{
name: 'link',
group: 'sharing',
help: 'mint a new share link for the document and copy it',
run: async () => {
const node = workspace.target
const id = node?.id
if (!id) return workspace.say('nothing selected', 'error')
if (!node?.shared) return workspace.say('not shared — :share first', 'error')
await workspace.guard(() => workspace.renewShareLink(id))
if (!node) return workspace.say('nothing selected', 'error')
const what = arg.trim()
if (what === 'off') return node.shared ? toggleSharing(node) : workspace.say('not shared')
if (what === 'new') return renewLink(node)
if (what) return workspace.say('usage: :share [off|new]', 'error')
if (node.shared) return workspace.say('already shared — :share new for a fresh link, :share off to take it back')
await workspace.guard(() => workspace.toggleShare(node.id))
},
},
{
@@ -1070,19 +1097,21 @@ export const commands: CommandSpec[] = [
name: 'export',
aliases: ['exp'],
group: 'sharing',
help: 'download the open document as a .md file',
run: () => {
if (!requireOpen()) return
const { name, body, created_at } = workspace.draft
const text = name.trim() ? `# ${name.trim()}\n\n${body}` : body
const url = URL.createObjectURL(new Blob([text], { type: 'text/markdown' }))
const anchor = document.createElement('a')
anchor.href = url
anchor.download = exportName(name.trim(), created_at)
anchor.click()
URL.revokeObjectURL(url)
workspace.say(`wrote ${anchor.download}`)
},
essential: true,
help: 'download what has focus — the file under the cursor as it is, a document as a .md file with what is typed so far',
run: () =>
workspace.guard(async () => {
const node = workspace.target
if (!node) return workspace.say('nothing selected — :export! downloads everything', 'error')
if (node.kind === 'document') return exportDocument(node)
if (!node.file) return workspace.say(`a ${node.kind} goes with :export!, the whole workspace`, 'error')
if (node.file.encrypted && !(await workspace.ensureUnlocked())) return
const anchor = document.createElement('a')
anchor.href = node.file.url
anchor.download = node.name
anchor.click()
workspace.say(`downloading ${node.name}`)
}),
bang: {
help: 'download the whole workspace — every space, document and file — as a zip',
run: async () => {
@@ -1101,25 +1130,6 @@ export const commands: CommandSpec[] = [
},
},
},
{
name: 'download',
aliases: ['dl'],
group: 'sharing',
help: 'download the file under the cursor, or the open one — the open document goes as :export writes it',
run: () =>
workspace.guard(async () => {
const node = workspace.target
if (!node) return workspace.say('nothing selected', 'error')
if (node.kind === 'document' && node.id === workspace.open?.id) return runCommand('export')
if (!node.file) return workspace.say(`a ${node.kind} is no file — :export! downloads everything`, 'error')
if (node.file.encrypted && !(await workspace.ensureUnlocked())) return
const anchor = document.createElement('a')
anchor.href = node.file.url
anchor.download = node.name
anchor.click()
workspace.say(`downloading ${node.name}`)
}),
},
{
name: 'source',
group: 'documents',
@@ -1131,15 +1141,20 @@ export const commands: CommandSpec[] = [
{
name: 'upload',
aliases: ['up'],
args: '[seal]',
group: 'editing',
help: "files for the open document — while writing, embedded at the cursor — or, from the list, into this folder",
run: () => (readingDocument() ? hooks.pickFiles() : hooks.pickTreeFiles(false)),
bang: {
help: 'put files into this folder sealed in the vault — encrypted before they touch the disk',
run: () =>
workspace.guard(async () => {
essential: true,
help: 'files for the open document — while writing, embedded at the cursor — or, from the list, into this folder; seal puts them into this folder sealed in the vault, encrypted before they touch the disk',
complete: () => ['seal'],
run: (arg) => {
const what = arg.trim()
if (what === 'seal') {
return workspace.guard(async () => {
if (await workspace.ensureUnlocked()) hooks.pickTreeFiles(true)
}),
})
}
if (what) return workspace.say('usage: :upload [seal]', 'error')
return readingDocument() ? hooks.pickFiles() : hooks.pickTreeFiles(false)
},
},
{
@@ -1174,8 +1189,8 @@ export const commands: CommandSpec[] = [
},
},
{
name: 'clear',
aliases: ['noh'],
name: 'noh',
aliases: ['nohlsearch'],
group: 'documents',
help: 'clear the search filter',
run: () => workspace.guard(() => workspace.search('')),
@@ -1208,12 +1223,13 @@ export const commands: CommandSpec[] = [
},
{
name: 'set',
args: '<option>',
args: '[option]',
group: 'view',
help: 'vim, novim, clipboard, noclipboard, board, noboard',
help: 'vim, novim, clipboard, noclipboard, board, noboard — bare, the settings sheet',
complete: () => ['vim', 'novim', 'clipboard', 'noclipboard', 'board', 'noboard'],
run: (arg) => {
const option = arg.trim()
if (!option) return openSettings('options')
if (option === 'vim') return workspace.setVim(true), workspace.say('vim keys on')
if (option === 'novim') return workspace.setVim(false), workspace.say('vim keys off')
if (option === 'clipboard')
@@ -1231,12 +1247,12 @@ export const commands: CommandSpec[] = [
name: 'backup',
group: 'view',
args: '[login <proton-username> | logout]',
help: 'when the workspace was last mirrored to proton drive — login sets the mirror up, logout forgets its session',
help: 'the proton drive mirror, in the settings sheet — login sets it up, logout forgets its session',
complete: () => ['login ', 'logout'],
run: (arg) =>
workspace.guard(async () => {
const trimmed = arg.trim()
if (trimmed === '') return describeBackup(await api.backupStatus())
if (trimmed === '') return openSettings('backup')
const loginMatch = /^login\s+(\S+)$/.exec(trimmed)
if (loginMatch) return protonLogin(loginMatch[1])
if (trimmed === 'logout') {
@@ -1270,18 +1286,12 @@ export const commands: CommandSpec[] = [
name: 'sessions',
args: '[rm <n>]',
group: 'view',
help: 'every browser signed in, numbered — rm signs one of them out, and locks the vault',
help: 'every browser signed in, in the settings sheet — rm signs the n-th out, and locks the vault',
run: (arg) =>
workspace.guard(async () => {
const trimmed = arg.trim()
if (trimmed === '' || trimmed === 'ls' || trimmed === 'list') return openSettings('sessions')
const sessions = await api.sessions()
if (trimmed === '' || trimmed === 'ls' || trimmed === 'list') {
return workspace.say(
sessions
.map((s, i) => `${i + 1} ${s.current ? 'this one' : browserOf(s.user_agent)}, seen ${relative(s.last_seen)}`)
.join(' · '),
)
}
const rmMatch = /^rm\s+(\d+)$/.exec(trimmed)
if (!rmMatch) return workspace.say('usage: :sessions [rm <n>]', 'error')
const target = sessions[Number(rmMatch[1]) - 1]
@@ -1296,15 +1306,12 @@ export const commands: CommandSpec[] = [
name: 'sshkey',
args: '[add <name> <key> | rm <name> | vault <name> on|off]',
group: 'view',
help: 'list, add or remove keys allowed to mount the workspace over sftp, and say which may read sealed files',
help: 'the keys allowed to mount the workspace over sftp, in the settings sheet — add, rm, and vault on|off for which may read sealed files',
complete: () => ['add ', 'rm ', 'vault '],
run: (arg) =>
workspace.guard(async () => {
const trimmed = arg.trim()
if (trimmed === '' || trimmed === 'ls' || trimmed === 'list') {
const keys = await api.listSshKeys()
if (keys.length === 0) return workspace.say('no keys registered — :sshkey add <name> <key>')
return workspace.say(keys.map((k) => `${k.name}${k.vault ? ' [vault]' : ''} ${k.fingerprint}`).join(' · '))
}
if (trimmed === '' || trimmed === 'ls' || trimmed === 'list') return openSettings('keys')
const addMatch = /^add\s+(\S+)\s+(.+)$/.exec(trimmed)
if (addMatch) {
const [, name, key] = addMatch
@@ -1340,7 +1347,7 @@ export const commands: CommandSpec[] = [
name: 'token',
args: '[add <name> | rm <name>]',
group: 'view',
help: 'list, add or remove tokens that capture into the workspace from outside it — add shows the token once and copies it',
help: 'the tokens that capture into the workspace from outside it, in the settings sheet — add shows a new one once and copies it, rm retires one',
complete: (arg) => {
// Tab cannot wait for the server, so the names are fetched the first
// time it is pressed here and offered from then on.
@@ -1353,16 +1360,7 @@ export const commands: CommandSpec[] = [
run: (arg) =>
workspace.guard(async () => {
const trimmed = arg.trim()
if (trimmed === '' || trimmed === 'ls' || trimmed === 'list') {
const tokens = await api.listTokens()
tokenNames = tokens.map((t) => t.name)
if (tokens.length === 0) return workspace.say('no tokens — :token add <name> makes one')
return workspace.say(
tokens
.map((t) => `${t.name}, ${t.last_used_at === null ? 'never used' : `used ${relative(t.last_used_at)}`}`)
.join(' · '),
)
}
if (trimmed === '' || trimmed === 'ls' || trimmed === 'list') return openSettings('tokens')
const addMatch = /^add\s+(.+)$/.exec(trimmed)
if (addMatch) {
const added = await api.addToken(addMatch[1].trim())
@@ -1388,73 +1386,27 @@ export const commands: CommandSpec[] = [
},
{
name: 'vault',
args: '[unlock|lock|passwd]',
group: 'vault',
help: 'whether the vault is open — or, the first time, set its password',
run: () =>
help: 'the vault in the settings sheet, or, the first time, set its password — unlock opens it, lock closes it now, passwd changes the password and re-seals every file under a new key',
complete: () => ['unlock', 'lock', 'passwd'],
run: (arg) =>
workspace.guard(async () => {
const what = arg.trim()
await workspace.loadVault()
const vault = workspace.vault
if (vault?.configured) {
const left = vault.locks_in ? ` · locks in ${Math.ceil(vault.locks_in / 60)}m unless used` : ''
const rekey = vault.rekey
? vault.unlocked
? ` · re-sealing after the password change, ${vault.rekey.total ? `${vault.rekey.done}/${vault.rekey.total}` : 'counting'}`
: ' · re-sealing after the password change waits for :unlock'
: ''
return workspace.say(
vault.unlocked ? `vault open${left}${rekey} — :lock closes it` : `vault locked${rekey} — :unlock opens it`,
)
if (what === 'unlock') {
if (workspace.vault?.unlocked) return workspace.say('the vault is already open')
return workspace.ensureUnlocked()
}
const password = await workspace.askSecret('new vault password, 10+ characters — it cannot be recovered')
if (password === null) return
const again = await workspace.askSecret('the same again')
if (again === null) return
if (again !== password) return workspace.say('the two passwords differ — nothing was set', 'error')
workspace.vault = await api.setupVault(password)
workspace.say('vault ready and open — :encrypt seals what is under the cursor, :upload! seals new files')
}),
},
{
name: 'unlock',
group: 'vault',
help: 'open the vault with its password, so sealed files can be read',
run: () =>
workspace.guard(async () => {
await workspace.loadVault()
if (workspace.vault?.unlocked) return workspace.say('the vault is already open')
await workspace.ensureUnlocked()
}),
},
{
name: 'lock',
group: 'vault',
help: 'close the vault now — it also closes itself when left alone, and on logout',
run: () =>
workspace.guard(async () => {
workspace.vault = await api.lockVault()
if (workspace.open?.file?.encrypted) workspace.open = await api.getNode(workspace.open.id)
workspace.say('vault locked')
}),
},
{
name: 'passwd',
group: 'vault',
help: "change the vault's password — every sealed file is then re-sealed under a new key, while the vault is open",
run: () =>
workspace.guard(async () => {
const current = await workspace.askSecret('current vault password')
if (current === null) return
const password = await workspace.askSecret('new vault password, 10+ characters')
if (password === null) return
const again = await workspace.askSecret('the same again')
if (again === null) return
if (again !== password) return workspace.say('the two passwords differ — nothing was changed', 'error')
workspace.vault = await api.changeVaultPassword(current, password)
workspace.say(
workspace.vault.rekey
? `vault password changed — re-sealing ${workspace.vault.sealed} file(s) under the new key; leave the vault open, :vault shows how far`
: 'vault password changed',
)
if (what === 'lock') {
workspace.vault = await api.lockVault()
if (workspace.open?.file?.encrypted) workspace.open = await api.getNode(workspace.open.id)
return workspace.say('vault locked')
}
if (what === 'passwd') return changeVaultPassword()
if (what) return workspace.say('usage: :vault [unlock|lock|passwd]', 'error')
if (!workspace.vault?.configured) return setupVault()
await openSettings('vault')
}),
},
{
@@ -1474,11 +1426,30 @@ export const commands: CommandSpec[] = [
{
name: 'help',
aliases: ['h'],
args: '[all]',
group: 'view',
help: 'show the key and command reference',
run: () => {
if (workspace.overlay === 'help') workspace.closeSheet()
else workspace.openSheet('help')
essential: true,
help: 'the keys to start with — ? again, or :help all, for every key and command',
complete: () => ['all'],
run: (arg) => {
const all = arg.trim() === 'all'
if (workspace.overlay === 'help' && !all) return workspace.closeSheet()
openHelp(all)
},
},
{
name: 'settings',
args: '[options|vault|backup|sessions|keys|tokens]',
group: 'view',
essential: true,
help: 'the settings sheet: vim and the clipboard, the vault, the backup, the browsers signed in, ssh keys and capture tokens — Tab between them (,)',
complete: () => settingsSections,
run: (arg) => {
const section = arg.trim()
if (section && !settingsSections.includes(section as SettingsSection)) {
return workspace.say(`usage: :settings [${settingsSections.join('|')}]`, 'error')
}
return openSettings((section || undefined) as SettingsSection | undefined)
},
},
{
@@ -1493,6 +1464,38 @@ export const commands: CommandSpec[] = [
},
]
/**
* Old spellings, kept for one release so the fingers and notes that know
* them still work: each runs as the line it is now. Help and Tab do not
* offer them.
*/
const renamed: Record<string, string | ((arg: string) => unknown)> = {
'space!': 'space new',
rmspace: 'space rm',
rms: 'space rm',
'list!': 'list rm',
'task!': 'task edit',
'upload!': 'upload seal',
'import!': 'import zip',
unshare: 'share off',
link: 'share new',
clear: 'noh',
download: 'export',
dl: 'export',
unlock: 'vault unlock',
lock: 'vault lock',
passwd: 'vault passwd',
// On the board it renamed the list, elsewhere the space.
'name!': (arg) => runCommand(`${onBoard() ? 'list' : 'space'} name ${arg}`),
// It linked the card to the document under the cursor, read or not.
'card!': (arg) => {
const node = workspace.target
if (node?.kind !== 'document') return workspace.say('no document to link — open one first', 'error')
return cardAbout(node, arg)
},
unmark: () => workspace.unmarkAll(),
}
const lookup = new Map<string, CommandSpec>()
for (const spec of commands) {
lookup.set(spec.name, spec)
@@ -1512,6 +1515,12 @@ export async function runCommand(line: string): Promise<void> {
const [head, ...rest] = input.split(/\s+/)
const arg = rest.join(' ')
const old = renamed[head]
if (old !== undefined) {
await (typeof old === 'string' ? runCommand(`${old} ${arg}`) : old(arg))
return
}
// `:12` jumps to a node by id, the way `:12` jumps to a line in vim.
if (/^\d+$/.test(head)) {
await workspace.guard(() => workspace.openNode(Number(head)))
@@ -1528,9 +1537,9 @@ export async function runCommand(line: string): Promise<void> {
if (forced) {
if (!spec.bang) return workspace.say(`E477: no ! allowed: ${head}`, 'error')
await spec.bang.run(arg)
return
}
await spec.run(arg)
} else await spec.run(arg)
// What a line changed shows in the settings sheet it was typed over.
if (workspace.overlay === 'settings') await workspace.guard(() => loadSettings())
}
/**
+44 -25
View File
@@ -26,6 +26,7 @@ export type Context =
| 'cards'
| 'history'
| 'share'
| 'settings'
| 'card'
| 'drawer'
| 'workspace'
@@ -64,24 +65,26 @@ export interface Binding {
hint?: string
/** The keycaps the footer and the rail show; from `keys` when not given. */
caps?: readonly string[]
/** On the help's first page, among the keys to start with. */
essential?: boolean
}
const digits = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'] as const
export const bindings = [
// --- browsing and reading; `always` is heard in the editor too ---------------
{ context: 'global', keys: digits, action: 'space', caps: ['1…9'], label: '1 … 9 · 0', help: 'switch to that space, like a workspace' },
{ context: 'global', keys: digits, action: 'space', caps: ['1…9'], label: '1 … 9 · 0', help: 'switch to that space, like a workspace', essential: true },
{ context: 'global', keys: digits.map((digit) => `Shift-${digit}`), action: 'send', label: 'Shift 1 … 9', help: 'send the marked nodes, or the selected one, to that space' },
{ context: 'global', keys: ['Tab', 'Shift-Tab'], action: 'cycle', label: 'Tab · Shift-Tab', help: 'next / previous space' },
{ context: 'global', keys: ['w'], action: 'overview', help: 'overview of every space — hjkl, then Enter' },
{ context: 'global', keys: ['f'], action: 'fullscreen', help: 'fullscreen the focused pane or tile; again or Esc to leave' },
{ context: 'always', keys: ['Alt-h', 'Alt-j', 'Alt-k', 'Alt-l'], action: 'focus', typing: true, label: 'Alt-h/j/k/l', help: 'focus the tile that way, from the editor too — left of the leftmost is the list' },
{ context: 'always', keys: ['Alt-h', 'Alt-j', 'Alt-k', 'Alt-l'], action: 'focus', typing: true, label: 'Alt-h/j/k/l', help: 'focus the tile that way, from the editor too — left of the leftmost is the list', essential: true },
{ context: 'always', keys: ['Alt-Shift-h', 'Alt-Shift-j', 'Alt-Shift-k', 'Alt-Shift-l'], action: 'swap', typing: true, label: 'Alt-Shift-h/j/k/l', help: 'move the focused tile that way' },
{ context: 'tree', keys: ['L'], action: 'beside', label: 'L · Shift-Enter', help: 'open the row in a new tile beside the others' },
{ context: 'global', keys: ['gt'], action: 'nextTile', label: 'gt / gT', help: 'next / previous tile — a view too' },
{ context: 'global', keys: ['gT'], action: 'previousTile' },
{ context: 'always', keys: ['Alt-q'], action: 'close', typing: true, help: 'close the focused tile — :close; :only closes the others' },
{ context: 'tree', keys: ['j', 'ArrowDown'], action: 'down', label: 'j / k · ↓ ↑', help: 'move down / up the list — scroll the document when it has focus' },
{ context: 'tree', keys: ['j', 'ArrowDown'], action: 'down', label: 'j / k · ↓ ↑', help: 'move down / up the list — scroll the document when it has focus', essential: true },
{ context: 'tree', keys: ['k', 'ArrowUp'], action: 'up' },
{ context: 'tree', keys: ['gg'], action: 'top', label: 'gg / G', help: 'first / last row, or top / bottom of the document — a first g, d, ] or [ waits a second in the status line' },
{ context: 'tree', keys: ['G'], action: 'bottom' },
@@ -90,49 +93,49 @@ export const bindings = [
{ context: 'tree', keys: ['zh', 'zl', 'zH', 'zL'], action: 'sideways', label: 'zl / zh · zL / zH', help: 'the wide table or code block at the top of the view, sideways — a little, or half its width' },
{ context: 'tree', keys: [']]'], action: 'nextHeading', label: ']] / [[', help: 'next / previous heading of the document — in the editor too' },
{ context: 'tree', keys: ['[['], action: 'previousHeading' },
{ context: 'tree', keys: ['gi'], action: 'info', help: "the info sheet: the open document's headings, earlier versions, backlinks, cards and sharing — Tab between them" },
{ context: 'tree', keys: ['gi'], action: 'info', help: "the info sheet: the open document's headings, earlier versions, backlinks, cards, files and sharing — :share there, Tab between them", essential: true },
{ context: 'tree', keys: ['gO'], action: 'outline', help: 'the headings as a list: j/k, Enter jumps there' },
{ context: 'tree', keys: ['F'], action: 'hints', help: 'hints over the links and buttons in view — type one to follow it' },
{ context: 'tree', label: 'gf / gx', help: 'in the editor: follow the link under the cursor / open it in a tab' },
{ context: 'tree', keys: ['gx'], action: 'inTab', help: 'open the file under the cursor in a tab of its own' },
{ context: 'tree', keys: ['Enter', 'Shift-Enter', 'l', 'ArrowRight'], action: 'open', label: 'Enter · l', help: 'open a document, or enter a folder — on an open keystore or sealed file, Enter asks for its password' },
{ context: 'tree', keys: ['h', 'ArrowLeft'], action: 'left', label: 'h', help: 'from the document to the list, from the list up a level' },
{ context: 'tree', keys: ['o'], action: 'new', help: 'new document here, straight into insert mode' },
{ context: 'tree', keys: ['Enter', 'Shift-Enter', 'l', 'ArrowRight'], action: 'open', label: 'Enter · l', help: 'open a document, or enter a folder — on an open keystore or sealed file, Enter asks for its password', essential: true },
{ context: 'tree', keys: ['h', 'ArrowLeft'], action: 'left', label: 'h', help: 'from the document to the list, from the list up a level', essential: true },
{ context: 'tree', keys: ['o'], action: 'new', help: 'new document here, straight into insert mode', essential: true },
{ context: 'tree', keys: ['O'], action: 'mkdir', caps: ['⇧ O'], help: 'new folder here' },
{ context: 'tree', keys: ['i', 'a'], action: 'edit', help: 'edit the open document' },
{ context: 'tree', keys: ['i', 'a'], action: 'edit', help: 'edit the open document', essential: true },
{ context: 'tree', keys: ['R'], action: 'rename', help: 'rename what is under the cursor — document, folder or card — starting from its name' },
{ context: 'tree', keys: ['m'], action: 'mark', label: 'm / p', help: 'pick nodes up, then drop them all into the folder you walk to' },
{ context: 'tree', keys: ['m'], action: 'mark', label: 'm / p', help: 'pick nodes up, then drop them all into the folder you walk to', essential: true },
{ context: 'tree', keys: ['p'], action: 'drop' },
{ context: 'tree', keys: ['za'], action: 'files', help: "a document's own files, folded open under it in the list — again folds them away; p drops marked files in there" },
{ context: 'tree', keys: ['M'], action: 'markAll', help: 'pick up the whole folder — again puts it all back' },
{ context: 'tree', label: 'Ctrl / Shift-click', help: 'pick a row up · every row up to it — drag them onto a folder' },
{ context: 'global', keys: ['S'], action: 'plan', help: 'the board, the agenda and the calendar, tiled ahead of what is open with the board focused — S again puts every view away' },
{ context: 'global', keys: ['t'], action: 'agenda', help: 'agenda — late, due this week, in hand, on every board — in a tile of its own' },
{ context: 'global', keys: ['T'], action: 'today', help: "today's diary entry, started if there is none — from templates/day when the diary has one" },
{ context: 'global', keys: ['S'], action: 'plan', help: 'the board, the agenda and the calendar, tiled ahead of what is open with the board focused — S again puts every view away', essential: true },
{ context: 'global', keys: ['t'], action: 'agenda', help: 'agenda — late, due this week, in hand, on every board — in a tile of its own', essential: true },
{ context: 'global', keys: ['T'], action: 'today', help: "today's diary entry, started if there is none — from templates/day when the diary has one", essential: true },
{ context: 'global', keys: ['C'], action: 'calendar', help: 'the diary by month, in a tile: h/l a day, j/k a week, H/L a month, . today, Enter opens or starts the day beside it' },
{ context: 'tree', keys: ['[d'], action: 'previousEntry', label: '[d / ]d', help: 'the diary entry before / after the one open, by date' },
{ context: 'tree', keys: [']d'], action: 'nextEntry' },
{ context: 'global', keys: ['Ctrl-p'], action: 'find', label: 'Ctrl-P', help: 'find anything, in every space — Enter opens the result lit beside them, n goes on' },
{ context: 'global', keys: ['Ctrl-p'], action: 'find', label: 'Ctrl-P', help: 'find anything, in every space — Enter opens the result lit beside them, n goes on', essential: true },
{ context: 'global', keys: ['b'], action: 'board', help: "the board, in a tile: this space's, or the last one shown — 1 … 9 there shows another's" },
{ context: 'tree', keys: ['c'], action: 'capture', help: "capture a card into this space's inbox, without leaving here — reading a document, a card about it; Tab on the line makes it a note, or a line in today's entry" },
{ context: 'tree', keys: ['q', 'Escape'], action: 'close', label: 'Esc · q', help: 'close the document, and its tile when there are several' },
{ context: 'tree', keys: ['/'], action: 'search', help: 'full-text search, inside the current space and its text files — Tab or Ctrl-N/P walk the results, Enter opens one' },
{ context: 'tree', keys: ['c'], action: 'capture', help: "capture a card into this space's inbox, without leaving here — reading a document, a card about it; Tab on the line makes it a note, or a line in today's entry", essential: true },
{ context: 'tree', keys: ['q'], action: 'close', label: 'Esc · q', help: 'close the document, and its tile when there are several — Esc first puts down what m picked up', essential: true },
{ context: 'tree', keys: ['Escape'], action: 'escape' },
{ context: 'tree', keys: ['/'], action: 'search', help: 'full-text search, inside the current space and its text files — Tab or Ctrl-N/P walk the results, Enter opens one', essential: true },
{ context: 'tree', keys: ['n'], action: 'next', label: 'n / N', help: 'next / previous search result, opened beside the results — :noh clears the search' },
{ context: 'tree', keys: ['N'], action: 'previous' },
{ context: 'tree', keys: ['s'], action: 'share', help: 'toggle sharing for the document — asks before taking it back' },
{ context: 'tree', keys: ['y'], action: 'link', help: 'mint a new share link and copy it — asks, the old one stops working' },
{ context: 'tree', keys: ['x', 'dd'], action: 'delete', help: 'delete what has focus — the row in the list, the document in its pane — into the trash (asks y/N)' },
{ context: 'tree', keys: ['u'], action: 'undo', help: 'undo the last delete: it comes back out of the trash — :trash has the rest' },
{ context: 'tree', keys: ['x', 'dd'], action: 'delete', help: 'delete what has focus — the row in the list, the document in its pane — into the trash (asks y/N)', essential: true },
{ context: 'tree', keys: ['u'], action: 'undo', help: 'undo the last delete: it comes back out of the trash — :trash has the rest', essential: true },
{ context: 'tree', keys: ['gh'], action: 'history', help: "the open document's earlier versions, as diffs — Enter brings one back" },
{ context: 'tree', keys: ['gb'], action: 'backlinks', help: 'the documents that link to the open one — [[title]] links by title, [[ completes one while writing' },
{ context: 'tree', keys: ['gc'], action: 'cards', help: 'the cards about the open document — j/k picks the one :due, :label, Space and H/L act on, Enter turns it over on its board' },
{ context: 'global', keys: [':'], action: 'line', help: 'command line — Tab / Shift-Tab cycle the matches, then the argument; ↑ ↓ recall what ran before' },
{ context: 'global', keys: [':'], action: 'line', help: 'command line — Tab / Shift-Tab cycle the matches, then the argument; ↑ ↓ recall what ran before', essential: true },
{ context: 'always', keys: ['Ctrl-s', 'Meta-s'], action: 'save', typing: true, label: 'Ctrl-S', help: 'save, from anywhere' },
{ context: 'global', keys: ['?'], action: 'help', help: 'this screen, from the editor too — h/l a column, / filters it down' },
{ context: 'global', keys: ['?'], action: 'help', help: 'this screen — the keys to start with, then on a second ? every key and command; h/l a column, / filters it down', essential: true },
{ context: 'global', keys: [','], action: 'settings', help: 'the settings sheet: vim and the clipboard, the vault, the backup, signed-in browsers, ssh keys, capture tokens', essential: true },
// --- the workspace around the panes ------------------------------------------
{ context: 'workspace', keys: ['Escape'], action: 'escape', typing: true },
{ context: 'workspace', keys: ['Q'], action: 'scratch', section: 'scratch', help: 'lower the scratchpad over whatever is on screen, or raise it again — :scratch <text> takes a note without it' },
{ context: 'workspace', keys: ['Q'], action: 'scratch', section: 'scratch', help: 'lower the scratchpad over whatever is on screen, or raise it again — :scratch <text> takes a note without it', essential: true },
// While a document is being written, a key that lands outside the editor
// hands the focus back to it instead of walking the list unseen.
{ context: 'writing', keys: ['*'], action: 'refocus' },
@@ -232,6 +235,7 @@ export const bindings = [
{ context: 'help', keys: ['g'], action: 'start', caps: ['g', 'G'], hint: 'start · end' },
{ context: 'help', keys: ['G'], action: 'end' },
{ context: 'help', keys: ['/'], action: 'filter', hint: 'filter, and read the lines whole' },
{ context: 'help', keys: ['?'], action: 'deeper', hint: 'all of it' },
{ context: 'overview', keys: ['h', 'ArrowLeft', 'l', 'ArrowRight', 'k', 'ArrowUp', 'j', 'ArrowDown'], action: 'step', caps: ['h', 'j', 'k', 'l'], hint: 'move' },
{ context: 'overview', keys: digits, action: 'go', caps: ['1…9', '↵'], hint: 'go' },
@@ -285,10 +289,18 @@ export const bindings = [
{ context: 'share', keys: ['s', 'Enter'], action: 'toggle', caps: ['s'], hint: 'share · stop' },
{ context: 'share', keys: ['y'], action: 'link', hint: 'new link' },
{ context: 'settings', keys: ['Tab'], action: 'next', caps: ['tab'], hint: 'next part' },
{ context: 'settings', keys: ['Shift-Tab'], action: 'previous' },
{ context: 'settings', keys: ['j', 'ArrowDown'], action: 'down', caps: ['j', 'k'], hint: 'move' },
{ context: 'settings', keys: ['k', 'ArrowUp'], action: 'up' },
{ context: 'settings', keys: ['G'], action: 'bottom' },
{ context: 'settings', keys: ['Enter', 'l', 'Space'], action: 'act', caps: ['↵'], hint: 'change' },
{ context: 'settings', keys: ['x'], action: 'remove', hint: 'remove' },
// --- the editor's own keymap, described only ---------------------------------------
{ context: 'editor', label: 'vim', help: 'the full keymap — hjkl, w/b, dd, ciw, visual mode, macros' },
{ context: 'editor', label: 'autosave', help: 'a document, a description or a note saves itself as the typing stops; :w saves sooner' },
{ context: 'editor', label: ':q · Esc', help: 'leave the editor, what is waiting written first — Esc in normal mode; :q! lets it go' },
{ context: 'editor', label: 'autosave', help: 'a document, a description or a note saves itself as the typing stops; :w saves sooner', essential: true },
{ context: 'editor', label: ':q · Esc', help: 'leave the editor, what is waiting written first — Esc in normal mode; :q! lets it go', essential: true },
{ context: 'editor', label: '[[', help: 'link a document by its title, offered as you type' },
{ context: 'editor', label: 'drop · paste', help: 'an image, video or audio file attaches it' },
{ context: 'editor', label: '$x^2$ · $$…$$', help: 'set as math' },
@@ -314,6 +326,13 @@ const named: Record<string, string> = { Escape: 'Esc' }
/** The keys as the help writes them. */
export const label = (binding: Binding) => binding.label ?? (binding.keys ?? []).map((key) => named[key] ?? key).join(' · ')
/** The help's first page: the keys marked to start with, in the table's order. */
export function essentials(): { key: string; description: string }[] {
return (bindings as readonly Binding[])
.filter((binding) => binding.essential && binding.help)
.map((binding) => ({ key: label(binding), description: binding.help! }))
}
/** The rows of one help column, in the table's order. */
export function rows(section: Section): { key: string; description: string }[] {
const contexts = sections.find((entry) => entry.section === section)?.contexts ?? []
+2 -2
View File
@@ -11,7 +11,7 @@
* tile what has the focus: the tree, or a view — board, agenda,
* calendar, a card's back
* views what the views share: q, gt, b
* global spaces, t, T, C, S, :, ?
* global spaces, t, T, C, S, :, ?, ,
*
* The `[y/N]` prompt sits over all of it, in App.svelte, and the command
* line keeps its own keys while it has them.
@@ -21,7 +21,7 @@ import { bindings, type Binding, type Context } from './keymap'
import { workspace } from './store.svelte'
/** The sheets with keys of their own, under the same name as the sheet. */
const sheets = new Set<string>(['help', 'overview', 'trash'])
const sheets = new Set<string>(['help', 'overview', 'trash', 'settings'])
/** The stack, the topmost first. */
export function stack(): Context[] {
+3 -3
View File
@@ -34,7 +34,7 @@ export type Pane = 'list' | 'document'
export type TileKind = 'node' | 'board' | 'agenda' | 'calendar' | 'card'
export type View = Exclude<TileKind, 'node'>
/** What floats over the tiles; one at a time. */
export type Sheet = 'help' | 'overview' | 'actions' | 'verify' | 'info' | 'trash'
export type Sheet = 'help' | 'overview' | 'actions' | 'verify' | 'info' | 'trash' | 'settings'
/** The info sheet's parts: what the open document holds, and what is around it. */
export type InfoSection = 'outline' | 'history' | 'backlinks' | 'cards' | 'files' | 'share'
export type Flash = { text: string; kind: 'info' | 'error' } | null
@@ -1403,7 +1403,7 @@ class Workspace {
/**
* Delete a space and everything in it. The app has to land somewhere
* afterwards, so it falls into the next space — or, if that was the last
* one, into an empty workspace that `:space! <name>` fills again.
* one, into an empty workspace that `:space new <name>` fills again.
*/
async deleteSpace(id: number) {
const { removed } = await api.deleteNode(id)
@@ -2384,7 +2384,7 @@ class Workspace {
return { url, sealed }
}
/** `:link`: a new link for a shared document, copied; the old one stops working. */
/** `:share new`: a new link for a shared document, copied; the old one stops working. */
async renewShareLink(id: number) {
const { url, sealed } = await this.mintShareLink(id)
this.say(`new link → ${url} (copied) — the previous one is dead${sealedNote(sealed)}`, sealed > 0 ? 'error' : undefined)