Files
narl-workspace/web/src/lib/commands.ts
T
nvrl e0565ea1b4 Catppuccin Mocha theme and a legible cursor
Mocha with the palette's red as the accent is now the default; the old
green scheme moves to `:set theme=green` alongside amber and ice.

Two readability fixes in the editor:

- The vim block cursor paints the character inside itself in that
  character's own syntax colour, so an accent-coloured glyph — a
  heading's `#`, a link — vanished against the accent-coloured block.
  The glyph is now forced to the background colour.
- CodeMirror injects its own `.cm-activeLine` rule after this stylesheet,
  so the equally specific override never applied and the current line
  rendered as a bright blue box. Qualified the selector and toned the
  band down to a faint tint of the foreground.

Insert mode also gets a 2px bar instead of the browser hairline caret.
2026-09-05 06:36:53 +02:00

263 lines
7.3 KiB
TypeScript

import { diary, type Theme } from './store.svelte'
import { parseDay } from './util'
/** Filled in by App.svelte so ex-commands can reach the DOM-bound bits. */
export const hooks = {
pickFiles: () => {},
focusEditor: () => {},
focusList: () => {},
insertText: (_text: string) => {},
openCommandLine: (_initial: string) => {},
}
export interface CommandSpec {
name: string
aliases?: string[]
args?: string
help: string
run: (arg: string) => void | Promise<void>
}
const requireOpen = () => {
if (!diary.open) {
diary.say('no entry open', 'error')
return null
}
return diary.open
}
export const commands: CommandSpec[] = [
{
name: 'write',
aliases: ['w'],
help: 'save the open entry',
run: () => diary.guard(() => diary.save()),
},
{
name: 'wq',
aliases: ['x'],
help: 'save and leave insert/editor mode',
run: async () => {
await diary.guard(() => diary.save())
diary.editing = false
hooks.focusList()
},
},
{
name: 'quit',
aliases: ['q'],
help: 'close the editor, or the entry when already reading',
run: () => {
if (diary.dirty) {
diary.say('unsaved changes — :w to write, :q! to discard', 'error')
return
}
if (diary.editing) diary.editing = false
else {
diary.open = null
diary.pane = 'list'
}
hooks.focusList()
},
},
{
name: 'q!',
help: 'discard changes and close the editor',
run: async () => {
const open = diary.open
diary.editing = false
diary.dirty = false
if (open) await diary.guard(() => diary.openEntry(open.id))
hooks.focusList()
},
},
{
name: 'new',
aliases: ['n', 'o'],
args: '[yyyy-mm-dd]',
help: 'start a new entry, optionally dated',
run: async (arg) => {
await diary.guard(async () => {
await diary.createEntry()
if (arg) {
const at = parseDay(arg)
if (at === null) return diary.say(`not a date: ${arg}`, 'error')
diary.draft.created_at = at
diary.dirty = true
await diary.save()
}
})
hooks.focusEditor()
},
},
{
name: 'edit',
aliases: ['e'],
args: '[id]',
help: 'edit the open entry, or open an entry by id',
run: async (arg) => {
const id = arg ? Number(arg) : diary.open?.id ?? diary.selected?.id
if (!id || Number.isNaN(id)) return diary.say('usage: :e <id>', 'error')
await diary.guard(() => diary.openEntry(id, true))
hooks.focusEditor()
},
},
{
name: 'delete',
aliases: ['d', 'rm'],
args: '[id]',
help: 'delete an entry (asks first)',
run: async (arg) => {
const id = arg ? Number(arg) : diary.open?.id ?? diary.selected?.id
if (!id || Number.isNaN(id)) return diary.say('nothing to delete', 'error')
if (!confirm(`delete entry #${id}? this cannot be undone.`)) return
await diary.guard(() => diary.deleteEntry(id))
},
},
{
name: 'title',
args: '<text>',
help: 'set the title of the open entry',
run: async (arg) => {
if (!requireOpen()) return
diary.draft.title = arg
diary.dirty = true
await diary.guard(() => diary.save())
},
},
{
name: 'date',
args: '<yyyy-mm-dd>',
help: 're-date the open entry',
run: async (arg) => {
if (!requireOpen()) return
const at = parseDay(arg)
if (at === null) return diary.say('usage: :date 2026-09-05', 'error')
diary.draft.created_at = at
diary.dirty = true
await diary.guard(() => diary.save())
},
},
{
name: 'share',
help: 'publish the entry behind an unguessable link and copy it',
run: async () => {
const id = diary.open?.id ?? diary.selected?.id
if (!id) return diary.say('no entry selected', 'error')
const entry = diary.open?.id === id ? diary.open : diary.selected
if (entry?.shared) return diary.say('already shared — :link to copy, :unshare to revoke')
await diary.guard(() => diary.toggleShare(id))
},
},
{
name: 'unshare',
help: 'revoke the share link',
run: async () => {
const id = diary.open?.id ?? diary.selected?.id
if (!id) return diary.say('no entry selected', 'error')
const entry = diary.open?.id === id ? diary.open : diary.selected
if (!entry?.shared) return diary.say('entry is not shared')
await diary.guard(() => diary.toggleShare(id))
},
},
{
name: 'link',
help: 'copy the share link of the current entry',
run: async () => {
const token = diary.open?.share_token ?? diary.selected?.share_token
if (!token) return diary.say('entry is not shared — :share first', 'error')
const url = `${location.origin}/s/${token}`
diary.say((await diary.copy(url)) ? `copied ${url}` : url)
},
},
{
name: 'upload',
aliases: ['up'],
help: 'pick files to attach at the cursor',
run: () => hooks.pickFiles(),
},
{
name: 'media',
help: 'browse everything you have uploaded',
run: () => diary.guard(async () => {
await diary.loadMedia()
diary.overlay = 'media'
}),
},
{
name: 'search',
aliases: ['se'],
args: '[text]',
help: 'full-text search (empty clears)',
run: (arg) => diary.guard(() => diary.search(arg)),
},
{
name: 'set',
args: '<option>',
help: 'theme=mocha|green|amber|ice, vim, novim',
run: (arg) => {
const option = arg.trim()
if (option === 'vim') return diary.setVim(true), diary.say('vim keys on')
if (option === 'novim') return diary.setVim(false), diary.say('vim keys off')
const theme = /^theme=(mocha|green|amber|ice)$/.exec(option)?.[1]
if (theme) return diary.setTheme(theme as Theme), diary.say(`theme ${theme}`)
diary.say(`unknown option: ${option}`, 'error')
},
},
{
name: 'help',
aliases: ['h'],
help: 'show the key and command reference',
run: () => {
diary.overlay = diary.overlay === 'help' ? 'none' : 'help'
},
},
{
name: 'logout',
help: 'end the session',
run: () => diary.guard(() => diary.logout()),
},
]
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 theme=amber`. */
export async function runCommand(line: string): Promise<void> {
const input = line.trim().replace(/^:/, '')
if (!input) return
if (input.startsWith('/')) {
await diary.guard(() => diary.search(input.slice(1)))
return
}
const [head, ...rest] = input.split(/\s+/)
const arg = rest.join(' ')
// `:12` jumps to an entry by id, the way `:12` jumps to a line in vim.
if (/^\d+$/.test(head)) {
await diary.guard(() => diary.openEntry(Number(head)))
return
}
const spec = lookup.get(head)
if (!spec) {
diary.say(`E492: not an editor command: ${head}`, 'error')
return
}
await spec.run(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)),
)
}