store: one workspace, made of parts

The Workspace class held every piece of client state in one 2500-line
file. It is now a root that composes nine parts, each in web/src/lib/state/:

- tree: spaces, folders, the list, search, marks, and what is done to nodes
- tiles: the tiles on screen, the focus trail, and the views among them
- drafts: document drafts and autosave, and text written in place
- cards: the board, the agenda, the card back, and the card verbs
- diary: today's entry, the calendar, stepping between days
- scratch: the scratchpad drawer and its ways out
- sheets: what floats over the tiles, and what each sheet lists
- vault: the vault status and unlocking
- prompts: the [y/N] question, the password box, Proton's check

The root keeps what belongs to no one part: the session, the status
line, guard, what has the focus, and the focused tile's open/draft/
editing. The keyboard layer still sees one `workspace`; call sites read
`workspace.cards.card`, `workspace.tree.nodes` and so on. Behaviour is
unchanged.
This commit is contained in:
nvrl committed 2026-10-22 16:14:38 +02:00
1 parent 7f8d625a8d
commit b1107013ee
40 files changed
+3077 -2924

No files matched your search

+1 -1
View File
@@ -30,7 +30,7 @@ real data stays out of it.
| `migrations/` | the SQLite schema, one numbered file per change |
| `web/src/lib/keymap.ts` | every key: where it is heard, what it is called, its help line |
| `web/src/lib/actions.ts`, `modes.ts` | what each key does, and which contexts hear it first |
| `web/src/lib/store.svelte.ts` | the client's state, and every call to the API |
| `web/src/lib/store.svelte.ts`, `state/` | the client's state: the one `workspace` the keys see, made of parts that each keep one thing — `tree`, `tiles`, `drafts`, `cards`, `diary`, `scratch`, `sheets`, `vault`, `prompts` |
| `web/src/lib/commands.ts` | the `:` commands |
| `web/src/lib/Help.svelte` | the `?` screen |
+25 -25
View File
@@ -72,14 +72,14 @@
if (!shareToken) void workspace.boot()
/* Where each tile sits, in the order of `workspace.tiles`. */
const layout = $derived(dwindle(workspace.tiles.length))
/* Where each tile sits, in the order of `workspace.tiles.all`. */
const layout = $derived(dwindle(workspace.tiles.all.length))
/*
* The tiles are drawn in the order they were made, whatever order they are
* in now, so moving one never moves its element — which would cost it its
* scroll position, and a PDF its page.
*/
const drawn = $derived([...workspace.tiles].sort((a, b) => a.id - b.id))
const drawn = $derived([...workspace.tiles.all].sort((a, b) => a.id - b.id))
$effect(() => {
folderInput?.setAttribute('webkitdirectory', '')
@@ -121,7 +121,7 @@
* window handler instead of being swallowed by the editor underneath.
*/
$effect(() => {
if (workspace.overlay !== 'none') {
if (workspace.sheets.shown !== 'none') {
overlayEl?.focus()
return () => (workspace.editing ? hooks.focusEditor() : shell?.focus())
}
@@ -134,7 +134,7 @@
*/
$effect(() => {
const answer = (event: KeyboardEvent) => {
const prompt = workspace.prompt
const prompt = workspace.prompts.prompt
if (!prompt || ['Shift', 'Control', 'Alt', 'Meta', 'CapsLock'].includes(event.key)) return
event.preventDefault()
event.stopPropagation()
@@ -153,9 +153,9 @@
if (!workspace.user) return
const refresh = () => {
if (document.visibilityState !== 'visible') return
void workspace.loadAgenda().then(() => workspace.openAgendaIfDue())
void workspace.cards.loadAgenda().then(() => workspace.cards.openAgendaIfDue())
// The vault locks itself when left alone; the bar should say so.
void workspace.loadVault()
void workspace.vault.load()
}
const timer = setInterval(refresh, 5 * 60 * 1000)
document.addEventListener('visibilitychange', refresh)
@@ -168,9 +168,9 @@
// The vault closes itself after a quiet spell; look again just after it
// would have, so the bar does not go on saying it is open.
$effect(() => {
const vault = workspace.vault
const vault = workspace.vault.status
if (!vault?.unlocked || vault.locks_in === null) return
const timer = setTimeout(() => void workspace.loadVault(), (vault.locks_in + 2) * 1000)
const timer = setTimeout(() => void workspace.vault.load(), (vault.locks_in + 2) * 1000)
return () => clearTimeout(timer)
})
@@ -183,7 +183,7 @@
// Guard against losing a half-written entry to a stray refresh.
$effect(() => {
if (!workspace.tiles.some((tile) => tile.dirty)) return
if (!workspace.tiles.all.some((tile) => tile.dirty)) return
const warn = (event: BeforeUnloadEvent) => event.preventDefault()
window.addEventListener('beforeunload', warn)
return () => window.removeEventListener('beforeunload', warn)
@@ -198,7 +198,7 @@
// While writing, they go in at the cursor too; while reading, the
// document only holds them.
const writing = workspace.editing
const created = await workspace.attach(files)
const created = await workspace.tree.attach(files)
if (writing && created.length > 0) hooks.insertText(created.map(embedSnippet).join('\n\n'))
})
}
@@ -208,7 +208,7 @@
const files = Array.from(input.files ?? [])
input.value = ''
if (files.length === 0) return
await workspace.guard(() => workspace.putFiles(files, sealUpload))
await workspace.guard(() => workspace.tree.putFiles(files, sealUpload))
}
/*
@@ -223,7 +223,7 @@
const root = importRoot
importRoot = false
if (files.length === 0) return
await workspace.guard(() => workspace.importFolder(files, root))
await workspace.guard(() => workspace.tree.importFolder(files, root))
}
/*
@@ -288,10 +288,10 @@
<main class="porthole">
<!-- Keyed on the space, so a switch slides the new one in, as the desktop
does between workspaces: from below when moving right, above when left. -->
{#key workspace.spaceId}
{#key workspace.tree.spaceId}
<div
class="panes switch"
class:reading={workspace.tiles.some((tile) => !tile.empty)}
class:reading={workspace.tiles.all.some((tile) => !tile.empty)}
class:full={workspace.fullscreen}
class:focus-doc={workspace.focus !== 'list'}
style="--dir: {workspace.switchDirection}"
@@ -302,20 +302,20 @@
class:focused={workspace.focus === 'list'}
onpointerdown={() => (workspace.pane = 'list')}
><Sidebar /></div>
<div class="tiles" class:only={workspace.tiles.length === 1}>
<div class="tiles" class:only={workspace.tiles.all.length === 1}>
{#each drawn as tile (tile.id)}
{@const index = workspace.tiles.indexOf(tile)}
{@const index = workspace.tiles.all.indexOf(tile)}
{@const rect = layout[index]}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="window entry"
class:view={tile.kind !== 'node'}
data-kind={tile.kind}
class:current={index === workspace.focused}
class:focused={!tile.empty && index === workspace.focused && workspace.pane === 'document'}
class:current={index === workspace.tiles.focused}
class:focused={!tile.empty && index === workspace.tiles.focused && workspace.pane === 'document'}
style="--x: {rect.x}; --y: {rect.y}; --w: {rect.w}; --h: {rect.h}"
onpointerdown={() => !tile.empty && workspace.focusTile(index)}
onfocusin={() => !tile.empty && index !== workspace.focused && workspace.focusTile(index)}
onpointerdown={() => !tile.empty && workspace.tiles.focusOn(index)}
onfocusin={() => !tile.empty && index !== workspace.tiles.focused && workspace.tiles.focusOn(index)}
>
{#if tile.kind === 'board'}
<div class="fill" bind:this={boardEl}><Board /></div>
@@ -357,7 +357,7 @@
<CommandLine initial={cmdline} global={cmdGlobal} close={() => ((cmdline = null), (cmdGlobal = false))} />
{/if}
{#if workspace.scratchOpen}
{#if workspace.scratch.open}
<Scratch />
{/if}
@@ -368,10 +368,10 @@
<Hints root={hintRoot} close={() => (hintRoot = null)} />
{/if}
{#if workspace.overlay !== 'none'}
{@const Shown = sheets[workspace.overlay]}
{#if workspace.sheets.shown !== 'none'}
{@const Shown = sheets[workspace.sheets.shown]}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_tabindex -->
<div class="overlay" tabindex="-1" bind:this={overlayEl} onclick={(e) => e.target === e.currentTarget && workspace.closeSheet()}>
<div class="overlay" tabindex="-1" bind:this={overlayEl} onclick={(e) => e.target === e.currentTarget && workspace.sheets.close()}>
<Shown />
</div>
{/if}
+10 -10
View File
@@ -10,7 +10,7 @@
import { hooks, runCommand } from './commands'
import { workspace } from './store.svelte'
const node = $derived(workspace.selected)
const node = $derived(workspace.tree.selected)
type Action = { label: string; run: () => unknown; danger?: boolean }
@@ -19,17 +19,17 @@
const sealed = node.file?.encrypted ?? node.sealed
const holdsFiles = isContainer(node) || node.kind === 'file'
const list: Action[] = [
{ label: isContainer(node) ? 'open' : 'read', run: () => workspace.guard(() => workspace.openSelected()) },
{ label: isContainer(node) ? 'open' : 'read', run: () => workspace.guard(() => workspace.tree.openSelected()) },
{ label: 'rename', run: () => hooks.openCommandLine(`:name ${node.name}`) },
workspace.isMarked(node.id)
? { label: 'put it back — not moving', run: () => workspace.mark(node, false) }
: { label: 'move — then open a folder and drop it there', run: () => workspace.mark(node, false) },
workspace.tree.isMarked(node.id)
? { label: 'put it back — not moving', run: () => workspace.tree.mark(node, false) }
: { label: 'move — then open a folder and drop it there', run: () => workspace.tree.mark(node, false) },
]
if (workspace.marked.length && workspace.parentId !== null && !workspace.isMarked(node.id)) {
const count = workspace.marked.length
if (workspace.tree.marked.length && workspace.tree.parentId !== null && !workspace.tree.isMarked(node.id)) {
const count = workspace.tree.marked.length
list.push({
label: `drop the ${count === 1 ? 'marked node' : `${count} marked nodes`} here`,
run: () => workspace.guard(() => workspace.drop()),
run: () => workspace.guard(() => workspace.tree.drop()),
})
}
if (node.kind === 'document') {
@@ -41,7 +41,7 @@
if (node.shared) list.push({ label: 'new share link — the old one stops working', run: () => runCommand('share new') })
}
// Inside a sealed folder only that folder opens, as a whole.
if (holdsFiles && node.kind !== 'space' && !(sealed && workspace.parentSealed)) {
if (holdsFiles && node.kind !== 'space' && !(sealed && workspace.tree.parentSealed)) {
list.push(
sealed
? { label: node.kind === 'file' ? 'take out of the vault' : 'unseal — take every file out', run: () => runCommand('decrypt') }
@@ -53,7 +53,7 @@
})
function act(action: Action) {
workspace.closeSheet()
workspace.sheets.close()
// The list is what the commands act on while it has the focus.
workspace.pane = 'list'
void action.run()
+15 -15
View File
@@ -16,22 +16,22 @@
const titles = { overdue: 'late', today: 'today', week: 'this week', doing: 'in hand' } as const
const groups = $derived(workspace.agendaGroups.filter((group) => group.cards.length > 0))
const total = $derived(workspace.agendaCards.length)
const groups = $derived(workspace.cards.agendaGroups.filter((group) => group.cards.length > 0))
const total = $derived(workspace.cards.agendaCards.length)
$effect(() => {
const index = workspace.agendaCursor
const index = workspace.cards.agendaCursor
sheet?.querySelector<HTMLElement>(`[data-index="${index}"]`)?.scrollIntoView({ block: 'nearest' })
})
/** An entry from an earlier year, in place in the diary. */
function open(entry: DiaryEntry) {
void workspace.guard(() => workspace.openPath(entry.path))
void workspace.guard(() => workspace.tree.openPath(entry.path))
}
/** Where a card sits in the walked order, across the groups. */
function indexOf(card: AgendaCard) {
return workspace.agendaCards.findIndex((c) => c.id === card.id)
return workspace.cards.agendaCards.findIndex((c) => c.id === card.id)
}
</script>
@@ -41,7 +41,7 @@
<span class="kbd tile-key" title="the key that comes to this tile">t</span>
</header>
{#if workspace.agenda.diary_today === false}
{#if workspace.cards.agenda.diary_today === false}
<p class="diary">
<span class="dot"></span> nothing in the diary today
{#if isTouchDevice}
@@ -61,10 +61,10 @@
<li>
<button
class="row"
class:at={index === workspace.agendaCursor}
class:at={index === workspace.cards.agendaCursor}
data-index={index}
onclick={() => workspace.guard(() => workspace.goToCard(card))}
onpointerenter={() => (workspace.agendaCursor = index)}
onclick={() => workspace.guard(() => workspace.cards.goToCard(card))}
onpointerenter={() => (workspace.cards.agendaCursor = index)}
>
<span class="text">{card.title}</span>
<span class="where">{card.space_slug}/{card.list_name}</span>
@@ -77,7 +77,7 @@
</button>
{#if isTouchDevice}
<!-- Space, for a thumb. -->
<button class="finish" aria-label="done: {card.title}" onclick={() => workspace.guard(() => workspace.toggleDone(card))}>
<button class="finish" aria-label="done: {card.title}" onclick={() => workspace.guard(() => workspace.cards.toggleDone(card))}>
<Icon name="check" size={18} />
</button>
{/if}
@@ -89,19 +89,19 @@
<p class="clear">nothing late, nothing due this week, nothing in hand. <span class="kbd">c</span> captures the next thing.</p>
{/each}
{#if workspace.agenda.on_this_day.length}
{#if workspace.cards.agenda.on_this_day.length}
<section class="past">
<h2>on this day <span class="n">{workspace.agenda.on_this_day.length}</span></h2>
<h2>on this day <span class="n">{workspace.cards.agenda.on_this_day.length}</span></h2>
<ul>
{#each workspace.agenda.on_this_day as entry, at (entry.id)}
{#each workspace.cards.agenda.on_this_day as entry, at (entry.id)}
{@const index = total + at}
<li>
<button
class="row"
class:at={index === workspace.agendaCursor}
class:at={index === workspace.cards.agendaCursor}
data-index={index}
onclick={() => open(entry)}
onpointerenter={() => (workspace.agendaCursor = index)}
onpointerenter={() => (workspace.cards.agendaCursor = index)}
>
<span class="text">{entry.name.trim() || entry.excerpt || 'untitled'}</span>
<span class="due">{entry.day.slice(0, 4)}</span>
+8 -8
View File
@@ -11,8 +11,8 @@
import { workspace } from './store.svelte'
// The space is the first crumb, and it is already named by its pill.
const crumbs = $derived(workspace.path.slice(1))
const due = $derived(workspace.dueCounts)
const crumbs = $derived(workspace.tree.path.slice(1))
const due = $derived(workspace.cards.dueCounts)
</script>
@@ -21,12 +21,12 @@
<span class="divider"></span>
<nav class="spaces" aria-label="spaces">
{#each workspace.spaces as space, index (space.id)}
{#each workspace.tree.spaces as space, index (space.id)}
<button
class="pill"
aria-current={space.id === workspace.spaceId}
aria-current={space.id === workspace.tree.spaceId}
title="{space.name} ({index < 10 ? (index + 1) % 10 : ''})"
onclick={() => workspace.guard(() => workspace.enterSpace(space.id))}
onclick={() => workspace.guard(() => workspace.tree.enterSpace(space.id))}
{@attach into(space.id, `→ ${space.slug}`)}
>
{#if index < 10}<span class="n">{(index + 1) % 10}</span>{/if}<span class="slug">{space.slug}</span>
@@ -43,8 +43,8 @@
<button
class="crumb"
class:here={crumbs.length === 0}
onclick={() => workspace.spaceId !== null && workspace.guard(() => workspace.goTo(workspace.spaceId!))}
{@attach into(workspace.spaceId, 'into ~')}
onclick={() => workspace.tree.spaceId !== null && workspace.guard(() => workspace.tree.goTo(workspace.tree.spaceId!))}
{@attach into(workspace.tree.spaceId, 'into ~')}
>
~
</button>
@@ -53,7 +53,7 @@
<button
class="crumb"
class:here={index === crumbs.length - 1}
onclick={() => workspace.guard(() => workspace.goTo(crumb.id))}
onclick={() => workspace.guard(() => workspace.tree.goTo(crumb.id))}
{@attach into(crumb.id, `into ${crumb.slug}`)}
>
{crumb.slug}
+18 -18
View File
@@ -12,16 +12,16 @@
// Keep the selected card on screen while walking the board with the keyboard.
$effect(() => {
const at = `${workspace.listCursor}:${workspace.cardCursor}`
const at = `${workspace.cards.listCursor}:${workspace.cards.cardCursor}`
columns?.querySelector<HTMLElement>(`[data-at="${at}"]`)?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
})
const total = $derived(workspace.board?.lists.reduce((n, list) => n + list.cards.length, 0) ?? 0)
const lastIndex = $derived((workspace.board?.lists.length ?? 0) - 1)
const total = $derived(workspace.cards.board?.lists.reduce((n, list) => n + list.cards.length, 0) ?? 0)
const lastIndex = $derived((workspace.cards.board?.lists.length ?? 0) - 1)
/** Done cards older than a week, kept off the board until `z` asks. */
const hidden = $derived(
(workspace.rawBoard?.lists.at(-1)?.cards.length ?? 0) - (workspace.board?.lists.at(-1)?.cards.length ?? 0),
(workspace.cards.rawBoard?.lists.at(-1)?.cards.length ?? 0) - (workspace.cards.board?.lists.at(-1)?.cards.length ?? 0),
)
/** What a column is for, by where it stands — the names are yours to choose. */
@@ -32,27 +32,27 @@
/** The spaces that have a board, with the digit that switches to each. */
const boards = $derived(
workspace.spaces.flatMap((space, index) => (space.has_board && index < 10 ? [{ space, digit: (index + 1) % 10 }] : [])),
workspace.tree.spaces.flatMap((space, index) => (space.has_board && index < 10 ? [{ space, digit: (index + 1) % 10 }] : [])),
)
function pick(list: number, card: number) {
workspace.listCursor = list
workspace.cardCursor = card
workspace.cards.listCursor = list
workspace.cards.cardCursor = card
}
</script>
<div class="board">
<div class="head label rule">
<span class="marker"></span>
<span class="name">{workspace.boardSpace?.slug ?? ''}</span>
<span class="name">{workspace.cards.boardSpace?.slug ?? ''}</span>
<span>board</span>
{#if workspace.board}<span class="count">{total} {total === 1 ? 'card' : 'cards'}</span>{/if}
{#if workspace.cards.board}<span class="count">{total} {total === 1 ? 'card' : 'cards'}</span>{/if}
<!-- Every board is a digit away: the digit switches space, and the board
follows the space. -->
{#if boards.length > 1}
<span class="boards">
{#each boards as { space, digit } (space.id)}
<button class="other" class:here={space.id === workspace.boardSpaceId} onclick={() => workspace.guard(() => workspace.switchSpace(digit === 0 ? 9 : digit - 1))}>
<button class="other" class:here={space.id === workspace.cards.boardSpaceId} onclick={() => workspace.guard(() => workspace.tree.switchSpace(digit === 0 ? 9 : digit - 1))}>
<span class="n">{digit}</span>{space.slug}
</button>
{/each}
@@ -62,9 +62,9 @@
</div>
<div class="columns" bind:this={columns}>
{#each workspace.board?.lists ?? [] as list, listIndex (list.id)}
{#each workspace.cards.board?.lists ?? [] as list, listIndex (list.id)}
{@const kind = role(listIndex)}
<section class="column" class:on={listIndex === workspace.listCursor} class:finished={kind === 'done'}>
<section class="column" class:on={listIndex === workspace.cards.listCursor} class:finished={kind === 'done'}>
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<header class="label" onclick={() => pick(listIndex, 0)}>
<span class="marker"></span>
@@ -78,11 +78,11 @@
<li>
<button
class="card"
class:active={listIndex === workspace.listCursor && cardIndex === workspace.cardCursor}
class:active={listIndex === workspace.cards.listCursor && cardIndex === workspace.cards.cardCursor}
class:done={card.done_at !== null}
data-at="{listIndex}:{cardIndex}"
onclick={() => pick(listIndex, cardIndex)}
ondblclick={() => workspace.guard(() => workspace.turnCard())}
ondblclick={() => workspace.guard(() => workspace.cards.turnCard())}
title="Enter turns the card over"
>
{#if card.labels.length}
@@ -121,10 +121,10 @@
{#if kind === 'done'}nothing finished this week{:else if kind === 'inbox'}empty — c captures from anywhere{:else}no cards — o adds one{/if}
</li>
{/each}
{#if kind === 'done' && (hidden > 0 || workspace.showOldDone)}
{#if kind === 'done' && (hidden > 0 || workspace.cards.showOldDone)}
<li>
<button class="older" onclick={() => (workspace.showOldDone = !workspace.showOldDone)}>
{workspace.showOldDone ? 'hide older' : `+${hidden} older`} <span class="kbd">z</span>
<button class="older" onclick={() => (workspace.cards.showOldDone = !workspace.cards.showOldDone)}>
{workspace.cards.showOldDone ? 'hide older' : `+${hidden} older`} <span class="kbd">z</span>
</button>
</li>
{/if}
@@ -132,7 +132,7 @@
</section>
{:else}
<div class="empty muted">
{#if !workspace.boardSpace}
{#if !workspace.cards.boardSpace}
no space has a board — :set board gives the one you are in one.
{:else}
no lists yet — :list &lt;name&gt; makes a column. then o adds a card to it.
+10 -10
View File
@@ -10,7 +10,7 @@
const WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
const month = $derived(workspace.calendarDay.slice(0, 7))
const month = $derived(workspace.diary.calendarDay.slice(0, 7))
const title = $derived.by(() => {
const [y, m] = month.split('-').map(Number)
return new Date(y, m - 1, 1).toLocaleDateString(undefined, { month: 'long', year: 'numeric' }).toLowerCase()
@@ -28,21 +28,21 @@
const today = $derived(formatDay(Math.floor(Date.now() / 1000)))
const counts = $derived(
workspace.calendarEntries.reduce((all, entry) => all.set(entry.day, (all.get(entry.day) ?? 0) + 1), new Map<string, number>()),
workspace.diary.calendarEntries.reduce((all, entry) => all.set(entry.day, (all.get(entry.day) ?? 0) + 1), new Map<string, number>()),
)
const chosen = $derived(workspace.calendarEntries.filter((entry) => entry.day === workspace.calendarDay))
const chosen = $derived(workspace.diary.calendarEntries.filter((entry) => entry.day === workspace.diary.calendarDay))
function pick(day: string) {
workspace.calendarDay = day
void workspace.guard(() => workspace.openCalendarDay())
workspace.diary.calendarDay = day
void workspace.guard(() => workspace.diary.openCalendarDay())
}
</script>
<div class="sheet">
<header>
<span class="label rule"><span class="marker"></span><span class="title">{title}</span> {workspace.calendarEntries.length} entries</span>
<button onclick={() => workspace.guard(() => workspace.moveCalendar(0, -1))} aria-label="previous month">‹</button>
<button onclick={() => workspace.guard(() => workspace.moveCalendar(0, 1))} aria-label="next month">›</button>
<span class="label rule"><span class="marker"></span><span class="title">{title}</span> {workspace.diary.calendarEntries.length} entries</span>
<button onclick={() => workspace.guard(() => workspace.diary.moveCalendar(0, -1))} aria-label="previous month">‹</button>
<button onclick={() => workspace.guard(() => workspace.diary.moveCalendar(0, 1))} aria-label="next month">›</button>
<span class="kbd tile-key" title="the key that comes to this tile">C</span>
</header>
@@ -55,7 +55,7 @@
<li>
<button
class="day"
class:at={day === workspace.calendarDay}
class:at={day === workspace.diary.calendarDay}
class:today={day === today}
class:written={counts.has(day)}
onclick={() => pick(day)}
@@ -71,7 +71,7 @@
</ol>
<section class="chosen">
<h2>{workspace.calendarDay}</h2>
<h2>{workspace.diary.calendarDay}</h2>
{#each chosen as entry (entry.id)}
<p class="entry"><span class="name">{entry.name.trim() || entry.excerpt || 'untitled'}</span></p>
{:else}
+17 -17
View File
@@ -12,10 +12,10 @@
import { workspace } from './store.svelte'
import { formatDay, labelHue, urgency } from './util'
const card = $derived(workspace.card)
const list = $derived(workspace.list)
const lists = $derived(workspace.board?.lists.length ?? 0)
const role = $derived(lists < 2 ? '' : workspace.listCursor === 0 ? 'inbox' : workspace.listCursor === lists - 1 ? 'done' : '')
const card = $derived(workspace.cards.card)
const list = $derived(workspace.cards.list)
const lists = $derived(workspace.cards.board?.lists.length ?? 0)
const role = $derived(lists < 2 ? '' : workspace.cards.listCursor === 0 ? 'inbox' : workspace.cards.listCursor === lists - 1 ? 'done' : '')
const ticked = $derived(card?.checklist.filter((task) => task.done).length ?? 0)
const html = $derived(card?.body.trim() ? renderMarkdown(card.body, { wikilinks: card.wikilinks }) : '')
@@ -26,12 +26,12 @@
$effect(() => {
if (card?.id === shown) return
shown = card?.id
workspace.taskCursor = 0
workspace.cards.taskCursor = 0
})
// Keep the step under the cursor on screen.
$effect(() => {
const index = workspace.taskCursor
const index = workspace.cards.taskCursor
root?.querySelector<HTMLElement>(`[data-index="${index}"]`)?.scrollIntoView({ block: 'nearest' })
})
@@ -45,12 +45,12 @@
}
function pick(index: number) {
workspace.taskCursor = index
workspace.cards.taskCursor = index
}
function tick(index: number) {
workspace.taskCursor = index
void workspace.guard(() => workspace.toggleTask())
workspace.cards.taskCursor = index
void workspace.guard(() => workspace.cards.toggleTask())
}
</script>
@@ -58,7 +58,7 @@
<header>
<span class="label rule">
<span class="marker"></span><span class="title">card</span>
{#if card}{workspace.boardSpace?.slug ?? ''} / {list?.name ?? ''}{role ? ` · ${role}` : ''}{/if}
{#if card}{workspace.cards.boardSpace?.slug ?? ''} / {list?.name ?? ''}{role ? ` · ${role}` : ''}{/if}
</span>
<span class="kbd tile-key" title="the key that turns a card over, on the board">↵</span>
</header>
@@ -73,29 +73,29 @@
{#if card.due_at !== null}<span class={urgency(card)}>due {formatDay(card.due_at)}</span>{/if}
{#if card.repeat !== null}<span>↻ {card.repeat}</span>{/if}
{#if card.node_id !== null}
<button class="doc" onclick={() => workspace.guard(() => workspace.openCard())}>→ {card.node_name ?? `#${card.node_id}`}</button>
<button class="doc" onclick={() => workspace.guard(() => workspace.cards.openCard())}>→ {card.node_name ?? `#${card.node_id}`}</button>
{/if}
</div>
{/if}
<section>
<div class="label head"><span class="marker"></span>description</div>
{#if workspace.describing && workspace.writing}
{#if workspace.cards.describing && workspace.drafts.writing}
{#await import('./Editor.svelte')}
<div class="empty label">loading editor</div>
{:then { default: Editor }}
{#key workspace.writing.session}
<Editor writing={workspace.writing} hint="what this card is about, in short — [[ links a document" />
{#key workspace.drafts.writing.session}
<Editor writing={workspace.drafts.writing} hint="what this card is about, in short — [[ links a document" />
{/key}
{/await}
{:else if html}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_element_interactions -->
<article class="md description" onclick={follow} ondblclick={() => workspace.describe()}>
<article class="md description" onclick={follow} ondblclick={() => workspace.cards.describe()}>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in renderMarkdown -->
{@html html}
</article>
{:else}
<button class="empty" onclick={() => workspace.describe()}>nothing written yet — <span class="kbd">e</span> writes it</button>
<button class="empty" onclick={() => workspace.cards.describe()}>nothing written yet — <span class="kbd">e</span> writes it</button>
{/if}
</section>
@@ -112,7 +112,7 @@
<ul>
{#each card.checklist as task, index (index)}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<li class:at={index === workspace.taskCursor && !workspace.describing} class:ticked={task.done} data-index={index} onclick={() => pick(index)}>
<li class:at={index === workspace.cards.taskCursor && !workspace.cards.describing} class:ticked={task.done} data-index={index} onclick={() => pick(index)}>
<button class="tick" aria-label={task.done ? 'untick' : 'tick'} onclick={(e) => (e.stopPropagation(), tick(index))}></button>
<span class="text">{task.text}</span>
</li>
+1 -1
View File
@@ -41,7 +41,7 @@
try {
let question = `password for ${name}`
for (;;) {
const password = await workspace.askSecret(question)
const password = await workspace.prompts.askSecret(question)
if (password === null) return
try {
return await load(password)
+6 -6
View File
@@ -117,7 +117,7 @@
$effect(() => {
if (!isSearch) return
const query = value.slice(1)
const timer = setTimeout(() => void workspace.guard(() => workspace.search(query, global)), 120)
const timer = setTimeout(() => void workspace.guard(() => workspace.tree.search(query, global)), 120)
return () => clearTimeout(timer)
})
@@ -146,8 +146,8 @@
// was typed rather than for the line a keystroke ago.
const query = line.slice(1)
await workspace.guard(async () => {
if (query && workspace.query !== query) await workspace.search(query, global)
if (query && (workspace.selected || workspace.foundCard || workspace.foundNote)) await workspace.openSelected()
if (query && workspace.tree.query !== query) await workspace.tree.search(query, global)
if (query && (workspace.tree.selected || workspace.tree.foundCard || workspace.tree.foundNote)) await workspace.tree.openSelected()
})
hooks.focusList()
return
@@ -202,14 +202,14 @@
} else if (event.key === 'Tab' && isSearch) {
// Nothing to complete in a search: Tab walks the results instead.
event.preventDefault()
workspace.move(event.shiftKey ? -1 : 1)
workspace.tree.move(event.shiftKey ? -1 : 1)
} else if (event.key === 'Tab') {
event.preventDefault()
if (!retarget(event.shiftKey ? -1 : 1)) complete(event.shiftKey ? -1 : 1)
} else if (ctrl && isSearch && (event.key === 'n' || event.key === 'p')) {
// The results, walked without leaving the line; Enter opens the one lit.
event.preventDefault()
workspace.move(event.key === 'n' ? 1 : -1)
workspace.tree.move(event.key === 'n' ? 1 : -1)
} else if (event.key === 'ArrowUp' || (ctrl && event.key === 'p')) {
event.preventDefault()
recall(1)
@@ -244,7 +244,7 @@
{/if}
<form onsubmit={submit}>
{#if isSearch}<span class="scope label">{global ? 'all spaces' : (workspace.space?.slug ?? '')}</span>{/if}
{#if isSearch}<span class="scope label">{global ? 'all spaces' : (workspace.tree.space?.slug ?? '')}</span>{/if}
{#if capture}<span class="scope label" title="Tab: card · note · today">→ {targetNames[capture]} <span class="kbd">tab</span></span>{/if}
<input bind:this={input} bind:value {onkeydown} {oninput} spellcheck="false" autocomplete="off" />
</form>
+4 -4
View File
@@ -106,15 +106,15 @@
const element = event.target as HTMLElement
const media = element.closest('img, video, audio') as HTMLImageElement | HTMLMediaElement | null
if (!media || !(media.getAttribute('src') ?? media.querySelector('source')?.getAttribute('src') ?? '').includes('/api/media/')) return
await workspace.loadVault()
if (!workspace.vault?.configured || workspace.vault.unlocked || !media.isConnected) return
await workspace.vault.load()
if (!workspace.vault.status?.configured || workspace.vault.status.unlocked || !media.isConnected) return
const button = document.createElement('button')
button.className = 'sealed-embed'
button.textContent = 'sealed — unlock to show'
button.onclick = async (click) => {
click.preventDefault()
click.stopPropagation()
if (!(await workspace.ensureUnlocked())) return
if (!(await workspace.vault.ensureUnlocked())) return
button.replaceWith(media)
if (media instanceof HTMLImageElement) media.src = media.src
else media.load()
@@ -190,7 +190,7 @@
bind:this={title}
placeholder="Untitled"
bind:value={tile.draft.name}
oninput={() => workspace.touch(tile)}
oninput={() => workspace.drafts.touch(tile)}
onkeydown={(event) => {
// The title is the line above the text: any of these goes back down.
if (['Enter', 'Escape', 'ArrowDown', 'Tab'].includes(event.key) && !event.shiftKey) {
+3 -3
View File
@@ -248,7 +248,7 @@
async function uploadFiles(files: File[]) {
if (files.length === 0) return
await workspace.guard(async () => {
const created = await workspace.attach(files, tile)
const created = await workspace.tree.attach(files, tile)
if (created.length > 0) insertAtCursor(created.map(embedSnippet).join('\n\n'))
})
}
@@ -257,9 +257,9 @@
const changed = (text: string) => {
if (writing) return writing.change(text)
tile!.draft.body = text
workspace.touch(tile)
workspace.drafts.touch(tile)
}
const save = () => void workspace.guard(() => (writing ? workspace.saveWriting() : workspace.save({ tile })))
const save = () => void workspace.guard(() => (writing ? workspace.drafts.saveWriting() : workspace.drafts.save({ tile })))
onMount(() => {
bindVim()
+1 -1
View File
@@ -14,7 +14,7 @@
const node = $derived(tile.open!)
const file = $derived(node.file!)
/* A sealed file cannot be fetched while the vault is shut. */
const locked = $derived(file.encrypted && !workspace.vault?.unlocked)
const locked = $derived(file.encrypted && !workspace.vault.status?.unlocked)
const kind = $derived(previewKind(node.name, file.mime))
/* What the browser can show by itself; everything else is a download. */
const viewable = $derived(file.mime !== 'application/octet-stream')
+2 -2
View File
@@ -18,12 +18,12 @@
export function openHelp(all = false) {
helpDepth.all = all
workspace.openSheet('help')
workspace.sheets.open('help')
}
/** `?` in the help: everything, and the help away once it is all on show. */
export function deeperHelp() {
if (helpDepth.all) workspace.closeSheet()
if (helpDepth.all) workspace.sheets.close()
else helpDepth.all = true
}
+50 -50
View File
@@ -15,42 +15,42 @@
/** Tab and Shift-Tab: the next part along, round the ends. */
export function turnInfo(by: number) {
const at = infoSections.indexOf(workspace.infoSection)
workspace.infoSection = infoSections[(at + by + infoSections.length) % infoSections.length]
const at = infoSections.indexOf(workspace.sheets.infoSection)
workspace.sheets.infoSection = infoSections[(at + by + infoSections.length) % infoSections.length]
}
/** Enter on a heading: the sheet away, and the text scrolled there. */
export function goHeading(index = workspace.outlineCursor) {
const heading = workspace.outline[index]
workspace.closeSheet()
export function goHeading(index = workspace.sheets.outlineCursor) {
const heading = workspace.sheets.outline[index]
workspace.sheets.close()
heading?.go()
}
/** What Enter and the button do: the shown revision becomes the document again. */
export async function restoreShown() {
const revision = workspace.revision
const revision = workspace.sheets.revision
if (!revision) return
if (!(await workspace.ask(`bring back the version from ${formatStamp(revision.saved_at)}? the text now is kept in the history`))) return
await workspace.guard(() => workspace.restoreRevision(revision))
if (!(await workspace.prompts.ask(`bring back the version from ${formatStamp(revision.saved_at)}? the text now is kept in the history`))) return
await workspace.guard(() => workspace.sheets.restoreRevision(revision))
}
export function moveHistory(by: number) {
const last = Math.max(workspace.history.length - 1, 0)
const index = Math.min(Math.max(workspace.historyCursor + by, 0), last)
if (index !== workspace.historyCursor) void workspace.guard(() => workspace.showRevision(index))
const last = Math.max(workspace.sheets.history.length - 1, 0)
const index = Math.min(Math.max(workspace.sheets.historyCursor + by, 0), last)
if (index !== workspace.sheets.historyCursor) void workspace.guard(() => workspace.sheets.showRevision(index))
}
/** Enter on one of the document's files: the file is opened. */
export function openFile(file: NodeSummary) {
workspace.closeSheet()
return workspace.guard(() => workspace.openNode(file.id))
workspace.sheets.close()
return workspace.guard(() => workspace.tree.openNode(file.id))
}
/** `i`: the file embedded at the cursor, while the document is being written. */
export function embedFile(file: NodeSummary) {
if (!workspace.editing) return workspace.say('embedding works while writing — i on the document first', 'error')
hooks.insertText(embedSnippet(file))
workspace.closeSheet()
workspace.sheets.close()
}
/** `y`: how to embed it, to paste into another document. */
@@ -61,29 +61,29 @@
/** Enter on a backlink: the linking document is opened. */
export function openBacklink(link: Backlink) {
workspace.closeSheet()
return workspace.guard(() => workspace.openPath(link.path))
workspace.sheets.close()
return workspace.guard(() => workspace.tree.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))
workspace.sheets.close()
return workspace.guard(() => workspace.cards.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')
if (node.shared && !(await workspace.ask(`stop sharing "${node.name.trim() || node.slug}"? the link stops working`))) return
await workspace.guard(() => workspace.toggleShare(node.id))
if (node.shared && !(await workspace.prompts.ask(`stop sharing "${node.name.trim() || node.slug}"? the link stops working`))) return
await workspace.guard(() => workspace.tree.toggleShare(node.id))
}
// A new link retires the one readers have, so it asks first.
export async function renewLink(node: NodeSummary | null | undefined) {
if (!node) return workspace.say('nothing selected', 'error')
if (!node.shared) return workspace.say('not shared — :share shares it', 'error')
if (!(await workspace.ask(`new share link for "${node.name.trim() || node.slug}"? the current one stops working`))) return
await workspace.guard(() => workspace.renewShareLink(node.id))
if (!(await workspace.prompts.ask(`new share link for "${node.name.trim() || node.slug}"? the current one stops working`))) return
await workspace.guard(() => workspace.tree.renewShareLink(node.id))
}
const REASONS: Record<string, string> = {
@@ -115,14 +115,14 @@
]
.filter(Boolean)
.join(' · ')
const section = $derived(workspace.infoSection)
const section = $derived(workspace.sheets.infoSection)
// The shallowest level in the document is the left edge, so a text that
// starts at `##` is not indented by a level it never uses.
const top = $derived(Math.min(...workspace.outline.map((heading) => heading.level)))
const top = $derived(Math.min(...workspace.sheets.outline.map((heading) => heading.level)))
// Against the text as it is now: what restoring would take out and put back.
const rows = $derived(section === 'history' && workspace.revision ? folded(diffLines(workspace.draft.body, workspace.revision.body)) : [])
const rows = $derived(section === 'history' && workspace.sheets.revision ? folded(diffLines(workspace.draft.body, workspace.sheets.revision.body)) : [])
const changed = $derived(rows.some((row) => row.kind === 'gone' || row.kind === 'added'))
/*
@@ -133,9 +133,9 @@
const sharePath = $derived(doc?.share_token ? `${location.origin}/s/${doc.share_token}#…` : null)
const tabs = $derived<[InfoSection, string][]>([
['outline', `outline ${workspace.outline.length}`],
['history', `history ${workspace.history.length}`],
['backlinks', `linked from ${workspace.backlinks.length}`],
['outline', `outline ${workspace.sheets.outline.length}`],
['history', `history ${workspace.sheets.history.length}`],
['backlinks', `linked from ${workspace.sheets.backlinks.length}`],
['cards', `cards ${doc?.cards.filter((card) => card.done_at === null).length ?? 0}`],
['files', `files ${doc?.files.length ?? 0}`],
['share', doc?.shared ? 'shared' : 'private'],
@@ -143,11 +143,11 @@
const cursor = $derived(
{
outline: workspace.outlineCursor,
history: workspace.historyCursor,
backlinks: workspace.backlinksCursor,
cards: workspace.cardsCursor,
files: workspace.filesCursor,
outline: workspace.sheets.outlineCursor,
history: workspace.sheets.historyCursor,
backlinks: workspace.sheets.backlinksCursor,
cards: workspace.sheets.cardsCursor,
files: workspace.sheets.filesCursor,
share: undefined,
}[section],
)
@@ -155,28 +155,28 @@
<Sheet title="info" detail={doc?.name.trim() || doc?.slug || ''} width={120} fill={section === 'history'} {cursor} keys={[section, 'info', 'sheet']}>
{#snippet actions()}
{#if section === 'history' && workspace.revision}
{#if section === 'history' && workspace.sheets.revision}
<button onclick={restoreShown}>restore</button>
{/if}
{/snippet}
<nav class="tabs">
{#each tabs as [name, text] (name)}
<button class:on={name === section} onclick={() => (workspace.infoSection = name)}>{text}</button>
<button class:on={name === section} onclick={() => (workspace.sheets.infoSection = name)}>{text}</button>
{/each}
</nav>
{#if section === 'outline'}
<ol class="items">
{#each workspace.outline as heading, index (index)}
{#each workspace.sheets.outline as heading, index (index)}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<li
class="item"
class:at={index === workspace.outlineCursor}
class:at={index === workspace.sheets.outlineCursor}
style:padding-left="{8 + (heading.level - top) * 18}px"
data-index={index}
onclick={() => goHeading(index)}
onpointerenter={() => (workspace.outlineCursor = index)}
onpointerenter={() => (workspace.sheets.outlineCursor = index)}
>
<span class="name">{heading.text}</span>
</li>
@@ -187,9 +187,9 @@
{:else if section === 'history'}
<div class="split">
<ul class="items versions">
{#each workspace.history as summary, index (summary.id)}
{#each workspace.sheets.history as summary, index (summary.id)}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<li class="item" class:at={index === workspace.historyCursor} data-index={index} onclick={() => workspace.guard(() => workspace.showRevision(index))}>
<li class="item" class:at={index === workspace.sheets.historyCursor} data-index={index} onclick={() => workspace.guard(() => workspace.sheets.showRevision(index))}>
<span class="when">{formatStamp(summary.saved_at)}</span>
<span class="muted">{relative(summary.saved_at)} · {reason(summary)} · {summary.size} chars</span>
</li>
@@ -199,10 +199,10 @@
</ul>
<div class="diff">
{#if workspace.revision}
{#if workspace.revision.name !== workspace.draft.name}
{#if workspace.sheets.revision}
{#if workspace.sheets.revision.name !== workspace.draft.name}
<div class="row gone">- # {workspace.draft.name}</div>
<div class="row added">+ # {workspace.revision.name}</div>
<div class="row added">+ # {workspace.sheets.revision.name}</div>
{/if}
{#each rows as row, index (index)}
{#if row.kind === 'fold'}
@@ -219,13 +219,13 @@
</div>
{:else if section === 'backlinks'}
<ul class="items">
{#each workspace.backlinks as link, index (link.id)}
{#each workspace.sheets.backlinks as link, index (link.id)}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<li
class="item"
class:at={index === workspace.backlinksCursor}
class:at={index === workspace.sheets.backlinksCursor}
data-index={index}
onpointerenter={() => (workspace.backlinksCursor = index)}
onpointerenter={() => (workspace.sheets.backlinksCursor = index)}
onclick={() => openBacklink(link)}
>
<span class="name">{link.name.trim() || link.slug}</span>
@@ -242,10 +242,10 @@
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<li
class="item"
class:at={index === workspace.cardsCursor}
class:at={index === workspace.sheets.cardsCursor}
class:done={card.done_at !== null}
data-index={index}
onpointerenter={() => (workspace.cardsCursor = index)}
onpointerenter={() => (workspace.sheets.cardsCursor = index)}
onclick={() => turnCard(card)}
>
<span class="name">{card.title}</span>
@@ -262,8 +262,8 @@
<li
class="item"
data-index={index}
class:at={index === workspace.filesCursor}
onpointerenter={() => (workspace.filesCursor = index)}
class:at={index === workspace.sheets.filesCursor}
onpointerenter={() => (workspace.sheets.filesCursor = index)}
onclick={() => openFile(file)}
>
<span class="name">{file.name}</span>
+9 -9
View File
@@ -20,32 +20,32 @@
})
function enter(spaceId: number) {
workspace.closeSheet()
void workspace.guard(() => workspace.enterSpace(spaceId))
workspace.sheets.close()
void workspace.guard(() => workspace.tree.enterSpace(spaceId))
}
/** A recent document opens in place, in its own space. */
function open(doc: NodeSummary) {
workspace.closeSheet()
workspace.sheets.close()
void workspace.guard(async () => {
const node = await api.getNode(doc.id)
await workspace.openPath(node.path.map((crumb) => crumb.slug).join('/'))
await workspace.tree.openPath(node.path.map((crumb) => crumb.slug).join('/'))
})
}
</script>
<Sheet title="overview" detail="{workspace.overview.length} spaces" width={140} cursor={workspace.overviewCursor} keys={['overview']}>
<Sheet title="overview" detail="{workspace.sheets.overview.length} spaces" width={140} cursor={workspace.sheets.overviewCursor} keys={['overview']}>
<ol class="grid" bind:this={grid}>
{#each workspace.overview as { space, recent }, index (space.id)}
{#each workspace.sheets.overview as { space, recent }, index (space.id)}
<li>
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div
class="card"
class:at={index === workspace.overviewCursor}
class:current={space.id === workspace.spaceId}
class:at={index === workspace.sheets.overviewCursor}
class:current={space.id === workspace.tree.spaceId}
data-index={index}
onclick={() => enter(space.id)}
onpointerenter={() => (workspace.overviewCursor = index)}
onpointerenter={() => (workspace.sheets.overviewCursor = index)}
>
<div class="top">
<span class="index">{index < 10 ? (index + 1) % 10 : ''}</span>
+6 -6
View File
@@ -1,18 +1,18 @@
<script lang="ts">
/*
* The `[y/N]` question `workspace.ask` raises, drawn where the command line
* The `[y/N]` question `workspace.prompts.ask` raises, drawn where the command line
* opens. The keys are read in App.svelte, ahead of everything else, so a
* stray `y` can never reach the editor or the list underneath.
*/
import { workspace } from './store.svelte'
</script>
{#if workspace.prompt}
<div class="prompt" role="alertdialog" aria-label={workspace.prompt.text}>
<span class="text">{workspace.prompt.text}</span>
{#if workspace.prompts.prompt}
<div class="prompt" role="alertdialog" aria-label={workspace.prompts.prompt.text}>
<span class="text">{workspace.prompts.prompt.text}</span>
<span class="choice">
<button class="yes" onclick={() => workspace.prompt?.resolve(true)}><span class="kbd">y</span> yes</button>
<button onclick={() => workspace.prompt?.resolve(false)}>no</button>
<button class="yes" onclick={() => workspace.prompts.prompt?.resolve(true)}><span class="kbd">y</span> yes</button>
<button onclick={() => workspace.prompts.prompt?.resolve(false)}>no</button>
</span>
</div>
{/if}
+11 -11
View File
@@ -33,7 +33,7 @@
const settings: Door = { icon: 'settings', label: 'Settings', keys: keysOf('global', 'settings'), run: () => runCommand('settings') }
const focus = workspace.focus
const board: Door = workspace.space?.has_board || focus === 'board'
const board: Door = workspace.tree.space?.has_board || focus === 'board'
? { icon: 'board', label: 'Board', keys: keysOf('global', 'board'), run: () => runCommand('board'), on: focus === 'board' }
: { icon: 'board', label: 'Give this space a board', keys: [':set board'], run: () => runCommand('set board'), dim: true }
const agenda: Door = { icon: 'agenda', label: 'Agenda — late, due, in hand', keys: keysOf('global', 'agenda'), run: () => runCommand('agenda'), on: focus === 'agenda' }
@@ -45,7 +45,7 @@
if (focus === 'board' || focus === 'card') {
// The card verbs are doors too, so a phone — which has no H, L or
// Space — can still move work along.
const card = !!workspace.card
const card = !!workspace.cards.card
return [
[board, agenda, calendar, ...close],
[
@@ -53,16 +53,16 @@
{ icon: 'column-plus', label: 'New list', keys: [':list'], run: () => hooks.openCommandLine(':list ') },
],
[
{ icon: 'left', label: 'Send the card left', keys: keysOf('board', 'sendLeft'), run: () => workspace.guard(() => workspace.sendCard(-1)), dim: !card },
{ icon: 'check', label: 'Done, or back to the inbox', keys: keysOf('board', 'done'), run: () => workspace.guard(() => workspace.toggleDone()), dim: !card },
{ icon: 'right', label: 'Send the card right', keys: keysOf('board', 'sendRight'), run: () => workspace.guard(() => workspace.sendCard(1)), dim: !card },
{ icon: 'left', label: 'Send the card left', keys: keysOf('board', 'sendLeft'), run: () => workspace.guard(() => workspace.cards.sendCard(-1)), dim: !card },
{ icon: 'check', label: 'Done, or back to the inbox', keys: keysOf('board', 'done'), run: () => workspace.guard(() => workspace.cards.toggleDone()), dim: !card },
{ icon: 'right', label: 'Send the card right', keys: keysOf('board', 'sendRight'), run: () => workspace.guard(() => workspace.cards.sendCard(1)), dim: !card },
{ icon: 'calendar', label: 'Due date', keys: keysOf('board', 'due'), run: () => hooks.openCommandLine(':due '), dim: !card },
{ icon: 'repeat', label: 'Repeat once done', keys: [':repeat'], run: () => hooks.openCommandLine(':repeat '), dim: !card },
{ icon: 'doc', label: "The card's notes", keys: keysOf('board', 'notes'), run: () => workspace.guard(() => workspace.openCard()), dim: !card },
{ icon: 'doc', label: "The card's notes", keys: keysOf('board', 'notes'), run: () => workspace.guard(() => workspace.cards.openCard()), dim: !card },
],
[
{ icon: 'up', label: 'Card up its list', keys: keysOf('board', 'nudgeUp'), run: () => workspace.guard(() => workspace.nudgeCard(-1)), dim: !card },
{ icon: 'down', label: 'Card down its list', keys: keysOf('board', 'nudgeDown'), run: () => workspace.guard(() => workspace.nudgeCard(1)), dim: !card },
{ icon: 'up', label: 'Card up its list', keys: keysOf('board', 'nudgeUp'), run: () => workspace.guard(() => workspace.cards.nudgeCard(-1)), dim: !card },
{ icon: 'down', label: 'Card down its list', keys: keysOf('board', 'nudgeDown'), run: () => workspace.guard(() => workspace.cards.nudgeCard(1)), dim: !card },
{
icon: 'rename',
label: card ? 'Rename the card' : 'Rename the list',
@@ -84,7 +84,7 @@
[
{ icon: 'doc-plus', label: 'New document', keys: keysOf('tree', 'new'), run: () => runCommand('new') },
{ icon: 'folder-plus', label: 'New folder', keys: keysOf('tree', 'mkdir'), run: () => hooks.openCommandLine(':mkdir ') },
...(workspace.space ? [board] : []),
...(workspace.tree.space ? [board] : []),
],
[
agenda,
@@ -96,8 +96,8 @@
[
{ icon: 'upload', label: 'Upload files into this folder', keys: [':upload'], run: () => hooks.pickTreeFiles(false) },
{ icon: 'trash', label: 'The trash — what was deleted', keys: [':trash'], run: () => runCommand('trash') },
...(workspace.marked.length
? [{ icon: 'drop' as const, label: `Drop ${dropping(workspace.marked)} here`, keys: keysOf('tree', 'drop'), run: () => workspace.guard(() => workspace.drop()), on: true }]
...(workspace.tree.marked.length
? [{ icon: 'drop' as const, label: `Drop ${dropping(workspace.tree.marked)} here`, keys: keysOf('tree', 'drop'), run: () => workspace.guard(() => workspace.tree.drop()), on: true }]
: []),
],
[
+18 -18
View File
@@ -13,63 +13,63 @@
let list = $state<HTMLUListElement>()
const note = $derived(workspace.scratchNote)
const html = $derived(note && !workspace.scratchEditing ? renderMarkdown(note.body) : '')
const note = $derived(workspace.scratch.note)
const html = $derived(note && !workspace.scratch.editing ? renderMarkdown(note.body) : '')
$effect(() => {
const at = workspace.scratchCursor
const at = workspace.scratch.cursor
list?.querySelector<HTMLElement>(`[data-index="${at}"]`)?.scrollIntoView({ block: 'nearest' })
})
function pick(index: number) {
if (workspace.scratchEditing) return
workspace.scratchCursor = index
if (workspace.scratch.editing) return
workspace.scratch.cursor = index
}
</script>
<div class="drawer">
<header class="label rule">
<span class="marker"></span><span class="title">scratch</span>
{workspace.scratchNotes.length} {workspace.scratchNotes.length === 1 ? 'note' : 'notes'}
{workspace.scratch.notes.length} {workspace.scratch.notes.length === 1 ? 'note' : 'notes'}
<span class="kbd tile-key" title="the key that lowers and raises the scratchpad">Q</span>
</header>
<div class="body">
<ul class="notes" bind:this={list}>
{#if workspace.scratchEditing && workspace.scratchNew}
<li class="at new"><span class="line">{headline(workspace.writing?.text ?? '') || 'new note'}</span><span class="when">now</span></li>
{#if workspace.scratch.editing && workspace.scratch.writingNew}
<li class="at new"><span class="line">{headline(workspace.drafts.writing?.text ?? '') || 'new note'}</span><span class="when">now</span></li>
{/if}
{#each workspace.scratchNotes as entry, index (entry.id)}
{#each workspace.scratch.notes as entry, index (entry.id)}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<li
class:at={index === workspace.scratchCursor && !workspace.scratchNew}
class:dim={workspace.scratchEditing}
class:at={index === workspace.scratch.cursor && !workspace.scratch.writingNew}
class:dim={workspace.scratch.editing}
data-index={index}
onclick={() => pick(index)}
ondblclick={() => workspace.editScratchNote()}
ondblclick={() => workspace.scratch.editNote()}
>
<span class="line">{headline(entry.body) || '—'}</span>
<span class="when">{relative(entry.updated_at)}</span>
</li>
{:else}
{#if !workspace.scratchNew}
{#if !workspace.scratch.writingNew}
<li class="none muted">nothing here — <span class="kbd">o</span> takes a note</li>
{/if}
{/each}
</ul>
<div class="page">
{#if workspace.scratchEditing && workspace.writing}
{#if workspace.scratch.editing && workspace.drafts.writing}
{#await import('./Editor.svelte')}
<div class="muted label">loading editor</div>
{:then { default: Editor }}
{#key workspace.writing.session}
<Editor writing={workspace.writing} fill hint="anything — it saves itself; :keep makes it a document" />
{#key workspace.drafts.writing.session}
<Editor writing={workspace.drafts.writing} fill hint="anything — it saves itself; :keep makes it a document" />
{/key}
{/await}
{:else if html}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<article class="md" ondblclick={() => workspace.editScratchNote()}>
<article class="md" ondblclick={() => workspace.scratch.editNote()}>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in renderMarkdown -->
{@html html}
</article>
@@ -79,7 +79,7 @@
</div>
</div>
{#if workspace.scratchEditing}
{#if workspace.scratch.editing}
<footer class="label">
<span>writing — it saves itself</span>
<span><span class="kbd">esc</span> back to the notes</span>
+8 -8
View File
@@ -1,6 +1,6 @@
<script lang="ts">
/*
* The password `workspace.askSecret` asks for, in the same place as the
* The password `workspace.prompts.askSecret` asks for, in the same place as the
* `[y/N]` prompt. A masked field of its own rather than the command line, so
* a password never shows on screen or lands in the command history.
*
@@ -25,7 +25,7 @@
let asking = false
$effect(() => {
if (workspace.secret) {
if (workspace.prompts.secret) {
value = ''
if (!asking) returnTo = document.activeElement as HTMLElement | null
asking = true
@@ -45,19 +45,19 @@
event.stopPropagation()
if (event.key === 'Enter') {
event.preventDefault()
workspace.secret?.resolve(value)
workspace.prompts.secret?.resolve(value)
value = ''
} else if (event.key === 'Escape') {
event.preventDefault()
workspace.secret?.resolve(null)
workspace.prompts.secret?.resolve(null)
value = ''
}
}
</script>
{#if workspace.secret}
<div class="prompt" role="dialog" aria-label={workspace.secret.text}>
<label class="text" for="secret">{workspace.secret.text}</label>
{#if workspace.prompts.secret}
<div class="prompt" role="dialog" aria-label={workspace.prompts.secret.text}>
<label class="text" for="secret">{workspace.prompts.secret.text}</label>
<input
id="secret"
type="password"
@@ -73,7 +73,7 @@
/>
<button
onclick={() => {
workspace.secret?.resolve(null)
workspace.prompts.secret?.resolve(null)
value = ''
}}>esc</button
>
+6 -6
View File
@@ -29,7 +29,7 @@
api.listSshKeys(),
api.listTokens(),
api.backupStatus(),
workspace.loadVault(),
workspace.vault.load(),
])
Object.assign(settings, { sessions, keys, tokens, backup })
settings.cursor = Math.min(settings.cursor, Math.max(settingRows().length - 1, 0))
@@ -43,7 +43,7 @@
settings.cursor = 0
}
await loadSettings()
workspace.openSheet('settings')
workspace.sheets.open('settings')
})
}
@@ -59,7 +59,7 @@
const run = (line: string) => () => runCommand(line)
const type = (line: string) => () => hooks.openCommandLine(line)
const confirm = (question: string, line: string) => async () => {
if (await workspace.ask(question)) await runCommand(line)
if (await workspace.prompts.ask(question)) await runCommand(line)
}
const used = (at: number | null) => (at === null ? 'never used' : `used ${relative(at)}`)
@@ -67,7 +67,7 @@
export function settingRows(section = settings.section): Setting[] {
switch (section) {
case 'options': {
const space = workspace.space
const space = workspace.tree.space
return [
{
name: 'vim keys',
@@ -91,7 +91,7 @@
]
}
case 'vault': {
const vault = workspace.vault
const vault = workspace.vault.status
if (!vault?.configured) {
return [{ name: 'set a password', detail: 'files sealed under it open only with it — it cannot be recovered', act: run('vault') }]
}
@@ -166,7 +166,7 @@
const tabs = $derived<[SettingsSection, string][]>([
['options', 'options'],
['vault', workspace.vault?.configured ? `vault ${workspace.vault.unlocked ? 'open' : 'locked'}` : 'vault'],
['vault', workspace.vault.status?.configured ? `vault ${workspace.vault.status.unlocked ? 'open' : 'locked'}` : 'vault'],
['backup', settings.backup?.configured ? `backup${settings.backup.last_error ? ' failing' : ''}` : 'backup off'],
['sessions', `sessions ${settings.sessions.length}`],
['keys', `ssh keys ${settings.keys.length}`],
+1 -1
View File
@@ -57,7 +57,7 @@
<header>
<span class="label rule"><span class="marker"></span><span class="title">{title}</span> {detail}</span>
{@render actions?.()}
<button onclick={() => workspace.closeSheet()}>esc</button>
<button onclick={() => workspace.sheets.close()}>esc</button>
</header>
{@render children()}
+41 -41
View File
@@ -11,7 +11,7 @@
// Keep the cursor line visible while navigating with j/k.
$effect(() => {
const index = workspace.cursor
const index = workspace.tree.cursor
const node = list?.querySelector<HTMLElement>(`[data-index="${index}"]`)
node?.scrollIntoView({ block: 'nearest' })
})
@@ -32,41 +32,41 @@
: `${node.child_count}`
/* The row a day heading compares with: the one above, past a document's files. */
const above = (index: number) => workspace.nodes.slice(0, index).filter((n) => !workspace.isNested(n)).at(-1)
const above = (index: number) => workspace.tree.nodes.slice(0, index).filter((n) => !workspace.tree.isNested(n)).at(-1)
const carriesFiles = (event: DragEvent) => event.dataTransfer?.types.includes('Files') ?? false
function drop(event: DragEvent) {
dropping = false
const files = Array.from(event.dataTransfer?.files ?? [])
if (files.length === 0 || workspace.query) return
if (files.length === 0 || workspace.tree.query) return
event.preventDefault()
void workspace.guard(() => workspace.putFiles(files))
void workspace.guard(() => workspace.tree.putFiles(files))
}
/* Ctrl or Cmd picks a row up, Shift every row from the cursor to it. */
function click(event: MouseEvent, index: number, node: NodeSummary) {
if (event.ctrlKey || event.metaKey) return workspace.mark(node, false)
if (event.shiftKey) return workspace.markRange(index)
if (event.ctrlKey || event.metaKey) return workspace.tree.mark(node, false)
if (event.shiftKey) return workspace.tree.markRange(index)
pick(index)
}
function pick(index: number) {
workspace.cursor = index
void workspace.guard(() => workspace.openSelected())
workspace.tree.cursor = index
void workspace.guard(() => workspace.tree.openSelected())
}
/* A tap opens a row, so on a phone the other verbs need a door of their own. */
function actions(index: number) {
workspace.cursor = index
workspace.openSheet('actions')
workspace.tree.cursor = index
workspace.sheets.open('actions')
}
/* What is in the folder itself, not the files folded open under its documents. */
const items = $derived(workspace.nodes.filter((node) => !workspace.isNested(node)).length)
const items = $derived(workspace.tree.nodes.filter((node) => !workspace.tree.isNested(node)).length)
/* Inside a folder, a phone has no h key and no crumbs on the bar: this is the way up. */
const nested = $derived(isTouchDevice && !workspace.query && workspace.path.length > 1)
const nested = $derived(isTouchDevice && !workspace.tree.query && workspace.tree.path.length > 1)
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -74,7 +74,7 @@
class="sidebar"
class:dropping
ondragover={(event) => {
if (!carriesFiles(event) || workspace.query) return
if (!carriesFiles(event) || workspace.tree.query) return
event.preventDefault()
dropping = true
}}
@@ -85,38 +85,38 @@
>
<div class="head label rule">
<span class="marker"></span>
<span class="name">{workspace.space?.slug ?? 'diary'}</span>
<span class="name">{workspace.tree.space?.slug ?? 'diary'}</span>
<span class="count">
{items} {items === 1 ? 'item' : 'items'}{#if workspace.foundCards.length > 0}
· {workspace.foundCards.length} {workspace.foundCards.length === 1 ? 'card' : 'cards'}{/if}{#if workspace.foundNotes.length > 0}
· {workspace.foundNotes.length} {workspace.foundNotes.length === 1 ? 'note' : 'notes'}{/if}
{items} {items === 1 ? 'item' : 'items'}{#if workspace.tree.foundCards.length > 0}
· {workspace.tree.foundCards.length} {workspace.tree.foundCards.length === 1 ? 'card' : 'cards'}{/if}{#if workspace.tree.foundNotes.length > 0}
· {workspace.tree.foundNotes.length} {workspace.tree.foundNotes.length === 1 ? 'note' : 'notes'}{/if}
</span>
</div>
{#if workspace.query}
{#if workspace.tree.query}
<div class="filter">
<span class="muted">/</span><span class="query">{workspace.query}</span>
{#if workspace.globalSearch}<span class="muted scope">all spaces</span>{/if}
<button title="clear search (n)" onclick={() => workspace.guard(() => workspace.search(''))}>esc</button>
<span class="muted">/</span><span class="query">{workspace.tree.query}</span>
{#if workspace.tree.globalSearch}<span class="muted scope">all spaces</span>{/if}
<button title="clear search (n)" onclick={() => workspace.guard(() => workspace.tree.search(''))}>esc</button>
</div>
{/if}
<ul bind:this={list}>
{#if nested}
<li>
<button class="row up" onclick={() => workspace.guard(() => workspace.up())}>
<button class="row up" onclick={() => workspace.guard(() => workspace.tree.up())}>
<span class="dot"></span>
<span class="title">..<span class="slash">/</span></span>
<span class="aside">up</span>
</button>
</li>
{/if}
{#each workspace.nodes as node, index (node.id)}
{#each workspace.tree.nodes as node, index (node.id)}
{@const previous = above(index)}
{@const nested = workspace.isNested(node)}
{@const nested = workspace.tree.isNested(node)}
<!-- Day headings only make sense in a list that is a journal: documents,
in date order, not a search and not a folder listing. -->
{#if !workspace.query && !nested && node.kind === 'document' && (!previous || previous.kind !== 'document' || formatDay(previous.created_at) !== formatDay(node.created_at))}
{#if !workspace.tree.query && !nested && node.kind === 'document' && (!previous || previous.kind !== 'document' || formatDay(previous.created_at) !== formatDay(node.created_at))}
<li class="daybreak label rule">
<span><span class="day">{formatDay(node.created_at)}</span> {formatWeekday(node.created_at)}</span>
</li>
@@ -134,9 +134,9 @@
event.preventDefault()
actions(index)
}}
class:active={index === workspace.cursor}
class:open={workspace.tiles.some((tile) => tile.open?.id === node.id)}
class:marked={workspace.isMarked(node.id)}
class:active={index === workspace.tree.cursor}
class:open={workspace.tiles.all.some((tile) => tile.open?.id === node.id)}
class:marked={workspace.tree.isMarked(node.id)}
class:nested
data-index={index}
onclick={(event) => click(event, index, node)}
@@ -144,10 +144,10 @@
<span class="dot"></span>
<span class="title">
{label(node)}{#if isContainer(node)}<span class="slash">/</span>{/if}
{#if workspace.query && node.location}<span class="where">{node.location}</span>{/if}
{#if workspace.query && node.in_text}<span class="hit" title="found in the file's text">{#each hitParts(node.excerpt) as part, at (at)}{#if part.lit}<mark>{part.text}</mark>{:else}{part.text}{/if}{/each}</span>{/if}
{#if workspace.tree.query && node.location}<span class="where">{node.location}</span>{/if}
{#if workspace.tree.query && node.in_text}<span class="hit" title="found in the file's text">{#each hitParts(node.excerpt) as part, at (at)}{#if part.lit}<mark>{part.text}</mark>{:else}{part.text}{/if}{/each}</span>{/if}
</span>
{#if workspace.isMarked(node.id)}<span class="badge" title="marked — p drops what is marked in a folder">m</span>{/if}
{#if workspace.tree.isMarked(node.id)}<span class="badge" title="marked — p drops what is marked in a folder">m</span>{/if}
{#if node.shared}<span class="shared" title="shared"></span>{/if}
{#if node.file?.encrypted}<span class="sealed" title="in the vault"><Icon name="lock" size={12} /></span>{/if}
{#if node.sealed}<span class="sealed" title="sealed — files added here go into the vault"><Icon name="lock" size={12} /></span>{/if}
@@ -161,13 +161,13 @@
</li>
{/each}
<!-- The cards a search found, after the nodes: Enter turns one over on its board. -->
{#if workspace.foundCards.length > 0}
{#if workspace.tree.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}
{#each workspace.tree.foundCards as card, at (card.id)}
{@const index = workspace.tree.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)}>
<button class="row card" class:active={index === workspace.tree.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>
@@ -175,22 +175,22 @@
</li>
{/each}
<!-- Then the notes it found on the scratchpad: Enter lowers it, at the note. -->
{#if workspace.foundNotes.length > 0}
{#if workspace.tree.foundNotes.length > 0}
<li class="daybreak label rule"><span>notes</span></li>
{/if}
{#each workspace.foundNotes as note, at (note.id)}
{@const index = workspace.nodes.length + workspace.foundCards.length + at}
{#each workspace.tree.foundNotes as note, at (note.id)}
{@const index = workspace.tree.nodes.length + workspace.tree.foundCards.length + at}
<li class="item">
<button class="row note" class:active={index === workspace.cursor} data-index={index} onclick={() => pick(index)}>
<button class="row note" class:active={index === workspace.tree.cursor} data-index={index} onclick={() => pick(index)}>
<span class="dot"></span>
<span class="title">{headline(note.body) || '—'}<span class="where">scratch</span></span>
<span class="aside">{relative(note.updated_at)}</span>
</button>
</li>
{/each}
{#if workspace.rows === 0}
{#if workspace.tree.rows === 0}
<li class="empty muted">
{#if workspace.query}
{#if workspace.tree.query}
no matches
{:else}
nothing here yet — <span class="kbd">o</span> writes, <span class="kbd">⇧ O</span> makes a folder, files dropped here land here
+3 -3
View File
@@ -12,7 +12,7 @@
// The path is the file name here: work/einarbeiten/01-was-ist-eebus.
const file = $derived(
workspace.open ? slugPath(workspace.open.path) : `${workspace.here || '~'}/`,
workspace.open ? slugPath(workspace.open.path) : `${workspace.tree.here || '~'}/`,
)
const lines = $derived(workspace.editing ? workspace.draft.body.split('\n').length : null)
@@ -32,9 +32,9 @@
<span class="msg" class:alarm={workspace.flash?.kind === 'error'}>{workspace.flash?.text ?? ''}</span>
{#if pending}<span class="pending" aria-live="polite">{pending}</span>{/if}
{#if workspace.tiles.length > 1}<span class="flag" title=":ls lists the tiles">tile {workspace.focused + 1}/{workspace.tiles.length}</span>{/if}
{#if workspace.tiles.all.length > 1}<span class="flag" title=":ls lists the tiles">tile {workspace.tiles.focused + 1}/{workspace.tiles.all.length}</span>{/if}
{#if workspace.fullscreen}<span class="flag">full</span>{/if}
{#if workspace.vault?.unlocked}<span class="flag vault" title="the vault is open — :vault lock closes it">vault open</span>{/if}
{#if workspace.vault.status?.unlocked}<span class="flag vault" title="the vault is open — :vault lock closes it">vault open</span>{/if}
{#if lines !== null}<span class="readout">{lines}<span class="unit">L</span></span>{/if}
<span class="hint">
{isTouchDevice ? 'tap : for commands' : '? help · : command · / search'}
+9 -9
View File
@@ -7,18 +7,18 @@
* Enter or r restores, x deletes for good.
*/
export function restoreTrashed(item: TrashItem) {
return workspace.guard(() => workspace.restore(item.id))
return workspace.guard(() => workspace.tree.restore(item.id))
}
export async function purgeTrashed(item: TrashItem) {
const inside = item.inside > 0 ? ` and the ${item.inside} inside it` : ''
if (!(await workspace.ask(`delete "${item.name.trim() || item.slug}"${inside} for good? this cannot be undone.`))) return
await workspace.guard(() => workspace.purge(item.id))
if (!(await workspace.prompts.ask(`delete "${item.name.trim() || item.slug}"${inside} for good? this cannot be undone.`))) return
await workspace.guard(() => workspace.sheets.purge(item.id))
}
export async function emptyTrash() {
if (!(await workspace.ask('empty the trash? everything in it is deleted for good.'))) return
await workspace.guard(() => workspace.emptyTrash())
if (!(await workspace.prompts.ask('empty the trash? everything in it is deleted for good.'))) return
await workspace.guard(() => workspace.sheets.emptyTrash())
}
</script>
@@ -29,17 +29,17 @@
const days = (at: number) => Math.max(Math.ceil((at - Date.now() / 1000) / 86400), 0)
</script>
<Sheet title="trash" detail="{workspace.trash.length} deleted" cursor={workspace.trashCursor} keys={['trash', 'sheet']}>
<Sheet title="trash" detail="{workspace.sheets.trash.length} deleted" cursor={workspace.sheets.trashCursor} keys={['trash', 'sheet']}>
{#snippet actions()}
{#if workspace.trash.length > 0}
{#if workspace.sheets.trash.length > 0}
<button class="danger" onclick={emptyTrash}>empty</button>
{/if}
{/snippet}
<ul class="items">
{#each workspace.trash as item, index (item.id)}
{#each workspace.sheets.trash as item, index (item.id)}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<li class="item" class:at={index === workspace.trashCursor} data-index={index} onpointerenter={() => (workspace.trashCursor = index)}>
<li class="item" class:at={index === workspace.sheets.trashCursor} data-index={index} onpointerenter={() => (workspace.sheets.trashCursor = index)}>
<span class="kind label">{item.kind}</span>
<div class="info">
+3 -3
View File
@@ -10,7 +10,7 @@
import { workspace } from './store.svelte'
const ORIGIN = 'https://verify.proton.me'
const asked = workspace.verify
const asked = workspace.prompts.verify
let height = $state(560)
function onmessage(event: MessageEvent) {
@@ -26,11 +26,11 @@
const payload = data?.payload
if (data?.type === 'HUMAN_VERIFICATION_SUCCESS' && typeof payload?.token === 'string') {
asked?.resolve(payload.token)
workspace.closeSheet()
workspace.sheets.close()
} else if (data?.type === 'RESIZE' && typeof payload?.height === 'number') {
height = Math.min(Math.max(payload.height, 320), 900)
} else if (data?.type === 'CLOSE') {
workspace.closeSheet()
workspace.sheets.close()
}
}
+132 -132
View File
@@ -35,16 +35,16 @@ const reading = () => !!workspace.open && !workspace.editing && workspace.pane =
/** 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))
workspace.sheets[cursor] = Math.min(Math.max(workspace.sheets[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),
await workspace.guard(() => workspace.sheets.loadOverview())
workspace.sheets.overviewCursor = Math.max(
workspace.sheets.overview.findIndex((entry) => entry.space.id === workspace.tree.spaceId),
0,
)
workspace.openSheet('overview')
workspace.sheets.open('overview')
}
/*
@@ -69,7 +69,7 @@ function openInTab() {
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')
if (file.encrypted && !workspace.vault.status?.unlocked) return workspace.say('sealed — :vault unlock first, then gx', 'error')
followLink(file.url, true)
}
@@ -91,23 +91,23 @@ function tileKey(direction: Direction, shift: boolean) {
if (direction === 'l' && !shift) focusPane('document')
return
}
const next = neighbour(dwindle(workspace.tiles.length), workspace.focused, direction)
const next = neighbour(dwindle(workspace.tiles.all.length), workspace.tiles.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
const tiles = [...workspace.tiles.all]
;[tiles[workspace.tiles.focused], tiles[next]] = [tiles[next], tiles[workspace.tiles.focused]]
workspace.tiles.all = tiles
workspace.tiles.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())
if (workspace.drafts.writing) guard(() => workspace.drafts.saveWriting())
else if (workspace.open) guard(() => workspace.drafts.save())
},
close: () => void runCommand('close'),
focus: (key) => tileKey(key.slice(-1) as Direction, false),
@@ -117,8 +117,8 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
sheet: {
// A note being written has had its Esc; the sheet stays up under it.
close: () => {
if (workspace.scratchEditing) return
workspace.closeSheet()
if (workspace.scratch.editing) return
workspace.sheets.close()
},
},
@@ -143,11 +143,11 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
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)
workspace.sheets.overviewCursor = Math.min(Math.max(workspace.sheets.overviewCursor + by, 0), workspace.sheets.overview.length - 1)
},
go: (key) => goToSpace(spaceIndex(key)),
enter: () => goToSpace(workspace.overviewCursor),
close: () => workspace.closeSheet(),
enter: () => goToSpace(workspace.sheets.overviewCursor),
close: () => workspace.sheets.close(),
},
/** The info sheet: Tab from one of its parts to the next. */
@@ -158,9 +158,9 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
/** 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),
down: () => step('outlineCursor', workspace.sheets.outline.length, 1),
up: () => step('outlineCursor', workspace.sheets.outline.length, -1),
bottom: () => step('outlineCursor', workspace.sheets.outline.length, Infinity),
go: () => goHeading(),
},
@@ -170,41 +170,41 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
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]
const file = workspace.open?.files[workspace.sheets.filesCursor]
if (file) void openFile(file)
},
insert: () => {
const file = workspace.open?.files[workspace.filesCursor]
const file = workspace.open?.files[workspace.sheets.filesCursor]
if (file) embedFile(file)
},
copy: () => {
const file = workspace.open?.files[workspace.filesCursor]
const file = workspace.open?.files[workspace.sheets.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),
down: () => step('trashCursor', workspace.sheets.trash.length, 1),
up: () => step('trashCursor', workspace.sheets.trash.length, -1),
bottom: () => step('trashCursor', workspace.sheets.trash.length, Infinity),
restore: () => {
const item = workspace.trash[workspace.trashCursor]
const item = workspace.sheets.trash[workspace.sheets.trashCursor]
if (item) void restoreTrashed(item)
},
purge: () => {
const item = workspace.trash[workspace.trashCursor]
const item = workspace.sheets.trash[workspace.sheets.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),
down: () => step('backlinksCursor', workspace.sheets.backlinks.length, 1),
up: () => step('backlinksCursor', workspace.sheets.backlinks.length, -1),
bottom: () => step('backlinksCursor', workspace.sheets.backlinks.length, Infinity),
open: () => {
const link = workspace.backlinks[workspace.backlinksCursor]
const link = workspace.sheets.backlinks[workspace.sheets.backlinksCursor]
if (link) void openBacklink(link)
},
},
@@ -218,20 +218,20 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
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]
const card = workspace.open?.cards[workspace.sheets.cardsCursor]
if (!card) return
workspace.closeSheet()
guard(() => workspace.turnTo(card))
workspace.sheets.close()
guard(() => workspace.cards.turnTo(card))
},
done: () => guard(() => workspace.toggleDone()),
sendLeft: () => guard(() => workspace.sendCard(-1)),
sendRight: () => guard(() => workspace.sendCard(1)),
done: () => guard(() => workspace.cards.toggleDone()),
sendLeft: () => guard(() => workspace.cards.sendCard(-1)),
sendRight: () => guard(() => workspace.cards.sendCard(1)),
due: () => {
if (workspace.focusedCard) line(':due ')
if (workspace.cards.focusedCard) line(':due ')
},
new: () => line(':card '),
label: () => {
if (workspace.focusedCard) line(':label ')
if (workspace.cards.focusedCard) line(':label ')
},
line: () => line(':'),
},
@@ -240,7 +240,7 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
history: {
down: () => moveHistory(1),
up: () => moveHistory(-1),
bottom: () => moveHistory(workspace.history.length),
bottom: () => moveHistory(workspace.sheets.history.length),
restore: () => void restoreShown(),
},
@@ -268,27 +268,27 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
* 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()),
down: () => void workspace.cards.moveTask(1),
up: () => void workspace.cards.moveTask(-1),
top: () => void workspace.cards.moveTask(-Infinity),
bottom: () => void workspace.cards.moveTask(Infinity),
tick: () => guard(() => workspace.cards.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]
const task = workspace.cards.card?.checklist[workspace.cards.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(),
nudgeDown: () => guard(() => workspace.cards.nudgeTask(1)),
nudgeUp: () => guard(() => workspace.cards.nudgeTask(-1)),
drop: () => guard(() => workspace.cards.deleteTask()),
describe: () => workspace.cards.describe(),
label: () => line(':label '),
rename: () => line(`:name ${workspace.card?.title ?? ''}`),
rename: () => line(`:name ${workspace.cards.card?.title ?? ''}`),
due: () => line(':due '),
sendLeft: () => guard(() => workspace.sendCard(-1)),
sendRight: () => guard(() => workspace.sendCard(1)),
notes: () => guard(() => workspace.openCard()),
sendLeft: () => guard(() => workspace.cards.sendCard(-1)),
sendRight: () => guard(() => workspace.cards.sendCard(1)),
notes: () => guard(() => workspace.cards.openCard()),
line: () => line(':'),
},
@@ -298,34 +298,34 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
* 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()),
down: () => void (workspace.scratch.cursor = Math.min(workspace.scratch.cursor + 1, Math.max(workspace.scratch.notes.length - 1, 0))),
up: () => void (workspace.scratch.cursor = Math.max(workspace.scratch.cursor - 1, 0)),
top: () => void (workspace.scratch.cursor = 0),
bottom: () => void (workspace.scratch.cursor = Math.max(workspace.scratch.notes.length - 1, 0)),
new: () => workspace.scratch.newNote(),
write: () => workspace.scratch.editNote(),
keep: () => guard(() => workspace.scratch.keepNote()),
card: () => guard(() => workspace.scratch.cardNote()),
append: () => {
const open = [workspace.tile, ...workspace.tiles].find((tile) => tile.open?.kind === 'document')?.open
const open = [workspace.tile, ...workspace.tiles.all].find((tile) => tile.open?.kind === 'document')?.open
line(`:append ${open ? open.name.trim() || open.slug : ''}`)
},
drop: () => {
if (!workspace.scratchNote) return
if (!workspace.scratch.note) return
void (async () => {
if (await workspace.ask('drop this scratch note?')) await workspace.guard(() => workspace.dropScratchNote())
if (await workspace.prompts.ask('drop this scratch note?')) await workspace.guard(() => workspace.scratch.dropNote())
})()
},
line: () => line(':'),
help: () => void runCommand('help'),
raise: () => void workspace.closeScratch(),
raise: () => void workspace.scratch.close(),
nothing: () => {},
},
workspace: {
/** Esc leaves one thing: the line, or fullscreen. */
escape: () => {
if (workspace.writing) return
if (workspace.drafts.writing) return
if (hooks.closeCommandLine()) return
if (workspace.fullscreen && !workspace.editing) return void (workspace.fullscreen = false)
return false
@@ -333,8 +333,8 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
// `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())
if (workspace.scratch.open) return false
guard(() => workspace.scratch.toggle())
},
},
@@ -348,27 +348,27 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
* 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))),
down: () => void (reading() ? hooks.scrollReader(1) : workspace.tree.move(1)),
up: () => void (reading() ? hooks.scrollReader(-1) : workspace.tree.move(-1)),
top: () => void (reading() ? hooks.scrollReader('top') : (workspace.tree.cursor = 0)),
bottom: () => void (reading() ? hooks.scrollReader('bottom') : (workspace.tree.cursor = Math.max(workspace.tree.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 (key === 'Shift-Enter' && !reading()) return guard(() => workspace.tiles.openBeside())
const shown = workspace.tiles.all.findIndex((tile) => tile.open && tile.open.id === workspace.tree.selected?.id)
if (shown >= 0) void focusTile(shown)
else guard(() => workspace.openSelected())
else guard(() => workspace.tree.openSelected())
},
beside: () => {
if (!reading()) guard(() => workspace.openBeside())
if (!reading()) guard(() => workspace.tiles.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())
else guard(() => workspace.tree.up())
},
sideways: (key) => {
const amount = ({ zh: -1, zl: 1, zH: 'halfleft', zL: 'halfright' } as const)[key as 'zh' | 'zl' | 'zH' | 'zL']
@@ -394,13 +394,13 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
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)),
previousEntry: () => guard(() => workspace.diary.stepEntry(-1)),
nextEntry: () => guard(() => workspace.diary.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()),
mark: () => workspace.tree.mark(),
markAll: () => workspace.tree.markAll(),
files: () => guard(() => workspace.tree.toggleFiles()),
drop: () => guard(() => workspace.tree.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.
@@ -417,20 +417,20 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
},
close: () => {
if (workspace.open) void runCommand('q')
else workspace.unmarkAll()
else workspace.tree.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.tree.marked.length) return workspace.tree.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()),
undo: () => guard(() => workspace.tree.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)),
next: () => guard(() => workspace.tree.nextResult(1)),
previous: () => guard(() => workspace.tree.nextResult(-1)),
search: () => line('/'),
},
@@ -441,36 +441,36 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
* that is not on screen.
*/
board: {
left: () => workspace.moveList(-1),
left: () => workspace.cards.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)
if (workspace.cards.listCursor >= (workspace.cards.board?.lists.length ?? 0) - 1) tileKey('l', false)
else workspace.cards.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()),
down: () => workspace.cards.moveCard(1),
up: () => workspace.cards.moveCard(-1),
top: () => void (workspace.cards.cardCursor = 0),
bottom: () => void (workspace.cards.cardCursor = Math.max((workspace.cards.list?.cards.length ?? 0) - 1, 0)),
nudgeDown: () => guard(() => workspace.cards.nudgeCard(1)),
nudgeUp: () => guard(() => workspace.cards.nudgeCard(-1)),
sendLeft: () => guard(() => workspace.cards.sendCard(-1)),
sendRight: () => guard(() => workspace.cards.sendCard(1)),
done: () => guard(() => workspace.cards.toggleDone()),
turn: () => guard(() => workspace.cards.turnCard()),
// The card's notes, without turning it over first: `gf` as on its back.
notes: () => guard(() => workspace.openCard()),
notes: () => guard(() => workspace.cards.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),
rename: () => line(workspace.cards.card ? `:name ${workspace.cards.card.title}` : `:list name ${workspace.cards.list?.name ?? ''}`),
old: () => void (workspace.cards.showOldDone = !workspace.cards.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()),
reload: () => guard(() => workspace.cards.reloadBoard()),
delete: () => void runCommand('delete'),
},
@@ -481,39 +481,39 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
*/
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
const last = Math.max(workspace.cards.agendaCards.length + workspace.cards.agenda.on_this_day.length - 1, 0)
if (workspace.cards.agendaCursor >= last) tileKey('j', false)
else workspace.cards.agendaCursor += 1
},
up: () => void (workspace.agendaCursor = Math.max(workspace.agendaCursor - 1, 0)),
up: () => void (workspace.cards.agendaCursor = Math.max(workspace.cards.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))
const card = workspace.cards.agendaCards[workspace.cards.agendaCursor]
const past = workspace.cards.agenda.on_this_day[workspace.cards.agendaCursor - workspace.cards.agendaCards.length]
if (card) guard(() => workspace.cards.goToCard(card))
// Beside the agenda, so `q` on it comes back here.
else if (past) guard(() => workspace.openPath(past.path))
else if (past) guard(() => workspace.tree.openPath(past.path))
},
finish: () => guard(() => workspace.toggleDone()),
finish: () => guard(() => workspace.cards.toggleDone()),
due: () => {
if (workspace.agendaCards[workspace.agendaCursor]) line(':due ')
if (workspace.cards.agendaCards[workspace.cards.agendaCursor]) line(':due ')
},
line: () => line(':'),
sendLeft: () => guard(() => workspace.sendCard(-1)),
sendRight: () => guard(() => workspace.sendCard(1)),
sendLeft: () => guard(() => workspace.cards.sendCard(-1)),
sendRight: () => guard(() => workspace.cards.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)),
day: (key) => guard(() => workspace.diary.moveCalendar(key === 'h' || key === 'ArrowLeft' ? -1 : 1, 0)),
week: (key) => guard(() => workspace.diary.moveCalendar(key === 'k' || key === 'ArrowUp' ? -7 : 7, 0)),
month: (key) => guard(() => workspace.diary.moveCalendar(0, key === 'H' ? -1 : 1)),
today: () => {
workspace.calendarDay = formatDay(Math.floor(Date.now() / 1000))
guard(() => workspace.loadCalendar())
workspace.diary.calendarDay = formatDay(Math.floor(Date.now() / 1000))
guard(() => workspace.diary.loadCalendar())
},
open: () => void workspace.guard(() => workspace.openCalendarDay()).then(() => hooks.focusEditor()),
open: () => void workspace.guard(() => workspace.diary.openCalendarDay()).then(() => hooks.focusEditor()),
},
/** What every view shares: `q` closes its tile, back to the one before. */
@@ -533,9 +533,9 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
* 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)),
space: (key) => guard(() => workspace.tree.switchSpace(spaceIndex(key))),
send: (key) => guard(() => workspace.tree.sendToSpace(spaceIndex(key))),
cycle: (key) => guard(() => workspace.tree.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.
@@ -562,8 +562,8 @@ export const actions: { [C in Context]: Record<ActionOf<C>, Run> } = {
}
function goToSpace(index: number) {
const entry = workspace.overview[index]
const entry = workspace.sheets.overview[index]
if (!entry) return
workspace.closeSheet()
guard(() => workspace.enterSpace(entry.space.id))
workspace.sheets.close()
guard(() => workspace.tree.enterSpace(entry.space.id))
}
+2 -2
View File
@@ -15,7 +15,7 @@ let carried: NodeSummary[] = []
/** `ondragstart` of a row. */
export function carry(event: DragEvent, node: NodeSummary) {
if (!event.dataTransfer) return
carried = workspace.isMarked(node.id) ? workspace.marked : [node]
carried = workspace.tree.isMarked(node.id) ? workspace.tree.marked : [node]
event.dataTransfer.setData(NODES, JSON.stringify(carried.map((n) => n.id)))
event.dataTransfer.effectAllowed = 'move'
}
@@ -47,7 +47,7 @@ export function into(parent: number | null, where: string): Attachment<HTMLEleme
event.stopPropagation()
const nodes = carried
carried = []
void workspace.guard(() => workspace.moveNodes(nodes, parent, where))
void workspace.guard(() => workspace.tree.moveNodes(nodes, parent, where))
}
element.addEventListener('dragover', over)
element.addEventListener('dragleave', leave)
+165 -165
View File
@@ -90,8 +90,8 @@ function verb(arg: string, verbs: readonly string[]): [string, string] {
/** Close the focused tile; the index of the one that has the focus after. */
const closeFocused = () => {
workspace.closeTile()
return workspace.focused
workspace.tiles.close()
return workspace.tiles.focused
}
/**
@@ -103,18 +103,18 @@ const closeFocused = () => {
export async function openInfo(section?: InfoSection) {
if (!requireOpen()) return
const { headings, current } = hooks.headings()
workspace.outline = headings
workspace.outlineCursor = current
workspace.sheets.outline = headings
workspace.sheets.outlineCursor = current
await workspace.guard(async () => {
// One after the other: each writes what is typed first, and two saves
// of the same text at once would race.
await workspace.loadHistory()
await workspace.loadBacklinks()
await workspace.sheets.loadHistory()
await workspace.sheets.loadBacklinks()
// Picked afresh each time, from the first: the open card leads.
workspace.cardsCursor = 0
workspace.filesCursor = 0
if (section) workspace.infoSection = section
workspace.openSheet('info')
workspace.sheets.cardsCursor = 0
workspace.sheets.filesCursor = 0
if (section) workspace.sheets.infoSection = section
workspace.sheets.open('info')
})
}
@@ -135,7 +135,7 @@ const requireOpen = () => {
/** A card about `node`, titled after it unless named. */
const cardAbout = (node: NodeSummary, title: string) =>
workspace.guard(() => workspace.createCard(title.trim() || node.name.trim() || node.slug, node.id))
workspace.guard(() => workspace.cards.createCard(title.trim() || node.name.trim() || node.slug, node.id))
const TITLE_HREF = titleHref('')
@@ -153,11 +153,11 @@ export function followLink(href: string, tab = false) {
if (tab) {
if (!path) return workspace.say(`no document called "${title}" yet`, 'error')
href = `/n/${path}`
} else return void workspace.guard(() => workspace.followTitle(title))
} else return void workspace.guard(() => workspace.tree.followTitle(title))
}
if (href.startsWith('/n/') && !tab) {
const path = href.slice(3).split('#')[0]
return void workspace.guard(() => workspace.openPath(decodeURIComponent(path)))
return void workspace.guard(() => workspace.tree.openPath(decodeURIComponent(path)))
}
window.open(new URL(href, location.href).href, '_blank', 'noopener,noreferrer')
}
@@ -167,8 +167,8 @@ export function followLink(href: string, tab = false) {
* otherwise the shell, whose keys then act on that tile.
*/
export async function focusTile(index: number) {
if (!workspace.tiles[index]) return
workspace.focusTile(index)
if (!workspace.tiles.all[index]) return
workspace.tiles.focusOn(index)
// The tile's editor fills the hooks once it has seen it has the focus.
await tick()
if (workspace.editing && workspace.pane === 'document') hooks.focusEditor()
@@ -177,38 +177,38 @@ export async function focusTile(index: number) {
/** `gt` / `gT`, `:bn` / `:bp`: the next or previous tile, round and round. */
export function cycleTile(delta: 1 | -1) {
const count = workspace.tiles.length
const count = workspace.tiles.all.length
if (count < 2) return workspace.say('only one tile — L on a row opens another', 'error')
return focusTile((workspace.focused + delta + count) % count)
return focusTile((workspace.tiles.focused + delta + count) % count)
}
/** `:ls`'s line for one tile: number, `%` for the focused one, `[+]` unsaved. */
const describeTile = (index: number) => {
const tile = workspace.tiles[index]
const tile = workspace.tiles.all[index]
const name = tile.kind !== 'node' ? tile.kind : tile.open ? tile.open.name.trim() || tile.open.slug : '(empty)'
return `${index + 1}${index === workspace.focused ? '%' : ''} ${name}${tile.dirty ? ' [+]' : ''}`
return `${index + 1}${index === workspace.tiles.focused ? '%' : ''} ${name}${tile.dirty ? ' [+]' : ''}`
}
/** `:space rm`: a space, the one you are in unless named, into the trash. */
async function removeSpace(name: string) {
const wanted = name.toLowerCase()
const space = wanted ? workspace.spaces.find((s) => s.slug === wanted || s.name.toLowerCase() === wanted) : workspace.space
const space = wanted ? workspace.tree.spaces.find((s) => s.slug === wanted || s.name.toLowerCase() === wanted) : workspace.tree.space
if (!space) return workspace.say(`no such space: ${wanted || '(none open)'}`, 'error')
if (!(await workspace.ask(`delete the space "${space.name}" and everything in it? it goes to the trash.`))) return
await workspace.guard(() => workspace.deleteSpace(space.id))
if (!(await workspace.prompts.ask(`delete the space "${space.name}" and everything in it? it goes to the trash.`))) return
await workspace.guard(() => workspace.tree.deleteSpace(space.id))
}
/** Move the cursor and follow it, so `:next` reads as well as `j` then `Enter`. */
const jumpTo = async (index: number) => {
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())
if (workspace.tree.rows === 0) return workspace.say('nothing here', 'error')
workspace.tree.cursor = Math.min(Math.max(index, 0), workspace.tree.rows - 1)
await workspace.guard(() => workspace.tree.openSelected())
}
/** A folder among the children currently listed, by slug or by name. */
const findFolder = (target: string) => {
const wanted = target.toLowerCase()
return workspace.nodes.find(
return workspace.tree.nodes.find(
(node) =>
isContainer(node) && (node.slug === wanted || node.name.toLowerCase() === wanted),
)
@@ -216,12 +216,12 @@ const findFolder = (target: string) => {
/** The folders listed here, by slug, then the two ways out — for `:cd` and `:mv`. */
const folderNames = () => [
...workspace.nodes.filter(isContainer).map((node) => node.slug),
...workspace.tree.nodes.filter(isContainer).map((node) => node.slug),
'..',
'/',
]
const spaceNames = () => workspace.spaces.map((space) => space.slug)
const spaceNames = () => workspace.tree.spaces.map((space) => space.slug)
/** The capture tokens' names, for Tab after `:token rm`; null until fetched. */
let tokenNames: string[] | null = null
@@ -250,7 +250,7 @@ function documentTitles(arg: string): string[] {
/** The document in a tile, the focused one first: what a note goes to the end of unless told otherwise. */
function documentOnScreen(): string | undefined {
const tile = [workspace.tile, ...workspace.tiles].find((t) => t.open?.kind === 'document')
const tile = [workspace.tile, ...workspace.tiles.all].find((t) => t.open?.kind === 'document')
return tile?.open ? `/n/${tile.open.path.map((crumb) => crumb.slug).join('/')}` : undefined
}
@@ -259,7 +259,7 @@ let templateCache: { space: number; names: string[] } | null = null
/** Tab cannot wait for the server: the names come on the press after the first. */
function templateNames(): string[] {
const here = workspace.spaceId
const here = workspace.tree.spaceId
if (here === null) return []
if (templateCache?.space !== here) {
templateCache = { space: here, names: [] }
@@ -281,7 +281,7 @@ const sealTarget = (encrypt: boolean) =>
if (node.file && node.file.encrypted === encrypt) {
return workspace.say(encrypt ? 'already in the vault' : 'not in the vault')
}
if (!encrypt && workspace.parentSealed) {
if (!encrypt && workspace.tree.parentSealed) {
return workspace.say(`this ${node.kind} is inside a sealed folder — decrypt that folder instead`, 'error')
}
const name = node.name || node.slug
@@ -289,13 +289,13 @@ const sealTarget = (encrypt: boolean) =>
const question = encrypt
? `seal ${node.kind} "${name}" — every file in it now, and every file added later? documents stay readable`
: `unseal ${node.kind} "${name}" and take every file in it out of the vault?`
if (!(await workspace.ask(question))) return
if (!(await workspace.prompts.ask(question))) return
} else if (!encrypt) {
if (!(await workspace.ask(`store "${name}" unencrypted again, and in the readable files/ view on proton drive?`))) return
if (!(await workspace.prompts.ask(`store "${name}" unencrypted again, and in the readable files/ view on proton drive?`))) return
}
if (!(await workspace.ensureUnlocked())) return
if (!(await workspace.vault.ensureUnlocked())) return
workspace.say(encrypt ? 'encrypting …' : 'decrypting …')
await workspace.seal(node.id, encrypt)
await workspace.vault.seal(node.id, encrypt)
})
/** `3 minutes ago`, or `never`. Backups are read at a glance, not to the second. */
@@ -346,7 +346,7 @@ export function describeBackup(status: BackupStatus): string {
/** The vault in a line: open or locked, and what it is busy with. */
export function describeVault(): string {
const vault = workspace.vault
const vault = workspace.vault.status
if (!vault?.configured) return 'no vault yet'
const left = vault.locks_in ? ` · locks in ${Math.ceil(vault.locks_in / 60)}m unless used` : ''
const rekey = vault.rekey
@@ -359,27 +359,27 @@ export function describeVault(): string {
/** The first time: a password for the vault, asked twice. */
async function setupVault() {
const password = await workspace.askSecret('new vault password, 10+ characters — it cannot be recovered')
const password = await workspace.prompts.askSecret('new vault password, 10+ characters — it cannot be recovered')
if (password === null) return
const again = await workspace.askSecret('the same again')
const again = await workspace.prompts.askSecret('the same again')
if (again === null) return
if (again !== password) return workspace.say('the two passwords differ — nothing was set', 'error')
workspace.vault = await api.setupVault(password)
workspace.vault.status = await api.setupVault(password)
workspace.say('vault ready and open — :encrypt seals what is under the cursor, :upload seal seals new files')
}
async function changeVaultPassword() {
const current = await workspace.askSecret('current vault password')
const current = await workspace.prompts.askSecret('current vault password')
if (current === null) return
const password = await workspace.askSecret('new vault password, 10+ characters')
const password = await workspace.prompts.askSecret('new vault password, 10+ characters')
if (password === null) return
const again = await workspace.askSecret('the same again')
const again = await workspace.prompts.askSecret('the same again')
if (again === null) return
if (again !== password) return workspace.say('the two passwords differ — nothing was changed', 'error')
workspace.vault = await api.changeVaultPassword(current, password)
workspace.vault.status = await api.changeVaultPassword(current, password)
workspace.say(
workspace.vault.rekey
? `vault password changed — re-sealing ${workspace.vault.sealed} file(s) under the new key; leave the vault open, :vault shows how far`
workspace.vault.status.rekey
? `vault password changed — re-sealing ${workspace.vault.status.sealed} file(s) under the new key; leave the vault open, :vault shows how far`
: 'vault password changed',
)
}
@@ -407,15 +407,15 @@ async function protonLogin(username: string) {
const unsealed = status.sealed_session
? ''
: ' The session will be stored unsealed on the data volume — set WORKSPACE_SESSION_KEY_FILE to seal it.'
if (!(await workspace.ask(`${status.disclosure ?? ''}${unsealed} Log in as ${username}?`))) return
const password = await workspace.askSecret(`proton password for ${username}`)
if (!(await workspace.prompts.ask(`${status.disclosure ?? ''}${unsealed} Log in as ${username}?`))) return
const password = await workspace.prompts.askSecret(`proton password for ${username}`)
if (password === null) return
workspace.say('logging in to proton …')
let answer: BackupLogin = await api.backupLogin(username, password)
for (let round = 0; answer.next !== 'done'; round++) {
if (round === 5) return workspace.say('proton keeps asking — try again later', 'error')
if (answer.next === 'totp') {
const code = await workspace.askSecret('proton two-factor code')
const code = await workspace.prompts.askSecret('proton two-factor code')
if (code === null) return workspace.say('proton login abandoned')
try {
answer = await api.backupTotp(code)
@@ -425,7 +425,7 @@ async function protonLogin(username: string) {
workspace.say(error.message, 'error')
}
} else {
const token = await workspace.askVerification(answer.url ?? '')
const token = await workspace.prompts.askVerification(answer.url ?? '')
if (token === null) return workspace.say('proton login abandoned')
workspace.say('logging in to proton …')
answer = await api.backupCaptcha(token)
@@ -447,9 +447,9 @@ const exportName = (title: string, at: number) => {
* `:q!`. A document `o` made and nothing was written in is not kept.
*/
async function leaveEditor() {
if (workspace.writing) return void workspace.leaveWriting()
if (await workspace.abandonIfBlank()) return hooks.focusList()
if (workspace.dirty && !(await workspace.guard(() => workspace.save()))) return
if (workspace.drafts.writing) return void workspace.drafts.leaveWriting()
if (await workspace.drafts.abandonIfBlank()) return hooks.focusList()
if (workspace.dirty && !(await workspace.guard(() => workspace.drafts.save()))) return
workspace.editing = false
hooks.focusList()
}
@@ -461,10 +461,10 @@ export const commands: CommandSpec[] = [
group: 'editing',
essential: true,
help: "save what is being written — the open document, a card's description, a note — rather than in a moment",
run: () => workspace.guard(() => (workspace.writing ? workspace.saveWriting() : workspace.save())),
run: () => workspace.guard(() => (workspace.drafts.writing ? workspace.drafts.saveWriting() : workspace.drafts.save())),
bang: {
help: 'save even over a newer version saved somewhere else since this one was opened',
run: () => workspace.guard(() => workspace.save({ force: true })),
run: () => workspace.guard(() => workspace.drafts.save({ force: true })),
},
},
{
@@ -476,7 +476,7 @@ export const commands: CommandSpec[] = [
bang: {
help: 'save over a newer version, as :w! does, and leave the editor',
run: async () => {
if (await workspace.guard(() => workspace.save({ force: true }))) await leaveEditor()
if (await workspace.guard(() => workspace.drafts.save({ force: true }))) await leaveEditor()
},
},
},
@@ -487,27 +487,27 @@ export const commands: CommandSpec[] = [
essential: true,
help: 'leave the editor with what is waiting written first, or close the document when already reading',
run: async () => {
if (workspace.writing || workspace.editing) return leaveEditor()
if (workspace.drafts.writing || workspace.editing) return leaveEditor()
// A document `o` made and nothing was written in is not kept.
if (await workspace.abandonIfBlank()) return hooks.focusList()
if (await workspace.drafts.abandonIfBlank()) return hooks.focusList()
if (workspace.dirty) {
workspace.say('unsaved changes — :w to write, :q! to discard', 'error')
return
}
if (workspace.tiles.length > 1) return focusTile(closeFocused())
workspace.closeTile()
if (workspace.tiles.all.length > 1) return focusTile(closeFocused())
workspace.tiles.close()
hooks.focusList()
},
bang: {
help: 'leave the editor, letting go of what is not saved yet',
run: async () => {
if (workspace.writing) return void workspace.leaveWriting(true)
if (workspace.drafts.writing) return void workspace.drafts.leaveWriting(true)
const open = workspace.open
workspace.editing = false
workspace.dirty = false
if (open) {
workspace.dropStash(open.id)
await workspace.guard(() => workspace.openNode(open.id))
workspace.drafts.dropStash(open.id)
await workspace.guard(() => workspace.tree.openNode(open.id))
}
hooks.focusList()
},
@@ -524,24 +524,24 @@ export const commands: CommandSpec[] = [
const [what, rest] = verb(arg, ['new', 'name', 'rm'])
if (what === 'new') {
if (!rest) return workspace.say('usage: :space new <name>', 'error')
return workspace.guard(() => workspace.createSpace(rest))
return workspace.guard(() => workspace.tree.createSpace(rest))
}
if (what === 'name') {
const space = workspace.space
const space = workspace.tree.space
if (!space) return workspace.say('no space open', 'error')
if (!rest) return workspace.say('usage: :space name <text>', 'error')
return workspace.guard(() => workspace.renameNode(space.id, rest))
return workspace.guard(() => workspace.tree.renameNode(space.id, rest))
}
if (what === 'rm') return removeSpace(rest)
const wanted = rest.toLowerCase()
if (!wanted) {
return workspace.say(workspace.spaces.map((space) => space.slug).join(' · ') || 'no spaces')
return workspace.say(workspace.tree.spaces.map((space) => space.slug).join(' · ') || 'no spaces')
}
const space = workspace.spaces.find(
const space = workspace.tree.spaces.find(
(candidate) => candidate.slug === wanted || candidate.name.toLowerCase() === wanted,
)
if (!space) return workspace.say(`no such space: ${wanted} — :space new ${wanted} makes it`, 'error')
await workspace.guard(() => workspace.enterSpace(space.id))
await workspace.guard(() => workspace.tree.enterSpace(space.id))
},
},
{
@@ -551,7 +551,7 @@ export const commands: CommandSpec[] = [
help: 'make a folder here',
run: async (arg) => {
if (!arg.trim()) return workspace.say('usage: :mkdir <name>', 'error')
await workspace.guard(() => workspace.createFolder(arg.trim()))
await workspace.guard(() => workspace.tree.createFolder(arg.trim()))
},
},
{
@@ -563,14 +563,14 @@ export const commands: CommandSpec[] = [
run: async (arg) => {
const target = arg.trim()
await workspace.guard(async () => {
if (!target || target === '..') return workspace.up()
if (!target || target === '..') return workspace.tree.up()
if (target === '/') {
const space = workspace.spaceId
return space === null ? undefined : workspace.goTo(space)
const space = workspace.tree.spaceId
return space === null ? undefined : workspace.tree.goTo(space)
}
const folder = findFolder(target)
if (!folder) return workspace.say(`no such folder here: ${target}`, 'error')
await workspace.goTo(folder.id)
await workspace.tree.goTo(folder.id)
})
},
},
@@ -592,14 +592,14 @@ export const commands: CommandSpec[] = [
name: 'board',
group: 'board',
help: "this space's board, in a tile — the last one shown when this space has none",
run: () => workspace.guard(() => workspace.showView('board')),
run: () => workspace.guard(() => workspace.tiles.show('board')),
},
{
name: 'plan',
aliases: ['special'],
group: 'board',
help: 'the board, the agenda and the calendar, tiled ahead of what is open — S puts them up, and every view away again; 1 … 9 on the board picks the board',
run: () => workspace.guard(() => workspace.togglePlan()),
run: () => workspace.guard(() => workspace.tiles.togglePlan()),
},
{
name: 'card',
@@ -611,7 +611,7 @@ export const commands: CommandSpec[] = [
const open = workspace.open
if (readingDocument() && open) return cardAbout(open, arg)
if (!arg.trim()) return workspace.say('usage: :card <title>', 'error')
await workspace.guard(() => workspace.createCard(arg.trim()))
await workspace.guard(() => workspace.cards.createCard(arg.trim()))
},
},
{
@@ -622,11 +622,11 @@ export const commands: CommandSpec[] = [
complete: () => ['today', 'tm', 'fri', '+3d'],
run: async (arg) => {
const target = arg.trim()
if (target === '-' || target === '') return workspace.guard(() => workspace.patchCard({ due_at: null }))
if (target === '-' || target === '') return workspace.guard(() => workspace.cards.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(async () => {
if (await workspace.patchCard({ due_at: at })) workspace.say(`due ${formatDay(at)}`)
if (await workspace.cards.patchCard({ due_at: at })) workspace.say(`due ${formatDay(at)}`)
})
},
},
@@ -640,7 +640,7 @@ export const commands: CommandSpec[] = [
const rule = arg.trim()
const repeat = rule === '-' || rule === '' ? null : rule
await workspace.guard(async () => {
const card = await workspace.patchCard({ repeat })
const card = await workspace.cards.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`)
})
@@ -650,7 +650,7 @@ export const commands: CommandSpec[] = [
name: 'done',
group: 'board',
help: 'move the card in focus into the done list, or back out to the inbox',
run: () => workspace.guard(() => workspace.toggleDone()),
run: () => workspace.guard(() => workspace.cards.toggleDone()),
},
{
name: 'scratch',
@@ -659,8 +659,8 @@ export const commands: CommandSpec[] = [
group: 'documents',
help: 'the scratchpad, lowered over whatever is on screen — Q does the same; with text, a note on it without leaving',
run: async (arg) => {
if (!arg.trim()) return workspace.guard(() => workspace.toggleScratch())
await workspace.guard(() => workspace.scratch(arg.trim()))
if (!arg.trim()) return workspace.guard(() => workspace.scratch.toggle())
await workspace.guard(() => workspace.scratch.add(arg.trim()))
},
},
{
@@ -670,10 +670,10 @@ export const commands: CommandSpec[] = [
help: 'put the scratch note at the end of a document, by its title — Tab completes it — or the one on screen, and take it off the scratchpad',
complete: documentTitles,
run: async (arg) => {
if (!workspace.scratchOpen) return workspace.say('no scratch note — Q lowers the scratchpad', 'error')
if (!workspace.scratch.open) return workspace.say('no scratch note — Q lowers the scratchpad', 'error')
const target = arg.trim() || documentOnScreen()
if (!target) return workspace.say('usage: :append <document> — or open the one it goes to', 'error')
await workspace.guard(() => workspace.appendScratchNote(target))
await workspace.guard(() => workspace.scratch.appendNote(target))
},
},
{
@@ -682,8 +682,8 @@ export const commands: CommandSpec[] = [
group: 'documents',
help: 'make the scratch note a document in the folder being browsed — titled by its first line unless given one — and take it off the scratchpad',
run: async (arg) => {
if (!workspace.scratchOpen) return workspace.say('no scratch note — Q lowers the scratchpad', 'error')
await workspace.guard(() => workspace.keepScratchNote(arg))
if (!workspace.scratch.open) return workspace.say('no scratch note — Q lowers the scratchpad', 'error')
await workspace.guard(() => workspace.scratch.keepNote(arg))
},
},
{
@@ -696,7 +696,7 @@ export const commands: CommandSpec[] = [
if (workspace.focus !== 'card') return workspace.say('no card turned over — Enter on the board turns one', 'error')
const [what, text] = verb(arg, ['add', 'edit'])
if (!text) return workspace.say(`usage: :task ${what === 'edit' ? 'edit' : '[add]'} <text>`, 'error')
await workspace.guard(() => (what === 'edit' ? workspace.renameTask(text) : workspace.addTask(text)))
await workspace.guard(() => (what === 'edit' ? workspace.cards.renameTask(text) : workspace.cards.addTask(text)))
},
},
{
@@ -704,11 +704,11 @@ export const commands: CommandSpec[] = [
args: '<name>',
group: 'board',
help: 'put a label on the card, or take it off when it has it — each name keeps its colour everywhere',
complete: () => workspace.boardLabels,
complete: () => workspace.cards.boardLabels,
run: async (arg) => {
const card = workspace.focusedCard
const card = workspace.cards.focusedCard
if (!arg.trim() && card) return workspace.say(card.labels.join(' · ') || 'no labels — :label <name> adds one')
await workspace.guard(() => workspace.toggleLabel(arg))
await workspace.guard(() => workspace.cards.toggleLabel(arg))
},
},
{
@@ -718,22 +718,22 @@ export const commands: CommandSpec[] = [
help: 'add a column to the board — name renames the one under the cursor, rm deletes it and every card on it (asks first)',
complete: () => ['name ', 'rm'],
run: async (arg) => {
if (!workspace.view('board')) return workspace.say('no board open — :board', 'error')
if (!workspace.tiles.view('board')) return workspace.say('no board open — :board', 'error')
const [what, rest] = verb(arg, ['name', 'rm'])
const list = workspace.list
const list = workspace.cards.list
if (what === 'name') {
if (!list) return workspace.say('no list selected', 'error')
if (!rest) return workspace.say('usage: :list name <text>', 'error')
return workspace.guard(() => workspace.renameList(rest))
return workspace.guard(() => workspace.cards.renameList(rest))
}
if (what === 'rm') {
if (!list) return workspace.say('no list selected', 'error')
const cards = list.cards.length
if (!(await workspace.ask(`delete the list "${list.name}" and its ${cards} card${cards === 1 ? '' : 's'}?`))) return
return workspace.guard(() => workspace.deleteList())
if (!(await workspace.prompts.ask(`delete the list "${list.name}" and its ${cards} card${cards === 1 ? '' : 's'}?`))) return
return workspace.guard(() => workspace.cards.deleteList())
}
if (!rest) return workspace.say('usage: :list <name>', 'error')
await workspace.guard(() => workspace.createList(rest))
await workspace.guard(() => workspace.cards.createList(rest))
},
},
{
@@ -745,7 +745,7 @@ export const commands: CommandSpec[] = [
help: 'move the marked nodes, or else the selected one, into a folder here, up one level, or to the root of the space',
complete: folderNames,
run: async (arg) => {
const node = workspace.marked[0] ?? workspace.target
const node = workspace.tree.marked[0] ?? workspace.target
if (!node) return workspace.say('nothing selected', 'error')
const target = arg.trim()
if (!target) return workspace.say('usage: :mv <folder|..|/>', 'error')
@@ -758,11 +758,11 @@ export const commands: CommandSpec[] = [
return parent.kind === 'space' ? undefined : (parent.parent_id ?? undefined)
}
const destination =
target === '..' ? await up() : target === '/' ? (workspace.spaceId ?? undefined) : findFolder(target)?.id
target === '..' ? await up() : target === '/' ? (workspace.tree.spaceId ?? undefined) : findFolder(target)?.id
if (destination === undefined) return workspace.say(`no such folder: ${target}`, 'error')
if (destination === node.id) return workspace.say('a folder cannot hold itself', 'error')
if (workspace.marked.length) return workspace.guard(() => workspace.moveNodes(workspace.marked, destination))
await workspace.guard(() => workspace.moveNode(node.id, destination))
if (workspace.tree.marked.length) return workspace.guard(() => workspace.tree.moveNodes(workspace.tree.marked, destination))
await workspace.guard(() => workspace.tree.moveNode(node.id, destination))
},
},
{
@@ -776,7 +776,7 @@ export const commands: CommandSpec[] = [
run: async (arg) => {
await workspace.guard(async () => {
const words = arg.trim().split(/\s+/).filter(Boolean)
const here = workspace.spaceId
const here = workspace.tree.spaceId
const templates = words.length && here !== null ? await api.templates(here) : []
if (words.length && here !== null) templateCache = { space: here, names: templates.map((t) => t.slug) }
const wanted = words[0]?.toLowerCase()
@@ -789,7 +789,7 @@ export const commands: CommandSpec[] = [
created_at = at
words.shift()
}
await workspace.createDocument(words.join(' '), { created_at, template_id: template?.id })
await workspace.tree.createDocument(words.join(' '), { created_at, template_id: template?.id })
})
hooks.focusEditor()
},
@@ -801,8 +801,8 @@ export const commands: CommandSpec[] = [
help: 'the diary by month — h/l a day, j/k a week, H/L a month, Enter opens the day or starts it',
run: () =>
workspace.guard(async () => {
if (workspace.view('calendar')) return workspace.showView('calendar')
await workspace.openCalendar()
if (workspace.tiles.view('calendar')) return workspace.tiles.show('calendar')
await workspace.diary.openCalendar()
}),
},
{
@@ -812,8 +812,8 @@ export const commands: CommandSpec[] = [
help: 'what is late, due this week, or in hand — across every board',
run: () =>
workspace.guard(async () => {
if (!workspace.view('agenda')) workspace.agendaCursor = 0
await workspace.showView('agenda')
if (!workspace.tiles.view('agenda')) workspace.cards.agendaCursor = 0
await workspace.tiles.show('agenda')
}),
},
{
@@ -822,8 +822,8 @@ export const commands: CommandSpec[] = [
group: 'documents',
help: "open today's diary entry, starting one if there is none yet; with text, a timed line at its end without going there",
run: async (arg) => {
if (arg.trim()) return workspace.guard(() => workspace.logToday(arg.trim()))
await workspace.guard(() => workspace.openDay())
if (arg.trim()) return workspace.guard(() => workspace.diary.logToday(arg.trim()))
await workspace.guard(() => workspace.diary.openDay())
hooks.focusEditor()
},
},
@@ -843,7 +843,7 @@ export const commands: CommandSpec[] = [
}
const id = arg ? Number(arg) : node?.id
if (!id || Number.isNaN(id)) return workspace.say('usage: :e <id>', 'error')
await workspace.guard(() => workspace.openNode(id, true))
await workspace.guard(() => workspace.tree.openNode(id, true))
hooks.focusEditor()
},
bang: {
@@ -852,8 +852,8 @@ export const commands: CommandSpec[] = [
const open = requireOpen()
if (!open) return
workspace.dirty = false
workspace.dropStash(open.id)
await workspace.guard(() => workspace.openNode(open.id, workspace.editing))
workspace.drafts.dropStash(open.id)
await workspace.guard(() => workspace.tree.openNode(open.id, workspace.editing))
workspace.say(`${open.slug} reloaded`)
},
},
@@ -862,13 +862,13 @@ export const commands: CommandSpec[] = [
name: 'next',
group: 'documents',
help: 'open the next node down the list',
run: () => jumpTo(workspace.cursor + 1),
run: () => jumpTo(workspace.tree.cursor + 1),
},
{
name: 'prev',
group: 'documents',
help: 'open the previous node',
run: () => jumpTo(workspace.cursor - 1),
run: () => jumpTo(workspace.tree.cursor - 1),
},
{
name: 'close',
@@ -877,10 +877,10 @@ export const commands: CommandSpec[] = [
help: 'close the focused tile — Alt-q; asks when its draft could not be written',
run: () =>
workspace.guard(async () => {
await workspace.flushTile()
await workspace.drafts.flushTile()
if (workspace.dirty) {
const name = workspace.open?.slug ?? 'the draft'
if (!(await workspace.ask(`${name} could not be written — close anyway? the draft stays in this browser`))) return
if (!(await workspace.prompts.ask(`${name} could not be written — close anyway? the draft stays in this browser`))) return
}
await focusTile(closeFocused())
}),
@@ -890,14 +890,14 @@ export const commands: CommandSpec[] = [
aliases: ['on'],
group: 'tiles',
help: 'close every tile but the focused one',
run: () => workspace.guard(() => workspace.onlyTile()),
run: () => workspace.guard(() => workspace.tiles.only()),
},
{
name: 'ls',
aliases: ['buffers', 'files'],
group: 'tiles',
help: 'list the open tiles, % on the focused one',
run: () => workspace.say(workspace.tiles.map((_, index) => describeTile(index)).join(' · ')),
run: () => workspace.say(workspace.tiles.all.map((_, index) => describeTile(index)).join(' · ')),
},
{
name: 'buffer',
@@ -905,18 +905,18 @@ export const commands: CommandSpec[] = [
args: '<n|name>',
group: 'tiles',
help: 'focus a tile by its number in :ls, or by the start of its name',
complete: () => workspace.tiles.flatMap((tile) => (tile.kind !== 'node' ? [tile.kind] : tile.open ? [tile.open.slug] : [])),
complete: () => workspace.tiles.all.flatMap((tile) => (tile.kind !== 'node' ? [tile.kind] : tile.open ? [tile.open.slug] : [])),
run: (arg) => {
const wanted = arg.trim().toLowerCase()
if (!wanted) return workspace.say(describeTile(workspace.focused))
if (!wanted) return workspace.say(describeTile(workspace.tiles.focused))
const index = /^\d+$/.test(wanted)
? Number(wanted) - 1
: workspace.tiles.findIndex((tile) =>
: workspace.tiles.all.findIndex((tile) =>
tile.kind !== 'node'
? tile.kind.startsWith(wanted)
: tile.open && (tile.open.slug.startsWith(wanted) || tile.open.name.toLowerCase().startsWith(wanted)),
)
if (!workspace.tiles[index]) return workspace.say(`E86: no tile ${arg.trim()} — :ls lists them`, 'error')
if (!workspace.tiles.all[index]) return workspace.say(`E86: no tile ${arg.trim()} — :ls lists them`, 'error')
return focusTile(index)
},
},
@@ -944,13 +944,13 @@ export const commands: CommandSpec[] = [
name: 'last',
group: 'documents',
help: 'open the last node in the list',
run: () => jumpTo(workspace.rows - 1),
run: () => jumpTo(workspace.tree.rows - 1),
},
{
name: 'random',
group: 'documents',
help: 'open something here at random — good for re-reading',
run: () => jumpTo(Math.floor(Math.random() * workspace.nodes.length)),
run: () => jumpTo(Math.floor(Math.random() * workspace.tree.nodes.length)),
},
{
name: 'delete',
@@ -961,23 +961,23 @@ export const commands: CommandSpec[] = [
run: async (arg) => {
// On the board the thing under the cursor is a card, not a node.
if (onBoard() && !arg) {
const card = workspace.card
const card = workspace.cards.card
if (!card) return workspace.say('no card selected', 'error')
if (!(await workspace.ask(`delete the card "${card.title}"?`))) return
return workspace.guard(() => workspace.deleteCard())
if (!(await workspace.prompts.ask(`delete the card "${card.title}"?`))) return
return workspace.guard(() => workspace.cards.deleteCard())
}
// Whatever has focus: the row under the cursor in the list, the open
// document only while the document pane is the one being looked at.
const id = arg ? Number(arg) : workspace.target?.id
if (!id || Number.isNaN(id)) return workspace.say('nothing to delete', 'error')
const node = workspace.open?.id === id ? workspace.open : workspace.nodes.find((n) => n.id === id)
const node = workspace.open?.id === id ? workspace.open : workspace.tree.nodes.find((n) => n.id === id)
const name = node ? `"${node.name.trim() || node.slug}"` : `#${id}`
const what =
node && isContainer(node)
? `delete the ${node.kind} ${name} and everything in it?`
: `delete ${name}?`
if (!(await workspace.ask(`${what} it goes to the trash.`))) return
await workspace.guard(() => workspace.deleteNode(id))
if (!(await workspace.prompts.ask(`${what} it goes to the trash.`))) return
await workspace.guard(() => workspace.tree.deleteNode(id))
},
},
{
@@ -989,9 +989,9 @@ export const commands: CommandSpec[] = [
run: async (arg) => {
if (!arg.trim() && workspace.editing && workspace.pane === 'document') return hooks.focusTitle()
if (onBoard()) {
if (!workspace.card) return workspace.say('no card here — :list name renames the list', 'error')
if (!workspace.cards.card) return workspace.say('no card here — :list name renames the list', 'error')
if (!arg.trim()) return workspace.say('usage: :name <text>', 'error')
return workspace.guard(() => workspace.patchCard({ title: arg.trim() }))
return workspace.guard(() => workspace.cards.patchCard({ title: arg.trim() }))
}
if (!arg.trim()) return workspace.say('usage: :name <text>', 'error')
@@ -1001,11 +1001,11 @@ export const commands: CommandSpec[] = [
if (node && node.id === workspace.open?.id) {
workspace.draft.name = arg
workspace.dirty = true
return workspace.guard(() => workspace.save())
return workspace.guard(() => workspace.drafts.save())
}
if (!node) return workspace.say('nothing selected — :space name renames the space', 'error')
await workspace.guard(() => workspace.renameNode(node.id, arg))
await workspace.guard(() => workspace.tree.renameNode(node.id, arg))
},
},
{
@@ -1043,9 +1043,9 @@ export const commands: CommandSpec[] = [
if (arg.trim() === 'purge') return emptyTrash()
if (arg.trim()) return workspace.say('usage: :trash [purge]', 'error')
await workspace.guard(async () => {
await workspace.loadTrash()
workspace.trashCursor = 0
workspace.openSheet('trash')
await workspace.sheets.loadTrash()
workspace.sheets.trashCursor = 0
workspace.sheets.open('trash')
})
},
},
@@ -1060,7 +1060,7 @@ export const commands: CommandSpec[] = [
if (at === null) return workspace.say('usage: :date 2026-09-05', 'error')
workspace.draft.created_at = at
workspace.dirty = true
await workspace.guard(() => workspace.save())
await workspace.guard(() => workspace.drafts.save())
},
},
{
@@ -1078,7 +1078,7 @@ export const commands: CommandSpec[] = [
if (what === 'new') return renewLink(node)
if (what) return workspace.say('usage: :share [off|new]', 'error')
if (node.shared) return workspace.say('already shared — :share new for a fresh link, :share off to take it back')
await workspace.guard(() => workspace.toggleShare(node.id))
await workspace.guard(() => workspace.tree.toggleShare(node.id))
},
},
{
@@ -1105,7 +1105,7 @@ export const commands: CommandSpec[] = [
if (!node) return workspace.say('nothing selected — :export! downloads everything', 'error')
if (node.kind === 'document') return exportDocument(node)
if (!node.file) return workspace.say(`a ${node.kind} goes with :export!, the whole workspace`, 'error')
if (node.file.encrypted && !(await workspace.ensureUnlocked())) return
if (node.file.encrypted && !(await workspace.vault.ensureUnlocked())) return
const anchor = document.createElement('a')
anchor.href = node.file.url
anchor.download = node.name
@@ -1117,12 +1117,12 @@ export const commands: CommandSpec[] = [
run: async () => {
// A plain navigation, so the browser streams the archive straight to
// disk instead of the tab holding all of it in memory first.
await workspace.flush()
await workspace.drafts.flush()
// Sealed files go into the archive opened, which needs the vault:
// asked here, because a refusal after the navigation would replace
// the whole app with an error page.
await workspace.loadVault()
if ((workspace.vault?.sealed ?? 0) > 0 && !(await workspace.ensureUnlocked())) {
await workspace.vault.load()
if ((workspace.vault.status?.sealed ?? 0) > 0 && !(await workspace.vault.ensureUnlocked())) {
return workspace.say('the export holds sealed files — it needs the vault open', 'error')
}
workspace.say('building the archive — the download starts on its own')
@@ -1150,7 +1150,7 @@ export const commands: CommandSpec[] = [
const what = arg.trim()
if (what === 'seal') {
return workspace.guard(async () => {
if (await workspace.ensureUnlocked()) hooks.pickTreeFiles(true)
if (await workspace.vault.ensureUnlocked()) hooks.pickTreeFiles(true)
})
}
if (what) return workspace.say('usage: :upload [seal]', 'error')
@@ -1182,10 +1182,10 @@ export const commands: CommandSpec[] = [
args: '[text]',
group: 'documents',
help: 'full-text search in this space (empty clears)',
run: (arg) => workspace.guard(() => workspace.search(arg, false)),
run: (arg) => workspace.guard(() => workspace.tree.search(arg, false)),
bang: {
help: 'full-text search across every space — Ctrl-P',
run: (arg) => workspace.guard(() => workspace.search(arg, true)),
run: (arg) => workspace.guard(() => workspace.tree.search(arg, true)),
},
},
{
@@ -1193,7 +1193,7 @@ export const commands: CommandSpec[] = [
aliases: ['nohlsearch'],
group: 'documents',
help: 'clear the search filter',
run: () => workspace.guard(() => workspace.search('')),
run: () => workspace.guard(() => workspace.tree.search('')),
},
{
name: 'reload',
@@ -1202,8 +1202,8 @@ export const commands: CommandSpec[] = [
help: 're-read the current folder from the server',
run: () =>
workspace.guard(async () => {
await workspace.refresh()
workspace.say(`${workspace.nodes.length} here`)
await workspace.tree.refresh()
workspace.say(`${workspace.tree.nodes.length} here`)
}),
},
{
@@ -1215,8 +1215,8 @@ export const commands: CommandSpec[] = [
const words = body.split(/\s+/).filter(Boolean).length
const open = workspace.open ? `${workspace.open.slug} ${words}w ${body.split('\n').length}L · ` : ''
workspace.say(
`${open}${workspace.here || '/'} · ${workspace.nodes.length} node${workspace.nodes.length === 1 ? '' : 's'}${
workspace.query ? ` matching "${workspace.query}"` : ''
`${open}${workspace.tree.here || '/'} · ${workspace.tree.nodes.length} node${workspace.tree.nodes.length === 1 ? '' : 's'}${
workspace.tree.query ? ` matching "${workspace.tree.query}"` : ''
}`,
)
},
@@ -1238,8 +1238,8 @@ export const commands: CommandSpec[] = [
return workspace.setClipboard(false), workspace.say('yanks stay in vim registers')
// A board is a property of the space, not of this browser, so unlike the
// options above this one goes to the server.
if (option === 'board') return workspace.guard(() => workspace.setBoard(true))
if (option === 'noboard') return workspace.guard(() => workspace.setBoard(false))
if (option === 'board') return workspace.guard(() => workspace.cards.setBoard(true))
if (option === 'noboard') return workspace.guard(() => workspace.cards.setBoard(false))
workspace.say(`unknown option: ${option}`, 'error')
},
},
@@ -1256,7 +1256,7 @@ export const commands: CommandSpec[] = [
const loginMatch = /^login\s+(\S+)$/.exec(trimmed)
if (loginMatch) return protonLogin(loginMatch[1])
if (trimmed === 'logout') {
if (!(await workspace.ask('Forget the Proton Drive session? What is mirrored stays on Drive.'))) return
if (!(await workspace.prompts.ask('Forget the Proton Drive session? What is mirrored stays on Drive.'))) return
await api.backupLogout()
return workspace.say('proton drive session forgotten — backups are off')
}
@@ -1268,7 +1268,7 @@ export const commands: CommandSpec[] = [
workspace.guard(async () => {
// Whatever is in the editor belongs in the backup that was just
// asked for, so it goes to the server before the mirror runs.
await workspace.flush()
await workspace.drafts.flush()
workspace.say('backing up …')
const status = await api.backupNow()
if (status.last_error) return workspace.say(`backup failed: ${status.last_error}`, 'error')
@@ -1298,7 +1298,7 @@ export const commands: CommandSpec[] = [
if (!target) return workspace.say(`no session ${rmMatch[1]} — :sessions lists them`, 'error')
if (target.current) return workspace.say('that is this browser — :logout ends it', 'error')
await api.revokeSession(target.id)
await workspace.loadVault()
await workspace.vault.load()
workspace.say(`signed out ${browserOf(target.user_agent)}, seen ${relative(target.last_seen)} — the vault is locked`)
}),
},
@@ -1393,19 +1393,19 @@ export const commands: CommandSpec[] = [
run: (arg) =>
workspace.guard(async () => {
const what = arg.trim()
await workspace.loadVault()
await workspace.vault.load()
if (what === 'unlock') {
if (workspace.vault?.unlocked) return workspace.say('the vault is already open')
return workspace.ensureUnlocked()
if (workspace.vault.status?.unlocked) return workspace.say('the vault is already open')
return workspace.vault.ensureUnlocked()
}
if (what === 'lock') {
workspace.vault = await api.lockVault()
workspace.vault.status = await api.lockVault()
if (workspace.open?.file?.encrypted) workspace.open = await api.getNode(workspace.open.id)
return workspace.say('vault locked')
}
if (what === 'passwd') return changeVaultPassword()
if (what) return workspace.say('usage: :vault [unlock|lock|passwd]', 'error')
if (!workspace.vault?.configured) return setupVault()
if (!workspace.vault.status?.configured) return setupVault()
await openSettings('vault')
}),
},
@@ -1433,7 +1433,7 @@ export const commands: CommandSpec[] = [
complete: () => ['all'],
run: (arg) => {
const all = arg.trim() === 'all'
if (workspace.overlay === 'help' && !all) return workspace.closeSheet()
if (workspace.sheets.shown === 'help' && !all) return workspace.sheets.close()
openHelp(all)
},
},
@@ -1493,7 +1493,7 @@ const renamed: Record<string, string | ((arg: string) => unknown)> = {
if (node?.kind !== 'document') return workspace.say('no document to link — open one first', 'error')
return cardAbout(node, arg)
},
unmark: () => workspace.unmarkAll(),
unmark: () => workspace.tree.unmarkAll(),
}
const lookup = new Map<string, CommandSpec>()
@@ -1508,7 +1508,7 @@ export async function runCommand(line: string): Promise<void> {
if (!input) return
if (input.startsWith('/')) {
await workspace.guard(() => workspace.search(input.slice(1)))
await workspace.guard(() => workspace.tree.search(input.slice(1)))
return
}
@@ -1523,7 +1523,7 @@ export async function runCommand(line: string): Promise<void> {
// `:12` jumps to a node by id, the way `:12` jumps to a line in vim.
if (/^\d+$/.test(head)) {
await workspace.guard(() => workspace.openNode(Number(head)))
await workspace.guard(() => workspace.tree.openNode(Number(head)))
return
}
@@ -1539,7 +1539,7 @@ export async function runCommand(line: string): Promise<void> {
await spec.bang.run(arg)
} else await spec.run(arg)
// What a line changed shows in the settings sheet it was typed over.
if (workspace.overlay === 'settings') await workspace.guard(() => loadSettings())
if (workspace.sheets.shown === 'settings') await workspace.guard(() => loadSettings())
}
/**
+4 -4
View File
@@ -26,14 +26,14 @@ const sheets = new Set<string>(['help', 'overview', 'trash', 'settings'])
/** The stack, the topmost first. */
export function stack(): Context[] {
const modes: Context[] = ['always']
const overlay = workspace.overlay
const overlay = workspace.sheets.shown
if (overlay !== 'none') {
// The info sheet hears the keys of the part on show, then its own Tab.
if (overlay === 'info') modes.push(workspace.infoSection, 'info')
if (overlay === 'info') modes.push(workspace.sheets.infoSection, 'info')
else if (sheets.has(overlay)) modes.push(overlay as Context)
return [...modes, 'sheet']
}
if (workspace.scratchOpen && !workspace.scratchEditing) modes.push('drawer')
if (workspace.scratch.open && !workspace.scratch.editing) modes.push('drawer')
modes.push('workspace')
const focus = workspace.focus
if (focus === 'list' || focus === 'node') {
@@ -41,7 +41,7 @@ export function stack(): Context[] {
modes.push('tree')
} else {
// While the description is written, the card's back has no keys of its own.
if (!(focus === 'card' && workspace.describing)) modes.push(focus)
if (!(focus === 'card' && workspace.cards.describing)) modes.push(focus)
modes.push('views')
}
modes.push('global')
+593
View File
@@ -0,0 +1,593 @@
import { api, ApiError, type Agenda, type AgendaCard, type Board, type BoardList, type Card, type Moved, type NodeSummary, type Task } from '../api'
import { formatDay, formatWeekday } from '../util'
import type { Workspace } from '../store.svelte'
/** `done: x`, and when a repeating card comes back. */
function doneSaid(moved: Moved): string {
const again = moved.again?.due_at
return again == null ? `done: ${moved.title}` : `done: ${moved.title} — again ${formatWeekday(again)} ${formatDay(again)}`
}
/**
* The boards, the agenda and the back of a card: one board on show at a
* time, the agenda over every board kept loaded, and the card verbs, which
* act on the card in focus wherever it is looked at.
*/
export class Cards {
readonly ws: Workspace
constructor(ws: Workspace) {
this.ws = ws
}
/** How long a finished card stays in sight on the board before `z` is needed. */
static readonly RECENT_DONE_DAYS = 7
/** The board as the server sent it, loaded only while it is being looked at. */
rawBoard = $state<Board | null>(null)
/*
* The board as it is walked and drawn: the done list keeps only what was
* finished this past week unless `z` asks for the rest, so the cursor
* indices and what is on screen are always the same thing.
*/
board = $derived.by((): Board | null => {
const raw = this.rawBoard
if (!raw || this.showOldDone) return raw
const since = Math.floor(Date.now() / 1000) - Cards.RECENT_DONE_DAYS * 86400
const last = raw.lists.length - 1
return {
...raw,
lists: raw.lists.map((list, index) =>
index === last && index > 0
? { ...list, cards: list.cards.filter((card) => (card.done_at ?? 0) >= since) }
: list,
),
}
})
listCursor = $state(0)
cardCursor = $state(0)
/** The step of the open card's checklist under the cursor. */
taskCursor = $state(0)
get describing() {
return this.ws.drafts.writing?.kind === 'description'
}
/** `z` on the board: show done cards older than a week too. */
showOldDone = $state(false)
/**
* The space whose board the board's tile shows: the one being browsed
* when it has a board, or else the last one shown, so that coming from the
* diary does not leave the board's tile empty.
*/
boardSpaceId = $state<number | null>(null)
/*
* What is due or in hand on every board, kept loaded all the time: the bar
* counts from it, so what is late is in sight without going to look.
*/
agenda = $state<Agenda>({ cards: [], diary_today: null, on_this_day: [], today: null })
agendaCursor = $state(0)
get list() {
return this.board?.lists[this.listCursor]
}
get card(): Card | undefined {
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.ws.open?.cards ?? []
if (this.ws.sheets.shown === 'info' && this.ws.sheets.infoSection === 'cards') return about[this.ws.sheets.cardsCursor]
switch (this.ws.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.ws.focus === 'node' ? (this.ws.open?.cards ?? []).filter((c) => c.done_at === null).length : 0
if (open > 1) this.ws.say(`${open} open cards about this — gc picks one`, 'error')
else if (this.ws.focus === 'node' && this.ws.open?.kind === 'document') this.ws.say('no open card about this document — c makes one', 'error')
else this.ws.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
* groups the cards the way the morning mail does.
*/
get agendaGroups(): { key: 'overdue' | 'today' | 'week' | 'doing'; cards: AgendaCard[] }[] {
const { start, end, week_end } = this.agenda.today ?? { start: 0, end: 0, week_end: 0 }
const groups = { overdue: [] as AgendaCard[], today: [] as AgendaCard[], week: [] as AgendaCard[], doing: [] as AgendaCard[] }
for (const card of this.agenda.cards) {
if (card.due_at !== null && card.due_at < start) groups.overdue.push(card)
else if (card.due_at !== null && card.due_at < end) groups.today.push(card)
else if (card.due_at !== null && card.due_at < week_end) groups.week.push(card)
else if (card.role === 'doing') groups.doing.push(card)
}
return (['overdue', 'today', 'week', 'doing'] as const).map((key) => ({ key, cards: groups[key] }))
}
get agendaCards(): AgendaCard[] {
return this.agendaGroups.flatMap((group) => group.cards)
}
/** `late 1 · today 2` — what the bar shows. */
get dueCounts(): { late: number; today: number } {
const [overdue, today] = this.agendaGroups
return { late: overdue.cards.length, today: today.cards.length }
}
async loadAgenda() {
try {
this.agenda = await api.agenda()
} catch {
/* the counter is a convenience; a failed refresh leaves the last one up */
}
this.agendaCursor = Math.min(this.agendaCursor, Math.max(this.agendaCards.length + this.agenda.on_this_day.length - 1, 0))
}
/**
* The agenda opens itself once a day when something is late or due — the
* point is to be told, not to have to remember to ask.
*/
async openAgendaIfDue() {
const today = this.agenda.today?.date
if (!today || localStorage.getItem('diary:agenda-seen') === today) return
const { late, today: due } = this.dueCounts
if (late + due === 0) return
localStorage.setItem('diary:agenda-seen', today)
this.agendaCursor = 0
await this.ws.tiles.show('agenda')
}
/** Enter in the agenda: to the card, on its own board, beside the agenda. */
async goToCard(card: AgendaCard) {
// From the agenda's tile, which comes along into the card's space.
if (this.ws.tree.spaceId !== card.space_id) await this.ws.tree.enterSpace(card.space_id)
await this.reloadBoard(card.space_id, card.id)
await this.ws.tiles.show('board')
}
/**
* Enter on a card a search found, or one picked in a document's info
* sheet: to its board, turned over beside it.
*/
async turnTo(card: AgendaCard) {
await this.goToCard(card)
await this.turnCard()
}
/*
* Boards. One per space, which is what makes "what am I working on" a
* question with an answer: work and uni are asked separately, and the diary
* is not asked at all. The board is reloaded whole after every change — it
* is a few dozen rows, and a board that disagrees with the server about
* where a card is would be worse than a round trip.
*/
/**
* The board of the space being browsed, or else the one shown last, or
* else the first space's that has one; null when no space has a board.
*/
async loadBoard() {
const space = this.ws.tree.space
const shown = this.boardSpace?.has_board ? this.boardSpace : this.ws.tree.spaces.find((s) => s.has_board)
const board = space?.has_board ? space : shown
if (!board) {
this.rawBoard = null
this.boardSpaceId = null
return
}
// Another space's board starts at its top left, as a space switch did.
if (board.id !== this.boardSpaceId) {
this.listCursor = 0
this.cardCursor = 0
}
await this.reloadBoard(board.id, board.id === this.boardSpaceId ? this.card?.id : undefined)
}
get boardSpace(): NodeSummary | undefined {
return this.ws.tree.spaces.find((space) => space.id === this.boardSpaceId)
}
async reloadBoard(spaceId = this.boardSpaceId ?? this.ws.tree.spaceId, keep = this.card?.id) {
if (spaceId === null) return
this.rawBoard = await api.board(spaceId)
this.boardSpaceId = spaceId
void this.loadAgenda()
// Follow the card that was just moved rather than whatever now sits under
// the cursor, so J J J walks one card down instead of shuffling three.
if (keep !== undefined) {
const at = this.locate(keep)
if (at) {
this.listCursor = at.list
this.cardCursor = at.card
}
}
this.clampBoard()
}
/** Where a card sits now, as list and card indices. */
locate(cardId: number): { list: number; card: number } | null {
const lists = this.board?.lists ?? []
for (let list = 0; list < lists.length; list++) {
const card = lists[list].cards.findIndex((c) => c.id === cardId)
if (card >= 0) return { list, card }
}
return null
}
clampBoard() {
const lists = this.board?.lists ?? []
this.listCursor = Math.min(Math.max(this.listCursor, 0), Math.max(lists.length - 1, 0))
const cards = lists[this.listCursor]?.cards ?? []
this.cardCursor = Math.min(Math.max(this.cardCursor, 0), Math.max(cards.length - 1, 0))
}
/** `h`/`l`: to the list next door, keeping the cursor as near as it can. */
moveList(delta: number) {
const lists = this.board?.lists ?? []
if (lists.length === 0) return
this.listCursor = Math.min(Math.max(this.listCursor + delta, 0), lists.length - 1)
this.clampBoard()
}
/** `j`/`k`: down and up the cards of the current list. */
moveCard(delta: number) {
const cards = this.list?.cards ?? []
if (cards.length === 0) return
this.cardCursor = Math.min(Math.max(this.cardCursor + delta, 0), cards.length - 1)
}
/** `J`/`K`: take the card with you instead of leaving it behind. */
async nudgeCard(delta: number) {
const card = this.card
const list = this.list
if (!card || !list) return
const to = this.cardCursor + delta
if (to < 0 || to >= list.cards.length) return
// The neighbour's own position, not its index: older done cards may be
// hidden above it.
await api.moveCard(card.id, list.id, list.cards[to].position)
await this.reloadBoard(this.boardSpaceId, card.id)
}
/**
* `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 #sendTo(card: Card, lists: BoardList[], index: number) {
const target = lists[index]
if (!target) return this.ws.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.ws.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.ws.focus === 'board' || this.ws.focus === 'card'
if (shown) await this.reloadBoard(this.boardSpaceId, here ? card.id : this.card?.id)
if (!shown || this.ws.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.ws.focus === 'agenda') this.agendaCursor = at
}
await this.#reloadCardsAbout()
if (this.ws.tree.foundCards.some((c) => c.id === card.id)) this.ws.tree.foundCards = await api.searchCards(this.ws.tree.query, this.ws.tree.searchSpace)
const at = this.ws.open?.cards.findIndex((c) => c.id === card.id) ?? -1
if (at >= 0) this.ws.sheets.cardsCursor = at
}
/** The cards about each document open in a tile, read again. */
async #reloadCardsAbout() {
await Promise.all(
this.ws.tiles.all.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
* a thought down must not cost the place you were at. On the board itself
* it goes on the list under the cursor instead.
*/
async createCard(title: string, nodeId: number | null = null, body = '') {
if (this.ws.focus === 'board' || this.ws.focus === 'card') {
const list = this.list
if (!list) return this.ws.say('no list to put it on', 'error')
const card = await api.createCard(list.id, { title, node_id: nodeId, body })
await this.reloadBoard(this.boardSpaceId, card.id)
await this.#reloadCardsAbout()
return this.ws.say(nodeId === null ? `carded "${card.title}"` : `carded "${card.title}" → #${nodeId}`)
}
const space = this.ws.tree.space?.has_board ? this.ws.tree.space : this.ws.tree.spaces.find((s) => s.has_board)
if (!space) return this.ws.say('no space has a board — :set board turns one on', 'error')
const inbox = (await api.board(space.id)).lists[0]
if (!inbox) return this.ws.say(`${space.slug} has no lists — :board, then :list <name>`, 'error')
const card = await api.createCard(inbox.id, { title, node_id: nodeId, body })
await this.loadAgenda()
await this.#reloadCardsAbout()
this.ws.say(`carded "${card.title}" → ${space.slug}/${inbox.name}`)
}
/** 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.#cardChanged(card)
return updated
}
/** Space: into the done list, or back out of it to the inbox. */
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.ws.say(done ? doneSaid(moved) : `reopened: ${card.title}`)
}
async deleteCard() {
const card = this.card
if (!card) return this.ws.say('no card selected', 'error')
await api.deleteCard(card.id)
await this.reloadBoard(this.boardSpaceId, undefined)
this.ws.say(`deleted "${card.title}"`)
}
/*
* The card sheet: one card opened up over its board, the way a card on a
* paper board is turned over to read its back — the description, the
* checklist, the labels. It is the card under the board's cursor, so the
* board keys that act on a card (`D`, `H`/`L`, `:repeat`) act on it here
* too, and the cursor follows it wherever a move puts it.
*/
/** Enter on a card: its back, in a tile beside the board. */
async turnCard() {
if (!this.card) return this.ws.say('no card selected', 'error')
this.taskCursor = 0
await this.ws.tiles.show('card')
}
/** `e`: write the description of the card turned over, from what it says now. */
describe() {
const card = this.card
if (!card) return
// Named, as the board's cursor may be on another card by the time it saves.
this.ws.drafts.write('description', card.id, card.body, (body) => this.patchCard({ body }, card).then(() => {}))
}
/*
* The checklist and the labels are written back whole, so two keys pressed
* faster than the board reloads would each start from the card as it was,
* and the second would undo the first. Each change waits for the one
* before it, and reads the card as that one left it.
*/
private cardWrites: Promise<unknown> = Promise.resolve()
private inTurn(change: () => Promise<unknown>): Promise<void> {
const next = this.cardWrites.then(change)
this.cardWrites = next.catch(() => {})
return next.then(() => {})
}
/** The checklist written back whole, with the cursor on step `at`. */
async writeChecklist(checklist: Task[], at = this.taskCursor) {
await this.patchCard({ checklist }, this.card)
this.taskCursor = Math.min(Math.max(at, 0), Math.max(checklist.length - 1, 0))
}
/** `o` / `O`: a new step below or above the cursor. */
addTask(text: string, above = false) {
return this.inTurn(async () => {
const card = this.card
if (!card) return this.ws.say('no card selected', 'error')
const tasks = [...card.checklist]
const at = tasks.length === 0 ? 0 : above ? this.taskCursor : this.taskCursor + 1
tasks.splice(at, 0, { text, done: false })
await this.writeChecklist(tasks, at)
})
}
/** Space: tick the step under the cursor, or untick it. */
toggleTask() {
return this.inTurn(async () => {
const tasks = this.card?.checklist ?? []
const task = tasks[this.taskCursor]
if (!task) return this.ws.say('no step here — o adds one', 'error')
const next = tasks.map((t, i) => (i === this.taskCursor ? { ...t, done: !t.done } : t))
await this.writeChecklist(next)
this.ws.say(`${next.filter((t) => t.done).length}/${next.length} done`)
})
}
renameTask(text: string) {
return this.inTurn(async () => {
const tasks = this.card?.checklist ?? []
if (!tasks[this.taskCursor]) return this.ws.say('no step here — o adds one', 'error')
await this.writeChecklist(tasks.map((t, i) => (i === this.taskCursor ? { ...t, text } : t)))
})
}
deleteTask() {
return this.inTurn(async () => {
const tasks = this.card?.checklist ?? []
const task = tasks[this.taskCursor]
if (!task) return this.ws.say('no step here', 'error')
await this.writeChecklist(tasks.filter((_, i) => i !== this.taskCursor))
this.ws.say(`dropped "${task.text}"`)
})
}
/** `J` / `K`: the step takes the cursor with it. */
nudgeTask(delta: number) {
return this.inTurn(async () => {
const tasks = [...(this.card?.checklist ?? [])]
const to = this.taskCursor + delta
if (!tasks[this.taskCursor] || to < 0 || to >= tasks.length) return
;[tasks[this.taskCursor], tasks[to]] = [tasks[to], tasks[this.taskCursor]]
await this.writeChecklist(tasks, to)
})
}
/** `j`/`k`, and `gg`/`G` as ±Infinity — in turn too, so a move lands on the steps as written. */
moveTask(delta: number) {
return this.inTurn(async () => {
const count = this.card?.checklist.length ?? 0
this.taskCursor = Math.min(Math.max(this.taskCursor + delta, 0), Math.max(count - 1, 0))
})
}
/** `:label work`: on the card, or off it when it is on already. */
toggleLabel(name: string) {
return this.inTurn(async () => {
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] }, card)
this.ws.say(on ? `${label} off` : `${label} on`)
})
}
/** Every label on the board, for Tab on `:label`. */
get boardLabels(): string[] {
const all = (this.rawBoard?.lists ?? []).flatMap((list) => list.cards.flatMap((card) => card.labels))
return [...new Set(all)].sort()
}
async createList(name: string) {
if (this.boardSpaceId === null) return
this.rawBoard = await api.createList(this.boardSpaceId, name)
// It goes in front of the done list, so it is not simply the last one.
const names = this.rawBoard.lists.map((list) => list.name)
this.listCursor = Math.max(names.lastIndexOf(name), 0)
this.cardCursor = 0
this.ws.say(`new list ${name}`)
}
async renameList(name: string) {
const list = this.list
if (!list) return this.ws.say('no list selected', 'error')
this.rawBoard = await api.renameList(list.id, name)
this.clampBoard()
this.ws.say(`list renamed to ${name}`)
}
async deleteList() {
const list = this.list
if (!list) return this.ws.say('no list selected', 'error')
this.rawBoard = await api.deleteList(list.id)
this.clampBoard()
this.ws.say(`deleted list ${list.name}`)
}
/**
* Open the document a card points at. A card without one gets one, named
* after it, in the space's root — so Enter always leads to a place to write
* the notes down. `q` on that document comes back here.
*/
async openCard() {
const card = this.card
const space = this.boardSpaceId
if (!card || space === null) return
let nodeId = card.node_id
if (nodeId !== null) {
// Deleted notes read as gone, but the card still points at them, so
// the way back to them is named rather than a bare "not found".
try {
await api.getNode(nodeId)
} catch (error) {
if (error instanceof ApiError && error.status === 404) {
return this.ws.say(`the notes of "${card.title}" are gone — :trash may still have them`, 'error')
}
throw error
}
}
if (nodeId === null) {
const node = await api.createNode({ parent_id: space, name: card.title, body: '' })
await api.updateCard(card.id, { node_id: node.id })
nodeId = node.id
}
if (this.ws.tree.parentId !== space) await this.ws.tree.goTo(space)
else await this.ws.tree.refresh()
// The notes open beside the board, and `q` takes their tile away again
// on the way back to it.
await this.ws.tree.openNode(nodeId, card.node_id === null)
if (card.node_id === null) this.ws.enterInsert = true
}
/** Turn a space's board on or off. The diary ships with it off. */
async setBoard(on: boolean) {
const space = this.ws.tree.space
if (!space) return
await api.updateNode(space.id, { has_board: on })
this.ws.tree.spaces = await api.listSpaces()
if (on) await this.ws.tiles.show('board')
else if (this.boardSpaceId === space.id) {
this.rawBoard = null
this.boardSpaceId = null
if (this.ws.tiles.view('board') || this.ws.tiles.view('card')) await this.loadBoard()
}
this.ws.say(on ? `${space.slug} has a board` : `${space.slug} has no board any more`)
}
}
+86
View File
@@ -0,0 +1,86 @@
import { api, type DiaryEntry } from '../api'
import { formatDay } from '../util'
import type { Workspace } from '../store.svelte'
/** The diary: today's entry, the calendar, and stepping from one day to the next. */
export class Diary {
readonly ws: Workspace
constructor(ws: Workspace) {
this.ws = ws
}
/** `C`: the day the calendar's cursor is on, `2026-10-20`, and its month's entries. */
calendarDay = $state('')
calendarEntries = $state.raw<DiaryEntry[]>([])
/** `C`: the calendar, on the day of the entry being read or else today. */
async openCalendar() {
const open = this.ws.open
const reading = open && this.ws.tree.spaces.find((space) => space.id === open.path[0]?.id)?.role === 'diary'
this.calendarDay = formatDay(reading ? open.created_at : Math.floor(Date.now() / 1000))
await this.ws.tiles.show('calendar')
}
async loadCalendar() {
this.calendarEntries = await api.diaryDays(this.calendarDay.slice(0, 7))
}
/** The calendar's cursor, `days` along or `months` over; a month keeps the day where it can. */
async moveCalendar(days: number, months = 0) {
const [y, m, d] = this.calendarDay.split('-').map(Number)
const last = new Date(y, m - 1 + months + 1, 0).getDate()
const date = new Date(y, m - 1 + months, Math.min(d, last) + days, 12)
const before = this.calendarDay.slice(0, 7)
this.calendarDay = formatDay(Math.floor(date.getTime() / 1000))
if (this.calendarDay.slice(0, 7) !== before) await this.loadCalendar()
}
/** Enter in the calendar: that day's entry, or a new one dated that day. */
async openCalendarDay() {
const day = this.calendarDay
const entry = this.calendarEntries.find((e) => e.day === day)
// Beside the calendar, so `q` on the entry comes back to it.
if (entry) return this.ws.tree.openPath(entry.path)
await this.openDay(day)
}
/**
* `T`, and a day in the calendar without an entry: the server finds the
* day's entry or starts it, by its own clock and at the root of the diary,
* as a capture does, so the three never write two entries for one day.
*/
async openDay(day?: string) {
const home = this.ws.tree.spaces.find((space) => space.role === 'diary')
if (!home) return this.ws.say('there is no diary to write in', 'error')
const entry = await api.diaryDay(day)
if (this.ws.tree.spaceId !== home.id) await this.ws.tree.enterSpace(home.id)
else if (this.ws.tree.query) await this.ws.tree.search('')
if (entry.created) await this.ws.tree.refresh()
await this.ws.tree.openNode(entry.id, true)
if (entry.created) {
// Left as it was started, it goes again, as a new document from `o` does.
this.ws.tile.fresh = entry.id
this.ws.enterInsert = true
}
}
/** `[d` / `]d`: the diary entry before or after the one being read. */
async stepEntry(by: -1 | 1) {
const open = this.ws.open
if (!open) return this.ws.say('nothing open', 'error')
const { prev, next } = await api.diaryAround(open.id)
const entry = by < 0 ? prev : next
if (!entry) return this.ws.say(by < 0 ? 'no entry before this one' : 'no entry after this one')
await this.ws.tree.openPath(entry.path)
this.ws.say(entry.day)
}
/** `:today <text>`: a line at the end of today's diary entry, without going there. */
async logToday(text: string) {
await this.ws.drafts.flush()
const landed = await api.capture({ text, to: 'diary' })
await this.ws.drafts.rereadNode(landed.id)
this.ws.say(`logged in ${landed.where}`)
}
}
+246
View File
@@ -0,0 +1,246 @@
import { api, unreachable } from '../api'
import { AUTOSAVE_MS, Writing, type WritingKind } from '../writing.svelte'
import { type Tile, type Draft } from './tiles.svelte'
import type { Workspace } from '../store.svelte'
/**
* What is being written, and getting it to the server: the documents' drafts
* in their tiles, and the short text written in place — a card's
* description, a note.
*/
export class Drafts {
readonly ws: Workspace
constructor(ws: Workspace) {
this.ws = ws
}
/*
* The short text being written in place, if one is: the description of the
* card turned over, or the note under the drawer's cursor. One at a time,
* as there is one place on screen each is written in.
*/
writing = $state<Writing | null>(null)
/*
* Autosave. Typing is where work gets lost, so a draft goes to localStorage
* on every change — cheap, synchronous, survives a killed tab — and to the
* server a couple of seconds after typing stops.
*/
#stashKey(id: number) {
return `diary:draft:${id}`
}
/** Call whenever the draft changes. Replaces a bare `dirty = true`. */
touch(tile = this.ws.tile) {
tile.dirty = true
this.stash(tile)
tile.stopAutosave()
tile.autosave = setTimeout(() => void this.#writeBack(tile), AUTOSAVE_MS)
}
stash(tile = this.ws.tile) {
if (!tile.open) return
try {
localStorage.setItem(this.#stashKey(tile.open.id), JSON.stringify(tile.draft))
} catch {
/* a full or disabled localStorage must not stop the typing */
}
}
dropStash(id: number) {
try {
localStorage.removeItem(this.#stashKey(id))
} catch {
/* nothing to do */
}
}
readStash(id: number): Draft | null {
try {
const raw = localStorage.getItem(this.#stashKey(id))
if (!raw) return null
const stashed = JSON.parse(raw)
// Drafts stashed before documents had names carry a title instead.
return { name: stashed.name ?? stashed.title ?? '', body: stashed.body, created_at: stashed.created_at }
} catch {
return null
}
}
async #writeBack(tile: Tile) {
tile.autosave = null
if (!tile.open || !tile.dirty) return
try {
await this.save({ quiet: true, tile })
if (tile.retryDelay > 0) this.ws.say('saved — the server is back')
tile.retryDelay = 0
} catch (error) {
if (!unreachable(error)) return this.ws.guard(() => Promise.reject(error))
// Nothing is lost — the draft is in localStorage — but it is not on the
// server either, so it is tried again, less often the longer it fails.
tile.retryDelay = Math.min(Math.max(tile.retryDelay * 2, 2000), 60_000)
this.ws.say(`not saved: the server cannot be reached — kept here, trying again in ${tile.retryDelay / 1000}s`, 'error')
tile.autosave = setTimeout(() => void this.#writeBack(tile), tile.retryDelay)
}
}
/** Back online: what is waiting goes now rather than at the next retry. */
watchConnection() {
window.addEventListener('online', () => {
for (const tile of this.ws.tiles.all) {
if (!tile.dirty) continue
tile.stopAutosave()
void this.#writeBack(tile)
}
if (this.writing?.dirty) void this.ws.guard(() => this.saveWriting())
})
window.addEventListener('offline', () => {
this.ws.say('offline — what is typed is kept here, and saved once the server answers', 'error')
})
}
/**
* Write out every pending draft before doing something that would replace
* one. Without this, clicking another document in the list discarded
* unsaved work without a word.
*/
async flush() {
for (const tile of this.ws.tiles.all) await this.flushTile(tile)
}
/** The same for one tile only, as it is about to show something else. */
async flushTile(tile = this.ws.tile) {
tile.stopAutosave()
if (await this.abandonIfBlank(tile)) return
if (tile.open && tile.dirty) await this.ws.guard(() => this.save({ quiet: true, tile }))
}
/**
* Drop the document `o` made if nothing was ever put in it. True when it
* went, so the caller knows there is nothing open any more.
*/
async abandonIfBlank(tile = this.ws.tile): Promise<boolean> {
const open = tile.open
if (!open || open.id !== tile.fresh) return false
if (tile.draft.name.trim() || tile.draft.body.trim()) return false
tile.clear()
this.dropStash(open.id)
this.ws.tiles.prune()
try {
await api.deleteNode(open.id)
} catch {
/* already gone, or the server is away; an empty row is no great loss */
}
await this.ws.tree.refresh()
return true
}
/**
* Write the draft. Refused when the document was saved somewhere else
* since it was opened here — another tab, the phone, the SFTP mount —
* unless `force`, which is `:w!`.
*/
async save({ quiet = false, force = false, restore = false, tile = this.ws.tile } = {}) {
if (!tile.open) return
// Snapshot what goes over the wire: an autosave races the typing that
// triggered it, and anything typed since must stay marked unsaved.
const sent = { ...tile.draft, name: tile.draft.name.trim() }
const saved = await api.updateNode(tile.open.id, {
...sent,
base_updated_at: force ? undefined : tile.open.updated_at,
restore,
})
tile.open = saved
tile.dirty =
tile.draft.body !== sent.body ||
tile.draft.name.trim() !== sent.name ||
tile.draft.created_at !== sent.created_at
if (tile.dirty) this.touch(tile)
else this.dropStash(saved.id)
// The response carries the same excerpt the list query builds, so the row
// can usually be patched in place rather than re-reading the whole list.
if (!this.ws.tree.patch(saved)) await this.ws.tree.refresh()
if (!quiet) this.ws.say(`"${saved.slug}" ${saved.body.split('\n').length}L written`)
}
/**
* Read the open documents again after a rename or a move: their paths may
* have changed with it, and their text too, where the server rewrote a
* link to what moved. One being written is left as it is — saving it is
* then refused, and `:e!` takes the rewritten text.
*/
async reread() {
for (const tile of this.ws.tiles.all) {
if (!tile.open || tile.editing || tile.dirty) continue
const node = await api.getNode(tile.open.id).catch(() => null)
if (!node) continue
tile.open = node
tile.draft = { name: node.name, body: node.body, created_at: node.created_at }
}
if (this.ws.open) this.ws.tree.syncUrl(this.ws.open.path)
}
/*
* Writing in place. A card's description and a note are written the way a
* document is: saved by themselves a moment after the typing stops, `:w`
* sooner, and `:q` or Esc leaves with what is still waiting written first.
*/
/** Begin writing `kind`: what it says on the server, unless this browser kept newer. */
write(kind: WritingKind, id: number | null, text: string, write: (text: string, writing: Writing) => Promise<void>) {
const writing: Writing = new Writing(
kind,
id,
text,
(text) => write(text, writing),
(error, retry) =>
unreachable(error)
? this.ws.say(`not saved: the server cannot be reached — kept here, trying again in ${retry / 1000}s`, 'error')
: void this.ws.guard(() => Promise.reject(error)),
)
this.writing = writing
if (writing.recovered) this.ws.say(`recovered unsaved ${kind} — :w keeps it, :q! drops it`, 'error')
}
/** `:w`, Ctrl-S: what is written goes now rather than in a moment. */
async saveWriting() {
const writing = this.writing
if (!writing) return
await writing.save()
if (writing.kind === 'description') this.ws.say('description written')
}
/**
* `:q`, Esc in normal mode: leave, with what is still waiting written
* first; `:q!` lets that go instead. False, and a word why, when writing
* failed, so the text stays on screen. A note left empty is not kept.
*/
async leaveWriting(discard = false): Promise<boolean> {
const writing = this.writing
if (!writing) return true
if (discard) writing.discard()
else if (!(await this.ws.guard(() => writing.save()))) return false
if (this.writing === writing) this.writing = null
const note = this.ws.scratch.notes.find((n) => n.id === writing.id)
if (writing.kind === 'note' && note && !note.body.trim()) await this.ws.guard(() => this.ws.scratch.dropNote(false, note))
return true
}
/** Leaving a card's back, or the drawer, while something is written there keeps it, as leaving a document's tile does. */
leaveCard() {
if (this.ws.tile.kind === 'card' && this.ws.cards.describing) void this.leaveWriting()
}
/** Every tile showing `id` reads it again, after it was written somewhere else. */
async rereadNode(id: number) {
for (const tile of this.ws.tiles.all) {
if (tile.open?.id !== id || tile.dirty) continue
const node = await api.getNode(id)
tile.open = node
tile.draft = { name: node.name, body: node.body, created_at: node.created_at }
}
}
}
+79
View File
@@ -0,0 +1,79 @@
import type { Workspace } from '../store.svelte'
/**
* Questions asked in the command line's place, and Proton's human check:
* each a promise answered from the keyboard.
*/
export class Prompts {
readonly ws: Workspace
constructor(ws: Workspace) {
this.ws = ws
}
/*
* A `[y/N]` question in the command line's place, instead of the browser's
* confirm(): it names the thing about to go, and it is answered from the
* keyboard without a modal the rest of the page cannot see past.
*/
prompt = $state<{ text: string; resolve: (yes: boolean) => void } | null>(null)
/** A password asked for in the same place, typed into a masked field. */
secret = $state.raw<{ text: string; resolve: (value: string | null) => void } | null>(null)
/** Proton's human check a login waits on, while its sheet is open. */
verify = $state.raw<{ url: string; resolve: (token: string | null) => void } | null>(null)
/** The token Proton's check at `url` hands back, or null when abandoned. */
askVerification(url: string): Promise<string | null> {
this.verify?.resolve(null)
return new Promise((resolve) => {
let answered = false
this.verify = {
url,
resolve: (token) => {
if (answered) return
answered = true
this.verify = null
resolve(token)
},
}
this.ws.sheets.open('verify')
})
}
/** A password, or null when it was waved away with Escape. */
askSecret(text: string): Promise<string | null> {
this.secret?.resolve(null)
return new Promise((resolve) => {
let answered = false
const asked = {
text,
resolve: (value: string | null) => {
if (answered) return
answered = true
// Put away a turn later, so a question that follows straight on —
// the second of two passwords — takes over the same box instead
// of a new one appearing under the fingers already typing it.
setTimeout(() => {
if (this.secret === asked) this.secret = null
})
resolve(value)
},
}
this.secret = asked
})
}
ask(text: string): Promise<boolean> {
this.prompt?.resolve(false)
return new Promise((resolve) => {
this.prompt = {
text,
resolve: (yes) => {
this.prompt = null
resolve(yes)
},
}
})
}
}
+176
View File
@@ -0,0 +1,176 @@
import { api, type ScratchNote } from '../api'
import type { Workspace } from '../store.svelte'
/** The scratchpad: the drawer of notes taken in passing, and the ways out of it. */
export class Scratchpad {
readonly ws: Workspace
constructor(ws: Workspace) {
this.ws = ws
}
/*
* The scratchpad: a drawer that comes down from under the bar over whatever
* is on screen, as the desktop's scratchpad does, with the notes taken in
* passing.
*/
open = $state(false)
notes = $state<ScratchNote[]>([])
cursor = $state(0)
get editing() {
return this.ws.drafts.writing?.kind === 'note'
}
/** The note being written has not been saved yet, so it is in no row. */
get writingNew() {
return this.editing && this.ws.drafts.writing?.id === null
}
get note(): ScratchNote | undefined {
return this.notes[this.cursor]
}
/** `Q`: the drawer down, read again, or up and away. */
async toggle() {
if (this.open) return this.close()
this.notes = await api.scratch()
this.cursor = Math.min(this.cursor, Math.max(this.notes.length - 1, 0))
this.open = true
}
async close() {
if (this.editing && !(await this.ws.drafts.leaveWriting())) return
this.open = false
}
/** `o`: a new note at the top, written straight away. */
newNote() {
this.#writeNote(null, '')
}
/** `e`, `i`, Enter: write the note under the cursor. */
editNote() {
const note = this.note
if (!note) return this.newNote()
this.#writeNote(note.id, note.body)
}
/*
* A new note is only made once it says something. The note touched last
* goes to the top of the list, and the cursor with it.
*/
#writeNote(id: number | null, body: string) {
this.ws.drafts.write('note', id, body, async (text, writing) => {
if (writing.id === null) {
if (!text.trim()) return
const note = await api.createScratch(text)
writing.id = note.id
this.notes = [note, ...this.notes]
} else {
const saved = await api.updateScratch(writing.id, text)
this.notes = [saved, ...this.notes.filter((n) => n.id !== saved.id)]
}
this.cursor = 0
})
}
async dropNote(say = true, note = this.note) {
if (!note) return
await api.deleteScratch(note.id)
this.#forgetNote(note.id)
if (say) this.ws.say('scratch note dropped')
}
/** A note gone from the drawer and from the search results, the cursors kept in range. */
#forgetNote(id: number) {
this.notes = this.notes.filter((n) => n.id !== id)
this.cursor = Math.min(this.cursor, Math.max(this.notes.length - 1, 0))
if (!this.ws.tree.foundNotes.some((n) => n.id === id)) return
this.ws.tree.foundNotes = this.ws.tree.foundNotes.filter((n) => n.id !== id)
this.ws.tree.cursor = Math.min(this.ws.tree.cursor, Math.max(this.ws.tree.rows - 1, 0))
}
/** A note found by a search: the drawer down, with it under the cursor. */
async showNote(id: number) {
if (!this.open) await this.toggle()
else this.notes = await api.scratch()
const at = this.notes.findIndex((note) => note.id === id)
if (at < 0) return this.ws.say('that note is gone', 'error')
this.cursor = at
}
/** The note under the cursor, saved and closed if it was being written, for it to leave. */
async #leavingNote(): Promise<ScratchNote | undefined> {
if (this.editing && !(await this.ws.drafts.leaveWriting())) return
const note = this.note
if (!note) this.ws.say('no scratch note under the cursor', 'error')
return note
}
/** A note split as a card or a document takes it: the first line, a `# heading` or not, and the rest. */
#split(note: ScratchNote): { title: string; rest: string } {
const [first, ...rest] = note.body.replace(/^\s+/, '').split('\n')
return { title: first.replace(/^#+\s*/, '').trim().slice(0, 200), rest: rest.join('\n').replace(/^\n+/, '') }
}
/**
* `C`: the note under the cursor made a card, where `:card` would put one
* — the list under the board's cursor, or this space's inbox. The first
* line is its title, and the rest its description.
*/
async cardNote() {
const note = await this.#leavingNote()
if (!note) return
const { title, rest } = this.#split(note)
if (!title) return this.ws.say('the note has nothing to call a card by', 'error')
// Made first, dropped after: a failure in between leaves two copies, never none.
await this.ws.cards.createCard(title, null, rest)
await this.dropNote(false, note)
}
/**
* `:append <document>`: the note under the cursor put at the end of a
* document, named as a link names it, and taken off the scratchpad. Open
* drafts are written first, and a tile showing the document reads it again.
*/
async appendNote(document: string) {
const note = await this.#leavingNote()
if (!note) return
await this.ws.drafts.flush()
const space = this.ws.tree.space?.role === 'diary' ? undefined : this.ws.tree.space?.slug
const landed = await api.capture({ text: note.body, to: 'document', document, space, note: note.id })
this.#forgetNote(note.id)
await this.ws.drafts.rereadNode(landed.id)
this.ws.say(`appended to ${landed.where}`)
}
/** `:scratch <text>`: a note from the command line, without the drawer. */
async add(text: string) {
const note = await api.createScratch(text)
this.notes = [note, ...this.notes.filter((n) => n.id !== note.id)]
this.ws.say('scratched — Q shows the scratchpad')
}
/**
* `:keep [title]`: the note under the cursor made a document in the folder
* being browsed, and taken off the scratchpad. Without a title, the first
* line is the title — a `# heading` or not — and the rest the body.
*/
async keepNote(title = '') {
const note = await this.#leavingNote()
if (!note) return
const parent = this.ws.tree.parentId ?? this.ws.tree.spaceId ?? this.ws.tree.spaces.find((space) => space.role !== 'diary')?.id
if (parent === undefined || parent === null) return this.ws.say('no space to keep it in', 'error')
const split = this.#split(note)
const name = title.trim() || split.title
const body = title.trim() ? note.body : split.rest
const node = await api.createNode({ parent_id: parent, name, body })
// Made first, dropped after: a failure in between leaves two copies, never none.
await this.dropNote(false, note)
this.open = false
await this.ws.tree.refresh(node.id)
await this.ws.tree.openNode(node.id)
this.ws.say(`kept as ${node.path.map((crumb) => crumb.slug).join('/')}`)
}
}
+128
View File
@@ -0,0 +1,128 @@
import { api, type Backlink, type Revision, type RevisionSummary, type SpaceOverview, type TrashItem } from '../api'
import { formatStamp, type Heading } from '../util'
import type { Workspace } from '../store.svelte'
/** What floats over the tiles; one at a time. */
export type Sheet = 'help' | 'overview' | 'actions' | 'verify' | 'info' | 'trash' | 'settings'
/** The info sheet's parts: what the open document holds, and what is around it. */
export type InfoSection = 'outline' | 'history' | 'backlinks' | 'cards' | 'files' | 'share'
/** What floats over the tiles, and what each sheet lists. */
export class Sheets {
readonly ws: Workspace
constructor(ws: Workspace) {
this.ws = ws
}
/** The sheet over everything, if any; `openSheet` and `closeSheet` move it. */
shown = $state<'none' | Sheet>('none')
/** The part of the info sheet on show; it stays for the next time it opens. */
infoSection = $state<InfoSection>('outline')
/** `gO`: the headings of what is on screen, to jump between from the keyboard. */
outline = $state.raw<Heading[]>([])
outlineCursor = $state(0)
/** What `:trash` lists: everything deleted, the last first. */
trash = $state<TrashItem[]>([])
trashCursor = $state(0)
/** `:history`: the open document's earlier versions, and the one shown. */
history = $state<RevisionSummary[]>([])
historyCursor = $state(0)
revision = $state.raw<Revision | null>(null)
/** `: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)
/** The file picked in the info sheet's files: Enter opens it, `i` embeds it while writing. */
filesCursor = $state(0)
overview = $state<SpaceOverview[]>([])
overviewCursor = $state(0)
/** `:history`: the open document's earlier versions, the newest first. */
async loadHistory() {
const open = this.ws.open
if (!open) return
// What is typed and not yet written is part of what a revision is
// compared with, and of what a restore must not lose.
await this.ws.drafts.flushTile()
this.history = await api.revisions(open.id)
this.historyCursor = 0
this.revision = null
await this.showRevision(0)
}
/** Read the revision at `index`, for the overlay to compare with the draft. */
async showRevision(index: number) {
const open = this.ws.open
const summary = this.history[index]
this.historyCursor = index
if (!open || !summary) return
const revision = await api.revision(open.id, summary.id)
// A later j may have moved on while this one was being read.
if (this.history[this.historyCursor]?.id === revision.id) this.revision = revision
}
/**
* Bring a revision back: it becomes the document again, and what it
* replaces is kept as a revision of its own, so a restore is undone the
* same way. The editor reads the draft when it opens, so this leaves it.
*/
async restoreRevision(revision: Revision) {
const tile = this.ws.tile
if (!tile.open) return
await this.ws.drafts.flushTile(tile)
tile.editing = false
tile.draft = { ...tile.draft, name: revision.name, body: revision.body }
tile.dirty = true
await this.ws.drafts.save({ quiet: true, restore: true, tile })
this.close()
this.ws.say(`restored the version from ${formatStamp(revision.saved_at)} — :history has the one it replaced`)
}
/** `:backlinks`: what links to the open document, the last written first. */
async loadBacklinks() {
const open = this.ws.open
if (!open) return
// A link typed and not yet written is not one the server knows about.
await this.ws.drafts.flush()
this.backlinks = await api.backlinks(open.id)
this.backlinksCursor = 0
}
async loadTrash() {
this.trash = await api.trash()
this.trashCursor = Math.min(this.trashCursor, Math.max(this.trash.length - 1, 0))
}
/** Delete one thing in the trash for good, files and all. */
async purge(id: number) {
await api.purge(id)
if (this.ws.tree.lastDeleted === id) this.ws.tree.lastDeleted = null
await this.loadTrash()
}
async emptyTrash() {
const { removed } = await api.emptyTrash()
this.ws.tree.lastDeleted = null
if (this.shown === 'trash') await this.loadTrash()
this.ws.say(removed === 1 ? 'the trash is empty — 1 thing deleted for good' : `the trash is empty — ${removed} things deleted for good`)
}
async loadOverview() {
this.overview = await api.overview()
}
open(sheet: Sheet) {
this.shown = sheet
}
/** Esc, q, a click beside it: the sheet away. */
close() {
this.shown = 'none'
}
}
+261
View File
@@ -0,0 +1,261 @@
import { isContainer, type WorkspaceNode } from '../api'
import { formatDay } from '../util'
import type { Workspace } from '../store.svelte'
/**
* What a tile shows: a node of the tree — a document or a file — or one of
* the views over the boards and the diary, which are tiled beside the
* documents like any other window.
*/
export type TileKind = 'node' | 'board' | 'agenda' | 'calendar' | 'card'
export type View = Exclude<TileKind, 'node'>
export type Draft = { name: string; body: string; created_at: number }
let tiles = 0
/**
* One window on screen, and everything that belongs to it rather than to
* the workspace: what it shows, the draft being written, whether that is
* saved, and the timer that will save it. A view keeps its state in the
* workspace — there is one board, one agenda, one calendar — so its tile
* holds only its kind.
*/
export class Tile {
readonly id = ++tiles
kind = $state<TileKind>('node')
open = $state<WorkspaceNode | null>(null)
draft = $state<Draft>({ name: '', body: '', created_at: 0 })
editing = $state(false)
/** Set when the editor should open in insert mode rather than normal mode. */
enterInsert = $state(false)
dirty = $state(false)
/** The editor's vim mode, for the status line while this tile has focus. */
vimMode = $state('normal')
/*
* The document `o` just made. If it is left with neither a name nor a word
* in it, it is removed on the way out rather than left as an empty row.
*/
fresh: number | null = null
/** Pending debounced write, if the draft has changed since the last save. */
autosave: ReturnType<typeof setTimeout> | null = null
/** Saves that failed for want of a server wait this long before trying again. */
retryDelay = 0
stopAutosave() {
if (this.autosave) clearTimeout(this.autosave)
this.autosave = null
}
constructor(kind: TileKind = 'node') {
this.kind = kind
}
/** Showing nothing: a node tile with no node in it. */
get empty(): boolean {
return this.kind === 'node' && !this.open
}
/** Put the tile back to showing nothing. */
clear() {
this.stopAutosave()
this.kind = 'node'
this.open = null
this.editing = false
this.dirty = false
this.fresh = null
}
}
/**
* The tiles on screen and which has the focus: opened, closed, walked, and
* the views — the board, the agenda, the calendar, a card's back — put up
* among them.
*/
export class Tiles {
readonly ws: Workspace
constructor(ws: Workspace) {
this.ws = ws
}
/*
* The documents on screen. There is always at least one tile, empty when
* nothing is open; the workspace's `open`, `draft`, `editing` … are the
* focused tile's, so most of the app reads one document as it always did.
*/
all = $state<Tile[]>([new Tile()])
focused = $state(0)
get current(): Tile {
return this.all[this.focused] ?? this.all[0]
}
/** Every tile showing `id`. */
showing(id: number): Tile[] {
return this.all.filter((tile) => tile.open?.id === id)
}
/*
* The tiles focused before the one focused now, the last the latest:
* closing a tile goes back to the one that had the focus before it — to
* the board a card's notes were opened from, to the calendar a day was.
*/
trail: Tile[] = []
/** The tile showing `kind`, if one is. */
view(kind: View): Tile | undefined {
return this.all.find((tile) => tile.kind === kind)
}
/**
* Focus a tile, and follow it in the list and the address bar, as opening
* its document did.
*/
focusOn(index: number) {
const tile = this.all[index]
if (!tile) return
if (tile !== this.current) {
this.ws.drafts.leaveCard()
this.trail = [...this.trail.filter((t) => t !== tile && t !== this.current), this.current].slice(-20)
}
this.focused = index
if (tile.empty) return
this.ws.pane = 'document'
if (!tile.open) return
const row = this.ws.tree.nodes.findIndex((n) => n.id === tile.open?.id)
if (row >= 0) this.ws.tree.cursor = row
this.ws.tree.syncUrl(tile.open.path)
}
/** `tiles` on screen in place of the ones there, `focus` focused, with no trail behind. */
replace(tiles: Tile[], focus?: Tile) {
this.all = tiles.length > 0 ? tiles : [new Tile()]
this.focused = focus ? this.all.indexOf(focus) : 0
this.trail = []
}
/** A tile of its own for what opens next, beside what is on screen. */
add(tile = new Tile()) {
this.all.push(tile)
this.focusOn(this.all.length - 1)
}
/**
* Tiles with nothing in them go, as long as one is left: a closed document
* closes its tile, as `:q` closes a window in vim. When the focused one
* went, the focus goes back along the trail.
*/
prune() {
const current = this.current
if (this.all.length > 1) {
const kept = this.all.filter((tile) => !tile.empty)
this.all = kept.length > 0 ? kept : [current]
const back = this.all.includes(current) ? current : [...this.trail].reverse().find((tile) => this.all.includes(tile))
this.focused = back ? this.all.indexOf(back) : Math.min(this.focused, this.all.length - 1)
}
this.trail = this.trail.filter((tile) => this.all.includes(tile) && tile !== this.current)
if (this.current.empty) this.ws.pane = 'list'
}
/** `:close`: the focused tile goes, or with only one, what it shows. */
close() {
this.ws.drafts.leaveCard()
this.current.clear()
this.prune()
if (!this.current.empty) this.focusOn(this.focused)
}
/**
* `L` / Shift-Enter: the row under the cursor in a tile of its own, next to
* what is open, rather than in its place.
*/
async openBeside() {
const selected = this.ws.tree.selected
if (!selected) return
if (isContainer(selected)) return this.ws.say(`${selected.slug} is a ${selected.kind} — l goes into it`, 'error')
const shown = this.all.findIndex((tile) => tile.open?.id === selected.id)
if (shown >= 0) return this.focusOn(shown)
if (!this.current.empty) this.add()
try {
await this.ws.tree.openSelected()
} finally {
this.prune()
}
}
/** `:only`: every other tile goes, after their drafts are written. */
async only() {
const others = this.all.filter((tile) => tile !== this.current)
for (const tile of others) await this.ws.drafts.flushTile(tile)
const unsaved = others.filter((tile) => tile.dirty)
if (unsaved.length > 0) {
const names = unsaved.map((tile) => tile.open?.slug).join(', ')
if (!(await this.ws.prompts.ask(`${names} could not be written — close anyway? the drafts stay in this browser`))) return
}
for (const tile of others) tile.stopAutosave()
this.all = [this.current]
this.focused = 0
this.trail = []
}
/*
* The views — the board, the agenda, the calendar, a card's back — are
* tiles, opened beside what is on screen. Each is read again whenever it is
* gone to: it is looked at in passing, and what it says should be what is
* true now.
*/
/** `b`, `t`, `C`, Enter on a card: to that view's tile, opening it first when it is not up. */
async show(kind: View) {
await this.loadView(kind)
if (!this.view(kind)) {
// The one empty tile there can be takes it; otherwise it goes beside.
if (this.current.empty) this.current.kind = kind
else this.all.push(new Tile(kind))
}
this.focusOn(this.all.indexOf(this.view(kind)!))
if (kind === 'board' && this.ws.cards.boardSpaceId === null) this.ws.say('no space has a board — :set board turns one on', 'error')
}
/** The views `S` puts up together, in the order they are tiled. */
static readonly PLAN: View[] = ['board', 'agenda', 'calendar']
/**
* `S`: the board, the agenda and the calendar, tiled ahead of the
* documents with the board focused — or, when all three are up, every view
* put away again.
*/
async togglePlan() {
if (Tiles.PLAN.every((kind) => this.view(kind))) {
for (const tile of this.all) if (tile.kind !== 'node') tile.clear()
this.prune()
if (!this.current.empty) this.focusOn(this.focused)
return
}
await Promise.all(Tiles.PLAN.map((kind) => this.loadView(kind)))
const current = this.current
const missing = Tiles.PLAN.filter((kind) => !this.view(kind)).map((kind) => new Tile(kind))
this.all = [...missing, ...this.all]
this.focused = this.all.indexOf(current)
this.prune()
this.focusOn(this.all.indexOf(this.view('board')!))
if (this.ws.cards.boardSpaceId === null) this.ws.say('no space has a board — :set board turns one on', 'error')
}
/** Read again what a view shows. */
async loadView(kind: View) {
if (kind === 'board' || kind === 'card') await this.ws.cards.loadBoard()
else if (kind === 'agenda') await this.ws.cards.loadAgenda()
else {
if (!this.ws.diary.calendarDay) this.ws.diary.calendarDay = formatDay(Math.floor(Date.now() / 1000))
await this.ws.diary.loadCalendar()
}
}
/** Every view on screen read again, after a space switch brought them. */
async loadViews() {
if (this.view('board') || this.view('card')) await this.ws.cards.loadBoard()
if (this.view('calendar')) await this.loadView('calendar')
}
}
+810
View File
@@ -0,0 +1,810 @@
import { api, isContainer, type AgendaCard, type Crumb, type WorkspaceNode, type NodeSummary, type ScratchNote } from '../api'
import { formatBytes, sealedNote } from '../util'
import { Tile, type View } from './tiles.svelte'
import type { Workspace, Pane } from '../store.svelte'
/**
* Where a space was left: the folder, the tiles, the row, the pane. A tile
* is the id of the node it shows, or the name of the view. `openId` is the
* focused tile's, all a spot held before there were tiles, and `open` the
* documents alone, all it held before there were views.
*/
type Spot = {
parentId: number
openId: number | null
cursor: number
pane: Pane
open?: number[]
tiles?: (number | View)[]
focused?: number
}
/**
* The spaces and the folders in them: where the list is, what it shows,
* what a search found, what `m` picked up — and what is done to the nodes
* from there: opening, making, renaming, moving, deleting, uploading.
*/
export class Tree {
readonly ws: Workspace
constructor(ws: Workspace) {
this.ws = ws
}
/** The top level: diary, work, uni, whatever else gets made. */
spaces = $state<NodeSummary[]>([])
/** The space being browsed, and the folder inside it the list is showing. */
spaceId = $state<number | null>(null)
parentId = $state<number | null>(null)
/** Ancestors of the current folder, the space first and the folder last. */
path = $state<Crumb[]>([])
/** The current folder is sealed: uploads into it go into the vault. */
parentSealed = $state(false)
/** Children of the current folder, or the results of a search. */
nodes = $state<NodeSummary[]>([])
/** The cards a search found, listed after the nodes, and the notes after them. */
foundCards = $state<AgendaCard[]>([])
foundNotes = $state<ScratchNote[]>([])
cursor = $state(0)
query = $state('')
/*
* The nodes picked up by `m`, waiting for the `p` that drops them into
* whatever folder the list is showing then. A move is two keystrokes apart
* in time because the destination is somewhere else in the tree — there is
* no way to name it and be somewhere else at once — so the marks stay put
* while the list walks, and can be gathered from several folders.
*/
marked = $state.raw<NodeSummary[]>([])
/** A search across every space rather than the one being browsed — Ctrl-P. */
globalSearch = $state(false)
/** The last thing deleted here, which `u` puts back. */
lastDeleted = $state<number | null>(null)
/** Documents whose files `za` has folded open under them in the list. */
unfolded = $state<number[]>([])
/*
* Spaces behave like the desktop's workspaces: each keeps the folder and the
* document it was left on, and coming back finds them where they were.
*/
#spots: Record<number, Spot> = JSON.parse(localStorage.getItem('diary:spots') ?? '{}')
get selected(): NodeSummary | undefined {
return this.nodes[this.cursor]
}
/** How many rows the list has to walk: the nodes, then the cards and the notes a search found. */
get rows(): number {
return this.nodes.length + this.foundCards.length + this.foundNotes.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
}
/** The note a search found under the cursor, past the cards. */
get foundNote(): ScratchNote | undefined {
const at = this.cursor - this.nodes.length - this.foundCards.length
return at >= 0 ? this.foundNotes[at] : undefined
}
get space(): NodeSummary | undefined {
return this.spaces.find((s) => s.id === this.spaceId)
}
/** `work/einarbeiten` — what the sidebar and the status line show. */
get here(): string {
return this.path.map((crumb) => crumb.slug).join('/')
}
/*
* Navigation. The list pane always shows one folder's children, so browsing
* is: pick a space, descend into folders, come back up. A search steps
* outside that and shows matches from anywhere in the current space.
*/
/** Keep the address bar on the node being looked at, so it can be linked to. */
syncUrl(path: Crumb[]) {
const slugs = path.map((crumb) => crumb.slug).join('/')
if (slugs) history.replaceState(null, '', `/n/${slugs}`)
}
/** Open whatever `work/einarbeiten/01-was-ist-eebus` names. */
async openPath(path: string) {
const node = await api.resolvePath(path)
if (!isContainer(node)) {
if (node.parent_id !== null) await this.goTo(node.parent_id)
await this.openNode(node.id)
} else {
await this.goTo(node.id)
}
}
async loadSpaces(startPath: string | null = null) {
this.spaces = await api.listSpaces()
if (startPath) {
try {
await this.openPath(startPath)
return
} catch {
// A stale or hand-typed address should not leave the app on nothing.
this.ws.say(`no such path: ${startPath}`, 'error')
}
}
const remembered = Number(localStorage.getItem('diary:space'))
const start =
this.spaces.find((s) => s.id === remembered) ??
this.spaces.find((s) => s.role === 'diary') ??
this.spaces[0]
if (start) await this.enterSpace(start.id)
}
/** `views` false: the views are leaving with the focus, and are not left behind. */
#rememberSpot(views = true) {
if (this.spaceId === null || this.parentId === null) return
// A card's back is the card under the board's cursor, which is not kept.
const kept = this.ws.tiles.all.filter((tile) => !tile.empty && (tile.kind === 'node' || (views && tile.kind !== 'card')))
this.#spots[this.spaceId] = {
parentId: this.parentId,
openId: this.ws.open?.id ?? null,
cursor: this.cursor,
pane: this.ws.pane,
tiles: kept.map((tile) => (tile.kind === 'node' ? tile.open!.id : tile.kind)),
focused: Math.max(kept.indexOf(this.ws.tile), 0),
}
localStorage.setItem('diary:spots', JSON.stringify(this.#spots))
}
async enterSpace(id: number) {
const leaving = this.spaceId !== null && this.spaceId !== id
/*
* The views look at every board and the whole diary, so with one of them
* focused they come along — moved, not copied — as the special workspace
* once did: a digit on the board shows that space's board, and the
* agenda stays beside it.
*/
let carried: Tile[] = []
let carriedFocus: Tile | undefined
if (leaving) {
await this.ws.drafts.flush()
const carry = this.ws.focus !== 'list' && this.ws.focus !== 'node'
this.#rememberSpot(!carry)
const from = this.spaces.findIndex((s) => s.id === this.spaceId)
const to = this.spaces.findIndex((s) => s.id === id)
this.ws.switchDirection = to >= from ? 1 : -1
if (carry) {
carried = this.ws.tiles.all.filter((tile) => tile.kind !== 'node' && tile.kind !== 'card')
carriedFocus = this.ws.tile.kind === 'card' ? (this.ws.tiles.view('board') ?? carried[0]) : this.ws.tile
}
// Each space has tiles of its own; these come back with this one.
for (const tile of this.ws.tiles.all) tile.stopAutosave()
this.ws.tiles.replace(carried, carriedFocus)
this.ws.pane = carried.length > 0 ? 'document' : 'list'
}
this.spaceId = id
localStorage.setItem('diary:space', String(id))
this.query = ''
// A remembered folder may have been deleted or moved to another space
// since; then the space's root is where it opens.
const spot = this.#spots[id]
try {
await this.goTo(spot?.parentId ?? id)
if (this.spaceId !== id) await this.goTo(id)
} catch {
await this.goTo(id)
}
if (spot && this.parentId === spot.parentId) {
const entries =
spot.tiles ?? spot.open ?? (spot.openId !== null && this.nodes.some((n) => n.id === spot.openId) ? [spot.openId] : [])
const restored: Tile[] = []
for (const entry of entries) {
if (typeof entry === 'string') {
let tile = this.ws.tiles.view(entry)
if (!tile) this.ws.tiles.all.push((tile = new Tile(entry)))
restored.push(tile)
continue
}
const empty = this.ws.tiles.all.findIndex((tile) => tile.empty)
if (empty >= 0) this.ws.tiles.focused = empty
else {
this.ws.tiles.all.push(new Tile())
this.ws.tiles.focused = this.ws.tiles.all.length - 1
}
try {
await this.openNode(entry)
restored.push(this.ws.tile)
} catch {
/* deleted or moved away since: one tile fewer */
}
}
this.ws.tiles.prune()
const back = carriedFocus ?? restored[Math.min(spot.focused ?? 0, restored.length - 1)]
if (back && this.ws.tiles.all.includes(back)) {
this.ws.tiles.focusOn(this.ws.tiles.all.indexOf(back))
if (!carriedFocus) this.ws.pane = spot.pane
} else {
this.cursor = Math.min(spot.cursor, Math.max(this.nodes.length - 1, 0))
}
}
this.ws.tiles.trail = []
await this.ws.tiles.loadViews()
}
/** Show the children of `id`, which must be a space or a folder. */
async goTo(id: number, keep?: number) {
await this.ws.drafts.flush()
const folder = await api.getNode(id)
// Walking into another space by a link leaves this one where it was; the
// views stay on screen, so they go along rather than stay behind too.
if (this.spaceId !== null && folder.path[0]?.id !== this.spaceId) this.#rememberSpot(false)
this.parentId = id
this.parentSealed = folder.sealed
this.query = ''
this.globalSearch = false
this.path = folder.path
this.spaceId = folder.path[0]?.id ?? id
this.nodes = await this.#withFiles(await api.listNodes({ parent: id }))
const index = this.nodes.findIndex((n) => n.id === keep)
this.cursor = index >= 0 ? index : 0
this.syncUrl(folder.path)
}
/** Up one level, selecting the folder just left so `h` then `l` is a no-op. */
async up() {
const from = this.path.at(-1)
const parent = this.path.at(-2)
if (!parent || !from) return
await this.goTo(parent.id, from.id)
}
/** `keep` follows a node that just arrived, instead of holding the cursor. */
async refresh(keep?: number) {
const keepId = keep ?? this.selected?.id
if (this.query) {
const space = this.searchSpace
// A note is in no space, so every search looks through them all.
;[this.nodes, this.foundCards, this.foundNotes] = await Promise.all([
api.listNodes({ q: this.query, space }),
api.searchCards(this.query, space),
api.scratch(this.query),
])
} else {
this.nodes = await this.#withFiles(await api.listNodes({ parent: this.parentId ?? undefined }))
this.foundCards = []
this.foundNotes = []
}
const index = this.nodes.findIndex((n) => n.id === keepId)
this.cursor = index >= 0 ? index : Math.min(this.cursor, Math.max(this.rows - 1, 0))
}
/** The list, with the files of each document `za` folded open under it. */
async #withFiles(list: NodeSummary[]): Promise<NodeSummary[]> {
const open = list.filter((n) => n.kind === 'document' && n.child_count > 0 && this.unfolded.includes(n.id))
if (open.length === 0) return list
const files = new Map(await Promise.all(open.map(async (doc) => [doc.id, await api.listNodes({ parent: doc.id })] as const)))
return list.flatMap((n) => [n, ...(files.get(n.id) ?? [])])
}
/** `za`: a document's own files, folded open under it in the list, or away again. */
async toggleFiles() {
const node = this.selected
if (!node || this.query) return
const doc = node.kind === 'document' ? node : this.nodes.find((n) => n.id === node.parent_id && n.kind === 'document')
if (!doc) return this.ws.say('za folds open the files of a document', 'error')
if (this.unfolded.includes(doc.id)) {
this.unfolded = this.unfolded.filter((id) => id !== doc.id)
} else {
if (doc.child_count === 0) return this.ws.say('no files — :upload while reading it gives it some', 'error')
this.unfolded = [...this.unfolded, doc.id]
}
await this.refresh(doc.id)
}
/** A row the list shows under its document, not in the folder itself. */
isNested(node: NodeSummary): boolean {
return !this.query && node.parent_id !== this.parentId
}
/** 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) {
this.query = query
this.globalSearch = global && query !== ''
this.cursor = 0
await this.refresh()
}
/**
* `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 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.ws.say('no search — / starts one', 'error')
const count = this.rows
if (count === 0) return this.ws.say(`no results for "${this.query}"`, 'error')
const next = this.cursor + delta
if (next >= count) this.ws.say('search hit BOTTOM, continuing at TOP')
else if (next < 0) this.ws.say('search hit TOP, continuing at BOTTOM')
this.cursor = (next + count) % count
const node = this.selected
if (node && !isContainer(node)) await this.openNode(node.id)
}
move(delta: number) {
if (this.rows === 0) return
this.cursor = Math.min(Math.max(this.cursor + delta, 0), this.rows - 1)
}
async openNode(id: number, edit = false) {
if (this.ws.open?.id !== id) await this.ws.drafts.flushTile()
const node = await api.getNode(id)
if (isContainer(node)) {
await this.goTo(node.id)
return
}
// A document already open in another tile is gone to, not opened twice:
// two drafts of one document would only overwrite each other.
const shown = this.ws.tiles.all.findIndex((tile) => tile !== this.ws.tile && tile.open?.id === id)
if (shown >= 0) {
this.ws.tiles.focusOn(shown)
if (edit && node.kind === 'document') this.ws.editing = true
return
}
// A view is never put away by what is opened from it — a card's notes,
// a day of the calendar, a search from the board: that opens beside it,
// and closing it goes back there.
if (this.ws.tile.kind !== 'node') this.ws.tiles.add()
this.ws.open = node
this.ws.draft = { name: node.name, body: node.body, created_at: node.created_at }
this.ws.dirty = false
// A file has a name and nothing to write in.
this.ws.editing = edit && node.kind === 'document'
this.ws.pane = 'document'
this.syncUrl(node.path)
const index = this.nodes.findIndex((n) => n.id === id)
if (index >= 0) this.cursor = index
// A stash that differs from what the server has is work the last session
// did not manage to write. Vim would call this a swap file.
const stashed = this.ws.drafts.readStash(id)
if (stashed && (stashed.body !== node.body || stashed.name !== node.name)) {
this.ws.draft = stashed
this.ws.dirty = true
this.ws.say('recovered an unsaved draft — :w to keep it, :e! to discard', 'error')
} else {
this.ws.drafts.dropStash(id)
}
}
/** 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.ws.cards.turnTo(found)
const note = this.foundNote
if (note) return this.ws.scratch.showNote(note.id)
const selected = this.selected
if (!selected) return
// A match from anywhere opens beside the results, as one from this space
// does, so `n` goes on from it; only a folder ends the search, by going in.
if (isContainer(selected)) await this.goTo(selected.id)
else await this.openNode(selected.id, edit)
}
/**
* A new document is created immediately so uploads have something to attach
* to. In the diary it starts from `templates/day` when there is one; the
* server sees to that, so capture does the same.
*/
async createDocument(name = '', from: { created_at?: number; template_id?: number } = {}) {
const parent = this.parentId
if (parent === null) return
const node = await api.createNode({ parent_id: parent, name, body: '', ...from })
this.query = ''
this.globalSearch = false
await this.refresh()
await this.openNode(node.id, true)
this.ws.tile.fresh = node.id
this.ws.enterInsert = true
this.ws.say(`new document ${this.here}/${node.slug}`)
}
async createFolder(name: string) {
const parent = this.parentId
if (parent === null) return
const node = await api.createNode({ parent_id: parent, kind: 'folder', name })
await this.refresh()
const index = this.nodes.findIndex((n) => n.id === node.id)
if (index >= 0) this.cursor = index
this.ws.say(`new folder ${this.here}/${node.slug}`)
}
async createSpace(name: string) {
const space = await api.createSpace(name)
await this.loadSpaces()
await this.enterSpace(space.id)
this.ws.say(`new space ${space.slug}`)
}
/**
* Fold a freshly saved document back into its list row. Returns false when
* the row cannot be patched in place — a new document, or one whose date or
* slug moved it somewhere else — and the caller should re-read the list.
*/
patch(node: WorkspaceNode): boolean {
const index = this.nodes.findIndex((n) => n.id === node.id)
if (index < 0) return false
const row = this.nodes[index]
if (row.created_at !== node.created_at || row.slug !== node.slug) return false
const { body: _body, path: _path, ...summary } = node
this.nodes[index] = summary
return true
}
async deleteNode(id: number) {
const showing = this.ws.tiles.showing(id)
for (const tile of showing) tile.stopAutosave()
this.ws.drafts.dropStash(id)
const node = showing[0]?.open ?? this.nodes.find((n) => n.id === id)
const { removed, purged } = await api.deleteNode(id)
for (const tile of showing) tile.clear()
this.ws.tiles.prune()
this.marked = this.marked.filter((n) => n.id !== id)
await this.refresh()
const name = node ? `"${node.name.trim() || node.slug}"` : `#${id}`
if (purged) return this.ws.say(`deleted ${name} for good`)
this.lastDeleted = id
const what = removed > 1 ? `${name} and ${removed - 1} inside it` : name
this.ws.say(`${what} went to the trash — u brings it back`)
}
/** `u`: the last delete, undone. */
async undoDelete() {
const id = this.lastDeleted
if (id === null) return this.ws.say('nothing deleted here to undo — :trash has the rest', 'error')
await this.restore(id)
}
/**
* Out of the trash, back where it was. A space is entered again; anything
* else in the space being browsed gets the cursor, so `u` lands on what it
* brought back.
*/
async restore(id: number) {
const { node, elsewhere } = await api.restore(id)
if (this.lastDeleted === id) this.lastDeleted = null
const name = `"${node.name.trim() || node.slug}"`
if (node.kind === 'space') {
this.spaces = await api.listSpaces()
if (this.ws.sheets.shown !== 'trash') await this.enterSpace(node.id)
this.ws.say(`restored the space ${name}`)
} else {
// A document's file comes back to the document, shown folded open in
// the list of the folder the document is in.
const owner = node.path.at(-2)?.kind === 'document' ? node.path.at(-2) : undefined
if (owner && !this.unfolded.includes(owner.id)) this.unfolded = [...this.unfolded, owner.id]
const parent = node.path.at(owner ? -3 : -2)
if (parent && node.path[0]?.id === this.spaceId) {
if (parent.id === this.parentId && !this.query) await this.refresh(node.id)
else if (this.ws.sheets.shown !== 'trash') await this.goTo(parent.id, node.id)
}
const where = node.path.slice(0, -1).map((crumb) => crumb.slug).join('/')
this.ws.say(elsewhere ? `restored ${name} to ${where} — the folder it was in is gone` : `restored ${name} to ${where}`)
}
if (this.ws.sheets.shown === 'trash') await this.ws.sheets.loadTrash()
}
/**
* Follow a `[[title]]`: to the document it names, or — when there is none
* — to a new one called that, here, after asking. Which document a title
* names the server says when the text is written, so it is written first.
*/
async followTitle(title: string) {
await this.ws.drafts.flushTile()
const path = this.ws.open?.wikilinks[title]
if (path) return this.openPath(path)
if (!(await this.ws.prompts.ask(`no document called "${title}" yet — write it in ${this.here}?`))) return
await this.createDocument(title)
}
/**
* Rename anything in the tree — a document, a folder or a space. Only the
* name is sent, so the text on the server stays as it is and a draft still
* being typed is saved by autosave as before. When the document is open
* here, the rename carries the version this tab last saw: one saved
* somewhere else since is not stamped over, which would let the next
* autosave write the draft over that save without a word.
*/
async renameNode(id: number, name: string) {
const open = this.ws.tiles.showing(id)[0]?.open
const saved = await api.updateNode(id, { name: name.trim(), base_updated_at: open?.updated_at })
for (const tile of this.ws.tiles.showing(id)) {
tile.open = saved
tile.draft.name = saved.name
}
if (this.path.some((crumb) => crumb.id === id)) this.path = saved.path
if (saved.kind === 'space') this.spaces = await api.listSpaces()
await this.ws.drafts.reread()
await this.refresh()
this.ws.say(`renamed to ${saved.slug}`)
}
/**
* Delete a space and everything in it. The app has to land somewhere
* afterwards, so it falls into the next space — or, if that was the last
* one, into an empty workspace that `:space new <name>` fills again.
*/
async deleteSpace(id: number) {
const { removed } = await api.deleteNode(id)
this.lastDeleted = id
for (const tile of this.ws.tiles.all) tile.clear()
this.ws.tiles.prune()
this.ws.cards.rawBoard = null
this.ws.cards.boardSpaceId = null
this.spaces = await api.listSpaces()
const next = this.spaces.find((space) => space.role === 'diary') ?? this.spaces[0]
if (next) await this.enterSpace(next.id)
else {
localStorage.removeItem('diary:space')
this.spaceId = null
this.parentId = null
this.path = []
this.nodes = []
this.cursor = 0
}
this.ws.say(`the space and ${removed - 1} node(s) in it went to the trash — u brings it back`)
}
/** Reparent the node under the cursor, for `:mv`. */
async moveNode(id: number, parentId: number) {
await api.moveNode(id, parentId)
await this.ws.drafts.reread()
await this.refresh()
this.ws.say(`moved #${id}`)
}
isMarked(id: number): boolean {
return this.marked.some((n) => n.id === id)
}
/**
* Move `nodes` into `parent`, all or none, and say how it went. An open one
* is saved and closed first: it is about to be somewhere else.
*/
async moveNodes(nodes: NodeSummary[], parent: number, where = 'here') {
if (nodes.length === 0) return this.ws.say('nothing marked — press m on a row first', 'error')
const leaving = this.ws.tiles.all.filter((tile) => nodes.some((n) => n.id === tile.open?.id))
for (const tile of leaving) {
await this.ws.drafts.flushTile(tile)
tile.clear()
}
this.ws.tiles.prune()
const { moved, unchanged } = await api.moveNodes(
nodes.map((n) => n.id),
parent,
)
const gone = new Set(nodes.map((n) => n.id))
this.marked = this.marked.filter((n) => !gone.has(n.id))
if (moved > 0) await this.ws.drafts.reread()
await this.refresh(nodes.length === 1 ? nodes[0].id : undefined)
const what = nodes.length === 1 ? nodes[0].name.trim() || nodes[0].slug : `${moved}`
const already = unchanged > 0 ? ` — ${unchanged} already ${unchanged === 1 ? 'was' : 'were'}` : ''
this.ws.say(moved === 0 ? `already ${where}` : `moved ${what} ${where}${already}`)
}
/** `1`..`9`, `0`: the space at that place on the bar. */
async switchSpace(index: number) {
const space = this.spaces[index]
if (!space) return this.ws.say(`no space ${index + 1}`, 'error')
if (space.id !== this.spaceId) await this.enterSpace(space.id)
}
/** Tab / Shift-Tab: the next or previous space, wrapping round. */
async cycleSpace(delta: number) {
const count = this.spaces.length
if (count < 2) return
const at = this.spaces.findIndex((s) => s.id === this.spaceId)
await this.enterSpace(this.spaces[(at + delta + count) % count].id)
}
/**
* Shift+digit: send the marked node, or else the one under the cursor, to
* the root of the space at that place — the window moved to workspace N.
*/
async sendToSpace(index: number) {
const space = this.spaces[index]
const nodes = this.marked.length ? this.marked : this.selected ? [this.selected] : []
if (!space) return this.ws.say(`no space ${index + 1}`, 'error')
if (nodes.length === 0) return this.ws.say('nothing selected', 'error')
await this.moveNodes(nodes, space.id, `→ ${index + 1} ${space.slug}`)
this.spaces = await api.listSpaces()
}
/**
* `m`: pick the node under the cursor up, or put it back down unmoved, and
* step to the next row — `mmm` picks up three in a row.
*/
mark(node = this.selected, step = true) {
if (!node) return this.ws.say('nothing selected', 'error')
this.marked = this.isMarked(node.id) ? this.marked.filter((n) => n.id !== node.id) : [...this.marked, node]
if (step && node === this.selected) this.move(1)
this.#sayMarked()
}
/** Shift-click: mark every row from the cursor to `index`. */
markRange(index: number) {
const [from, to] = [Math.min(this.cursor, index), Math.max(this.cursor, index)]
const rows = this.nodes.slice(from, to + 1).filter((n) => !this.isMarked(n.id))
this.marked = [...this.marked, ...rows]
this.cursor = index
this.#sayMarked()
}
/** `M`: mark the whole folder, or, when it all is, none of it. */
markAll() {
const all = this.nodes.length > 0 && this.nodes.every((n) => this.isMarked(n.id))
const here = new Set(this.nodes.map((n) => n.id))
this.marked = all
? this.marked.filter((n) => !here.has(n.id))
: [...this.marked, ...this.nodes.filter((n) => !this.isMarked(n.id))]
this.#sayMarked()
}
unmarkAll() {
if (this.marked.length === 0) return
this.marked = []
this.ws.say('unmarked')
}
#sayMarked() {
const count = this.marked.length
if (count === 0) return this.ws.say('unmarked')
const first = this.marked[0]
const what = count === 1 ? first.name.trim() || first.slug : `${count} nodes`
this.ws.say(`marked ${what} — p drops ${count === 1 ? 'it' : 'them'} in the folder you are in`)
}
/**
* `p`: drop the marked nodes into the folder the list is showing — or,
* when they are files and the cursor is in a document's files folded open,
* into that document.
*/
async drop() {
const parent = this.parentId
if (this.marked.length === 0) return this.ws.say('nothing marked — press m on a row first', 'error')
if (parent === null) return this.ws.say('no folder open', 'error')
const at = this.selected
const doc = at && this.isNested(at) ? at.parent_id : at?.id
if (doc && this.unfolded.includes(doc) && this.marked.every((n) => n.kind === 'file')) {
const name = this.nodes.find((n) => n.id === doc)?.name.trim()
return this.moveNodes(this.marked, doc, `to ${name || 'the document'}`)
}
await this.moveNodes(this.marked, parent)
}
async toggleShare(id: number) {
const node = this.ws.open?.id === id ? this.ws.open : await api.getNode(id)
if (node.kind !== 'document') return this.ws.say('only a document can be shared', 'error')
if (node.shared) {
await api.unshare(id)
this.ws.say(`#${id} is private again`)
} else {
const { url, sealed } = await this.mintShareLink(id)
this.ws.say(`shared → ${url} (copied)${sealedNote(sealed)}`, sealed > 0 ? 'error' : undefined)
}
for (const tile of this.ws.tiles.showing(id)) tile.open = await api.getNode(id)
await this.refresh()
}
/*
* The server keeps only a hash of the key that lives in the link's fragment,
* so a link exists in full exactly once: here, in the moment it is minted.
* There is nothing to re-copy later — asking again mints a new link and
* retires the old one.
*/
async mintShareLink(id: number): Promise<{ url: string; sealed: number }> {
const { path, sealed } = await api.share(id)
const url = `${location.origin}${path}`
await this.ws.copy(url)
return { url, sealed }
}
/** `:share new`: a new link for a shared document, copied; the old one stops working. */
async renewShareLink(id: number) {
const { url, sealed } = await this.mintShareLink(id)
this.ws.say(`new link → ${url} (copied) — the previous one is dead${sealedNote(sealed)}`, sealed > 0 ? 'error' : undefined)
for (const tile of this.ws.tiles.showing(id)) tile.open = await api.getNode(id)
}
/**
* Import a picked folder (or a zip of one) into the folder being browsed.
* The report is worth a line of its own: an import that quietly skipped half
* its files would only be noticed much later.
*/
async importFolder(files: File[], root = false) {
const parent = this.parentId
if (parent === null && !root) return
if (!root && this.parentSealed && !(await this.ws.vault.ensureUnlocked())) return
this.ws.say(`importing ${files.length} file${files.length === 1 ? '' : 's'} …`)
const report = root ? await api.importRoot(files) : await api.importFolder(parent!, files)
if (root) this.spaces = await api.listSpaces()
await this.refresh()
const parts = [
...(report.spaces > 0 ? [`${report.spaces} space${report.spaces === 1 ? '' : 's'}`] : []),
`${report.documents} document${report.documents === 1 ? '' : 's'}`,
`${report.folders} folder${report.folders === 1 ? '' : 's'}`,
`${report.files} file${report.files === 1 ? '' : 's'}`,
`${report.links} link${report.links === 1 ? '' : 's'} rewritten`,
...(report.cards > 0 ? [`${report.cards} card${report.cards === 1 ? '' : 's'}`] : []),
]
if (report.skipped.length > 0) parts.push(`${report.skipped.length} skipped`)
this.ws.say(`imported ${parts.join(', ')}`)
}
/**
* Upload files into the folder being browsed, as nodes of their own. The
* status line counts up while they go, because a large one takes a while.
*/
async putFiles(files: File[], encrypt = false) {
const parent = this.parentId
if (parent === null) return
// A sealed folder seals them anyway, and needs the vault open for it.
if (this.parentSealed) {
if (!(await this.ws.vault.ensureUnlocked())) return
encrypt = true
}
const total = files.reduce((sum, file) => sum + file.size, 0)
const what = files.length === 1 ? files[0].name : `${files.length} files`
this.ws.say(`uploading ${what} (${formatBytes(total)})${encrypt ? ' into the vault' : ''} …`)
const created = await api.putFiles(parent, files, encrypt, (fraction) =>
this.ws.say(`uploading ${what} — ${Math.floor(fraction * 100)}%`),
)
this.query = ''
this.globalSearch = false
await this.refresh(created[0]?.id)
this.ws.say(created.length === 1 ? `uploaded ${this.here}/${created[0].slug}` : `uploaded ${created.length} files`)
}
/**
* Give the document in `tile` files of its own — what a picture pasted
* while writing is too. Answers the file nodes, for the editor to embed.
*/
async attach(files: File[], tile = this.ws.tile): Promise<NodeSummary[]> {
const doc = tile.open
if (doc?.kind !== 'document') {
this.ws.say('files go to a document — open one first', 'error')
return []
}
// In a sealed folder they are sealed on the way, which needs the vault.
if (doc.sealed && !(await this.ws.vault.ensureUnlocked())) return []
const what = files.length === 1 ? files[0].name : `${files.length} files`
const total = files.reduce((sum, file) => sum + file.size, 0)
const name = doc.name.trim() || doc.slug
this.ws.say(`uploading ${what} (${formatBytes(total)}) to ${name} …`)
const created = await api.putFiles(doc.id, files, doc.sealed, (fraction) =>
this.ws.say(`uploading ${what} — ${Math.floor(fraction * 100)}%`),
)
// Its list of files, without touching what is being written in it.
if (tile.open?.id === doc.id) {
tile.open = { ...tile.open, files: [...tile.open.files, ...created], child_count: tile.open.child_count + created.length }
}
await this.refresh()
this.ws.say(`${created.length === 1 ? created[0].name : `${created.length} files`} now belong${created.length === 1 ? 's' : ''} to ${name}`)
return created
}
}
+56
View File
@@ -0,0 +1,56 @@
import { api, type VaultStatus } from '../api'
import type { Workspace } from '../store.svelte'
/** The vault, as last heard from the server, and opening it when something needs it. */
export class Vault {
readonly ws: Workspace
constructor(ws: Workspace) {
this.ws = ws
}
/** The vault, as last heard from the server; null until asked. */
status = $state<VaultStatus | null>(null)
async load() {
this.status = await api.vaultStatus()
}
/**
* Whatever needs the vault asks for the password itself rather than failing
* with "locked": opening it is the obvious next step anyway. False when
* there is no vault, or the question was declined.
*/
async ensureUnlocked(): Promise<boolean> {
await this.load()
if (!this.status?.configured) {
this.ws.say('there is no vault yet — :vault sets one up', 'error')
return false
}
if (this.status.unlocked) return true
const password = await this.ws.prompts.askSecret('vault password')
if (password === null) return false
// The key is stretched on purpose, which takes a moment worth saying.
this.ws.say('unlocking …')
this.status = await api.unlockVault(password)
this.ws.say('vault unlocked')
return true
}
/** Seal what is under the cursor, or take it back out, and show the result. */
async seal(id: number, encrypt: boolean) {
const { files, unchanged, failed, shared, sealed } = await api.seal(id, encrypt)
const verb = encrypt ? 'encrypted' : 'decrypted'
const already = unchanged > 0 ? `, ${unchanged} already were` : ''
const broken = failed > 0 ? `, ${failed} could not be` : ''
const later = sealed ? ' — what lands in the folder from now on is sealed too' : ''
const readers =
shared > 0 ? ` — ${shared} shared document${shared === 1 ? '' : 's'} embed it, and their readers no longer see it` : ''
this.ws.say(
`${verb} ${files} file${files === 1 ? '' : 's'}${already}${broken}${later}${readers}`,
failed > 0 || shared > 0 ? 'error' : undefined,
)
await this.ws.tree.refresh()
for (const tile of this.ws.tiles.all) if (tile.open?.kind === 'file') tile.open = await api.getNode(tile.open.id)
}
}
File diff suppressed because it is too large. Load diff