Skip to content

perf(ui): panels, viewer, dialogs render path - #110

Merged
leszek3737 merged 3 commits into
mainfrom
audit/13-perf-ui
Jul 31, 2026
Merged

perf(ui): panels, viewer, dialogs render path#110
leszek3737 merged 3 commits into
mainfrom
audit/13-perf-ui

Conversation

@leszek3737

@leszek3737 leszek3737 commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Audit PR-13: Performance — viewer, panels, dialogs, theme, render path

35 findings. 21 fixed, 14 rejected (justification below).

Fixed

File Finding Fix
panels/mod.rs #2 with_capacity(content_width+8) undersizes for CJK Sized at content_width * 4 + 8
panels/mod.rs #4 dirs::home_dir() every frame Cached thread-locally via OnceCell
panels/mod.rs #5 display_permissions_raw allocates per visible row ChaMode written directly into buffer via write!; width is constant 9
panels/mod.rs #7 format_size(entry.size()) duplicates cached size_str Reuse entry.size_str field
panels/mod.rs #8 format_size(panel.selected_size()) per frame write_size formats inline without String alloc
panels/mod.rs #9 scratch.clone() every status-bar render Separate summary and meta buffers, no clone
panels/mod.rs #10 skip(start_idx) walks from zero each frame Indexed filtered_get(i) in start_idx..end_idx range
viewer/open.rs #7 "<DIR>".to_string() per dir entry Cow::Borrowed("<DIR>")
viewer/open.rs #8 Vec::with_capacity up to 100MB Start at READ_CHUNK, grow naturally
viewer/toggle.rs #4 Three separate borrow_mut() Grouped into single scope
viewer/toggle.rs #5 compute_text_metrics O(n) on every toggle Guard on line_offsets.is_empty() — computed once, reused
viewer/search.rs #2 with_capacity(slice.len()) undersizes for U+FFFD Reserve slice.len() * 3
viewer/hex.rs #2 push_byte_hex 3x buf.push() per byte Stack array + single push_str
input/normal.rs #7 prev_path.clone() on Alt+Backspace Read path back from panel after set_path (no clone)
input/menu_actions.rs #1 Hotlist path re-queried in .any() Bound once before scan
dialogs/archive.rs #3 INPUT_BUF doesn't save allocations Removed — render window.text directly
dialogs/archive.rs #4 Vec::with_capacity(buttons.len()) under-reserves buttons.len() * 2 for 2n-1 spans
dialogs/input.rs #2 SegmentCache shared by all fields Documented single-active-field assumption
dialogs/list_picker.rs #3 dialog_with_colors computed twice Hoisted to let
render.rs #5 icon_theme() computed on Viewing path Moved after early-return paths
theme.rs #3 to_ascii_lowercase() allocates String eq_ignore_ascii_case

Rejected

File Finding Reason
panels/mod.rs #3 Width recompute of status bar out One UnicodeWidthStr::width call on a short string; accumulation adds complexity for negligible gain
panels/mod.rs #6 format!(" Error: {err}") per frame Only fires when listing is empty AND error present — rare transient state, not hot path
render_dialog_map.rs #2 ArchiveExtract per-frame format! Composed from PathBuf display + entry count; correct caching requires persistent state, violating the pure-render contract. Dialog is transient.
dialogs/mod.rs #3 DialogKind derives Clone UI DialogKind is never cloned on the render path — constructed fresh each frame. Removing the derive risks breakage for zero benefit.
input/normal.rs #8 Help build_help_message().to_string() per F1 build_help_message already OnceLock-caches as &'static str. The .to_string() is one alloc per keypress, not per frame. Changing DialogKind::Help to store &'static str churns the type for negligible gain.
mode_dispatch.rs #2 dispatch_viewer_key match per keypress Single O(1) match on Option per keypress — structural dispatch, not hot loop.
viewer/loader.rs #3 20ms sleep-poll loop for child wait Background image-preview thread, not UI event loop. Fixing adds wait-timeout crate (new dep) for a non-UI-blocking path.
viewer/tests.rs #4 Busy-wait spin with yield_now Test-only code. Comment already notes acceptable for short-lived test helper.
viewer/open.rs #9 Three full passes over raw bytes Open path (once per file), not per-frame. Merging passes requires significant refactoring of compute_line_offsets + compute_max_line_width + UTF-8 validation.
viewer/open.rs #10 from_utf8 full-buffer scan for bool Open path, not per-frame. NUL scan in should_open_as_text only covers first NUL_BYTE_SCAN_LIMIT bytes; full UTF-8 check still needed for has_invalid_utf8 warning.
menu.rs #2 dropdown_item_max_widths OnceLock Already cached via OnceLock, computed once. Audit marks "acceptable".
menu.rs #3 render_padded_text 3x set_stringn Three direct buffer writes avoid a String allocation — the zero-alloc approach. Pre-padding in caller trades buffer writes for heap allocs.
panels/tests.rs #3 entry_line allocates unused suffix suffix is a required parameter of format_entry_line (scratch buffer). Added clarifying comment.
dialogs/archive.rs #2 Line::from(vec![...]) for 2 spans ratatui::Line does not impl From<[Span; N]>Vec<Span> is the only option.

CI

  • cargo fmt
  • cargo clippy --locked --all-targets -- -D warnings
  • cargo test --locked ✅ (302 passed)
  • cargo build --release --locked

Summary by Sourcery

Tune UI render paths for panels, viewer, dialogs, and theming to reduce allocations and per-frame work while preserving behavior.

Enhancements:

  • Cache the home directory thread-locally and improve panel listing and status bar rendering to avoid repeated allocations and width scans, including inline size formatting and direct permission rendering.
  • Optimize viewer behavior by grouping cache invalidation borrows, memoizing text metrics across view-mode toggles, tightening hex rendering, and reducing large upfront buffer reservations when opening files.
  • Streamline dialog rendering by removing unused input buffers, correctly sizing button span allocations, and hoisting reused theme styles for list pickers.
  • Make icon theme selection case-insensitive without allocating intermediate strings and adjust search decoding buffers to better match worst-case expansion.
  • Clarify assumptions and usage around shared grapheme segmentation cache and panel entry-line scratch buffers to document performance-oriented design choices.

Tests:

  • Update panel rendering tests to account for the scratch suffix buffer requirement without asserting on it.

Audit PR-13 (35 performance findings in the UI render path).

Fixed (21 findings):
- panels/mod.rs: cache dirs::home_dir() thread-locally; indexed access
  replaces skip() iterator; ChaMode written straight into buffers instead
  of display_permissions_raw String alloc per row; format_size replaced by
  inline write_size; status bar reuses cached entry.size_str and eliminates
  scratch.clone(); with_capacity sized for CJK (4× width).
- viewer/open.rs: "<DIR>" borrowed instead of .to_string() per dir entry;
  Vec::with_capacity starts at READ_CHUNK not up to 100MB.
- viewer/toggle.rs: text metrics computed once then reused (line_offsets
  non-empty guard); grouped borrow_mut in invalidate_visual_cache.
- viewer/search.rs: decode_lossy_with_map reserves 3× for U+FFFD expansion.
- viewer/hex.rs: push_byte_hex uses stack array + push_str (1 write, not 3).
- input/normal.rs: Alt+Backspace avoids prev_path.clone().
- input/menu_actions.rs: hotlist path bound once before .any() scan.
- dialogs/archive.rs: removed redundant INPUT_BUF (compute_visible_window
  already returns owned String); render_button_row reserves 2n capacity.
- dialogs/input.rs: documented single-active-field SegmentCache assumption.
- dialogs/list_picker.rs: hoisted dialog_with_colors to a single let.
- render.rs: icon_theme computed only on the panel-layout path, not Viewing.
- theme.rs: eq_ignore_ascii_case replaces to_ascii_lowercase() allocation.

Rejected (14 findings) with justification in PR body.

cargo fmt + clippy -D warnings + test (302 pass) + release build all green.
@sourcery-ai

sourcery-ai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Reviewer's Guide

This PR focuses on reducing allocations and per-frame work in the panels, viewer, dialogs, search, and theme render paths, mainly by caching expensive values, reusing buffers, and avoiding unnecessary String creation while preserving behavior and UI semantics.

Sequence diagram for viewer mode toggle text metrics caching

sequenceDiagram
    participant ViewerState
    participant RenderCache

    ViewerState->>ViewerState: toggle_view_mode()
    ViewerState->>ViewerState: invalidate_visual_cache()
    alt [view_mode == Text && originally_binary]
        opt [line_offsets.is_empty()]
            ViewerState->>ViewerState: compute_text_metrics(raw_bytes)
            ViewerState->>ViewerState: set line_offsets, line_count, max_line_width
            ViewerState->>RenderCache: cached_line_num_col_width.set(line_number_column_width(line_count))
        end
    end
