Skip to content

Commit 538ec5a

Browse files
committed
Bugfix: Clear selection after file operations
- Selection persisted on stale indices after move/copy/delete/trash, pointing at unrelated files - Clear selection on the source pane after every operation completes, errors, or is cancelled - Diff-driven adjustment: during move/delete/trash, re-resolve selected names to new indices on each `directory-diff` via batch `findFileIndices` IPC - Gradual deselection: new `write-source-item-done` backend event emitted per top-level source item from all 5 operation types (same-FS move, cross-FS move, copy, delete, trash) - `SourceItemTracker` handles per-source file counting for sorted/interleaved scan results - `allSelected` sentinel avoids snapshotting 200k names; on cancel calls `selectAll()` for move/delete/trash, leaves untouched for copy - Generation counter discards stale async `findFileIndices` callbacks on rapid diffs or operation end - `sourcePaneSide` field identifies the correct pane even when focus shifts during operations
1 parent fbdba5b commit 538ec5a

25 files changed

Lines changed: 979 additions & 18 deletions

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

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@ use crate::file_system::{
1313
cancel_all_write_operations as ops_cancel_all_write_operations, cancel_listing as ops_cancel_listing,
1414
cancel_write_operation as ops_cancel_write_operation, copy_between_volumes as ops_copy_between_volumes,
1515
copy_files_start as ops_copy_files_start, delete_files_start as ops_delete_files_start,
16-
find_file_index as ops_find_file_index, get_file_at as ops_get_file_at, get_file_range as ops_get_file_range,
17-
get_listing_stats as ops_get_listing_stats, get_max_filename_width as ops_get_max_filename_width,
18-
get_operation_status as ops_get_operation_status, get_total_count as ops_get_total_count, get_volume_manager,
19-
list_active_operations as ops_list_active_operations, list_directory_end as ops_list_directory_end,
20-
list_directory_start_streaming as ops_list_directory_start_streaming,
16+
find_file_index as ops_find_file_index, find_file_indices as ops_find_file_indices, get_file_at as ops_get_file_at,
17+
get_file_range as ops_get_file_range, get_listing_stats as ops_get_listing_stats,
18+
get_max_filename_width as ops_get_max_filename_width, get_operation_status as ops_get_operation_status,
19+
get_total_count as ops_get_total_count, get_volume_manager, list_active_operations as ops_list_active_operations,
20+
list_directory_end as ops_list_directory_end, list_directory_start_streaming as ops_list_directory_start_streaming,
2121
list_directory_start_with_volume as ops_list_directory_start_with_volume, move_files_start as ops_move_files_start,
2222
refresh_listing_index_sizes as ops_refresh_listing_index_sizes, resort_listing as ops_resort_listing,
2323
scan_for_volume_copy as ops_scan_for_volume_copy, trash_files_start as ops_trash_files_start,
@@ -257,6 +257,15 @@ pub fn find_file_index(listing_id: String, name: String, include_hidden: bool) -
257257
ops_find_file_index(&listing_id, &name, include_hidden)
258258
}
259259

