Skip to content

Commit 9e54719

Browse files
committed
Drag out: completion toasts + signs-of-life affordance (M3)
- One toast per drag SESSION (not per file): a signs-of-life in-progress toast when the first fulfillment begins ("Downloading 3 items…"), replaced in place by a completion toast when the session drains. Finder shows nothing while a promise downloads, so this is Cmdr's feedback surface. - Success wording reuses the shared `composeTransferCompleteToast` selection-split ("Copied 2 files and 1 folder."); failures name the file(s) and complement Finder's own NSError alert rather than duplicating it. - BE: `native_drag/session_summary.rs` folds per-item outcomes (file/folder/failure) into the toast counts; `promises.rs` records each fulfillment's outcome on the serial queue and emits `drag-out-session-started` / `drag-out-session-complete` at the session lifecycle points (plain Tauri events, FE-mirrored payloads — same pattern as the downloads watcher). `fulfillment.rs` now returns the resolved kind. - FE: `drag-out-event-bridge.ts` listens to both events and shows the toast; `drag-out-toast.ts` is the pure composer. Both TDD-tested. - Affordance is no-user-cancel in v1 (Finder owns the gesture); honest because it reflects real in-flight work, not a guessed size threshold. - Docs: drag/CLAUDE.md now describes the shipped feature (was M1 "clean no-op"); native_drag docs gain the toast section; virtual-mtp.md notes the device is the drag-out test rig; plan's open decisions marked resolved. - CHANGELOG: user-facing "drag files and folders from your phone or NAS straight to Finder".
1 parent c97a032 commit 9e54719

14 files changed

