mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
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:
1 parent
7f8d625a8d
commit
b1107013ee
40 files changed
+3077
-2924
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 <name> makes a column. then o adds a card to it.
|
||||
|
||||
+10
-10
@@ -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
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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}`],
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -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())
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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('/')}`)
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+53
-2335
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user