ui: touch parity, saves that survive outages, edits that do not collide

PUT /api/nodes/{id} takes base_updated_at and answers 409 when the node
was saved since; the editor sends it, so a save from another tab, the
phone or the SFTP mount is no longer overwritten in silence. :w! and
:wq! now force the write.

An autosave the server could not take is kept and retried with backoff,
and at once on the online event; network failures say "the server
cannot be reached" rather than "Failed to fetch". At boot that is no
longer shown as a login screen. A session that expires mid-edit shows
the login with a note, and logging back in resumes the open draft.

Folder listings and the media list are read page by page, so more than
500 children are no longer cut off. Search results carry the space and
folder they sit in, and the overview's recent list includes files.
Embedding uploads report progress, like uploads into a folder.

On touch devices vim is off by default. Each row has an actions sheet
(rename, move, share, seal, delete), folders start with a .. row, and
the dock gains today's entry, upload, search all spaces, drop for a
marked node, help, and the board's list, reorder, rename and delete.
The agenda gets a done button per card and a button for today's entry.

Sharing a folder or a file is refused in the client. :mv .. in a search
moves up from the match's own folder. Files dropped outside the list or
editor no longer navigate the tab away. The public share view renders
/n/ links as plain text, and tells a network error from a dead link.
This commit is contained in:
nvrl committed 2026-10-20 05:53:34 +02:00
1 parent ee13ae44fa
commit 2733ac1a3a
15 files changed
+472 -74

No files matched your search

+16 -5
View File
@@ -22,6 +22,8 @@ export const hooks = {
overviewColumns: (): number => 1,
openOverview: () => {},
openCommandLine: (_initial: string) => {},
/** Ctrl-P: a search across every space. */
openGlobalSearch: () => {},
}
/** Help groups the command list under these headings, in this order. */
@@ -121,7 +123,10 @@ export const commands: CommandSpec[] = [
group: 'editing',
help: 'save the open document',
run: () => workspace.guard(() => workspace.save()),
bang: { help: 'same as :w — the bang is accepted out of habit', run: () => workspace.guard(() => workspace.save()) },
bang: {
help: 'save even over a newer version saved somewhere else since this one was opened',
run: () => workspace.guard(() => workspace.save({ force: true })),
},
},
{
name: 'wq',
@@ -134,9 +139,9 @@ export const commands: CommandSpec[] = [
hooks.focusList()
},
bang: {
help: 'same as :wq — the bang is accepted out of habit',
help: 'save over a newer version, as :w! does, and leave the editor',
run: async () => {
await workspace.guard(() => workspace.save())
await workspace.guard(() => workspace.save({ force: true }))
workspace.editing = false
hooks.focusList()
},
@@ -335,8 +340,14 @@ export const commands: CommandSpec[] = [
const target = arg.trim()
if (!target) return workspace.say('usage: :mv <folder|..>', 'error')
const destination =
target === '..' ? workspace.path.at(-2)?.id : findFolder(target)?.id
// `..` is up from where the node is, which in a search result is not
// the folder being browsed.
const up = async () => {
if (node.parent_id === null) return undefined
const parent = await api.getNode(node.parent_id)
return parent.kind === 'space' ? undefined : (parent.parent_id ?? undefined)
}
const destination = target === '..' ? await up() : 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')
await workspace.guard(() => workspace.moveNode(node.id, destination))