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:
nvrl committed 2026-10-20 11:13:52 +02:00
1 parent b19fb21fc4
commit 6bf88e7ffd
15 files changed
+407 -111

No files matched your search

+4 -2
View File
@@ -67,7 +67,7 @@ it; in the document pane it is that document. In the browse pane:
| key | action |
| --- | --- |
| `1` … `9`, `0` | switch to that space |
| `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 |
@@ -80,7 +80,8 @@ it; in the document pane it is that document. In the browse pane:
| `O` | a new folder here |
| `i`, `a` | edit the open entry |
| `R` | rename what is highlighted — a document, a folder or a file (`:name!` renames the space) |
| `m` / `p` | pick a node up, then drop it into the folder you walk to |
| `m` / `p` | pick nodes up — `m` again puts one back, `M` takes the whole folder — then drop them all into the folder you walk to |
| `Ctrl`/`Shift`-click | pick a row up / every row from the cursor to it; drag picked-up rows onto a folder, a crumb or a space |
| `b` | the board of this space |
| `t` | the agenda: what is late, due this week, or in hand, on every board |
| `T` | today's diary entry, started if there is none |
@@ -557,6 +558,7 @@ sealed or not.
| `PUT`/`DELETE` | `/api/cards/{id}` | update (title, body, due, done, link) / delete |
| `POST` | `/api/cards/{id}/move` | to another list and place in it |
| `POST` | `/api/nodes/{id}/move` | reparent, and place among the new siblings |
| `POST` | `/api/nodes/move` | `{ ids, parent_id }`: several into one folder, all or none |
| `POST` | `/api/nodes/{id}/import` | import a folder or zip (multipart) under this node |
| `POST` | `/api/import` | import an export at the root: directories become spaces |
| `GET` | `/api/resolve?path=` | the node behind a slug path, `work/notes/monday` |
+1
View File
@@ -63,6 +63,7 @@ pub fn api_router(max_upload: usize) -> Router<AppState> {
.route("/nodes/{id}", get(nodes::get))
.route("/nodes/{id}", put(nodes::update))
.route("/nodes/{id}", delete(nodes::remove))
.route("/nodes/move", post(nodes::move_many))
.route("/nodes/{id}/move", post(nodes::move_node))
.route("/import", post(import::import_root).layer(uploads))
.route("/nodes/{id}/import", post(import::import).layer(uploads))
+129 -53
View File
@@ -1,4 +1,4 @@
use std::collections::BTreeSet;
use std::collections::{BTreeSet, HashSet};
use axum::{
extract::{Path, Query, State},
@@ -171,6 +171,23 @@ pub struct MoveInput {
pub position: Option<i64>,
}
#[derive(Deserialize)]
pub struct MoveManyInput {
pub ids: Vec<i64>,
pub parent_id: i64,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct Moved {
pub moved: usize,
/// Already in the folder they were sent to.
pub unchanged: usize,
}
/// How many nodes one move takes: a folder's worth picked up by hand, with
/// room to spare, and still one short transaction.
const MOVE_AT_ONCE: usize = 1000;
/// How much of a body the list query reads. Comfortably more than an excerpt
/// needs, and a rounding error next to documents that run to thousands of words.
const HEAD_CHARS: usize = 1024;
@@ -763,68 +780,127 @@ pub async fn move_node(
Path(id): Path<i64>,
Json(input): Json<MoveInput>,
) -> AppResult<Json<Node>> {
if input.parent_id == id {
return Err(AppError::BadRequest("a node cannot contain itself".into()));
move_nodes(&state, &[id], input.parent_id, input.position).await?;
load(&state, id).await.map(Json)
}
/// Move several nodes into one folder: all of them, or — when one of them
/// cannot go there — none.
pub async fn move_many(
_: Session,
State(state): State<AppState>,
Json(input): Json<MoveManyInput>,
) -> AppResult<Json<Moved>> {
let mut seen = HashSet::new();
let ids: Vec<i64> = input
.ids
.into_iter()
.filter(|id| seen.insert(*id))
.collect();
if ids.is_empty() {
return Err(AppError::BadRequest("there is nothing to move".into()));
}
// Into a sealed folder, the files go sealed or not at all — and only
// into a place it may go at all, which the transaction checks again.
if ids.len() > MOVE_AT_ONCE {
return Err(AppError::BadRequest(format!(
"{MOVE_AT_ONCE} nodes at most move at once"
)));
}
move_nodes(&state, &ids, input.parent_id, None)
.await
.map(Json)
}
/// Put `ids` under `parent`, in that order. Every node is checked before any
/// moves; into a sealed folder they go sealed, or nothing goes.
pub async fn move_nodes(
state: &AppState,
ids: &[i64],
parent: i64,
position: Option<i64>,
) -> AppResult<Moved> {
// Checked before sealing, so that nothing is sealed for a move that is
// refused anyway, and again in the transaction that moves them.
{
let mut conn = state.db.acquire().await?;
container(&mut conn, input.parent_id).await?;
if descendants(&mut conn, id).await?.contains(&input.parent_id) {
return Err(AppError::BadRequest(
"a folder cannot be moved into itself".into(),
));
}
movable(&mut conn, ids, parent).await?;
}
for &id in ids {
crate::routes::seal::enter(state, id, parent).await?;
}
crate::routes::seal::enter(&state, id, input.parent_id).await?;
let mut tx = crate::db::begin_write(&state.db).await?;
let row = sqlx::query("SELECT kind, name FROM nodes WHERE id = ?1")
let nodes = movable(&mut tx, ids, parent).await?;
let mut done = Moved {
moved: 0,
unchanged: 0,
};
for (id, name, from) in nodes {
if from == Some(parent) && position.is_none() {
done.unchanged += 1;
continue;
}
// The slug has to be free where it lands, not where it came from —
// and it sees the ones moved in before it, so two `notes` stay apart.
let slug = unique_slug(&mut tx, Some(parent), &slugify(&name), Some(id)).await?;
sqlx::query(
"UPDATE nodes
SET parent_id = ?1, slug = ?2, position = COALESCE(?3, position), updated_at = ?4
WHERE id = ?5",
)
.bind(parent)
.bind(&slug)
.bind(position)
.bind(crate::now())
.bind(id)
.fetch_optional(&mut *tx)
.await?
.ok_or(AppError::NotFound)?;
let kind: String = row.get("kind");
let name: String = row.get("name");
// A space is a root: under a folder it would drop out of the switcher
// while its board and its cards carried on as if nothing had happened.
if kind == SPACE {
return Err(AppError::BadRequest(
"a space cannot be moved into anything".into(),
));
.execute(&mut *tx)
.await?;
done.moved += 1;
}
container(&mut tx, input.parent_id).await?;
// Moving a folder into its own subtree would cut that subtree loose from
// every root, where nothing could reach it again.
if descendants(&mut tx, id).await?.contains(&input.parent_id) {
return Err(AppError::BadRequest(
"a folder cannot be moved into itself".into(),
));
}
// The slug has to be free where it lands, not where it came from.
let slug = unique_slug(&mut tx, Some(input.parent_id), &slugify(&name), Some(id)).await?;
sqlx::query(
"UPDATE nodes
SET parent_id = ?1, slug = ?2, position = COALESCE(?3, position), updated_at = ?4
WHERE id = ?5",
)
.bind(input.parent_id)
.bind(&slug)
.bind(input.position)
.bind(crate::now())
.bind(id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
state.backup.signal();
load(&state, id).await.map(Json)
if done.moved > 0 {
state.backup.signal();
}
Ok(done)
}
/// Refuse the move unless every one of `ids` may go into `parent`; the id,
/// name and present parent of each when they all may.
async fn movable(
db: &mut SqliteConnection,
ids: &[i64],
parent: i64,
) -> AppResult<Vec<(i64, String, Option<i64>)>> {
container(&mut *db, parent).await?;
let mut nodes = Vec::with_capacity(ids.len());
for &id in ids {
if id == parent {
return Err(AppError::BadRequest("a node cannot contain itself".into()));
}
let row = sqlx::query("SELECT kind, name, parent_id FROM nodes WHERE id = ?1")
.bind(id)
.fetch_optional(&mut *db)
.await?
.ok_or(AppError::NotFound)?;
let kind: String = row.get("kind");
let name: String = row.get("name");
// A space is a root: under a folder it would drop out of the switcher
// while its board and its cards carried on as if nothing had happened.
if kind == SPACE {
return Err(AppError::BadRequest(format!(
"{name} is a space, and a space cannot be moved into anything"
)));
}
// Moving a folder into its own subtree would cut that subtree loose
// from every root, where nothing could reach it again.
if descendants(&mut *db, id).await?.contains(&parent) {
return Err(AppError::BadRequest(format!(
"{name} cannot be moved into itself"
)));
}
nodes.push((id, name, row.get("parent_id")));
}
Ok(nodes)
}
/// Refuse anything but a space or a folder as the place a node goes into.
+65
View File
@@ -508,6 +508,71 @@ mod tests {
enter(&w.state, doc, w.folder).await.unwrap();
}
#[tokio::test]
async fn several_move_together_or_not_at_all() {
use crate::routes::nodes::{move_nodes, Moved};
let w = world().await;
let from = node(&w.state, Some(w.space), "folder", "from").await;
let a = plain_file(&w.state, from, "notes.txt").await.unwrap();
let b = node(&w.state, Some(w.space), "folder", "inner").await;
let c = plain_file(&w.state, b, "notes.txt").await.unwrap();
let slug = |id: i64| {
sqlx::query_scalar::<_, String>("SELECT slug FROM nodes WHERE id = ?1")
.bind(id)
.fetch_one(&w.state.db)
};
let parent = |id: i64| {
sqlx::query_scalar::<_, i64>("SELECT parent_id FROM nodes WHERE id = ?1")
.bind(id)
.fetch_one(&w.state.db)
};
// A folder among them that would go into itself stops all of them.
let refused = move_nodes(&w.state, &[a, b], b, None).await.unwrap_err();
assert!(matches!(&refused, AppError::BadRequest(m) if m.contains("itself")));
assert_eq!(parent(a).await.unwrap(), from);
// So does a space.
assert!(move_nodes(&w.state, &[a, w.space], w.folder, None)
.await
.is_err());
assert_eq!(parent(a).await.unwrap(), from);
// Two of a name land side by side, and what is already there stays.
let done = move_nodes(&w.state, &[a, c, from], w.space, None).await;
assert_eq!(
done.unwrap(),
Moved {
moved: 2,
unchanged: 1
}
);
assert_eq!(parent(c).await.unwrap(), w.space);
assert_ne!(slug(a).await.unwrap(), slug(c).await.unwrap());
// Into a sealed folder with the vault shut, none goes.
seal_node(&w.state, w.folder).await.unwrap();
w.state.vault.lock();
assert!(matches!(
move_nodes(&w.state, &[a, c], w.folder, None).await,
Err(AppError::Locked)
));
assert_eq!(parent(a).await.unwrap(), w.space);
// With it open, they all go, and sealed.
w.state.vault.open_with(Zeroizing::new([7u8; 32]));
let done = move_nodes(&w.state, &[a, c, b], w.folder, None).await;
assert_eq!(
done.unwrap(),
Moved {
moved: 3,
unchanged: 0
}
);
assert!(encrypted(&w.state, a).await && encrypted(&w.state, c).await);
assert!(sealed(&w.state, b).await.unwrap());
}
#[tokio::test]
async fn only_the_sealed_folder_itself_opens() {
let w = world().await;
+6 -1
View File
@@ -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()
+2
View File
@@ -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 {
+10 -3
View File
@@ -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
+4
View File
@@ -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 -2
View File
@@ -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'],
+5 -2
View File
@@ -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 }]
: []),
],
[
+20 -3
View File
@@ -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); }
+3
View File
@@ -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) =>
+61
View File
@@ -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)
}
}
}
+9 -2
View File
@@ -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
View File
@@ -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)
}
/*