mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
POST /api/capture takes a line of text and puts it on a card in an inbox, or at the end of today's diary entry, starting the entry when there is none yet. It answers a logged-in browser, and also a request that carries an Authorization: Bearer token from :token add, so a phone shortcut, a script or curl can capture with no tab open. A token is nwc_ and 32 random bytes. It is shown once, copied, and stored only as its SHA-256, the way a share key is. Only the capture route reads the header. The Session extractor never does, so a token opens nothing else. Wrong tokens go through the login's backoff on a throttle of their own. A removed token is refused from the next request on, because each capture looks the hash up again. A card lands where :card puts one outside the board: the inbox of the space named by "space", or else of the first space with a board. That rule now lives server-side as boards::inbox_of. The first line of the text becomes the title and the rest becomes the notes. Diary text becomes "- HH:MM text" in WORKSPACE_TIMEZONE, appended to the first document written today at the top of the diary. That is the entry :today opens. The append keeps a revision and indexes links like any other save. :token lists, adds and removes tokens, and completes their names after rm. The message that shows a new token stays in the status bar instead of fading, because it cannot be shown again.
1395 lines
53 KiB
TypeScript
1395 lines
53 KiB
TypeScript
import { tick } from 'svelte'
|
|
import { api, ApiError, isContainer, type BackupLogin, type BackupStatus } from './api'
|
|
import { workspace } from './store.svelte'
|
|
import { emptyTrash } from './Trash.svelte'
|
|
import { formatDay, parseDay, parseDue, relative, sealedNote, 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: () => {},
|
|
}
|
|
|
|
/** 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) => void | Promise<void>
|
|
/** 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. */
|
|
bang?: { help: string; run: (arg: string) => void | Promise<void> }
|
|
}
|
|
|
|
/** Close the focused tile; the index of the one that has the focus after. */
|
|
const closeFocused = () => {
|
|
workspace.closeTile()
|
|
return workspace.focused
|
|
}
|
|
|
|
const requireOpen = () => {
|
|
if (!workspace.open) {
|
|
workspace.say('no document open', 'error')
|
|
return null
|
|
}
|
|
return workspace.open
|
|
}
|
|
|
|
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.followTitle(title))
|
|
}
|
|
if (href.startsWith('/n/') && !tab) {
|
|
const path = href.slice(3).split('#')[0]
|
|
return void workspace.guard(() => workspace.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[index]) return
|
|
workspace.focusTile(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.length
|
|
if (count < 2) return workspace.say('only one tile — L on a row opens another', 'error')
|
|
return focusTile((workspace.focused + delta + count) % count)
|
|
}
|
|
|
|
/** `:ls`'s line for one tile: number, `%` for the focused one, `[+]` unsaved. */
|
|
const describeTile = (index: number) => {
|
|
const tile = workspace.tiles[index]
|
|
const name = tile.open ? tile.open.name.trim() || tile.open.slug : '(empty)'
|
|
return `${index + 1}${index === workspace.focused ? '%' : ''} ${name}${tile.dirty ? ' [+]' : ''}`
|
|
}
|
|
|
|
/** Move the cursor and follow it, so `:next` reads as well as `j` then `Enter`. */
|
|
const jumpTo = async (index: number) => {
|
|
if (workspace.nodes.length === 0) return workspace.say('nothing here', 'error')
|
|
workspace.cursor = Math.min(Math.max(index, 0), workspace.nodes.length - 1)
|
|
await workspace.guard(() => workspace.openSelected())
|
|
}
|
|
|
|
/** A folder among the children currently listed, by slug or by name. */
|
|
const findFolder = (target: string) => {
|
|
const wanted = target.toLowerCase()
|
|
return workspace.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.nodes.filter(isContainer).map((node) => node.slug),
|
|
'..',
|
|
'/',
|
|
]
|
|
|
|
const spaceNames = () => workspace.spaces.map((space) => space.slug)
|
|
|
|
/** The capture tokens' names, for Tab after `:token rm`; null until fetched. */
|
|
let tokenNames: string[] | null = null
|
|
|
|
/** `: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.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.ask(question))) return
|
|
} else if (!encrypt) {
|
|
if (!(await workspace.ask(`store "${name}" unencrypted again, and in the readable files/ view on proton drive?`))) return
|
|
}
|
|
if (!(await workspace.ensureUnlocked())) return
|
|
workspace.say(encrypt ? 'encrypting …' : 'decrypting …')
|
|
await workspace.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. */
|
|
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
|
|
}
|
|
|
|
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')
|
|
}
|
|
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)}`)
|
|
}
|
|
|
|
/**
|
|
* 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.ask(`${status.disclosure ?? ''}${unsealed} Log in as ${username}?`))) return
|
|
const password = await workspace.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.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.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`
|
|
}
|
|
|
|
export const commands: CommandSpec[] = [
|
|
{
|
|
name: 'write',
|
|
aliases: ['w'],
|
|
group: 'editing',
|
|
help: 'save the open document',
|
|
run: () => workspace.guard(() => workspace.save()),
|
|
bang: {
|
|
help: 'save even over a newer version saved somewhere else since this one was opened',
|
|
run: () => workspace.guard(() => workspace.save({ force: true })),
|
|
},
|
|
},
|
|
{
|
|
name: 'wq',
|
|
aliases: ['x'],
|
|
group: 'editing',
|
|
help: 'save and leave insert/editor mode',
|
|
run: async () => {
|
|
await workspace.guard(() => workspace.save())
|
|
workspace.editing = false
|
|
hooks.focusList()
|
|
},
|
|
bang: {
|
|
help: 'save over a newer version, as :w! does, and leave the editor',
|
|
run: async () => {
|
|
await workspace.guard(() => workspace.save({ force: true }))
|
|
workspace.editing = false
|
|
hooks.focusList()
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: 'quit',
|
|
aliases: ['q'],
|
|
group: 'editing',
|
|
help: 'close the editor, or the document when already reading',
|
|
run: async () => {
|
|
// A document `o` made and nothing was written in is not kept.
|
|
if (await workspace.abandonIfBlank()) {
|
|
if (workspace.returnTo === 'board') await workspace.guard(() => workspace.returnToBoard())
|
|
return hooks.focusList()
|
|
}
|
|
if (workspace.dirty) {
|
|
workspace.say('unsaved changes — :w to write, :q! to discard', 'error')
|
|
return
|
|
}
|
|
if (workspace.editing) workspace.editing = false
|
|
else if (workspace.returnTo === 'board') return void workspace.guard(() => workspace.returnToBoard())
|
|
else if (workspace.tiles.length > 1) return focusTile(closeFocused())
|
|
else workspace.closeTile()
|
|
hooks.focusList()
|
|
},
|
|
bang: {
|
|
help: 'discard changes and close the editor',
|
|
run: async () => {
|
|
const open = workspace.open
|
|
workspace.editing = false
|
|
workspace.dirty = false
|
|
if (open) {
|
|
workspace.dropStash(open.id)
|
|
await workspace.guard(() => workspace.openNode(open.id))
|
|
}
|
|
hooks.focusList()
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: 'space',
|
|
aliases: ['sp'],
|
|
args: '[name]',
|
|
group: 'tree',
|
|
help: 'switch to a space, or list them',
|
|
complete: spaceNames,
|
|
run: async (arg) => {
|
|
const wanted = arg.trim().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')
|
|
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',
|
|
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.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.up()
|
|
if (target === '/') {
|
|
const space = workspace.spaceId
|
|
return space === null ? undefined : workspace.goTo(space)
|
|
}
|
|
const folder = findFolder(target)
|
|
if (!folder) return workspace.say(`no such folder here: ${target}`, 'error')
|
|
await workspace.goTo(folder.id)
|
|
})
|
|
},
|
|
},
|
|
{
|
|
name: 'import',
|
|
aliases: ['imp'],
|
|
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() === '/'),
|
|
},
|
|
},
|
|
{
|
|
name: 'board',
|
|
group: 'board',
|
|
help: "show this space's board, or go back to the tree",
|
|
run: () => workspace.guard(() => workspace.toggleBoardView()),
|
|
},
|
|
{
|
|
name: 'card',
|
|
aliases: ['c'],
|
|
args: '<title>',
|
|
group: 'board',
|
|
help: "add a card — on the board to the list under the cursor, elsewhere to this space's inbox",
|
|
run: async (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 — Enter on it opens the document',
|
|
run: async (arg) => {
|
|
// On the board the tree is not on screen, so only the open document
|
|
// can be meant; elsewhere it is whatever has focus.
|
|
const node = workspace.view === 'board' ? workspace.open : 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',
|
|
args: '<when|->',
|
|
group: 'board',
|
|
help: 'due date of the card — on the board, or highlighted in the agenda: today, tm, fri, +3d, +2w, 10-31, 2026-10-31 — - clears',
|
|
complete: () => ['today', 'tm', 'fri', '+3d'],
|
|
run: async (arg) => {
|
|
const agenda = workspace.overlay === 'agenda'
|
|
if (workspace.view !== 'board' && !agenda) return workspace.say('no board open — :board', 'error')
|
|
const set = (due_at: number | null) =>
|
|
agenda ? workspace.dueAgendaCard(due_at) : workspace.patchCard({ due_at })
|
|
const target = arg.trim()
|
|
if (target === '-' || target === '') return workspace.guard(() => set(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(() => set(at))
|
|
workspace.say(`due ${formatDay(at)}`)
|
|
},
|
|
},
|
|
{
|
|
name: 'done',
|
|
group: 'board',
|
|
help: 'move the selected card into the done list, or back out to the inbox',
|
|
run: () => {
|
|
if (workspace.view !== 'board') return workspace.say('no board open — :board', 'error')
|
|
return workspace.guard(() => workspace.toggleDone())
|
|
},
|
|
},
|
|
{
|
|
name: 'list',
|
|
args: '<name>',
|
|
group: 'board',
|
|
help: 'add a column to the board',
|
|
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
|
|
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())
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: 'move',
|
|
aliases: ['mv'],
|
|
args: '<folder|..|/>',
|
|
group: 'tree',
|
|
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.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.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.marked.length) return workspace.guard(() => workspace.moveNodes(workspace.marked, destination))
|
|
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: '[yyyy-mm-dd]',
|
|
group: 'documents',
|
|
help: 'start a new document here, optionally dated',
|
|
run: async (arg) => {
|
|
await workspace.guard(async () => {
|
|
await workspace.createDocument()
|
|
if (arg) {
|
|
const at = parseDay(arg)
|
|
if (at === null) return workspace.say(`not a date: ${arg}`, 'error')
|
|
workspace.draft.created_at = at
|
|
workspace.dirty = true
|
|
await workspace.save()
|
|
}
|
|
})
|
|
hooks.focusEditor()
|
|
},
|
|
},
|
|
{
|
|
name: 'agenda',
|
|
aliases: ['ag'],
|
|
group: 'board',
|
|
help: 'what is late, due this week, or in hand — across every board',
|
|
run: () =>
|
|
workspace.guard(async () => {
|
|
await workspace.loadAgenda()
|
|
workspace.agendaCursor = 0
|
|
workspace.overlay = workspace.overlay === 'agenda' ? 'none' : 'agenda'
|
|
}),
|
|
},
|
|
{
|
|
name: 'today',
|
|
group: 'documents',
|
|
help: "open today's diary entry, starting one if there is none yet",
|
|
run: async () => {
|
|
await workspace.guard(async () => {
|
|
// The diary is one space among several now, so :today goes there first
|
|
// rather than writing the day into whatever folder was being browsed.
|
|
const home = workspace.spaces.find((space) => space.role === 'diary')
|
|
if (home && workspace.parentId !== home.id) await workspace.enterSpace(home.id)
|
|
else if (workspace.query) await workspace.search('')
|
|
|
|
const midnight = new Date()
|
|
midnight.setHours(0, 0, 0, 0)
|
|
const from = Math.floor(midnight.getTime() / 1000)
|
|
const today = workspace.nodes.find(
|
|
(node) =>
|
|
node.kind === 'document' && node.created_at >= from && node.created_at < from + 86400,
|
|
)
|
|
if (today) await workspace.openNode(today.id, true)
|
|
else await workspace.createDocument()
|
|
})
|
|
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.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.dropStash(open.id)
|
|
await workspace.guard(() => workspace.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.cursor + 1),
|
|
},
|
|
{
|
|
name: 'prev',
|
|
group: 'documents',
|
|
help: 'open the previous node',
|
|
run: () => jumpTo(workspace.cursor - 1),
|
|
},
|
|
{
|
|
name: 'close',
|
|
aliases: ['clo'],
|
|
group: 'tiles',
|
|
help: 'close the focused tile — Alt-q; asks when its draft could not be written',
|
|
run: () =>
|
|
workspace.guard(async () => {
|
|
await workspace.flushTile()
|
|
if (workspace.dirty) {
|
|
const name = workspace.open?.slug ?? 'the draft'
|
|
if (!(await workspace.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.onlyTile()),
|
|
},
|
|
{
|
|
name: 'ls',
|
|
aliases: ['buffers', 'files'],
|
|
group: 'tiles',
|
|
help: 'list the open tiles, % on the focused one',
|
|
run: () => workspace.say(workspace.tiles.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.flatMap((tile) => (tile.open ? [tile.open.slug] : [])),
|
|
run: (arg) => {
|
|
const wanted = arg.trim().toLowerCase()
|
|
if (!wanted) return workspace.say(describeTile(workspace.focused))
|
|
const index = /^\d+$/.test(wanted)
|
|
? Number(wanted) - 1
|
|
: workspace.tiles.findIndex(
|
|
(tile) => tile.open && (tile.open.slug.startsWith(wanted) || tile.open.name.toLowerCase().startsWith(wanted)),
|
|
)
|
|
if (!workspace.tiles[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.nodes.length - 1),
|
|
},
|
|
{
|
|
name: 'random',
|
|
group: 'documents',
|
|
help: 'open something here at random — good for re-reading',
|
|
run: () => jumpTo(Math.floor(Math.random() * workspace.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 (workspace.view === 'board') {
|
|
const card = workspace.card
|
|
if (!card) return workspace.say('no card selected', 'error')
|
|
if (!(await workspace.ask(`delete the card "${card.title}"?`))) return
|
|
return workspace.guard(() => workspace.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.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.ask(`${what} it goes to the trash.`))) return
|
|
await workspace.guard(() => workspace.deleteNode(id))
|
|
},
|
|
},
|
|
{
|
|
name: 'name',
|
|
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',
|
|
run: async (arg) => {
|
|
if (!arg.trim() && workspace.editing && workspace.pane === 'document') return hooks.focusTitle()
|
|
if (workspace.view === 'board') {
|
|
if (!arg.trim()) return workspace.say('usage: :name <text>', 'error')
|
|
return workspace.guard(() => workspace.patchCard({ title: arg.trim() }))
|
|
}
|
|
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.save())
|
|
}
|
|
|
|
if (!node) return workspace.say('nothing selected — :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 (workspace.view === 'board') {
|
|
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',
|
|
aliases: ['hist'],
|
|
group: 'documents',
|
|
help: "the open document's earlier versions as diffs — j/k, Enter brings one back (gh)",
|
|
run: async () => {
|
|
if (!requireOpen()) return
|
|
await workspace.guard(async () => {
|
|
await workspace.loadHistory()
|
|
workspace.overlay = 'history'
|
|
})
|
|
},
|
|
},
|
|
{
|
|
name: 'backlinks',
|
|
aliases: ['bl'],
|
|
group: 'documents',
|
|
help: 'the documents that link to the open one — j/k, Enter opens one (gb)',
|
|
run: async () => {
|
|
if (!requireOpen()) return
|
|
await workspace.guard(async () => {
|
|
await workspace.loadBacklinks()
|
|
workspace.overlay = 'backlinks'
|
|
})
|
|
},
|
|
},
|
|
{
|
|
name: 'trash',
|
|
args: '[purge]',
|
|
group: 'tree',
|
|
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.loadTrash()
|
|
workspace.trashCursor = 0
|
|
workspace.overlay = '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.save())
|
|
},
|
|
},
|
|
{
|
|
name: 'share',
|
|
group: 'sharing',
|
|
help: 'publish the document behind an unguessable link 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('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(async () => {
|
|
const { url, sealed } = await workspace.mintShareLink(id)
|
|
workspace.say(`new link → ${url} (copied) — the previous one is dead${sealedNote(sealed)}`)
|
|
})
|
|
},
|
|
},
|
|
{
|
|
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',
|
|
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}`)
|
|
},
|
|
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.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.loadVault()
|
|
if ((workspace.vault?.sealed ?? 0) > 0 && !(await workspace.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: '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',
|
|
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'],
|
|
group: 'editing',
|
|
help: 'while writing, attach files at the cursor — otherwise put files into this folder',
|
|
run: () => (workspace.editing ? 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 () => {
|
|
if (await workspace.ensureUnlocked()) hooks.pickTreeFiles(true)
|
|
}),
|
|
},
|
|
},
|
|
{
|
|
name: 'media',
|
|
group: 'editing',
|
|
help: 'browse everything you have uploaded — j/k, i inserts it while writing, y copies its address, x deletes',
|
|
run: () => workspace.guard(async () => {
|
|
await workspace.loadMedia()
|
|
workspace.mediaCursor = 0
|
|
workspace.overlay = 'media'
|
|
}),
|
|
},
|
|
{
|
|
name: 'outline',
|
|
aliases: ['toc'],
|
|
group: 'documents',
|
|
help: "the open document's headings to pick from — gO, in the editor too",
|
|
run: () => {
|
|
if (!requireOpen()) return
|
|
const { headings, current } = hooks.headings()
|
|
if (headings.length === 0) return workspace.say('no headings in this document')
|
|
workspace.outline = headings
|
|
workspace.outlineCursor = current
|
|
workspace.overlay = 'outline'
|
|
},
|
|
},
|
|
{
|
|
name: 'search',
|
|
aliases: ['se'],
|
|
args: '[text]',
|
|
group: 'documents',
|
|
help: 'full-text search in this space (empty clears)',
|
|
run: (arg) => workspace.guard(() => workspace.search(arg, false)),
|
|
bang: {
|
|
help: 'full-text search across every space — Ctrl-P',
|
|
run: (arg) => workspace.guard(() => workspace.search(arg, true)),
|
|
},
|
|
},
|
|
{
|
|
name: 'clear',
|
|
aliases: ['noh'],
|
|
group: 'documents',
|
|
help: 'clear the search filter',
|
|
run: () => workspace.guard(() => workspace.search('')),
|
|
},
|
|
{
|
|
name: 'reload',
|
|
aliases: ['r'],
|
|
group: 'documents',
|
|
help: 're-read the current folder from the server',
|
|
run: () =>
|
|
workspace.guard(async () => {
|
|
await workspace.refresh()
|
|
workspace.say(`${workspace.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.here || '/'} · ${workspace.nodes.length} node${workspace.nodes.length === 1 ? '' : 's'}${
|
|
workspace.query ? ` matching "${workspace.query}"` : ''
|
|
}`,
|
|
)
|
|
},
|
|
},
|
|
{
|
|
name: 'set',
|
|
args: '<option>',
|
|
group: 'view',
|
|
help: 'vim, novim, clipboard, noclipboard, board, noboard',
|
|
complete: () => ['vim', 'novim', 'clipboard', 'noclipboard', 'board', 'noboard'],
|
|
run: (arg) => {
|
|
const option = arg.trim()
|
|
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.setBoard(true))
|
|
if (option === 'noboard') return workspace.guard(() => workspace.setBoard(false))
|
|
workspace.say(`unknown option: ${option}`, 'error')
|
|
},
|
|
},
|
|
{
|
|
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',
|
|
complete: () => ['login ', 'logout'],
|
|
run: (arg) =>
|
|
workspace.guard(async () => {
|
|
const trimmed = arg.trim()
|
|
if (trimmed === '') return describeBackup(await api.backupStatus())
|
|
const loginMatch = /^login\s+(\S+)$/.exec(trimmed)
|
|
if (loginMatch) return protonLogin(loginMatch[1])
|
|
if (trimmed === 'logout') {
|
|
if (!(await workspace.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.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, numbered — rm signs one of them out, and locks the vault',
|
|
run: (arg) =>
|
|
workspace.guard(async () => {
|
|
const trimmed = arg.trim()
|
|
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]
|
|
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.loadVault()
|
|
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: 'list, add or remove keys allowed to mount the workspace over sftp, and say which may read sealed files',
|
|
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(' · '))
|
|
}
|
|
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: 'list, add or remove tokens that capture into the workspace from outside it — add shows the token once and copies it',
|
|
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') {
|
|
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(' · '),
|
|
)
|
|
}
|
|
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',
|
|
group: 'vault',
|
|
help: 'whether the vault is open — or, the first time, set its password',
|
|
run: () =>
|
|
workspace.guard(async () => {
|
|
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`,
|
|
)
|
|
}
|
|
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',
|
|
)
|
|
}),
|
|
},
|
|
{
|
|
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'],
|
|
group: 'view',
|
|
help: 'show the key and command reference',
|
|
run: () => {
|
|
workspace.overlay = workspace.overlay === 'help' ? 'none' : 'help'
|
|
},
|
|
},
|
|
{
|
|
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)),
|
|
},
|
|
},
|
|
]
|
|
|
|
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.search(input.slice(1)))
|
|
return
|
|
}
|
|
|
|
const [head, ...rest] = input.split(/\s+/)
|
|
const arg = rest.join(' ')
|
|
|
|
// `: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)))
|
|
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)
|
|
return
|
|
}
|
|
await spec.run(arg)
|
|
}
|
|
|
|
/**
|
|
* 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)),
|
|
)
|
|
}
|