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
+140
-17
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 document here, straight into insert mode |
|
||||||
| `O` | a new folder here |
|
| `O` | a new folder here |
|
||||||
| `i`, `a` | edit the open entry |
|
| `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 |
|
| `Esc`, `q` | back out to the document |
|
||||||
| `h` | up one level in the tree |
|
| `h` | up one level in the tree |
|
||||||
| `/` | full-text search (`n` clears it) |
|
| `/` | 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`,
|
`:w`, `:wq`, `:q!`, `:new [yyyy-mm-dd]`, `:date`, `:name`, `:share`, `:link`,
|
||||||
`:upload`, `:media`, `:search`, `:backup`, `:set theme=mocha|green|amber|ice` and
|
`:upload`, `:media`, `:search`, `:backup`, `:set theme=mocha|green|amber|ice` and
|
||||||
`:set novim`. The tree has its own: `:space [name]` switches (`:space! <name>`
|
`:set novim`. The tree has its own: `:space [name]` switches (`:space! <name>`
|
||||||
makes one), `:mkdir <name>` makes a folder, `:cd <name|..|/>` walks it and
|
makes one, `:rmspace [name]` deletes one and everything in it), `:mkdir <name>`
|
||||||
`:mv <folder|..>` moves what is selected, and `:import` uploads a folder into
|
makes a folder, `:cd <name|..|/>` walks it and `:mv <folder|..>` moves what is
|
||||||
the one being browsed (`:import!` takes a `.zip` instead). The board has
|
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),
|
`:board`, `:card <title>` (`:card!` links the open document to it),
|
||||||
`:due <yyyy-mm-dd|->`, `:done` and `:list <name>` (`:list!` deletes one).
|
`: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
|
## Boards
|
||||||
|
|
||||||
`:board` swaps the tree for the board of the space being browsed, and swaps it
|
`b`, the `board` button above the list, or `:board` swaps the tree for the board
|
||||||
back. Every space has one except the diary — a journal has no backlog — and
|
of the space being browsed, and swaps it back. Every space has one except the
|
||||||
`:set board` / `:set noboard` changes that for the space you are in. A new
|
diary — a journal has no backlog — and `:set board` / `:set noboard` changes
|
||||||
board starts with `backlog`, `doing` and `done`; `:list <name>` adds columns.
|
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 |
|
| key | action |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
|||||||
@@ -135,6 +135,9 @@
|
|||||||
case 'o':
|
case 'o':
|
||||||
cmdline = ':card '
|
cmdline = ':card '
|
||||||
return true
|
return true
|
||||||
|
case 'R':
|
||||||
|
cmdline = ':name '
|
||||||
|
return true
|
||||||
case 't':
|
case 't':
|
||||||
case ' ':
|
case ' ':
|
||||||
void workspace.guard(() => workspace.toggleDone())
|
void workspace.guard(() => workspace.toggleDone())
|
||||||
@@ -267,6 +270,17 @@
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
void runCommand('edit')
|
void runCommand('edit')
|
||||||
break
|
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 'h':
|
||||||
case 'ArrowLeft':
|
case 'ArrowLeft':
|
||||||
// Out of the document if one is open, otherwise up a level — the same
|
// Out of the document if one is open, otherwise up a level — the same
|
||||||
@@ -339,6 +353,8 @@
|
|||||||
│ │
|
│ │
|
||||||
│ o write a new document │
|
│ o write a new document │
|
||||||
│ O make a folder │
|
│ O make a folder │
|
||||||
|
│ R rename what is marked │
|
||||||
|
│ b this space's board │
|
||||||
│ j/k browse, Enter opens │
|
│ j/k browse, Enter opens │
|
||||||
│ h up one level │
|
│ h up one level │
|
||||||
│ / search this space │
|
│ / search this space │
|
||||||
|
|||||||
@@ -45,8 +45,8 @@
|
|||||||
{workspace.board?.lists.reduce((n, list) => n + list.cards.length, 0) ?? 0} cards
|
{workspace.board?.lists.reduce((n, list) => n + list.cards.length, 0) ?? 0} cards
|
||||||
</span>
|
</span>
|
||||||
<span class="tools">
|
<span class="tools">
|
||||||
<button title="new card (o)" onclick={() => hooks.openCommandLine(':card ')}>+</button>
|
<button title="new card on this list (o)" onclick={() => hooks.openCommandLine(':card ')}>+ card</button>
|
||||||
<button title="new list" onclick={() => hooks.openCommandLine(':list ')}>[+]</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="back to the tree (:board)" onclick={() => runCommand('board')}>tree</button>
|
||||||
<button title="command line (:)" onclick={() => hooks.openCommandLine(':')}>:</button>
|
<button title="command line (:)" onclick={() => hooks.openCommandLine(':')}>:</button>
|
||||||
<button title="help (?)" onclick={() => runCommand('help')}>?</button>
|
<button title="help (?)" onclick={() => runCommand('help')}>?</button>
|
||||||
@@ -91,12 +91,14 @@
|
|||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{:else}
|
{:else}
|
||||||
<li class="empty faint">—</li>
|
<li class="empty faint">no cards — o adds one</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
{:else}
|
{: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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
['o', 'new document here, straight into insert mode'],
|
['o', 'new document here, straight into insert mode'],
|
||||||
['O', 'new folder here'],
|
['O', 'new folder here'],
|
||||||
['i · a', 'edit the open document'],
|
['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'],
|
['Esc · q', 'back out to the list'],
|
||||||
['/', 'full-text search, inside the current space'],
|
['/', 'full-text search, inside the current space'],
|
||||||
['n', 'clear the search'],
|
['n', 'clear the search'],
|
||||||
@@ -31,6 +33,7 @@
|
|||||||
['Enter', 'open the document the card links to'],
|
['Enter', 'open the document the card links to'],
|
||||||
['t · Space', 'tick the card off, or un-tick it'],
|
['t · Space', 'tick the card off, or un-tick it'],
|
||||||
['o', 'new card on this list'],
|
['o', 'new card on this list'],
|
||||||
|
['R', 'rename the card'],
|
||||||
['x', 'delete the card (asks first)'],
|
['x', 'delete the card (asks first)'],
|
||||||
['q · Esc', 'back to the tree'],
|
['q · Esc', 'back to the tree'],
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -35,6 +35,13 @@
|
|||||||
{workspace.nodes.length} {workspace.nodes.length === 1 ? 'item' : 'items'}
|
{workspace.nodes.length} {workspace.nodes.length === 1 ? 'item' : 'items'}
|
||||||
</span>
|
</span>
|
||||||
<span class="tools">
|
<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 document (o)" onclick={() => runCommand('new')}>+</button>
|
||||||
<button title="new folder (O)" onclick={() => hooks.openCommandLine(':mkdir ')}>[+]</button>
|
<button title="new folder (O)" onclick={() => hooks.openCommandLine(':mkdir ')}>[+]</button>
|
||||||
<button title="search (/)" onclick={() => hooks.openCommandLine('/')}>/</button>
|
<button title="search (/)" onclick={() => hooks.openCommandLine('/')}>/</button>
|
||||||
@@ -156,6 +163,8 @@
|
|||||||
.count { flex: 1; text-align: right; overflow: hidden; white-space: nowrap; }
|
.count { flex: 1; text-align: right; overflow: hidden; white-space: nowrap; }
|
||||||
.tools { display: flex; gap: 2px; flex: none; }
|
.tools { display: flex; gap: 2px; flex: none; }
|
||||||
.tools button { padding: 0 6px; line-height: 18px; }
|
.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. */
|
/* Roomier tap targets where there is no keyboard. */
|
||||||
@media (pointer: coarse) {
|
@media (pointer: coarse) {
|
||||||
|
|||||||
+42
-6
@@ -442,19 +442,55 @@ export const commands: CommandSpec[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'name',
|
name: 'name',
|
||||||
aliases: ['title'],
|
aliases: ['title', 'rename'],
|
||||||
args: '<text>',
|
args: '<text>',
|
||||||
group: 'editing',
|
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) => {
|
run: async (arg) => {
|
||||||
if (workspace.view === 'board') {
|
if (workspace.view === 'board') {
|
||||||
if (!arg.trim()) return workspace.say('usage: :name <text>', 'error')
|
if (!arg.trim()) return workspace.say('usage: :name <text>', 'error')
|
||||||
return workspace.guard(() => workspace.patchCard({ title: arg.trim() }))
|
return workspace.guard(() => workspace.patchCard({ title: arg.trim() }))
|
||||||
}
|
}
|
||||||
if (!requireOpen()) return
|
if (!arg.trim()) return workspace.say('usage: :name <text>', 'error')
|
||||||
workspace.draft.name = arg
|
|
||||||
workspace.dirty = true
|
// The open document is renamed through the draft, so an unsaved body is
|
||||||
await workspace.guard(() => workspace.save())
|
// 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))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -417,6 +417,55 @@ class Workspace {
|
|||||||
this.say(removed > 1 ? `deleted ${removed} nodes` : `deleted #${id}`)
|
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`. */
|
/** Reparent the node under the cursor, for `:mv`. */
|
||||||
async moveNode(id: number, parentId: number) {
|
async moveNode(id: number, parentId: number) {
|
||||||
await api.moveNode(id, parentId)
|
await api.moveNode(id, parentId)
|
||||||
|
|||||||
Reference in new issue
Block a user