History

Add drag-and-drop to move files and folders in the sidebar

Drag items in the Everything tree to re-parent them: drop on a folder to move inside it, on a file to move into that file's folder, or on empty space to move to the workspace root. A Cmd/Shift multi-selection moves as one batch. Open tabs, pinned files, and expanded-folder state follow the move, and name collisions are reported instead of overwriting. Implemented with pointer events (not HTML5 DnD) so it coexists with the existing Finder-drop-to-open window handler, which otherwise suppresses HTML5 drag events in the webview. No Rust changes -- rename_entry is the move primitive and the watcher syncs both affected directories. Selection is settled on pointer-down (mousedown model) so drag and click never act on a stale selection; inline rename and drag-move share one write path (use-move-entry). The drop target highlights as a single container block, and the ghost is a 1:1 copy of the grabbed row. See SPECs/sidebar-drag-and-drop-move-spec.md.

Joel committed 4 months ago

Commit 2355b691 parent 4011bf514 files · +1016 −132Browse files

Showing the changes to SPECs (1 of 14 files). Show all

Unified · Split

SPECs/sidebar-drag-and-drop-move-spec.md+94−0
@@ -0,0 +1,94 @@
1+# Sidebar Drag-and-Drop Move Spec
2+
3+## Summary
4+
5+Let users move files and folders by dragging them within the sidebar's `Everything`
6+file tree. Dropping an item onto a folder re-parents it into that folder; dropping onto a
7+file targets the file's parent folder; dropping on empty tree space moves to the workspace
8+root. The tree stays auto-sorted — this is re-parenting only, not manual ordering.
9+
10+## Goals
11+
12+- Drag a file or folder in the `Everything` tree and drop it into another folder to move it.
13+- Forgiving drop targets: folder → into it, file → into its parent, empty space → root.
14+- Support dragging a multi-selection (Cmd/Shift-click) as one batch.
15+- Keep open tabs, pinned files, and expanded-folder state correct after a move.
16+- Reuse one write path for both inline rename and drag-move.
17+
18+## Non-Goals
19+
20+- Manual reordering / custom sort order within a folder.
21+- Dragging files in from / out to Finder (OS drag-drop). The existing Finder-drop-to-open
22+ behavior is preserved untouched.
23+- Dragging from the `Pinned` / `Recents` sections (those rows are not drag sources).
24+- Spring-loaded auto-expand of collapsed folders on hover (possible follow-up).
25+
26+## Why pointer events, not HTML5 drag-and-drop
27+
28+The Tauri window enables OS-level drag-drop (default) and uses it in `lib.rs`
29+(`WindowEvent::DragDrop`) to open files dropped from Finder. While that handler is active,
30+the WKWebView suppresses HTML5 `dragstart`/`dragover`/`drop` DOM events. Disabling it
31+(`dragDropEnabled: false`) is the only way to make HTML5 DnD fire, but that would regress
32+the Finder-drop-to-open feature. So drag is implemented with raw pointer events
33+(`pointerdown`/`pointermove`/`pointerup`), which are unaffected by the OS drag-drop config.
34+
35+## Interaction
36+
37+1. `pointerdown` settles selection up front (the mousedown-selection model): Shift/Cmd adjust
38+ the selection and never drag; a plain press on a row outside the current multi-selection
39+ discards that selection immediately. It then decides what a drag would carry — the whole
40+ multi-selection if the pressed row belongs to it, otherwise just that row. A plain click
41+ (no drag — drags suppress the click) opens/toggles the row. Settling selection at press
42+ time keeps drag and click from ever acting on a stale selection.
43+2. After the pointer moves past a 4px threshold the drag begins: a floating 1:1 copy of the
44+ grabbed row (same selected fill, size, indent, icon, and label) follows the cursor, plus a
45+ small count badge when several items are dragged; the dragged rows dim in place.
46+3. On move, the folder under the pointer is resolved to a destination and its "container"
47+ is highlighted with a soft background block — the destination folder row plus its visible
48+ descendants when expanded (the whole tree for the root), measured into one rounded
49+ rectangle rather than per-row outlines. The tree auto-scrolls near its top/bottom edge.
50+4. On `pointerup`, each dragged item is moved into the destination; the trailing click is
51+ suppressed so a drag never also opens a file. The destination folder is expanded so the
52+ moved items are visible.
53+
54+## Validation
55+
56+- A move is rejected (no highlight, no-op) when the destination is the item's current
57+ parent, or when a folder is dropped onto itself or one of its descendants.
58+- Name collisions are detected with `file_exists` before moving; a colliding item is
59+ skipped. After a batch, a single alert summarizes any skipped/failed items.
60+
61+## Implementation
62+
63+- `tree-move.ts` — pure helpers: `resolveDropDir`, `canMoveInto`, `computeMovePath`, and
64+ `resolveDropRange` (the destination folder's contiguous visible-descendant run, used to
65+ measure the highlight block) — unit-tested in `tests/tree-move.test.ts`.
66+- `use-move-entry.ts` — the single write path. `applyPathChange(entry, newPath)` renames on
67+ disk via `rename_entry`, rewrites editor/workspace state (open files, pins, and—for
68+ folders—expanded dirs), and refreshes both affected directories. `moveEntry(entry, destDir)`
69+ guards and collision-checks, then calls it. Inline rename in `file-tree.tsx` and
70+ `sidebar-navigator.tsx` now funnel through `applyPathChange`, removing the previously
71+ duplicated rename tail.
72+- `use-tree-drag.ts` — pointer-drag state machine: `beginDrag(event, entries, primary)` arms a
73+ drag for an already-decided set, then threshold, ghost positioning, row-geometry drop
74+ hit-testing (`rowPathAtY`), auto-scroll, click suppression, batch drop, and a layout effect
75+ that measures the drop-target container range into a `dropHighlight` rect. It owns drag only
76+ — selection lives in `file-tree.tsx` and is settled on pointer-down, so the two no longer
77+ coordinate through a shared ref at drag-start.
78+- During a drag, `body.tree-dragging` forces the grabbing cursor and sets `pointer-events: none`
79+ on the rows (`[data-tree-path]`). That keeps WebKit from leaving rows stuck in `:hover` as the
80+ cursor passes over them mid-drag; because the rows are then non-hit-testable, drop targeting
81+ uses row geometry (`rowPathAtY`) rather than `elementFromPoint`.
82+- `drag-ghost.tsx` — the cursor-following 1:1 copy of the grabbed row (selected fill, size,
83+ indent, icon, label) plus a multi-item count badge; `file-tree-icons.tsx` holds the shared
84+ file/folder glyphs used by both tree rows and the ghost.
85+- `file-tree.tsx` / `file-tree-node.tsx` — wire the container ref, per-row `onPointerDown`,
86+ the `isDragging` dim, and the single drop-highlight block rendered behind the rows. Drag
87+ props are only passed by `FileTree`, so `Pinned`/`Recents` rows stay non-draggable.
88+
89+## Backend
90+
91+No Rust changes. `rename_entry` (`std::fs::rename`) is the move primitive, and the watcher
92+emits `fs:directory-changed` for both the source and destination directories, keeping other
93+windows and external state in sync. The immediate `refreshDirectory` calls make the active
94+window update instantly.