diff --git a/src/app/panel_ops.rs b/src/app/panel_ops.rs index e281b3a..addbfee 100644 --- a/src/app/panel_ops.rs +++ b/src/app/panel_ops.rs @@ -36,27 +36,22 @@ pub fn refresh_panel(panel: &mut PanelState, visible_height: usize) -> Option(&mut self, mut predicate: F) + where + F: FnMut(&FileEntry) -> bool, + { + self.entries.clear(); + self.entries.reserve(self.unfiltered_entries.len()); + for (i, entry) in self.unfiltered_entries.iter().enumerate() { + if predicate(entry) { + self.entries.push(i); + } + } + if self.state == ListingState::NeedsRebuild { + self.state = ListingState::Clean; + } + } + /// Set the filtered view to the full backing store, in storage order /// (the no-filter case). The view is now consistent with the store, so the /// panel is marked `Clean` (cancelling any pending `NeedsRebuild` from a diff --git a/src/app/watcher_sync/tests.rs b/src/app/watcher_sync/tests.rs index 940ac84..20d9d50 100644 --- a/src/app/watcher_sync/tests.rs +++ b/src/app/watcher_sync/tests.rs @@ -318,6 +318,48 @@ fn watcher_skips_update_when_metadata_unchanged() { assert_entry_counts(&panel, 2, 2); } +/// Regression: `rebuild_visible_entries` sorts the backing store in place, so +/// `path_index` must be rebuilt or watcher upsert/remove mutate the wrong slot. +/// Without the rebuild, an upsert targeting `alpha` (relocated by the sort) +/// lands on whichever entry used to occupy that slot pre-sort. +#[test] +fn rebuild_visible_entries_keeps_path_index_consistent_for_watcher_upsert() { + let dir = tempfile::tempdir().unwrap(); + let alpha = dir.path().join("alpha.txt"); + let beta = dir.path().join("beta.txt"); + fs::write(&alpha, b"a").unwrap(); + fs::write(&beta, b"b").unwrap(); + + let mut panel = test_panel(dir.path()); + // Insert in reverse name order so the name sort actually reorders. + assert!(apply_watcher_upsert_if_matches(&mut panel, &beta)); + assert!(apply_watcher_upsert_if_matches(&mut panel, &alpha)); + rebuild(&mut panel); + assert_entry_names_eq(&panel, &["..", "alpha.txt", "beta.txt"]); + + // Update alpha's content and size; upsert must land on alpha, not beta. + fs::write(&alpha, b"alpha-grow").unwrap(); + assert!(apply_watcher_upsert_if_matches(&mut panel, &alpha)); + rebuild(&mut panel); + + let alpha_entry = panel + .listing + .unfiltered() + .iter() + .find(|e| e.name == "alpha.txt") + .unwrap(); + assert_eq!(alpha_entry.size(), b"alpha-grow".len() as u64); + + // Sanity: beta untouched by the alpha upsert (the stale-index symptom). + let beta_entry = panel + .listing + .unfiltered() + .iter() + .find(|e| e.name == "beta.txt") + .unwrap(); + assert_eq!(beta_entry.size(), b"b".len() as u64); +} + #[test] fn watcher_updates_when_metadata_changes() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/fs/cha.rs b/src/fs/cha.rs index f920a7d..2ae2cf5 100644 --- a/src/fs/cha.rs +++ b/src/fs/cha.rs @@ -147,6 +147,7 @@ impl ChaMode { } #[inline] + #[cfg(test)] pub(crate) fn is_dir(&self) -> bool { self.typ() == ChaType::Dir } @@ -171,7 +172,9 @@ impl ChaMode { // Recognizable sentinel so dummy dirs sort to the epoch and callers can detect them. const DIR_SENTINEL_MTIME: SystemTime = UNIX_EPOCH; -#[derive(Debug, Clone, PartialEq, Eq)] +// All fields are Copy (ChaKind, ChaMode, u64, Option, u32), so Cha +// is Copy — avoids a Clone per use in hot paths (sorting, filtering, rendering). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Cha { pub kind: ChaKind, pub mode: ChaMode, @@ -272,7 +275,9 @@ impl Cha { /// from mode bits alone — so this is intentionally *not* a bare passthrough. #[inline] pub fn is_dir(&self) -> bool { - self.mode.is_dir() || (self.mode.is_link() && self.kind.dir_target) + // Compute typ() once — previously is_dir() + is_link() each called typ(). + let typ = self.mode.typ(); + typ == ChaType::Dir || (typ == ChaType::Link && self.kind.dir_target) } /// Delegates to [`ChaMode::is_file`] (the canonical type predicate). @@ -451,14 +456,14 @@ mod tests { #[test] fn cha_hits_identity() { let a = Cha::dummy_dir(); - let b = a.clone(); + let b = a; // Copy — Cha is now Copy assert!(a.hits(&b)); } #[test] fn cha_hits_different_mtime() { let a = Cha::dummy_dir(); - let mut b = a.clone(); + let mut b = a; // Copy b.mtime = Some(SystemTime::now()); assert!(!a.hits(&b)); } diff --git a/src/fs/reader.rs b/src/fs/reader.rs index b6bf08d..80cddab 100755 --- a/src/fs/reader.rs +++ b/src/fs/reader.rs @@ -19,7 +19,11 @@ use crate::fs::cha::Cha; #[cfg(unix)] const CACHE_MAX_SIZE: usize = 1024; -const INITIAL_DIR_CAPACITY: usize = 256; +/// Initial capacity for `read_directory`. Most directories have <64 entries; +/// the Vec grows as needed, so this is just an over-allocation vs re-alloc +/// tradeoff. 64 (≈1.5 KiB) avoids wasting memory on small dirs while still +/// preventing early reallocations for typical directories. +const INITIAL_DIR_CAPACITY: usize = 64; pub use crate::app::types::FileEntry; @@ -188,8 +192,10 @@ fn file_name_from_path(path: &Path) -> String { fn build_file_entry(entry: &std::fs::DirEntry) -> io::Result { let path = entry.path(); let file_name = os_str_to_string(&entry.file_name()); - let is_symlink = entry.file_type()?.is_symlink(); let metadata = fs::symlink_metadata(&path)?; + // symlink_metadata already gives us the link type — entry.file_type() + // would be a redundant syscall on filesystems without d_type support. + let is_symlink = metadata.is_symlink(); let target_meta = if is_symlink { fs::metadata(&path).ok() } else { diff --git a/src/ops/archive/tar.rs b/src/ops/archive/tar.rs index 2f21b5d..7bcc1ca 100644 --- a/src/ops/archive/tar.rs +++ b/src/ops/archive/tar.rs @@ -188,6 +188,8 @@ pub fn list_tar(file: File, format: ArchiveFormat) -> Result, let mut entries = Vec::new(); let mut truncated = false; + // Hoist the format string out of the loop — it's constant per archive. + let method = format!("{format:?}").into_boxed_str(); for entry in archive.entries()? { if entries.len() >= MAX_LIST_ENTRIES { truncated = true; @@ -215,7 +217,7 @@ pub fn list_tar(file: File, format: ArchiveFormat) -> Result, .ok() .map(|t| std::time::UNIX_EPOCH + std::time::Duration::from_secs(t)), is_dir: header.entry_type().is_dir(), - method: format!("{format:?}").into_boxed_str(), + method: method.clone(), }); } if truncated { diff --git a/src/ops/archive/zip.rs b/src/ops/archive/zip.rs index 9b9320b..cde0409 100644 --- a/src/ops/archive/zip.rs +++ b/src/ops/archive/zip.rs @@ -318,7 +318,14 @@ fn add_dir_to_zip( )) })? .to_string_lossy() - .replace('\\', "/"); + .into_owned(); + // Single allocation: only replace backslashes if any are present, + // otherwise reuse the lossy string directly. + let name = if name.contains('\\') { + name.replace('\\', "/") + } else { + name + }; // Single symlink_metadata read: skip symlinks (create-side filter) and // reuse the same metadata to distinguish dir vs file, avoiding a second diff --git a/src/ops/file_ops/entry_ops.rs b/src/ops/file_ops/entry_ops.rs index f0ea58c..e32e2d8 100644 --- a/src/ops/file_ops/entry_ops.rs +++ b/src/ops/file_ops/entry_ops.rs @@ -93,8 +93,13 @@ pub fn rename_entry(old: &Path, new_name: &str) -> io::Result<()> { Err(err) if err.kind() == io::ErrorKind::NotFound => None, Err(err) => return Err(err), }; - let same_file = match (fs::symlink_metadata(old), new_meta.as_ref()) { - (Ok(old_meta), Some(new_meta)) => super::common::same_inode(&old_meta, new_meta), + // Only stat `old` when the dest exists (for the same-inode check). + // When dest doesn't exist, the old stat is unnecessary. + let same_file = match (new_meta.as_ref(), new_meta.is_some()) { + (Some(new_meta), true) => match fs::symlink_metadata(old) { + Ok(old_meta) => super::common::same_inode(&old_meta, new_meta), + _ => false, + }, _ => false, }; // TOCTOU: this check + `fs::rename` is non-atomic. On POSIX, rename diff --git a/src/ops/helpers.rs b/src/ops/helpers.rs index 9a20758..0014f33 100644 --- a/src/ops/helpers.rs +++ b/src/ops/helpers.rs @@ -182,7 +182,7 @@ fn dir_size_rec( /// **Blocking:** walks the directory tree synchronously on the caller's thread. /// Must be invoked from `job_runner`, not the event loop. pub(crate) fn dir_size(path: &Path, cancel: Option<&AtomicBool>) -> io::Result { - let mut visited = HashSet::new(); + let mut visited = HashSet::with_capacity(256); seed_visited_dir(path, &mut visited); dir_size_rec(path, 0, &mut visited, cancel) } diff --git a/src/ops/natsort.rs b/src/ops/natsort.rs index 351f06b..118d982 100644 --- a/src/ops/natsort.rs +++ b/src/ops/natsort.rs @@ -23,7 +23,9 @@ pub struct SegData(Box<[u8]>); impl SegData { fn from_slice(s: &[u8]) -> Self { - Self(s.to_vec().into_boxed_slice()) + // Box::from(&[u8]) is a single allocation; the previous + // to_vec().into_boxed_slice() was two (Vec grow + Box). + Self(Box::from(s)) } fn build(s: &[u8], fold_ascii: bool) -> Self { diff --git a/src/ops/search/content.rs b/src/ops/search/content.rs index 2cf9231..3429c7b 100644 --- a/src/ops/search/content.rs +++ b/src/ops/search/content.rs @@ -51,14 +51,12 @@ pub fn search_content( } else { Vec::new() }; - search_in_file( - path, - pattern, - case_sensitive, - &pattern_bytes, - &mut outcome, - cancel, - ); + let finder = memmem::Finder::new(if case_sensitive { + pattern.as_bytes() + } else { + &pattern_bytes + }); + search_in_file(path, pattern, case_sensitive, &finder, &mut outcome, cancel); return outcome; } search_content_recursive( @@ -87,13 +85,19 @@ fn search_content_recursive( } else { Vec::new() }; + // One Finder for the entire recursive scan — previously rebuilt per file. + let finder = memmem::Finder::new(if case_sensitive { + pattern.as_bytes() + } else { + &pattern_bytes + }); let mut visited = HashSet::with_capacity(256); seed_visited_dir(path, &mut visited); let mut ctx = ContentSearchContext { pattern, case_sensitive, - pattern_bytes: &pattern_bytes, + finder: &finder, recursive, outcome, visited: &mut visited, @@ -106,7 +110,42 @@ fn search_content_recursive_inner(path: &Path, depth: usize, ctx: &mut ContentSe if ctx.cancel.load(Ordering::Relaxed) { return; } - if !path.is_dir() { + // The root call already verified this is a directory; deeper calls pass + // file_type from the dirent in process_content_entry, avoiding a redundant + // stat() here. + if depth == 0 && !path.is_dir() { + return; + } + let Some(entries) = prepare_content_dir_scan( + path, + depth, + MAX_SEARCH_DEPTH, + MAX_SEARCH_ITEMS, + MAX_CONTENT_RESULTS, + ctx.outcome, + ) else { + return; + }; + + for entry in entries { + if ctx.cancel.load(Ordering::Relaxed) { + return; + } + if process_content_entry(entry, path, depth, ctx) { + return; + } + } +} + +/// Like `search_content_recursive_inner` but skips the `path.is_dir()` check — +/// the caller (process_content_entry) already confirmed the type from the +/// dirent, so a second stat syscall is unnecessary. +fn search_content_recursive_inner_skip_dir_check( + path: &Path, + depth: usize, + ctx: &mut ContentSearchContext<'_>, +) { + if ctx.cancel.load(Ordering::Relaxed) { return; } let Some(entries) = prepare_content_dir_scan( @@ -190,7 +229,9 @@ fn process_content_entry( if file_type.is_dir() { if ctx.recursive && should_recurse(entry.metadata(), ctx.visited) { - search_content_recursive_inner(&entry_path, depth + 1, ctx); + // file_type from the dirent already confirms this is a directory; + // no need for search_content_recursive_inner to re-stat it. + search_content_recursive_inner_skip_dir_check(&entry_path, depth + 1, ctx); } } else { // `search_in_file` opens with O_NOFOLLOW and validates the type/size from @@ -201,7 +242,7 @@ fn process_content_entry( &entry_path, ctx.pattern, ctx.case_sensitive, - ctx.pattern_bytes, + ctx.finder, ctx.outcome, ctx.cancel, ); @@ -232,7 +273,7 @@ fn search_in_file( path: &Path, pattern: &str, case_sensitive: bool, - pattern_bytes: &[u8], + finder: &memmem::Finder<'_>, outcome: &mut SearchOutcome, cancel: &AtomicBool, ) { @@ -274,15 +315,13 @@ fn search_in_file( return; } - let mut reader = BufReader::with_capacity(MAX_CONTENT_LINE_BYTES as usize, file); + // 8 KiB default — the reader grows its buffer as needed for long lines, so + // the previous 64 KiB allocation was wasteful for small files. + let mut reader = BufReader::with_capacity(8 * 1024, file); let mut ctx = ScanContext { path, case_sensitive, - finder: memmem::Finder::new(if case_sensitive { - pattern.as_bytes() - } else { - pattern_bytes - }), + finder, bufs: ScanBuffers::new(), cancel, }; @@ -292,7 +331,7 @@ fn search_in_file( struct ScanContext<'a> { path: &'a Path, case_sensitive: bool, - finder: memmem::Finder<'a>, + finder: &'a memmem::Finder<'a>, bufs: ScanBuffers, cancel: &'a AtomicBool, } @@ -382,8 +421,8 @@ fn scan_lines( reader: &mut BufReader, outcome: &mut SearchOutcome, ) { - // One Arc per file, shared by every match in it (paths are not re-cloned). - let file_path: Arc = Arc::from(ctx.path); + // Arc allocated lazily — only if the file yields at least one match. + let mut file_path: Option> = None; let mut line_no = 0_usize; let mut non_utf8_lines = 0usize; loop { @@ -425,7 +464,7 @@ fn scan_lines( if process_raw_chunk( ctx, outcome, - &file_path, + &mut file_path, end, &mut line_no, &mut non_utf8_lines, @@ -457,7 +496,7 @@ fn scan_lines( fn process_raw_chunk( ctx: &mut ScanContext<'_>, outcome: &mut SearchOutcome, - file_path: &Arc, + file_path: &mut Option>, end: usize, line_no: &mut usize, non_utf8_lines: &mut usize, @@ -508,7 +547,7 @@ fn process_raw_chunk( fn try_record_line( ctx: &mut ScanContext<'_>, outcome: &mut SearchOutcome, - file_path: &Arc, + file_path: &mut Option>, line_start: usize, line_end: usize, line_no: usize, @@ -526,7 +565,7 @@ fn try_record_line( // Case-sensitive prefilter on raw bytes: skip non-matching lines before // paying for UTF-8 validation. - if ctx.case_sensitive && !line_contains_needle(&ctx.finder, line, None, &mut ctx.bufs.ci_buf) { + if ctx.case_sensitive && !line_contains_needle(ctx.finder, line, None, &mut ctx.bufs.ci_buf) { return false; } @@ -546,14 +585,16 @@ fn try_record_line( }; if !ctx.case_sensitive - && !line_contains_needle(&ctx.finder, line, Some(line_text), &mut ctx.bufs.ci_buf) + && !line_contains_needle(ctx.finder, line, Some(line_text), &mut ctx.bufs.ci_buf) { return false; } + // Allocate the Arc only on the first match in this file. + let arc = file_path.get_or_insert_with(|| Arc::from(ctx.path)); outcome .matches - .push((Arc::clone(file_path), line_no, line_text.to_owned())); + .push((Arc::clone(arc), line_no, line_text.to_owned())); false } diff --git a/src/ops/search/name.rs b/src/ops/search/name.rs index 18446ab..2ec2611 100644 --- a/src/ops/search/name.rs +++ b/src/ops/search/name.rs @@ -89,12 +89,11 @@ fn search_files_recursive( continue; } }; - let entry_path = entry.path(); let file_type = match entry.file_type() { Ok(file_type) => file_type, Err(err) => { ctx.outcome.errors.push(SearchError { - path: Some(entry_path.clone()), + path: Some(entry.path()), kind: SearchErrorKind::FileType, message: err.to_string(), }); @@ -116,10 +115,21 @@ fn search_files_recursive( let plain_dir = recursive && file_type.is_dir() && !file_type.is_symlink(); let dir_meta: Option> = plain_dir.then(|| entry.metadata()); - if matched { + // Whether this entry needs its path allocated (for a match result or + // for recursion). Non-matching, non-recursive entries skip the PathBuf. + let needs_path = matched || dir_meta.is_some() || (recursive && file_type.is_symlink()); + + let entry_path = needs_path.then(|| entry.path()); + + if let Some(entry_path) = &entry_path + && matched + { let built = match &dir_meta { Some(Ok(meta)) => Ok(file_info_from_metadata(entry_path.clone(), meta)), - _ => get_file_info(&entry_path), + // dir_meta Some(Err): the stat already failed; don't retry via + // get_file_info (which would repeat the failed lstat). + Some(Err(e)) => Err(std::io::Error::new(e.kind(), e.to_string())), + None => get_file_info(entry_path), }; match built { Ok(file_entry) => ctx.outcome.matches.push(file_entry), @@ -132,17 +142,20 @@ fn search_files_recursive( } if let Some(meta) = dir_meta { - if should_recurse(meta, ctx.visited) { - search_files_recursive(&entry_path, pattern, recursive, depth + 1, ctx, scratch); + if should_recurse(meta, ctx.visited) + && let Some(ref entry_path) = entry_path + { + search_files_recursive(entry_path, pattern, recursive, depth + 1, ctx, scratch); } } else if recursive && file_type.is_symlink() { // Follow the symlink once; recurse only when the target is a dir // and its inode is new. - if let Ok(meta) = std::fs::metadata(&entry_path) + if let Some(ref entry_path) = entry_path + && let Ok(meta) = std::fs::metadata(entry_path) && meta.is_dir() && should_recurse(Ok(meta), ctx.visited) { - search_files_recursive(&entry_path, pattern, recursive, depth + 1, ctx, scratch); + search_files_recursive(entry_path, pattern, recursive, depth + 1, ctx, scratch); } } } diff --git a/src/ops/search/pattern.rs b/src/ops/search/pattern.rs index c16a84e..d60b826 100644 --- a/src/ops/search/pattern.rs +++ b/src/ops/search/pattern.rs @@ -19,6 +19,16 @@ pub(super) fn contains_case_insensitive( if finder.needle().is_empty() { return true; } + // ASCII fast path: if both haystack and needle are pure ASCII, fold the + // haystack byte-wise — no char iterator, no to_lowercase Unicode expansion. + // This is the common case for source code and log files. + if haystack.is_ascii() && finder.needle().is_ascii() { + buf.clear(); + buf.push_str(haystack); + // ASCII lowercase is a simple byte-wise bit op on the String's buffer. + buf.make_ascii_lowercase(); + return finder.find(buf.as_bytes()).is_some(); + } buf.clear(); buf.extend(haystack.chars().flat_map(char::to_lowercase)); finder.find(buf.as_bytes()).is_some() @@ -288,37 +298,43 @@ impl CompiledPattern { if pattern.contains('?') { return None; } - let star_count = pattern.chars().filter(|&c| c == '*').count(); - if star_count == 1 { - let pos = pattern.find('*')?; - return Some(Self { - kind: PatternKind::WildcardAffix(WildcardAffix::new( - &pattern[..pos], - &pattern[pos + 1..], + // Single pass to find all '*' positions, avoiding the prior count + + // find + rfind triple iteration. + let star_positions: Vec = pattern + .char_indices() + .filter(|&(_, c)| c == '*') + .map(|(i, _)| i) + .collect(); + match star_positions.len() { + 1 => { + let pos = star_positions[0]; + Some(Self { + kind: PatternKind::WildcardAffix(WildcardAffix::new( + &pattern[..pos], + &pattern[pos + 1..], + insensitive, + )), insensitive, - )), - insensitive, - }); - } - if star_count == 2 { - let f = pattern.find('*')?; - let l = pattern.rfind('*')?; - if l <= f { - return None; - } - let inner = &pattern[f + 1..l]; - if inner.is_empty() { - return None; + }) } - // `*inner*` is a pure substring test — represent it as Plain. - if pattern[..f].is_empty() && pattern[l + 1..].is_empty() { - return Some(Self { - kind: PatternKind::Plain(Box::new(Plain::new(inner, insensitive))), - insensitive, - }); + 2 if star_positions[1] > star_positions[0] => { + let f = star_positions[0]; + let l = star_positions[1]; + let inner = &pattern[f + 1..l]; + if inner.is_empty() { + return None; + } + // `*inner*` is a pure substring test — represent it as Plain. + if pattern[..f].is_empty() && pattern[l + 1..].is_empty() { + return Some(Self { + kind: PatternKind::Plain(Box::new(Plain::new(inner, insensitive))), + insensitive, + }); + } + None } + _ => None, } - None } pub fn matches(&self, name: &str) -> bool { diff --git a/src/ops/search/walk.rs b/src/ops/search/walk.rs index 7cca682..15fc3e1 100644 --- a/src/ops/search/walk.rs +++ b/src/ops/search/walk.rs @@ -3,6 +3,8 @@ use std::path::Path; use std::sync::Arc; use std::sync::atomic::AtomicBool; +use memchr::memmem; + use crate::app::types::FileEntry; use crate::ops::helpers::get_inode_key; use crate::ops::search::{SearchError, SearchErrorKind, SearchOutcome, TruncationReason}; @@ -18,7 +20,8 @@ pub(super) struct FileSearchContext<'a> { pub(super) struct ContentSearchContext<'a> { pub(super) pattern: &'a str, pub(super) case_sensitive: bool, - pub(super) pattern_bytes: &'a [u8], + /// Precomputed memmem Finder — built once per recursive scan, not per file. + pub(super) finder: &'a memmem::Finder<'a>, pub(super) recursive: bool, pub(super) outcome: &'a mut SearchOutcome<(Arc, usize, String), SearchError>, pub(super) visited: &'a mut HashSet<(u64, u64)>, @@ -54,8 +57,11 @@ pub(super) fn should_recurse( /// Single source of truth for the per-scan item cap. Records the `ItemLimit` /// truncation and returns whether the cap is reached. Shared by /// `prepare_dir_scan` and the per-entry loops in `name.rs` / `content.rs`. -pub(super) fn item_limit_reached( - outcome: &mut SearchOutcome, +/// +/// Non-generic: the type parameter is unused, so genericizing would produce +/// duplicate monomorphized copies with no benefit. +pub(super) fn item_limit_reached( + outcome: &mut SearchOutcome, max_items: usize, ) -> bool { if outcome.items_scanned >= max_items {