Lines changed: 792 additions & 61 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ The format is based on [keep a changelog](https://keepachangelog.com/en/1.1.0/),
1818
[07877792](https://github.com/vdavid/cmdr/commit/07877792))
1919
- Block ejecting a volume while a copy, move, or delete is touching it
2020
([fe2a0987](https://github.com/vdavid/cmdr/commit/fe2a0987))
21+
- Drag files and folders from your phone or NAS straight to Finder or the Desktop. Cmdr downloads them on drop, under
22+
the name Finder picks, the same gesture Photos and Mail already use. Multi-select and whole folders work, and a toast
23+
keeps you posted while the download runs
2124

2225
### Changed
2326

@@ -41,8 +44,8 @@ The format is based on [keep a changelog](https://keepachangelog.com/en/1.1.0/),
4144
### Fixed
4245

4346
- Dragging a file from a phone or network pane out to Finder, the Desktop, or a terminal no longer drops a junk
44-
`.textClipping` file (or pastes a meaningless path). The drag just does nothing outside Cmdr for now, and still works
45-
as before inside Cmdr
47+
`.textClipping` file or pastes a meaningless path. Finder and the Desktop now download the real file (see Added);
48+
terminals and apps that can't take the download get a clean no-op instead of garbage
4649
- Resolve conflicts file by file inside a folder merge on network and phone drives (SMB, MTP, and cross-volume). A newer
4750
file deep in the tree no longer loses to an older one behind a single folder-level OK; each clashing file follows your
4851
conflict choice ([6e305a47](https://github.com/vdavid/cmdr/commit/6e305a47))

apps/desktop/src-tauri/src/native_drag/CLAUDE.md

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ The whole module is `#[cfg(target_os = "macos")]`.
1515
| `type_plan.rs` | Pure, locality-aware pasteboard composition (`plan_pasteboard_items`). Local = file-url + text + filenames; virtual = empty (the textClipping fix). Unit-tested policy. |
1616
| `source.rs` | `CmdrDragSource` (`define_class!`, `MainThreadOnly`): the `NSDraggingSource`. Returns the permissive operation mask and, on `draggingSession:endedAtPoint:operation:`, tells the promise machinery the gesture ended so a virtual session's objects can be freed. |
1717
| `promises.rs` | The file-promise providers + delegate (`CmdrPromiseDelegate`), the shared serial queue, the session-lifetime storage, and the `NSError` mapping. |
18-
| `fulfillment.rs` | The plain-Rust fulfillment service: downloads a virtual file to the Finder-chosen destination. NO AppKit; unit-testable. |
18+
| `fulfillment.rs` | The plain-Rust fulfillment service: downloads a virtual file to the Finder-chosen destination. NO AppKit; unit-testable. Returns a `FulfillOutcome { is_dir }` so the session summary can split the completion toast by kind. |
19+
| `session_summary.rs` | Pure per-session outcome accounting (`ItemOutcome`, `SessionSummary`, `summarize`). Folds per-item outcomes into the top-level file/folder/failure counts the completion toast reads. NO AppKit, NO Tauri; unit-tested in isolation. |
1920
| `uti.rs` | Pure filename-extension → UTI mapping for promise providers (`public.jpeg`, …, fallback `public.data`; folders `public.folder`). |
2021

2122
## How drag-out works
@@ -106,6 +107,31 @@ runtime and the source `VolumeReadStream` (MTP's `Drop` cancels the USB transfer
106107
producer); a device disconnect surfaces as a `next_chunk` read error. Either way the cleanup contract
107108
removes the partial. No explicit teardown hook is needed beyond the existing runtime shutdown.
108109

110+
### Completion toasts (M3)
111+
112+
Finder shows nothing while a promise downloads, so Cmdr is the only feedback surface. The session storage emits two
113+
plain Tauri events (FE-mirrored payloads, same pattern as the downloads watcher's `download-detected` — no specta
114+
binding), turned into ONE toast per drag SESSION by `lib/file-explorer/drag/drag-out-event-bridge.ts`:
115+
116+
- **`drag-out-session-started`** — emitted by `enter_fulfillment` the FIRST time a session's fulfillment begins (Finder
117+
asked). Carries `total_items`. This is the **signs-of-life affordance**: the FE raises a neutral `default`-level
118+
persistent in-progress toast ("Downloading 3 items…") within ~1 s, so a multi-GB / slow MTP drag doesn't feel hung. No Cancel button — v1 stays no-user-cancel (Finder owns the gesture; see the plan's Scope). The trigger is
119+
fulfillment-start, not drag-start, because a drag the user drops back into Cmdr never fulfills and must show nothing.
120+
- **`drag-out-session-complete`** — emitted when the session DRAINS (gesture ended AND `in_flight == 0`), carrying the
121+
folded `SessionSummary` (top-level `files_succeeded` / `folders_succeeded` / `failures` leaf names). The FE replaces
122+
the in-progress toast in place (same `drag-out:<sessionKey>` id) with the completion toast: success counts via the
123+
shared `composeTransferCompleteToast` ("Copied 2 files and 1 folder."), or a failure toast naming the file(s). A clean
124+
no-op session (dropped on a non-Finder target, nothing ever fulfilled) summarizes to empty and emits NO event — no
125+
toast.
126+
127+
**Counts are top-level dragged items**, consistent with the selection-split contract: one dragged folder counts as one
128+
folder regardless of how many files land inside it. The delegate records each item's `ItemOutcome` (success + `is_dir`,
129+
or failure + leaf) on the queue thread via `leave_fulfillment`; the drain point folds them with `session_summary::summarize`.
130+
131+
**Failure complements Finder, not duplicates it.** Finder shows its own NSError alert per failed item (see NSError
132+
mapping below). Our failure toast names the file and leans on Finder for the technical detail (the friendly copy already
133+
rode the `FriendlyError` pipeline). Mirrors the transfer-failure pattern.
134+
109135
## NSError mapping
110136

111137
A `FulfillError` carries a rendered `FriendlyError`. The delegate maps it to an `NSError` in domain
@@ -121,8 +147,11 @@ quiet; a real failure uses code 1 and shows the title.
121147
cleanup, and the busy-volume seam (drive a blocking stream, assert busy during + released after).
122148
- `uti.rs`: extension → UTI mapping units.
123149
- `promises.rs`: delegate smoke (construct a provider, `fileNameForType` returns the leaf), NSError
124-
domain/title/code mapping, and the COUNTERS session-lifetime state machine. The AppKit-touching
125-
tests guard on `MainThreadMarker::new()` and skip off-main (nextest runs tests on worker threads).
150+
domain/title/code mapping, and the COUNTERS session-lifetime state machine (incl. outcome
151+
accumulation across in-flight fulfillments). The AppKit-touching tests guard on
152+
`MainThreadMarker::new()` and skip off-main (nextest runs tests on worker threads).
153+
- `session_summary.rs`: the pure outcome fold (empty/no-toast, single file, mixed file+folder split,
154+
failures recording leaf names, all-failed still surfaces a toast).
126155
- `type_plan.rs`: the pure pasteboard policy (local byte-identical, virtual empty across every item).
127156

128157
The Finder leg itself can't be automated honestly (Finder owns the drop gesture); the manual

apps/desktop/src-tauri/src/native_drag/fulfillment.rs

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -100,14 +100,28 @@ impl VolumeResolver for RegistryResolver {
100100
}
101101
}
102102

103+
/// What a successful fulfillment produced, so the session-summary accounting can
104+
/// split the completion toast by kind ("Copied 2 files and 1 folder.").
105+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106+
pub struct FulfillOutcome {
107+
/// Whether the dragged item was a directory (recursively downloaded) vs a
108+
/// single file.
109+
pub is_dir: bool,
110+
}
111+
103112
/// Fulfills one dragged item: downloads `source_path` from the volume identified
104113
/// by `source_volume_id` to the exact `dest_path` Finder supplied.
105114
///
106115
/// Marks the source volume busy for the eject guard for the whole transfer
107-
/// (released on every exit via an RAII guard). Returns `Ok(())` on success; on
108-
/// any failure removes the partial/created destination and returns a
109-
/// [`FulfillError`] carrying friendly copy.
110-
pub async fn fulfill(source_volume_id: &str, source_path: &Path, dest_path: &Path) -> Result<(), FulfillError> {
116+
/// (released on every exit via an RAII guard). Returns the resolved
117+
/// [`FulfillOutcome`] (file vs folder) on success; on any failure removes the
118+
/// partial/created destination and returns a [`FulfillError`] carrying friendly
119+
/// copy.
120+
pub async fn fulfill(
121+
source_volume_id: &str,
122+
source_path: &Path,
123+
dest_path: &Path,
124+
) -> Result<FulfillOutcome, FulfillError> {
111125
fulfill_with_resolver(&RegistryResolver, source_volume_id, source_path, dest_path).await
112126
}
113127

@@ -138,7 +152,7 @@ pub(crate) async fn fulfill_with_resolver(
138152
source_volume_id: &str,
139153
source_path: &Path,
140154
dest_path: &Path,
141-
) -> Result<(), FulfillError> {
155+
) -> Result<FulfillOutcome, FulfillError> {
142156
let Some(volume) = resolver.resolve(source_volume_id) else {
143157
// The source vanished between drag-start and fulfillment.
144158
let err = VolumeError::DeviceDisconnected(format!("Volume '{source_volume_id}' is no longer available"));
@@ -181,7 +195,7 @@ pub(crate) async fn fulfill_with_resolver(
181195
err.friendly.title
182196
);
183197
}
184-
result
198+
result.map(|()| FulfillOutcome { is_dir })
185199
}
186200

187201
/// Streams one source file to the EXACT `dest_path`. On any error, removes the
@@ -349,9 +363,10 @@ mod tests {
349363
let dest = dest_dir.path().join("sunset 2.jpg");
350364

351365
let resolver = FixedResolver(Some(v));
352-
fulfill_with_resolver(&resolver, "phone", Path::new("/DCIM/photo-001.jpg"), &dest)
366+
let outcome = fulfill_with_resolver(&resolver, "phone", Path::new("/DCIM/photo-001.jpg"), &dest)
353367
.await
354368
.expect("fulfillment should succeed");
369+
assert!(!outcome.is_dir, "a single file fulfillment reports is_dir = false");
355370

356371
assert!(dest.exists(), "file must land at the exact Finder leaf");
357372
assert_eq!(std::fs::read(&dest).unwrap(), b"sunset bytes");
@@ -521,9 +536,10 @@ mod tests {
521536
let dest = dest_dir.path().join("DCIM copy");
522537

523538
let resolver = FixedResolver(Some(v));
524-
fulfill_with_resolver(&resolver, "phone", Path::new("/DCIM"), &dest)
539+
let outcome = fulfill_with_resolver(&resolver, "phone", Path::new("/DCIM"), &dest)
525540
.await
526541
.expect("folder fulfillment should succeed");
542+
assert!(outcome.is_dir, "a folder fulfillment reports is_dir = true");
527543

528544
assert!(dest.is_dir(), "the dest folder must land under the Finder leaf");
529545
assert_eq!(std::fs::read(dest.join("a.jpg")).unwrap(), b"aaa");

apps/desktop/src-tauri/src/native_drag/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
4343
pub mod fulfillment;
4444
pub mod promises;
45+
pub mod session_summary;
4546
pub mod source;
4647
pub mod type_plan;
4748
pub mod uti;

0 commit comments

Comments
 (0)