diff --git a/README.md b/README.md index 879c9d8..66664a3 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,8 @@ The desktop UI is modal. In the browse pane: | `o` | a new document here, straight into insert mode | | `O` | a new folder here | | `i`, `a` | edit the open entry | +| `R` | rename what is highlighted — document, folder or space | +| `b` | the board of this space | | `Esc`, `q` | back out to the document | | `h` | up one level in the tree | | `/` | full-text search (`n` clears it) | @@ -73,9 +75,12 @@ mode, macros, `:w` to write. `:help` lists every ex command; the useful ones are `:w`, `:wq`, `:q!`, `:new [yyyy-mm-dd]`, `:date`, `:name`, `:share`, `:link`, `:upload`, `:media`, `:search`, `:backup`, `:set theme=mocha|green|amber|ice` and `:set novim`. The tree has its own: `:space [name]` switches (`:space! ` -makes one), `:mkdir ` makes a folder, `:cd ` walks it and -`:mv ` moves what is selected, and `:import` uploads a folder into -the one being browsed (`:import!` takes a `.zip` instead). The board has +makes one, `:rmspace [name]` deletes one and everything in it), `:mkdir ` +makes a folder, `:cd ` walks it and `:mv ` moves what is +selected, and `:import` uploads a folder into the one being browsed +(`:import!` takes a `.zip` instead). `:name ` renames whatever is under +the cursor — a document, a folder, or a card on a board — and `:name!` renames +the space itself. The board has `:board`, `:card ` (`:card!` links the open document to it), `:due <yyyy-mm-dd|->`, `:done` and `:list <name>` (`:list!` deletes one). @@ -83,10 +88,13 @@ On a touch device modal editing is turned off and the same actions are buttons. ## Boards -`:board` swaps the tree for the board of the space being browsed, and swaps it -back. Every space has one except the diary — a journal has no backlog — and -`:set board` / `:set noboard` changes that for the space you are in. A new -board starts with `backlog`, `doing` and `done`; `:list <name>` adds columns. +`b`, the `board` button above the list, or `:board` swaps the tree for the board +of the space being browsed, and swaps it back. Every space has one except the +diary — a journal has no backlog — and `:set board` / `:set noboard` changes +that for the space you are in (the `+board` button above the list does the +same). A new board starts with `backlog`, `doing` and `done`; `:list <name>` +adds columns, and `o` or the `+ card` button puts a card on the one under the +cursor. | key | action | | --- | --- | diff --git a/web/src/App.svelte b/web/src/App.svelte index ab5686f..58fbf97 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -135,6 +135,9 @@ case 'o': cmdline = ':card ' return true + case 'R': + cmdline = ':name ' + return true case 't': case ' ': void workspace.guard(() => workspace.toggleDone()) @@ -267,6 +270,17 @@ event.preventDefault() void runCommand('edit') break + case 'b': + // The board is a view of the space, not a row in the list, so it needs + // a key as plain as the one that opens a document. + event.preventDefault() + void runCommand('board') + break + case 'R': + // Renaming needs a name typed, and names are typed on the command line. + event.preventDefault() + cmdline = ':name ' + break case 'h': case 'ArrowLeft': // Out of the document if one is open, otherwise up a level — the same @@ -339,6 +353,8 @@ │ │ │ o write a new document │ │ O make a folder │ + │ R rename what is marked │ + │ b this space's board │ │ j/k browse, Enter opens │ │ h up one level │ │ / search this space │ diff --git a/web/src/lib/Board.svelte b/web/src/lib/Board.svelte index 5565672..ea73db8 100644 --- a/web/src/lib/Board.svelte +++ b/web/src/lib/Board.svelte @@ -45,8 +45,8 @@ {workspace.board?.lists.reduce((n, list) => n + list.cards.length, 0) ?? 0} cards </span> <span class="tools"> - <button title="new card (o)" onclick={() => hooks.openCommandLine(':card ')}>+</button> - <button title="new list" onclick={() => hooks.openCommandLine(':list ')}>[+]</button> + <button title="new card on this list (o)" onclick={() => hooks.openCommandLine(':card ')}>+ card</button> + <button title="new list (:list <name>)" onclick={() => hooks.openCommandLine(':list ')}>+ list</button> <button title="back to the tree (:board)" onclick={() => runCommand('board')}>tree</button> <button title="command line (:)" onclick={() => hooks.openCommandLine(':')}>:</button> <button title="help (?)" onclick={() => runCommand('help')}>?</button> @@ -91,12 +91,14 @@ </button> </li> {:else} - <li class="empty faint">—</li> + <li class="empty faint">no cards — o adds one</li> {/each} </ul> </section> {:else} - <div class="empty faint">no lists — :list <name> makes one</div> + <div class="empty faint"> + no lists yet — [+] above, or :list <name>, makes a column. then o, or +, adds a card to it. + </div> {/each} </div> </div> diff --git a/web/src/lib/Help.svelte b/web/src/lib/Help.svelte index 55b8aa8..23904f2 100644 --- a/web/src/lib/Help.svelte +++ b/web/src/lib/Help.svelte @@ -10,6 +10,8 @@ ['o', 'new document here, straight into insert mode'], ['O', 'new folder here'], ['i · a', 'edit the open document'], + ['R', 'rename what is marked — document, folder or card'], + ['b', "open this space's board"], ['Esc · q', 'back out to the list'], ['/', 'full-text search, inside the current space'], ['n', 'clear the search'], @@ -31,6 +33,7 @@ ['Enter', 'open the document the card links to'], ['t · Space', 'tick the card off, or un-tick it'], ['o', 'new card on this list'], + ['R', 'rename the card'], ['x', 'delete the card (asks first)'], ['q · Esc', 'back to the tree'], ] diff --git a/web/src/lib/Sidebar.svelte b/web/src/lib/Sidebar.svelte index 60e1dd0..6f5054b 100644 --- a/web/src/lib/Sidebar.svelte +++ b/web/src/lib/Sidebar.svelte @@ -35,6 +35,13 @@ {workspace.nodes.length} {workspace.nodes.length === 1 ? 'item' : 'items'} </span> <span class="tools"> + <!-- The board is the one view with no row in the list to click, so it + needs a door of its own; a space without one is offered the switch. --> + {#if workspace.space?.has_board} + <button class="board" title="board (b)" onclick={() => runCommand('board')}>board</button> + {:else if workspace.space} + <button class="board faint" title="give this space a board (:set board)" onclick={() => runCommand('set board')}>+board</button> + {/if} <button title="new document (o)" onclick={() => runCommand('new')}>+</button> <button title="new folder (O)" onclick={() => hooks.openCommandLine(':mkdir ')}>[+]</button> <button title="search (/)" onclick={() => hooks.openCommandLine('/')}>/</button> @@ -156,6 +163,8 @@ .count { flex: 1; text-align: right; overflow: hidden; white-space: nowrap; } .tools { display: flex; gap: 2px; flex: none; } .tools button { padding: 0 6px; line-height: 18px; } + .tools .board { color: var(--accent); } + .tools .board.faint { color: var(--fg-faint); } /* Roomier tap targets where there is no keyboard. */ @media (pointer: coarse) { diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts index b341be4..630a471 100644 --- a/web/src/lib/commands.ts +++ b/web/src/lib/commands.ts @@ -442,19 +442,55 @@ export const commands: CommandSpec[] = [ }, { name: 'name', - aliases: ['title'], + aliases: ['title', 'rename'], args: '<text>', group: 'editing', - help: 'rename the open document, or the selected card on a board', + help: 'rename what is under the cursor — document, folder, or card on a board', run: async (arg) => { if (workspace.view === 'board') { if (!arg.trim()) return workspace.say('usage: :name <text>', 'error') return workspace.guard(() => workspace.patchCard({ title: arg.trim() })) } - if (!requireOpen()) return - workspace.draft.name = arg - workspace.dirty = true - await workspace.guard(() => workspace.save()) + if (!arg.trim()) return workspace.say('usage: :name <text>', 'error') + + // The open document is renamed through the draft, so an unsaved body is + // written with the new name instead of being left behind by it. + if (workspace.open) { + workspace.draft.name = arg + workspace.dirty = true + return workspace.guard(() => workspace.save()) + } + + const node = workspace.selected + if (!node) return workspace.say('nothing selected — :name! renames the space', 'error') + await workspace.guard(() => workspace.renameNode(node.id, arg)) + }, + bang: { + help: 'rename the space you are in', + run: async (arg) => { + const space = workspace.space + if (!space) return workspace.say('no space open', 'error') + if (!arg.trim()) return workspace.say('usage: :name! <text>', 'error') + await workspace.guard(() => workspace.renameNode(space.id, arg)) + }, + }, + }, + { + name: 'rmspace', + aliases: ['rms'], + args: '[name]', + group: 'tree', + help: 'delete a space and everything in it (asks first)', + run: async (arg) => { + const wanted = arg.trim().toLowerCase() + const space = wanted + ? workspace.spaces.find((s) => s.slug === wanted || s.name.toLowerCase() === wanted) + : workspace.space + if (!space) return workspace.say(`no such space: ${wanted || '(none open)'}`, 'error') + if (!confirm(`delete the space "${space.name}" and everything in it? this cannot be undone.`)) { + return + } + await workspace.guard(() => workspace.deleteSpace(space.id)) }, }, { diff --git a/web/src/lib/store.svelte.ts b/web/src/lib/store.svelte.ts index 32e4675..09b8749 100644 --- a/web/src/lib/store.svelte.ts +++ b/web/src/lib/store.svelte.ts @@ -417,6 +417,55 @@ class Workspace { this.say(removed > 1 ? `deleted ${removed} nodes` : `deleted #${id}`) } + /** + * Rename anything in the tree — a document, a folder or a space. The body + * has to ride along because the update is a whole-node write; for the node + * being edited that is the draft, which would otherwise be rolled back to + * whatever the server still holds. + */ + async renameNode(id: number, name: string) { + const current = + this.open?.id === id ? { ...this.open, body: this.draft.body } : await api.getNode(id) + const saved = await api.updateNode(id, { + name: name.trim(), + body: current.body, + created_at: current.created_at, + }) + if (this.open?.id === id) { + this.open = saved + this.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.refresh() + this.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! <name>` fills again. + */ + async deleteSpace(id: number) { + const { removed } = await api.deleteNode(id) + this.open = null + this.editing = false + this.board = null + this.view = 'tree' + this.spaces = await api.listSpaces() + const next = this.spaces.find((space) => space.slug === '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.say(`deleted the space and ${removed - 1} node(s) in it`) + } + /** Reparent the node under the cursor, for `:mv`. */ async moveNode(id: number, parentId: number) { await api.moveNode(id, parentId)