mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
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:
1 parent
d8e72c688d
commit
7f8d625a8d
34 files changed
+799
-457
No files matched your search
+4
-2
@@ -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. */
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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],
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 ?? []
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user