store: what belongs to one document lives in a Tile

The open node, its draft, whether it is being written and whether that is
saved, the autosave timer and its back-off, the fresh-document marker,
the board to go back to and the editor's vim mode move out of Workspace
into a Tile. The workspace holds `tiles` and `focused`, always at least
one tile, and the old names are getters on the focused tile, so the call
sites read as before. Nothing visible changes yet: there is one tile.

`flush()` writes every dirty tile, deleting, moving, renaming, sharing or
sealing a node updates every tile that shows it, and the page warns before
unloading when any tile is dirty.

The panes and the editor are handed their tile, and fill the shared hooks
only while that tile has focus; the others stand by. Vim's actions are
defined once for the page and act on the editor the key was pressed in,
rather than on whichever editor happened to be mounted last.
This commit is contained in:
nvrl committed 2026-10-20 15:22:43 +02:00
1 parent c72eccf86b
commit e0de88938a
5 files changed
+369 -265

No files matched your search

+3 -3
View File
@@ -145,7 +145,7 @@
// Guard against losing a half-written entry to a stray refresh. // Guard against losing a half-written entry to a stray refresh.
$effect(() => { $effect(() => {
if (!workspace.dirty) return if (!workspace.tiles.some((tile) => tile.dirty)) return
const warn = (event: BeforeUnloadEvent) => event.preventDefault() const warn = (event: BeforeUnloadEvent) => event.preventDefault()
window.addEventListener('beforeunload', warn) window.addEventListener('beforeunload', warn)
return () => window.removeEventListener('beforeunload', warn) return () => window.removeEventListener('beforeunload', warn)
@@ -851,9 +851,9 @@
onpointerdown={() => workspace.open && (workspace.pane = 'document')} onpointerdown={() => workspace.open && (workspace.pane = 'document')}
> >
{#if workspace.open?.kind === 'file'} {#if workspace.open?.kind === 'file'}
<FilePane /> <FilePane tile={workspace.tile} />
{:else if workspace.open} {:else if workspace.open}
<DocumentPane /> <DocumentPane tile={workspace.tile} />
{:else} {:else}
<div class="placeholder"> <div class="placeholder">
<div class="standby resolve"> <div class="standby resolve">
+19 -14
View File
@@ -2,7 +2,7 @@
import { followLink, hooks, runCommand, type ScrollAmount } from './commands' import { followLink, hooks, runCommand, type ScrollAmount } from './commands'
import { renderMarkdown } from './markdown' import { renderMarkdown } from './markdown'
import Outline from './Outline.svelte' import Outline from './Outline.svelte'
import { workspace } from './store.svelte' import { workspace, type Tile } from './store.svelte'
import Icon from './Icon.svelte' import Icon from './Icon.svelte'
import { import {
formatStamp, formatStamp,
@@ -15,10 +15,14 @@
scrollPage, scrollPage,
} from './util' } from './util'
const doc = $derived(workspace.open!) /** The tile this pane draws; the keys only reach it while it has focus. */
const html = $derived(renderMarkdown(workspace.editing ? workspace.draft.body : doc.body)) const { tile }: { tile: Tile } = $props()
const focused = $derived(workspace.tile === tile)
const doc = $derived(tile.open!)
const html = $derived(renderMarkdown(tile.editing ? tile.draft.body : doc.body))
const words = $derived( const words = $derived(
(workspace.editing ? workspace.draft.body : doc.body).split(/\s+/).filter(Boolean).length, (tile.editing ? tile.draft.body : doc.body).split(/\s+/).filter(Boolean).length,
) )
/* /*
* Only the token half of a share link is recoverable — the key lives in the * Only the token half of a share link is recoverable — the key lives in the
@@ -34,7 +38,7 @@
// `:name` with nothing after it, while writing: up into the title. // `:name` with nothing after it, while writing: up into the title.
$effect(() => { $effect(() => {
const field = title const field = title
if (!field) return if (!field || !focused) return
// A turn later: vim gives the focus back to the text once its `:` line // A turn later: vim gives the focus back to the text once its `:` line
// has run, and that would land after this. // has run, and that would land after this.
const focus = () => const focus = () =>
@@ -56,6 +60,7 @@
$effect(() => { $effect(() => {
// A space switch remounts this pane, and the old one's cleanup can run // A space switch remounts this pane, and the old one's cleanup can run
// after the new one has set the hook — so it only clears its own. // after the new one has set the hook — so it only clears its own.
if (!focused) return
const scroll = (amount: ScrollAmount) => { const scroll = (amount: ScrollAmount) => {
if (!reader) return false if (!reader) return false
scrollPage(reader, amount) scrollPage(reader, amount)
@@ -71,7 +76,7 @@
$effect(() => { $effect(() => {
const scroller = reader const scroller = reader
const root = article const root = article
if (!scroller || !root) return if (!scroller || !root || !focused) return
const jump = (direction: 1 | -1) => jumpHeading(scroller, headingsIn(root), direction) const jump = (direction: 1 | -1) => jumpHeading(scroller, headingsIn(root), direction)
const list = () => readerHeadings(scroller, root) const list = () => readerHeadings(scroller, root)
// The outline in the margin is links too, so `F` reaches it as well. // The outline in the margin is links too, so `F` reaches it as well.
@@ -139,15 +144,15 @@
<button class="back" aria-label="back to the list" onclick={() => runCommand('q')}> <button class="back" aria-label="back to the list" onclick={() => runCommand('q')}>
<Icon name="back" size={16} /> <Icon name="back" size={16} />
</button> </button>
<span class="marker" class:live={workspace.editing}></span> <span class="marker" class:live={tile.editing}></span>
<span class="path">{workspace.here}/{doc.slug}</span> <span class="path">{workspace.here}/{doc.slug}</span>
{#if workspace.dirty}<span class="dirty">modified</span>{/if} {#if tile.dirty}<span class="dirty">modified</span>{/if}
</div> </div>
<div class="row"> <div class="row">
<dl class="meta"> <dl class="meta">
<dt>written</dt> <dt>written</dt>
<dd>{formatStamp(workspace.draft.created_at)} {formatWeekday(workspace.draft.created_at)}</dd> <dd>{formatStamp(tile.draft.created_at)} {formatWeekday(tile.draft.created_at)}</dd>
<dt>edited</dt> <dt>edited</dt>
<dd>{relative(doc.updated_at)}</dd> <dd>{relative(doc.updated_at)}</dd>
<dt>words</dt> <dt>words</dt>
@@ -155,7 +160,7 @@
</dl> </dl>
<div class="actions"> <div class="actions">
{#if workspace.editing} {#if tile.editing}
<button onclick={() => runCommand('w')}>:w</button> <button onclick={() => runCommand('w')}>:w</button>
<button onclick={() => runCommand('upload')}>attach</button> <button onclick={() => runCommand('upload')}>attach</button>
<button onclick={() => runCommand('wq')}>done</button> <button onclick={() => runCommand('wq')}>done</button>
@@ -180,13 +185,13 @@
</div> </div>
{/if} {/if}
{#if workspace.editing} {#if tile.editing}
<input <input
class="title" class="title"
bind:this={title} bind:this={title}
placeholder="Untitled" placeholder="Untitled"
bind:value={workspace.draft.name} bind:value={tile.draft.name}
oninput={() => workspace.touch()} oninput={() => workspace.touch(tile)}
onkeydown={(event) => { onkeydown={(event) => {
// The title is the line above the text: any of these goes back down. // The title is the line above the text: any of these goes back down.
if (['Enter', 'Escape', 'ArrowDown', 'Tab'].includes(event.key) && !event.shiftKey) { if (['Enter', 'Escape', 'ArrowDown', 'Tab'].includes(event.key) && !event.shiftKey) {
@@ -201,7 +206,7 @@
<div class="loading label">loading editor<span class="cursor">_</span></div> <div class="loading label">loading editor<span class="cursor">_</span></div>
{:then module} {:then module}
{@const Editor = module.default} {@const Editor = module.default}
<Editor /> <Editor {tile} />
{/await} {/await}
{/key} {/key}
</div> </div>
+155 -133
View File
@@ -1,6 +1,126 @@
<script lang="ts" module> <script lang="ts" module>
/* The vim register controller is global, so it is wrapped once per page. */ import { EditorView } from '@codemirror/view'
let clipboardMirrored = false import { Vim, getCM, vim } from '@replit/codemirror-vim'
import { commands, followLink, runCommand } from './commands'
import { linkAt, sourceHeadings } from './util'
import { workspace as ws } from './store.svelte'
/*
* Vim's actions, ex commands and registers are global: one definition
* serves every editor on the page. Each action is handed the editor the key
* was pressed in, so with several documents open it acts on that one.
*/
let vimBound = false
/** What vim hands an action: CodeMirror 5's face over the editor. */
type Adapter = { cm6: EditorView }
/** Route every `:command` typed inside the editor through the shared handler. */
function bridgeExCommands() {
for (const spec of commands) {
const names = [spec.name, ...(spec.aliases ?? [])]
for (const name of names) {
Vim.defineEx(name, name, (_cm: unknown, params: { argString?: string }) => {
const arg = (params.argString ?? '').trim()
// vim parses `:q!` as command `q` with the bang left in the arguments,
// so put it back on the name before handing the line over.
const bang = arg.startsWith('!')
void runCommand(`${spec.name}${bang ? '!' : ''} ${bang ? arg.slice(1).trim() : arg}`)
})
}
}
}
/**
* `set clipboard=unnamed`, in effect: every yank or delete that lands in the
* unnamed register is copied out to the system clipboard too. An explicit
* register (`"ay`) is left alone, and `"+p` still pastes the clipboard back.
*/
function mirrorUnnamedRegisterToClipboard() {
const registers = Vim.getRegisterController?.()
if (!registers) return
const push = registers.pushText.bind(registers)
registers.pushText = (name, operator, text, linewise, blockwise) => {
push(name, operator, text, linewise, blockwise)
if (!name && text && ws.clipboard) void ws.copy(text)
}
}
/**
* `?` is vim's search-backwards, but the workspace advertises it as the help key
* everywhere else, so it opens the reference here too. `/` still searches.
*/
function bindHelpKey() {
Vim.defineAction('workspaceHelp', () => void runCommand('help'))
for (const context of ['normal', 'visual'] as const) {
Vim.mapCommand('?', 'action', 'workspaceHelp', {}, { context })
}
}
function gotoLine(view: EditorView, number: number) {
const line = view.state.doc.line(number)
view.dispatch({
selection: { anchor: line.from },
effects: EditorView.scrollIntoView(line.from, { y: 'start', yMargin: 24 }),
})
view.focus()
}
/*
* vim's `]]` and `[[` walk sections, which in a markdown file are the
* headings — the same jump the reader makes, where the text is. `gO` lists
* them, as it does in vim's own help files.
*/
function bindHeadingKeys() {
const step = (view: EditorView, direction: 1 | -1) => {
const at = view.state.doc.lineAt(view.state.selection.main.head).number
const lines = sourceHeadings(view.state.doc.toString()).map((heading) => heading.line)
let target: number | undefined
for (const line of lines) {
if (direction > 0 && line > at) {
target = line
break
}
if (direction < 0 && line < at) target = line
}
gotoLine(view, target ?? (direction > 0 ? view.state.doc.lines : 1))
}
Vim.defineAction('workspaceNextHeading', (cm) => step((cm as unknown as Adapter).cm6, 1))
Vim.defineAction('workspacePrevHeading', (cm) => step((cm as unknown as Adapter).cm6, -1))
Vim.defineAction('workspaceOutline', () => void runCommand('outline'))
Vim.mapCommand(']]', 'action', 'workspaceNextHeading', {}, { context: 'normal' })
Vim.mapCommand('[[', 'action', 'workspacePrevHeading', {}, { context: 'normal' })
Vim.mapCommand('gO', 'action', 'workspaceOutline', {}, { context: 'normal' })
}
/*
* `gf` follows the link under the cursor — a workspace link opens that
* document, saving this one first, anything else gets a tab — and `gx`
* opens it in a tab whatever it is, as vim hands it to the browser.
*/
function bindLinkKeys() {
const follow = (view: EditorView, tab: boolean) => {
const head = view.state.selection.main.head
const line = view.state.doc.lineAt(head)
const href = linkAt(line.text, head - line.from)
if (!href) return ws.say('no link under the cursor', 'error')
followLink(href, tab)
}
Vim.defineAction('workspaceFollowLink', (cm) => follow((cm as unknown as Adapter).cm6, false))
Vim.defineAction('workspaceOpenLink', (cm) => follow((cm as unknown as Adapter).cm6, true))
Vim.mapCommand('gf', 'action', 'workspaceFollowLink', {}, { context: 'normal' })
Vim.mapCommand('gx', 'action', 'workspaceOpenLink', {}, { context: 'normal' })
}
function bindVim() {
if (vimBound) return
vimBound = true
bridgeExCommands()
bindHelpKey()
bindHeadingKeys()
bindLinkKeys()
mirrorUnnamedRegisterToClipboard()
}
</script> </script>
<script lang="ts"> <script lang="ts">
@@ -9,7 +129,6 @@
import { HighlightStyle, indentOnInput, syntaxHighlighting } from '@codemirror/language' import { HighlightStyle, indentOnInput, syntaxHighlighting } from '@codemirror/language'
import { EditorState } from '@codemirror/state' import { EditorState } from '@codemirror/state'
import { import {
EditorView,
drawSelection, drawSelection,
dropCursor, dropCursor,
highlightActiveLine, highlightActiveLine,
@@ -17,12 +136,13 @@
placeholder, placeholder,
} from '@codemirror/view' } from '@codemirror/view'
import { tags } from '@lezer/highlight' import { tags } from '@lezer/highlight'
import { Vim, getCM, vim } from '@replit/codemirror-vim'
import { onDestroy, onMount } from 'svelte' import { onDestroy, onMount } from 'svelte'
import { commands, followLink, hooks, runCommand } from './commands' import { hooks } from './commands'
import { embedSnippet } from './markdown' import { embedSnippet } from './markdown'
import { linkAt, sourceHeadings } from './util' import { workspace, type Tile } from './store.svelte'
import { workspace } from './store.svelte'
/** The tile this editor writes in, which need not be the one with focus. */
const { tile }: { tile: Tile } = $props()
let host = $state<HTMLDivElement>() let host = $state<HTMLDivElement>()
let view: EditorView | undefined let view: EditorView | undefined
@@ -83,50 +203,6 @@
{ dark: true }, { dark: true },
) )
/** Route every `:command` typed inside the editor through the shared handler. */
function bridgeExCommands() {
for (const spec of commands) {
const names = [spec.name, ...(spec.aliases ?? [])]
for (const name of names) {
Vim.defineEx(name, name, (_cm: unknown, params: { argString?: string }) => {
const arg = (params.argString ?? '').trim()
// vim parses `:q!` as command `q` with the bang left in the arguments,
// so put it back on the name before handing the line over.
const bang = arg.startsWith('!')
void runCommand(`${spec.name}${bang ? '!' : ''} ${bang ? arg.slice(1).trim() : arg}`)
})
}
}
}
/**
* `set clipboard=unnamed`, in effect: every yank or delete that lands in the
* unnamed register is copied out to the system clipboard too. An explicit
* register (`"ay`) is left alone, and `"+p` still pastes the clipboard back.
*/
function mirrorUnnamedRegisterToClipboard() {
if (clipboardMirrored) return
const registers = Vim.getRegisterController?.()
if (!registers) return
clipboardMirrored = true
const push = registers.pushText.bind(registers)
registers.pushText = (name, operator, text, linewise, blockwise) => {
push(name, operator, text, linewise, blockwise)
if (!name && text && workspace.clipboard) void workspace.copy(text)
}
}
/**
* `?` is vim's search-backwards, but the workspace advertises it as the help key
* everywhere else, so it opens the reference here too. `/` still searches.
*/
function bindHelpKey() {
Vim.defineAction('workspaceHelp', () => void runCommand('help'))
for (const context of ['normal', 'visual'] as const) {
Vim.mapCommand('?', 'action', 'workspaceHelp', {}, { context })
}
}
/** The `#` lines being written, as `gO` lists them, and the one the cursor is under. */ /** The `#` lines being written, as `gO` lists them, and the one the cursor is under. */
function editorHeadings() { function editorHeadings() {
if (!view) return { headings: [], current: 0 } if (!view) return { headings: [], current: 0 }
@@ -137,68 +213,10 @@
found.forEach((heading, i) => { found.forEach((heading, i) => {
if (heading.line <= at) current = i if (heading.line <= at) current = i
}) })
const headings = found.map(({ text, level, line }) => ({ text, level, go: () => gotoLine(line) })) const headings = found.map(({ text, level, line }) => ({ text, level, go: () => gotoLine(editor, line) }))
return { headings, current } return { headings, current }
} }
function gotoLine(number: number) {
if (!view) return
const line = view.state.doc.line(number)
view.dispatch({
selection: { anchor: line.from },
effects: EditorView.scrollIntoView(line.from, { y: 'start', yMargin: 24 }),
})
view.focus()
}
/*
* vim's `]]` and `[[` walk sections, which in a markdown file are the
* headings — the same jump the reader makes, where the text is. `gO` lists
* them, as it does in vim's own help files.
*/
function bindHeadingKeys() {
const step = (direction: 1 | -1) => {
if (!view) return
const at = view.state.doc.lineAt(view.state.selection.main.head).number
const lines = sourceHeadings(view.state.doc.toString()).map((heading) => heading.line)
let target: number | undefined
for (const line of lines) {
if (direction > 0 && line > at) {
target = line
break
}
if (direction < 0 && line < at) target = line
}
gotoLine(target ?? (direction > 0 ? view.state.doc.lines : 1))
}
Vim.defineAction('workspaceNextHeading', () => step(1))
Vim.defineAction('workspacePrevHeading', () => step(-1))
Vim.defineAction('workspaceOutline', () => void runCommand('outline'))
Vim.mapCommand(']]', 'action', 'workspaceNextHeading', {}, { context: 'normal' })
Vim.mapCommand('[[', 'action', 'workspacePrevHeading', {}, { context: 'normal' })
Vim.mapCommand('gO', 'action', 'workspaceOutline', {}, { context: 'normal' })
}
/*
* `gf` follows the link under the cursor — a workspace link opens that
* document, saving this one first, anything else gets a tab — and `gx`
* opens it in a tab whatever it is, as vim hands it to the browser.
*/
function bindLinkKeys() {
const follow = (tab: boolean) => {
if (!view) return
const head = view.state.selection.main.head
const line = view.state.doc.lineAt(head)
const href = linkAt(line.text, head - line.from)
if (!href) return workspace.say('no link under the cursor', 'error')
followLink(href, tab)
}
Vim.defineAction('workspaceFollowLink', () => follow(false))
Vim.defineAction('workspaceOpenLink', () => follow(true))
Vim.mapCommand('gf', 'action', 'workspaceFollowLink', {}, { context: 'normal' })
Vim.mapCommand('gx', 'action', 'workspaceOpenLink', {}, { context: 'normal' })
}
/** /**
* Media goes on its own line after the one the cursor is on, so attaching a * Media goes on its own line after the one the cursor is on, so attaching a
* file never cuts a sentence in half. * file never cuts a sentence in half.
@@ -226,11 +244,7 @@
} }
onMount(() => { onMount(() => {
bridgeExCommands() bindVim()
bindHelpKey()
bindHeadingKeys()
bindLinkKeys()
mirrorUnnamedRegisterToClipboard()
const extensions = [ const extensions = [
...(workspace.vimEnabled ? [vim({ status: false })] : []), ...(workspace.vimEnabled ? [vim({ status: false })] : []),
@@ -249,7 +263,7 @@
{ {
key: 'Mod-s', key: 'Mod-s',
preventDefault: true, preventDefault: true,
run: () => (void workspace.guard(() => workspace.save()), true), run: () => (void workspace.guard(() => workspace.save({ tile })), true),
}, },
...defaultKeymap, ...defaultKeymap,
...historyKeymap, ...historyKeymap,
@@ -257,8 +271,8 @@
]), ]),
EditorView.updateListener.of((update) => { EditorView.updateListener.of((update) => {
if (update.docChanged) { if (update.docChanged) {
workspace.draft.body = update.state.doc.toString() tile.draft.body = update.state.doc.toString()
workspace.touch() workspace.touch(tile)
} }
}), }),
EditorView.domEventHandlers({ EditorView.domEventHandlers({
@@ -280,14 +294,11 @@
] ]
view = new EditorView({ view = new EditorView({
state: EditorState.create({ doc: workspace.draft.body, extensions }), state: EditorState.create({ doc: tile.draft.body, extensions }),
parent: host!, parent: host!,
}) })
hooks.focusEditor = focus if (workspace.tile === tile) view.focus()
hooks.insertText = insertAtCursor
hooks.headings = editorHeadings
view.focus()
const cm = workspace.vimEnabled ? getCM(view) : null const cm = workspace.vimEnabled ? getCM(view) : null
if (cm) { if (cm) {
@@ -296,28 +307,39 @@
on?: (event: string, handler: (e: { mode: string }) => void) => void on?: (event: string, handler: (e: { mode: string }) => void) => void
} }
emitter.on?.('vim-mode-change', (event) => { emitter.on?.('vim-mode-change', (event) => {
workspace.vimMode = event.mode tile.vimMode = event.mode
}) })
// A fresh entry starts in insert mode; an existing one starts in normal // A fresh entry starts in insert mode; an existing one starts in normal
// mode, the way opening a file in vim does. // mode, the way opening a file in vim does.
if (workspace.enterInsert) { if (tile.enterInsert) {
workspace.enterInsert = false tile.enterInsert = false
Vim.handleKey(cm, 'i', 'workspace') Vim.handleKey(cm, 'i', 'workspace')
} }
} }
}) })
/*
* The keys reach whichever editor is in the focused tile: that one fills
* the hooks, and the rest stand by until their tile is focused.
*/
$effect(() => {
if (workspace.tile !== tile) return
hooks.focusEditor = focus
hooks.insertText = insertAtCursor
hooks.headings = editorHeadings
// Only clear the hooks if no other editor has taken them already.
return () => {
if (hooks.focusEditor === focus) hooks.focusEditor = () => {}
if (hooks.insertText === insertAtCursor) hooks.insertText = () => {}
if (hooks.headings === editorHeadings) hooks.headings = () => ({ headings: [], current: 0 })
}
})
onDestroy(() => { onDestroy(() => {
view?.destroy() view?.destroy()
view = undefined view = undefined
// Only clear the hooks if no newer editor has taken them already. tile.vimMode = 'normal'
if (hooks.focusEditor === focus) {
hooks.focusEditor = () => {}
hooks.insertText = () => {}
if (hooks.headings === editorHeadings) hooks.headings = () => ({ headings: [], current: 0 })
workspace.vimMode = 'normal'
}
}) })
</script> </script>
+8 -3
View File
@@ -4,10 +4,14 @@
import Icon from './Icon.svelte' import Icon from './Icon.svelte'
import { renderMarkdown } from './markdown' import { renderMarkdown } from './markdown'
import { looksLikeText, parseTable, previewKind, separatorOf } from './preview' import { looksLikeText, parseTable, previewKind, separatorOf } from './preview'
import { workspace } from './store.svelte' import { workspace, type Tile } from './store.svelte'
import { formatBytes, formatStamp, headingsIn, jumpHeading, readerHeadings, relative, scrollPage } from './util' import { formatBytes, formatStamp, headingsIn, jumpHeading, readerHeadings, relative, scrollPage } from './util'
const node = $derived(workspace.open!) /** The tile this pane draws; the keys only reach it while it has focus. */
const { tile }: { tile: Tile } = $props()
const focused = $derived(workspace.tile === tile)
const node = $derived(tile.open!)
const file = $derived(node.file!) const file = $derived(node.file!)
/* A sealed file cannot be fetched while the vault is shut. */ /* 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?.unlocked)
@@ -76,6 +80,7 @@
// and Enter does the one thing the file is waiting for — the vault's // and Enter does the one thing the file is waiting for — the vault's
// password while it is sealed, a keystore's when it has one. // password while it is sealed, a keystore's when it has one.
$effect(() => { $effect(() => {
if (!focused) return
const scroll = (amount: ScrollAmount) => { const scroll = (amount: ScrollAmount) => {
if (!reader) return false if (!reader) return false
scrollPage(reader, amount) scrollPage(reader, amount)
@@ -108,7 +113,7 @@
$effect(() => { $effect(() => {
const scroller = reader const scroller = reader
const root = article const root = article
if (!scroller || !root) return if (!scroller || !root || !focused) return
const jump = (direction: 1 | -1) => jumpHeading(scroller, headingsIn(root), direction) const jump = (direction: 1 | -1) => jumpHeading(scroller, headingsIn(root), direction)
const list = () => readerHeadings(scroller, root) const list = () => readerHeadings(scroller, root)
hooks.jumpHeading = jump hooks.jumpHeading = jump
+184 -112
View File
@@ -24,6 +24,56 @@ export type Flash = { text: string; kind: 'info' | 'error' } | null
/** Where a space was left: the folder, the open document, the row, the pane. */ /** Where a space was left: the folder, the open document, the row, the pane. */
type Spot = { parentId: number; openId: number | null; cursor: number; pane: Pane } type Spot = { parentId: number; openId: number | null; cursor: number; pane: Pane }
export type Draft = { name: string; body: string; created_at: number }
let tiles = 0
/**
* One document 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.
*/
export class Tile {
readonly id = ++tiles
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')
/*
* Set when a card's document was opened from the board, so closing the
* document goes back to the board — on the same card — rather than leaving
* you in a tree you never asked for.
*/
returnTo = $state<'board' | null>(null)
/*
* 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
}
/** Put the tile back to showing nothing. Where it goes back to stays. */
clear() {
this.stopAutosave()
this.open = null
this.editing = false
this.dirty = false
this.fresh = null
}
}
/** /**
* The whole application state. It is deliberately one object: the keyboard * The whole application state. It is deliberately one object: the keyboard
* layer needs to see everything at once to decide what a key means. * layer needs to see everything at once to decide what a key means.
@@ -47,12 +97,65 @@ class Workspace {
cursor = $state(0) cursor = $state(0)
query = $state('') query = $state('')
open = $state<WorkspaceNode | null>(null) /*
draft = $state({ name: '', body: '', created_at: 0 }) * The documents on screen. There is always at least one tile, empty when
editing = $state(false) * nothing is open; the names below (`open`, `draft`, `editing` …) are the
/** Set when the editor should open in insert mode rather than normal mode. */ * focused tile's, so most of the app reads one document as it always did.
enterInsert = $state(false) */
dirty = $state(false) tiles = $state<Tile[]>([new Tile()])
focused = $state(0)
get tile(): Tile {
return this.tiles[this.focused] ?? this.tiles[0]
}
get open() {
return this.tile.open
}
set open(node: WorkspaceNode | null) {
this.tile.open = node
}
get draft() {
return this.tile.draft
}
set draft(draft: Draft) {
this.tile.draft = draft
}
get editing() {
return this.tile.editing
}
set editing(editing: boolean) {
this.tile.editing = editing
}
get enterInsert() {
return this.tile.enterInsert
}
set enterInsert(insert: boolean) {
this.tile.enterInsert = insert
}
get dirty() {
return this.tile.dirty
}
set dirty(dirty: boolean) {
this.tile.dirty = dirty
}
get returnTo() {
return this.tile.returnTo
}
set returnTo(to: 'board' | null) {
this.tile.returnTo = to
}
get vimMode() {
return this.tile.vimMode
}
set vimMode(mode: string) {
this.tile.vimMode = mode
}
/** Every tile showing `id`. */
tilesWith(id: number): Tile[] {
return this.tiles.filter((tile) => tile.open?.id === id)
}
/** The board of the current space, loaded only while it is being looked at. */ /** The board of the current space, loaded only while it is being looked at. */
view = $state<View>('tree') view = $state<View>('tree')
@@ -81,13 +184,6 @@ class Workspace {
cardCursor = $state(0) cardCursor = $state(0)
/** `z` on the board: show done cards older than a week too. */ /** `z` on the board: show done cards older than a week too. */
showOldDone = $state(false) showOldDone = $state(false)
/*
* Set when a card's document was opened from the board, so closing the
* document goes back to the board — on the same card — rather than leaving
* you in a tree you never asked for.
*/
returnTo = $state<'board' | null>(null)
/* /*
* The nodes picked up by `m`, waiting for the `p` that drops them into * 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 * whatever folder the list is showing then. A move is two keystrokes apart
@@ -98,7 +194,6 @@ class Workspace {
marked = $state.raw<NodeSummary[]>([]) marked = $state.raw<NodeSummary[]>([])
pane = $state<Pane>('list') pane = $state<Pane>('list')
vimMode = $state('normal')
flash = $state<Flash>(null) flash = $state<Flash>(null)
overlay = $state<'none' | 'help' | 'media' | 'overview' | 'agenda' | 'actions' | 'verify' | 'outline'>('none') overlay = $state<'none' | 'help' | 'media' | 'overview' | 'agenda' | 'actions' | 'verify' | 'outline'>('none')
@@ -116,12 +211,6 @@ class Workspace {
/** A search across every space rather than the one being browsed — Ctrl-P. */ /** A search across every space rather than the one being browsed — Ctrl-P. */
globalSearch = $state(false) globalSearch = $state(false)
/*
* 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
/* /*
* A `[y/N]` question in the command line's place, instead of the browser's * 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 * confirm(): it names the thing about to go, and it is answered from the
@@ -154,15 +243,9 @@ class Workspace {
/** What the login screen has to say: why it is showing, if not for a first visit. */ /** What the login screen has to say: why it is showing, if not for a first visit. */
notice = $state('') notice = $state('')
/** Saves that failed for want of a server wait this long before trying again. */
#retryDelay = 0
/** Mirror vim yanks and deletes into the system clipboard, like `set clipboard=unnamed`. */ /** Mirror vim yanks and deletes into the system clipboard, like `set clipboard=unnamed`. */
clipboard = $state(localStorage.getItem('diary:clipboard') !== 'off') clipboard = $state(localStorage.getItem('diary:clipboard') !== 'off')
/** Pending debounced write, if the draft has changed since the last save. */
#autosave: ReturnType<typeof setTimeout> | null = null
get selected(): NodeSummary | undefined { get selected(): NodeSummary | undefined {
return this.nodes[this.cursor] return this.nodes[this.cursor]
} }
@@ -301,7 +384,7 @@ class Workspace {
failed > 0 || shared > 0 ? 'error' : undefined, failed > 0 || shared > 0 ? 'error' : undefined,
) )
await this.refresh() await this.refresh()
if (this.open?.kind === 'file') this.open = await api.getNode(this.open.id) for (const tile of this.tiles) if (tile.open?.kind === 'file') tile.open = await api.getNode(tile.open.id)
} }
ask(text: string): Promise<boolean> { ask(text: string): Promise<boolean> {
@@ -341,17 +424,17 @@ class Workspace {
} }
/** Call whenever the draft changes. Replaces a bare `dirty = true`. */ /** Call whenever the draft changes. Replaces a bare `dirty = true`. */
touch() { touch(tile = this.tile) {
this.dirty = true tile.dirty = true
this.stash() this.stash(tile)
if (this.#autosave) clearTimeout(this.#autosave) tile.stopAutosave()
this.#autosave = setTimeout(() => void this.#writeBack(), Workspace.AUTOSAVE_MS) tile.autosave = setTimeout(() => void this.#writeBack(tile), Workspace.AUTOSAVE_MS)
} }
stash() { stash(tile = this.tile) {
if (!this.open) return if (!tile.open) return
try { try {
localStorage.setItem(this.#stashKey(this.open.id), JSON.stringify(this.draft)) localStorage.setItem(this.#stashKey(tile.open.id), JSON.stringify(tile.draft))
} catch { } catch {
/* a full or disabled localStorage must not stop the typing */ /* a full or disabled localStorage must not stop the typing */
} }
@@ -365,7 +448,7 @@ class Workspace {
} }
} }
#readStash(id: number): typeof this.draft | null { #readStash(id: number): Draft | null {
try { try {
const raw = localStorage.getItem(this.#stashKey(id)) const raw = localStorage.getItem(this.#stashKey(id))
if (!raw) return null if (!raw) return null
@@ -377,29 +460,31 @@ class Workspace {
} }
} }
async #writeBack() { async #writeBack(tile: Tile) {
this.#autosave = null tile.autosave = null
if (!this.open || !this.dirty) return if (!tile.open || !tile.dirty) return
try { try {
await this.save({ quiet: true }) await this.save({ quiet: true, tile })
if (this.#retryDelay > 0) this.say('saved — the server is back') if (tile.retryDelay > 0) this.say('saved — the server is back')
this.#retryDelay = 0 tile.retryDelay = 0
} catch (error) { } catch (error) {
if (!unreachable(error)) return this.guard(() => Promise.reject(error)) if (!unreachable(error)) return this.guard(() => Promise.reject(error))
// Nothing is lost — the draft is in localStorage — but it is not on the // 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. // server either, so it is tried again, less often the longer it fails.
this.#retryDelay = Math.min(Math.max(this.#retryDelay * 2, 2000), 60_000) tile.retryDelay = Math.min(Math.max(tile.retryDelay * 2, 2000), 60_000)
this.say(`not saved: the server cannot be reached — kept here, trying again in ${this.#retryDelay / 1000}s`, 'error') this.say(`not saved: the server cannot be reached — kept here, trying again in ${tile.retryDelay / 1000}s`, 'error')
this.#autosave = setTimeout(() => void this.#writeBack(), this.#retryDelay) tile.autosave = setTimeout(() => void this.#writeBack(tile), tile.retryDelay)
} }
} }
/** Back online: what is waiting goes now rather than at the next retry. */ /** Back online: what is waiting goes now rather than at the next retry. */
#watchConnection() { #watchConnection() {
window.addEventListener('online', () => { window.addEventListener('online', () => {
if (!this.dirty) return for (const tile of this.tiles) {
if (this.#autosave) clearTimeout(this.#autosave) if (!tile.dirty) continue
void this.#writeBack() tile.stopAutosave()
void this.#writeBack(tile)
}
}) })
window.addEventListener('offline', () => { window.addEventListener('offline', () => {
this.say('offline — what is typed is kept here, and saved once the server answers', 'error') this.say('offline — what is typed is kept here, and saved once the server answers', 'error')
@@ -407,37 +492,32 @@ class Workspace {
} }
/** /**
* Write out a pending draft before doing something that would replace it. * Write out every pending draft before doing something that would replace
* Without this, clicking another document in the list discarded unsaved work * one. Without this, clicking another document in the list discarded
* without a word. * unsaved work without a word.
*/ */
async flush() { async flush() {
if (this.#autosave) { for (const tile of this.tiles) await this.flushTile(tile)
clearTimeout(this.#autosave) }
this.#autosave = null
} /** The same for one tile only, as it is about to show something else. */
if (await this.abandonIfBlank()) return async flushTile(tile = this.tile) {
if (this.open && this.dirty) await this.guard(() => this.save({ quiet: true })) tile.stopAutosave()
if (await this.abandonIfBlank(tile)) return
if (tile.open && tile.dirty) await this.guard(() => this.save({ quiet: true, tile }))
} }
/** /**
* Drop the document `o` made if nothing was ever put in it. True when it * 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. * went, so the caller knows there is nothing open any more.
*/ */
async abandonIfBlank(): Promise<boolean> { async abandonIfBlank(tile = this.tile): Promise<boolean> {
const open = this.open const open = tile.open
if (!open || open.id !== this.#fresh) return false if (!open || open.id !== tile.fresh) return false
if (this.draft.name.trim() || this.draft.body.trim()) return false if (tile.draft.name.trim() || tile.draft.body.trim()) return false
this.#fresh = null tile.clear()
if (this.#autosave) {
clearTimeout(this.#autosave)
this.#autosave = null
}
this.dropStash(open.id) this.dropStash(open.id)
this.open = null if (tile === this.tile) this.pane = 'list'
this.editing = false
this.dirty = false
this.pane = 'list'
try { try {
await api.deleteNode(open.id) await api.deleteNode(open.id)
} catch { } catch {
@@ -482,7 +562,9 @@ class Workspace {
// the draft included, so pick up where it stopped rather than start over. // the draft included, so pick up where it stopped rather than start over.
if (this.spaces.length > 0) { if (this.spaces.length > 0) {
await this.refresh() await this.refresh()
if (this.open && this.dirty) await this.guard(() => this.save({ quiet: true })) for (const tile of this.tiles) {
if (tile.open && tile.dirty) await this.guard(() => this.save({ quiet: true, tile }))
}
return return
} }
await this.loadSpaces(Workspace.addressedPath()) await this.loadSpaces(Workspace.addressedPath())
@@ -497,8 +579,7 @@ class Workspace {
this.vault = null this.vault = null
this.spaces = [] this.spaces = []
this.nodes = [] this.nodes = []
this.open = null for (const tile of this.tiles) tile.clear()
this.editing = false
} }
/* /*
@@ -569,9 +650,8 @@ class Workspace {
const from = this.spaces.findIndex((s) => s.id === this.spaceId) const from = this.spaces.findIndex((s) => s.id === this.spaceId)
const to = this.spaces.findIndex((s) => s.id === id) const to = this.spaces.findIndex((s) => s.id === id)
this.switchDirection = to >= from ? 1 : -1 this.switchDirection = to >= from ? 1 : -1
this.tile.clear()
this.returnTo = null this.returnTo = null
this.open = null
this.editing = false
this.pane = 'list' this.pane = 'list'
} }
this.spaceId = id this.spaceId = id
@@ -676,7 +756,7 @@ class Workspace {
} }
async openNode(id: number, edit = false, underBoard = false) { async openNode(id: number, edit = false, underBoard = false) {
if (this.open?.id !== id) await this.flush() if (this.open?.id !== id) await this.flushTile()
const node = await api.getNode(id) const node = await api.getNode(id)
// What is asked for from the board — `:12`, `T`, a search — is shown, not // What is asked for from the board — `:12`, `T`, a search — is shown, not
@@ -737,7 +817,7 @@ class Workspace {
this.globalSearch = false this.globalSearch = false
await this.refresh() await this.refresh()
await this.openNode(node.id, true) await this.openNode(node.id, true)
this.#fresh = node.id this.tile.fresh = node.id
this.enterInsert = true this.enterInsert = true
this.say(`new document ${this.here}/${node.slug}`) this.say(`new document ${this.here}/${node.slug}`)
} }
@@ -764,22 +844,22 @@ class Workspace {
* since it was opened here — another tab, the phone, the SFTP mount — * since it was opened here — another tab, the phone, the SFTP mount —
* unless `force`, which is `:w!`. * unless `force`, which is `:w!`.
*/ */
async save({ quiet = false, force = false } = {}) { async save({ quiet = false, force = false, tile = this.tile } = {}) {
if (!this.open) return if (!tile.open) return
// Snapshot what goes over the wire: an autosave races the typing that // Snapshot what goes over the wire: an autosave races the typing that
// triggered it, and anything typed since must stay marked unsaved. // triggered it, and anything typed since must stay marked unsaved.
const sent = { ...this.draft, name: this.draft.name.trim() } const sent = { ...tile.draft, name: tile.draft.name.trim() }
const saved = await api.updateNode(this.open.id, { const saved = await api.updateNode(tile.open.id, {
...sent, ...sent,
base_updated_at: force ? undefined : this.open.updated_at, base_updated_at: force ? undefined : tile.open.updated_at,
}) })
this.open = saved tile.open = saved
this.dirty = tile.dirty =
this.draft.body !== sent.body || tile.draft.body !== sent.body ||
this.draft.name.trim() !== sent.name || tile.draft.name.trim() !== sent.name ||
this.draft.created_at !== sent.created_at tile.draft.created_at !== sent.created_at
if (this.dirty) this.touch() if (tile.dirty) this.touch(tile)
else this.dropStash(saved.id) else this.dropStash(saved.id)
// The response carries the same excerpt the list query builds, so the row // 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. // can usually be patched in place rather than re-reading the whole list.
@@ -803,19 +883,13 @@ class Workspace {
} }
async deleteNode(id: number) { async deleteNode(id: number) {
if (this.#autosave) { const showing = this.tilesWith(id)
clearTimeout(this.#autosave) for (const tile of showing) tile.stopAutosave()
this.#autosave = null
}
this.dropStash(id) this.dropStash(id)
const node = this.open?.id === id ? this.open : this.nodes.find((n) => n.id === id) const node = showing[0]?.open ?? this.nodes.find((n) => n.id === id)
const { removed } = await api.deleteNode(id) const { removed } = await api.deleteNode(id)
if (this.open?.id === id) { for (const tile of showing) tile.clear()
this.open = null if (showing.includes(this.tile)) this.pane = 'list'
this.editing = false
this.dirty = false
this.pane = 'list'
}
this.marked = this.marked.filter((n) => n.id !== id) this.marked = this.marked.filter((n) => n.id !== id)
await this.refresh() await this.refresh()
const name = node ? `"${node.name.trim() || node.slug}"` : `#${id}` const name = node ? `"${node.name.trim() || node.slug}"` : `#${id}`
@@ -836,9 +910,9 @@ class Workspace {
body: current.body, body: current.body,
created_at: current.created_at, created_at: current.created_at,
}) })
if (this.open?.id === id) { for (const tile of this.tilesWith(id)) {
this.open = saved tile.open = saved
this.draft.name = saved.name tile.draft.name = saved.name
} }
if (this.path.some((crumb) => crumb.id === id)) this.path = saved.path if (this.path.some((crumb) => crumb.id === id)) this.path = saved.path
if (saved.kind === 'space') this.spaces = await api.listSpaces() if (saved.kind === 'space') this.spaces = await api.listSpaces()
@@ -853,8 +927,7 @@ class Workspace {
*/ */
async deleteSpace(id: number) { async deleteSpace(id: number) {
const { removed } = await api.deleteNode(id) const { removed } = await api.deleteNode(id)
this.open = null for (const tile of this.tiles) tile.clear()
this.editing = false
this.rawBoard = null this.rawBoard = null
this.view = 'tree' this.view = 'tree'
this.spaces = await api.listSpaces() this.spaces = await api.listSpaces()
@@ -888,12 +961,12 @@ class Workspace {
*/ */
async moveNodes(nodes: NodeSummary[], parent: number, where = 'here') { async moveNodes(nodes: NodeSummary[], parent: number, where = 'here') {
if (nodes.length === 0) return this.say('nothing marked — press m on a row first', 'error') if (nodes.length === 0) return this.say('nothing marked — press m on a row first', 'error')
if (this.open && nodes.some((n) => n.id === this.open?.id)) { const leaving = this.tiles.filter((tile) => nodes.some((n) => n.id === tile.open?.id))
await this.flush() for (const tile of leaving) {
this.open = null await this.flushTile(tile)
this.editing = false tile.clear()
this.pane = 'list'
} }
if (leaving.includes(this.tile)) this.pane = 'list'
const { moved, unchanged } = await api.moveNodes( const { moved, unchanged } = await api.moveNodes(
nodes.map((n) => n.id), nodes.map((n) => n.id),
parent, parent,
@@ -1244,9 +1317,8 @@ class Workspace {
/** Back to the board a card's document was opened from. */ /** Back to the board a card's document was opened from. */
async returnToBoard() { async returnToBoard() {
this.tile.clear()
this.returnTo = null this.returnTo = null
this.open = null
this.editing = false
this.pane = 'list' this.pane = 'list'
await this.reloadBoard() await this.reloadBoard()
this.view = 'board' this.view = 'board'
@@ -1276,7 +1348,7 @@ class Workspace {
const { url, sealed } = await this.mintShareLink(id) const { url, sealed } = await this.mintShareLink(id)
this.say(`shared → ${url} (copied)${sealedNote(sealed)}`, sealed > 0 ? 'error' : undefined) this.say(`shared → ${url} (copied)${sealedNote(sealed)}`, sealed > 0 ? 'error' : undefined)
} }
if (this.open?.id === id) this.open = await api.getNode(id) for (const tile of this.tilesWith(id)) tile.open = await api.getNode(id)
await this.refresh() await this.refresh()
} }