mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
rename and stuff, better navigation
This commit is contained in:
1 parent
49b2056215
commit
d8ebf4b850
7 files changed
+138
-15
No files matched your search
@@ -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 |
|
||||
| --- | --- |
|
||||
|
||||
@@ -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 │
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'],
|
||||
]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+40
-4
@@ -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
|
||||
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
|
||||
await workspace.guard(() => workspace.save())
|
||||
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))
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user