Skip to content

Commit f977ed9

Browse files
committed
Transfer toasts: real file/folder split for drag-and-drop and paste
- Drag-and-drop and clipboard paste now report the true top-level "N files and M folders" split in the confirmation dialog and the completion toast, instead of misreporting every dropped folder as a file (DnD) or flattening to "Copied 50,000 files" (paste). - New `stat_paths_kinds(paths) -> TimedOut<Vec<Option<bool>>>` Tauri command (`commands/file_system/stat.rs`): one batched `symlink_metadata` probe in `spawn_blocking` under the 2s read timeout, never walks subtrees. Per-item failures → `None` (unknown), so a virtual MTP/SMB path on the pasteboard or a vanished entry can't poison the batch. - `read_clipboard_files` now returns `is_directory: Vec<Option<bool>>` index-aligned with `paths`, statted off the main thread via the shared `stat_paths_kinds_blocking` helper. - DnD: `handleFileDrop` fetches the kind flags (one IPC) and threads them through `buildTransferPropsFromDroppedPaths`; paste: `pasteFromClipboard` computes the split and threads it through `startTransferProgress`. - Both paths are all-or-nothing: if ANY kind is unknown (or length mismatch / stat error), the whole batch falls back to today's approximate shape so the composer uses flattened wording. Honest beats half-right. - Measured cost: 50k local stats ~110ms single-threaded (warm), well under the read timeout; no caps added. - Tests: red-first coverage for the BE batch command (incl. unknown / non-local items), the DnD flags plumbing (3 dropped folders → 0 files / 3 folders, any-unknown fallback), the paste threading, and the stat-rejection fallback. Updated stale "Approximate counts" / "absent on the clipboard-paste path" comments and the drag / FE-transfer / commands CLAUDE.md docs. - Regenerated typed IPC bindings.
1 parent 9ef30f0 commit f977ed9

20 files changed