Loading

File-Level Changes

Change Details Files
Optimize panel listing and status-bar rendering to reduce per-frame allocations and unnecessary work.
  • Cache $HOME using a thread-local OnceCell and reuse it in shorten_home
  • Iterate visible entries via index range and filtered_get instead of filtered().skip().take()
  • Increase per-line String capacity to handle wider CJK content by using content_width * 4 + 8
  • Write permission bits directly via ChaMode into buffers instead of allocating Strings via display_permissions_raw
  • Format selected size inline into the status summary using a new write_size helper instead of format_size (String alloc)
  • Split status bar rendering into separate summary and meta buffers, eliminating scratch.clone() and recomputations
src/ui/panels/mod.rs
Reduce viewer overhead in toggle, open, hex, and search paths by avoiding redundant work and oversized allocations.
  • Group multiple borrow_mut() calls in invalidate_visual_cache into a single scope
  • Compute text-mode metrics (line offsets/widths) only once and reuse them across toggles based on line_offsets.is_empty()
  • Avoid pre-reserving up to MAX_VIEW_SIZE for raw_bytes; start with READ_CHUNK.min(file_size) and grow naturally
  • Use Cow<'_, str> for directory size labels, borrowing "" instead of allocating a String each time
  • Pre-reserve decoded and map buffers in lossy UTF-8 search to 3× slice.len() to avoid reallocations
  • Optimize push_byte_hex by building a 3-byte stack array and pushing via push_str in one call
src/ui/viewer/toggle.rs
src/ui/viewer/open.rs
src/ui/viewer/hex.rs
src/ui/viewer/search.rs
Streamline dialogs rendering by removing ineffective caches, properly sizing buffers, and documenting SegmentCache assumptions.
  • Remove the thread-local INPUT_BUF cache and render window.text directly in the archive input dialog
  • Pre-size the button row spans Vec to buttons.len() * 2 to account for labels plus separators
  • Document the single-active-field assumption and cache behavior for SegmentCache in input dialogs
  • Hoist Theme::dialog_with_colors(colors) in list_picker into a local dialog_style so it is computed once and reused
src/ui/dialogs/archive.rs
src/ui/dialogs/input.rs
src/ui/dialogs/list_picker.rs
Avoid needless String allocations and work in theme and render paths.
  • Replace to_ascii_lowercase() + match on icon_theme with eq_ignore_ascii_case checks on the trimmed config string to avoid allocating a lowercase String
  • Move colors.icon_theme() in render_ui after early-return paths so it is only computed for panel layouts that need it
src/ui/theme.rs
src/render.rs
Minor input and menu-path performance/clarity improvements.
  • Bind active_panel().path().to_path_buf() once before scanning the hotlist, reusing it inside .any() and push
  • Avoid cloning prev_path on Alt+Backspace by moving it into set_path and later reading the path back from the active panel for the status message
src/input/menu_actions.rs
src/input/normal.rs
Adjust tests to reflect new panel formatting behavior and scratch-buffer usage.
  • Import format_size in panel tests and assert entry_line via format_entry_line while explicitly marking suffix as an unused scratch buffer parameter
src/ui/panels/tests.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 2 issues, and left some high level feedback:

  • The new write_size helper duplicates the logic of format_size; consider refactoring so they share a single implementation to avoid divergence in formatting behavior over time.
  • In push_byte_hex, the from_utf8 call is guaranteed to succeed given the fixed ASCII data, so you can avoid the Result handling (e.g., with from_utf8_unchecked or a precomputed table) to reduce overhead on the hex render hot path.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The new `write_size` helper duplicates the logic of `format_size`; consider refactoring so they share a single implementation to avoid divergence in formatting behavior over time.
- In `push_byte_hex`, the `from_utf8` call is guaranteed to succeed given the fixed ASCII data, so you can avoid the `Result` handling (e.g., with `from_utf8_unchecked` or a precomputed table) to reduce overhead on the hex render hot path.

## Individual Comments

