diff --git a/src/app/config.rs b/src/app/config.rs index d200906..12c1807 100755 --- a/src/app/config.rs +++ b/src/app/config.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::fs::{self, File}; use std::io::{self, Write}; use std::path::{Path, PathBuf}; @@ -11,9 +12,9 @@ use crate::app::types::{ActivePanel, AppState, ListingMode, PanelState, SortMode pub struct PersistedPanel { #[serde(default)] pub path: Option, - #[serde(default, deserialize_with = "deserialize_listing_mode_with_fallback")] + #[serde(default, deserialize_with = "deserialize_with_fallback")] pub listing_mode: ListingMode, - #[serde(default, deserialize_with = "deserialize_sort_mode_with_fallback")] + #[serde(default, deserialize_with = "deserialize_with_fallback")] pub sort_mode: SortMode, #[serde(default)] pub filter: String, @@ -27,26 +28,21 @@ fn default_true() -> bool { true } -// Falls back to default on invalid config values, logging via debug_log. +// Falls back to `T::default()` on invalid config values, logging via debug_log. // This runs during deserialization — in a TUI app eprintln! would corrupt -// the alternate screen buffer. debug_log writes to a file instead. -fn deserialize_listing_mode_with_fallback<'de, D>(d: D) -> Result +// the alternate screen buffer. debug_log writes to a file instead. Generic over +// the field type so every fallible persisted field shares one implementation. +fn deserialize_with_fallback<'de, D, T>(d: D) -> Result where D: serde::Deserializer<'de>, + T: Deserialize<'de> + Default, { - ListingMode::deserialize(d).or_else(|_| { - crate::debug_log!("config: invalid value for listing_mode, using default"); - Ok(ListingMode::default()) - }) -} - -fn deserialize_sort_mode_with_fallback<'de, D>(d: D) -> Result -where - D: serde::Deserializer<'de>, -{ - SortMode::deserialize(d).or_else(|_| { - crate::debug_log!("config: invalid value for sort_mode, using default"); - Ok(SortMode::default()) + T::deserialize(d).or_else(|_| { + crate::debug_log!( + "config: invalid value for {}, using default", + std::any::type_name::() + ); + Ok(T::default()) }) } @@ -56,7 +52,7 @@ pub struct PersistedSetup { pub active_panel: String, #[serde(default = "default_true")] pub dir_first: bool, - #[serde(default, rename = "sort_sensitive", alias = "sensitive")] + #[serde(default, alias = "sort_sensitive")] pub sensitive: bool, #[serde(default)] pub left: PersistedPanel, @@ -91,7 +87,7 @@ impl Settings { sensitive: sort_options.sensitive, left: panel_to_persisted(&state.left_panel), right: panel_to_persisted(&state.right_panel), - hotlist: state.directory_hotlist.clone(), + hotlist: state.ui.directory_hotlist.clone(), } } @@ -112,11 +108,7 @@ impl Settings { impl From<&Settings> for PersistedSetup { fn from(settings: &Settings) -> Self { Self { - active_panel: match settings.active_panel { - ActivePanel::Left => "left", - ActivePanel::Right => "right", - } - .to_string(), + active_panel: active_panel_to_wire(settings.active_panel).to_string(), dir_first: settings.dir_first, sensitive: settings.sensitive, left: settings.left.clone(), @@ -129,39 +121,58 @@ impl From<&Settings> for PersistedSetup { impl From for Settings { fn from(setup: PersistedSetup) -> Self { Self { - active_panel: { - if setup.active_panel.eq_ignore_ascii_case("right") { - ActivePanel::Right - } else if setup.active_panel.eq_ignore_ascii_case("left") { - ActivePanel::Left - } else { - if !setup.active_panel.is_empty() { - crate::debug_log!( - "config: invalid active_panel value '{}', using default Left", - setup.active_panel - ); - } - ActivePanel::Left - } - }, + active_panel: active_panel_from_wire(&setup.active_panel), dir_first: setup.dir_first, sensitive: setup.sensitive, left: setup.left, right: setup.right, - hotlist: setup - .hotlist - .unwrap_or_default() - .iter() - .filter(|s| !s.trim().is_empty()) - .map(|s| { - let path = crate::fs::path::clean_path(&crate::fs::path::expand_path(s)); - fs::canonicalize(&path).unwrap_or(path) - }) - .collect(), + hotlist: canonicalize_hotlist(&setup.hotlist.unwrap_or_default()), } } } +/// Maps an [`ActivePanel`] to its persisted wire string. +fn active_panel_to_wire(panel: ActivePanel) -> &'static str { + match panel { + ActivePanel::Left => "left", + ActivePanel::Right => "right", + } +} + +/// Parses a persisted `active_panel` string into [`ActivePanel`], defaulting to +/// `Left` (and logging) on any unrecognized non-empty value. Inverse of +/// [`active_panel_to_wire`]. +fn active_panel_from_wire(s: &str) -> ActivePanel { + if s.eq_ignore_ascii_case("right") { + ActivePanel::Right + } else if s.eq_ignore_ascii_case("left") { + ActivePanel::Left + } else { + if !s.is_empty() { + crate::debug_log!("config: invalid active_panel value '{s}', using default Left"); + } + ActivePanel::Left + } +} + +/// Resolves persisted hotlist strings to canonical paths. Each unique cleaned +/// path is canonicalized at most once — the `fs::canonicalize` syscall result is +/// cached so repeated entries skip redundant I/O. Empty/whitespace strings are +/// dropped; duplicate inputs are preserved in the output. +fn canonicalize_hotlist(raw: &[String]) -> Vec { + let mut cache: HashMap = HashMap::new(); + raw.iter() + .filter(|s| !s.trim().is_empty()) + .map(|s| { + let path = crate::fs::path::clean_path(&crate::fs::path::expand_path(s)); + cache + .entry(path.clone()) + .or_insert_with(|| fs::canonicalize(&path).unwrap_or_else(|_| path.clone())) + .clone() + }) + .collect() +} + fn panel_to_persisted(panel: &PanelState) -> PersistedPanel { PersistedPanel { path: path_to_utf8_string(panel.path()), @@ -216,7 +227,7 @@ pub fn save_settings(settings: &Settings) -> io::Result { } pub fn load_setup(state: &mut AppState) -> Result, String> { - let Some(raw) = read_config_raw()? else { + let Some(raw) = read_config_raw_with_env(&paths::ProcessEnv)? else { return Ok(None); }; // clone() is required because toml::Value only implements IntoDeserializer @@ -243,10 +254,6 @@ pub fn load_settings_with_env(env: &impl paths::EnvProvider) -> Result Result, String> { - read_config_raw_with_env(&paths::ProcessEnv) -} - fn read_config_raw_with_env(env: &impl paths::EnvProvider) -> Result, String> { let Some(path) = paths::config_file_path_with_env(env) else { return Ok(None); @@ -262,27 +269,11 @@ fn read_config_raw_with_env(env: &impl paths::EnvProvider) -> Result Option<(PathBuf, Option)> { + let path = crate::fs::path::clean_path(&crate::fs::path::expand_path(path_str)); + match fs::canonicalize(&path) { + Ok(canonical) if canonical.is_dir() => Some((canonical.clone(), Some(canonical))), + Ok(_) => None, + Err(_) => { + crate::debug_log!( + "config: canonicalize failed for {}, falling back to raw path", + path.display() + ); + if path.is_dir() { + Some((path, None)) + } else { + crate::debug_log!("configured panel path ignored: {}", path.display()); + None + } + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -308,9 +323,8 @@ mod tests { #[test] fn settings_from_state_captures_persisted_fields() { let tmp_dir = std::env::temp_dir(); - let state = AppState { + let mut state = AppState { active_panel: ActivePanel::Right, - directory_hotlist: vec![tmp_dir.clone(), PathBuf::from("/usr")], left_panel: PanelState { path: tmp_dir.clone(), listing_mode: ListingMode::Brief, @@ -321,6 +335,7 @@ mod tests { }, ..AppState::default() }; + state.hotlist_set(vec![tmp_dir.clone(), PathBuf::from("/usr")]); let settings = Settings::from_state(&state); @@ -341,7 +356,7 @@ mod tests { ); assert_eq!(settings.left.filter, "rs"); assert!(!settings.left.show_hidden); - assert_eq!(settings.hotlist, state.directory_hotlist); + assert_eq!(settings.hotlist, state.ui.directory_hotlist); } #[test] @@ -382,7 +397,7 @@ mod tests { ); assert_eq!(state.left_panel.filter(), Some("txt")); assert!(!state.left_panel.show_hidden()); - assert_eq!(state.directory_hotlist, hotlist); + assert_eq!(state.ui.directory_hotlist, hotlist); } #[allow(clippy::unwrap_used)] diff --git a/src/app/debug_log.rs b/src/app/debug_log.rs index ae4fffd..1314994 100644 --- a/src/app/debug_log.rs +++ b/src/app/debug_log.rs @@ -1,3 +1,4 @@ +use std::cell::RefCell; use std::fs::OpenOptions; use std::io::{BufWriter, Write}; use std::sync::atomic::{AtomicU32, Ordering}; @@ -14,7 +15,7 @@ const MAX_LOG_SIZE_BYTES: u64 = 10 * MIB; /// /// Usage: `debug_log!("message: {}", value)` — same syntax as eprintln! /// -/// For pre-TUI and post-TUI output, use eprintln!/println! with #[allow] instead. +/// For pre-TUI and post-TUI output, use eprintln!/println! with `#[allow]` instead. /// /// **Blocking behavior:** The internal mutex uses a blocking `std::sync::Mutex`. /// On a stalled filesystem (network mount, writeback pressure) the lock @@ -45,11 +46,33 @@ fn log_path() -> std::path::PathBuf { static CHECK_COUNTER: AtomicU32 = AtomicU32::new(0); const CHECK_INTERVAL: u32 = 256; +/// Flush the `BufWriter` every N entries instead of after each write, so the +/// buffer can actually batch syscalls. Kept small to bound how many entries a +/// crash can lose, while still amortizing the vast majority of writes. +const FLUSH_INTERVAL: u32 = 16; + +/// Shared tag for failures to open/reopen the log file. Deduplicated so both +/// the initial-open and post-truncate-reopen paths report consistently. +const OPEN_ERROR_TAG: &str = "open_error"; + +thread_local! { + /// Caches the formatted timestamp for the current whole second. The log + /// timestamp has 1s resolution, so reformat only when the second changes. + /// Thread-local avoids an extra lock: `log()` already serializes on the + /// `LOG_FILE` mutex, so cross-thread sharing buys nothing here. + static TS_CACHE: RefCell<(i64, String)> = const { RefCell::new((i64::MIN, String::new())) }; +} + fn ensure_log_file() -> std::io::Result> { let path = log_path(); if let Some(parent) = path.parent() && let Err(e) = std::fs::create_dir_all(parent) { + // Double-noise note: when mkdir fails, the open() below almost always + // fails too, emitting a second `open_error` line on stderr. We keep + // both deliberately — the mkdir error names the more specific cause + // (e.g. EACCES on the parent), while open_error confirms the file is + // unusable. report_error("mkdir_error", &e); } OpenOptions::new() @@ -88,7 +111,7 @@ pub fn log(args: std::fmt::Arguments<'_>) { match ensure_log_file() { Ok(file) => *guard = Some(file), Err(e) => { - report_error("open_error", &e); + report_error(OPEN_ERROR_TAG, &e); return; } } @@ -109,19 +132,31 @@ pub fn log(args: std::fmt::Arguments<'_>) { match OpenOptions::new().write(true).truncate(true).open(&path) { Ok(f) => *guard = Some(BufWriter::new(f)), Err(e) => { - report_error("open_error", &e); + report_error(OPEN_ERROR_TAG, &e); return; } } } if let Some(bw) = guard.as_mut() { - // Timestamp formatting per call — not cached. Acceptable for a debug - // logger; chrono's Local::now() + format is ~microsecond-scale. - let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S"); - if let Err(e) = writeln!(bw, "[{timestamp}] {args}") { - report_error("write_error", &e); + let now = Local::now(); + let secs = now.timestamp(); + TS_CACHE.with_borrow_mut(|(cached_secs, cached_str)| { + if *cached_secs != secs { + use std::fmt::Write as _; + *cached_secs = secs; + cached_str.clear(); + let _ = write!(cached_str, "{}", now.format("%Y-%m-%d %H:%M:%S")); + } + if let Err(e) = writeln!(bw, "[{cached_str}] {args}") { + report_error("write_error", &e); + } + }); + // Batch flush: let the BufWriter coalesce writes and flush only every + // FLUSH_INTERVAL entries (and right after open). This is the main win — + // the previous per-entry flush defeated the BufWriter entirely. + if freshly_opened || count.is_multiple_of(FLUSH_INTERVAL) { + let _ = bw.flush(); } - let _ = bw.flush(); } } diff --git a/src/app/file_type.rs b/src/app/file_type.rs index cc136d1..05f1778 100644 --- a/src/app/file_type.rs +++ b/src/app/file_type.rs @@ -135,46 +135,65 @@ fn has_any_suffix(name: &str, suffixes: &[&str]) -> bool { .any(|suffix| ends_with_ignore_ascii_case(name, suffix)) } +// NOTE: case-sensitivity is chosen at compile time via `cfg!(target_os)` rather +// than by probing the underlying filesystem's case-folding behavior at runtime. +// This is a deliberate simplification: Linux filesystems are case-sensitive by +// default, while macOS/Windows are case-insensitive by default. It can misjudge +// atypical mounts (a case-insensitive volume on Linux, or a case-sensitive APFS +// volume on macOS), but the result only drives icon/color categorization, never +// file operations, so a wrong guess is purely cosmetic. `exact_name_match` and +// `prefix_match` share this single policy so that name and prefix checks fold +// identically (e.g. `.env` and `.env.local` behave the same way). +#[inline] +fn name_is_case_sensitive() -> bool { + cfg!(target_os = "linux") +} + #[inline] fn exact_name_match(name: &str, expected: &str) -> bool { - #[cfg(target_os = "linux")] - { + if name_is_case_sensitive() { name == expected - } - #[cfg(not(target_os = "linux"))] - { + } else { name.eq_ignore_ascii_case(expected) } } #[inline] -pub fn is_archive(name: &str) -> bool { - has_any_suffix(name, ARCHIVE_SUFFIXES) -} - -#[inline] -pub fn is_image(name: &str) -> bool { - has_any_suffix(name, IMAGE_SUFFIXES) -} - -#[inline] -pub fn is_source_code(name: &str) -> bool { - has_any_suffix(name, SOURCE_CODE_SUFFIXES) -} - -#[inline] -pub fn is_document(name: &str) -> bool { - has_any_suffix(name, DOCUMENT_SUFFIXES) +fn prefix_match(name: &str, prefix: &str) -> bool { + let (name_bytes, prefix_bytes) = (name.as_bytes(), prefix.as_bytes()); + if name_bytes.len() < prefix_bytes.len() { + return false; + } + let head = &name_bytes[..prefix_bytes.len()]; + if name_is_case_sensitive() { + head == prefix_bytes + } else { + head.eq_ignore_ascii_case(prefix_bytes) + } } -#[inline] -pub fn is_audio(name: &str) -> bool { - has_any_suffix(name, AUDIO_SUFFIXES) +// Generates the family of `pub fn is_xxx(&str) -> bool` suffix predicates. +// Each one was an identical `has_any_suffix(name, TABLE)` wrapper; the macro +// keeps them as distinct public functions while removing the boilerplate. +macro_rules! suffix_predicates { + ($($name:ident => $suffixes:ident),+ $(,)?) => { + $( + #[inline] + pub fn $name(name: &str) -> bool { + has_any_suffix(name, $suffixes) + } + )+ + }; } -#[inline] -pub fn is_video(name: &str) -> bool { - has_any_suffix(name, VIDEO_SUFFIXES) +suffix_predicates! { + is_archive => ARCHIVE_SUFFIXES, + is_image => IMAGE_SUFFIXES, + is_source_code => SOURCE_CODE_SUFFIXES, + is_document => DOCUMENT_SUFFIXES, + is_audio => AUDIO_SUFFIXES, + is_video => VIDEO_SUFFIXES, + is_font => FONT_SUFFIXES, } #[inline] @@ -182,15 +201,10 @@ pub fn is_config(name: &str) -> bool { CONFIG_EXACT_NAMES .iter() .any(|&n| exact_name_match(name, n)) - || CONFIG_PREFIXES.iter().any(|&p| name.starts_with(p)) + || CONFIG_PREFIXES.iter().any(|&p| prefix_match(name, p)) || has_any_suffix(name, CONFIG_SUFFIXES) } -#[inline] -pub fn is_font(name: &str) -> bool { - has_any_suffix(name, FONT_SUFFIXES) -} - pub fn category(name: &str, is_dir: bool, is_exec: bool, is_link: bool) -> FileCategory { if is_link { return FileCategory::Symlink; diff --git a/src/app/job_runner.rs b/src/app/job_runner.rs index f6470f1..fc85e21 100644 --- a/src/app/job_runner.rs +++ b/src/app/job_runner.rs @@ -10,6 +10,38 @@ use crate::ops; use super::types::{ActivePanel, AppMode, AppState, DialogKind, FileEntry}; +/// Status shown when an action is requested while another job is in flight. +const ANOTHER_JOB_RUNNING: &str = "Another job is already running"; + +/// Max time the last-resort reaper waits for a worker to finish before +/// detaching it. See `RunningJob`'s `Drop` impl for the rationale. +const REAPER_JOIN_DEADLINE: Duration = Duration::from_secs(5); +/// Poll cadence while the reaper waits for the worker to finish. +const REAPER_POLL_INTERVAL: Duration = Duration::from_millis(50); + +/// Joins a finished (or finishing) worker thread, swallowing and logging any +/// panic payload tagged with `context`. +/// +/// Returns `true` if the worker exited cleanly, `false` if it panicked. Every +/// job-teardown path funnels its `join()` through here so a worker panic is +/// contained (logged) instead of propagating into the event loop. +fn handle_worker_result(handle: JoinHandle<()>, context: &str) -> bool { + match handle.join() { + Ok(()) => true, + Err(panic_payload) => { + // `join()` yields `Box`; `{:?}` on it only prints "Any". + // Downcast to the common panic payload types to log the real message. + let msg = panic_payload + .downcast_ref::<&str>() + .copied() + .or_else(|| panic_payload.downcast_ref::().map(String::as_str)) + .unwrap_or("unknown panic payload"); + debug_log!("{context}: {msg}"); + false + } + } +} + enum JobMessage { Progress(ops::batch::BatchProgress), Finished { @@ -45,6 +77,17 @@ impl RunningJob { // last-resort safety net when a job is dropped during panic unwinding, where a // failed spawn or a worker holding a lock just means we log and detach rather // than block teardown. +// +// Cancellation-granularity trade-off (`REAPER_JOIN_DEADLINE`): +// `cancel` is cooperative — the worker only observes it between its own cancel +// checks (in `ops::batch`). A single long syscall (e.g. copying one huge file) +// can run well past the 5 s deadline without ever re-checking `cancel`, so the +// reaper detaches it. Detaching is acceptable here because this path only runs +// during panic unwinding: the process is already heading for exit, and the OS +// reclaims the worker's file descriptors at process teardown. Finer-grained +// cancellation would have to live in the worker (`ops::batch`, outside this +// module); blocking teardown on a chunked copy instead would risk hanging a +// panicking process, so we keep the bounded wait + detach. impl Drop for RunningJob { fn drop(&mut self) { self.cancel.store(true, Ordering::Relaxed); @@ -55,11 +98,9 @@ impl Drop for RunningJob { let result = std::thread::Builder::new() .name("job-reaper".into()) .spawn(move || { - let deadline = Duration::from_secs(5); - let poll_interval = Duration::from_millis(50); let start = std::time::Instant::now(); - while !handle.is_finished() && start.elapsed() < deadline { - std::thread::sleep(poll_interval); + while !handle.is_finished() && start.elapsed() < REAPER_JOIN_DEADLINE { + std::thread::sleep(REAPER_POLL_INTERVAL); } if handle.is_finished() { if let Err(e) = handle.join() { @@ -82,10 +123,10 @@ impl Drop for RunningJob { pub fn start_confirmed_action(state: &mut AppState, running_job: &mut Option) { if running_job.is_some() { - state.status_message = Some("Another job is already running".to_string()); + state.ui.status_message = Some(ANOTHER_JOB_RUNNING.to_string()); return; } - let action = match state.pending_action.take() { + let action = match state.ui.pending_action.take() { Some(a) => a, None => return, }; @@ -108,7 +149,7 @@ pub fn start_confirmed_action(state: &mut AppState, running_job: &mut Option, pattern: &str) { if running_job.is_some() { - state.status_message = Some("Another job is already running".to_string()); + state.ui.status_message = Some(ANOTHER_JOB_RUNNING.to_string()); return; } let dir = state.active_panel().path().to_path_buf(); @@ -148,13 +189,13 @@ pub fn start_search_job(state: &mut AppState, running_job: &mut Option { - debug_log!("worker thread panicked (no Finished): {:?}", panic_payload); - let _ = running_job.take(); - state.mode = AppMode::Normal; - if let Some(panel) = state.menu_restore_panel.take() { - state.active_panel = panel; - } - state.status_message = - Some("Operation failed: worker thread panicked".to_string()); - refresh_both(state); - dirty = true; - } - Ok(()) => { - debug_log!("worker exited normally without sending Finished — cleaning up"); - let _ = running_job.take(); - state.mode = AppMode::Normal; - if let Some(panel) = state.menu_restore_panel.take() { - state.active_panel = panel; - } - state.status_message = - Some("Operation completed (worker finished without report)".to_string()); - refresh_both(state); - dirty = true; - } + let exited_cleanly = + handle_worker_result(handle, "worker thread panicked (no Finished)"); + if exited_cleanly { + debug_log!("worker exited normally without sending Finished — cleaning up"); + } + let _ = running_job.take(); + state.mode = AppMode::Normal; + if let Some(panel) = state.ui.menu_restore_panel.take() { + state.active_panel = panel; } + state.ui.status_message = Some(if exited_cleanly { + "Operation completed (worker finished without report)".to_string() + } else { + "Operation failed: worker thread panicked".to_string() + }); + refresh_both(state); + dirty = true; } } @@ -322,9 +350,9 @@ fn finish_running_job( report: &ops::batch::BatchReport, refresh_both: fn(&mut AppState), ) { - state.status_message = Some(report.format_summary()); + state.ui.status_message = Some(report.format_summary()); state.mode = AppMode::Normal; - if let Some(panel) = state.menu_restore_panel.take() { + if let Some(panel) = state.ui.menu_restore_panel.take() { state.active_panel = panel; } refresh_both(state); @@ -348,12 +376,10 @@ fn finish_search_job( refresh_both(state); let panel = state.panel_or_active_mut(search_origin); - if let Some(pos) = panel - .listing - .entries - .iter() - .position(|e| e.path == first.path) - { + // Resolve the index first so the borrowing iterator is dropped before the + // mutable cursor update below. + let pos = panel.listing.filtered().position(|e| e.path == first.path); + if let Some(pos) = pos { panel.cursor = pos; panel.ensure_cursor_visible(crate::app::panel_ops::current_visible_height()); } @@ -379,9 +405,9 @@ fn finish_search_job( }; msg.push_str(&format!(", truncated ({label})")); } - state.status_message = Some(msg); + state.ui.status_message = Some(msg); state.mode = AppMode::Normal; - if let Some(panel) = state.menu_restore_panel.take() { + if let Some(panel) = state.ui.menu_restore_panel.take() { state.active_panel = panel; } } diff --git a/src/app/keymap.rs b/src/app/keymap.rs index cf41b09..49e38a1 100644 --- a/src/app/keymap.rs +++ b/src/app/keymap.rs @@ -17,661 +17,674 @@ pub struct KeyBinding { pub description: &'static str, } +/// Canonical mode-name labels shared by the keymap table, the help output, +/// and (eventually) other modules. Defining them here keeps each string in +/// exactly one place instead of duplicating literals across call sites. +pub const MODE_NORMAL: &str = "Normal"; +pub const MODE_VIEWING: &str = "Viewing"; +pub const MODE_COMMAND_LINE: &str = "CommandLine"; +pub const MODE_SEARCH: &str = "Search"; +pub const MODE_MENU: &str = "Menu"; +pub const MODE_LIST_PICKER: &str = "ListPicker"; +pub const MODE_DIRECTORY_TREE: &str = "DirectoryTree"; +pub const MODE_DIALOG_CONFIRM: &str = "Dialog/Confirm"; +pub const MODE_DIALOG_INPUT: &str = "Dialog/Input"; + /// Static table covering all mc-compatible shortcuts. pub static KEYBINDINGS: &[KeyBinding] = &[ // ── Normal ─────────────────────────────────────────────────────────────── KeyBinding { - mode: "Normal", + mode: MODE_NORMAL, key: "F1", action: "Help", description: "Show help dialog", }, KeyBinding { - mode: "Normal", + mode: MODE_NORMAL, key: "F2", action: "UserMenu", description: "Open user menu", }, KeyBinding { - mode: "Normal", + mode: MODE_NORMAL, key: "F3", action: "View", description: "View file in internal viewer", }, KeyBinding { - mode: "Normal", + mode: MODE_NORMAL, key: "F4", action: "Edit", description: "Edit file in external editor", }, KeyBinding { - mode: "Normal", + mode: MODE_NORMAL, key: "F5", action: "Copy", description: "Copy selected files", }, KeyBinding { - mode: "Normal", + mode: MODE_NORMAL, key: "F6", action: "Move", description: "Move/rename selected files", }, KeyBinding { - mode: "Normal", + mode: MODE_NORMAL, key: "F7", action: "Mkdir/ArchiveExtract", description: "Create directory or extract archive", }, KeyBinding { - mode: "Normal", + mode: MODE_NORMAL, key: "F8", action: "Delete", description: "Delete selected files", }, KeyBinding { - mode: "Normal", + mode: MODE_NORMAL, key: "F9", action: "Menu", description: "Open left bottom menu", }, KeyBinding { - mode: "Normal", + mode: MODE_NORMAL, key: "F10", action: "Quit", description: "Quit the application", }, KeyBinding { - mode: "Normal", + mode: MODE_NORMAL, key: "F11", action: "Rename", description: "Rename file or directory", }, KeyBinding { - mode: "Normal", + mode: MODE_NORMAL, key: "Tab", action: "SwitchPanel", description: "Switch active panel", }, KeyBinding { - mode: "Normal", + mode: MODE_NORMAL, key: "Insert", action: "ToggleSelect", description: "Toggle selection and move down", }, KeyBinding { - mode: "Normal", + mode: MODE_NORMAL, key: "Up", action: "CursorUp", description: "Move cursor up", }, KeyBinding { - mode: "Normal", + mode: MODE_NORMAL, key: "Down", action: "CursorDown", description: "Move cursor down", }, KeyBinding { - mode: "Normal", + mode: MODE_NORMAL, key: "k", action: "CursorUp", description: "Move cursor up (vi)", }, KeyBinding { - mode: "Normal", + mode: MODE_NORMAL, key: "j", action: "CursorDown", description: "Move cursor down (vi)", }, KeyBinding { - mode: "Normal", + mode: MODE_NORMAL, key: "Home", action: "Top", description: "Go to first entry", }, KeyBinding { - mode: "Normal", + mode: MODE_NORMAL, key: "End", action: "Bottom", description: "Go to last entry", }, KeyBinding { - mode: "Normal", + mode: MODE_NORMAL, key: "PageUp", action: "PageUp", description: "Page up", }, KeyBinding { - mode: "Normal", + mode: MODE_NORMAL, key: "PageDown", action: "PageDown", description: "Page down", }, KeyBinding { - mode: "Normal", + mode: MODE_NORMAL, key: "Shift+Up", action: "ExtendSelection", description: "Extend selection upward", }, KeyBinding { - mode: "Normal", + mode: MODE_NORMAL, key: "Shift+Down", action: "ExtendSelection", description: "Extend selection downward", }, KeyBinding { - mode: "Normal", + mode: MODE_NORMAL, key: "Enter", action: "OpenDir", description: "Open directory", }, KeyBinding { - mode: "Normal", + mode: MODE_NORMAL, key: "Alt+Enter", action: "Properties", description: "Show file properties", }, KeyBinding { - mode: "Normal", + mode: MODE_NORMAL, key: "Ctrl+U", action: "SwapPanels", description: "Swap left and right panels", }, KeyBinding { - mode: "Normal", + mode: MODE_NORMAL, key: "Alt+1..9", action: "Hotlist", description: "Navigate to hotlist directory", }, KeyBinding { - mode: "Normal", + mode: MODE_NORMAL, key: "Alt+Backspace", action: "GoBack", description: "Previous directory in history", }, KeyBinding { - mode: "Normal", + mode: MODE_NORMAL, key: "Alt+C", action: "QuickCd", description: "Quick change directory", }, KeyBinding { - mode: "Normal", + mode: MODE_NORMAL, key: "Ctrl+S", action: "Search", description: "Start incremental search/filter", }, KeyBinding { - mode: "Normal", + mode: MODE_NORMAL, key: "Ctrl+H", action: "ToggleHidden", description: "Toggle hidden files visibility", }, KeyBinding { - mode: "Normal", + mode: MODE_NORMAL, key: "Ctrl+R", action: "Refresh", description: "Refresh panel contents", }, KeyBinding { - mode: "Normal", + mode: MODE_NORMAL, key: "Ctrl+O", action: "ExternalView", description: "Toggle external panel view", }, KeyBinding { - mode: "Normal", + mode: MODE_NORMAL, key: "Alt+X", action: "CommandLine", description: "Open command line", }, KeyBinding { - mode: "Normal", + mode: MODE_NORMAL, key: "F12", action: "ArchiveMenu", description: "Archive operations menu", }, // ── Viewer ─────────────────────────────────────────────────────────────── KeyBinding { - mode: "Viewing", + mode: MODE_VIEWING, key: "Esc", action: "Close", description: "Close viewer", }, KeyBinding { - mode: "Viewing", + mode: MODE_VIEWING, key: "F3", action: "Close", description: "Close viewer", }, KeyBinding { - mode: "Viewing", + mode: MODE_VIEWING, key: "F10", action: "Close", description: "Close viewer", }, KeyBinding { - mode: "Viewing", + mode: MODE_VIEWING, key: "q", action: "Close", description: "Close viewer", }, KeyBinding { - mode: "Viewing", + mode: MODE_VIEWING, key: "Up", action: "ScrollUp", description: "Scroll up one line", }, KeyBinding { - mode: "Viewing", + mode: MODE_VIEWING, key: "Down", action: "ScrollDown", description: "Scroll down one line", }, KeyBinding { - mode: "Viewing", + mode: MODE_VIEWING, key: "k", action: "ScrollUp", description: "Scroll up one line (vi)", }, KeyBinding { - mode: "Viewing", + mode: MODE_VIEWING, key: "j", action: "ScrollDown", description: "Scroll down one line (vi)", }, KeyBinding { - mode: "Viewing", + mode: MODE_VIEWING, key: "PageUp", action: "PageUp", description: "Page up", }, KeyBinding { - mode: "Viewing", + mode: MODE_VIEWING, key: "PageDown", action: "PageDown", description: "Page down", }, KeyBinding { - mode: "Viewing", + mode: MODE_VIEWING, key: "Home", action: "Top", description: "Go to beginning of file", }, KeyBinding { - mode: "Viewing", + mode: MODE_VIEWING, key: "End", action: "Bottom", description: "Go to end of file", }, KeyBinding { - mode: "Viewing", + mode: MODE_VIEWING, key: "Left", action: "ScrollLeft", description: "Scroll left", }, KeyBinding { - mode: "Viewing", + mode: MODE_VIEWING, key: "Right", action: "ScrollRight", description: "Scroll right", }, KeyBinding { - mode: "Viewing", + mode: MODE_VIEWING, key: "l", action: "ToggleLineNum", description: "Toggle line numbers", }, KeyBinding { - mode: "Viewing", + mode: MODE_VIEWING, key: "w", action: "ToggleWrap", description: "Toggle line wrapping", }, KeyBinding { - mode: "Viewing", + mode: MODE_VIEWING, key: "h", action: "ToggleHex", description: "Toggle hex mode", }, KeyBinding { - mode: "Viewing", + mode: MODE_VIEWING, key: "n", action: "NextMatch", description: "Next search match", }, KeyBinding { - mode: "Viewing", + mode: MODE_VIEWING, key: "N", action: "PrevMatch", description: "Previous search match", }, KeyBinding { - mode: "Viewing", + mode: MODE_VIEWING, key: "/", action: "Search", description: "Open search dialog", }, // ── CommandLine ────────────────────────────────────────────────────────── KeyBinding { - mode: "CommandLine", + mode: MODE_COMMAND_LINE, key: "Esc", action: "Cancel", description: "Cancel command line", }, KeyBinding { - mode: "CommandLine", + mode: MODE_COMMAND_LINE, key: "Enter", action: "Execute", description: "Execute shell command", }, KeyBinding { - mode: "CommandLine", + mode: MODE_COMMAND_LINE, key: "Backspace", action: "DeleteChar", description: "Delete character before cursor", }, KeyBinding { - mode: "CommandLine", + mode: MODE_COMMAND_LINE, key: "Up", action: "HistoryPrev", description: "Previous command in history", }, KeyBinding { - mode: "CommandLine", + mode: MODE_COMMAND_LINE, key: "Down", action: "HistoryNext", description: "Next command in history", }, KeyBinding { - mode: "CommandLine", + mode: MODE_COMMAND_LINE, key: "Ctrl+A", action: "CursorHome", description: "Move cursor to beginning of line", }, KeyBinding { - mode: "CommandLine", + mode: MODE_COMMAND_LINE, key: "Ctrl+E", action: "CursorEnd", description: "Move cursor to end of line", }, KeyBinding { - mode: "CommandLine", + mode: MODE_COMMAND_LINE, key: "Ctrl+U", action: "ClearToStart", description: "Clear line before cursor", }, KeyBinding { - mode: "CommandLine", + mode: MODE_COMMAND_LINE, key: "Ctrl+W", action: "DeleteWordBack", description: "Delete word before cursor", }, // ── Search ─────────────────────────────────────────────────────────────── KeyBinding { - mode: "Search", + mode: MODE_SEARCH, key: "Esc", action: "Cancel", description: "Cancel search and restore", }, KeyBinding { - mode: "Search", + mode: MODE_SEARCH, key: "Enter", action: "Accept", description: "Accept current search filter", }, KeyBinding { - mode: "Search", + mode: MODE_SEARCH, key: "Backspace", action: "DeleteChar", description: "Delete character before cursor", }, // ── Menu ───────────────────────────────────────────────────────────────── KeyBinding { - mode: "Menu", + mode: MODE_MENU, key: "Esc", action: "Close", description: "Close menu", }, KeyBinding { - mode: "Menu", + mode: MODE_MENU, key: "F9", action: "Close", description: "Close menu", }, KeyBinding { - mode: "Menu", + mode: MODE_MENU, key: "F10", action: "Close", description: "Close menu", }, KeyBinding { - mode: "Menu", + mode: MODE_MENU, key: "Left", action: "PrevCategory", description: "Previous menu category", }, KeyBinding { - mode: "Menu", + mode: MODE_MENU, key: "Right", action: "NextCategory", description: "Next menu category", }, KeyBinding { - mode: "Menu", + mode: MODE_MENU, key: "Up", action: "PrevItem", description: "Select previous menu item", }, KeyBinding { - mode: "Menu", + mode: MODE_MENU, key: "Down", action: "NextItem", description: "Select next menu item", }, KeyBinding { - mode: "Menu", + mode: MODE_MENU, key: "Enter", action: "Execute", description: "Execute selected menu action", }, // ── Dialog/Confirm ─────────────────────────────────────────────────────── KeyBinding { - mode: "Dialog/Confirm", + mode: MODE_DIALOG_CONFIRM, key: "y", action: "Confirm", description: "Confirm action", }, KeyBinding { - mode: "Dialog/Confirm", + mode: MODE_DIALOG_CONFIRM, key: "Y", action: "Confirm", description: "Confirm action", }, KeyBinding { - mode: "Dialog/Confirm", + mode: MODE_DIALOG_CONFIRM, key: "n", action: "Cancel", description: "Cancel action", }, KeyBinding { - mode: "Dialog/Confirm", + mode: MODE_DIALOG_CONFIRM, key: "N", action: "Cancel", description: "Cancel action", }, KeyBinding { - mode: "Dialog/Confirm", + mode: MODE_DIALOG_CONFIRM, key: "Enter", action: "Confirm", description: "Confirm or cancel based on selection", }, KeyBinding { - mode: "Dialog/Confirm", + mode: MODE_DIALOG_CONFIRM, key: "Esc", action: "Cancel", description: "Cancel dialog", }, KeyBinding { - mode: "Dialog/Confirm", + mode: MODE_DIALOG_CONFIRM, key: "Left", action: "ToggleButton", description: "Toggle Yes/No button", }, KeyBinding { - mode: "Dialog/Confirm", + mode: MODE_DIALOG_CONFIRM, key: "Right", action: "ToggleButton", description: "Toggle Yes/No button", }, // ── Dialog/Input ───────────────────────────────────────────────────────── KeyBinding { - mode: "Dialog/Input", + mode: MODE_DIALOG_INPUT, key: "Enter", action: "Submit", description: "Submit input", }, KeyBinding { - mode: "Dialog/Input", + mode: MODE_DIALOG_INPUT, key: "Esc", action: "Cancel", description: "Cancel input", }, KeyBinding { - mode: "Dialog/Input", + mode: MODE_DIALOG_INPUT, key: "Backspace", action: "DeleteChar", description: "Delete character before cursor", }, KeyBinding { - mode: "Dialog/Input", + mode: MODE_DIALOG_INPUT, key: "Delete", action: "DeleteCharFwd", description: "Delete character at cursor", }, KeyBinding { - mode: "Dialog/Input", + mode: MODE_DIALOG_INPUT, key: "Left", action: "CursorLeft", description: "Move cursor left", }, KeyBinding { - mode: "Dialog/Input", + mode: MODE_DIALOG_INPUT, key: "Right", action: "CursorRight", description: "Move cursor right", }, KeyBinding { - mode: "Dialog/Input", + mode: MODE_DIALOG_INPUT, key: "Home", action: "CursorHome", description: "Move cursor to start", }, KeyBinding { - mode: "Dialog/Input", + mode: MODE_DIALOG_INPUT, key: "End", action: "CursorEnd", description: "Move cursor to end", }, // ── ListPicker ─────────────────────────────────────────────────────────── KeyBinding { - mode: "ListPicker", + mode: MODE_LIST_PICKER, key: "Esc", action: "Cancel", description: "Close picker", }, KeyBinding { - mode: "ListPicker", + mode: MODE_LIST_PICKER, key: "Up", action: "PrevItem", description: "Select previous item", }, KeyBinding { - mode: "ListPicker", + mode: MODE_LIST_PICKER, key: "Down", action: "NextItem", description: "Select next item", }, KeyBinding { - mode: "ListPicker", + mode: MODE_LIST_PICKER, key: "Enter", action: "Select", description: "Confirm selection", }, KeyBinding { - mode: "ListPicker", + mode: MODE_LIST_PICKER, key: "Home", action: "Top", description: "Go to first item", }, KeyBinding { - mode: "ListPicker", + mode: MODE_LIST_PICKER, key: "End", action: "Bottom", description: "Go to last item", }, // ── DirectoryTree ──────────────────────────────────────────────────────── KeyBinding { - mode: "DirectoryTree", + mode: MODE_DIRECTORY_TREE, key: "Esc", action: "Close", description: "Close directory tree", }, KeyBinding { - mode: "DirectoryTree", + mode: MODE_DIRECTORY_TREE, key: "Up", action: "Prev", description: "Select previous entry", }, KeyBinding { - mode: "DirectoryTree", + mode: MODE_DIRECTORY_TREE, key: "Down", action: "Next", description: "Select next entry", }, KeyBinding { - mode: "DirectoryTree", + mode: MODE_DIRECTORY_TREE, key: "k", action: "Prev", description: "Select previous entry (vi)", }, KeyBinding { - mode: "DirectoryTree", + mode: MODE_DIRECTORY_TREE, key: "j", action: "Next", description: "Select next entry (vi)", }, KeyBinding { - mode: "DirectoryTree", + mode: MODE_DIRECTORY_TREE, key: "Home", action: "Top", description: "Go to first entry", }, KeyBinding { - mode: "DirectoryTree", + mode: MODE_DIRECTORY_TREE, key: "End", action: "Bottom", description: "Go to last entry", }, KeyBinding { - mode: "DirectoryTree", + mode: MODE_DIRECTORY_TREE, key: "PageUp", action: "PageUp", description: "Page up", }, KeyBinding { - mode: "DirectoryTree", + mode: MODE_DIRECTORY_TREE, key: "PageDown", action: "PageDown", description: "Page down", }, KeyBinding { - mode: "DirectoryTree", + mode: MODE_DIRECTORY_TREE, key: "Enter", action: "ToggleExpand", description: "Toggle expand dir or open file", }, KeyBinding { - mode: "DirectoryTree", + mode: MODE_DIRECTORY_TREE, key: "c", action: "CdToDir", description: "Change to selected directory", @@ -682,7 +695,9 @@ pub static KEYBINDINGS: &[KeyBinding] = &[ /// within the same mode. Empty vec means no duplicates. #[cfg(test)] fn find_duplicate_keys() -> Vec<(&'static str, &'static str)> { - let mut seen: HashSet<(&str, &str)> = HashSet::new(); + let mut seen: HashSet<(&str, &str)> = HashSet::with_capacity(KEYBINDINGS.len()); + // Duplicates are expected to be empty in a valid table, so leave this + // unsized rather than pre-allocating memory that is almost never used. let mut duplicates = Vec::new(); for binding in KEYBINDINGS { @@ -699,9 +714,12 @@ fn find_duplicate_keys() -> Vec<(&'static str, &'static str)> { pub fn build_help_message() -> &'static str { static CACHE: OnceLock = OnceLock::new(); CACHE.get_or_init(|| { - // Rough capacity estimate: ~40 bytes per binding (mode header overhead, - // indent, padded key, description). Conservative to minimize reallocations. - let mut msg = String::with_capacity(KEYBINDINGS.len() * 40); + // Rough capacity estimate: ~45 bytes per binding. Each line is a + // 2-space indent, a key padded to 16 columns, a space, the description, + // and a newline (~20 bytes of fixed overhead plus the description), + // amortizing the per-mode header. Slightly over-estimates to minimize + // reallocations. + let mut msg = String::with_capacity(KEYBINDINGS.len() * 45); let mut current_mode = ""; for b in KEYBINDINGS { if b.mode != current_mode { @@ -723,16 +741,6 @@ mod tests { use super::*; use crate::app::types::{AppMode, DialogKind, InputAction, PickerKind}; - const NORMAL_MODE: &str = "Normal"; - const VIEWING_MODE: &str = "Viewing"; - const COMMAND_LINE_MODE: &str = "CommandLine"; - const SEARCH_MODE: &str = "Search"; - const MENU_MODE: &str = "Menu"; - const LIST_PICKER_MODE: &str = "ListPicker"; - const DIRECTORY_TREE_MODE: &str = "DirectoryTree"; - const DIALOG_CONFIRM_MODE: &str = "Dialog/Confirm"; - const DIALOG_INPUT_MODE: &str = "Dialog/Input"; - #[test] fn no_duplicate_keys_per_mode() { let duplicates = find_duplicate_keys(); @@ -784,9 +792,9 @@ mod tests { } for mode in representative_app_modes() { - let coverage = keymap_coverage_for_mode(&mode); + let keymap_mode = keymap_coverage_for_mode(&mode); assert!( - keymap_modes.contains(coverage.keymap_mode()), + keymap_modes.contains(keymap_mode), "{mode:?} must have keymap coverage or documented fallback" ); } @@ -809,31 +817,17 @@ mod tests { } } - #[derive(Debug, Clone, Copy)] - enum KeymapCoverage { - Direct(&'static str), - Fallback(&'static str), - } - - impl KeymapCoverage { - fn keymap_mode(self) -> &'static str { - match self { - Self::Direct(mode) | Self::Fallback(mode) => mode, - } - } - } - fn documented_keymap_modes() -> &'static [&'static str] { &[ - NORMAL_MODE, - VIEWING_MODE, - COMMAND_LINE_MODE, - SEARCH_MODE, - MENU_MODE, - LIST_PICKER_MODE, - DIRECTORY_TREE_MODE, - DIALOG_CONFIRM_MODE, - DIALOG_INPUT_MODE, + MODE_NORMAL, + MODE_VIEWING, + MODE_COMMAND_LINE, + MODE_SEARCH, + MODE_MENU, + MODE_LIST_PICKER, + MODE_DIRECTORY_TREE, + MODE_DIALOG_CONFIRM, + MODE_DIALOG_INPUT, ] } @@ -853,34 +847,38 @@ mod tests { ] } - fn keymap_coverage_for_mode(mode: &AppMode) -> KeymapCoverage { + /// Returns the keymap mode whose bindings drive `mode`, either directly or + /// via a documented fallback. The match stays exhaustive (no wildcard) so + /// adding a `DialogKind`/`PickerKind` variant forces a review here. + fn keymap_coverage_for_mode(mode: &AppMode) -> &'static str { match mode { - AppMode::Normal => KeymapCoverage::Direct(NORMAL_MODE), - AppMode::Viewing => KeymapCoverage::Direct(VIEWING_MODE), - AppMode::CommandLine => KeymapCoverage::Direct(COMMAND_LINE_MODE), - AppMode::Search => KeymapCoverage::Direct(SEARCH_MODE), - AppMode::Menu => KeymapCoverage::Direct(MENU_MODE), - AppMode::ListPicker(kind) => match kind { + AppMode::Normal => MODE_NORMAL, + AppMode::Viewing => MODE_VIEWING, + AppMode::CommandLine => MODE_COMMAND_LINE, + AppMode::Search => MODE_SEARCH, + AppMode::Menu => MODE_MENU, + AppMode::ListPicker( PickerKind::History | PickerKind::Hotlist | PickerKind::CompareMode | PickerKind::UserMenu - | PickerKind::ArchiveMenu => KeymapCoverage::Direct(LIST_PICKER_MODE), - }, - AppMode::DirectoryTree => KeymapCoverage::Direct(DIRECTORY_TREE_MODE), - AppMode::Dialog(kind) => match kind { - DialogKind::Confirm(_) => KeymapCoverage::Direct(DIALOG_CONFIRM_MODE), - DialogKind::Input { .. } => KeymapCoverage::Direct(DIALOG_INPUT_MODE), - DialogKind::Error(_) + | PickerKind::ArchiveMenu, + ) => MODE_LIST_PICKER, + AppMode::DirectoryTree => MODE_DIRECTORY_TREE, + AppMode::Dialog( + DialogKind::Confirm(_) + | DialogKind::Error(_) | DialogKind::Progress { .. } | DialogKind::CopyMove(_) | DialogKind::Properties(_) - | DialogKind::OverwriteConfirm(_) => KeymapCoverage::Fallback(DIALOG_CONFIRM_MODE), - DialogKind::Help { .. } => KeymapCoverage::Fallback(VIEWING_MODE), - DialogKind::ArchiveExtract(_) | DialogKind::ArchiveCreate(_) => { - KeymapCoverage::Fallback(DIALOG_INPUT_MODE) - } - }, + | DialogKind::OverwriteConfirm(_), + ) => MODE_DIALOG_CONFIRM, + AppMode::Dialog( + DialogKind::Input { .. } + | DialogKind::ArchiveExtract(_) + | DialogKind::ArchiveCreate(_), + ) => MODE_DIALOG_INPUT, + AppMode::Dialog(DialogKind::Help { .. }) => MODE_VIEWING, } } } diff --git a/src/app/mime.rs b/src/app/mime.rs index 3209d40..584449a 100644 --- a/src/app/mime.rs +++ b/src/app/mime.rs @@ -2,37 +2,79 @@ use std::path::Path; use crate::app::types::FileCategory; +// Shared MIME literals: single source of truth for strings that appear both in +// the public classification tables below and in `mime_to_category`'s match arms. +// Naming them once stops the tables and the match from silently drifting apart +// (e.g. a value fixed in one place but forgotten in the other). +// +// MIME prefixes (tested with `str::starts_with`). +const PREFIX_IMAGE: &str = "image/"; +const PREFIX_AUDIO: &str = "audio/"; +const PREFIX_VIDEO: &str = "video/"; +const PREFIX_OPENDOCUMENT: &str = "application/vnd.oasis.opendocument."; +const PREFIX_OOXML: &str = "application/vnd.openxmlformats-officedocument."; +// +// Text-bearing `application/*` MIME types. +const MIME_JSON: &str = "application/json"; +const MIME_TOML: &str = "application/toml"; +const MIME_YAML: &str = "application/yaml"; +const MIME_X_YAML: &str = "application/x-yaml"; +const MIME_XML: &str = "application/xml"; +const MIME_JAVASCRIPT: &str = "application/javascript"; +const MIME_TYPESCRIPT: &str = "application/typescript"; +const MIME_ECMASCRIPT: &str = "application/ecmascript"; +const MIME_SQL: &str = "application/sql"; +const MIME_PHP: &str = "application/x-httpd-php"; +const MIME_SH: &str = "application/x-sh"; +const MIME_RTF: &str = "application/rtf"; +// +// Binary `application/*` MIME types. +const MIME_ZIP: &str = "application/zip"; +const MIME_TAR: &str = "application/x-tar"; +const MIME_GZIP: &str = "application/gzip"; +const MIME_X_GZIP: &str = "application/x-gzip"; +const MIME_BZIP2: &str = "application/x-bzip2"; +const MIME_XZ: &str = "application/x-xz"; +const MIME_7Z: &str = "application/x-7z-compressed"; +const MIME_RAR: &str = "application/vnd.rar"; +const MIME_RAR_COMPRESSED: &str = "application/x-rar-compressed"; +const MIME_ZSTD: &str = "application/zstd"; +const MIME_PDF: &str = "application/pdf"; +const MIME_MSWORD: &str = "application/msword"; +const MIME_EPUB: &str = "application/epub+zip"; +const MIME_WASM: &str = "application/wasm"; + pub const TEXT_APPLICATION_MIMES: &[&str] = &[ - "application/json", - "application/toml", - "application/yaml", - "application/x-yaml", - "application/xml", - "application/javascript", - "application/typescript", - "application/ecmascript", - "application/sql", - "application/x-httpd-php", - "application/x-sh", - "application/rtf", + MIME_JSON, + MIME_TOML, + MIME_YAML, + MIME_X_YAML, + MIME_XML, + MIME_JAVASCRIPT, + MIME_TYPESCRIPT, + MIME_ECMASCRIPT, + MIME_SQL, + MIME_PHP, + MIME_SH, + MIME_RTF, ]; pub const KNOWN_BINARY_MIMES: &[&str] = &[ "application/octet-stream", - "application/zip", - "application/x-tar", - "application/gzip", - "application/x-gzip", - "application/x-bzip2", - "application/x-xz", - "application/x-7z-compressed", - "application/vnd.rar", - "application/x-rar-compressed", - "application/zstd", - "application/pdf", - "application/msword", - "application/epub+zip", - "application/wasm", + MIME_ZIP, + MIME_TAR, + MIME_GZIP, + MIME_X_GZIP, + MIME_BZIP2, + MIME_XZ, + MIME_7Z, + MIME_RAR, + MIME_RAR_COMPRESSED, + MIME_ZSTD, + MIME_PDF, + MIME_MSWORD, + MIME_EPUB, + MIME_WASM, "application/x-mach-binary", "application/x-dosexec", "application/x-executable", @@ -41,23 +83,23 @@ pub const KNOWN_BINARY_MIMES: &[&str] = &[ ]; pub const KNOWN_BINARY_PREFIXES: &[&str] = &[ - "image/", - "audio/", - "video/", - "application/vnd.oasis.opendocument.", - "application/vnd.openxmlformats-officedocument.", + PREFIX_IMAGE, + PREFIX_AUDIO, + PREFIX_VIDEO, + PREFIX_OPENDOCUMENT, + PREFIX_OOXML, "application/vnd.ms-", ]; -pub fn detect_mime_from_bytes(path: &Path, bytes: &[u8]) -> Option { - infer::get(bytes) - .map(|kind| kind.mime_type().to_string()) - .or_else(|| { - path.file_name() - .and_then(|name| name.to_str()) - .and_then(extension_mime) - .map(str::to_string) - }) +pub fn detect_mime_from_bytes(path: &Path, bytes: &[u8]) -> Option<&'static str> { + // Both sources already yield `&'static str` (`infer` returns static MIME + // strings, `extension_mime` returns table literals), so no allocation is + // needed. + infer::get(bytes).map(|kind| kind.mime_type()).or_else(|| { + path.file_name() + .and_then(|name| name.to_str()) + .and_then(extension_mime) + }) } #[must_use] @@ -65,17 +107,17 @@ pub fn mime_to_category(mime: &str) -> FileCategory { if mime == "inode/directory" { return FileCategory::Dir; } - if mime.starts_with("image/") { + if mime.starts_with(PREFIX_IMAGE) { return if mime == "image/vnd.djvu" { FileCategory::Document } else { FileCategory::Image }; } - if mime.starts_with("audio/") { + if mime.starts_with(PREFIX_AUDIO) { return FileCategory::Audio; } - if mime.starts_with("video/") { + if mime.starts_with(PREFIX_VIDEO) { return FileCategory::Video; } if mime.starts_with("text/") { @@ -93,40 +135,33 @@ pub fn mime_to_category(mime: &str) -> FileCategory { } if mime.starts_with("application/") { return match mime { - "application/json" | "application/toml" | "application/yaml" | "application/x-yaml" - | "application/xml" => FileCategory::Config, - "application/pdf" - | "application/msword" - | "application/rtf" - | "application/epub+zip" + MIME_JSON | MIME_TOML | MIME_YAML | MIME_X_YAML | MIME_XML => FileCategory::Config, + MIME_PDF + | MIME_MSWORD + | MIME_RTF + | MIME_EPUB | "application/x-mobipocket-ebook" | "application/vnd.amazon.ebook" | "application/vnd.ms-htmlhelp" | "application/x-tex" => FileCategory::Document, - m if m.starts_with("application/vnd.oasis.opendocument.") => FileCategory::Document, - m if m.starts_with("application/vnd.openxmlformats-officedocument.") => { - FileCategory::Document - } - "application/javascript" - | "application/typescript" - | "application/ecmascript" - | "application/sql" - | "application/wasm" - | "application/x-httpd-php" - | "application/x-sh" => FileCategory::Code, + m if m.starts_with(PREFIX_OPENDOCUMENT) => FileCategory::Document, + m if m.starts_with(PREFIX_OOXML) => FileCategory::Document, + MIME_JAVASCRIPT | MIME_TYPESCRIPT | MIME_ECMASCRIPT | MIME_SQL | MIME_WASM + | MIME_PHP | MIME_SH => FileCategory::Code, "application/vnd.ms-fontobject" => FileCategory::Font, - // NOTE: large archive match — consider extracting to a helper or - // using a phf set if the match count grows further. - "application/zip" - | "application/x-tar" - | "application/gzip" - | "application/x-gzip" - | "application/x-bzip2" - | "application/x-xz" - | "application/x-7z-compressed" - | "application/vnd.rar" - | "application/x-rar-compressed" - | "application/zstd" + // NOTE: phf/trie intentionally avoided here — a linear match is fine + // at TUI scale. Shared MIME constants keep these arms in sync with + // the public tables above. + MIME_ZIP + | MIME_TAR + | MIME_GZIP + | MIME_X_GZIP + | MIME_BZIP2 + | MIME_XZ + | MIME_7Z + | MIME_RAR + | MIME_RAR_COMPRESSED + | MIME_ZSTD | "application/x-lzma" | "application/vnd.ms-cab-compressed" | "application/x-iso9660-image" @@ -170,22 +205,26 @@ pub fn category_from_ext(name: &str) -> FileCategory { } fn ends_with_ignore_ascii_case(s: &str, suffix: &str) -> bool { - s.len() >= suffix.len() - && s.chars() - .rev() - .zip(suffix.chars().rev()) - .all(|(a, b)| a.eq_ignore_ascii_case(&b)) + let (s, suffix) = (s.as_bytes(), suffix.as_bytes()); + s.len() >= suffix.len() && s[s.len() - suffix.len()..].eq_ignore_ascii_case(suffix) } #[must_use] fn dotless_config_mime(name: &str) -> Option<&'static str> { - match name.to_ascii_lowercase().as_str() { - "makefile" => Some("text/x-makefile"), - "dockerfile" | "containerfile" => Some("text/x-dockerfile"), - "vagrantfile" | "rakefile" | "gemfile" | "brewfile" => Some("text/x-ruby"), - "justfile" => Some("text/x-justfile"), - "jenkinsfile" => Some("text/x-groovy"), - _ => None, + // ASCII case-insensitive compare without allocating a lowercased copy. + let eq = |candidate: &str| name.eq_ignore_ascii_case(candidate); + if eq("makefile") { + Some("text/x-makefile") + } else if eq("dockerfile") || eq("containerfile") { + Some("text/x-dockerfile") + } else if eq("vagrantfile") || eq("rakefile") || eq("gemfile") || eq("brewfile") { + Some("text/x-ruby") + } else if eq("justfile") { + Some("text/x-justfile") + } else if eq("jenkinsfile") { + Some("text/x-groovy") + } else { + None } } diff --git a/src/app/panel_ops.rs b/src/app/panel_ops.rs index d567364..6533af3 100644 --- a/src/app/panel_ops.rs +++ b/src/app/panel_ops.rs @@ -61,12 +61,14 @@ pub fn refresh_panel(panel: &mut PanelState, visible_height: usize) { panel.sort_mode(), *panel.sort_options(), ); + // Both listing stores receive pre-sorted data: `set_unfiltered` takes + // the sorted backing store, and `set_filtered` takes the sorted + // filtered slice and maps each entry back to its backing slot by path. + // Ordering is the caller's responsibility; the listing never reorders. panel.listing.set_unfiltered(sorted_unfiltered); - panel.listing.set_entries(new_filtered); + panel.listing.set_filtered(&new_filtered); restore_panel_selection(panel, &saved); - panel.recalculate_selection_stats(); - restore_panel_cursor(panel, current_name.as_deref()); - panel.ensure_cursor_visible(visible_height); + finalize_view(panel, current_name.as_deref(), visible_height); } Err(e) => { panel.listing.clear(); @@ -82,11 +84,14 @@ pub(crate) fn update_panel_read_errors(panel: &mut PanelState, errors: &[io::Err if errors.is_empty() { panel.set_last_error(None); } else { - let error_summary = errors - .iter() - .map(ToString::to_string) - .collect::>() - .join("; "); + // Fold straight into one String (no intermediate Vec alloc). + let error_summary = errors.iter().fold(String::new(), |mut acc, e| { + if !acc.is_empty() { + acc.push_str("; "); + } + acc.push_str(&e.to_string()); + acc + }); panel.set_last_error(Some(format!( "{} file(s) failed to read: {error_summary}", errors.len() @@ -97,20 +102,13 @@ pub(crate) fn update_panel_read_errors(panel: &mut PanelState, errors: &[io::Err fn current_panel_entry_name(panel: &PanelState) -> Option { panel .listing - .entries - .get(panel.cursor) + .filtered_get(panel.cursor) .filter(|e| e.name != "..") .map(|e| e.name.clone()) } fn selected_panel_paths(panel: &PanelState) -> HashSet { - panel - .listing - .entries - .iter() - .filter(|e| e.selected) - .map(|e| e.path.clone()) - .collect() + panel.selected_entries().map(|e| e.path.clone()).collect() } pub fn filtered_sorted_entries( @@ -120,30 +118,36 @@ pub fn filtered_sorted_entries( sort_options: SortOptions, show_hidden: bool, ) -> Vec { + // PERF FOLLOW-UP: `CompiledPattern` is rebuilt on every call (once per + // filtered_sorted_entries). It cannot be cached on `PanelState` yet because + // `CompiledPattern` derives none of `Debug`/`Clone`/`PartialEq` that + // `PanelState` requires; caching needs those derives added in + // ops/search/pattern.rs first (cross-module, separate change). let compiled = filter.map(|f| ops::CompiledPattern::new(f, false)); - let mut sort_entries: Vec = entries + // PERF FOLLOW-UP: `.cloned()` copies each (potentially large) FileEntry into + // the filtered Vec. A `Vec<&FileEntry>` / index view would avoid the copy, + // but `ops::sort_entries` takes `&mut [FileEntry]` (owned), so a borrow-only + // view needs a reference-sorting variant in `ops` first (cross-module). + let mut filtered_entries: Vec = entries .iter() .filter(|e| entry_matches_panel(e, compiled.as_ref(), show_hidden)) .cloned() .collect(); - ops::sort_entries(&mut sort_entries, sort_mode, sort_options); - sort_entries + ops::sort_entries(&mut filtered_entries, sort_mode, sort_options); + filtered_entries } pub fn rebuild_visible_entries(panel: &mut PanelState, visible_height: usize) { - panel.sync_unfiltered_selection(); let current_name = current_panel_entry_name(panel); let filtered = filtered_sorted_entries( - &panel.listing.unfiltered_entries, + panel.listing.unfiltered(), panel.filter(), panel.sort_mode(), *panel.sort_options(), panel.show_hidden(), ); - panel.listing.set_entries(filtered); - panel.recalculate_selection_stats(); - restore_panel_cursor(panel, current_name.as_deref()); - panel.ensure_cursor_visible(visible_height); + panel.listing.set_filtered(&filtered); + finalize_view(panel, current_name.as_deref(), visible_height); } pub(crate) fn entry_matches_panel( @@ -163,18 +167,27 @@ fn restore_selection_for(entries: &mut [reader::FileEntry], saved: &HashSet) { - restore_selection_for(&mut panel.listing.entries, saved); - restore_selection_for(&mut panel.listing.unfiltered_entries, saved); + // Single backing store now owns selection; the filtered view borrows it. + restore_selection_for(panel.listing.unfiltered_mut(), saved); +} + +/// Shared post-rebuild steps for both the full refresh and the filter-only +/// rebuild: recompute selection stats, re-anchor the cursor on the previously +/// focused entry name (if still visible) and clamp it into the viewport. +fn finalize_view(panel: &mut PanelState, current_name: Option<&str>, visible_height: usize) { + panel.recalculate_selection_stats(); + restore_panel_cursor(panel, current_name); + panel.ensure_cursor_visible(visible_height); } fn restore_panel_cursor(panel: &mut PanelState, current_name: Option<&str>) { if let Some(name) = current_name - && let Some(pos) = panel.listing.entries.iter().position(|e| e.name == name) + && let Some(pos) = panel.listing.filtered().position(|e| e.name == name) { panel.cursor = pos; } - if panel.cursor >= panel.listing.entries.len() { - panel.cursor = panel.listing.entries.len().saturating_sub(1); + if panel.cursor >= panel.listing.filtered_len() { + panel.cursor = panel.listing.filtered_len().saturating_sub(1); } } @@ -203,22 +216,25 @@ pub fn refresh_both(state: &mut AppState) { } pub fn set_active_panel(state: &mut AppState, panel: ActivePanel) { - state.active_panel = panel; + state.set_active_panel(panel); } +// Indices into the top menu bar (`crate::menu::MENUS`): +// 0:Left 1:File 2:Command 3:Options 4:Right. The "Left"/"Right" menus drive the +// panel of the same name, so their selection maps onto the matching `ActivePanel`. const MENU_ITEM_LEFT_PANEL: usize = 0; const MENU_ITEM_RIGHT_PANEL: usize = 4; pub fn with_menu_panel(state: &mut AppState, f: impl FnOnce(&mut AppState) -> T) -> T { let original = state.active_panel; - match state.menu_selected { + match state.ui.menu_selected { MENU_ITEM_LEFT_PANEL => set_active_panel(state, ActivePanel::Left), MENU_ITEM_RIGHT_PANEL => set_active_panel(state, ActivePanel::Right), _ => {} } let result = f(state); if matches!(state.mode, AppMode::Dialog(_)) { - state.menu_restore_panel = Some(original); + state.ui.menu_restore_panel = Some(original); } else { set_active_panel(state, original); } @@ -234,13 +250,12 @@ pub fn navigate_to_hotlist(state: &mut AppState, index: usize) { Some(p) => p.clone(), None => { let len = state.hotlist().len(); - state.status_message = - Some(format!("Hotlist index {} out of range (0..{})", index, len)); + state.set_status(format!("Hotlist index {} out of range (0..{})", index, len)); return; } }; if !path.is_dir() { - state.status_message = Some(format!("{} is not a directory", path.display())); + state.set_status(format!("{} is not a directory", path.display())); return; } let display = path.display().to_string(); @@ -251,7 +266,7 @@ pub fn navigate_to_hotlist(state: &mut AppState, index: usize) { panel.scroll_offset = 0; panel.set_filter(None); refresh_active(state); - state.status_message = Some(format!("cd to {display}")); + state.set_status(format!("cd to {display}")); } #[cfg(test)] diff --git a/src/app/paths.rs b/src/app/paths.rs index 8d52a9e..7deb3b9 100644 --- a/src/app/paths.rs +++ b/src/app/paths.rs @@ -59,31 +59,31 @@ impl EnvProvider for MapEnv { } } -#[must_use] -pub fn config_file_path() -> Option { - config_file_path_with_env(&ProcessEnv) +/// Generate a zero-arg public accessor that delegates to its `*_with_env` +/// counterpart using the real process environment. +macro_rules! process_env_accessor { + ($(#[$meta:meta])* $vis:vis fn $name:ident -> $with_env:ident) => { + $(#[$meta])* + $vis fn $name() -> Option { + $with_env(&ProcessEnv) + } + }; } +process_env_accessor!(#[must_use] pub fn config_file_path -> config_file_path_with_env); +process_env_accessor!(#[must_use] pub fn user_menu_path -> user_menu_path_with_env); +process_env_accessor!(#[must_use] pub fn terminal_state_file_path -> terminal_state_file_path_with_env); + #[must_use] pub fn config_file_path_with_env(env: &impl EnvProvider) -> Option { config_home(env).map(|dir| dir.join("config.toml")) } -#[must_use] -pub fn user_menu_path() -> Option { - user_menu_path_with_env(&ProcessEnv) -} - #[must_use] pub fn user_menu_path_with_env(env: &impl EnvProvider) -> Option { config_home(env).map(|dir| dir.join("menu")) } -#[must_use] -pub fn terminal_state_file_path() -> Option { - terminal_state_file_path_with_env(&ProcessEnv) -} - #[must_use] pub fn terminal_state_file_path_with_env(env: &impl EnvProvider) -> Option { cache_home(env).map(|dir| dir.join("terminal_state")) @@ -119,27 +119,26 @@ pub(crate) fn cache_home(env: &impl EnvProvider) -> Option { xdg_dir(env, "XDG_CACHE_HOME", ".cache", platform_cache_home) } -/// On Windows, HOME/XDG are often unset; fall back to platform dirs. -/// On other platforms, HOME is always available, so this returns `None`. -#[cfg(windows)] -fn platform_config_home() -> Option { - dirs::config_dir().map(|dir| dir.join(APP_NAME)) -} - -#[cfg(not(windows))] -fn platform_config_home() -> Option { - None -} +/// Generate a platform fallback for an app directory. +/// +/// On Windows, HOME/XDG are often unset, so fall back to the OS-specific dir. +/// On other platforms HOME is always available, so this returns `None`. +macro_rules! platform_home { + ($name:ident, $dirs_fn:path) => { + #[cfg(windows)] + fn $name() -> Option { + $dirs_fn().map(|dir| dir.join(APP_NAME)) + } -#[cfg(windows)] -fn platform_cache_home() -> Option { - dirs::cache_dir().map(|dir| dir.join(APP_NAME)) + #[cfg(not(windows))] + fn $name() -> Option { + None + } + }; } -#[cfg(not(windows))] -fn platform_cache_home() -> Option { - None -} +platform_home!(platform_config_home, dirs::config_dir); +platform_home!(platform_cache_home, dirs::cache_dir); #[cfg(test)] mod tests { diff --git a/src/app/shell.rs b/src/app/shell.rs index b80ab4c..484763d 100644 --- a/src/app/shell.rs +++ b/src/app/shell.rs @@ -18,6 +18,21 @@ use crate::debug_log; const EVENT_POLL_TIMEOUT_MS: u64 = 100; pub const MAX_HISTORY: usize = 100; +/// User-facing prompts shown while the TUI is suspended for an external command. +const MSG_COMMAND_SUCCEEDED: &str = "\n[Command succeeded. Press Enter to return]"; +const PRESS_ENTER_TO_RETURN: &str = "Press Enter to return]"; +const MSG_EXTERNAL_VIEW_ACTIVE: &str = + "External view active. Press Enter/Esc/Ctrl+O/Ctrl+C to return to Libre Commander."; + +/// Reads a shell path from environment variable `var`, falling back to +/// `default` when the variable is unset or empty. +fn get_shell_from_env(var: &str, default: &str) -> String { + std::env::var(var) + .ok() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| default.to_string()) +} + fn enter_tui_stdout() -> io::Result<()> { enable_raw_mode()?; if let Err(err) = execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture, Hide) { @@ -71,10 +86,7 @@ impl Drop for TerminalRestoreGuard { fn get_shell(_for_menu: bool) -> &'static (String, &'static str) { static SHELL: OnceLock<(String, &'static str)> = OnceLock::new(); SHELL.get_or_init(|| { - let shell = std::env::var("COMSPEC") - .ok() - .filter(|s| !s.is_empty()) - .unwrap_or("cmd.exe".to_string()); + let shell = get_shell_from_env("COMSPEC", "cmd.exe"); (shell, "/C") }) } @@ -94,10 +106,7 @@ fn get_shell(for_menu: bool) -> &'static (String, &'static str) { MENU_SHELL.get_or_init(|| ("sh".to_string(), "-c")) } else { INTERACTIVE_SHELL.get_or_init(|| { - let shell = std::env::var("SHELL") - .ok() - .filter(|s| !s.is_empty()) - .unwrap_or("sh".to_string()); + let shell = get_shell_from_env("SHELL", "sh"); (shell, "-c") }) } @@ -108,13 +117,36 @@ pub fn push_history(state: &mut AppState, cmd: &str) { return; } // O(n) dedup scan; acceptable because MAX_HISTORY == 100 - state.command_history.retain(|entry| entry != cmd); - state.command_history.push_back(cmd.to_string()); - if state.command_history.len() > MAX_HISTORY { - state.command_history.pop_front(); + state.input.command_history.retain(|entry| entry != cmd); + state.input.command_history.push_back(cmd.to_string()); + if state.input.command_history.len() > MAX_HISTORY { + state.input.command_history.pop_front(); } } +/// Runs `cmd` through a shell (`$SHELL -c` for interactive commands, `sh -c` +/// for menu commands) with the active panel directory as cwd. +/// +/// # Threat model (shell injection is by design) +/// +/// `cmd` is handed verbatim to `sh -c` / `$SHELL -c`, so the shell performs +/// full word-splitting, globbing and command substitution. This is intentional: +/// this is a shell-command runner, exactly like the command line and the user +/// menu in `mc`. +/// +/// Sources of `cmd`: +/// * Interactive command line (`for_menu == false`): typed by the user — no +/// trust boundary, the user is executing their own commands. +/// * Global user menu (`for_menu == true`, `MenuSource::Global`): read from the +/// user's own config — trusted to the same degree as their dotfiles. +/// * Local directory menu (`for_menu == true`, `MenuSource::Local`): +/// ATTACKER-CONTROLLED. The menu file ships inside the browsed directory, so a +/// hostile archive/repo can plant arbitrary commands. The only defense is the +/// "Trust Local Menu?" confirm dialog raised in `input/pickers.rs` before this +/// function is ever reached. There is no sandboxing beyond that prompt; if the +/// user confirms, the command runs with their full privileges. This matches +/// the accepted threat model for a file manager, but the confirm gate MUST +/// remain the sole entry point for local-menu execution. pub fn run_shell_command( state: &mut AppState, cmd: &str, @@ -126,7 +158,7 @@ pub fn run_shell_command( } if leave_tui_stdout().is_err() { - state.status_message = Some("Terminal suspend failed".into()); + state.ui.status_message = Some("Terminal suspend failed".into()); return; } @@ -146,9 +178,9 @@ pub fn run_shell_command( // Intentional stdout: TUI is suspended, user must see the prompt. #[allow(clippy::print_stdout)] match status { - Ok(s) if s.success() => println!("\n[Command succeeded. Press Enter to return]"), - Ok(s) => println!("\n[Command exited with status: {s}. Press Enter to return]"), - Err(e) => println!("\n[Command failed: {e}. Press Enter to return]"), + Ok(s) if s.success() => println!("{MSG_COMMAND_SUCCEEDED}"), + Ok(s) => println!("\n[Command exited with status: {s}. {PRESS_ENTER_TO_RETURN}"), + Err(e) => println!("\n[Command failed: {e}. {PRESS_ENTER_TO_RETURN}"), } let mut buf = String::new(); // Intentionally ignoring read_line error: if stdin is unavailable there's nothing to wait for. @@ -156,12 +188,31 @@ pub fn run_shell_command( match enter_tui_stdout() { Ok(()) => restore_guard.already_restored = true, Err(e) => { - state.status_message = Some(format!("Terminal restore failed: {e}")); + state.ui.status_message = Some(format!("Terminal restore failed: {e}")); } } refresh_active(state); } +/// Blocks until the user leaves the external view by pressing Enter, Esc, or +/// Ctrl+O / Ctrl+C. Assumes raw mode is already enabled by the caller. +fn wait_for_external_view_exit() -> io::Result<()> { + loop { + if event::poll(Duration::from_millis(EVENT_POLL_TIMEOUT_MS))? + && let Event::Key(key) = event::read()? + { + match (key.code, key.modifiers) { + (KeyCode::Char('o'), m) if m.contains(KeyModifiers::CONTROL) => break, + (KeyCode::Char('c'), m) if m.contains(KeyModifiers::CONTROL) => break, + (KeyCode::Enter, _) => break, + (KeyCode::Esc, _) => break, + _ => {} + } + } + } + Ok(()) +} + /// Toggle external panel view (Ctrl+O / Ctrl+C) - hide panels to see terminal output. #[allow(clippy::print_stdout)] pub fn toggle_external_view( @@ -175,26 +226,11 @@ pub fn toggle_external_view( }; // Show message to user. - println!("External view active. Press Enter/Esc/Ctrl+O/Ctrl+C to return to Libre Commander."); + println!("{MSG_EXTERNAL_VIEW_ACTIVE}"); // Wait for Ctrl+O or any key. enable_raw_mode()?; - let wait_result = (|| -> io::Result<()> { - loop { - if event::poll(Duration::from_millis(EVENT_POLL_TIMEOUT_MS))? - && let Event::Key(key) = event::read()? - { - match (key.code, key.modifiers) { - (KeyCode::Char('o'), m) if m.contains(KeyModifiers::CONTROL) => break, - (KeyCode::Char('c'), m) if m.contains(KeyModifiers::CONTROL) => break, - (KeyCode::Enter, _) => break, - (KeyCode::Esc, _) => break, - _ => {} - } - } - } - Ok(()) - })(); + let wait_result = wait_for_external_view_exit(); let raw_result = disable_raw_mode(); let resume_result = enter_tui_stdout(); diff --git a/src/app/types/app_state.rs b/src/app/types/app_state.rs index 20dd16f..ab56e3d 100644 --- a/src/app/types/app_state.rs +++ b/src/app/types/app_state.rs @@ -1,5 +1,6 @@ use std::collections::VecDeque; use std::path::PathBuf; +use std::time::Instant; use super::modes::{AppMode, PendingAction}; use super::panel::{ActivePanel, PanelState}; @@ -9,28 +10,36 @@ use crate::app::user_menu::{MenuEntry, MenuSource}; use crate::debug_log; use crate::ui::theme::ColorPalette; -#[derive(Debug, Clone, PartialEq)] -pub struct AppState { - pub left_panel: PanelState, - pub right_panel: PanelState, - pub active_panel: ActivePanel, - pub mode: AppMode, - /// Mode saved before entering a temporary mode (e.g. CommandLine). - /// Used by `restore_prev_mode()` to return to the previous mode. - pub prev_mode: Option, - pub should_quit: bool, +/// Text-entry and editing state shared across the dialog, command-line and +/// search surfaces. Grouped out of [`AppState`] to shrink the former god +/// object and keep editing concerns together. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct InputState { pub dialog_input: TextInput, pub dialog_selection: usize, - pub pending_action: Option, pub command_line: TextInput, pub command_history: VecDeque, pub history_index: Option, pub command_draft: String, pub search_query: String, pub search_cursor: usize, +} + +/// Transient presentation state: status line, menus, pickers, the directory +/// hotlist, user-menu data, the deferred action awaiting confirmation, and the +/// viewer spinner animation. +/// +/// `Default` is implemented by hand because [`MenuSource`] has no `Default`. +#[derive(Debug, Clone, PartialEq)] +pub struct UiState { pub status_message: Option, pub menu_selected: usize, pub menu_item_selected: usize, + // NOTE: `menu_selected` / `menu_item_selected` / `picker_selected` are bare + // `usize` indices (primitive obsession). A `MenuIndex` / `PickerIndex` + // newtype was considered but is not worth the churn: these indices flow + // through ~130 call sites and many slice operations that expect `usize`. + // Follow-up: introduce index newtypes once the call sites settle. pub picker_selected: usize, pub user_menu_entries: Vec, pub user_menu_source: MenuSource, @@ -39,16 +48,72 @@ pub struct AppState { pub pending_menu_command: Option, pub menu_restore_panel: Option, pub directory_hotlist: Vec, - pub tree_root: PathBuf, - pub tree_entries: Vec, - pub tree_selected: usize, - pub tree_scroll: usize, - pub last_click_time: Option, - pub last_click_position: Option<(u16, u16)>, + pub pending_action: Option, + pub viewer_spinner_frame: u64, + pub viewer_spinner_last_tick: Option, +} + +impl Default for UiState { + fn default() -> Self { + Self { + status_message: None, + menu_selected: 0, + menu_item_selected: 0, + picker_selected: 0, + user_menu_entries: Vec::new(), + user_menu_source: MenuSource::Global, + cached_hotlist_strings: Vec::new(), + cached_user_menu_strings: Vec::new(), + pending_menu_command: None, + menu_restore_panel: None, + directory_hotlist: Vec::new(), + pending_action: None, + viewer_spinner_frame: 0, + viewer_spinner_last_tick: None, + } + } +} + +/// Directory-tree browser view state (the `DirectoryTree` mode). +#[derive(Debug, Clone, PartialEq, Default)] +pub struct TreeState { + pub root: PathBuf, + pub entries: Vec, + pub selected: usize, + pub scroll: usize, +} + +/// Pointer-interaction state used for double-click detection and drag +/// selection in the panels. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct InteractionState { + /// Last single click as `(timestamp, (col, row))`. Reset to `None` once a + /// double-click fires or the mouse button is released. + /// + /// Merged from the former separate `last_click_time` / `last_click_position` + /// fields so the timestamp and position can never drift out of sync. + pub last_click: Option<(Instant, (u16, u16))>, pub drag_anchor_index: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct AppState { + // --- Core --- + pub left_panel: PanelState, + pub right_panel: PanelState, + pub active_panel: ActivePanel, + pub mode: AppMode, + /// Mode saved before entering a temporary mode (e.g. CommandLine). + /// Used by `restore_prev_mode()` to return to the previous mode. + pub prev_mode: Option, + pub should_quit: bool, pub theme_colors: ColorPalette, - pub viewer_spinner_frame: u64, - pub viewer_spinner_last_tick: Option, + + // --- Aggregates --- + pub input: InputState, + pub ui: UiState, + pub tree: TreeState, + pub interaction: InteractionState, } impl AppState { @@ -65,36 +130,15 @@ impl AppState { mode: AppMode::Normal, prev_mode: None, should_quit: false, - dialog_input: TextInput::default(), - dialog_selection: 0, - pending_action: None, - command_line: TextInput::default(), - command_history: VecDeque::new(), - history_index: None, - command_draft: String::new(), - search_query: String::new(), - search_cursor: 0, - status_message: None, - menu_selected: 0, - menu_item_selected: 0, - picker_selected: 0, - user_menu_entries: Vec::new(), - user_menu_source: MenuSource::Global, - cached_hotlist_strings: vec![current_dir.display().to_string()], - cached_user_menu_strings: Vec::new(), - pending_menu_command: None, - menu_restore_panel: None, - directory_hotlist: vec![current_dir], - tree_root: PathBuf::new(), - tree_entries: Vec::new(), - tree_selected: 0, - tree_scroll: 0, - last_click_time: None, - last_click_position: None, - drag_anchor_index: None, theme_colors: crate::ui::theme::DEFAULT_COLORS, - viewer_spinner_frame: 0, - viewer_spinner_last_tick: None, + input: InputState::default(), + ui: UiState { + cached_hotlist_strings: vec![current_dir.display().to_string()], + directory_hotlist: vec![current_dir], + ..UiState::default() + }, + tree: TreeState::default(), + interaction: InteractionState::default(), } } @@ -116,57 +160,102 @@ impl AppState { self.panel_mut(which) } + // --- Core invariant accessors ------------------------------------------- + // Added incrementally (highest-risk migration): callers still touch the + // `pub` core fields directly today. New code should prefer these so the + // invariants live in one place; the fields can be made private in a later + // wave once callers have moved over. + + /// Switch keyboard focus to an explicit panel. + pub fn set_active_panel(&mut self, panel: ActivePanel) { + self.active_panel = panel; + } + + /// Toggle keyboard focus between the left and right panel. + pub fn toggle_active_panel(&mut self) { + self.active_panel = self.active_panel.toggle(); + } + + pub fn mode(&self) -> &AppMode { + &self.mode + } + + /// Set the current mode. Centralized so mode-transition invariants can be + /// enforced here later (e.g. clearing per-mode scratch state). + pub fn set_mode(&mut self, mode: AppMode) { + self.mode = mode; + } + + pub fn should_quit(&self) -> bool { + self.should_quit + } + + /// Request application shutdown. One-way by design: there is no public way + /// to clear the flag, so a quit request can never be silently undone. + pub fn request_quit(&mut self) { + self.should_quit = true; + } + pub fn hotlist(&self) -> &[PathBuf] { - &self.directory_hotlist + &self.ui.directory_hotlist + } + + /// Rebuild a display-string cache from a source slice. Shared by the + /// hotlist and user-menu caches, which differ only in how each entry is + /// rendered to a `String`. + fn rebuild_string_cache( + source: &[T], + cache: &mut Vec, + render: impl Fn(&T) -> String, + ) { + cache.clear(); + cache.reserve(source.len()); + cache.extend(source.iter().map(render)); } pub fn rebuild_hotlist_cache(&mut self) { - let n = self.directory_hotlist.len(); - self.cached_hotlist_strings = Vec::with_capacity(n); - self.cached_hotlist_strings.extend( - self.directory_hotlist - .iter() - .map(|p| p.display().to_string()), + Self::rebuild_string_cache( + &self.ui.directory_hotlist, + &mut self.ui.cached_hotlist_strings, + |p| p.display().to_string(), ); } pub fn rebuild_user_menu_cache(&mut self) { - let n = self.user_menu_entries.len(); - self.cached_user_menu_strings = Vec::with_capacity(n); - self.cached_user_menu_strings.extend( - self.user_menu_entries - .iter() - .map(|e| format!("{} {}", e.hotkey, e.title)), + Self::rebuild_string_cache( + &self.ui.user_menu_entries, + &mut self.ui.cached_user_menu_strings, + |e| format!("{} {}", e.hotkey, e.title), ); } pub fn hotlist_push(&mut self, path: PathBuf) { - if self.directory_hotlist.iter().any(|p| p == &path) { + if self.ui.directory_hotlist.iter().any(|p| p == &path) { return; } - self.directory_hotlist.push(path); + self.ui.directory_hotlist.push(path); self.rebuild_hotlist_cache(); } pub fn hotlist_remove(&mut self, index: usize) { - if index >= self.directory_hotlist.len() { + if index >= self.ui.directory_hotlist.len() { return; } - self.directory_hotlist.remove(index); + self.ui.directory_hotlist.remove(index); self.rebuild_hotlist_cache(); } /// Replace the entire directory hotlist and rebuild the string cache. /// Used when loading a persisted hotlist from config. pub fn hotlist_set(&mut self, hotlist: Vec) { - self.directory_hotlist = hotlist; + self.ui.directory_hotlist = hotlist; self.rebuild_hotlist_cache(); } /// Replace all user-menu entries and rebuild the display-string cache. /// Called after parsing a `.mc.menu` file or switching menu source. pub fn user_menu_set(&mut self, entries: Vec) { - self.user_menu_entries = entries; + self.ui.user_menu_entries = entries; self.rebuild_user_menu_cache(); } @@ -174,22 +263,22 @@ impl AppState { /// `restore_prev_mode()`. Command-line mode always returns to `Normal` /// directly, so there is no previous mode to preserve. pub fn enter_command_line_mode(&mut self) { - self.command_line.clear(); - self.history_index = None; + self.input.command_line.clear(); + self.input.history_index = None; self.prev_mode = None; - self.mode = AppMode::CommandLine; + self.set_mode(AppMode::CommandLine); } pub fn set_status(&mut self, msg: impl Into) { - self.status_message = Some(msg.into()); + self.ui.status_message = Some(msg.into()); } pub fn clear_status(&mut self) { - self.status_message = None; + self.ui.status_message = None; } pub fn reset_drag_state(&mut self) { - self.drag_anchor_index = None; + self.interaction.drag_anchor_index = None; } pub fn panel_mut(&mut self, panel: ActivePanel) -> &mut PanelState { diff --git a/src/app/types/dialogs.rs b/src/app/types/dialogs.rs index 79a765a..05c3f86 100644 --- a/src/app/types/dialogs.rs +++ b/src/app/types/dialogs.rs @@ -4,6 +4,15 @@ use std::time::SystemTime; use super::text_input::TextInput; use crate::ops::archive::ArchiveEntry; +// NOTE (Message newtype / Cow): the message-carrying `String` fields below +// (`ConfirmDetails::{title,message}`, `DialogKind::{Error, Help.message, +// Input.prompt, Progress.message}`) were considered for a `Message` newtype and +// for `Cow<'static, str>`. Both are deferred: these strings are read as `&str` +// by the render layer (`render_dialog_map`) and constructed at ~60 call sites +// across `input::` and the tests, so either change cascades widely without +// adding real invariant safety here. Follow-up: introduce `Message` (and/or +// `Cow`) when the render boundary is reworked. + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ConfirmDetails { pub title: String, @@ -12,20 +21,21 @@ pub struct ConfirmDetails { } impl ConfirmDetails { - pub fn simple(title: &str, message: &str) -> Self { + /// Shared constructor; `simple` and `with_files` differ only in `files`. + fn build(title: &str, message: &str, files: Option>) -> Self { Self { title: title.to_string(), message: message.to_string(), - files: None, + files, } } + pub fn simple(title: &str, message: &str) -> Self { + Self::build(title, message, None) + } + pub fn with_files(title: &str, message: &str, files: Vec) -> Self { - Self { - title: title.to_string(), - message: message.to_string(), - files: Some(files), - } + Self::build(title, message, Some(files)) } } @@ -40,12 +50,73 @@ pub enum InputAction { ViewerSearch, } +/// Whether a [`CopyMoveDetails`] dialog confirms a copy or a move. Replaces the +/// former `is_move: bool` flag. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CopyMoveKind { + Copy, + Move, +} + +impl CopyMoveKind { + pub fn is_move(self) -> bool { + matches!(self, Self::Move) + } +} + #[derive(Debug, Clone, PartialEq)] pub struct CopyMoveDetails { pub source: Vec, - pub source_display: Vec, pub dest: PathBuf, - pub is_move: bool, + pub kind: CopyMoveKind, +} + +impl CopyMoveDetails { + /// Per-source display labels (file name, falling back to the full path). + /// Derived on demand from `source` instead of being stored as a parallel + /// `source_display` field that could drift out of sync. + pub fn source_display(&self) -> Vec { + self.source + .iter() + .map(|p| { + p.file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| p.display().to_string()) + }) + .collect() + } +} + +/// The kind of filesystem object a [`PropertiesDetails`] describes. Replaces the +/// former `is_dir: bool` / `is_symlink: bool` flag pair, which could encode the +/// nonsensical "both true / which wins?" states. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileKind { + File, + Directory, + Symlink, +} + +impl FileKind { + /// Build from the legacy `(is_dir, is_symlink)` metadata flags. Symlinks + /// take precedence over directories, matching the previous render logic. + pub fn from_metadata_flags(is_dir: bool, is_symlink: bool) -> Self { + if is_symlink { + Self::Symlink + } else if is_dir { + Self::Directory + } else { + Self::File + } + } + + pub fn label(self) -> &'static str { + match self { + Self::File => "File", + Self::Directory => "Directory", + Self::Symlink => "Symlink", + } + } } #[derive(Debug, Clone, PartialEq)] @@ -56,8 +127,7 @@ pub struct PropertiesDetails { pub permissions: u32, pub owner: String, pub group: String, - pub is_dir: bool, - pub is_symlink: bool, + pub kind: FileKind, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -109,6 +179,10 @@ impl DialogKind { pub fn progress(message: String, fraction: f32, cancellable: bool) -> Self { Self::Progress { message, + // Intentional fixup: out-of-range inputs (e.g. a computed 1.5 or a + // negative fraction) are silently snapped into 0.0..=1.0 rather than + // rejected. Progress is cosmetic, so clamping is preferred over an + // error path or a debug assertion here. progress_fraction: fraction.clamp(0.0, 1.0), cancellable, } diff --git a/src/app/types/file_entry.rs b/src/app/types/file_entry.rs index f4e7828..cd057c7 100644 --- a/src/app/types/file_entry.rs +++ b/src/app/types/file_entry.rs @@ -153,9 +153,10 @@ pub fn format_size(size: u64) -> String { FileSize(size).to_string() } -pub fn format_permissions(mode: u32) -> String { - FileEntry::display_permissions_raw(mode) -} +// PR-07 WS-B: the trivial free `format_permissions` wrapper was removed. +// Callers must call `FileEntry::display_permissions_raw(mode)` directly. +// Wave 2 migrates `ui/panels/mod.rs`, `fs/reader.rs`, and the +// `crate::app::types::mod.rs` re-export accordingly. pub(crate) fn format_system_time(modified: SystemTime) -> Option { let duration = modified.duration_since(std::time::UNIX_EPOCH).ok()?; @@ -176,6 +177,16 @@ pub fn compute_category(cha: &Cha, name: &str) -> FileCategory { crate::app::file_type::category(name, cha.is_dir(), cha.is_executable(), cha.is_link()) } +/// Interns a string into an `Arc`. +/// +/// Taking `impl AsRef` and building the `Arc` from the `&str` performs a +/// single allocation here (the `Arc` buffer). Compared to a `&str` -> `String` +/// -> `Arc` chain it avoids an intermediate owned `String`; when the caller +/// already owns a `String`, that buffer is simply dropped after the copy. +fn intern_str(value: impl AsRef) -> Arc { + Arc::from(value.as_ref()) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct FileEntry { pub name: String, @@ -205,6 +216,29 @@ pub struct FileEntryBuilder { mime_type: Option, } +/// Error returned by [`FileEntryBuilder::build`] when the accumulated +/// configuration cannot produce a valid [`FileEntry`]. +/// +/// Validation is deferred to `build` so that chaining setters never panics on +/// partially-built input; the public builder therefore reports misuse as a +/// recoverable error instead of aborting the process. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BuildError { + /// `name` was never set, or was set to an empty string. Every directory + /// entry must carry a non-empty file name. + EmptyName, +} + +impl std::fmt::Display for BuildError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::EmptyName => write!(f, "FileEntry name must not be empty"), + } + } +} + +impl std::error::Error for BuildError {} + impl FileEntryBuilder { pub fn name(mut self, v: impl Into) -> Self { self.name = v.into(); @@ -218,27 +252,27 @@ impl FileEntryBuilder { self.cha = v; self } - pub fn is_dir(mut self, v: bool) -> Self { + /// Applies file-type bits while preserving permission bits and clearing + /// resolved-symlink-target flags. When `enable` is false and the entry is + /// `currently` of this type, it is demoted back to a regular file. + fn set_type(mut self, enable: bool, type_bits: u32, currently: bool) -> Self { let perms = self.cha.mode.permissions(); - if v { - self.cha.mode = ChaMode::new(MODE_DIR | perms); + if enable { + self.cha.mode = ChaMode::new(type_bits | perms); self.cha.kind.remove(ChaKind::DIR_TARGET | ChaKind::FOLLOW); - } else if self.cha.is_dir() { + } else if currently { self.cha.mode = ChaMode::new(MODE_FILE | perms); self.cha.kind.remove(ChaKind::DIR_TARGET | ChaKind::FOLLOW); } self } - pub fn is_symlink(mut self, v: bool) -> Self { - let perms = self.cha.mode.permissions(); - if v { - self.cha.mode = ChaMode::new(MODE_SYMLINK | perms); - self.cha.kind.remove(ChaKind::DIR_TARGET | ChaKind::FOLLOW); - } else if self.cha.is_link() { - self.cha.mode = ChaMode::new(MODE_FILE | perms); - self.cha.kind.remove(ChaKind::DIR_TARGET | ChaKind::FOLLOW); - } - self + pub fn is_dir(self, v: bool) -> Self { + let currently = self.cha.is_dir(); + self.set_type(v, MODE_DIR, currently) + } + pub fn is_symlink(self, v: bool) -> Self { + let currently = self.cha.is_link(); + self.set_type(v, MODE_SYMLINK, currently) } pub fn is_executable(mut self, v: bool) -> Self { self.cha.set_executable(v); @@ -261,12 +295,12 @@ impl FileEntryBuilder { self.cha.mode = ChaMode::new(file_type | (v & 0o7777)); self } - pub fn owner(mut self, v: impl Into) -> Self { - self.owner = Arc::from(v.into()); + pub fn owner(mut self, v: impl AsRef) -> Self { + self.owner = intern_str(v); self } - pub fn group(mut self, v: impl Into) -> Self { - self.group = Arc::from(v.into()); + pub fn group(mut self, v: impl AsRef) -> Self { + self.group = intern_str(v); self } pub fn selected(mut self, v: bool) -> Self { @@ -281,13 +315,20 @@ impl FileEntryBuilder { self.mime_type = v; self } - pub fn build(self) -> FileEntry { - assert!(!self.name.is_empty(), "FileEntry name must not be empty"); + /// Finalizes the builder into a [`FileEntry`]. + /// + /// Returns [`BuildError::EmptyName`] if `name` was never set (or set to an + /// empty string) instead of panicking, so callers control how invalid input + /// is handled. + pub fn build(self) -> Result { + if self.name.is_empty() { + return Err(BuildError::EmptyName); + } let (time_str, size_str, name_width, size_width, time_width) = FileEntry::cached_fields(&self.cha, &self.name); let category = compute_category(&self.cha, &self.name); let sanitized_name = sanitize_name(&self.name); - FileEntry { + Ok(FileEntry { name: self.name, path: self.path, cha: self.cha, @@ -302,11 +343,16 @@ impl FileEntryBuilder { time_width, category, sanitized_name, - } + }) } } impl FileEntry { + // SCOPE-OUT (PR-07 WS-B): the precomputed display fields below + // (`time_str`, `size_str`, `*_width`, `category`, `sanitized_name`) are an + // eagerly materialized cache stored per entry. A field-cache/storage + // redesign (e.g. lazy or columnar storage) is intentionally deferred to a + // later PR and is NOT part of this change. pub fn cached_fields(cha: &Cha, name: &str) -> (String, String, usize, usize, usize) { let time_str = format_time(cha.mtime().unwrap_or(std::time::UNIX_EPOCH)); let size_str = if cha.is_dir() { @@ -397,7 +443,10 @@ impl FileEntry { } pub fn format_size(size: u64) -> String { - format!("{:>6}", crate::app::types::format_size(size)) + // Right-align to width 6. The padding must be applied to the formatted + // `String` (via the free `format_size`): `FileSize`'s Display impl writes + // its parts directly and ignores the formatter's width/fill flags. + format!("{:>6}", format_size(size)) } pub fn display_permissions(&self) -> String { diff --git a/src/app/types/mod.rs b/src/app/types/mod.rs index ab34512..a68c08e 100644 --- a/src/app/types/mod.rs +++ b/src/app/types/mod.rs @@ -7,27 +7,57 @@ mod sorting; mod text_input; #[cfg(test)] +// `TestEntry::build` surfaces the (unreachable for tests) `BuildError` via +// `.expect`; allow the restriction lint for this test-only helper module. +#[allow(clippy::expect_used)] pub(crate) mod test_helpers; #[cfg(test)] -#[allow(clippy::panic, clippy::unwrap_used)] +#[allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)] mod tests; -pub use app_state::AppState; +// --- Re-exports ----------------------------------------------------------- +// Grouped by shape: data types (structs/enums) first, then free utility +// functions. WS-E debt: this is a flat ~30-symbol facade; a later pass could +// split it into per-concern submodule facades if the surface keeps growing. + +// State containers & aggregates (AppState plus its extracted sub-states). +pub use app_state::{AppState, InputState, InteractionState, TreeState, UiState}; + +// Dialog data types. pub use dialogs::{ - ArchiveCreateDetails, ArchiveExtractDetails, ConfirmDetails, CopyMoveDetails, DialogKind, - InputAction, OverwriteConfirmDetails, PickerKind, PropertiesDetails, -}; -// sanitize_for_display is only needed by test helpers; sanitize_name is used in -// production code (e.g. file entry construction) and must always be available. -#[cfg(test)] -pub(crate) use file_entry::sanitize_for_display; -pub(crate) use file_entry::sanitize_name; -pub use file_entry::{ - FileCategory, FileEntry, FileEntryBuilder, FileSize, compute_category, format_permissions, - format_size, format_time, + ArchiveCreateDetails, ArchiveExtractDetails, ConfirmDetails, CopyMoveDetails, CopyMoveKind, + DialogKind, FileKind, InputAction, OverwriteConfirmDetails, PickerKind, PropertiesDetails, }; + +// File-entry data types. `BuildError` is the error half of the now fallible +// `FileEntryBuilder::build` and is re-exported so callers can name it. +pub use file_entry::{BuildError, FileCategory, FileEntry, FileEntryBuilder, FileSize}; + +// Modes & actions. pub use modes::{AppMode, CompareMode, PendingAction, TransferAction, ViewMode}; + +// Panel data types. WS-E debt: `panel::ToggleResult` is intentionally NOT +// re-exported — `toggle_selection`'s result is ignored at the only caller, so no +// external code names the type yet. Re-export it once a caller consumes it. pub use panel::{ActivePanel, ListingState, PanelListing, PanelState}; + +// Sorting data types. WS-E debt: `sorting::ParseSortError` is intentionally NOT +// re-exported — it is only produced by the in-crate `FromStr` impls and has no +// external consumer yet (config uses serde, not `FromStr`). Re-export when named +// outside `types`. pub use sorting::{Direction, ListingMode, SortField, SortMode, SortOptions}; + pub use text_input::TextInput; + +// Utility functions (not data types). Kept in a separate group from the type +// re-exports above. The free `format_permissions` wrapper was removed (WS-B); +// callers use `FileEntry::display_permissions_raw` directly. +pub use file_entry::{compute_category, format_size, format_time}; + +// cfg(test) asymmetry (intentional, do not collapse): `sanitize_for_display` is +// only needed by test helpers, so it is gated to test builds; `sanitize_name` is +// used in production file-entry construction and must always be available. +#[cfg(test)] +pub(crate) use file_entry::sanitize_for_display; +pub(crate) use file_entry::sanitize_name; diff --git a/src/app/types/modes.rs b/src/app/types/modes.rs index 5e254f0..31d2029 100644 --- a/src/app/types/modes.rs +++ b/src/app/types/modes.rs @@ -31,8 +31,24 @@ pub enum ViewMode { Image, } +/// Copy/move payload shared by [`PendingAction::Copy`] and +/// [`PendingAction::Move`]. +/// +/// NOTE (overwrite unification): the `overwrite` flag is intentionally a bare +/// `bool` here and is mirrored in [`PendingAction::ExtractArchive`] / +/// [`PendingAction::CreateArchive`]. The duplication is centralized behind the +/// [`PendingAction::set_overwrite`] mutator and the [`PendingAction::overwrite`] +/// reader so call sites never poke individual variants. An `Overwrite` newtype +/// was considered but deferred: the flag is consumed as a plain `bool` by ~40 +/// `ops::` functions (copy/move/archive), so a newtype would cascade `.0` +/// conversions far outside this module. Follow-up: introduce `Overwrite` once +/// the `ops` boundary takes it natively. #[derive(Debug, Clone, PartialEq)] pub struct TransferAction { + // NOTE (NonEmpty): `sources` must be non-empty for the action to be valid, + // but is typed as `Vec` because no `NonEmpty`/`nonempty` crate is + // in the dependency tree. Follow-up: model emptiness out once a suitable + // type is available (do not add a dependency just for this). pub sources: Vec, pub dest: PathBuf, pub overwrite: bool, @@ -59,6 +75,10 @@ pub enum PendingAction { } impl CompareMode { + // NOTE: `ALL` is hand-maintained and MUST stay in sync with the variants + // above. The idiomatic fix is `#[derive(strum::EnumIter)]`, but `strum` is + // not a dependency of this crate. Follow-up: switch to `EnumIter` if/when + // `strum` is added (do not add the dependency solely for this). pub const ALL: [Self; 3] = [Self::Quick, Self::Size, Self::Thorough]; pub fn label(self) -> &'static str { @@ -71,15 +91,31 @@ impl CompareMode { } impl PendingAction { + /// Marks the action as "overwrite existing targets". + /// + /// Unifies the duplicated `overwrite` flag across every variant in one place. + /// In-place (`&mut self`) so callers can mutate the action directly inside + /// `Option` without a `take()`/re-insert dance. `Delete` has no destination + /// to overwrite, so it is a no-op. pub fn set_overwrite(&mut self) { match self { - Self::Copy(t) | Self::Move(t) => { - t.overwrite = true; - } + Self::Copy(t) | Self::Move(t) => t.overwrite = true, Self::Delete { .. } => {} Self::ExtractArchive { overwrite, .. } | Self::CreateArchive { overwrite, .. } => { *overwrite = true; } } } + + /// Unified reader for the (duplicated) `overwrite` flag. `Delete` never + /// overwrites and always reports `false`. + pub fn overwrite(&self) -> bool { + match self { + Self::Copy(t) | Self::Move(t) => t.overwrite, + Self::Delete { .. } => false, + Self::ExtractArchive { overwrite, .. } | Self::CreateArchive { overwrite, .. } => { + *overwrite + } + } + } } diff --git a/src/app/types/panel.rs b/src/app/types/panel.rs index 580b63a..79758bc 100644 --- a/src/app/types/panel.rs +++ b/src/app/types/panel.rs @@ -12,35 +12,216 @@ pub enum ListingState { NeedsFullRead, } +/// Outcome of a single-entry selection toggle. +/// +/// The cursor toggle used to be a silent no-op for the `..` parent link and for +/// an empty listing, leaving callers unable to distinguish "nothing happened" +/// from "selection flipped off". Encoding the result as a type makes those +/// states explicit (type-driven: invalid/ignored cases are not silently +/// swallowed). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToggleResult { + /// Selection flipped; payload is the entry's new `selected` state. + Toggled(bool), + /// The cursor entry is the `..` parent link; selection is not allowed. + SkippedParent, + /// No entry exists under the cursor (empty or out-of-range view). + NoEntry, +} + +/// A directory listing backed by a single owning store. +/// +/// `unfiltered_entries` is the sole owner of every [`FileEntry`] (and the sole +/// home of per-entry selection state). `entries` is the *filtered view*: a list +/// of indices into `unfiltered_entries`, in display order. Storing the view as +/// indices (rather than a second `Vec`) removes the dual-store +/// duplication whose selection had to be hand-synced on every toggle/rebuild — +/// the historic source of selection-desync bugs. +/// +/// `path_index` maps each entry path to its slot in `unfiltered_entries` and is +/// kept consistent by every mutator below; it is the canonical path lookup used +/// by the watcher upsert/remove fast paths. +/// +/// Invariant: indices in `entries` either point at a live slot in +/// `unfiltered_entries` or are transiently stale after an in-place +/// `upsert`/`remove` — in which case the owning panel is marked dirty and the +/// view is rebuilt before it is read again. Reads defensively skip dead indices, +/// so a stale index can never panic. #[derive(Debug, Clone, PartialEq)] pub struct PanelListing { - pub entries: Vec, - pub unfiltered_entries: Vec, - pub path_index: HashMap, + unfiltered_entries: Vec, + entries: Vec, + path_index: HashMap, state: ListingState, } impl PanelListing { pub fn new() -> Self { Self { - entries: Vec::new(), unfiltered_entries: Vec::new(), + entries: Vec::new(), path_index: HashMap::new(), state: ListingState::NeedsFullRead, } } + /// Replace the backing store. Rebuilds `path_index` and invalidates the + /// filtered view (rebuild it afterwards via + /// [`set_filtered`](Self::set_filtered) or + /// [`set_filtered_all`](Self::set_filtered_all)). pub fn set_unfiltered(&mut self, entries: Vec) { self.path_index.clear(); + self.path_index.reserve(entries.len()); for (i, entry) in entries.iter().enumerate() { + // Owned-key clone is required: the HashMap key must outlive `entries`, + // which is moved into the backing store on the next line. This is the + // index's own cost; the *filtered view* no longer pays a clone since it + // stores indices, not entry copies. self.path_index.insert(entry.path.clone(), i); } self.unfiltered_entries = entries; + self.entries.clear(); self.state = ListingState::Clean; } - pub fn set_entries(&mut self, entries: Vec) { - self.entries = entries; + /// Rebuild the filtered view from an ordered slice of entries, mapping each + /// back to its slot in the backing store by path. Entries whose path is not + /// in the store are skipped. + /// + /// Selection is intentionally NOT copied from `ordered` (which may be a stale + /// clone): it lives solely in `unfiltered_entries`, so the filtered view can + /// never carry a divergent selection. + pub fn set_filtered(&mut self, ordered: &[FileEntry]) { + self.ensure_index(); + self.entries.clear(); + self.entries.reserve(ordered.len()); + for e in ordered { + if let Some(&idx) = self.path_index.get(&e.path) { + self.entries.push(idx); + } + } + } + + /// Set the filtered view to the full backing store, in storage order + /// (the no-filter case). + pub fn set_filtered_all(&mut self) { + self.entries.clear(); + self.entries.extend(0..self.unfiltered_entries.len()); + } + + /// Number of entries in the filtered (visible) view. + pub fn filtered_len(&self) -> usize { + self.entries.len() + } + + /// Whether the filtered (visible) view is empty. + pub fn filtered_is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Iterate the filtered (visible) entries in display order. Dead indices + /// (transiently stale after an in-place mutation) are skipped. + pub fn filtered(&self) -> impl Iterator { + self.entries + .iter() + .filter_map(|&i| self.unfiltered_entries.get(i)) + } + + /// Entry at filtered position `i`, if any. + pub fn filtered_get(&self, i: usize) -> Option<&FileEntry> { + self.entries + .get(i) + .and_then(|&idx| self.unfiltered_entries.get(idx)) + } + + /// The backing store (every entry, regardless of filter), as a slice. + pub fn unfiltered(&self) -> &[FileEntry] { + &self.unfiltered_entries + } + + /// Mutable access to the backing store for in-place edits (e.g. restoring + /// selection by path). Cannot resize — use [`upsert`](Self::upsert) or + /// [`remove`](Self::remove) for that. + pub fn unfiltered_mut(&mut self) -> &mut [FileEntry] { + &mut self.unfiltered_entries + } + + /// Look up a backing entry by path via `path_index`. + pub fn entry_by_path(&self, path: &Path) -> Option<&FileEntry> { + self.path_index + .get(path) + .and_then(|&i| self.unfiltered_entries.get(i)) + } + + /// Backing-store index for `path`, if present. + pub fn index_of(&self, path: &Path) -> Option { + self.path_index.get(path).copied() + } + + /// Whether `path` is present in the backing store. + pub fn contains_path(&self, path: &Path) -> bool { + self.path_index.contains_key(path) + } + + /// Rebuild `path_index` only if it is currently empty (lazy refresh used by + /// the watcher fast paths before a lookup). + pub fn ensure_index(&mut self) { + if self.path_index.is_empty() { + self.rebuild_index(); + } + } + + /// Unconditionally rebuild `path_index` from the backing store. + pub fn rebuild_index(&mut self) { + self.path_index.clear(); + self.path_index.reserve(self.unfiltered_entries.len()); + for (i, entry) in self.unfiltered_entries.iter().enumerate() { + self.path_index.insert(entry.path.clone(), i); + } + } + + /// Insert `entry`, or replace the existing entry with the same path + /// (preserving its selection). Keeps `path_index` consistent. + /// + /// Note: a replace edits in place (filtered view unaffected), but the caller + /// should mark the panel dirty so the view is rebuilt — a newly inserted + /// entry is not yet part of the filtered view. + pub fn upsert(&mut self, mut entry: FileEntry) { + self.ensure_index(); + if let Some(&idx) = self.path_index.get(&entry.path) { + if let Some(existing) = self.unfiltered_entries.get_mut(idx) { + entry.selected = existing.selected; + *existing = entry; + } + } else { + let new_idx = self.unfiltered_entries.len(); + self.path_index.insert(entry.path.clone(), new_idx); + self.unfiltered_entries.push(entry); + } + } + + /// Remove the entry at `path`, returning whether it existed. Uses + /// `swap_remove` and repairs `path_index` for the moved tail entry. + /// + /// `swap_remove` can leave indices in the filtered view stale; the caller + /// must mark the panel dirty so the view is rebuilt before the next read. + pub fn remove(&mut self, path: &Path) -> bool { + if self.unfiltered_entries.is_empty() { + return false; + } + self.ensure_index(); + let Some(idx) = self.path_index.remove(path) else { + return false; + }; + let last = self.unfiltered_entries.len() - 1; + if idx < last { + let last_path = self.unfiltered_entries[last].path.clone(); + self.unfiltered_entries.swap_remove(idx); + self.path_index.insert(last_path, idx); + } else { + self.unfiltered_entries.pop(); + } + true } pub fn state(&self) -> ListingState { @@ -94,10 +275,16 @@ impl Default for PanelListing { } } -// NOTE: ~30 pub getters/setters below. By design this struct exposes -// individual field access instead of a single `set_fields()` mega-method -// so input handlers can update only what changed without re-allocating -// the rest. If you add a field, add its getter+setter pair. +// NOTE: ~30 pub getters/setters below. By design this struct exposes individual +// field access instead of a single `set_fields()` mega-method so input handlers +// can update only what changed without re-allocating the rest. Invariant-bearing +// fields are NOT plain beans: their setters enforce the invariant (`set_path` +// re-canonicalizes, `push_history` caps at `MAX_HISTORY`) and selection is +// single-sourced in `listing` (see `PanelListing`). `cursor`/`scroll_offset` +// stay public because their only invariant — staying within the view and +// on-screen — depends on the viewport height, which a field setter does not +// have; callers enforce it via `ensure_cursor_visible(height)`. If you add a +// field, add its getter+setter pair (and any invariant it carries). #[derive(Debug, Clone, PartialEq)] pub struct PanelState { pub(crate) path: PathBuf, @@ -262,103 +449,65 @@ impl PanelState { } pub fn current_entry(&self) -> Option<&FileEntry> { - self.listing.entries.get(self.cursor) - } - - pub fn toggle_selection(&mut self) { - if let Some(entry) = self.listing.entries.get_mut(self.cursor) { - if entry.name == ".." { - return; - } - entry.selected = !entry.selected; - let size = entry.size(); - let selected = entry.selected; - let path = entry.path.clone(); - self.update_selection_stats(size, selected); - self.set_unfiltered_selection(&path, selected); + self.listing.filtered_get(self.cursor) + } + + /// Toggle selection of the entry under the cursor. + /// + /// Selection is mutated directly on the single backing store via the + /// filtered index, so there is no path lookup and no second store to sync. + pub fn toggle_selection(&mut self) -> ToggleResult { + let Some(&idx) = self.listing.entries.get(self.cursor) else { + return ToggleResult::NoEntry; + }; + // Defensive `get_mut` (rather than indexing): the filtered view may be + // transiently stale after an in-place mutation, matching the rest of the + // read API which skips dead indices instead of panicking. + let Some(entry) = self.listing.unfiltered_entries.get_mut(idx) else { + return ToggleResult::NoEntry; + }; + if entry.name == ".." { + return ToggleResult::SkippedParent; } + entry.selected = !entry.selected; + let size = entry.size(); + let selected = entry.selected; + self.update_selection_stats(size, selected); + ToggleResult::Toggled(selected) } pub fn set_selection_at(&mut self, index: usize, selected: bool) { - if let Some(entry) = self.listing.entries.get_mut(index) { - if entry.name == ".." || entry.selected == selected { - return; - } - entry.selected = selected; - let size = entry.size(); - let path = entry.path.clone(); - self.update_selection_stats(size, selected); - self.set_unfiltered_selection(&path, selected); + let Some(&idx) = self.listing.entries.get(index) else { + return; + }; + let Some(entry) = self.listing.unfiltered_entries.get_mut(idx) else { + return; + }; + if entry.name == ".." || entry.selected == selected { + return; } + entry.selected = selected; + let size = entry.size(); + self.update_selection_stats(size, selected); } pub fn toggle_selection_at(&mut self, index: usize) { - let selected = self.listing.entries.get(index).is_some_and(|e| !e.selected); - self.set_selection_at(index, selected); - } - - fn set_unfiltered_selection(&mut self, path: &Path, selected: bool) { - if let Some(&idx) = self.listing.path_index.get(path) { - if let Some(ue) = self.listing.unfiltered_entries.get_mut(idx) { - ue.selected = selected; - } - } else { - // O(n) fallback — path_index miss, scanning unfiltered_entries - let found = self - .listing - .unfiltered_entries - .iter_mut() - .find(|e| e.path == *path); - if let Some(ue) = found { - ue.selected = selected; - } - // path_index should always cover unfiltered_entries; a miss - // means index is stale (listing changed without rebuilding it) - } - } - - pub fn sync_unfiltered_selection(&mut self) { - if self.listing.unfiltered_entries.is_empty() { - return; - } - - let index: HashMap = self + let selected = self .listing - .unfiltered_entries - .iter() - .enumerate() - .map(|(i, e)| (e.path.clone(), i)) - .collect(); - - for entry in &self.listing.entries { - if let Some(&idx) = index.get(&entry.path) - && let Some(ue) = self.listing.unfiltered_entries.get_mut(idx) - { - ue.selected = entry.selected; - } - } - } - - fn source_entries(&self) -> &[FileEntry] { - if self.listing.unfiltered_entries.is_empty() { - &self.listing.entries - } else { - &self.listing.unfiltered_entries - } + .filtered_get(index) + .is_some_and(|e| !e.selected); + self.set_selection_at(index, selected); } - pub fn selected_entries(&self) -> Vec<&FileEntry> { - self.source_entries() - .iter() - .filter(|e| e.selected) - .collect() + /// Iterate the currently selected entries. Selection lives only in the + /// backing store, so this returns a borrowing iterator (no allocation). + pub fn selected_entries(&self) -> impl Iterator { + self.listing.unfiltered().iter().filter(|e| e.selected) } pub fn clear_selection(&mut self) { - for entry in &mut self.listing.entries { - entry.selected = false; - } - for entry in &mut self.listing.unfiltered_entries { + // Single store: clearing the backing entries clears the filtered view too. + for entry in self.listing.unfiltered_mut() { entry.selected = false; } self.selected_count = 0; @@ -369,7 +518,7 @@ impl PanelState { let mut selected_count: usize = 0; let mut selected_size: u64 = 0; let mut total_size: u64 = 0; - for entry in self.source_entries() { + for entry in self.listing.unfiltered() { total_size = total_size.saturating_add(entry.size()); if entry.selected { selected_count = selected_count.saturating_add(1); @@ -382,14 +531,15 @@ impl PanelState { } pub fn move_cursor_up(&mut self, max_height: usize) { - if self.listing.entries.is_empty() { + let len = self.listing.filtered_len(); + if len == 0 { return; } if self.cursor == 0 { - self.cursor = self.listing.entries.len().saturating_sub(1); + self.cursor = len.saturating_sub(1); if max_height > 0 { - self.scroll_offset = self.listing.entries.len().saturating_sub(max_height); + self.scroll_offset = len.saturating_sub(max_height); } } else { self.cursor = self.cursor.saturating_sub(1); @@ -400,11 +550,12 @@ impl PanelState { } pub fn move_cursor_down(&mut self, max_height: usize) { - if self.listing.entries.is_empty() { + let len = self.listing.filtered_len(); + if len == 0 { return; } - let max_index = self.listing.entries.len() - 1; + let max_index = len - 1; if self.cursor >= max_index { self.cursor = 0; @@ -418,7 +569,7 @@ impl PanelState { } pub fn ensure_cursor_visible(&mut self, visible_height: usize) { - let max_scroll = self.listing.entries.len().saturating_sub(1); + let max_scroll = self.listing.filtered_len().saturating_sub(1); if self.scroll_offset > max_scroll { self.scroll_offset = max_scroll; } @@ -430,10 +581,12 @@ impl PanelState { } } + /// Replace the listing with `entries`, no filter applied (the filtered view + /// becomes the full set). No clone: the dual-store duplication that once + /// required one is gone. pub fn set_entries(&mut self, entries: Vec) { - // Clone required: unfiltered_entries holds the full set, entries holds the filtered view - self.listing.set_unfiltered(entries.clone()); - self.listing.set_entries(entries); + self.listing.set_unfiltered(entries); + self.listing.set_filtered_all(); self.cursor = 0; self.scroll_offset = 0; self.recalculate_selection_stats(); diff --git a/src/app/types/sorting.rs b/src/app/types/sorting.rs index 221fa08..02c5ae0 100644 --- a/src/app/types/sorting.rs +++ b/src/app/types/sorting.rs @@ -1,15 +1,46 @@ +use std::fmt; +use std::str::FromStr; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; fn default_true() -> bool { true } +/// Error returned when a [`SortField`], [`Direction`], or [`SortMode`] cannot be +/// parsed from its string form. Carries the offending input so callers (and the +/// serde `Deserialize` impls) can surface a precise message. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ParseSortError { + /// String did not match any known sort field (canonical name or alias). + Field(String), + /// String did not match any known direction. + Direction(String), + /// String was not a well-formed `field_direction` sort-mode token. + Mode(String), +} + +impl fmt::Display for ParseSortError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Field(s) => write!(f, "unknown sort field: {s}"), + Self::Direction(s) => write!(f, "unknown direction: {s}"), + Self::Mode(s) => write!(f, "invalid sort mode: {s}"), + } + } +} + +impl std::error::Error for ParseSortError {} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub struct SortOptions { #[serde(default = "default_true")] pub dir_first: bool, - // Backward compat: old configs used "sort_sensitive" before rename to "sensitive" + // Canonical key for the case-sensitivity flag is "sensitive"; "sort_sensitive" + // is the legacy alias kept for old on-disk configs. `config.rs`'s parallel + // `PersistedSetup` field MUST match this (canonical "sensitive", alias + // "sort_sensitive") so a config written by either type is read by both. #[serde(default, alias = "sort_sensitive")] pub sensitive: bool, } @@ -46,16 +77,24 @@ impl SortField { Self::Btime => "btime", } } +} - fn from_str(s: &str) -> Option { +impl FromStr for SortField { + type Err = ParseSortError; + + /// Parses a sort field from its canonical name (the value produced by + /// [`SortField::as_str`]). Two legacy aliases are accepted for + /// backward-compatible configs: `"mod"` for `mod_time` and `"natural"` for + /// `natural_name`. + fn from_str(s: &str) -> Result { match s { - "name" => Some(Self::Name), - "extension" => Some(Self::Extension), - "size" => Some(Self::Size), - "mod_time" | "mod" => Some(Self::ModTime), - "natural_name" | "natural" => Some(Self::NaturalName), - "btime" => Some(Self::Btime), - _ => None, + "name" => Ok(Self::Name), + "extension" => Ok(Self::Extension), + "size" => Ok(Self::Size), + "mod_time" | "mod" => Ok(Self::ModTime), + "natural_name" | "natural" => Ok(Self::NaturalName), + "btime" => Ok(Self::Btime), + other => Err(ParseSortError::Field(other.to_owned())), } } } @@ -87,12 +126,16 @@ impl Direction { Self::Desc => "desc", } } +} + +impl FromStr for Direction { + type Err = ParseSortError; - fn from_str(s: &str) -> Option { + fn from_str(s: &str) -> Result { match s { - "asc" => Some(Self::Asc), - "desc" => Some(Self::Desc), - _ => None, + "asc" => Ok(Self::Asc), + "desc" => Ok(Self::Desc), + other => Err(ParseSortError::Direction(other.to_owned())), } } } @@ -128,24 +171,43 @@ impl SortMode { } } +impl fmt::Display for SortMode { + /// Encodes a sort mode as a single `field_direction` token, e.g. `name_asc`. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}_{}", self.field.as_str(), self.direction.as_str()) + } +} + +impl FromStr for SortMode { + type Err = ParseSortError; + + /// Parses the `field_direction` token produced by [`SortMode`]'s `Display`. + /// The direction is the final `_`-delimited segment, so multi-word field + /// names such as `mod_time` and `natural_name` round-trip correctly. + fn from_str(s: &str) -> Result { + let (field_str, dir_str) = s + .rsplit_once('_') + .ok_or_else(|| ParseSortError::Mode(s.to_owned()))?; + Ok(Self { + field: field_str.parse()?, + direction: dir_str.parse()?, + }) + } +} + +// Persisted as the single `field_direction` token rather than a nested table so +// configs stay terse and human-editable; both directions reuse the FromStr / +// Display logic above as the single source of truth. impl Serialize for SortMode { fn serialize(&self, serializer: S) -> Result { - let s = format!("{}_{}", self.field.as_str(), self.direction.as_str()); - serializer.serialize_str(&s) + serializer.collect_str(self) } } impl<'de> Deserialize<'de> for SortMode { fn deserialize>(deserializer: D) -> Result { let s = String::deserialize(deserializer)?; - let (field_str, dir_str) = s - .rsplit_once('_') - .ok_or_else(|| serde::de::Error::custom(format!("invalid sort mode: {s}")))?; - let field = SortField::from_str(field_str) - .ok_or_else(|| serde::de::Error::custom(format!("unknown sort field: {field_str}")))?; - let direction = Direction::from_str(dir_str) - .ok_or_else(|| serde::de::Error::custom(format!("unknown direction: {dir_str}")))?; - Ok(SortMode { field, direction }) + s.parse().map_err(serde::de::Error::custom) } } @@ -156,3 +218,128 @@ pub enum ListingMode { Long, Brief, } + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use super::*; + + #[test] + fn sort_field_canonical_round_trips() { + // Every variant must parse back from the exact value as_str emits. + for field in [ + SortField::Name, + SortField::Extension, + SortField::Size, + SortField::ModTime, + SortField::NaturalName, + SortField::Btime, + ] { + assert_eq!(field.as_str().parse::(), Ok(field)); + } + } + + #[test] + fn sort_field_accepts_legacy_aliases() { + // Backward-compat: short aliases from old configs must still parse. + assert_eq!("mod".parse::(), Ok(SortField::ModTime)); + assert_eq!("natural".parse::(), Ok(SortField::NaturalName)); + // Canonical names keep working alongside the aliases. + assert_eq!("mod_time".parse::(), Ok(SortField::ModTime)); + assert_eq!( + "natural_name".parse::(), + Ok(SortField::NaturalName) + ); + } + + #[test] + fn sort_field_rejects_unknown() { + assert_eq!( + "bogus".parse::(), + Err(ParseSortError::Field("bogus".to_owned())) + ); + } + + #[test] + fn direction_round_trips() { + assert_eq!("asc".parse::(), Ok(Direction::Asc)); + assert_eq!("desc".parse::(), Ok(Direction::Desc)); + assert_eq!( + "up".parse::(), + Err(ParseSortError::Direction("up".to_owned())) + ); + } + + #[test] + fn sort_mode_from_str_handles_multiword_and_alias_fields() { + // rsplit_once keeps multi-word canonical field names intact. + assert_eq!( + "mod_time_asc".parse::(), + Ok(SortMode::new(SortField::ModTime, Direction::Asc)) + ); + assert_eq!( + "natural_name_desc".parse::(), + Ok(SortMode::new(SortField::NaturalName, Direction::Desc)) + ); + // Alias field embedded in a sort-mode token still resolves. + assert_eq!( + "mod_desc".parse::(), + Ok(SortMode::new(SortField::ModTime, Direction::Desc)) + ); + } + + #[test] + fn sort_mode_from_str_rejects_malformed() { + assert_eq!( + "name".parse::(), + Err(ParseSortError::Mode("name".to_owned())) + ); + } + + #[test] + fn sort_mode_serde_round_trip() { + // Exercises the custom Serialize -> "name_asc" -> custom Deserialize path + // through a real serde data format (toml), validating the canonical token. + #[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] + struct Holder { + mode: SortMode, + } + + let original = Holder { + mode: SortMode::new(SortField::Name, Direction::Asc), + }; + let text = toml::to_string(&original).expect("serialize sort mode"); + assert!(text.contains("\"name_asc\""), "unexpected toml: {text}"); + + let restored: Holder = toml::from_str(&text).expect("deserialize sort mode"); + assert_eq!(restored, original); + } + + #[test] + fn sort_mode_serde_round_trip_all_variants() { + // Canonical-name fields (mod_time, natural_name) must survive a serde + // round-trip without being mangled by the direction split. + #[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] + struct Holder { + mode: SortMode, + } + + for field in [ + SortField::Name, + SortField::Extension, + SortField::Size, + SortField::ModTime, + SortField::NaturalName, + SortField::Btime, + ] { + for direction in [Direction::Asc, Direction::Desc] { + let original = Holder { + mode: SortMode::new(field, direction), + }; + let text = toml::to_string(&original).expect("serialize"); + let restored: Holder = toml::from_str(&text).expect("deserialize"); + assert_eq!(restored, original); + } + } + } +} diff --git a/src/app/types/test_helpers.rs b/src/app/types/test_helpers.rs index a5f14f2..932fb85 100644 --- a/src/app/types/test_helpers.rs +++ b/src/app/types/test_helpers.rs @@ -22,6 +22,11 @@ pub struct TestEntry { raw_mode: Option, } +// `#[allow(dead_code)]` is hoisted to the impl block (collapsed from 11 +// per-method attributes): this shared test builder exposes setters that are used +// à la carte across the whole suite, so any given setter can be legitimately +// unused in a single compilation unit. +#[allow(dead_code)] impl TestEntry { pub fn new(name: impl Into) -> Self { let name = name.into(); @@ -47,72 +52,54 @@ impl TestEntry { self } + /// Marks the entry as a regular file of `size` bytes. This is the canonical + /// size setter; the former identical `.size()` alias and the no-op `.dir()` + /// (the default kind is already `Directory`) were removed as redundant. pub fn file(mut self, size: u64) -> Self { self.kind = EntryKind::File(size); self } - #[allow(dead_code)] - pub fn dir(mut self) -> Self { - self.kind = EntryKind::Directory; - self - } - - #[allow(dead_code)] - pub fn size(mut self, size: u64) -> Self { - self.kind = EntryKind::File(size); - self - } - - #[allow(dead_code)] pub fn selected(mut self) -> Self { self.selected = true; self } - #[allow(dead_code)] pub fn symlink(mut self) -> Self { self.symlink = true; self } - #[allow(dead_code)] pub fn permissions(mut self, perms: u32) -> Self { self.permissions = Some(perms); self } - #[allow(dead_code)] pub fn hidden(mut self) -> Self { self.hidden = Some(true); self } - #[allow(dead_code)] pub fn modified(mut self, t: SystemTime) -> Self { self.modified = Some(t); self } - #[allow(dead_code)] pub fn created(mut self, t: SystemTime) -> Self { self.created = Some(t); self } - #[allow(dead_code)] pub fn owner(mut self, o: impl Into) -> Self { self.owner = Some(o.into()); self } - #[allow(dead_code)] pub fn group(mut self, g: impl Into) -> Self { self.group = Some(g.into()); self } - #[allow(dead_code)] pub fn raw_mode(mut self, mode: u32) -> Self { self.raw_mode = Some(mode); self @@ -143,6 +130,12 @@ impl TestEntry { builder = builder.group(group); } + // File type + permission bits. + // + // Precedence: an explicit `raw_mode` is the authoritative source for the + // type (dir/symlink/regular) and the permission bits, since it mirrors a + // real `stat()` `st_mode`. Without it, the type comes from `kind` (file vs + // directory) and permissions from an explicit `.permissions(..)`. if let Some(mode) = self.raw_mode { let is_link = (mode & 0o170000) == 0o120000; let is_directory = (mode & 0o170000) == 0o040000; @@ -153,8 +146,8 @@ impl TestEntry { .permissions(perms); } else { match self.kind { - EntryKind::File(size) => { - builder = builder.is_dir(false).size(size); + EntryKind::File(_) => { + builder = builder.is_dir(false); } EntryKind::Directory => { builder = builder.is_dir(true); @@ -165,6 +158,19 @@ impl TestEntry { } } + // Byte size composes independently of the type source: `raw_mode` carries + // no length, so `.file(size)` is always honored, even alongside `raw_mode` + // (WS-C fix: previously `.raw_mode(..).file(N)` silently dropped the size). + // A `Directory` kind keeps size 0 (a listing's directory size is + // meaningless here). + if let EntryKind::File(size) = self.kind { + builder = builder.size(size); + } + + // Symlink precedence: an explicit `.symlink()` is a hard override that + // upgrades the entry to a symlink even when `raw_mode` encoded a + // non-symlink type. It only ever upgrades — it never downgrades a symlink + // that `raw_mode` already established. if self.symlink { builder = builder.is_symlink(true); } @@ -172,6 +178,9 @@ impl TestEntry { let hidden = self.hidden.unwrap_or_else(|| self.name.starts_with('.')); builder = builder.is_hidden(hidden).selected(self.selected); - builder.build() + // `TestEntry::build` keeps returning `FileEntry` (not `Result`): test code + // always supplies a non-empty name, so the only `BuildError` is unreachable + // here and is surfaced as a panic with a clear message. + builder.build().expect("valid test entry") } } diff --git a/src/app/types/tests.rs b/src/app/types/tests.rs index a68f29d..bb66ff8 100644 --- a/src/app/types/tests.rs +++ b/src/app/types/tests.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -use super::dialogs::{ConfirmDetails, CopyMoveDetails, DialogKind, InputAction, PickerKind}; +use super::dialogs::{ConfirmDetails, CopyMoveDetails, CopyMoveKind, DialogKind, InputAction}; use super::file_entry::{FileCategory, FileEntry}; use super::modes::AppMode; use super::panel::{ActivePanel, PanelState}; @@ -19,18 +19,26 @@ fn entry(name: &str) -> TestEntry { TestEntry::new(name).path(test_path(name)) } -fn panel_with_n_entries(n: u32) -> PanelState { +/// A panel at `/test` whose full (unfiltered) listing is `entries`. Centralizes +/// the `new()` + `set_entries()` setup repeated across the suite (the filtered +/// view becomes the full set, and selection stats are recalculated). +fn panel_from(entries: Vec) -> PanelState { let mut panel = PanelState::new(PathBuf::from("/test")); - for i in 0..n { - panel.listing.entries.push( - TestEntry::new(format!("file{}.txt", i)) - .path(test_path(format!("file{}.txt", i))) + panel.set_entries(entries); + panel +} + +fn panel_with_n_entries(n: u32) -> PanelState { + let entries = (0..n) + .map(|i| { + TestEntry::new(format!("file{i}.txt")) + .path(test_path(format!("file{i}.txt"))) .file(100) .permissions(0o644) - .build(), - ); - } - panel + .build() + }) + .collect(); + panel_from(entries) } fn panel_with_cursor(n: u32, cursor: usize, scroll_offset: usize) -> PanelState { @@ -72,7 +80,6 @@ fn test_file_entry_display_permissions() { } #[test] -#[allow(clippy::expect_used)] fn test_file_entry_display_modified() { let entry = entry("test.txt").file(100).permissions(0o644).build(); let expected = chrono::DateTime::from_timestamp(1_000_000_000, 0) @@ -96,7 +103,7 @@ fn test_panel_state_new() { let path = PathBuf::from("/test"); let panel = PanelState::new(path.clone()); assert_eq!(panel.path(), path); - assert_eq!(panel.listing.entries.len(), 0); + assert_eq!(panel.listing.filtered_len(), 0); assert_eq!(panel.cursor, 0); assert_eq!(panel.scroll_offset, 0); assert_eq!(panel.sort_mode(), SortMode::default()); @@ -112,13 +119,10 @@ fn test_panel_state_current_entry_none_when_empty() { } #[test] -#[allow(clippy::expect_used)] fn test_panel_state_current_entry_some() { - let mut panel = PanelState::new(PathBuf::from("/test")); - panel - .listing - .entries - .push(entry("file1.txt").file(100).permissions(0o644).build()); + let mut panel = panel_from(vec![ + entry("file1.txt").file(100).permissions(0o644).build(), + ]); panel.cursor = 0; assert_eq!( panel.current_entry().expect("current entry exists").name, @@ -128,136 +132,124 @@ fn test_panel_state_current_entry_some() { #[test] fn test_panel_state_current_entry_out_of_bounds() { - let mut panel = PanelState::new(PathBuf::from("/test")); - panel - .listing - .entries - .push(entry("file1.txt").file(100).permissions(0o644).build()); + let mut panel = panel_from(vec![ + entry("file1.txt").file(100).permissions(0o644).build(), + ]); panel.cursor = 5; - assert!(panel.current_entry().is_none()); + assert!( + panel.current_entry().is_none(), + "out-of-range cursor yields no current entry" + ); } #[test] fn test_panel_state_toggle_selection_toggle_on() { - let mut panel = PanelState::new(PathBuf::from("/test")); - panel - .listing - .entries - .push(entry("file1.txt").file(100).permissions(0o644).build()); + let mut panel = panel_from(vec![ + entry("file1.txt").file(100).permissions(0o644).build(), + ]); panel.cursor = 0; panel.toggle_selection(); - assert!(panel.listing.entries[0].selected); - assert_eq!(panel.selected_count(), 1); - assert_eq!(panel.selected_size(), 100); + assert!( + panel.current_entry().expect("entry under cursor").selected, + "toggle selects the cursor entry" + ); + assert_eq!( + panel.selected_count(), + 1, + "one entry selected after toggle on" + ); + assert_eq!( + panel.selected_size(), + 100, + "selected size equals the toggled entry's size" + ); } #[test] fn test_panel_state_toggle_selection_toggle_off() { - let mut panel = PanelState::new(PathBuf::from("/test")); - panel.listing.entries.push( + let mut panel = panel_from(vec![ entry("file1.txt") .file(100) .permissions(0o644) .selected() .build(), - ); + ]); panel.cursor = 0; - assert!(panel.listing.entries[0].selected); + assert!( + panel.current_entry().expect("entry under cursor").selected, + "entry starts selected" + ); panel.toggle_selection(); - assert!(!panel.listing.entries[0].selected); - assert_eq!(panel.selected_count(), 0); - assert_eq!(panel.selected_size(), 0); + assert!( + !panel.current_entry().expect("entry under cursor").selected, + "toggle deselects the cursor entry" + ); + assert_eq!( + panel.selected_count(), + 0, + "no entries selected after toggle off" + ); + assert_eq!( + panel.selected_size(), + 0, + "selected size cleared after toggle off" + ); } #[test] fn test_panel_state_set_selection_at_on() { - let mut panel = PanelState::new(PathBuf::from("/test")); - panel - .listing - .entries - .push(entry("file1.txt").file(100).permissions(0o644).build()); + let mut panel = panel_from(vec![ + entry("file1.txt").file(100).permissions(0o644).build(), + ]); panel.set_selection_at(0, true); - assert!(panel.listing.entries[0].selected); + assert!( + panel.current_entry().expect("entry under cursor").selected, + "set_selection_at(true) selects the entry" + ); assert_eq!(panel.selected_count(), 1); assert_eq!(panel.selected_size(), 100); } #[test] fn test_panel_state_set_selection_at_off() { - let mut panel = PanelState::new(PathBuf::from("/test")); - panel.listing.entries.push( + let mut panel = panel_from(vec![ entry("file1.txt") .file(100) .permissions(0o644) .selected() .build(), - ); + ]); panel.set_selection_at(0, false); - assert!(!panel.listing.entries[0].selected); + assert!( + !panel.current_entry().expect("entry under cursor").selected, + "set_selection_at(false) deselects the entry" + ); assert_eq!(panel.selected_count(), 0); assert_eq!(panel.selected_size(), 0); } -#[test] -fn test_panel_state_sync_unfiltered_selection() { - let mut panel = PanelState::new(PathBuf::from("/test")); - panel.listing.entries = vec![ - entry("file1.txt") - .file(100) - .permissions(0o644) - .selected() - .build(), - entry("file2.txt").file(200).permissions(0o644).build(), - ]; - panel.listing.unfiltered_entries = vec![ - entry("file1.txt").file(100).permissions(0o644).build(), - entry("file2.txt") - .file(200) - .permissions(0o644) - .selected() - .build(), - entry("file3.txt") - .file(300) - .permissions(0o644) - .selected() - .build(), - ]; - - panel.sync_unfiltered_selection(); - - assert!(panel.listing.unfiltered_entries[0].selected); - assert!(!panel.listing.unfiltered_entries[1].selected); - assert!(panel.listing.unfiltered_entries[2].selected); -} - #[test] fn test_panel_state_selected_entries() { - let mut panel = PanelState::new(PathBuf::from("/test")); - panel.listing.entries.push( + let panel = panel_from(vec![ entry("file1.txt") .file(100) .permissions(0o644) .selected() .build(), - ); - panel - .listing - .entries - .push(entry("file2.txt").file(200).permissions(0o644).build()); - panel.listing.entries.push( + entry("file2.txt").file(200).permissions(0o644).build(), entry("file3.txt") .file(300) .permissions(0o644) .selected() .build(), - ); + ]); - let selected = panel.selected_entries(); - assert_eq!(selected.len(), 2); + let selected: Vec<_> = panel.selected_entries().collect(); + assert_eq!(selected.len(), 2, "two entries are selected"); assert_eq!(selected[0].name, "file1.txt"); assert_eq!(selected[1].name, "file3.txt"); } @@ -271,11 +263,9 @@ fn test_panel_state_move_cursor_up() { #[test] fn test_panel_state_move_cursor_up_at_top() { - let mut panel = PanelState::new(PathBuf::from("/test")); - panel - .listing - .entries - .push(entry("file1.txt").file(100).permissions(0o644).build()); + let mut panel = panel_from(vec![ + entry("file1.txt").file(100).permissions(0o644).build(), + ]); panel.cursor = 0; panel.move_cursor_up(10); assert_eq!(panel.cursor, 0); @@ -316,8 +306,8 @@ fn test_app_state_new_sets_field_defaults() { let state = AppState::new(); assert_eq!(state.active_panel, ActivePanel::Left); assert_eq!(state.mode, AppMode::Normal); - assert!(!state.should_quit); - assert!(state.status_message.is_none()); + assert!(!state.should_quit()); + assert!(state.ui.status_message.is_none()); } #[test] @@ -326,15 +316,15 @@ fn test_app_state_substate_defaults() { assert_eq!(state.active_panel, ActivePanel::Left); assert_eq!(state.mode, AppMode::Normal); - assert!(!state.should_quit); - assert!(state.status_message.is_none()); - assert_eq!(state.dialog_input.cursor(), 0); - assert_eq!(state.picker_selected, 0); - assert_eq!(state.menu_selected, 0); - assert_eq!(state.menu_item_selected, 0); - assert!(state.tree_entries.is_empty()); - assert!(state.user_menu_entries.is_empty()); - assert_eq!(state.directory_hotlist.len(), 1); + assert!(!state.should_quit()); + assert!(state.ui.status_message.is_none()); + assert_eq!(state.input.dialog_input.cursor(), 0); + assert_eq!(state.ui.picker_selected, 0); + assert_eq!(state.ui.menu_selected, 0); + assert_eq!(state.ui.menu_item_selected, 0); + assert!(state.tree.entries.is_empty()); + assert!(state.ui.user_menu_entries.is_empty()); + assert_eq!(state.ui.directory_hotlist.len(), 1); } #[test] @@ -434,7 +424,6 @@ fn test_confirm_details_simple() { } #[test] -#[allow(clippy::expect_used)] fn test_confirm_details_with_files() { let files = vec!["/tmp/a.txt".to_string(), "/tmp/b.txt".to_string()]; let cd = ConfirmDetails::with_files("Delete", "Delete 2 entries?", files); @@ -495,72 +484,20 @@ fn test_dialog_kind_progress() { fn test_dialog_kind_copy_move() { let sources = vec![PathBuf::from("/source1"), PathBuf::from("/source2")]; let dest = PathBuf::from("/dest"); - let source_display: Vec = sources - .iter() - .map(|p| { - p.file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| p.display().to_string()) - }) - .collect(); let dialog = DialogKind::CopyMove(Box::new(CopyMoveDetails { source: sources.clone(), - source_display: source_display.clone(), dest: dest.clone(), - is_move: true, + kind: CopyMoveKind::Move, })); let DialogKind::CopyMove(details) = dialog else { panic!("Expected CopyMove variant"); }; assert_eq!(details.source, sources); - assert_eq!(details.source_display, source_display); + // `source_display()` is now derived on demand from `source` (file name, + // falling back to the full path) instead of a stored parallel field. + assert_eq!(details.source_display(), vec!["source1", "source2"]); assert_eq!(details.dest, dest); - assert!(details.is_move); -} - -// Smoke test: verifies PartialEq derivation is correct and all variants compile -#[test] -fn test_app_mode_variants() { - let normal = AppMode::Normal; - assert_eq!(normal, AppMode::Normal); - - let viewing = AppMode::Viewing; - assert_eq!(viewing, AppMode::Viewing); - - let cmd_line = AppMode::CommandLine; - assert_eq!(cmd_line, AppMode::CommandLine); - - let dialog = AppMode::Dialog(DialogKind::Confirm(ConfirmDetails::simple("Test", "test"))); - if let AppMode::Dialog(DialogKind::Confirm(cd)) = &dialog { - assert_eq!(cd.message, "test"); - } - - let search = AppMode::Search; - assert_eq!(search, AppMode::Search); - - let menu = AppMode::Menu; - assert_eq!(menu, AppMode::Menu); - - let picker = AppMode::ListPicker(PickerKind::History); - assert_eq!(picker, AppMode::ListPicker(PickerKind::History)); -} - -// Smoke test: verifies PartialEq derivation is correct and variants compile -#[test] -fn test_active_panel_variants() { - let left = ActivePanel::Left; - assert_eq!(left, ActivePanel::Left); - - let right = ActivePanel::Right; - assert_eq!(right, ActivePanel::Right); -} - -// Smoke test: verifies Default derivation produces the same state as new() -#[test] -fn test_app_state_default() { - let state = AppState::default(); - assert_eq!(state.active_panel, ActivePanel::Left); - assert!(!state.should_quit); + assert!(details.kind.is_move()); } #[test] @@ -625,8 +562,7 @@ fn test_panel_state_ensure_cursor_visible_edge_case() { #[test] fn test_total_size_computed_by_recalculate() { - let mut panel = PanelState::new(PathBuf::from("/test")); - panel.listing.entries = vec![ + let mut panel = panel_from(vec![ entry("a.txt").file(100).permissions(0o644).build(), entry("b.txt").file(200).permissions(0o644).build(), entry("c.txt") @@ -634,7 +570,7 @@ fn test_total_size_computed_by_recalculate() { .permissions(0o644) .selected() .build(), - ]; + ]); panel.recalculate_selection_stats(); assert_eq!(panel.total_size(), 600); assert_eq!(panel.selected_count(), 1); @@ -645,7 +581,7 @@ fn test_total_size_computed_by_recalculate() { fn test_hidden_script_is_code() { let entry = entry(".script.sh") .raw_mode(0o100755) - .size(100) + .file(100) .hidden() .build(); assert_eq!(entry.category(), FileCategory::Code); @@ -655,7 +591,7 @@ fn test_hidden_script_is_code() { fn test_hidden_archive_is_archive() { let entry = entry(".backup.zip") .raw_mode(0o100644) - .size(100) + .file(100) .hidden() .build(); assert_eq!(entry.category(), FileCategory::Archive); @@ -675,7 +611,7 @@ fn test_symlink_overrides_hidden() { #[test] fn test_executable_without_extension_is_executable() { - let entry = entry("mybinary").raw_mode(0o100755).size(100).build(); + let entry = entry("mybinary").raw_mode(0o100755).file(100).build(); assert_eq!(entry.category(), FileCategory::Executable); } @@ -683,7 +619,7 @@ fn test_executable_without_extension_is_executable() { fn test_hidden_apk_is_archive() { let entry = entry(".app.apk") .raw_mode(0o100644) - .size(100) + .file(100) .hidden() .build(); assert_eq!(entry.category(), FileCategory::Archive); @@ -691,15 +627,14 @@ fn test_hidden_apk_is_archive() { #[test] fn test_total_size_includes_all_entries() { - let mut panel = PanelState::new(PathBuf::from("/test")); - panel.listing.entries = vec![ + let mut panel = panel_from(vec![ entry("small.txt").file(50).permissions(0o644).build(), entry("big.txt") .file(5000) .permissions(0o644) .selected() .build(), - ]; + ]); panel.recalculate_selection_stats(); assert_eq!(panel.total_size(), 5050); assert_eq!(panel.selected_size(), 5000); @@ -708,15 +643,14 @@ fn test_total_size_includes_all_entries() { #[test] fn test_panel_state_empty_entries_cursor_scroll_zero() { let panel = PanelState::new(PathBuf::from("/test")); - assert_eq!(panel.listing.entries.len(), 0); + assert_eq!(panel.listing.filtered_len(), 0); assert_eq!(panel.cursor, 0); assert_eq!(panel.scroll_offset, 0); } #[test] fn test_panel_state_single_item_cursor() { - let mut panel = PanelState::new(PathBuf::from("/test")); - panel.listing.entries = vec![entry("only.txt").file(10).permissions(0o644).build()]; + let mut panel = panel_from(vec![entry("only.txt").file(10).permissions(0o644).build()]); assert_eq!(panel.cursor, 0); panel.move_cursor_down(10); @@ -727,18 +661,19 @@ fn test_panel_state_single_item_cursor() { #[test] fn test_panel_state_cursor_stays_at_last_after_entry_removal() { - let mut panel = PanelState::new(PathBuf::from("/test")); - panel.listing.entries = vec![ + let mut panel = panel_from(vec![ entry("a.txt").file(10).permissions(0o644).build(), entry("b.txt").file(10).permissions(0o644).build(), entry("c.txt").file(10).permissions(0o644).build(), - ]; - panel.cursor = 2; + ]); - panel.listing.entries.truncate(1); + // Simulate the listing shrinking to a single entry (e.g. a watcher refresh), + // leaving the cursor stale at its old index past the new end. + panel.set_entries(vec![entry("a.txt").file(10).permissions(0o644).build()]); + panel.cursor = 2; // Tests same clamping logic as restore_panel_cursor() in panel_ops.rs - let max_index = panel.listing.entries.len().saturating_sub(1); + let max_index = panel.listing.filtered_len().saturating_sub(1); panel.cursor = panel.cursor.min(max_index); assert_eq!(panel.cursor, 0); @@ -746,11 +681,10 @@ fn test_panel_state_cursor_stays_at_last_after_entry_removal() { #[test] fn test_panel_state_move_cursor_down_clamped_at_last() { - let mut panel = PanelState::new(PathBuf::from("/test")); - panel.listing.entries = vec![ + let mut panel = panel_from(vec![ entry("a.txt").file(10).permissions(0o644).build(), entry("b.txt").file(10).permissions(0o644).build(), - ]; + ]); panel.cursor = 1; panel.move_cursor_down(10); @@ -762,8 +696,7 @@ fn test_panel_state_move_cursor_down_clamped_at_last() { #[test] fn test_panel_state_move_cursor_up_clamped_at_zero() { - let mut panel = PanelState::new(PathBuf::from("/test")); - panel.listing.entries = vec![entry("a.txt").file(10).permissions(0o644).build()]; + let mut panel = panel_from(vec![entry("a.txt").file(10).permissions(0o644).build()]); panel.cursor = 0; panel.move_cursor_up(10); @@ -780,16 +713,17 @@ fn test_panel_state_current_entry_empty_returns_none() { #[test] fn test_panel_state_scroll_offset_with_many_entries() { - let mut panel = PanelState::new(PathBuf::from("/test")); - panel.listing.entries = (0..100) - .map(|i| { - TestEntry::new(format!("file{i:03}.txt")) - .path(test_path(format!("file{i:03}.txt"))) - .file(10) - .permissions(0o644) - .build() - }) - .collect(); + let mut panel = panel_from( + (0..100) + .map(|i| { + TestEntry::new(format!("file{i:03}.txt")) + .path(test_path(format!("file{i:03}.txt"))) + .file(10) + .permissions(0o644) + .build() + }) + .collect(), + ); let visible_height = 20; @@ -825,7 +759,8 @@ fn builder_clears_dir_target_follow_on_type_change() { .name("d") .path(PathBuf::from("d")) .is_dir(true) - .build(); + .build() + .expect("valid test entry"); let mut cha = dir_entry.cha; cha.kind.insert(ChaKind::DIR_TARGET | ChaKind::FOLLOW); assert!(cha.kind.contains(ChaKind::DIR_TARGET)); @@ -836,7 +771,8 @@ fn builder_clears_dir_target_follow_on_type_change() { .path(PathBuf::from("d")) .cha(cha) .is_dir(false) - .build(); + .build() + .expect("valid test entry"); assert!(!cleared.cha.kind.contains(ChaKind::DIR_TARGET)); assert!(!cleared.cha.kind.contains(ChaKind::FOLLOW)); @@ -844,7 +780,8 @@ fn builder_clears_dir_target_follow_on_type_change() { .name("l") .path(PathBuf::from("l")) .is_symlink(true) - .build(); + .build() + .expect("valid test entry"); let mut cha = link_entry.cha; cha.kind.insert(ChaKind::DIR_TARGET | ChaKind::FOLLOW); assert!(cha.kind.contains(ChaKind::DIR_TARGET)); @@ -855,18 +792,19 @@ fn builder_clears_dir_target_follow_on_type_change() { .path(PathBuf::from("l")) .cha(cha) .is_symlink(false) - .build(); + .build() + .expect("valid test entry"); assert!(!cleared.cha.kind.contains(ChaKind::DIR_TARGET)); assert!(!cleared.cha.kind.contains(ChaKind::FOLLOW)); } #[test] -#[allow(clippy::expect_used)] fn mtime_none_displays_unknown() { let no_mtime = FileEntry::builder() .name("unknown.txt") .path(PathBuf::from("unknown.txt")) - .build(); + .build() + .expect("valid test entry"); let expected_epoch = chrono::DateTime::from_timestamp(0, 0) .expect("valid timestamp") .with_timezone(&chrono::Local) @@ -886,11 +824,7 @@ fn test_move_cursor_up_wraps_to_last_entry() { #[test] fn test_move_cursor_up_wraps_with_single_entry() { - let mut panel = PanelState::new(PathBuf::from("/test")); - panel - .listing - .entries - .push(entry("file.txt").file(100).permissions(0o644).build()); + let mut panel = panel_from(vec![entry("file.txt").file(100).permissions(0o644).build()]); panel.cursor = 0; panel.move_cursor_up(3); assert_eq!(panel.cursor, 0); @@ -907,11 +841,7 @@ fn test_move_cursor_down_wraps_to_first_entry() { #[test] fn test_move_cursor_down_wraps_with_single_entry() { - let mut panel = PanelState::new(PathBuf::from("/test")); - panel - .listing - .entries - .push(entry("file.txt").file(100).permissions(0o644).build()); + let mut panel = panel_from(vec![entry("file.txt").file(100).permissions(0o644).build()]); panel.cursor = 0; panel.move_cursor_down(3); assert_eq!(panel.cursor, 0); diff --git a/src/app/types/text_input.rs b/src/app/types/text_input.rs index 1f0a7b8..3d68d05 100644 --- a/src/app/types/text_input.rs +++ b/src/app/types/text_input.rs @@ -7,19 +7,24 @@ use unicode_width::UnicodeWidthStr; /// /// * `cursor <= grapheme_count` at all times. /// * `grapheme_count` is kept in sync with `text` and is O(1) to read. -/// * `scroll_offset` reflects the display-column offset of the left edge of the -/// visible window, snapped to grapheme boundaries, whenever `visible_width > 0`. /// -// INVARIANT: `cursor` MUST be ≤ `grapheme_count`. `grapheme_count` MUST match -// `text.graphemes(true).count()`. Direct mutation of `.text`/`.cursor` BREAKS these -// without immediate call to `recompute_grapheme_count()`/`cursor_end()` (for text) -// or `clamp_cursor()` (for cursor). Prefer `set_text()` / `set_cursor()`. +/// The horizontal scroll offset is **derived** state: it is computed on demand +/// from `text`, `cursor` and `visible_width` by [`TextInput::scroll_offset`], +/// so no mutator needs to refresh it. +/// +// INVARIANT: `cursor` MUST be ≤ `grapheme_count`, and `grapheme_count` MUST +// match `text.graphemes(true).count()`. These are upheld at a SINGLE set of +// entry points — `set_text`, `set_text_at_end`, `set_cursor`, `cursor_end`, +// `cursor_start`, `take_text`, `clear` — each of which (re)computes the count +// and/or clamps the cursor. Every other mutator only moves the cursor inside the +// already-valid range, so it relies on the invariant instead of re-clamping +// defensively. Direct mutation of the private `text`/`cursor` fields bypasses +// this and MUST be followed by `recompute_grapheme_count()` + `clamp_cursor()`. #[derive(Debug, Clone, PartialEq)] pub struct TextInput { text: String, cursor: usize, grapheme_count: usize, - scroll_offset: usize, visible_width: usize, } @@ -39,7 +44,6 @@ impl TextInput { text: String::new(), cursor: 0, grapheme_count: 0, - scroll_offset: 0, visible_width: 0, } } @@ -52,15 +56,21 @@ impl TextInput { self.cursor } + /// Display-column offset of the left edge of the visible window, snapped to + /// a grapheme boundary. Derived on demand from the current text, cursor and + /// `visible_width`; returns 0 when `visible_width == 0`. pub fn scroll_offset(&self) -> usize { - self.scroll_offset + self.current_scroll_offset() } pub fn set_visible_width(&mut self, width: usize) { self.visible_width = width; - self.recompute_scroll_offset(); } + // O(n) in the cursor index, but only invoked from `current_scroll_offset`, + // which itself runs only when `scroll_offset()` is read (cursor positioning + // on a mouse click) — not on the per-keystroke edit path — so caching it + // would add invalidation complexity for no measurable win. fn cursor_display(&self) -> usize { self.text .graphemes(true) @@ -69,21 +79,30 @@ impl TextInput { .sum() } - fn recompute_scroll_offset(&mut self) { + /// Centralized derivation of the horizontal scroll offset. This is the only + /// place the scroll window is computed; mutators never refresh it eagerly. + fn current_scroll_offset(&self) -> usize { if self.visible_width == 0 { - return; + return 0; } let cursor_display = self.cursor_display(); let raw_scroll = cursor_display.saturating_sub(self.visible_width.saturating_sub(1)); if raw_scroll == 0 { - self.scroll_offset = 0; - return; + return 0; } let widths: Vec = self .text .graphemes(true) .map(UnicodeWidthStr::width) .collect(); + // The scan yields the offset *before* each grapheme, so its largest + // value is the start of the last grapheme — never the trailing end + // offset. `position` therefore returns `None` exactly when the cursor + // sits at the very end and the last grapheme is wider than + // `visible_width`. Fall back to `widths.len()` so the offset becomes the + // full text width, scrolling past the wide grapheme and keeping the + // end-of-line cursor visible (rather than the start of that grapheme, + // which would push the cursor off the right edge). let start_idx = widths .iter() .scan(0usize, |cum, &w| { @@ -92,28 +111,25 @@ impl TextInput { Some(c) }) .position(|cum| cum >= raw_scroll) - .unwrap_or(0); - self.scroll_offset = widths[..start_idx].iter().sum(); + .unwrap_or(widths.len()); + widths[..start_idx].iter().sum() } pub fn set_text(&mut self, text: String) { self.text = text; self.recompute_grapheme_count(); self.clamp_cursor(); - self.recompute_scroll_offset(); } pub fn set_text_at_end(&mut self, text: String) { self.text = text; self.recompute_grapheme_count(); self.cursor = self.grapheme_count; - self.recompute_scroll_offset(); } pub fn set_cursor(&mut self, cursor: usize) { self.cursor = cursor; self.clamp_cursor(); - self.recompute_scroll_offset(); } pub fn recompute_grapheme_count(&mut self) { @@ -127,7 +143,6 @@ impl TextInput { pub fn take_text(&mut self) -> String { self.cursor = 0; self.grapheme_count = 0; - self.scroll_offset = 0; std::mem::take(&mut self.text) } @@ -135,7 +150,6 @@ impl TextInput { self.text.clear(); self.cursor = 0; self.grapheme_count = 0; - self.scroll_offset = 0; } pub fn grapheme_count(&self) -> usize { @@ -164,21 +178,23 @@ impl TextInput { } pub fn insert_char(&mut self, c: char) { - self.clamp_cursor(); let pos = self.byte_pos(); self.text.insert(pos, c); if c.is_ascii() { self.cursor += 1; self.grapheme_count += 1; } else { + // A non-ASCII char may extend an existing grapheme cluster (e.g. a + // combining mark merges with the previous grapheme), so the new + // cursor index cannot be derived by a simple `+1`. Recompute both + // counts via segmentation. Kept O(n) deliberately: an incremental + // O(1) update would be incorrect under grapheme-cluster merging. self.cursor = self.text[..pos + c.len_utf8()].graphemes(true).count(); self.recompute_grapheme_count(); } - self.recompute_scroll_offset(); } pub fn backspace(&mut self) -> bool { - self.clamp_cursor(); if self.cursor == 0 { return false; } @@ -186,49 +202,39 @@ impl TextInput { self.grapheme_count -= 1; let pos = self.byte_pos(); self.delete_grapheme_at(pos); - self.recompute_scroll_offset(); true } pub fn delete_forward(&mut self) -> bool { - self.clamp_cursor(); let pos = self.byte_pos(); if pos >= self.text.len() { return false; } self.delete_grapheme_at(pos); self.grapheme_count -= 1; - self.recompute_scroll_offset(); true } pub fn cursor_left(&mut self) { - self.clamp_cursor(); self.cursor = self.cursor.saturating_sub(1); - self.recompute_scroll_offset(); } pub fn cursor_right(&mut self) { - self.clamp_cursor(); if self.cursor < self.grapheme_count { self.cursor += 1; } - self.recompute_scroll_offset(); } pub fn cursor_start(&mut self) { self.cursor = 0; - self.recompute_scroll_offset(); } pub fn cursor_end(&mut self) { self.recompute_grapheme_count(); self.cursor = self.grapheme_count; - self.recompute_scroll_offset(); } pub fn delete_word_backward(&mut self) -> bool { - self.clamp_cursor(); let pos = self.byte_pos(); if pos == 0 { return false; @@ -245,17 +251,42 @@ impl TextInput { self.text.drain(word_start..pos); self.cursor = self.cursor.saturating_sub(removed_graphemes); self.grapheme_count -= removed_graphemes; - self.recompute_scroll_offset(); removed_graphemes > 0 } pub fn drain_to_start(&mut self) { - self.clamp_cursor(); let pos = self.byte_pos(); let removed = self.cursor; self.text.drain(..pos); self.cursor = 0; self.grapheme_count -= removed; - self.recompute_scroll_offset(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Regression: with the cursor at end-of-line and the last grapheme wider + // than the viewport, the scroll offset must scroll far enough right to keep + // the cursor visible, not snap to the start of that grapheme (which left the + // cursor off the right edge). + #[test] + fn scroll_keeps_end_cursor_visible_past_wide_grapheme() { + let mut ti = TextInput::new(); + ti.set_visible_width(1); + // Widths: 'a'=1, 'b'=1, '世'=2 -> cursor display column 4 at end of line. + ti.set_text_at_end("ab世".to_string()); + // The window (width 1) must start at column 4 so the cursor is visible at + // its left edge. The old fallback produced 2 (start of the wide grapheme). + assert_eq!(ti.scroll_offset(), 4); + } + + #[test] + fn scroll_offset_zero_when_text_fits() { + let mut ti = TextInput::new(); + ti.set_visible_width(10); + ti.set_text_at_end("hello".to_string()); + assert_eq!(ti.scroll_offset(), 0); } } diff --git a/src/app/user_menu.rs b/src/app/user_menu.rs index b0c85e4..395cbd0 100755 --- a/src/app/user_menu.rs +++ b/src/app/user_menu.rs @@ -103,8 +103,17 @@ fn non_utf8_err() -> String { NON_UTF8_ERR.to_owned() } +/// Extract the UTF-8 file name component of `path`, erroring on non-UTF-8. +fn file_name_str(path: &Path) -> Result<&str, String> { + path.file_name() + .and_then(|n| n.to_str()) + .ok_or_else(non_utf8_err) +} + pub fn apply_substitutions(cmd: &str, ctx: &SubstContext<'_>) -> Result { - let mut out = String::with_capacity(cmd.len() * 2); + // Most commands expand by a small constant (a few quoted names), so a flat + // headroom beats `len * 2`, which over-allocates for substitution-free text. + let mut out = String::with_capacity(cmd.len() + 16); let mut chars = cmd.chars().peekable(); while let Some(ch) = chars.next() { @@ -116,12 +125,7 @@ pub fn apply_substitutions(cmd: &str, ctx: &SubstContext<'_>) -> Result out.push('%'), Some('%') => out.push('%'), Some('f') => { - let name = ctx - .current_file - .file_name() - .and_then(|n| n.to_str()) - .ok_or_else(non_utf8_err)?; - out.push_str(&safe_file_arg(name)); + out.push_str(&safe_file_arg(file_name_str(ctx.current_file)?)); } Some('d') => { out.push_str(&shell_quote( @@ -135,19 +139,16 @@ pub fn apply_substitutions(cmd: &str, ctx: &SubstContext<'_>) -> Result { if ctx.tagged.is_empty() { - let name = ctx - .current_file - .file_name() - .and_then(|n| n.to_str()) - .ok_or_else(non_utf8_err)?; - out.push_str(&safe_file_arg(name)); + out.push_str(&safe_file_arg(file_name_str(ctx.current_file)?)); } else { - let quoted: Result, String> = ctx - .tagged - .iter() - .map(|p| tagged_name(p, ctx.active_dir).map(|n| safe_file_arg(&n))) - .collect(); - out.push_str("ed?.join(" ")); + // Write space-separated quoted names straight into `out`, + // avoiding an intermediate Vec and its join(). + for (i, p) in ctx.tagged.iter().enumerate() { + if i > 0 { + out.push(' '); + } + out.push_str(&safe_file_arg(&tagged_name(p, ctx.active_dir)?)); + } } } Some(other) => { @@ -197,7 +198,8 @@ fn collect_body_lines( initial_condition: Option, initial_condition_line: usize, ) -> BodyCollectResult { - let mut body_lines: Vec = Vec::new(); + // Menu bodies are typically a few lines; preallocate to skip early regrows. + let mut body_lines: Vec = Vec::with_capacity(4); let mut condition = initial_condition; let mut condition_line = initial_condition_line; diff --git a/src/app/watcher_sync.rs b/src/app/watcher_sync.rs index 854bfb8..1f6cc35 100644 --- a/src/app/watcher_sync.rs +++ b/src/app/watcher_sync.rs @@ -367,20 +367,15 @@ fn apply_successful_read( ) { let current_name = panel .listing - .entries - .get(panel.cursor) + .filtered_get(panel.cursor) .filter(|entry| entry.name != "..") .map(|entry| entry.name.clone()); - let saved: HashSet = panel - .selected_entries() - .into_iter() - .map(|e| e.path.clone()) - .collect(); + let saved: HashSet = panel.selected_entries().map(|e| e.path.clone()).collect(); update_panel_read_errors(panel, errors); panel.listing.set_unfiltered(entries); panel.set_canonical_path(panel.path().canonicalize().ok()); - for entry in &mut panel.listing.unfiltered_entries { + for entry in panel.listing.unfiltered_mut() { entry.selected = saved.contains(&entry.path); } rebuild_visible_entries(panel, current_name.as_deref()); @@ -482,7 +477,7 @@ fn path_parent_matches_cached(path: &Path, cache: &PanelCache) -> bool { } fn apply_watcher_upsert(panel: &mut PanelState, path: &Path) -> bool { - let Ok(mut entry) = reader::get_file_info(path) else { + let Ok(entry) = reader::get_file_info(path) else { return false; }; @@ -491,16 +486,13 @@ fn apply_watcher_upsert(panel: &mut PanelState, path: &Path) -> bool { } reader::ensure_path_index(panel); - let existing = panel - .listing - .path_index - .get(&entry.path) - .and_then(|&idx| panel.listing.unfiltered_entries.get(idx)); - if let Some(existing) = existing { - if existing.cha.hits(&entry.cha) { - return false; - } - entry.selected = existing.selected; + let existing = panel.listing.entry_by_path(&entry.path); + // An unchanged entry is a no-op. `selected` preservation on a real update is + // handled by `PanelListing::upsert`, which copies it from the existing entry. + if let Some(existing) = existing + && existing.cha.hits(&entry.cha) + { + return false; } reader::upsert_entry(panel, entry); @@ -510,7 +502,7 @@ fn apply_watcher_upsert(panel: &mut PanelState, path: &Path) -> bool { fn apply_watcher_remove(panel: &mut PanelState, path: &Path) -> bool { reader::ensure_path_index(panel); - let existed = panel.listing.path_index.contains_key(path); + let existed = panel.listing.contains_path(path); if existed { reader::remove_entry(panel, path); panel.mark_dirty(); @@ -529,8 +521,7 @@ pub(crate) fn refresh_panel_from_disk(panel: &mut PanelState) { fn rebuild_visible_entries(panel: &mut PanelState, preferred_name: Option<&str>) { let current_name = panel .listing - .entries - .get(panel.cursor) + .filtered_get(panel.cursor) .filter(|entry| entry.name != "..") .map(|entry| entry.name.clone()) .or_else(|| preferred_name.map(str::to_string)); @@ -543,21 +534,20 @@ fn rebuild_visible_entries(panel: &mut PanelState, preferred_name: Option<&str>) if let Some(name) = current_name.as_deref() && let Some(pos) = panel .listing - .entries - .iter() + .filtered() .position(|entry| entry.name == name) { panel.cursor = pos; } - if panel.listing.entries.is_empty() { + if panel.listing.filtered_is_empty() { panel.cursor = 0; panel.scroll_offset = 0; - } else if panel.cursor >= panel.listing.entries.len() { - panel.cursor = panel.listing.entries.len() - 1; + } else if panel.cursor >= panel.listing.filtered_len() { + panel.cursor = panel.listing.filtered_len() - 1; } - let max_scroll = panel.listing.entries.len().saturating_sub(1); + let max_scroll = panel.listing.filtered_len().saturating_sub(1); if panel.scroll_offset > max_scroll { panel.scroll_offset = max_scroll; } diff --git a/src/app/watcher_sync/tests.rs b/src/app/watcher_sync/tests.rs index b7a1204..9f0f3b9 100644 --- a/src/app/watcher_sync/tests.rs +++ b/src/app/watcher_sync/tests.rs @@ -9,10 +9,8 @@ const OVERFLOW_EVENT_COUNT: usize = WATCHER_CHANNEL_CAPACITY + 1; fn test_panel(path: &Path) -> PanelState { let mut panel = PanelState::new(path.to_path_buf()); - panel.listing.unfiltered_entries = vec![parent_entry(path)]; - panel.listing.entries = panel.listing.unfiltered_entries.clone(); + panel.set_entries(vec![parent_entry(path)]); panel.listing.force_state(ListingState::Clean); - panel.recalculate_selection_stats(); panel } @@ -24,6 +22,7 @@ fn parent_entry(path: &Path) -> reader::FileEntry { .is_executable(true) .permissions(0o755) .build() + .expect("valid test entry") } fn select_entry_by_name(entries: &mut [reader::FileEntry], name: &str) { @@ -46,12 +45,7 @@ fn build_panel_with_files(dir: &Path, files: &[(&str, &[u8])]) -> PanelState { } fn assert_entry_names_eq(panel: &PanelState, expected: &[&str]) { - let names: Vec<_> = panel - .listing - .entries - .iter() - .map(|e| e.name.as_str()) - .collect(); + let names: Vec<_> = panel.listing.filtered().map(|e| e.name.as_str()).collect(); assert_eq!(names, expected); } @@ -69,10 +63,24 @@ fn assert_no_entry(entries: &[reader::FileEntry], name: &str) { ); } +fn assert_visible_has_entry(panel: &PanelState, name: &str) { + assert!( + panel.listing.filtered().any(|e| e.name == name), + "expected visible entry `{name}`" + ); +} + +fn assert_visible_no_entry(panel: &PanelState, name: &str) { + assert!( + !panel.listing.filtered().any(|e| e.name == name), + "did not expect visible entry `{name}`" + ); +} + fn assert_entry_counts(panel: &PanelState, visible: usize, unfiltered: usize) { - assert_eq!(panel.listing.entries.len(), visible, "visible entry count"); + assert_eq!(panel.listing.filtered_len(), visible, "visible entry count"); assert_eq!( - panel.listing.unfiltered_entries.len(), + panel.listing.unfiltered().len(), unfiltered, "unfiltered entry count" ); @@ -174,19 +182,18 @@ fn watcher_upsert_respects_filter_and_preserves_selection() { panel.set_filter(Some("*.txt".to_string())); assert!(apply_watcher_upsert_if_matches(&mut panel, &keep)); rebuild(&mut panel); - select_entry_by_name(&mut panel.listing.entries, "keep.txt"); - panel.sync_unfiltered_selection(); + select_entry_by_name(panel.listing.unfiltered_mut(), "keep.txt"); + panel.recalculate_selection_stats(); fs::write(&keep, b"updated").unwrap(); assert!(apply_watcher_upsert_if_matches(&mut panel, &keep)); assert!(apply_watcher_upsert_if_matches(&mut panel, &drop)); rebuild(&mut panel); - assert_eq!(panel.listing.entries.len(), 2); + assert_eq!(panel.listing.filtered_len(), 2); let keep_entry = panel .listing - .entries - .iter() + .filtered() .find(|e| e.name == "keep.txt") .unwrap(); assert!(keep_entry.selected); @@ -303,7 +310,7 @@ fn watcher_upsert_uses_panel_sort_mode() { fn watcher_skips_update_when_metadata_unchanged() { let dir = tempfile::tempdir().unwrap(); let mut panel = build_panel_with_files(dir.path(), &[("same.txt", b"content")]); - assert_eq!(panel.listing.entries.len(), 2); + assert_eq!(panel.listing.filtered_len(), 2); let file = dir.path().join("same.txt"); assert!(!apply_watcher_upsert_if_matches(&mut panel, &file)); @@ -323,7 +330,7 @@ fn watcher_updates_when_metadata_changes() { assert!(apply_watcher_upsert_if_matches(&mut panel, &file)); rebuild(&mut panel); - assert_eq!(panel.listing.entries.len(), 2); + assert_eq!(panel.listing.filtered_len(), 2); assert_eq!(panel.total_size(), 18); } @@ -343,7 +350,7 @@ fn poll_watcher_events_processes_at_most_256_events() { assert!(poll_watcher_events(&mut state, &rx)); - let entries = &state.left_panel.listing.unfiltered_entries; + let entries = state.left_panel.listing.unfiltered(); assert_eq!(entries.len(), OVERFLOW_EVENT_COUNT); assert_has_entry(entries, ".."); assert_has_entry(entries, "file0.txt"); @@ -355,8 +362,8 @@ fn poll_watcher_events_processes_at_most_256_events() { fn full_refresh_preserves_selected_entries() { let dir = tempfile::tempdir().unwrap(); let mut panel = build_panel_with_files(dir.path(), &[("selected.txt", b"selected")]); - select_entry_by_name(&mut panel.listing.entries, "selected.txt"); - panel.sync_unfiltered_selection(); + select_entry_by_name(panel.listing.unfiltered_mut(), "selected.txt"); + panel.recalculate_selection_stats(); let selected = dir.path().join("selected.txt"); refresh_panel_from_disk(&mut panel); @@ -364,7 +371,7 @@ fn full_refresh_preserves_selected_entries() { assert!( panel .listing - .unfiltered_entries + .unfiltered() .iter() .any(|entry| entry.path == selected && entry.selected) ); @@ -384,11 +391,11 @@ fn overflow_event_triggers_full_refresh_on_both_panels() { harness.poll(); assert_has_entry( - &harness.state.left_panel.listing.unfiltered_entries, + harness.state.left_panel.listing.unfiltered(), "existing.txt", ); assert_has_entry( - &harness.state.right_panel.listing.unfiltered_entries, + harness.state.right_panel.listing.unfiltered(), "existing.txt", ); } @@ -427,12 +434,7 @@ fn deleted_panel_dir_navigates_to_parent_and_refreshes() { parent.path().canonicalize().ok() ); assert!( - !harness - .state - .left_panel - .listing - .unfiltered_entries - .is_empty(), + !harness.state.left_panel.listing.unfiltered().is_empty(), "panel should have refreshed entries from parent" ); } @@ -462,10 +464,7 @@ fn renamed_panel_dir_updates_path_and_refreshes() { let dirty = harness.poll(); assert!(dirty); assert_eq!(harness.state.left_panel.path(), new_name); - assert_has_entry( - &harness.state.left_panel.listing.unfiltered_entries, - "marker.txt", - ); + assert_has_entry(harness.state.left_panel.listing.unfiltered(), "marker.txt"); } #[test] @@ -476,7 +475,7 @@ fn full_refresh_on_error_clears_entries_and_resets_viewport() { fs::write(&file, b"data").unwrap(); assert!(apply_watcher_upsert_if_matches(&mut panel, &file)); rebuild(&mut panel); - assert!(panel.listing.entries.len() > 1); + assert!(panel.listing.filtered_len() > 1); let gone = tempfile::tempdir().unwrap(); let gone_path = gone.path().to_path_buf(); @@ -484,9 +483,8 @@ fn full_refresh_on_error_clears_entries_and_resets_viewport() { panel.set_path(gone_path); refresh_panel_from_disk(&mut panel); - assert!(panel.listing.entries.is_empty()); - assert!(panel.listing.unfiltered_entries.is_empty()); - assert!(panel.listing.path_index.is_empty()); + assert!(panel.listing.filtered_is_empty()); + assert!(panel.listing.unfiltered().is_empty()); assert_eq!(panel.listing.state(), ListingState::NeedsFullRead); assert_eq!(panel.cursor, 0); assert_eq!(panel.scroll_offset, 0); @@ -508,20 +506,20 @@ fn full_refresh_recovers_after_error() { drop(gone); panel.set_path(gone_path); refresh_panel_from_disk(&mut panel); - assert!(panel.listing.entries.is_empty()); + assert!(panel.listing.filtered_is_empty()); panel.set_path(dir.path().to_path_buf()); refresh_panel_from_disk(&mut panel); assert!( - !panel.listing.entries.is_empty(), + !panel.listing.filtered_is_empty(), "should have entries after recovery" ); assert!( panel.last_error().is_none(), "last_error should be cleared on success" ); - assert_has_entry(&panel.listing.unfiltered_entries, "recovery.txt"); + assert_has_entry(panel.listing.unfiltered(), "recovery.txt"); } #[test] @@ -593,14 +591,14 @@ fn deleted_child_file_removes_from_panel() { &file )); rebuild(&mut harness.state.left_panel); - assert_has_entry(&harness.state.left_panel.listing.entries, "to_delete.txt"); + assert_visible_has_entry(&harness.state.left_panel, "to_delete.txt"); fs::remove_file(&file).unwrap(); harness.send(WatchEvent::Deleted(file.clone())); let dirty = harness.poll(); assert!(dirty); - assert_no_entry(&harness.state.left_panel.listing.entries, "to_delete.txt"); + assert_visible_no_entry(&harness.state.left_panel, "to_delete.txt"); } #[test] @@ -615,7 +613,7 @@ fn created_child_file_appears_in_panel() { let dirty = harness.poll(); assert!(dirty); - assert_has_entry(&harness.state.left_panel.listing.entries, "new_file.txt"); + assert_visible_has_entry(&harness.state.left_panel, "new_file.txt"); } #[test] @@ -728,14 +726,14 @@ fn symlink_target_change_detected() { let mut panel = test_panel(dir.path()); assert!(apply_watcher_upsert_if_matches(&mut panel, &link)); rebuild(&mut panel); - assert_has_entry(&panel.listing.entries, "link.txt"); + assert_visible_has_entry(&panel, "link.txt"); fs::remove_file(&link).unwrap(); symlink(&target_b, &link).unwrap(); apply_watcher_upsert_if_matches(&mut panel, &link); rebuild(&mut panel); - assert_has_entry(&panel.listing.entries, "link.txt"); + assert_visible_has_entry(&panel, "link.txt"); } #[cfg(unix)] @@ -751,7 +749,7 @@ fn broken_symlink_handled_gracefully() { assert!(apply_watcher_upsert_if_matches(&mut panel, &dangling)); rebuild(&mut panel); - assert_has_entry(&panel.listing.entries, "dangling.txt"); + assert_visible_has_entry(&panel, "dangling.txt"); } #[test] @@ -773,6 +771,6 @@ fn stale_events_ignored_after_path_change() { state.right_panel.set_path(dir_b.path().to_path_buf()); poll_watcher_events(&mut state, &rx); - assert_no_entry(&state.left_panel.listing.entries, "stale.txt"); - assert_no_entry(&state.right_panel.listing.entries, "stale.txt"); + assert_visible_no_entry(&state.left_panel, "stale.txt"); + assert_visible_no_entry(&state.right_panel, "stale.txt"); } diff --git a/src/fs/reader.rs b/src/fs/reader.rs index cc54984..959b71f 100755 --- a/src/fs/reader.rs +++ b/src/fs/reader.rs @@ -12,7 +12,7 @@ use std::sync::{Arc, LazyLock, Mutex}; #[cfg(test)] use std::time::SystemTime; -use crate::app::types::{PanelListing, PanelState, compute_category, sanitize_name}; +use crate::app::types::{PanelState, compute_category, sanitize_name}; use crate::fs::cha::Cha; /// Maximum number of uid/gid name mappings to keep per cache. @@ -21,9 +21,6 @@ const CACHE_MAX_SIZE: usize = 1024; const INITIAL_DIR_CAPACITY: usize = 256; -#[cfg(test)] -use crate::app::types::format_permissions; - pub use crate::app::types::FileEntry; #[cfg(unix)] @@ -207,19 +204,8 @@ fn build_file_entry_from_metadata( } } -fn rebuild_path_index(listing: &mut PanelListing) { - listing.path_index.clear(); - listing.path_index.reserve(listing.unfiltered_entries.len()); - for (i, entry) in listing.unfiltered_entries.iter().enumerate() { - listing.path_index.insert(entry.path.clone(), i); - } -} - pub fn ensure_path_index(panel: &mut PanelState) { - if !panel.listing.path_index.is_empty() { - return; - } - rebuild_path_index(&mut panel.listing); + panel.listing.ensure_index(); } pub fn read_directory(path: &Path) -> io::Result<(Vec, Vec)> { @@ -331,41 +317,17 @@ pub fn file_info_from_metadata(path: PathBuf, metadata: &fs::Metadata) -> FileEn build_file_entry_from_metadata(path, file_name, metadata, None) } -pub fn upsert_entry(panel: &mut PanelState, mut entry: FileEntry) { +pub fn upsert_entry(panel: &mut PanelState, entry: FileEntry) { + // The `..` parent link is synthesized per directory read and must never be + // tracked as a real entry; guard before delegating to the listing store. if is_parent_entry(&entry) { return; } - - ensure_path_index(panel); - - if let Some(&idx) = panel.listing.path_index.get(&entry.path) { - if let Some(existing) = panel.listing.unfiltered_entries.get_mut(idx) { - entry.selected = existing.selected; - *existing = entry; - } - } else { - let new_idx = panel.listing.unfiltered_entries.len(); - panel.listing.path_index.insert(entry.path.clone(), new_idx); - panel.listing.unfiltered_entries.push(entry); - } + panel.listing.upsert(entry); } pub fn remove_entry(panel: &mut PanelState, path: &Path) { - if panel.listing.unfiltered_entries.is_empty() { - return; - } - - ensure_path_index(panel); - if let Some(idx) = panel.listing.path_index.remove(path) { - let last = panel.listing.unfiltered_entries.len() - 1; - if idx < last { - let last_path = panel.listing.unfiltered_entries[last].path.clone(); - panel.listing.unfiltered_entries.swap_remove(idx); - panel.listing.path_index.insert(last_path, idx); - } else { - panel.listing.unfiltered_entries.pop(); - } - } + panel.listing.remove(path); } #[cfg(test)] @@ -394,7 +356,7 @@ pub fn is_executable(_mode: u32) -> bool { } #[cfg(test)] -#[allow(clippy::unwrap_used)] +#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; use crate::app::types::FileEntry as CanonicalFileEntry; @@ -417,6 +379,7 @@ mod tests { .group("group") .selected(selected) .build() + .expect("valid test entry") } fn parent_entry() -> FileEntry { @@ -428,12 +391,12 @@ mod tests { .modified(SystemTime::now()) .permissions(0o755) .build() + .expect("valid test entry") } fn test_panel(entries: Vec) -> PanelState { let mut panel = PanelState::new(std::env::temp_dir()); - panel.listing.entries = entries; - panel.recalculate_selection_stats(); + panel.set_entries(entries); panel } @@ -446,7 +409,8 @@ mod tests { .modified(SystemTime::now()) .owner("user") .group("group") - .build(); + .build() + .expect("valid test entry"); assert_eq!(entry.display_size(), " 0 B"); } @@ -459,7 +423,8 @@ mod tests { .modified(SystemTime::now()) .owner("user") .group("group") - .build(); + .build() + .expect("valid test entry"); assert_eq!(entry.display_size(), " 500 B"); } @@ -472,7 +437,8 @@ mod tests { .modified(SystemTime::now()) .owner("user") .group("group") - .build(); + .build() + .expect("valid test entry"); let result = entry.display_size(); assert!(result.contains("KB")); } @@ -486,7 +452,8 @@ mod tests { .modified(SystemTime::now()) .owner("user") .group("group") - .build(); + .build() + .expect("valid test entry"); let result = entry.display_size(); assert!(result.contains("MB")); } @@ -500,7 +467,8 @@ mod tests { .modified(SystemTime::now()) .owner("user") .group("group") - .build(); + .build() + .expect("valid test entry"); let result = entry.display_size(); assert!(result.contains("GB")); } @@ -514,18 +482,19 @@ mod tests { .modified(SystemTime::now()) .owner("user") .group("group") - .build(); + .build() + .expect("valid test entry"); let result = entry.display_size(); assert!(result.contains("TB")); } #[test] fn test_format_permissions_rwx() { - assert_eq!(format_permissions(0o755), "rwxr-xr-x"); - assert_eq!(format_permissions(0o644), "rw-r--r--"); - assert_eq!(format_permissions(0o700), "rwx------"); - assert_eq!(format_permissions(0o000), "---------"); - assert_eq!(format_permissions(0o777), "rwxrwxrwx"); + assert_eq!(FileEntry::display_permissions_raw(0o755), "rwxr-xr-x"); + assert_eq!(FileEntry::display_permissions_raw(0o644), "rw-r--r--"); + assert_eq!(FileEntry::display_permissions_raw(0o700), "rwx------"); + assert_eq!(FileEntry::display_permissions_raw(0o000), "---------"); + assert_eq!(FileEntry::display_permissions_raw(0o777), "rwxrwxrwx"); } #[test] @@ -689,31 +658,29 @@ mod tests { #[test] fn test_upsert_entry_adds_new_entry() { let mut panel = test_panel(vec![parent_entry(), test_entry("b.txt", false)]); - panel.listing.unfiltered_entries = panel.listing.entries.clone(); upsert_entry(&mut panel, test_entry("a.txt", false)); assert!( panel .listing - .unfiltered_entries + .unfiltered() .iter() .any(|entry| entry.name == "a.txt") ); - assert_eq!(panel.listing.unfiltered_entries.len(), 3); + assert_eq!(panel.listing.unfiltered().len(), 3); } #[test] fn test_upsert_entry_updates_existing_and_preserves_selection() { let mut panel = test_panel(vec![test_entry("file.txt", true)]); - panel.listing.unfiltered_entries = panel.listing.entries.clone(); let mut updated = test_entry("file.txt", false); updated.cha.len = 99; upsert_entry(&mut panel, updated); - assert_eq!(panel.listing.unfiltered_entries.len(), 1); - assert_eq!(panel.listing.unfiltered_entries[0].cha.len, 99); - assert!(panel.listing.unfiltered_entries[0].selected); + assert_eq!(panel.listing.unfiltered().len(), 1); + assert_eq!(panel.listing.unfiltered()[0].cha.len, 99); + assert!(panel.listing.unfiltered()[0].selected); } #[test] @@ -724,21 +691,20 @@ mod tests { removed.clone(), test_entry("keep.txt", false), ]); - panel.listing.unfiltered_entries = panel.listing.entries.clone(); remove_entry(&mut panel, &removed.path); assert!( !panel .listing - .unfiltered_entries + .unfiltered() .iter() .any(|entry| entry.name == "remove.txt") ); assert!( panel .listing - .unfiltered_entries + .unfiltered() .iter() .any(|entry| entry.name == "keep.txt") ); @@ -747,14 +713,13 @@ mod tests { #[test] fn test_upsert_adds_hidden_to_unfiltered() { let mut panel = test_panel(vec![parent_entry(), test_entry("visible.txt", false)]); - panel.listing.unfiltered_entries = panel.listing.entries.clone(); panel.set_show_hidden(false); upsert_entry(&mut panel, test_entry(".hidden", false)); assert!( panel .listing - .unfiltered_entries + .unfiltered() .iter() .any(|entry| entry.name == ".hidden") ); @@ -762,26 +727,27 @@ mod tests { #[test] fn test_upsert_with_empty_unfiltered_inserts_entry() { - let mut panel = test_panel(vec![parent_entry(), test_entry("main.rs", false)]); + // Single-store model: an empty backing store is the "empty unfiltered" + // precondition this test exercises (the dual-store split is gone). + let mut panel = test_panel(vec![]); panel.set_filter(Some("*.rs".to_string())); upsert_entry(&mut panel, test_entry("notes.txt", false)); - assert_eq!(panel.listing.unfiltered_entries.len(), 1); - assert_eq!(panel.listing.unfiltered_entries[0].name, "notes.txt"); + assert_eq!(panel.listing.unfiltered().len(), 1); + assert_eq!(panel.listing.unfiltered()[0].name, "notes.txt"); } #[test] fn test_remove_entry_preserves_parent_entry() { let mut panel = test_panel(vec![parent_entry(), test_entry("file.txt", false)]); - panel.listing.unfiltered_entries = panel.listing.entries.clone(); remove_entry(&mut panel, &std::env::temp_dir().join("file.txt")); assert!( panel .listing - .unfiltered_entries + .unfiltered() .iter() .any(|entry| entry.name == "..") ); diff --git a/src/input/command_line.rs b/src/input/command_line.rs index 8efc50b..dd63ee4 100644 --- a/src/input/command_line.rs +++ b/src/input/command_line.rs @@ -5,20 +5,20 @@ use lc::app::{shell, types::*}; use crate::app::panel_ops::refresh_active; fn reset_history(state: &mut AppState) { - state.history_index = None; + state.input.history_index = None; } fn cancel_command_input(state: &mut AppState) { state.mode = AppMode::Normal; - state.command_line.clear(); - state.command_draft.clear(); + state.input.command_line.clear(); + state.input.command_draft.clear(); reset_history(state); } fn command_execute(state: &mut AppState) { - let cmd = state.command_line.take_text(); + let cmd = state.input.command_line.take_text(); state.mode = AppMode::Normal; - state.command_draft.clear(); + state.input.command_draft.clear(); reset_history(state); if !cmd.is_empty() { shell::run_shell_command(state, &cmd, false, refresh_active); @@ -29,19 +29,19 @@ pub(crate) fn handle_command_line(state: &mut AppState, key: KeyEvent) { if key.modifiers.contains(KeyModifiers::CONTROL) { match key.code { KeyCode::Char('a') => { - state.command_line.cursor_start(); + state.input.command_line.cursor_start(); return; } KeyCode::Char('e') => { - state.command_line.cursor_end(); + state.input.command_line.cursor_end(); return; } KeyCode::Char('u') => { - state.command_line.drain_to_start(); + state.input.command_line.drain_to_start(); return; } KeyCode::Char('w') => { - if state.command_line.delete_word_backward() { + if state.input.command_line.delete_word_backward() { reset_history(state); } return; @@ -56,7 +56,7 @@ pub(crate) fn handle_command_line(state: &mut AppState, key: KeyEvent) { } if key.modifiers.contains(KeyModifiers::ALT) { - if key.code == KeyCode::Backspace && state.command_line.delete_word_backward() { + if key.code == KeyCode::Backspace && state.input.command_line.delete_word_backward() { reset_history(state); } return; @@ -69,48 +69,51 @@ pub(crate) fn handle_command_line(state: &mut AppState, key: KeyEvent) { KeyCode::Enter => { command_execute(state); } - KeyCode::Backspace if state.command_line.backspace() => { + KeyCode::Backspace if state.input.command_line.backspace() => { reset_history(state); } KeyCode::Left => { - state.command_line.cursor_left(); + state.input.command_line.cursor_left(); } KeyCode::Right => { - state.command_line.cursor_right(); + state.input.command_line.cursor_right(); } - KeyCode::Up if !state.command_history.is_empty() => { - if state.history_index.is_none() { - state.command_draft = state.command_line.text().to_owned(); - state.command_line.set_text(String::new()); + KeyCode::Up if !state.input.command_history.is_empty() => { + if state.input.history_index.is_none() { + state.input.command_draft = state.input.command_line.text().to_owned(); + state.input.command_line.set_text(String::new()); } - let idx = match state.history_index { + let idx = match state.input.history_index { Some(i) if i > 0 => i - 1, // idx == 0: already at oldest entry, clamp here Some(i) => i, - None => state.command_history.len() - 1, + None => state.input.command_history.len() - 1, }; - state.history_index = Some(idx); + state.input.history_index = Some(idx); state + .input .command_line - .set_text_at_end(state.command_history[idx].clone()); + .set_text_at_end(state.input.command_history[idx].clone()); } - KeyCode::Down if !state.command_history.is_empty() => { - if let Some(idx) = state.history_index { - if idx + 1 < state.command_history.len() { - state.history_index = Some(idx + 1); + KeyCode::Down if !state.input.command_history.is_empty() => { + if let Some(idx) = state.input.history_index { + if idx + 1 < state.input.command_history.len() { + state.input.history_index = Some(idx + 1); state + .input .command_line - .set_text_at_end(state.command_history[idx + 1].clone()); + .set_text_at_end(state.input.command_history[idx + 1].clone()); } else { - state.history_index = None; + state.input.history_index = None; state + .input .command_line - .set_text_at_end(state.command_draft.clone()); + .set_text_at_end(state.input.command_draft.clone()); } } } KeyCode::Char(c) => { - state.command_line.insert_char(c); + state.input.command_line.insert_char(c); reset_history(state); } _ => {} @@ -126,7 +129,10 @@ mod tests { command_line.set_text(line.to_string()); command_line.set_cursor(cursor); AppState { - command_line, + input: InputState { + command_line, + ..Default::default() + }, ..Default::default() } } @@ -138,35 +144,35 @@ mod tests { &mut state, KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), ); - assert_eq!(state.command_line.text(), "hell"); - assert_eq!(state.command_line.cursor(), 4); + assert_eq!(state.input.command_line.text(), "hell"); + assert_eq!(state.input.command_line.cursor(), 4); } #[test] fn cmd_backspace_at_start_does_nothing() { let mut state = make_cmd_state("hello", 0); - state.history_index = Some(0); + state.input.history_index = Some(0); handle_command_line( &mut state, KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), ); - assert_eq!(state.command_line.text(), "hello"); - assert_eq!(state.command_line.cursor(), 0); - assert_eq!(state.history_index, Some(0)); + assert_eq!(state.input.command_line.text(), "hello"); + assert_eq!(state.input.command_line.cursor(), 0); + assert_eq!(state.input.history_index, Some(0)); } #[test] fn cmd_left_moves_cursor() { let mut state = make_cmd_state("hello", 3); handle_command_line(&mut state, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); - assert_eq!(state.command_line.cursor(), 2); + assert_eq!(state.input.command_line.cursor(), 2); } #[test] fn cmd_left_at_start_does_nothing() { let mut state = make_cmd_state("hello", 0); handle_command_line(&mut state, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); - assert_eq!(state.command_line.cursor(), 0); + assert_eq!(state.input.command_line.cursor(), 0); } #[test] @@ -176,7 +182,7 @@ mod tests { &mut state, KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), ); - assert_eq!(state.command_line.cursor(), 3); + assert_eq!(state.input.command_line.cursor(), 3); } #[test] @@ -186,7 +192,7 @@ mod tests { &mut state, KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), ); - assert_eq!(state.command_line.cursor(), 5); + assert_eq!(state.input.command_line.cursor(), 5); } #[test] @@ -196,7 +202,7 @@ mod tests { &mut state, KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL), ); - assert_eq!(state.command_line.cursor(), 0); + assert_eq!(state.input.command_line.cursor(), 0); } #[test] @@ -206,7 +212,7 @@ mod tests { &mut state, KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL), ); - assert_eq!(state.command_line.cursor(), 5); + assert_eq!(state.input.command_line.cursor(), 5); } #[test] @@ -216,34 +222,34 @@ mod tests { &mut state, KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL), ); - assert_eq!(state.command_line.text(), " world"); - assert_eq!(state.command_line.cursor(), 0); + assert_eq!(state.input.command_line.text(), " world"); + assert_eq!(state.input.command_line.cursor(), 0); } #[test] fn cmd_ctrl_w_deletes_word() { let mut state = make_cmd_state("hello world", 11); - state.history_index = Some(0); + state.input.history_index = Some(0); handle_command_line( &mut state, KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL), ); - assert_eq!(state.command_line.text(), "hello "); - assert_eq!(state.command_line.cursor(), 6); - assert!(state.history_index.is_none()); + assert_eq!(state.input.command_line.text(), "hello "); + assert_eq!(state.input.command_line.cursor(), 6); + assert!(state.input.history_index.is_none()); } #[test] fn cmd_ctrl_w_at_start_keeps_history_index() { let mut state = make_cmd_state("hello", 0); - state.history_index = Some(0); + state.input.history_index = Some(0); handle_command_line( &mut state, KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL), ); - assert_eq!(state.command_line.text(), "hello"); - assert_eq!(state.command_line.cursor(), 0); - assert_eq!(state.history_index, Some(0)); + assert_eq!(state.input.command_line.text(), "hello"); + assert_eq!(state.input.command_line.cursor(), 0); + assert_eq!(state.input.history_index, Some(0)); } #[test] @@ -253,8 +259,8 @@ mod tests { &mut state, KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), ); - assert_eq!(state.command_line.text(), "hello"); - assert_eq!(state.command_line.cursor(), 2); + assert_eq!(state.input.command_line.text(), "hello"); + assert_eq!(state.input.command_line.cursor(), 2); } #[test] @@ -264,41 +270,41 @@ mod tests { &mut state, KeyEvent::new(KeyCode::Char('ą'), KeyModifiers::NONE), ); - assert_eq!(state.command_line.text(), "testą"); - assert_eq!(state.command_line.cursor(), 5); + assert_eq!(state.input.command_line.text(), "testą"); + assert_eq!(state.input.command_line.cursor(), 5); } #[test] fn cmd_esc_clears() { let mut state = make_cmd_state("hello", 5); - state.history_index = Some(0); + state.input.history_index = Some(0); handle_command_line(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert_eq!(state.mode, AppMode::Normal); - assert_eq!(state.command_line.text(), ""); - assert_eq!(state.command_line.cursor(), 0); - assert!(state.history_index.is_none()); + assert_eq!(state.input.command_line.text(), ""); + assert_eq!(state.input.command_line.cursor(), 0); + assert!(state.input.history_index.is_none()); } #[test] fn cmd_up_loads_history() { let mut state = make_cmd_state("", 0); - state.command_history.push_back("first".to_string()); - state.command_history.push_back("second".to_string()); + state.input.command_history.push_back("first".to_string()); + state.input.command_history.push_back("second".to_string()); handle_command_line(&mut state, KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); - assert_eq!(state.command_line.text(), "second"); - assert_eq!(state.command_line.cursor(), 6); - assert_eq!(state.history_index, Some(1)); + assert_eq!(state.input.command_line.text(), "second"); + assert_eq!(state.input.command_line.cursor(), 6); + assert_eq!(state.input.history_index, Some(1)); } #[test] fn cmd_down_restores_draft() { let mut state = make_cmd_state("draft", 5); - state.command_history.push_back("first".to_string()); - state.history_index = Some(0); - state.command_draft = "draft".to_string(); + state.input.command_history.push_back("first".to_string()); + state.input.history_index = Some(0); + state.input.command_draft = "draft".to_string(); handle_command_line(&mut state, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); - assert_eq!(state.command_line.text(), "draft"); - assert!(state.history_index.is_none()); + assert_eq!(state.input.command_line.text(), "draft"); + assert!(state.input.history_index.is_none()); } #[test] @@ -308,14 +314,14 @@ mod tests { &mut state, KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL), ); - assert_eq!(state.command_line.text(), ""); - assert_eq!(state.command_line.cursor(), 0); + assert_eq!(state.input.command_line.text(), ""); + assert_eq!(state.input.command_line.cursor(), 0); } #[test] fn cmd_cursor_respects_char_boundaries() { let mut state = make_cmd_state("testą", 5); handle_command_line(&mut state, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); - assert_eq!(state.command_line.cursor(), 4); + assert_eq!(state.input.command_line.cursor(), 4); } } diff --git a/src/input/dialogs.rs b/src/input/dialogs.rs index 424ba52..7179ec9 100644 --- a/src/input/dialogs.rs +++ b/src/input/dialogs.rs @@ -59,11 +59,11 @@ fn validate_path_name(input: &str) -> ValidationResult { fn reset_dialog_state(state: &mut AppState) { state.mode = AppMode::Normal; - state.pending_action = None; - state.pending_menu_command = None; - state.status_message = None; - state.dialog_selection = 0; - if let Some(panel) = state.menu_restore_panel.take() { + state.ui.pending_action = None; + state.ui.pending_menu_command = None; + state.ui.status_message = None; + state.input.dialog_selection = 0; + if let Some(panel) = state.ui.menu_restore_panel.take() { set_active_panel(state, panel); } } @@ -73,20 +73,20 @@ fn dismiss_dialog_and_restore(state: &mut AppState) { } pub(crate) fn finish_confirmed_action(state: &mut AppState) { - state.dialog_selection = 0; - if state.status_message.is_some() + state.input.dialog_selection = 0; + if state.ui.status_message.is_some() && !matches!(state.mode, AppMode::Dialog(DialogKind::Progress { .. })) { - let msg = state.status_message.take(); + let msg = state.ui.status_message.take(); dismiss_dialog(state); - state.status_message = msg; + state.ui.status_message = msg; refresh_both(state); } } fn dispatch_with_overwrite_check(state: &mut AppState, running_job: &mut Option) { if let Some(conflicting) = check_overwrite_conflict(state) { - state.dialog_selection = 0; + state.input.dialog_selection = 0; state.mode = AppMode::Dialog(DialogKind::OverwriteConfirm(Box::new( OverwriteConfirmDetails { conflicting }, ))); @@ -121,7 +121,7 @@ fn is_same_file(src: &std::path::Path, dest: &std::path::Path) -> bool { } pub(crate) fn check_overwrite_conflict(state: &AppState) -> Option> { - let action = state.pending_action.as_ref()?; + let action = state.ui.pending_action.as_ref()?; match action { PendingAction::Copy(t) | PendingAction::Move(t) => { if t.overwrite { @@ -204,13 +204,17 @@ fn confirm_dialog_key(state: &mut AppState, key: KeyCode) -> Option { match key { KeyCode::Char('y' | 'Y') => Some(true), KeyCode::Char('n' | 'N') => Some(false), - KeyCode::Enter => Some(state.dialog_selection == 0), + KeyCode::Enter => Some(state.input.dialog_selection == 0), KeyCode::Esc => { dismiss_dialog(state); None } KeyCode::Left | KeyCode::Right => { - state.dialog_selection = if state.dialog_selection == 0 { 1 } else { 0 }; + state.input.dialog_selection = if state.input.dialog_selection == 0 { + 1 + } else { + 0 + }; None } _ => None, @@ -223,9 +227,9 @@ fn handle_confirm_dialog(state: &mut AppState, running_job: &mut Option { - state.dialog_selection = state.dialog_selection.saturating_sub(1); + state.input.dialog_selection = state.input.dialog_selection.saturating_sub(1); return; } KeyCode::Right => { - state.dialog_selection = (state.dialog_selection + 1).min(1); + state.input.dialog_selection = (state.input.dialog_selection + 1).min(1); return; } KeyCode::Char('o' | 'O') => { - if let Some(action) = state.pending_action.as_mut() { - action.set_overwrite(); + if let Some(a) = &mut state.ui.pending_action { + a.set_overwrite(); } } KeyCode::Char('c' | 'C') => { dismiss_dialog(state); return; } - KeyCode::Enter => match state.dialog_selection { + KeyCode::Enter => match state.input.dialog_selection { 0 => { - if let Some(action) = state.pending_action.as_mut() { - action.set_overwrite(); + if let Some(a) = &mut state.ui.pending_action { + a.set_overwrite(); } } 1 => { @@ -300,9 +304,9 @@ fn handle_quick_cd(state: &mut AppState, input: &str) { state.hotlist_push(expanded); } } else if expanded.exists() { - state.status_message = Some(format!("Not a directory: {input}")); + state.ui.status_message = Some(format!("Not a directory: {input}")); } else { - state.status_message = Some(format!("Directory not found: {input}")); + state.ui.status_message = Some(format!("Directory not found: {input}")); } } @@ -313,36 +317,36 @@ fn handle_input_action( action: &InputAction, terminal_height: u16, ) { - let input = state.dialog_input.text().to_owned(); + let input = state.input.dialog_input.text().to_owned(); match action { InputAction::ViewerSearch => { if let Some(vs) = viewer_state.as_mut() { vs.search(&input, terminal_height.saturating_sub(3) as usize); } state.mode = AppMode::Viewing; - state.dialog_input.clear(); + state.input.dialog_input.clear(); return; } InputAction::CreateDirectory => { if let Err(msg) = validate_create_or_rename(&input, "Directory name") { - state.status_message = Some(msg); + state.ui.status_message = Some(msg); return; } let target = fs::path::resolve_user_path(state.active_panel().path(), &input); if let Err(err) = ops::create_directory(&target) { - state.status_message = Some(format!("Create directory failed: {err}")); + state.ui.status_message = Some(format!("Create directory failed: {err}")); } } InputAction::Rename => { if let Err(msg) = validate_create_or_rename(&input, "New name") { - state.status_message = Some(msg); + state.ui.status_message = Some(msg); return; } if let Some(entry) = state.active_panel().current_entry() && input != entry.name && let Err(err) = ops::rename_entry(&entry.path, &input) { - state.status_message = Some(format!("Rename failed: {err}")); + state.ui.status_message = Some(format!("Rename failed: {err}")); } } InputAction::Chmod => { @@ -350,9 +354,9 @@ fn handle_input_action( Some(m) => m, None => { if input.trim().is_empty() { - state.status_message = Some("Octal mode cannot be empty".to_string()); + state.ui.status_message = Some("Octal mode cannot be empty".to_string()); } else { - state.status_message = Some(format!("Invalid octal mode '{input}'")); + state.ui.status_message = Some(format!("Invalid octal mode '{input}'")); } return; } @@ -360,7 +364,7 @@ fn handle_input_action( if let Some(entry) = state.active_panel().current_entry() && let Err(err) = ops::chmod(&entry.path, mode) { - state.status_message = Some(format!("Chmod failed: {err}")); + state.ui.status_message = Some(format!("Chmod failed: {err}")); } } InputAction::Filter => { @@ -370,7 +374,7 @@ fn handle_input_action( } else { Some(input) }); - if panel.listing.needs_full_read() || panel.listing.unfiltered_entries.is_empty() { + if panel.listing.needs_full_read() || panel.listing.unfiltered().is_empty() { refresh_active(state); } else { rebuild_visible_entries(panel, panel_visible_height(terminal_height)); @@ -383,11 +387,11 @@ fn handle_input_action( } } state.mode = AppMode::Normal; - state.dialog_input.clear(); + state.input.dialog_input.clear(); if !matches!(action, InputAction::Filter) { refresh_active(state); } - if let Some(panel) = state.menu_restore_panel.take() { + if let Some(panel) = state.ui.menu_restore_panel.take() { set_active_panel(state, panel); } } @@ -401,7 +405,7 @@ fn validate_create_or_rename(input: &str, label: &str) -> Result<(), String> { } fn apply_text_edit(state: &mut AppState, key: KeyCode) { - apply_dialog_text_edit(&mut state.dialog_input, key); + apply_dialog_text_edit(&mut state.input.dialog_input, key); } fn handle_input_dialog( @@ -422,8 +426,8 @@ fn handle_input_dialog( } else { state.mode = AppMode::Normal; } - state.dialog_input.clear(); - if let Some(panel) = state.menu_restore_panel.take() { + state.input.dialog_input.clear(); + if let Some(panel) = state.ui.menu_restore_panel.take() { set_active_panel(state, panel); } } @@ -444,7 +448,7 @@ fn handle_progress_dialog(state: &mut AppState, running_job: &Option && let Some(job) = running_job.as_ref() { job.cancel.store(true, Ordering::Relaxed); - state.status_message = Some("Cancel requested".to_string()); + state.ui.status_message = Some("Cancel requested".to_string()); } } @@ -516,10 +520,14 @@ fn handle_archive_extract_dialog( return; } KeyCode::Left | KeyCode::Right => { - state.dialog_selection = if state.dialog_selection == 0 { 1 } else { 0 }; + state.input.dialog_selection = if state.input.dialog_selection == 0 { + 1 + } else { + 0 + }; return; } - KeyCode::Enter if state.dialog_selection == 1 => { + KeyCode::Enter if state.input.dialog_selection == 1 => { dismiss_dialog(state); return; } @@ -531,11 +539,11 @@ fn handle_archive_extract_dialog( return; }; if dest_text.trim().is_empty() { - state.status_message = Some("Destination path cannot be empty".to_string()); + state.ui.status_message = Some("Destination path cannot be empty".to_string()); return; } let dest = fs::path::resolve_user_path(state.active_panel().path(), &dest_text); - state.pending_action = Some(PendingAction::ExtractArchive { + state.ui.pending_action = Some(PendingAction::ExtractArchive { source, dest, overwrite: false, @@ -561,10 +569,14 @@ fn handle_archive_create_dialog( return; } KeyCode::Left | KeyCode::Right => { - state.dialog_selection = if state.dialog_selection == 0 { 1 } else { 0 }; + state.input.dialog_selection = if state.input.dialog_selection == 0 { + 1 + } else { + 0 + }; return; } - KeyCode::Enter if state.dialog_selection == 1 => { + KeyCode::Enter if state.input.dialog_selection == 1 => { dismiss_dialog(state); return; } @@ -579,15 +591,15 @@ fn handle_archive_create_dialog( return; }; if dest_text.trim().is_empty() { - state.status_message = Some("Archive path cannot be empty".to_string()); + state.ui.status_message = Some("Archive path cannot be empty".to_string()); return; } let dest = fs::path::resolve_user_path(state.active_panel().path(), &dest_text); let Some(format) = archive_format_from_path(&dest) else { - state.status_message = Some("Unsupported archive format. Use: zip, tar, tar.gz, tar.bz2, tar.xz, tar.zst, 7z".to_string()); + state.ui.status_message = Some("Unsupported archive format. Use: zip, tar, tar.gz, tar.bz2, tar.xz, tar.zst, 7z".to_string()); return; }; - state.pending_action = Some(PendingAction::CreateArchive { + state.ui.pending_action = Some(PendingAction::CreateArchive { sources, dest, format, @@ -619,7 +631,7 @@ fn handle_copymove_dialog( dest: details.dest.clone(), overwrite: false, }; - if details.is_move { + if details.kind.is_move() { PendingAction::Move(transfer) } else { PendingAction::Copy(transfer) @@ -627,7 +639,7 @@ fn handle_copymove_dialog( } else { return; }; - state.pending_action = Some(action); + state.ui.pending_action = Some(action); dispatch_with_overwrite_check(state, running_job); } else { dismiss_dialog(state); @@ -744,7 +756,10 @@ mod tests { dialog_input.set_text(text.to_string()); dialog_input.set_cursor(cursor); AppState { - dialog_input, + input: InputState { + dialog_input, + ..Default::default() + }, ..Default::default() } } @@ -753,113 +768,119 @@ mod tests { fn text_edit_insert_char() { let mut state = make_input_state("hello", 5); apply_text_edit(&mut state, KeyCode::Char('!')); - assert_eq!(state.dialog_input.text(), "hello!"); - assert_eq!(state.dialog_input.cursor(), 6); + assert_eq!(state.input.dialog_input.text(), "hello!"); + assert_eq!(state.input.dialog_input.cursor(), 6); } #[test] fn text_edit_insert_middle() { let mut state = make_input_state("helo", 2); apply_text_edit(&mut state, KeyCode::Char('l')); - assert_eq!(state.dialog_input.text(), "hello"); - assert_eq!(state.dialog_input.cursor(), 3); + assert_eq!(state.input.dialog_input.text(), "hello"); + assert_eq!(state.input.dialog_input.cursor(), 3); } #[test] fn text_edit_backspace() { let mut state = make_input_state("hello", 5); apply_text_edit(&mut state, KeyCode::Backspace); - assert_eq!(state.dialog_input.text(), "hell"); - assert_eq!(state.dialog_input.cursor(), 4); + assert_eq!(state.input.dialog_input.text(), "hell"); + assert_eq!(state.input.dialog_input.cursor(), 4); } #[test] fn text_edit_backspace_at_start() { let mut state = make_input_state("hello", 0); apply_text_edit(&mut state, KeyCode::Backspace); - assert_eq!(state.dialog_input.text(), "hello"); - assert_eq!(state.dialog_input.cursor(), 0); + assert_eq!(state.input.dialog_input.text(), "hello"); + assert_eq!(state.input.dialog_input.cursor(), 0); } #[test] fn text_edit_delete() { let mut state = make_input_state("hello", 0); apply_text_edit(&mut state, KeyCode::Delete); - assert_eq!(state.dialog_input.text(), "ello"); - assert_eq!(state.dialog_input.cursor(), 0); + assert_eq!(state.input.dialog_input.text(), "ello"); + assert_eq!(state.input.dialog_input.cursor(), 0); } #[test] fn text_edit_delete_at_end() { let mut state = make_input_state("hello", 5); apply_text_edit(&mut state, KeyCode::Delete); - assert_eq!(state.dialog_input.text(), "hello"); - assert_eq!(state.dialog_input.cursor(), 5); + assert_eq!(state.input.dialog_input.text(), "hello"); + assert_eq!(state.input.dialog_input.cursor(), 5); } #[test] fn text_edit_left_right() { let mut state = make_input_state("hello", 3); apply_text_edit(&mut state, KeyCode::Left); - assert_eq!(state.dialog_input.cursor(), 2); + assert_eq!(state.input.dialog_input.cursor(), 2); apply_text_edit(&mut state, KeyCode::Right); - assert_eq!(state.dialog_input.cursor(), 3); + assert_eq!(state.input.dialog_input.cursor(), 3); } #[test] fn text_edit_home_end() { let mut state = make_input_state("hello", 3); apply_text_edit(&mut state, KeyCode::Home); - assert_eq!(state.dialog_input.cursor(), 0); + assert_eq!(state.input.dialog_input.cursor(), 0); apply_text_edit(&mut state, KeyCode::End); - assert_eq!(state.dialog_input.cursor(), 5); + assert_eq!(state.input.dialog_input.cursor(), 5); } #[test] fn text_edit_multibyte_insert() { let mut state = make_input_state("hello", 5); apply_text_edit(&mut state, KeyCode::Char('ą')); - assert_eq!(state.dialog_input.text(), "helloą"); - assert_eq!(state.dialog_input.cursor(), 6); + assert_eq!(state.input.dialog_input.text(), "helloą"); + assert_eq!(state.input.dialog_input.cursor(), 6); } #[test] fn text_edit_multibyte_backspace() { let mut state = make_input_state("helloą", 6); apply_text_edit(&mut state, KeyCode::Backspace); - assert_eq!(state.dialog_input.text(), "hello"); - assert_eq!(state.dialog_input.cursor(), 5); + assert_eq!(state.input.dialog_input.text(), "hello"); + assert_eq!(state.input.dialog_input.cursor(), 5); } #[test] fn text_edit_emoji_insert() { let mut state = make_input_state("test", 4); apply_text_edit(&mut state, KeyCode::Char('🎉')); - assert_eq!(state.dialog_input.text(), "test🎉"); - assert_eq!(state.dialog_input.cursor(), 5); + assert_eq!(state.input.dialog_input.text(), "test🎉"); + assert_eq!(state.input.dialog_input.cursor(), 5); } #[test] fn text_edit_rejects_multibyte_char_past_byte_limit() { let mut state = make_input_state(&"a".repeat(MAX_DIALOG_INPUT_BYTES - 1), 4095); apply_text_edit(&mut state, KeyCode::Char('ą')); - assert_eq!(state.dialog_input.text().len(), MAX_DIALOG_INPUT_BYTES - 1); - assert_eq!(state.dialog_input.cursor(), 4095); + assert_eq!( + state.input.dialog_input.text().len(), + MAX_DIALOG_INPUT_BYTES - 1 + ); + assert_eq!(state.input.dialog_input.cursor(), 4095); } #[test] fn text_edit_allows_char_at_exact_byte_limit() { let mut state = make_input_state(&"a".repeat(MAX_DIALOG_INPUT_BYTES - 1), 4095); apply_text_edit(&mut state, KeyCode::Char('!')); - assert_eq!(state.dialog_input.text().len(), MAX_DIALOG_INPUT_BYTES); - assert_eq!(state.dialog_input.cursor(), 4096); + assert_eq!( + state.input.dialog_input.text().len(), + MAX_DIALOG_INPUT_BYTES + ); + assert_eq!(state.input.dialog_input.cursor(), 4096); } #[test] fn text_edit_emoji_backspace() { let mut state = make_input_state("test🎉", 5); apply_text_edit(&mut state, KeyCode::Backspace); - assert_eq!(state.dialog_input.text(), "test"); - assert_eq!(state.dialog_input.cursor(), 4); + assert_eq!(state.input.dialog_input.text(), "test"); + assert_eq!(state.input.dialog_input.cursor(), 4); } } diff --git a/src/input/directory_tree.rs b/src/input/directory_tree.rs index 7c6f08c..4e0229e 100644 --- a/src/input/directory_tree.rs +++ b/src/input/directory_tree.rs @@ -17,33 +17,34 @@ pub(crate) fn handle_directory_tree( KeyCode::Esc => { state.mode = AppMode::Normal; } - KeyCode::Up | KeyCode::Char('k') if state.tree_selected > 0 => { - state.tree_selected -= 1; + KeyCode::Up | KeyCode::Char('k') if state.tree.selected > 0 => { + state.tree.selected -= 1; } KeyCode::Down | KeyCode::Char('j') - if !state.tree_entries.is_empty() - && state.tree_selected + 1 < state.tree_entries.len() => + if !state.tree.entries.is_empty() + && state.tree.selected + 1 < state.tree.entries.len() => { - state.tree_selected += 1; + state.tree.selected += 1; } - KeyCode::Home if !state.tree_entries.is_empty() => { - state.tree_selected = 0; - state.tree_scroll = 0; + KeyCode::Home if !state.tree.entries.is_empty() => { + state.tree.selected = 0; + state.tree.scroll = 0; } - KeyCode::End if !state.tree_entries.is_empty() => { - state.tree_selected = state.tree_entries.len() - 1; + KeyCode::End if !state.tree.entries.is_empty() => { + state.tree.selected = state.tree.entries.len() - 1; } KeyCode::PageUp => { - state.tree_selected = state.tree_selected.saturating_sub(visible_height); - state.tree_scroll = state.tree_scroll.saturating_sub(visible_height); + state.tree.selected = state.tree.selected.saturating_sub(visible_height); + state.tree.scroll = state.tree.scroll.saturating_sub(visible_height); } - KeyCode::PageDown if !state.tree_entries.is_empty() => { - state.tree_selected = - (state.tree_selected + visible_height).min(state.tree_entries.len() - 1); - state.tree_scroll = state - .tree_scroll + KeyCode::PageDown if !state.tree.entries.is_empty() => { + state.tree.selected = + (state.tree.selected + visible_height).min(state.tree.entries.len() - 1); + state.tree.scroll = state + .tree + .scroll .saturating_add(visible_height) - .min(state.tree_entries.len().saturating_sub(visible_height)); + .min(state.tree.entries.len().saturating_sub(visible_height)); } KeyCode::Enter => { handle_tree_enter(state, viewer_loader); @@ -54,7 +55,7 @@ pub(crate) fn handle_directory_tree( _ => {} } - ensure_selected_visible(state.tree_selected, &mut state.tree_scroll, visible_height); + ensure_selected_visible(state.tree.selected, &mut state.tree.scroll, visible_height); } fn ensure_selected_visible(selected: usize, scroll: &mut usize, visible_height: usize) { @@ -91,21 +92,21 @@ pub(crate) fn set_tree_diagnostic_status( } fn handle_tree_enter(state: &mut AppState, viewer_loader: &mut Option) { - let selected = state.tree_selected; - let is_dir = state.tree_entries.get(selected).is_some_and(|e| e.is_dir); + let selected = state.tree.selected; + let is_dir = state.tree.entries.get(selected).is_some_and(|e| e.is_dir); if is_dir { let show_hidden = state.active_panel().show_hidden(); let diagnostics = dir_tree::toggle_expand_with_diagnostics( - &mut state.tree_entries, + &mut state.tree.entries, selected, show_hidden, ); - set_tree_diagnostic_status(&mut state.status_message, &diagnostics); - if state.tree_selected >= state.tree_entries.len() && !state.tree_entries.is_empty() { - state.tree_selected = state.tree_entries.len() - 1; + set_tree_diagnostic_status(&mut state.ui.status_message, &diagnostics); + if state.tree.selected >= state.tree.entries.len() && !state.tree.entries.is_empty() { + state.tree.selected = state.tree.entries.len() - 1; } - } else if let Some(entry) = state.tree_entries.get(selected) { + } else if let Some(entry) = state.tree.entries.get(selected) { let path = entry.path.clone(); *viewer_loader = Some(viewer::ViewerState::open_background(path)); state.prev_mode = Some(std::mem::replace(&mut state.mode, AppMode::Viewing)); @@ -113,7 +114,7 @@ fn handle_tree_enter(state: &mut AppState, viewer_loader: &mut Option (e.is_dir, e.path.clone()), None => return, }; @@ -130,14 +131,15 @@ fn handle_tree_cd(state: &mut AppState) { panel.set_path(target); panel.cursor = 0; panel.scroll_offset = 0; - state.tree_selected = 0; - state.tree_scroll = 0; + state.tree.selected = 0; + state.tree.scroll = 0; refresh_active(state); state.mode = AppMode::Normal; } } #[cfg(test)] +#[allow(clippy::field_reassign_with_default)] mod tests { use super::*; use std::path::PathBuf; @@ -162,88 +164,71 @@ mod tests { #[test] fn tree_esc_returns_normal() { - let mut state = AppState { - mode: AppMode::DirectoryTree, - tree_entries: make_tree_entries(10), - ..Default::default() - }; + let mut state = AppState::default(); + state.mode = AppMode::DirectoryTree; + state.tree.entries = make_tree_entries(10); handle_directory_tree(&mut state, &mut None, &mut None, KeyCode::Esc, 24); assert_eq!(state.mode, AppMode::Normal); } #[test] fn tree_up_at_top_does_nothing() { - let mut state = AppState { - mode: AppMode::DirectoryTree, - tree_entries: make_tree_entries(10), - tree_selected: 0, - ..Default::default() - }; + let mut state = AppState::default(); + state.mode = AppMode::DirectoryTree; + state.tree.entries = make_tree_entries(10); handle_directory_tree(&mut state, &mut None, &mut None, KeyCode::Up, 24); - assert_eq!(state.tree_selected, 0); + assert_eq!(state.tree.selected, 0); } #[test] fn tree_down_moves() { - let mut state = AppState { - mode: AppMode::DirectoryTree, - tree_entries: make_tree_entries(10), - tree_selected: 0, - ..Default::default() - }; + let mut state = AppState::default(); + state.mode = AppMode::DirectoryTree; + state.tree.entries = make_tree_entries(10); handle_directory_tree(&mut state, &mut None, &mut None, KeyCode::Down, 24); - assert_eq!(state.tree_selected, 1); + assert_eq!(state.tree.selected, 1); } #[test] fn tree_down_at_end_does_nothing() { - let mut state = AppState { - mode: AppMode::DirectoryTree, - tree_entries: make_tree_entries(5), - tree_selected: 4, - ..Default::default() - }; + let mut state = AppState::default(); + state.mode = AppMode::DirectoryTree; + state.tree.entries = make_tree_entries(5); + state.tree.selected = 4; handle_directory_tree(&mut state, &mut None, &mut None, KeyCode::Down, 24); - assert_eq!(state.tree_selected, 4); + assert_eq!(state.tree.selected, 4); } #[test] fn tree_home_resets() { - let mut state = AppState { - mode: AppMode::DirectoryTree, - tree_entries: make_tree_entries(50), - tree_selected: 25, - tree_scroll: 20, - ..Default::default() - }; + let mut state = AppState::default(); + state.mode = AppMode::DirectoryTree; + state.tree.entries = make_tree_entries(50); + state.tree.selected = 25; + state.tree.scroll = 20; handle_directory_tree(&mut state, &mut None, &mut None, KeyCode::Home, 24); - assert_eq!(state.tree_selected, 0); - assert_eq!(state.tree_scroll, 0); + assert_eq!(state.tree.selected, 0); + assert_eq!(state.tree.scroll, 0); } #[test] fn tree_end_goes_to_last() { - let mut state = AppState { - mode: AppMode::DirectoryTree, - tree_entries: make_tree_entries(50), - tree_selected: 0, - ..Default::default() - }; + let mut state = AppState::default(); + state.mode = AppMode::DirectoryTree; + state.tree.entries = make_tree_entries(50); handle_directory_tree(&mut state, &mut None, &mut None, KeyCode::End, 24); - assert_eq!(state.tree_selected, 49); + assert_eq!(state.tree.selected, 49); } #[test] fn tree_empty_entries_doesnt_panic() { - let mut state = AppState { - mode: AppMode::DirectoryTree, - tree_entries: vec![], - ..Default::default() - }; + let mut state = AppState::default(); + state.mode = AppMode::DirectoryTree; + state.tree.entries = vec![]; handle_directory_tree(&mut state, &mut None, &mut None, KeyCode::Down, 24); - assert_eq!(state.tree_selected, 0); + assert_eq!(state.tree.selected, 0); handle_directory_tree(&mut state, &mut None, &mut None, KeyCode::End, 24); - assert_eq!(state.tree_selected, 0); + assert_eq!(state.tree.selected, 0); handle_directory_tree(&mut state, &mut None, &mut None, KeyCode::Enter, 24); } diff --git a/src/input/menu_actions.rs b/src/input/menu_actions.rs index 5e248d5..7165a8e 100644 --- a/src/input/menu_actions.rs +++ b/src/input/menu_actions.rs @@ -11,7 +11,7 @@ use super::directory_tree::set_tree_diagnostic_status; use crate::app::panel_ops::{current_visible_height, rebuild_visible_entries, with_menu_panel}; pub fn execute_menu_action(state: &mut AppState) -> Option<(KeyCode, KeyModifiers, bool)> { - let action = menu_action_at(state.menu_selected, state.menu_item_selected)?; + let action = menu_action_at(state.ui.menu_selected, state.ui.menu_item_selected)?; match action { MenuAction::ToggleListingMode | MenuAction::CycleSortOrder @@ -51,22 +51,22 @@ pub fn execute_menu_action(state: &mut AppState) -> Option<(KeyCode, KeyModifier { state.hotlist_push(state.active_panel().path().to_path_buf()); } - state.status_message = + state.ui.status_message = Some("Path added to hotlist (run Save Setup to persist)".to_string()); }); None } MenuAction::Quit => { - state.should_quit = true; + state.request_quit(); None } MenuAction::SaveSetup => { match config::save_setup(state) { Ok(path) => { - state.status_message = Some(format!("Setup saved to {}", path.display())); + state.ui.status_message = Some(format!("Setup saved to {}", path.display())); } Err(err) => { - state.status_message = Some(format!("Save setup failed: {err}")); + state.ui.status_message = Some(format!("Save setup failed: {err}")); } } None @@ -96,7 +96,7 @@ fn execute_panel_config_action( ListingMode::Long => "Long", ListingMode::Brief => "Brief", }; - state.status_message = Some(format!("Layout changed to {label}")); + state.ui.status_message = Some(format!("Layout changed to {label}")); }); None } @@ -110,7 +110,7 @@ fn execute_panel_config_action( } MenuAction::OpenFilter => { with_menu_panel(state, |state| { - state.dialog_input.set_text_at_end( + state.input.dialog_input.set_text_at_end( state .active_panel() .filter() @@ -129,7 +129,7 @@ fn execute_panel_config_action( let panel = state.active_panel_mut(); panel.set_filter(None); rebuild_visible_entries(panel, current_visible_height()); - state.status_message = Some("Panel filter reset".to_string()); + state.ui.status_message = Some("Panel filter reset".to_string()); }); None } @@ -138,7 +138,7 @@ fn execute_panel_config_action( let panel = state.active_panel_mut(); let show = !panel.show_permissions(); panel.set_show_permissions(show); - state.status_message = + state.ui.status_message = Some(format!("Permissions: {}", if show { "ON" } else { "OFF" })); }); None @@ -161,17 +161,17 @@ fn execute_nav_action( TREE_INITIAL_EXPAND_DEPTH, show_hidden, ); - state.tree_root = path; - state.tree_entries = tree.entries; - state.tree_selected = 0; - state.tree_scroll = 0; + state.tree.root = path; + state.tree.entries = tree.entries; + state.tree.selected = 0; + state.tree.scroll = 0; state.mode = AppMode::DirectoryTree; - set_tree_diagnostic_status(&mut state.status_message, &tree.diagnostics); + set_tree_diagnostic_status(&mut state.ui.status_message, &tree.diagnostics); }); None } MenuAction::FindFile => { - state.dialog_input.clear(); + state.input.dialog_input.clear(); state.mode = AppMode::Dialog(DialogKind::Input { prompt: "Find file:".to_string(), action: InputAction::FindFile, @@ -179,17 +179,17 @@ fn execute_nav_action( None } MenuAction::CompareDirs => { - state.picker_selected = 0; + state.ui.picker_selected = 0; state.mode = AppMode::ListPicker(PickerKind::CompareMode); None } MenuAction::History => { - state.picker_selected = 0; + state.ui.picker_selected = 0; state.mode = AppMode::ListPicker(PickerKind::History); None } MenuAction::DirectoryHotlist => { - state.picker_selected = 0; + state.ui.picker_selected = 0; state.mode = AppMode::ListPicker(PickerKind::Hotlist); None } @@ -212,7 +212,7 @@ fn execute_dialog_action( if let Some(name) = entry_name && name != ".." { - state.dialog_input.set_text_at_end(name); + state.input.dialog_input.set_text_at_end(name); state.mode = AppMode::Dialog(DialogKind::Input { prompt: "Rename to:".to_string(), action: InputAction::Rename, @@ -231,6 +231,7 @@ fn execute_dialog_action( && name != ".." { state + .input .dialog_input .set_text_at_end(format!("{:o}", permissions & 0o7777)); state.mode = AppMode::Dialog(DialogKind::Input { @@ -279,11 +280,11 @@ pub fn open_user_menu(state: &mut AppState) { parts.push("Local .mc.menu loaded — commands require confirmation".to_string()); } if !parts.is_empty() { - state.status_message = Some(parts.join(" | ")); + state.ui.status_message = Some(parts.join(" | ")); } - state.user_menu_source = loaded.source; + state.ui.user_menu_source = loaded.source; state.user_menu_set(loaded.entries); - state.picker_selected = 0; + state.ui.picker_selected = 0; state.mode = AppMode::ListPicker(PickerKind::UserMenu); } Err(msg) => { diff --git a/src/input/mode_dispatch.rs b/src/input/mode_dispatch.rs index be2f352..adb06a3 100644 --- a/src/input/mode_dispatch.rs +++ b/src/input/mode_dispatch.rs @@ -17,7 +17,7 @@ const HORIZONTAL_SCROLL_STEP: usize = 4; fn refresh_or_rebuild(state: &mut AppState, visible_height: usize) { let needs_refresh = { let panel = state.active_panel(); - panel.listing.needs_full_read() || panel.listing.unfiltered_entries.is_empty() + panel.listing.needs_full_read() || panel.listing.unfiltered().is_empty() }; if needs_refresh { panel_ops::refresh_active(state); @@ -28,8 +28,8 @@ fn refresh_or_rebuild(state: &mut AppState, visible_height: usize) { pub(crate) fn clear_search_state(state: &mut AppState, visible_height: usize) { state.restore_prev_mode(); - state.search_query.clear(); - state.search_cursor = 0; + state.input.search_query.clear(); + state.input.search_cursor = 0; state.active_panel_mut().set_filter(None); refresh_or_rebuild(state, visible_height); } @@ -39,7 +39,7 @@ fn set_active_panel_filter(state: &mut AppState, filter: String) { } fn apply_search_filter(state: &mut AppState, visible: usize) { - let filter_query = state.search_query.clone(); + let filter_query = state.input.search_query.clone(); set_active_panel_filter(state, filter_query); refresh_or_rebuild(state, visible); } @@ -51,9 +51,9 @@ pub(crate) fn initiate_search( visible_height: usize, ) { state.prev_mode = Some(prev_mode); - state.search_query.push(c); - state.search_cursor = state.search_query.len(); - let filter_query = state.search_query.clone(); + state.input.search_query.push(c); + state.input.search_cursor = state.input.search_query.len(); + let filter_query = state.input.search_query.clone(); state.mode = AppMode::Search; set_active_panel_filter(state, filter_query); refresh_or_rebuild(state, visible_height); @@ -149,6 +149,7 @@ pub(crate) fn handle_viewer_mode( KeyCode::Char('N') => vs.prev_match(page_height), KeyCode::Char('/') => { state + .input .dialog_input .set_text_at_end(vs.search_query.as_deref().unwrap_or("").to_owned()); state.mode = AppMode::Dialog(DialogKind::Input { @@ -174,17 +175,17 @@ pub(crate) fn handle_search_mode(state: &mut AppState, key: KeyCode, terminal_he clear_search_state(state, visible); } KeyCode::Backspace => { - state.search_query.pop(); - state.search_cursor = state.search_query.len(); - if state.search_query.is_empty() { + state.input.search_query.pop(); + state.input.search_cursor = state.input.search_query.len(); + if state.input.search_query.is_empty() { clear_search_state(state, visible); } else { apply_search_filter(state, visible); } } KeyCode::Char(c) => { - state.search_query.push(c); - state.search_cursor = state.search_query.len(); + state.input.search_query.push(c); + state.input.search_cursor = state.input.search_query.len(); apply_search_filter(state, visible); } _ => {} @@ -239,7 +240,7 @@ pub(crate) fn handle_menu_mode( terminal: &mut ratatui::Terminal, ) { let total = MENUS.len(); - let max_items = menu_item_count(state.menu_selected); + let max_items = menu_item_count(state.ui.menu_selected); if max_items == 0 { state.mode = AppMode::Normal; return; @@ -250,26 +251,26 @@ pub(crate) fn handle_menu_mode( state.restore_prev_mode(); } KeyCode::Left => { - state.menu_selected = if state.menu_selected == 0 { + state.ui.menu_selected = if state.ui.menu_selected == 0 { total - 1 } else { - state.menu_selected - 1 + state.ui.menu_selected - 1 }; - state.menu_item_selected = 0; + state.ui.menu_item_selected = 0; } KeyCode::Right => { - state.menu_selected = (state.menu_selected + 1) % total; - state.menu_item_selected = 0; + state.ui.menu_selected = (state.ui.menu_selected + 1) % total; + state.ui.menu_item_selected = 0; } KeyCode::Up => { - state.menu_item_selected = if state.menu_item_selected == 0 { + state.ui.menu_item_selected = if state.ui.menu_item_selected == 0 { max_items - 1 } else { - state.menu_item_selected - 1 + state.ui.menu_item_selected - 1 }; } KeyCode::Down => { - state.menu_item_selected = (state.menu_item_selected + 1) % max_items; + state.ui.menu_item_selected = (state.ui.menu_item_selected + 1) % max_items; } KeyCode::Enter => { run_selected_menu_action( diff --git a/src/input/mouse.rs b/src/input/mouse.rs index 2746120..5b6bed3 100644 --- a/src/input/mouse.rs +++ b/src/input/mouse.rs @@ -201,7 +201,7 @@ fn handle_mouse_scroll( state.active_panel = ActivePanel::Right; } let panel = state.active_panel_mut(); - let len = panel.listing.entries.len(); + let len = panel.listing.filtered_len(); match kind { MouseEventKind::ScrollUp => { panel.cursor = panel.cursor.saturating_sub(SCROLL_LINES); @@ -359,25 +359,25 @@ fn handle_confirm_click( if geo.hit_button_row(pos) { let new_sel = if pos.col < geo.btn_center { 0 } else { 1 }; - if state.dialog_selection == new_sel { + if state.input.dialog_selection == new_sel { if new_sel == 0 { - if state.pending_action.is_some() { + if state.ui.pending_action.is_some() { if let Some(conflicting) = check_overwrite_conflict(state) { - state.dialog_selection = 0; + state.input.dialog_selection = 0; state.mode = AppMode::Dialog(DialogKind::OverwriteConfirm(Box::new( OverwriteConfirmDetails { conflicting }, ))); return Some(MouseOutcome::Consumed); } - let status_message = state.status_message.take(); + let status_message = state.ui.status_message.take(); start_confirmed_action(state, running_job); - if state.status_message.is_none() { - state.status_message = status_message; + if state.ui.status_message.is_none() { + state.ui.status_message = status_message; } finish_confirmed_action(state); return Some(MouseOutcome::Consumed); } - if let Some(cmd) = state.pending_menu_command.take() { + if let Some(cmd) = state.ui.pending_menu_command.take() { state.mode = AppMode::Normal; shell::run_shell_command(state, &cmd, true, refresh_active); return Some(MouseOutcome::Consumed); @@ -388,7 +388,7 @@ fn handle_confirm_click( dismiss_dialog(state); } } else { - state.dialog_selection = new_sel; + state.input.dialog_selection = new_sel; } } Some(MouseOutcome::Consumed) @@ -403,11 +403,11 @@ fn handle_overwrite_click( if geo.hit_button_row(pos) { let new_sel = if pos.col < geo.btn_center { 0 } else { 1 }; - if state.dialog_selection == new_sel { + if state.input.dialog_selection == new_sel { match new_sel { 0 => { - if let Some(action) = state.pending_action.as_mut() { - action.set_overwrite(); + if let Some(a) = &mut state.ui.pending_action { + a.set_overwrite(); } start_confirmed_action(state, running_job); finish_confirmed_action(state); @@ -418,7 +418,7 @@ fn handle_overwrite_click( _ => {} } } else { - state.dialog_selection = new_sel; + state.input.dialog_selection = new_sel; } } Some(MouseOutcome::Consumed) @@ -435,7 +435,7 @@ fn handle_progress_click( && let Some(job) = running_job.as_ref() { job.cancel.store(true, std::sync::atomic::Ordering::Relaxed); - state.status_message = Some("Cancel requested".to_string()); + state.ui.status_message = Some("Cancel requested".to_string()); } Some(MouseOutcome::Consumed) } @@ -454,8 +454,8 @@ fn handle_mouse_menu_bar(state: &mut AppState, pos: &MousePosition) -> Option= x_offset && pos.col < x_offset + title_width { - state.menu_selected = i; - state.menu_item_selected = 0; + state.ui.menu_selected = i; + state.ui.menu_item_selected = 0; if state.mode != AppMode::Menu { state.prev_mode = Some(state.mode.clone()); state.mode = AppMode::Menu; @@ -470,7 +470,7 @@ fn handle_mouse_menu_dropdown(state: &mut AppState, pos: &MousePosition) -> Opti if !matches!(state.mode, AppMode::Menu) || pos.row < 1 { return None; } - let items = MENUS[state.menu_selected].items; + let items = MENUS[state.ui.menu_selected].items; let dropdown_width = items .iter() .map(|s| UnicodeWidthStr::width(*s)) @@ -478,7 +478,7 @@ fn handle_mouse_menu_dropdown(state: &mut AppState, pos: &MousePosition) -> Opti .unwrap_or(10) as u16 + 4; let menu_bar_area = Rect::new(0, 0, pos.width, 1); - let dropdown_x = menu_dropdown_x(menu_bar_area, state.menu_selected, dropdown_width); + let dropdown_x = menu_dropdown_x(menu_bar_area, state.ui.menu_selected, dropdown_width); let inner_x = dropdown_x + 1; let inner_y = 2u16; @@ -487,7 +487,10 @@ fn handle_mouse_menu_dropdown(state: &mut AppState, pos: &MousePosition) -> Opti let max_visible = pos.height.saturating_sub(1); let dropdown_height = ((items.len().min(u16::MAX as usize - 2)) as u16 + 2).min(max_visible); let visible_items = dropdown_height.saturating_sub(2) as usize; - let clamped_selected = state.menu_item_selected.min(items.len().saturating_sub(1)); + let clamped_selected = state + .ui + .menu_item_selected + .min(items.len().saturating_sub(1)); let scroll_offset = if items.len() <= visible_items { 0 } else { @@ -501,7 +504,7 @@ fn handle_mouse_menu_dropdown(state: &mut AppState, pos: &MousePosition) -> Opti { let item_idx = scroll_offset + (pos.row - inner_y) as usize; if item_idx < items.len() { - state.menu_item_selected = item_idx; + state.ui.menu_item_selected = item_idx; return Some(MouseOutcome::MenuAction); } } @@ -570,29 +573,28 @@ fn handle_mouse_panels( let relative_row = pos.row.saturating_sub(list_start_row); let clicked_index = panel.scroll_offset + relative_row as usize; - if clicked_index >= panel.listing.entries.len() { + if clicked_index >= panel.listing.filtered_len() { return; } let now = std::time::Instant::now(); - let is_double_click = if let Some(last_time) = state.last_click_time { - if let Some(last_pos) = state.last_click_position { - last_pos.0 == pos.col - && last_pos.1 == pos.row - && now.duration_since(last_time) < Duration::from_millis(DOUBLE_CLICK_THRESHOLD_MS) - } else { - false - } + let is_double_click = if let Some((last_time, last_pos)) = state.interaction.last_click { + last_pos.0 == pos.col + && last_pos.1 == pos.row + && now.duration_since(last_time) < Duration::from_millis(DOUBLE_CLICK_THRESHOLD_MS) } else { false }; if is_double_click { - state.last_click_time = None; - state.last_click_position = None; - state.drag_anchor_index = None; + state.interaction.last_click = None; + state.interaction.drag_anchor_index = None; - let entry = &panel.listing.entries[clicked_index]; + // Bail out gracefully if the entry vanished between the bounds check + // and here (e.g. a concurrent refresh) instead of panicking. + let Some(entry) = panel.listing.filtered_get(clicked_index) else { + return; + }; let is_dir = entry.is_dir(); let path = entry.path.clone(); if is_dir { @@ -612,9 +614,8 @@ fn handle_mouse_panels( state.mode = AppMode::Viewing; } } else { - state.last_click_time = Some(now); - state.last_click_position = Some((pos.col, pos.row)); - state.drag_anchor_index = Some(clicked_index); + state.interaction.last_click = Some((now, (pos.col, pos.row))); + state.interaction.drag_anchor_index = Some(clicked_index); let panel_mut = state.active_panel_mut(); panel_mut.cursor = clicked_index; @@ -632,7 +633,7 @@ fn handle_mouse_drag(state: &mut AppState, pos: &MousePosition) { } let (panel_start_row, panel_end_row) = panel_bounds(pos.height); - let anchor = match state.drag_anchor_index { + let anchor = match state.interaction.drag_anchor_index { Some(idx) => idx, None => return, }; @@ -654,7 +655,7 @@ fn handle_mouse_drag(state: &mut AppState, pos: &MousePosition) { let relative_row = pos.row.saturating_sub(list_start_row); let current_index = panel.scroll_offset + relative_row as usize; - if current_index >= panel.listing.entries.len() { + if current_index >= panel.listing.filtered_len() { return; } @@ -671,9 +672,8 @@ fn handle_mouse_drag(state: &mut AppState, pos: &MousePosition) { } fn handle_mouse_up(state: &mut AppState) { - state.drag_anchor_index = None; - state.last_click_time = None; - state.last_click_position = None; + state.interaction.drag_anchor_index = None; + state.interaction.last_click = None; } #[cfg(test)] diff --git a/src/input/mouse/tests.rs b/src/input/mouse/tests.rs index 29ce234..2ac6375 100644 --- a/src/input/mouse/tests.rs +++ b/src/input/mouse/tests.rs @@ -2,8 +2,9 @@ use super::*; use crate::app::types::{ - ArchiveCreateDetails, ArchiveExtractDetails, ConfirmDetails, DialogKind, InputAction, - OverwriteConfirmDetails, PendingAction, PropertiesDetails, TextInput, TransferAction, + ArchiveCreateDetails, ArchiveExtractDetails, ConfirmDetails, DialogKind, FileKind, InputAction, + InputState, InteractionState, OverwriteConfirmDetails, PendingAction, PropertiesDetails, + TextInput, TransferAction, UiState, }; fn mp(col: u16, row: u16, width: u16, height: u16) -> MousePosition { @@ -96,11 +97,14 @@ fn mouse_input_dialog_outside_preserves_text() { prompt: "Name:".to_string(), action: InputAction::CreateDirectory, }), - dialog_input: { - let mut ti = TextInput::new(); - ti.set_text("draft".to_string()); - ti.set_cursor(5); - ti + input: InputState { + dialog_input: { + let mut ti = TextInput::new(); + ti.set_text("draft".to_string()); + ti.set_cursor(5); + ti + }, + ..Default::default() }, ..Default::default() }; @@ -111,8 +115,8 @@ fn mouse_input_dialog_outside_preserves_text() { state.mode, AppMode::Dialog(DialogKind::Input { .. }) )); - assert_eq!(state.dialog_input.text(), "draft"); - assert_eq!(state.dialog_input.cursor(), 5); + assert_eq!(state.input.dialog_input.text(), "draft"); + assert_eq!(state.input.dialog_input.cursor(), 5); } #[test] @@ -122,10 +126,13 @@ fn mouse_input_dialog_inside_consumes_click() { prompt: "Name:".to_string(), action: InputAction::CreateDirectory, }), - dialog_input: { - let mut ti = TextInput::new(); - ti.set_text("draft".to_string()); - ti + input: InputState { + dialog_input: { + let mut ti = TextInput::new(); + ti.set_text("draft".to_string()); + ti + }, + ..Default::default() }, ..Default::default() }; @@ -136,7 +143,7 @@ fn mouse_input_dialog_inside_consumes_click() { state.mode, AppMode::Dialog(DialogKind::Input { .. }) )); - assert_eq!(state.dialog_input.text(), "draft"); + assert_eq!(state.input.dialog_input.text(), "draft"); } #[test] @@ -235,8 +242,7 @@ fn mouse_properties_dialog_click_does_not_dismiss() { permissions: 0o644, owner: String::new(), group: String::new(), - is_dir: false, - is_symlink: false, + kind: FileKind::from_metadata_flags(false, false), }))), ..Default::default() }; @@ -273,7 +279,10 @@ fn mouse_confirm_dialog_keeps_existing_behavior() { mode: AppMode::Dialog(DialogKind::Confirm(ConfirmDetails::simple( "Confirm", "Run?", ))), - dialog_selection: 1, + input: InputState { + dialog_selection: 1, + ..Default::default() + }, ..Default::default() }; let mut running_job = None; @@ -293,7 +302,10 @@ fn mouse_overwrite_confirm_dialog_handled() { conflicting: vec![], }, ))), - dialog_selection: 0, + input: InputState { + dialog_selection: 0, + ..Default::default() + }, ..Default::default() }; let mut running_job = None; @@ -351,13 +363,16 @@ fn mouse_scroll_handles_help_dialog() { #[test] fn mouse_up_clears_drag_anchor() { let mut state = AppState { - drag_anchor_index: Some(5), + interaction: InteractionState { + drag_anchor_index: Some(5), + ..Default::default() + }, ..Default::default() }; handle_mouse_up(&mut state); - assert!(state.drag_anchor_index.is_none()); + assert!(state.interaction.drag_anchor_index.is_none()); } #[test] @@ -370,26 +385,23 @@ fn drag_select_range() { mk_entry("e"), ]; let mut left_panel = crate::app::types::PanelState::new(std::path::PathBuf::from("/")); - left_panel.listing.entries = entries.clone(); + left_panel.set_entries(entries.clone()); let mut right_panel = crate::app::types::PanelState::new(std::path::PathBuf::from("/")); - right_panel.listing.entries = entries; + right_panel.set_entries(entries); let mut state = AppState { left_panel, right_panel, - drag_anchor_index: Some(0), + interaction: InteractionState { + drag_anchor_index: Some(0), + ..Default::default() + }, ..Default::default() }; handle_mouse_drag(&mut state, &mp(1, 5, 80, 24)); - let selected: Vec<_> = state - .left_panel - .listing - .entries - .iter() - .filter(|e| e.selected) - .collect(); - assert_eq!(selected.len(), 4); + let selected_count = state.left_panel.selected_entries().count(); + assert_eq!(selected_count, 4); } #[test] @@ -478,12 +490,18 @@ fn mouse_confirm_click_with_overwrite_conflict_shows_overwrite_dialog() { mode: AppMode::Dialog(DialogKind::Confirm(ConfirmDetails::simple( "Copy", "Proceed?", ))), - dialog_selection: 0, - pending_action: Some(PendingAction::Copy(TransferAction { - sources: vec![dirs.src.join("clash.txt")], - dest: dirs.dest, - overwrite: false, - })), + input: InputState { + dialog_selection: 0, + ..Default::default() + }, + ui: UiState { + pending_action: Some(PendingAction::Copy(TransferAction { + sources: vec![dirs.src.join("clash.txt")], + dest: dirs.dest, + overwrite: false, + })), + ..Default::default() + }, ..Default::default() }; let mut running_job = None; @@ -503,7 +521,7 @@ fn mouse_confirm_click_with_overwrite_conflict_shows_overwrite_dialog() { state.mode, AppMode::Dialog(DialogKind::OverwriteConfirm(..)) )); - assert!(state.pending_action.is_some()); + assert!(state.ui.pending_action.is_some()); } #[test] @@ -515,12 +533,18 @@ fn mouse_confirm_click_without_conflict_starts_action() { mode: AppMode::Dialog(DialogKind::Confirm(ConfirmDetails::simple( "Copy", "Proceed?", ))), - dialog_selection: 0, - pending_action: Some(PendingAction::Copy(TransferAction { - sources: vec![dirs.src.join("unique.txt")], - dest: dirs.dest, - overwrite: false, - })), + input: InputState { + dialog_selection: 0, + ..Default::default() + }, + ui: UiState { + pending_action: Some(PendingAction::Copy(TransferAction { + sources: vec![dirs.src.join("unique.txt")], + dest: dirs.dest, + overwrite: false, + })), + ..Default::default() + }, ..Default::default() }; let mut running_job = None; @@ -551,13 +575,19 @@ fn mouse_confirm_click_preserves_status_message() { mode: AppMode::Dialog(DialogKind::Confirm(ConfirmDetails::simple( "Copy", "Proceed?", ))), - dialog_selection: 0, - status_message: Some("Queued".to_string()), - pending_action: Some(PendingAction::Copy(TransferAction { - sources: vec![dirs.src.join("unique.txt")], - dest: dirs.dest, - overwrite: false, - })), + input: InputState { + dialog_selection: 0, + ..Default::default() + }, + ui: UiState { + status_message: Some("Queued".to_string()), + pending_action: Some(PendingAction::Copy(TransferAction { + sources: vec![dirs.src.join("unique.txt")], + dest: dirs.dest, + overwrite: false, + })), + ..Default::default() + }, ..Default::default() }; let mut running_job = None; @@ -573,7 +603,7 @@ fn mouse_confirm_click_preserves_status_message() { ); assert!(matches!(outcome, Some(MouseOutcome::Consumed))); - assert_eq!(state.status_message.as_deref(), Some("Queued")); + assert_eq!(state.ui.status_message.as_deref(), Some("Queued")); assert!(matches!( state.mode, AppMode::Dialog(DialogKind::Progress { .. }) @@ -596,12 +626,18 @@ fn mouse_confirm_click_keeps_new_status_message() { mode: AppMode::Dialog(DialogKind::Confirm(ConfirmDetails::simple( "Copy", "Proceed?", ))), - dialog_selection: 0, - pending_action: Some(PendingAction::Copy(TransferAction { - sources: vec![first_src_dir.join("first.txt")], - dest: dest_dir.clone(), - overwrite: false, - })), + input: InputState { + dialog_selection: 0, + ..Default::default() + }, + ui: UiState { + pending_action: Some(PendingAction::Copy(TransferAction { + sources: vec![first_src_dir.join("first.txt")], + dest: dest_dir.clone(), + overwrite: false, + })), + ..Default::default() + }, ..Default::default() }; let mut running_job = None; @@ -620,8 +656,8 @@ fn mouse_confirm_click_keeps_new_status_message() { state.mode = AppMode::Dialog(DialogKind::Confirm(ConfirmDetails::simple( "Copy", "Proceed?", ))); - state.status_message = Some("Queued".to_string()); - state.pending_action = Some(PendingAction::Copy(TransferAction { + state.ui.status_message = Some("Queued".to_string()); + state.ui.pending_action = Some(PendingAction::Copy(TransferAction { sources: vec![second_src_dir.join("second.txt")], dest: dest_dir, overwrite: false, @@ -635,7 +671,7 @@ fn mouse_confirm_click_keeps_new_status_message() { assert!(matches!(outcome, Some(MouseOutcome::Consumed))); assert_eq!( - state.status_message.as_deref(), + state.ui.status_message.as_deref(), Some("Another job is already running") ); } diff --git a/src/input/normal.rs b/src/input/normal.rs index af0455c..2ec2ef3 100644 --- a/src/input/normal.rs +++ b/src/input/normal.rs @@ -62,17 +62,17 @@ pub(crate) fn handle_function_keys( } KeyCode::F(9) => { state.prev_mode = Some(std::mem::replace(&mut state.mode, AppMode::Menu)); - state.menu_item_selected = 0; + state.ui.menu_item_selected = 0; } KeyCode::F(10) => { - state.should_quit = true; + state.request_quit(); } KeyCode::F(11) => { let entry_name = state.active_panel().current_entry().map(|e| e.name.clone()); if let Some(name) = entry_name && name != ".." { - state.dialog_input.set_text_at_end(name); + state.input.dialog_input.set_text_at_end(name); state.mode = AppMode::Dialog(lc::app::types::DialogKind::Input { prompt: "Rename to:".to_string(), action: InputAction::Rename, @@ -96,7 +96,7 @@ fn handle_f7_key(state: &mut AppState) { prompt: "Create directory:".to_string(), action: InputAction::CreateDirectory, }); - state.dialog_input.clear(); + state.input.dialog_input.clear(); } } @@ -118,7 +118,7 @@ fn handle_f12_key(state: &mut AppState) { ))); return; } - state.picker_selected = 0; + state.ui.picker_selected = 0; state.mode = AppMode::ListPicker(PickerKind::ArchiveMenu); } @@ -135,7 +135,7 @@ pub(crate) fn launch_editor( { let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string()); if let Err(e) = leave_tui_stdout() { - state.status_message = Some(format!("Terminal suspend failed: {e}")); + state.ui.status_message = Some(format!("Terminal suspend failed: {e}")); return; } if let Some(terminal_state_file) = terminal_state_file_path() { @@ -172,7 +172,7 @@ pub(crate) fn launch_editor( } match (status, resume_result) { (Err(e), _) => { - state.status_message = Some(format!("Editor error: {e}")); + state.ui.status_message = Some(format!("Editor error: {e}")); } (Ok(s), Err(e)) => { let mut parts = Vec::new(); @@ -180,10 +180,10 @@ pub(crate) fn launch_editor( parts.push(format!("Editor exited with status: {s}")); } parts.push(format!("Terminal restore failed: {e}")); - state.status_message = Some(parts.join("; ")); + state.ui.status_message = Some(parts.join("; ")); } (Ok(s), Ok(())) if !s.success() => { - state.status_message = Some(format!("Editor exited with status: {s}")); + state.ui.status_message = Some(format!("Editor exited with status: {s}")); } (Ok(_), Ok(_)) => {} } @@ -216,11 +216,11 @@ pub(crate) fn confirm_file_transfer( dest_dir.display() ) }; - state.dialog_selection = 0; + state.input.dialog_selection = 0; state.mode = AppMode::Dialog(lc::app::types::DialogKind::Confirm( lc::app::types::ConfirmDetails::with_files(label, &msg, file_names), )); - state.pending_action = Some(make_pending(paths, dest_dir)); + state.ui.pending_action = Some(make_pending(paths, dest_dir)); } pub(crate) fn confirm_delete(state: &mut AppState) { @@ -235,11 +235,11 @@ pub(crate) fn confirm_delete(state: &mut AppState) { } else { format!("Delete {} entries?", paths.len()) }; - state.dialog_selection = 0; + state.input.dialog_selection = 0; state.mode = AppMode::Dialog(lc::app::types::DialogKind::Confirm( lc::app::types::ConfirmDetails::with_files("Delete Confirm", &msg, file_names), )); - state.pending_action = Some(lc::app::types::PendingAction::Delete { paths }); + state.ui.pending_action = Some(lc::app::types::PendingAction::Delete { paths }); } pub(crate) fn handle_navigation_keys( @@ -251,7 +251,7 @@ pub(crate) fn handle_navigation_keys( match key { KeyCode::Up if modifiers.contains(KeyModifiers::SHIFT) => { let panel = state.active_panel_mut(); - if panel.listing.entries.is_empty() { + if panel.listing.filtered_is_empty() { return; } panel.toggle_selection_at(panel.cursor); @@ -259,7 +259,7 @@ pub(crate) fn handle_navigation_keys( } KeyCode::Down if modifiers.contains(KeyModifiers::SHIFT) => { let panel = state.active_panel_mut(); - if panel.listing.entries.is_empty() { + if panel.listing.filtered_is_empty() { return; } panel.toggle_selection_at(panel.cursor); @@ -279,7 +279,7 @@ pub(crate) fn handle_navigation_keys( p.scroll_offset = 0; } KeyCode::End => { - let len = state.active_panel().listing.entries.len(); + let len = state.active_panel().listing.filtered_len(); if len > 0 { let p = state.active_panel_mut(); p.cursor = len - 1; @@ -292,7 +292,7 @@ pub(crate) fn handle_navigation_keys( p.scroll_offset = p.scroll_offset.saturating_sub(visible); } KeyCode::PageDown => { - let len = state.active_panel().listing.entries.len(); + let len = state.active_panel().listing.filtered_len(); let p = state.active_panel_mut(); p.cursor = (p.cursor + visible).min(len.saturating_sub(1)); p.scroll_offset = (p.scroll_offset + visible).min(len.saturating_sub(visible)); @@ -300,17 +300,17 @@ pub(crate) fn handle_navigation_keys( KeyCode::Tab => { state.active_panel = state.active_panel.toggle(); let p = state.active_panel_mut(); - let max = p.listing.entries.len().saturating_sub(1); + let max = p.listing.filtered_len().saturating_sub(1); p.cursor = p.cursor.min(max); p.ensure_cursor_visible(visible); } KeyCode::Insert => { let panel = state.active_panel_mut(); - if panel.listing.entries.is_empty() { + if panel.listing.filtered_is_empty() { return; } panel.toggle_selection(); - if panel.cursor < panel.listing.entries.len() - 1 { + if panel.cursor < panel.listing.filtered_len() - 1 { panel.move_cursor_down(visible); } } @@ -323,17 +323,19 @@ pub(crate) fn reposition_cursor_to_entry( prev_dir_name: Option<&str>, visible: usize, ) { - if let Some(name) = prev_dir_name - && let Some(idx) = state + if let Some(name) = prev_dir_name { + // Resolve the index first so the borrowing iterator is dropped before the + // mutable panel access below. + let idx = state .active_panel() .listing - .entries - .iter() - .position(|e| e.name == name) - { - let p = state.active_panel_mut(); - p.cursor = idx; - p.ensure_cursor_visible(visible); + .filtered() + .position(|e| e.name == name); + if let Some(idx) = idx { + let p = state.active_panel_mut(); + p.cursor = idx; + p.ensure_cursor_visible(visible); + } } } @@ -377,7 +379,7 @@ pub(crate) fn show_archive_dialog(state: &mut AppState) { let entries = match archive::list::list_archive(&source) { Ok(list) => list, Err(e) => { - state.status_message = Some(format!("Failed to list archive: {e}")); + state.ui.status_message = Some(format!("Failed to list archive: {e}")); return; } }; @@ -400,13 +402,9 @@ pub(crate) fn handle_ctrl_keys(state: &mut AppState, key: KeyCode, terminal_heig state.active_panel = state.active_panel.toggle(); } KeyCode::Char('s') => { - let panel = state.active_panel_mut(); - if panel.listing.unfiltered_entries.is_empty() { - panel.listing.set_unfiltered(panel.listing.entries.clone()); - } state.mode = AppMode::Search; - state.search_query.clear(); - state.search_cursor = 0; + state.input.search_query.clear(); + state.input.search_cursor = 0; } KeyCode::Char('h') => { let visible = panel_ops::panel_visible_height(terminal_height); @@ -421,7 +419,7 @@ pub(crate) fn handle_ctrl_keys(state: &mut AppState, key: KeyCode, terminal_heig } KeyCode::Char('o') => { if let Err(e) = shell::toggle_external_view(state, panel_ops::refresh_both) { - state.status_message = Some(format!("External view error: {e}")); + state.ui.status_message = Some(format!("External view error: {e}")); } } _ => {} @@ -442,8 +440,10 @@ pub(crate) fn handle_alt_keys(state: &mut AppState, key: KeyCode, visible: usize permissions: entry.mode_bits(), owner: entry.owner.to_string(), group: entry.group.to_string(), - is_dir: entry.is_dir(), - is_symlink: entry.is_symlink(), + kind: lc::app::types::FileKind::from_metadata_flags( + entry.is_dir(), + entry.is_symlink(), + ), }, ))); } @@ -458,7 +458,7 @@ pub(crate) fn handle_alt_keys(state: &mut AppState, key: KeyCode, visible: usize panel.scroll_offset = 0; panel_ops::refresh_active(state); reposition_cursor_to_entry(state, prev_dir_name.as_deref(), visible); - state.status_message = Some(format!("cd to {}", prev_path.display())); + state.ui.status_message = Some(format!("cd to {}", prev_path.display())); } else { panel.push_history(prev_path); } @@ -473,6 +473,7 @@ pub(crate) fn handle_alt_keys(state: &mut AppState, key: KeyCode, visible: usize action: InputAction::QuickCd, }); state + .input .dialog_input .set_text_at_end(state.active_panel().path().display().to_string()); } @@ -498,7 +499,6 @@ pub(crate) fn selected_or_current_paths(state: &AppState) -> Vec { let selected: Vec = panel .selected_entries() - .into_iter() .filter(|entry| is_not_parent_dir(entry)) .map(|entry| entry.path.clone()) .collect(); diff --git a/src/input/pickers.rs b/src/input/pickers.rs index 9f1ab39..f75a3a0 100644 --- a/src/input/pickers.rs +++ b/src/input/pickers.rs @@ -41,19 +41,19 @@ fn handle_history_picker(state: &mut AppState, key: KeyCode, len: usize) { KeyCode::Esc => { state.mode = AppMode::Normal; } - KeyCode::Up => move_cursor(len, &mut state.picker_selected, MoveDirection::Up), - KeyCode::Down => move_cursor(len, &mut state.picker_selected, MoveDirection::Down), - KeyCode::Home => move_cursor(len, &mut state.picker_selected, MoveDirection::Home), - KeyCode::End => move_cursor(len, &mut state.picker_selected, MoveDirection::End), + KeyCode::Up => move_cursor(len, &mut state.ui.picker_selected, MoveDirection::Up), + KeyCode::Down => move_cursor(len, &mut state.ui.picker_selected, MoveDirection::Down), + KeyCode::Home => move_cursor(len, &mut state.ui.picker_selected, MoveDirection::Home), + KeyCode::End => move_cursor(len, &mut state.ui.picker_selected, MoveDirection::End), KeyCode::Enter => { - if state.picker_selected >= len { + if state.ui.picker_selected >= len { state.mode = AppMode::Normal; return; } // History displays most-recent-first; reverse visual index to VecDeque position - let idx = len - 1 - state.picker_selected; - if let Some(cmd) = state.command_history.get(idx).cloned() { - state.command_line.set_text_at_end(cmd); + let idx = len - 1 - state.ui.picker_selected; + if let Some(cmd) = state.input.command_history.get(idx).cloned() { + state.input.command_line.set_text_at_end(cmd); state.mode = AppMode::CommandLine; } else { state.mode = AppMode::Normal; @@ -68,28 +68,28 @@ fn handle_hotlist_picker(state: &mut AppState, key: KeyCode, len: usize) { KeyCode::Esc => { state.mode = AppMode::Normal; } - KeyCode::Up => move_cursor(len, &mut state.picker_selected, MoveDirection::Up), - KeyCode::Down => move_cursor(len, &mut state.picker_selected, MoveDirection::Down), - KeyCode::Home => move_cursor(len, &mut state.picker_selected, MoveDirection::Home), - KeyCode::End => move_cursor(len, &mut state.picker_selected, MoveDirection::End), + KeyCode::Up => move_cursor(len, &mut state.ui.picker_selected, MoveDirection::Up), + KeyCode::Down => move_cursor(len, &mut state.ui.picker_selected, MoveDirection::Down), + KeyCode::Home => move_cursor(len, &mut state.ui.picker_selected, MoveDirection::Home), + KeyCode::End => move_cursor(len, &mut state.ui.picker_selected, MoveDirection::End), KeyCode::Enter => { - let idx = state.picker_selected; + let idx = state.ui.picker_selected; state.mode = AppMode::Normal; navigate_to_hotlist(state, idx); } KeyCode::Char('a') => { let cur = state.active_panel().path().to_path_buf(); if state.hotlist().iter().any(|p| p == &cur) { - state.status_message = Some("Directory already in hotlist".to_string()); + state.ui.status_message = Some("Directory already in hotlist".to_string()); } else { state.hotlist_push(cur); - state.status_message = Some("Added current directory to hotlist".to_string()); + state.ui.status_message = Some("Added current directory to hotlist".to_string()); } } - KeyCode::Char('d') if state.picker_selected < state.hotlist().len() => { - state.hotlist_remove(state.picker_selected); - if state.picker_selected > 0 && state.picker_selected >= state.hotlist().len() { - state.picker_selected -= 1; + KeyCode::Char('d') if state.ui.picker_selected < state.hotlist().len() => { + state.hotlist_remove(state.ui.picker_selected); + if state.ui.picker_selected > 0 && state.ui.picker_selected >= state.hotlist().len() { + state.ui.picker_selected -= 1; } } _ => {} @@ -103,12 +103,12 @@ fn handle_compare_mode_picker(state: &mut AppState, key: KeyCode) { KeyCode::Esc => { state.mode = AppMode::Normal; } - KeyCode::Up => move_cursor(len, &mut state.picker_selected, MoveDirection::Up), - KeyCode::Down => move_cursor(len, &mut state.picker_selected, MoveDirection::Down), - KeyCode::Home => move_cursor(len, &mut state.picker_selected, MoveDirection::Home), - KeyCode::End => move_cursor(len, &mut state.picker_selected, MoveDirection::End), + KeyCode::Up => move_cursor(len, &mut state.ui.picker_selected, MoveDirection::Up), + KeyCode::Down => move_cursor(len, &mut state.ui.picker_selected, MoveDirection::Down), + KeyCode::Home => move_cursor(len, &mut state.ui.picker_selected, MoveDirection::Home), + KeyCode::End => move_cursor(len, &mut state.ui.picker_selected, MoveDirection::End), KeyCode::Enter => { - let Some(&chosen) = modes.get(state.picker_selected) else { + let Some(&chosen) = modes.get(state.ui.picker_selected) else { state.mode = AppMode::Normal; return; }; @@ -120,19 +120,19 @@ fn handle_compare_mode_picker(state: &mut AppState, key: KeyCode) { } fn handle_user_menu_picker(state: &mut AppState, key: KeyCode) { - let len = state.user_menu_entries.len(); + let len = state.ui.user_menu_entries.len(); match key { KeyCode::Esc => { state.mode = AppMode::Normal; } - KeyCode::Up => move_cursor(len, &mut state.picker_selected, MoveDirection::Up), - KeyCode::Down => move_cursor(len, &mut state.picker_selected, MoveDirection::Down), - KeyCode::Home => move_cursor(len, &mut state.picker_selected, MoveDirection::Home), - KeyCode::End => move_cursor(len, &mut state.picker_selected, MoveDirection::End), + KeyCode::Up => move_cursor(len, &mut state.ui.picker_selected, MoveDirection::Up), + KeyCode::Down => move_cursor(len, &mut state.ui.picker_selected, MoveDirection::Down), + KeyCode::Home => move_cursor(len, &mut state.ui.picker_selected, MoveDirection::Home), + KeyCode::End => move_cursor(len, &mut state.ui.picker_selected, MoveDirection::End), KeyCode::Enter => { - let idx = state.picker_selected.min(len.saturating_sub(1)); + let idx = state.ui.picker_selected.min(len.saturating_sub(1)); state.mode = AppMode::Normal; - if let Some(entry) = state.user_menu_entries.get(idx).cloned() { + if let Some(entry) = state.ui.user_menu_entries.get(idx).cloned() { let active_dir = state.active_panel().path().to_path_buf(); let other_dir = state.inactive_panel().path().to_path_buf(); let current_file = state @@ -143,7 +143,6 @@ fn handle_user_menu_picker(state: &mut AppState, key: KeyCode) { let tagged: Vec = state .active_panel() .selected_entries() - .into_iter() .filter(|e| e.name != "..") .map(|e| e.path.clone()) .collect(); @@ -156,13 +155,13 @@ fn handle_user_menu_picker(state: &mut AppState, key: KeyCode) { let cmd = match user_menu::apply_substitutions(&entry.command, &ctx) { Ok(c) => c, Err(e) => { - state.status_message = Some(e); + state.ui.status_message = Some(e); return; } }; - if state.user_menu_source == MenuSource::Local { - state.pending_menu_command = Some(cmd); - state.dialog_selection = 0; + if state.ui.user_menu_source == MenuSource::Local { + state.ui.pending_menu_command = Some(cmd); + state.input.dialog_selection = 0; state.mode = AppMode::Dialog(DialogKind::Confirm(ConfirmDetails::simple( "Trust Local Menu?", "This menu comes from the current directory.\n\ @@ -185,12 +184,12 @@ fn handle_archive_menu_picker(state: &mut AppState, key: KeyCode) { KeyCode::Esc => { state.mode = AppMode::Normal; } - KeyCode::Up => move_cursor(len, &mut state.picker_selected, MoveDirection::Up), - KeyCode::Down => move_cursor(len, &mut state.picker_selected, MoveDirection::Down), - KeyCode::Home => move_cursor(len, &mut state.picker_selected, MoveDirection::Home), - KeyCode::End => move_cursor(len, &mut state.picker_selected, MoveDirection::End), + KeyCode::Up => move_cursor(len, &mut state.ui.picker_selected, MoveDirection::Up), + KeyCode::Down => move_cursor(len, &mut state.ui.picker_selected, MoveDirection::Down), + KeyCode::Home => move_cursor(len, &mut state.ui.picker_selected, MoveDirection::Home), + KeyCode::End => move_cursor(len, &mut state.ui.picker_selected, MoveDirection::End), KeyCode::Enter => { - let choice = state.picker_selected; + let choice = state.ui.picker_selected; state.mode = AppMode::Normal; match choice { 0 => { @@ -198,7 +197,7 @@ fn handle_archive_menu_picker(state: &mut AppState, key: KeyCode) { if entry.name != ".." && file_type::is_archive(&entry.name) { super::normal::show_archive_dialog(state); } else { - state.status_message = + state.ui.status_message = Some("Cursor is not on an archive file".to_string()); } } @@ -206,7 +205,7 @@ fn handle_archive_menu_picker(state: &mut AppState, key: KeyCode) { 1 => { let paths = super::normal::selected_or_current_paths(state); if paths.is_empty() { - state.status_message = Some("No files selected".to_string()); + state.ui.status_message = Some("No files selected".to_string()); } else { show_create_dialog(state, paths); } @@ -235,7 +234,7 @@ pub(crate) fn handle_list_picker(state: &mut AppState, key: KeyCode) { match kind { PickerKind::History => { - handle_history_picker(state, key, state.command_history.len()); + handle_history_picker(state, key, state.input.command_history.len()); } PickerKind::Hotlist => { handle_hotlist_picker(state, key, state.hotlist().len()); @@ -253,11 +252,9 @@ pub(crate) fn handle_list_picker(state: &mut AppState, key: KeyCode) { } fn effective_entries(panel: &PanelState) -> &[FileEntry] { - if panel.listing.unfiltered_entries.is_empty() { - &panel.listing.entries - } else { - &panel.listing.unfiltered_entries - } + // Single backing store: always compare over the full set, regardless of + // any active filter. + panel.listing.unfiltered() } pub(crate) fn compare_directories(state: &mut AppState, mode: CompareMode) { @@ -267,8 +264,8 @@ pub(crate) fn compare_directories(state: &mut AppState, mode: CompareMode) { ops::apply_compare_to_panels(&mut state.left_panel, &mut state.right_panel, &report); let mode_name = mode.label(); - state.status_message = None; - state.dialog_selection = 0; + state.ui.status_message = None; + state.input.dialog_selection = 0; state.mode = AppMode::Dialog(DialogKind::Confirm(ConfirmDetails::simple( "Compare Results", &format!( @@ -296,7 +293,10 @@ mod tests { fn history_picker_enter_empty_history() { let mut state = AppState { mode: AppMode::ListPicker(PickerKind::History), - picker_selected: 0, + ui: UiState { + picker_selected: 0, + ..Default::default() + }, ..Default::default() }; handle_list_picker(&mut state, KeyCode::Enter); @@ -307,28 +307,34 @@ mod tests { fn history_picker_navigate_bounds() { let mut state = AppState { mode: AppMode::ListPicker(PickerKind::History), - picker_selected: 0, + ui: UiState { + picker_selected: 0, + ..Default::default() + }, ..Default::default() }; - state.command_history.push_back("cmd1".to_string()); - state.command_history.push_back("cmd2".to_string()); + state.input.command_history.push_back("cmd1".to_string()); + state.input.command_history.push_back("cmd2".to_string()); handle_list_picker(&mut state, KeyCode::Up); - assert_eq!(state.picker_selected, 0); + assert_eq!(state.ui.picker_selected, 0); handle_list_picker(&mut state, KeyCode::Down); - assert_eq!(state.picker_selected, 1); + assert_eq!(state.ui.picker_selected, 1); handle_list_picker(&mut state, KeyCode::Down); - assert_eq!(state.picker_selected, 1); + assert_eq!(state.ui.picker_selected, 1); } #[test] fn hotlist_picker_empty_hotlist_enter() { let mut state = AppState { mode: AppMode::ListPicker(PickerKind::Hotlist), - directory_hotlist: vec![], - picker_selected: 0, + ui: UiState { + directory_hotlist: vec![], + picker_selected: 0, + ..Default::default() + }, ..Default::default() }; handle_list_picker(&mut state, KeyCode::Enter); @@ -339,34 +345,40 @@ mod tests { fn compare_mode_picker_navigate() { let mut state = AppState { mode: AppMode::ListPicker(PickerKind::CompareMode), - picker_selected: 0, + ui: UiState { + picker_selected: 0, + ..Default::default() + }, ..Default::default() }; handle_list_picker(&mut state, KeyCode::Down); - assert_eq!(state.picker_selected, 1); + assert_eq!(state.ui.picker_selected, 1); handle_list_picker(&mut state, KeyCode::Down); - assert_eq!(state.picker_selected, 2); + assert_eq!(state.ui.picker_selected, 2); handle_list_picker(&mut state, KeyCode::Down); - assert_eq!(state.picker_selected, 2); + assert_eq!(state.ui.picker_selected, 2); handle_list_picker(&mut state, KeyCode::Up); - assert_eq!(state.picker_selected, 1); + assert_eq!(state.ui.picker_selected, 1); handle_list_picker(&mut state, KeyCode::Up); - assert_eq!(state.picker_selected, 0); + assert_eq!(state.ui.picker_selected, 0); handle_list_picker(&mut state, KeyCode::Up); - assert_eq!(state.picker_selected, 0); + assert_eq!(state.ui.picker_selected, 0); } #[test] fn user_menu_picker_empty_list() { let mut state = AppState { mode: AppMode::ListPicker(PickerKind::UserMenu), - user_menu_entries: vec![], - picker_selected: 0, + ui: UiState { + user_menu_entries: vec![], + picker_selected: 0, + ..Default::default() + }, ..Default::default() }; handle_list_picker(&mut state, KeyCode::Down); - assert_eq!(state.picker_selected, 0); + assert_eq!(state.ui.picker_selected, 0); handle_list_picker(&mut state, KeyCode::Enter); assert_eq!(state.mode, AppMode::Normal); } @@ -375,12 +387,15 @@ mod tests { fn hotlist_picker_delete_empty_after_delete() { let mut state = AppState { mode: AppMode::ListPicker(PickerKind::Hotlist), - directory_hotlist: vec![PathBuf::from("/only")], - picker_selected: 0, + ui: UiState { + directory_hotlist: vec![PathBuf::from("/only")], + picker_selected: 0, + ..Default::default() + }, ..Default::default() }; handle_list_picker(&mut state, KeyCode::Char('d')); - assert!(state.directory_hotlist.is_empty()); - assert_eq!(state.picker_selected, 0); + assert!(state.ui.directory_hotlist.is_empty()); + assert_eq!(state.ui.picker_selected, 0); } } diff --git a/src/main.rs b/src/main.rs index 752fc9d..122ed83 100644 --- a/src/main.rs +++ b/src/main.rs @@ -131,7 +131,7 @@ fn poll_viewer_loader( changed = true; } Ok(Err(e)) => { - state.status_message = Some(format!("Failed to open file: {e}")); + state.ui.status_message = Some(format!("Failed to open file: {e}")); state.mode = AppMode::Normal; *viewer_loader = None; changed = true; @@ -139,16 +139,17 @@ fn poll_viewer_loader( Err(std::sync::mpsc::TryRecvError::Empty) => { let now = std::time::Instant::now(); let should_redraw = state + .ui .viewer_spinner_last_tick .is_none_or(|last| now.duration_since(last) >= SPINNER_TICK_INTERVAL); if should_redraw { - state.viewer_spinner_last_tick = Some(now); - state.viewer_spinner_frame = state.viewer_spinner_frame.wrapping_add(1); + state.ui.viewer_spinner_last_tick = Some(now); + state.ui.viewer_spinner_frame = state.ui.viewer_spinner_frame.wrapping_add(1); changed = true; } } Err(std::sync::mpsc::TryRecvError::Disconnected) => { - state.status_message = Some("Viewer load failed: thread panicked".to_string()); + state.ui.status_message = Some("Viewer load failed: thread panicked".to_string()); state.mode = AppMode::Normal; *viewer_loader = None; changed = true; @@ -178,7 +179,7 @@ fn poll_image_preview( } Err(mpsc::TryRecvError::Empty) => false, Err(mpsc::TryRecvError::Disconnected) => { - state.status_message = Some("Image preview failed: thread panicked".to_string()); + state.ui.status_message = Some("Image preview failed: thread panicked".to_string()); *image_preview_loader = None; true } @@ -237,14 +238,14 @@ fn run_app(terminal: &mut Terminal>) -> io::Result< let config_raw = match app::config::load_setup(&mut state) { Ok(raw) => raw, Err(e) => { - state.status_message = Some(e); + state.ui.status_message = Some(e); None } }; if let Some(ref raw) = config_raw && let Err(e) = ui::theme::Theme::apply_from_value_to_palette(raw, &mut state.theme_colors) { - state.status_message = Some(e); + state.ui.status_message = Some(e); } let mut viewer_state: Option = None; @@ -256,7 +257,7 @@ fn run_app(terminal: &mut Terminal>) -> io::Result< Ok(w) => Some(w), Err(err) => { let msg = format!("watcher disabled: {err}"); - state.status_message = Some(match state.status_message.take() { + state.ui.status_message = Some(match state.ui.status_message.take() { Some(prev) => format!("{prev}; {msg}"), None => msg, }); @@ -325,7 +326,7 @@ fn run_app(terminal: &mut Terminal>) -> io::Result< )?; } - if state.should_quit { + if state.should_quit() { shutdown_job(&mut running_job); return Ok(()); } diff --git a/src/ops/compare.rs b/src/ops/compare.rs index c0653a6..a7caf16 100644 --- a/src/ops/compare.rs +++ b/src/ops/compare.rs @@ -133,9 +133,8 @@ pub fn compare_entries( /// Apply a [`CompareReport`] to both panels, selecting the marked entries. /// -/// Marks are applied to both the visible `entries` and the `unfiltered_entries` -/// cache so the selection survives a filter toggle, then each panel's selection -/// stats are recomputed. +/// Marks are applied to each panel's single entry store so the selection +/// survives a filter toggle, then each panel's selection stats are recomputed. pub fn apply_compare_to_panels( left_panel: &mut PanelState, right_panel: &mut PanelState, @@ -149,16 +148,13 @@ pub fn apply_compare_to_panels( } fn apply_marks(panel: &mut PanelState, marks: &HashSet) { - let apply = |entries: &mut [FileEntry]| { - for entry in entries { - entry.selected = entry.name != PARENT_DIR && marks.contains(&entry.name); - } - }; - apply(&mut panel.listing.entries); - apply(&mut panel.listing.unfiltered_entries); + for entry in panel.listing.unfiltered_mut() { + entry.selected = entry.name != PARENT_DIR && marks.contains(&entry.name); + } } #[cfg(test)] +#[allow(clippy::expect_used)] mod tests { use super::*; use crate::app::types::FileEntry; @@ -170,6 +166,7 @@ mod tests { .path(format!("/tmp/{name}")) .size(size) .build() + .expect("valid test entry") } fn dir_entry(name: &str) -> FileEntry { @@ -179,11 +176,12 @@ mod tests { .is_dir(true) .permissions(0o755) .build() + .expect("valid test entry") } fn panel_with_entries(entries: Vec) -> PanelState { let mut panel = PanelState::new(PathBuf::from("/tmp")); - panel.listing.entries = entries; + panel.set_entries(entries); panel } @@ -224,7 +222,8 @@ mod tests { .size(100) .modified(t) .created(std::time::SystemTime::UNIX_EPOCH) - .build(), + .build() + .expect("valid test entry"), ]; let right = vec![ FileEntry::builder() @@ -233,7 +232,8 @@ mod tests { .size(100) .modified(t + std::time::Duration::from_secs(3)) .created(std::time::SystemTime::UNIX_EPOCH) - .build(), + .build() + .expect("valid test entry"), ]; let report = compare_entries(&left, &right, CompareMode::Thorough); @@ -249,7 +249,8 @@ mod tests { .path("/tmp/..") .is_dir(true) .permissions(0o755) - .build(), + .build() + .expect("valid test entry"), ]; let right = vec![]; @@ -281,7 +282,8 @@ mod tests { .is_dir(true) .size(4096) .permissions(0o755) - .build(), + .build() + .expect("valid test entry"), ]; let right = vec![ FileEntry::builder() @@ -290,7 +292,8 @@ mod tests { .is_dir(true) .size(8192) .permissions(0o755) - .build(), + .build() + .expect("valid test entry"), ]; let report = compare_entries(&left, &right, CompareMode::Size); @@ -309,7 +312,8 @@ mod tests { .is_dir(true) .size(4096) .permissions(0o755) - .build(), + .build() + .expect("valid test entry"), ]; let right = vec![ FileEntry::builder() @@ -318,7 +322,8 @@ mod tests { .is_dir(true) .size(4096) .permissions(0o755) - .build(), + .build() + .expect("valid test entry"), ]; let report = compare_entries(&left, &right, CompareMode::Size); @@ -339,7 +344,8 @@ mod tests { .size(4096) .modified(t) .permissions(0o755) - .build(), + .build() + .expect("valid test entry"), ]; let right = vec![ FileEntry::builder() @@ -349,7 +355,8 @@ mod tests { .size(8192) .modified(t + std::time::Duration::from_secs(60)) .permissions(0o755) - .build(), + .build() + .expect("valid test entry"), ]; let report = compare_entries(&left, &right, CompareMode::Thorough); @@ -370,7 +377,8 @@ mod tests { .size(4096) .modified(t) .permissions(0o755) - .build(), + .build() + .expect("valid test entry"), ]; let right = vec![ FileEntry::builder() @@ -380,7 +388,8 @@ mod tests { .size(4096) .modified(t) .permissions(0o755) - .build(), + .build() + .expect("valid test entry"), ]; let report = compare_entries(&left, &right, CompareMode::Thorough); @@ -413,7 +422,8 @@ mod tests { .size(100) .modified(t) .created(std::time::SystemTime::UNIX_EPOCH) - .build(), + .build() + .expect("valid test entry"), ]; let report = compare_entries(&left, &right, CompareMode::Thorough); @@ -434,6 +444,7 @@ mod tests { .modified(t + std::time::Duration::from_secs(delta)) .created(std::time::SystemTime::UNIX_EPOCH) .build() + .expect("valid test entry") }; let left = vec![make(0)]; let right = vec![make(2)]; @@ -455,6 +466,7 @@ mod tests { .modified(t + std::time::Duration::from_secs(delta)) .created(std::time::SystemTime::UNIX_EPOCH) .build() + .expect("valid test entry") }; let left = vec![make(0)]; let right = vec![make(3)]; @@ -533,8 +545,26 @@ mod tests { apply_compare_to_panels(&mut left_panel, &mut right_panel, &report); - assert!(!left_panel.listing.entries[0].selected); - assert!(left_panel.listing.entries[1].selected); - assert!(!right_panel.listing.entries[0].selected); + assert!( + !left_panel + .listing + .filtered_get(0) + .expect("entry 0") + .selected + ); + assert!( + left_panel + .listing + .filtered_get(1) + .expect("entry 1") + .selected + ); + assert!( + !right_panel + .listing + .filtered_get(0) + .expect("entry 0") + .selected + ); } } diff --git a/src/ops/sorting/tests.rs b/src/ops/sorting/tests.rs index ce69080..f413280 100644 --- a/src/ops/sorting/tests.rs +++ b/src/ops/sorting/tests.rs @@ -1,3 +1,5 @@ +#![allow(clippy::expect_used)] + use super::*; use std::time::SystemTime; @@ -29,7 +31,7 @@ fn make_entry( if let Some(btime) = btime_secs { builder = builder.created(SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(btime)); } - builder.build() + builder.build().expect("valid test entry") } fn create_test_entry(name: &str, is_dir: bool, size: u64, modified_secs: u64) -> FileEntry { @@ -898,12 +900,14 @@ fn test_sort_mtime_none_after_known() { let no_mtime = FileEntry::builder() .name("unknown.txt") .path("unknown.txt") - .build(); + .build() + .expect("valid test entry"); let with_mtime = FileEntry::builder() .name("known.txt") .path("known.txt") .modified(SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000_000)) - .build(); + .build() + .expect("valid test entry"); let mut entries = vec![no_mtime, with_mtime]; sort_entries( diff --git a/src/render.rs b/src/render.rs index 93a4ed5..d64f2ff 100644 --- a/src/render.rs +++ b/src/render.rs @@ -40,15 +40,11 @@ pub(crate) fn render_ui( AppMode::Viewing => { if let Some(vs) = viewer_state { match vs.view_mode { - ViewMode::Hex => { - viewer::render_hex_view_with_colors(f, f.area(), vs, colors); - } + ViewMode::Hex => viewer::render_hex_view_with_colors(f, f.area(), vs, colors), ViewMode::Image => { - viewer::render_image_view_with_colors(f, f.area(), vs, colors); - } - ViewMode::Text => { - viewer::render_viewer_with_colors(f, f.area(), vs, colors); + viewer::render_image_view_with_colors(f, f.area(), vs, colors) } + ViewMode::Text => viewer::render_viewer_with_colors(f, f.area(), vs, colors), } return; } @@ -58,7 +54,7 @@ pub(crate) fn render_ui( f.area(), &loader.path, colors, - state.viewer_spinner_frame, + state.ui.viewer_spinner_frame, ); return; } @@ -68,10 +64,10 @@ pub(crate) fn render_ui( AppMode::DirectoryTree => { ui::dir_tree::render_directory_tree_with_colors( f, - &state.tree_root, - &state.tree_entries, - state.tree_selected, - state.tree_scroll, + &state.tree.root, + &state.tree.entries, + state.tree.selected, + state.tree.scroll, colors, ); return; @@ -126,13 +122,18 @@ pub(crate) fn render_ui( let cmd_text: Cow<'_, str> = if state.mode == AppMode::CommandLine { cursor_line( "$ ", - state.command_line.text(), - state.command_line.byte_pos(), + state.input.command_line.text(), + state.input.command_line.byte_pos(), ) .into() } else if state.mode == AppMode::Search { - cursor_line("Search: ", &state.search_query, state.search_cursor).into() - } else if let Some(ref msg) = state.status_message { + cursor_line( + "Search: ", + &state.input.search_query, + state.input.search_cursor, + ) + .into() + } else if let Some(ref msg) = state.ui.status_message { Cow::Borrowed(msg.as_str()) } else { active.path().to_string_lossy() @@ -156,8 +157,8 @@ fn render_overlays(f: &mut Frame, state: &AppState, menu_bar_area: Rect, colors: ui::menu::render_menu_bar_with_colors( f, menu_bar_area, - state.menu_selected, - state.menu_item_selected, + state.ui.menu_selected, + state.ui.menu_item_selected, colors, ); } @@ -194,13 +195,14 @@ fn render_list_picker_overlay( kind: &PickerKind, colors: &ColorPalette, ) { - let selected = state.picker_selected; + let selected = state.ui.picker_selected; match kind { PickerKind::History => { // command_history is mutable state stored newest-last in a VecDeque, // so a reversed contiguous slice doesn't exist; we materialize per // frame. SmallVec keeps typical histories off the heap (no alloc). let items: SmallVec<[&str; 32]> = state + .input .command_history .iter() .rev() @@ -218,7 +220,7 @@ fn render_list_picker_overlay( PickerKind::Hotlist => render_picker( f, "Directory Hotlist", - &state.cached_hotlist_strings, + &state.ui.cached_hotlist_strings, selected, "Enter: cd a: add current d: delete Esc: close", colors, @@ -237,7 +239,7 @@ fn render_list_picker_overlay( PickerKind::UserMenu => render_picker( f, "User Menu", - &state.cached_user_menu_strings, + &state.ui.cached_user_menu_strings, selected, "Enter: run Esc: cancel", colors, diff --git a/src/render_dialog_map.rs b/src/render_dialog_map.rs index ea17df9..dd9ca1b 100644 --- a/src/render_dialog_map.rs +++ b/src/render_dialog_map.rs @@ -21,14 +21,14 @@ pub(super) fn to_ui_dialog<'a>( app::types::DialogKind::Confirm(cd) => dialogs::DialogKind::Confirm { title: Cow::Borrowed(&cd.title), message: Cow::Borrowed(&cd.message), - selection: state.dialog_selection, + selection: state.input.dialog_selection, files: Cow::Borrowed(cd.files.as_deref().unwrap_or(&[])), }, app::types::DialogKind::Input { prompt, .. } => dialogs::DialogKind::Input { title: Cow::Borrowed(TITLE_INPUT), prompt: Cow::Borrowed(prompt), - value: Cow::Borrowed(state.dialog_input.text()), - cursor_pos: state.dialog_input.cursor(), + value: Cow::Borrowed(state.input.dialog_input.text()), + cursor_pos: state.input.dialog_input.cursor(), }, app::types::DialogKind::Error(msg) => dialogs::DialogKind::Error { title: Cow::Borrowed(TITLE_ERROR), @@ -53,7 +53,11 @@ pub(super) fn to_ui_dialog<'a>( cancellable: *cancellable, }, app::types::DialogKind::CopyMove(details) => { - let action = if details.is_move { "Move" } else { "Copy" }; + let action = if details.kind.is_move() { + "Move" + } else { + "Copy" + }; // Per-frame alloc. Not cacheable to Cow::Borrowed: the message is // synthesized from counts + PathBuf displays (no borrowable source). // A real cache would need persistent state keyed on the dialog data, @@ -70,20 +74,20 @@ pub(super) fn to_ui_dialog<'a>( details.dest.display(), ); dialogs::DialogKind::Confirm { - title: Cow::Borrowed(if details.is_move { + title: Cow::Borrowed(if details.kind.is_move() { "Move Confirm" } else { "Copy Confirm" }), message: Cow::Owned(msg), - selection: state.dialog_selection, - files: Cow::Borrowed(&details.source_display), + selection: state.input.dialog_selection, + files: Cow::Owned(details.source_display()), } } app::types::DialogKind::Properties(details) => properties_to_ui_dialog(details), app::types::DialogKind::OverwriteConfirm(details) => { dialogs::DialogKind::OverwriteConfirm { - selection: state.dialog_selection, + selection: state.input.dialog_selection, files: Cow::Borrowed(&details.conflicting), } } @@ -100,26 +104,20 @@ pub(super) fn to_ui_dialog<'a>( info: Cow::Owned(info), dest_value: Cow::Borrowed(details.dest_input.text()), dest_cursor: details.dest_input.cursor(), - selection: state.dialog_selection, + selection: state.input.dialog_selection, } } app::types::DialogKind::ArchiveCreate(details) => dialogs::DialogKind::ArchiveCreate { source_count: details.sources.len(), dest_value: Cow::Borrowed(details.dest_input.text()), dest_cursor: details.dest_input.cursor(), - selection: state.dialog_selection, + selection: state.input.dialog_selection, }, } } fn properties_to_ui_dialog(details: &app::types::PropertiesDetails) -> dialogs::DialogKind<'_> { - let file_type = if details.is_symlink { - "Symlink" - } else if details.is_dir { - "Directory" - } else { - "File" - }; + let file_type = details.kind.label(); let mtime_str: Cow<'static, str> = if let Ok(duration) = details.mtime.duration_since(std::time::UNIX_EPOCH) { // Per-frame alloc; low cost for short strings. diff --git a/src/tests/compare.rs b/src/tests/compare.rs index 45129ad..9ef778f 100644 --- a/src/tests/compare.rs +++ b/src/tests/compare.rs @@ -9,8 +9,8 @@ fn state_with_panels( right: Vec, ) -> AppState { let mut state = AppState::default(); - state.left_panel.listing.entries = left; - state.right_panel.listing.entries = right; + state.left_panel.set_entries(left); + state.right_panel.set_entries(right); state } @@ -57,8 +57,7 @@ fn compare_directories_reports_summary() { state .left_panel .listing - .entries - .iter() + .filtered() .any(|e| e.name == "a.txt" && e.selected), "left panel should mark 'a.txt' as selected after compare" ); @@ -66,8 +65,7 @@ fn compare_directories_reports_summary() { state .right_panel .listing - .entries - .iter() + .filtered() .any(|e| e.name == "b.txt" && e.selected), "right panel should mark 'b.txt' as selected after compare" ); @@ -83,19 +81,39 @@ fn compare_directories_marks_unique_entries_selected() { pickers::compare_directories(&mut state, CompareMode::Quick); assert!( - !state.left_panel.listing.entries[0].selected, + !state + .left_panel + .listing + .filtered_get(0) + .expect("left entry 0") + .selected, "'same.txt' on left should not be selected" ); assert!( - state.left_panel.listing.entries[1].selected, + state + .left_panel + .listing + .filtered_get(1) + .expect("left entry 1") + .selected, "'left.txt' on left should be selected" ); assert!( - !state.right_panel.listing.entries[0].selected, + !state + .right_panel + .listing + .filtered_get(0) + .expect("right entry 0") + .selected, "'same.txt' on right should not be selected" ); assert!( - state.right_panel.listing.entries[1].selected, + state + .right_panel + .listing + .filtered_get(1) + .expect("right entry 1") + .selected, "'right.txt' on right should be selected" ); } @@ -113,8 +131,7 @@ fn compare_directories_size_mode_reports_mismatches() { state .left_panel .listing - .entries - .iter() + .filtered() .any(|e| e.name == "file.txt" && e.selected), "left panel 'file.txt' should be selected (size mismatch)" ); @@ -122,8 +139,7 @@ fn compare_directories_size_mode_reports_mismatches() { state .right_panel .listing - .entries - .iter() + .filtered() .any(|e| e.name == "file.txt" && e.selected), "right panel 'file.txt' should be selected (size mismatch)" ); @@ -140,7 +156,7 @@ fn compare_directories_quick_empty_dirs() { ); assert_summary_counts(&state, 0, 0, 0); assert_eq!( - state.dialog_selection, 0, + state.input.dialog_selection, 0, "dialog_selection should default to 0" ); } @@ -148,12 +164,12 @@ fn compare_directories_quick_empty_dirs() { #[test] fn compare_mode_picker_maps_index_to_mode() { let mut state = AppState::default(); - state.left_panel.listing.entries = vec![entry("a.txt").build()]; + state.left_panel.set_entries(vec![entry("a.txt").build()]); for (idx, mode) in CompareMode::ALL.iter().enumerate() { // Reset to picker mode for each iteration — simulates fresh picker invocation state.mode = AppMode::ListPicker(app::types::PickerKind::CompareMode); - state.picker_selected = idx; + state.ui.picker_selected = idx; pickers::handle_list_picker(&mut state, KeyCode::Enter); let label = mode.label(); @@ -175,7 +191,10 @@ fn compare_mode_picker_maps_index_to_mode() { fn compare_mode_picker_esc_cancels() { let mut state = AppState { mode: AppMode::ListPicker(app::types::PickerKind::CompareMode), - picker_selected: 1, + ui: app::types::UiState { + picker_selected: 1, + ..Default::default() + }, ..Default::default() }; @@ -203,16 +222,11 @@ fn compare_directories_identical_content_mixed_types_symlinks() { assert_summary_counts(&state, 0, 0, 0); assert!( - state.left_panel.listing.entries.iter().all(|e| !e.selected), + state.left_panel.listing.filtered().all(|e| !e.selected), "no left entries should be selected when panels are identical" ); assert!( - state - .right_panel - .listing - .entries - .iter() - .all(|e| !e.selected), + state.right_panel.listing.filtered().all(|e| !e.selected), "no right entries should be selected when panels are identical" ); } diff --git a/src/tests/dialogs.rs b/src/tests/dialogs.rs index fb0406f..734e591 100644 --- a/src/tests/dialogs.rs +++ b/src/tests/dialogs.rs @@ -45,8 +45,14 @@ fn confirm_enter_without_pending_action_dismisses_dialog() { mode: AppMode::Dialog(app::types::DialogKind::Confirm( app::types::ConfirmDetails::simple("Info", "Nothing to run"), )), - dialog_selection: 0, - pending_action: None, + input: app::types::InputState { + dialog_selection: 0, + ..Default::default() + }, + ui: app::types::UiState { + pending_action: None, + ..Default::default() + }, ..Default::default() }; @@ -70,12 +76,20 @@ fn confirm_enter_with_pending_action_starts_action() { mode: AppMode::Dialog(app::types::DialogKind::Confirm( app::types::ConfirmDetails::simple("Delete", "Delete selected?"), )), - dialog_selection: 0, - pending_action: Some(app::types::PendingAction::Delete { paths: vec![src] }), + input: app::types::InputState { + dialog_selection: 0, + ..Default::default() + }, + ui: app::types::UiState { + pending_action: Some(app::types::PendingAction::Delete { paths: vec![src] }), + ..Default::default() + }, active_panel: app::types::ActivePanel::Left, ..Default::default() }; - state.left_panel.listing.entries = vec![entry("delme.txt").build()]; + state + .left_panel + .set_entries(vec![entry("delme.txt").build()]); state.left_panel.cursor = 0; dialogs::handle_dialog( @@ -100,7 +114,9 @@ fn confirm_enter_with_pending_action_starts_action() { #[test] fn confirm_file_transfer_copy_opens_dialog() { let mut state = AppState::default(); - state.left_panel.listing.entries = vec![entry("a.txt").build(), entry("b.txt").build()]; + state + .left_panel + .set_entries(vec![entry("a.txt").build(), entry("b.txt").build()]); state.left_panel.cursor = 0; state.active_panel = app::types::ActivePanel::Left; confirm_file_transfer(&mut state, "Copy Confirm", "Copy", |sources, dest| { @@ -115,16 +131,18 @@ fn confirm_file_transfer_copy_opens_dialog() { AppMode::Dialog(DialogKind::Confirm(_)) )); assert!( - matches!(state.pending_action, Some(PendingAction::Copy(_))), + matches!(state.ui.pending_action, Some(PendingAction::Copy(_))), "expected PendingAction::Copy, got: {:?}", - state.pending_action + state.ui.pending_action ); } #[test] fn confirm_delete_opens_dialog() { let mut state = AppState::default(); - state.left_panel.listing.entries = vec![entry("delme.txt").build()]; + state + .left_panel + .set_entries(vec![entry("delme.txt").build()]); state.left_panel.cursor = 0; state.active_panel = app::types::ActivePanel::Left; confirm_delete(&mut state); @@ -235,8 +253,11 @@ fn progress_dialog_nan_percent_handled() { fn menu_dropdown_renders_over_panels() { let state = AppState { mode: AppMode::Menu, - menu_selected: 1, - menu_item_selected: 0, + ui: app::types::UiState { + menu_selected: 1, + menu_item_selected: 0, + ..Default::default() + }, ..Default::default() }; let rendered = render_and_get_text(&state); @@ -248,10 +269,16 @@ fn menu_dropdown_renders_over_panels() { fn list_picker_overlay_renders_title() { let mut state = AppState { mode: AppMode::ListPicker(PickerKind::History), - picker_selected: 0, + ui: app::types::UiState { + picker_selected: 0, + ..Default::default() + }, ..Default::default() }; - state.command_history.push_back("echo hello".to_string()); + state + .input + .command_history + .push_back("echo hello".to_string()); let rendered = render_and_get_text(&state); assert!(rendered.contains("Command History")); assert!(rendered.contains("echo hello")); @@ -299,13 +326,13 @@ fn chmod_valid_input_applies_mode_and_dismisses() { }), ..Default::default() }; - state.dialog_input.set_text_at_end("755".to_string()); - state.left_panel.listing.entries = vec![ + state.input.dialog_input.set_text_at_end("755".to_string()); + state.left_panel.set_entries(vec![ TestEntry::new("chmod_target.txt") .path(&file) .file(4) .build(), - ]; + ]); state.left_panel.cursor = 0; state.active_panel = app::types::ActivePanel::Left; @@ -331,8 +358,10 @@ fn chmod_invalid_input_shows_error_stays_in_dialog() { }), ..Default::default() }; - state.dialog_input.set_text_at_end("bad".to_string()); - state.left_panel.listing.entries = vec![entry("f.txt").file(4).build()]; + state.input.dialog_input.set_text_at_end("bad".to_string()); + state + .left_panel + .set_entries(vec![entry("f.txt").file(4).build()]); state.left_panel.cursor = 0; state.active_panel = app::types::ActivePanel::Left; @@ -349,7 +378,7 @@ fn chmod_invalid_input_shows_error_stays_in_dialog() { "expected Input dialog to remain open, got: {:?}", state.mode ); - let msg = state.status_message.as_deref().unwrap_or(""); + let msg = state.ui.status_message.as_deref().unwrap_or(""); assert!( msg.to_lowercase().contains("invalid"), "expected 'Invalid' in status_message, got: {msg}" @@ -372,13 +401,13 @@ fn chmod_esc_dismisses_without_changing_mode() { }), ..Default::default() }; - state.dialog_input.set_text_at_end("777".to_string()); - state.left_panel.listing.entries = vec![ + state.input.dialog_input.set_text_at_end("777".to_string()); + state.left_panel.set_entries(vec![ TestEntry::new("chmod_target.txt") .path(&file) .file(4) .build(), - ]; + ]); state.left_panel.cursor = 0; state.active_panel = app::types::ActivePanel::Left; diff --git a/src/tests/history.rs b/src/tests/history.rs index 0d798ec..264f336 100644 --- a/src/tests/history.rs +++ b/src/tests/history.rs @@ -9,7 +9,7 @@ fn make_history_picker(commands: &[&str], selected: usize) -> AppState { shell::push_history(&mut state, cmd); } state.mode = AppMode::ListPicker(PickerKind::History); - state.picker_selected = selected; + state.ui.picker_selected = selected; state } @@ -18,8 +18,8 @@ fn history_dedup_consecutive() { let mut state = AppState::default(); shell::push_history(&mut state, "echo hi"); shell::push_history(&mut state, "echo hi"); - assert_eq!(state.command_history.len(), 1); - assert_eq!(state.command_history[0], "echo hi"); + assert_eq!(state.input.command_history.len(), 1); + assert_eq!(state.input.command_history[0], "echo hi"); } #[test] @@ -27,7 +27,7 @@ fn history_dedup_different_commands() { let mut state = AppState::default(); shell::push_history(&mut state, "echo hi"); shell::push_history(&mut state, "ls -la"); - assert_eq!(state.command_history.len(), 2); + assert_eq!(state.input.command_history.len(), 2); } #[test] @@ -36,9 +36,9 @@ fn history_cap_at_100() { for i in 0..101 { shell::push_history(&mut state, &format!("cmd_{i}")); } - assert_eq!(state.command_history.len(), 100); - assert_eq!(state.command_history[0], "cmd_1"); - assert_eq!(state.command_history[99], "cmd_100"); + assert_eq!(state.input.command_history.len(), 100); + assert_eq!(state.input.command_history[0], "cmd_1"); + assert_eq!(state.input.command_history[99], "cmd_100"); } #[test] @@ -46,7 +46,7 @@ fn history_picker_enter_loads_command_line() { let mut state = make_history_picker(&["git status", "git log"], 0); pickers::handle_list_picker(&mut state, KeyCode::Enter); assert_eq!(state.mode, AppMode::CommandLine); - assert_eq!(state.command_line.text(), "git log"); + assert_eq!(state.input.command_line.text(), "git log"); } #[test] @@ -60,9 +60,9 @@ fn history_picker_esc_cancels() { fn history_picker_navigate_up_down() { let mut state = make_history_picker(&["cmd1", "cmd2", "cmd3"], 0); pickers::handle_list_picker(&mut state, KeyCode::Down); - assert_eq!(state.picker_selected, 1); + assert_eq!(state.ui.picker_selected, 1); pickers::handle_list_picker(&mut state, KeyCode::Up); - assert_eq!(state.picker_selected, 0); + assert_eq!(state.ui.picker_selected, 0); } #[test] @@ -76,14 +76,14 @@ fn empty_history_does_not_open_picker() { fn history_skips_empty_command() { let mut state = AppState::default(); shell::push_history(&mut state, ""); - assert!(state.command_history.is_empty()); + assert!(state.input.command_history.is_empty()); } #[test] fn history_skips_whitespace_command() { let mut state = AppState::default(); shell::push_history(&mut state, " "); - assert!(state.command_history.is_empty()); + assert!(state.input.command_history.is_empty()); } #[test] @@ -91,8 +91,8 @@ fn history_whitespace_after_valid_command() { let mut state = AppState::default(); shell::push_history(&mut state, "ls -la"); shell::push_history(&mut state, " "); - assert_eq!(state.command_history.len(), 1); - assert_eq!(state.command_history[0], "ls -la"); + assert_eq!(state.input.command_history.len(), 1); + assert_eq!(state.input.command_history[0], "ls -la"); } #[test] @@ -100,10 +100,10 @@ fn history_picker_home_end() { let mut state = make_history_picker(&["cmd1", "cmd2", "cmd3"], 1); pickers::handle_list_picker(&mut state, KeyCode::Home); - assert_eq!(state.picker_selected, 0); + assert_eq!(state.ui.picker_selected, 0); pickers::handle_list_picker(&mut state, KeyCode::End); - assert_eq!(state.picker_selected, 2); + assert_eq!(state.ui.picker_selected, 2); } #[test] @@ -112,9 +112,9 @@ fn history_dedup_non_consecutive_moves_to_end() { shell::push_history(&mut state, "echo A"); shell::push_history(&mut state, "echo B"); shell::push_history(&mut state, "echo A"); - assert_eq!(state.command_history.len(), 2); - assert_eq!(state.command_history[0], "echo B"); - assert_eq!(state.command_history[1], "echo A"); + assert_eq!(state.input.command_history.len(), 2); + assert_eq!(state.input.command_history[0], "echo B"); + assert_eq!(state.input.command_history[1], "echo A"); } #[test] @@ -128,14 +128,14 @@ fn history_picker_enter_selected_beyond_len() { fn history_picker_up_at_zero_clamps() { let mut state = make_history_picker(&["cmd1", "cmd2"], 0); pickers::handle_list_picker(&mut state, KeyCode::Up); - assert_eq!(state.picker_selected, 0); + assert_eq!(state.ui.picker_selected, 0); } #[test] fn history_picker_down_at_last_clamps() { let mut state = make_history_picker(&["cmd1", "cmd2"], 1); pickers::handle_list_picker(&mut state, KeyCode::Down); - assert_eq!(state.picker_selected, 1); + assert_eq!(state.ui.picker_selected, 1); } #[test] @@ -143,6 +143,6 @@ fn history_picker_empty_list_all_directions_clamped() { let mut state = make_history_picker(&[], 0); for key in [KeyCode::Up, KeyCode::Down, KeyCode::Home, KeyCode::End] { pickers::handle_list_picker(&mut state, key); - assert_eq!(state.picker_selected, 0, "failed on {key:?}"); + assert_eq!(state.ui.picker_selected, 0, "failed on {key:?}"); } } diff --git a/src/tests/keybinds.rs b/src/tests/keybinds.rs index 8fcac55..1eb5509 100644 --- a/src/tests/keybinds.rs +++ b/src/tests/keybinds.rs @@ -34,7 +34,7 @@ fn ctrl_s_starts_search_mode() { ); assert_eq!(state.mode, AppMode::Search); - assert_eq!(state.search_query, ""); + assert_eq!(state.input.search_query, ""); } #[test] @@ -73,7 +73,7 @@ fn ctrl_r_refreshes() { std::fs::write(temp_dir.path().join("existing.txt"), b"data").unwrap(); state.left_panel.set_path(temp_dir.path().to_path_buf()); state.left_panel.set_entries(vec![]); - assert!(state.left_panel.listing.entries.is_empty()); + assert!(state.left_panel.listing.filtered_is_empty()); dispatch_key( &mut state, @@ -87,8 +87,7 @@ fn ctrl_r_refreshes() { state .left_panel .listing - .entries - .iter() + .filtered() .any(|e| e.name == "existing.txt"), "refresh_active should have loaded directory entries" ); @@ -129,7 +128,7 @@ fn alt_j_does_not_start_search_mode() { ); assert_eq!(state.mode, AppMode::Normal); - assert_eq!(state.search_query, ""); + assert_eq!(state.input.search_query, ""); } #[test] @@ -251,16 +250,19 @@ fn alt_c_opens_quick_cd() { #[test] fn alt_x_opens_command_line() { let mut state = AppState::default(); - state.command_line.set_text_at_end("draft".to_string()); - state.history_index = Some(0); + state + .input + .command_line + .set_text_at_end("draft".to_string()); + state.input.history_index = Some(0); state.prev_mode = Some(AppMode::Search); handle_alt_keys(&mut state, KeyCode::Char('X'), VISIBLE_HEIGHT); assert_eq!(state.mode, AppMode::CommandLine); - assert!(state.command_line.text().is_empty()); - assert_eq!(state.command_line.cursor(), 0); - assert_eq!(state.history_index, None); + assert!(state.input.command_line.text().is_empty()); + assert_eq!(state.input.command_line.cursor(), 0); + assert_eq!(state.input.history_index, None); assert_eq!(state.prev_mode, None); } @@ -287,7 +289,7 @@ fn f7_opens_create_directory_dialog() { .. }) )); - assert!(state.dialog_input.text().is_empty()); + assert!(state.input.dialog_input.text().is_empty()); } #[test] @@ -297,7 +299,7 @@ fn f9_enters_menu_mode() { let mut terminal = test_terminal(); handle_function_keys(&mut state, &mut viewer, KeyCode::F(9), &mut terminal); assert!(matches!(state.mode, AppMode::Menu)); - assert_eq!(state.menu_item_selected, 0); + assert_eq!(state.ui.menu_item_selected, 0); } #[test] @@ -306,7 +308,7 @@ fn f10_sets_should_quit() { let mut viewer = None; let mut terminal = test_terminal(); handle_function_keys(&mut state, &mut viewer, KeyCode::F(10), &mut terminal); - assert!(state.should_quit); + assert!(state.should_quit()); } #[test] @@ -366,37 +368,46 @@ fn tab_clamps_cursor() { #[test] fn directory_tree_page_down_uses_terminal_height() { let mut state = AppState { - tree_entries: dummy_tree_entries(50), + tree: app::types::TreeState { + entries: dummy_tree_entries(50), + ..Default::default() + }, ..Default::default() }; directory_tree::handle_directory_tree(&mut state, &mut None, &mut None, KeyCode::PageDown, 12); - assert_eq!(state.tree_selected, 9); - assert_eq!(state.tree_scroll, 9); + assert_eq!(state.tree.selected, 9); + assert_eq!(state.tree.scroll, 9); } #[test] fn directory_tree_page_up_uses_terminal_height() { let mut state = AppState { - tree_entries: dummy_tree_entries(50), - tree_selected: 25, - tree_scroll: 25, + tree: app::types::TreeState { + entries: dummy_tree_entries(50), + selected: 25, + scroll: 25, + ..Default::default() + }, ..Default::default() }; directory_tree::handle_directory_tree(&mut state, &mut None, &mut None, KeyCode::PageUp, 12); - assert_eq!(state.tree_selected, 16); - assert_eq!(state.tree_scroll, 16); + assert_eq!(state.tree.selected, 16); + assert_eq!(state.tree.scroll, 16); } #[test] fn command_line_up_loads_last_history_entry() { let mut state = AppState::default(); - state.command_history.push_back("git status".to_string()); + state + .input + .command_history + .push_back("git status".to_string()); command_line::handle_command_line(&mut state, KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); - assert_eq!(state.command_line.text(), "git status"); + assert_eq!(state.input.command_line.text(), "git status"); } diff --git a/src/tests/keyevents.rs b/src/tests/keyevents.rs index 5ccc1c9..ca94edd 100644 --- a/src/tests/keyevents.rs +++ b/src/tests/keyevents.rs @@ -117,14 +117,14 @@ fn key_repeat_text_edit_updates_input_dialog() { prompt: "Create directory:".to_string(), action: InputAction::CreateDirectory, }), - dialog_input: { - let mut ti = TextInput::new(); - ti.set_text("ab".to_string()); - ti.set_cursor(2); - ti - }, ..Default::default() }; + state.input.dialog_input = { + let mut ti = TextInput::new(); + ti.set_text("ab".to_string()); + ti.set_cursor(2); + ti + }; let mut terminal = test_terminal(); let key = KeyEvent::new_with_kind(KeyCode::Backspace, KeyModifiers::NONE, KeyEventKind::Repeat); @@ -132,8 +132,8 @@ fn key_repeat_text_edit_updates_input_dialog() { dispatch_test_event(&mut state, &mut terminal, &Event::Key(key)); assert!(handled.is_ok()); - assert_eq!(state.dialog_input.text(), "a"); - assert_eq!(state.dialog_input.cursor(), 1); + assert_eq!(state.input.dialog_input.text(), "a"); + assert_eq!(state.input.dialog_input.cursor(), 1); } #[test] @@ -148,7 +148,7 @@ fn key_repeat_destructive_is_ignored() { assert!(handled.is_ok()); assert!(matches!(state.mode, AppMode::Normal)); - assert!(state.pending_action.is_none()); + assert!(state.ui.pending_action.is_none()); } #[test] diff --git a/src/tests/menu.rs b/src/tests/menu.rs index e3bd746..23b8080 100644 --- a/src/tests/menu.rs +++ b/src/tests/menu.rs @@ -32,8 +32,8 @@ fn menu_toggle_hidden_files() { state.left_panel.set_path(temp_dir.path().to_path_buf()); state.left_panel.set_show_hidden(initial); state.mode = AppMode::Menu; - state.menu_selected = 3; - state.menu_item_selected = 0; + state.ui.menu_selected = 3; + state.ui.menu_item_selected = 0; dispatch_menu(&mut state, KeyCode::Enter); @@ -45,18 +45,18 @@ fn menu_toggle_hidden_files() { fn menu_rename_opens_input_dialog_with_current_name() { let tmp = tempfile::tempdir().unwrap(); let mut state = AppState::default(); - state.left_panel.listing.entries.push( + state.left_panel.set_entries(vec![ TestEntry::new("old.txt") .path(tmp.path().join("old.txt")) .build(), - ); + ]); state.mode = AppMode::Menu; - state.menu_selected = 1; - state.menu_item_selected = 7; + state.ui.menu_selected = 1; + state.ui.menu_item_selected = 7; dispatch_menu(&mut state, KeyCode::Enter); - assert_eq!(state.dialog_input.text(), "old.txt"); + assert_eq!(state.input.dialog_input.text(), "old.txt"); assert!(matches!( state.mode, AppMode::Dialog(app::types::DialogKind::Input { @@ -72,13 +72,14 @@ fn menu_rename_confirms_and_renames_file() { let old_path = dir.path().join("old.txt"); std::fs::write(&old_path, "content").unwrap(); let mut state = AppState::default(); - state.left_panel.listing.entries = - vec![TestEntry::new("old.txt").path(&old_path).file(1).build()]; + state.left_panel.set_entries(vec![ + TestEntry::new("old.txt").path(&old_path).file(1).build(), + ]); state.left_panel.cursor = 0; state.active_panel = ActivePanel::Left; state.mode = AppMode::Menu; - state.menu_selected = 1; - state.menu_item_selected = 7; + state.ui.menu_selected = 1; + state.ui.menu_item_selected = 7; dispatch_menu(&mut state, KeyCode::Enter); @@ -89,7 +90,10 @@ fn menu_rename_confirms_and_renames_file() { .. }) )); - state.dialog_input.set_text_at_end("new.txt".to_string()); + state + .input + .dialog_input + .set_text_at_end("new.txt".to_string()); crate::input::dialogs::handle_dialog( &mut state, @@ -112,16 +116,19 @@ fn menu_rename_confirms_and_renames_file() { fn menu_history_opens_picker() { let mut state = AppState { mode: AppMode::Menu, - menu_selected: 2, - menu_item_selected: 5, + ui: lc::app::types::UiState { + menu_selected: 2, + menu_item_selected: 5, + ..Default::default() + }, ..Default::default() }; - state.command_history.push_back("ls -la".to_string()); + state.input.command_history.push_back("ls -la".to_string()); dispatch_menu(&mut state, KeyCode::Enter); assert_eq!(state.mode, AppMode::ListPicker(PickerKind::History)); - assert_eq!(state.picker_selected, 0); + assert_eq!(state.ui.picker_selected, 0); } #[test] @@ -129,8 +136,11 @@ fn menu_hotlist_opens_picker() { let tmp = tempfile::tempdir().unwrap(); let mut state = AppState { mode: AppMode::Menu, - menu_selected: 2, - menu_item_selected: 6, + ui: lc::app::types::UiState { + menu_selected: 2, + menu_item_selected: 6, + ..Default::default() + }, ..Default::default() }; state.hotlist_push(tmp.path().to_path_buf()); @@ -138,19 +148,23 @@ fn menu_hotlist_opens_picker() { dispatch_menu(&mut state, KeyCode::Enter); assert_eq!(state.mode, AppMode::ListPicker(PickerKind::Hotlist)); - assert_eq!(state.picker_selected, 0); + assert_eq!(state.ui.picker_selected, 0); } #[test] fn menu_sort_preserves_current_entry_focus() { let mut state = AppState { mode: AppMode::Menu, - menu_selected: 0, - menu_item_selected: 1, + ui: lc::app::types::UiState { + menu_selected: 0, + menu_item_selected: 1, + ..Default::default() + }, ..Default::default() }; - state.left_panel.listing.entries = vec![entry("zeta.txt").build(), entry("alpha.txt").build()]; - state.left_panel.listing.unfiltered_entries = state.left_panel.listing.entries.clone(); + state + .left_panel + .set_entries(vec![entry("zeta.txt").build(), entry("alpha.txt").build()]); state.left_panel.cursor = 0; state .left_panel @@ -168,8 +182,24 @@ fn menu_sort_preserves_current_entry_focus() { lc::app::types::Direction::Asc, ) ); - assert_eq!(state.left_panel.listing.entries[0].name, "alpha.txt"); - assert_eq!(state.left_panel.listing.entries[1].name, "zeta.txt"); + assert_eq!( + state + .left_panel + .listing + .filtered_get(0) + .expect("entry 0") + .name, + "alpha.txt" + ); + assert_eq!( + state + .left_panel + .listing + .filtered_get(1) + .expect("entry 1") + .name, + "zeta.txt" + ); assert_eq!( state .left_panel @@ -183,13 +213,20 @@ fn menu_sort_preserves_current_entry_focus() { fn menu_reset_filter_preserves_current_entry_focus() { let mut state = AppState { mode: AppMode::Menu, - menu_selected: 0, - menu_item_selected: 4, + ui: lc::app::types::UiState { + menu_selected: 0, + menu_item_selected: 4, + ..Default::default() + }, ..Default::default() }; - state.left_panel.listing.entries = vec![entry("beta.txt").build()]; - state.left_panel.listing.unfiltered_entries = - vec![entry("alpha.txt").build(), entry("beta.txt").build()]; + state + .left_panel + .set_entries(vec![entry("alpha.txt").build(), entry("beta.txt").build()]); + state + .left_panel + .listing + .set_filtered(&[entry("beta.txt").build()]); state.left_panel.set_filter(Some("beta".to_string())); run_menu_action(&mut state); @@ -207,7 +244,10 @@ fn menu_reset_filter_preserves_current_entry_focus() { fn run_selected_menu_action_fallback_to_normal() { let mut state = AppState { mode: AppMode::Menu, - menu_item_selected: 99, + ui: lc::app::types::UiState { + menu_item_selected: 99, + ..Default::default() + }, ..Default::default() }; @@ -221,8 +261,11 @@ fn menu_command_line_clears_stale_prev_mode() { let mut state = AppState { mode: AppMode::Menu, prev_mode: Some(AppMode::Search), - menu_selected: 2, - menu_item_selected: 7, + ui: lc::app::types::UiState { + menu_selected: 2, + menu_item_selected: 7, + ..Default::default() + }, ..Default::default() }; @@ -236,8 +279,11 @@ fn menu_command_line_clears_stale_prev_mode() { fn menu_right_panel_sort_changes_right_panel() { let mut state = AppState { mode: AppMode::Menu, - menu_selected: 4, - menu_item_selected: 1, + ui: lc::app::types::UiState { + menu_selected: 4, + menu_item_selected: 1, + ..Default::default() + }, active_panel: ActivePanel::Left, ..Default::default() }; @@ -265,8 +311,11 @@ fn menu_right_panel_sort_changes_right_panel() { fn menu_right_panel_filter_applies_to_right_panel() { let mut state = AppState { mode: AppMode::Menu, - menu_selected: 4, - menu_item_selected: 2, + ui: lc::app::types::UiState { + menu_selected: 4, + menu_item_selected: 2, + ..Default::default() + }, active_panel: ActivePanel::Left, ..Default::default() }; @@ -286,8 +335,11 @@ fn menu_right_panel_filter_applies_to_right_panel() { fn menu_right_panel_listing_mode_toggles_right() { let mut state = AppState { mode: AppMode::Menu, - menu_selected: 4, - menu_item_selected: 0, + ui: lc::app::types::UiState { + menu_selected: 4, + menu_item_selected: 0, + ..Default::default() + }, active_panel: ActivePanel::Left, ..Default::default() }; @@ -303,8 +355,11 @@ fn menu_right_panel_refresh_refreshes_right() { let temp_dir = tempfile::tempdir().unwrap(); let mut state = AppState { mode: AppMode::Menu, - menu_selected: 4, - menu_item_selected: 3, + ui: lc::app::types::UiState { + menu_selected: 4, + menu_item_selected: 3, + ..Default::default() + }, active_panel: ActivePanel::Left, ..Default::default() }; @@ -317,8 +372,7 @@ fn menu_right_panel_refresh_refreshes_right() { state .right_panel .listing - .entries - .iter() + .filtered() .any(|e| e.name == "test.txt") ); } @@ -328,8 +382,11 @@ fn menu_cancel_from_search_restores_search_mode() { let mut state = AppState { mode: AppMode::Menu, prev_mode: Some(AppMode::Search), - menu_selected: 1, - menu_item_selected: 0, + ui: lc::app::types::UiState { + menu_selected: 1, + menu_item_selected: 0, + ..Default::default() + }, ..Default::default() }; @@ -344,8 +401,11 @@ fn menu_cancel_from_normal_returns_to_normal() { let mut state = AppState { mode: AppMode::Menu, prev_mode: Some(AppMode::Normal), - menu_selected: 1, - menu_item_selected: 0, + ui: lc::app::types::UiState { + menu_selected: 1, + menu_item_selected: 0, + ..Default::default() + }, ..Default::default() }; @@ -360,8 +420,11 @@ fn menu_cancel_with_no_prev_mode_defaults_to_normal() { let mut state = AppState { mode: AppMode::Menu, prev_mode: None, - menu_selected: 1, - menu_item_selected: 0, + ui: lc::app::types::UiState { + menu_selected: 1, + menu_item_selected: 0, + ..Default::default() + }, ..Default::default() }; @@ -376,8 +439,11 @@ fn menu_cancel_with_f9_restores_prev_mode() { let mut state = AppState { mode: AppMode::Menu, prev_mode: Some(AppMode::Viewing), - menu_selected: 1, - menu_item_selected: 0, + ui: lc::app::types::UiState { + menu_selected: 1, + menu_item_selected: 0, + ..Default::default() + }, ..Default::default() }; @@ -396,22 +462,23 @@ fn menu_rename_collision_shows_error_message() { std::fs::write(&existing_path, "existing content").unwrap(); let mut state = AppState::default(); - state.left_panel.listing.entries = vec![ + state.left_panel.set_entries(vec![ TestEntry::new("old.txt").path(&old_path).file(1).build(), TestEntry::new("existing.txt") .path(&existing_path) .file(1) .build(), - ]; + ]); state.left_panel.cursor = 0; state.active_panel = ActivePanel::Left; state.mode = AppMode::Menu; - state.menu_selected = 1; - state.menu_item_selected = 7; + state.ui.menu_selected = 1; + state.ui.menu_item_selected = 7; dispatch_menu(&mut state, KeyCode::Enter); state + .input .dialog_input .set_text_at_end("existing.txt".to_string()); @@ -423,7 +490,7 @@ fn menu_rename_collision_shows_error_message() { ratatui::layout::Size::new(80, TEST_HEIGHT), ); - assert!(state.status_message.is_some()); + assert!(state.ui.status_message.is_some()); assert_eq!(state.mode, AppMode::Normal); assert!(old_path.exists()); assert!(existing_path.exists()); diff --git a/src/tests/misc.rs b/src/tests/misc.rs index 4b4b004..c04f79a 100644 --- a/src/tests/misc.rs +++ b/src/tests/misc.rs @@ -6,10 +6,15 @@ struct IsolatedEnv { home: Option, } +/// Wraps a path as the owned `OsString` the env map stores. +fn os(path: &std::path::Path) -> Option { + Some(path.as_os_str().to_owned()) +} + impl IsolatedEnv { fn new(xdg_config: &std::path::Path) -> Self { Self { - xdg_config: Some(xdg_config.as_os_str().to_owned()), + xdg_config: os(xdg_config), home: Some(std::ffi::OsString::from("/nonexistent")), } } @@ -30,7 +35,7 @@ impl IsolatedEnv { fn xdg(xdg_config: &std::path::Path) -> Self { Self { - xdg_config: Some(xdg_config.as_os_str().to_owned()), + xdg_config: os(xdg_config), home: None, } } @@ -59,22 +64,38 @@ fn file_name_str_root_returns_none() { assert_eq!(file_name_str(std::path::Path::new("/")), None); } +#[cfg(unix)] #[test] fn file_name_str_non_utf8_returns_lossy() { use std::ffi::OsStr; use std::os::unix::ffi::OsStrExt; + // 0xFF is not valid UTF-8, so to_string_lossy replaces it with U+FFFD. let bad = OsStr::from_bytes(b"bad\xFFname"); let path = std::path::Path::new("/tmp").join(bad); let result = file_name_str(&path); assert_eq!(result.as_deref(), Some("bad\u{fffd}name")); } +#[cfg(windows)] +#[test] +fn file_name_str_non_utf8_returns_lossy() { + use std::ffi::OsString; + use std::os::windows::ffi::OsStringExt; + // 0xD800 is an unpaired high surrogate: invalid UTF-16, so to_string_lossy + // replaces it with U+FFFD. + let bad = OsString::from_wide(&[0x0062, 0x0061, 0x0064, 0xD800, 0x006E]); + let path = std::path::Path::new("C:\\tmp").join(bad); + let result = file_name_str(&path); + assert_eq!(result.as_deref(), Some("bad\u{fffd}n")); +} + #[test] fn config_load_missing_file_ok() { let tmp = tempfile::tempdir().expect("tempdir creation"); let env = IsolatedEnv::new(tmp.path()); let result = app::config::load_settings_with_env(&env); - assert!(result.is_ok()); + // No config file present -> Ok(None): not an error, and no defaults invented. + assert_eq!(result.expect("load should succeed"), None); } #[test] @@ -84,8 +105,65 @@ fn config_load_invalid_toml() { std::fs::create_dir_all(&config_dir).expect("create config dir"); std::fs::write(config_dir.join("config.toml"), "[[broken toml {{{").expect("write config"); let env = IsolatedEnv::new(tmp.path()); - let result = app::config::load_settings_with_env(&env); - assert!(result.is_err()); + let err = app::config::load_settings_with_env(&env).expect_err("invalid toml must error"); + assert!( + err.contains("parse"), + "error should mention the parse failure: {err}" + ); +} + +#[test] +fn config_load_full_toml_parses_all_fields() { + use lc::app::types::{ActivePanel, Direction, ListingMode, SortField, SortMode}; + + let tmp = tempfile::tempdir().expect("tempdir creation"); + let config_dir = tmp.path().join("lc"); + std::fs::create_dir_all(&config_dir).expect("create config dir"); + let toml = "\ +active_panel = \"right\" +dir_first = false +sensitive = true +hotlist = [\"/tmp\"] + +[left] +path = \"/tmp\" +listing_mode = \"brief\" +sort_mode = \"size_desc\" +filter = \"rs\" +show_hidden = false +show_permissions = true + +[right] +listing_mode = \"long\" +sort_mode = \"name_asc\" +"; + std::fs::write(config_dir.join("config.toml"), toml).expect("write config"); + let env = IsolatedEnv::new(tmp.path()); + + let settings = app::config::load_settings_with_env(&env) + .expect("load should succeed") + .expect("config present"); + + assert_eq!(settings.active_panel, ActivePanel::Right); + assert!(!settings.dir_first); + // `sensitive` is the canonical field name (alias: `sort_sensitive`). + assert!(settings.sensitive); + assert_eq!(settings.left.listing_mode, ListingMode::Brief); + assert_eq!( + settings.left.sort_mode, + SortMode::new(SortField::Size, Direction::Desc) + ); + assert_eq!(settings.left.filter, "rs"); + assert!(!settings.left.show_hidden); + assert!(settings.left.show_permissions); + assert_eq!(settings.right.listing_mode, ListingMode::Long); + assert_eq!( + settings.right.sort_mode, + SortMode::new(SortField::Name, Direction::Asc) + ); + // "/tmp" exists, so it is canonicalized into a single hotlist entry (the + // absolute form is platform-dependent, e.g. /private/tmp on macOS). + assert_eq!(settings.hotlist.len(), 1); } #[test] diff --git a/src/tests/overwrite.rs b/src/tests/overwrite.rs index 3664c3d..f778468 100644 --- a/src/tests/overwrite.rs +++ b/src/tests/overwrite.rs @@ -1,8 +1,8 @@ use crate::input::dialogs; use crossterm::event::KeyCode; use lc::app::types::{ - ActivePanel, AppMode, AppState, ArchiveExtractDetails, DialogKind, PendingAction, TextInput, - TransferAction, + ActivePanel, AppMode, AppState, ArchiveExtractDetails, DialogKind, InputState, PendingAction, + TextInput, TransferAction, UiState, }; use lc::ops::archive::ArchiveFormat; use ratatui::layout::Size; @@ -31,11 +31,14 @@ fn check_overwrite_no_conflicts_returns_none() { setup_src_dest(&src, &dest, &["new.txt"]); let state = AppState { - pending_action: Some(PendingAction::Copy(TransferAction { - sources: vec![src.join("new.txt")], - dest, - overwrite: false, - })), + ui: UiState { + pending_action: Some(PendingAction::Copy(TransferAction { + sources: vec![src.join("new.txt")], + dest, + overwrite: false, + })), + ..Default::default() + }, ..Default::default() }; @@ -51,11 +54,14 @@ fn check_overwrite_one_conflict_returns_some() { setup_dest_files(&dest, &["clash.txt"]); let state = AppState { - pending_action: Some(PendingAction::Copy(TransferAction { - sources: vec![src.join("clash.txt")], - dest, - overwrite: false, - })), + ui: UiState { + pending_action: Some(PendingAction::Copy(TransferAction { + sources: vec![src.join("clash.txt")], + dest, + overwrite: false, + })), + ..Default::default() + }, ..Default::default() }; @@ -72,11 +78,14 @@ fn check_overwrite_all_conflicts_returns_all_names() { setup_dest_files(&dest, &["a.txt", "b.txt"]); let state = AppState { - pending_action: Some(PendingAction::Copy(TransferAction { - sources: vec![src.join("a.txt"), src.join("b.txt")], - dest, - overwrite: false, - })), + ui: UiState { + pending_action: Some(PendingAction::Copy(TransferAction { + sources: vec![src.join("a.txt"), src.join("b.txt")], + dest, + overwrite: false, + })), + ..Default::default() + }, ..Default::default() }; @@ -93,11 +102,14 @@ fn check_overwrite_source_equals_dest_skipped() { std::fs::write(&file, b"data").unwrap(); let state = AppState { - pending_action: Some(PendingAction::Copy(TransferAction { - sources: vec![file], - dest: tmp.path().to_path_buf(), - overwrite: false, - })), + ui: UiState { + pending_action: Some(PendingAction::Copy(TransferAction { + sources: vec![file], + dest: tmp.path().to_path_buf(), + overwrite: false, + })), + ..Default::default() + }, ..Default::default() }; @@ -115,11 +127,14 @@ fn check_overwrite_broken_symlink_at_dest_is_conflict() { std::os::unix::fs::symlink("/nonexistent/broken", dest.join("link.txt")).unwrap(); let state = AppState { - pending_action: Some(PendingAction::Copy(TransferAction { - sources: vec![src.join("link.txt")], - dest, - overwrite: false, - })), + ui: UiState { + pending_action: Some(PendingAction::Copy(TransferAction { + sources: vec![src.join("link.txt")], + dest, + overwrite: false, + })), + ..Default::default() + }, ..Default::default() }; @@ -136,11 +151,14 @@ fn check_overwrite_move_conflict() { setup_dest_files(&dest, &["file.txt"]); let state = AppState { active_panel: ActivePanel::Left, - pending_action: Some(PendingAction::Move(TransferAction { - sources: vec![src.join("file.txt")], - dest, - overwrite: false, - })), + ui: UiState { + pending_action: Some(PendingAction::Move(TransferAction { + sources: vec![src.join("file.txt")], + dest, + overwrite: false, + })), + ..Default::default() + }, ..Default::default() }; let conflicts = dialogs::check_overwrite_conflict(&state); @@ -155,11 +173,14 @@ fn check_overwrite_move_same_file_no_conflict() { let file = src.join("file.txt"); let state = AppState { active_panel: ActivePanel::Left, - pending_action: Some(PendingAction::Move(TransferAction { - sources: vec![file], - dest: src, - overwrite: false, - })), + ui: UiState { + pending_action: Some(PendingAction::Move(TransferAction { + sources: vec![file], + dest: src, + overwrite: false, + })), + ..Default::default() + }, ..Default::default() }; let conflicts = dialogs::check_overwrite_conflict(&state); @@ -175,11 +196,14 @@ fn check_overwrite_move_overwrite_no_conflict() { setup_dest_files(&dest, &["file.txt"]); let state = AppState { active_panel: ActivePanel::Left, - pending_action: Some(PendingAction::Move(TransferAction { - sources: vec![src.join("file.txt")], - dest, - overwrite: true, - })), + ui: UiState { + pending_action: Some(PendingAction::Move(TransferAction { + sources: vec![src.join("file.txt")], + dest, + overwrite: true, + })), + ..Default::default() + }, ..Default::default() }; let conflicts = dialogs::check_overwrite_conflict(&state); @@ -189,9 +213,12 @@ fn check_overwrite_move_overwrite_no_conflict() { #[test] fn check_overwrite_delete_no_conflict() { let state = AppState { - pending_action: Some(PendingAction::Delete { - paths: vec![PathBuf::from("/tmp/nonexistent")], - }), + ui: UiState { + pending_action: Some(PendingAction::Delete { + paths: vec![PathBuf::from("/tmp/nonexistent")], + }), + ..Default::default() + }, ..Default::default() }; let conflicts = dialogs::check_overwrite_conflict(&state); @@ -224,11 +251,14 @@ fn check_overwrite_extract_archive_no_conflict_empty_dest() { let dest = tmp.path().join("dest"); std::fs::create_dir_all(&dest).unwrap(); let state = AppState { - pending_action: Some(PendingAction::ExtractArchive { - source: src, - dest, - overwrite: false, - }), + ui: UiState { + pending_action: Some(PendingAction::ExtractArchive { + source: src, + dest, + overwrite: false, + }), + ..Default::default() + }, ..Default::default() }; assert!(dialogs::check_overwrite_conflict(&state).is_none()); @@ -242,11 +272,14 @@ fn check_overwrite_extract_archive_conflict_with_existing_file() { std::fs::create_dir_all(&dest).unwrap(); std::fs::write(dest.join("file1.txt"), b"existing").unwrap(); let state = AppState { - pending_action: Some(PendingAction::ExtractArchive { - source: src, - dest, - overwrite: false, - }), + ui: UiState { + pending_action: Some(PendingAction::ExtractArchive { + source: src, + dest, + overwrite: false, + }), + ..Default::default() + }, ..Default::default() }; let conflicts = dialogs::check_overwrite_conflict(&state).unwrap(); @@ -261,11 +294,14 @@ fn check_overwrite_extract_archive_overwrite_true_no_conflict() { std::fs::create_dir_all(&dest).unwrap(); std::fs::write(dest.join("file1.txt"), b"existing").unwrap(); let state = AppState { - pending_action: Some(PendingAction::ExtractArchive { - source: src, - dest, - overwrite: true, - }), + ui: UiState { + pending_action: Some(PendingAction::ExtractArchive { + source: src, + dest, + overwrite: true, + }), + ..Default::default() + }, ..Default::default() }; assert!(dialogs::check_overwrite_conflict(&state).is_none()); @@ -279,12 +315,15 @@ fn check_overwrite_create_archive_dest_exists_conflict() { std::fs::write(&file, b"data").unwrap(); std::fs::write(&dest, b"existing").unwrap(); let state = AppState { - pending_action: Some(PendingAction::CreateArchive { - sources: vec![file], - dest, - format: ArchiveFormat::TarGz, - overwrite: false, - }), + ui: UiState { + pending_action: Some(PendingAction::CreateArchive { + sources: vec![file], + dest, + format: ArchiveFormat::TarGz, + overwrite: false, + }), + ..Default::default() + }, ..Default::default() }; let conflicts = dialogs::check_overwrite_conflict(&state).unwrap(); @@ -298,12 +337,15 @@ fn check_overwrite_create_archive_dest_not_exists_no_conflict() { let file = tmp.path().join("file.txt"); std::fs::write(&file, b"data").unwrap(); let state = AppState { - pending_action: Some(PendingAction::CreateArchive { - sources: vec![file], - dest, - format: ArchiveFormat::TarGz, - overwrite: false, - }), + ui: UiState { + pending_action: Some(PendingAction::CreateArchive { + sources: vec![file], + dest, + format: ArchiveFormat::TarGz, + overwrite: false, + }), + ..Default::default() + }, ..Default::default() }; assert!(dialogs::check_overwrite_conflict(&state).is_none()); @@ -317,12 +359,15 @@ fn check_overwrite_create_archive_overwrite_true_no_conflict() { std::fs::write(&file, b"data").unwrap(); std::fs::write(&dest, b"existing").unwrap(); let state = AppState { - pending_action: Some(PendingAction::CreateArchive { - sources: vec![file], - dest, - format: ArchiveFormat::TarGz, - overwrite: true, - }), + ui: UiState { + pending_action: Some(PendingAction::CreateArchive { + sources: vec![file], + dest, + format: ArchiveFormat::TarGz, + overwrite: true, + }), + ..Default::default() + }, ..Default::default() }; assert!(dialogs::check_overwrite_conflict(&state).is_none()); @@ -338,11 +383,14 @@ fn check_overwrite_copy_directory_source_conflict() { std::fs::create_dir_all(dest.join("mydir")).unwrap(); let state = AppState { - pending_action: Some(PendingAction::Copy(TransferAction { - sources: vec![src_dir], - dest, - overwrite: false, - })), + ui: UiState { + pending_action: Some(PendingAction::Copy(TransferAction { + sources: vec![src_dir], + dest, + overwrite: false, + })), + ..Default::default() + }, ..Default::default() }; @@ -359,11 +407,14 @@ fn check_overwrite_copy_directory_source_no_conflict() { std::fs::create_dir_all(&dest).unwrap(); let state = AppState { - pending_action: Some(PendingAction::Copy(TransferAction { - sources: vec![src_dir], - dest, - overwrite: false, - })), + ui: UiState { + pending_action: Some(PendingAction::Copy(TransferAction { + sources: vec![src_dir], + dest, + overwrite: false, + })), + ..Default::default() + }, ..Default::default() }; @@ -387,11 +438,14 @@ fn check_overwrite_copy_symlink_source_conflict() { std::fs::write(dest.join("link.txt"), b"existing").unwrap(); let state = AppState { - pending_action: Some(PendingAction::Copy(TransferAction { - sources: vec![link], - dest, - overwrite: false, - })), + ui: UiState { + pending_action: Some(PendingAction::Copy(TransferAction { + sources: vec![link], + dest, + overwrite: false, + })), + ..Default::default() + }, ..Default::default() }; @@ -403,11 +457,14 @@ fn check_overwrite_copy_symlink_source_conflict() { fn check_overwrite_copy_empty_sources_no_panic() { let dir = tempfile::tempdir().unwrap(); let state = AppState { - pending_action: Some(PendingAction::Copy(TransferAction { - sources: vec![], - dest: dir.path().to_path_buf(), - overwrite: false, - })), + ui: UiState { + pending_action: Some(PendingAction::Copy(TransferAction { + sources: vec![], + dest: dir.path().to_path_buf(), + overwrite: false, + })), + ..Default::default() + }, ..Default::default() }; let result = dialogs::check_overwrite_conflict(&state); @@ -418,11 +475,14 @@ fn check_overwrite_copy_empty_sources_no_panic() { fn check_overwrite_move_empty_sources_no_panic() { let dir = tempfile::tempdir().unwrap(); let state = AppState { - pending_action: Some(PendingAction::Move(TransferAction { - sources: vec![], - dest: dir.path().to_path_buf(), - overwrite: false, - })), + ui: UiState { + pending_action: Some(PendingAction::Move(TransferAction { + sources: vec![], + dest: dir.path().to_path_buf(), + overwrite: false, + })), + ..Default::default() + }, ..Default::default() }; let result = dialogs::check_overwrite_conflict(&state); @@ -448,7 +508,10 @@ fn archive_extract_enter_with_conflict_shows_overwrite_dialog_without_starting_a dest_input, }, ))), - dialog_selection: 0, + input: InputState { + dialog_selection: 0, + ..Default::default() + }, ..Default::default() }; let mut running_job = None; @@ -467,7 +530,7 @@ fn archive_extract_enter_with_conflict_shows_overwrite_dialog_without_starting_a )); assert!(running_job.is_none()); assert!(matches!( - state.pending_action, + state.ui.pending_action, Some(PendingAction::ExtractArchive { overwrite: false, .. @@ -478,7 +541,10 @@ fn archive_extract_enter_with_conflict_shows_overwrite_dialog_without_starting_a #[test] fn check_overwrite_pending_action_none_returns_none() { let state = AppState { - pending_action: None, + ui: UiState { + pending_action: None, + ..Default::default() + }, ..Default::default() }; assert!(dialogs::check_overwrite_conflict(&state).is_none()); @@ -493,11 +559,14 @@ fn check_overwrite_copy_overwrite_true_no_conflict() { setup_dest_files(&dest, &["file.txt"]); let state = AppState { - pending_action: Some(PendingAction::Copy(TransferAction { - sources: vec![src.join("file.txt")], - dest, - overwrite: true, - })), + ui: UiState { + pending_action: Some(PendingAction::Copy(TransferAction { + sources: vec![src.join("file.txt")], + dest, + overwrite: true, + })), + ..Default::default() + }, ..Default::default() }; @@ -515,11 +584,14 @@ fn check_overwrite_copy_duplicate_sources_different_dirs_conflict() { setup_dest_files(&dest, &["same.txt"]); let state = AppState { - pending_action: Some(PendingAction::Copy(TransferAction { - sources: vec![src_a.join("same.txt"), src_b.join("same.txt")], - dest, - overwrite: false, - })), + ui: UiState { + pending_action: Some(PendingAction::Copy(TransferAction { + sources: vec![src_a.join("same.txt"), src_b.join("same.txt")], + dest, + overwrite: false, + })), + ..Default::default() + }, ..Default::default() }; @@ -535,11 +607,14 @@ fn check_overwrite_copy_nonexistent_dest_no_panic() { setup_src_dest(&src, &nonexistent_dest, &["file.txt"]); let state = AppState { - pending_action: Some(PendingAction::Copy(TransferAction { - sources: vec![src.join("file.txt")], - dest: nonexistent_dest, - overwrite: false, - })), + ui: UiState { + pending_action: Some(PendingAction::Copy(TransferAction { + sources: vec![src.join("file.txt")], + dest: nonexistent_dest, + overwrite: false, + })), + ..Default::default() + }, ..Default::default() }; diff --git a/src/tests/pickers.rs b/src/tests/pickers.rs index 079a739..712a349 100644 --- a/src/tests/pickers.rs +++ b/src/tests/pickers.rs @@ -4,26 +4,28 @@ use lc::app::types::{AppMode, AppState, PanelState, PickerKind}; use std::path::PathBuf; fn hotlist_state(paths: &[&str]) -> AppState { - AppState { + let mut state = AppState { mode: AppMode::ListPicker(PickerKind::Hotlist), - directory_hotlist: paths.iter().map(PathBuf::from).collect(), ..Default::default() - } + }; + state.ui.directory_hotlist = paths.iter().map(PathBuf::from).collect(); + state } fn with_history(mut state: AppState, cmds: &[&str]) -> AppState { for cmd in cmds { - state.command_history.push_back(cmd.to_string()); + state.input.command_history.push_back(cmd.to_string()); } state } fn picker_at(kind: PickerKind, selected: usize) -> AppState { - AppState { + let mut state = AppState { mode: AppMode::ListPicker(kind), - picker_selected: selected, ..Default::default() - } + }; + state.ui.picker_selected = selected; + state } #[test] @@ -32,15 +34,15 @@ fn hotlist_picker_add_and_dedup() { let tmp_path = tmp.path().to_path_buf(); let mut state = AppState { left_panel: PanelState::new(tmp_path.clone()), - directory_hotlist: vec![], mode: AppMode::ListPicker(PickerKind::Hotlist), ..Default::default() }; + state.ui.directory_hotlist = vec![]; pickers::handle_list_picker(&mut state, KeyCode::Char('a')); assert!(state.hotlist().contains(&tmp_path)); assert_eq!( - state.status_message, + state.ui.status_message, Some("Added current directory to hotlist".to_string()) ); @@ -51,7 +53,7 @@ fn hotlist_picker_add_and_dedup() { 1 ); assert_eq!( - state.status_message, + state.ui.status_message, Some("Directory already in hotlist".to_string()) ); } @@ -59,31 +61,31 @@ fn hotlist_picker_add_and_dedup() { #[test] fn hotlist_picker_delete_middle_and_last() { let mut state = hotlist_state(&["/a", "/b", "/c"]); - state.picker_selected = 1; + state.ui.picker_selected = 1; pickers::handle_list_picker(&mut state, KeyCode::Char('d')); assert_eq!(state.hotlist().len(), 2); assert!(!state.hotlist().contains(&PathBuf::from("/b"))); - state.picker_selected = state.hotlist().len() - 1; + state.ui.picker_selected = state.hotlist().len() - 1; pickers::handle_list_picker(&mut state, KeyCode::Char('d')); assert_eq!(state.hotlist().len(), 1); - assert_eq!(state.picker_selected, 0); + assert_eq!(state.ui.picker_selected, 0); } #[test] fn picker_wrap_empty_and_single() { let mut empty = picker_at(PickerKind::History, 0); pickers::handle_list_picker(&mut empty, KeyCode::Up); - assert_eq!(empty.picker_selected, 0); + assert_eq!(empty.ui.picker_selected, 0); pickers::handle_list_picker(&mut empty, KeyCode::Down); - assert_eq!(empty.picker_selected, 0); + assert_eq!(empty.ui.picker_selected, 0); let mut single = with_history(picker_at(PickerKind::History, 0), &["only"]); pickers::handle_list_picker(&mut single, KeyCode::Up); - assert_eq!(single.picker_selected, 0); + assert_eq!(single.ui.picker_selected, 0); pickers::handle_list_picker(&mut single, KeyCode::Down); - assert_eq!(single.picker_selected, 0); + assert_eq!(single.ui.picker_selected, 0); } #[test] @@ -91,11 +93,11 @@ fn picker_three_items_no_wrap_at_bounds() { let mut state = with_history(picker_at(PickerKind::History, 0), &["a", "b", "c"]); pickers::handle_list_picker(&mut state, KeyCode::Up); - assert_eq!(state.picker_selected, 0); + assert_eq!(state.ui.picker_selected, 0); - state.picker_selected = 2; + state.ui.picker_selected = 2; pickers::handle_list_picker(&mut state, KeyCode::Down); - assert_eq!(state.picker_selected, 2); + assert_eq!(state.ui.picker_selected, 2); } #[test] @@ -113,12 +115,12 @@ fn picker_escape_returns_normal() { fn list_picker_returns_early_if_not_list_picker_mode() { let mut state = AppState { mode: AppMode::Normal, - status_message: Some("preserved".to_string()), ..Default::default() }; + state.ui.status_message = Some("preserved".to_string()); pickers::handle_list_picker(&mut state, KeyCode::Enter); assert_eq!(state.mode, AppMode::Normal); - assert_eq!(state.status_message, Some("preserved".to_string())); + assert_eq!(state.ui.status_message, Some("preserved".to_string())); } #[test] @@ -126,10 +128,10 @@ fn history_picker_home_end() { let mut state = with_history(picker_at(PickerKind::History, 2), &["a", "b", "c"]); pickers::handle_list_picker(&mut state, KeyCode::Home); - assert_eq!(state.picker_selected, 0); + assert_eq!(state.ui.picker_selected, 0); pickers::handle_list_picker(&mut state, KeyCode::End); - assert_eq!(state.picker_selected, 2); + assert_eq!(state.ui.picker_selected, 2); } #[test] @@ -137,10 +139,10 @@ fn hotlist_picker_home_end() { let mut state = hotlist_state(&["/a", "/b", "/c"]); pickers::handle_list_picker(&mut state, KeyCode::End); - assert_eq!(state.picker_selected, 2); + assert_eq!(state.ui.picker_selected, 2); pickers::handle_list_picker(&mut state, KeyCode::Home); - assert_eq!(state.picker_selected, 0); + assert_eq!(state.ui.picker_selected, 0); } #[test] @@ -155,16 +157,16 @@ fn archive_menu_picker_navigate_bounds() { let mut state = picker_at(PickerKind::ArchiveMenu, 0); pickers::handle_list_picker(&mut state, KeyCode::Down); - assert_eq!(state.picker_selected, 1); + assert_eq!(state.ui.picker_selected, 1); pickers::handle_list_picker(&mut state, KeyCode::Down); - assert_eq!(state.picker_selected, 1); + assert_eq!(state.ui.picker_selected, 1); pickers::handle_list_picker(&mut state, KeyCode::Up); - assert_eq!(state.picker_selected, 0); + assert_eq!(state.ui.picker_selected, 0); pickers::handle_list_picker(&mut state, KeyCode::Up); - assert_eq!(state.picker_selected, 0); + assert_eq!(state.ui.picker_selected, 0); } #[test] @@ -172,8 +174,8 @@ fn archive_menu_picker_home_end() { let mut state = picker_at(PickerKind::ArchiveMenu, 0); pickers::handle_list_picker(&mut state, KeyCode::End); - assert_eq!(state.picker_selected, 1); + assert_eq!(state.ui.picker_selected, 1); pickers::handle_list_picker(&mut state, KeyCode::Home); - assert_eq!(state.picker_selected, 0); + assert_eq!(state.ui.picker_selected, 0); } diff --git a/src/tests/search.rs b/src/tests/search.rs index 4fe6dd0..f4b36ec 100644 --- a/src/tests/search.rs +++ b/src/tests/search.rs @@ -3,17 +3,17 @@ use crate::apply_search_filter; use crate::input::mode_dispatch::handle_search_mode; use crossterm::event::KeyCode; use lc::app; -use lc::app::types::{AppMode, AppState}; +use lc::app::types::{AppMode, AppState, InputState}; fn entry(name: &str) -> TestEntry { TestEntry::new(name).path(test_path(name)) } /// Seed the left panel with `entries` as both the visible and unfiltered -/// listing — the common `entries = X.clone(); unfiltered_entries = X` setup. +/// listing — the common "filtered view == full backing store" setup. fn setup_entries(state: &mut AppState, entries: Vec) { - state.left_panel.listing.entries = entries.clone(); - state.left_panel.listing.unfiltered_entries = entries; + state.left_panel.listing.set_unfiltered(entries); + state.left_panel.listing.set_filtered_all(); } fn setup_temp_files(names: &[&str]) -> tempfile::TempDir { @@ -31,15 +31,21 @@ fn search_enter_preserves_current_entry_focus() { let beta = temp_dir.path().join("beta.txt"); let mut state = AppState { mode: AppMode::Search, - search_query: "beta".to_string(), + input: InputState { + search_query: "beta".to_string(), + ..Default::default() + }, ..Default::default() }; state.left_panel.set_path(temp_dir.path().to_path_buf()); - state.left_panel.listing.entries = vec![TestEntry::new("beta.txt").path(&beta).file(1).build()]; - state.left_panel.listing.unfiltered_entries = vec![ + state.left_panel.listing.set_unfiltered(vec![ TestEntry::new("alpha.txt").path(&alpha).file(1).build(), TestEntry::new("beta.txt").path(&beta).file(1).build(), - ]; + ]); + state + .left_panel + .listing + .set_filtered(&[TestEntry::new("beta.txt").path(&beta).file(1).build()]); state.left_panel.set_filter(Some("beta".to_string())); handle_search_mode(&mut state, KeyCode::Enter, TERMINAL_HEIGHT); @@ -59,14 +65,17 @@ fn search_enter_refreshes_when_unfiltered_cache_is_dirty() { let stale = temp_dir.path().join("stale.txt"); let mut state = AppState { mode: AppMode::Search, - search_query: "fresh".to_string(), + input: InputState { + search_query: "fresh".to_string(), + ..Default::default() + }, ..Default::default() }; state.left_panel.set_path(temp_dir.path().to_path_buf()); - state.left_panel.listing.entries = - vec![TestEntry::new("stale.txt").path(&stale).file(1).build()]; - state.left_panel.listing.unfiltered_entries = - vec![TestEntry::new("stale.txt").path(&stale).file(1).build()]; + state.left_panel.listing.set_unfiltered(vec![ + TestEntry::new("stale.txt").path(&stale).file(1).build(), + ]); + state.left_panel.listing.set_filtered_all(); state.left_panel.mark_unfiltered_dirty(); state.left_panel.set_filter(Some("fresh".to_string())); @@ -76,16 +85,14 @@ fn search_enter_refreshes_when_unfiltered_cache_is_dirty() { state .left_panel .listing - .entries - .iter() + .filtered() .any(|entry| entry.name == "fresh.txt") ); assert!( !state .left_panel .listing - .entries - .iter() + .filtered() .any(|entry| entry.name == "stale.txt") ); } @@ -96,35 +103,39 @@ fn search_enter_clears_filter_and_restores_unfiltered_entries() { let mut state = AppState { mode: AppMode::Search, - search_query: "alpha".to_string(), + input: InputState { + search_query: "alpha".to_string(), + ..Default::default() + }, ..Default::default() }; state.left_panel.set_path(temp_dir.path().to_path_buf()); - state.left_panel.listing.entries = vec![entry("alpha.txt").file(1).build()]; - state.left_panel.listing.unfiltered_entries = vec![ + state.left_panel.listing.set_unfiltered(vec![ entry("alpha.txt").file(1).build(), entry("beta.txt").file(2).build(), - ]; + ]); + state + .left_panel + .listing + .set_filtered(&[entry("alpha.txt").file(1).build()]); state.left_panel.set_filter(Some("alpha".to_string())); handle_search_mode(&mut state, KeyCode::Enter, TERMINAL_HEIGHT); assert_eq!(state.mode, AppMode::Normal); - assert_eq!(state.search_query, ""); + assert_eq!(state.input.search_query, ""); assert!(state.left_panel.filter().is_none()); assert!( state .left_panel .listing - .entries - .iter() + .filtered() .any(|e| e.name == "alpha.txt"), "alpha.txt missing: {:?}", state .left_panel .listing - .entries - .iter() + .filtered() .map(|e| &e.name) .collect::>() ); @@ -132,15 +143,13 @@ fn search_enter_clears_filter_and_restores_unfiltered_entries() { state .left_panel .listing - .entries - .iter() + .filtered() .any(|e| e.name == "beta.txt"), "beta.txt missing: {:?}", state .left_panel .listing - .entries - .iter() + .filtered() .map(|e| &e.name) .collect::>() ); @@ -151,7 +160,6 @@ fn search_mode_with_empty_panel_handles_enter_gracefully() { let tmp = tempfile::tempdir().unwrap(); let mut state = AppState::default(); state.left_panel.set_path(tmp.path().to_path_buf()); - state.left_panel.listing.entries = vec![]; state.active_panel = app::types::ActivePanel::Left; state.mode = AppMode::Search; handle_search_mode(&mut state, KeyCode::Enter, TERMINAL_HEIGHT); @@ -163,7 +171,6 @@ fn search_mode_with_empty_panel_handles_esc_gracefully() { let tmp = tempfile::tempdir().unwrap(); let mut state = AppState::default(); state.left_panel.set_path(tmp.path().to_path_buf()); - state.left_panel.listing.entries = vec![]; state.active_panel = app::types::ActivePanel::Left; state.mode = AppMode::Search; handle_search_mode(&mut state, KeyCode::Esc, TERMINAL_HEIGHT); @@ -175,11 +182,10 @@ fn search_mode_with_empty_panel_handles_char_gracefully() { let tmp = tempfile::tempdir().unwrap(); let mut state = AppState::default(); state.left_panel.set_path(tmp.path().to_path_buf()); - state.left_panel.listing.entries = vec![]; state.active_panel = app::types::ActivePanel::Left; state.mode = AppMode::Search; handle_search_mode(&mut state, KeyCode::Char('x'), TERMINAL_HEIGHT); - assert_eq!(state.search_query, "x"); + assert_eq!(state.input.search_query, "x"); } #[test] @@ -189,14 +195,7 @@ fn apply_search_filter_exact_match() { setup_entries(&mut state, entries); state.left_panel.set_filter(Some("foo".to_string())); apply_search_filter(&mut state.left_panel); - assert!( - state - .left_panel - .listing - .entries - .iter() - .all(|e| e.name == "foo") - ); + assert!(state.left_panel.listing.filtered().all(|e| e.name == "foo")); } #[test] @@ -206,7 +205,7 @@ fn apply_search_filter_no_match_clears_entries() { setup_entries(&mut state, entries); state.left_panel.set_filter(Some("xyz".to_string())); apply_search_filter(&mut state.left_panel); - assert!(state.left_panel.listing.entries.is_empty()); + assert!(state.left_panel.listing.filtered_is_empty()); } #[test] @@ -217,7 +216,7 @@ fn apply_search_filter_empty_pattern_shows_all() { setup_entries(&mut state, entries); state.left_panel.set_filter(None); apply_search_filter(&mut state.left_panel); - assert_eq!(state.left_panel.listing.entries.len(), count); + assert_eq!(state.left_panel.listing.filtered_len(), count); } #[test] @@ -234,8 +233,7 @@ fn apply_search_filter_partial_match() { let names: Vec<&str> = state .left_panel .listing - .entries - .iter() + .filtered() .map(|e| e.name.as_str()) .collect(); assert_eq!( @@ -256,8 +254,16 @@ fn apply_search_filter_unicode_cjk() { setup_entries(&mut state, entries); state.left_panel.set_filter(Some("文件".to_string())); apply_search_filter(&mut state.left_panel); - assert_eq!(state.left_panel.listing.entries.len(), 1); - assert_eq!(state.left_panel.listing.entries[0].name, "文件.txt"); + assert_eq!(state.left_panel.listing.filtered_len(), 1); + assert_eq!( + state + .left_panel + .listing + .filtered_get(0) + .expect("one filtered entry") + .name, + "文件.txt" + ); } #[test] @@ -271,8 +277,16 @@ fn apply_search_filter_unicode_emoji() { setup_entries(&mut state, entries); state.left_panel.set_filter(Some("🎉".to_string())); apply_search_filter(&mut state.left_panel); - assert_eq!(state.left_panel.listing.entries.len(), 1); - assert_eq!(state.left_panel.listing.entries[0].name, "🎉party.txt"); + assert_eq!(state.left_panel.listing.filtered_len(), 1); + assert_eq!( + state + .left_panel + .listing + .filtered_get(0) + .expect("one filtered entry") + .name, + "🎉party.txt" + ); } #[test] @@ -286,8 +300,16 @@ fn apply_search_filter_unicode_combining_chars() { .left_panel .set_filter(Some("cafe\u{0301}".to_string())); apply_search_filter(&mut state.left_panel); - assert_eq!(state.left_panel.listing.entries.len(), 1); - assert_eq!(state.left_panel.listing.entries[0].name, decomposed); + assert_eq!(state.left_panel.listing.filtered_len(), 1); + assert_eq!( + state + .left_panel + .listing + .filtered_get(0) + .expect("one filtered entry") + .name, + decomposed + ); } #[test] @@ -301,12 +323,15 @@ fn search_esc_restores_entries_documents_cursor() { TestEntry::new("beta.txt").path(&beta).build(), TestEntry::new("gamma.txt").path(&gamma).build(), ]; - state.left_panel.listing.entries = vec![TestEntry::new("beta.txt").path(&beta).build()]; state.left_panel.listing.set_unfiltered(entries.clone()); + state + .left_panel + .listing + .set_filtered(&[TestEntry::new("beta.txt").path(&beta).build()]); state.left_panel.cursor = 0; state.left_panel.set_filter(Some("beta".to_string())); - state.search_query = "beta".to_string(); - state.search_cursor = 4; + state.input.search_query = "beta".to_string(); + state.input.search_cursor = 4; state.mode = AppMode::Search; handle_search_mode(&mut state, KeyCode::Esc, TERMINAL_HEIGHT); @@ -314,12 +339,23 @@ fn search_esc_restores_entries_documents_cursor() { assert_eq!(state.mode, AppMode::Normal); assert!(state.left_panel.filter().is_none()); assert!( - state.search_query.is_empty(), + state.input.search_query.is_empty(), "Esc must clear the search query" ); - assert_eq!(state.search_cursor, 0, "Esc must reset the search cursor"); - assert_eq!(state.left_panel.listing.entries.len(), entries.len()); - assert_eq!(state.left_panel.listing.entries[1].name, "beta.txt"); + assert_eq!( + state.input.search_cursor, 0, + "Esc must reset the search cursor" + ); + assert_eq!(state.left_panel.listing.filtered_len(), entries.len()); + assert_eq!( + state + .left_panel + .listing + .filtered_get(1) + .expect("second filtered entry") + .name, + "beta.txt" + ); assert_eq!(state.left_panel.cursor, 1); } @@ -328,8 +364,11 @@ fn search_backspace_shortens_query_and_cursor() { let temp_dir = setup_temp_files(&["alpha.txt", "beta.txt"]); let mut state = AppState { mode: AppMode::Search, - search_query: "alp".to_string(), - search_cursor: 3, + input: InputState { + search_query: "alp".to_string(), + search_cursor: 3, + ..Default::default() + }, ..Default::default() }; state.left_panel.set_path(temp_dir.path().to_path_buf()); @@ -344,8 +383,11 @@ fn search_backspace_shortens_query_and_cursor() { handle_search_mode(&mut state, KeyCode::Backspace, TERMINAL_HEIGHT); - assert_eq!(state.search_query, "al"); - assert_eq!(state.search_cursor, 2, "cursor follows the shortened query"); + assert_eq!(state.input.search_query, "al"); + assert_eq!( + state.input.search_cursor, 2, + "cursor follows the shortened query" + ); assert_eq!( state.mode, AppMode::Search, @@ -362,8 +404,11 @@ fn search_backspace_to_empty_clears_search() { let temp_dir = setup_temp_files(&["alpha.txt"]); let mut state = AppState { mode: AppMode::Search, - search_query: "a".to_string(), - search_cursor: 1, + input: InputState { + search_query: "a".to_string(), + search_cursor: 1, + ..Default::default() + }, ..Default::default() }; state.left_panel.set_path(temp_dir.path().to_path_buf()); @@ -377,8 +422,8 @@ fn search_backspace_to_empty_clears_search() { AppMode::Normal, "emptying the query exits search" ); - assert!(state.search_query.is_empty()); - assert_eq!(state.search_cursor, 0); + assert!(state.input.search_query.is_empty()); + assert_eq!(state.input.search_cursor, 0); assert!(state.left_panel.filter().is_none()); } @@ -387,7 +432,10 @@ fn search_clear_resets_scroll_offset() { let temp_dir = setup_temp_files(&["a.txt", "b.txt", "c.txt"]); let mut state = AppState { mode: AppMode::Search, - search_query: "a".to_string(), + input: InputState { + search_query: "a".to_string(), + ..Default::default() + }, ..Default::default() }; state.left_panel.set_path(temp_dir.path().to_path_buf()); @@ -408,10 +456,10 @@ fn search_clear_resets_scroll_offset() { // into bounds and never leave it ahead of the cursor. let panel = &state.left_panel; assert!( - panel.scroll_offset < panel.listing.entries.len(), + panel.scroll_offset < panel.listing.filtered_len(), "scroll_offset {} must be within the {}-entry listing", panel.scroll_offset, - panel.listing.entries.len() + panel.listing.filtered_len() ); assert!( panel.scroll_offset <= panel.cursor, diff --git a/src/tests/selection.rs b/src/tests/selection.rs index c7beb15..c27e5ee 100644 --- a/src/tests/selection.rs +++ b/src/tests/selection.rs @@ -13,42 +13,76 @@ use std::path::PathBuf; // `set_selected_count()` calls are needed after `set_entries()`. fn assert_selections(state: &AppState, panel: ActivePanel, expected: &[bool]) { - let entries = match panel { - ActivePanel::Left => &state.left_panel.listing.entries, - ActivePanel::Right => &state.right_panel.listing.entries, + let panel = match panel { + ActivePanel::Left => &state.left_panel, + ActivePanel::Right => &state.right_panel, }; + let selected: Vec = panel.listing.filtered().map(|e| e.selected).collect(); for (i, &exp) in expected.iter().enumerate() { assert_eq!( - entries[i].selected, exp, - "expected entries[{i}].selected = {exp}" + selected[i], exp, + "expected filtered entry [{i}].selected = {exp}" ); } + // Enforce the sync invariant documented above: `selected_count` must equal + // the number of entries whose `selected` flag is set across the whole + // backing store (not just the filtered view), matching `selected_count`'s + // own source. This is what the doc-comment promises but no test previously + // verified. + let actual_count = panel.selected_entries().count(); + assert_eq!( + panel.selected_count(), + actual_count, + "selected_count out of sync with per-entry selected flags" + ); } fn entry(name: &str) -> TestEntry { TestEntry::new(name).path(test_path(name)) } -// Helper for selected_or_current_paths tests. -fn check_selected_paths(entries: Vec, cursor: usize, expected: Vec<&str>) { +// Two plain file entries `a.txt` / `b.txt`, the most common fixture in this +// module. Cuts the repeated two-line `set_entries(vec![...])` boilerplate. +fn test_files_ab() -> Vec { + vec![ + entry("a.txt").file(10).build(), + entry("b.txt").file(20).build(), + ] +} + +// Helper for selected_or_current_paths tests. Loads `entries` into the given +// panel, makes it active, and asserts the resolved paths. Covering both panels +// guards against the resolver hard-coding `left_panel`. +fn check_selected_paths_on( + panel: ActivePanel, + entries: Vec, + cursor: usize, + expected: Vec<&str>, +) { let mut state = AppState::new(); - state.active_panel = ActivePanel::Left; - state.left_panel.set_entries(entries); - state.left_panel.cursor = cursor; + state.active_panel = panel; + let target = match panel { + ActivePanel::Left => &mut state.left_panel, + ActivePanel::Right => &mut state.right_panel, + }; + target.set_entries(entries); + target.cursor = cursor; let paths = selected_or_current_paths(&state); let expected: Vec = expected.into_iter().map(test_path).collect(); assert_eq!(paths, expected); } +// Backwards-compatible wrapper: existing cases target the left panel. +fn check_selected_paths(entries: Vec, cursor: usize, expected: Vec<&str>) { + check_selected_paths_on(ActivePanel::Left, entries, cursor, expected); +} + #[test] fn shift_down_toggles_current_then_moves() { let mut terminal = test_terminal(); let mut state = AppState::new(); - state.left_panel.set_entries(vec![ - entry("a.txt").file(10).build(), - entry("b.txt").file(20).build(), - ]); + state.left_panel.set_entries(test_files_ab()); dispatch_key( &mut state, @@ -193,6 +227,38 @@ fn selected_or_current_paths_all_dotdot_selected_fallback() { ); } +#[test] +fn selected_or_current_paths_right_panel_fallback_to_cursor() { + check_selected_paths_on( + ActivePanel::Right, + vec![ + entry("file_a.txt").file(100).build(), + entry("file_b.txt").file(100).build(), + ], + 1, + vec!["file_b.txt"], + ); +} + +#[test] +fn selected_or_current_paths_right_panel_uses_selection_when_present() { + check_selected_paths_on( + ActivePanel::Right, + vec![ + entry("file_a.txt").file(100).selected().build(), + entry("file_b.txt").file(100).build(), + entry("file_c.txt").file(100).selected().build(), + ], + 1, + vec!["file_a.txt", "file_c.txt"], + ); +} + +// The 2nd argument to `reposition_cursor_to_entry(state, name, visible)` is +// `visible`: the number of rows the panel can display at once. After moving the +// cursor onto the matched entry it is forwarded to `ensure_cursor_visible` so +// the viewport scrolls to keep the new cursor on screen. The value (20 here) +// only affects scroll_offset, not the resolved cursor index these tests assert. #[test] fn reposition_cursor_finds_matching_name() { let mut state = AppState::new(); @@ -240,10 +306,7 @@ fn reposition_cursor_empty_list_no_panic() { fn insert_toggles_current_on_then_moves_down() { let mut terminal = test_terminal(); let mut state = AppState::new(); - state.left_panel.set_entries(vec![ - entry("a.txt").file(10).build(), - entry("b.txt").file(20).build(), - ]); + state.left_panel.set_entries(test_files_ab()); dispatch_key( &mut state, @@ -273,7 +336,14 @@ fn insert_toggles_current_off_stays_on_last() { &mut terminal, ); - assert!(!state.left_panel.listing.entries[1].selected); + assert!( + !state + .left_panel + .listing + .filtered_get(1) + .expect("entry 1 exists") + .selected + ); assert_eq!(state.left_panel.cursor, 1); } @@ -281,10 +351,7 @@ fn insert_toggles_current_off_stays_on_last() { fn insert_on_last_entry_cursor_stays_selection_toggled() { let mut terminal = test_terminal(); let mut state = AppState::new(); - state.left_panel.set_entries(vec![ - entry("a.txt").file(10).build(), - entry("b.txt").file(20).build(), - ]); + state.left_panel.set_entries(test_files_ab()); state.left_panel.cursor = 1; dispatch_key( @@ -341,10 +408,7 @@ fn insert_on_dotdot_only_entry_no_toggle_cursor_stays() { fn shift_wraparound_down_on_last_and_up_on_first() { let mut terminal = test_terminal(); let mut state = AppState::new(); - state.left_panel.set_entries(vec![ - entry("a.txt").file(10).build(), - entry("b.txt").file(20).build(), - ]); + state.left_panel.set_entries(test_files_ab()); state.left_panel.cursor = 1; dispatch_key( diff --git a/src/tests/user_menu.rs b/src/tests/user_menu.rs index 9c23cc1..deca28e 100644 --- a/src/tests/user_menu.rs +++ b/src/tests/user_menu.rs @@ -6,7 +6,7 @@ use crate::input::pickers; use crossterm::event::{KeyCode, KeyModifiers}; use lc::app; use lc::app::types::PickerKind; -use lc::app::types::{AppMode, AppState}; +use lc::app::types::{AppMode, AppState, UiState}; use lc::app::user_menu::MenuSource; use lc::ui::viewer; use std::path::Path; @@ -24,11 +24,24 @@ fn create_menu_file(dir: &Path) { .unwrap(); } +/// Create a `.mc.menu` file that exists but contains no usable entries (only a +/// comment), exercising the "file present but empty" path distinct from the +/// "no file" path. +fn create_empty_menu_file(dir: &Path) { + use std::io::Write; + let menu_path = dir.join(".mc.menu"); + let mut f = std::fs::File::create(&menu_path).unwrap(); + writeln!(f, "# only a comment, no entries").unwrap(); +} + fn test_menu_state(tmp: &tempfile::TempDir) -> AppState { let mut state = AppState { mode: AppMode::Menu, - menu_selected: FILE_MENU_INDEX, - menu_item_selected: 0, + ui: UiState { + menu_selected: FILE_MENU_INDEX, + menu_item_selected: 0, + ..Default::default() + }, ..Default::default() }; state.left_panel.set_path(tmp.path().to_path_buf()); @@ -65,13 +78,26 @@ fn test_viewer_refs() -> (Option, Option) -> AppState { + AppState { + mode, + ui: UiState { + user_menu_entries: entries, + ..Default::default() + }, + ..Default::default() + } +} + #[test] fn user_menu_picker_esc_closes() { - let mut state = AppState { - mode: AppMode::ListPicker(PickerKind::UserMenu), - user_menu_entries: single_menu_entry(), - ..Default::default() - }; + let mut state = create_test_state_with_mode( + AppMode::ListPicker(PickerKind::UserMenu), + single_menu_entry(), + ); pickers::handle_list_picker(&mut state, KeyCode::Esc); @@ -80,61 +106,57 @@ fn user_menu_picker_esc_closes() { #[test] fn user_menu_picker_navigate() { - let mut state = AppState { - mode: AppMode::ListPicker(PickerKind::UserMenu), - user_menu_entries: test_user_menu_entries(), - ..Default::default() - }; + let mut state = create_test_state_with_mode( + AppMode::ListPicker(PickerKind::UserMenu), + test_user_menu_entries(), + ); pickers::handle_list_picker(&mut state, KeyCode::Down); - assert_eq!(state.picker_selected, 1); + assert_eq!(state.ui.picker_selected, 1); pickers::handle_list_picker(&mut state, KeyCode::Up); - assert_eq!(state.picker_selected, 0); + assert_eq!(state.ui.picker_selected, 0); } #[test] fn user_menu_picker_boundary_top_no_wrap() { - let mut state = AppState { - mode: AppMode::ListPicker(PickerKind::UserMenu), - user_menu_entries: test_user_menu_entries(), - ..Default::default() - }; + let mut state = create_test_state_with_mode( + AppMode::ListPicker(PickerKind::UserMenu), + test_user_menu_entries(), + ); pickers::handle_list_picker(&mut state, KeyCode::Up); - assert_eq!(state.picker_selected, 0); + assert_eq!(state.ui.picker_selected, 0); } #[test] fn user_menu_picker_boundary_bottom_no_wrap() { - let mut state = AppState { - mode: AppMode::ListPicker(PickerKind::UserMenu), - user_menu_entries: test_user_menu_entries(), - ..Default::default() - }; - state.picker_selected = 1; + let mut state = create_test_state_with_mode( + AppMode::ListPicker(PickerKind::UserMenu), + test_user_menu_entries(), + ); + state.ui.picker_selected = 1; pickers::handle_list_picker(&mut state, KeyCode::Down); - assert_eq!(state.picker_selected, 1); + assert_eq!(state.ui.picker_selected, 1); } #[test] fn user_menu_picker_enter_dismisses() { - let mut state = AppState { - mode: AppMode::ListPicker(PickerKind::UserMenu), - user_menu_entries: vec![app::user_menu::MenuEntry { + let mut state = create_test_state_with_mode( + AppMode::ListPicker(PickerKind::UserMenu), + vec![app::user_menu::MenuEntry { hotkey: 'A', title: "Archive".to_string(), command: "echo ok".to_string(), condition: app::user_menu::CompiledCondition::Always, }], - user_menu_source: MenuSource::Local, - ..Default::default() - }; + ); + state.ui.user_menu_source = MenuSource::Local; pickers::handle_list_picker(&mut state, KeyCode::Enter); - assert_eq!(state.user_menu_source, MenuSource::Local); + assert_eq!(state.ui.user_menu_source, MenuSource::Local); assert!(matches!( state.mode, AppMode::Dialog(app::types::DialogKind::Confirm(_)) @@ -181,10 +203,14 @@ fn user_menu_file_menu_with_entries_opens_picker() { ); assert_eq!(state.mode, AppMode::ListPicker(PickerKind::UserMenu)); - assert_eq!(state.picker_selected, 0); - assert_eq!(state.user_menu_entries.len(), 2); - assert_eq!(state.user_menu_entries[0].hotkey, 'A'); - assert_eq!(state.user_menu_entries[1].hotkey, 'B'); + assert_eq!(state.ui.picker_selected, 0); + assert_eq!(state.ui.user_menu_entries.len(), 2); + assert_eq!(state.ui.user_menu_entries[0].hotkey, 'A'); + assert_eq!(state.ui.user_menu_entries[0].title, "Archive"); + assert_eq!(state.ui.user_menu_entries[0].command, "tar czf a.tgz"); + assert_eq!(state.ui.user_menu_entries[1].hotkey, 'B'); + assert_eq!(state.ui.user_menu_entries[1].title, "Build"); + assert_eq!(state.ui.user_menu_entries[1].command, "cargo build"); } #[test] @@ -208,8 +234,14 @@ fn f2_loads_user_menu_file_with_entries() { ); assert_eq!(state.mode, AppMode::ListPicker(PickerKind::UserMenu)); - assert_eq!(state.user_menu_entries.len(), 2); - assert_eq!(state.picker_selected, 0); + assert_eq!(state.ui.user_menu_entries.len(), 2); + assert_eq!(state.ui.user_menu_entries[0].hotkey, 'A'); + assert_eq!(state.ui.user_menu_entries[0].title, "Archive"); + assert_eq!(state.ui.user_menu_entries[0].command, "tar czf a.tgz"); + assert_eq!(state.ui.user_menu_entries[1].hotkey, 'B'); + assert_eq!(state.ui.user_menu_entries[1].title, "Build"); + assert_eq!(state.ui.user_menu_entries[1].command, "cargo build"); + assert_eq!(state.ui.picker_selected, 0); } #[test] @@ -249,3 +281,43 @@ fn empty_user_menu_no_file() { AppMode::Dialog(app::types::DialogKind::Error(_)) )); } + +#[test] +fn empty_user_menu_file_with_no_entries_shows_error() { + let tmp = tempfile::tempdir().unwrap(); + create_empty_menu_file(tmp.path()); + let mut state = AppState::default(); + state.left_panel.set_path(tmp.path().to_path_buf()); + + open_user_menu(&mut state); + + // A `.mc.menu` exists but has no entries: stay out of the picker and report + // the error rather than opening an empty list. + assert!(matches!( + state.mode, + AppMode::Dialog(app::types::DialogKind::Error(_)) + )); + assert!(state.ui.user_menu_entries.is_empty()); +} + +#[test] +fn user_menu_picker_enter_out_of_bounds_selection_is_clamped() { + let mut state = create_test_state_with_mode( + AppMode::ListPicker(PickerKind::UserMenu), + single_menu_entry(), + ); + // Local source routes Enter through the confirmation dialog instead of + // spawning a shell, keeping the test deterministic and side-effect free. + state.ui.user_menu_source = MenuSource::Local; + // Selection points past the single entry (index 5 vs len 1). + state.ui.picker_selected = 5; + + // Must not panic; the handler clamps `picker_selected` to the last valid + // entry and acts on it. + pickers::handle_list_picker(&mut state, KeyCode::Enter); + + assert!(matches!( + state.mode, + AppMode::Dialog(app::types::DialogKind::Confirm(_)) + )); +} diff --git a/src/tests/viewer.rs b/src/tests/viewer.rs index 65abfce..72c241c 100644 --- a/src/tests/viewer.rs +++ b/src/tests/viewer.rs @@ -3,7 +3,7 @@ use crate::input::dialogs; use crossterm::event::KeyCode; use lc::app::job_runner::RunningJob; use lc::app::types::{ - ActivePanel, AppMode, AppState, DialogKind, InputAction, TextInput, ViewMode, + ActivePanel, AppMode, AppState, DialogKind, InputAction, InputState, TextInput, ViewMode, }; use lc::ui; use lc::ui::viewer; @@ -149,11 +149,14 @@ fn viewer_search_esc_keeps_viewer_previous_mode() { prompt: "Viewer search:".to_string(), action: InputAction::ViewerSearch, }), - dialog_input: { - let mut ti = TextInput::new(); - ti.set_text("needle".to_string()); - ti.set_cursor(6); - ti + input: InputState { + dialog_input: { + let mut ti = TextInput::new(); + ti.set_text("needle".to_string()); + ti.set_cursor(6); + ti + }, + ..Default::default() }, prev_mode: Some(AppMode::Normal), ..Default::default() @@ -171,8 +174,8 @@ fn viewer_search_esc_keeps_viewer_previous_mode() { assert!(matches!(state.mode, AppMode::Viewing)); assert_eq!(state.prev_mode, Some(AppMode::Normal)); - assert!(state.dialog_input.text().is_empty()); - assert_eq!(state.dialog_input.cursor(), 0); + assert!(state.input.dialog_input.text().is_empty()); + assert_eq!(state.input.dialog_input.cursor(), 0); } #[test] diff --git a/src/ui/panels/mod.rs b/src/ui/panels/mod.rs index f2fcc0e..b24918d 100644 --- a/src/ui/panels/mod.rs +++ b/src/ui/panels/mod.rs @@ -12,9 +12,7 @@ use unicode_width::UnicodeWidthStr; use super::theme::{ColorCtx, ColorPalette, DEFAULT_COLORS, IconTheme, Theme}; -use crate::app::types::{ - FileCategory, FileEntry, ListingMode, PanelState, format_permissions, format_size, -}; +use crate::app::types::{FileCategory, FileEntry, ListingMode, PanelState, format_size}; const FN_KEY_TEXTS: [&str; 10] = [ " F1 ", " F2 ", " F3 ", " F4 ", " F5 ", " F6 ", " F7 ", " F8 ", " F9 ", " F10 ", @@ -167,7 +165,7 @@ pub fn render_panel_with_colors( .constraints([Constraint::Min(1), Constraint::Length(1)]) .split(inner_area); - let entry_count = panel.listing.entries.len(); + let entry_count = panel.listing.filtered_len(); let start_idx = panel.scroll_offset.min(entry_count); let end_idx = std::cmp::min(entry_count, start_idx + inner_area.height as usize); @@ -184,8 +182,7 @@ pub fn render_panel_with_colors( for entry in panel .listing - .entries - .iter() + .filtered() .skip(start_idx) .take(end_idx.saturating_sub(start_idx)) { @@ -242,7 +239,7 @@ pub fn render_panel_with_colors( f.render_stateful_widget(list, chunks[0], &mut list_state); - if panel.listing.entries.is_empty() + if panel.listing.filtered_is_empty() && let Some(err) = panel.last_error() { let err_text = @@ -250,7 +247,7 @@ pub fn render_panel_with_colors( f.render_widget(err_text, chunks[0]); } - if !panel.listing.entries.is_empty() { + if !panel.listing.filtered_is_empty() { render_scrollbar_with_colors(f, chunks[1], panel, is_active, colors, &mut suffix_buf); } } @@ -276,7 +273,7 @@ fn build_suffix_into( let size_date_width = size_width + date_width + 2; if show_permissions { - let perms_str = format_permissions(entry.mode_bits()); + let perms_str = FileEntry::display_permissions_raw(entry.mode_bits()); let perms_width = UnicodeWidthStr::width(perms_str.as_str()); let full_width = size_date_width + perms_width + 1; if 2 + full_width <= width { @@ -386,7 +383,7 @@ fn format_entry_line( fn write_status_metadata(buf: &mut String, size: &str, entry: &FileEntry, show_permissions: bool) { if show_permissions { - let perms = format_permissions(entry.mode_bits()); + let perms = FileEntry::display_permissions_raw(entry.mode_bits()); write!(buf, "{size} | {perms} | {} | {}", entry.owner, entry.group).ok(); } else { write!(buf, "{size} | {} | {}", entry.owner, entry.group).ok(); @@ -439,11 +436,11 @@ pub fn render_scrollbar_with_colors( colors: &ColorPalette, buf: &mut String, ) { - if panel.listing.entries.is_empty() { + if panel.listing.filtered_is_empty() { return; } - let total_entries = panel.listing.entries.len(); + let total_entries = panel.listing.filtered_len(); let height = area.height as usize; let max_scroll = total_entries.saturating_sub(height); let scroll_offset = panel.scroll_offset.min(max_scroll); @@ -486,7 +483,7 @@ pub fn render_scrollbar_with_colors( pub fn panel_status_summary(panel: &PanelState, buf: &mut String) -> usize { buf.clear(); - let total = panel.listing.entries.len(); + let total = panel.listing.filtered_len(); if total == 0 { return 0; } @@ -529,8 +526,9 @@ pub fn render_status_bar_with_colors( let mut out = String::with_capacity(remaining + right_summary.len() + 8); - if !panel.listing.entries.is_empty() && panel.cursor < panel.listing.entries.len() { - let entry = &panel.listing.entries[panel.cursor]; + // Render the cursor entry's info only when it exists; an out-of-range or + // empty listing simply skips the left side rather than panicking. + if let Some(entry) = panel.listing.filtered_get(panel.cursor) { let display_name = entry.display_name(); let size_str = format_size(entry.size()); diff --git a/src/ui/panels/tests.rs b/src/ui/panels/tests.rs index 41ec7fe..ca93b7f 100644 --- a/src/ui/panels/tests.rs +++ b/src/ui/panels/tests.rs @@ -1,3 +1,5 @@ +#![allow(clippy::expect_used)] + use super::*; use crate::app::file_type::*; use crate::app::types::format_time; @@ -23,6 +25,7 @@ fn entry_with(name: &str, is_dir: bool, is_exec: bool, is_symlink: bool, size: u .group("group") .is_hidden(name.starts_with('.')) .build() + .expect("valid test entry") } fn create_test_entry(name: &str, is_dir: bool, is_exec: bool, is_symlink: bool) -> FileEntry { @@ -312,19 +315,19 @@ fn test_format_size_u64_max() { #[test] fn test_format_permissions_full() { - let result = format_permissions(0o755); + let result = FileEntry::display_permissions_raw(0o755); assert_eq!(result, "rwxr-xr-x"); } #[test] fn test_format_permissions_readonly() { - let result = format_permissions(0o444); + let result = FileEntry::display_permissions_raw(0o444); assert_eq!(result, "r--r--r--"); } #[test] fn test_format_permissions_no_permissions() { - let result = format_permissions(0o000); + let result = FileEntry::display_permissions_raw(0o000); assert_eq!(result, "---------"); } @@ -682,11 +685,11 @@ fn test_panel_status_summary_empty_panel() { #[test] fn test_panel_status_summary_first_item() { let mut panel = PanelState::new(PathBuf::from("/test")); - panel.listing.entries = vec![ + panel.set_entries(vec![ create_test_entry("a.txt", false, false, false), create_test_entry("b.txt", false, false, false), create_test_entry("c.txt", false, false, false), - ]; + ]); panel.cursor = 0; let mut buf = String::new(); let _ = panel_status_summary(&panel, &mut buf); @@ -696,11 +699,11 @@ fn test_panel_status_summary_first_item() { #[test] fn test_panel_status_summary_last_item() { let mut panel = PanelState::new(PathBuf::from("/test")); - panel.listing.entries = vec![ + panel.set_entries(vec![ create_test_entry("a.txt", false, false, false), create_test_entry("b.txt", false, false, false), create_test_entry("c.txt", false, false, false), - ]; + ]); panel.cursor = 2; let mut buf = String::new(); let _ = panel_status_summary(&panel, &mut buf); @@ -710,10 +713,10 @@ fn test_panel_status_summary_last_item() { #[test] fn test_panel_status_summary_with_selection() { let mut panel = PanelState::new(PathBuf::from("/test")); - panel.listing.entries = vec![ + panel.set_entries(vec![ create_test_entry("a.txt", false, false, false), create_test_entry("b.txt", false, false, false), - ]; + ]); panel.cursor = 0; panel.set_selected_count(1); panel.set_selected_size(1024); @@ -725,7 +728,7 @@ fn test_panel_status_summary_with_selection() { #[test] fn test_panel_status_summary_no_selection_when_zero() { let mut panel = PanelState::new(PathBuf::from("/test")); - panel.listing.entries = vec![create_test_entry("a.txt", false, false, false)]; + panel.set_entries(vec![create_test_entry("a.txt", false, false, false)]); panel.cursor = 0; panel.set_selected_count(0); let mut buf = String::new(); @@ -797,10 +800,10 @@ fn render_to_string(width: u16, height: u16, f: impl FnOnce(&mut ratatui::Frame< #[test] fn test_render_panel_no_panic() { let mut panel = PanelState::new(PathBuf::from("/test")); - panel.listing.entries = vec![ + panel.set_entries(vec![ create_test_entry("file.txt", false, false, false), create_test_entry("mydir", true, false, false), - ]; + ]); let content = render_to_string(80, 24, |f| { render_panel(f, f.area(), &panel, true); }); @@ -819,7 +822,7 @@ fn test_render_panel_empty_no_panic() { #[test] fn test_render_status_bar_no_panic() { let mut panel = PanelState::new(PathBuf::from("/test")); - panel.listing.entries = vec![create_test_entry("file.txt", false, false, false)]; + panel.set_entries(vec![create_test_entry("file.txt", false, false, false)]); let content = render_to_string(80, 2, |f| { render_status_bar(f, f.area(), &panel); }); diff --git a/src/ui/viewer/open.rs b/src/ui/viewer/open.rs index a729dab..a106b63 100644 --- a/src/ui/viewer/open.rs +++ b/src/ui/viewer/open.rs @@ -341,8 +341,8 @@ impl ViewerState { let mime = crate::app::mime::detect_mime_from_bytes(path, &raw_bytes[..raw_bytes.len().min(8192)]); - let open_as_text = should_open_as_text(path, mime.as_deref(), &raw_bytes); - let is_image = is_image_mime(mime.as_deref()); + let open_as_text = should_open_as_text(path, mime, &raw_bytes); + let is_image = is_image_mime(mime); let view_mode = if is_image { ViewMode::Image } else if open_as_text { @@ -356,7 +356,7 @@ impl ViewerState { raw_bytes, file_size, view_mode, - mime, + mime.map(String::from), file_truncated, )) }