diff --git a/src/diff.rs b/src/diff.rs index 956e0b55..c7929231 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -114,9 +114,12 @@ mod tests { use super::*; fn file(name: &str, size: u64) -> EntryInfo { + let name = name.to_string(); + let path = PathBuf::from(&name); EntryInfo { - name: name.to_string(), - path: PathBuf::from(name), + lower_name: name.to_lowercase(), + name, + path, kind: EntryKind::File, size_bytes: Some(size), modified: None, @@ -125,9 +128,12 @@ mod tests { } fn dir(name: &str) -> EntryInfo { + let name = name.to_string(); + let path = PathBuf::from(&name); EntryInfo { - name: name.to_string(), - path: PathBuf::from(name), + lower_name: name.to_lowercase(), + name, + path, kind: EntryKind::Directory, size_bytes: None, modified: None, diff --git a/src/fs.rs b/src/fs.rs index f5c10d32..b020f7a3 100644 --- a/src/fs.rs +++ b/src/fs.rs @@ -9,6 +9,9 @@ use crate::action::CollisionPolicy; #[derive(Clone, Debug, Eq, PartialEq)] pub struct EntryInfo { pub name: String, + /// Lower-case version of `name`, cached at construction to avoid repeated + /// `to_lowercase()` allocations during sorting and filtering. + pub lower_name: String, pub path: PathBuf, pub kind: EntryKind, pub size_bytes: Option, @@ -164,6 +167,7 @@ pub fn scan_directory(path: &Path) -> Result, FileSystemError> { None }; results.push(EntryInfo { + lower_name: name.to_lowercase(), name, path: entry_path, kind, @@ -192,7 +196,7 @@ pub fn scan_directory(path: &Path) -> Result, FileSystemError> { results.sort_by(|left, right| { left.kind .cmp(&right.kind) - .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase())) + .then_with(|| left.lower_name.cmp(&right.lower_name)) }); Ok(results) diff --git a/src/fs/local.rs b/src/fs/local.rs index ef8b6e44..f91dc223 100644 --- a/src/fs/local.rs +++ b/src/fs/local.rs @@ -93,6 +93,7 @@ impl FsBackend for LocalBackend { None }; Ok(EntryInfo { + lower_name: name.to_lowercase(), name, path: path.to_path_buf(), kind, diff --git a/src/fs/scan_diff.rs b/src/fs/scan_diff.rs index 60990534..b55874e7 100644 --- a/src/fs/scan_diff.rs +++ b/src/fs/scan_diff.rs @@ -70,9 +70,12 @@ mod tests { use std::time::{Duration, SystemTime}; fn entry(name: &str, kind: EntryKind, size: u64, mtime_offset_secs: u64) -> EntryInfo { + let name = name.to_string(); + let path = PathBuf::from(&name); EntryInfo { - name: name.to_string(), - path: PathBuf::from(name), + lower_name: name.to_lowercase(), + name, + path, kind, size_bytes: Some(size), modified: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(mtime_offset_secs)), diff --git a/src/fs/sftp.rs b/src/fs/sftp.rs index adaddaad..ce2f6e41 100644 --- a/src/fs/sftp.rs +++ b/src/fs/sftp.rs @@ -49,12 +49,14 @@ impl SftpBackend { EntryKind::Other }; + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_string(); EntryInfo { - name: path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("") - .to_string(), + lower_name: name.to_lowercase(), + name, path: path.to_path_buf(), kind, size_bytes: Some(stat.size.unwrap_or(0)), @@ -85,6 +87,7 @@ impl FsBackend for SftpBackend { // Add parent directory entry ("..") if let Some(parent) = remote_path.parent() { result.push(EntryInfo { + lower_name: "..".to_string(), name: "..".to_string(), path: parent.to_path_buf(), kind: EntryKind::Directory, diff --git a/src/jobs.rs b/src/jobs.rs index 9de84bae..e35ff1b2 100644 --- a/src/jobs.rs +++ b/src/jobs.rs @@ -900,6 +900,7 @@ pub fn spawn_workers() -> (WorkerChannels, Receiver, Receiver (WorkerChannels, Receiver, Receiver (WorkerChannels, Receiver, Receiver (WorkerChannels, Receiver, Receiver Self { - match self { - Self::Name => Self::NameDesc, - Self::NameDesc => Self::Size, - Self::Size => Self::SizeDesc, - Self::SizeDesc => Self::Modified, - Self::Modified => Self::ModifiedDesc, - Self::ModifiedDesc => Self::Extension, - Self::Extension => Self::Name, - } - } - - /// Short label shown in the pane title bar. - pub fn label(self) -> &'static str { - match self { - Self::Name => "name \u{2191}", - Self::NameDesc => "name \u{2193}", - Self::Size => "size \u{2191}", - Self::SizeDesc => "size \u{2193}", - Self::Modified => "date \u{2191}", - Self::ModifiedDesc => "date \u{2193}", - Self::Extension => "ext \u{2191}", - } - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum PaneMode { - Real, - Archive { - source: PathBuf, - inner_path: PathBuf, - }, - Remote { - address: String, - base_path: PathBuf, - }, -} - -/// Buffer for in-place filename editing (T3-3 inline rename). -#[derive(Clone, Debug)] -pub struct InlineRenameState { - /// Current text in the edit buffer (starts as the entry's name). - pub buffer: String, - /// Absolute path of the entry being renamed. - pub original_path: PathBuf, -} - -/// Cached result of the last successful directory scan for one pane. -/// -/// Held in `PaneState::scan_cache`. The cache is considered fresh when -/// the directory's modification time has not changed since the scan. -#[derive(Clone, Debug)] -pub struct ScanCache { - /// The directory that was scanned. - pub path: PathBuf, - /// Modification time of `path` at scan time. - pub dir_mtime: SystemTime, - /// The raw entries returned by the scan (before ".." is prepended). - pub entries: Vec, -} - -impl ScanCache { - /// Return `true` when the cached entries are still valid for `path`. - /// - /// Validity is defined as: the path matches AND the OS-reported - /// modification time of the directory equals the recorded mtime. - pub fn is_fresh(&self, path: &std::path::Path) -> bool { - if self.path != path { - return false; - } - std::fs::metadata(path) - .and_then(|m| m.modified()) - .map(|mtime| mtime == self.dir_mtime) - .unwrap_or(false) - } -} - -#[derive(Clone, Debug)] -pub struct PaneState { - pub title: String, - pub cwd: PathBuf, - pub entries: Vec, - pub selection: usize, - pub scroll_offset: usize, - pub show_hidden: bool, - pub sort_mode: SortMode, - pub marked: BTreeSet, - pub filter_query: String, - pub filter_active: bool, - /// Anchor index for Shift+arrow range selection. `None` when no range is active. - pub mark_anchor: Option, - /// When true, the pane renders a flat column view (icon | name | size | date). - pub details_view: bool, - /// Active inline rename; drives `FocusLayer::PaneInlineRename`. - pub rename_state: Option, - // Navigation history - pub history_back: Vec, // dirs we came FROM (oldest first) - pub history_forward: Vec, // dirs we can go forward to - pub(crate) filtered_indices: RefCell>, - pub(crate) cache_dirty: Cell, - pub(crate) cache_entry_count: Cell, - pub(crate) cache_sort_mode: Cell, - pub(crate) cache_filter_active: Cell, - pub(crate) cache_filter_query: RefCell, - pub mode: PaneMode, // New: real fs or archive mode - /// Cached result of the last completed directory scan. - pub scan_cache: Option, -} - -impl PaneState { - pub fn in_archive(&self) -> bool { - matches!(self.mode, PaneMode::Archive { .. }) - } - pub fn archive_source(&self) -> Option<&PathBuf> { - match &self.mode { - PaneMode::Archive { source, .. } => Some(source), - _ => None, - } - } - pub fn in_remote(&self) -> bool { - matches!(self.mode, PaneMode::Remote { .. }) - } - pub fn remote_address(&self) -> Option<&str> { - match &self.mode { - PaneMode::Remote { address, .. } => Some(address.as_str()), - _ => None, - } - } - pub fn empty(title: impl Into, cwd: PathBuf) -> Self { - Self { - title: title.into(), - cwd, - entries: Vec::new(), - mode: PaneMode::Real, - selection: 0, - scroll_offset: 0, - show_hidden: false, - sort_mode: SortMode::Name, - marked: BTreeSet::new(), - filter_query: String::new(), - filter_active: false, - mark_anchor: None, - details_view: true, - rename_state: None, - history_back: Vec::new(), - history_forward: Vec::new(), - scan_cache: None, - filtered_indices: RefCell::new(Vec::new()), - cache_dirty: Cell::new(true), - cache_entry_count: Cell::new(0), - cache_sort_mode: Cell::new(SortMode::Name), - cache_filter_active: Cell::new(false), - cache_filter_query: RefCell::new(String::new()), - } - } - - /// Push current cwd to back-stack and clear forward-stack before navigating. - pub fn push_history(&mut self) { - let current = self.cwd.clone(); - self.history_back.push(current); - if self.history_back.len() > 50 { - self.history_back.remove(0); - } - self.history_forward.clear(); - } - - /// Returns the path to navigate back to, if any. - pub fn pop_back(&mut self) -> Option { - let prev = self.history_back.pop()?; - self.history_forward.push(self.cwd.clone()); - Some(prev) - } - - /// Returns the path to navigate forward to, if any. - pub fn pop_forward(&mut self) -> Option { - let next = self.history_forward.pop()?; - self.history_back.push(self.cwd.clone()); - Some(next) - } - - pub fn can_go_back(&self) -> bool { - !self.history_back.is_empty() - } - - pub fn can_go_forward(&self) -> bool { - !self.history_forward.is_empty() - } - - pub fn load(title: impl Into, cwd: PathBuf) -> Result { - let mut pane = Self::empty(title, cwd); - let entries = scan_directory(&pane.cwd)?; - pane.set_entries(entries); - Ok(pane) - } - - pub fn set_entries(&mut self, entries: Vec) { - self.entries = entries - .into_iter() - // Always keep ".." even when hidden files are off (it starts with '.'). - .filter(|entry| entry.name == ".." || self.show_hidden || !entry.name.starts_with('.')) - .collect(); - let entry_paths: BTreeSet = self - .entries - .iter() - .map(|entry| entry.path.clone()) - .collect(); - self.marked.retain(|path| entry_paths.contains(path)); - - self.clamp_selection(); - } - - /// Apply an incremental scan diff to the current entry list. - /// - /// - Removed entries are dropped (the `".."` sentinel is always kept). - /// - Modified entries are updated in place. - /// - Added entries are appended, respecting the `show_hidden` setting. - /// - Stale marks are purged for removed paths. - /// - /// Callers should pass raw entries (without `".."`) from - /// [`crate::fs::scan_diff::compute_scan_diff`]. - pub fn apply_scan_diff(&mut self, diff: crate::fs::scan_diff::ScanDiff) { - if diff.is_empty() { - return; - } - - let removed_paths: BTreeSet<&std::path::Path> = - diff.removed.iter().map(|e| e.path.as_path()).collect(); - - self.entries - .retain(|e| e.name == ".." || !removed_paths.contains(e.path.as_path())); - - for removed in &diff.removed { - self.marked.remove(&removed.path); - } - - for modified in &diff.modified { - if let Some(entry) = self.entries.iter_mut().find(|e| e.path == modified.path) { - *entry = modified.clone(); - } - } - - for added in diff.added { - if self.show_hidden || !added.name.starts_with('.') { - self.entries.push(added); - } - } - - self.refresh_filter(); - } - - pub fn set_show_hidden(&mut self, show_hidden: bool) -> Result<(), FileSystemError> { - self.show_hidden = show_hidden; - let entries = scan_directory(&self.cwd)?; - self.set_entries(entries); - Ok(()) - } - - pub fn move_selection_down(&mut self) { - if self.selection + 1 < self.filtered_len() { - self.selection += 1; - } - } - - pub fn move_selection_up(&mut self) { - self.selection = self.selection.saturating_sub(1); - } - - pub fn selected_entry(&self) -> Option<&EntryInfo> { - self.ensure_cache(); - let idx = *self.filtered_indices.borrow().get(self.selection)?; - self.entries.get(idx) - } - - pub fn selected_path(&self) -> Option { - self.selected_entry().map(|entry| entry.path.clone()) - } - - pub fn selected_marked_path(&self) -> Option { - self.selected_path() - } - - pub fn toggle_mark_selected(&mut self) -> Option { - let path = self.selected_path()?; - // ".." is a navigation sentinel — never mark it. - if self.selected_entry().is_some_and(|e| e.name == "..") { - return None; - } - if self.marked.contains(&path) { - self.marked.remove(&path); - Some(false) - } else { - self.marked.insert(path); - Some(true) - } - } - - pub fn clear_marks(&mut self) { - self.marked.clear(); - } - - /// Clear the range-selection anchor without touching the mark set. - pub fn reset_mark_anchor(&mut self) { - self.mark_anchor = None; - } - - /// Extend selection downward, marking every entry stepped over. - /// Sets the anchor on first call; marks anchor + new position. - pub fn extend_selection_down(&mut self) { - if self.mark_anchor.is_none() { - self.mark_anchor = Some(self.selection); - // Mark the anchor entry. - if let Some(path) = self.selected_path() { - if self.selected_entry().is_some_and(|e| e.name != "..") { - self.marked.insert(path); - } - } - } - self.move_selection_down(); - if let Some(path) = self.selected_path() { - if self.selected_entry().is_some_and(|e| e.name != "..") { - self.marked.insert(path); - } - } - } - - /// Extend selection upward, marking every entry stepped over. - /// Sets the anchor on first call; marks anchor + new position. - pub fn extend_selection_up(&mut self) { - if self.mark_anchor.is_none() { - self.mark_anchor = Some(self.selection); - // Mark the anchor entry. - if let Some(path) = self.selected_path() { - if self.selected_entry().is_some_and(|e| e.name != "..") { - self.marked.insert(path); - } - } - } - self.move_selection_up(); - if let Some(path) = self.selected_path() { - if self.selected_entry().is_some_and(|e| e.name != "..") { - self.marked.insert(path); - } - } - } - - pub fn is_marked(&self, path: &PathBuf) -> bool { - self.marked.contains(path) - } - - pub fn marked_count(&self) -> usize { - self.marked.len() - } - - pub fn can_enter_selected(&self) -> bool { - self.selected_entry().is_some_and(|entry| { - entry.kind == EntryKind::Directory || entry.kind == EntryKind::Archive - }) - } - - pub fn parent_path(&self) -> Option { - self.cwd.parent().map(PathBuf::from) - } - - #[cfg(test)] - pub fn sorted_entries(&self) -> Vec<&EntryInfo> { - // Delegates to the shared cache so sort logic is not duplicated. - self.filtered_entries() - } - - pub fn filtered_entries(&self) -> Vec<&EntryInfo> { - self.ensure_cache(); - self.filtered_indices - .borrow() - .iter() - .filter_map(|&idx| self.entries.get(idx)) - .collect() - } - - pub fn visible_entries(&self, height: usize) -> Vec<&EntryInfo> { - self.ensure_cache(); - let indices = self.filtered_indices.borrow(); - if height == 0 || indices.is_empty() { - return Vec::new(); - } - - let start = self.visible_start_for(height, indices.len()); - let end = (start + height).min(indices.len()); - indices[start..end] - .iter() - .filter_map(|&idx| self.entries.get(idx)) - .collect() - } - - pub fn visible_selection(&self, height: usize) -> Option { - let count = self.filtered_len(); - if count == 0 || height == 0 { - return None; - } - - Some( - self.selection - .saturating_sub(self.visible_start_for(height, count)), - ) - } - - pub fn select_path(&mut self, path: &std::path::Path) -> bool { - self.ensure_cache(); - if let Some(index) = self - .filtered_indices - .borrow() - .iter() - .position(|&entry_index| self.entries[entry_index].path == path) - { - self.selection = index; - true - } else { - false - } - } - - pub fn clear_filter(&mut self) { - self.filter_active = false; - self.filter_query.clear(); - self.selection = 0; - self.scroll_offset = 0; - self.invalidate_cache(); - } - - pub fn refresh_filter(&mut self) { - self.invalidate_cache(); - self.clamp_selection(); - } - - pub fn filtered_len_pub(&self) -> usize { - self.filtered_len() - } - - pub fn filtered_count(&self) -> usize { - self.filtered_len() - } - - fn filtered_len(&self) -> usize { - self.ensure_cache(); - self.filtered_indices.borrow().len() - } - - fn clamp_selection(&mut self) { - let count = self.filtered_len(); - if self.selection >= count { - self.selection = count.saturating_sub(1); - } - - if self.scroll_offset > self.selection { - self.scroll_offset = self.selection; - } - } - - fn visible_start_for(&self, height: usize, count: usize) -> usize { - if self.selection < self.scroll_offset { - self.selection - } else if self.selection >= self.scroll_offset + height { - self.selection + 1 - height - } else { - self.scroll_offset.min(count.saturating_sub(height)) - } - } - - fn invalidate_cache(&self) { - self.cache_dirty.set(true); - } - - fn ensure_cache(&self) { - if self.cache_dirty.get() - || self.cache_entry_count.get() != self.entries.len() - || self.cache_sort_mode.get() != self.sort_mode - || self.cache_filter_active.get() != self.filter_active - || *self.cache_filter_query.borrow() != self.filter_query - { - self.rebuild_cache(); - } - } - - fn rebuild_cache(&self) { - let indices: Vec = (0..self.entries.len()).collect(); - // Always pin ".." at index 0, sort/filter everything else. - let (parent_indices, mut rest_indices): (Vec, Vec) = indices - .into_iter() - .partition(|&i| self.entries[i].name == ".."); - - // Pre-compute lowercase names once to avoid repeated per-comparison allocations. - let lower_names: Vec = self.entries.iter().map(|e| e.name.to_lowercase()).collect(); - - rest_indices.sort_by(|&left_idx, &right_idx| { - let left = &self.entries[left_idx]; - let right = &self.entries[right_idx]; - match self.sort_mode { - SortMode::Name => dir_first(left, right) - .then_with(|| lower_names[left_idx].cmp(&lower_names[right_idx])), - SortMode::NameDesc => dir_first(left, right) - .then_with(|| lower_names[right_idx].cmp(&lower_names[left_idx])), - SortMode::Size => dir_first(left, right).then_with(|| { - left.size_bytes - .unwrap_or(0) - .cmp(&right.size_bytes.unwrap_or(0)) - }), - SortMode::SizeDesc => dir_first(left, right).then_with(|| { - right - .size_bytes - .unwrap_or(0) - .cmp(&left.size_bytes.unwrap_or(0)) - }), - SortMode::Modified => { - dir_first(left, right).then_with(|| left.modified.cmp(&right.modified)) - } - SortMode::ModifiedDesc => { - dir_first(left, right).then_with(|| right.modified.cmp(&left.modified)) - } - SortMode::Extension => dir_first(left, right).then_with(|| { - let ext_a = left - .path - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("") - .to_lowercase(); - let ext_b = right - .path - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("") - .to_lowercase(); - ext_a - .cmp(&ext_b) - .then_with(|| lower_names[left_idx].cmp(&lower_names[right_idx])) - }), - } - }); - - if self.filter_active && !self.filter_query.is_empty() { - // ".." is always visible even during filtering. - rest_indices.retain(|&idx| { - crate::utils::glob_match::matches(&self.filter_query, &self.entries[idx].name) - }); - } - - // Prepend ".." (if present) before all other entries. - let mut indices = parent_indices; - indices.extend(rest_indices); - - *self.filtered_indices.borrow_mut() = indices; - self.cache_entry_count.set(self.entries.len()); - self.cache_sort_mode.set(self.sort_mode); - self.cache_filter_active.set(self.filter_active); - *self.cache_filter_query.borrow_mut() = self.filter_query.clone(); - self.cache_dirty.set(false); - } -} - -fn dir_first(a: &EntryInfo, b: &EntryInfo) -> std::cmp::Ordering { - match (a.kind, b.kind) { - (EntryKind::Directory, EntryKind::Directory) => std::cmp::Ordering::Equal, - (EntryKind::Directory, _) => std::cmp::Ordering::Less, - (_, EntryKind::Directory) => std::cmp::Ordering::Greater, - _ => std::cmp::Ordering::Equal, - } -} - -#[cfg(test)] -mod tests { - use std::collections::BTreeSet; - use std::path::PathBuf; - - use crate::fs::{EntryInfo, EntryKind}; - - use super::{PaneState, SortMode}; - - fn file(name: &str) -> EntryInfo { - EntryInfo { - name: name.to_string(), - path: PathBuf::from(format!("./{name}")), - kind: EntryKind::File, - size_bytes: Some(1), - modified: None, - link_target: None, - } - } - - fn dir(name: &str) -> EntryInfo { - EntryInfo { - name: name.to_string(), - path: PathBuf::from(format!("./{name}")), - kind: EntryKind::Directory, - size_bytes: None, - modified: None, - link_target: None, - } - } - - fn pane_with_entries(entries: Vec) -> PaneState { - let mut pane = PaneState::empty("left", PathBuf::from(".")); - pane.entries = entries; - pane - } - - fn empty_pane(cwd: &str) -> PaneState { - PaneState::empty("test", PathBuf::from(cwd)) - } - - #[test] - fn clamps_selection_at_zero() { - let mut pane = empty_pane("."); - pane.move_selection_up(); - assert_eq!(pane.selection, 0); - } - - #[test] - fn visible_selection_tracks_scrolled_window() { - let mut pane = pane_with_entries( - (0..10) - .map(|index| EntryInfo { - name: format!("item-{index}"), - path: PathBuf::from(format!("./item-{index}")), - kind: EntryKind::File, - size_bytes: Some(index as u64 * 16), - modified: None, - link_target: None, - }) - .collect(), - ); - pane.selection = 7; - - assert_eq!(pane.visible_selection(4), Some(3)); - assert_eq!(pane.visible_entries(4).len(), 4); - - pane.move_selection_up(); - assert_eq!(pane.visible_selection(4), Some(3)); - } - - #[test] - fn sort_by_name_puts_dirs_first() { - let pane = pane_with_entries(vec![file("zzz.txt"), dir("aaa"), file("aaa.txt")]); - let sorted = pane.sorted_entries(); - assert_eq!(sorted[0].kind, EntryKind::Directory); - assert_eq!(sorted[0].name, "aaa"); - assert_eq!(sorted[1].name, "aaa.txt"); - assert_eq!(sorted[2].name, "zzz.txt"); - } - - #[test] - fn sort_by_size_desc_orders_largest_first() { - let mut pane = pane_with_entries(vec![ - file("small.txt"), - file("large.txt"), - file("medium.txt"), - ]); - pane.entries[0].size_bytes = Some(10); - pane.entries[1].size_bytes = Some(9999); - pane.entries[2].size_bytes = Some(500); - pane.sort_mode = SortMode::SizeDesc; - - let sorted = pane.sorted_entries(); - assert_eq!(sorted[0].name, "large.txt"); - assert_eq!(sorted[1].name, "medium.txt"); - assert_eq!(sorted[2].name, "small.txt"); - } - - #[test] - fn sort_by_extension_groups_by_ext() { - let mut pane = pane_with_entries(vec![file("b.rs"), file("a.md"), file("a.rs")]); - pane.sort_mode = SortMode::Extension; - let sorted = pane.sorted_entries(); - assert_eq!(sorted[0].name, "a.md"); - assert_eq!(sorted[1].name, "a.rs"); - assert_eq!(sorted[2].name, "b.rs"); - } - - #[test] - fn cycle_sort_mode_wraps_around() { - let mut mode = SortMode::Name; - mode = mode.next(); - mode = mode.next(); - mode = mode.next(); - mode = mode.next(); - mode = mode.next(); - mode = mode.next(); - mode = mode.next(); - assert_eq!(mode, SortMode::Name); - } - - #[test] - fn toggle_mark_selected_adds_and_removes_mark() { - let mut pane = pane_with_entries(vec![file("file.txt")]); - assert_eq!(pane.toggle_mark_selected(), Some(true)); - assert_eq!(pane.marked_count(), 1); - assert_eq!(pane.toggle_mark_selected(), Some(false)); - assert_eq!(pane.marked_count(), 0); - } - - #[test] - fn clear_marks_removes_all() { - let mut pane = pane_with_entries(vec![file("file.txt")]); - pane.marked = BTreeSet::from([PathBuf::from("./file.txt")]); - pane.clear_marks(); - assert_eq!(pane.marked_count(), 0); - } - - #[test] - fn set_entries_purges_stale_marks() { - let mut pane = pane_with_entries(vec![file("file.txt")]); - pane.marked = BTreeSet::from([PathBuf::from("./file.txt"), PathBuf::from("./missing.txt")]); - pane.set_entries(vec![file("file.txt")]); - assert_eq!(pane.marked_count(), 1); - assert!(pane.is_marked(&PathBuf::from("./file.txt"))); - } - - #[test] - fn marked_count_tracks_number_of_marks() { - let mut pane = pane_with_entries(vec![file("a.txt"), file("b.txt")]); - assert_eq!(pane.marked_count(), 0); - let _ = pane.toggle_mark_selected(); - pane.move_selection_down(); - let _ = pane.toggle_mark_selected(); - assert_eq!(pane.marked_count(), 2); - } - - #[test] - fn filter_active_hides_non_matching_entries() { - let mut pane = - pane_with_entries(vec![file("main.rs"), file("README.md"), file("Cargo.toml")]); - pane.filter_active = true; - pane.filter_query = String::from("read"); - let names: Vec<_> = pane - .visible_entries(10) - .into_iter() - .map(|e| e.name.clone()) - .collect(); - assert_eq!(names, vec![String::from("README.md")]); - } - - #[test] - fn filter_empty_query_shows_all_entries() { - let mut pane = pane_with_entries(vec![file("a.rs"), file("b.rs")]); - pane.filter_active = true; - assert_eq!(pane.visible_entries(10).len(), 2); - } - - #[test] - fn filter_is_case_insensitive() { - let mut pane = pane_with_entries(vec![file("README.md"), file("main.rs")]); - pane.filter_active = true; - pane.filter_query = String::from("read"); - assert_eq!( - pane.selected_entry().map(|e| e.name.as_str()), - Some("README.md") - ); - } - - #[test] - fn push_history_records_previous_dir() { - let mut pane = empty_pane("/home"); - pane.push_history(); - assert_eq!(pane.history_back, vec![PathBuf::from("/home")]); - } - - #[test] - fn pop_back_returns_previous_and_moves_current_to_forward() { - let mut pane = empty_pane("/home"); - pane.push_history(); - pane.cwd = PathBuf::from("/home/user"); - - let back = pane.pop_back(); - assert_eq!(back, Some(PathBuf::from("/home"))); - assert_eq!(pane.history_forward, vec![PathBuf::from("/home/user")]); - assert!(pane.history_back.is_empty()); - } - - #[test] - fn pop_forward_returns_next_and_moves_current_to_back() { - let mut pane = empty_pane("/home"); - pane.push_history(); - pane.cwd = PathBuf::from("/home/user"); - let _back = pane.pop_back(); - pane.cwd = PathBuf::from("/home"); - - let fwd = pane.pop_forward(); - assert_eq!(fwd, Some(PathBuf::from("/home/user"))); - assert_eq!(pane.history_back, vec![PathBuf::from("/home")]); - assert!(pane.history_forward.is_empty()); - } - - #[test] - fn push_history_clears_forward_stack() { - let mut pane = empty_pane("/home"); - pane.push_history(); - pane.cwd = PathBuf::from("/home/user"); - let _back = pane.pop_back(); - pane.cwd = PathBuf::from("/home"); - pane.push_history(); - assert!(pane.history_forward.is_empty()); - } - - #[test] - fn history_capped_at_50_entries() { - let mut pane = empty_pane("/start"); - for i in 0..60 { - pane.cwd = PathBuf::from(format!("/dir/{i}")); - pane.push_history(); - } - assert_eq!(pane.history_back.len(), 50); - } -} +use std::cell::{Cell, RefCell}; +use std::collections::BTreeSet; +use std::path::PathBuf; +use std::time::SystemTime; + +use crate::fs::{scan_directory, EntryInfo, EntryKind, FileSystemError}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PaneId { + Left, + Right, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub enum SortMode { + Name, // dirs first, then files, alphabetical + NameDesc, // reverse + Size, // smallest first (dirs show as 0) + SizeDesc, // largest first + Modified, // oldest first + ModifiedDesc, // newest first + Extension, // alphabetical by extension, then name +} + +impl SortMode { + /// Cycle to the next mode (wraps around). + pub fn next(self) -> Self { + match self { + Self::Name => Self::NameDesc, + Self::NameDesc => Self::Size, + Self::Size => Self::SizeDesc, + Self::SizeDesc => Self::Modified, + Self::Modified => Self::ModifiedDesc, + Self::ModifiedDesc => Self::Extension, + Self::Extension => Self::Name, + } + } + + /// Short label shown in the pane title bar. + pub fn label(self) -> &'static str { + match self { + Self::Name => "name \u{2191}", + Self::NameDesc => "name \u{2193}", + Self::Size => "size \u{2191}", + Self::SizeDesc => "size \u{2193}", + Self::Modified => "date \u{2191}", + Self::ModifiedDesc => "date \u{2193}", + Self::Extension => "ext \u{2191}", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PaneMode { + Real, + Archive { + source: PathBuf, + inner_path: PathBuf, + }, + Remote { + address: String, + base_path: PathBuf, + }, +} + +/// Buffer for in-place filename editing (T3-3 inline rename). +#[derive(Clone, Debug)] +pub struct InlineRenameState { + /// Current text in the edit buffer (starts as the entry's name). + pub buffer: String, + /// Absolute path of the entry being renamed. + pub original_path: PathBuf, +} + +/// Cached result of the last successful directory scan for one pane. +/// +/// Held in `PaneState::scan_cache`. The cache is considered fresh when +/// the directory's modification time has not changed since the scan. +#[derive(Clone, Debug)] +pub struct ScanCache { + /// The directory that was scanned. + pub path: PathBuf, + /// Modification time of `path` at scan time. + pub dir_mtime: SystemTime, + /// The raw entries returned by the scan (before ".." is prepended). + pub entries: Vec, +} + +impl ScanCache { + /// Return `true` when the cached entries are still valid for `path`. + /// + /// Validity is defined as: the path matches AND the OS-reported + /// modification time of the directory equals the recorded mtime. + pub fn is_fresh(&self, path: &std::path::Path) -> bool { + if self.path != path { + return false; + } + std::fs::metadata(path) + .and_then(|m| m.modified()) + .map(|mtime| mtime == self.dir_mtime) + .unwrap_or(false) + } +} + +#[derive(Clone, Debug)] +pub struct PaneState { + pub title: String, + pub cwd: PathBuf, + pub entries: Vec, + pub selection: usize, + pub scroll_offset: usize, + pub show_hidden: bool, + pub sort_mode: SortMode, + pub marked: BTreeSet, + pub filter_query: String, + pub filter_active: bool, + /// Anchor index for Shift+arrow range selection. `None` when no range is active. + pub mark_anchor: Option, + /// When true, the pane renders a flat column view (icon | name | size | date). + pub details_view: bool, + /// Active inline rename; drives `FocusLayer::PaneInlineRename`. + pub rename_state: Option, + // Navigation history + pub history_back: Vec, // dirs we came FROM (oldest first) + pub history_forward: Vec, // dirs we can go forward to + pub(crate) filtered_indices: RefCell>, + pub(crate) cache_dirty: Cell, + pub(crate) cache_entry_count: Cell, + pub(crate) cache_sort_mode: Cell, + pub(crate) cache_filter_active: Cell, + pub(crate) cache_filter_query: RefCell, + pub mode: PaneMode, // New: real fs or archive mode + /// Cached result of the last completed directory scan. + pub scan_cache: Option, +} + +impl PaneState { + pub fn in_archive(&self) -> bool { + matches!(self.mode, PaneMode::Archive { .. }) + } + pub fn archive_source(&self) -> Option<&PathBuf> { + match &self.mode { + PaneMode::Archive { source, .. } => Some(source), + _ => None, + } + } + pub fn in_remote(&self) -> bool { + matches!(self.mode, PaneMode::Remote { .. }) + } + pub fn remote_address(&self) -> Option<&str> { + match &self.mode { + PaneMode::Remote { address, .. } => Some(address.as_str()), + _ => None, + } + } + pub fn empty(title: impl Into, cwd: PathBuf) -> Self { + Self { + title: title.into(), + cwd, + entries: Vec::new(), + mode: PaneMode::Real, + selection: 0, + scroll_offset: 0, + show_hidden: false, + sort_mode: SortMode::Name, + marked: BTreeSet::new(), + filter_query: String::new(), + filter_active: false, + mark_anchor: None, + details_view: true, + rename_state: None, + history_back: Vec::new(), + history_forward: Vec::new(), + scan_cache: None, + filtered_indices: RefCell::new(Vec::new()), + cache_dirty: Cell::new(true), + cache_entry_count: Cell::new(0), + cache_sort_mode: Cell::new(SortMode::Name), + cache_filter_active: Cell::new(false), + cache_filter_query: RefCell::new(String::new()), + } + } + + /// Push current cwd to back-stack and clear forward-stack before navigating. + pub fn push_history(&mut self) { + let current = self.cwd.clone(); + self.history_back.push(current); + if self.history_back.len() > 50 { + self.history_back.remove(0); + } + self.history_forward.clear(); + } + + /// Returns the path to navigate back to, if any. + pub fn pop_back(&mut self) -> Option { + let prev = self.history_back.pop()?; + self.history_forward.push(self.cwd.clone()); + Some(prev) + } + + /// Returns the path to navigate forward to, if any. + pub fn pop_forward(&mut self) -> Option { + let next = self.history_forward.pop()?; + self.history_back.push(self.cwd.clone()); + Some(next) + } + + pub fn can_go_back(&self) -> bool { + !self.history_back.is_empty() + } + + pub fn can_go_forward(&self) -> bool { + !self.history_forward.is_empty() + } + + pub fn load(title: impl Into, cwd: PathBuf) -> Result { + let mut pane = Self::empty(title, cwd); + let entries = scan_directory(&pane.cwd)?; + pane.set_entries(entries); + Ok(pane) + } + + pub fn set_entries(&mut self, entries: Vec) { + self.entries = entries + .into_iter() + // Always keep ".." even when hidden files are off (it starts with '.'). + .filter(|entry| entry.name == ".." || self.show_hidden || !entry.name.starts_with('.')) + .collect(); + let entry_paths: BTreeSet = self + .entries + .iter() + .map(|entry| entry.path.clone()) + .collect(); + self.marked.retain(|path| entry_paths.contains(path)); + + self.clamp_selection(); + } + + /// Apply an incremental scan diff to the current entry list. + /// + /// - Removed entries are dropped (the `".."` sentinel is always kept). + /// - Modified entries are updated in place. + /// - Added entries are appended, respecting the `show_hidden` setting. + /// - Stale marks are purged for removed paths. + /// + /// Callers should pass raw entries (without `".."`) from + /// [`crate::fs::scan_diff::compute_scan_diff`]. + pub fn apply_scan_diff(&mut self, diff: crate::fs::scan_diff::ScanDiff) { + if diff.is_empty() { + return; + } + + let removed_paths: BTreeSet<&std::path::Path> = + diff.removed.iter().map(|e| e.path.as_path()).collect(); + + self.entries + .retain(|e| e.name == ".." || !removed_paths.contains(e.path.as_path())); + + for removed in &diff.removed { + self.marked.remove(&removed.path); + } + + for modified in &diff.modified { + if let Some(entry) = self.entries.iter_mut().find(|e| e.path == modified.path) { + *entry = modified.clone(); + } + } + + for added in diff.added { + if self.show_hidden || !added.name.starts_with('.') { + self.entries.push(added); + } + } + + self.refresh_filter(); + } + + pub fn set_show_hidden(&mut self, show_hidden: bool) -> Result<(), FileSystemError> { + self.show_hidden = show_hidden; + let entries = scan_directory(&self.cwd)?; + self.set_entries(entries); + Ok(()) + } + + pub fn move_selection_down(&mut self) { + if self.selection + 1 < self.filtered_len() { + self.selection += 1; + } + } + + pub fn move_selection_up(&mut self) { + self.selection = self.selection.saturating_sub(1); + } + + pub fn selected_entry(&self) -> Option<&EntryInfo> { + self.ensure_cache(); + let idx = *self.filtered_indices.borrow().get(self.selection)?; + self.entries.get(idx) + } + + pub fn selected_path(&self) -> Option<&std::path::Path> { + self.selected_entry().map(|entry| entry.path.as_path()) + } + + pub fn selected_marked_path(&self) -> Option<&std::path::Path> { + self.selected_path() + } + + pub fn toggle_mark_selected(&mut self) -> Option { + // ".." is a navigation sentinel — never mark it. + if self.selected_entry().is_some_and(|e| e.name == "..") { + return None; + } + let path = self.selected_entry()?.path.clone(); + if self.marked.contains(&path) { + self.marked.remove(&path); + Some(false) + } else { + self.marked.insert(path); + Some(true) + } + } + + pub fn clear_marks(&mut self) { + self.marked.clear(); + } + + /// Clear the range-selection anchor without touching the mark set. + pub fn reset_mark_anchor(&mut self) { + self.mark_anchor = None; + } + + /// Extend selection downward, marking every entry stepped over. + /// Sets the anchor on first call; marks anchor + new position. + pub fn extend_selection_down(&mut self) { + if self.mark_anchor.is_none() { + self.mark_anchor = Some(self.selection); + // Mark the anchor entry. + if self.selected_entry().is_some_and(|e| e.name != "..") { + if let Some(path) = self.selected_path() { + self.marked.insert(path.to_path_buf()); + } + } + } + self.move_selection_down(); + if self.selected_entry().is_some_and(|e| e.name != "..") { + if let Some(path) = self.selected_path() { + self.marked.insert(path.to_path_buf()); + } + } + } + + /// Extend selection upward, marking every entry stepped over. + /// Sets the anchor on first call; marks anchor + new position. + pub fn extend_selection_up(&mut self) { + if self.mark_anchor.is_none() { + self.mark_anchor = Some(self.selection); + // Mark the anchor entry. + if self.selected_entry().is_some_and(|e| e.name != "..") { + if let Some(path) = self.selected_path() { + self.marked.insert(path.to_path_buf()); + } + } + } + self.move_selection_up(); + if self.selected_entry().is_some_and(|e| e.name != "..") { + if let Some(path) = self.selected_path() { + self.marked.insert(path.to_path_buf()); + } + } + } + + pub fn is_marked(&self, path: &PathBuf) -> bool { + self.marked.contains(path) + } + + pub fn marked_count(&self) -> usize { + self.marked.len() + } + + pub fn can_enter_selected(&self) -> bool { + self.selected_entry().is_some_and(|entry| { + entry.kind == EntryKind::Directory || entry.kind == EntryKind::Archive + }) + } + + pub fn parent_path(&self) -> Option { + self.cwd.parent().map(PathBuf::from) + } + + #[cfg(test)] + pub fn sorted_entries(&self) -> Vec<&EntryInfo> { + // Delegates to the shared cache so sort logic is not duplicated. + self.filtered_entries() + } + + pub fn filtered_entries(&self) -> Vec<&EntryInfo> { + self.ensure_cache(); + self.filtered_indices + .borrow() + .iter() + .filter_map(|&idx| self.entries.get(idx)) + .collect() + } + + pub fn visible_entries(&self, height: usize) -> Vec<&EntryInfo> { + self.ensure_cache(); + let indices = self.filtered_indices.borrow(); + if height == 0 || indices.is_empty() { + return Vec::new(); + } + + let start = self.visible_start_for(height, indices.len()); + let end = (start + height).min(indices.len()); + indices[start..end] + .iter() + .filter_map(|&idx| self.entries.get(idx)) + .collect() + } + + pub fn visible_selection(&self, height: usize) -> Option { + let count = self.filtered_len(); + if count == 0 || height == 0 { + return None; + } + + Some( + self.selection + .saturating_sub(self.visible_start_for(height, count)), + ) + } + + pub fn select_path(&mut self, path: &std::path::Path) -> bool { + self.ensure_cache(); + if let Some(index) = self + .filtered_indices + .borrow() + .iter() + .position(|&entry_index| self.entries[entry_index].path == path) + { + self.selection = index; + true + } else { + false + } + } + + pub fn clear_filter(&mut self) { + self.filter_active = false; + self.filter_query.clear(); + self.selection = 0; + self.scroll_offset = 0; + self.invalidate_cache(); + } + + pub fn refresh_filter(&mut self) { + self.invalidate_cache(); + self.clamp_selection(); + } + + pub fn filtered_len_pub(&self) -> usize { + self.filtered_len() + } + + pub fn filtered_count(&self) -> usize { + self.filtered_len() + } + + fn filtered_len(&self) -> usize { + self.ensure_cache(); + self.filtered_indices.borrow().len() + } + + fn clamp_selection(&mut self) { + let count = self.filtered_len(); + if self.selection >= count { + self.selection = count.saturating_sub(1); + } + + if self.scroll_offset > self.selection { + self.scroll_offset = self.selection; + } + } + + fn visible_start_for(&self, height: usize, count: usize) -> usize { + if self.selection < self.scroll_offset { + self.selection + } else if self.selection >= self.scroll_offset + height { + self.selection + 1 - height + } else { + self.scroll_offset.min(count.saturating_sub(height)) + } + } + + fn invalidate_cache(&self) { + self.cache_dirty.set(true); + } + + fn ensure_cache(&self) { + if self.cache_dirty.get() + || self.cache_entry_count.get() != self.entries.len() + || self.cache_sort_mode.get() != self.sort_mode + || self.cache_filter_active.get() != self.filter_active + || *self.cache_filter_query.borrow() != self.filter_query + { + self.rebuild_cache(); + } + } + + fn rebuild_cache(&self) { + let mut filtered = self.filtered_indices.borrow_mut(); + filtered.clear(); + filtered.reserve(self.entries.len()); + + // Partition: ".." goes straight into output, everything else into + // rest_indices for sorting/filtering. + let mut rest_indices = Vec::with_capacity(self.entries.len()); + for i in 0..self.entries.len() { + if self.entries[i].name == ".." { + filtered.push(i); + } else { + rest_indices.push(i); + } + } + + // lower_name is cached in EntryInfo, so no need to recompute here. + + rest_indices.sort_by(|&left_idx, &right_idx| { + let left = &self.entries[left_idx]; + let right = &self.entries[right_idx]; + match self.sort_mode { + SortMode::Name => { + dir_first(left, right).then_with(|| left.lower_name.cmp(&right.lower_name)) + } + SortMode::NameDesc => { + dir_first(left, right).then_with(|| right.lower_name.cmp(&left.lower_name)) + } + SortMode::Size => dir_first(left, right).then_with(|| { + left.size_bytes + .unwrap_or(0) + .cmp(&right.size_bytes.unwrap_or(0)) + }), + SortMode::SizeDesc => dir_first(left, right).then_with(|| { + right + .size_bytes + .unwrap_or(0) + .cmp(&left.size_bytes.unwrap_or(0)) + }), + SortMode::Modified => { + dir_first(left, right).then_with(|| left.modified.cmp(&right.modified)) + } + SortMode::ModifiedDesc => { + dir_first(left, right).then_with(|| right.modified.cmp(&left.modified)) + } + SortMode::Extension => dir_first(left, right).then_with(|| { + let ext_a = left + .path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + let ext_b = right + .path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + ext_a + .cmp(&ext_b) + .then_with(|| left.lower_name.cmp(&right.lower_name)) + }), + } + }); + + if self.filter_active && !self.filter_query.is_empty() { + // ".." is always visible even during filtering. + rest_indices.retain(|&idx| { + crate::utils::glob_match::matches(&self.filter_query, &self.entries[idx].name) + }); + } + + filtered.extend(rest_indices); + self.cache_entry_count.set(self.entries.len()); + self.cache_sort_mode.set(self.sort_mode); + self.cache_filter_active.set(self.filter_active); + *self.cache_filter_query.borrow_mut() = self.filter_query.clone(); + self.cache_dirty.set(false); + } +} + +fn dir_first(a: &EntryInfo, b: &EntryInfo) -> std::cmp::Ordering { + match (a.kind, b.kind) { + (EntryKind::Directory, EntryKind::Directory) => std::cmp::Ordering::Equal, + (EntryKind::Directory, _) => std::cmp::Ordering::Less, + (_, EntryKind::Directory) => std::cmp::Ordering::Greater, + _ => std::cmp::Ordering::Equal, + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + use std::path::PathBuf; + + use crate::fs::{EntryInfo, EntryKind}; + + use super::{PaneState, SortMode}; + + fn file(name: &str) -> EntryInfo { + let name = name.to_string(); + let path = PathBuf::from(format!("./{name}")); + EntryInfo { + lower_name: name.to_lowercase(), + name, + path, + kind: EntryKind::File, + size_bytes: Some(1), + modified: None, + link_target: None, + } + } + + fn dir(name: &str) -> EntryInfo { + let name = name.to_string(); + let path = PathBuf::from(format!("./{name}")); + EntryInfo { + lower_name: name.to_lowercase(), + name, + path, + kind: EntryKind::Directory, + size_bytes: None, + modified: None, + link_target: None, + } + } + + fn pane_with_entries(entries: Vec) -> PaneState { + let mut pane = PaneState::empty("left", PathBuf::from(".")); + pane.entries = entries; + pane + } + + fn empty_pane(cwd: &str) -> PaneState { + PaneState::empty("test", PathBuf::from(cwd)) + } + + #[test] + fn clamps_selection_at_zero() { + let mut pane = empty_pane("."); + pane.move_selection_up(); + assert_eq!(pane.selection, 0); + } + + #[test] + fn visible_selection_tracks_scrolled_window() { + let mut pane = pane_with_entries( + (0..10) + .map(|index| { + let name = format!("item-{index}"); + EntryInfo { + lower_name: name.to_lowercase(), + name, + path: PathBuf::from(format!("./item-{index}")), + kind: EntryKind::File, + size_bytes: Some(index as u64 * 16), + modified: None, + link_target: None, + } + }) + .collect(), + ); + pane.selection = 7; + + assert_eq!(pane.visible_selection(4), Some(3)); + assert_eq!(pane.visible_entries(4).len(), 4); + + pane.move_selection_up(); + assert_eq!(pane.visible_selection(4), Some(3)); + } + + #[test] + fn sort_by_name_puts_dirs_first() { + let pane = pane_with_entries(vec![file("zzz.txt"), dir("aaa"), file("aaa.txt")]); + let sorted = pane.sorted_entries(); + assert_eq!(sorted[0].kind, EntryKind::Directory); + assert_eq!(sorted[0].name, "aaa"); + assert_eq!(sorted[1].name, "aaa.txt"); + assert_eq!(sorted[2].name, "zzz.txt"); + } + + #[test] + fn sort_by_size_desc_orders_largest_first() { + let mut pane = pane_with_entries(vec![ + file("small.txt"), + file("large.txt"), + file("medium.txt"), + ]); + pane.entries[0].size_bytes = Some(10); + pane.entries[1].size_bytes = Some(9999); + pane.entries[2].size_bytes = Some(500); + pane.sort_mode = SortMode::SizeDesc; + + let sorted = pane.sorted_entries(); + assert_eq!(sorted[0].name, "large.txt"); + assert_eq!(sorted[1].name, "medium.txt"); + assert_eq!(sorted[2].name, "small.txt"); + } + + #[test] + fn sort_by_extension_groups_by_ext() { + let mut pane = pane_with_entries(vec![file("b.rs"), file("a.md"), file("a.rs")]); + pane.sort_mode = SortMode::Extension; + let sorted = pane.sorted_entries(); + assert_eq!(sorted[0].name, "a.md"); + assert_eq!(sorted[1].name, "a.rs"); + assert_eq!(sorted[2].name, "b.rs"); + } + + #[test] + fn cycle_sort_mode_wraps_around() { + let mut mode = SortMode::Name; + mode = mode.next(); + mode = mode.next(); + mode = mode.next(); + mode = mode.next(); + mode = mode.next(); + mode = mode.next(); + mode = mode.next(); + assert_eq!(mode, SortMode::Name); + } + + #[test] + fn toggle_mark_selected_adds_and_removes_mark() { + let mut pane = pane_with_entries(vec![file("file.txt")]); + assert_eq!(pane.toggle_mark_selected(), Some(true)); + assert_eq!(pane.marked_count(), 1); + assert_eq!(pane.toggle_mark_selected(), Some(false)); + assert_eq!(pane.marked_count(), 0); + } + + #[test] + fn clear_marks_removes_all() { + let mut pane = pane_with_entries(vec![file("file.txt")]); + pane.marked = BTreeSet::from([PathBuf::from("./file.txt")]); + pane.clear_marks(); + assert_eq!(pane.marked_count(), 0); + } + + #[test] + fn set_entries_purges_stale_marks() { + let mut pane = pane_with_entries(vec![file("file.txt")]); + pane.marked = BTreeSet::from([PathBuf::from("./file.txt"), PathBuf::from("./missing.txt")]); + pane.set_entries(vec![file("file.txt")]); + assert_eq!(pane.marked_count(), 1); + assert!(pane.is_marked(&PathBuf::from("./file.txt"))); + } + + #[test] + fn marked_count_tracks_number_of_marks() { + let mut pane = pane_with_entries(vec![file("a.txt"), file("b.txt")]); + assert_eq!(pane.marked_count(), 0); + let _ = pane.toggle_mark_selected(); + pane.move_selection_down(); + let _ = pane.toggle_mark_selected(); + assert_eq!(pane.marked_count(), 2); + } + + #[test] + fn filter_active_hides_non_matching_entries() { + let mut pane = + pane_with_entries(vec![file("main.rs"), file("README.md"), file("Cargo.toml")]); + pane.filter_active = true; + pane.filter_query = String::from("read"); + let names: Vec<_> = pane + .visible_entries(10) + .into_iter() + .map(|e| e.name.clone()) + .collect(); + assert_eq!(names, vec![String::from("README.md")]); + } + + #[test] + fn filter_empty_query_shows_all_entries() { + let mut pane = pane_with_entries(vec![file("a.rs"), file("b.rs")]); + pane.filter_active = true; + assert_eq!(pane.visible_entries(10).len(), 2); + } + + #[test] + fn filter_is_case_insensitive() { + let mut pane = pane_with_entries(vec![file("README.md"), file("main.rs")]); + pane.filter_active = true; + pane.filter_query = String::from("read"); + assert_eq!( + pane.selected_entry().map(|e| e.name.as_str()), + Some("README.md") + ); + } + + #[test] + fn push_history_records_previous_dir() { + let mut pane = empty_pane("/home"); + pane.push_history(); + assert_eq!(pane.history_back, vec![PathBuf::from("/home")]); + } + + #[test] + fn pop_back_returns_previous_and_moves_current_to_forward() { + let mut pane = empty_pane("/home"); + pane.push_history(); + pane.cwd = PathBuf::from("/home/user"); + + let back = pane.pop_back(); + assert_eq!(back, Some(PathBuf::from("/home"))); + assert_eq!(pane.history_forward, vec![PathBuf::from("/home/user")]); + assert!(pane.history_back.is_empty()); + } + + #[test] + fn pop_forward_returns_next_and_moves_current_to_back() { + let mut pane = empty_pane("/home"); + pane.push_history(); + pane.cwd = PathBuf::from("/home/user"); + let _back = pane.pop_back(); + pane.cwd = PathBuf::from("/home"); + + let fwd = pane.pop_forward(); + assert_eq!(fwd, Some(PathBuf::from("/home/user"))); + assert_eq!(pane.history_back, vec![PathBuf::from("/home")]); + assert!(pane.history_forward.is_empty()); + } + + #[test] + fn push_history_clears_forward_stack() { + let mut pane = empty_pane("/home"); + pane.push_history(); + pane.cwd = PathBuf::from("/home/user"); + let _back = pane.pop_back(); + pane.cwd = PathBuf::from("/home"); + pane.push_history(); + assert!(pane.history_forward.is_empty()); + } + + #[test] + fn history_capped_at_50_entries() { + let mut pane = empty_pane("/start"); + for i in 0..60 { + pane.cwd = PathBuf::from(format!("/dir/{i}")); + pane.push_history(); + } + assert_eq!(pane.history_back.len(), 50); + } +} diff --git a/src/state/mod.rs b/src/state/mod.rs index 312bfdb3..8f116258 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -1015,7 +1015,7 @@ impl AppState { } Action::OpenInDefaultApp => { if let Some(path) = self.panes.active_pane().selected_path() { - match open::that(&path) { + match open::that(path) { Ok(()) => { self.status_message = StatusMessage::info(format!( "opened {} with system default", @@ -1671,7 +1671,7 @@ impl AppState { self.overlay.modal = Some(crate::state::overlay::ModalState::OpenWith { items, selection: 0, - target: path, + target: path.to_path_buf(), }); } else { self.set_status(String::from("no file selected")); @@ -2639,6 +2639,7 @@ impl AppState { } else { // Full replace (navigation to a new directory). let parent_entry = path.parent().map(|parent| crate::fs::EntryInfo { + lower_name: String::from(".."), name: String::from(".."), path: parent.to_path_buf(), kind: EntryKind::Directory, @@ -4162,6 +4163,7 @@ mod tests { title: String::from("left"), cwd: PathBuf::from("."), entries: vec![EntryInfo { + lower_name: String::from("note.txt"), name: String::from("note.txt"), path: PathBuf::from(path), kind: EntryKind::File, @@ -4954,6 +4956,7 @@ mod tests { let mut state = test_state(); state.panes.right.cwd = PathBuf::from("/tmp/target"); state.panes.left.entries.push(EntryInfo { + lower_name: String::from("two.txt"), name: String::from("two.txt"), path: PathBuf::from("./two.txt"), kind: EntryKind::File, diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 341dc24d..b2deb310 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -25,6 +25,68 @@ use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use ratatui::Frame; +use std::borrow::Cow; +use std::sync::LazyLock; + +/// Pre-computed repeats of common chars to avoid per-frame allocations. +/// Covers lengths 0–128; larger widths fall back to `String::repeat`. +static REPEAT_CACHE: LazyLock = LazyLock::new(RepeatCache::new); + +struct RepeatCache { + space: Vec<&'static str>, + bar: Vec<&'static str>, + shade: Vec<&'static str>, +} + +impl RepeatCache { + fn new() -> Self { + const MAX: usize = 128; + Self { + space: (0..=MAX) + .map(|n| { + let s: &'static str = Box::leak(" ".repeat(n).into_boxed_str()); + s + }) + .collect(), + bar: (0..=MAX) + .map(|n| { + let s: &'static str = Box::leak("─".repeat(n).into_boxed_str()); + s + }) + .collect(), + shade: (0..=MAX) + .map(|n| { + let s: &'static str = Box::leak("░".repeat(n).into_boxed_str()); + s + }) + .collect(), + } + } + + fn space(&self, n: usize) -> Cow<'static, str> { + self.space + .get(n) + .copied() + .map(Cow::Borrowed) + .unwrap_or_else(|| Cow::Owned(" ".repeat(n))) + } + + fn bar(&self, n: usize) -> Cow<'static, str> { + self.bar + .get(n) + .copied() + .map(Cow::Borrowed) + .unwrap_or_else(|| Cow::Owned("─".repeat(n))) + } + + fn shade(&self, n: usize) -> Cow<'static, str> { + self.shade + .get(n) + .copied() + .map(Cow::Borrowed) + .unwrap_or_else(|| Cow::Owned("░".repeat(n))) + } +} use crate::pane::PaneId; use crate::state::{AppState, MessageKind, PaneLayout}; @@ -447,7 +509,7 @@ fn render_status_bar( palette: crate::config::ThemePalette, ) { let zones = state.status_zones(); - let mut spans = Vec::new(); + let mut spans = Vec::with_capacity(6); if let Some(ref progress) = zones.progress { let op_text = format!( @@ -461,8 +523,14 @@ fn render_status_bar( 0 }; let empty = bar_width.saturating_sub(filled); + let cache = &*REPEAT_CACHE; + let mut bar = String::with_capacity(op_text.len() + filled + empty + 1); + bar.push_str(&op_text); + bar.push_str(&cache.bar(filled)); + bar.push_str(&cache.shade(empty)); + bar.push(' '); spans.push(Span::styled( - format!("{}{}{} ", op_text, "─".repeat(filled), "░".repeat(empty)), + bar, Style::default().fg(palette.status_fg).bg(palette.status_bg), )); } else { @@ -774,7 +842,7 @@ fn render_key_hints( // Fill remainder with status background so the bar doesn't look torn. if used_width < area.width { spans.push(Span::styled( - " ".repeat((area.width - used_width) as usize), + REPEAT_CACHE.space((area.width - used_width) as usize), sep_style, )); } diff --git a/src/ui/pane.rs b/src/ui/pane.rs index 3acea3de..c6866b7b 100644 --- a/src/ui/pane.rs +++ b/src/ui/pane.rs @@ -13,6 +13,7 @@ use crate::git::{FileStatus, RepoStatus}; use crate::icon::icon_for_entry; use crate::pane::PaneState; use crate::state::AppState; +use crate::ui::REPEAT_CACHE; pub struct PaneChrome { pub border: Style, @@ -252,7 +253,7 @@ pub fn render_pane(frame: &mut Frame<'_>, area: Rect, args: RenderPaneArgs<'_>) query_display, pane_filter_strip_style(palette).add_modifier(Modifier::BOLD), ), - Span::styled(" ".repeat(pad), pane_filter_strip_style(palette)), + Span::styled(REPEAT_CACHE.space(pad), pane_filter_strip_style(palette)), Span::styled( count_display, Style::default() @@ -369,7 +370,7 @@ fn render_item(args: RenderItemArgs<'_>) -> ListItem<'static> { Span::styled(git_char, Style::default().fg(git_colour)), Span::raw(" "), Span::styled(name, name_style), - Span::raw(" ".repeat(spacer_width)), + Span::styled(REPEAT_CACHE.space(spacer_width), Style::default()), Span::styled(size_str, row_styles.meta), Span::raw(" "), Span::styled(date_str, row_styles.meta), @@ -695,6 +696,7 @@ mod tests { ] { let item = render_item(RenderItemArgs { entry: &EntryInfo { + lower_name: String::from("note.txt"), name: String::from("note.txt"), path: PathBuf::from("./note.txt"), kind: EntryKind::File, @@ -745,6 +747,7 @@ mod tests { fn normal_pane_row_uses_full_available_width() { let item = render_item(RenderItemArgs { entry: &EntryInfo { + lower_name: String::from("note.txt"), name: String::from("note.txt"), path: PathBuf::from("./note.txt"), kind: EntryKind::File, diff --git a/src/ui/preview.rs b/src/ui/preview.rs index 22407093..fa76048a 100644 --- a/src/ui/preview.rs +++ b/src/ui/preview.rs @@ -3,6 +3,7 @@ use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; use ratatui::Frame; +use std::borrow::Cow; use unicode_width::UnicodeWidthChar; use crate::config::ThemePalette; @@ -93,11 +94,11 @@ pub fn wrap_preview_line( rows } -pub fn preview_gutter_label(line_number: usize, is_continuation: bool) -> String { +pub fn preview_gutter_label(line_number: usize, is_continuation: bool) -> Cow<'static, str> { if is_continuation { - " ".repeat(PREVIEW_GUTTER_WIDTH as usize) + crate::ui::REPEAT_CACHE.space(PREVIEW_GUTTER_WIDTH as usize) } else { - format!("{:>3} ", line_number) + Cow::Owned(format!("{:>3} ", line_number)) } } diff --git a/tests/hooks_integration.rs b/tests/hooks_integration.rs index eb016b60..8e3cf8a3 100644 --- a/tests/hooks_integration.rs +++ b/tests/hooks_integration.rs @@ -1,224 +1,227 @@ -//! Integration tests for shell hook trigger points. - -use std::path::PathBuf; -use std::time::Instant; - -use zeta::config::{AppConfig, ConfigSource, HookConfig, HookEvent, LoadedConfig}; -use zeta::state::AppState; - -fn make_state(config: AppConfig) -> AppState { - let loaded = LoadedConfig { - config, - path: PathBuf::from(""), - source: ConfigSource::File, - }; - AppState::bootstrap(loaded, Instant::now()).expect("bootstrap failed") -} - -fn config_with_hook(event: HookEvent, command: &str) -> AppConfig { - AppConfig { - hooks: vec![HookConfig { - event, - command: command.into(), - }], - ..AppConfig::default() - } -} - -#[test] -fn on_start_hook_fires_in_initial_commands() { - let cfg = config_with_hook(HookEvent::OnStart, "echo start"); - let mut state = make_state(cfg); - let cmds = state.initial_commands(); - let hook_cmds: Vec<_> = cmds - .iter() - .filter(|c| matches!(c, zeta::action::Command::RunHook { .. })) - .collect(); - assert_eq!(hook_cmds.len(), 1, "expected 1 on_start RunHook command"); -} - -#[test] -fn on_open_hook_command_built_correctly() { - use zeta::action::Command; - use zeta::config::{HookConfig, HookEvent}; - use zeta::hooks::{commands_for_event, HookEnv}; - - let hooks = vec![HookConfig { - event: HookEvent::OnOpen, - command: "echo open".into(), - }]; - let env = HookEnv { - path: "/home/user/file.txt".into(), - old_path: None, - pane: "left".into(), - version: String::new(), - }; - let cmds = commands_for_event(&hooks, HookEvent::OnOpen, &env, 0); - assert_eq!(cmds.len(), 1); - match &cmds[0] { - Command::RunHook { - command, env: e, .. - } => { - assert_eq!(command, "echo open"); - assert!(e - .iter() - .any(|(k, v)| k == "ZETA_PATH" && v == "/home/user/file.txt")); - assert!(e.iter().any(|(k, v)| k == "ZETA_PANE" && v == "left")); - assert!(!e.iter().any(|(k, _)| k == "ZETA_OLD_PATH")); - } - _ => panic!("expected RunHook"), - } -} - -#[test] -fn on_open_hook_fires_via_apply_for_file_not_dir() { - use zeta::action::{Action, Command}; - use zeta::config::HookEvent; - use zeta::fs::{EntryInfo, EntryKind}; - use zeta::jobs::JobResult; - use zeta::pane::PaneId; - - let cfg = config_with_hook(HookEvent::OnOpen, "echo open"); - let mut state = make_state(cfg); - - let base = PathBuf::from("/test"); - - // Populate left pane with a file entry so OpenSelectedInEditor has something to open. - let file_entry = EntryInfo { - name: "readme.md".into(), - path: base.join("readme.md"), - kind: EntryKind::File, - size_bytes: Some(42), - modified: None, - link_target: None, - }; - state.apply_job_result_commands(JobResult::DirectoryScanned { - workspace_id: 0, - pane: PaneId::Left, - path: base.clone(), - entries: vec![file_entry], - elapsed_ms: 0, - }); - - // After a DirectoryScanned, selection is at index 0 which is the ".." parent entry. - // Move down once to select "readme.md". - state - .apply(Action::MoveSelectionDown) - .expect("MoveSelectionDown should succeed"); - - // Positive case: file selected → on_open hook fires. - let cmds = state - .apply(Action::OpenSelectedInEditor) - .expect("apply should succeed"); - let hook_cmds: Vec<_> = cmds - .iter() - .filter(|c| matches!(c, Command::RunHook { .. })) - .collect(); - assert_eq!( - hook_cmds.len(), - 1, - "expected 1 on_open RunHook for file entry" - ); - - // Negative case: directory selected → hook must NOT fire. - let dir_entry = EntryInfo { - name: "subdir".into(), - path: base.join("subdir"), - kind: EntryKind::Directory, - size_bytes: None, - modified: None, - link_target: None, - }; - state.apply_job_result_commands(JobResult::DirectoryScanned { - workspace_id: 0, - pane: PaneId::Left, - path: base.clone(), - entries: vec![dir_entry], - elapsed_ms: 0, - }); - - // Move to select "subdir" (index 0 is "..", index 1 is "subdir"). - state - .apply(Action::MoveSelectionDown) - .expect("MoveSelectionDown should succeed"); - - let dir_cmds = state - .apply(Action::OpenSelectedInEditor) - .expect("apply should succeed"); - assert!( - dir_cmds - .iter() - .all(|c| !matches!(c, Command::RunHook { .. })), - "on_open hook must not fire for directory entry" - ); -} - -#[test] -fn no_hooks_initial_commands_has_no_run_hook() { - let mut state = make_state(AppConfig::default()); - let cmds = state.initial_commands(); - assert!( - cmds.iter() - .all(|c| !matches!(c, zeta::action::Command::RunHook { .. })), - "expected no RunHook commands with no hooks configured" - ); -} - -#[test] -fn on_cd_hook_fires_on_directory_change_not_refresh() { - use zeta::action::Command; - use zeta::jobs::JobResult; - use zeta::pane::PaneId; - - let cfg = config_with_hook(HookEvent::OnCd, "echo cd"); - let mut state = make_state(cfg); - - let new_path = PathBuf::from("/some/new/path"); - - // Positive case: navigate to a new directory → hook fires. - // Initial pane entries are empty, so is_refresh is always false on first scan. - let cmds = state.apply_job_result_commands(JobResult::DirectoryScanned { - workspace_id: 0, - pane: PaneId::Left, - path: new_path.clone(), - entries: vec![], - elapsed_ms: 0, - }); - let hook_cmds: Vec<_> = cmds - .iter() - .filter(|c| matches!(c, Command::RunHook { .. })) - .collect(); - assert_eq!(hook_cmds.len(), 1, "expected on_cd RunHook for navigation"); - - // Populate entries so the pane has non-empty state, enabling refresh detection. - let dummy_entry = zeta::fs::EntryInfo { - name: "file.txt".into(), - path: new_path.join("file.txt"), - kind: zeta::fs::EntryKind::File, - size_bytes: None, - modified: None, - link_target: None, - }; - state.apply_job_result_commands(JobResult::DirectoryScanned { - workspace_id: 0, - pane: PaneId::Left, - path: new_path.clone(), - entries: vec![dummy_entry.clone()], - elapsed_ms: 0, - }); - - // Negative case: same path with existing entries → refresh, no hook. - let refresh_cmds = state.apply_job_result_commands(JobResult::DirectoryScanned { - workspace_id: 0, - pane: PaneId::Left, - path: new_path.clone(), - entries: vec![dummy_entry], - elapsed_ms: 0, - }); - assert!( - refresh_cmds - .iter() - .all(|c| !matches!(c, Command::RunHook { .. })), - "expected no RunHook for directory refresh" - ); -} +//! Integration tests for shell hook trigger points. + +use std::path::PathBuf; +use std::time::Instant; + +use zeta::config::{AppConfig, ConfigSource, HookConfig, HookEvent, LoadedConfig}; +use zeta::state::AppState; + +fn make_state(config: AppConfig) -> AppState { + let loaded = LoadedConfig { + config, + path: PathBuf::from(""), + source: ConfigSource::File, + }; + AppState::bootstrap(loaded, Instant::now()).expect("bootstrap failed") +} + +fn config_with_hook(event: HookEvent, command: &str) -> AppConfig { + AppConfig { + hooks: vec![HookConfig { + event, + command: command.into(), + }], + ..AppConfig::default() + } +} + +#[test] +fn on_start_hook_fires_in_initial_commands() { + let cfg = config_with_hook(HookEvent::OnStart, "echo start"); + let mut state = make_state(cfg); + let cmds = state.initial_commands(); + let hook_cmds: Vec<_> = cmds + .iter() + .filter(|c| matches!(c, zeta::action::Command::RunHook { .. })) + .collect(); + assert_eq!(hook_cmds.len(), 1, "expected 1 on_start RunHook command"); +} + +#[test] +fn on_open_hook_command_built_correctly() { + use zeta::action::Command; + use zeta::config::{HookConfig, HookEvent}; + use zeta::hooks::{commands_for_event, HookEnv}; + + let hooks = vec![HookConfig { + event: HookEvent::OnOpen, + command: "echo open".into(), + }]; + let env = HookEnv { + path: "/home/user/file.txt".into(), + old_path: None, + pane: "left".into(), + version: String::new(), + }; + let cmds = commands_for_event(&hooks, HookEvent::OnOpen, &env, 0); + assert_eq!(cmds.len(), 1); + match &cmds[0] { + Command::RunHook { + command, env: e, .. + } => { + assert_eq!(command, "echo open"); + assert!(e + .iter() + .any(|(k, v)| k == "ZETA_PATH" && v == "/home/user/file.txt")); + assert!(e.iter().any(|(k, v)| k == "ZETA_PANE" && v == "left")); + assert!(!e.iter().any(|(k, _)| k == "ZETA_OLD_PATH")); + } + _ => panic!("expected RunHook"), + } +} + +#[test] +fn on_open_hook_fires_via_apply_for_file_not_dir() { + use zeta::action::{Action, Command}; + use zeta::config::HookEvent; + use zeta::fs::{EntryInfo, EntryKind}; + use zeta::jobs::JobResult; + use zeta::pane::PaneId; + + let cfg = config_with_hook(HookEvent::OnOpen, "echo open"); + let mut state = make_state(cfg); + + let base = PathBuf::from("/test"); + + // Populate left pane with a file entry so OpenSelectedInEditor has something to open. + let file_entry = EntryInfo { + lower_name: "readme.md".into(), + name: "readme.md".into(), + path: base.join("readme.md"), + kind: EntryKind::File, + size_bytes: Some(42), + modified: None, + link_target: None, + }; + state.apply_job_result_commands(JobResult::DirectoryScanned { + workspace_id: 0, + pane: PaneId::Left, + path: base.clone(), + entries: vec![file_entry], + elapsed_ms: 0, + }); + + // After a DirectoryScanned, selection is at index 0 which is the ".." parent entry. + // Move down once to select "readme.md". + state + .apply(Action::MoveSelectionDown) + .expect("MoveSelectionDown should succeed"); + + // Positive case: file selected → on_open hook fires. + let cmds = state + .apply(Action::OpenSelectedInEditor) + .expect("apply should succeed"); + let hook_cmds: Vec<_> = cmds + .iter() + .filter(|c| matches!(c, Command::RunHook { .. })) + .collect(); + assert_eq!( + hook_cmds.len(), + 1, + "expected 1 on_open RunHook for file entry" + ); + + // Negative case: directory selected → hook must NOT fire. + let dir_entry = EntryInfo { + lower_name: "subdir".into(), + name: "subdir".into(), + path: base.join("subdir"), + kind: EntryKind::Directory, + size_bytes: None, + modified: None, + link_target: None, + }; + state.apply_job_result_commands(JobResult::DirectoryScanned { + workspace_id: 0, + pane: PaneId::Left, + path: base.clone(), + entries: vec![dir_entry], + elapsed_ms: 0, + }); + + // Move to select "subdir" (index 0 is "..", index 1 is "subdir"). + state + .apply(Action::MoveSelectionDown) + .expect("MoveSelectionDown should succeed"); + + let dir_cmds = state + .apply(Action::OpenSelectedInEditor) + .expect("apply should succeed"); + assert!( + dir_cmds + .iter() + .all(|c| !matches!(c, Command::RunHook { .. })), + "on_open hook must not fire for directory entry" + ); +} + +#[test] +fn no_hooks_initial_commands_has_no_run_hook() { + let mut state = make_state(AppConfig::default()); + let cmds = state.initial_commands(); + assert!( + cmds.iter() + .all(|c| !matches!(c, zeta::action::Command::RunHook { .. })), + "expected no RunHook commands with no hooks configured" + ); +} + +#[test] +fn on_cd_hook_fires_on_directory_change_not_refresh() { + use zeta::action::Command; + use zeta::jobs::JobResult; + use zeta::pane::PaneId; + + let cfg = config_with_hook(HookEvent::OnCd, "echo cd"); + let mut state = make_state(cfg); + + let new_path = PathBuf::from("/some/new/path"); + + // Positive case: navigate to a new directory → hook fires. + // Initial pane entries are empty, so is_refresh is always false on first scan. + let cmds = state.apply_job_result_commands(JobResult::DirectoryScanned { + workspace_id: 0, + pane: PaneId::Left, + path: new_path.clone(), + entries: vec![], + elapsed_ms: 0, + }); + let hook_cmds: Vec<_> = cmds + .iter() + .filter(|c| matches!(c, Command::RunHook { .. })) + .collect(); + assert_eq!(hook_cmds.len(), 1, "expected on_cd RunHook for navigation"); + + // Populate entries so the pane has non-empty state, enabling refresh detection. + let dummy_entry = zeta::fs::EntryInfo { + lower_name: "file.txt".into(), + name: "file.txt".into(), + path: new_path.join("file.txt"), + kind: zeta::fs::EntryKind::File, + size_bytes: None, + modified: None, + link_target: None, + }; + state.apply_job_result_commands(JobResult::DirectoryScanned { + workspace_id: 0, + pane: PaneId::Left, + path: new_path.clone(), + entries: vec![dummy_entry.clone()], + elapsed_ms: 0, + }); + + // Negative case: same path with existing entries → refresh, no hook. + let refresh_cmds = state.apply_job_result_commands(JobResult::DirectoryScanned { + workspace_id: 0, + pane: PaneId::Left, + path: new_path.clone(), + entries: vec![dummy_entry], + elapsed_ms: 0, + }); + assert!( + refresh_cmds + .iter() + .all(|c| !matches!(c, Command::RunHook { .. })), + "expected no RunHook for directory refresh" + ); +}