1mod archive;
4mod background;
5mod bulk_edit;
6mod commands;
7mod constants;
8mod deletion;
9mod duplicate_filter;
10#[cfg(test)]
11mod harness;
12pub mod input;
13#[cfg(test)]
14mod journeys;
15mod keys;
16mod listing;
17mod mouse;
18mod navigate;
19mod operations;
20mod plugins;
21mod previews;
22mod render;
23mod scanning;
24mod searching;
25mod selection;
26pub mod state;
27
28use std::collections::{HashMap, HashSet};
29use std::path::{Path, PathBuf};
30use std::sync::Arc;
31use std::time::Duration;
32
33use crossterm::event::EnableMouseCapture;
34use crossterm::execute;
35use ratatui::{DefaultTerminal, Frame};
36use tokio::sync::{RwLock, mpsc, oneshot};
37use tokio_util::sync::CancellationToken;
38
39use gravityfile_analyze::{AgeReport, DuplicateReport};
40use gravityfile_core::FileTree;
41use gravityfile_ops::{ConflictRequest, OperationProgress, UndoLog};
42use gravityfile_scan::ScanProgress;
43
44use crate::TuiConfig;
45use crate::preview::{ImageContext, PreviewContent, PreviewMode, PreviewState};
46use crate::search::SearchState;
47use crate::theme::Theme;
48
49use self::archive::{ArchiveCancellation, ArchiveCompletion};
50use self::commands::CommandInput;
51use self::constants::TICK_INTERVAL_MS;
52use self::input::InputState;
53use self::state::{
54 AppMode, BookmarkListState, ClipboardMode, ClipboardState, DeletionProgress,
55 DuplicatesViewState, LayoutMode, ScanResult, SelectedInfo, SettingsState, SortMode, TabManager,
56 UserSettings, View,
57};
58
59pub type AppResult<T> = color_eyre::Result<T>;
61
62struct PreviewLoad {
63 path: PathBuf,
64 mode: PreviewMode,
65 result: Result<PreviewContent, crate::preview::PreviewError>,
66}
67
68struct PreviewRequest {
69 path: PathBuf,
70 mode: PreviewMode,
71 image_context: Option<ImageContext>,
72}
73
74fn quick_list_with_settings(
75 path: &Path,
76 settings: &UserSettings,
77 apparent_size: bool,
78) -> Result<FileTree, gravityfile_scan::ScanError> {
79 let mut config = gravityfile_scan::ScanConfig::new(path);
80 config.apparent_size = apparent_size;
81 config.include_hidden = settings.show_hidden;
82 config.max_entries = settings.max_scan_entries;
83 gravityfile_scan::quick_list(path, Some(&config))
84}
85
86enum Background {
89 Scan(Option<ScanResult>),
90 Operation(Option<ScanResult>),
91 Take(Option<ScanResult>),
92 Preview(Option<PreviewLoad>),
93 Archive(Option<ArchiveCompletion>),
94 Refresh(Option<Vec<state::Refreshed>>),
95 Listed(Option<state::Listed>),
96 History(Option<Option<gravityfile_core::history::Record>>),
97 Hooks(Option<Vec<gravityfile_plugin::HookResult>>),
98}
99
100pub struct App {
102 keymap: crate::keymap::Keymap,
104 apparent_size: bool,
106 snapshot: Option<PathBuf>,
108 path: PathBuf,
110 nav: state::Navigation,
113 mode: AppMode,
115 view: View,
117 theme: Theme,
119 image_picker: Option<ratatui_image::picker::Picker>,
124 preview_area: std::cell::Cell<ratatui::layout::Rect>,
129 rendered_offsets: std::cell::Cell<(usize, usize)>,
133 help_scroll: usize,
135 help_max_scroll: std::cell::Cell<usize>,
137 clicks: crate::event::ClickTracker,
139 tree: Option<Arc<FileTree>>,
146 cached_tree_len: usize,
148 duplicates: Option<Arc<DuplicateReport>>,
150 duplicate_filter: duplicate_filter::DuplicateFilter,
151 age_report: Option<AgeReport>,
153 duplicates_state: DuplicatesViewState,
155 selected_stale_dir: usize,
157 show_details: bool,
159 error: Option<String>,
161 marked: HashSet<PathBuf>,
163 clipboard: ClipboardState,
165 active_paste_mode: ClipboardMode,
166 delete_auto_marked: bool,
169 undo_log: UndoLog,
171 replace_policy: gravityfile_ops::ReplacePolicy,
173 delete_mode: gravityfile_ops::DeleteMode,
175 layout_mode: LayoutMode,
177 cached_miller_len: usize,
179 input_state: Option<InputState>,
181 operation_progress: Option<OperationProgress>,
183 pending_conflict: Option<ConflictRequest>,
185 operation_message: Option<(bool, String)>,
187 deletion_message: Option<(bool, String)>,
189 deletion_progress: Option<DeletionProgress>,
191 scan_progress: Option<ScanProgress>,
193 scan_rx: Option<mpsc::Receiver<ScanResult>>,
195 take_rx: Option<mpsc::Receiver<ScanResult>>,
196 scan_token: CancellationToken,
198 op_rx: Option<mpsc::Receiver<ScanResult>>,
201 op_token: CancellationToken,
203 warnings: Vec<gravityfile_core::ScanWarning>,
205 operation_warnings: Vec<gravityfile_core::ScanWarning>,
207 selected_warning: usize,
209 analyzing: bool,
211 has_full_scan: bool,
213 needs_redraw: bool,
215 command_input: CommandInput,
217 sort_mode: SortMode,
219 search_state: SearchState,
221 tab_manager: TabManager,
223 preview_state: PreviewState,
225 preview_rx: Option<oneshot::Receiver<PreviewLoad>>,
227 pending_preview: Option<PreviewRequest>,
228 archive_rx: Option<oneshot::Receiver<ArchiveCompletion>>,
230 archive_cancel: Option<ArchiveCancellation>,
231 refresh_rx: Option<oneshot::Receiver<Vec<state::Refreshed>>>,
233 queued_refresh: Vec<PathBuf>,
235 scan_on_startup: bool,
237 user_settings: UserSettings,
239 settings_state: Option<SettingsState>,
241 bookmark_list_state: Option<BookmarkListState>,
243 listing_tx: mpsc::UnboundedSender<state::Listed>,
245 listing_rx: mpsc::UnboundedReceiver<state::Listed>,
246 listings: HashSet<(state::ListingFor, PathBuf)>,
248 history_file: Option<PathBuf>,
250 history_rx: Option<oneshot::Receiver<Option<gravityfile_core::history::Record>>>,
251 history_baseline: Option<gravityfile_core::history::Record>,
253 cached_parent_tree: Option<FileTree>,
256 pending_suspend_command: Option<std::process::Command>,
258 pending_deletion: Option<Vec<(PathBuf, u64)>>,
261 scanned_cache: Option<HashMap<PathBuf, Arc<FileTree>>>,
265 bulk_rename_state: Option<state::BulkRenameState>,
267 bulk_rename_edit: Option<bulk_edit::BulkRenameEdit>,
268 cached_treemap_len: usize,
270 visual_state: Option<state::VisualState>,
272 plugin_manager: Option<Arc<RwLock<gravityfile_plugin::PluginManager>>>,
274 plugins_loaded: bool,
276 hook_tx: mpsc::UnboundedSender<Vec<gravityfile_plugin::HookResult>>,
278 hook_rx: mpsc::UnboundedReceiver<Vec<gravityfile_plugin::HookResult>>,
279 hooks_in_flight: usize,
281 viewport_height: usize,
284}
285
286impl App {
287 pub fn new(path: PathBuf) -> Self {
290 Self::with_config(path, TuiConfig::default())
291 }
292
293 pub fn with_config(path: PathBuf, config: TuiConfig) -> Self {
297 Self::with_loaded_settings(path, config, UserSettings::try_load())
298 }
299
300 fn with_loaded_settings(
301 path: PathBuf,
302 mut config: TuiConfig,
303 loaded: std::io::Result<UserSettings>,
304 ) -> Self {
305 let (user_settings, settings_error) = match loaded {
306 Ok(settings) => (settings, None),
307 Err(error) => (
308 UserSettings {
309 scan_on_startup: false,
310 ..UserSettings::default()
311 },
312 Some(format!(
313 "Cannot load settings; automatic scanning disabled unless explicitly requested: {error}"
314 )),
315 ),
316 };
317
318 let snapshot = config.snapshot.take();
321 let snapshot_source = snapshot.as_ref().map(|snapshot| snapshot.source.clone());
322 let is_snapshot = snapshot_source.is_some();
323 let (quick_tree, listing_error) = match snapshot {
324 Some(snapshot) => (Some(snapshot.tree), None),
325 None => match quick_list_with_settings(&path, &user_settings, config.apparent_size) {
326 Ok(tree) => (Some(tree), None),
327 Err(error) => (
328 None,
329 Some(format!("Cannot list {}: {error}", path.display())),
330 ),
331 },
332 };
333
334 let path = quick_tree
338 .as_ref()
339 .map_or(path, |tree| tree.root_path.clone());
340
341 let scan_on_startup = !is_snapshot
343 && config
344 .scan_on_startup
345 .unwrap_or(user_settings.scan_on_startup);
346
347 let plugin_config =
351 gravityfile_plugin::PluginConfig::default().with_fs_read_roots(vec![path.clone()]);
352 let mut pm_inner = gravityfile_plugin::PluginManager::new(plugin_config);
353 if let Some(executable) = &config.plugin_worker_executable
354 && let Err(error) = gravityfile_plugin::ProcessRuntime::new(executable.clone())
355 .and_then(|runtime| pm_inner.register_runtime(Box::new(runtime)))
356 {
357 tracing::error!(%error, "Plugin worker registration failed");
358 }
359
360 let plugin_manager = Arc::new(RwLock::new(pm_inner));
361 let (listing_tx, listing_rx) = mpsc::unbounded_channel();
362 let (hook_tx, hook_rx) = mpsc::unbounded_channel();
363 let history_file = (!cfg!(test) && !is_snapshot && user_settings.record_history)
365 .then(gravityfile_core::paths::Dirs::discover)
366 .flatten()
367 .map(|dirs| dirs.history_file());
368 let (theme, theme_error) = match Theme::named(&user_settings.theme) {
369 Some(theme) => (theme, None),
370 None => (
371 Theme::named("gestalt").unwrap_or_default(),
372 Some(format!(
373 "Unknown theme {:?}; use gestalt, latte, nord, dracula or ansi",
374 user_settings.theme
375 )),
376 ),
377 };
378 let (keymap, keymap_problems) = crate::keymap::Keymap::load();
379 let keymap_error = (!keymap_problems.is_empty())
380 .then(|| format!("Ignored keymap entries: {}", keymap_problems.join("; ")));
381
382 let mut app = Self {
383 keymap,
384 apparent_size: config.apparent_size,
385 snapshot: snapshot_source,
386 path: path.clone(),
387 nav: state::Navigation::new(path.clone()),
388 mode: AppMode::default(),
389 view: View::default(),
390 theme,
391 image_picker: None,
392 preview_area: std::cell::Cell::new(ratatui::layout::Rect::new(0, 0, 40, 20)),
393 rendered_offsets: std::cell::Cell::new((0, 0)),
394 help_scroll: 0,
395 help_max_scroll: std::cell::Cell::new(0),
396 clicks: crate::event::ClickTracker::default(),
397 tree: quick_tree.map(Arc::new),
398 cached_tree_len: 0,
399 duplicates: None,
400 duplicate_filter: duplicate_filter::DuplicateFilter::default(),
401 age_report: None,
402 duplicates_state: DuplicatesViewState::new(),
403 selected_stale_dir: 0,
404 show_details: true,
405 error: [settings_error, theme_error, keymap_error, listing_error]
406 .into_iter()
407 .flatten()
408 .reduce(|first, next| format!("{first}; {next}")),
409 marked: HashSet::new(),
410 clipboard: ClipboardState::default(),
411 active_paste_mode: ClipboardMode::Empty,
412 delete_auto_marked: false,
413 undo_log: UndoLog::new(100),
414 replace_policy: config.replace_policy,
415 delete_mode: config.delete_mode,
416 layout_mode: user_settings.default_layout,
417 cached_miller_len: 0,
418 input_state: None,
419 operation_progress: None,
420 pending_conflict: None,
421 operation_message: None,
422 deletion_message: None,
423 deletion_progress: None,
424 scan_progress: None,
425 scan_rx: None,
426 take_rx: None,
427 scan_token: CancellationToken::new(),
428 op_rx: None,
429 op_token: CancellationToken::new(),
430 warnings: Vec::new(),
431 operation_warnings: Vec::new(),
432 selected_warning: 0,
433 analyzing: false,
434 has_full_scan: is_snapshot,
435 needs_redraw: true,
436 command_input: CommandInput::new(),
437 sort_mode: SortMode::default(),
438 search_state: SearchState::new(),
439 tab_manager: TabManager::new(10),
440 preview_state: PreviewState::new(),
441 preview_rx: None,
442 pending_preview: None,
443 archive_rx: None,
444 archive_cancel: None,
445 refresh_rx: None,
446 queued_refresh: Vec::new(),
447 scan_on_startup,
448 user_settings,
449 settings_state: None,
450 bookmark_list_state: None,
451 history_file,
452 history_rx: None,
453 history_baseline: None,
454 listing_tx,
455 listing_rx,
456 listings: HashSet::new(),
457 cached_parent_tree: None,
458 pending_suspend_command: None,
459 pending_deletion: None,
460 scanned_cache: Some(HashMap::new()),
461 bulk_rename_state: None,
462 bulk_rename_edit: None,
463 cached_treemap_len: 0,
464 visual_state: None,
465 plugin_manager: Some(plugin_manager),
466 plugins_loaded: false,
467 hook_tx,
468 hook_rx,
469 hooks_in_flight: 0,
470 viewport_height: 40, };
472
473 app.update_cached_tree_len();
475 app.update_cached_miller_len();
476 app.update_cached_treemap_len();
477 app.update_cached_parent();
479
480 app
481 }
482
483 pub(crate) fn image_previews_enabled(&self) -> bool {
484 self.user_settings.image_previews
485 }
486
487 pub fn with_image_picker(mut self, picker: Option<ratatui_image::picker::Picker>) -> Self {
492 self.image_picker = picker;
493 self
494 }
495
496 pub async fn run(self, terminal: DefaultTerminal) -> AppResult<()> {
498 self.run_and_return_exit_data(terminal).await.map(|_| ())
499 }
500
501 pub async fn run_and_return_exit_data(
503 mut self,
504 mut terminal: DefaultTerminal,
505 ) -> AppResult<crate::ExitData> {
506 self.search_state.enable_background();
507 self.init_plugins().await;
508 self.fire(gravityfile_plugin::Hook::OnStartup);
509
510 if self.scan_on_startup {
512 self.start_scan();
513 }
514 let period = Duration::from_millis(TICK_INTERVAL_MS);
517 let mut interval = tokio::time::interval(period);
518 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
520 let mut events = crate::event_source::EventSource::new()?;
521
522 while self.mode != AppMode::Quit {
523 if self.needs_redraw {
524 terminal.draw(|frame| self.draw(frame))?;
525 self.adopt_rendered_offsets();
526 self.needs_redraw = false;
527 }
528
529 let old_view_root = self.nav.view_root.clone();
530
531 tokio::select! {
534 event = events.next() => {
535 let event = event
536 .ok_or_else(|| color_eyre::eyre::eyre!("terminal event stream closed"))??;
537 self.handle_terminal_event(event);
538
539 for _ in 0..32 {
542 if self.mode == AppMode::Quit {
543 break;
544 }
545 match events.try_next() {
546 Ok(Ok(event)) => self.handle_terminal_event(event),
547 Ok(Err(error)) => return Err(error.into()),
548 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
549 return Err(color_eyre::eyre::eyre!(
550 "terminal event stream closed"
551 ));
552 }
553 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
554 }
555 }
556 self.needs_redraw = true;
557 }
558
559 background = self.next_background(), if self.has_background() => {
560 self.handle_background(background);
561 }
562
563 _ = interval.tick(), if self.scan_progress.is_some() || self.deletion_progress.is_some() || self.search_state.has_background_work() => {
564 self.search_state.poll_background();
566 self.needs_redraw = true;
567 }
568 }
569
570 if self.mode == AppMode::Quit {
572 break;
573 }
574
575 self.after_input(&old_view_root).await;
576
577 if let Some(mut cmd) = self.pending_suspend_command.take() {
579 let is_bulk_rename = self.bulk_rename_edit.is_some();
581
582 drop(events);
586 crate::terminal_session::restore_terminal()?;
587
588 let command_result = cmd.status();
590
591 terminal = ratatui::try_init()?;
593 execute!(std::io::stdout(), EnableMouseCapture)?;
594 events = crate::event_source::EventSource::new()?;
595
596 let command_succeeded =
597 command_result.as_ref().is_ok_and(|status| status.success());
598 match command_result {
599 Ok(status) if status.success() => {}
600 Ok(status) => {
601 self.error = Some(format!("External command exited with status {status}"));
602 }
603 Err(error) => {
604 self.error = Some(format!("Failed to run external command: {error}"));
605 }
606 }
607
608 if is_bulk_rename {
610 if command_succeeded {
611 self.process_bulk_rename_from_editor();
612 } else {
613 self.bulk_rename_edit = None;
614 }
615 }
616
617 self.needs_redraw = true;
618 }
619 }
620
621 self.dispatch_and_wait(gravityfile_plugin::Hook::OnShutdown)
622 .await;
623
624 self.scan_token.cancel();
625 self.op_token.cancel();
626 self.cancel_archive_operation();
627
628 let tree = self.tree.take();
631 let cache = self.scanned_cache.take();
632 let duplicates = self.duplicates.take();
633 let age_report = self.age_report.take();
634 let pm = self.plugin_manager.take();
635 let parent_tree = self.cached_parent_tree.take();
636
637 tokio::spawn(async move {
638 drop(tree);
639 drop(cache);
640 drop(duplicates);
641 drop(age_report);
642 drop(pm);
643 drop(parent_tree);
644 });
645
646 Ok(crate::ExitData {
647 last_cwd: self.snapshot.is_none().then_some(self.nav.view_root),
648 })
649 }
650
651 async fn after_input(&mut self, old_view_root: &Path) {
654 self.check_navigation(old_view_root);
655 if let Some(items) = self.pending_deletion.take() {
656 self.finalize_deletion(items).await;
657 self.needs_redraw = true;
658 }
659 }
660
661 fn warning_count(&self) -> usize {
662 self.operation_warnings.len() + self.warnings.len()
663 }
664
665 fn displayed_warning(&self, index: usize) -> Option<&gravityfile_core::ScanWarning> {
666 if index < self.operation_warnings.len() {
667 self.operation_warnings.get(index)
668 } else {
669 self.warnings.get(index - self.operation_warnings.len())
670 }
671 }
672
673 fn draw(&mut self, frame: &mut Frame) {
675 let chrome_rows: u16 = if self.tab_manager.len() > 1 { 5 } else { 4 };
678 let h = frame.area().height.saturating_sub(chrome_rows) as usize;
679 if h > 0 {
680 self.viewport_height = h;
681 }
682 frame.render_widget(&*self, frame.area());
683 }
684
685 fn open_selected_file(&mut self) {
688 let Some(path) = self.get_selected_path() else {
689 return;
690 };
691
692 let openers = &self.user_settings.openers;
694 let editor_config = &self.user_settings.editor;
695
696 match crate::opener::open_file(&path, openers, editor_config) {
697 crate::opener::OpenResult::Opened => {
698 }
700 crate::opener::OpenResult::NeedsSuspend(cmd) => {
701 self.suspend_and_run(cmd);
703 }
704 crate::opener::OpenResult::Error(msg) => {
705 self.error = Some(msg);
706 }
707 }
708 }
709
710 fn suspend_and_run(&mut self, cmd: std::process::Command) {
712 self.pending_suspend_command = Some(cmd);
716 }
717
718 pub fn is_marked(&self, path: &PathBuf) -> bool {
720 self.marked.contains(path)
721 }
722
723 fn save_settings_with(&mut self, persist: impl FnOnce(&UserSettings) -> std::io::Result<()>) {
725 if let Some(state) = &self.settings_state
726 && state.dirty
727 {
728 if let Err(error) = persist(&state.settings) {
729 self.error = Some(format!("Failed to save settings: {error}"));
730 self.needs_redraw = true;
731 return;
732 }
733 self.user_settings = state.settings.clone();
734 self.scan_on_startup = self.user_settings.scan_on_startup;
735 if let Some(theme) = Theme::named(&self.user_settings.theme) {
736 self.theme = theme;
737 }
738 }
739 self.settings_state = None;
740 self.mode = AppMode::Normal;
741 self.needs_redraw = true;
742 }
743
744 fn get_filtered_duplicates(&self) -> Option<(Vec<&gravityfile_analyze::DuplicateGroup>, u64)> {
746 let report = self.duplicates.as_ref()?;
747 let cached = self
748 .duplicate_filter
749 .get(report, &self.nav.view_root, &self.path);
750 Some((
751 cached
752 .groups
753 .iter()
754 .map(|(index, _)| &report.groups[*index])
755 .collect(),
756 cached.wasted,
757 ))
758 }
759
760 fn filtered_duplicate_path_count(&self, index: usize) -> usize {
761 let Some(report) = &self.duplicates else {
762 return 0;
763 };
764 self.duplicate_filter
765 .get(report, &self.nav.view_root, &self.path)
766 .groups
767 .get(index)
768 .map_or(0, |(_, count)| *count)
769 }
770
771 fn get_filtered_stale_dirs(&self) -> Option<Vec<&gravityfile_analyze::StaleDirectory>> {
773 let age = self.age_report.as_ref()?;
774
775 if self.nav.view_root == self.path {
776 return Some(age.stale_directories.iter().collect());
777 }
778
779 let filtered: Vec<&gravityfile_analyze::StaleDirectory> = age
780 .stale_directories
781 .iter()
782 .filter(|d| d.path.starts_with(&self.nav.view_root))
783 .collect();
784
785 Some(filtered)
786 }
787}
788
789#[cfg(test)]
790mod tests {
791 use super::*;
792 use crate::event::{KeyAction, MouseAction};
793 use crate::test_support::sample_dir_on_disk;
794 use crate::ui::TreeView;
795 use gravityfile_core::FileNode;
796 use ratatui::buffer::Buffer;
797 use ratatui::layout::Rect;
798 use ratatui::widgets::Widget;
799 use state::{AppMode, LayoutMode, ScanResult, SortMode, View};
800
801 #[test]
802 fn all_explorer_sort_modes_handle_deep_trees() {
803 use gravityfile_core::{FileNode, Timestamps};
804 let timestamps = Timestamps::with_modified(std::time::UNIX_EPOCH);
805 let mut root = FileNode::new_directory("d", timestamps);
806 for _ in 0..20_000 {
807 let mut parent = FileNode::new_directory("d", timestamps);
808 parent.children.push(crate::test_support::file("z", 10));
809 let mut file = crate::test_support::file("z", 10);
810 file.timestamps.modified = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1);
811 parent.children.push(file);
812 parent.children.push(root);
813 root = parent;
814 }
815 let mut all_sorted = true;
816 for mode in [
817 SortMode::SizeDescending,
818 SortMode::SizeAscending,
819 SortMode::NameAscending,
820 SortMode::NameDescending,
821 SortMode::ModifiedDescending,
822 SortMode::ModifiedAscending,
823 SortMode::CountDescending,
824 SortMode::CountAscending,
825 ] {
826 App::sort_node_tree(&mut root, mode);
827 let mut current = &root;
828 while current.children.len() == 2 {
829 let directory = current.children.iter().find(|node| node.is_dir()).unwrap();
830 let file_first = match mode {
831 SortMode::SizeDescending
832 | SortMode::NameDescending
833 | SortMode::ModifiedDescending => true,
834 SortMode::CountAscending => !directory.children.is_empty(),
835 _ => false,
836 };
837 all_sorted &= current.children[0].is_file() == file_first;
838 current = directory;
839 }
840 }
841 let mut pending = std::mem::take(&mut root.children);
842 while let Some(mut node) = pending.pop() {
843 pending.append(&mut node.children);
844 }
845 assert!(all_sorted);
846 }
847
848 #[cfg(unix)]
849 #[test]
850 fn native_name_ties_sort_consistently_across_explorer_modes() {
851 use std::os::unix::ffi::OsStringExt;
852 let low = std::ffi::OsString::from_vec(b"same-\xfe".to_vec());
853 let high = std::ffi::OsString::from_vec(b"same-\xff".to_vec());
854 let mut a = crate::test_support::file("", 10);
855 a.set_file_name(&low);
856 let mut b = a.clone();
857 b.set_file_name(&high);
858 assert_eq!(a.name, b.name);
859 for mode in [
860 SortMode::SizeDescending,
861 SortMode::SizeAscending,
862 SortMode::NameAscending,
863 SortMode::NameDescending,
864 SortMode::ModifiedDescending,
865 SortMode::ModifiedAscending,
866 SortMode::CountDescending,
867 SortMode::CountAscending,
868 ] {
869 for children in [vec![a.clone(), b.clone()], vec![b.clone(), a.clone()]] {
870 let mut root = crate::test_support::directory("root", children);
871 App::sort_node_tree(&mut root, mode);
872 let expected = if matches!(mode, SortMode::NameDescending) {
873 &high
874 } else {
875 &low
876 };
877 assert_eq!(root.children[0].file_name(), expected);
878 root.sort_children_by_size();
879 assert_eq!(root.children[0].file_name(), &low);
880 }
881 }
882 let fixture = tempfile::tempdir().unwrap();
883 let mut app = App::with_loaded_settings(
884 fixture.path().into(),
885 crate::TuiConfig::default(),
886 Ok(UserSettings::default()),
887 );
888 let tree = Arc::make_mut(app.tree.as_mut().unwrap());
889 tree.root = crate::test_support::directory("root", vec![b, a]);
890 let rects = crate::ui::TreemapView::new(
891 &tree.root,
892 &tree.root_path,
893 &app.theme,
894 &app.marked,
895 &app.clipboard,
896 )
897 .calculate_rects(Rect::new(0, 0, 80, 24));
898 assert_eq!(rects.len(), 2);
899 for (index, expected) in [low, high].iter().enumerate() {
900 app.nav.treemap_state.selected = index;
901 let (_, selected) = app.get_selected_treemap_child().unwrap();
902 assert_eq!(selected, app.nav.view_root.join(expected));
903 assert_eq!(rects[index].path, selected);
904 }
905 }
906
907 #[cfg(unix)]
908 #[test]
909 fn native_name_collisions_keep_selection_lookup_and_search_paths_distinct() {
910 use std::os::unix::ffi::OsStringExt;
911 let fixture = tempfile::tempdir().unwrap();
912 let mut app = App::with_loaded_settings(
913 fixture.path().into(),
914 crate::TuiConfig::default(),
915 Ok(UserSettings::default()),
916 );
917 let names = [
918 std::ffi::OsString::from_vec(b"bad-\xff".to_vec()),
919 std::ffi::OsString::from_vec(b"bad-\xfe".to_vec()),
920 ];
921 let tree = Arc::make_mut(app.tree.as_mut().unwrap());
922 for name in &names {
923 let mut child = crate::test_support::file("", 1);
924 child.set_file_name(name);
925 tree.root.children.push(child);
926 }
927 assert_eq!(tree.root.children[0].name, tree.root.children[1].name);
928 assert_ne!(
929 tree.root.children[0].display_name(),
930 tree.root.children[1].display_name()
931 );
932 let search = App::collect_search_paths(
933 tree,
934 &tree.root_path,
935 &std::sync::atomic::AtomicBool::new(false),
936 );
937 for name in &names {
938 assert!(
939 search
940 .iter()
941 .any(|entry| entry.path == tree.root_path.join(name))
942 );
943 }
944 app.layout_mode = LayoutMode::Miller;
945 for (index, name) in names.iter().enumerate() {
946 app.nav.miller_state.selected = index;
947 let path = app.nav.view_root.join(name);
948 assert_eq!(app.get_selected_path(), Some(path.clone()));
949 let tree = app.tree.as_ref().unwrap();
950 assert_eq!(
951 App::find_node_at_path(&tree.root, &path, &tree.root_path)
952 .unwrap()
953 .file_name(),
954 name
955 );
956 app.select_entry(Some(&path));
957 assert_eq!(app.nav.miller_state.selected, index);
958 }
959 let displayed = text(&render(&app, Rect::new(0, 0, 200, 30)));
960 assert!(displayed.contains(r"bad-\xff"), "{displayed}");
961 assert!(displayed.contains(r"bad-\xfe"), "{displayed}");
962 }
963
964 #[tokio::test]
965 async fn saved_entry_budget_reaches_startup_navigation_and_rescans() {
966 let fixture = tempfile::tempdir().unwrap();
967 std::fs::create_dir(fixture.path().join("nested")).unwrap();
968 std::fs::write(fixture.path().join("nested/one"), b"one").unwrap();
969 std::fs::write(fixture.path().join("nested/two"), b"two").unwrap();
970 let settings = UserSettings {
971 max_scan_entries: Some(1),
972 ..UserSettings::default()
973 };
974 let mut app = App::with_loaded_settings(
975 fixture.path().into(),
976 crate::TuiConfig::default(),
977 Ok(settings),
978 );
979 assert_eq!(app.tree.as_ref().unwrap().config.max_entries, Some(1));
980 assert!(matches!(
981 quick_list_with_settings(
982 &fixture.path().join("nested"),
983 &app.user_settings,
984 app.apparent_size,
985 ),
986 Err(gravityfile_scan::ScanError::EntryLimitExceeded { limit: 1 })
987 ));
988 for _ in 0..2 {
989 app.start_scan();
990 let mut receiver = app.scan_rx.take().unwrap();
991 let result = tokio::time::timeout(std::time::Duration::from_secs(10), async {
992 loop {
993 if let ScanResult::ScanComplete(result) = receiver
994 .recv()
995 .await
996 .expect("scan worker closed without a final result")
997 {
998 break result;
999 }
1000 }
1001 })
1002 .await
1003 .unwrap();
1004 assert!(matches!(
1005 result,
1006 Err(gravityfile_scan::ScanError::EntryLimitExceeded { limit: 1 })
1007 ));
1008 app.handle_scan_result(ScanResult::ScanComplete(result));
1009 assert!(
1010 app.error
1011 .as_ref()
1012 .unwrap()
1013 .contains("Scan entry limit exceeded")
1014 );
1015 assert!(app.scan_progress.is_none());
1016 assert!(!app.analyzing);
1017 assert_eq!(app.tree.as_ref().unwrap().config.max_depth, Some(1));
1018 }
1019 let over_budget = App::with_loaded_settings(
1020 fixture.path().join("nested"),
1021 crate::TuiConfig::default(),
1022 Ok(app.user_settings.clone()),
1023 );
1024 assert!(over_budget.tree.is_none());
1025 assert!(
1026 over_budget
1027 .error
1028 .as_ref()
1029 .unwrap()
1030 .contains("Scan entry limit exceeded")
1031 );
1032 }
1033
1034 #[tokio::test]
1035 async fn preview_requests_coalesce_and_forced_reload_rejects_old_completion() {
1036 let fixture = tempfile::tempdir().unwrap();
1037 let path = fixture.path().join("latest.txt");
1038 std::fs::write(&path, b"fresh").unwrap();
1039 let mut app = App::with_loaded_settings(
1040 fixture.path().into(),
1041 crate::TuiConfig::default(),
1042 Ok(UserSettings::default()),
1043 );
1044 let (sender, receiver) = oneshot::channel();
1045 app.preview_rx = Some(receiver); for index in 0..1000 {
1047 app.schedule_preview(fixture.path().join(format!("obsolete-{index}")), true);
1048 }
1049 app.schedule_preview(path.clone(), true);
1050 assert_eq!(app.pending_preview.as_ref().unwrap().path, path);
1051 assert!(!sender.is_closed()); sender
1053 .send(PreviewLoad {
1054 path: path.clone(),
1055 mode: app.preview_state.mode,
1056 result: Ok(PreviewContent::Error("stale".into())),
1057 })
1058 .unwrap_or_else(|_| panic!("receiver lost"));
1059 let old = app.preview_rx.take().unwrap().await.unwrap();
1060 app.finish_preview(Some(old));
1061 assert!(matches!(app.preview_state.content, PreviewContent::Loading));
1062 assert!(app.pending_preview.is_none());
1063 let latest = tokio::time::timeout(
1064 std::time::Duration::from_secs(5),
1065 app.preview_rx.take().unwrap(),
1066 )
1067 .await
1068 .unwrap()
1069 .unwrap();
1070 assert_eq!(latest.path, path);
1071 app.finish_preview(Some(latest));
1072 let PreviewContent::Text { lines, .. } = &app.preview_state.content else {
1073 panic!("latest preview not installed")
1074 };
1075 assert_eq!(lines[0].to_string(), "fresh");
1076 assert!(!app.preview_state.loading);
1077 }
1078
1079 #[tokio::test]
1080 async fn clearing_preview_retains_active_slot_and_recovers_from_worker_loss() {
1081 let fixture = tempfile::tempdir().unwrap();
1082 let path = fixture.path().join("latest.txt");
1083 std::fs::write(&path, b"fresh").unwrap();
1084 let mut app = App::with_loaded_settings(
1085 fixture.path().into(),
1086 crate::TuiConfig::default(),
1087 Ok(UserSettings::default()),
1088 );
1089 let (sender, receiver) = oneshot::channel();
1090 app.preview_rx = Some(receiver);
1091 app.schedule_preview(path.clone(), false);
1092 app.clear_preview();
1093 assert!(app.preview_rx.is_some());
1094 assert!(app.pending_preview.is_none());
1095 assert!(matches!(app.preview_state.content, PreviewContent::Empty));
1096 app.schedule_preview(path, false);
1097 assert!(!sender.is_closed());
1098 drop(sender);
1099 assert!(app.preview_rx.take().unwrap().await.is_err());
1100 app.finish_preview(None);
1101 let latest = tokio::time::timeout(
1102 std::time::Duration::from_secs(5),
1103 app.preview_rx.take().unwrap(),
1104 )
1105 .await
1106 .unwrap()
1107 .unwrap();
1108 app.finish_preview(Some(latest));
1109 assert!(matches!(
1110 app.preview_state.content,
1111 PreviewContent::Text { .. }
1112 ));
1113 app.preview_state.loading = true;
1114 app.finish_preview(None);
1115 assert!(matches!(
1116 app.preview_state.content,
1117 PreviewContent::Error(_)
1118 ));
1119 assert!(!app.preview_state.loading);
1120 }
1121
1122 #[test]
1123 #[ignore = "manual optimized background search measurement"]
1124 fn background_search_scale_benchmark() {
1125 use crate::test_support::{directory, file};
1126 use std::time::Instant;
1127 let mut measurements = Vec::new();
1128 for count in [100_000, 1_000_000] {
1129 let fixture_started = Instant::now();
1130 let root = sample_dir_on_disk();
1131 let mut app = App::with_config(
1132 dunce::canonicalize(root.path()).unwrap(),
1133 crate::TuiConfig::default(),
1134 );
1135 Arc::make_mut(app.tree.as_mut().unwrap()).root = directory(
1136 "root",
1137 (1..=count)
1138 .map(|index| file(&format!("report-{index:07}.rs"), 1))
1139 .collect(),
1140 );
1141 app.search_state.enable_background();
1142 let fixture_ms = fixture_started.elapsed().as_secs_f64() * 1000.0;
1143 for sample in 0..3 {
1144 let started = Instant::now();
1145 app.start_search();
1146 let activation_ms = started.elapsed().as_secs_f64() * 1000.0;
1147 let submit_started = Instant::now();
1148 app.search_state.set_query("rpt".into());
1149 let submit_ms = submit_started.elapsed().as_secs_f64() * 1000.0;
1150 let mut poll_max_ms = 0.0_f64;
1151 let mut polls = 0;
1152 while app.search_state.has_background_work() {
1153 let poll_started = Instant::now();
1154 app.search_state.poll_background();
1155 poll_max_ms = poll_max_ms.max(poll_started.elapsed().as_secs_f64() * 1000.0);
1156 polls += 1;
1157 assert!(started.elapsed() < std::time::Duration::from_secs(30));
1158 std::thread::sleep(std::time::Duration::from_millis(1));
1159 }
1160 let completion_ms = started.elapsed().as_secs_f64() * 1000.0;
1161 assert!(app.search_state.error.is_none());
1162 assert_eq!(app.search_state.results.len(), 1000);
1163 assert!(
1164 app.search_state
1165 .results
1166 .iter()
1167 .all(|result| result.display.starts_with("report-"))
1168 );
1169 measurements.push(serde_json::json!({"paths": count, "sample": sample,
1170 "fixture_ms": fixture_ms, "activation_ms": activation_ms,
1171 "query_submission_ms": submit_ms, "max_poll_ms": poll_max_ms,
1172 "completion_ms": completion_ms, "polls": polls}));
1173 }
1174 }
1175 println!(
1176 "BACKGROUND_SEARCH_JSON={}",
1177 serde_json::json!({"schema_version": 1,
1178 "measurements": measurements, "limits": ["Synthetic flat FileNode tree; actual app index collector",
1179 "1 ms test polling, not the interactive event loop", "No terminal rendering or filesystem scan",
1180 "Fixture construction excluded from completion time", "Three samples; uncontrolled host load"]})
1181 );
1182 }
1183
1184 #[tokio::test]
1185 async fn incomplete_undo_requeues_the_remainder_for_retry() {
1186 let (_dir, mut app) = scanned_app();
1187 let remaining = gravityfile_ops::UndoEntry::new(
1188 7,
1189 gravityfile_ops::UndoableOperation::FilesMoved {
1190 moves: vec![(PathBuf::from("/a"), PathBuf::from("/b"))],
1191 },
1192 "Moved 2 item(s) (undo incomplete)",
1193 );
1194 app.handle_scan_result(ScanResult::OperationComplete {
1195 cancelled: false,
1196 operation_type: gravityfile_ops::OperationType::Move,
1197 succeeded: 1,
1198 failed: 1,
1199 skipped: 0,
1200 bytes_processed: 0,
1201 errors: vec![gravityfile_ops::OperationError::new(
1202 "/b".into(),
1203 "occupied",
1204 )],
1205 undo_info: None,
1206 undo_remaining: Some(remaining),
1207 touched: Vec::new(),
1208 });
1209 assert_eq!(app.undo_log.peek().unwrap().id, 7);
1210 assert!(app.error.as_deref().unwrap().contains("press u to retry"));
1211 let (success, message) = app.operation_message.clone().unwrap();
1212 assert!(!success && message.contains("undo incomplete"));
1213 }
1214
1215 #[tokio::test]
1216 async fn delete_and_marks_never_target_the_scan_root() {
1217 let (_dir, mut app) = scanned_app();
1218 app.nav.tree_state.selected = 0;
1219 assert_eq!(app.get_selected_path().unwrap(), app.nav.view_root);
1220 app.handle_action(KeyAction::Delete);
1221 assert!(app.marked.is_empty());
1222 assert_eq!(app.mode, AppMode::Normal);
1223 assert!(!app.operation_message.as_ref().unwrap().0);
1224 app.handle_action(KeyAction::ToggleMark);
1225 assert!(app.marked.is_empty());
1226 app.marked.insert(app.path.clone());
1227 app.marked.insert(app.path.parent().unwrap().to_path_buf());
1228 app.mode = AppMode::ConfirmDelete;
1229 app.handle_delete_confirmation(crossterm::event::KeyEvent::from(
1230 crossterm::event::KeyCode::Enter,
1231 ));
1232 assert!(
1233 app.pending_deletion.is_none(),
1234 "protected paths must be filtered out before deletion"
1235 );
1236 }
1237
1238 #[tokio::test]
1239 async fn declining_a_delete_keeps_hand_made_marks() {
1240 let (_dir, mut app) = scanned_app();
1241 let chosen = app.path.join("copy-a.bin");
1242 app.marked.insert(chosen.clone());
1243 app.handle_action(KeyAction::Delete);
1244 assert_eq!(app.mode, AppMode::ConfirmDelete);
1245 app.handle_delete_confirmation(crossterm::event::KeyEvent::from(
1246 crossterm::event::KeyCode::Esc,
1247 ));
1248 assert!(
1249 app.marked.contains(&chosen),
1250 "user marks survive a declined delete"
1251 );
1252
1253 app.marked.clear();
1254 app.nav.tree_state.selected = tree_row(&app, "copy-a.bin");
1255 app.handle_action(KeyAction::Delete);
1256 assert_eq!(app.mode, AppMode::ConfirmDelete);
1257 app.handle_delete_confirmation(crossterm::event::KeyEvent::from(
1258 crossterm::event::KeyCode::Esc,
1259 ));
1260 assert!(app.marked.is_empty(), "the automatic mark is dropped");
1261 }
1262
1263 #[tokio::test]
1264 async fn marking_a_duplicate_group_keeps_one_copy() {
1265 use gravityfile_analyze::{AnalysisStatus, DuplicateGroup, DuplicateReport};
1266 let (_dir, mut app) = scanned_app();
1267 let paths = vec![app.path.join("copy-a.bin"), app.path.join("copy-b.bin")];
1268 let report = Arc::new(DuplicateReport {
1269 status: AnalysisStatus::default(),
1270 groups: vec![DuplicateGroup {
1271 hash: gravityfile_core::ContentHash::new([0; 32]),
1272 size: 8192,
1273 paths: paths.clone(),
1274 wasted_bytes: 8192,
1275 }],
1276 total_duplicate_size: 16384,
1277 total_wasted_space: 8192,
1278 files_analyzed: 2,
1279 files_with_duplicates: 2,
1280 group_count: 1,
1281 groups_omitted: 0,
1282 });
1283 let counts =
1284 duplicate_filter::prepare_counts(&report, &app.path, &CancellationToken::new())
1285 .unwrap();
1286 app.duplicate_filter.seed(&report, app.path.clone(), counts);
1287 app.duplicates = Some(report);
1288 app.view = View::Duplicates;
1289 app.handle_action(KeyAction::ToggleMark);
1290 assert_eq!(app.marked, HashSet::from([paths[1].clone()]));
1291 app.handle_action(KeyAction::ToggleMark);
1292 assert!(app.marked.is_empty());
1293
1294 let mut truncated = (**app.duplicates.as_ref().unwrap()).clone();
1296 truncated.groups_omitted = 5;
1297 app.duplicates = Some(Arc::new(truncated));
1298 let screen: String = render(&app, Rect::new(0, 0, 140, 30))
1299 .content()
1300 .iter()
1301 .map(|cell| cell.symbol())
1302 .collect();
1303 assert!(screen.contains("5 smaller groups not listed"), "{screen}");
1304 }
1305
1306 #[tokio::test]
1307 async fn clicks_hit_the_row_on_screen_after_scrolling() {
1308 let (_dir, mut app) = scanned_app();
1309 let src = tree_row(&app, "src");
1310 app.nav.tree_state.selected = src;
1311 app.toggle_selected();
1312 let area = Rect::new(0, 0, 80, 12);
1313 app.nav.tree_state.selected = app.cached_tree_len - 1;
1314 render(&app, area);
1315 app.adopt_rendered_offsets();
1316 let offset = app.nav.tree_state.offset;
1317 assert!(offset > 0, "the selection forced a scroll");
1318 let top = app.estimated_list_top_row();
1319 app.handle_mouse_action(MouseAction::Click { x: 5, y: top });
1320 assert_eq!(
1321 app.nav.tree_state.selected, offset,
1322 "the first visible row was hit"
1323 );
1324 }
1325
1326 #[tokio::test]
1327 async fn back_reloads_replaced_tree_and_retains_unavailable_history() {
1328 for layout in [LayoutMode::Tree, LayoutMode::Miller] {
1329 let fixture = tempfile::tempdir().unwrap();
1330 let origin = fixture.path().join("origin");
1331 let destination = fixture.path().join("destination");
1332 std::fs::create_dir(&origin).unwrap();
1333 std::fs::create_dir(&destination).unwrap();
1334 std::fs::write(origin.join("original-file"), "contents").unwrap();
1335 let mut app = App::with_loaded_settings(
1336 origin.clone(),
1337 crate::TuiConfig::default(),
1338 Ok(UserSettings::default()),
1339 );
1340 app.layout_mode = layout;
1341 app.scan_on_startup = false;
1342 let origin = app.path.clone();
1343 app.user_settings.bookmarks.set('d', destination);
1344 app.jump_to_bookmark('d');
1345 let current = app.nav.view_root.clone();
1346 let token = app.scan_token.clone();
1347 app.go_back();
1348 assert!(token.is_cancelled());
1349 assert_eq!(app.nav.view_root, origin);
1350 assert_eq!(app.tree.as_ref().unwrap().root_path, origin);
1351 assert!(
1352 app.get_view_root_node()
1353 .unwrap()
1354 .0
1355 .children
1356 .iter()
1357 .any(|node| &*node.name == "original-file")
1358 );
1359 assert!(app.nav.back.is_empty());
1360 assert_eq!(app.nav.forward.last().unwrap().dir, current);
1361 app.jump_to_bookmark('d');
1362 let before = Arc::clone(app.tree.as_ref().unwrap());
1363 let history = app.nav.back.clone();
1364 let forward = app.nav.forward.clone();
1365 std::fs::rename(&origin, fixture.path().join("moved-origin")).unwrap();
1366 app.go_back();
1367 assert_eq!(app.nav.view_root, current);
1368 assert!(Arc::ptr_eq(app.tree.as_ref().unwrap(), &before));
1369 assert_eq!(app.nav.back, history);
1370 assert_eq!(app.nav.forward, forward);
1371 assert!(app.error.as_ref().unwrap().contains("Cannot list"));
1372 }
1373 }
1374
1375 #[test]
1376 fn bookmark_failures_preserve_view_and_unloaded_destinations_get_a_tree() {
1377 let fixture = sample_dir_on_disk();
1378 let mut app = App::with_loaded_settings(
1379 fixture.path().to_path_buf(),
1380 crate::TuiConfig::default(),
1381 Ok(UserSettings::default()),
1382 );
1383 app.scan_on_startup = false;
1384 let original = Arc::clone(app.tree.as_ref().unwrap());
1385 let root = app.nav.view_root.clone();
1386 let file = root.join("bookmark-file");
1387 std::fs::write(&file, "file").unwrap();
1388 app.user_settings.bookmarks.set('f', file);
1389 app.user_settings.bookmarks.set('m', root.join("missing"));
1390 for key in ['z', 'f', 'm'] {
1391 app.jump_to_bookmark(key);
1392 assert!(app.error.is_some());
1393 assert_eq!(app.nav.view_root, root);
1394 assert!(Arc::ptr_eq(app.tree.as_ref().unwrap(), &original));
1395 assert!(app.nav.back.is_empty());
1396 }
1397 let destination = root.join("new/nested");
1398 std::fs::create_dir_all(&destination).unwrap();
1399 std::fs::write(destination.join("needle"), "contents").unwrap();
1400 app.user_settings
1401 .bookmarks
1402 .set('n', root.join("new/./nested"));
1403 let old_token = app.scan_token.clone();
1404 app.jump_to_bookmark('n');
1405 assert!(old_token.is_cancelled());
1406 assert_eq!(app.nav.view_root, destination);
1407 assert_eq!(app.path, destination);
1408 assert_eq!(app.tree.as_ref().unwrap().root_path, destination);
1409 assert!(
1410 app.get_view_root_node()
1411 .unwrap()
1412 .0
1413 .children
1414 .iter()
1415 .any(|child| &*child.name == "needle")
1416 );
1417 assert_eq!(app.nav.back.len(), 1);
1418 app.jump_to_bookmark('n');
1419 assert_eq!(app.nav.back.len(), 1);
1420 #[cfg(unix)]
1421 {
1422 let alias = root.join("alias");
1423 std::os::unix::fs::symlink(&destination, &alias).unwrap();
1424 app.user_settings.bookmarks.set('a', alias);
1425 app.jump_to_bookmark('a');
1426 assert_eq!(app.nav.view_root, destination);
1427 assert_eq!(app.nav.back.len(), 1);
1428 }
1429 }
1430
1431 #[test]
1432 fn cd_normalizes_components_and_reports_failures_without_navigation() {
1433 use crate::test_support::{directory, file};
1434 let fixture = sample_dir_on_disk();
1435 let mut app = App::with_loaded_settings(
1436 fixture.path().to_path_buf(),
1437 crate::TuiConfig::default(),
1438 Ok(UserSettings::default()),
1439 );
1440 let root = app.path.clone();
1441 Arc::make_mut(app.tree.as_mut().unwrap()).root = directory(
1442 "root",
1443 vec![
1444 directory("first", vec![]),
1445 directory("two 日本", vec![]),
1446 file("file.txt", 1),
1447 ],
1448 );
1449 app.cd_to_path("./first");
1450 assert_eq!(app.nav.view_root, root.join("first"));
1451 app.cd_to_path("../two 日本/.");
1452 assert_eq!(app.nav.view_root, root.join("two 日本"));
1453 let history_len = app.nav.back.len();
1454 app.cd_to_path(".");
1455 assert_eq!(app.nav.back.len(), history_len);
1456 for (target, message) in [
1457 ("/missing", "not in the loaded tree"),
1458 ("/file.txt", "Not a directory"),
1459 ("../../escape", "leaves the scan root"),
1460 ] {
1461 app.cd_to_path(target);
1462 assert!(app.error.as_ref().unwrap().contains(message));
1463 assert_eq!(app.nav.view_root, root.join("two 日本"));
1464 assert_eq!(app.nav.back.len(), history_len);
1465 }
1466 app.cd_to_path("/first/../two 日本");
1467 assert_eq!(app.nav.view_root, root.join("two 日本"));
1468 assert_eq!(app.nav.back.len(), history_len);
1469 app.tree = None;
1470 app.cd_to_path("first");
1471 assert!(app.error.as_ref().unwrap().contains("no directory tree"));
1472 assert_eq!(app.nav.back.len(), history_len);
1473 }
1474
1475 #[test]
1476 fn constructor_uses_listing_root_for_relative_and_aliased_paths() {
1477 const WORKER: &str = "GRAVITYFILE_TEST_RELATIVE_ROOT_WORKER";
1478 if std::env::var_os(WORKER).is_none() {
1479 let workspace = tempfile::tempdir().unwrap();
1483 let output = std::process::Command::new(std::env::current_exe().unwrap())
1484 .args([
1485 "--exact",
1486 "app::tests::constructor_uses_listing_root_for_relative_and_aliased_paths",
1487 "--nocapture",
1488 ])
1489 .env(WORKER, "1")
1490 .current_dir(workspace.path())
1491 .output()
1492 .unwrap();
1493 assert!(output.status.success(), "{output:?}");
1494 return;
1495 }
1496 let fixture = tempfile::tempdir_in(".").unwrap();
1497 let real = fixture.path().join("real");
1498 std::fs::create_dir(&real).unwrap();
1499 std::fs::write(real.join("needle.txt"), "contents").unwrap();
1500 let canonical = dunce::canonicalize(&real).unwrap();
1501 let mut inputs = vec![real.clone(), real.join("."), real.join("../real")];
1502 #[cfg(unix)]
1503 {
1504 let alias = fixture.path().join("alias");
1505 std::os::unix::fs::symlink(&canonical, &alias).unwrap();
1506 inputs.push(alias);
1507 }
1508 inputs.push(canonical.clone());
1510 for input in inputs {
1511 let mut app = App::with_loaded_settings(
1512 input,
1513 crate::TuiConfig::default(),
1514 Ok(UserSettings::default()),
1515 );
1516 assert_eq!(app.path, canonical);
1517 assert_eq!(app.nav.view_root, canonical);
1518 assert_eq!(app.tree.as_ref().unwrap().root_path, canonical);
1519 assert!(app.get_view_root_node().is_some());
1520 app.search_state.enable_background();
1521 app.start_search();
1522 app.search_state.set_query("needle".into());
1523 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
1524 while app.search_state.has_background_work() {
1525 app.search_state.poll_background();
1526 assert!(std::time::Instant::now() < deadline);
1527 std::thread::yield_now();
1528 }
1529 assert!(app.search_state.error.is_none());
1530 assert_eq!(app.search_state.results.len(), 1);
1531 assert_eq!(
1532 app.search_state.results[0].path,
1533 canonical.join("needle.txt")
1534 );
1535 }
1536 }
1537
1538 #[test]
1539 fn scope_lookup_cancellation_stops_within_wide_directory() {
1540 use crate::test_support::{directory, file};
1541 let root = directory(
1542 "root",
1543 (1..=1000)
1544 .map(|index| file(&format!("file-{index:04}"), 1))
1545 .collect(),
1546 );
1547 let root_path = Path::new("root");
1548 let target = root_path.join("file-1000");
1549 let mut checks = 0;
1550 let result = App::find_node_at_path_while(&root, &target, root_path, || {
1551 checks += 1;
1552 checks <= 10
1553 });
1554 assert!(result.is_none());
1555 assert_eq!(checks, 11);
1556 assert_eq!(
1557 &*App::find_node_at_path(&root, &target, root_path)
1558 .unwrap()
1559 .name,
1560 "file-1000"
1561 );
1562 assert!(App::find_node_at_path_while(&root, root_path, root_path, || false).is_none());
1563 }
1564
1565 #[test]
1566 fn search_index_scopes_nested_directories_and_preserves_preorder() {
1567 use crate::test_support::{directory, file};
1568 let root = sample_dir_on_disk();
1569 let mut app = App::with_config(root.path().to_path_buf(), crate::TuiConfig::default());
1570 let scan_root = app.tree.as_ref().unwrap().root_path.clone();
1571 Arc::make_mut(app.tree.as_mut().unwrap()).root = directory(
1572 "root",
1573 vec![
1574 directory(
1575 "src",
1576 vec![
1577 file("日本.rs", 1),
1578 directory("nested", vec![file("deep.rs", 1)]),
1579 ],
1580 ),
1581 directory("src-other", vec![file("outside.rs", 1)]),
1582 ],
1583 );
1584 let tree = app.tree.as_ref().unwrap();
1585 for (scope, expected) in [
1586 (
1587 scan_root.clone(),
1588 vec![
1589 ".",
1590 "src",
1591 "src/nested",
1592 "src/nested/deep.rs",
1593 "src/日本.rs",
1594 "src-other",
1595 "src-other/outside.rs",
1596 ],
1597 ),
1598 (
1599 scan_root.join("src"),
1600 vec![".", "nested", "nested/deep.rs", "日本.rs"],
1601 ),
1602 (scan_root.join("src/nested"), vec![".", "deep.rs"]),
1603 (scan_root.join("missing"), vec![]),
1604 (scan_root.parent().unwrap().to_path_buf(), vec![]),
1605 ] {
1606 let paths =
1607 App::collect_search_paths(tree, &scope, &std::sync::atomic::AtomicBool::new(false));
1608 let expected: Vec<_> = expected.iter().map(PathBuf::from).collect();
1609 assert_eq!(
1610 paths
1611 .iter()
1612 .map(|entry| PathBuf::from(&entry.display))
1613 .collect::<Vec<_>>(),
1614 expected
1615 );
1616 for crate::search::SearchEntry { path, display, .. } in paths {
1617 assert!(path.starts_with(&scope));
1618 assert_eq!(
1619 path,
1620 if display == "." {
1621 scope.clone()
1622 } else {
1623 scope.join(display)
1624 }
1625 );
1626 }
1627 }
1628 app.nav.view_root = scan_root.join("src/nested");
1629 app.search_state.enable_background();
1630 app.start_search();
1631 app.search_state.set_query("deep".into());
1632 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
1633 while app.search_state.has_background_work() {
1634 app.search_state.poll_background();
1635 assert!(std::time::Instant::now() < deadline);
1636 std::thread::yield_now();
1637 }
1638 assert_eq!(app.search_state.results.len(), 1);
1639 assert_eq!(
1640 app.search_state.results[0].path,
1641 scan_root.join("src/nested/deep.rs")
1642 );
1643 }
1644
1645 #[test]
1646 fn pending_search_renders_feedback_and_enter_waits_for_current_results() {
1647 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1648 let root = sample_dir_on_disk();
1649 let mut app = App::with_config(root.path().to_path_buf(), crate::TuiConfig::default());
1650 app.mode = AppMode::Search;
1651 app.search_state.activate();
1652 app.search_state.enable_background();
1653 app.search_state.set_query("file".into());
1654 app.handle_search_input(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
1655 assert_eq!(app.mode, AppMode::Search);
1656 assert!(app.search_state.active);
1657 let area = Rect::new(0, 0, 100, 30);
1658 let mut buffer = Buffer::empty(area);
1659 app.render(area, &mut buffer);
1660 let rendered: String = buffer.content.iter().map(|cell| cell.symbol()).collect();
1661 assert!(rendered.contains("Searching…"));
1662 assert!(!rendered.contains("No matches found"));
1663 app.handle_search_input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
1664 assert_eq!(app.mode, AppMode::Normal);
1665 assert!(!app.search_state.is_searching());
1666 }
1667
1668 #[test]
1669 fn failed_initial_listing_is_reported_as_an_error() {
1670 let root = tempfile::tempdir().unwrap();
1671 let missing = root.path().join("missing");
1672 let app = App::with_config(missing, crate::TuiConfig::default());
1673 assert!(app.tree.is_none());
1674 assert!(
1675 app.error
1676 .as_deref()
1677 .is_some_and(|error| error.contains("Cannot list"))
1678 );
1679 }
1680
1681 #[test]
1682 fn lazy_listing_failure_preserves_tree_and_invalidates_stale_parent_cache() {
1683 let root = sample_dir_on_disk();
1684 let path = dunce::canonicalize(root.path()).unwrap();
1685 let mut app = App::with_config(path.clone(), crate::TuiConfig::default());
1686 let original = Arc::clone(app.tree.as_ref().unwrap());
1687 app.lazy_load_directory(&path.join("missing"));
1688 assert!(Arc::ptr_eq(app.tree.as_ref().unwrap(), &original));
1689 assert!(
1690 app.error
1691 .as_deref()
1692 .is_some_and(|error| error.contains("Cannot list"))
1693 );
1694 app.cached_parent_tree = Some((*original).clone());
1695 app.tree = None;
1696 app.update_cached_parent();
1697 assert!(app.cached_parent_tree.is_none());
1698 }
1699
1700 const SIZES: &[(u16, u16)] = &[
1705 (1, 1),
1706 (2, 2),
1707 (3, 20),
1708 (4, 3),
1709 (5, 24),
1710 (6, 30),
1711 (7, 12),
1712 (8, 5),
1713 (8, 30),
1714 (9, 40),
1715 (10, 6),
1716 (10, 30),
1717 (11, 24),
1718 (12, 8),
1719 (12, 40),
1720 (14, 30),
1721 (16, 10),
1722 (18, 24),
1723 (20, 12),
1724 (24, 10),
1725 (24, 30),
1726 (30, 14),
1727 (36, 24),
1728 (40, 16),
1729 (44, 30),
1730 (50, 20),
1731 (60, 24),
1732 (79, 24),
1733 (80, 24),
1734 (81, 24),
1735 (100, 30),
1736 (120, 40),
1737 (200, 60),
1738 ];
1739
1740 const VIEWS: &[View] = &[
1741 View::Explorer,
1742 View::Duplicates,
1743 View::Age,
1744 View::Errors,
1745 View::Treemap,
1746 ];
1747
1748 const MODES: &[AppMode] = &[
1749 AppMode::Normal,
1750 AppMode::Help,
1751 AppMode::Search,
1752 AppMode::ConfirmDelete,
1753 AppMode::Deleting,
1754 AppMode::Copying,
1755 AppMode::Moving,
1756 AppMode::Undoing,
1757 AppMode::UpdatingEntry,
1758 AppMode::Renaming,
1759 AppMode::CreatingFile,
1760 AppMode::CreatingDirectory,
1761 AppMode::Taking,
1762 AppMode::GoingTo,
1763 AppMode::Command,
1764 AppMode::Settings,
1765 AppMode::SettingBookmark,
1766 AppMode::JumpingToBookmark,
1767 AppMode::BookmarkList,
1768 AppMode::ConfirmBulkRename,
1769 AppMode::BulkRenaming,
1770 AppMode::Visual,
1771 ];
1772
1773 #[test]
1774 fn settings_failure_disables_automatic_scans_but_honors_explicit_override() {
1775 let directory = tempfile::tempdir().unwrap();
1776 for override_value in [None, Some(false), Some(true)] {
1777 let config = crate::TuiConfig {
1778 scan_on_startup: override_value,
1779 ..Default::default()
1780 };
1781 let app = App::with_loaded_settings(
1782 directory.path().into(),
1783 config,
1784 Err(std::io::Error::other("unreadable settings")),
1785 );
1786 assert_eq!(app.scan_on_startup, override_value.unwrap_or(false));
1787 assert!(!app.user_settings.scan_on_startup);
1788 assert!(
1789 app.error
1790 .as_ref()
1791 .unwrap()
1792 .contains("automatic scanning disabled")
1793 );
1794 assert!(app.tree.is_some()); }
1796 let app = App::with_loaded_settings(
1797 directory.path().into(),
1798 crate::TuiConfig::default(),
1799 Ok(UserSettings::default()),
1800 );
1801 assert!(app.scan_on_startup);
1802 assert!(app.error.is_none());
1803 }
1804
1805 #[test]
1806 fn startup_reports_settings_and_listing_failures_together() {
1807 let directory = tempfile::tempdir().unwrap();
1808 let app = App::with_loaded_settings(
1809 directory.path().join("missing"),
1810 crate::TuiConfig::default(),
1811 Err(std::io::Error::other("invalid settings fixture")),
1812 );
1813 let error = app.error.as_ref().unwrap();
1814 assert!(error.contains("invalid settings fixture"));
1815 assert!(error.contains("Cannot list"));
1816 assert!(!app.user_settings.scan_on_startup);
1817 assert!(app.image_previews_enabled());
1818 }
1819
1820 #[test]
1821 fn settings_save_failure_retains_edits_and_successful_retry_applies_them() {
1822 let directory = tempfile::tempdir().unwrap();
1823 let mut app = App::with_config(directory.path().into(), crate::TuiConfig::default());
1824 let original_startup = app.user_settings.scan_on_startup;
1825 let original_runtime_startup = app.scan_on_startup;
1826 let mut edited = SettingsState::new(app.user_settings.clone());
1827 edited.settings.scan_on_startup = !original_startup;
1828 edited.dirty = true;
1829 app.settings_state = Some(edited);
1830 app.mode = AppMode::Settings;
1831 app.needs_redraw = false;
1832
1833 app.save_settings_with(|_| Err(std::io::Error::other("injected storage failure")));
1834 assert_eq!(app.user_settings.scan_on_startup, original_startup);
1835 assert_eq!(app.scan_on_startup, original_runtime_startup);
1836 assert_eq!(app.mode, AppMode::Settings);
1837 assert!(app.settings_state.as_ref().unwrap().dirty);
1838 assert!(
1839 app.error
1840 .as_ref()
1841 .unwrap()
1842 .contains("injected storage failure")
1843 );
1844 assert!(app.needs_redraw);
1845
1846 app.save_settings_with(|settings| {
1847 assert_eq!(settings.scan_on_startup, !original_startup);
1848 Ok(())
1849 });
1850 assert_eq!(app.user_settings.scan_on_startup, !original_startup);
1851 assert_eq!(app.scan_on_startup, !original_startup);
1852 assert!(app.settings_state.is_none());
1853 assert_eq!(app.mode, AppMode::Normal);
1854 }
1855
1856 #[test]
1857 fn unchanged_settings_close_without_writing() {
1858 let directory = tempfile::tempdir().unwrap();
1859 let mut app = App::with_config(directory.path().into(), crate::TuiConfig::default());
1860 app.settings_state = Some(SettingsState::new(app.user_settings.clone()));
1861 app.mode = AppMode::Settings;
1862 app.save_settings_with(|_| panic!("unchanged settings must not write"));
1863 assert!(app.settings_state.is_none());
1864 assert_eq!(app.mode, AppMode::Normal);
1865 }
1866
1867 fn scanned_app() -> (tempfile::TempDir, App) {
1872 let dir = sample_dir_on_disk();
1873 let root = dunce::canonicalize(dir.path()).expect("canonicalize temp dir");
1877
1878 let mut app = App::with_config(
1880 root.clone(),
1881 crate::TuiConfig {
1882 replace_policy: gravityfile_ops::ReplacePolicy::Permanent,
1883 delete_mode: gravityfile_ops::DeleteMode::Permanent,
1884 ..crate::TuiConfig::default()
1885 },
1886 );
1887
1888 let config = gravityfile_core::ScanConfig::new(&root);
1889 let tree = gravityfile_scan::JwalkScanner::new()
1890 .scan(&config, CancellationToken::new())
1891 .expect("scan the fixture");
1892 app.handle_scan_result(ScanResult::ScanComplete(Ok(tree)));
1893 app.scan_rx = None;
1896
1897 (dir, app)
1898 }
1899
1900 fn populated_app() -> (tempfile::TempDir, App) {
1903 let (dir, mut app) = scanned_app();
1904 let root = app.path.clone();
1905 let tree = Arc::clone(app.tree.as_ref().expect("scan produced a tree"));
1906
1907 let duplicates = gravityfile_analyze::DuplicateFinder::with_config(
1908 gravityfile_analyze::DuplicateConfig::builder()
1909 .min_size(1u64)
1910 .build()
1911 .expect("duplicate config uses only defaulted fields"),
1912 )
1913 .unwrap()
1914 .find_duplicates(&tree, CancellationToken::new());
1915 assert!(
1916 !duplicates.groups.is_empty(),
1917 "the fixture must contain a duplicate pair"
1918 );
1919 app.duplicates = Some(Arc::new(duplicates));
1920 app.age_report = Some(
1921 gravityfile_analyze::AgeAnalyzer::with_config(Default::default())
1922 .unwrap()
1923 .analyze(&tree, CancellationToken::new()),
1924 );
1925
1926 app.warnings = vec![
1927 gravityfile_core::ScanWarning::new(
1928 root.join("denied"),
1929 gravityfile_core::WarningKind::PermissionDenied,
1930 "permission denied",
1931 ),
1932 gravityfile_core::ScanWarning::new(
1933 root.join("dangling"),
1934 gravityfile_core::WarningKind::BrokenSymlink,
1935 "broken symlink",
1936 ),
1937 ];
1938
1939 app.marked.insert(root.join("empty"));
1940 app.clipboard.yank([root.join("copy-a.bin")], root.clone());
1941
1942 app.error = Some("something went wrong".to_string());
1943 app.operation_message = Some((true, "Copied 3 items, 1 skipped".to_string()));
1944 app.deletion_message = Some((false, "Deleted 1 item, 1 failed".to_string()));
1945
1946 app.operation_progress = Some(gravityfile_ops::OperationProgress::new(
1947 gravityfile_ops::OperationType::Copy,
1948 10,
1949 4096,
1950 ));
1951 app.deletion_progress = Some(state::DeletionProgress::new(3));
1952 app.scan_progress = Some(gravityfile_scan::ScanProgress::new());
1953
1954 app.input_state = Some(InputState::with_initial("some-name.txt"));
1955 app.settings_state = Some(SettingsState::new(app.user_settings.clone()));
1956 app.bookmark_list_state = Some(BookmarkListState::new(&app.user_settings.bookmarks));
1957 app.bulk_rename_state = Some(state::BulkRenameState::new(
1958 vec![root.join("copy-a.bin"), root.join("copy-b.bin")],
1959 vec!["one.bin".to_string(), "two.bin".to_string()],
1960 ));
1961 app.visual_state = Some(state::VisualState::new(1));
1962 app.tab_manager
1964 .open(&mut app.nav, state::Navigation::new(root.join("src")));
1965 app.tab_manager.switch(&mut app.nav, 0);
1966
1967 (dir, app)
1968 }
1969
1970 fn render(app: &App, area: Rect) -> Buffer {
1971 let mut buf = Buffer::empty(area);
1972 Widget::render(app, area, &mut buf);
1973 buf
1974 }
1975
1976 fn text(buf: &Buffer) -> String {
1977 buf.content().iter().map(|cell| cell.symbol()).collect()
1978 }
1979
1980 fn tree_row(app: &App, name: &str) -> usize {
1982 let (node, path) = app.get_view_root_node().expect("a view root node");
1983 TreeView::new(node, &path, &app.theme, &app.marked, &app.clipboard)
1984 .flatten(&app.nav.tree_state)
1985 .iter()
1986 .position(|item| item.node.name == name)
1987 .unwrap_or_else(|| panic!("{name} is not visible"))
1988 }
1989
1990 #[tokio::test]
1991 async fn completion_errors_override_zero_failure_count_in_ui_summary() {
1992 let (_dir, mut app) = scanned_app();
1993 let path = app.path.join("copy-a.bin");
1994 app.handle_scan_result(ScanResult::OperationComplete {
1995 operation_type: gravityfile_ops::OperationType::Copy,
1996 cancelled: false,
1997 succeeded: 1,
1998 failed: 0,
1999 skipped: 0,
2000 bytes_processed: 0,
2001 errors: vec![gravityfile_ops::OperationError::new(
2002 path.clone(),
2003 "late diagnostic",
2004 )],
2005 undo_info: Some(gravityfile_ops::UndoableOperation::FilesCopied {
2006 created: vec![path],
2007 }),
2008 undo_remaining: None,
2009 touched: Vec::new(),
2010 });
2011 let (success, message) = app.operation_message.as_ref().unwrap();
2012 assert!(!success);
2013 assert!(message.contains("1 errors"));
2014 assert_eq!(app.operation_warnings.len(), 1);
2015 assert_eq!(app.undo_log.len(), 1, "retain undo for the completed item");
2016 assert_eq!(app.view, View::Errors);
2017 app.scan_token.cancel();
2018 app.scan_rx = None;
2019 }
2020
2021 #[cfg(target_os = "linux")]
2022 #[tokio::test]
2023 async fn archive_default_extension_preserves_native_parent_path() {
2024 use std::os::unix::ffi::OsStringExt;
2025 for name in ["bundle", "bundle.zip"] {
2026 let temporary = tempfile::tempdir().unwrap();
2027 let parent = temporary
2028 .path()
2029 .join(std::ffi::OsString::from_vec(b"native-\xff".to_vec()));
2030 let decoy = temporary.path().join("native-\u{fffd}");
2031 std::fs::create_dir(&parent).unwrap();
2032 std::fs::create_dir(&decoy).unwrap();
2033 let source = parent.join("source.txt");
2034 std::fs::write(&source, b"native directory contents").unwrap();
2035 let mut app = App::with_config(parent.clone(), crate::TuiConfig::default());
2036 app.marked.insert(source);
2037 app.compress_files(name);
2038 let completion = tokio::time::timeout(
2039 std::time::Duration::from_secs(5),
2040 app.archive_rx.take().expect("archive task started"),
2041 )
2042 .await
2043 .unwrap()
2044 .unwrap();
2045 let ArchiveCompletion::Compress {
2046 archive_path,
2047 result,
2048 ..
2049 } = completion
2050 else {
2051 panic!("expected compression completion");
2052 };
2053 result.unwrap();
2054 assert_eq!(archive_path, parent.join("bundle.zip"));
2055 assert!(archive_path.is_file());
2056 assert!(!decoy.join("bundle.zip").exists());
2057 let extracted = parent.join("extracted");
2058 gravityfile_ops::extract_archive(&archive_path, &extracted).unwrap();
2059 assert_eq!(
2060 std::fs::read(extracted.join("source.txt")).unwrap(),
2061 b"native directory contents"
2062 );
2063 }
2064 }
2065
2066 #[tokio::test]
2067 async fn dropping_app_cancels_archive_work_without_misreporting_publication() {
2068 let (_directory, mut app) = scanned_app();
2069 let destination = app.nav.view_root.join("drop.zip");
2070 app.marked.insert(app.nav.view_root.join("copy-a.bin"));
2071 app.compress_files("drop.zip");
2072 let cancel = app.archive_cancel.as_ref().unwrap().0.clone();
2073 let receiver = app.archive_rx.take().unwrap();
2074 drop(app);
2075 assert!(cancel.is_cancelled());
2076 let completion = tokio::time::timeout(std::time::Duration::from_secs(5), receiver)
2077 .await
2078 .unwrap()
2079 .unwrap();
2080 let ArchiveCompletion::Compress { result, .. } = completion else {
2081 panic!("expected creation")
2082 };
2083 match result {
2084 Ok(()) => assert!(
2085 destination.is_file(),
2086 "publication won the cancellation race"
2087 ),
2088 Err(gravityfile_ops::ArchiveError::Cancelled) => assert!(!destination.exists()),
2089 other => panic!("unexpected archive result: {other:?}"),
2090 }
2091 }
2092
2093 #[test]
2094 fn queued_archive_extraction_quit_cancels_and_waits_for_completion() {
2095 let executor = tokio::runtime::Builder::new_current_thread()
2096 .enable_all()
2097 .max_blocking_threads(1)
2098 .build()
2099 .unwrap();
2100 let (release, wait) = std::sync::mpsc::channel::<()>();
2101 let (ready, started) = std::sync::mpsc::channel();
2102 let blocker = executor.spawn_blocking(move || {
2103 ready.send(()).unwrap();
2104 let _ = wait.recv();
2105 });
2106 started
2107 .recv_timeout(std::time::Duration::from_secs(5))
2108 .unwrap();
2109 executor.block_on(async {
2110 let temporary = tempfile::tempdir().unwrap();
2111 let source = temporary.path().join("source");
2112 std::fs::write(&source, b"contents").unwrap();
2113 let root = temporary.path().join("view");
2114 std::fs::create_dir(&root).unwrap();
2115 let root = dunce::canonicalize(&root).unwrap();
2116 let archive = root.join("archive.zip");
2117 gravityfile_ops::create_archive(
2118 &[source],
2119 &archive,
2120 gravityfile_ops::ArchiveFormat::Zip,
2121 )
2122 .unwrap();
2123 let mut app = App::with_config(root.clone(), crate::TuiConfig::default());
2124 let tree = gravityfile_scan::JwalkScanner::new()
2125 .scan(
2126 &gravityfile_core::ScanConfig::new(&root),
2127 CancellationToken::new(),
2128 )
2129 .unwrap();
2130 app.handle_scan_result(ScanResult::ScanComplete(Ok(tree)));
2131 app.scan_rx = None;
2132 app.layout_mode = LayoutMode::Miller;
2133 app.select_entry(Some(&archive));
2134 assert_eq!(app.get_selected_path(), Some(archive.clone()));
2135 app.marked.insert(archive.clone());
2136 app.extract_archive(Some("extracted".into()));
2137 app.handle_action(KeyAction::Quit);
2138 assert_ne!(app.mode, AppMode::Quit);
2139 assert!(app.archive_cancel.as_ref().unwrap().0.is_cancelled());
2140 assert!(app.archive_rx.is_some());
2141 drop(release);
2142 blocker.await.unwrap();
2143 let completion = tokio::time::timeout(
2144 std::time::Duration::from_secs(5),
2145 app.archive_rx.as_mut().unwrap(),
2146 )
2147 .await
2148 .unwrap()
2149 .ok();
2150 app.finish_archive_task(completion);
2151 assert!(app.archive_cancel.is_none());
2152 assert!(app.archive_rx.is_none());
2153 assert!(!root.join("extracted").exists());
2154 assert!(app.marked.contains(&archive));
2155 assert!(
2156 app.error
2157 .as_deref()
2158 .unwrap()
2159 .contains("extraction cancelled before publication")
2160 );
2161 });
2162 }
2163
2164 #[test]
2165 fn queued_archive_creation_can_be_cancelled_without_publication() {
2166 let executor = tokio::runtime::Builder::new_current_thread()
2167 .enable_all()
2168 .max_blocking_threads(1)
2169 .build()
2170 .unwrap();
2171 let (release, wait) = std::sync::mpsc::channel::<()>();
2172 let (ready, started) = std::sync::mpsc::channel();
2173 let blocker = executor.spawn_blocking(move || {
2174 ready.send(()).unwrap();
2175 let _ = wait.recv();
2176 });
2177 started
2178 .recv_timeout(std::time::Duration::from_secs(5))
2179 .unwrap();
2180 executor.block_on(async {
2181 let (_directory, mut app) = scanned_app();
2182 let source = app.nav.view_root.join("copy-a.bin");
2183 app.marked.insert(source.clone());
2184 app.compress_files("cancelled.zip");
2185 app.handle_action(KeyAction::Cancel);
2186 assert!(app.archive_cancel.as_ref().unwrap().0.is_cancelled());
2187 assert!(
2188 app.archive_rx.is_some(),
2189 "retain admission until the worker completes"
2190 );
2191 drop(release);
2192 blocker.await.unwrap();
2193 let completion = tokio::time::timeout(
2194 std::time::Duration::from_secs(5),
2195 app.archive_rx.as_mut().unwrap(),
2196 )
2197 .await
2198 .unwrap()
2199 .ok();
2200 app.finish_archive_task(completion);
2201 assert!(app.archive_cancel.is_none());
2202 assert!(app.archive_rx.is_none());
2203 assert!(!app.nav.view_root.join("cancelled.zip").exists());
2204 assert!(app.marked.contains(&source));
2205 assert!(
2206 app.error
2207 .as_deref()
2208 .unwrap()
2209 .contains("cancelled before publication")
2210 );
2211 });
2212 }
2213
2214 #[tokio::test]
2215 async fn lost_archive_worker_refreshes_uncertain_output_and_releases_admission() {
2216 for active_creation in [false, true] {
2217 let (_directory, mut app) = scanned_app();
2218 let root = app.nav.view_root.clone();
2219 let source = root.join("copy-a.bin");
2220 app.marked.insert(source.clone());
2221 app.operation_message = Some((true, "Creating archive…".into()));
2222 std::fs::write(
2224 root.join("uncertain-output"),
2225 b"completed before reply loss",
2226 )
2227 .unwrap();
2228 if active_creation {
2229 app.execute_create_file(root.clone(), "created".into());
2230 }
2231 let token = app.op_token.clone();
2232 let message = app.operation_message.clone();
2233 let (sender, receiver) = oneshot::channel();
2234 app.archive_rx = Some(receiver);
2235 drop(sender);
2236 let completion = app.archive_rx.as_mut().unwrap().await.ok();
2237 app.finish_archive_task(completion);
2238 assert!(app.archive_rx.is_none());
2239 if active_creation {
2241 assert!(app.scan_rx.is_none());
2242 assert!(!app.queued_refresh.is_empty());
2243 } else {
2244 assert!(app.scan_rx.is_some());
2245 }
2246 assert!(
2247 app.error
2248 .as_deref()
2249 .unwrap()
2250 .contains("inspect the destination before retrying")
2251 );
2252 if active_creation {
2253 assert!(!token.is_cancelled());
2254 assert_eq!(app.operation_message, message);
2255 }
2256 pump(&mut app, |_| false).await;
2257 assert!(
2258 app.tree
2259 .as_ref()
2260 .unwrap()
2261 .root
2262 .children
2263 .iter()
2264 .any(|node| node.name == "uncertain-output")
2265 );
2266 assert!(app.marked.contains(&source));
2267 assert!(app.queued_refresh.is_empty());
2268 assert!(
2269 !app.operation_message
2270 .as_ref()
2271 .is_some_and(|(_, message)| message == "Creating archive…")
2272 );
2273 }
2274 }
2275
2276 #[tokio::test]
2277 async fn incomplete_extraction_rollback_refreshes_without_clearing_selection() {
2278 for active_creation in [false, true] {
2279 let (_directory, mut app) = scanned_app();
2280 let root = app.nav.view_root.clone();
2281 let source = root.join("copy-a.bin");
2282 let archive = root.join("result.zip");
2283 std::fs::write(&archive, b"leftover extraction output").unwrap();
2284 app.marked.insert(source.clone());
2285 if active_creation {
2286 app.execute_create_file(root.clone(), "created".into());
2287 }
2288 let token = app.op_token.clone();
2289 app.handle_archive_completion(ArchiveCompletion::Extract {
2290 archive_path: root.join("input.zip"),
2291 destination: root.clone(),
2292 result: Err(gravityfile_ops::ArchiveError::ExtractionRollback {
2293 destination: root.clone(),
2294 source: Box::new(
2295 std::io::Error::other("private/path publication failure").into(),
2296 ),
2297 left_behind: vec![archive.clone()],
2298 }),
2299 });
2300 let message = app.error.as_deref().unwrap();
2301 assert!(
2302 message.starts_with("Archive extraction failed; 1 extracted entries"),
2303 "{message}"
2304 );
2305 assert!(!message.contains("private/path"));
2306 assert!(app.marked.contains(&source));
2307 assert!(if active_creation {
2308 app.op_rx.is_some()
2309 } else {
2310 app.scan_rx.is_some()
2311 });
2312 if active_creation {
2313 assert!(!token.is_cancelled());
2314 assert!(!app.queued_refresh.is_empty());
2315 }
2316 pump(&mut app, |_| false).await;
2317 assert!(
2318 app.tree
2319 .as_ref()
2320 .unwrap()
2321 .root
2322 .children
2323 .iter()
2324 .any(|node| node.name == "result.zip")
2325 );
2326 assert!(app.marked.contains(&source));
2327 assert!(app.queued_refresh.is_empty());
2328 assert!(
2329 app.error
2330 .as_deref()
2331 .unwrap()
2332 .contains("could not be withdrawn")
2333 );
2334 }
2335 }
2336
2337 #[tokio::test]
2338 async fn archive_completion_refresh_waits_for_an_active_creation() {
2339 for compress in [false, true] {
2340 let (_directory, mut app) = scanned_app();
2341 let root = app.nav.view_root.clone();
2342 let archived_source = root.join("copy-a.bin");
2343 let unrelated_mark = root.join("copy-b.bin");
2344 app.marked.insert(archived_source.clone());
2345 app.marked.insert(unrelated_mark.clone());
2346 app.execute_create_file(root.clone(), "created".into());
2347 let token = app.op_token.clone();
2348 let message = app.operation_message.clone();
2349 let completion = if compress {
2350 ArchiveCompletion::Compress {
2351 archive_path: root.join("result.zip"),
2352 sources: vec![archived_source.clone(), root.join("unmarked-source")],
2353 result: Ok(()),
2354 }
2355 } else {
2356 ArchiveCompletion::Extract {
2357 archive_path: root.join("source.zip"),
2358 destination: root.clone(),
2359 result: Ok(vec![root.join("extracted")]),
2360 }
2361 };
2362 app.handle_archive_completion(completion);
2363 assert_eq!(app.marked.contains(&archived_source), !compress);
2364 assert!(app.marked.contains(&unrelated_mark));
2365 assert_eq!(app.mode, AppMode::UpdatingEntry);
2366 assert!(app.op_rx.is_some());
2367 assert!(!token.is_cancelled());
2368 assert_eq!(app.operation_message, message);
2369 assert!(!app.queued_refresh.is_empty());
2370 tokio::time::timeout(
2371 std::time::Duration::from_secs(5),
2372 pump_until_complete(&mut app),
2373 )
2374 .await
2375 .unwrap();
2376 assert!(root.join("created").is_file());
2377 assert_eq!(app.undo_log.len(), 1);
2378 assert!(app.queued_refresh.is_empty());
2379 }
2380 }
2381
2382 #[tokio::test]
2383 async fn oversized_selection_totals_remain_renderable_and_saturate() {
2384 let (_directory, mut app) = scanned_app();
2385 Arc::make_mut(app.tree.as_mut().unwrap()).root.size = u64::MAX;
2386 app.marked.insert(app.path.clone());
2387 app.marked.insert(app.path.join("copy-a.bin"));
2388 let expected = crate::ui::format_size(u64::MAX);
2389 for mode in [AppMode::Normal, AppMode::ConfirmDelete] {
2390 app.mode = mode;
2391 let rendered = text(&render(&app, Rect::new(0, 0, 120, 40)));
2392 assert!(rendered.contains(&expected));
2393 }
2394 let (_directory, mut app) = populated_app();
2395 let root = app.path.clone();
2396 let report = app.age_report.as_mut().unwrap();
2397 report.stale_directories = [root.clone(), root.join("src")]
2398 .into_iter()
2399 .map(|path| gravityfile_analyze::StaleDirectory {
2400 path,
2401 size: u64::MAX,
2402 newest_file_age: std::time::Duration::from_secs(86400),
2403 file_count: 1,
2404 })
2405 .collect();
2406 report.stale_directory_status = gravityfile_analyze::StaleDirectoryStatus::Evaluated;
2407 assert_eq!(report.total_stale_size(), u64::MAX);
2408 app.view = View::Age;
2409 app.mode = AppMode::Normal;
2410 let rendered = text(&render(&app, Rect::new(0, 0, 120, 40)));
2411 assert!(rendered.contains(&expected));
2412 }
2413
2414 #[tokio::test]
2415 async fn commands_use_exact_internal_spacing_for_navigation_and_creation() {
2416 let (_directory, mut app) = scanned_app();
2417 let root = app.path.clone();
2418 let destination = root.join("two spaces");
2419 std::fs::create_dir(&destination).unwrap();
2420 std::fs::create_dir(root.join("two spaces")).unwrap();
2421 app.start_scan();
2422 tokio::time::timeout(std::time::Duration::from_secs(5), async {
2423 loop {
2424 let result = app.scan_rx.as_mut().unwrap().recv().await.unwrap();
2425 let complete = matches!(result, ScanResult::ScanComplete(_));
2426 app.handle_scan_result(result);
2427 if complete {
2428 break;
2429 }
2430 }
2431 })
2432 .await
2433 .unwrap();
2434 app.execute_command("cd two spaces");
2435 assert_eq!(app.nav.view_root, destination);
2436 app.execute_command("touch note 日本.txt");
2437 tokio::time::timeout(
2438 std::time::Duration::from_secs(5),
2439 pump_until_complete(&mut app),
2440 )
2441 .await
2442 .unwrap();
2443 assert!(destination.join("note 日本.txt").is_file());
2444 assert!(!destination.join("note 日本.txt").exists());
2445 assert_eq!(app.undo_log.len(), 1);
2446 }
2447
2448 #[tokio::test]
2449 async fn invalid_palette_commands_preserve_sort_bookmarks_and_navigation() {
2450 let (_directory, mut app) = scanned_app();
2451 let root = app.nav.view_root.clone();
2452 app.user_settings.bookmarks.set('a', root.clone());
2453 let sort = app.sort_mode;
2454 for command in ["sort typo", "delmark ab", "go a extra", "unknown"] {
2455 app.error = None;
2456 app.execute_command(command);
2457 assert!(app.error.is_some());
2458 assert_eq!(app.sort_mode, sort);
2459 assert_eq!(app.nav.view_root, root);
2460 assert_eq!(app.user_settings.bookmarks.get('a'), Some(&root));
2461 assert!(app.scan_rx.is_none());
2462 }
2463 }
2464
2465 #[tokio::test]
2466 async fn pending_operations_render_before_the_first_progress_update() {
2467 let (_dir, mut app) = scanned_app();
2468 for mode in [
2469 AppMode::Copying,
2470 AppMode::Moving,
2471 AppMode::Undoing,
2472 AppMode::UpdatingEntry,
2473 ] {
2474 app.mode = mode;
2475 app.operation_progress = None;
2476 for message in [None, Some((false, "Waiting for filesystem work".into()))] {
2477 app.operation_message = message;
2478 for &(width, height) in SIZES {
2479 let _ = render(&app, Rect::new(3, 2, width, height));
2480 }
2481 let rendered = text(&render(&app, Rect::new(0, 0, 80, 24)));
2482 assert!(rendered.contains("Operation in progress"));
2483 let expected = if app.operation_message.is_some() {
2484 "Waiting for filesystem work"
2485 } else {
2486 "Waiting for worker progress..."
2487 };
2488 assert!(rendered.contains(expected));
2489 app.operation_progress = Some(OperationProgress::new(
2490 gravityfile_ops::OperationType::Rename,
2491 1,
2492 0,
2493 ));
2494 let rendered = text(&render(&app, Rect::new(0, 0, 80, 24)));
2495 assert!(rendered.contains("Renaming..."));
2496 assert!(!rendered.contains("Operation in progress"));
2497 app.operation_progress = None;
2498 }
2499 }
2500 }
2501
2502 #[tokio::test]
2503 async fn renders_every_view_and_mode_at_every_terminal_size() {
2504 let (_dir, mut app) = populated_app();
2508
2509 for &layout in &[LayoutMode::Tree, LayoutMode::Miller] {
2510 for &view in VIEWS {
2511 for &mode in MODES {
2512 for &(width, height) in SIZES {
2513 app.layout_mode = layout;
2514 app.view = view;
2515 app.mode = mode;
2516 let _ = render(&app, Rect::new(0, 0, width, height));
2517 }
2518 }
2519 }
2520 }
2521 }
2522
2523 #[tokio::test]
2524 async fn renders_at_every_size_before_a_scan_has_produced_a_tree() {
2525 let dir = tempfile::tempdir().unwrap();
2527 let mut app = App::with_config(dir.path().to_path_buf(), crate::TuiConfig::default());
2528 app.tree = None;
2529 app.error = Some("scan failed".to_string());
2530
2531 for &layout in &[LayoutMode::Tree, LayoutMode::Miller] {
2532 for &view in VIEWS {
2533 for &(width, height) in SIZES {
2534 app.layout_mode = layout;
2535 app.view = view;
2536 let _ = render(&app, Rect::new(0, 0, width, height));
2537 }
2538 }
2539 }
2540 }
2541
2542 #[tokio::test]
2543 async fn the_explorer_renders_the_scanned_entries() {
2544 let (_dir, app) = scanned_app();
2548 let rendered = text(&render(&app, Rect::new(0, 0, 120, 30)));
2549 assert!(rendered.contains("src"), "explorer is blank: {rendered}");
2550 }
2551
2552 #[tokio::test]
2553 async fn both_layouts_render_the_selected_directory() {
2554 let (_dir, mut app) = scanned_app();
2555
2556 app.layout_mode = LayoutMode::Tree;
2557 assert!(text(&render(&app, Rect::new(0, 0, 120, 30))).contains("src"));
2558
2559 app.layout_mode = LayoutMode::Miller;
2560 let rendered = text(&render(&app, Rect::new(0, 0, 120, 30)));
2561 assert!(rendered.contains("Current"), "miller columns missing");
2562 assert!(rendered.contains("src"), "miller is blank: {rendered}");
2563 }
2564
2565 #[tokio::test]
2566 async fn tree_navigation_is_clamped_to_the_visible_item_count() {
2567 let (_dir, mut app) = scanned_app();
2568 app.layout_mode = LayoutMode::Tree;
2569 let last = app.cached_tree_len - 1;
2570 assert!(last > 0, "the fixture should expand to several rows");
2571
2572 app.jump_to_bottom();
2573 assert_eq!(app.nav.tree_state.selected, last);
2574 app.move_down();
2575 app.page_down();
2576 assert_eq!(app.nav.tree_state.selected, last);
2577
2578 app.jump_to_top();
2579 assert_eq!(app.nav.tree_state.selected, 0);
2580 app.move_up();
2581 app.page_up();
2582 assert_eq!(app.nav.tree_state.selected, 0);
2583 }
2584
2585 #[tokio::test]
2586 async fn expanding_a_directory_grows_the_visible_item_count() {
2587 let (_dir, mut app) = scanned_app();
2588 app.layout_mode = LayoutMode::Tree;
2589 let collapsed_len = app.cached_tree_len;
2590
2591 app.nav.tree_state.selected = tree_row(&app, "src");
2592 app.expand_selected();
2593 assert!(
2594 app.cached_tree_len > collapsed_len,
2595 "expanding src revealed nothing"
2596 );
2597
2598 app.collapse_selected();
2599 assert_eq!(app.cached_tree_len, collapsed_len);
2600 }
2601
2602 #[tokio::test]
2603 async fn drilling_into_a_directory_and_back_restores_the_previous_root() {
2604 let (_dir, mut app) = scanned_app();
2605 app.layout_mode = LayoutMode::Tree;
2606 let original_root = app.nav.view_root.clone();
2607
2608 let src_index = tree_row(&app, "src");
2609 app.nav.tree_state.selected = src_index;
2610 app.enter_selected();
2611 assert_eq!(app.nav.view_root, original_root.join("src"));
2612
2613 app.go_back();
2614 assert_eq!(app.nav.view_root, original_root);
2615 assert_eq!(
2616 app.nav.tree_state.selected, src_index,
2617 "returning restores the row the user drilled from"
2618 );
2619 }
2620
2621 #[tokio::test]
2622 async fn drilling_in_miller_mode_and_back_restores_the_previous_root() {
2623 let (_dir, mut app) = scanned_app();
2624 app.layout_mode = LayoutMode::Miller;
2625 let original_root = app.nav.view_root.clone();
2626
2627 let (node, _) = app.get_view_root_node().unwrap();
2628 let src_index = node
2629 .children
2630 .iter()
2631 .position(|child| child.name.as_str() == "src")
2632 .expect("src is a child");
2633 app.nav.miller_state.selected = src_index;
2634
2635 app.enter_selected();
2636 assert_eq!(app.nav.view_root, original_root.join("src"));
2637
2638 app.go_back();
2639 assert_eq!(app.nav.view_root, original_root);
2640 assert_eq!(app.nav.miller_state.selected, src_index);
2641 }
2642
2643 #[tokio::test]
2644 async fn drilling_into_a_file_does_nothing() {
2645 let (_dir, mut app) = scanned_app();
2646 app.layout_mode = LayoutMode::Tree;
2647 let root = app.nav.view_root.clone();
2648
2649 app.nav.tree_state.selected = tree_row(&app, "empty");
2650 app.enter_selected();
2651 assert_eq!(
2652 app.nav.view_root, root,
2653 "a file is not a directory to enter"
2654 );
2655 assert!(app.nav.back.is_empty(), "no history entry was recorded");
2656 }
2657
2658 #[tokio::test]
2659 async fn navigating_back_from_the_scan_root_walks_up_the_filesystem() {
2660 let (_dir, mut app) = scanned_app();
2661 let root = app.nav.view_root.clone();
2662 let parent = root
2663 .parent()
2664 .expect("the temp dir has a parent")
2665 .to_path_buf();
2666
2667 app.go_back();
2668 assert_eq!(app.nav.view_root, parent);
2669 }
2670
2671 #[tokio::test]
2672 async fn the_treemap_selection_is_bounded_by_the_rectangle_count() {
2673 let (_dir, mut app) = scanned_app();
2674 app.view = View::Treemap;
2675 app.update_cached_treemap_len();
2676 let last = app.cached_treemap_len - 1;
2677
2678 app.jump_to_bottom();
2679 assert_eq!(app.nav.treemap_state.selected, last);
2680 app.move_down();
2681 app.page_down();
2682 assert_eq!(app.nav.treemap_state.selected, last);
2683
2684 app.jump_to_top();
2685 assert_eq!(app.nav.treemap_state.selected, 0);
2686 app.move_up();
2687 app.page_up();
2688 assert_eq!(app.nav.treemap_state.selected, 0);
2689 }
2690
2691 #[tokio::test]
2692 async fn deletion_failures_reach_the_errors_view_and_success_clears_old_details() {
2693 let (_directory, mut app) = scanned_app();
2694 let missing = app.path.join("missing-delete-target");
2695 let removed = app.path.join("temporary-delete-target");
2696 std::fs::write(&removed, b"temporary").unwrap();
2697 app.operation_message = Some((true, "Old copy summary".into()));
2698 let mut rx = deletion::start_deletion(
2699 vec![(missing.clone(), 0), (removed.clone(), 9)],
2700 gravityfile_ops::DeleteMode::Permanent,
2701 );
2702 tokio::time::timeout(std::time::Duration::from_secs(5), async {
2703 loop {
2704 let result = rx.recv().await.expect("deletion completion");
2705 let complete = matches!(result, ScanResult::DeletionComplete { .. });
2706 app.handle_scan_result(result);
2707 if complete {
2708 break;
2709 }
2710 }
2711 })
2712 .await
2713 .unwrap();
2714 assert!(!removed.exists());
2715 assert_eq!(app.view, View::Errors);
2716 assert!(app.operation_message.is_none());
2717 assert!(!app.deletion_message.as_ref().unwrap().0);
2718 assert!(
2719 app.deletion_message
2720 .as_ref()
2721 .unwrap()
2722 .1
2723 .contains("Permanently deleted")
2724 );
2725 assert_eq!(app.operation_warnings.len(), 1);
2726 assert_eq!(app.operation_warnings[0].path, missing);
2727 assert!(
2728 app.operation_warnings[0]
2729 .message
2730 .contains("Cannot access path")
2731 );
2732 let rendered = text(&render(&app, Rect::new(0, 0, 120, 40)));
2733 assert!(rendered.contains("missing-delete-target"), "{rendered}");
2734 assert!(rendered.contains("Cannot access path"), "{rendered}");
2735 assert!(!rendered.contains("Old copy summary"));
2736 app.handle_scan_result(ScanResult::DeletionComplete {
2737 mode: gravityfile_ops::DeleteMode::Trash,
2738 deleted: vec![removed.clone()],
2739 failed: 0,
2740 bytes_freed: 9,
2741 errors: Vec::new(),
2742 });
2743 assert!(app.operation_warnings.is_empty());
2744 assert!(app.deletion_message.as_ref().unwrap().0);
2745 }
2746
2747 #[tokio::test]
2748 async fn undo_failure_details_survive_refresh_and_render_in_errors_view() {
2749 let (_directory, mut app) = scanned_app();
2750 let path = app.path.join("work-in-progress.txt");
2751 std::fs::write(&path, b"user content").unwrap();
2752 app.undo_log.record_create_file(path.clone());
2753 app.execute_undo();
2754 let mut rx = app.op_rx.take().unwrap();
2755 tokio::time::timeout(std::time::Duration::from_secs(5), async {
2756 loop {
2757 let result = rx.recv().await.expect("undo completion");
2758 let complete = matches!(result, ScanResult::OperationComplete { .. });
2759 app.handle_scan_result(result);
2760 if complete {
2761 break;
2762 }
2763 }
2764 })
2765 .await
2766 .unwrap();
2767 assert_eq!(app.view, View::Errors);
2768 assert_eq!(app.operation_warnings.len(), 1);
2769 assert_eq!(app.operation_warnings[0].path, path);
2770 assert!(
2771 app.operation_warnings[0]
2772 .message
2773 .contains("now contains data")
2774 );
2775 assert!(
2776 app.warnings.is_empty(),
2777 "automatic refresh clears scan warnings only"
2778 );
2779 let rendered = text(&render(&app, Rect::new(0, 0, 120, 40)));
2780 assert!(rendered.contains("work-in-progress.txt"), "{rendered}");
2781 assert!(rendered.contains("now contains data"), "{rendered}");
2782 app.warnings.push(gravityfile_core::ScanWarning::new(
2783 app.path.join("scan-warning"),
2784 gravityfile_core::WarningKind::ReadError,
2785 "scan issue",
2786 ));
2787 app.jump_to_bottom();
2788 assert_eq!(app.selected_warning, 1);
2789 assert_eq!(app.displayed_warning(1).unwrap().message, "scan issue");
2790 app.jump_to_top();
2791 assert_eq!(app.displayed_warning(0).unwrap().path, path);
2792 assert_eq!(std::fs::read(path).unwrap(), b"user content");
2793 }
2794
2795 #[cfg(unix)]
2796 #[tokio::test]
2797 async fn rename_does_not_prefill_lossy_filename_bytes() {
2798 use std::os::unix::ffi::OsStringExt;
2799 let (_directory, mut app) = scanned_app();
2800 let path = app
2801 .path
2802 .join(std::ffi::OsString::from_vec(b"name-\xff".to_vec()));
2803 app.operation_warnings
2804 .push(gravityfile_core::ScanWarning::new(
2805 &path,
2806 gravityfile_core::WarningKind::ReadError,
2807 "diagnostic",
2808 ));
2809 app.view = View::Errors;
2810 app.start_rename();
2811 let input = app.input_state.as_ref().unwrap();
2812 assert_eq!(input.context_path, Some(path));
2813 assert_eq!(input.buffer(), "");
2814 assert!(
2815 app.operation_message
2816 .as_ref()
2817 .unwrap()
2818 .1
2819 .contains("not UTF-8")
2820 );
2821 app.scan_rx = None;
2823 app.handle_input_event(crossterm::event::KeyEvent::new(
2824 crossterm::event::KeyCode::Enter,
2825 crossterm::event::KeyModifiers::NONE,
2826 ));
2827 assert!(app.scan_rx.is_none());
2828 assert!(!app.operation_message.as_ref().unwrap().0);
2829 }
2830
2831 #[tokio::test]
2832 async fn rename_preserves_unicode_prefill_and_rejects_filesystem_root() {
2833 let (_directory, mut app) = scanned_app();
2834 let path = app.path.join("name-é-�");
2835 app.operation_warnings
2836 .push(gravityfile_core::ScanWarning::new(
2837 &path,
2838 gravityfile_core::WarningKind::ReadError,
2839 "diagnostic",
2840 ));
2841 app.view = View::Errors;
2842 app.start_rename();
2843 assert_eq!(app.input_state.as_ref().unwrap().buffer(), "name-é-�");
2844 app.input_state = None;
2845 app.mode = AppMode::Normal;
2846 let root = app.path.ancestors().last().unwrap().to_path_buf();
2847 app.operation_warnings[0].path = root;
2848 app.start_rename();
2849 assert!(app.input_state.is_none());
2850 assert_eq!(app.mode, AppMode::Normal);
2851 assert!(
2852 app.operation_message
2853 .as_ref()
2854 .unwrap()
2855 .1
2856 .contains("root cannot be renamed")
2857 );
2858 }
2859
2860 #[tokio::test]
2861 async fn node_lookup_follows_components_and_rejects_outside_paths() {
2862 let (_dir, app) = scanned_app();
2863 let tree = app.tree.as_ref().unwrap();
2864 let deep = app.path.join("src/nested/deep.txt");
2865 assert_eq!(
2866 App::find_node_at_path(&tree.root, &deep, &app.path)
2867 .unwrap()
2868 .name,
2869 "deep.txt"
2870 );
2871 assert!(std::ptr::eq(
2872 App::find_node_at_path(&tree.root, &app.path, &app.path).unwrap(),
2873 &tree.root
2874 ));
2875 assert!(
2876 App::find_node_at_path(
2877 &tree.root,
2878 &app.path.join("src/missing/deep.txt"),
2879 &app.path
2880 )
2881 .is_none()
2882 );
2883 assert!(
2884 App::find_node_at_path(&tree.root, &app.path.join("../outside"), &app.path).is_none()
2885 );
2886 assert!(
2887 App::find_node_at_path(&tree.root, &app.path.with_extension("outside"), &app.path)
2888 .is_none()
2889 );
2890 }
2891
2892 #[test]
2893 fn mutable_node_lookup_handles_deep_synthetic_paths() {
2894 use gravityfile_core::{FileNode, Timestamps};
2895 let timestamps = Timestamps::with_modified(std::time::UNIX_EPOCH);
2896 let mut node = FileNode::new_directory("d", timestamps);
2898 node.size = 42;
2899 let mut target = PathBuf::from("root");
2900 for _ in 0..20_000 {
2901 let mut parent = FileNode::new_directory("d", timestamps);
2902 parent.children.push(node);
2903 node = parent;
2904 target.push("d");
2905 }
2906 let actual =
2907 App::find_node_mut(&mut node, &target, Path::new("root")).map(|found| found.size);
2908 let mut pending = std::mem::take(&mut node.children);
2909 while let Some(mut child) = pending.pop() {
2910 pending.append(&mut child.children);
2911 }
2912 assert_eq!(actual, Some(42));
2913 }
2914
2915 #[tokio::test]
2916 async fn mutable_node_lookup_uses_the_same_path_scope() {
2917 let (_dir, mut app) = scanned_app();
2918 let tree = std::sync::Arc::make_mut(app.tree.as_mut().unwrap());
2919 let deep = app.path.join("src/nested/deep.txt");
2920 let found = App::find_node_mut(&mut tree.root, &deep, &app.path).unwrap();
2921 found.size = 12345;
2922 assert_eq!(
2923 App::find_node_at_path(&tree.root, &deep, &app.path)
2924 .unwrap()
2925 .size,
2926 12345
2927 );
2928 for path in [
2929 app.path.join("src/missing/deep.txt"),
2930 app.path.join("../outside"),
2931 app.path.with_extension("outside"),
2932 ] {
2933 assert!(App::find_node_mut(&mut tree.root, &path, &app.path).is_none());
2934 }
2935 let root: *const FileNode = &tree.root;
2936 let found = App::find_node_mut(&mut tree.root, &app.path, &app.path).unwrap();
2937 assert!(std::ptr::eq(found, root));
2938 }
2939
2940 #[tokio::test]
2941 async fn file_actions_follow_the_active_view_instead_of_the_explorer_cursor() {
2942 for layout in [LayoutMode::Tree, LayoutMode::Miller] {
2943 let (_dir, mut app) = populated_app();
2944 app.layout_mode = layout;
2945 app.marked.clear();
2946 app.clipboard.clear();
2947 app.view = View::Duplicates;
2948 app.duplicates_state.expanded_groups.insert(0);
2949 app.duplicates_state.selected_in_group.insert(0, 1);
2950 let duplicate = app.duplicates.as_ref().unwrap().groups[0].paths[0].clone();
2951 assert_eq!(app.get_selected_path(), Some(duplicate.clone()));
2952 assert_eq!(app.get_selected_info().unwrap().path, duplicate);
2953 app.yank_selection();
2954 assert_eq!(app.clipboard.paths, vec![duplicate.clone()]);
2955 app.start_rename();
2956 assert_eq!(
2957 app.input_state.as_ref().unwrap().context_path,
2958 Some(duplicate)
2959 );
2960 app.input_state = None;
2961 app.mode = AppMode::Normal;
2962 app.duplicates_state.expanded_groups.clear();
2965 assert!(app.get_selected_path().is_none());
2966 assert!(app.get_selected_info().is_none());
2967 app.start_rename();
2968 assert!(app.input_state.is_none());
2969
2970 let diagnosed = app.path.join("copy-b.bin");
2971 app.operation_warnings
2972 .push(gravityfile_core::ScanWarning::new(
2973 &diagnosed,
2974 gravityfile_core::WarningKind::ReadError,
2975 "operation failed",
2976 ));
2977 app.view = View::Errors;
2978 app.selected_warning = 0;
2979 assert_eq!(app.get_selected_path(), Some(diagnosed.clone()));
2980 assert_eq!(app.get_selected_info().unwrap().path, diagnosed);
2981 app.cut_selection();
2982 assert_eq!(app.clipboard.paths, vec![diagnosed]);
2983 app.selected_warning = 1;
2984 assert_eq!(app.get_selected_path(), Some(app.warnings[0].path.clone()));
2985 assert!(
2986 app.get_selected_info().is_none(),
2987 "unknown diagnostics must not borrow another file's details"
2988 );
2989
2990 let stale = app.path.join("src");
2991 app.age_report.as_mut().unwrap().stale_directories =
2992 vec![gravityfile_analyze::StaleDirectory {
2993 path: stale.clone(),
2994 size: 4096,
2995 newest_file_age: std::time::Duration::from_secs(86400),
2996 file_count: 2,
2997 }];
2998 app.view = View::Age;
2999 app.selected_stale_dir = 0;
3000 assert_eq!(app.get_selected_path(), Some(stale.clone()));
3001 assert_eq!(app.get_selected_info().unwrap().path, stale);
3002 assert!(!app.is_selected_file());
3003 app.selected_stale_dir = 1;
3004 assert!(app.get_selected_path().is_none());
3005 }
3006 }
3007
3008 #[tokio::test]
3009 async fn the_errors_view_selection_is_bounded_by_the_warning_count() {
3010 let (_dir, mut app) = populated_app();
3011 app.view = View::Errors;
3012 let last = app.warnings.len() - 1;
3013
3014 app.jump_to_bottom();
3015 assert_eq!(app.selected_warning, last);
3016 app.move_down();
3017 app.page_down();
3018 assert_eq!(app.selected_warning, last);
3019
3020 app.jump_to_top();
3021 app.move_up();
3022 assert_eq!(app.selected_warning, 0);
3023 }
3024
3025 #[tokio::test]
3026 async fn navigation_on_empty_views_does_not_underflow() {
3027 let (_dir, mut app) = scanned_app();
3030 app.duplicates = None;
3031 app.age_report = None;
3032 app.warnings.clear();
3033
3034 for view in VIEWS {
3035 app.view = *view;
3036 app.move_up();
3037 app.move_down();
3038 app.page_up();
3039 app.page_down();
3040 app.jump_to_top();
3041 app.jump_to_bottom();
3042 }
3043 }
3044
3045 #[tokio::test]
3046 async fn switching_layout_keeps_the_same_item_selected() {
3047 let (_dir, mut app) = scanned_app();
3048 app.layout_mode = LayoutMode::Tree;
3049 app.nav.tree_state.selected = tree_row(&app, "src");
3050 let selected_before = app.get_selected_path();
3051 assert!(selected_before.is_some());
3052
3053 app.toggle_layout();
3054 assert_eq!(app.layout_mode, LayoutMode::Miller);
3055 assert_eq!(app.get_selected_path(), selected_before);
3056
3057 app.toggle_layout();
3058 assert_eq!(app.layout_mode, LayoutMode::Tree);
3059 assert_eq!(app.get_selected_path(), selected_before);
3060 }
3061
3062 #[tokio::test]
3063 async fn view_cycling_wraps_in_both_directions() {
3064 let (_dir, mut app) = scanned_app();
3065 let start = app.view;
3066
3067 let mut seen = vec![start];
3068 for _ in 1..VIEWS.len() {
3069 app.handle_action(KeyAction::NextView);
3070 seen.push(app.view);
3071 }
3072 seen.sort_unstable_by_key(|view| *view as usize);
3073 seen.dedup_by_key(|view| *view as usize);
3074 assert_eq!(seen.len(), VIEWS.len(), "cycling must visit every view");
3075
3076 app.handle_action(KeyAction::NextView);
3077 assert_eq!(app.view, start, "NextView must wrap");
3078
3079 for _ in 0..VIEWS.len() {
3080 app.handle_action(KeyAction::PrevView);
3081 }
3082 assert_eq!(app.view, start, "PrevView must wrap");
3083 }
3084
3085 #[tokio::test]
3086 async fn escape_clears_the_clipboard_before_the_marks_and_never_quits() {
3087 let (_dir, mut app) = scanned_app();
3088 let root = app.nav.view_root.clone();
3089 app.marked.insert(root.join("empty"));
3090 app.clipboard.yank([root.join("copy-a.bin")], root.clone());
3091
3092 app.handle_action(KeyAction::Cancel);
3093 assert!(app.clipboard.is_empty(), "first Esc clears the clipboard");
3094 assert!(!app.marked.is_empty(), "marks survive the first Esc");
3095
3096 app.handle_action(KeyAction::Cancel);
3097 assert!(app.marked.is_empty(), "second Esc clears the marks");
3098
3099 app.handle_action(KeyAction::Cancel);
3100 assert_eq!(app.mode, AppMode::Normal, "Esc must never quit");
3101 }
3102
3103 #[tokio::test]
3104 async fn delete_requires_confirmation_and_cancelling_drops_the_auto_mark() {
3105 let (_dir, mut app) = scanned_app();
3106 assert!(app.marked.is_empty());
3107 app.nav.tree_state.selected = tree_row(&app, "copy-a.bin");
3108
3109 app.handle_action(KeyAction::Delete);
3110 assert_eq!(app.mode, AppMode::ConfirmDelete);
3111 assert!(!app.marked.is_empty(), "delete auto-marks the selection");
3112
3113 app.handle_action(KeyAction::Cancel);
3115 assert_eq!(app.mode, AppMode::ConfirmDelete);
3116 app.handle_delete_confirmation(crossterm::event::KeyEvent::from(
3117 crossterm::event::KeyCode::Char('n'),
3118 ));
3119 assert_eq!(app.mode, AppMode::Normal);
3120 assert!(
3121 app.marked.is_empty(),
3122 "cancelling must not leave the auto-mark behind"
3123 );
3124 }
3125
3126 #[tokio::test]
3127 async fn help_and_settings_swallow_navigation_keys() {
3128 let (_dir, mut app) = scanned_app();
3129 app.layout_mode = LayoutMode::Tree;
3130 app.mode = AppMode::Help;
3131
3132 app.handle_action(KeyAction::MoveDown);
3133 assert_eq!(
3134 app.nav.tree_state.selected, 0,
3135 "help must not move the cursor"
3136 );
3137
3138 app.handle_action(KeyAction::ToggleHelp);
3139 assert_eq!(app.mode, AppMode::Normal);
3140 }
3141
3142 #[tokio::test]
3143 async fn a_completed_operation_reports_skips_separately_from_failures() {
3144 let (_dir, mut app) = scanned_app();
3145
3146 app.handle_scan_result(ScanResult::OperationComplete {
3147 cancelled: false,
3148 operation_type: gravityfile_ops::OperationType::Copy,
3149 succeeded: 3,
3150 failed: 0,
3151 skipped: 2,
3152 bytes_processed: 1024,
3153 errors: Vec::new(),
3154 undo_info: None,
3155 undo_remaining: None,
3156 touched: Vec::new(),
3157 });
3158 let (success, message) = app.operation_message.clone().expect("a message is set");
3159 assert!(success, "skipping is a user decision, not a failure");
3160 assert!(message.contains("2 skipped"), "{message}");
3161 assert!(!message.contains("failed"), "{message}");
3162
3163 app.handle_scan_result(ScanResult::OperationComplete {
3164 cancelled: false,
3165 operation_type: gravityfile_ops::OperationType::Copy,
3166 succeeded: 1,
3167 failed: 1,
3168 skipped: 1,
3169 bytes_processed: 1024,
3170 errors: Vec::new(),
3171 undo_info: None,
3172 undo_remaining: None,
3173 touched: Vec::new(),
3174 });
3175 let (success, message) = app.operation_message.clone().expect("a message is set");
3176 assert!(!success, "a failure marks the operation unsuccessful");
3177 assert!(
3178 message.contains("1 skipped") && message.contains("1 failed"),
3179 "{message}"
3180 );
3181 }
3182
3183 #[tokio::test]
3184 async fn duplicate_marking_preserves_single_file_and_header_semantics() {
3185 let (_directory, mut app) = populated_app();
3186 app.marked.clear();
3187 app.view = View::Duplicates;
3188 let paths = app.duplicates.as_ref().unwrap().groups[0].paths.clone();
3189 assert!(paths.len() >= 2);
3190 app.duplicates_state.selected_group = 0;
3191 app.duplicates_state.expanded_groups.insert(0);
3192 app.duplicates_state
3193 .selected_in_group
3194 .insert(0, paths.len());
3195 app.toggle_mark();
3196 assert_eq!(app.marked, HashSet::from([paths.last().unwrap().clone()]));
3197 app.toggle_mark();
3198 assert!(app.marked.is_empty());
3199 app.duplicates_state.selected_in_group.insert(0, 0);
3200 app.toggle_mark();
3201 assert_eq!(app.marked, paths.iter().skip(1).cloned().collect());
3203 app.toggle_mark();
3204 assert!(app.marked.is_empty());
3205 app.marked.insert(paths[0].clone());
3206 app.toggle_mark();
3207 assert!(app.marked.is_empty());
3208 app.duplicates_state.selected_in_group.insert(0, usize::MAX);
3209 app.toggle_mark();
3210 assert!(app.marked.is_empty());
3211 app.duplicates_state.selected_group = usize::MAX;
3212 app.toggle_mark();
3213 assert!(app.marked.is_empty());
3214 }
3215
3216 #[tokio::test]
3219 #[ignore = "manual million-file explorer benchmark; run with --release --ignored --nocapture"]
3220 async fn explorer_frame_benchmark() {
3221 use gravityfile_core::{FileNode, FileTree, ScanConfig, Timestamps, TreeStats};
3222 let directory = tempfile::tempdir().unwrap();
3223 let root_path = dunce::canonicalize(directory.path()).unwrap();
3224 let time = Timestamps::with_modified(std::time::UNIX_EPOCH);
3225 let mut root = FileNode::new_directory("root", time);
3226 root.set_file_name(root_path.file_name().unwrap());
3227 for dir in 0..100 {
3228 let mut node = FileNode::new_directory(format!("dir-{dir:03}"), time);
3229 let files = if dir == 0 { 200_000 } else { 8_000 };
3230 node.children = (0..files)
3231 .map(|file| {
3232 FileNode::new_file(
3233 format!("file-{file:07}.bin"),
3234 (file as u64 * 7919) % 100_000,
3235 8,
3236 time,
3237 false,
3238 )
3239 })
3240 .collect();
3241 root.children.push(node);
3242 }
3243 root.update_counts();
3244 let mut tree = FileTree::new(
3245 root,
3246 root_path.clone(),
3247 ScanConfig::new(&root_path),
3248 TreeStats::new(),
3249 std::time::Duration::ZERO,
3250 Vec::new(),
3251 );
3252 tree.remeasure(true, true);
3253 let mut app = App::with_config(root_path.clone(), crate::TuiConfig::default());
3254 app.handle_scan_result(ScanResult::ScanComplete(Ok(tree)));
3255 app.scan_rx = None;
3256 app.nav.tree_state.expand(&root_path.join("dir-000"));
3257 app.update_cached_tree_len();
3258 let area = Rect::new(0, 0, 160, 50);
3259 let frame = |app: &App| {
3260 let mut samples: Vec<f64> = (0..21)
3261 .map(|_| {
3262 let start = std::time::Instant::now();
3263 std::hint::black_box(render(app, area));
3264 start.elapsed().as_secs_f64() * 1000.0
3265 })
3266 .collect();
3267 samples.sort_by(f64::total_cmp);
3268 samples[10]
3269 };
3270 let tree_ms = frame(&app);
3271 for file in 0..100 {
3272 app.marked
3273 .insert(root_path.join(format!("dir-001/file-{file:07}.bin")));
3274 }
3275 let marked_ms = frame(&app);
3276 app.marked.clear();
3277 app.nav.tree_state.selected = 150_000;
3278 let deep_ms = frame(&app);
3279 let start = std::time::Instant::now();
3280 app.handle_action(KeyAction::MoveDown);
3281 let key_ms = start.elapsed().as_secs_f64() * 1000.0;
3282 app.layout_mode = LayoutMode::Miller;
3283 app.update_cached_miller_len();
3284 let miller_ms = frame(&app);
3285 app.nav.view_root = root_path.join("dir-000");
3286 app.update_cached_miller_len();
3287 app.nav.miller_state.selected = 150_000;
3288 let miller_large_ms = frame(&app);
3289 println!(
3290 "explorer frame (median of 21), 1M files, 200k expanded: tree {tree_ms:.2} ms, \
3291 with 100 marks {marked_ms:.2} ms, selection 150k deep {deep_ms:.2} ms, \
3292 miller {miller_ms:.2} ms, miller inside the 200k directory {miller_large_ms:.2} ms; \
3293 j key {key_ms:.2} ms"
3294 );
3295 }
3296
3297 #[tokio::test]
3299 #[ignore = "manual million-path render benchmark; run with --release --ignored --nocapture"]
3300 async fn duplicate_render_benchmark() {
3301 use gravityfile_analyze::{AnalysisStatus, DuplicateGroup, DuplicateReport};
3302 let mut measurements = Vec::new();
3303 for group_count in [1usize, 1_000] {
3304 let directory = tempfile::tempdir().unwrap();
3305 let mut app = App::with_config(directory.path().into(), crate::TuiConfig::default());
3306 let paths_per_group = 1_000_000 / group_count;
3307 let groups = (0..group_count)
3308 .map(|group| DuplicateGroup {
3309 hash: gravityfile_core::ContentHash::new([0; 32]),
3310 size: 256,
3311 paths: (0..paths_per_group)
3312 .map(|file| app.path.join(format!("group-{group:04}/file-{file:09}")))
3313 .collect(),
3314 wasted_bytes: 256 * (paths_per_group as u64 - 1),
3315 })
3316 .collect();
3317 app.duplicates = Some(Arc::new(DuplicateReport {
3318 status: AnalysisStatus::default(),
3319 groups,
3320 total_duplicate_size: 256_000_000,
3321 total_wasted_space: 256 * (1_000_000 - group_count as u64),
3322 files_analyzed: 1_000_000,
3323 files_with_duplicates: 1_000_000,
3324 group_count,
3325 groups_omitted: 0,
3326 }));
3327 let start = std::time::Instant::now();
3328 let report = app.duplicates.as_ref().unwrap();
3329 let counts =
3330 duplicate_filter::prepare_counts(report, &app.path, &CancellationToken::new())
3331 .unwrap();
3332 assert!(counts.iter().all(|(_, sorted)| *sorted));
3333 let preparation_ms = start.elapsed().as_secs_f64() * 1000.0;
3334 let start = std::time::Instant::now();
3335 app.duplicate_filter.seed(report, app.path.clone(), counts);
3336 let installation_ms = start.elapsed().as_secs_f64() * 1000.0;
3337 app.view = View::Duplicates;
3338 app.duplicates_state.expanded_groups.extend(0..group_count);
3339 app.duplicates_state.selected_group = group_count - 1;
3340 app.duplicates_state
3341 .selected_in_group
3342 .insert(group_count - 1, paths_per_group);
3343 let area = Rect::new(0, 0, 120, 40);
3344 let start = std::time::Instant::now();
3345 std::hint::black_box(render(&app, area));
3346 let cold_ms = start.elapsed().as_secs_f64() * 1000.0;
3347 let mut samples = Vec::new();
3348 for _ in 0..31 {
3349 let start = std::time::Instant::now();
3350 std::hint::black_box(render(&app, area));
3351 samples.push(start.elapsed().as_secs_f64() * 1000.0);
3352 }
3353 let mut sorted = samples.clone();
3354 sorted.sort_by(f64::total_cmp);
3355 let mut scope_samples = Vec::new();
3356 for iteration in 0..31 {
3357 let scope = app.path.join(if iteration % 2 == 0 {
3358 "group-0000"
3359 } else {
3360 "missing"
3361 });
3362 let start = std::time::Instant::now();
3363 let filtered = app.duplicate_filter.get(report, &scope, &app.path);
3364 scope_samples.push(start.elapsed().as_secs_f64() * 1000.0);
3365 let count: usize = filtered.groups.iter().map(|(_, count)| count).sum();
3366 assert_eq!(
3367 count,
3368 if iteration % 2 == 0 {
3369 paths_per_group
3370 } else {
3371 0
3372 }
3373 );
3374 }
3375 let mut sorted_scope = scope_samples.clone();
3376 sorted_scope.sort_by(f64::total_cmp);
3377 let expected_mark = app.duplicates.as_ref().unwrap().groups[group_count - 1]
3378 .paths
3379 .last()
3380 .unwrap()
3381 .clone();
3382 drop(app.duplicate_filter.get(
3384 app.duplicates.as_ref().unwrap(),
3385 &app.nav.view_root,
3386 &app.path,
3387 ));
3388 let mut mark_samples = Vec::new();
3389 for iteration in 0..31 {
3390 let start = std::time::Instant::now();
3391 app.toggle_mark();
3392 mark_samples.push(start.elapsed().as_secs_f64() * 1000.0);
3393 assert_eq!(app.marked.contains(&expected_mark), iteration % 2 == 0);
3394 assert_eq!(app.marked.len(), usize::from(iteration % 2 == 0));
3395 }
3396 app.marked.clear();
3397 let mut sorted_mark = mark_samples.clone();
3398 sorted_mark.sort_by(f64::total_cmp);
3399 measurements.push(serde_json::json!({
3400 "groups": group_count, "paths": 1_000_000,
3401 "all_groups_expanded": true, "selection": "last file",
3402 "preparation_ms": preparation_ms, "installation_ms": installation_ms,
3403 "first_frame_ms": cold_ms, "cached_frame_ms": samples,
3404 "cached_median_ms": sorted[15], "cached_p95_ms": sorted[29],
3405 "scope_refresh_ms": scope_samples,
3406 "scope_refresh_median_ms": sorted_scope[15],
3407 "scope_refresh_p95_ms": sorted_scope[29],
3408 "single_mark_ms": mark_samples,
3409 "single_mark_median_ms": sorted_mark[15],
3410 "single_mark_p95_ms": sorted_mark[29],
3411 }));
3412 }
3413 println!(
3414 "DUPLICATE_RENDER_BENCHMARK {}",
3415 serde_json::json!({
3416 "schema_version": 1, "debug_assertions": cfg!(debug_assertions),
3417 "width": 120, "height": 40, "measurements": measurements,
3418 "scope": "full App widget to in-memory buffer; synthetic paths; excludes terminal I/O, analysis, and report construction"
3419 })
3420 );
3421 }
3422
3423 #[tokio::test]
3424 async fn new_navigation_branches_clear_forward_history_and_save_active_cursor() {
3425 for layout in [LayoutMode::Tree, LayoutMode::Miller] {
3426 for action in ["take", "cd", "search-directory", "search-file", "bookmark"] {
3427 let (_directory, mut app) = scanned_app();
3428 let root = app.nav.view_root.clone();
3429 app.layout_mode = layout;
3430 app.nav.tree_state.selected = 1;
3431 app.nav.miller_state.selected = 2;
3432 let expected_cursor = app.get_selected_path();
3433 assert!(expected_cursor.is_some());
3434 app.nav.forward.push(state::Visit {
3435 dir: root.join("obsolete"),
3436 selected: None,
3437 expanded: HashSet::new(),
3438 });
3439 let destination = if action == "search-file" {
3440 root.join("src/nested")
3441 } else {
3442 root.join("src")
3443 };
3444 match action {
3445 "take" => app.finish_take(destination.clone(), Ok(false)),
3446 "cd" => app.cd_to_path("src"),
3447 "search-directory" => app.navigate_to_search_result(&destination),
3448 "search-file" => app.navigate_to_search_result(&destination.join("deep.txt")),
3449 "bookmark" => {
3450 app.user_settings.bookmarks.set('a', destination.clone());
3451 app.jump_to_bookmark('a');
3452 }
3453 _ => unreachable!(),
3454 }
3455 assert_eq!(app.nav.view_root, destination, "{action}");
3456 assert!(app.nav.forward.is_empty(), "{action}");
3457 let origin = app.nav.back.last().unwrap();
3458 assert_eq!(origin.dir, root, "{action}");
3459 assert_eq!(origin.selected, expected_cursor, "{action}");
3460 if action == "cd" && layout == LayoutMode::Miller {
3461 assert_eq!(app.nav.miller_state.selected, 0);
3462 assert_eq!(app.cached_miller_len, 2);
3463 }
3464 }
3465 }
3466 }
3467
3468 #[tokio::test]
3469 async fn create_and_enter_waits_for_success_and_records_only_new_directories() {
3470 for existing in [false, true] {
3471 let (_directory, mut app) = scanned_app();
3472 let root = app.nav.view_root.clone();
3473 let destination = root.join("take-target");
3474 if existing {
3475 std::fs::create_dir(&destination).unwrap();
3476 }
3477 app.execute_take(root.clone(), "take-target".into());
3478 assert_eq!(
3479 app.nav.view_root, root,
3480 "navigation must wait for completion"
3481 );
3482 let mut rx = app.take_rx.take().unwrap();
3483 let result = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
3484 .await
3485 .unwrap()
3486 .unwrap();
3487 assert!(
3488 matches!(&result, ScanResult::TakeComplete { result: Ok(created), .. } if *created != existing)
3489 );
3490 app.handle_scan_result(result);
3491 assert_eq!(app.nav.view_root, destination);
3492 assert!(destination.is_dir());
3493 assert_eq!(app.undo_log.len(), usize::from(!existing));
3494 if let Some(entry) = app.undo_log.pop() {
3495 assert!(
3496 matches!(entry.operation, gravityfile_ops::UndoableOperation::DirectoryCreated { path } if path == destination)
3497 );
3498 }
3499 }
3500 }
3501
3502 #[tokio::test]
3503 async fn create_and_enter_completion_does_not_override_later_navigation() {
3504 let (_directory, mut app) = scanned_app();
3505 let root = app.nav.view_root.clone();
3506 app.execute_take(root.clone(), "created-later".into());
3507 let mut rx = app.take_rx.take().unwrap();
3508 let destination = root.join("src");
3509 app.nav.view_root = destination.clone();
3510 app.start_scan();
3511 let later_scan = app.scan_token.clone();
3512 let result = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
3513 .await
3514 .unwrap()
3515 .unwrap();
3516 app.handle_scan_result(result);
3517 assert_eq!(app.nav.view_root, destination);
3518 assert!(root.join("created-later").is_dir());
3519 assert_eq!(app.undo_log.len(), 1);
3520 assert!(!later_scan.is_cancelled());
3521 assert!(app.scan_rx.is_some());
3522 }
3523
3524 #[tokio::test]
3525 async fn take_result_survives_another_operation_and_rejects_overlapping_take() {
3526 let (_directory, mut app) = scanned_app();
3527 let root = app.nav.view_root.clone();
3528 app.execute_take(root.clone(), "first".into());
3529 app.handle_action(KeyAction::Quit);
3530 assert_eq!(app.mode, AppMode::Normal);
3531 app.execute_command("q");
3532 assert_eq!(app.mode, AppMode::Normal);
3533 assert!(app.take_rx.is_some());
3534 app.execute_take(root.clone(), "second".into());
3535 assert!(
3536 app.operation_message
3537 .as_ref()
3538 .unwrap()
3539 .1
3540 .contains("still in progress")
3541 );
3542 app.execute_create_file(root.clone(), "file".into());
3543 let later_token = app.op_token.clone();
3544 let mut take = app.take_rx.take().unwrap();
3545 let result = tokio::time::timeout(std::time::Duration::from_secs(5), take.recv())
3546 .await
3547 .unwrap()
3548 .unwrap();
3549 app.handle_scan_result(result);
3550 assert_eq!(app.mode, AppMode::UpdatingEntry);
3551 assert_eq!(app.nav.view_root, root);
3552 assert!(app.op_rx.is_some());
3553 assert!(!later_token.is_cancelled());
3554 assert_eq!(app.undo_log.len(), 1);
3555 tokio::time::timeout(
3556 std::time::Duration::from_secs(5),
3557 pump_until_complete(&mut app),
3558 )
3559 .await
3560 .unwrap();
3561 assert_eq!(app.undo_log.len(), 2);
3562 assert!(root.join("first").is_dir());
3563 assert!(root.join("file").is_file());
3564 assert!(!root.join("second").exists());
3565 }
3566
3567 #[tokio::test]
3568 async fn create_and_enter_preserves_navigation_on_invalid_names_and_conflicts() {
3569 let (_directory, mut app) = scanned_app();
3570 let root = app.nav.view_root.clone();
3571 for name in ["..", "bad\nname", "name."] {
3572 app.execute_take(root.clone(), name.into());
3573 assert!(app.take_rx.is_none());
3574 assert_eq!(app.nav.view_root, root);
3575 assert!(!app.operation_message.as_ref().unwrap().0);
3576 }
3577 #[cfg(unix)]
3578 {
3579 std::os::unix::fs::symlink(root.join("src"), root.join("linked-dir")).unwrap();
3580 }
3581 let conflicts: &[&str] = if cfg!(unix) {
3582 &["copy-a.bin", "linked-dir"]
3583 } else {
3584 &["copy-a.bin"]
3585 };
3586 for &name in conflicts {
3587 app.execute_take(root.clone(), name.into());
3588 let mut rx = app.take_rx.take().unwrap();
3589 let result = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
3590 .await
3591 .unwrap()
3592 .unwrap();
3593 app.handle_scan_result(result);
3594 assert_eq!(app.nav.view_root, root);
3595 assert!(app.undo_log.is_empty());
3596 assert!(!app.operation_message.as_ref().unwrap().0);
3597 }
3598 assert_eq!(
3599 std::fs::read(root.join("copy-a.bin")).unwrap(),
3600 vec![7u8; 8192]
3601 );
3602 }
3603
3604 #[tokio::test]
3605 async fn create_and_rename_ui_adapters_release_idle_workers() {
3606 let (create_tx, create_rx) = mpsc::channel(1);
3607 drop(App::adapt_create_rx(PathBuf::from("created"), create_rx));
3608 tokio::time::timeout(std::time::Duration::from_secs(5), create_tx.closed())
3609 .await
3610 .unwrap();
3611 let (rename_tx, rename_rx) = mpsc::channel(1);
3612 drop(App::adapt_rename_rx(
3613 PathBuf::from("original"),
3614 "renamed".into(),
3615 rename_rx,
3616 ));
3617 tokio::time::timeout(std::time::Duration::from_secs(5), rename_tx.closed())
3618 .await
3619 .unwrap();
3620 }
3621
3622 #[tokio::test]
3623 async fn create_adapter_records_undo_only_for_successful_mutations() {
3624 use gravityfile_ops::{CreateResult, OperationComplete, OperationType};
3625 let path = PathBuf::from("created");
3626 for operation_type in [OperationType::CreateFile, OperationType::CreateDirectory] {
3627 for (succeeded, failed, cancelled, expected_undo) in [
3628 (0, 0, false, false),
3629 (1, 0, false, true),
3630 (0, 1, false, false),
3631 (0, 0, true, false),
3632 ] {
3633 let (tx, rx) = mpsc::channel(1);
3634 let mut adapted = App::adapt_create_rx(path.clone(), rx);
3635 tx.send(CreateResult::Complete(OperationComplete {
3636 operation_type,
3637 succeeded,
3638 failed,
3639 cancelled,
3640 skipped: 0,
3641 bytes_processed: 0,
3642 errors: Vec::new(),
3643 }))
3644 .await
3645 .unwrap();
3646 drop(tx);
3647 let Some(ScanResult::OperationComplete {
3648 undo_info,
3649 succeeded: actual_succeeded,
3650 cancelled: actual_cancelled,
3651 ..
3652 }) = adapted.recv().await
3653 else {
3654 panic!("completion expected")
3655 };
3656 assert_eq!(actual_succeeded, succeeded);
3657 assert_eq!(actual_cancelled, cancelled);
3658 assert_eq!(undo_info.is_some(), expected_undo);
3659 if let Some(undo) = undo_info {
3660 match (operation_type, undo) {
3661 (
3662 OperationType::CreateFile,
3663 gravityfile_ops::UndoableOperation::FileCreated { path: actual },
3664 )
3665 | (
3666 OperationType::CreateDirectory,
3667 gravityfile_ops::UndoableOperation::DirectoryCreated { path: actual },
3668 ) => assert_eq!(actual, path),
3669 _ => panic!("wrong undo kind"),
3670 }
3671 }
3672 }
3673 }
3674 }
3675
3676 #[tokio::test]
3677 async fn cancelled_undo_delivers_completion_through_ui_adapter() {
3678 let directory = tempfile::tempdir().unwrap();
3679 let path = directory.path().join("created");
3680 std::fs::write(&path, []).unwrap();
3681 let mut app = App::with_config(directory.path().into(), crate::TuiConfig::default());
3682 app.undo_log.record_create_file(path.clone());
3683 app.execute_undo();
3684 app.op_token.cancel();
3685 let mut rx = app.op_rx.take().unwrap();
3686 let complete = tokio::time::timeout(std::time::Duration::from_secs(5), async {
3687 while let Some(result) = rx.recv().await {
3688 if let ScanResult::OperationComplete {
3689 cancelled,
3690 succeeded,
3691 ..
3692 } = result
3693 {
3694 return Some((cancelled, succeeded));
3695 }
3696 }
3697 None
3698 })
3699 .await
3700 .unwrap();
3701 assert_eq!(complete, Some((true, 0)));
3702 assert!(path.exists());
3703 }
3704
3705 #[tokio::test]
3706 async fn filesystem_operations_cancel_analysis_but_not_a_scan() {
3707 let operations = ["rename", "file", "directory", "take", "undo", "paste"];
3708 for (operation, analyzing) in operations
3709 .into_iter()
3710 .flat_map(|operation| [(operation, true), (operation, false)])
3711 {
3712 let (_dir, mut app) = scanned_app();
3713 let tree = Arc::clone(app.tree.as_ref().unwrap());
3714 let root = app.path.clone();
3715 let (old_tx, old_rx) = mpsc::channel(1);
3716 app.scan_rx = Some(old_rx);
3717 app.scan_progress = (!analyzing).then(ScanProgress::new);
3718 app.analyzing = analyzing;
3719 let old_token = app.scan_token.clone();
3720 match operation {
3721 "rename" => app.execute_rename(root.join("copy-a.bin"), "renamed".into()),
3722 "file" => app.execute_create_file(root, "created".into()),
3723 "directory" => app.execute_create_directory(root, "created".into()),
3724 "take" => app.execute_take(root, "created".into()),
3725 "undo" => {
3726 let path = root.join("created");
3727 std::fs::write(&path, []).unwrap();
3728 app.undo_log.record_create_file(path);
3729 app.execute_undo();
3730 }
3731 "paste" => {
3732 app.clipboard.yank([root.join("copy-a.bin")], root.clone());
3733 app.nav.view_root = root.join("src");
3734 app.nav.tree_state.selected = 0;
3735 app.paste_with_resolution(Some(gravityfile_ops::ConflictResolution::Skip));
3736 }
3737 _ => unreachable!(),
3738 }
3739 if analyzing {
3740 assert!(old_token.is_cancelled(), "{operation}");
3742 assert!(old_tx.is_closed(), "{operation}");
3743 assert!(!app.scan_token.is_cancelled(), "{operation}");
3744 assert!(!app.analyzing);
3745 } else {
3746 assert!(!old_token.is_cancelled(), "{operation}");
3747 assert!(app.scan_rx.is_some(), "{operation}");
3748 assert!(app.scan_progress.is_some(), "{operation}");
3749 }
3750 assert!(Arc::ptr_eq(app.tree.as_ref().unwrap(), &tree));
3751 let mut rx = if operation == "take" {
3752 app.take_rx.take()
3753 } else {
3754 app.op_rx.take()
3755 }
3756 .expect("operation receiver");
3757 tokio::time::timeout(std::time::Duration::from_secs(5), async {
3758 let mut completed = false;
3759 while let Some(result) = rx.recv().await {
3760 completed |= matches!(
3761 result,
3762 ScanResult::OperationComplete { .. } | ScanResult::TakeComplete { .. }
3763 );
3764 }
3765 assert!(completed, "{operation}");
3766 })
3767 .await
3768 .unwrap();
3769 }
3770 }
3771
3772 #[tokio::test]
3773 async fn submitted_create_and_rename_retain_completion_and_undo() {
3774 for mode in [
3775 AppMode::Renaming,
3776 AppMode::CreatingFile,
3777 AppMode::CreatingDirectory,
3778 ] {
3779 for conflict in [false, true] {
3780 let (_dir, mut app) = scanned_app();
3781 let root = app.path.clone();
3782 let destination = root.join("result");
3783 if conflict {
3784 std::fs::write(&destination, b"existing").unwrap();
3785 }
3786 let mut input = InputState::with_initial("result");
3787 input.context_path = Some(if mode == AppMode::Renaming {
3788 root.join("copy-a.bin")
3789 } else {
3790 root
3791 });
3792 app.input_state = Some(input);
3793 app.mode = mode;
3794 app.dispatch_key_event(crossterm::event::KeyEvent::new(
3795 crossterm::event::KeyCode::Enter,
3796 crossterm::event::KeyModifiers::NONE,
3797 ));
3798 assert_eq!(app.mode, AppMode::UpdatingEntry);
3799 for key in [
3800 crossterm::event::KeyCode::Char('R'),
3801 crossterm::event::KeyCode::Char('u'),
3802 crossterm::event::KeyCode::Esc,
3803 crossterm::event::KeyCode::Char('q'),
3804 ] {
3805 app.dispatch_key_event(crossterm::event::KeyEvent::new(
3806 key,
3807 crossterm::event::KeyModifiers::NONE,
3808 ));
3809 assert_eq!(app.mode, AppMode::UpdatingEntry);
3810 assert!(app.op_rx.is_some());
3811 }
3812 tokio::time::timeout(
3813 std::time::Duration::from_secs(5),
3814 pump_until_complete(&mut app),
3815 )
3816 .await
3817 .unwrap();
3818 assert_eq!(app.mode, AppMode::Normal);
3819 assert_eq!(app.undo_log.len(), usize::from(!conflict));
3820 assert_eq!(app.operation_message.as_ref().unwrap().0, !conflict);
3821 assert!(destination.exists());
3822 if conflict {
3823 assert_eq!(std::fs::read(destination).unwrap(), b"existing");
3824 }
3825 }
3826 }
3827 }
3828
3829 #[tokio::test]
3830 async fn closed_operation_channel_releases_operation_modes_without_claiming_success() {
3831 let (_dir, mut app) = scanned_app();
3832 let tree = Arc::clone(app.tree.as_ref().unwrap());
3833 app.undo_log.record_create_file(app.path.join("recorded"));
3834 for mode in [
3835 AppMode::Copying,
3836 AppMode::Moving,
3837 AppMode::Undoing,
3838 AppMode::Deleting,
3839 AppMode::BulkRenaming,
3840 AppMode::ConflictResolution,
3841 AppMode::Normal,
3842 AppMode::Search,
3843 AppMode::CreatingFile,
3844 ] {
3845 pump(&mut app, |_| false).await;
3848 let (tx, rx) = mpsc::channel(1);
3849 drop(tx);
3850 app.op_rx = Some(rx);
3851 app.op_token = CancellationToken::new();
3852 app.operation_progress = Some(OperationProgress::new(
3853 gravityfile_ops::OperationType::Copy,
3854 1,
3855 0,
3856 ));
3857 app.operation_message = Some((true, "old success".into()));
3858 app.deletion_message = Some((true, "old deletion".into()));
3859 app.mode = mode;
3860 let Background::Operation(None) = app.next_background().await else {
3861 panic!("the closed operation channel reports first");
3862 };
3863 app.handle_background(Background::Operation(None));
3864 let expected = match mode {
3865 AppMode::Search | AppMode::CreatingFile => mode,
3866 _ => AppMode::Normal,
3867 };
3868 assert_eq!(app.mode, expected);
3869 assert!(app.op_token.is_cancelled());
3870 assert!(app.op_rx.is_none());
3871 assert!(app.operation_progress.is_none());
3872 assert!(app.operation_message.is_none());
3873 assert!(app.deletion_message.is_none());
3874 assert!(app.error.as_ref().unwrap().contains("outcome unknown"));
3875 assert!(Arc::ptr_eq(app.tree.as_ref().unwrap(), &tree));
3878 assert_eq!(app.undo_log.len(), 1);
3879 app.cancel_scan();
3880 }
3881 }
3882
3883 #[tokio::test]
3884 async fn plugin_notices_and_failures_reach_the_status_line() {
3885 use gravityfile_plugin::{HookResult, Notice, NoticeLevel};
3886 let (_dir, mut app) = scanned_app();
3887 app.fire(gravityfile_plugin::Hook::OnStartup);
3889 assert_eq!(app.hooks_in_flight, 0);
3890
3891 app.hooks_in_flight = 1;
3892 let mut said = HookResult::ok();
3893 said.plugin = Some("git".into());
3894 said.notices = vec![Notice::new(NoticeLevel::Info, "3 untracked files")];
3895 app.finish_hook(vec![said]);
3896 assert_eq!(app.hooks_in_flight, 0);
3897 assert_eq!(
3898 app.operation_message,
3899 Some((true, "git: 3 untracked files".into()))
3900 );
3901
3902 let mut failed = HookResult::error("attempt to index a nil value");
3903 failed.plugin = Some("broken".into());
3904 app.finish_hook(vec![failed]);
3905 assert_eq!(
3906 app.operation_message,
3907 Some((false, "broken failed: attempt to index a nil value".into()))
3908 );
3909 }
3910
3911 #[tokio::test]
3912 async fn closed_scan_channel_ends_the_scan_and_keeps_the_listing() {
3913 let (_dir, mut app) = scanned_app();
3914 let tree = Arc::clone(app.tree.as_ref().unwrap());
3915 let (tx, rx) = mpsc::channel(1);
3916 drop(tx);
3917 app.scan_rx = Some(rx);
3918 app.scan_progress = Some(ScanProgress::new());
3919 let token = app.scan_token.clone();
3920 app.handle_background(Background::Scan(None));
3921 assert!(token.is_cancelled());
3922 assert!(app.scan_rx.is_none() && app.scan_progress.is_none());
3923 assert!(
3924 app.error
3925 .as_ref()
3926 .unwrap()
3927 .contains("Scan ended without a result")
3928 );
3929 assert!(Arc::ptr_eq(app.tree.as_ref().unwrap(), &tree));
3930 }
3931
3932 #[tokio::test]
3933 async fn undo_retains_exclusive_ui_ownership_until_cancelled_completion() {
3934 let directory = tempfile::tempdir().unwrap();
3935 let path = directory.path().join("created");
3936 std::fs::write(&path, []).unwrap();
3937 let mut app = App::with_config(directory.path().into(), crate::TuiConfig::default());
3938 app.undo_log
3939 .record_create_file(directory.path().join("older"));
3940 app.undo_log.record_create_file(path.clone());
3941 app.execute_undo();
3942 assert_eq!(app.mode, AppMode::Undoing);
3943 let token = app.op_token.clone();
3944 app.execute_undo();
3945 app.handle_action(KeyAction::Undo);
3946 app.dispatch_key_event(crossterm::event::KeyEvent::new(
3947 crossterm::event::KeyCode::Char('R'),
3948 crossterm::event::KeyModifiers::NONE,
3949 ));
3950 assert_eq!(app.undo_log.len(), 1);
3951 assert!(!token.is_cancelled());
3952 assert_eq!(app.mode, AppMode::Undoing);
3953 for action in [KeyAction::Cancel, KeyAction::Quit, KeyAction::ForceQuit] {
3954 app.handle_action(action);
3955 assert!(token.is_cancelled());
3956 assert!(app.op_rx.is_some());
3957 assert_eq!(app.mode, AppMode::Undoing);
3958 }
3959 let mut rx = app.op_rx.take().unwrap();
3960 let result = tokio::time::timeout(std::time::Duration::from_secs(5), async {
3961 loop {
3962 let result = rx.recv().await.expect("undo completion");
3963 if matches!(result, ScanResult::OperationComplete { .. }) {
3964 break result;
3965 }
3966 }
3967 })
3968 .await
3969 .unwrap();
3970 assert!(matches!(
3971 &result,
3972 ScanResult::OperationComplete {
3973 cancelled: true,
3974 succeeded: 0,
3975 ..
3976 }
3977 ));
3978 app.handle_scan_result(result);
3979 assert_eq!(app.mode, AppMode::Normal);
3980 assert_eq!(app.undo_log.len(), 2);
3982 assert_eq!(
3983 app.undo_log.peek().unwrap().operation,
3984 gravityfile_ops::UndoableOperation::FileCreated { path: path.clone() }
3985 );
3986 assert!(path.exists());
3987 }
3988
3989 #[test]
3990 fn warning_merge_preserves_distinct_details_and_stable_order() {
3991 use gravityfile_core::{ScanWarning, WarningKind};
3992 let directory = tempfile::tempdir().unwrap();
3993 let mut app = App::with_config(directory.path().into(), crate::TuiConfig::default());
3994 let path = directory.path().join("file");
3995 let first = ScanWarning::new(&path, WarningKind::ReadError, "first failure");
3996 let different_kind = ScanWarning::new(&path, WarningKind::MetadataError, "first failure");
3997 let different_message = ScanWarning::new(&path, WarningKind::ReadError, "second failure");
3998 app.warnings.push(first.clone());
3999 app.merge_scan_warnings(&[
4000 first.clone(),
4001 different_kind.clone(),
4002 different_message.clone(),
4003 different_kind.clone(),
4004 ]);
4005 assert_eq!(app.warnings, vec![first, different_kind, different_message]);
4006 }
4007
4008 #[test]
4009 fn warning_rows_render_in_the_same_order_as_action_selection() {
4010 use gravityfile_core::{ScanWarning, WarningKind};
4011 let directory = tempfile::tempdir().unwrap();
4012 let mut app = App::with_config(directory.path().into(), crate::TuiConfig::default());
4013 app.view = View::Errors;
4014 app.operation_warnings = vec![ScanWarning::new(
4015 "operation-entry",
4016 WarningKind::ReadError,
4017 "operation detail",
4018 )];
4019 app.warnings = vec![ScanWarning::new(
4020 "scan-entry",
4021 WarningKind::BrokenSymlink,
4022 "scan detail",
4023 )];
4024 for selected in 0..2 {
4025 app.selected_warning = selected;
4026 let area = Rect::new(0, 0, 120, 30);
4027 let mut buffer = Buffer::empty(area);
4028 app.render(area, &mut buffer);
4029 let rendered: String = buffer.content.iter().map(|cell| cell.symbol()).collect();
4030 let operation = rendered
4031 .find("operation-entry")
4032 .expect("operation row visible");
4033 let scan = rendered.find("scan-entry").expect("scan row visible");
4034 assert!(operation < scan);
4035 assert_eq!(rendered.contains("[d to delete]"), selected == 1);
4036 let expected = ["operation-entry", "scan-entry"][selected];
4037 assert_eq!(
4038 app.displayed_warning(selected).unwrap().path,
4039 PathBuf::from(expected)
4040 );
4041 app.mode = AppMode::Normal;
4042 app.marked.clear();
4043 app.handle_action(KeyAction::Delete);
4044 if selected == 1 {
4045 assert_eq!(app.mode, AppMode::ConfirmDelete);
4046 assert_eq!(app.marked.len(), 1);
4047 assert!(app.marked.contains(&PathBuf::from("scan-entry")));
4048 } else {
4049 assert_eq!(app.mode, AppMode::Normal);
4050 assert!(app.marked.is_empty());
4051 }
4052 }
4053 }
4054
4055 #[test]
4056 fn warning_merge_handles_large_repeated_final_batches() {
4057 use gravityfile_core::{ScanWarning, WarningKind};
4058 let directory = tempfile::tempdir().unwrap();
4059 let mut app = App::with_config(directory.path().into(), crate::TuiConfig::default());
4060 let warnings: Vec<_> = (0..10_000)
4061 .map(|index| {
4062 ScanWarning::new(
4063 directory.path().join(index.to_string()),
4064 WarningKind::PermissionDenied,
4065 "denied",
4066 )
4067 })
4068 .collect();
4069 app.warnings.extend_from_slice(&warnings[..5_000]);
4070 app.merge_scan_warnings(&warnings);
4071 app.merge_scan_warnings(&warnings);
4072 assert_eq!(app.warnings, warnings);
4073 }
4074
4075 #[tokio::test]
4076 async fn scan_completion_preserves_open_dialog_and_unrelated_error() {
4077 let (_directory, mut app) = scanned_app();
4078 let tree = app.tree.as_ref().unwrap().as_ref().clone();
4079 app.mode = AppMode::Settings;
4080 let mut edited = SettingsState::new(app.user_settings.clone());
4081 edited.dirty = true;
4082 edited.settings.scan_on_startup = !edited.settings.scan_on_startup;
4083 app.settings_state = Some(edited);
4084 app.error = Some("Cannot load settings; using defaults".into());
4085 app.handle_scan_result(ScanResult::ScanComplete(Ok(tree)));
4086 assert_eq!(app.mode, AppMode::Settings);
4087 assert!(app.settings_state.as_ref().unwrap().dirty);
4088 assert_eq!(
4089 app.error.as_deref(),
4090 Some("Cannot load settings; using defaults")
4091 );
4092 app.handle_scan_result(ScanResult::ScanComplete(Err(
4093 gravityfile_scan::ScanError::Other {
4094 message: "scan failed".into(),
4095 },
4096 )));
4097 assert_eq!(app.mode, AppMode::Settings);
4098 assert!(app.settings_state.as_ref().unwrap().dirty);
4099 assert!(app.error.as_ref().unwrap().contains("scan failed"));
4100 }
4101
4102 #[test]
4103 fn escape_acknowledges_error_before_changing_selection_or_work() {
4104 let directory = tempfile::tempdir().unwrap();
4105 let mut app = App::with_config(directory.path().into(), crate::TuiConfig::default());
4106 let marked = directory.path().join("marked");
4107 app.marked.insert(marked.clone());
4108 app.error = Some("visible error".into());
4109 app.scan_progress = Some(ScanProgress::new());
4110 app.handle_action(KeyAction::Cancel);
4111 assert!(app.error.is_none());
4112 assert!(app.marked.contains(&marked));
4113 assert!(app.scan_progress.is_some());
4114 assert!(!app.scan_token.is_cancelled());
4115 app.handle_action(KeyAction::Cancel);
4116 assert!(app.marked.is_empty());
4117 }
4118
4119 #[test]
4120 fn editor_handoff_preserves_source_mapping_and_rejects_invalid_edits() {
4121 for valid in [false, true] {
4122 let directory = tempfile::tempdir().unwrap();
4123 let first = directory.path().join("a");
4124 let second = directory.path().join("z");
4125 std::fs::write(&first, b"first").unwrap();
4126 std::fs::write(&second, b"second").unwrap();
4127 let mut app = App::with_config(directory.path().into(), crate::TuiConfig::default());
4128 app.marked.extend([second.clone(), first.clone()]);
4129 app.start_bulk_rename();
4130 let edit_path = app.bulk_rename_edit.as_ref().unwrap().path().to_owned();
4131 assert_eq!(std::fs::read_to_string(&edit_path).unwrap(), "a\nz\n");
4132 app.pending_suspend_command = None; std::fs::write(&edit_path, if valid { "b\ny\n" } else { "b\n\n" }).unwrap();
4134 app.process_bulk_rename_from_editor();
4135 assert!(!edit_path.exists());
4136 if valid {
4137 let state = app.bulk_rename_state.as_ref().unwrap();
4138 assert_eq!(state.entries[0].original, first);
4139 assert_eq!(state.entries[0].new_name, "b");
4140 assert_eq!(state.entries[1].original, second);
4141 assert_eq!(state.entries[1].new_name, "y");
4142 assert_eq!(app.mode, AppMode::ConfirmBulkRename);
4143 } else {
4144 assert!(app.bulk_rename_state.is_none());
4145 assert!(app.error.as_ref().unwrap().contains("edited rename list"));
4146 }
4147 assert_eq!(app.marked.len(), 2);
4148 assert_eq!(std::fs::read(first).unwrap(), b"first");
4149 assert_eq!(std::fs::read(second).unwrap(), b"second");
4150 }
4151 }
4152
4153 #[tokio::test]
4154 async fn cancelling_bulk_rename_waits_for_worker_result_and_preserves_marks() {
4155 let directory = tempfile::tempdir().unwrap();
4156 let source = directory.path().join("source");
4157 std::fs::write(&source, b"payload").unwrap();
4158 let mut app = App::with_config(directory.path().into(), crate::TuiConfig::default());
4159 app.marked.insert(source.clone());
4160 app.bulk_rename_state = Some(state::BulkRenameState::new(
4161 vec![source.clone()],
4162 vec!["target".into()],
4163 ));
4164 app.execute_bulk_rename();
4165 app.handle_action(KeyAction::Cancel);
4166 assert!(app.op_token.is_cancelled());
4167 assert_eq!(app.mode, AppMode::BulkRenaming);
4168 assert!(app.op_rx.is_some());
4169 let result =
4170 tokio::time::timeout(Duration::from_secs(5), app.op_rx.as_mut().unwrap().recv())
4171 .await
4172 .unwrap()
4173 .unwrap();
4174 app.handle_scan_result(result);
4175 assert_eq!(app.mode, AppMode::Normal);
4176 assert!(!app.operation_message.as_ref().unwrap().0);
4177 assert!(
4178 app.operation_message
4179 .as_ref()
4180 .unwrap()
4181 .1
4182 .contains("cancelled")
4183 );
4184 assert!(app.marked.contains(&source));
4185 assert_eq!(std::fs::read(source).unwrap(), b"payload");
4186 assert!(!directory.path().join("target").exists());
4187 }
4188
4189 #[tokio::test]
4190 async fn bulk_rename_runs_in_background_and_reports_success_or_failure() {
4191 for fail in [false, true] {
4192 let directory = tempfile::tempdir().unwrap();
4193 let source = directory.path().join("source");
4194 let target = directory.path().join("target");
4195 std::fs::write(&source, b"payload").unwrap();
4196 if fail {
4197 std::fs::write(&target, b"existing").unwrap();
4198 }
4199 let mut app = App::with_config(directory.path().into(), crate::TuiConfig::default());
4200 app.marked.insert(source.clone());
4201 app.bulk_rename_state = Some(state::BulkRenameState::new(
4202 vec![source.clone()],
4203 vec!["target".into()],
4204 ));
4205 app.execute_bulk_rename();
4206 assert_eq!(app.mode, AppMode::BulkRenaming);
4207 app.handle_action(KeyAction::MoveDown);
4208 assert_eq!(app.mode, AppMode::BulkRenaming);
4209 let result =
4210 tokio::time::timeout(Duration::from_secs(5), app.op_rx.as_mut().unwrap().recv())
4211 .await
4212 .unwrap()
4213 .unwrap();
4214 assert!(matches!(result, ScanResult::BulkRenameComplete(_)));
4215 app.handle_scan_result(result);
4216 assert_eq!(app.mode, AppMode::Normal);
4217 assert_eq!(app.operation_message.as_ref().unwrap().0, !fail);
4218 if fail {
4219 assert_eq!(std::fs::read(&source).unwrap(), b"payload");
4220 assert_eq!(std::fs::read(&target).unwrap(), b"existing");
4221 assert!(app.marked.contains(&source));
4222 } else {
4223 assert!(!source.exists());
4224 assert_eq!(std::fs::read(&target).unwrap(), b"payload");
4225 assert!(app.marked.is_empty());
4226 }
4227 }
4228 }
4229
4230 #[tokio::test]
4231 async fn dropping_tui_operation_adapters_closes_idle_upstream_channels() {
4232 let (transfer_tx, transfer_rx) = mpsc::channel(1);
4233 drop(App::adapt_transfer_rx(transfer_rx));
4234 tokio::time::timeout(Duration::from_secs(5), transfer_tx.closed())
4235 .await
4236 .expect("idle transfer adapter must release its upstream receiver");
4237 }
4238
4239 #[tokio::test]
4240 async fn cancelled_completion_is_visible_and_keeps_partial_undo() {
4241 let (_dir, mut app) = scanned_app();
4242 app.handle_scan_result(ScanResult::OperationComplete {
4243 cancelled: true,
4244 operation_type: gravityfile_ops::OperationType::Copy,
4245 succeeded: 1,
4246 failed: 0,
4247 skipped: 0,
4248 bytes_processed: 10,
4249 errors: Vec::new(),
4250 undo_info: Some(gravityfile_ops::UndoableOperation::FilesCopied {
4251 created: vec![app.path.join("copied")],
4252 }),
4253 undo_remaining: None,
4254 touched: Vec::new(),
4255 });
4256 let (success, message) = app.operation_message.as_ref().unwrap();
4257 assert!(!success);
4258 assert!(message.contains("cancelled"));
4259 assert!(message.contains("1 items"));
4260 assert!(app.undo_log.pop().is_some());
4261 }
4262
4263 fn stage_conflicting_paste(app: &mut App) -> PathBuf {
4267 let root = app.nav.view_root.clone();
4268 let source = root.join("src").join("copy-a.bin");
4269 std::fs::write(&source, b"replacement contents").expect("write the source");
4270
4271 app.clipboard.yank([source], root.join("src"));
4272
4273 app.layout_mode = LayoutMode::Tree;
4277 app.nav.tree_state.selected = 0;
4278
4279 root.join("copy-a.bin")
4280 }
4281
4282 async fn pump_until_conflict(app: &mut App) {
4284 pump(app, |background| {
4285 matches!(
4286 background,
4287 Background::Operation(Some(ScanResult::OperationConflict(_)))
4288 )
4289 })
4290 .await;
4291 assert!(app.pending_conflict.is_some(), "no conflict was reported");
4292 }
4293
4294 async fn pump(app: &mut App, stop: impl Fn(&Background) -> bool) {
4297 while app.has_background() {
4298 let background =
4299 tokio::time::timeout(std::time::Duration::from_secs(10), app.next_background())
4300 .await
4301 .expect("a background task stopped making progress");
4302 let done = stop(&background);
4303 app.handle_background(background);
4304 if done {
4305 return;
4306 }
4307 }
4308 }
4309
4310 async fn pump_until_complete(app: &mut App) {
4312 pump(app, |background| {
4313 matches!(
4314 background,
4315 Background::Operation(Some(ScanResult::OperationComplete { .. }))
4316 )
4317 })
4318 .await;
4319 }
4320
4321 #[tokio::test]
4322 async fn a_paste_conflict_pauses_until_the_user_answers_and_skip_preserves_the_file() {
4323 let (_dir, mut app) = scanned_app();
4324 let destination = stage_conflicting_paste(&mut app);
4325 let before = std::fs::read(&destination).expect("read the destination");
4326
4327 app.paste_clipboard();
4328 assert_eq!(app.mode, AppMode::Copying);
4329
4330 pump_until_conflict(&mut app).await;
4331 assert_eq!(app.mode, AppMode::ConflictResolution);
4332
4333 assert_eq!(
4335 std::fs::read(&destination).unwrap(),
4336 before,
4337 "the operation wrote before the user answered"
4338 );
4339
4340 app.dispatch_key_event(crossterm::event::KeyEvent::new(
4342 crossterm::event::KeyCode::Char('s'),
4343 crossterm::event::KeyModifiers::NONE,
4344 ));
4345 assert!(app.pending_conflict.is_none(), "the request was answered");
4346 assert_eq!(app.mode, AppMode::Copying, "the operation is still running");
4347
4348 pump_until_complete(&mut app).await;
4349
4350 assert_eq!(
4351 std::fs::read(&destination).unwrap(),
4352 before,
4353 "skip must leave the existing file alone"
4354 );
4355 let (_, message) = app
4356 .operation_message
4357 .clone()
4358 .expect("a summary is reported");
4359 assert!(message.contains("1 skipped"), "{message}");
4360 }
4361
4362 #[tokio::test]
4363 async fn answering_a_paste_conflict_with_overwrite_replaces_the_file() {
4364 let (_dir, mut app) = scanned_app();
4365 let destination = stage_conflicting_paste(&mut app);
4366 let before = std::fs::read(&destination).expect("read the destination");
4367
4368 app.paste_clipboard();
4369 pump_until_conflict(&mut app).await;
4370
4371 app.dispatch_key_event(crossterm::event::KeyEvent::new(
4373 crossterm::event::KeyCode::Char('o'),
4374 crossterm::event::KeyModifiers::NONE,
4375 ));
4376 pump_until_complete(&mut app).await;
4377
4378 let after = std::fs::read(&destination).unwrap();
4379 assert_ne!(after, before, "overwrite left the old contents in place");
4380 assert_eq!(after, b"replacement contents");
4381 }
4382
4383 #[tokio::test]
4384 async fn paste_cancellation_retains_results_until_completion() {
4385 for mode in [ClipboardMode::Copy, ClipboardMode::Cut] {
4386 for at_conflict in [false, true] {
4387 let (_dir, mut app) = scanned_app();
4388 let destination = stage_conflicting_paste(&mut app);
4389 let source = app.clipboard.paths[0].clone();
4390 app.clipboard.mode = mode;
4391 let before = std::fs::read(&destination).unwrap();
4392 app.paste_clipboard();
4393 let expected = if mode == ClipboardMode::Cut {
4394 AppMode::Moving
4395 } else {
4396 AppMode::Copying
4397 };
4398 if at_conflict {
4399 pump_until_conflict(&mut app).await;
4400 }
4401 app.dispatch_key_event(crossterm::event::KeyEvent::new(
4402 crossterm::event::KeyCode::Esc,
4403 crossterm::event::KeyModifiers::NONE,
4404 ));
4405 assert_eq!(app.mode, expected);
4406 assert!(app.op_token.is_cancelled());
4407 assert!(app.op_rx.is_some());
4408 app.handle_action(KeyAction::Undo);
4409 assert_eq!(app.mode, expected);
4410 tokio::time::timeout(
4411 std::time::Duration::from_secs(5),
4412 pump_until_complete(&mut app),
4413 )
4414 .await
4415 .unwrap();
4416 assert_eq!(app.mode, AppMode::Normal);
4417 assert!(
4418 app.operation_message
4419 .as_ref()
4420 .unwrap()
4421 .1
4422 .contains("cancelled")
4423 );
4424 assert_eq!(std::fs::read(&destination).unwrap(), before);
4425 assert_eq!(std::fs::read(&source).unwrap(), b"replacement contents");
4426 }
4427 }
4428 }
4429
4430 #[tokio::test]
4431 async fn skipped_move_conflict_resumes_move_mode_after_clipboard_clears() {
4432 let (_dir, mut app) = scanned_app();
4433 stage_conflicting_paste(&mut app);
4434 app.clipboard.mode = ClipboardMode::Cut;
4435 app.paste_clipboard();
4436 assert!(app.clipboard.is_empty());
4437 pump_until_conflict(&mut app).await;
4438 app.dispatch_key_event(crossterm::event::KeyEvent::new(
4439 crossterm::event::KeyCode::Char('s'),
4440 crossterm::event::KeyModifiers::NONE,
4441 ));
4442 assert_eq!(app.mode, AppMode::Moving);
4443 pump_until_complete(&mut app).await;
4444 assert!(
4445 app.operation_message
4446 .as_ref()
4447 .unwrap()
4448 .1
4449 .contains("1 skipped")
4450 );
4451 }
4452
4453 #[tokio::test]
4454 async fn an_unanswered_conflict_modal_renders_at_every_size() {
4455 let (_dir, mut app) = scanned_app();
4456 stage_conflicting_paste(&mut app);
4457 app.paste_clipboard();
4458 pump_until_conflict(&mut app).await;
4459
4460 for &(width, height) in SIZES {
4461 let _ = render(&app, Rect::new(0, 0, width, height));
4462 }
4463
4464 app.scan_token.cancel();
4465 app.scan_rx = None;
4466 }
4467}