Files
narl-workspace/web/src/lib/actions.ts
T
nvrl 0eabcb2333 keys: one table drives dispatch, help, footers, the rail and the wiki
keymap.ts lists every key with its context, action and help line.
actions.ts holds what each one does, typed against the table so a key
without an action, or an action without a key, does not compile.
modes.ts stacks the contexts (always > sheet > drawer > workspace >
writing > focused tile > special > global) and writes the two-key
pending logic once. App.svelte's 480-line onkeydown is now one call.

Help, the panel footers (a shared Keys component), the rail's keycaps
and Commands.md all read the table; the wiki gains the card and
scratchpad key tables.

One fix on the way: Ctrl-S in the editor no longer saves twice, which
could make the second save fail as "changed somewhere else".
2026-10-22 03:42:19 +02:00

558 lines
23 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, runCommand } from './commands'
import { bindings, type Context } from './keymap'
import { focusHelpFilter, scrollHelp } from './Help.svelte'
import { copyMedia, insertMedia, removeMedia } from './MediaBrowser.svelte'
import { purgeTrashed, restoreTrashed } from './Trash.svelte'
import { openBacklink } from './Backlinks.svelte'
import { moveHistory, restoreShown } from './History.svelte'
import { workspace, type SpecialTile } 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' | 'mediaCursor' | 'trashCursor' | 'backlinksCursor', 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.overlay = '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 — :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.open) return
workspace.pane = pane
if (pane === 'document' && workspace.editing) hooks.focusEditor()
else hooks.focusList()
}
/*
* The special workspace is three tiles: the board on the left, the agenda
* over the calendar on the right. Alt-h/j/k/l moves between them.
*/
const specialNeighbours: Record<SpecialTile, Partial<Record<Direction, SpecialTile>>> = {
board: { l: 'agenda' },
agenda: { h: 'board', j: 'calendar' },
calendar: { h: 'board', k: 'agenda' },
}
/*
* 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.view === 'special') {
const next = specialNeighbours[workspace.specialFocus][direction]
if (next && !shift) workspace.specialFocus = next
return
}
if (workspace.pane === 'list' || !workspace.open) {
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.scratchEditing) guard(() => workspace.saveScratchNote())
else if (workspace.describing) guard(() => workspace.saveDescription())
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 description or a note being written has had its Esc; the sheet
// stays up under it.
close: () => {
if (workspace.describing || workspace.scratchEditing) return
workspace.overlay = 'none'
},
},
/*
* 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(),
},
/** 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: () => void (workspace.overlay = 'none'),
},
/** `gO`'s list: j/k down the headings, 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: () => {
const heading = workspace.outline[workspace.outlineCursor]
workspace.overlay = 'none'
heading?.go()
},
},
/** The media list: j/k down the files, i inserts, y copies, x deletes. */
media: {
down: () => step('mediaCursor', workspace.media.length, 1),
up: () => step('mediaCursor', workspace.media.length, -1),
bottom: () => step('mediaCursor', workspace.media.length, Infinity),
insert: () => {
const file = workspace.media[workspace.mediaCursor]
if (file) insertMedia(file)
},
copy: () => {
const file = workspace.media[workspace.mediaCursor]
if (file) void copyMedia(file)
},
delete: () => {
const file = workspace.media[workspace.mediaCursor]
if (file) void removeMedia(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)
},
},
/** The 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)
},
},
/** The 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(),
},
/*
* The card sheet 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 under 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: () => line(':task '),
reword: () => {
const task = workspace.card?.checklist[workspace.taskCursor]
if (task) line(`:task! ${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: () => {
workspace.overlay = 'none'
guard(() => workspace.openCard())
},
line: () => line(':'),
close: () => workspace.closeCardSheet(),
},
/*
* 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()),
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.describing || workspace.scratchEditing) 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.nodes.length - 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())
},
// `gt` / `gT` walk the tiles, as they walk vim's tab pages.
nextTile: () => void cycleTile(1),
previousTile: () => void cycleTile(-1),
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')
},
outline: () => void runCommand('outline'),
history: () => void runCommand('history'),
backlinks: () => void runCommand('backlinks'),
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(),
drop: () => guard(() => workspace.drop()),
new: () => void runCommand('new'),
// Capture: a thought goes on this space's inbox without leaving here.
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'),
// 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'),
// 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()
},
delete: () => void runCommand('d'),
// A delete goes to the trash, so it can be undone the way vim undoes.
undo: () => guard(() => workspace.undoDelete()),
// Sharing is a key away; taking a link back from its readers asks first.
share: () =>
void (async () => {
const target = workspace.target
if (target?.shared && !(await workspace.ask(`stop sharing "${target.name.trim() || target.slug}"? the link stops working`))) return
void runCommand(target?.shared ? 'unshare' : 'share')
})(),
// A vim hand presses y to yank; here it retires the link readers have,
// so it asks first.
link: () =>
void (async () => {
const target = workspace.target
if (!target?.shared) return void runCommand('link')
if (await workspace.ask(`new share link for "${target.name.trim() || target.slug}"? the current one stops working`)) void runCommand('link')
})(),
// 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 agenda, the tile beside the board.
right: () => {
if (workspace.listCursor >= (workspace.board?.lists.length ?? 0) - 1) workspace.specialFocus = 'agenda'
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: () => workspace.openCardSheet(),
// 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}` : `: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'),
leave: () => workspace.closeSpecial(),
},
/*
* 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 calendar under it; `h` is the board to its left.
*/
agenda: {
down: () => {
const last = Math.max(workspace.agendaCards.length + workspace.agenda.on_this_day.length - 1, 0)
if (workspace.agendaCursor >= last) workspace.specialFocus = 'calendar'
else workspace.agendaCursor += 1
},
up: () => void (workspace.agendaCursor = Math.max(workspace.agendaCursor - 1, 0)),
left: () => void (workspace.specialFocus = 'board'),
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))
// Opened from the special workspace, so `q` on it comes back here.
else if (past) guard(() => workspace.openPath(past.path))
},
finish: () => {
const card = workspace.agendaCards[workspace.agendaCursor]
if (card) guard(() => workspace.finishCard(card))
},
due: () => {
if (workspace.agendaCards[workspace.agendaCursor]) line(':due ')
},
line: () => line(':'),
sendLeft: () => sendAgendaCard(-1),
sendRight: () => sendAgendaCard(1),
today: () => void runCommand('today'),
leave: () => workspace.closeSpecial(),
},
/** 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()),
leave: () => workspace.closeSpecial(),
},
special: {
// Tab walks the tiles here, as it walks the spaces everywhere else; the
// digits still switch space, and the board with it.
cycle: (key) => workspace.cycleSpecial(shifted(key) ? -1 : 1),
board: () => void runCommand('board'),
leave: () => workspace.closeSpecial(),
},
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),
// 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.
special: () => void runCommand('special'),
line: () => line(':'),
help: () => void runCommand('help'),
},
editor: {},
}
function goToSpace(index: number) {
const entry = workspace.overview[index]
if (!entry) return
workspace.overlay = 'none'
guard(() => workspace.enterSpace(entry.space.id))
}
function sendAgendaCard(by: 1 | -1) {
const card = workspace.agendaCards[workspace.agendaCursor]
if (card) guard(() => workspace.sendAgendaCard(card, by))
}