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

+15 -7
View File
@@ -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! <name>`
makes one), `:mkdir <name>` makes a folder, `:cd <name|..|/>` walks it and
`:mv <folder|..>` 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 <name>`
makes a folder, `:cd <name|..|/>` walks it and `:mv <folder|..>` moves what is
selected, and `:import` uploads a folder into the one being browsed
(`:import!` takes a `.zip` instead). `:name <text>` 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 <title>` (`: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 |
| --- | --- |
+16
View File
@@ -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 │
+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)