Files
narl-workspace/web/src/lib/commands.ts
T
nvrl 650fc40fa7 keys: Ctrl moves between tiles, Ctrl-Q jots from anywhere, the plan gets a screen of its own
Alt never reached the app, so the tile keys are Ctrl-h/j/k/l and
Ctrl-Shift-h/j/k/l, taken before vim sees them, and h/l walk the tiles
while reading. Ctrl-Q lowers the scratchpad from the editor straight
into the last note, and raises it back to the document, still writing.
The drawer sits in the porthole's opening with a full rim. The board,
the agenda, the calendar and a card's back live on a plan screen that
S goes to and comes back from, so documents and views never share the
screen. The calendar fits its tile instead of scrolling.
2026-10-22 16:51:50 +02:00

1569 lines
64 KiB
TypeScript

import { tick } from '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'
export type { ScrollAmount, SidewaysAmount }
/** Filled in by App.svelte so ex-commands can reach the DOM-bound bits. */
export const hooks = {
pickFiles: () => {},
/** Files for the folder being browsed, rather than for the document. */
pickTreeFiles: (_encrypt: boolean) => {},
pickFolder: () => {},
/** `root`: into the workspace itself, where each directory is a space. */
pickArchive: (_root: boolean) => {},
focusEditor: () => {},
/** Filled in while a document is being written: into its title field. */
focusTitle: () => {},
focusList: () => {},
insertText: (_text: string) => {},
/* Filled in while a document is on screen and not being edited: the reading
pane owns the scrollback, so the list keys have somewhere to send it. */
scrollReader: (_amount: ScrollAmount): boolean => false,
/* `zh` / `zl`: the wide table or code block at the top of the view,
sideways; false when nothing on screen is wider than the column. */
scrollWide: (_amount: SidewaysAmount): boolean => false,
/* `F`: what holds the links and buttons on screen, null when nothing does. */
hintRoot: (): HTMLElement | null => null,
/* Filled in by the file pane: what Enter does there, false when nothing. */
fileAction: (): boolean => false,
/* `:source`: a markdown file between rendered and raw; false when the pane
shows no markdown. */
toggleSource: (): boolean => false,
/* `]]` / `[[` in whatever is being read; false when it has no headings. */
jumpHeading: (_direction: 1 | -1): boolean => false,
/* `gO`: the headings of what is on screen, read or being written, and the
one the reader or the cursor is in. */
headings: (): { headings: Heading[]; current: number } => ({ headings: [], current: 0 }),
/** How many space cards the overview fits in a row, for j/k to step by. */
overviewColumns: (): number => 1,
openOverview: () => {},
openCommandLine: (_initial: string) => {},
/** Ctrl-P: a search across every space. */
openGlobalSearch: () => {},
/** Esc with the line open but the keyboard elsewhere: the line away; false when it is not open. */
closeCommandLine: (): boolean => false,
/** `F`: letters over the links and buttons inside `root`. */
showHints: (_root: HTMLElement) => {},
/** The board on screen, for `F` over its cards. */
boardRoot: (): HTMLElement | null => null,
}
/** Help groups the command list under these headings, in this order. */
export const groups = ['tree', 'board', 'documents', 'tiles', 'editing', 'sharing', 'vault', 'view'] as const
export type Group = (typeof groups)[number]
export interface CommandSpec {
name: string
aliases?: string[]
args?: string
group: Group
help: string
run: (arg: string) => unknown
/** What the argument can be, for Tab on the command line. */
complete?: (arg: string) => string[]
/**
* 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. */
const closeFocused = () => {
workspace.tiles.close()
return workspace.tiles.focused
}
/**
* The info sheet over the open document, at `section` or where it was last
* left. Every part is read now, so each one's count is in its tab: the
* headings from the pane, the versions and backlinks from the server, and
* the cards with the document.
*/
export async function openInfo(section?: InfoSection) {
if (!requireOpen()) return
const { headings, current } = hooks.headings()
workspace.sheets.outline = headings
workspace.sheets.outlineCursor = current
await workspace.guard(async () => {
// One after the other: each writes what is typed first, and two saves
// of the same text at once would race.
await workspace.sheets.loadHistory()
await workspace.sheets.loadBacklinks()
// Picked afresh each time, from the first: the open card leads.
workspace.sheets.cardsCursor = 0
workspace.sheets.filesCursor = 0
if (section) workspace.sheets.infoSection = section
workspace.sheets.open('info')
})
}
/** A document is open and in focus — being written or read — rather than the list. */
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. */
const onBoard = () => workspace.focus === 'board' || workspace.focus === 'card'
const requireOpen = () => {
if (!workspace.open) {
workspace.say('no document open', 'error')
return null
}
return workspace.open
}
/** A card about `node`, titled after it unless named. */
const cardAbout = (node: NodeSummary, title: string) =>
workspace.guard(() => workspace.cards.createCard(title.trim() || node.name.trim() || node.slug, node.id))
const TITLE_HREF = titleHref('')
/**
* Where a link in a document goes. `/n/` links are the workspace's own — the
* importer writes them between documents — and are opened in place, as is a
* `[[title]]`, or the offer to write it when no document has it; anything
* else would take the workspace's tab with it, so it gets a tab of its own,
* as does a workspace link asked for with `gx`.
*/
export function followLink(href: string, tab = false) {
if (href.startsWith(TITLE_HREF)) {
const title = decodeURIComponent(href.slice(TITLE_HREF.length))
const path = workspace.open?.wikilinks[title]
if (tab) {
if (!path) return workspace.say(`no document called "${title}" yet`, 'error')
href = `/n/${path}`
} else return void workspace.guard(() => workspace.tree.followTitle(title))
}
if (href.startsWith('/n/') && !tab) {
const path = href.slice(3).split('#')[0]
return void workspace.guard(() => workspace.tree.openPath(decodeURIComponent(path)))
}
window.open(new URL(href, location.href).href, '_blank', 'noopener,noreferrer')
}
/**
* Focus a tile and hand it the keys: its editor while it is being written,
* otherwise the shell, whose keys then act on that tile.
*/
export async function focusTile(index: number) {
if (!workspace.tiles.all[index]) return
workspace.tiles.focusOn(index)
// The tile's editor fills the hooks once it has seen it has the focus.
await tick()
if (workspace.editing && workspace.pane === 'document') hooks.focusEditor()
else hooks.focusList()
}
/** `gt` / `gT`, `:bn` / `:bp`: the next or previous tile, round and round. */
export function cycleTile(delta: 1 | -1) {
const count = workspace.tiles.all.length
if (count < 2) return workspace.say('only one tile — L on a row opens another', 'error')
return focusTile((workspace.tiles.focused + delta + count) % count)
}
/** `:ls`'s line for one tile: number, `%` for the focused one, `[+]` unsaved. */
const describeTile = (index: number) => {
const tile = workspace.tiles.all[index]
const name = tile.kind !== 'node' ? tile.kind : tile.open ? tile.open.name.trim() || tile.open.slug : '(empty)'
return `${index + 1}${index === workspace.tiles.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.tree.spaces.find((s) => s.slug === wanted || s.name.toLowerCase() === wanted) : workspace.tree.space
if (!space) return workspace.say(`no such space: ${wanted || '(none open)'}`, 'error')
if (!(await workspace.prompts.ask(`delete the space "${space.name}" and everything in it? it goes to the trash.`))) return
await workspace.guard(() => workspace.tree.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.tree.rows === 0) return workspace.say('nothing here', 'error')
workspace.tree.cursor = Math.min(Math.max(index, 0), workspace.tree.rows - 1)
await workspace.guard(() => workspace.tree.openSelected())
}
/** A folder among the children currently listed, by slug or by name. */
const findFolder = (target: string) => {
const wanted = target.toLowerCase()
return workspace.tree.nodes.find(
(node) =>
isContainer(node) && (node.slug === wanted || node.name.toLowerCase() === wanted),
)
}
/** The folders listed here, by slug, then the two ways out — for `:cd` and `:mv`. */
const folderNames = () => [
...workspace.tree.nodes.filter(isContainer).map((node) => node.slug),
'..',
'/',
]
const spaceNames = () => workspace.tree.spaces.map((space) => space.slug)
/** The capture tokens' names, for Tab after `:token rm`; null until fetched. */
let tokenNames: string[] | null = null
/*
* The titles of the documents a search finds for what is typed after
* `:append`, for Tab — fetched as the typing goes, so by the time Tab is
* pressed they are usually there.
*/
let titlesFor = ''
let titles: string[] = []
function documentTitles(arg: string): string[] {
const wanted = arg.trim()
if (wanted && wanted !== titlesFor) {
titlesFor = wanted
void api.listNodes({ q: wanted }).then(
(found) => {
if (titlesFor !== wanted) return
titles = found.filter((node) => node.kind === 'document').map((node) => node.name.trim() || node.slug)
},
() => {},
)
}
return titles
}
/** The document in a tile, the focused one first: what a note goes to the end of unless told otherwise. */
function documentOnScreen(): string | undefined {
const tile = [workspace.tile, ...workspace.tiles.all].find((t) => t.open?.kind === 'document')
return tile?.open ? `/n/${tile.open.path.map((crumb) => crumb.slug).join('/')}` : undefined
}
/** The templates of the space last asked about, for `:new` to complete. */
let templateCache: { space: number; names: string[] } | null = null
/** Tab cannot wait for the server: the names come on the press after the first. */
function templateNames(): string[] {
const here = workspace.tree.spaceId
if (here === null) return []
if (templateCache?.space !== here) {
templateCache = { space: here, names: [] }
void api.templates(here).then(
(templates) => (templateCache = { space: here, names: templates.map((t) => t.slug) }),
() => (templateCache = null),
)
}
return templateCache.names
}
/** `:encrypt` and `:decrypt`: a file, or everything under a folder. */
const sealTarget = (encrypt: boolean) =>
workspace.guard(async () => {
const node = workspace.target
if (!node || node.kind === 'document') {
return workspace.say('only files go into the vault — put the cursor on a file or a folder', 'error')
}
if (node.file && node.file.encrypted === encrypt) {
return workspace.say(encrypt ? 'already in the vault' : 'not in the vault')
}
if (!encrypt && workspace.tree.parentSealed) {
return workspace.say(`this ${node.kind} is inside a sealed folder — decrypt that folder instead`, 'error')
}
const name = node.name || node.slug
if (isContainer(node)) {
const question = encrypt
? `seal ${node.kind} "${name}" — every file in it now, and every file added later? documents stay readable`
: `unseal ${node.kind} "${name}" and take every file in it out of the vault?`
if (!(await workspace.prompts.ask(question))) return
} else if (!encrypt) {
if (!(await workspace.prompts.ask(`store "${name}" unencrypted again, and in the readable files/ view on proton drive?`))) return
}
if (!(await workspace.vault.ensureUnlocked())) return
workspace.say(encrypt ? 'encrypting …' : 'decrypting …')
await workspace.vault.seal(node.id, encrypt)
})
/** `3 minutes ago`, or `never`. Backups are read at a glance, not to the second. */
const ago = (at: number | null) => {
if (!at) return 'never'
const seconds = Math.max(0, Math.floor(Date.now() / 1000) - at)
if (seconds < 90) return `${seconds}s ago`
if (seconds < 5400) return `${Math.round(seconds / 60)}m ago`
if (seconds < 172800) return `${Math.round(seconds / 3600)}h ago`
return `${Math.round(seconds / 86400)}d ago`
}
/** "firefox on linux", out of a user-agent string, as far as it says. */
export function browserOf(agent: string): string {
const browser = /Edg\//.test(agent)
? 'edge'
: /Firefox\//.test(agent)
? 'firefox'
: /Chrome\//.test(agent)
? 'chrome'
: /Safari\//.test(agent)
? 'safari'
: /curl\//.test(agent)
? 'curl'
: 'a browser'
const system = /Android/.test(agent)
? 'android'
: /iPhone|iPad/.test(agent)
? 'ios'
: /Mac OS X/.test(agent)
? 'macos'
: /Windows/.test(agent)
? 'windows'
: /Linux/.test(agent)
? 'linux'
: ''
return system ? `${browser} on ${system}` : browser
}
/** 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'
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.status
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.prompts.askSecret('new vault password, 10+ characters — it cannot be recovered')
if (password === null) return
const again = await workspace.prompts.askSecret('the same again')
if (again === null) return
if (again !== password) return workspace.say('the two passwords differ — nothing was set', 'error')
workspace.vault.status = 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.prompts.askSecret('current vault password')
if (current === null) return
const password = await workspace.prompts.askSecret('new vault password, 10+ characters')
if (password === null) return
const again = await workspace.prompts.askSecret('the same again')
if (again === null) return
if (again !== password) return workspace.say('the two passwords differ — nothing was changed', 'error')
workspace.vault.status = await api.changeVaultPassword(current, password)
workspace.say(
workspace.vault.status.rekey
? `vault password changed — re-sealing ${workspace.vault.status.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}`)
}
/**
* Log the mirror in to Proton: the disclosure Proton asks for, then the
* password, then whatever the account asks for next — a two-factor code, or
* Proton's human check in a sheet of its own.
*/
async function protonLogin(username: string) {
const status = await api.backupStatus()
const unsealed = status.sealed_session
? ''
: ' The session will be stored unsealed on the data volume — set WORKSPACE_SESSION_KEY_FILE to seal it.'
if (!(await workspace.prompts.ask(`${status.disclosure ?? ''}${unsealed} Log in as ${username}?`))) return
const password = await workspace.prompts.askSecret(`proton password for ${username}`)
if (password === null) return
workspace.say('logging in to proton …')
let answer: BackupLogin = await api.backupLogin(username, password)
for (let round = 0; answer.next !== 'done'; round++) {
if (round === 5) return workspace.say('proton keeps asking — try again later', 'error')
if (answer.next === 'totp') {
const code = await workspace.prompts.askSecret('proton two-factor code')
if (code === null) return workspace.say('proton login abandoned')
try {
answer = await api.backupTotp(code)
} catch (error) {
// A wrong code leaves the login waiting; ask again.
if (!(error instanceof ApiError) || error.status !== 400 || !/two-factor code/.test(error.message)) throw error
workspace.say(error.message, 'error')
}
} else {
const token = await workspace.prompts.askVerification(answer.url ?? '')
if (token === null) return workspace.say('proton login abandoned')
workspace.say('logging in to proton …')
answer = await api.backupCaptcha(token)
}
}
const device = answer.status.device ? ` as ${answer.status.device}` : ''
workspace.say(`proton drive connected${device} — the first backup follows shortly, :backup! runs one now`)
}
/** A filename that survives a download folder: `2026-09-05-first-light.md`. */
const exportName = (title: string, at: number) => {
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
return `${formatDay(at)}${slug ? `-${slug}` : ''}.md`
}
/*
* Out of the editor, one level down: what is waiting is written first, and
* where that fails the text stays on screen to be tried again or let go with
* `:q!`. A document `o` made and nothing was written in is not kept.
*/
async function leaveEditor() {
if (workspace.drafts.writing) return void workspace.drafts.leaveWriting()
if (await workspace.drafts.abandonIfBlank()) return hooks.focusList()
if (workspace.dirty && !(await workspace.guard(() => workspace.drafts.save()))) return
workspace.editing = false
hooks.focusList()
}
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.drafts.writing ? workspace.drafts.saveWriting() : workspace.drafts.save())),
bang: {
help: 'save even over a newer version saved somewhere else since this one was opened',
run: () => workspace.guard(() => workspace.drafts.save({ force: true })),
},
},
{
name: 'wq',
aliases: ['x'],
group: 'editing',
help: 'leave the editor with everything written, as :q does',
run: () => leaveEditor(),
bang: {
help: 'save over a newer version, as :w! does, and leave the editor',
run: async () => {
if (await workspace.guard(() => workspace.drafts.save({ force: true }))) await leaveEditor()
},
},
},
{
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.drafts.writing || workspace.editing) return leaveEditor()
// A document `o` made and nothing was written in is not kept.
if (await workspace.drafts.abandonIfBlank()) return hooks.focusList()
if (workspace.dirty) {
workspace.say('unsaved changes — :w to write, :q! to discard', 'error')
return
}
if (workspace.tiles.all.length > 1) return focusTile(closeFocused())
workspace.tiles.close()
hooks.focusList()
},
bang: {
help: 'leave the editor, letting go of what is not saved yet',
run: async () => {
if (workspace.drafts.writing) return void workspace.drafts.leaveWriting(true)
const open = workspace.open
workspace.editing = false
workspace.dirty = false
if (open) {
workspace.drafts.dropStash(open.id)
await workspace.guard(() => workspace.tree.openNode(open.id))
}
hooks.focusList()
},
},
},
{
name: 'space',
aliases: ['sp'],
args: '[name] | new <name> | name <text> | rm [name]',
group: 'tree',
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 [what, rest] = verb(arg, ['new', 'name', 'rm'])
if (what === 'new') {
if (!rest) return workspace.say('usage: :space new <name>', 'error')
return workspace.guard(() => workspace.tree.createSpace(rest))
}
if (what === 'name') {
const space = workspace.tree.space
if (!space) return workspace.say('no space open', 'error')
if (!rest) return workspace.say('usage: :space name <text>', 'error')
return workspace.guard(() => workspace.tree.renameNode(space.id, rest))
}
if (what === 'rm') return removeSpace(rest)
const wanted = rest.toLowerCase()
if (!wanted) {
return workspace.say(workspace.tree.spaces.map((space) => space.slug).join(' · ') || 'no spaces')
}
const space = workspace.tree.spaces.find(
(candidate) => candidate.slug === wanted || candidate.name.toLowerCase() === wanted,
)
if (!space) return workspace.say(`no such space: ${wanted} — :space new ${wanted} makes it`, 'error')
await workspace.guard(() => workspace.tree.enterSpace(space.id))
},
},
{
name: 'mkdir',
args: '<name>',
group: 'tree',
help: 'make a folder here',
run: async (arg) => {
if (!arg.trim()) return workspace.say('usage: :mkdir <name>', 'error')
await workspace.guard(() => workspace.tree.createFolder(arg.trim()))
},
},
{
name: 'cd',
args: '<name|..|/>',
group: 'tree',
help: 'enter a folder, .. goes up, / back to the space',
complete: folderNames,
run: async (arg) => {
const target = arg.trim()
await workspace.guard(async () => {
if (!target || target === '..') return workspace.tree.up()
if (target === '/') {
const space = workspace.tree.spaceId
return space === null ? undefined : workspace.tree.goTo(space)
}
const folder = findFolder(target)
if (!folder) return workspace.say(`no such folder here: ${target}`, 'error')
await workspace.tree.goTo(folder.id)
})
},
},
{
name: 'import',
aliases: ['imp'],
args: '[zip [/]]',
group: 'tree',
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()
},
},
{
name: 'board',
group: 'board',
help: "this space's board, on the plan screen — the last one shown when this space has none",
run: () => workspace.guard(() => workspace.tiles.show('board')),
},
{
name: 'plan',
aliases: ['special'],
group: 'board',
help: 'the plan screen: the board, the agenda and the calendar, in place of the documents — S goes there and back again; 1 … 9 on the board picks the board',
run: () => workspace.guard(() => workspace.tiles.togglePlan()),
},
{
name: 'card',
aliases: ['c'],
args: '[title]',
group: 'board',
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.cards.createCard(arg.trim()))
},
},
{
name: 'due',
args: '<when|->',
group: 'board',
help: "due date of the card in focus — on the board, its back, in the agenda, or the open document's: today, tm, fri, +3d, +2w, 10-31, 2026-10-31 — - clears",
complete: () => ['today', 'tm', 'fri', '+3d'],
run: async (arg) => {
const target = arg.trim()
if (target === '-' || target === '') return workspace.guard(() => workspace.cards.patchCard({ due_at: null }))
const at = parseDue(target)
if (at === null) return workspace.say(`not a date: ${target} — try fri, +3d or 2026-09-30`, 'error')
await workspace.guard(async () => {
if (await workspace.cards.patchCard({ due_at: at })) workspace.say(`due ${formatDay(at)}`)
})
},
},
{
name: 'repeat',
args: '<rule|->',
group: 'board',
help: 'once the card is done, a copy comes back in the inbox: daily, weekly, monthly, yearly, +3d, +2w, +6m — - stops it',
complete: () => ['daily', 'weekly', 'monthly', 'yearly', '+2w', '-'],
run: async (arg) => {
const rule = arg.trim()
const repeat = rule === '-' || rule === '' ? null : rule
await workspace.guard(async () => {
const card = await workspace.cards.patchCard({ repeat })
// The server's spelling of the rule: `+14d` is kept as `+2w`.
if (card) workspace.say(card.repeat === null ? 'repeats no more' : `repeats ${card.repeat} once done`)
})
},
},
{
name: 'done',
group: 'board',
help: 'move the card in focus into the done list, or back out to the inbox',
run: () => workspace.guard(() => workspace.cards.toggleDone()),
},
{
name: 'scratch',
aliases: ['sc'],
args: '[text]',
group: 'documents',
help: 'the scratchpad, lowered over whatever is on screen — Q does the same; with text, a note on it without leaving',
run: async (arg) => {
if (!arg.trim()) return workspace.guard(() => workspace.scratch.toggle())
await workspace.guard(() => workspace.scratch.add(arg.trim()))
},
},
{
name: 'append',
args: '[document]',
group: 'documents',
help: 'put the scratch note at the end of a document, by its title — Tab completes it — or the one on screen, and take it off the scratchpad',
complete: documentTitles,
run: async (arg) => {
if (!workspace.scratch.open) return workspace.say('no scratch note — Q lowers the scratchpad', 'error')
const target = arg.trim() || documentOnScreen()
if (!target) return workspace.say('usage: :append <document> — or open the one it goes to', 'error')
await workspace.guard(() => workspace.scratch.appendNote(target))
},
},
{
name: 'keep',
args: '[title]',
group: 'documents',
help: 'make the scratch note a document in the folder being browsed — titled by its first line unless given one — and take it off the scratchpad',
run: async (arg) => {
if (!workspace.scratch.open) return workspace.say('no scratch note — Q lowers the scratchpad', 'error')
await workspace.guard(() => workspace.scratch.keepNote(arg))
},
},
{
name: 'task',
aliases: ['step'],
args: '[add] <text> | edit <text>',
group: 'board',
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')
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.cards.renameTask(text) : workspace.cards.addTask(text)))
},
},
{
name: 'label',
args: '<name>',
group: 'board',
help: 'put a label on the card, or take it off when it has it — each name keeps its colour everywhere',
complete: () => workspace.cards.boardLabels,
run: async (arg) => {
const card = workspace.cards.focusedCard
if (!arg.trim() && card) return workspace.say(card.labels.join(' · ') || 'no labels — :label <name> adds one')
await workspace.guard(() => workspace.cards.toggleLabel(arg))
},
},
{
name: 'list',
args: '<name> | name <text> | rm',
group: '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.tiles.view('board')) return workspace.say('no board open — :board', 'error')
const [what, rest] = verb(arg, ['name', 'rm'])
const list = workspace.cards.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.cards.renameList(rest))
}
if (what === 'rm') {
if (!list) return workspace.say('no list selected', 'error')
const cards = list.cards.length
if (!(await workspace.prompts.ask(`delete the list "${list.name}" and its ${cards} card${cards === 1 ? '' : 's'}?`))) return
return workspace.guard(() => workspace.cards.deleteList())
}
if (!rest) return workspace.say('usage: :list <name>', 'error')
await workspace.guard(() => workspace.cards.createList(rest))
},
},
{
name: 'move',
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) => {
const node = workspace.tree.marked[0] ?? workspace.target
if (!node) return workspace.say('nothing selected', 'error')
const target = arg.trim()
if (!target) return workspace.say('usage: :mv <folder|..|/>', 'error')
// `..` is up from where the node is, which in a search result is not
// the folder being browsed.
const up = async () => {
if (node.parent_id === null) return undefined
const parent = await api.getNode(node.parent_id)
return parent.kind === 'space' ? undefined : (parent.parent_id ?? undefined)
}
const destination =
target === '..' ? await up() : target === '/' ? (workspace.tree.spaceId ?? undefined) : findFolder(target)?.id
if (destination === undefined) return workspace.say(`no such folder: ${target}`, 'error')
if (destination === node.id) return workspace.say('a folder cannot hold itself', 'error')
if (workspace.tree.marked.length) return workspace.guard(() => workspace.tree.moveNodes(workspace.tree.marked, destination))
await workspace.guard(() => workspace.tree.moveNode(node.id, destination))
},
},
{
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) => {
await workspace.guard(async () => {
const words = arg.trim().split(/\s+/).filter(Boolean)
const here = workspace.tree.spaceId
const templates = words.length && here !== null ? await api.templates(here) : []
if (words.length && here !== null) templateCache = { space: here, names: templates.map((t) => t.slug) }
const wanted = words[0]?.toLowerCase()
const template = templates.find((t) => t.slug === wanted || t.name.toLowerCase() === wanted)
if (template) words.shift()
let created_at: number | undefined
if (words.length && /^\d{4}-\d{2}-\d{2}$/.test(words[0])) {
const at = parseDay(words[0])
if (at === null) return workspace.say(`not a date: ${words[0]}`, 'error')
created_at = at
words.shift()
}
await workspace.tree.createDocument(words.join(' '), { created_at, template_id: template?.id })
})
hooks.focusEditor()
},
},
{
name: 'cal',
aliases: ['calendar'],
group: 'documents',
help: 'the diary by month — h/l a day, j/k a week, H/L a month, Enter opens the day or starts it',
run: () =>
workspace.guard(async () => {
if (workspace.tiles.view('calendar')) return workspace.tiles.show('calendar')
await workspace.diary.openCalendar()
}),
},
{
name: 'agenda',
aliases: ['ag'],
group: 'board',
help: 'what is late, due this week, or in hand — across every board',
run: () =>
workspace.guard(async () => {
if (!workspace.tiles.view('agenda')) workspace.cards.agendaCursor = 0
await workspace.tiles.show('agenda')
}),
},
{
name: 'today',
args: '[text]',
group: 'documents',
help: "open today's diary entry, starting one if there is none yet; with text, a timed line at its end without going there",
run: async (arg) => {
if (arg.trim()) return workspace.guard(() => workspace.diary.logToday(arg.trim()))
await workspace.guard(() => workspace.diary.openDay())
hooks.focusEditor()
},
},
{
name: 'edit',
aliases: ['e'],
args: '[id]',
group: 'editing',
help: 'edit the open document, or open a node by id',
run: async (arg) => {
const node = arg ? undefined : workspace.target
if (node?.kind === 'file') {
return workspace.say(`${node.slug} is a file — nothing to write in, :name renames it`, 'error')
}
if (node && node.kind !== 'document') {
return workspace.say(`${node.slug} is a ${node.kind} — l opens it`, 'error')
}
const id = arg ? Number(arg) : node?.id
if (!id || Number.isNaN(id)) return workspace.say('usage: :e <id>', 'error')
await workspace.guard(() => workspace.tree.openNode(id, true))
hooks.focusEditor()
},
bang: {
help: 'throw away the draft and re-read the document from the server',
run: async () => {
const open = requireOpen()
if (!open) return
workspace.dirty = false
workspace.drafts.dropStash(open.id)
await workspace.guard(() => workspace.tree.openNode(open.id, workspace.editing))
workspace.say(`${open.slug} reloaded`)
},
},
},
{
name: 'next',
group: 'documents',
help: 'open the next node down the list',
run: () => jumpTo(workspace.tree.cursor + 1),
},
{
name: 'prev',
group: 'documents',
help: 'open the previous node',
run: () => jumpTo(workspace.tree.cursor - 1),
},
{
name: 'close',
aliases: ['clo'],
group: 'tiles',
help: 'close the focused tile — q while reading; on the plan screen, back to the documents; asks when its draft could not be written',
run: () =>
workspace.guard(async () => {
await workspace.drafts.flushTile()
if (workspace.dirty) {
const name = workspace.open?.slug ?? 'the draft'
if (!(await workspace.prompts.ask(`${name} could not be written — close anyway? the draft stays in this browser`))) return
}
await focusTile(closeFocused())
}),
},
{
name: 'only',
aliases: ['on'],
group: 'tiles',
help: 'close every tile but the focused one',
run: () => workspace.guard(() => workspace.tiles.only()),
},
{
name: 'ls',
aliases: ['buffers', 'files'],
group: 'tiles',
help: 'list the open tiles, % on the focused one',
run: () => workspace.say(workspace.tiles.all.map((_, index) => describeTile(index)).join(' · ')),
},
{
name: 'buffer',
aliases: ['b'],
args: '<n|name>',
group: 'tiles',
help: 'focus a tile by its number in :ls, or by the start of its name',
complete: () => workspace.tiles.all.flatMap((tile) => (tile.kind !== 'node' ? [tile.kind] : tile.open ? [tile.open.slug] : [])),
run: (arg) => {
const wanted = arg.trim().toLowerCase()
if (!wanted) return workspace.say(describeTile(workspace.tiles.focused))
const index = /^\d+$/.test(wanted)
? Number(wanted) - 1
: workspace.tiles.all.findIndex((tile) =>
tile.kind !== 'node'
? tile.kind.startsWith(wanted)
: tile.open && (tile.open.slug.startsWith(wanted) || tile.open.name.toLowerCase().startsWith(wanted)),
)
if (!workspace.tiles.all[index]) return workspace.say(`E86: no tile ${arg.trim()} — :ls lists them`, 'error')
return focusTile(index)
},
},
{
name: 'bnext',
aliases: ['bn'],
group: 'tiles',
help: 'the next tile — gt',
run: () => cycleTile(1),
},
{
name: 'bprevious',
aliases: ['bp', 'bN'],
group: 'tiles',
help: 'the previous tile — gT',
run: () => cycleTile(-1),
},
{
name: 'first',
group: 'documents',
help: 'open the first node in the list',
run: () => jumpTo(0),
},
{
name: 'last',
group: 'documents',
help: 'open the last node in the list',
run: () => jumpTo(workspace.tree.rows - 1),
},
{
name: 'random',
group: 'documents',
help: 'open something here at random — good for re-reading',
run: () => jumpTo(Math.floor(Math.random() * workspace.tree.nodes.length)),
},
{
name: 'delete',
aliases: ['d', 'rm'],
args: '[id]',
group: 'documents',
help: 'delete a node, and everything under it, into the trash (asks first) — u brings it back',
run: async (arg) => {
// On the board the thing under the cursor is a card, not a node.
if (onBoard() && !arg) {
const card = workspace.cards.card
if (!card) return workspace.say('no card selected', 'error')
if (!(await workspace.prompts.ask(`delete the card "${card.title}"?`))) return
return workspace.guard(() => workspace.cards.deleteCard())
}
// Whatever has focus: the row under the cursor in the list, the open
// document only while the document pane is the one being looked at.
const id = arg ? Number(arg) : workspace.target?.id
if (!id || Number.isNaN(id)) return workspace.say('nothing to delete', 'error')
const node = workspace.open?.id === id ? workspace.open : workspace.tree.nodes.find((n) => n.id === id)
const name = node ? `"${node.name.trim() || node.slug}"` : `#${id}`
const what =
node && isContainer(node)
? `delete the ${node.kind} ${name} and everything in it?`
: `delete ${name}?`
if (!(await workspace.prompts.ask(`${what} it goes to the trash.`))) return
await workspace.guard(() => workspace.tree.deleteNode(id))
},
},
{
name: 'name',
aliases: ['title', 'rename'],
args: '<text>',
group: 'editing',
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.cards.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.cards.patchCard({ title: arg.trim() }))
}
if (!arg.trim()) return workspace.say('usage: :name <text>', 'error')
// The open document is renamed through the draft, so an unsaved body is
// written with the new name instead of being left behind by it.
const node = workspace.target
if (node && node.id === workspace.open?.id) {
workspace.draft.name = arg
workspace.dirty = true
return workspace.guard(() => workspace.drafts.save())
}
if (!node) return workspace.say('nothing selected — :space name renames the space', 'error')
await workspace.guard(() => workspace.tree.renameNode(node.id, arg))
},
},
{
name: 'history',
aliases: ['hist'],
group: 'documents',
help: "the open document's earlier versions as diffs — j/k, Enter brings one back (gh)",
run: async () => {
await openInfo('history')
},
},
{
name: 'backlinks',
aliases: ['bl'],
group: 'documents',
help: 'the documents that link to the open one — j/k, Enter opens one (gb)',
run: async () => {
await openInfo('backlinks')
},
},
{
name: 'cards',
group: 'documents',
help: 'the cards about the open document — its own, and the ones whose summary links here: j/k picks the one :due, :label, Space and H/L act on, Enter turns it over on its board (gc)',
run: () => openInfo('cards'),
},
{
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) => {
if (arg.trim() === 'purge') return emptyTrash()
if (arg.trim()) return workspace.say('usage: :trash [purge]', 'error')
await workspace.guard(async () => {
await workspace.sheets.loadTrash()
workspace.sheets.trashCursor = 0
workspace.sheets.open('trash')
})
},
},
{
name: 'date',
args: '<yyyy-mm-dd>',
group: 'editing',
help: 're-date the open document',
run: async (arg) => {
if (!requireOpen()) return
const at = parseDay(arg)
if (at === null) return workspace.say('usage: :date 2026-09-05', 'error')
workspace.draft.created_at = at
workspace.dirty = true
await workspace.guard(() => workspace.drafts.save())
},
},
{
name: 'share',
args: '[off|new]',
group: 'sharing',
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
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.tree.toggleShare(node.id))
},
},
{
name: 'copy',
aliases: ['yank'],
group: 'sharing',
help: 'copy the whole document to the clipboard as markdown',
run: async () => {
if (!requireOpen()) return
const { name, body } = workspace.draft
const text = name.trim() ? `# ${name.trim()}\n\n${body}` : body
workspace.say((await workspace.copy(text)) ? 'document copied' : 'the clipboard said no', 'info')
},
},
{
name: 'export',
aliases: ['exp'],
group: 'sharing',
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.vault.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 () => {
// A plain navigation, so the browser streams the archive straight to
// disk instead of the tab holding all of it in memory first.
await workspace.drafts.flush()
// Sealed files go into the archive opened, which needs the vault:
// asked here, because a refusal after the navigation would replace
// the whole app with an error page.
await workspace.vault.load()
if ((workspace.vault.status?.sealed ?? 0) > 0 && !(await workspace.vault.ensureUnlocked())) {
return workspace.say('the export holds sealed files — it needs the vault open', 'error')
}
workspace.say('building the archive — the download starts on its own')
location.href = '/api/export'
},
},
},
{
name: 'source',
group: 'documents',
help: 'show an open markdown file as written, or rendered again',
run: () => {
if (!hooks.toggleSource()) workspace.say('only an open markdown file has a source to show', 'error')
},
},
{
name: 'upload',
aliases: ['up'],
args: '[seal]',
group: 'editing',
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.vault.ensureUnlocked()) hooks.pickTreeFiles(true)
})
}
if (what) return workspace.say('usage: :upload [seal]', 'error')
return readingDocument() ? hooks.pickFiles() : hooks.pickTreeFiles(false)
},
},
{
name: 'outline',
aliases: ['toc'],
group: 'documents',
help: "the open document's headings to pick from — gO, in the editor too",
run: () => openInfo('outline'),
},
{
name: 'info',
args: '[outline|history|backlinks|cards|files|share]',
group: 'documents',
help: 'what is known about the open document: its headings, earlier versions, backlinks, cards, files and sharing — Tab between them (gi)',
complete: () => infoSections,
run: (arg) => {
const section = arg.trim()
if (section && !infoSections.includes(section as InfoSection)) return workspace.say('usage: :info [outline|history|backlinks|cards|files|share]', 'error')
return openInfo((section || undefined) as InfoSection | undefined)
},
},
{
name: 'search',
aliases: ['se'],
args: '[text]',
group: 'documents',
help: 'full-text search in this space (empty clears)',
run: (arg) => workspace.guard(() => workspace.tree.search(arg, false)),
bang: {
help: 'full-text search across every space — Ctrl-P',
run: (arg) => workspace.guard(() => workspace.tree.search(arg, true)),
},
},
{
name: 'noh',
aliases: ['nohlsearch'],
group: 'documents',
help: 'clear the search filter',
run: () => workspace.guard(() => workspace.tree.search('')),
},
{
name: 'reload',
aliases: ['r'],
group: 'documents',
help: 're-read the current folder from the server',
run: () =>
workspace.guard(async () => {
await workspace.tree.refresh()
workspace.say(`${workspace.tree.nodes.length} here`)
}),
},
{
name: 'stats',
group: 'view',
help: 'word, line and node counts',
run: () => {
const body = workspace.open ? workspace.draft.body : ''
const words = body.split(/\s+/).filter(Boolean).length
const open = workspace.open ? `${workspace.open.slug} ${words}w ${body.split('\n').length}L · ` : ''
workspace.say(
`${open}${workspace.tree.here || '/'} · ${workspace.tree.nodes.length} node${workspace.tree.nodes.length === 1 ? '' : 's'}${
workspace.tree.query ? ` matching "${workspace.tree.query}"` : ''
}`,
)
},
},
{
name: 'set',
args: '[option]',
group: 'view',
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')
return workspace.setClipboard(true), workspace.say('yanks go to the system clipboard')
if (option === 'noclipboard')
return workspace.setClipboard(false), workspace.say('yanks stay in vim registers')
// A board is a property of the space, not of this browser, so unlike the
// options above this one goes to the server.
if (option === 'board') return workspace.guard(() => workspace.cards.setBoard(true))
if (option === 'noboard') return workspace.guard(() => workspace.cards.setBoard(false))
workspace.say(`unknown option: ${option}`, 'error')
},
},
{
name: 'backup',
group: 'view',
args: '[login <proton-username> | logout]',
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 openSettings('backup')
const loginMatch = /^login\s+(\S+)$/.exec(trimmed)
if (loginMatch) return protonLogin(loginMatch[1])
if (trimmed === 'logout') {
if (!(await workspace.prompts.ask('Forget the Proton Drive session? What is mirrored stays on Drive.'))) return
await api.backupLogout()
return workspace.say('proton drive session forgotten — backups are off')
}
workspace.say('usage: :backup [login <proton-username> | logout]', 'error')
}),
bang: {
help: 'back up to proton drive now, rather than when the workspace falls quiet',
run: () =>
workspace.guard(async () => {
// Whatever is in the editor belongs in the backup that was just
// asked for, so it goes to the server before the mirror runs.
await workspace.drafts.flush()
workspace.say('backing up …')
const status = await api.backupNow()
if (status.last_error) return workspace.say(`backup failed: ${status.last_error}`, 'error')
const last = status.last
workspace.say(
last
? `backed up · ${last.uploaded} file${last.uploaded === 1 ? '' : 's'} uploaded, ${last.skipped} unchanged`
: 'proton drive backups are off — :backup login <proton-username> sets them up',
last ? 'info' : 'error',
)
}),
},
},
{
name: 'sessions',
args: '[rm <n>]',
group: 'view',
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()
const rmMatch = /^rm\s+(\d+)$/.exec(trimmed)
if (!rmMatch) return workspace.say('usage: :sessions [rm <n>]', 'error')
const target = sessions[Number(rmMatch[1]) - 1]
if (!target) return workspace.say(`no session ${rmMatch[1]} — :sessions lists them`, 'error')
if (target.current) return workspace.say('that is this browser — :logout ends it', 'error')
await api.revokeSession(target.id)
await workspace.vault.load()
workspace.say(`signed out ${browserOf(target.user_agent)}, seen ${relative(target.last_seen)} — the vault is locked`)
}),
},
{
name: 'sshkey',
args: '[add <name> <key> | rm <name> | vault <name> on|off]',
group: 'view',
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') return openSettings('keys')
const addMatch = /^add\s+(\S+)\s+(.+)$/.exec(trimmed)
if (addMatch) {
const [, name, key] = addMatch
const added = await api.addSshKey(name, key)
return workspace.say(`added ${added.name} (${added.fingerprint})`)
}
const rmMatch = /^rm\s+(\S+)$/.exec(trimmed)
if (rmMatch) {
const keys = await api.listSshKeys()
const target = keys.find((k) => k.name === rmMatch[1])
if (!target) return workspace.say(`no key named ${rmMatch[1]}`, 'error')
await api.removeSshKey(target.id)
return workspace.say(`removed ${target.name}`)
}
// A key reads sealed files over the mount only once given vault access.
const vaultMatch = /^vault\s+(\S+)\s+(on|off)$/.exec(trimmed)
if (vaultMatch) {
const keys = await api.listSshKeys()
const target = keys.find((k) => k.name === vaultMatch[1])
if (!target) return workspace.say(`no key named ${vaultMatch[1]}`, 'error')
const on = vaultMatch[2] === 'on'
await api.setSshKeyVault(target.id, on)
return workspace.say(
on
? `${target.name} reads sealed files over the mount while the vault is open`
: `${target.name} no longer reads sealed files`,
)
}
workspace.say('usage: :sshkey [add <name> <key> | rm <name> | vault <name> on|off]', 'error')
}),
},
{
name: 'token',
args: '[add <name> | rm <name>]',
group: 'view',
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.
if (tokenNames === null) {
tokenNames = []
void api.listTokens().then((tokens) => (tokenNames = tokens.map((t) => t.name)), () => (tokenNames = null))
}
return arg.startsWith('rm ') ? tokenNames.map((name) => `rm ${name}`) : ['add ', 'rm ']
},
run: (arg) =>
workspace.guard(async () => {
const trimmed = arg.trim()
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())
tokenNames = null
const copied = await workspace.copy(added.token)
return workspace.say(
`token ${added.name}: ${added.token} — ${copied ? 'copied; ' : ''}shown this once, the server keeps only its hash`,
'info',
true,
)
}
const rmMatch = /^rm\s+(.+)$/.exec(trimmed)
if (rmMatch) {
const tokens = await api.listTokens()
const target = tokens.find((t) => t.name === rmMatch[1].trim())
if (!target) return workspace.say(`no token named ${rmMatch[1].trim()}`, 'error')
await api.removeToken(target.id)
tokenNames = null
return workspace.say(`removed ${target.name} — it captures nothing from now on`)
}
workspace.say('usage: :token [add <name> | rm <name>]', 'error')
}),
},
{
name: 'vault',
args: '[unlock|lock|passwd]',
group: 'vault',
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.vault.load()
if (what === 'unlock') {
if (workspace.vault.status?.unlocked) return workspace.say('the vault is already open')
return workspace.vault.ensureUnlocked()
}
if (what === 'lock') {
workspace.vault.status = 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.status?.configured) return setupVault()
await openSettings('vault')
}),
},
{
name: 'encrypt',
aliases: ['seal'],
group: 'vault',
help: 'seal the file under the cursor in the vault — a folder stays sealed: every file in it, and every file added later',
run: () => sealTarget(true),
},
{
name: 'decrypt',
aliases: ['unseal'],
group: 'vault',
help: 'take the file under the cursor back out of the vault — on a sealed folder, unseal it and every file inside',
run: () => sealTarget(false),
},
{
name: 'help',
aliases: ['h'],
args: '[all]',
group: 'view',
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.sheets.shown === 'help' && !all) return workspace.sheets.close()
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)
},
},
{
name: 'logout',
group: 'view',
help: 'end the session',
run: () => workspace.guard(() => workspace.logout()),
bang: {
help: 'end every session, on every device — for a lost phone or a shared computer',
run: () => workspace.guard(() => workspace.logout(true)),
},
},
]
/**
* 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.tree.unmarkAll(),
}
const lookup = new Map<string, CommandSpec>()
for (const spec of commands) {
lookup.set(spec.name, spec)
for (const alias of spec.aliases ?? []) lookup.set(alias, spec)
}
/** Run an ex-command line such as `w`, `date 2026-01-01` or `set novim`. */
export async function runCommand(line: string): Promise<void> {
const input = line.trim().replace(/^:/, '')
if (!input) return
if (input.startsWith('/')) {
await workspace.guard(() => workspace.tree.search(input.slice(1)))
return
}
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.tree.openNode(Number(head)))
return
}
// `:q!`, `:quit!` — the bang is part of the command name, not an argument.
const forced = head.endsWith('!')
const spec = lookup.get(forced ? head.slice(0, -1) : head)
if (!spec) {
workspace.say(`E492: not an editor command: ${head}`, 'error')
return
}
if (forced) {
if (!spec.bang) return workspace.say(`E477: no ! allowed: ${head}`, 'error')
await spec.bang.run(arg)
} else await spec.run(arg)
// What a line changed shows in the settings sheet it was typed over.
if (workspace.sheets.shown === 'settings') await workspace.guard(() => loadSettings())
}
/**
* What Tab offers for the argument of `line`, a command name and the start of
* an argument: whatever the command's `complete` lists that begins that way.
* Null when the name is not finished yet, or names no command.
*/
export function argCompletions(line: string): string[] | null {
const match = /^:?(\S+)\s(.*)$/.exec(line)
if (!match) return null
const [, head, arg] = match
if (head.endsWith('!')) return []
const spec = lookup.get(head)
if (!spec) return null
const wanted = arg.toLowerCase()
return (spec.complete?.(arg) ?? []).filter((candidate) => candidate.toLowerCase().startsWith(wanted) && candidate !== arg)
}
/** Command names for the `:` completion menu. */
export function completions(prefix: string): CommandSpec[] {
const head = prefix.trim().replace(/^:/, '').split(/\s+/)[0] ?? ''
if (!head) return commands
return commands.filter(
(spec) => spec.name.startsWith(head) || spec.aliases?.some((a) => a.startsWith(head)),
)
}