cards: a document and its cards, seen from both ends

A document now lists the cards about it: the ones whose notes it is and
the ones whose description links to it. GET /nodes/{id}/cards answers
that, and the node itself carries it as `cards`. A line under the
document's meta line says what is still open and when it is first due;
it and `gc` open a new cards part of the info sheet, where j/k picks a
card, Space, H/L, D, a and `:` act on it, and Enter turns it over on its
board. `c` while reading a document makes a card about it.

Card verbs act on the card in focus wherever it is looked at: the
board's cursor, the card turned over, the agenda's highlight, the card
picked in the info sheet, or a document's only open card. patchCard,
sendCard and toggleDone take that card, so patchAgendaCard,
sendAgendaCard and finishCard are gone, and :due, :repeat, :label and
:done no longer refuse to run away from the board.

Migration 0027 adds card_links, links for a card's description: indexed
on every write, backfilled on start, followed and rewritten on a rename
or move like a document's, and claimed when a title they named appears.
It also adds cards_fts over title, description and checklist steps;
GET /cards?q= searches it, and / lists the cards found after the nodes,
Enter turning one over on its board.
This commit is contained in:
nvrl committed 2026-10-22 10:45:20 +02:00
1 parent 5684614d0a
commit b8b3641e6e
21 files changed
+896 -274

No files matched your search