260+
#[tauri::command]
261+
pub fn find_file_indices(
262+
listing_id: String,
263+
names: Vec<String>,
264+
include_hidden: bool,
265+
) -> Result<std::collections::HashMap<String, usize>, String> {
266+
ops_find_file_indices(&listing_id, &names, include_hidden)
267+
}
268+
260269
#[tauri::command]
261270
pub fn get_file_at(listing_id: String, index: usize, include_hidden: bool) -> Result<Option<FileEntry>, String> {
262271
ops_get_file_at(&listing_id, index, include_hidden)

apps/desktop/src-tauri/src/file_system/listing/CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ Frontend Backend
5252
1. `list_directory_start_streaming()` receives listing ID from frontend, spawns task
5353
2. Background task reads directory, sorts, stores in cache
5454
3. Frontend calls `get_file_range()` for visible entries (on-demand)
55-
4. `list_directory_end()` stops watcher, removes from cache
55+
4. Frontend calls `find_file_indices()` to batch-resolve file names to indices (used by selection adjustment during operations)
56+
5. `list_directory_end()` stops watcher, removes from cache
5657

5758
**Concurrency**: Multiple listings can coexist (different panes, rapid navigation). Each has unique ID.
5859

apps/desktop/src-tauri/src/file_system/listing/hidden_files_test.rs

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
//! files starting with "." from directory listings.
55
66
use super::caching::{CachedListing, LISTING_CACHE};
7-
use super::operations::{find_file_index, get_file_at, get_file_range, get_total_count, list_directory_end};
7+
use super::operations::{
8+
find_file_index, find_file_indices, get_file_at, get_file_range, get_total_count, list_directory_end,
9+
};
810
use super::sorting::DirectorySortMode;
911
use super::{FileEntry, SortColumn, SortOrder};
1012
use crate::file_system::volume::{InMemoryVolume, Volume};
@@ -496,3 +498,132 @@ fn test_directory_with_no_hidden_files() {
496498
assert_eq!(count_with, 2, "Both files should be counted");
497499
assert_eq!(count_without, 2, "Both files should be counted (none are hidden)");
498500
}
501+
502+
// ============================================================================
503+
// Tests for find_file_indices (batch name→index lookup)
504+
// ============================================================================
505+
506+
/// Helper: inserts entries into the listing cache and returns the listing ID.
507+
fn insert_test_listing(id: &str, entries: Vec<FileEntry>) -> String {
508+
let listing_id = id.to_string();
509+
let mut cache = LISTING_CACHE.write().unwrap();
510+
cache.insert(
511+
listing_id.clone(),
512+
CachedListing {
513+
volume_id: "test".to_string(),
514+
path: std::path::PathBuf::from("/"),
515+
entries,
516+
sort_by: SortColumn::Name,
517+
sort_order: SortOrder::Ascending,
518+
directory_sort_mode: DirectorySortMode::LikeFiles,
519+
},
520+
);
521+
listing_id
522+
}
523+
524+
#[test]
525+
fn test_find_file_indices_basic_lookup() {
526+
let volume = create_test_volume();
527+
let entries = volume.list_directory(Path::new("")).unwrap();
528+
let listing_id = insert_test_listing("test-find-indices-basic", entries);
529+
530+
let names = vec!["Documents".to_string(), "file.txt".to_string()];
531+
let result = find_file_indices(&listing_id, &names, true).unwrap();
532+
533+
list_directory_end(&listing_id);
534+
535+
assert_eq!(result.len(), 2);
536+
assert!(result.contains_key("Documents"));
537+
assert!(result.contains_key("file.txt"));
538+
// Indices must match the singular find_file_index
539+
// (We already tested the volume has these entries)
540+
}
541+
542+
#[test]
543+
fn test_find_file_indices_hidden_filtering() {
544+
let volume = create_test_volume();
545+
let entries = volume.list_directory(Path::new("")).unwrap();
546+
let listing_id = insert_test_listing("test-find-indices-hidden", entries);
547+
548+
let names = vec![
549+
".gitignore".to_string(),
550+
"Documents".to_string(),
551+
".hidden_file".to_string(),
552+
];
553+
554+
let with_hidden = find_file_indices(&listing_id, &names, true).unwrap();
555+
let without_hidden = find_file_indices(&listing_id, &names, false).unwrap();
556+
557+
list_directory_end(&listing_id);
558+
559+
assert_eq!(with_hidden.len(), 3, "All 3 found when hidden included");
560+
assert_eq!(without_hidden.len(), 1, "Only Documents found when hidden excluded");
561+
assert!(without_hidden.contains_key("Documents"));
562+
}
563+
564+
#[test]
565+
fn test_find_file_indices_names_not_in_listing() {
566+
let volume = create_test_volume();
567+
let entries = volume.list_directory(Path::new("")).unwrap();
568+
let listing_id = insert_test_listing("test-find-indices-missing", entries);
569+
570+
let names = vec!["nonexistent.txt".to_string(), "also_missing".to_string()];
571+
let result = find_file_indices(&listing_id, &names, true).unwrap();
572+
573+
list_directory_end(&listing_id);
574+
575+
assert!(result.is_empty(), "No names should be found");
576+
}
577+
578+
#[test]
579+
fn test_find_file_indices_empty_names() {
580+
let volume = create_test_volume();
581+
let entries = volume.list_directory(Path::new("")).unwrap();
582+
let listing_id = insert_test_listing("test-find-indices-empty", entries);
583+
584+
let names: Vec<String> = vec![];
585+
let result = find_file_indices(&listing_id, &names, true).unwrap();
586+
587+
list_directory_end(&listing_id);
588+
589+
assert!(result.is_empty(), "Empty input should produce empty output");
590+
}
591+
592+
#[test]
593+
fn test_find_file_indices_duplicate_names_in_input() {
594+
let volume = create_test_volume();
595+
let entries = volume.list_directory(Path::new("")).unwrap();
596+
let listing_id = insert_test_listing("test-find-indices-dupes", entries);
597+
598+
let names = vec!["file.txt".to_string(), "file.txt".to_string(), "Documents".to_string()];
599+
let result = find_file_indices(&listing_id, &names, true).unwrap();
600+
601+
list_directory_end(&listing_id);
602+
603+
// Duplicates in input collapse to one key in output
604+
assert_eq!(result.len(), 2);
605+
assert!(result.contains_key("file.txt"));
606+
assert!(result.contains_key("Documents"));
607+
}
608+
609+
#[test]
610+
fn test_find_file_indices_consistent_with_find_file_index() {
611+
let volume = create_test_volume();
612+
let entries = volume.list_directory(Path::new("")).unwrap();
613+
let listing_id = insert_test_listing("test-find-indices-consistent", entries);
614+
615+
let names = vec!["Documents".to_string(), "file.txt".to_string(), "readme.md".to_string()];
616+
let batch = find_file_indices(&listing_id, &names, false).unwrap();
617+
618+
for name in &names {
619+
let single = find_file_index(&listing_id, name, false).unwrap();
620+
assert_eq!(
621+
batch.get(name.as_str()).copied(),
622+
single,
623+
"Batch and single must agree for '{}'",
624+
name
625+
);
626+
}
627+
628+
list_directory_end(&listing_id);
629+
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ pub(crate) mod streaming;
1111
// These re-exports make the types available both externally and locally in this module
1212
pub use metadata::{ExtendedMetadata, FileEntry};
1313
pub use operations::{
14-
ListingStartResult, ListingStats, ResortResult, find_file_index, get_file_at, get_file_range, get_listing_stats,
15-
get_max_filename_width, get_total_count, list_directory_end, list_directory_start_with_volume,
14+
ListingStartResult, ListingStats, ResortResult, find_file_index, find_file_indices, get_file_at, get_file_range,
15+
get_listing_stats, get_max_filename_width, get_total_count, list_directory_end, list_directory_start_with_volume,
1616
refresh_listing_index_sizes, resort_listing,
1717
};
1818
pub use reading::{get_single_entry, list_directory_core, list_directory_core_with_progress};

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
#![allow(dead_code, reason = "Boilerplate for future use")]
77

88
use serde::{Deserialize, Serialize};
9+
use std::collections::HashMap;
910
use std::path::{Path, PathBuf};
1011
use uuid::Uuid;
1112

@@ -242,6 +243,38 @@ pub fn find_file_index(listing_id: &str, name: &str, include_hidden: bool) -> Re
242243
}
243244
}
244245