### Comment 1
<location path="src/ui/viewer/hex.rs" line_range="22-35" />
<code_context>
-    buf.push(' ');
+    // Build into a 3-byte stack array, then push in one shot — avoids three
+    // separate push calls (amortized check + len update) per byte.
+    let trio = [
+        HEX_CHARS[(b >> NIBBLE_BITS) as usize],
+        HEX_CHARS[(b & LOW_NIBBLE_MASK) as usize],
+        b' ',
+    ];
+    // `trio` is built solely from `HEX_CHARS` (ASCII) and a space, so it is
+    // always valid UTF-8. `from_utf8` never fails here.
+    if let Ok(s) = std::str::from_utf8(&trio) {
+        buf.push_str(s);
+    }
 }
</code_context>
<issue_to_address>
**suggestion (performance):** The `from_utf8` check in `push_byte_hex` is unnecessary and adds overhead; consider a cheaper path.

Since `trio` is built only from `HEX_CHARS` (0–9, a–f) and a space, it’s statically guaranteed to be valid UTF-8, so `from_utf8` can never fail. Calling it in this per-byte hot loop adds an unnecessary branch and error path. To keep the single `push_str` optimization without the extra check, either retain the original per-char `push` calls or use `unsafe { std::str::from_utf8_unchecked(&trio) }` with a brief comment documenting this invariant.

```suggestion
fn push_byte_hex(buf: &mut String, b: u8) {
    // Build into a 3-byte stack array, then push in one shot — avoids three
    // separate push calls (amortized check + len update) per byte.
    let trio = [
        HEX_CHARS[(b >> NIBBLE_BITS) as usize],
        HEX_CHARS[(b & LOW_NIBBLE_MASK) as usize],
        b' ',
    ];
    // `trio` is built solely from `HEX_CHARS` (ASCII '0'–'9', 'a'–'f') and a space,
    // so it is statically guaranteed to be valid UTF-8. This makes the unchecked
    // conversion safe and avoids a per-byte branch in this hot loop.
    let s = unsafe { std::str::from_utf8_unchecked(&trio) };
    buf.push_str(s);
}
```
</issue_to_address>

### Comment 2
<location path="src/ui/panels/tests.rs" line_range="56" />
<code_context>
+fn entry_line(entry: &FileEntry, width: usize, show_permissions: bool) -> String {
</code_context>
<issue_to_address>
**suggestion (testing):** Extend `entry_line`-based tests to cover CJK/wide-character content to validate the new capacity sizing.

The new `format_entry_line` implementation allocates with `String::with_capacity(content_width.saturating_mul(4) + 8)` to support CJK/wide characters, but current tests only cover narrow content. Please add `entry_line`-based cases with double-width filenames/suffixes (e.g., Japanese/Chinese text, combining marks) to verify lines render fully, truncate correctly, and never panic, so the updated sizing is validated and protected against future regressions.
</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/ui/viewer/hex.rs
Comment on lines 22 to 35
fn push_byte_hex(buf: &mut String, b: u8) {
buf.push(HEX_CHARS[(b >> NIBBLE_BITS) as usize] as char);
buf.push(HEX_CHARS[(b & LOW_NIBBLE_MASK) as usize] as char);
buf.push(' ');
// Build into a 3-byte stack array, then push in one shot — avoids three
// separate push calls (amortized check + len update) per byte.
let trio = [
HEX_CHARS[(b >> NIBBLE_BITS) as usize],
HEX_CHARS[(b & LOW_NIBBLE_MASK) as usize],
b' ',
];
// `trio` is built solely from `HEX_CHARS` (ASCII) and a space, so it is
// always valid UTF-8. `from_utf8` never fails here.
if let Ok(s) = std::str::from_utf8(&trio) {
buf.push_str(s);
}
}

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 (performance): The from_utf8 check in push_byte_hex is unnecessary and adds overhead; consider a cheaper path.

Since trio is built only from HEX_CHARS (0–9, a–f) and a space, it’s statically guaranteed to be valid UTF-8, so from_utf8 can never fail. Calling it in this per-byte hot loop adds an unnecessary branch and error path. To keep the single push_str optimization without the extra check, either retain the original per-char push calls or use unsafe { std::str::from_utf8_unchecked(&trio) } with a brief comment documenting this invariant.

Suggested change
fn push_byte_hex(buf: &mut String, b: u8) {
buf.push(HEX_CHARS[(b >> NIBBLE_BITS) as usize] as char);
buf.push(HEX_CHARS[(b & LOW_NIBBLE_MASK) as usize] as char);
buf.push(' ');
// Build into a 3-byte stack array, then push in one shot — avoids three
// separate push calls (amortized check + len update) per byte.
let trio = [
HEX_CHARS[(b >> NIBBLE_BITS) as usize],
HEX_CHARS[(b & LOW_NIBBLE_MASK) as usize],
b' ',
];
// `trio` is built solely from `HEX_CHARS` (ASCII) and a space, so it is
// always valid UTF-8. `from_utf8` never fails here.
if let Ok(s) = std::str::from_utf8(&trio) {
buf.push_str(s);
}
}
fn push_byte_hex(buf: &mut String, b: u8) {
// Build into a 3-byte stack array, then push in one shot — avoids three
// separate push calls (amortized check + len update) per byte.
let trio = [
HEX_CHARS[(b >> NIBBLE_BITS) as usize],
HEX_CHARS[(b & LOW_NIBBLE_MASK) as usize],
b' ',
];
// `trio` is built solely from `HEX_CHARS` (ASCII '0'–'9', 'a'–'f') and a space,
// so it is statically guaranteed to be valid UTF-8. This makes the unchecked
// conversion safe and avoids a per-byte branch in this hot loop.
let s = unsafe { std::str::from_utf8_unchecked(&trio) };
buf.push_str(s);
}

Comment thread src/ui/panels/tests.rs
&mut suffix,
&mut out,
);
let _ = &suffix; // scratch buffer required by format_entry_line, not asserted

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 (testing): Extend entry_line-based tests to cover CJK/wide-character content to validate the new capacity sizing.

