Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 23 additions & 23 deletions src/app/panel_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,27 +36,22 @@ pub fn refresh_panel(panel: &mut PanelState, visible_height: usize) -> Option<St
update_panel_read_errors(panel, &errors);
let current_path = current_panel_entry_path(panel);
let saved = selected_panel_paths(panel);
let new_unfiltered = entries;
let compiled = panel.compiled_filter_cached();
let new_filtered = filter_and_sort(
&new_unfiltered,
compiled.as_ref(),
panel.sort_mode(),
*panel.sort_options(),
panel.show_hidden(),
);
let mut sorted_unfiltered = new_unfiltered;
let mut sorted_unfiltered = entries;
// Single sort of the backing store. The filtered view is derived as
// indices into this already-sorted store, so the previous double-sort
// (once on a cloned filtered Vec, once on the unfiltered store) and
// the per-entry clone of every visible FileEntry are both eliminated.
ops::sort_entries(
&mut sorted_unfiltered,
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.
let compiled = panel.compiled_filter_cached();
let show_hidden = panel.show_hidden();
panel.listing.set_unfiltered(sorted_unfiltered);
panel.listing.set_filtered(&new_filtered);
panel
.listing
.set_filtered_indices(|e| entry_matches_panel(e, compiled.as_ref(), show_hidden));
restore_panel_selection(panel, &saved);
finalize_view(panel, current_path.as_deref(), visible_height);
None
Expand Down Expand Up @@ -150,14 +145,19 @@ fn filter_and_sort(
pub fn rebuild_visible_entries(panel: &mut PanelState, visible_height: usize) {
let current_path = current_panel_entry_path(panel);
let compiled = panel.compiled_filter_cached();
let filtered = filter_and_sort(
panel.listing.unfiltered(),
compiled.as_ref(),
panel.sort_mode(),
*panel.sort_options(),
panel.show_hidden(),
);
panel.listing.set_filtered(&filtered);
let show_hidden = panel.show_hidden();
// Hoof sort params out before the mutable borrow of the listing.
let sort_mode = panel.sort_mode();
let sort_options = *panel.sort_options();
// Re-sort the backing store in place, then rebuild the filtered view as
// indices into the now-sorted store. Avoids cloning every FileEntry.
ops::sort_entries(panel.listing.unfiltered_mut(), sort_mode, sort_options);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Backing sort invalidates path index

When a sort or filter rebuild reorders unfiltered_entries, path_index retains the old slot values because set_filtered_indices only rebuilds the visible view. A subsequent watcher upsert or removal therefore updates or removes the wrong file entry before the next full directory refresh.

// The in-place sort moved entries, so path_index (PathBuf→old index) is
// stale. Rebuild it or the next watcher upsert/remove hits the wrong slot.
panel.listing.rebuild_index();
panel
.listing
.set_filtered_indices(|e| entry_matches_panel(e, compiled.as_ref(), show_hidden));
finalize_view(panel, current_path.as_deref(), visible_height);
}

Expand Down
24 changes: 24 additions & 0 deletions src/app/types/panel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,30 @@ impl PanelListing {
}
}

/// Rebuild the filtered view from the already-sorted backing store using a
/// predicate. Entries that pass the predicate are added to the filtered view
/// in backing-store order (which is already sorted). This avoids cloning
/// every visible `FileEntry` into a throwaway `Vec` only to map it back to
/// an index — the filtered view stores indices, not entry copies.
///
/// The view is now consistent with the store, so a pending `NeedsRebuild`
/// (from `set_unfiltered` or `mark_dirty`) is cleared to `Clean`.
pub fn set_filtered_indices<F>(&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
Expand Down
42 changes: 42 additions & 0 deletions src/app/watcher_sync/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
13 changes: 9 additions & 4 deletions src/fs/cha.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ impl ChaMode {
}

#[inline]
#[cfg(test)]
pub(crate) fn is_dir(&self) -> bool {
self.typ() == ChaType::Dir
}
Expand All @@ -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<SystemTime>, 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,
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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));
}
Expand Down
10 changes: 8 additions & 2 deletions src/fs/reader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -188,8 +192,10 @@ fn file_name_from_path(path: &Path) -> String {
fn build_file_entry(entry: &std::fs::DirEntry) -> io::Result<FileEntry> {
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 {
Expand Down
4 changes: 3 additions & 1 deletion src/ops/archive/tar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,8 @@ pub fn list_tar(file: File, format: ArchiveFormat) -> Result<Vec<ArchiveEntry>,

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;
Expand Down Expand Up @@ -215,7 +217,7 @@ pub fn list_tar(file: File, format: ArchiveFormat) -> Result<Vec<ArchiveEntry>,
.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 {
Expand Down
9 changes: 8 additions & 1 deletion src/ops/archive/zip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions src/ops/file_ops/entry_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Comment on lines +98 to +99

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: The match on (new_meta.as_ref(), new_meta.is_some()) is more complex than needed and slightly obscures intent.

The previous version matched directly on (fs::symlink_metadata(old), new_meta.as_ref()), which was clearer. The new code adds a redundant boolean arm and nests an extra match, making it harder to read.

You can keep the same behavior and “only stat old when dest exists” optimization with a simpler structure:

let same_file = match new_meta.as_ref() {
    Some(new_meta) => match fs::symlink_metadata(old) {
        Ok(old_meta) => super::common::same_inode(&old_meta, new_meta),
        _ => false,
    },
    None => false,
};

This removes the unnecessary boolean and clarifies intent.

Suggested implementation:

    // 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() {
        Some(new_meta) => match fs::symlink_metadata(old) {
            Ok(old_meta) => super::common::same_inode(&old_meta, new_meta),
            _ => false,
        },
        None => false,
    };

No additional changes are required; this preserves the "only stat old when dest exists" behavior while simplifying the control flow and clarifying intent.

Ok(old_meta) => super::common::same_inode(&old_meta, new_meta),
_ => false,
},
_ => false,
};
// TOCTOU: this check + `fs::rename` is non-atomic. On POSIX, rename
Expand Down
2 changes: 1 addition & 1 deletion src/ops/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u64> {
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)
}
Expand Down
4 changes: 3 additions & 1 deletion src/ops/natsort.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading