From 6bf88e7ffdde865dace39f68090063be8c4434e7 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 20 Oct 2026 11:13:52 +0200 Subject: [PATCH] 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. --- README.md | 6 +- src/routes/mod.rs | 1 + src/routes/nodes.rs | 182 +++++++++++++++++++++++++----------- src/routes/seal.rs | 65 +++++++++++++ web/src/App.svelte | 7 +- web/src/app.css | 2 + web/src/lib/Actions.svelte | 13 ++- web/src/lib/Bar.svelte | 4 + web/src/lib/Help.svelte | 6 +- web/src/lib/Rail.svelte | 7 +- web/src/lib/Sidebar.svelte | 23 ++++- web/src/lib/api.ts | 3 + web/src/lib/carry.ts | 61 ++++++++++++ web/src/lib/commands.ts | 11 ++- web/src/lib/store.svelte.ts | 127 ++++++++++++++++--------- 15 files changed, 407 insertions(+), 111 deletions(-) create mode 100644 web/src/lib/carry.ts diff --git a/README.md b/README.md index e857ddd..ec022db 100644 --- a/README.md +++ b/README.md @@ -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` | diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 8f17c7b..4de1c15 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -63,6 +63,7 @@ pub fn api_router(max_upload: usize) -> Router { .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)) diff --git a/src/routes/nodes.rs b/src/routes/nodes.rs index 670f332..0a456c9 100644 --- a/src/routes/nodes.rs +++ b/src/routes/nodes.rs @@ -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, } +#[derive(Deserialize)] +pub struct MoveManyInput { + pub ids: Vec, + 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, Json(input): Json, ) -> AppResult> { - 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, + Json(input): Json, +) -> AppResult> { + let mut seen = HashSet::new(); + let ids: Vec = 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, +) -> AppResult { + // 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)>> { + 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. diff --git a/src/routes/seal.rs b/src/routes/seal.rs index eb94c1a..8299b0d 100644 --- a/src/routes/seal.rs +++ b/src/routes/seal.rs @@ -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; diff --git a/web/src/App.svelte b/web/src/App.svelte index 60a7ac5..c67cb8d 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -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() diff --git a/web/src/app.css b/web/src/app.css index a30f6ac..a60293a 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -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 { diff --git a/web/src/lib/Actions.svelte b/web/src/lib/Actions.svelte index 8293f03..463f58a 100644 --- a/web/src/lib/Actions.svelte +++ b/web/src/lib/Actions.svelte @@ -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 diff --git a/web/src/lib/Bar.svelte b/web/src/lib/Bar.svelte index 7d33087..6c1e9fa 100644 --- a/web/src/lib/Bar.svelte +++ b/web/src/lib/Bar.svelte @@ -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}{(index + 1) % 10}{/if}{space.slug} @@ -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 ~')} > ~ @@ -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} diff --git a/web/src/lib/Help.svelte b/web/src/lib/Help.svelte index f315941..1036aa2 100644 --- a/web/src/lib/Help.svelte +++ b/web/src/lib/Help.svelte @@ -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'], diff --git a/web/src/lib/Rail.svelte b/web/src/lib/Rail.svelte index f5f4958..4012263 100644 --- a/web/src/lib/Rail.svelte +++ b/web/src/lib/Rail.svelte @@ -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 }] : []), ], [ diff --git a/web/src/lib/Sidebar.svelte b/web/src/lib/Sidebar.svelte index e465027..0a3bf34 100644 --- a/web/src/lib/Sidebar.svelte +++ b/web/src/lib/Sidebar.svelte @@ -1,5 +1,6 @@