The new format_entry_line implementation allocates with String::with_capacity(content_width.saturating_mul(4) + 8) to support CJK/wide characters, but current tests only cover narrow content. Please add entry_line-based cases with double-width filenames/suffixes (e.g., Japanese/Chinese text, combining marks) to verify lines render fully, truncate correctly, and never panic, so the updated sizing is validated and protected against future regressions.

@greptile-apps

greptile-apps Bot commented Jul 31, 2026

Copy link
Copy Markdown

Greptile Summary

Performance-focused render-path optimizations preserve existing behavior while reducing repeated allocation and computation.

  • Reuses cached panel metadata and directly indexes visible entries.
  • Reduces viewer, search, hexadecimal rendering, and archive-listing allocations.
  • Avoids unnecessary theme work and repeated text-metric computation.
  • Simplifies dialog rendering and navigation-path ownership.

Confidence Score: 5/5

The PR appears safe to merge, with no concrete behavioral, security, or build regressions identified.

The changed paths retain the prior rendering, formatting, navigation, and viewer-state semantics while reducing allocations and redundant computation.

Important Files Changed

Filename Overview
src/ui/panels/mod.rs Optimizes visible-entry traversal, metadata formatting, home-directory lookup, and status-bar buffers without changing established output contracts.
src/ui/viewer/toggle.rs Reuses immutable text metrics across mode toggles and safely groups mutations of distinct render-cache cells.
src/ui/viewer/open.rs Reduces initial read allocation and avoids allocating directory-size labels while preserving viewer content and truncation behavior.
src/ui/viewer/search.rs Correctly reserves for worst-case UTF-8 replacement expansion without changing decoded text or byte mappings.
src/ui/dialogs/archive.rs Renders the already-owned visible input window directly and improves button-span capacity sizing.
src/input/normal.rs Moves the history path into panel state and safely reads it back after refresh, which does not alter the panel path.
src/render.rs Defers icon-theme lookup until the panel-rendering path where it is actually required.
src/ui/theme.rs Replaces allocating lowercase conversion with equivalent ASCII-insensitive comparisons.

Reviews (1): Last reviewed commit: "perf(ui): render path — panels, viewer, ..." | Re-trigger Greptile

@opencode-agent

Copy link
Copy Markdown

Now I have a thorough understanding of all the changes. Let me compile my review.


PR Review: perf(ui): panels, viewer, dialogs render path

Overall: Well-executed PR with thorough documentation. CI is green (302 tests, clippy clean, fmt clean). The vast majority of changes are correct, genuinely reduce per-frame allocations, and preserve behavior. I verified the equivalence of ChaMode Display vs display_permissions_raw (identical — same code path), write_size vs format_size (faithful mirror), and the line_offsets.is_empty() toggle guard (safe — compute_line_offsets always returns non-empty).

However, I found one behavioral bug and a few minor concerns.


Bug: entry.size_str in status bar changes display output (panels/mod.rs:583)

The PR replaces format_size(entry.size()) with &entry.size_str, claiming it's a cached duplicate. It is not the same value. entry.size_str is built by cached_fields as:

  • Files: format!("{:>10}", format_size(cha.len))right-padded to 10 columns
  • Directories: " <DIR>"

I verified concretely:

format_size(1500)  = "1.5 KB"       ← old status bar
entry.size_str     = "    1.5 KB"   ← new status bar (4 leading spaces)

dir format_size()  = "4.0 KB"       ← old (dir's byte size)
dir entry.size_str = "     <DIR>"   ← new

The status bar metadata line now has leading whitespace before file sizes, and directories show <DIR> instead of their byte size. The <DIR> change may even be desirable, but it's undocumented and a side-effect of "reusing" a differently-formatted field — not a true deduplication.

Fix: The file already defines write_size() for allocation-free size formatting. Use it instead of &entry.size_str:

// In render_status_bar_with_colors, replace:
let size_str = &entry.size_str;
// with formatting into the meta buffer directly, e.g.:
write!(meta, "{} | ", display_name).ok();  // or restructure write_status_metadata
write_size(&mut meta, entry.size());

Or at minimum, trim the size_str before use, or document this as an intentional display change.


Minor: menu_actions.rs — allocation in the common case (line 94)

The new code allocates to_path_buf() unconditionally before the .any() check. The old code only allocated when actually pushing. For the common case (path already in hotlist), the new code adds one wasted allocation. The "re-querying" it avoids is just repeated cheap field access (active_panel().path()), not a syscall. Since this is a menu action (not per-frame), impact is negligible — but the change isn't clearly an improvement.


Minor: open.rs — Vec reservation tradeoff (line 360)

Vec::with_capacity(READ_CHUNK.min(file_size)) starts at 64KB and grows. For files between 64KB and 100MB (the common case), this introduces ~7-11 reallocations where the old file_size.min(MAX_VIEW_SIZE + 1) had zero (exact upfront reservation). The benefit (avoiding 100MB upfront reservation) only helps the rare case of cancelling mid-read of a >100MB file. A better middle ground might be file_size.min(MAX_VIEW_SIZE) — exact for the common case, capped for the extreme case. Impact is small either way since this is one-time file-open cost.


Verified correct (highlights)

Change Verification
ChaMode::new() via write! replaces display_permissions_raw display_permissions_raw is literally ChaMode::new(mode).to_string() — byte-identical output. PERMS_WIDTH = 9 is correct (3×3 ASCII chars).
line_offsets.is_empty() guard in toggle.rs compute_line_offsets always returns vec![0] minimum — never empty after first call. Safe.
write_size mirrors format_size Logic, constants (1024.0), units, and format specifiers all match exactly.
filtered_get(i) loop replaces skip().take() Genuinely O(1) per row vs O(start_idx) walk; handles dead indices via let-else continue.
OnceCell home_dir cache Correct; to_string_lossy() per-call is no regression (original did the same).
Cow::Borrowed("<DIR>") in viewer listing Correct — avoids per-dir-entry allocation.
eq_ignore_ascii_case in theme.rs Correct — avoids to_ascii_lowercase() allocation, identical matching logic.
Thread-local borrow grouping in toggle.rs Correct — three RefCell borrows grouped into one scope.

The documentation comments (especially the SegmentCache single-active-field assumption in input.rs) are valuable additions.

New%20session%20-%202026-07-31T11%3A39%3A21.901Z
opencode session  |  github run

The perf optimization in 17aa022 reused entry.size_str in the status bar
to avoid a per-frame format_size alloc, but size_str is the column-padded
cached form ({:>10} for files, "     <DIR>" for dirs) — leaking leading
spaces into the status metadata ("    1.0 KB | ..."), a visual regression
vs the old unpadded format_size(entry.size()).

Fix: trim_start() the cached string — format_size output never carries
leading whitespace, so this recovers the unpadded display form with zero
allocation. Added a render test pinning the unpadded form.
@opencode-agent

Copy link
Copy Markdown

I've completed a thorough review. CI is green (fmt, clippy, 302 tests, release build all pass). Here's my analysis.

Review: perf(ui): panels, viewer, dialogs render path

Overall this is a high-quality PR — well-documented, most changes genuinely reduce per-frame allocations, and behavior is preserved in the vast majority of cases. I verified equivalence of write_sizeformat_size (identical logic/constants), the filtered_get loop, the OnceCell home cache, the is_empty() toggle guard, and the normal.rs path read-back. However, I found one unresolved behavioral bug plus a few minor concerns.


Bug: status bar shows <DIR> instead of byte size for directories (src/ui/panels/mod.rs:585)

The follow-up commit 199daef added trim_start() to fix the file-padding leak, but this only handles files. For directories, entry.size_str is the literal " <DIR>" (see cached_fields at src/app/types/file_entry.rs:191), so trim_start() yields "<DIR>". The old code was format_size(entry.size()), which rendered the directory's byte size (e.g. 4.0 KB).

So a directory at the cursor now shows dirname | <DIR> | … instead of dirname | 4.0 KB | … — an undocumented behavioral change in a PR that claims to preserve behavior. The new regression test (test_render_status_bar_size_is_unpadded) only covers the file case, so this slipped through.

The PR's own premise (finding #7: "format_size(entry.size()) duplicates cached size_str") is factually wrong for directories — they were never duplicates.

Recommended fix: the file already defines write_size() for allocation-free formatting. Use it instead of reusing the differently-semantics size_str:

// replace:  let size_str = entry.size_str.trim_start();
let mut meta = String::with_capacity(48);
write!(meta, "{} | ", display_name).ok();   // or restructure write_status_metadata
write_size(&mut meta, entry.size());

This preserves the exact old behavior for both files and dirs, stays zero-alloc, and is consistent with how selected_size() is already formatted via write_size. (If <DIR> is intentionally desired, it should be documented and covered by a test — but it shouldn't be a silent side effect of an "optimization".)


Minor concerns

  1. open.rs:360 — Vec reservation is a pessimization on the common path. Vec::with_capacity(READ_CHUNK.min(file_size)) (64 KB) forces ~8 reallocations + memcpys for any file between 64 KB and 100 MB, where the old file_size.min(MAX_VIEW_SIZE + 1) reserved exactly once. Worse, for a >100 MB file read to the cap, doubling leaves capacity at ~128 MB — more peak memory than the old 100 MB reservation. The stated rationale ("cancelled read wastes allocation") is weak since reads complete quickly and the old reservation was already capped. Suggest file_size.min(MAX_VIEW_SIZE) — exact for the common case, capped for the extreme case.

  2. menu_actions.rs:94 — unconditional alloc. let path = active_panel().path().to_path_buf() now allocates even when the path is already in the hotlist (the common case); the old code allocated only on push. The "re-querying" being avoided is cheap field access, not a syscall. Negligible (menu action, not per-frame), but it's not clearly an improvement.

  3. hex.rs:31 — re: Sourcery's unsafe suggestion. Sourcery recommended unsafe { from_utf8_unchecked }. Do not take that suggestion — it violates the project's hard rule unsafe_code = "forbid" (AGENTS.md). The current if let Ok(s) = from_utf8(&trio) is the correct approach under that constraint; the silent drop is acceptable since failure is statically impossible.

  4. write_size duplicates format_size. They're byte-identical today, but two copies risk divergence. Consider making format_size wrap write_size so there's one source of truth. Non-blocking.


Verified correct (highlights)

Change Check
filtered_get(i) loop replaces skip().take() O(1)/row vs O(start_idx); dead indices handled via let-else continue (panels/mod.rs:211)
line_offsets.is_empty() guard Safe — compute_line_offsets always returns vec![0] minimum (open.rs:88)
normal.rs path read-back after set_path Correct — refresh_panel never mutates panel.path() in either branch (panel_ops.rs:33)
theme.rs eq_ignore_ascii_case Equivalent for the all-ASCII config values
toggle.rs borrow grouping / invalidate_visual_cache Correct — three RefCell borrows in one scope
archive.rs INPUT_BUF removal Correct — window.text rendered directly, no copy

The documentation additions (especially the SegmentCache single-active-field assumption) are valuable. Net: request changes for the directory status-bar bug; the rest are optional polish.

New%20session%20-%202026-07-31T12%3A23%3A20.485Z
opencode session  |  github run

199daef trimmed entry.size_str to recover the unpadded form, but for
directories size_str is the column-cache placeholder "     <DIR>", not a
size — so the status bar showed "<DIR>" instead of the directory's byte
size that the pre-perf code produced via format_size(entry.size()).

Format the entry size directly into a scratch buffer via the existing
zero-alloc write_size helper, for every entry. Files keep their unpadded
human size; directories show the real byte size again.

Tests: extend the status-bar coverage with a directory case asserting the
bar shows a size (unit suffix) and never the <DIR> placeholder.
@opencode-agent

Copy link
Copy Markdown

Clippy is clean and all regression tests pass. Here is my review.

Review: perf(ui): panels, viewer, dialogs render path

Verdict: Approve. The one real bug (directories showing <DIR> in the status bar) was already caught in earlier review rounds and is now fixed and covered by a regression test. I verified every correctness-sensitive change by hand and ran the tests + clippy.

Bug status: resolved

The status bar now formats the cursor entry's size via write_size(&mut size_buf, entry.size()) (src/ui/panels/mod.rs:586-587) rather than reusing the column-padded entry.size_str cache (which is " <DIR>" for directories). This restores the exact pre-PR behavior for both files and dirs. The new tests test_render_status_bar_size_is_unpadded and test_render_status_bar_directory_shows_size_not_dir_label cover both cases and pass.

Verified correct (spot-checked each)

Change Verification
ChaMode::new(mode) via write! replaces display_permissions_raw display_permissions_raw is literally ChaMode::new(mode).to_string() (file_entry.rs:267-270). PERM_TRIPLES has exactly 3 entries × 3 ASCII chars → width is always 9, so the hardcoded PERMS_WIDTH = 9 is correct (special bits like setuid substitute single chars s/S/t, width unchanged).
line_offsets.is_empty() toggle guard (toggle.rs:69) compute_line_offsets always returns ≥ vec![0] (open.rs:88-91); for originally_binary files build initializes line_offsets to Vec::new() (open.rs:237), so the guard computes once then reuses. Safe.
write_size mirrors format_size Logic, constants, rounding, and format specifiers are identical.
normal.rs path read-back (normal.rs:583) set_path stores prev_path into panel.path; refresh_panel never mutates panel.path on either branch; pop_history only touches the history deque. Read-back is equivalent to the old clone.
filtered_get(i) loop (panels/mod.rs:211) O(1)/row vs old O(start_idx) walk; dead indices handled by let-else continue.
theme.rs eq_ignore_ascii_case Equivalent for the all-ASCII config token set.
toggle.rs borrow grouping, archive.rs INPUT_BUF removal, Cow::Borrowed("<DIR>"), render.rs icon-theme deferral All correct.

CI confirms: clippy -D warnings clean, 3/3 status-bar regression tests pass.

Optional polish (non-blocking)

  1. write_size duplicates format_size (panels/mod.rs:518 vs file_entry.rs:82). Byte-identical today, but two copies can drift. Recommend making format_size wrap write_size so there's one source of truth. (Also flagged by Sourcery.)

  2. open.rs:357 Vec reservation is a pessimization on the common path. READ_CHUNK.min(file_size) (64 KB) forces ~8 reallocations for any file between 64 KB and 100 MB, where the old file_size.min(MAX_VIEW_SIZE + 1) reserved exactly once. Suggest file_size.min(MAX_VIEW_SIZE) — exact for the common case, still capped for the extreme case.

  3. menu_actions.rs:94 now allocates to_path_buf() unconditionally (even when the path is already hotlisted). Negligible (menu action, not per-frame), but the change isn't clearly an improvement over the old lazy allocation.

  4. Sourcery's unsafe { from_utf8_unchecked } suggestion for hex.rs should be rejected — it violates the project's hard rule unsafe_code = "forbid". The current if let Ok(s) = from_utf8(&trio) is the correct approach under that constraint; the silent drop is acceptable since failure is statically impossible.

None of these block merge. Nice work on the documentation comments (especially the SegmentCache single-active-field assumption in input.rs).

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

@leszek3737
leszek3737 merged commit 28ec19f into main Jul 31, 2026
6 checks passed
@leszek3737
leszek3737 deleted the audit/13-perf-ui branch July 31, 2026 13:15
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