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
@@ -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` |
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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