246+
/// Finds the indices of multiple files by name in a cached listing (batch version of `find_file_index`).
247+
///
248+
/// Single pass over cached entries, O(entries + names). Returns only found names as keys.
249+
pub fn find_file_indices(
250+
listing_id: &str,
251+
names: &[String],
252+
include_hidden: bool,
253+
) -> Result<HashMap<String, usize>, String> {
254+
let cache = LISTING_CACHE.read().map_err(|_| "Failed to acquire cache lock")?;
255+
256+
let listing = cache
257+
.get(listing_id)
258+
.ok_or_else(|| format!("Listing not found: {}", listing_id))?;
259+
260+
let lookup: std::collections::HashSet<&str> = names.iter().map(|n| n.as_str()).collect();
261+
let mut result = HashMap::with_capacity(names.len());
262+
263+
let entries: Box<dyn Iterator<Item = &FileEntry>> = if include_hidden {
264+
Box::new(listing.entries.iter())
265+
} else {
266+
Box::new(listing.entries.iter().filter(|e| is_visible(e)))
267+
};
268+
269+
for (idx, entry) in entries.enumerate() {
270+
if lookup.contains(entry.name.as_str()) {
271+
result.insert(entry.name.clone(), idx);
272+
}
273+
}
274+
275+
Ok(result)
276+
}
277+
245278
/// Gets a single file at the given index.
246279
pub fn get_file_at(listing_id: &str, index: usize, include_hidden: bool) -> Result<Option<FileEntry>, String> {
247280
let cache = LISTING_CACHE.read().map_err(|_| "Failed to acquire cache lock")?;

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ use std::sync::{Arc, LazyLock};
2525
pub use listing::ExtendedMetadata;
2626
pub use listing::{
2727
DirectorySortMode, FileEntry, ListingStartResult, ListingStats, ResortResult, SortColumn, SortOrder,
28-
StreamingListingStartResult, cancel_listing, find_file_index, get_file_at, get_file_range, get_listing_stats,
29-
get_max_filename_width, get_total_count, list_directory_end, list_directory_start_streaming,
28+
StreamingListingStartResult, cancel_listing, find_file_index, find_file_indices, get_file_at, get_file_range,
29+
get_listing_stats, get_max_filename_width, get_total_count, list_directory_end, list_directory_start_streaming,
3030
list_directory_start_with_volume, refresh_listing_index_sizes, resort_listing,
3131
};
3232
// macOS-only exports (used by drag operations)

apps/desktop/src-tauri/src/file_system/write_operations/CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ from pre-computed item sizes. Partial failure is supported: if some items fail,
104104
| `write-complete` | Operation finished successfully |
105105
| `write-cancelled` | Operation cancelled (includes `rolled_back` flag) |
106106
| `write-error` | Operation failed |
107+
| `write-source-item-done` | All files for a top-level source item processed (for gradual deselection) |
107108
| `dry-run-complete` | `config.dry_run == true` (returns `DryRunResult`) |
108109
| `scan-preview-progress` | During `start_scan_preview` |
109110
| `scan-preview-complete` | Preview scan finished |

apps/desktop/src-tauri/src/file_system/write_operations/copy.rs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ use super::copy_strategy::copy_file_with_strategy;
1515
use super::helpers::{
1616
is_same_file, resolve_conflict, run_cancellable, spawn_async_sync, validate_disk_space, validate_path_length,
1717
};
18-
use super::scan::{handle_dry_run, scan_sources, take_cached_scan_result};
18+
use super::scan::{SourceItemTracker, handle_dry_run, scan_sources, take_cached_scan_result};
1919
use super::state::{CopyTransaction, WriteOperationState, update_operation_status};
2020
use super::types::{
2121
ConflictResolution, WriteCancelledEvent, WriteCompleteEvent, WriteErrorEvent, WriteOperationConfig,
22-
WriteOperationError, WriteOperationPhase, WriteOperationType, WriteProgressEvent,
22+
WriteOperationError, WriteOperationPhase, WriteOperationType, WriteProgressEvent, WriteSourceItemDoneEvent,
2323
};
2424

2525
// ============================================================================
@@ -180,6 +180,8 @@ pub(super) fn copy_files_with_progress(
180180
scan_result.files.len()
181181
);
182182

183+
let mut tracker = SourceItemTracker::new(&scan_result.files);
184+
183185
let result: Result<(), WriteOperationError> = (|| {
184186
for file_info in &scan_result.files {
185187
log::debug!(
@@ -206,6 +208,16 @@ pub(super) fn copy_files_with_progress(
206208
&mut apply_to_all_resolution,
207209
&mut created_dirs,
208210
)?;
211+
212+
if let Some(source_path) = tracker.record(file_info) {
213+
let _ = app.emit(
214+
"write-source-item-done",
215+
WriteSourceItemDoneEvent {
216+
operation_id: operation_id.to_string(),
217+
source_path: source_path.display().to_string(),
218+
},
219+
);
220+
}
209221
}
210222
Ok(())
211223
})();

apps/desktop/src-tauri/src/file_system/write_operations/delete.rs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ use std::sync::atomic::Ordering;
77
use std::time::Instant;
88

99
use super::helpers::spawn_async_sync;
10-
use super::scan::scan_sources;
10+
use super::scan::{SourceItemTracker, scan_sources};
1111
use super::state::{WriteOperationState, update_operation_status};
1212
use super::types::{
1313
DryRunResult, WriteCancelledEvent, WriteCompleteEvent, WriteOperationConfig, WriteOperationError,
14-
WriteOperationPhase, WriteOperationType, WriteProgressEvent,
14+
WriteOperationPhase, WriteOperationType, WriteProgressEvent, WriteSourceItemDoneEvent,
1515
};
1616
use crate::file_system::volume::Volume;
1717

@@ -60,6 +60,8 @@ pub(super) fn delete_files_with_progress(
6060
let mut bytes_done = 0u64;
6161
let mut last_progress_time = Instant::now();
6262

63+
let mut tracker = SourceItemTracker::new(&scan_result.files);
64+
6365
// Delete files
6466
for file_info in &scan_result.files {
6567
// Check cancellation
@@ -89,6 +91,16 @@ pub(super) fn delete_files_with_progress(
8991
files_done += 1;
9092
bytes_done += file_size;
9193

94+
if let Some(source_path) = tracker.record(file_info) {
95+
let _ = app.emit(
96+
"write-source-item-done",
97+
WriteSourceItemDoneEvent {
98+
operation_id: operation_id.to_string(),
99+
source_path: source_path.display().to_string(),
100+
},
101+
);
102+
}
103+
92104
// Emit progress
93105
if last_progress_time.elapsed() >= state.progress_interval {
94106
let current_file = file_info.path.file_name().map(|n| n.to_string_lossy().to_string());

0 commit comments

Comments
 (0)