mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
tree: several nodes picked up and moved at once
m now adds the row under the cursor to a set of marks and steps on, so
mmm picks up three; M takes the whole folder, Esc or :unmark puts it all
back. Marks stay while the list walks, so they can be gathered from
several folders. p, :mv and Shift+digit move the whole set.
With a pointer, Ctrl- or Cmd-click marks a row and Shift-click every row
from the cursor to it. Rows drag onto a folder row, a crumb or a space
pill; a marked row carries every marked node with it.
POST /api/nodes/move takes { ids, parent_id } and moves them all or
none: every node is checked before any moves, sealing into a sealed
folder happens before the one transaction that moves them, and two of a
name get slugs of their own. The single move goes the same way.
This commit is contained in:
1 parent
b19fb21fc4
commit
6bf88e7ffd
15 files changed
+407
-111
No files matched your search
+6
-1
@@ -568,10 +568,14 @@
|
||||
else void workspace.guard(() => workspace.openSelected())
|
||||
break
|
||||
case 'm':
|
||||
// Pick a node up here, walk to a folder, put it down with `p`.
|
||||
// Pick nodes up here, walk to a folder, put them down with `p`.
|
||||
event.preventDefault()
|
||||
workspace.mark()
|
||||
break
|
||||
case 'M':
|
||||
event.preventDefault()
|
||||
workspace.markAll()
|
||||
break
|
||||
case 'p':
|
||||
event.preventDefault()
|
||||
void workspace.guard(() => workspace.drop())
|
||||
@@ -619,6 +623,7 @@
|
||||
case 'Escape':
|
||||
event.preventDefault()
|
||||
if (workspace.open) void runCommand('q')
|
||||
else workspace.unmarkAll()
|
||||
break
|
||||
case 'x':
|
||||
event.preventDefault()
|
||||
|
||||
@@ -119,6 +119,8 @@ a.button { display: inline-flex; align-items: center; justify-content: center; t
|
||||
:is(button, a.button):hover { background: var(--surface-alt); color: var(--light); border-color: var(--outline); }
|
||||
:is(button, a.button):active { transform: translateY(1px); }
|
||||
:is(button, a.button):focus-visible { background: var(--light); color: var(--ink); border-color: var(--light); outline: none; }
|
||||
/* A folder, crumb or space that rows dragged from the list would land in. */
|
||||
.carry-over { outline: 1px dashed var(--light); outline-offset: -1px; border-radius: var(--radius-s); }
|
||||
button:disabled { color: var(--muted); cursor: default; }
|
||||
|
||||
input, textarea {
|
||||
|
||||
@@ -20,10 +20,17 @@
|
||||
const list: Action[] = [
|
||||
{ label: isContainer(node) ? 'open' : 'read', run: () => workspace.guard(() => workspace.openSelected()) },
|
||||
{ label: 'rename', run: () => hooks.openCommandLine(`:name ${node.name}`) },
|
||||
workspace.marked?.id === node.id
|
||||
? { label: 'put it back — not moving', run: () => workspace.mark() }
|
||||
: { label: 'move — then open a folder and drop it there', run: () => workspace.mark() },
|
||||
workspace.isMarked(node.id)
|
||||
? { label: 'put it back — not moving', run: () => workspace.mark(node, false) }
|
||||
: { label: 'move — then open a folder and drop it there', run: () => workspace.mark(node, false) },
|
||||
]
|
||||
if (workspace.marked.length && workspace.parentId !== null && !workspace.isMarked(node.id)) {
|
||||
const count = workspace.marked.length
|
||||
list.push({
|
||||
label: `drop the ${count === 1 ? 'marked node' : `${count} marked nodes`} here`,
|
||||
run: () => workspace.guard(() => workspace.drop()),
|
||||
})
|
||||
}
|
||||
if (node.kind === 'document') {
|
||||
list.push(
|
||||
node.shared
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* that is genuinely faster to walk with a pointer than with `:cd`.
|
||||
*/
|
||||
import Clock from './Clock.svelte'
|
||||
import { into } from './carry'
|
||||
import { hooks, runCommand } from './commands'
|
||||
import { workspace } from './store.svelte'
|
||||
|
||||
@@ -26,6 +27,7 @@
|
||||
aria-current={space.id === workspace.spaceId}
|
||||
title="{space.name} ({index < 10 ? (index + 1) % 10 : ''})"
|
||||
onclick={() => workspace.guard(() => workspace.enterSpace(space.id))}
|
||||
{@attach into(space.id, `→ ${space.slug}`)}
|
||||
>
|
||||
{#if index < 10}<span class="n">{(index + 1) % 10}</span>{/if}<span class="slug">{space.slug}</span>
|
||||
</button>
|
||||
@@ -45,6 +47,7 @@
|
||||
class="crumb"
|
||||
class:here={crumbs.length === 0}
|
||||
onclick={() => workspace.spaceId !== null && workspace.guard(() => workspace.goTo(workspace.spaceId!))}
|
||||
{@attach into(workspace.spaceId, 'into ~')}
|
||||
>
|
||||
~
|
||||
</button>
|
||||
@@ -54,6 +57,7 @@
|
||||
class="crumb"
|
||||
class:here={index === crumbs.length - 1}
|
||||
onclick={() => workspace.guard(() => workspace.goTo(crumb.id))}
|
||||
{@attach into(crumb.id, `into ${crumb.slug}`)}
|
||||
>
|
||||
{crumb.slug}
|
||||
</button>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
const keys: [string, string][] = [
|
||||
['1 … 9 · 0', 'switch to that space, like a workspace'],
|
||||
['Shift 1 … 9', 'send the marked or selected node to that space'],
|
||||
['Shift 1 … 9', 'send the marked nodes, or the selected one, to that space'],
|
||||
['Tab · Shift-Tab', 'next / previous space'],
|
||||
['w', 'overview of every space — hjkl, then Enter'],
|
||||
['f', 'fullscreen the focused pane; again or Esc to leave'],
|
||||
@@ -18,7 +18,9 @@
|
||||
['O', 'new folder here'],
|
||||
['i · a', 'edit the open document'],
|
||||
['R', 'rename what is under the cursor — document, folder or card'],
|
||||
['m / p', 'pick a node up, then drop it into the folder you walk to'],
|
||||
['m / p', 'pick nodes up, then drop them all into the folder you walk to'],
|
||||
['M', 'pick up the whole folder — again puts it all back'],
|
||||
['Ctrl / Shift-click', 'pick a row up · every row up to it — drag them onto a folder'],
|
||||
['t', 'agenda — late, due this week, in hand, on every board'],
|
||||
['T', "today's diary entry, started if there is none"],
|
||||
['Ctrl-P', 'find anything, in every space'],
|
||||
|
||||
@@ -7,12 +7,15 @@
|
||||
* same doors out in a row for the bottom band on a phone, without the ones
|
||||
* that only make sense with a keyboard or two panes.
|
||||
*/
|
||||
import type { NodeSummary } from './api'
|
||||
import { hooks, runCommand } from './commands'
|
||||
import Icon, { type IconName } from './Icon.svelte'
|
||||
import { workspace } from './store.svelte'
|
||||
|
||||
const { dock = false }: { dock?: boolean } = $props()
|
||||
|
||||
const dropping = (nodes: NodeSummary[]) => (nodes.length === 1 ? nodes[0].name.trim() || nodes[0].slug : `${nodes.length} nodes`)
|
||||
|
||||
type Door = {
|
||||
icon: IconName
|
||||
label: string
|
||||
@@ -80,8 +83,8 @@
|
||||
],
|
||||
[
|
||||
{ icon: 'upload', label: 'Upload files into this folder', keys: [':upload'], run: () => hooks.pickTreeFiles(false) },
|
||||
...(workspace.marked
|
||||
? [{ icon: 'drop' as const, label: `Drop ${workspace.marked.name.trim() || workspace.marked.slug} here`, keys: ['p'], run: () => workspace.guard(() => workspace.drop()), on: true }]
|
||||
...(workspace.marked.length
|
||||
? [{ icon: 'drop' as const, label: `Drop ${dropping(workspace.marked)} here`, keys: ['p'], run: () => workspace.guard(() => workspace.drop()), on: true }]
|
||||
: []),
|
||||
],
|
||||
[
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { isContainer, type NodeSummary } from './api'
|
||||
import { carry, into, release } from './carry'
|
||||
import Icon from './Icon.svelte'
|
||||
import { workspace } from './store.svelte'
|
||||
import { formatBytes, formatDay, formatTime, formatWeekday, isTouchDevice } from './util'
|
||||
@@ -37,6 +38,13 @@
|
||||
void workspace.guard(() => workspace.putFiles(files))
|
||||
}
|
||||
|
||||
/* Ctrl or Cmd picks a row up, Shift every row from the cursor to it. */
|
||||
function click(event: MouseEvent, index: number, node: NodeSummary) {
|
||||
if (event.ctrlKey || event.metaKey) return workspace.mark(node, false)
|
||||
if (event.shiftKey) return workspace.markRange(index)
|
||||
pick(index)
|
||||
}
|
||||
|
||||
function pick(index: number) {
|
||||
workspace.cursor = index
|
||||
void workspace.guard(() => workspace.openSelected())
|
||||
@@ -99,7 +107,13 @@
|
||||
<span><span class="day">{formatDay(node.created_at)}</span> {formatWeekday(node.created_at)}</span>
|
||||
</li>
|
||||
{/if}
|
||||
<li class="item">
|
||||
<li
|
||||
class="item"
|
||||
draggable={!isTouchDevice}
|
||||
ondragstart={(event) => carry(event, node)}
|
||||
ondragend={release}
|
||||
{@attach into(isContainer(node) ? node.id : null, `into ${label(node)}`)}
|
||||
>
|
||||
<button
|
||||
class="row"
|
||||
oncontextmenu={(event) => {
|
||||
@@ -108,15 +122,16 @@
|
||||
}}
|
||||
class:active={index === workspace.cursor}
|
||||
class:open={workspace.open?.id === node.id}
|
||||
class:marked={workspace.isMarked(node.id)}
|
||||
data-index={index}
|
||||
onclick={() => pick(index)}
|
||||
onclick={(event) => click(event, index, node)}
|
||||
>
|
||||
<span class="dot"></span>
|
||||
<span class="title">
|
||||
{label(node)}{#if isContainer(node)}<span class="slash">/</span>{/if}
|
||||
{#if workspace.query && node.location}<span class="where">{node.location}</span>{/if}
|
||||
</span>
|
||||
{#if workspace.marked?.id === node.id}<span class="badge" title="marked — p drops it in a folder">m</span>{/if}
|
||||
{#if workspace.isMarked(node.id)}<span class="badge" title="marked — p drops what is marked in a folder">m</span>{/if}
|
||||
{#if node.shared}<span class="shared" title="shared"></span>{/if}
|
||||
{#if node.file?.encrypted}<span class="sealed" title="in the vault"><Icon name="lock" size={12} /></span>{/if}
|
||||
{#if node.sealed}<span class="sealed" title="sealed — files added here go into the vault"><Icon name="lock" size={12} /></span>{/if}
|
||||
@@ -214,6 +229,8 @@
|
||||
.row:hover { background: var(--surface-alt); color: var(--light); border-color: transparent; }
|
||||
.row:active { transform: none; }
|
||||
.row.open { color: var(--light); }
|
||||
/* Picked up: a rule down the left edge, which the cursor's fill still shows. */
|
||||
.row.marked { box-shadow: inset 2px 0 0 var(--light-dim); color: var(--light); }
|
||||
.row.active, .row:focus-visible { background: var(--light); color: var(--ink); }
|
||||
.row.active :is(.aside, .slash, .badge), .row:focus-visible :is(.aside, .slash, .badge) { color: var(--ink); }
|
||||
|
||||
|
||||
@@ -359,6 +359,9 @@ export const api = {
|
||||
|
||||
moveNode: (id: number, parent_id: number, position?: number) =>
|
||||
request<WorkspaceNode>(`/nodes/${id}/move`, { method: 'POST', body: body({ parent_id, position }) }),
|
||||
/** All of them into `parent_id`, or none when one cannot go. */
|
||||
moveNodes: (ids: number[], parent_id: number) =>
|
||||
request<{ moved: number; unchanged: number }>('/nodes/move', { method: 'POST', body: body({ ids, parent_id }) }),
|
||||
|
||||
/** Publishes the document, or mints a fresh key for one already published. */
|
||||
share: (id: number) =>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Rows dragged out of the list onto a folder row, a crumb or a space pill —
|
||||
* the pointer's way of `m` and `p`. A marked row carries every marked node,
|
||||
* any other row only itself. The nodes stay here; the drag carries a type of
|
||||
* its own, so files from the desktop, or text, are never taken for them.
|
||||
*/
|
||||
import type { Attachment } from 'svelte/attachments'
|
||||
import type { NodeSummary } from './api'
|
||||
import { workspace } from './store.svelte'
|
||||
|
||||
const NODES = 'application/x-workspace-nodes'
|
||||
|
||||
let carried: NodeSummary[] = []
|
||||
|
||||
/** `ondragstart` of a row. */
|
||||
export function carry(event: DragEvent, node: NodeSummary) {
|
||||
if (!event.dataTransfer) return
|
||||
carried = workspace.isMarked(node.id) ? workspace.marked : [node]
|
||||
event.dataTransfer.setData(NODES, JSON.stringify(carried.map((n) => n.id)))
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
|
||||
/** `ondragend` of a row: dropped or not, nothing is carried any more. */
|
||||
export function release() {
|
||||
carried = []
|
||||
}
|
||||
|
||||
/** Make an element a place the carried nodes can be dropped into `parent`. */
|
||||
export function into(parent: number | null, where: string): Attachment<HTMLElement> {
|
||||
return (element) => {
|
||||
if (parent === null) return
|
||||
// A folder does not take itself, or a drag that is not of rows.
|
||||
const takes = (event: DragEvent) =>
|
||||
!!event.dataTransfer?.types.includes(NODES) && carried.length > 0 && !carried.some((n) => n.id === parent)
|
||||
const over = (event: DragEvent) => {
|
||||
if (!takes(event)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.dataTransfer!.dropEffect = 'move'
|
||||
element.classList.add('carry-over')
|
||||
}
|
||||
const leave = () => element.classList.remove('carry-over')
|
||||
const drop = (event: DragEvent) => {
|
||||
leave()
|
||||
if (!takes(event)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const nodes = carried
|
||||
carried = []
|
||||
void workspace.guard(() => workspace.moveNodes(nodes, parent, where))
|
||||
}
|
||||
element.addEventListener('dragover', over)
|
||||
element.addEventListener('dragleave', leave)
|
||||
element.addEventListener('drop', drop)
|
||||
return () => {
|
||||
element.removeEventListener('dragover', over)
|
||||
element.removeEventListener('dragleave', leave)
|
||||
element.removeEventListener('drop', drop)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -337,9 +337,9 @@ export const commands: CommandSpec[] = [
|
||||
aliases: ['mv'],
|
||||
args: '<folder|..>',
|
||||
group: 'tree',
|
||||
help: 'move the selected node into a folder here, or up one level',
|
||||
help: 'move the marked nodes, or else the selected one, into a folder here, or up one level',
|
||||
run: async (arg) => {
|
||||
const node = workspace.target
|
||||
const node = workspace.marked[0] ?? workspace.target
|
||||
if (!node) return workspace.say('nothing selected', 'error')
|
||||
const target = arg.trim()
|
||||
if (!target) return workspace.say('usage: :mv <folder|..>', 'error')
|
||||
@@ -354,9 +354,16 @@ export const commands: CommandSpec[] = [
|
||||
const destination = target === '..' ? await up() : findFolder(target)?.id
|
||||
if (destination === undefined) return workspace.say(`no such folder: ${target}`, 'error')
|
||||
if (destination === node.id) return workspace.say('a folder cannot hold itself', 'error')
|
||||
if (workspace.marked.length) return workspace.guard(() => workspace.moveNodes(workspace.marked, destination))
|
||||
await workspace.guard(() => workspace.moveNode(node.id, destination))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'unmark',
|
||||
group: 'tree',
|
||||
help: 'put down everything picked up with m',
|
||||
run: () => workspace.unmarkAll(),
|
||||
},
|
||||
{
|
||||
name: 'new',
|
||||
aliases: ['n', 'o'],
|
||||
|
||||
+84
-43
@@ -89,12 +89,13 @@ class Workspace {
|
||||
returnTo = $state<'board' | null>(null)
|
||||
|
||||
/*
|
||||
* The node picked up by `m`, waiting for the `p` that drops it into whatever
|
||||
* folder the list is showing then. A move is two keystrokes apart in time
|
||||
* because the destination is somewhere else in the tree — there is no way to
|
||||
* name it and be somewhere else at once.
|
||||
* The nodes picked up by `m`, waiting for the `p` that drops them into
|
||||
* whatever folder the list is showing then. A move is two keystrokes apart
|
||||
* in time because the destination is somewhere else in the tree — there is
|
||||
* no way to name it and be somewhere else at once — so the marks stay put
|
||||
* while the list walks, and can be gathered from several folders.
|
||||
*/
|
||||
marked = $state<NodeSummary | null>(null)
|
||||
marked = $state.raw<NodeSummary[]>([])
|
||||
|
||||
pane = $state<Pane>('list')
|
||||
vimMode = $state('normal')
|
||||
@@ -758,7 +759,7 @@ class Workspace {
|
||||
this.dirty = false
|
||||
this.pane = 'list'
|
||||
}
|
||||
if (this.marked?.id === id) this.marked = null
|
||||
this.marked = this.marked.filter((n) => n.id !== id)
|
||||
await this.refresh()
|
||||
const name = node ? `"${node.name.trim() || node.slug}"` : `#${id}`
|
||||
this.say(removed > 1 ? `deleted ${name} and ${removed - 1} inside it` : `deleted ${name}`)
|
||||
@@ -820,6 +821,34 @@ class Workspace {
|
||||
this.say(`moved #${id}`)
|
||||
}
|
||||
|
||||
isMarked(id: number): boolean {
|
||||
return this.marked.some((n) => n.id === id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Move `nodes` into `parent`, all or none, and say how it went. An open one
|
||||
* is saved and closed first: it is about to be somewhere else.
|
||||
*/
|
||||
async moveNodes(nodes: NodeSummary[], parent: number, where = 'here') {
|
||||
if (nodes.length === 0) return this.say('nothing marked — press m on a row first', 'error')
|
||||
if (this.open && nodes.some((n) => n.id === this.open?.id)) {
|
||||
await this.flush()
|
||||
this.open = null
|
||||
this.editing = false
|
||||
this.pane = 'list'
|
||||
}
|
||||
const { moved, unchanged } = await api.moveNodes(
|
||||
nodes.map((n) => n.id),
|
||||
parent,
|
||||
)
|
||||
const gone = new Set(nodes.map((n) => n.id))
|
||||
this.marked = this.marked.filter((n) => !gone.has(n.id))
|
||||
await this.refresh(nodes.length === 1 ? nodes[0].id : undefined)
|
||||
const what = nodes.length === 1 ? nodes[0].name.trim() || nodes[0].slug : `${moved}`
|
||||
const already = unchanged > 0 ? ` — ${unchanged} already ${unchanged === 1 ? 'was' : 'were'}` : ''
|
||||
this.say(moved === 0 ? `already ${where}` : `moved ${what} ${where}${already}`)
|
||||
}
|
||||
|
||||
/** `1`..`9`, `0`: the space at that place on the bar. */
|
||||
async switchSpace(index: number) {
|
||||
const space = this.spaces[index]
|
||||
@@ -841,22 +870,11 @@ class Workspace {
|
||||
*/
|
||||
async sendToSpace(index: number) {
|
||||
const space = this.spaces[index]
|
||||
const node = this.marked ?? this.selected
|
||||
const nodes = this.marked.length ? this.marked : this.selected ? [this.selected] : []
|
||||
if (!space) return this.say(`no space ${index + 1}`, 'error')
|
||||
if (!node) return this.say('nothing selected', 'error')
|
||||
if (node.parent_id === space.id) return this.say(`already in ${space.slug}`)
|
||||
|
||||
if (this.open?.id === node.id) {
|
||||
await this.flush()
|
||||
this.open = null
|
||||
this.editing = false
|
||||
this.pane = 'list'
|
||||
}
|
||||
await api.moveNode(node.id, space.id)
|
||||
this.marked = null
|
||||
await this.refresh()
|
||||
if (nodes.length === 0) return this.say('nothing selected', 'error')
|
||||
await this.moveNodes(nodes, space.id, `→ ${index + 1} ${space.slug}`)
|
||||
this.spaces = await api.listSpaces()
|
||||
this.say(`moved ${node.name.trim() || node.slug} → ${index + 1} ${space.slug}`)
|
||||
}
|
||||
|
||||
async loadAgenda() {
|
||||
@@ -904,33 +922,56 @@ class Workspace {
|
||||
this.overview = await api.overview()
|
||||
}
|
||||
|
||||
/** `m`: pick the node under the cursor up, or put it back down unmoved. */
|
||||
mark() {
|
||||
const node = this.selected
|
||||
/**
|
||||
* `m`: pick the node under the cursor up, or put it back down unmoved, and
|
||||
* step to the next row — `mmm` picks up three in a row.
|
||||
*/
|
||||
mark(node = this.selected, step = true) {
|
||||
if (!node) return this.say('nothing selected', 'error')
|
||||
if (this.marked?.id === node.id) {
|
||||
this.marked = null
|
||||
return this.say('unmarked')
|
||||
}
|
||||
this.marked = node
|
||||
this.say(`marked ${node.name.trim() || node.slug} — p drops it in this folder`)
|
||||
this.marked = this.isMarked(node.id) ? this.marked.filter((n) => n.id !== node.id) : [...this.marked, node]
|
||||
if (step && node === this.selected) this.move(1)
|
||||
this.#sayMarked()
|
||||
}
|
||||
|
||||
/** `p`: drop the marked node into the folder the list is showing. */
|
||||
async drop() {
|
||||
const node = this.marked
|
||||
const parent = this.parentId
|
||||
if (!node) return this.say('nothing marked — press m on a row first', 'error')
|
||||
if (parent === null) return this.say('no folder open', 'error')
|
||||
if (node.parent_id === parent) {
|
||||
this.marked = null
|
||||
return this.say('already here')
|
||||
}
|
||||
/** Shift-click: mark every row from the cursor to `index`. */
|
||||
markRange(index: number) {
|
||||
const [from, to] = [Math.min(this.cursor, index), Math.max(this.cursor, index)]
|
||||
const rows = this.nodes.slice(from, to + 1).filter((n) => !this.isMarked(n.id))
|
||||
this.marked = [...this.marked, ...rows]
|
||||
this.cursor = index
|
||||
this.#sayMarked()
|
||||
}
|
||||
|
||||
await api.moveNode(node.id, parent)
|
||||
this.marked = null
|
||||
await this.refresh(node.id)
|
||||
this.say(`moved ${node.name.trim() || node.slug} here`)
|
||||
/** `M`: mark the whole folder, or, when it all is, none of it. */
|
||||
markAll() {
|
||||
const all = this.nodes.length > 0 && this.nodes.every((n) => this.isMarked(n.id))
|
||||
const here = new Set(this.nodes.map((n) => n.id))
|
||||
this.marked = all
|
||||
? this.marked.filter((n) => !here.has(n.id))
|
||||
: [...this.marked, ...this.nodes.filter((n) => !this.isMarked(n.id))]
|
||||
this.#sayMarked()
|
||||
}
|
||||
|
||||
unmarkAll() {
|
||||
if (this.marked.length === 0) return
|
||||
this.marked = []
|
||||
this.say('unmarked')
|
||||
}
|
||||
|
||||
#sayMarked() {
|
||||
const count = this.marked.length
|
||||
if (count === 0) return this.say('unmarked')
|
||||
const first = this.marked[0]
|
||||
const what = count === 1 ? first.name.trim() || first.slug : `${count} nodes`
|
||||
this.say(`marked ${what} — p drops ${count === 1 ? 'it' : 'them'} in the folder you are in`)
|
||||
}
|
||||
|
||||
/** `p`: drop the marked nodes into the folder the list is showing. */
|
||||
async drop() {
|
||||
const parent = this.parentId
|
||||
if (this.marked.length === 0) return this.say('nothing marked — press m on a row first', 'error')
|
||||
if (parent === null) return this.say('no folder open', 'error')
|
||||
await this.moveNodes(this.marked, parent)
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
Reference in new issue
Block a user