Skip to content

refactor(app): AppState aggregates, panel entries-as-indices, type/config debt#83

Merged
leszek3737 merged 2 commits into
mainfrom
fab7
Jun 14, 2026
Merged

refactor(app): AppState aggregates, panel entries-as-indices, type/config debt#83
leszek3737 merged 2 commits into
mainfrom
fab7

Conversation

@leszek3737

Copy link
Copy Markdown
Owner

Tackle the AppState god-object and types/* modeling debt, fix type-level bugs, and dedup config/paths/shell/keymap/mime boilerplate.

App state & types:

  • Split AppState's 36 flat fields into UiState/InputState/TreeState/ InteractionState aggregates; merge last_click_time/last_click_position into one Option<(Instant,(u16,u16))>; add invariant accessors (set_mode/set_active_panel/request_quit) and a generic string-cache helper.
  • Redesign PanelListing into a single backing store with a filtered index view (entries: Vec), removing the dual-store sync bug; selection is single-sourced; toggle_selection returns ToggleResult; selected_entries is now a zero-alloc iterator. Migrate all consumers.

Type-level bugs:

  • FileEntry::build() now returns Result<_, BuildError> instead of panicking.
  • Fix size formatting: pad the formatted String (FileSize's Display ignores formatter width flags).
  • text_input: keep the cursor visible on the right when the last grapheme is wider than the viewport; centralize scroll recompute and the cursor invariant.
  • test_helpers: compose raw_mode + size; define explicit symlink precedence.

Sorting & config:

  • sorting: canonical field/direction names + FromStr + serde round-trip tests; resolve the cross-naming with config (canonical "sensitive").
  • config/paths/shell/keymap/mime/file_type: dedup wrappers via generics/macros, fold MIME constants, mode-name consts; mime returns Option<&'static str>.
  • file_type: make the .env. prefix check follow the exact-name case policy.

Safety & misc:

  • shell: document the by-design shell-injection threat model (local menu is attacker-controlled, gated by the confirm dialog).
  • job_runner: extract handle_worker_result; document the reaper timeout trade-off.
  • debug_log: batch BufWriter flushes; cache the per-second timestamp.
  • Fix the Windows test cfg gate in misc.rs.

CI gate green: build + clippy -D warnings + 1212 tests passing.

…nfig debt

PR-07: tackle the AppState god-object and types/* modeling debt, fix
type-level bugs, and dedup config/paths/shell/keymap/mime boilerplate.

App state & types:
- Split AppState's 36 flat fields into UiState/InputState/TreeState/
  InteractionState aggregates; merge last_click_time/last_click_position
  into one Option<(Instant,(u16,u16))>; add invariant accessors
  (set_mode/set_active_panel/request_quit) and a generic string-cache helper.
- Redesign PanelListing into a single backing store with a filtered index
  view (entries: Vec<usize>), removing the dual-store sync bug; selection is
  single-sourced; toggle_selection returns ToggleResult; selected_entries
  is now a zero-alloc iterator. Migrate all consumers.

Type-level bugs:
- FileEntry::build() now returns Result<_, BuildError> instead of panicking.
- Fix size formatting: pad the formatted String (FileSize's Display ignores
  formatter width flags).
- text_input: keep the cursor visible on the right when the last grapheme is
  wider than the viewport; centralize scroll recompute and the cursor invariant.
- test_helpers: compose raw_mode + size; define explicit symlink precedence.

Sorting & config:
- sorting: canonical field/direction names + FromStr + serde round-trip tests;
  resolve the cross-naming with config (canonical "sensitive").
- config/paths/shell/keymap/mime/file_type: dedup wrappers via generics/macros,
  fold MIME constants, mode-name consts; mime returns Option<&'static str>.
- file_type: make the .env. prefix check follow the exact-name case policy.

Safety & misc:
- shell: document the by-design shell-injection threat model (local menu is
  attacker-controlled, gated by the confirm dialog).
- job_runner: extract handle_worker_result; document the reaper timeout trade-off.
- debug_log: batch BufWriter flushes; cache the per-second timestamp.
- Fix the Windows test cfg gate in misc.rs.

CI gate green: build + clippy -D warnings + 1212 tests passing.

@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.

Sorry @leszek3737, your pull request is larger than the review limit of 150000 diff characters

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request refactors state management by splitting the monolithic AppState into focused sub-states (InputState, UiState, TreeState, and InteractionState) and introduces a single-source-of-truth PanelListing model to prevent selection desynchronization. Feedback on these changes highlights a performance regression from deriving source_display on demand during frame rendering, ineffective panic payload logging that should downcast to extract the actual message, and an unidiomatic builder pattern for PendingAction that would be cleaner as an in-place mutator.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/app/types/dialogs.rs
Comment on lines +74 to +88
impl CopyMoveDetails {
/// Per-source display labels (file name, falling back to the full path).
/// Derived on demand from `source` instead of being stored as a parallel
/// `source_display` field that could drift out of sync.
pub fn source_display(&self) -> Vec<String> {
self.source
.iter()
.map(|p| {
p.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| p.display().to_string())
})
.collect()
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Deriving source_display on demand on every frame render introduces a significant performance regression. Since to_ui_dialog is called on every frame tick (especially with animations like the spinner), this method will repeatedly iterate over self.source, perform multiple heap allocations (to_string_lossy().into_owned(), to_string()), and collect them into a new Vec<String>.

Since CopyMoveDetails is immutable once the dialog is opened, there is no risk of the display labels drifting out of sync. It is much more efficient to pre-compute source_display once during construction and store it as a field (as it was before) to avoid high memory churn during rendering.

Comment thread src/app/job_runner.rs
Comment on lines +28 to +36
fn handle_worker_result(handle: JoinHandle<()>, context: &str) -> bool {
match handle.join() {
Ok(()) => true,
Err(panic_payload) => {
debug_log!("{context}: {:?}", panic_payload);
false
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The panic_payload is of type Box<dyn Any + Send>. Formatting it with {:?} simply prints "Any", which does not provide the actual panic message (e.g., "index out of bounds"). To make this log useful for troubleshooting, we should downcast the payload to &str or String to extract and log the actual panic message.

fn handle_worker_result(handle: JoinHandle<()>, context: &str) -> bool {
    match handle.join() {
        Ok(()) => true,
        Err(panic_payload) => {
            let msg = if let Some(s) = panic_payload.downcast_ref::<&str>() {
                *s
            } else if let Some(s) = panic_payload.downcast_ref::<String>() {
                s.as_str()
            } else {
                "unknown panic payload"
            };
            debug_log!("{context}: {msg}");
            false
        }
    }
}

Comment thread src/app/types/modes.rs
Comment on lines 93 to +109
impl PendingAction {
pub fn set_overwrite(&mut self) {
match self {
Self::Copy(t) | Self::Move(t) => {
t.overwrite = true;
}
/// Builder that marks the action as "overwrite existing targets".
///
/// Replaces the former `set_overwrite(&mut self)` mutator. Unifies the
/// duplicated `overwrite` flag across every variant in one place. `Delete`
/// has no destination to overwrite, so it is a no-op.
#[must_use]
pub fn with_overwrite(mut self) -> Self {
match &mut self {
Self::Copy(t) | Self::Move(t) => t.overwrite = true,
Self::Delete { .. } => {}
Self::ExtractArchive { overwrite, .. } | Self::CreateArchive { overwrite, .. } => {
*overwrite = true;
}
}
self
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Using a builder pattern (with_overwrite(self) -> Self) for PendingAction forces callers to move the action out of state.ui.pending_action via take(), apply the mutation, and then re-insert it back into the Option.

Using an in-place mutator (set_overwrite(&mut self)) is much more idiomatic and cleaner in Rust, as it allows callers to mutate the action directly inside the Option using if let Some(a) = &mut state.ui.pending_action { a.set_overwrite(); }, avoiding the take() and re-assignment boilerplate.

impl PendingAction {
    /// Marks the action as "overwrite existing targets".
    ///
    /// Unifies the duplicated `overwrite` flag across every variant in one place.
    /// `Delete` has no destination to overwrite, so it is a no-op.
    pub fn set_overwrite(&mut self) {
        match self {
            Self::Copy(t) | Self::Move(t) => t.overwrite = true,
            Self::Delete { .. } => {}
            Self::ExtractArchive { overwrite, .. } | Self::CreateArchive { overwrite, .. } => {
                *overwrite = true;
            }
        }
    }
}

Comment thread src/input/dialogs.rs
Comment on lines 262 to 266
KeyCode::Char('o' | 'O') => {
if let Some(action) = state.pending_action.as_mut() {
action.set_overwrite();
if let Some(a) = state.ui.pending_action.take() {
state.ui.pending_action = Some(a.with_overwrite());
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

With set_overwrite(&mut self) on PendingAction, we can mutate the action in-place without taking it out of the Option.

Suggested change
KeyCode::Char('o' | 'O') => {
if let Some(action) = state.pending_action.as_mut() {
action.set_overwrite();
if let Some(a) = state.ui.pending_action.take() {
state.ui.pending_action = Some(a.with_overwrite());
}
}
KeyCode::Char('o' | 'O') => {
if let Some(a) = &mut state.ui.pending_action {
a.set_overwrite();
}
}

Comment thread src/input/dialogs.rs
Comment on lines +271 to 276
KeyCode::Enter => match state.input.dialog_selection {
0 => {
if let Some(action) = state.pending_action.as_mut() {
action.set_overwrite();
if let Some(a) = state.ui.pending_action.take() {
state.ui.pending_action = Some(a.with_overwrite());
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

With set_overwrite(&mut self) on PendingAction, we can mutate the action in-place without taking it out of the Option.

Suggested change
KeyCode::Enter => match state.input.dialog_selection {
0 => {
if let Some(action) = state.pending_action.as_mut() {
action.set_overwrite();
if let Some(a) = state.ui.pending_action.take() {
state.ui.pending_action = Some(a.with_overwrite());
}
}
KeyCode::Enter => match state.input.dialog_selection {
0 => {
if let Some(a) = &mut state.ui.pending_action {
a.set_overwrite();
}
}

Comment thread src/input/mouse.rs
Comment on lines 408 to 411
0 => {
if let Some(action) = state.pending_action.as_mut() {
action.set_overwrite();
if let Some(a) = state.ui.pending_action.take() {
state.ui.pending_action = Some(a.with_overwrite());
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

With set_overwrite(&mut self) on PendingAction, we can mutate the action in-place without taking it out of the Option.

Suggested change
0 => {
if let Some(action) = state.pending_action.as_mut() {
action.set_overwrite();
if let Some(a) = state.ui.pending_action.take() {
state.ui.pending_action = Some(a.with_overwrite());
}
0 => {
if let Some(a) = &mut state.ui.pending_action {
a.set_overwrite();
}

@greptile-apps

greptile-apps Bot commented Jun 14, 2026

Copy link
Copy Markdown

Greptile Summary

This refactor tackles AppState's 36-field god-object, the dual-store PanelListing sync bug, and config/sorting/MIME/keymap boilerplate. It is a large but well-scoped mechanical migration with clear separation between structural changes and behaviour fixes.

  • AppState decomposition: flat fields split into InputState/UiState/TreeState/InteractionState aggregates with invariant accessors; all 50+ consumer files updated consistently.
  • PanelListing redesign: single backing store + Vec<usize> filtered-view index replaces dual-store with swap_remove-based remove() and defensive index reads; selection is now single-sourced and zero-copy.
  • Type-level fixes: FileEntryBuilder::build() returns Result instead of panicking; FileEntry::format_size applies right-padding correctly; TextInput derives scroll offset on demand; SortMode/SortField/Direction gain canonical FromStr + serde round-trips with backward-compat aliases.

Confidence Score: 4/5

The refactor is mechanically correct and well-tested; the AppState decomposition and PanelListing redesign can go in as-is. One issue in text_input.rs needs attention before merge.

The text_input scroll-offset fallback leaves the cursor invisible in the specific case the PR description states it fixes: a cursor positioned after a grapheme whose display width exceeds the visible viewport. The remaining ~3500 lines of changes are solid mechanical migrations with thorough test coverage.

src/app/types/text_input.rs — the current_scroll_offset fallback path

Important Files Changed

Filename Overview
src/app/types/panel.rs Core redesign: replaces dual-store Vec with a single backing store plus a Vec filtered-view index, eliminating selection-desync bugs. swap_remove-based remove() is correct: callers mark the panel dirty before the next read.
src/app/types/text_input.rs Removes scroll_offset as stored state (now derived on demand). The fallback unwrap_or(widths.len().saturating_sub(1)) in current_scroll_offset leaves the cursor invisible when it follows a grapheme wider than visible_width; unwrap_or(widths.len()) would fix it.
src/app/types/app_state.rs Splits 36 flat fields into four aggregate structs and adds invariant accessors. Migration is mechanically correct and all consumers updated.
src/app/types/sorting.rs Adds FromStr + serde round-trip for SortField/Direction/SortMode. Canonicalises config field to 'sensitive' with alias 'sort_sensitive'. Tests cover all variants.
src/app/types/file_entry.rs FileEntryBuilder::build() now returns Result<_, BuildError>. FileEntry::format_size applies right-alignment that FileSize::Display ignores.
src/app/debug_log.rs Adds per-second timestamp cache via thread_local RefCell and batched flushing every 16 entries. Correctly scoped and safe.
src/app/shell.rs Adds threat-model doc-comment for shell injection, extracts helpers, migrates state field accesses. PRESS_ENTER_TO_RETURN has a style issue with a split bracket pair.
src/app/config.rs Unifies deserialize_with_fallback into one generic function, renames sensitive canonical key with backward-compat alias, adds canonicalize_hotlist helper.
src/app/job_runner.rs Extracts handle_worker_result, documents reaper timeout, migrates state field accesses. Refactored poll_running_job correctly handles both clean-exit and panic cases.
src/app/file_type.rs Introduces exact_name_match/prefix_match with platform-aware case sensitivity, replaces boilerplate with suffix_predicates! macro, fixes .env. prefix check.

Reviews (1): Last reviewed commit: "refactor(app): AppState aggregates, pane..." | Re-trigger Greptile

Comment on lines 103 to +112
@@ -92,28 +108,25 @@ impl TextInput {
Some(c)
})
.position(|cum| cum >= raw_scroll)
.unwrap_or(0);
self.scroll_offset = widths[..start_idx].iter().sum();
.unwrap_or(widths.len().saturating_sub(1));
widths[..start_idx].iter().sum()

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 Fallback scroll offset still hides cursor after wide grapheme

The fallback .unwrap_or(widths.len().saturating_sub(1)) resolves to the start of the last grapheme, not the end. When the cursor is placed after a grapheme whose display width exceeds visible_width, cursor_display can easily exceed scroll_offset + visible_width, leaving the cursor outside the viewport.

Concrete: widths = [3, 10], visible_width = 5, cursor = 2 (end of text).

  • cursor_display = 13, raw_scroll = 9.
  • No boundary ≥ 9 → current fallback gives start_idx = 1, scroll_offset = 3.
  • Viewport [3, 7]; cursor at display 13 — not visible.

With widths.len() as the fallback, scroll_offset = 13 = cursor_display. Viewport [13, 17]; cursor at relative 0 — visible on the left edge. This matches the stated goal in the PR description: "keep the cursor visible on the right when the last grapheme is wider than the viewport".

Comment thread src/app/shell.rs
Comment on lines +22 to +23
const MSG_COMMAND_SUCCEEDED: &str = "\n[Command succeeded. Press Enter to return]";
const PRESS_ENTER_TO_RETURN: &str = "Press Enter to return]";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 The constant PRESS_ENTER_TO_RETURN contains a trailing ] but no leading [; the opening bracket lives in the format strings that embed it. This splits a single syntactic bracket pair across two source locations, which makes the strings hard to parse at a glance and risks mismatched brackets if either side is edited independently.

Suggested change
const MSG_COMMAND_SUCCEEDED: &str = "\n[Command succeeded. Press Enter to return]";
const PRESS_ENTER_TO_RETURN: &str = "Press Enter to return]";
const MSG_COMMAND_SUCCEEDED: &str = "\n[Command succeeded. Press Enter to return]";
const MSG_COMMAND_FAILED_SUFFIX: &str = " Press Enter to return]";

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@opencode-agent

Copy link
Copy Markdown

I've completed a thorough review of this PR. Here's my assessment:

Summary

This is a high-quality refactoring PR with excellent documentation, comprehensive test coverage (1212 tests), and the CI gate is fully green (build + clippy -D warnings + all tests pass). The changes are well-motivated and correctly address real bugs.

Strengths

  1. AppState decomposition: Splitting 36 flat fields into InputState/UiState/TreeState/InteractionState aggregates is clean. Merging last_click_time/last_click_position into a single Option<(Instant, (u16, u16))> eliminates a real desync risk.

  2. PanelListing single-store redesign (src/app/types/panel.rs:50-56): Storing the filtered view as Vec<usize> indices into the sole owning store eliminates the historic dual-store selection-desync bug at the type level. The ToggleResult enum makes the former silent no-ops explicit.

  3. FileEntryBuilder::build()Result (src/app/types/file_entry.rs:323): Replacing the assert! panic with BuildError is the correct Rust idiom.

  4. Size formatting fix (src/app/types/file_entry.rs:445-450): Correctly identifies that FileSize's Display impl ignores formatter width flags, and applies padding to the String instead. Good catch.

  5. text_input scroll fix (src/app/types/text_input.rs:103-112): The unwrap_or(widths.len().saturating_sub(1)) fallback (vs the old unwrap_or(0)) keeps the cursor visible when the last grapheme is wider than the viewport.

  6. shell.rs threat model doc (src/app/shell.rs:130-149): Thoroughly documents the attacker-controlled local menu vector and the confirm-dialog gate.

  7. debug_log batching (src/app/debug_log.rs:52,157-159): Batching BufWriter flushes every 16 entries + caching the per-second timestamp is a real hot-path improvement.

  8. Windows test cfg fix (src/tests/misc.rs:67,79-90): Correctly gates the Unix-specific test and adds a Windows equivalent using invalid UTF-16.

Issues & Suggestions

Minor: Naming collision on set_active_panel

There are now two functions with the same name:

  • AppState::set_active_panel(&mut self, panel) — method (app_state.rs:170)
  • panel_ops::set_active_panel(state, panel) — free function (panel_ops.rs:218)

The free function is called from ~7 sites. Consider deprecating/removing it in favor of the method to avoid confusion, or at least having the free function delegate to the method.

Minor: Incremental accessor migration is very partial

The new invariant accessors (set_mode, set_active_panel, request_quit) are declared but set_mode is used at only 1 call site (enter_command_line_mode), while 91 sites still do state.mode = ... directly (request_quit fares better — it's used in normal.rs:75). The comment at app_state.rs:163-167 acknowledges this is intentional ("fields can be made private in a later wave"), which is acceptable — just flagging that the "invariant enforcement" benefit is currently zero for mode transitions.

Nit: ensure_index calls rebuild_index on every invocation when store is empty

PanelListing::ensure_index (panel.rs:166-170) checks path_index.is_empty(). When unfiltered_entries is also empty, rebuild_index runs (as a no-op) on every call. Negligible cost for an empty Vec, but a bool built flag would make the lazy contract tighter.

Nit: with_overwrite take-and-reinsert pattern

In input/dialogs.rs:265-268 and input/mouse.rs:409-411:

if let Some(a) = state.ui.pending_action.take() {
    state.ui.pending_action = Some(a.with_overwrite());
}

This is correct (necessary because with_overwrite consumes self), but a one-line comment explaining why take() is needed would help future readers who might "simplify" it.

No Bugs Found

The core invariant changes were verified:

  • All last_click_time/last_click_position references migrated
  • sync_unfiltered_selection and source_entries fully removed
  • format_permissions wrapper fully removed
  • set_overwrite fully replaced by with_overwrite
  • listing.entries/listing.unfiltered_entries direct field access eliminated outside the module
  • SortMode::from_str correctly uses rsplit_once('_') to handle multi-word field names (mod_time, natural_name)
  • The PanelListing::remove swap_remove + path_index repair logic is correct

Verdict

Approve. This is a well-executed, carefully documented refactor that fixes real bugs (dual-store desync, size formatting, scroll offset, panic-on-build) while reducing boilerplate. The partial accessor migration is the only thing I'd want tracked as a follow-up, and it's already self-documented in the code.

New%20session%20-%202026-06-14T16%3A20%3A24.742Z
opencode session  |  github run

- text_input: fix the scroll fallback for an end-of-line cursor past a
  grapheme wider than the viewport. The scan only yields grapheme *start*
  offsets, so `position` returns None exactly in that case; fall back to
  widths.len() (full width) instead of widths.len()-1 so the cursor stays
  visible. Add a regression test.
- job_runner: log the real panic message by downcasting the join() payload
  to &str/String ({:?} on Box<dyn Any> only prints "Any").
- modes: revert PendingAction::with_overwrite(self) back to an in-place
  set_overwrite(&mut self) mutator; callers now edit the action inside the
  Option directly instead of take()/re-insert.
- panel_ops::set_active_panel delegates to AppState::set_active_panel to
  avoid two same-named functions diverging.
- Fix broken intra-doc links introduced by the refactor (set_overwrite,
  PanelListing::set_filtered/set_filtered_all/upsert/remove, debug_log #[allow]).

Gate: clippy -D warnings clean; 1214 tests passing.
@opencode-agent

Copy link
Copy Markdown

I've completed my independent review by examining the actual code (both commits), running the CI gate, and verifying each prior reviewer's claims. Here's my assessment.

Review Summary

Verdict: Approve. High-quality, well-documented refactor with the CI gate fully green (fmt clean, clippy -D warnings clean, 1214 tests pass). Several prior-review concerns were already addressed in commit 2; the remainder are minor.

Prior issues already fixed in commit 2 ✅

Issue Status
text_input scroll-offset P1 (greptile) Fixed — unwrap_or(widths.len()) + regression test at text_input.rs:274
handle_worker_result panic downcast (gemini) Fixed — properly downcasts &str/String at job_runner.rs:34-38
PendingAction::set_overwrite(&mut self) (gemini) Fixed — all 3 call sites now mutate in-place, no more take()/reinsert

Remaining issues (minor/nits)

  1. shell.rs:23 split bracket pairPRESS_ENTER_TO_RETURN = "Press Enter to return]" has a lone ]; the matching [ lives in the format strings on lines 182-183. This splits one syntactic pair across two locations. Either inline the constant or make it self-contained ("Press Enter to return" without the ]).

  2. job_runner.rs panic-logging inconsistency (new finding) — handle_worker_result (line 28) correctly downcasts, but shutdown() (line 69) and the Drop reaper (line 107) still use {:?}, which only prints "Any". Route these through the same helper so all panic paths log the real message.

  3. PanelListing::ensure_index redundant no-op rebuilds (panel.rs:168) — when both path_index and unfiltered_entries are empty, rebuild_index runs as a no-op on every call. Negligible cost, but a bool built flag (or also checking unfiltered_entries.is_empty()) would tighten the lazy contract.

  4. set_active_panel naming collisionAppState::set_active_panel (method, app_state.rs:170) and panel_ops::set_active_panel (free function, panel_ops.rs:218) share a name; the latter now just delegates. ~6 call sites use the wrapper — could migrate to the method and drop it.

  5. CopyMoveDetails::source_display() per-frame alloc (dialogs.rs:78) — allocates a Vec<String> every frame while the copy/move confirm dialog is open. The desync-avoidance tradeoff is reasonable, but since CopyMoveDetails is immutable post-construction, precomputing once would remove the churn. Low impact (only during the confirm dialog).

  6. Invariant accessors are aspirationalset_mode is used at 1 site while ~91 sites still do state.mode = ... directly. The code comment self-documents this as intentional incremental migration, but the doc strings slightly overpromise ("invariants can be enforced here later"). Current enforcement benefit is zero.

Strengths

  • AppState decomposition into InputState/UiState/TreeState/InteractionState is clean; merging last_click_time/last_click_position into one Option<(Instant, (u16,u16))> eliminates a real desync vector.
  • PanelListing single-store design (panel.rs:50-56) removes the dual-store selection-desync bug at the type level. Verified all remove/upsert callers (watcher_sync.rs:499,508) correctly mark_dirty().
  • FileEntryBuilder::build() -> Result is the correct Rust idiom, replacing a panic.
  • sorting.rs FromStr/serde round-trips are thorough, with legacy aliases and correct rsplit_once('_') for multi-word fields.
  • debug_log.rs batching + per-second timestamp cache are real hot-path wins, well-scoped with thread_local.
  • shell.rs threat-model doc (lines 130-149) is exemplary.
  • AGENTS.md conventions upheld: no unsafe, no unguarded println!/eprintln!/dbg!, #[allow] only on tests/print_stdout when TUI suspended.

The remaining items are stylistic and suitable for follow-up; none block merge.

New%20session%20-%202026-06-14T16%3A47%3A14.310Z
opencode session  |  github run

@leszek3737 leszek3737 merged commit ec3ec73 into main Jun 14, 2026
6 checks passed
@leszek3737 leszek3737 deleted the fab7 branch June 14, 2026 21:08
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