Skip to main content

gravityfile_tui/app/
mod.rs

1//! Main application state and logic.
2
3mod 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
59/// Application result type.
60pub 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
86/// A result from one of the explorer's background tasks; `None` when the task
87/// ended without reporting.
88enum 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
100/// Main application state.
101pub struct App {
102    /// Key bindings: defaults with the user's `keymap.toml` applied.
103    keymap: crate::keymap::Keymap,
104    /// Sizes are file lengths rather than disk usage.
105    apparent_size: bool,
106    /// The snapshot file being browsed, read-only, instead of the filesystem.
107    snapshot: Option<PathBuf>,
108    /// Path being analyzed (scan root).
109    path: PathBuf,
110    /// The active tab's directory, selection and history. Inactive tabs'
111    /// navigation is kept by `tab_manager`.
112    nav: state::Navigation,
113    /// Current mode.
114    mode: AppMode,
115    /// Current view (normal mode).
116    view: View,
117    /// Color theme.
118    theme: Theme,
119    /// Detected terminal graphics capabilities, queried once at startup.
120    ///
121    /// `None` when the terminal could not be queried, in which case image
122    /// files fall back to the ordinary binary preview.
123    image_picker: Option<ratatui_image::picker::Picker>,
124    /// Size of the preview pane at the last render, in character cells.
125    ///
126    /// Images are encoded to fit this, so it is captured during draw and used
127    /// by the next preview load. `Cell` because rendering takes `&self`.
128    preview_area: std::cell::Cell<ratatui::layout::Rect>,
129    /// Scroll offsets computed by the last render (tree, miller). Rendering
130    /// is `&self`, so these are adopted into the view states after each frame;
131    /// mouse hit-testing depends on them matching what is on screen.
132    rendered_offsets: std::cell::Cell<(usize, usize)>,
133    /// How far the help is scrolled.
134    help_scroll: usize,
135    /// The help's largest useful scroll, recorded when it is drawn.
136    help_max_scroll: std::cell::Cell<usize>,
137    /// Recent clicks, to recognize double clicks.
138    clicks: crate::event::ClickTracker,
139    /// Scanned file tree.
140    ///
141    /// Shared rather than owned: `FileTree::clone` is a deep copy of every
142    /// node, and this tree is handed to background analysis and stashed in the
143    /// navigation cache. Mutation goes through `Arc::make_mut`, which only
144    /// copies when a background task still holds a reference.
145    tree: Option<Arc<FileTree>>,
146    /// Cached count of visible tree items (to avoid flatten on every nav).
147    cached_tree_len: usize,
148    /// Duplicate analysis report.
149    duplicates: Option<Arc<DuplicateReport>>,
150    duplicate_filter: duplicate_filter::DuplicateFilter,
151    /// Age analysis report.
152    age_report: Option<AgeReport>,
153    /// Duplicates view state (expanded groups, selected files).
154    duplicates_state: DuplicatesViewState,
155    /// Selected stale directory index.
156    selected_stale_dir: usize,
157    /// Show details panel.
158    show_details: bool,
159    /// Error message to display.
160    error: Option<String>,
161    /// Paths marked/selected for operations.
162    marked: HashSet<PathBuf>,
163    /// Clipboard state for yank/cut/paste.
164    clipboard: ClipboardState,
165    active_paste_mode: ClipboardMode,
166    /// Whether the current marks were added by `d` itself (so a declined
167    /// confirmation removes them) rather than by the user.
168    delete_auto_marked: bool,
169    /// Undo log for reversible operations.
170    undo_log: UndoLog,
171    /// Disposal of items replaced by an approved overwrite.
172    replace_policy: gravityfile_ops::ReplacePolicy,
173    /// Disposal of deleted items.
174    delete_mode: gravityfile_ops::DeleteMode,
175    /// Layout mode (tree vs miller).
176    layout_mode: LayoutMode,
177    /// Cached count of entries in current directory for Miller view.
178    cached_miller_len: usize,
179    /// Input state for rename/create modes.
180    input_state: Option<InputState>,
181    /// Current file operation progress (copy/move).
182    operation_progress: Option<OperationProgress>,
183    /// Pending conflict requiring resolution.
184    pending_conflict: Option<ConflictRequest>,
185    /// Last operation result message.
186    operation_message: Option<(bool, String)>,
187    /// Last deletion result message.
188    deletion_message: Option<(bool, String)>,
189    /// Current deletion progress (during async deletion).
190    deletion_progress: Option<DeletionProgress>,
191    /// Current scan progress (for display during scanning).
192    scan_progress: Option<ScanProgress>,
193    /// Results of the running scan or analysis.
194    scan_rx: Option<mpsc::Receiver<ScanResult>>,
195    take_rx: Option<mpsc::Receiver<ScanResult>>,
196    /// Cancels the running scan or analysis.
197    scan_token: CancellationToken,
198    /// Results of the running filesystem operation, which runs alongside a
199    /// scan rather than replacing it.
200    op_rx: Option<mpsc::Receiver<ScanResult>>,
201    /// Cancels the running filesystem operation.
202    op_token: CancellationToken,
203    /// Scan warnings/errors (populated in real-time during scan).
204    warnings: Vec<gravityfile_core::ScanWarning>,
205    /// Latest operation diagnostics survive the automatic scan refresh.
206    operation_warnings: Vec<gravityfile_core::ScanWarning>,
207    /// Selected warning index.
208    selected_warning: usize,
209    /// Whether analysis is running.
210    analyzing: bool,
211    /// Whether a full recursive scan has been completed (vs just quick_list).
212    has_full_scan: bool,
213    /// Flag indicating UI needs redraw.
214    needs_redraw: bool,
215    /// Command palette input state.
216    command_input: CommandInput,
217    /// Current sort mode for file listings.
218    sort_mode: SortMode,
219    /// Fuzzy search state.
220    search_state: SearchState,
221    /// Directory tab manager.
222    tab_manager: TabManager,
223    /// File preview state.
224    preview_state: PreviewState,
225    /// Completion channel for the latest off-thread preview load.
226    preview_rx: Option<oneshot::Receiver<PreviewLoad>>,
227    pending_preview: Option<PreviewRequest>,
228    /// Completion channel for a background archive operation.
229    archive_rx: Option<oneshot::Receiver<ArchiveCompletion>>,
230    archive_cancel: Option<ArchiveCancellation>,
231    /// Completion channel for re-scanning paths changed by an operation.
232    refresh_rx: Option<oneshot::Receiver<Vec<state::Refreshed>>>,
233    /// Changed paths waiting for the running refresh to finish.
234    queued_refresh: Vec<PathBuf>,
235    /// Whether to auto-scan on startup.
236    scan_on_startup: bool,
237    /// User settings (persistent configuration).
238    user_settings: UserSettings,
239    /// Settings modal state.
240    settings_state: Option<SettingsState>,
241    /// Bookmark list modal state.
242    bookmark_list_state: Option<BookmarkListState>,
243    /// Directory listings run off the UI thread report here.
244    listing_tx: mpsc::UnboundedSender<state::Listed>,
245    listing_rx: mpsc::UnboundedReceiver<state::Listed>,
246    /// Listings in flight, so one directory is not listed twice at once.
247    listings: HashSet<(state::ListingFor, PathBuf)>,
248    /// The scan history to add full scans to; None when not recording.
249    history_file: Option<PathBuf>,
250    history_rx: Option<oneshot::Receiver<Option<gravityfile_core::history::Record>>>,
251    /// The latest earlier scan of this place, for the change shown in the header.
252    history_baseline: Option<gravityfile_core::history::Record>,
253    /// Cached parent tree for Miller columns when at tree root.
254    /// This allows showing the parent column even when navigated beyond the original scan root.
255    cached_parent_tree: Option<FileTree>,
256    /// Pending command that requires terminal suspension (e.g., opening files in editor).
257    pending_suspend_command: Option<std::process::Command>,
258    /// Items staged for deletion, awaiting the async `on_delete_start` plugin
259    /// veto check in the run loop before the background delete is actually started.
260    pending_deletion: Option<Vec<(PathBuf, u64)>>,
261    /// Cache of scanned directories by path.
262    /// This preserves scan data when navigating to parent directories and allows
263    /// restoring it when navigating back.
264    scanned_cache: Option<HashMap<PathBuf, Arc<FileTree>>>,
265    /// Bulk rename state for the confirmation modal.
266    bulk_rename_state: Option<state::BulkRenameState>,
267    bulk_rename_edit: Option<bulk_edit::BulkRenameEdit>,
268    /// Cached count of treemap rectangles for navigation.
269    cached_treemap_len: usize,
270    /// Visual selection mode state.
271    visual_state: Option<state::VisualState>,
272    /// Plugin manager.
273    plugin_manager: Option<Arc<RwLock<gravityfile_plugin::PluginManager>>>,
274    /// Whether any plugin is loaded; hooks are not fired otherwise.
275    plugins_loaded: bool,
276    /// Fired hooks report their results here.
277    hook_tx: mpsc::UnboundedSender<Vec<gravityfile_plugin::HookResult>>,
278    hook_rx: mpsc::UnboundedReceiver<Vec<gravityfile_plugin::HookResult>>,
279    /// Hooks fired and not yet reported back.
280    hooks_in_flight: usize,
281    /// Last known terminal content-area height (rows available for the file list).
282    /// Updated on every render; used for scroll/ensure_visible calculations.
283    viewport_height: usize,
284}
285
286impl App {
287    /// Create a new application with default config.
288    /// Immediately loads a quick directory listing for instant display.
289    pub fn new(path: PathBuf) -> Self {
290        Self::with_config(path, TuiConfig::default())
291    }
292
293    /// Create a new application with custom config.
294    /// Immediately loads a quick directory listing for instant display,
295    /// and optionally starts a full recursive scan based on config.
296    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        // Load quick tree immediately for instant display.
319        // A snapshot replaces the listing: its tree is the whole explorer.
320        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        // The quick listing resolves relative paths and directory aliases. Use
335        // that same identity for every navigation owner; retaining the supplied
336        // spelling would make subtree lookup reject the freshly loaded tree.
337        let path = quick_tree
338            .as_ref()
339            .map_or(path, |tree| tree.root_path.clone());
340
341        // Determine scan_on_startup: CLI flag (Some) overrides user settings; None defers to settings.
342        let scan_on_startup = !is_snapshot
343            && config
344                .scan_on_startup
345                .unwrap_or(user_settings.scan_on_startup);
346
347        // Initialize plugin manager and register runtimes. Scope the plugin
348        // filesystem API to the scan root so hook plugins can read scanned files
349        // without being granted access to the whole filesystem.
350        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        // Unit tests must never write to the user's real history.
364        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, // reasonable default before first render
471        };
472
473        // Update cached lengths for immediate navigation
474        app.update_cached_tree_len();
475        app.update_cached_miller_len();
476        app.update_cached_treemap_len();
477        // Cache parent for Miller columns display
478        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    /// Attach the terminal's detected graphics capabilities.
488    ///
489    /// Queried by the caller after the terminal is in raw mode, since
490    /// detection writes control sequences and reads the reply.
491    pub fn with_image_picker(mut self, picker: Option<ratatui_image::picker::Picker>) -> Self {
492        self.image_picker = picker;
493        self
494    }
495
496    /// Run the application with async event loop.
497    pub async fn run(self, terminal: DefaultTerminal) -> AppResult<()> {
498        self.run_and_return_exit_data(terminal).await.map(|_| ())
499    }
500
501    /// Run the application and return exit data for shell integration.
502    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        // Start scan if configured to do so
511        if self.scan_on_startup {
512            self.start_scan();
513        }
514        // Otherwise quick list is already loaded in new() for immediate display
515
516        let period = Duration::from_millis(TICK_INTERVAL_MS);
517        let mut interval = tokio::time::interval(period);
518        // Redraw the latest state; missed frames do not need to be replayed.
519        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            // Keep Tokio's randomized polling order: fixed input priority can
532            // starve completions even when each input drain is bounded.
533            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                    // Drain a bounded batch. An unbounded drain can starve scan,
540                    // operation, and preview completions while input is busy.
541                    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                    // Poll search completions without blocking input or accumulating queries.
565                    self.search_state.poll_background();
566                    self.needs_redraw = true;
567                }
568            }
569
570            // Skip post-event work when quitting for instant exit
571            if self.mode == AppMode::Quit {
572                break;
573            }
574
575            self.after_input(&old_view_root).await;
576
577            // Handle pending suspend command (opening files in external apps)
578            if let Some(mut cmd) = self.pending_suspend_command.take() {
579                // Check if this is a bulk rename operation
580                let is_bulk_rename = self.bulk_rename_edit.is_some();
581
582                // Join the only terminal reader before handing stdin to the editor.
583                // Dropping also discards queued browser input; do not drain an
584                // unbounded live terminal stream while a producer can refill it.
585                drop(events);
586                crate::terminal_session::restore_terminal()?;
587
588                // Run the command and wait for it to complete
589                let command_result = cmd.status();
590
591                // Reinitialize terminal
592                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 this was bulk rename, process the edited file
609                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        // Take heavy structures — they'll be dropped when the runtime shuts down
629        // in the background, avoiding any delay on the main thread.
630        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    /// Work that follows each turn of the event loop: navigation hooks, then
652    /// the plugin veto check for a staged deletion, which starts or aborts it.
653    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    /// Draw the application into the given frame, updating `viewport_height` first.
674    fn draw(&mut self, frame: &mut Frame) {
675        // Subtract rows for header(1) + view_tabs(1) + footer(1) = 3, plus dir_tabs(1)
676        // when multiple directory tabs are open.
677        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    // Navigation methods
686    /// Open the currently selected file with the configured opener.
687    fn open_selected_file(&mut self) {
688        let Some(path) = self.get_selected_path() else {
689            return;
690        };
691
692        // Get opener configuration
693        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                // File opened with system command (non-blocking)
699            }
700            crate::opener::OpenResult::NeedsSuspend(cmd) => {
701                // Need to suspend terminal and run command
702                self.suspend_and_run(cmd);
703            }
704            crate::opener::OpenResult::Error(msg) => {
705                self.error = Some(msg);
706            }
707        }
708    }
709
710    /// Suspend the terminal, run a command, and restore.
711    fn suspend_and_run(&mut self, cmd: std::process::Command) {
712        // We need to restore terminal before running the command
713        // and reinitialize after. This is handled by setting a flag
714        // that the main loop will process.
715        self.pending_suspend_command = Some(cmd);
716    }
717
718    /// Check if a path is marked for deletion.
719    pub fn is_marked(&self, path: &PathBuf) -> bool {
720        self.marked.contains(path)
721    }
722
723    /// Commit edited settings only after persistence succeeds; retain edits for retry.
724    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    /// Get duplicate groups filtered to current view_root.
745    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    /// Get stale directories filtered to current view_root.
772    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); // Hold an active load deterministically.
1046        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()); // No schedule replaced the active receiver.
1052        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        // When only the most wasteful groups were kept, the view says so.
1295        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            // Relative paths need a writable working directory, but the checkout
1480            // may be read-only. Isolate cwd in a child instead of changing the
1481            // process-wide directory beneath concurrently running tests.
1482            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        // Keep the collection mutable on platforms without symlink fixtures.
1509        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    /// Terminal sizes chosen to bracket every layout threshold: below the
1701    /// minimum width for a details panel, below the width of a right-anchored
1702    /// size bar, and tall-and-narrow (a short terminal exits the row loop
1703    /// before the interesting arithmetic ever runs).
1704    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()); // Quick browsing remains available.
1795        }
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    /// An app with a completed scan over a real directory.
1868    ///
1869    /// The `TempDir` is returned alongside because dropping it would delete the
1870    /// tree out from under the app.
1871    fn scanned_app() -> (tempfile::TempDir, App) {
1872        let dir = sample_dir_on_disk();
1873        // The scanner stores a canonical root, and on macOS a `TempDir` lives
1874        // under a `/var` symlink — without this the view root never matches a
1875        // node and the explorer silently renders nothing.
1876        let root = dunce::canonicalize(dir.path()).expect("canonicalize temp dir");
1877
1878        // Tests must never move fixtures into the user's real Trash.
1879        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        // `ScanComplete` schedules a background scan-complete hook; the tests
1894        // never poll for it, and the app state is already final here.
1895        app.scan_rx = None;
1896
1897        (dir, app)
1898    }
1899
1900    /// The scanned app with every optional piece of state populated, so each
1901    /// modal and overlay has real content to draw.
1902    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        // A second tab, with the first one active.
1963        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    /// Index of a named row in the tree view's flattened list.
1981    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            // The worker may have changed the filesystem before losing its reply.
2223            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            // Unknown outcome: refresh now, or once the creation finishes.
2240            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        // A terminal can be resized to anything, including one column wide, and
2505        // a panic in a widget aborts the whole app. This sweep is the backstop
2506        // for the right-edge and viewport arithmetic across all the widgets.
2507        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        // Startup and scan failure both leave the app with no tree at all.
2526        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        // Guards the sweep above: a render that draws nothing would also never
2545        // panic, so at least one size has to prove real content reaches the
2546        // buffer.
2547        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        // Empty submission is rejected before any rename task is created.
2822        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        // The deepest node is the only one with a nonzero size.
2897        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            // A collapsed group still remembers its child cursor, but no child
2963            // is visibly selected and single-file actions must have no target.
2964            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        // Before analysis runs, the duplicates and age views have no rows at
3028        // all, and a scan with no problems leaves the errors view empty.
3029        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        // Actions do not reach behind the dialog; only its own keys answer.
3114        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        // A header marks every copy but the first, like `d` on a header.
3202        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    /// Manual release-mode measurement of explorer frames over a synthetic
3217    /// million-file tree with a 200,000-file directory expanded.
3218    #[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    /// Manual release-mode measurement; synthetic paths do not touch disk.
3298    #[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            // Restore and warm the displayed root after the scope-refresh series.
3383            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                // Analysis of the tree the operation is about to change.
3741                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            // The previous round's refresh is still running; the run loop
3846            // selects among ready tasks at random, so let it finish first.
3847            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            // Nothing is known to have changed: the tree stays until the
3876            // refresh it starts reports back.
3877            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        // Without loaded plugins nothing is fired at all.
3888        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        // Nothing was attempted, so the whole entry is back for a retry.
3981        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; // No external editor is launched by this test.
4133            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    /// Set up a paste whose destination already holds a file of the same name.
4264    ///
4265    /// Returns the destination path that will collide.
4266    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        // Row 0 of the tree is the view root itself, which is a directory, so
4274        // the paste destination is the scan root — where `copy-a.bin` already
4275        // exists.
4276        app.layout_mode = LayoutMode::Tree;
4277        app.nav.tree_state.selected = 0;
4278
4279        root.join("copy-a.bin")
4280    }
4281
4282    /// Deliver results until the running operation reports a conflict.
4283    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    /// Deliver background results as the run loop does, until `stop` accepts
4295    /// one (which is handled too) or nothing is left running.
4296    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    /// Deliver results until the running operation completes.
4311    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        // The operation is paused, not cancelled: the file is still untouched.
4334        assert_eq!(
4335            std::fs::read(&destination).unwrap(),
4336            before,
4337            "the operation wrote before the user answered"
4338        );
4339
4340        // 's' skips this one item.
4341        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        // 'o' overwrites this one item.
4372        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}