Skip to content

Commit 7426334

Browse files
committed
Feature: Add multifile external drag&drop
1 parent f4c4c21 commit 7426334

11 files changed

Lines changed: 643 additions & 39 deletions

File tree

apps/desktop/src-tauri/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/desktop/src-tauri/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ tauri-plugin-clipboard-manager = "2"
4242
notify-debouncer-full = "0.6.0"
4343
bincode2 = "2"
4444
tauri-plugin-drag = "2.1.0"
45+
drag = "2.1.0"
4546
tauri-plugin-fs = "2.4.4"
4647
alphanumeric-sort = "1.5"
4748
ed25519-dalek = { version = "2.1", features = ["rand_core"] }

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

Lines changed: 89 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@ use crate::file_system::{
88
copy_files_start as ops_copy_files_start, delete_files_start as ops_delete_files_start,
99
find_file_index as ops_find_file_index, get_file_at as ops_get_file_at, get_file_range as ops_get_file_range,
1010
get_listing_stats as ops_get_listing_stats, get_max_filename_width as ops_get_max_filename_width,
11-
get_operation_status as ops_get_operation_status, get_total_count as ops_get_total_count,
12-
list_active_operations as ops_list_active_operations, list_directory_end as ops_list_directory_end,
13-
list_directory_start_streaming as ops_list_directory_start_streaming,
11+
get_operation_status as ops_get_operation_status, get_paths_at_indices as ops_get_paths_at_indices,
12+
get_total_count as ops_get_total_count, list_active_operations as ops_list_active_operations,
13+
list_directory_end as ops_list_directory_end, list_directory_start_streaming as ops_list_directory_start_streaming,
1414
list_directory_start_with_volume as ops_list_directory_start_with_volume, move_files_start as ops_move_files_start,
1515
resort_listing as ops_resort_listing,
1616
};
1717
use std::path::PathBuf;
18+
use std::sync::mpsc::channel;
19+
use tauri::Manager;
1820

1921
/// Checks if a path exists.
2022
///
@@ -378,6 +380,90 @@ pub fn get_operation_status(operation_id: String) -> Option<OperationStatus> {
378380
ops_get_operation_status(&operation_id)
379381
}
380382

383+
// ============================================================================
384+
// Drag operations
385+
// ============================================================================
386+
387+
/// Starts a native drag operation for selected files from a cached listing.
388+
///
389+
/// This initiates the drag from Rust directly, avoiding IPC transfer of file paths.
390+
/// The paths are looked up from LISTING_CACHE using the provided indices.
391+
///
392+
/// # Arguments
393+
/// * `app` - Tauri app handle for accessing the window
394+
/// * `listing_id` - The listing ID from `list_directory_start`
395+
/// * `selected_indices` - Frontend indices of selected files
396+
/// * `include_hidden` - Whether hidden files are shown (affects index mapping)
397+
/// * `has_parent` - Whether the ".." entry is shown at index 0
398+
/// * `mode` - Drag mode: "copy" or "move"
399+
/// * `icon_path` - Path to the drag preview icon (temp file)
400+
#[tauri::command]
401+
pub fn start_selection_drag(
402+
app: tauri::AppHandle,
403+
listing_id: String,
404+
selected_indices: Vec<usize>,
405+
include_hidden: bool,
406+
has_parent: bool,
407+
mode: String,
408+
icon_path: String,
409+
) -> Result<(), String> {
410+
// Get file paths from the cached listing
411+
let paths = ops_get_paths_at_indices(&listing_id, &selected_indices, include_hidden, has_parent)?;
412+
413+
if paths.is_empty() {
414+
return Err("No valid files to drag".to_string());
415+
}
416+
417+
// Get the main window
418+
let window = app.get_webview_window("main").ok_or("Main window not found")?;
419+
420+
// Determine drag mode (Send-safe)
421+
let is_copy_mode = mode == "copy";
422+
423+
// Store icon path for use in closure (PathBuf is Send)
424+
let icon_path_buf = PathBuf::from(icon_path);
425+
426+
// Use a channel to get the result from the main thread
427+
let (tx, rx) = channel();
428+
429+
// Run on main thread (required by macOS for drag operations)
430+
// Create DragItem inside the closure since it's not Send
431+
app.run_on_main_thread(move || {
432+
// Build DragItem inside the closure (not Send due to Data variant)
433+
let item = drag::DragItem::Files(paths);
434+
435+
// Load icon from file path
436+
let icon = drag::Image::File(icon_path_buf);
437+
438+
// Create options with the drag mode
439+
let options = drag::Options {
440+
skip_animatation_on_cancel_or_failure: false,
441+
mode: if is_copy_mode {
442+
drag::DragMode::Copy
443+
} else {
444+
drag::DragMode::Move
445+
},
446+
};
447+
448+
let result = drag::start_drag(
449+
&window,
450+
item,
451+
icon,
452+
|_result, _cursor_pos| {
453+
// Callback when drag completes - we don't need to do anything here
454+
},
455+
options,
456+
);
457+
let _ = tx.send(result);
458+
})
459+
.map_err(|e| format!("Failed to run on main thread: {}", e))?;
460+
461+
// Wait for the result
462+
rx.recv()
463+
.map_err(|_| "Failed to receive drag result")?
464+
.map_err(|e| format!("Drag operation failed: {}", e))
465+
}
466+
381467
/// Expands tilde (~) to the user's home directory.
382468
fn expand_tilde(path: &str) -> String {
383469
if (path.starts_with("~/") || path == "~")

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ pub use mock_provider::MockFileSystemProvider;
2626
pub use operations::{
2727
FileEntry, ListingStartResult, ListingStats, ResortResult, SortColumn, SortOrder, StreamingListingStartResult,
2828
cancel_listing, find_file_index, get_file_at, get_file_range, get_listing_stats, get_max_filename_width,
29-
get_total_count, list_directory_end, list_directory_start_streaming, list_directory_start_with_volume,
30-
resort_listing,
29+
get_paths_at_indices, get_total_count, list_directory_end, list_directory_start_streaming,
30+
list_directory_start_with_volume, resort_listing,
3131
};
3232
// FileEntry also re-exported for internal test modules
3333
#[cfg(test)]

apps/desktop/src-tauri/src/file_system/operations.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -759,6 +759,55 @@ pub fn get_file_at(listing_id: &str, index: usize, include_hidden: bool) -> Resu
759759
}
760760
}
761761

