Files
narl-workspace/web/src/lib/actions.ts
T
nvrl 7f8d625a8d commands: one grammar for !, merged commands, a settings sheet, two-level help
A `!` now means force or everything and nothing else (`:w!`, `:search!`,
`:export!`, `:logout!`). A command that did a second thing behind its bang
takes a word for it instead: `:space new|name|rm`, `:list name|rm`,
`:task [add]|edit`, `:upload seal`, `:import zip [/]`. `:card` while reading
a document makes a card about it, so `:card!` is gone.

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

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

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

`?` shows the keys and commands to start with, flagged in the keymap and the
command table; a second `?` (or `:help all`) shows all of them, and a filter
always searches all of them. Help rows are keyed by key and description, as
two contexts can share a label.
2026-10-22 15:35:36 +02:00

570 lines
24 KiB
TypeScript

/*
* What the keys in keymap.ts do, under the names the table gives them, one
* record per context. The type below holds the two together: a binding
* whose action is missing here, or an action no binding names, does not
* compile.
*
* An action is handed the key as `keyName` spelled it, for the ones that
* share an action and differ in direction. Returning `false` lets the key
* on to the next context down, as if it had not been bound here.
*/
import { cycleTile, focusTile, followLink, hooks, openInfo, runCommand } from './commands'
import { bindings, type Context } from './keymap'
import { deeperHelp, focusHelpFilter, scrollHelp } from './Help.svelte'
import { actSetting, moveSettings, removeSetting, turnSettings, openSettings } from './Settings.svelte'
import { purgeTrashed, restoreTrashed } from './Trash.svelte'
import { copyEmbed, embedFile, goHeading, moveHistory, openBacklink, openFile, renewLink, restoreShown, toggleSharing, turnInfo } from './Info.svelte'
import { workspace } from './store.svelte'
import { dwindle, formatDay, neighbour, type Direction } from './util'
export type Run = (key: string, event: KeyboardEvent) => boolean | void
type Bound = (typeof bindings)[number]
type ActionOf<C extends Context> = Extract<Bound, { context: C; action: string }>['action']
const guard = (task: () => Promise<unknown>) => void workspace.guard(task)
const line = (text: string) => hooks.openCommandLine(text)
const shifted = (key: string) => key.startsWith('Shift-')
/** The space a digit key stands for: 1 is the first, 0 the tenth. */
const spaceIndex = (key: string) => {
const digit = key.slice(-1)
return digit === '0' ? 9 : Number(digit) - 1
}
/** The document on screen is what j/k scroll, rather than the list. */
const reading = () => !!workspace.open && !workspace.editing && workspace.pane === 'document'
/** A cursor down a sheet's list, kept on it. */
function step(cursor: 'outlineCursor' | 'filesCursor' | 'trashCursor' | 'backlinksCursor' | 'cardsCursor', length: number, by: number) {
workspace[cursor] = Math.min(Math.max(workspace[cursor] + by, 0), Math.max(length - 1, 0))
}
export async function openOverview() {
await workspace.guard(() => workspace.loadOverview())
workspace.overviewCursor = Math.max(
workspace.overview.findIndex((entry) => entry.space.id === workspace.spaceId),
0,
)
workspace.openSheet('overview')
}
/*
* `F`: the hints are pinned where the targets are, so a pane still sliding
* in — a space switch, the board just opened — is let settle first.
*/
async function showHints(root: HTMLElement) {
// What moves the root may be an animation on a pane around it. A blinking
// cursor never finishes, so only what ends is waited for.
const settling = document.getAnimations().filter((animation) => {
const target = (animation.effect as KeyframeEffect | null)?.target
if (!target || animation.effect?.getComputedTiming().endTime === Infinity) return false
return target.contains(root) || root.contains(target)
})
await Promise.all(settling.map((animation) => animation.finished.catch(() => {})))
if (root.isConnected) hooks.showHints(root)
}
/** `gx`: the file as the browser shows it, in a tab of its own. */
function openInTab() {
const file = workspace.target?.file
if (!file) return workspace.say('gx opens a file — put the cursor on one', 'error')
// A tab opened after the vault's question would be stopped as a popup, so
// a sealed file asks for the vault to be opened first instead.
if (file.encrypted && !workspace.vault?.unlocked) return workspace.say('sealed — :vault unlock first, then gx', 'error')
followLink(file.url, true)
}
/** Focus moves between the panes, out of the editor too. */
function focusPane(pane: 'list' | 'document') {
if (pane === 'document' && workspace.tile.empty) return
workspace.pane = pane
if (pane === 'document' && workspace.editing) hooks.focusEditor()
else hooks.focusList()
}
/*
* Alt + h/j/k/l: the focus goes to the tile that way, out of the editor
* too; left of the leftmost tile is the list. With Shift the focused tile
* goes that way instead, swapping places with the one it lands on.
*/
function tileKey(direction: Direction, shift: boolean) {
if (workspace.focus === 'list') {
if (direction === 'l' && !shift) focusPane('document')
return
}
const next = neighbour(dwindle(workspace.tiles.length), workspace.focused, direction)
if (next < 0) {
if (direction === 'h' && !shift) focusPane('list')
return
}
if (!shift) return void focusTile(next)
const tiles = [...workspace.tiles]
;[tiles[workspace.focused], tiles[next]] = [tiles[next], tiles[workspace.focused]]
workspace.tiles = tiles
workspace.focused = next
}
export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
always: {
save: () => {
if (workspace.writing) guard(() => workspace.saveWriting())
else if (workspace.open) guard(() => workspace.save())
},
close: () => void runCommand('close'),
focus: (key) => tileKey(key.slice(-1) as Direction, false),
swap: (key) => tileKey(key.slice(-1) as Direction, true),
},
sheet: {
// A note being written has had its Esc; the sheet stays up under it.
close: () => {
if (workspace.scratchEditing) return
workspace.closeSheet()
},
},
/*
* The help's columns run on to the right, so h/l and j/k move a column,
* Ctrl-D/U a screen, g/G to either end, and `/` narrows it down.
*/
help: {
next: () => scrollHelp(1),
previous: () => scrollHelp(-1),
pageDown: () => scrollHelp('pagedown'),
pageUp: () => scrollHelp('pageup'),
page: (key) => scrollHelp(shifted(key) ? 'pageup' : 'pagedown'),
start: () => scrollHelp('start'),
end: () => scrollHelp('end'),
filter: () => focusHelpFilter(),
deeper: () => deeperHelp(),
},
/** The overview: hjkl across the grid, a digit or Enter to go. */
overview: {
step: (key) => {
const columns = hooks.overviewColumns()
const by = { h: -1, ArrowLeft: -1, l: 1, ArrowRight: 1, k: -columns, ArrowUp: -columns, j: columns, ArrowDown: columns }[key] ?? 0
workspace.overviewCursor = Math.min(Math.max(workspace.overviewCursor + by, 0), workspace.overview.length - 1)
},
go: (key) => goToSpace(spaceIndex(key)),
enter: () => goToSpace(workspace.overviewCursor),
close: () => workspace.closeSheet(),
},
/** The info sheet: Tab from one of its parts to the next. */
info: {
next: () => turnInfo(1),
previous: () => turnInfo(-1),
},
/** Its headings: j/k down them, Enter to the one picked. */
outline: {
down: () => step('outlineCursor', workspace.outline.length, 1),
up: () => step('outlineCursor', workspace.outline.length, -1),
bottom: () => step('outlineCursor', workspace.outline.length, Infinity),
go: () => goHeading(),
},
/** Its files: j/k down them, Enter opens one, i embeds it while writing, y copies how. */
files: {
down: () => step('filesCursor', workspace.open?.files.length ?? 0, 1),
up: () => step('filesCursor', workspace.open?.files.length ?? 0, -1),
bottom: () => step('filesCursor', workspace.open?.files.length ?? 0, Infinity),
open: () => {
const file = workspace.open?.files[workspace.filesCursor]
if (file) void openFile(file)
},
insert: () => {
const file = workspace.open?.files[workspace.filesCursor]
if (file) embedFile(file)
},
copy: () => {
const file = workspace.open?.files[workspace.filesCursor]
if (file) void copyEmbed(file)
},
},
/** The trash: j/k down what was deleted, Enter or r restores, x deletes for good. */
trash: {
down: () => step('trashCursor', workspace.trash.length, 1),
up: () => step('trashCursor', workspace.trash.length, -1),
bottom: () => step('trashCursor', workspace.trash.length, Infinity),
restore: () => {
const item = workspace.trash[workspace.trashCursor]
if (item) void restoreTrashed(item)
},
purge: () => {
const item = workspace.trash[workspace.trashCursor]
if (item) void purgeTrashed(item)
},
},
/** Its backlinks: j/k through the documents that link here, Enter opens one. */
backlinks: {
down: () => step('backlinksCursor', workspace.backlinks.length, 1),
up: () => step('backlinksCursor', workspace.backlinks.length, -1),
bottom: () => step('backlinksCursor', workspace.backlinks.length, Infinity),
open: () => {
const link = workspace.backlinks[workspace.backlinksCursor]
if (link) void openBacklink(link)
},
},
/**
* Its cards: j/k picks the one the card verbs act on — Space, H/L, `:due`,
* `:label` — and Enter turns it over on its board.
*/
cards: {
down: () => step('cardsCursor', workspace.open?.cards.length ?? 0, 1),
up: () => step('cardsCursor', workspace.open?.cards.length ?? 0, -1),
bottom: () => step('cardsCursor', workspace.open?.cards.length ?? 0, Infinity),
turn: () => {
const card = workspace.open?.cards[workspace.cardsCursor]
if (!card) return
workspace.closeSheet()
guard(() => workspace.turnTo(card))
},
done: () => guard(() => workspace.toggleDone()),
sendLeft: () => guard(() => workspace.sendCard(-1)),
sendRight: () => guard(() => workspace.sendCard(1)),
due: () => {
if (workspace.focusedCard) line(':due ')
},
new: () => line(':card '),
label: () => {
if (workspace.focusedCard) line(':label ')
},
line: () => line(':'),
},
/** Its history: j/k through the versions, Enter brings the one shown back. */
history: {
down: () => moveHistory(1),
up: () => moveHistory(-1),
bottom: () => moveHistory(workspace.history.length),
restore: () => void restoreShown(),
},
/** Its sharing: s shares or stops, y makes a new link. */
share: {
toggle: () => void toggleSharing(workspace.open),
link: () => void renewLink(workspace.open),
},
/** The settings sheet: Tab from part to part, j/k down it, Enter changes, x removes. */
settings: {
next: () => turnSettings(1),
previous: () => turnSettings(-1),
down: () => moveSettings(1),
up: () => moveSettings(-1),
bottom: () => moveSettings(Infinity),
act: () => actSetting(),
remove: () => removeSetting(),
},
/*
* A card's back reads the list keys over its checklist, and the board's
* card keys for the card itself — `D`, `H`/`L`, `R` act on it as they do
* on the board beside it. While the description is being written the keys
* are the editor's.
*/
card: {
down: () => void workspace.moveTask(1),
up: () => void workspace.moveTask(-1),
top: () => void workspace.moveTask(-Infinity),
bottom: () => void workspace.moveTask(Infinity),
tick: () => guard(() => workspace.toggleTask()),
// `add` spelled out, so a step that starts with "edit" is added too.
add: () => line(':task add '),
reword: () => {
const task = workspace.card?.checklist[workspace.taskCursor]
if (task) line(`:task edit ${task.text}`)
},
nudgeDown: () => guard(() => workspace.nudgeTask(1)),
nudgeUp: () => guard(() => workspace.nudgeTask(-1)),
drop: () => guard(() => workspace.deleteTask()),
describe: () => workspace.describe(),
label: () => line(':label '),
rename: () => line(`:name ${workspace.card?.title ?? ''}`),
due: () => line(':due '),
sendLeft: () => guard(() => workspace.sendCard(-1)),
sendRight: () => guard(() => workspace.sendCard(1)),
notes: () => guard(() => workspace.openCard()),
line: () => line(':'),
},
/*
* The scratchpad, while it is down and its note is not being written: the
* list keys over its notes. It sits over whatever was on screen, so
* nothing else hears a key until it is raised again.
*/
drawer: {
down: () => void (workspace.scratchCursor = Math.min(workspace.scratchCursor + 1, Math.max(workspace.scratchNotes.length - 1, 0))),
up: () => void (workspace.scratchCursor = Math.max(workspace.scratchCursor - 1, 0)),
top: () => void (workspace.scratchCursor = 0),
bottom: () => void (workspace.scratchCursor = Math.max(workspace.scratchNotes.length - 1, 0)),
new: () => workspace.newScratchNote(),
write: () => workspace.editScratchNote(),
keep: () => guard(() => workspace.keepScratchNote()),
card: () => guard(() => workspace.cardScratchNote()),
append: () => {
const open = [workspace.tile, ...workspace.tiles].find((tile) => tile.open?.kind === 'document')?.open
line(`:append ${open ? open.name.trim() || open.slug : ''}`)
},
drop: () => {
if (!workspace.scratchNote) return
void (async () => {
if (await workspace.ask('drop this scratch note?')) await workspace.guard(() => workspace.dropScratchNote())
})()
},
line: () => line(':'),
help: () => void runCommand('help'),
raise: () => void workspace.closeScratch(),
nothing: () => {},
},
workspace: {
/** Esc leaves one thing: the line, or fullscreen. */
escape: () => {
if (workspace.writing) return
if (hooks.closeCommandLine()) return
if (workspace.fullscreen && !workspace.editing) return void (workspace.fullscreen = false)
return false
},
// `Q`, a quick note: the scratchpad lowered from anywhere a key is not
// being typed — a letter, since a backtick is a dead key on many layouts.
scratch: () => {
if (workspace.scratchOpen) return false
guard(() => workspace.toggleScratch())
},
},
writing: {
refocus: () => hooks.focusEditor(),
},
/*
* The focused pane owns j/k. In the document they scroll what is on
* screen; `h` moves focus to the list with the document still open, where
* they walk the rows again, and `l` goes back.
*/
tree: {
down: () => void (reading() ? hooks.scrollReader(1) : workspace.move(1)),
up: () => void (reading() ? hooks.scrollReader(-1) : workspace.move(-1)),
top: () => void (reading() ? hooks.scrollReader('top') : (workspace.cursor = 0)),
bottom: () => void (reading() ? hooks.scrollReader('bottom') : (workspace.cursor = Math.max(workspace.rows - 1, 0))),
open: (key) => {
const enter = key === 'Enter' || key === 'Shift-Enter'
if (enter && reading() && hooks.fileAction()) return
// Shift-Enter, like `L`: beside what is open, in a tile of its own.
if (key === 'Shift-Enter' && !reading()) return guard(() => workspace.openBeside())
const shown = workspace.tiles.findIndex((tile) => tile.open && tile.open.id === workspace.selected?.id)
if (shown >= 0) void focusTile(shown)
else guard(() => workspace.openSelected())
},
beside: () => {
if (!reading()) guard(() => workspace.openBeside())
},
// Leftwards at every depth: from the document to the list, from the
// list up a level.
left: () => {
if (workspace.open && workspace.pane === 'document') focusPane('list')
else guard(() => workspace.up())
},
sideways: (key) => {
const amount = ({ zh: -1, zl: 1, zH: 'halfleft', zL: 'halfright' } as const)[key as 'zh' | 'zl' | 'zH' | 'zL']
if (!reading() || !hooks.scrollWide(amount)) workspace.say('nothing wider than the column in view', 'error')
},
// Past the last heading there is still the end, as in vim.
nextHeading: () => {
if (reading() && !hooks.jumpHeading(1)) hooks.scrollReader('bottom')
},
previousHeading: () => {
if (reading() && !hooks.jumpHeading(-1)) hooks.scrollReader('top')
},
info: () => void openInfo(),
outline: () => void runCommand('outline'),
history: () => void runCommand('history'),
backlinks: () => void runCommand('backlinks'),
cards: () => void runCommand('cards'),
inTab: () => openInTab(),
// `f` already fills the screen, so the hints vimium puts on `f` live on
// its other key.
hints: () => {
const root = reading() ? hooks.hintRoot() : null
if (root) void showHints(root)
},
// `[d` / `]d`: the diary entry of the day before or after, by its date.
previousEntry: () => guard(() => workspace.stepEntry(-1)),
nextEntry: () => guard(() => workspace.stepEntry(1)),
// Pick nodes up here, walk to a folder, put them down with `p`.
mark: () => workspace.mark(),
markAll: () => workspace.markAll(),
files: () => guard(() => workspace.toggleFiles()),
drop: () => guard(() => workspace.drop()),
new: () => void runCommand('new'),
// Capture: a thought goes on this space's inbox without leaving here.
// Reading a document, `:card` makes it about that document.
capture: () => line(':card '),
// The name has to come from somewhere, and the command line is where
// names are typed — this only saves typing `:mkdir `.
mkdir: () => line(':mkdir '),
edit: () => void runCommand('edit'),
// Renaming starts from the name it has, so a typo is a fix, not a retype.
rename: () => {
const target = workspace.target
const current = target && target.id === workspace.open?.id ? workspace.draft.name : (target?.name ?? '')
line(`:name ${current.trim()}`)
},
close: () => {
if (workspace.open) void runCommand('q')
else workspace.unmarkAll()
},
// Esc lets go of one thing at a time: what is carried, then the document.
escape: () => {
if (workspace.marked.length) return workspace.unmarkAll()
if (workspace.open) void runCommand('q')
},
delete: () => void runCommand('d'),
// A delete goes to the trash, so it can be undone the way vim undoes.
undo: () => guard(() => workspace.undoDelete()),
// Through the results, as vim's `n` goes through the matches; the
// search itself stays until `:noh`.
next: () => guard(() => workspace.nextResult(1)),
previous: () => guard(() => workspace.nextResult(-1)),
search: () => line('/'),
},
/*
* The board reads the same keys as the list, one dimension wider: h/l walk
* the lists, j/k the cards, and the shifted pair takes the card along.
* Nothing falls through to the tree keys, because they would act on a tree
* that is not on screen.
*/
board: {
left: () => workspace.moveList(-1),
// Right of the last list is the tile beside the board.
right: () => {
if (workspace.listCursor >= (workspace.board?.lists.length ?? 0) - 1) tileKey('l', false)
else workspace.moveList(1)
},
down: () => workspace.moveCard(1),
up: () => workspace.moveCard(-1),
top: () => void (workspace.cardCursor = 0),
bottom: () => void (workspace.cardCursor = Math.max((workspace.list?.cards.length ?? 0) - 1, 0)),
nudgeDown: () => guard(() => workspace.nudgeCard(1)),
nudgeUp: () => guard(() => workspace.nudgeCard(-1)),
sendLeft: () => guard(() => workspace.sendCard(-1)),
sendRight: () => guard(() => workspace.sendCard(1)),
done: () => guard(() => workspace.toggleDone()),
turn: () => guard(() => workspace.turnCard()),
// The card's notes, without turning it over first: `gf` as on its back.
notes: () => guard(() => workspace.openCard()),
new: () => line(':card '),
due: () => line(':due '),
// On an empty list there is no card to rename, so it is the list. The
// name is there to be edited, not typed again from nothing.
rename: () => line(workspace.card ? `:name ${workspace.card.title}` : `:list name ${workspace.list?.name ?? ''}`),
old: () => void (workspace.showOldDone = !workspace.showOldDone),
// A letter over every card in view: typing it puts the cursor there.
hints: () => {
const root = hooks.boardRoot()
if (root) void showHints(root)
},
reload: () => guard(() => workspace.reloadBoard()),
delete: () => void runCommand('delete'),
},
/*
* The agenda: j/k down the list, Enter to the card, Space finishes it.
* Past the cards the cursor walks on into what was written on this day,
* and past that into the tile under it; `h` is the tile to its left.
*/
agenda: {
down: () => {
const last = Math.max(workspace.agendaCards.length + workspace.agenda.on_this_day.length - 1, 0)
if (workspace.agendaCursor >= last) tileKey('j', false)
else workspace.agendaCursor += 1
},
up: () => void (workspace.agendaCursor = Math.max(workspace.agendaCursor - 1, 0)),
left: () => tileKey('h', false),
open: () => {
const card = workspace.agendaCards[workspace.agendaCursor]
const past = workspace.agenda.on_this_day[workspace.agendaCursor - workspace.agendaCards.length]
if (card) guard(() => workspace.goToCard(card))
// Beside the agenda, so `q` on it comes back here.
else if (past) guard(() => workspace.openPath(past.path))
},
finish: () => guard(() => workspace.toggleDone()),
due: () => {
if (workspace.agendaCards[workspace.agendaCursor]) line(':due ')
},
line: () => line(':'),
sendLeft: () => guard(() => workspace.sendCard(-1)),
sendRight: () => guard(() => workspace.sendCard(1)),
today: () => void runCommand('today'),
},
/** The calendar: h/l a day, j/k a week, H/L a month, Enter to the day. */
calendar: {
day: (key) => guard(() => workspace.moveCalendar(key === 'h' || key === 'ArrowLeft' ? -1 : 1, 0)),
week: (key) => guard(() => workspace.moveCalendar(key === 'k' || key === 'ArrowUp' ? -7 : 7, 0)),
month: (key) => guard(() => workspace.moveCalendar(0, key === 'H' ? -1 : 1)),
today: () => {
workspace.calendarDay = formatDay(Math.floor(Date.now() / 1000))
guard(() => workspace.loadCalendar())
},
open: () => void workspace.guard(() => workspace.openCalendarDay()).then(() => hooks.focusEditor()),
},
/** What every view shares: `q` closes its tile, back to the one before. */
views: {
close: () => void runCommand('close'),
},
global: {
// Ctrl-P: find anything, in any space — the one search that is not
// confined to where you are.
find: () => hooks.openGlobalSearch(),
// Ctrl-D / Ctrl-U page the open document, as they do in a pager.
halfDown: () => (workspace.open && !workspace.editing ? void hooks.scrollReader('halfdown') : false),
halfUp: () => (workspace.open && !workspace.editing ? void hooks.scrollReader('halfup') : false),
/*
* Spaces are workspaces: digits jump to one, shifted digits send the node
* there, Tab walks along the bar. Digits are read from `event.code`, so
* Shift+1 means space 1 whatever symbol the layout puts on that key.
*/
space: (key) => guard(() => workspace.switchSpace(spaceIndex(key))),
send: (key) => guard(() => workspace.sendToSpace(spaceIndex(key))),
cycle: (key) => guard(() => workspace.cycleSpace(shifted(key) ? -1 : 1)),
overview: () => void openOverview(),
fullscreen: () => void (workspace.fullscreen = !workspace.fullscreen),
// `gt` / `gT` walk the tiles, as they walk vim's tab pages.
nextTile: () => void cycleTile(1),
previousTile: () => void cycleTile(-1),
// The board is a view of the space, not a row in the list, so it needs
// a key as plain as the one that opens a document.
board: () => void runCommand('board'),
// The agenda and today's entry are a key away from anywhere, the board
// included: they are the two questions asked most.
agenda: () => void runCommand('agenda'),
today: () => void runCommand('today'),
calendar: () => void runCommand('cal'),
// `S`, as the desktop's special workspace is bound: the board, the
// agenda and the calendar, put up and away again. `b`, `t` and `C` only
// ever go to their tile.
plan: () => void runCommand('plan'),
line: () => line(':'),
help: () => void runCommand('help'),
settings: () => void openSettings(),
},
editor: {},
}
function goToSpace(index: number) {
const entry = workspace.overview[index]
if (!entry) return
workspace.closeSheet()
guard(() => workspace.enterSpace(entry.space.id))
}