rename and stuff, better navigation

This commit is contained in:
nvrl committed 2026-09-19 20:46:05 +02:00
1 parent 49b2056215
commit d8ebf4b850
7 files changed
+140 -17

No files matched your search

+6 -4
View File
@@ -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 &lt;name&gt;)" 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 &lt;name&gt; makes one</div>
<div class="empty faint">
no lists yet — [+] above, or :list &lt;name&gt;, makes a column. then o, or +, adds a card to it.
</div>
{/each}
</div>
</div>
+3
View File
@@ -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'],
]
+9
View File
@@ -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) {
+42 -6
View File
@@ -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))
},
},
{
+49
View File
@@ -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)