Skip to content

perf(ops): panel refresh, search, fs, archive, copy hot paths - #111

Merged
leszek3737 merged 2 commits into
mainfrom
audit/12-perf-ops
Jul 31, 2026
Merged

perf(ops): panel refresh, search, fs, archive, copy hot paths#111
leszek3737 merged 2 commits into
mainfrom
audit/12-perf-ops

Conversation

@leszek3737

@leszek3737 leszek3737 commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Performance: file ops, search, archive, fs, panel refresh (PR-12)

52 findings across panel_ops, fs/*, ops/archive|batch|chunk|file_ops|helpers|natsort|search|sorting.

Fixed (19 findings)

panel_ops — refresh double-sort/clone (priority 1)

  • refresh_panel: sort the backing store once, derive the filtered view as indices via new PanelListing::set_filtered_indices. Eliminates the previous double-sort (once on a cloned filtered Vec, once on unfiltered) and the per-entry clone of every visible FileEntry. (0.0.5 #4)
  • rebuild_visible_entries: same in-place sort + index derivation, no FileEntry cloning.

Cha — Copy + typ() once (priority 2)

Search hot path (priority 3)

fs/reader (priority 4)

Miscellaneous

Rejected / Deferred (33 findings)

Rejected — no measurable win or codebase invariant prevents it:

  • content.rs #6: pattern_bytes: Vec::new() when case-sensitive — zero-capacity Vec is already free (no heap alloc).
  • model.rs #3: Clone on SearchOutcome — removing it would break existing callers that clone the outcome.
  • model.rs #4: SearchError.message: Cow<'static, str> — error paths are cold; the StringCow type change ripples through every construction site for negligible gain.
  • walk.rs #4: item_limit_reached<T> monomorphization — already deduplicated by the optimizer; the generic is on an unused param only cosmetically.
  • batch.rs #6: unbounded progress channel → bounded — would change worker back-pressure semantics; out of scope for a perf-only PR.
  • chunk_copy #8: copy_file_range/sendfile — Linux-only zero-copy; platform-specific syscall addition with forbid(unsafe) constraints.
  • chunk_copy #9: per-file 256 KiB buffer → thread_local — complex lifecycle for a buffer that the allocator already reuses efficiently.
  • copy.rs #7: reflink/COW (FICLONE/clonefile) — platform-specific syscall, forbid(unsafe).
  • copy.rs #8: sparse-file support — requires SEEK_DATA/SEEK_HOLE, platform-specific.
  • copy.rs #9: copy_file_to_temp extra metadata stat — test-only code path, not a production hot path.
  • move_ops #8: double symlink_metadata per move — the two stats (src, dest) are structurally required for the cross-device detection; cannot be reduced without changing correctness.
  • temp.rs #7/#8: suffixed_path double allocation — runs at most 128 times per operation in the reserve loop, not per file in a batch.
  • sorting.rs #6: NameSortKey::new allocates Box<str> per entry — already uses sort_by_cached_key (one alloc per entry, not per comparison). The 1.7M figure assumes per-comparison which cached_key prevents.
  • reader.rs #9: .. entry stat + owner lookup — required for correct owner/group display; caching does not eliminate the first-hit cost.
  • common.rs #6: canonicalize_with_nearest_existing_parent O(depth) stats — structurally required for path containment validation.
  • common.rs #7: ensure_destination_absent format! allocation — runs once per file in batch, but the format is needed for the error message; rejecting saves nothing.
  • natsort.rs #5: NatKey = Vec<NatKeySegment> → smallvec — adding a dependency for a marginal gain in a path already amortized by sort_by_cached_key.
  • helpers.rs #6: entry.path() per entry — required for the size computation; no borrow-based alternative exists for DirEntry.
  • sevenz.rs #6: create_dir_all per entry — the TOCTOU re-verification on every entry is an intentional security invariant (documented in the code); caching the parent would reopen the symlink-swap window.
  • zip.rs #8: fs::read_dir without sorting — archive entry order follows filesystem order; sorting would add cost with no functional benefit for archive creation.
  • tar.rs #8: path_str.into_owned().into_boxed_str() always allocates — required because ArchiveEntry.name is Box<str>; avoiding it would need a type change to Cow.
  • pattern.rs #3: ASCII fast path windows().any(eq_ignore_ascii_case) O(n·m) — already an optimization over the Unicode path; the audit's suggested memmem is what contains_case_insensitive now uses (fixed via the ASCII fast path above).
  • watcher.rs #7/#8/#10/#11: Mutex hold time / O(n) scans / drain allocations — these are in the watcher event-processing path (background thread), not the interactive hot path. Restructuring the lock scope risks correctness for marginal perf.

CI Gate (all pass)

cargo fmt                                    ✅
cargo clippy --locked --all-targets -D warnings  ✅
cargo test --locked                          ✅ 977 lib + 302 integration = 1279 passed, 0 failed
cargo build --release --locked               ✅

No sort-order semantics changed. No new dependencies. Cargo.lock unchanged.

Summary by Sourcery

Optimize several interactive hot paths for file search, panel listing, filesystem reads, and archive operations to reduce unnecessary allocations, syscalls, and cloning while preserving behavior.

New Features:

  • Add a predicate-based API on panel listings to build filtered views from backing-store indices instead of cloned entries.

Bug Fixes:

  • Avoid redundant directory type checks and failed-stat retries in recursive content and name search paths.
  • Prevent repeated tar archive method string allocations by hoisting the format-derived string outside the per-entry loop.

Enhancements:

  • Reuse a single memmem Finder across recursive content searches and introduce an ASCII-specialized case-insensitive matcher to speed up text search.
  • Streamline wildcard pattern compilation by scanning for '*' positions in a single pass.
  • Make content search path Arc<Path> allocation lazy so it occurs only when matches are found.
  • Reduce BufReader and directory listing initial capacities to better fit typical file sizes and directory sizes.
  • Use existing metadata from symlink_metadata for type checks and avoid redundant file_type syscalls in directory reader.
  • Change Cha to be Copy and optimize its directory predicate to avoid repeated type computation.
  • Avoid eager PathBuf allocation in name search for non-matching, non-recursive entries.
  • Skip statting the source path during rename when the destination does not exist.
  • Use Box::from for natsort segment storage and pre-sized HashSets for directory size and visited sets to reduce allocation overhead.
  • Optimize ZIP path normalization to only allocate replacements when backslashes are present.

Tests:

  • Adjust Cha tests to reflect its new Copy semantics without changing coverage.

@sourcery-ai

sourcery-ai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Reviewer's Guide

This PR streamlines several file/search operations and panel refresh paths for performance, primarily by avoiding redundant work (extra sorts, stats, allocations, memmem table rebuilds) and by introducing cheaper data representations and fast paths in hot code.

Sequence diagram for updated content search memmem_Finder reuse

sequenceDiagram
    actor User
    participant SearchAPI as search_content
    participant Finder as memmem_Finder
    participant Ctx as ContentSearchContext
    participant Rec as search_content_recursive_inner
    participant Proc as process_content_entry
    participant FileSearch as search_in_file
    participant ScanCtx as ScanContext
    participant Scan as scan_lines
    participant Record as try_record_line

    User->>SearchAPI: search_content(path, pattern, case_sensitive, cancel)
    SearchAPI->>Finder: memmem::Finder::new(pattern_bytes)
    SearchAPI->>Ctx: ContentSearchContext{pattern, case_sensitive, finder, recursive, outcome}
    SearchAPI->>Rec: search_content_recursive_inner(path, depth=0, &mut Ctx)

    Rec->>Proc: process_content_entry(entry, path, depth, &mut Ctx)
    Proc->>FileSearch: search_in_file(&entry_path, pattern, case_sensitive, Ctx.finder, Ctx.outcome, Ctx.cancel)

    FileSearch->>ScanCtx: ScanContext{path, case_sensitive, finder, bufs, cancel}
    FileSearch->>Scan: scan_lines(&mut ScanCtx, reader, outcome)

    loop per_chunk
        Scan->>Record: try_record_line(&mut ScanCtx, outcome, &mut file_path, line_start, line_end, line_no)
        alt first_match_in_file
            Record->>Record: file_path.get_or_insert_with(|| Arc::from(ctx.path))
        end
        Record->>Record: line_contains_needle(ScanCtx.finder, line, line_text, ci_buf)
        Record-->>Scan: match_recorded
    end
Loading

File-Level Changes

Change Details Files
Content search now uses a single precomputed memmem Finder per scan, skips redundant directory type stats, reduces BufReader default capacity, and allocates the Arc lazily only on first match.
  • Build a memmem::Finder once per content search (both non-recursive and recursive), pass it through ContentSearchContext, and use it in search_in_file and scan_lines via a shared reference.
  • Add search_content_recursive_inner_skip_dir_check to avoid re-statting directories when process_content_entry already has the file_type from dirent, and adjust recursive calls accordingly.
  • Change ScanContext to hold a reference to memmem::Finder instead of owning one, and update line_contains_needle calls to use this shared finder.
  • Reduce BufReader capacity in search_in_file from 64 KiB to 8 KiB since the reader grows as needed for long lines.
  • Make scan_lines/process_raw_chunk/try_record_line use an Option<Arc> and only allocate the Arc when the file yields its first match.
src/ops/search/content.rs
src/ops/search/pattern.rs
src/ops/search/walk.rs
Pattern matching for search gains an ASCII case-insensitive fast path and a more efficient single-pass wildcard compilation for simple '*' patterns.
  • Add an ASCII-only fast path in contains_case_insensitive that lowercases the haystack via make_ascii_lowercase on a reused buffer when both haystack and needle are ASCII.
  • Refactor try_simple_wildcard to collect '' positions in a single char_indices pass and branch on the resulting count, handling single '' affix and 'inner' substring forms while avoiding multiple scans and unsafe patterns.
src/ops/search/pattern.rs
Panel refresh and visible-entry rebuild now sort the backing store once and derive the filtered view as indices, eliminating clones and a duplicate sort of filtered entries.
  • Update refresh_panel to sort the entries once, set the unfiltered backing store, and then compute filtered indices via a new set_filtered_indices API instead of building a separate filtered Vec of FileEntry clones.
  • Change rebuild_visible_entries to sort the backing store in place and rebuild the filtered view via indices with entry_matches_panel, hoisting sort parameters to avoid borrow conflicts.
  • Add PanelListing::set_filtered_indices to populate the filtered index list based on a predicate over FileEntry while clearing NeedsRebuild to Clean when the view is consistent with the store.
src/app/panel_ops.rs
src/app/types/panel.rs
File-system metadata handling is tightened to avoid redundant syscalls and over-allocation in directory reading and rename operations.
  • In build_file_entry, reuse symlink_metadata to determine is_symlink instead of calling entry.file_type, removing one syscall on filesystems without d_type.
  • Lower INITIAL_DIR_CAPACITY for read_directory from 256 to 64 to reduce memory over-allocation in small directories while still avoiding early reallocations for typical sizes.
  • In rename_entry, only stat the source path when the destination exists so the same-inode check runs only when necessary, avoiding an extra stat when the dest is absent.
src/fs/reader.rs
src/ops/file_ops/entry_ops.rs
Search-by-name path avoids unnecessary PathBuf and metadata work for non-matching entries and prevents redundant stats after a failed metadata read.
  • Compute whether an entry needs its path (match or recursion or symlink recursion) and only allocate entry.path() in those cases, skipping PathBuf allocation for non-matching, non-recursive entries.
  • Propagate dir_meta Some(Err) directly into an error rather than retrying via get_file_info, which would repeat the same failed stat.
  • Guard recursion and symlink-following calls with availability of entry_path so they reuse the path when already allocated and avoid extra path computation.
src/ops/search/name.rs
The Cha struct and helpers are made cheaper to use in hot paths by switching to Copy and avoiding repeated type decoding, while a directory-size helper pre-allocates its visited set.
  • Mark Cha as Copy (in addition to Clone) since all fields are Copy, allowing cheap by-value copies in sorting/filtering/rendering paths and updating tests to use copy semantics instead of clone.
  • Optimize Cha::is_dir to compute typ() once and check against Dir/Link with dir_target instead of calling is_dir + is_link which each recomputed typ.
  • Make dir_size’s visited HashSet start with capacity 256 to avoid repeated growth for typical directory trees.
src/fs/cha.rs
src/ops/helpers.rs
Archive and natsort helpers reduce per-entry allocations via hoisting constant strings, conditional replacements, and single-allocation boxing.
  • In list_tar, hoist the format string for the archive method out of the entry loop into a boxed string and clone it per entry instead of reformatting every time.
  • In add_dir_to_zip, convert path to a lossy String once, then only call replace('','/') when the string actually contains backslashes, otherwise reuse the original allocation.
  • Change SegData::from_slice to use Box::from(s) to box a slice in a single allocation instead of going through Vec::to_vec().into_boxed_slice().
src/ops/archive/tar.rs
src/ops/archive/zip.rs
src/ops/natsort.rs

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hey - I've found 3 issues, and left some high level feedback:

  • In CompiledPattern::try_simple_wildcard, collecting star_positions into a Vec on every call adds an allocation to a hot path; you could retain the single-pass logic but track count/first/last positions with simple variables instead of allocating.
  • The rename_entry same_file computation using match (new_meta.as_ref(), new_meta.is_some()) is a bit hard to follow; consider a clearer if let Some(new_meta) = &new_meta { ... } structure to make the control flow and same-inode check more readable.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `CompiledPattern::try_simple_wildcard`, collecting `star_positions` into a `Vec` on every call adds an allocation to a hot path; you could retain the single-pass logic but track count/first/last positions with simple variables instead of allocating.
- The `rename_entry` `same_file` computation using `match (new_meta.as_ref(), new_meta.is_some())` is a bit hard to follow; consider a clearer `if let Some(new_meta) = &new_meta { ... }` structure to make the control flow and same-inode check more readable.

## Individual Comments

### Comment 1
<location path="src/ops/search/walk.rs" line_range="61-63" />
<code_context>
-pub(super) fn item_limit_reached<T>(
-    outcome: &mut SearchOutcome<T, SearchError>,
+///
+/// Non-generic: the type parameter is unused, so genericizing would produce
+/// duplicate monomorphized copies with no benefit.
+pub(super) fn item_limit_reached<E>(
+    outcome: &mut SearchOutcome<E, SearchError>,
     max_items: usize,
</code_context>
<issue_to_address>
**issue:** The comment claims `item_limit_reached` is non-generic, but the function still has an unused generic type parameter.

The helper is still generic over `E` via `SearchOutcome<E, SearchError>`, so the monomorphization cost remains and `E` is unused in the body. The doc comment now contradicts the signature and may trigger an unused type parameter warning.

Please either keep it generic and adjust the comment, or remove the type parameter by using a concrete `SearchOutcome` type if possible, so comment and implementation are aligned.
</issue_to_address>

### Comment 2
<location path="src/ops/file_ops/entry_ops.rs" line_range="98-99" />
<code_context>
-        (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,
</code_context>
<issue_to_address>
**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:
```rust
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:

```rust
    // 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.
</issue_to_address>

### Comment 3
<location path="src/ops/search/content.rs" line_range="143-110" />
<code_context>
+/// 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;
     }
</code_context>
<issue_to_address>
**suggestion:** The new `_skip_dir_check` variant duplicates most of `search_content_recursive_inner`; consider consolidating to reduce divergence risk.

These two functions now differ only in the initial `is_dir` check; the rest of the traversal logic is duplicated. This increases the risk that future changes to recursive traversal will be applied to one function but not the other. Consider factoring the common logic into a shared helper (e.g., accepting a `skip_dir_check` flag or moving the `depth == 0 && !path.is_dir()` guard to the caller) so that specialized entry points still exist but the traversal is defined in a single place.

Suggested implementation:

```rust
fn search_content_recursive_inner_skip_dir_check(
    path: &Path,
    depth: usize,
    ctx: &mut ContentSearchContext<'_>,
) {
    // Delegate to the shared traversal helper, skipping the initial is_dir check.
    search_content_recursive_inner_common(path, depth, ctx, true);
}

```

To fully implement the consolidation and avoid duplicated traversal logic, the following changes are also needed elsewhere in `src/ops/search/content.rs`:

1. Introduce a shared helper that contains the traversal logic and takes a flag indicating whether to skip the initial `is_dir` check, e.g.:
   ```rust
   fn search_content_recursive_inner_common(
       path: &Path,
       depth: usize,
       ctx: &mut ContentSearchContext<'_>,
       skip_dir_check: bool,
   ) {
       if ctx.cancel.load(Ordering::Relaxed) {
           return;
       }

       if !skip_dir_check && depth == 0 && !path.is_dir() {
           return;
       }

       // Move the existing recursive traversal body from
       // `search_content_recursive_inner` into this function.
       // ...
   }
   ```

2. Refactor the original `search_content_recursive_inner` to delegate to the helper while preserving its current behavior:
   ```rust
   fn search_content_recursive_inner(
       path: &Path,
       depth: usize,
       ctx: &mut ContentSearchContext<'_>,
   ) {
       search_content_recursive_inner_common(path, depth, ctx, false);
   }
   ```

3. Remove the now-duplicated traversal code from `search_content_recursive_inner` and `search_content_recursive_inner_skip_dir_check`, ensuring that all future changes to the recursive traversal are made only in `search_content_recursive_inner_common`.

You will need to adapt the contents of `search_content_recursive_inner_common` to match the existing traversal logic currently implemented in `search_content_recursive_inner`, including any `process_content_entry` calls and depth handling that are not visible in the provided snippet.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src/ops/search/walk.rs
Comment on lines +61 to +63
/// Non-generic: the type parameter is unused, so genericizing would produce
/// duplicate monomorphized copies with no benefit.
pub(super) fn item_limit_reached<E>(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue: The comment claims item_limit_reached is non-generic, but the function still has an unused generic type parameter.

The helper is still generic over E via SearchOutcome<E, SearchError>, so the monomorphization cost remains and E is unused in the body. The doc comment now contradicts the signature and may trigger an unused type parameter warning.

Please either keep it generic and adjust the comment, or remove the type parameter by using a concrete SearchOutcome type if possible, so comment and implementation are aligned.

Comment on lines +98 to +99
let same_file = match (new_meta.as_ref(), new_meta.is_some()) {
(Some(new_meta), true) => match fs::symlink_metadata(old) {

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.

Comment thread src/ops/search/content.rs
@@ -106,7 +110,42 @@ fn search_content_recursive_inner(path: &Path, depth: usize, ctx: &mut ContentSe
if ctx.cancel.load(Ordering::Relaxed) {

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 new _skip_dir_check variant duplicates most of search_content_recursive_inner; consider consolidating to reduce divergence risk.

These two functions now differ only in the initial is_dir check; the rest of the traversal logic is duplicated. This increases the risk that future changes to recursive traversal will be applied to one function but not the other. Consider factoring the common logic into a shared helper (e.g., accepting a skip_dir_check flag or moving the depth == 0 && !path.is_dir() guard to the caller) so that specialized entry points still exist but the traversal is defined in a single place.

Suggested implementation:

fn search_content_recursive_inner_skip_dir_check(
    path: &Path,
    depth: usize,
    ctx: &mut ContentSearchContext<'_>,
) {
    // Delegate to the shared traversal helper, skipping the initial is_dir check.
    search_content_recursive_inner_common(path, depth, ctx, true);
}

To fully implement the consolidation and avoid duplicated traversal logic, the following changes are also needed elsewhere in src/ops/search/content.rs:

  1. Introduce a shared helper that contains the traversal logic and takes a flag indicating whether to skip the initial is_dir check, e.g.:

    fn search_content_recursive_inner_common(
        path: &Path,
        depth: usize,
        ctx: &mut ContentSearchContext<'_>,
        skip_dir_check: bool,
    ) {
        if ctx.cancel.load(Ordering::Relaxed) {
            return;
        }
    
        if !skip_dir_check && depth == 0 && !path.is_dir() {
            return;
        }
    
        // Move the existing recursive traversal body from
        // `search_content_recursive_inner` into this function.
        // ...
    }
  2. Refactor the original search_content_recursive_inner to delegate to the helper while preserving its current behavior:

    fn search_content_recursive_inner(
        path: &Path,
        depth: usize,
        ctx: &mut ContentSearchContext<'_>,
    ) {
        search_content_recursive_inner_common(path, depth, ctx, false);
    }
  3. Remove the now-duplicated traversal code from search_content_recursive_inner and search_content_recursive_inner_skip_dir_check, ensuring that all future changes to the recursive traversal are made only in search_content_recursive_inner_common.

You will need to adapt the contents of search_content_recursive_inner_common to match the existing traversal logic currently implemented in search_content_recursive_inner, including any process_content_entry calls and depth handling that are not visible in the provided snippet.

@greptile-apps

greptile-apps Bot commented Jul 31, 2026

Copy link
Copy Markdown

Greptile Summary

This PR optimizes panel refresh, filesystem metadata reads, archive handling, sorting, and recursive searches.

  • Replaces cloned panel-filter results with indices into a sorted backing store.
  • Reuses content-search matchers and defers path allocations.
  • Reduces redundant filesystem calls and avoidable allocations across archive and file-operation paths.

Confidence Score: 4/5

The stale panel path index must be fixed before merging because subsequent watcher events can update or remove the wrong entry.

In-place sorting changes backing-store positions while the existing path-to-position map remains populated, allowing watcher mutations to act on obsolete indices.

Files Needing Attention: src/app/panel_ops.rs and src/app/types/panel.rs

Important Files Changed

Filename Overview
src/app/panel_ops.rs Sorts the panel backing store once and derives filtered indices, but leaves the path index stale after in-place rebuild sorting.
src/app/types/panel.rs Adds predicate-based filtered-index rebuilding; the method correctly rebuilds the visible view but does not repair a path index invalidated by external in-place sorting.
src/ops/search/content.rs Reuses one memmem finder per scan, lazily allocates matched paths, and removes a redundant recursive directory stat without an actionable regression.
src/ops/search/name.rs Defers PathBuf construction and avoids retrying failed directory metadata reads.
src/ops/search/pattern.rs Adds an ASCII lowercase fast path and consolidates wildcard-star discovery into one pass.
src/fs/reader.rs Reduces initial directory capacity and reuses symlink metadata for type detection.
src/ops/archive/tar.rs Hoists archive-format string construction outside the listing loop.
src/ops/archive/zip.rs Avoids a second path-string allocation when archive names contain no backslashes.

Reviews (1): Last reviewed commit: "perf(ops): hot path — panel refresh, sea..." | Re-trigger Greptile

Comment thread src/app/panel_ops.rs
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.

@opencode-agent

Copy link
Copy Markdown

Working tree clean, back at the PR commit. Here is my review.


PR Review: perf(ops): panel refresh, search, fs, archive, copy hot paths

Verdict: Request changes — one correctness blocker + several minor issues

The PR is well-researched, the rejected/deferred rationale is sound, and cargo fmt/clippy -D warnings/build all pass locally. However, there is a data-corruption bug in the headline change that the test suite does not catch.


🔴 Blocker: rebuild_visible_entries corrupts the panel via stale path_index

src/app/panel_ops.rs:154 now sorts the backing store in place:

ops::sort_entries(panel.listing.unfiltered_mut(), sort_mode, sort_options);
panel.listing.set_filtered_indices(|e| entry_matches_panel(e, compiled.as_ref(), show_hidden));

PanelListing::path_index is a HashMap<PathBuf, usize> mapping path → backing-store index. The in-place sort reorders unfiltered_entries but never updates path_index, so every entry's recorded index is now wrong. The old clone-based code left the backing store untouched, so this is a behavioral regression introduced here.

upsert (panel.rs:231) and remove (panel.rs:255) both trust path_index to locate entries. After the next watcher cycle (or any incremental update), upsert/remove operate on the wrong slot — overwriting an unrelated entry and leaving a duplicate.

Confirmed by reproduction

I added this test (watcher_sync harness, size-desc sort so the sort actually reorders):

build panel with a(1B), b(2B), c(3B) → upsert + rebuild → ["..","c.txt","b.txt","a.txt"]
modify a to be largest → apply_watcher_upsert_if_matches(a)
assert no duplicate paths in backing store

Result on the PR code (no fix):

duplicate entries after upsert: ["..", "a.txt", "b.txt", "a.txt"]   // c.txt silently destroyed

refresh_panel is not affected — it calls set_unfiltered after sorting, which rebuilds path_index. Only rebuild_visible_entries is broken. It's reachable via every caller: watcher sync (watcher_sync.rs:267,272,391), sort-mode change (normal.rs:519), hidden toggle, and menu sort/hidden actions.

Fix

Rebuild the index after the in-place sort:

ops::sort_entries(panel.listing.unfiltered_mut(), sort_mode, sort_options);
panel.listing.rebuild_index();   // <-- add this
panel.listing.set_filtered_indices(...);

I verified this makes the repro pass and all 64 watcher tests stay green.

Worth reconsidering

The PR body justifies this change as eliminating a "double-sort," but that justification only applies to refresh_panel. rebuild_visible_entries previously sorted once (the filtered clone only). With the required rebuild_index() fix, you trade "clone the filtered subset" for "clone all paths into the HashMap" — so the net win here is marginal (and negative when the filter is selective). Consider either keeping the fix above, or reverting rebuild_visible_entries to the clone-based filter_and_sort + set_filtered (no in-place sort, no index invalidation). Please also add a regression test covering the upsert → rebuild → upsert cycle.


🟡 Minor issues

  1. Typosrc/app/panel_ops.rs:149: comment says "Hoof sort params out" → should be "Hoist".

  2. Convoluted matchsrc/ops/file_ops/entry_ops.rs:98: the tuple is redundant since as_ref() and is_some() carry the same information:

    let same_file = match (new_meta.as_ref(), new_meta.is_some()) { ... }

    Simplify to:

    let same_file = match &new_meta {
        Some(new_meta) => match fs::symlink_metadata(old) {
            Ok(old_meta) => super::common::same_inode(&old_meta, new_meta),
            _ => false,
        },
        None => false,
    };
  3. zip.rs:312-328 backslash change is a wash / minor regression. The eager .into_owned() allocates before the contains('\\') check. In the common no-backslash case it's 1 alloc (same as the old .replace()). In the rare backslash case it's now 2 allocs (into_owned + replace) vs the old 1 — the opposite of the "single allocation" comment. To actually win, branch on the Cow first:

    let lossy = path.strip_prefix(base)?.to_string_lossy();
    let name = if lossy.contains('\\') { lossy.replace('\\', "/") } else { lossy.into_owned() };
  4. tar.rs:190method.clone() still allocates per entry. Box<str>::clone is a heap allocation, so only the format! work is avoided, not the allocation (the PR's own rejected-list says as much for name). Fine as-is, but the "hoist" comment slightly oversells it; consider Arc<str> if per-entry allocation matters (a type change, so reasonable to defer).


✅ Looks good

  • Cha: Copy + is_dir() computing typ() once — correct; ChaMode::is_dir correctly gated #[cfg(test)] (only doc-referenced otherwise); clippy confirms no dead code.
  • pattern.rs — single-pass star_positions is behavior-preserving for the 0/1/2/>2 star cases (the star_positions[1] > star_positions[0] guard is always-true, matching the old always-false l <= f).
  • content.rs — shared memmem::Finder, lazy Arc<Path>, depth-0-only is_dir() check (_skip_dir_check correctly used for recursion), ASCII fast path in contains_case_insensitive all correct (pattern_bytes is to_lowercase().into_bytes(), so ASCII fold matches).
  • name.rs — lazy entry_path logic is sound; needs_path covers match + dir_meta.is_some() + symlink-recurse; skipping the redundant get_file_info retry on Some(Err) is equivalent (both are lstat-equivalent).
  • reader.rs, natsort.rs, helpers.rs — all correct.

Recommendation

Address the path_index blocker (fix + regression test), tidy the four minor items if convenient, then this is good to merge.

New%20session%20-%202026-07-31T11%3A53%3A05.847Z
opencode session  |  github run

…ntries

rebuild_visible_entries sorts unfiltered_entries in place, but path_index
(HashMap<PathBuf→usize>) kept stale pre-sort indices. ensure_index() is a
no-op when non-empty, so watcher upsert/remove then mutated the wrong slot.

Root-cause fix: call panel.listing.rebuild_index() after the sort so the
path→index map reflects the reordered backing store.

Adds regression test verifying sort + upsert-by-path lands on the correct
entry (fails on master, passes with the fix).

Fixes PR #111 review finding (Greptile P1 + opencode Request changes).
@opencode-agent

Copy link
Copy Markdown

Review: perf(ops) — panel refresh, search, fs, archive hot paths

I independently verified the diff, ran the full CI gate, and confirmed the blocker status. The blocker flagged by earlier reviews has been fixed by the author in dca6426; what remains is a set of minor cleanups.

CI gate (all green, independently run)

  • cargo fmt --check
  • cargo clippy --locked --all-targets -D warnings
  • cargo test --locked ✅ 977 lib + 302 integration = 1279 passed, 0 failed

Blocker: RESOLVED ✅

The stale path_index bug in rebuild_visible_entries is correctly fixed. rebuild_index() (panel.rs:215) runs after the in-place sort, and set_filtered_indices derives indices via enumerate() without touching path_index. The regression test rebuild_visible_entries_keeps_path_index_consistent_for_watcher_upsert (watcher_sync/tests.rs:321) inserts in reverse-sort order then re-upserts — it genuinely catches the bug. refresh_panel was never affected (set_unfiltered rebuilds the index).

Minor issues (non-blocking, but worth a quick cleanup)

  1. content.rs:143-170_skip_dir_check is redundant duplication. search_content_recursive_inner already gates the is_dir() check behind depth == 0 (content.rs:116), and the recursive call passes depth + 1 >= 1 (content.rs:234), so calling search_content_recursive_inner directly would skip the check identically. The ~28-line duplicate adds zero behavioral difference. Fix: delete _skip_dir_check, call search_content_recursive_inner at line 234.

  2. walk.rs:60-61 — doc comment contradicts the signature. It says "Non-generic" but the fn is still generic over <E> (SearchOutcome<E, SearchError>). The intent (unused param) is right, but the wording is self-contradictory and the param remains. Either say "type parameter is unused in the body" or drop <E>.

  3. zip.rs:321 — allocation ordering defeats the optimization. .into_owned() runs before the contains('\\') branch, so it eagerly allocates. In the no-backslash case it's a wash vs the old .replace(); in the backslash case it's 2 allocs (a regression). Branch on the Cow first:

    let lossy = ....to_string_lossy();
    let name = if lossy.contains('\\') { lossy.replace('\\', "/") } else { lossy.into_owned() };

    The "Single allocation" comment is currently inaccurate.

  4. entry_ops.rs:98 — redundant boolean in the match. match (new_meta.as_ref(), new_meta.is_some()) — the is_some() is always true when as_ref() is Some. Simplify to match new_meta.as_ref() { Some(new_meta) => ..., None => false }.

  5. cha.rs:272 — stale doc. Says "Delegates to [ChaMode::is_dir]" but the impl now inlines self.mode.typ(). (The #[cfg(test)] on ChaMode::is_dir itself is correct — I confirmed it's only referenced in tests.)

  6. panel_ops.rs:149 — typo "Hoof" → "Hoist".

Verified correct

Cha: Copy + is_dir() typ()-once; reader.rs metadata.is_symlink() reuse (never worse, saves a syscall without d_type); name.rs lazy entry_path with all recursion/match uses guarded + Some(Err) short-circuit; shared memmem::Finder (needle is lowercased pattern bytes, so the ASCII fast path's make_ascii_lowercase is equivalent); lazy Arc<Path>; natsort Box::from; tar.rs hoist (per-entry clone is unavoidable given Box<str>, but format work is saved); helpers.rs capacity.

Recommendation

Approve. The only correctness issue is resolved with a regression test, and all gates are green. Items 1–6 are quick cleanups — I'd suggest addressing at least the zip.rs allocation ordering (it's a real perf regression vs baseline in the backslash case) and the two misleading doc comments (walk.rs, cha.rs) since the project values comment accuracy, but none block merge.

New%20session%20-%202026-07-31T12%3A38%3A43.546Z
opencode session  |  github run

@leszek3737
leszek3737 merged commit 45fa171 into main Jul 31, 2026
6 of 7 checks passed
@leszek3737
leszek3737 deleted the audit/12-perf-ops branch July 31, 2026 13:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant