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.
$effect(() => {
if (!workspace.dirty) return
if (!workspace.tiles.some((tile) => tile.dirty)) return
const warn = (event: BeforeUnloadEvent) => event.preventDefault()
window.addEventListener('beforeunload', warn)
return () => window.removeEventListener('beforeunload', warn)
@@ -851,9 +851,9 @@
onpointerdown={() => workspace.open && (workspace.pane = 'document')}
>
{#if workspace.open?.kind === 'file'}
<FilePane />
<FilePane tile={workspace.tile} />
{:else if workspace.open}
<DocumentPane />
<DocumentPane tile={workspace.tile} />
{:else}
<div class="placeholder">
<div class="standby resolve">
+19 -14
View File
@@ -2,7 +2,7 @@
import { followLink, hooks, runCommand, type ScrollAmount } from './commands'
import { renderMarkdown } from './markdown'
import Outline from './Outline.svelte'
import { workspace } from './store.svelte'
import { workspace, type Tile } from './store.svelte'
import Icon from './Icon.svelte'
import {
formatStamp,
@@ -15,10 +15,14 @@
scrollPage,
} from './util'
const doc = $derived(workspace.open!)
const html = $derived(renderMarkdown(workspace.editing ? workspace.draft.body : doc.body))
/** 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 doc = $derived(tile.open!)
const html = $derived(renderMarkdown(tile.editing ? tile.draft.body : doc.body))
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
@@ -34,7 +38,7 @@
// `:name` with nothing after it, while writing: up into the title.
$effect(() => {
const field = title
if (!field) return
if (!field || !focused) return
// A turn later: vim gives the focus back to the text once its `:` line
// has run, and that would land after this.
const focus = () =>
@@ -56,6 +60,7 @@
$effect(() => {
// 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.
if (!focused) return
const scroll = (amount: ScrollAmount) => {
if (!reader) return false
scrollPage(reader, amount)
@@ -71,7 +76,7 @@
$effect(() => {
const scroller = reader
const root = article
if (!scroller || !root) return
if (!scroller || !root || !focused) return
const jump = (direction: 1 | -1) => jumpHeading(scroller, headingsIn(root), direction)
const list = () => readerHeadings(scroller, root)
// 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')}>
<Icon name="back" size={16} />
</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>
{#if workspace.dirty}<span class="dirty">modified</span>{/if}
{#if tile.dirty}<span class="dirty">modified</span>{/if}
</div>
<div class="row">
<dl class="meta">
<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>
<dd>{relative(doc.updated_at)}</dd>
<dt>words</dt>
@@ -155,7 +160,7 @@
</dl>
<div class="actions">
{#if workspace.editing}
{#if tile.editing}
<button onclick={() => runCommand('w')}>:w</button>
<button onclick={() => runCommand('upload')}>attach</button>
<button onclick={() => runCommand('wq')}>done</button>
@@ -180,13 +185,13 @@
</div>
{/if}
{#if workspace.editing}
{#if tile.editing}
<input
class="title"
bind:this={title}
placeholder="Untitled"
bind:value={workspace.draft.name}
oninput={() => workspace.touch()}
bind:value={tile.draft.name}
oninput={() => workspace.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) {
@@ -201,7 +206,7 @@
<div class="loading label">loading editor<span class="cursor">_</span></div>
{:then module}
{@const Editor = module.default}
<Editor />
<Editor {tile} />
{/await}
{/key}
</div>
+155 -133
View File
@@ -1,6 +1,126 @@
<script lang="ts" module>
/* The vim register controller is global, so it is wrapped once per page. */
let clipboardMirrored = false
import { EditorView } from '@codemirror/view'
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 lang="ts">
@@ -9,7 +129,6 @@
import { HighlightStyle, indentOnInput, syntaxHighlighting } from '@codemirror/language'
import { EditorState } from '@codemirror/state'
import {
EditorView,
drawSelection,
dropCursor,
highlightActiveLine,
@@ -17,12 +136,13 @@
placeholder,
} from '@codemirror/view'
import { tags } from '@lezer/highlight'
import { Vim, getCM, vim } from '@replit/codemirror-vim'
import { onDestroy, onMount } from 'svelte'
import { commands, followLink, hooks, runCommand } from './commands'
import { hooks } from './commands'
import { embedSnippet } from './markdown'
import { linkAt, sourceHeadings } from './util'
import { workspace } from './store.svelte'
import { workspace, type Tile } 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 view: EditorView | undefined
@@ -83,50 +203,6 @@
{ 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. */
function editorHeadings() {
if (!view) return { headings: [], current: 0 }
@@ -137,68 +213,10 @@
found.forEach((heading, 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 }
}
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
* file never cuts a sentence in half.
@@ -226,11 +244,7 @@
}
onMount(() => {
bridgeExCommands()
bindHelpKey()
bindHeadingKeys()
bindLinkKeys()
mirrorUnnamedRegisterToClipboard()
bindVim()
const extensions = [
...(workspace.vimEnabled ? [vim({ status: false })] : []),
@@ -249,7 +263,7 @@
{
key: 'Mod-s',
preventDefault: true,
run: () => (void workspace.guard(() => workspace.save()), true),
run: () => (void workspace.guard(() => workspace.save({ tile })), true),
},
...defaultKeymap,
...historyKeymap,
@@ -257,8 +271,8 @@
]),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
workspace.draft.body = update.state.doc.toString()
workspace.touch()
tile.draft.body = update.state.doc.toString()
workspace.touch(tile)
}
}),
EditorView.domEventHandlers({
@@ -280,14 +294,11 @@
]
view = new EditorView({
state: EditorState.create({ doc: workspace.draft.body, extensions }),
state: EditorState.create({ doc: tile.draft.body, extensions }),
parent: host!,
})
hooks.focusEditor = focus
hooks.insertText = insertAtCursor
hooks.headings = editorHeadings
view.focus()
if (workspace.tile === tile) view.focus()
const cm = workspace.vimEnabled ? getCM(view) : null
if (cm) {
@@ -296,28 +307,39 @@
on?: (event: string, handler: (e: { mode: string }) => void) => void
}
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
// mode, the way opening a file in vim does.
if (workspace.enterInsert) {
workspace.enterInsert = false
if (tile.enterInsert) {
tile.enterInsert = false
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(() => {
view?.destroy()
view = undefined
// Only clear the hooks if no newer editor has taken them already.
if (hooks.focusEditor === focus) {
hooks.focusEditor = () => {}
hooks.insertText = () => {}
if (hooks.headings === editorHeadings) hooks.headings = () => ({ headings: [], current: 0 })
workspace.vimMode = 'normal'
}
tile.vimMode = 'normal'
})
</script>
+8 -3
View File
@@ -4,10 +4,14 @@
import Icon from './Icon.svelte'
import { renderMarkdown } from './markdown'
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'
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!)
/* A sealed file cannot be fetched while the vault is shut. */
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
// password while it is sealed, a keystore's when it has one.
$effect(() => {
if (!focused) return
const scroll = (amount: ScrollAmount) => {
if (!reader) return false
scrollPage(reader, amount)
@@ -108,7 +113,7 @@
$effect(() => {
const scroller = reader
const root = article
if (!scroller || !root) return
if (!scroller || !root || !focused) return
const jump = (direction: 1 | -1) => jumpHeading(scroller, headingsIn(root), direction)
const list = () => readerHeadings(scroller, root)
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. */
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
* layer needs to see everything at once to decide what a key means.
@@ -47,12 +97,65 @@ class Workspace {
cursor = $state(0)
query = $state('')
open = $state<WorkspaceNode | null>(null)
draft = $state({ 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 documents on screen. There is always at least one tile, empty when
* nothing is open; the names below (`open`, `draft`, `editing` …) are the
* focused tile's, so most of the app reads one document as it always did.
*/
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. */
view = $state<View>('tree')
@@ -81,13 +184,6 @@ class Workspace {
cardCursor = $state(0)
/** `z` on the board: show done cards older than a week too. */
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
* whatever folder the list is showing then. A move is two keystrokes apart
@@ -98,7 +194,6 @@ class Workspace {
marked = $state.raw<NodeSummary[]>([])
pane = $state<Pane>('list')
vimMode = $state('normal')
flash = $state<Flash>(null)
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. */
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
* 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. */
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`. */
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 {
return this.nodes[this.cursor]
}
@@ -301,7 +384,7 @@ class Workspace {
failed > 0 || shared > 0 ? 'error' : undefined,
)
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> {
@@ -341,17 +424,17 @@ class Workspace {
}
/** Call whenever the draft changes. Replaces a bare `dirty = true`. */
touch() {
this.dirty = true
this.stash()
if (this.#autosave) clearTimeout(this.#autosave)
this.#autosave = setTimeout(() => void this.#writeBack(), Workspace.AUTOSAVE_MS)
touch(tile = this.tile) {
tile.dirty = true
this.stash(tile)
tile.stopAutosave()
tile.autosave = setTimeout(() => void this.#writeBack(tile), Workspace.AUTOSAVE_MS)
}
stash() {
if (!this.open) return
stash(tile = this.tile) {
if (!tile.open) return
try {
localStorage.setItem(this.#stashKey(this.open.id), JSON.stringify(this.draft))
localStorage.setItem(this.#stashKey(tile.open.id), JSON.stringify(tile.draft))
} catch {
/* 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 {
const raw = localStorage.getItem(this.#stashKey(id))
if (!raw) return null
@@ -377,29 +460,31 @@ class Workspace {
}
}
async #writeBack() {
this.#autosave = null
if (!this.open || !this.dirty) return
async #writeBack(tile: Tile) {
tile.autosave = null
if (!tile.open || !tile.dirty) return
try {
await this.save({ quiet: true })
if (this.#retryDelay > 0) this.say('saved — the server is back')
this.#retryDelay = 0
await this.save({ quiet: true, tile })
if (tile.retryDelay > 0) this.say('saved — the server is back')
tile.retryDelay = 0
} catch (error) {
if (!unreachable(error)) return this.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.
this.#retryDelay = Math.min(Math.max(this.#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.#autosave = setTimeout(() => void this.#writeBack(), this.#retryDelay)
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 ${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', () => {
if (!this.dirty) return
if (this.#autosave) clearTimeout(this.#autosave)
void this.#writeBack()
for (const tile of this.tiles) {
if (!tile.dirty) continue
tile.stopAutosave()
void this.#writeBack(tile)
}
})
window.addEventListener('offline', () => {
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.
* Without this, clicking another document in the list discarded unsaved work
* without a word.
* 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() {
if (this.#autosave) {
clearTimeout(this.#autosave)
this.#autosave = null
}
if (await this.abandonIfBlank()) return
if (this.open && this.dirty) await this.guard(() => this.save({ quiet: true }))
for (const tile of this.tiles) await this.flushTile(tile)
}
/** The same for one tile only, as it is about to show something else. */
async flushTile(tile = this.tile) {
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
* went, so the caller knows there is nothing open any more.
*/
async abandonIfBlank(): Promise<boolean> {
const open = this.open
if (!open || open.id !== this.#fresh) return false
if (this.draft.name.trim() || this.draft.body.trim()) return false
this.#fresh = null
if (this.#autosave) {
clearTimeout(this.#autosave)
this.#autosave = null
}
async abandonIfBlank(tile = this.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.open = null
this.editing = false
this.dirty = false
this.pane = 'list'
if (tile === this.tile) this.pane = 'list'
try {
await api.deleteNode(open.id)
} catch {
@@ -482,7 +562,9 @@ class Workspace {
// the draft included, so pick up where it stopped rather than start over.
if (this.spaces.length > 0) {
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
}
await this.loadSpaces(Workspace.addressedPath())
@@ -497,8 +579,7 @@ class Workspace {
this.vault = null
this.spaces = []
this.nodes = []
this.open = null
this.editing = false
for (const tile of this.tiles) tile.clear()
}
/*
@@ -569,9 +650,8 @@ class Workspace {
const from = this.spaces.findIndex((s) => s.id === this.spaceId)
const to = this.spaces.findIndex((s) => s.id === id)
this.switchDirection = to >= from ? 1 : -1
this.tile.clear()
this.returnTo = null
this.open = null
this.editing = false
this.pane = 'list'
}
this.spaceId = id
@@ -676,7 +756,7 @@ class Workspace {
}
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)
// What is asked for from the board — `:12`, `T`, a search — is shown, not
@@ -737,7 +817,7 @@ class Workspace {
this.globalSearch = false
await this.refresh()
await this.openNode(node.id, true)
this.#fresh = node.id
this.tile.fresh = node.id
this.enterInsert = true
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 —
* unless `force`, which is `:w!`.
*/
async save({ quiet = false, force = false } = {}) {
if (!this.open) return
async save({ quiet = false, force = false, tile = this.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 = { ...this.draft, name: this.draft.name.trim() }
const saved = await api.updateNode(this.open.id, {
const sent = { ...tile.draft, name: tile.draft.name.trim() }
const saved = await api.updateNode(tile.open.id, {
...sent,
base_updated_at: force ? undefined : this.open.updated_at,
base_updated_at: force ? undefined : tile.open.updated_at,
})
this.open = saved
this.dirty =
this.draft.body !== sent.body ||
this.draft.name.trim() !== sent.name ||
this.draft.created_at !== sent.created_at
if (this.dirty) this.touch()
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.
@@ -803,19 +883,13 @@ class Workspace {
}
async deleteNode(id: number) {
if (this.#autosave) {
clearTimeout(this.#autosave)
this.#autosave = null
}
const showing = this.tilesWith(id)
for (const tile of showing) tile.stopAutosave()
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)
if (this.open?.id === id) {
this.open = null
this.editing = false
this.dirty = false
this.pane = 'list'
}
for (const tile of showing) tile.clear()
if (showing.includes(this.tile)) this.pane = 'list'
this.marked = this.marked.filter((n) => n.id !== id)
await this.refresh()
const name = node ? `"${node.name.trim() || node.slug}"` : `#${id}`
@@ -836,9 +910,9 @@ class Workspace {
body: current.body,
created_at: current.created_at,
})
if (this.open?.id === id) {
this.open = saved
this.draft.name = saved.name
for (const tile of this.tilesWith(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()
@@ -853,8 +927,7 @@ class Workspace {
*/
async deleteSpace(id: number) {
const { removed } = await api.deleteNode(id)
this.open = null
this.editing = false
for (const tile of this.tiles) tile.clear()
this.rawBoard = null
this.view = 'tree'
this.spaces = await api.listSpaces()
@@ -888,12 +961,12 @@ class Workspace {
*/
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 (this.open && nodes.some((n) => n.id === this.open?.id)) {
await this.flush()
this.open = null
this.editing = false
this.pane = 'list'
const leaving = this.tiles.filter((tile) => nodes.some((n) => n.id === tile.open?.id))
for (const tile of leaving) {
await this.flushTile(tile)
tile.clear()
}
if (leaving.includes(this.tile)) this.pane = 'list'
const { moved, unchanged } = await api.moveNodes(
nodes.map((n) => n.id),
parent,
@@ -1244,9 +1317,8 @@ class Workspace {
/** Back to the board a card's document was opened from. */
async returnToBoard() {
this.tile.clear()
this.returnTo = null
this.open = null
this.editing = false
this.pane = 'list'
await this.reloadBoard()
this.view = 'board'
@@ -1276,7 +1348,7 @@ class Workspace {
const { url, sealed } = await this.mintShareLink(id)
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()
}