+1 -1
View File
@@ -77,7 +77,7 @@
</button>
{#if isTouchDevice}
<!-- Space, for a thumb. -->
<button class="finish" aria-label="done: {card.title}" onclick={() => workspace.guard(() => workspace.finishCard(card))}>
<button class="finish" aria-label="done: {card.title}" onclick={() => workspace.guard(() => workspace.toggleDone(card))}>
<Icon name="check" size={18} />
</button>
{/if}
+1 -1
View File
@@ -129,7 +129,7 @@
const query = line.slice(1)
await workspace.guard(async () => {
if (query && workspace.query !== query) await workspace.search(query, global)
if (query && workspace.selected) await workspace.openSelected()
if (query && (workspace.selected || workspace.foundCard)) await workspace.openSelected()
})
hooks.focusList()
return
+18
View File
@@ -5,6 +5,7 @@
import { workspace, type Tile } from './store.svelte'
import Icon from './Icon.svelte'
import {
formatDay,
formatStamp,
formatWeekday,
headingsIn,
@@ -26,6 +27,9 @@
const words = $derived(
(tile.editing ? tile.draft.body : doc.body).split(/\s+/).filter(Boolean).length,
)
/** What is still to be done about it, and when the first of that is due. */
const tasks = $derived(doc.cards.filter((card) => card.done_at === null))
const due = $derived(tasks.reduce<number | null>((first, card) => (card.due_at !== null && (first === null || card.due_at < first) ? card.due_at : first), null))
let reader = $state<HTMLElement>()
let article = $state<HTMLElement>()
let title = $state<HTMLInputElement>()
@@ -162,6 +166,18 @@
<span class="field public"><span class="ring"></span>public</span>
{/if}
</button>
<!-- The work about it, in a line: what is still to do here — gc lists it. -->
{#if tasks.length > 0}
<button class="meta tasks" title="cards (gc)" onclick={() => openInfo('cards')}>
<span class="field">
<span class="caption">{tasks.length === 1 ? 'card' : 'cards'}</span>
{#if tasks.length === 1}<span class="what">{tasks[0].title}</span> · {tasks[0].list_name}{:else}{tasks.length} open{/if}
</span>
{#if due !== null}
<span class="field" class:late={due < Date.now() / 1000}><span class="caption">due</span>{formatWeekday(due)} {formatDay(due)}</span>
{/if}
</button>
{/if}
</header>
{#if tile.editing}
@@ -277,6 +293,8 @@
.meta:hover .caption, .meta:focus-visible .caption { color: var(--light); }
.caption { margin-right: 8px; color: var(--muted); letter-spacing: 0.18em; text-transform: uppercase; }
.public { display: flex; align-items: center; gap: 6px; color: var(--light); }
.tasks .what { color: var(--light); }
.tasks .late { color: var(--alarm); }
.ring { width: 7px; height: 7px; flex: none; border: 1.5px solid var(--light); border-radius: 999px; }
.title {
+53 -5
View File
@@ -1,5 +1,5 @@
<script lang="ts" module>
import type { Backlink, NodeSummary, RevisionSummary } from './api'
import type { AgendaCard, Backlink, NodeSummary, RevisionSummary } from './api'
import { workspace, type InfoSection } from './store.svelte'
import { formatStamp } from './util'
@@ -7,9 +7,9 @@
* What the keys in actions.ts do here too, so a key and a click are the
* same thing. The info sheet holds what is known about the open document
* besides its text: its headings, its earlier versions, what links to it,
* and whether it is shared.
* the cards about it, and whether it is shared.
*/
export const infoSections: InfoSection[] = ['outline', 'history', 'backlinks', 'share']
export const infoSections: InfoSection[] = ['outline', 'history', 'backlinks', 'cards', 'share']
/** Tab and Shift-Tab: the next part along, round the ends. */
export function turnInfo(by: number) {
@@ -44,6 +44,12 @@
return workspace.guard(() => workspace.openPath(link.path))
}
/** Enter on a card: the sheet away, and the card turned over on its board. */
export function turnCard(card: AgendaCard) {
workspace.closeSheet()
return workspace.guard(() => workspace.turnTo(card))
}
// Sharing is a key away; taking a link back from its readers asks first.
export async function toggleSharing(node: NodeSummary | null | undefined) {
if (!node) return workspace.say('nothing selected', 'error')
@@ -66,14 +72,28 @@
restore: 'replaced by a restore',
}
const reason = (summary: RevisionSummary) => REASONS[summary.reason] ?? summary.reason
/** Where a card stands, as one word: done, or the list it is on. */
const standing = (card: AgendaCard) => (card.done_at !== null ? 'done' : card.list_name)
</script>
<script lang="ts">
import Sheet from './Sheet.svelte'
import { diffLines, folded } from './diff'
import { relative } from './util'
import { formatDay, relative } from './util'
const doc = $derived(workspace.open)
/** `work · doing · due 2026-10-23 · mentions it`: a card's line under its title. */
const where = (card: AgendaCard) =>
[
card.space_slug,
standing(card),
card.due_at !== null && card.done_at === null ? `due ${formatDay(card.due_at)}` : '',
card.node_id !== doc?.id ? 'mentions it' : '',
]
.filter(Boolean)
.join(' · ')
const section = $derived(workspace.infoSection)
// The shallowest level in the document is the left edge, so a text that
@@ -95,11 +115,18 @@
['outline', `outline ${workspace.outline.length}`],
['history', `history ${workspace.history.length}`],
['backlinks', `linked from ${workspace.backlinks.length}`],
['cards', `cards ${doc?.cards.filter((card) => card.done_at === null).length ?? 0}`],
['share', doc?.shared ? 'shared' : 'private'],
])
const cursor = $derived(
{ outline: workspace.outlineCursor, history: workspace.historyCursor, backlinks: workspace.backlinksCursor, share: undefined }[section],
{
outline: workspace.outlineCursor,
history: workspace.historyCursor,
backlinks: workspace.backlinksCursor,
cards: workspace.cardsCursor,
share: undefined,
}[section],
)
</script>
@@ -186,6 +213,25 @@
<li class="muted">nothing links here yet — a link is [text](/n/…) or [[title]].</li>
{/each}
</ul>
{:else if section === 'cards'}
<ul class="items">
{#each doc?.cards ?? [] as card, index (card.id)}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<li
class="item"
class:at={index === workspace.cardsCursor}
class:done={card.done_at !== null}
data-index={index}
onpointerenter={() => (workspace.cardsCursor = index)}
onclick={() => turnCard(card)}
>
<span class="name">{card.title}</span>
<span class="muted">{where(card)}</span>
</li>
{:else}
<li class="muted">no card about this document — c makes one, and a card's [[title]] counts too.</li>
{/each}
</ul>
{:else}
<div class="share">
{#if sharePath}
@@ -236,6 +282,8 @@
.row.added { background: color-mix(in oklab, var(--light) 14%, transparent); }
.row.fold { color: var(--muted); }
.done .name { color: var(--muted); text-decoration: line-through; }
.context { font-size: 12px; color: var(--subtle); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.share { display: flex; flex-direction: column; gap: 12px; font-size: 12px; }
+24 -3
View File
@@ -77,7 +77,10 @@
<div class="head label rule">
<span class="marker"></span>
<span class="name">{workspace.space?.slug ?? 'diary'}</span>
<span class="count">{workspace.nodes.length} {workspace.nodes.length === 1 ? 'item' : 'items'}</span>
<span class="count">
{workspace.nodes.length} {workspace.nodes.length === 1 ? 'item' : 'items'}{#if workspace.foundCards.length > 0}
· {workspace.foundCards.length} {workspace.foundCards.length === 1 ? 'card' : 'cards'}{/if}
</span>
</div>
{#if workspace.query}
@@ -144,7 +147,22 @@
</button>
{/if}
</li>
{:else}
{/each}
<!-- The cards a search found, after the nodes: Enter turns one over on its board. -->
{#if workspace.foundCards.length > 0}
<li class="daybreak label rule"><span>cards</span></li>
{/if}
{#each workspace.foundCards as card, at (card.id)}
{@const index = workspace.nodes.length + at}
<li class="item">
<button class="row card" class:active={index === workspace.cursor} class:done={card.done_at !== null} data-index={index} onclick={() => pick(index)}>
<span class="dot"></span>
<span class="title">{card.title}<span class="where">{card.space_slug}/{card.list_name}</span></span>
<span class="aside">{card.done_at !== null ? 'done' : card.due_at !== null ? formatDay(card.due_at) : 'card'}</span>
</button>
</li>
{/each}
{#if workspace.rows === 0}
<li class="empty muted">
{#if workspace.query}
no matches
@@ -152,7 +170,7 @@
nothing here yet — <span class="kbd">o</span> writes, <span class="kbd">⇧ O</span> makes a folder, files dropped here land here
{/if}
</li>
{/each}
{/if}
</ul>
</div>
@@ -246,6 +264,9 @@
}
.row.open .dot { background: var(--light); }
.row.open.active .dot { background: var(--ink); }
/* A card is told from a node by its square. */
.row.card .dot { border: 1px solid currentColor; border-radius: 1px; }
.row.card.done .title { color: var(--muted); text-decoration: line-through; }
.title {
flex: 1;
+35 -14
View File
@@ -34,7 +34,7 @@ const spaceIndex = (key: string) => {
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) {
function step(cursor: 'outlineCursor' | 'mediaCursor' | 'trashCursor' | 'backlinksCursor' | 'cardsCursor', length: number, by: number) {
workspace[cursor] = Math.min(Math.max(workspace[cursor] + by, 0), Math.max(length - 1, 0))
}
@@ -209,6 +209,33 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
},
},
/**
* 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),
@@ -307,7 +334,7 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
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))),
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
@@ -341,6 +368,7 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
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.
@@ -357,7 +385,8 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
drop: () => guard(() => workspace.drop()),
new: () => void runCommand('new'),
// Capture: a thought goes on this space's inbox without leaving here.
capture: () => line(':card '),
// Reading a document, the card is about it.
capture: () => line(workspace.focus === 'node' && workspace.open?.kind === 'document' ? ':card! ' : ':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 '),
@@ -445,16 +474,13 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
// Beside the agenda, 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))
},
finish: () => guard(() => workspace.toggleDone()),
due: () => {
if (workspace.agendaCards[workspace.agendaCursor]) line(':due ')
},
line: () => line(':'),
sendLeft: () => sendAgendaCard(-1),
sendRight: () => sendAgendaCard(1),
sendLeft: () => guard(() => workspace.sendCard(-1)),
sendRight: () => guard(() => workspace.sendCard(1)),
today: () => void runCommand('today'),
},
@@ -520,8 +546,3 @@ function goToSpace(index: number) {
workspace.closeSheet()
guard(() => workspace.enterSpace(entry.space.id))
}
function sendAgendaCard(by: 1 | -1) {
const card = workspace.agendaCards[workspace.agendaCursor]
if (card) guard(() => workspace.sendAgendaCard(card, by))
}
+13 -2
View File
@@ -53,6 +53,8 @@ export interface WorkspaceNode extends NodeSummary {
path: Crumb[]
/** How many documents link here. */
backlinks: number
/** The cards about it — its own, and those whose summary links here — open ones first. */
cards: AgendaCard[]
/** Where each `[[title]]` in the body leads, as an /n/ path; a title that leads nowhere is missing. */
wikilinks: Record<string, string>
}
@@ -191,12 +193,16 @@ export interface Moved extends Card {
again: Card | null
}
/** An open card as the agenda lists it, with the board it lives on. */
/**
* A card with the board it lives on: as the agenda lists it, as a document
* lists the cards about it, and as a search finds it.
*/
export interface AgendaCard extends Card {
space_id: number
space_slug: string
list_name: string
role: 'inbox' | 'doing'
/** The agenda only ever has the first two. */
role: 'inbox' | 'doing' | 'done'
}
export interface Agenda {
@@ -456,6 +462,11 @@ export const api = {
},
getNode: (id: number) => request<WorkspaceNode>(`/nodes/${id}`),
/** The cards about a document, as `getNode` has them. */
nodeCards: (id: number) => request<AgendaCard[]>(`/nodes/${id}/cards`),
/** The cards whose title, summary or steps match — in one space's board with `space`. */
searchCards: (q: string, space?: number) =>
request<AgendaCard[]>(`/cards?${new URLSearchParams(space === undefined ? { q } : { q, space: String(space) })}`),
/** `work/einarbeiten/01-was-ist-eebus` — the node behind a `/n/...` address. */
resolvePath: (path: string) => request<WorkspaceNode>(`/resolve?path=${encodeURIComponent(path)}`),
+26 -31
View File
@@ -78,7 +78,8 @@ const closeFocused = () => {
/**
* The info sheet over the open document, at `section` or where it was last
* left. Every part is read now, so each one's count is in its tab: the
* headings from the pane, the versions and backlinks from the server.
* headings from the pane, the versions and backlinks from the server, and
* the cards with the document.
*/
export async function openInfo(section?: InfoSection) {
if (!requireOpen()) return
@@ -90,6 +91,8 @@ export async function openInfo(section?: InfoSection) {
// of the same text at once would race.
await workspace.loadHistory()
await workspace.loadBacklinks()
// Picked afresh each time, from the first: the open card leads.
workspace.cardsCursor = 0
if (section) workspace.infoSection = section
workspace.openSheet('info')
})
@@ -97,8 +100,6 @@ export async function openInfo(section?: InfoSection) {
/** The board's cursor, or the card turned over beside it, has the keys. */
const onBoard = () => workspace.focus === 'board' || workspace.focus === 'card'
/** A card has the keys: on the board, its back, or highlighted in the agenda. */
const cardFocused = () => onBoard() || workspace.focus === 'agenda'
const requireOpen = () => {
if (!workspace.open) {
@@ -162,8 +163,8 @@ const describeTile = (index: number) => {
/** 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)
if (workspace.rows === 0) return workspace.say('nothing here', 'error')
workspace.cursor = Math.min(Math.max(index, 0), workspace.rows - 1)
await workspace.guard(() => workspace.openSelected())
}
@@ -509,19 +510,16 @@ export const commands: CommandSpec[] = [
name: 'due',
args: '<when|->',
group: 'board',
help: 'due date of the card — on the board, its back, or highlighted in the agenda: today, tm, fri, +3d, +2w, 10-31, 2026-10-31 — - clears',
help: "due date of the card in focus — on the board, its back, in the agenda, or the open document's: today, tm, fri, +3d, +2w, 10-31, 2026-10-31 — - clears",
complete: () => ['today', 'tm', 'fri', '+3d'],
run: async (arg) => {
const agenda = workspace.focus === 'agenda'
if (!cardFocused()) return workspace.say('no card in focus — :board', 'error')
const set = (due_at: number | null) =>
agenda ? workspace.patchAgendaCard({ due_at }) : workspace.patchCard({ due_at })
const target = arg.trim()
if (target === '-' || target === '') return workspace.guard(() => set(null))
if (target === '-' || target === '') return workspace.guard(() => workspace.patchCard({ due_at: null }))
const at = parseDue(target)
if (at === null) return workspace.say(`not a date: ${target} — try fri, +3d or 2026-09-30`, 'error')
await workspace.guard(() => set(at))
workspace.say(`due ${formatDay(at)}`)
await workspace.guard(async () => {
if (await workspace.patchCard({ due_at: at })) workspace.say(`due ${formatDay(at)}`)
})
},
},
{
@@ -531,12 +529,10 @@ export const commands: CommandSpec[] = [
help: 'once the card is done, a copy comes back in the inbox: daily, weekly, monthly, yearly, +3d, +2w, +6m — - stops it',
complete: () => ['daily', 'weekly', 'monthly', 'yearly', '+2w', '-'],
run: async (arg) => {
const agenda = workspace.focus === 'agenda'
if (!cardFocused()) return workspace.say('no card in focus — :board', 'error')
const rule = arg.trim()
const repeat = rule === '-' || rule === '' ? null : rule
await workspace.guard(async () => {
const card = await (agenda ? workspace.patchAgendaCard({ repeat }) : workspace.patchCard({ repeat }))
const card = await workspace.patchCard({ repeat })
// The server's spelling of the rule: `+14d` is kept as `+2w`.
if (card) workspace.say(card.repeat === null ? 'repeats no more' : `repeats ${card.repeat} once done`)
})
@@ -545,15 +541,8 @@ export const commands: CommandSpec[] = [
{
name: 'done',
group: 'board',
help: 'move the selected card into the done list, or back out to the inbox',
run: () => {
if (workspace.focus === 'agenda') {
const card = workspace.agendaCards[workspace.agendaCursor]
return card ? workspace.guard(() => workspace.finishCard(card)) : workspace.say('no card highlighted', 'error')
}
if (!onBoard()) return workspace.say('no card in focus — :board', 'error')
return workspace.guard(() => workspace.toggleDone())
},
help: 'move the card in focus into the done list, or back out to the inbox',
run: () => workspace.guard(() => workspace.toggleDone()),
},
{
name: 'scratch',
@@ -603,8 +592,8 @@ export const commands: CommandSpec[] = [
help: 'put a label on the card, or take it off when it has it — each name keeps its colour everywhere',
complete: () => workspace.boardLabels,
run: async (arg) => {
if (!onBoard()) return workspace.say('no card in focus — :board', 'error')
if (!arg.trim()) return workspace.say(workspace.card?.labels.join(' · ') || 'no labels — :label <name> adds one')
const card = workspace.focusedCard
if (!arg.trim() && card) return workspace.say(card.labels.join(' · ') || 'no labels — :label <name> adds one')
await workspace.guard(() => workspace.toggleLabel(arg))
},
},
@@ -840,7 +829,7 @@ export const commands: CommandSpec[] = [
name: 'last',
group: 'documents',
help: 'open the last node in the list',
run: () => jumpTo(workspace.nodes.length - 1),
run: () => jumpTo(workspace.rows - 1),
},
{
name: 'random',
@@ -953,6 +942,12 @@ export const commands: CommandSpec[] = [
await openInfo('backlinks')
},
},
{
name: 'cards',
group: 'documents',
help: 'the cards about the open document — its own, and the ones whose summary links here: j/k picks the one :due, :label, Space and H/L act on, Enter turns it over on its board (gc)',
run: () => openInfo('cards'),
},
{
name: 'trash',
args: '[purge]',
@@ -1126,13 +1121,13 @@ export const commands: CommandSpec[] = [
},
{
name: 'info',
args: '[outline|history|backlinks|share]',
args: '[outline|history|backlinks|cards|share]',
group: 'documents',
help: 'what is known about the open document: its headings, earlier versions, backlinks and sharing — Tab between them (gi)',
help: 'what is known about the open document: its headings, earlier versions, backlinks, cards and sharing — Tab between them (gi)',
complete: () => infoSections,
run: (arg) => {
const section = arg.trim()
if (section && !infoSections.includes(section as InfoSection)) return workspace.say('usage: :info [outline|history|backlinks|share]', 'error')
if (section && !infoSections.includes(section as InfoSection)) return workspace.say('usage: :info [outline|history|backlinks|cards|share]', 'error')
return openInfo((section || undefined) as InfoSection | undefined)
},
},
+17 -2
View File
@@ -23,6 +23,7 @@ export type Context =
| 'media'
| 'trash'
| 'backlinks'
| 'cards'
| 'history'
| 'share'
| 'card'
@@ -89,7 +90,7 @@ export const bindings = [
{ context: 'tree', keys: ['zh', 'zl', 'zH', 'zL'], action: 'sideways', label: 'zl / zh · zL / zH', help: 'the wide table or code block at the top of the view, sideways — a little, or half its width' },
{ context: 'tree', keys: [']]'], action: 'nextHeading', label: ']] / [[', help: 'next / previous heading of the document — in the editor too' },
{ context: 'tree', keys: ['[['], action: 'previousHeading' },
{ context: 'tree', keys: ['gi'], action: 'info', help: "the info sheet: the open document's headings, earlier versions, backlinks and sharing — Tab between them" },
{ context: 'tree', keys: ['gi'], action: 'info', help: "the info sheet: the open document's headings, earlier versions, backlinks, cards and sharing — Tab between them" },
{ context: 'tree', keys: ['gO'], action: 'outline', help: 'the headings as a list: j/k, Enter jumps there' },
{ context: 'tree', keys: ['F'], action: 'hints', help: 'hints over the links and buttons in view — type one to follow it' },
{ context: 'tree', label: 'gf / gx', help: 'in the editor: follow the link under the cursor / open it in a tab' },
@@ -112,7 +113,7 @@ export const bindings = [
{ context: 'tree', keys: [']d'], action: 'nextEntry' },
{ context: 'global', keys: ['Ctrl-p'], action: 'find', label: 'Ctrl-P', help: 'find anything, in every space — Enter opens the result lit beside them, n goes on' },
{ context: 'global', keys: ['b'], action: 'board', help: "the board, in a tile: this space's, or the last one shown — 1 … 9 there shows another's" },
{ context: 'tree', keys: ['c'], action: 'capture', help: "capture a card into this space's inbox, without leaving here" },
{ context: 'tree', keys: ['c'], action: 'capture', help: "capture a card into this space's inbox, without leaving here — reading a document, a card about it" },
{ context: 'tree', keys: ['q', 'Escape'], action: 'close', label: 'Esc · q', help: 'close the document, and its tile when there are several' },
{ context: 'tree', keys: ['/'], action: 'search', help: 'full-text search, inside the current space and its text files — Tab or Ctrl-N/P walk the results, Enter opens one' },
{ context: 'tree', keys: ['n'], action: 'next', label: 'n / N', help: 'next / previous search result, opened beside the results — :noh clears the search' },
@@ -123,6 +124,7 @@ export const bindings = [
{ context: 'tree', keys: ['u'], action: 'undo', help: 'undo the last delete: it comes back out of the trash — :trash has the rest' },
{ context: 'tree', keys: ['gh'], action: 'history', help: "the open document's earlier versions, as diffs — Enter brings one back" },
{ context: 'tree', keys: ['gb'], action: 'backlinks', help: 'the documents that link to the open one — [[title]] links by title, [[ completes one while writing' },
{ context: 'tree', keys: ['gc'], action: 'cards', help: 'the cards about the open document — j/k picks the one :due, :label, Space and H/L act on, Enter turns it over on its board' },
{ context: 'global', keys: [':'], action: 'line', help: 'command line — Tab / Shift-Tab cycle the matches, then the argument; ↑ ↓ recall what ran before' },
{ context: 'always', keys: ['Ctrl-s', 'Meta-s'], action: 'save', typing: true, label: 'Ctrl-S', help: 'save, from anywhere' },
{ context: 'global', keys: ['?'], action: 'help', help: 'this screen, from the editor too — h/l a column, / filters it down' },
@@ -259,6 +261,19 @@ export const bindings = [
{ context: 'backlinks', keys: ['G'], action: 'bottom' },
{ context: 'backlinks', keys: ['Enter'], action: 'open', hint: 'open' },
{ context: 'cards', keys: ['j', 'ArrowDown'], action: 'down', caps: ['j', 'k'], hint: 'move' },
{ context: 'cards', keys: ['k', 'ArrowUp'], action: 'up' },
{ context: 'cards', keys: ['G'], action: 'bottom' },
{ context: 'cards', keys: ['Enter'], action: 'turn', hint: 'turn over' },
{ context: 'cards', keys: ['Space'], action: 'done', hint: 'done' },
{ context: 'cards', keys: ['H'], action: 'sendLeft', caps: ['H', 'L'], hint: 'send' },
{ context: 'cards', keys: ['L'], action: 'sendRight' },
{ context: 'cards', keys: ['D'], action: 'due', hint: 'due' },
{ context: 'cards', keys: ['o', 'c'], action: 'new', caps: ['o'], hint: 'new' },
{ context: 'cards', keys: ['a'], action: 'label', hint: 'label' },
// The sheet stays up behind the line, so `:due` and `:repeat` know which card.
{ context: 'cards', keys: [':'], action: 'line' },
{ context: 'history', keys: ['j', 'ArrowDown'], action: 'down', caps: ['j', 'k'], hint: 'earlier / later' },
{ context: 'history', keys: ['k', 'ArrowUp'], action: 'up' },
{ context: 'history', keys: ['G'], action: 'bottom' },
+153 -79
View File
@@ -7,6 +7,7 @@ import {
type AgendaCard,
type Backlink,
type Board,
type BoardList,
type Card,
type Crumb,
type DiaryEntry,
@@ -35,7 +36,7 @@ export type View = Exclude<TileKind, 'node'>
/** What floats over the tiles; one at a time. */
export type Sheet = 'help' | 'media' | 'overview' | 'actions' | 'verify' | 'info' | 'trash'
/** The info sheet's parts: what the open document holds, and what is around it. */
export type InfoSection = 'outline' | 'history' | 'backlinks' | 'share'
export type InfoSection = 'outline' | 'history' | 'backlinks' | 'cards' | 'share'
export type Flash = { text: string; kind: 'info' | 'error' } | null
/**
@@ -137,6 +138,8 @@ class Workspace {
/** Children of the current folder, or the results of a search. */
nodes = $state<NodeSummary[]>([])
/** The cards a search found, listed after the nodes. */
foundCards = $state<AgendaCard[]>([])
cursor = $state(0)
query = $state('')
@@ -407,6 +410,8 @@ class Workspace {
/** `:backlinks`: the documents that link to the open one. */
backlinks = $state<Backlink[]>([])
backlinksCursor = $state(0)
/** The info sheet's cards: where its cursor is in the open document's. */
cardsCursor = $state(0)
overview = $state<SpaceOverview[]>([])
overviewCursor = $state(0)
@@ -434,6 +439,16 @@ class Workspace {
return this.nodes[this.cursor]
}
/** How many rows the list has to walk: the nodes, then the cards a search found. */
get rows(): number {
return this.nodes.length + this.foundCards.length
}
/** The card a search found under the cursor, past the nodes it found. */
get foundCard(): AgendaCard | undefined {
return this.cursor >= this.nodes.length ? this.foundCards[this.cursor - this.nodes.length] : undefined
}
/**
* What a verb acts on: the open document while the document pane has focus,
* the row under the cursor while the list does. Walking the list with a
@@ -460,6 +475,38 @@ class Workspace {
return this.list?.cards[this.cardCursor]
}
/**
* The card the card verbs act on, wherever it is looked at: picked in the
* info sheet, under the board's cursor or turned over beside it,
* highlighted in the agenda — or, reading a document, the one open card
* about it, when there is only one.
*/
get focusedCard(): Card | AgendaCard | undefined {
const about = this.open?.cards ?? []
if (this.overlay === 'info' && this.infoSection === 'cards') return about[this.cardsCursor]
switch (this.focus) {
case 'board':
case 'card':
return this.card
case 'agenda':
return this.agendaCards[this.agendaCursor]
case 'node': {
const open = about.filter((card) => card.done_at === null)
return open.length === 1 ? open[0] : undefined
}
}
}
/** The focused card, or `undefined` once it has been said why there is none. */
#needCard(): Card | AgendaCard | undefined {
const card = this.focusedCard
if (card) return card
const open = this.focus === 'node' ? (this.open?.cards ?? []).filter((c) => c.done_at === null).length : 0
if (open > 1) this.say(`${open} open cards about this — gc picks one`, 'error')
else if (this.focus === 'node' && this.open?.kind === 'document') this.say('no open card about this document — c makes one', 'error')
else this.say('no card in focus — :board', 'error')
}
/**
* The agenda in the order it is drawn and walked, bucketed by urgency. The
* day is the server's, in the workspace's timezone, so a browser on a trip
@@ -946,11 +993,20 @@ class Workspace {
/** `keep` follows a node that just arrived, instead of holding the cursor. */
async refresh(keep?: number) {
const keepId = keep ?? this.selected?.id
this.nodes = this.query
? await api.listNodes({ q: this.query, space: this.globalSearch ? undefined : (this.spaceId ?? undefined) })
: await api.listNodes({ parent: this.parentId ?? undefined })
if (this.query) {
const space = this.#searchSpace
;[this.nodes, this.foundCards] = await Promise.all([api.listNodes({ q: this.query, space }), api.searchCards(this.query, space)])
} else {
this.nodes = await api.listNodes({ parent: this.parentId ?? undefined })
this.foundCards = []
}
const index = this.nodes.findIndex((n) => n.id === keepId)
this.cursor = index >= 0 ? index : Math.min(this.cursor, Math.max(this.nodes.length - 1, 0))
this.cursor = index >= 0 ? index : Math.min(this.cursor, Math.max(this.rows - 1, 0))
}
/** The space a search keeps to; none for one across every space. */
get #searchSpace(): number | undefined {
return this.globalSearch ? undefined : (this.spaceId ?? undefined)
}
async search(query: string, global = this.globalSearch) {
@@ -962,25 +1018,25 @@ class Workspace {
/**
* `n` / `N`: the next or previous search result after the cursor, opened
* beside the results so the next `n` goes on from there. A folder is only
* highlighted: going into it would end the search. Past the last result it
* starts again at the first, as vim's `wrapscan` does.
* beside the results so the next `n` goes on from there. A folder or a card
* is only highlighted: going to it would end the search. Past the last
* result it starts again at the first, as vim's `wrapscan` does.
*/
async nextResult(delta: 1 | -1) {
if (!this.query) return this.say('no search — / starts one', 'error')
const count = this.nodes.length
const count = this.rows
if (count === 0) return this.say(`no results for "${this.query}"`, 'error')
const next = this.cursor + delta
if (next >= count) this.say('search hit BOTTOM, continuing at TOP')
else if (next < 0) this.say('search hit TOP, continuing at BOTTOM')
this.cursor = (next + count) % count
const node = this.nodes[this.cursor]
if (!isContainer(node)) await this.openNode(node.id)
const node = this.selected
if (node && !isContainer(node)) await this.openNode(node.id)
}
move(delta: number) {
if (this.nodes.length === 0) return
this.cursor = Math.min(Math.max(this.cursor + delta, 0), this.nodes.length - 1)
if (this.rows === 0) return
this.cursor = Math.min(Math.max(this.cursor + delta, 0), this.rows - 1)
}
async openNode(id: number, edit = false) {
@@ -1028,6 +1084,8 @@ class Workspace {
/** What Enter does: descend into a folder, open a document or a file. */
async openSelected(edit = false) {
const found = this.foundCard
if (found) return this.turnTo(found)
const selected = this.selected
if (!selected) return
// A match from anywhere opens beside the results, as one from this space
@@ -1470,44 +1528,13 @@ class Workspace {
await this.showView('board')
}
/** Space in the agenda: into that board's done list, from wherever it is. */
async finishCard(card: AgendaCard) {
const done = (await api.board(card.space_id)).lists.at(-1)
if (!done) return
const moved = await api.moveCard(card.id, done.id, done.cards.length)
await this.loadAgenda()
if (this.boardSpaceId === card.space_id) await this.reloadBoard()
this.say(doneSaid(moved))
}
/** `:due` or `:repeat` with the agenda up: the highlighted card, on its own board. */
async patchAgendaCard(patch: Parameters<typeof api.updateCard>[1]) {
const card = this.agendaCards[this.agendaCursor]
if (!card) return this.say('no card highlighted', 'error')
const updated = await api.updateCard(card.id, patch)
await this.loadAgenda()
// It may have changed group; the cursor goes with it.
const at = this.agendaCards.findIndex((c) => c.id === card.id)
if (at >= 0) this.agendaCursor = at
if (this.boardSpaceId === card.space_id) await this.reloadBoard()
return updated
}
/**
* `H` / `L` in the agenda: the card to the list left or right of the one it
* is in, without going to the board — `L` on the last list but one is done.
* Enter on a card a search found, or one picked in a document's info
* sheet: to its board, turned over beside it.
*/
async sendAgendaCard(card: AgendaCard, delta: number) {
const lists = (await api.board(card.space_id)).lists
const from = lists.findIndex((list) => list.id === card.list_id)
const target = lists[from + delta]
if (from < 0 || !target) return this.say(`${card.title} is already in the ${delta < 0 ? 'first' : 'last'} list`, 'error')
const moved = await api.moveCard(card.id, target.id, target.cards.length)
await this.loadAgenda()
const at = this.agendaCards.findIndex((c) => c.id === card.id)
if (at >= 0) this.agendaCursor = at
if (this.boardSpaceId === card.space_id) await this.reloadBoard()
this.say(from + delta === lists.length - 1 ? doneSaid(moved) : `"${card.title}" → ${target.name}`)
async turnTo(card: AgendaCard) {
await this.goToCard(card)
await this.turnCard()
}
async loadOverview() {
@@ -1721,25 +1748,75 @@ class Workspace {
await this.reloadBoard(this.boardSpaceId, card.id)
}
/** `H`/`L`: the same card, one list over — the move a board is really for. */
async sendCard(delta: number) {
await this.sendCardTo(this.listCursor + delta)
/**
* `H`/`L`: the same card, one list over — the move a board is really for.
* From the agenda or a document too, without going to the board.
*/
async sendCard(delta: number, card = this.#needCard()) {
if (!card) return
const lists = await this.#listsOf(card)
await this.#sendTo(card, lists, lists.findIndex((list) => list.id === card.list_id) + delta)
}
/** The digits on the board: the card under the cursor to that list. */
async sendCardTo(index: number) {
const card = this.card
if (!card || index === this.listCursor) return
await this.#sendTo(card, this.board?.lists ?? [], index)
}
/** The lists of a card's board: the one shown, or read for it. */
async #listsOf(card: Card | AgendaCard): Promise<BoardList[]> {
const shown = this.board?.lists ?? []
if (shown.some((list) => list.id === card.list_id)) return shown
return 'space_id' in card ? (await api.board(card.space_id)).lists : []
}
/*
* The last list is "done": the server stamps a card finished as it arrives
* there and clears the stamp as it leaves, so moving is all there is to it.
*/
async sendCardTo(index: number) {
const card = this.card
const lists = this.board?.lists ?? []
async #sendTo(card: Card, lists: BoardList[], index: number) {
const target = lists[index]
if (!card || !target || index === this.listCursor) return
const moved = await api.moveCard(card.id, target.id, target.cards.length)
await this.reloadBoard(this.boardSpaceId, card.id)
if (!target) return this.say(`"${card.title}" is already in the ${index < 0 ? 'first' : 'last'} list`, 'error')
const moved = await api.moveCard(card.id, target.id)
await this.#cardChanged(card)
this.say(index === lists.length - 1 ? doneSaid(moved) : `"${card.title}" → ${target.name}`)
}
/**
* After a card changed, wherever it was changed from: the board, the
* agenda and the open documents read it again, and the cursor that was on
* it stays on it.
*/
async #cardChanged(card: Card) {
const shown = this.rawBoard?.lists.some((list) => list.cards.some((c) => c.id === card.id)) ?? false
const here = this.focus === 'board' || this.focus === 'card'
if (shown) await this.reloadBoard(this.boardSpaceId, here ? card.id : this.card?.id)
if (!shown || this.focus === 'agenda') {
await this.loadAgenda()
// It may have changed group; the cursor goes with it.
const at = this.agendaCards.findIndex((c) => c.id === card.id)
if (at >= 0 && this.focus === 'agenda') this.agendaCursor = at
}
await this.#reloadCardsAbout()
if (this.foundCards.some((c) => c.id === card.id)) this.foundCards = await api.searchCards(this.query, this.#searchSpace)
const at = this.open?.cards.findIndex((c) => c.id === card.id) ?? -1
if (at >= 0) this.cardsCursor = at
}
/** The cards about each document open in a tile, read again. */
async #reloadCardsAbout() {
await Promise.all(
this.tiles.map(async (tile) => {
const id = tile.open?.kind === 'document' ? tile.open.id : null
if (id === null) return
const cards = await api.nodeCards(id).catch(() => null)
if (cards && tile.open?.id === id) tile.open.cards = cards
}),
)
}
/**
* A card lands in the first list — the inbox — of this space's board, or of
* the first space that has one, without leaving the view you are in: writing
@@ -1752,6 +1829,7 @@ class Workspace {
if (!list) return this.say('no list to put it on', 'error')
const card = await api.createCard(list.id, { title, node_id: nodeId })
await this.reloadBoard(this.boardSpaceId, card.id)
await this.#reloadCardsAbout()
return this.say(nodeId === null ? `carded "${card.title}"` : `carded "${card.title}" → #${nodeId}`)
}
@@ -1761,30 +1839,25 @@ class Workspace {
if (!inbox) return this.say(`${space.slug} has no lists — :board, then :list <name>`, 'error')
const card = await api.createCard(inbox.id, { title, node_id: nodeId })
await this.loadAgenda()
await this.#reloadCardsAbout()
this.say(`carded "${card.title}" → ${space.slug}/${inbox.name}`)
}
async patchCard(patch: Parameters<typeof api.updateCard>[1]) {
const card = this.card
if (!card) return this.say('no card selected', 'error')
/** A change to the focused card — or to `card`, when it is known which. */
async patchCard(patch: Parameters<typeof api.updateCard>[1], card = this.#needCard()) {
if (!card) return
const updated = await api.updateCard(card.id, patch)
await this.reloadBoard(this.boardSpaceId, card.id)
await this.#cardChanged(card)
return updated
}
/** Space: into the done list, or back out of it to the inbox. */
async toggleDone() {
const card = this.card
const lists = this.board?.lists ?? []
if (!card) return this.say('no card selected', 'error')
if (lists.length < 2) return this.say('a board needs two lists for one to mean done', 'error')
const last = lists.length - 1
if (this.listCursor === last) {
await this.sendCardTo(0)
this.say(`reopened: ${card.title}`)
} else {
await this.sendCardTo(last)
}
async toggleDone(card = this.#needCard()) {
if (!card) return
const done = card.done_at === null
const moved = await api.updateCard(card.id, { done })
await this.#cardChanged(card)
this.say(done ? doneSaid(moved) : `reopened: ${card.title}`)
}
async deleteCard() {
@@ -1972,7 +2045,8 @@ class Workspace {
async saveDescription() {
const card = this.card
if (!card || !this.describing) return
await this.patchCard({ body: this.description })
// Named, as this also runs when the focus has just left the card.
await this.patchCard({ body: this.description }, card)
this.descriptionDirty = false
this.say(`"${card.title}" described`)
}
@@ -2003,7 +2077,7 @@ class Workspace {
/** The checklist written back whole, with the cursor on step `at`. */
async writeChecklist(checklist: Task[], at = this.taskCursor) {
await this.patchCard({ checklist })
await this.patchCard({ checklist }, this.card)
this.taskCursor = Math.min(Math.max(at, 0), Math.max(checklist.length - 1, 0))
}
@@ -2071,11 +2145,11 @@ class Workspace {
/** `:label work`: on the card, or off it when it is on already. */
toggleLabel(name: string) {
return this.inTurn(async () => {
const card = this.card
if (!card) return this.say('no card selected', 'error')
const card = this.#needCard()
if (!card) return
const label = name.trim().toLowerCase()
const on = card.labels.includes(label)
await this.patchCard({ labels: on ? card.labels.filter((l) => l !== label) : [...card.labels, label] })
await this.patchCard({ labels: on ? card.labels.filter((l) => l !== label) : [...card.labels, label] }, card)
this.say(on ? `${label} off` : `${label} on`)
})
}