Lines changed: 506 additions & 43 deletions

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ immediately to business-logic modules. No significant logic lives here.
99
|------|--------|-------|
1010
| `mod.rs` | Re-exports | `mtp`, `network` gated behind `#[cfg(any(target_os = "macos", target_os = "linux"))]`; `volumes` behind `#[cfg(target_os = "macos")]`; `volumes_linux` behind `#[cfg(target_os = "linux")]` |
1111
| `util.rs` | Shared helpers | `TimedOut<T>`, `IpcError`, `blocking_with_timeout`, `blocking_with_timeout_flag`, `blocking_result_with_timeout`. See "Timeout-aware return types" below. |
12-
| `file_system/` | File listing & writes | Directory module split by operation type. `mod.rs` has `expand_tilde()`, re-exports, and tests. `listing.rs`: streaming + virtual-scroll listing API, path queries, `find_first_fuzzy_match` (type-to-jump), benchmarking, `get_brief_column_text_widths` (per-column widest-filename text widths for Brief mode; replaces the removed `get_max_filename_width`). `refresh_listing` short-circuits on watcher-backed listings (`Volume::listing_is_watched(path) == true`): the cache is already kept fresh by `notify_mutation`, so a redundant full re-read after every transfer outcome (the FE's `refreshPanesAfterTransfer`) used to wedge slow volumes (MTP 17 s + USB session collision). Logs at debug `target: "refresh_listing"` when the short-circuit fires. `write_ops.rs`: create, copy, move, delete, trash, scan preview, conflict resolution, synthetic diff helpers. `volume_copy.rs`: cross-volume copy/move/scan, `SourceItemInput`. `scan_volume_for_conflicts` optionally takes a source volume id + source paths and resolves each item's real `is_directory` + size from the source volume via ONE batched `scan_for_copy_batch` (O(top-level items), never a subtree walk), overriding the FE's name-only placeholders so dir-vs-dir collisions classify as silent merges; back-compatible when omitted. `drag.rs`: native drag, self-drag overlay. `e2e_support.rs`: feature-gated E2E/debug commands. |
12+
| `file_system/` | File listing & writes | Directory module split by operation type. `mod.rs` has `expand_tilde()`, re-exports, and tests. `listing.rs`: streaming + virtual-scroll listing API, path queries, `find_first_fuzzy_match` (type-to-jump), benchmarking, `get_brief_column_text_widths` (per-column widest-filename text widths for Brief mode; replaces the removed `get_max_filename_width`). `refresh_listing` short-circuits on watcher-backed listings (`Volume::listing_is_watched(path) == true`): the cache is already kept fresh by `notify_mutation`, so a redundant full re-read after every transfer outcome (the FE's `refreshPanesAfterTransfer`) used to wedge slow volumes (MTP 17 s + USB session collision). Logs at debug `target: "refresh_listing"` when the short-circuit fires. `write_ops.rs`: create, copy, move, delete, trash, scan preview, conflict resolution, synthetic diff helpers. `volume_copy.rs`: cross-volume copy/move/scan, `SourceItemInput`. `scan_volume_for_conflicts` optionally takes a source volume id + source paths and resolves each item's real `is_directory` + size from the source volume via ONE batched `scan_for_copy_batch` (O(top-level items), never a subtree walk), overriding the FE's name-only placeholders so dir-vs-dir collisions classify as silent merges; back-compatible when omitted. `stat.rs`: `stat_paths_kinds(paths) -> TimedOut<Vec<Option<bool>>>` — batched top-level "is this a directory?" probe for the drag-and-drop transfer path (`Some(true)` = dir, `Some(false)` = file, `None` = unknown / non-local / vanished). One `spawn_blocking` under the read timeout, never a subtree walk; per-item failures map to `None` so a virtual MTP/SMB path on the pasteboard can't poison the batch. The pure `stat_paths_kinds_blocking` helper is reused by `clipboard.rs::read_clipboard_files`. `drag.rs`: native drag, self-drag overlay. `e2e_support.rs`: feature-gated E2E/debug commands. |
1313
| `volumes.rs` | Volume management (macOS) | `list_volumes`, `get_default_volume_id`, `get_volume_space`, `resolve_path_volume` (statfs-based, no volume enumeration) |
1414
| `volumes_linux.rs` | Volume management (Linux) | Same interface as `volumes.rs`, delegates to `volumes_linux` module |
1515
| `mtp.rs` | MTP devices | Full MTP command surface (connect, disconnect, list, download, upload, delete, rename, move, scan) |
@@ -25,7 +25,7 @@ immediately to business-logic modules. No significant logic lives here.
2525
| `mcp.rs` | MCP server | `set_mcp_enabled`, `set_mcp_port`: live start/stop/port-change of the MCP server without app restart. `get_mcp_token`: returns the per-instance bearer token so in-process / E2E callers can authenticate the destructive-auto-confirm tools (see `mcp/CLAUDE.md` § Authentication) |
2626
| `licensing.rs` | Licensing | Status query, activation, expiry, reminder, key validation |
2727
| `indexing.rs` | Drive index | `start_drive_index`, `stop_drive_index`, `get_index_status`, `get_dir_stats`, `get_dir_stats_batch`, `clear_drive_index`, `set_indexing_enabled`, `get_index_debug_status` (dev-only extended stats). Uses `State<IndexManagerState>`. |
28-
| `clipboard.rs` | Clipboard file ops | `copy_files_to_clipboard`, `cut_files_to_clipboard`, `copy_paths_to_clipboard` / `cut_paths_to_clipboard` (paths-by-value siblings used by the search-results pane, which has no backend listing for index-based ops), `read_clipboard_files`, `clear_clipboard_cut_state`. macOS uses NSPasteboard via `clipboard::pasteboard`; non-macOS stubs return errors. |
28+
| `clipboard.rs` | Clipboard file ops | `copy_files_to_clipboard`, `cut_files_to_clipboard`, `copy_paths_to_clipboard` / `cut_paths_to_clipboard` (paths-by-value siblings used by the search-results pane, which has no backend listing for index-based ops), `read_clipboard_files`, `clear_clipboard_cut_state`. macOS uses NSPasteboard via `clipboard::pasteboard`; non-macOS stubs return errors. `read_clipboard_files` returns `ClipboardReadResult { paths, is_cut, is_directory }` where `is_directory` is an index-aligned `Vec<Option<bool>>` from a batched off-main-thread `stat_paths_kinds_blocking` (`Some(true)` = dir, `Some(false)` = file, `None` = unknown), so the paste completion toast can split files vs. folders without walking trees. |
2929
| `crash_reporter.rs` | Crash reporting | `check_pending_crash_report`, `dismiss_crash_report`, `send_crash_report`. Delegates to `crash_reporter` module. Send is skipped in dev/CI. |
3030
| `error_reporter.rs` | Error reports (Flow A) | `prepare_error_report_preview`, `send_error_report`. Two-step so the preview dialog is deterministic without shipping the full bundle through IPC twice. Delegates to `error_reporter` module. Upload is skipped in dev/CI. |
3131
| `search.rs` | Drive search | Thin IPC wrappers over `search` module. `resolve_ai_backend` for AI provider config. Post-filters directory sizes after `fill_directory_sizes`. |

apps/desktop/src-tauri/src/commands/clipboard.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ use crate::clipboard;
1515
pub struct ClipboardReadResult {
1616
paths: Vec<String>,
1717
is_cut: bool,
18+
/// Per-path top-level kind, index-aligned with `paths`: `Some(true)` =
19+
/// directory, `Some(false)` = file, `None` = unknown (stat failed). Lets the
20+
/// paste path split the completion toast into files vs. folders without
21+
/// walking trees. Clipboard file URLs are always real local paths, so in
22+
/// practice these resolve; `None` falls back to the flattened wording.
23+
is_directory: Vec<Option<bool>>,
1824
}
1925

2026
/// Resolves selected file paths and writes them to the system clipboard.
@@ -197,7 +203,21 @@ pub async fn read_clipboard_files(app: tauri::AppHandle) -> Result<ClipboardRead
197203
.map(|p| p.to_string_lossy().into_owned())
198204
.collect();
199205

200-
Ok(ClipboardReadResult { paths, is_cut })
206+
// Resolve each path's top-level kind so the paste completion toast can split
207+
// files vs. folders. One batched stat off the main thread (the pasteboard
208+
// read already happened above); per-item failures map to `None`, never an
209+
// error for the batch. Clipboard URLs are real local paths, so this is fast.
210+
let paths_for_stat = paths.clone();
211+
let is_directory =
212+
tokio::task::spawn_blocking(move || crate::commands::file_system::stat_paths_kinds_blocking(&paths_for_stat))
213+
.await
214+
.unwrap_or_else(|_| vec![None; paths.len()]);
215+
216+
Ok(ClipboardReadResult {
217+
paths,
218+
is_cut,
219+
is_directory,
220+
})
201221
}
202222

203223
/// Reads plain text from the system clipboard.

apps/desktop/src-tauri/src/commands/file_system/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ mod drag;
55
mod e2e_support;
66
mod git;
77
mod listing;
8+
mod stat;
89
mod volume_copy;
910
mod write_ops;
1011

@@ -13,6 +14,7 @@ pub use drag::*;
1314
pub use e2e_support::*;
1415
pub use git::*;
1516
pub use listing::*;
17+
pub use stat::*;
1618
pub use volume_copy::*;
1719
pub use write_ops::*;
1820

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
//! Batched per-path "is this a directory?" probe.
2+
//!
3+
//! Used by the drag-and-drop transfer path: dropped paths arrive from the OS
4+
//! pasteboard as bare absolute paths with no type info, so the confirmation
5+
//! dialog and completion toast can't split them into files vs. folders without
6+
//! a stat. This command resolves the top-level kind of each path in ONE batched
7+
//! IPC, never walking subtrees.
8+
//!
9+
//! Per-item failures (a path that doesn't resolve to the local filesystem — a
10+
//! virtual MTP/SMB path that landed on the pasteboard, a vanished entry, a
11+
//! permission error) map to `None` ("unknown"), never an error for the whole
12+
//! batch. The caller treats `None` as "fall back to today's approximate
13+
//! behavior" rather than blocking the drop.
14+
15+
use crate::commands::util::{TimedOut, blocking_with_timeout_flag};
16+
use std::path::Path;
17+
use tokio::time::Duration;
18+
19+
/// Reads stat for paths from the pasteboard, so use the read timeout. A batch
20+
/// of plain `symlink_metadata` calls on local paths is microseconds each; the
21+
/// timeout only bites if one of the paths sits on a hung mount.
22+
const STAT_PATHS_TIMEOUT: Duration = Duration::from_secs(2);
23+
24+
/// For each input path, returns:
25+
/// - `Some(true)` — the path is a directory,
26+
/// - `Some(false)` — the path is a non-directory (file, symlink, …),
27+
/// - `None` — the kind is unknown (stat failed: the path doesn't resolve
28+
/// to the local filesystem, vanished, or we lack permission).
29+
///
30+
/// The result vector is index-aligned with `paths`. This is a pure,
31+
/// Tauri-free helper so the kind logic stays unit-testable. We use
32+
/// `symlink_metadata` (not `metadata`) so a symlink reports as a non-directory
33+
/// rather than following into its (possibly slow / missing) target.
34+
pub fn stat_paths_kinds_blocking(paths: &[String]) -> Vec<Option<bool>> {
35+
paths
36+
.iter()
37+
.map(|p| match std::fs::symlink_metadata(Path::new(p)) {
38+
Ok(meta) => Some(meta.is_dir()),
39+
Err(_) => None,
40+
})
41+
.collect()
42+
}
43+
44+
/// Batched per-path directory probe for the drag-and-drop transfer path.
45+
///
46+
/// Returns a `Vec<Option<bool>>` index-aligned with `paths` (see
47+
/// `stat_paths_kinds_blocking`). Runs in `spawn_blocking` under the read
48+
/// timeout; on a batch timeout the whole vector falls back to `None`
49+
/// (all-unknown) with `timed_out: true`, so the caller cleanly degrades to the
50+
/// approximate count shape rather than freezing the drop on slow volume I/O.
51+
#[tauri::command]
52+
#[specta::specta]
53+
pub async fn stat_paths_kinds(paths: Vec<String>) -> TimedOut<Vec<Option<bool>>> {
54+
let count = paths.len();
55+
let fallback = vec![None; count];
56+
blocking_with_timeout_flag(STAT_PATHS_TIMEOUT, fallback, move || stat_paths_kinds_blocking(&paths)).await
57+
}
58+
59+
#[cfg(test)]
60+
mod tests {
61+
use super::*;
62+
use std::fs;
63+
64+
fn test_dir(name: &str) -> std::path::PathBuf {
65+
let dir = std::env::temp_dir().join(format!("cmdr_stat_kinds_test_{name}"));
66+
let _ = fs::remove_dir_all(&dir);
67+
fs::create_dir_all(&dir).expect("create test dir");
68+
dir
69+
}
70+
71+
#[test]
72+
fn classifies_files_and_dirs() {
73+
let tmp = test_dir("mixed");
74+
let file = tmp.join("a.txt");
75+
fs::write(&file, b"hi").unwrap();
76+
let subdir = tmp.join("sub");
77+
fs::create_dir(&subdir).unwrap();
78+
79+
let paths = vec![
80+
file.to_string_lossy().into_owned(),
81+
subdir.to_string_lossy().into_owned(),
82+
];
83+
let kinds = stat_paths_kinds_blocking(&paths);
84+
assert_eq!(kinds, vec![Some(false), Some(true)]);
85+
86+
let _ = fs::remove_dir_all(&tmp);
87+
}
88+
89+
#[test]
90+
fn unknown_for_nonexistent_and_virtual_paths() {
91+
// A vanished local path, an MTP-shaped virtual path, and an SMB virtual
92+
// path all fail `symlink_metadata` → None. They must NOT poison the
93+
// whole batch, and the result stays index-aligned.
94+
let tmp = test_dir("unknown");
95+
let real = tmp.join("real");
96+
fs::create_dir(&real).unwrap();
97+
98+
let paths = vec![
99+
real.to_string_lossy().into_owned(),
100+
"/this/does/not/exist/12345".to_string(),
101+
"mtp-1234://Internal storage/DCIM".to_string(),
102+
"smb://server/share/file".to_string(),
103+
];
104+
let kinds = stat_paths_kinds_blocking(&paths);
105+
assert_eq!(kinds, vec![Some(true), None, None, None]);
106+
107+
let _ = fs::remove_dir_all(&tmp);
108+
}
109+
110+
#[test]
111+
fn empty_input_yields_empty() {
112+
assert_eq!(stat_paths_kinds_blocking(&[]), Vec::<Option<bool>>::new());
113+
}
114+
115+
#[tokio::test]
116+
async fn command_returns_aligned_kinds_without_timeout() {
117+
let tmp = test_dir("cmd");
118+
let file = tmp.join("f");
119+
fs::write(&file, b"x").unwrap();
120+
let paths = vec![file.to_string_lossy().into_owned(), "/nope/nope".to_string()];
121+
122+
let result = stat_paths_kinds(paths).await;
123+
assert!(!result.timed_out);
124+
assert_eq!(result.data, vec![Some(false), None]);
125+
126+
let _ = fs::remove_dir_all(&tmp);
127+
}
128+
}

apps/desktop/src-tauri/src/ipc.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ pub fn builder() -> Builder<tauri::Wry> {
8484
crate::commands::file_system::resort_listing,
8585
crate::commands::file_system::get_path_limits,
8686
crate::commands::file_system::path_exists,
87+
crate::commands::file_system::stat_paths_kinds,
8788
crate::commands::file_system::create_directory,
8889
crate::commands::file_system::create_file,
8990
crate::commands::file_system::benchmark_log,

apps/desktop/src-tauri/src/ipc_collectors.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ pub(crate) fn collect_cross_platform_types(types: &mut Types) -> Vec<Function> {
3232
crate::commands::file_system::resort_listing,
3333
crate::commands::file_system::get_path_limits,
3434
crate::commands::file_system::path_exists,
35+
crate::commands::file_system::stat_paths_kinds,
3536
crate::commands::file_system::create_directory,
3637
crate::commands::file_system::create_file,
3738
crate::commands::file_system::benchmark_log,

apps/desktop/src/lib/file-explorer/drag/CLAUDE.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,15 @@ Key files:
5858
- `drag-position.ts`: Corrects Tauri coords for docked DevTools (dev-only, zero overhead in prod)
5959
- Integration in `DualPaneExplorer.svelte`
6060

61+
Drop count split: on drop, `pane/drag-drop-controller.svelte.ts::handleFileDrop` fetches each dropped path's top-level
62+
kind (file vs. folder) in one batched `stat_paths_kinds` IPC before opening the confirmation dialog, so both the dialog
63+
and the completion toast report the real "N files and M folders" split. The stat runs under the backend read timeout (2
64+
s) and falls back to all-unknown on a hung mount, so it never blocks the drop. The split is all-or-nothing: if ANY
65+
path's kind is unknown (a virtual MTP/SMB path that landed on the pasteboard, a vanished entry, a stat timeout, or a
66+
length mismatch), `buildTransferPropsFromDroppedPaths` reverts the whole batch to the legacy approximate shape
67+
(`fileCount = count`, `folderCount = 0`), which makes the toast composer fall back to flattened file-count wording.
68+
Honest beats half-right — a partial split would misreport.
69+
6170
### Drag image detection (macOS-specific hack)
6271

6372
**Problem**: Tauri's `DragDropEvent` doesn't include drag image size. Need size to decide whether to suppress Cmdr's

apps/desktop/src/lib/file-explorer/pane/clipboard-operations.test.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect, vi, beforeEach } from 'vitest'
22
import type { PaneAccess } from './pane-access'
33
import type { FilePaneAPI } from './types'
4+
import type { TransferProgressPropsData } from './dialog-state.svelte'
45

56
const {
67
copyFilesToClipboardSpy,
@@ -18,7 +19,7 @@ const {
1819
cutFilesToClipboardSpy: vi.fn<() => Promise<number>>(),
1920
copyPathsToClipboardSpy: vi.fn<() => Promise<number>>(),
2021
cutPathsToClipboardSpy: vi.fn<() => Promise<number>>(),
21-
readClipboardFilesSpy: vi.fn<() => Promise<{ paths: string[]; isCut: boolean }>>(),
22+
readClipboardFilesSpy: vi.fn<() => Promise<{ paths: string[]; isCut: boolean; isDirectory?: (boolean | null)[] }>>(),
2223
clearClipboardCutStateSpy: vi.fn<() => Promise<void>>(),
2324
addToastSpy: vi.fn<(content: unknown, options?: unknown) => string>(),
2425
resolveSnapshotPathsSpy: vi.fn<() => string[]>(),
@@ -101,7 +102,7 @@ function buildAccess(config: AccessConfig = {}): PaneAccess {
101102
}
102103
}
103104

104-
const dialogsStub = { startTransferProgress: vi.fn() }
105+
const dialogsStub = { startTransferProgress: vi.fn<(props: TransferProgressPropsData) => void>() }
105106

106107
function buildDialogs() {
107108
return dialogsStub as unknown as Parameters<typeof createClipboardOperations>[1]
@@ -277,6 +278,51 @@ describe('pasteFromClipboard', () => {
277278
expect(dialogsStub.startTransferProgress.mock.calls[0][0]).toMatchObject({ operationType: 'move' })
278279
expect(clearClipboardCutStateSpy).not.toHaveBeenCalled()
279280
})
281+
282+
it('threads the file/folder split when every clipboard kind flag is known', async () => {
283+
readClipboardFilesSpy.mockResolvedValue({
284+
paths: ['/x/a.txt', '/x/dir1', '/x/dir2'],
285+
isCut: false,
286+
isDirectory: [false, true, true],
287+
})
288+
getCommonParentPathSpy.mockReturnValue('/x')
289+
const access = buildAccess({ volumeId: 'root', path: '/dest' })
290+
291+
await createClipboardOperations(access, buildDialogs()).pasteFromClipboard(false)
292+
293+
expect(dialogsStub.startTransferProgress.mock.calls[0][0]).toMatchObject({
294+
fileCount: 1,
295+
folderCount: 2,
296+
})
297+
})
298+
299+
it('omits the split (composer falls back) when any clipboard kind flag is unknown', async () => {
300+
readClipboardFilesSpy.mockResolvedValue({
301+
paths: ['/x/a.txt', '/x/mystery'],
302+
isCut: false,
303+
isDirectory: [false, null],
304+
})
305+
getCommonParentPathSpy.mockReturnValue('/x')
306+
const access = buildAccess({ volumeId: 'root', path: '/dest' })
307+
308+
await createClipboardOperations(access, buildDialogs()).pasteFromClipboard(false)
309+
310+
const props = dialogsStub.startTransferProgress.mock.calls[0][0]
311+
expect(props.fileCount).toBeUndefined()
312+
expect(props.folderCount).toBeUndefined()
313+
})
314+
315+
it('omits the split when the clipboard carries no kind flags (legacy shape)', async () => {
316+
readClipboardFilesSpy.mockResolvedValue({ paths: ['/x/a.txt'], isCut: false })
317+
getCommonParentPathSpy.mockReturnValue('/x')
318+
const access = buildAccess({ volumeId: 'root', path: '/dest' })
319+
320+
await createClipboardOperations(access, buildDialogs()).pasteFromClipboard(false)
321+
322+
const props = dialogsStub.startTransferProgress.mock.calls[0][0]
323+
expect(props.fileCount).toBeUndefined()
324+
expect(props.folderCount).toBeUndefined()
325+
})
280326
})
281327

282328
describe('getSnapshotClipboardPaths', () => {

0 commit comments

Comments
 (0)