762+
/// Gets file paths at specific indices from a cached listing.
763+
///
764+
/// This is optimized for drag operations where we only need paths, not full FileEntry objects.
765+
///
766+
/// # Arguments
767+
/// * `listing_id` - The listing ID from `list_directory_start`
768+
/// * `selected_indices` - Frontend indices of selected files
769+
/// * `include_hidden` - Whether hidden files are visible (affects index mapping)
770+
/// * `has_parent` - Whether the ".." entry is shown (index 0 in frontend)
771+
///
772+
/// # Returns
773+
/// Vector of absolute file paths for the selected files.
774+
pub fn get_paths_at_indices(
775+
listing_id: &str,
776+
selected_indices: &[usize],
777+
include_hidden: bool,
778+
has_parent: bool,
779+
) -> Result<Vec<PathBuf>, String> {
780+
let cache = LISTING_CACHE.read().map_err(|_| "Failed to acquire cache lock")?;
781+
782+
let listing = cache
783+
.get(listing_id)
784+
.ok_or_else(|| format!("Listing not found: {}", listing_id))?;
785+
786+
// Build visible entries view (with or without hidden files)
787+
let visible: Vec<&FileEntry> = if include_hidden {
788+
listing.entries.iter().collect()
789+
} else {
790+
listing.entries.iter().filter(|e| !e.name.starts_with('.')).collect()
791+
};
792+
793+
let mut paths = Vec::with_capacity(selected_indices.len());
794+
for &frontend_idx in selected_indices {
795+
// Skip ".." entry (frontend index 0 when has_parent is true)
796+
if has_parent && frontend_idx == 0 {
797+
continue;
798+
}
799+
800+
// Convert frontend index to backend index
801+
let backend_idx = if has_parent { frontend_idx - 1 } else { frontend_idx };
802+
803+
if let Some(entry) = visible.get(backend_idx) {
804+
paths.push(PathBuf::from(&entry.path));
805+
}
806+
}
807+
808+
Ok(paths)
809+
}
810+
762811
/// Ends a directory listing and cleans up the cache.
763812
///
764813
/// # Arguments

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ pub fn run() {
265265
commands::file_system::list_active_operations,
266266
commands::file_system::get_operation_status,
267267
commands::file_system::get_listing_stats,
268+
commands::file_system::start_selection_drag,
268269
commands::font_metrics::store_font_metrics,
269270
commands::font_metrics::has_font_metrics,
270271
commands::icons::get_icons,

0 commit comments

Comments
 (0)