Skip to main content

trail/app/
state.rs

1//! Core application state: `AppState`, `Entry`, `EntryKind`.
2//!
3//! The central data model that every other module reads or mutates.
4//! `state.dirty` is the invariant that drives the render loop: every
5//! mutation sets it, every render clears it.
6
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use nucleo::pattern::{CaseMatching, Normalization, Pattern};
11use nucleo::{Config, Matcher, Utf32Str};
12use thiserror::Error;
13
14use crate::app::history::NavigationHistory;
15use crate::app::mode::Mode;
16use crate::app::tabs::TabManager;
17use crate::config::TrailConfig;
18use crate::input::command_parser::{CommandHistory, TabState};
19use crate::plugin::bookmarks::{BookmarkError, BookmarkStore};
20use crate::preview::provider::PreviewContent;
21
22// ── Error type ────────────────────────────────────────────────────────────────
23
24/// Errors that can arise when loading or mutating `AppState`.
25#[derive(Debug, Error)]
26pub enum StateError {
27    /// A directory listing failed.
28    #[error("failed to read directory {path}: {source}")]
29    ReadDir {
30        path: PathBuf,
31        #[source]
32        source: std::io::Error,
33    },
34    /// A filesystem mutation (rename/move/delete/etc.) failed.
35    #[error("filesystem error: {0}")]
36    FsOp(#[from] crate::actions::fs_ops::FsError),
37    /// Built-in configuration failed to load.
38    #[error("config error: {0}")]
39    Config(#[from] crate::config::ConfigError),
40}
41
42// ── Entry ─────────────────────────────────────────────────────────────────────
43
44/// The filesystem kind of a directory entry.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum EntryKind {
47    /// A directory (or a junction on Windows).
48    Dir,
49    /// A regular file.
50    File,
51    /// A symbolic link (the target kind is not resolved for display purposes).
52    Symlink,
53}
54
55/// The git status of a single file within a repository.
56///
57/// Populated asynchronously by the git worker (Phase 4); `None` until then.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum GitFileStatus {
60    /// File is untracked.
61    #[allow(dead_code)]
62    Untracked,
63    /// File has been modified.
64    #[allow(dead_code)]
65    Modified,
66    /// File has been added to the index.
67    #[allow(dead_code)]
68    Added,
69    /// File has been deleted.
70    #[allow(dead_code)]
71    Deleted,
72    /// File has been renamed.
73    #[allow(dead_code)]
74    Renamed,
75    /// File is unmodified / clean.
76    Clean,
77}
78
79/// A single entry shown in the navigation panel.
80#[derive(Debug, Clone)]
81pub struct Entry {
82    /// Full absolute path to this entry.
83    pub path: PathBuf,
84    /// The file-name component (no parent path), pre-extracted for display.
85    pub file_name: String,
86    /// Whether this entry is a directory, file, or symlink.
87    pub kind: EntryKind,
88    /// Whether the entry name starts with `.` (Unix hidden-file convention).
89    pub is_hidden: bool,
90    /// Filesystem metadata, if available.
91    ///
92    /// Collected by the directory iterator as it walks — one `lstat` that has
93    /// already been paid for — which is why sorting by size or time and showing
94    /// either in the listing cost no I/O at all. `None` when the entry could
95    /// not be stat'd; see [`crate::app::sort::sort_entries`] for where such an
96    /// entry lands.
97    pub metadata: Option<fs::Metadata>,
98    /// Git status, populated asynchronously by the git worker (Phase 4).
99    /// `None` before the worker reports back, or outside a git repo.
100    pub git_status: Option<GitFileStatus>,
101    /// Whether this entry is a text file, once anything has had reason to find
102    /// out. `None` means "not yet classified", which is the state every entry
103    /// starts in.
104    ///
105    /// This used to be decided for every file during the directory listing,
106    /// which meant opening and reading 8 KB of each one on the UI thread — a
107    /// directory of 500 files cost 500 reads per navigation. The answer is now
108    /// produced by the highlight worker, which has to read the file anyway, and
109    /// cached here by `workers::merge` so the next preview of the same entry is
110    /// immediate.
111    pub is_text: Option<bool>,
112}
113
114impl Entry {
115    /// Constructs an `Entry` from a `DirEntry`.
116    ///
117    /// Returns `None` if the file name cannot be represented as UTF-8.
118    ///
119    /// Performs no I/O beyond the `stat` the directory iterator already carries:
120    /// whether a file is text is left to the worker that reads it, because
121    /// answering it here cost one open and one 8 KB read per entry, on the UI
122    /// thread, every time a directory was listed.
123    fn from_dir_entry(de: &fs::DirEntry) -> Option<Self> {
124        let path = de.path();
125        let file_name = path.file_name()?.to_str()?.to_owned();
126        let is_hidden = file_name.starts_with('.');
127        let metadata = de.metadata().ok();
128
129        let kind = if let Some(ref m) = metadata {
130            if m.is_symlink() {
131                EntryKind::Symlink
132            } else if m.is_dir() {
133                EntryKind::Dir
134            } else {
135                EntryKind::File
136            }
137        } else {
138            // If we can't stat, treat as file.
139            EntryKind::File
140        };
141
142        Some(Entry {
143            path,
144            file_name,
145            kind,
146            is_hidden,
147            metadata,
148            git_status: None,
149            // Unknown until something reads the file. See the field's docs.
150            is_text: None,
151        })
152    }
153}
154
155// ── Git directory state ───────────────────────────────────────────────────────
156
157/// Git repository information for the current directory.
158///
159/// Populated asynchronously by the git worker (Phase 4). `AppState::git`
160/// holds `None` until the worker reports back.
161#[derive(Debug, Clone)]
162pub struct GitDirState {
163    /// The active branch name, or `"HEAD"` if in detached-HEAD state.
164    pub branch: String,
165    /// Whether the working tree has any uncommitted changes.
166    pub is_dirty: bool,
167}
168
169// ── Preview slot ──────────────────────────────────────────────────────────────
170
171/// The preview pane's current content and the generation it belongs to.
172///
173/// `generation` is incremented on every selection change so that
174/// late-arriving worker results for a since-abandoned selection can be
175/// discarded in `workers::merge` (Phase 4). The field is defined here in
176/// Phase 1 so the state shape is stable — the guard is exercised in Phase 4/5.
177#[derive(Debug, Clone, Default)]
178pub struct PreviewSlot {
179    /// The path whose preview is currently being displayed (or loading).
180    pub for_path: PathBuf,
181    /// Monotonically increasing counter. Every selection change bumps this.
182    pub generation: u64,
183    /// The actual content to render, or a loading placeholder.
184    pub content: PreviewContent,
185    /// Index of the first content line drawn in the pane.
186    ///
187    /// Counted in *logical* lines (one entry of the content's line vector), not
188    /// wrapped terminal rows, because the render path slices the line vector
189    /// rather than offsetting a `Paragraph`. The limits it is clamped to do
190    /// count wrapped rows — see [`Self::viewport_width`].
191    pub scroll: usize,
192    /// Height in rows of the pane's interior, recorded by the render pass.
193    ///
194    /// Only the renderer knows the pane size, so it writes this on every frame
195    /// and the scroll actions clamp against it. It is zero until the first
196    /// render, which makes every scroll request a no-op — harmless, because no
197    /// key can be pressed before the first frame is drawn.
198    pub viewport_height: usize,
199    /// Width in columns of the pane's interior, recorded by the render pass.
200    ///
201    /// A line wider than this wraps onto further rows, so the scroll limit and
202    /// the page step measure rows with it rather than assuming one row per
203    /// line. Zero until the first render, which counts every line as one row.
204    pub viewport_width: u16,
205    /// Whether the loaded content stops short of the end of the file.
206    ///
207    /// Set from `WorkerMsg::Preview` when the highlight worker hit
208    /// `[preview] max_lines`. The renderer marks such a preview so the pane
209    /// never silently claims to show a whole file.
210    pub truncated: bool,
211    /// Whether the last frame drew an inline image.
212    ///
213    /// Written by the renderer, for the renderer. An inline image is placed by
214    /// the terminal itself rather than by ratatui, so no cell diff can erase it;
215    /// the frame that stops showing one has to clear the screen, and this is how
216    /// it knows. Everything else relies on the ordinary diff.
217    pub drew_image: bool,
218}
219
220/// Number of lines a page scroll leaves on screen for continuity.
221///
222/// Matches the overlap Vim's `Ctrl-F`/`Ctrl-B` keep, so the eye has an anchor
223/// in the new screenful.
224const PAGE_OVERLAP_LINES: usize = 2;
225
226impl PreviewSlot {
227    /// Prepares the slot for a preview of `path` and returns the new generation.
228    ///
229    /// Bumps `generation` so that in-flight worker results for the previous
230    /// request are dropped by [`crate::workers::merge`], and resets the scroll
231    /// position **only when `path` differs from the current `for_path`**. A
232    /// re-preview of the same path (a filesystem-watch tick, `R`, or toggling
233    /// hidden files) therefore keeps the reader where they were.
234    pub fn begin(&mut self, path: &Path) -> u64 {
235        self.generation = self.generation.wrapping_add(1);
236        if self.for_path != path {
237            self.for_path = path.to_path_buf();
238            self.scroll = 0;
239            self.truncated = false;
240        }
241        self.generation
242    }
243
244    /// Returns the largest useful scroll offset: the offset at which the last
245    /// content line ends on the pane's last row.
246    ///
247    /// Wrapped lines count for every row they take, so a file with long lines
248    /// can still be scrolled until its end is on screen.
249    ///
250    /// Returns `0` for content that does not scroll (an image, a placeholder)
251    /// and for content that fits the pane.
252    pub fn max_scroll(&self) -> usize {
253        let len = self.content.scrollable_len().unwrap_or(0);
254        len - self.lines_fitting_before(len)
255    }
256
257    /// Number of logical lines drawn whole when the pane starts at line `start`.
258    ///
259    /// At least `1` while `start` is inside the content and the pane has a row:
260    /// a single line taller than the pane still counts as the one being shown,
261    /// so a page step always advances. `0` past the end or for content that
262    /// does not scroll.
263    pub fn lines_visible_from(&self, start: usize) -> usize {
264        let len = self.content.scrollable_len().unwrap_or(0);
265        let mut rows = 0;
266        let mut count = 0;
267        for index in start..len {
268            rows += self.content.display_rows(index, self.viewport_width);
269            if rows > self.viewport_height {
270                break;
271            }
272            count += 1;
273        }
274        self.at_least_one(count, start < len)
275    }
276
277    /// Number of logical lines ending just above line `end` that the pane can
278    /// show whole — the lines a screen ending there would hold.
279    ///
280    /// The mirror of [`Self::lines_visible_from`], with the same floor of `1`.
281    fn lines_fitting_before(&self, end: usize) -> usize {
282        let mut rows = 0;
283        let mut count = 0;
284        for index in (0..end).rev() {
285            rows += self.content.display_rows(index, self.viewport_width);
286            if rows > self.viewport_height {
287                break;
288            }
289            count += 1;
290        }
291        self.at_least_one(count, end > 0)
292    }
293
294    /// Raises a line count of zero to one when there is a line to show and a
295    /// row to show it in, so an over-tall line never stalls the scroll.
296    fn at_least_one(&self, count: usize, has_line: bool) -> usize {
297        if has_line && self.viewport_height > 0 {
298            count.max(1)
299        } else {
300            count
301        }
302    }
303
304    /// Clamps `scroll` into range, leaving it alone for content that does not
305    /// scroll.
306    ///
307    /// Called by the render pass once it has recorded `viewport_height`: it is
308    /// the only place that sees a terminal resize, and it must not zero a
309    /// position that a `Loading` frame is about to replace with real content.
310    pub fn clamp_scroll(&mut self) {
311        if self.content.scrollable_len().is_none() {
312            return;
313        }
314        self.scroll = self.scroll.min(self.max_scroll());
315    }
316
317    /// The scroll offset one page down from `from`: the lines on screen less
318    /// [`PAGE_OVERLAP_LINES`], and never less than one line on.
319    fn page_down_from(&self, from: usize) -> usize {
320        let step = self
321            .lines_visible_from(from)
322            .saturating_sub(PAGE_OVERLAP_LINES)
323            .max(1);
324        from.saturating_add(step)
325    }
326
327    /// The scroll offset one page up from `from`: the screen that ends
328    /// [`PAGE_OVERLAP_LINES`] into the current one, and never less than one
329    /// line back.
330    fn page_up_from(&self, from: usize) -> usize {
331        let len = self.content.scrollable_len().unwrap_or(0);
332        let end = from.saturating_add(PAGE_OVERLAP_LINES).min(len);
333        let target = end.saturating_sub(self.lines_fitting_before(end));
334        target.min(from.saturating_sub(1))
335    }
336}
337
338/// Offsets `base` by `delta`, saturating at zero instead of wrapping.
339///
340/// `usize` cannot hold a negative intermediate, so an upward scroll past the
341/// first line must clamp rather than underflow.
342fn offset_by(base: usize, delta: isize) -> usize {
343    if delta >= 0 {
344        base.saturating_add(delta as usize)
345    } else {
346        base.saturating_sub(delta.unsigned_abs())
347    }
348}
349
350// ── Filter state ──────────────────────────────────────────────────────────────
351
352/// Active fuzzy-filter state while in Search Mode.
353///
354/// `matches` holds indices into `AppState::entries` ordered by descending
355/// fuzzy-match score. `scores` is a parallel `Vec` keeping the score for
356/// each corresponding match (used for sorting; not rendered directly).
357#[derive(Debug, Clone, Default)]
358pub struct FilterState {
359    /// The current query string.
360    pub query: String,
361    /// Indices into `AppState::entries`, ordered by descending match score.
362    pub matches: Vec<usize>,
363    /// Match scores parallel to `matches`. `scores[i]` is the score for
364    /// `matches[i]`.
365    pub scores: Vec<u32>,
366}
367
368// ── Status bar state ──────────────────────────────────────────────────────────
369
370/// Derived values cached for the status bar so the render path is pure.
371///
372/// Updated whenever the underlying state changes rather than recomputed
373/// on every frame.
374#[derive(Debug, Clone, Default)]
375pub struct StatusBarState {
376    /// Human-readable string for the current working directory.
377    pub cwd_display: String,
378    /// Total number of entries (after hidden-file filtering).
379    pub entry_count: usize,
380}
381
382// ── Notices ───────────────────────────────────────────────────────────────────
383
384/// Whether a [`Notice`] reports a failure or just says what happened.
385///
386/// The distinction is the whole point of the type: before it existed, a
387/// successful `:bookmark` reported itself through the error field and the status
388/// bar dutifully rendered `Error: bookmark added: name` in red.
389#[derive(Debug, Clone, Copy, PartialEq, Eq)]
390pub enum NoticeLevel {
391    /// Something worked, and saying so is useful — a yank, a bookmark.
392    Info,
393    /// Something failed. Rendered in the theme's error colour.
394    Error,
395}
396
397/// One line of feedback for the status bar.
398#[derive(Debug, Clone, PartialEq, Eq)]
399pub struct Notice {
400    /// The text to show. Kept short by the caller; the status bar elides what
401    /// does not fit.
402    pub text: String,
403    /// Whether this is a failure or an ordinary report.
404    pub level: NoticeLevel,
405}
406
407// ── AppState ──────────────────────────────────────────────────────────────────
408
409/// Central application state owned by the UI thread.
410///
411/// Every mutation must set `dirty = true` so the render loop knows to
412/// redraw. Every render clears `dirty`.
413#[derive(Debug)]
414pub struct AppState {
415    /// Runtime configuration loaded from defaults plus any user TOML file.
416    pub config: TrailConfig,
417    /// Current working directory being displayed.
418    ///
419    /// Always absolute, and always in the form [`crate::pathfmt::simplified`]
420    /// produces — on Windows that means no `\\?\` prefix unless the path
421    /// genuinely needs one. Entry paths, the panel title, yanks and the
422    /// `--cwd-file` handoff are all derived from here, so normalizing this
423    /// field is what keeps the prefix out of all of them.
424    pub cwd: PathBuf,
425    /// The directory Trail was launched from — the shell's working directory
426    /// at startup, which is unaffected by navigation.
427    ///
428    /// This is the base for `yr` (copy relative path): the point of a relative
429    /// path is that it can be pasted back into the shell that started Trail,
430    /// and that shell has not moved. Relative to `cwd` instead, every yank
431    /// would be the selection's own file name, which is what `yn` yanks.
432    ///
433    /// Canonicalized through [`crate::pathfmt::canonicalize`], the same as
434    /// `cwd` and therefore as entry paths, so the two can be related
435    /// component-by-component. Both spellings have to match: relating a
436    /// `\\?\C:\…` base to a `C:\…` target would fail on the prefix component
437    /// and silently fall back to yanking the absolute path.
438    pub launch_dir: PathBuf,
439    /// Directory-first sorted listing of the current directory.
440    /// Hidden entries are included but may be filtered from display.
441    pub entries: Vec<Entry>,
442    /// Index into `entries` of the currently highlighted item.
443    pub selected: usize,
444    /// Where the navigation panel was scrolled to on the last frame.
445    ///
446    /// Written by the renderer, which is the only thing that knows the pane
447    /// height, through [`crate::app::scroll::NavScroll::update`]. `None` until
448    /// the first frame.
449    pub nav_scroll: Option<crate::app::scroll::NavScroll>,
450    /// Current interaction mode.
451    pub mode: Mode,
452    /// Navigation history for `u`/`Ctrl-r` back/forward.
453    pub history: NavigationHistory,
454    /// Active fuzzy-filter while in Search Mode; `None` otherwise.
455    pub filter: Option<FilterState>,
456    /// Current preview pane content + generation counter.
457    pub preview: PreviewSlot,
458    /// Git repository state for the current directory.
459    /// `None` before the git worker reports back or outside a git repo.
460    pub git: Option<GitDirState>,
461    /// Cached status bar strings.
462    pub status: StatusBarState,
463    /// Whether hidden files are shown. Toggled by a keybinding (Phase 1).
464    pub show_hidden: bool,
465    /// How the active tab orders its listing.
466    ///
467    /// A mirror of the active [`crate::app::tabs::TabState`]'s `sort`, kept
468    /// flat here for the same reason `cwd` and `selected` are: the listing code
469    /// reads it on every `load_dir` and should not have to go through the tab
470    /// manager to do so. `save_to_active_tab` and `restore_from_active_tab`
471    /// keep the two in step.
472    pub sort: crate::app::sort::SortSettings,
473    /// Set `true` on any state mutation; cleared after each render.
474    pub dirty: bool,
475    /// The entry to re-select when returning to a directory visited before.
476    ///
477    /// Key: the directory's absolute path; value: the absolute path of the entry
478    /// that was selected in it. A **path**, not an index: an index names
479    /// whichever file happens to occupy that row now, so a directory whose
480    /// contents changed while the user was away would come back with the
481    /// selection on something else.
482    pub selection_memory: std::collections::HashMap<PathBuf, PathBuf>,
483
484    // ── Phase 3 fields ────────────────────────────────────────────────────────
485    /// `true` while awaiting the user's confirmation of a `dd` delete.
486    ///
487    /// The status bar renders a confirmation prompt while this is set.
488    /// Confirmed via `Enter`; cancelled via `Esc`.
489    pub pending_delete: bool,
490    /// The one message the status bar is currently showing, if any.
491    ///
492    /// Set through [`AppState::notify`] and [`AppState::set_error`] rather than
493    /// directly, so that every message reaches the log as well as the screen.
494    /// Cleared by [`AppState::clear_notice`] on the next keystroke, which is
495    /// what makes it a flash rather than something that lingers over
496    /// directories it has nothing to do with.
497    pub notice: Option<Notice>,
498    /// The most-recently yanked string (set by clipboard operations).
499    ///
500    /// The full text, not the summary the status bar flashes — observable in
501    /// tests and the record of what the OS clipboard was handed.
502    pub last_yank: Option<String>,
503    /// Persisted command history for Command Mode.
504    pub command_history: CommandHistory,
505    /// Tab-completion cycling state for Command Mode. Stored here so it
506    /// survives across individual key dispatches within the same mode session.
507    pub tab_state: TabState,
508
509    // ── Phase 3 multi-key sequence state ───────────────────────────────────────
510    /// Tracks the first key of multi-key Navigation Mode sequences:
511    /// `y` (for `ya`/`yr`/`yn`/`yc`) and `d` (for `dd`).
512    pub pending_nav_key: Option<char>,
513
514    // ── Phase 6 shell integration ──────────────────────────────────────────────
515    /// A pending `RunExternal` action to be executed by the event loop.
516    ///
517    /// Set by `apply()` when an editor-open or `!<shell command>` action is
518    /// requested. The event loop in `main.rs` drains this field after each
519    /// key dispatch, calling `shell_exec::run_external` and forcing a full
520    /// redraw. Using a state field rather than a channel keeps `apply()`
521    /// synchronous and testable without a running terminal.
522    pub pending_external: Option<crate::actions::Action>,
523
524    // ── Phase 8 tab management ─────────────────────────────────────────────────
525    /// Multi-tab state manager.
526    ///
527    /// The active tab's `cwd`/`entries`/`selected`/`history` are kept in sync
528    /// with the flat `AppState` fields so all existing code continues to work
529    /// unchanged. On a tab switch the active state is written to the old tab
530    /// slot and read from the new one.
531    pub tab_manager: TabManager,
532
533    // -- Phase 8 bookmark store --------------------------------------------------
534    /// Persisted named-path bookmark registry.
535    ///
536    /// None before startup initialization. Bookmark operations silently
537    /// degrade when None (e.g. data directory unavailable).
538    pub bookmark_store: Option<BookmarkStore>,
539    /// Phase 8 plugin engine for firing hooks on navigation.
540    pub plugin_engine: Option<crate::plugin::PluginEngine>,
541    /// Recent directories tracker.
542    pub recent_dirs: crate::session::RecentDirs,
543    /// The terminal panel and its shells.
544    pub terminal: crate::terminal::TerminalPanel,
545    /// Copies and moves running in the background.
546    pub transfers: crate::actions::transfer::Transfers,
547    /// Entries marked for a paste, move, copy or delete. One set for every
548    /// tab, so marks made in one can be pasted from another.
549    pub marks: crate::app::marks::MarkSet,
550    /// The `:set` changes made this session, for `:config write`.
551    pub settings: crate::config::persist::SessionSettings,
552    /// The last file operation, for `@` to do again.
553    pub last_repeatable: Option<crate::actions::Repeatable>,
554}
555
556impl AppState {
557    /// Creates a new `AppState` rooted at `start_path`.
558    ///
559    /// Loads the initial directory listing synchronously. Returns an error
560    /// if `start_path` cannot be read.
561    ///
562    /// # Errors
563    ///
564    /// Returns [`StateError::ReadDir`] if the initial directory listing fails.
565    // clippy: dead_code — used heavily by integration tests in `tests/`
566    #[allow(dead_code)]
567    pub fn new(start_path: PathBuf) -> Result<Self, StateError> {
568        let config = crate::config::load(None)?;
569        Self::with_config(start_path, config)
570    }
571
572    /// Creates a new `AppState` rooted at `start_path` using `config`.
573    ///
574    /// # Errors
575    ///
576    /// Returns [`StateError::ReadDir`] if the initial directory listing fails.
577    pub fn with_config(start_path: PathBuf, config: TrailConfig) -> Result<Self, StateError> {
578        let cwd = crate::pathfmt::canonicalize(&start_path).unwrap_or(start_path);
579
580        // The process working directory at startup is the shell's, since
581        // nothing has navigated yet — Trail changes `cwd` here, never the
582        // process's own directory. If it cannot be read (the shell was started
583        // in a directory that has since been deleted), fall back to the start
584        // path, which at worst makes `yr` behave as it did before.
585        let launch_dir = std::env::current_dir()
586            .map(|dir| crate::pathfmt::canonicalize(&dir).unwrap_or(dir))
587            .unwrap_or_else(|e| {
588                tracing::debug!("could not read the launch directory: {e}");
589                cwd.clone()
590            });
591
592        // The configured order seeds the first tab; every tab opened later
593        // inherits the order of the tab it was opened from.
594        let sort = config.navigation.sort_settings();
595
596        let mut state = AppState {
597            config,
598            cwd: cwd.clone(),
599            launch_dir,
600            entries: Vec::new(),
601            selected: 0,
602            nav_scroll: None,
603            mode: Mode::default(),
604            history: NavigationHistory::new(),
605            filter: None,
606            preview: PreviewSlot::default(),
607            git: None,
608            status: StatusBarState::default(),
609            show_hidden: false,
610            sort,
611            dirty: true,
612            selection_memory: std::collections::HashMap::new(),
613            pending_delete: false,
614            notice: None,
615            last_yank: None,
616            command_history: CommandHistory::new(),
617            tab_state: TabState::new(),
618            pending_nav_key: None,
619            pending_external: None,
620            tab_manager: TabManager::new(cwd.clone(), sort),
621            bookmark_store: None,
622            plugin_engine: None,
623            recent_dirs: crate::session::RecentDirs::default(),
624            terminal: crate::terminal::TerminalPanel::default(),
625            transfers: crate::actions::transfer::Transfers::default(),
626            marks: crate::app::marks::MarkSet::default(),
627            settings: crate::config::persist::SessionSettings::default(),
628            last_repeatable: None,
629        };
630
631        state.load_dir(&cwd)?;
632        Ok(state)
633    }
634
635    /// Recomputes the fuzzy-filter against the current `entries` using
636    /// `query`, storing results sorted by descending score in
637    /// `self.filter`. Also auto-selects the top match (index 0).
638    ///
639    /// Call this every time the query changes in Search Mode. The filter
640    /// runs synchronously on the UI thread — the architecture doc
641    /// designates fuzzy filtering as "fast enough not to need offloading."
642    ///
643    /// An empty query matches all entries (no score order; original listing
644    /// order is preserved).
645    pub fn apply_filter(&mut self, query: String) {
646        let mut filter = FilterState {
647            query: query.clone(),
648            matches: Vec::new(),
649            scores: Vec::new(),
650        };
651
652        if query.is_empty() {
653            // Empty query: show all visible entries in their original order.
654            filter.matches = (0..self.entries.len())
655                .filter(|&i| self.show_hidden || !self.entries[i].is_hidden)
656                .collect();
657            filter.scores = vec![0u32; filter.matches.len()];
658        } else {
659            let pattern = Pattern::parse(&query, CaseMatching::Ignore, Normalization::Smart);
660            let mut matcher = Matcher::new(Config::DEFAULT);
661
662            // Score each visible entry and collect those that match (score > 0).
663            let mut scored: Vec<(usize, u32)> = self
664                .entries
665                .iter()
666                .enumerate()
667                .filter(|(_, e)| self.show_hidden || !e.is_hidden)
668                .filter_map(|(idx, entry)| {
669                    let mut buf = Vec::new();
670                    let haystack = Utf32Str::new(&entry.file_name, &mut buf);
671                    pattern.score(haystack, &mut matcher).map(|s| (idx, s))
672                })
673                .collect();
674
675            // Sort by score descending so the best match is first.
676            scored.sort_by_key(|b| std::cmp::Reverse(b.1));
677
678            filter.matches = scored.iter().map(|(idx, _)| *idx).collect();
679            filter.scores = scored.iter().map(|(_, score)| *score).collect();
680        }
681
682        // Auto-select the top match.
683        self.selected = 0;
684        self.filter = Some(filter);
685
686        // Keep Mode::Search.query in sync with the FilterState.query.
687        if let Mode::Search {
688            query: q,
689            matches: m,
690        } = &mut self.mode
691        {
692            *q = query;
693            *m = self
694                .filter
695                .as_ref()
696                .map(|f| f.matches.clone())
697                .unwrap_or_default();
698        }
699
700        self.dirty = true;
701    }
702
703    /// Drops the Search Mode filter, keeping the selection on the entry it was on.
704    ///
705    /// While filtering, `selected` counts rows of the match list; without the
706    /// filter it counts rows of the full listing. Clearing the filter alone
707    /// would leave the cursor on whatever entry sits at that row, losing the
708    /// one the search found. Does not change `mode`. A no-op without a filter.
709    pub fn clear_filter(&mut self) {
710        if self.filter.is_none() {
711            return;
712        }
713        let anchor = self.selected_entry().map(|e| e.path.clone());
714        self.filter = None;
715        self.restore_selection(anchor.as_deref(), 0);
716        self.dirty = true;
717    }
718
719    /// Returns the number of filtered-visible entries, or `visible_count()`
720    /// when no filter is active.
721    pub fn filtered_count(&self) -> usize {
722        match &self.filter {
723            Some(f) => f.matches.len(),
724            None => self.visible_count(),
725        }
726    }
727
728    /// Returns an iterator over the entries visible given the current filter
729    /// and `show_hidden` flag.
730    ///
731    /// When a filter is active, entries are yielded in match-score order.
732    /// When no filter is active, entries are yielded in listing order
733    /// (excluding hidden entries unless `show_hidden` is `true`).
734    pub fn filtered_entries(&self) -> impl Iterator<Item = (usize, &Entry)> {
735        // Return a concrete `Vec` iterator so both branches have the same type.
736        let pairs: Vec<(usize, &Entry)> = match &self.filter {
737            Some(f) => f
738                .matches
739                .iter()
740                .filter_map(|&idx| self.entries.get(idx).map(|e| (idx, e)))
741                .collect(),
742            None => self
743                .entries
744                .iter()
745                .enumerate()
746                .filter(|(_, e)| self.show_hidden || !e.is_hidden)
747                .collect(),
748        };
749        pairs.into_iter()
750    }
751
752    /// Returns the currently selected `Entry` under the active filter,
753    /// or `None` if the visible (filtered) list is empty.
754    pub fn selected_filtered_entry(&self) -> Option<&Entry> {
755        self.filtered_entries().nth(self.selected).map(|(_, e)| e)
756    }
757
758    /// Reads and sorts the directory at `path`, replacing `self.entries`.
759    ///
760    /// Sort order comes from `self.sort`, which is the active tab's — see
761    /// [`crate::app::sort::sort_entries`] for the rules. Hidden entries are
762    /// always included in `entries`; visibility is controlled by `show_hidden`
763    /// at render time.
764    ///
765    /// Also updates `status` and sets `dirty`.
766    ///
767    /// # Errors
768    ///
769    /// Returns [`StateError::ReadDir`] if the directory cannot be listed.
770    pub fn load_dir(&mut self, path: &Path) -> Result<(), StateError> {
771        let read = fs::read_dir(path).map_err(|e| StateError::ReadDir {
772            path: path.to_owned(),
773            source: e,
774        })?;
775
776        let mut entries: Vec<Entry> = read
777            .filter_map(|res| res.ok())
778            .filter_map(|de| Entry::from_dir_entry(&de))
779            .collect();
780
781        crate::app::sort::sort_entries(&mut entries, self.sort);
782
783        self.entries = entries;
784
785        // Re-select the entry this directory was left on, by path, so a listing
786        // that changed in the meantime does not hand the selection to a
787        // different file. Falls back to the top when that entry is gone.
788        let remembered = self.selection_memory.get(path).cloned();
789        self.restore_selection(remembered.as_deref(), 0);
790
791        self.update_status();
792        self.dirty = true;
793        Ok(())
794    }
795
796    /// Returns the row `path` occupies in the list the user is looking at, or
797    /// `None` when it is not in it.
798    ///
799    /// Counted the same way `selected` is: against the filtered list in Search
800    /// Mode and against the visible listing otherwise, so the result can be
801    /// assigned to `selected` directly.
802    fn index_of_path(&self, path: &Path) -> Option<usize> {
803        if self.filter.is_some() {
804            self.filtered_entries().position(|(_, e)| e.path == path)
805        } else {
806            self.visible_entries().position(|e| e.path == path)
807        }
808    }
809
810    /// Puts the selection back on `anchor`, or on `fallback` when that entry is
811    /// no longer in the list.
812    ///
813    /// The selection is an index, so anything that changes the length or the
814    /// order of the list — revealing hidden files, a re-read after a filesystem
815    /// change, returning to a directory — moves it onto a different entry unless
816    /// the entry it was on is looked up again by path. Both arguments are
817    /// clamped, and an empty list selects row zero.
818    fn restore_selection(&mut self, anchor: Option<&Path>, fallback: usize) {
819        let count = self.filtered_count();
820        if count == 0 {
821            self.selected = 0;
822            return;
823        }
824        let found = anchor.and_then(|path| self.index_of_path(path));
825        self.selected = found.unwrap_or_else(|| fallback.min(count - 1));
826    }
827
828    /// Records what a worker found out about whether `path` is text.
829    ///
830    /// The classification is a fact about the file rather than about the current
831    /// selection, so it is worth keeping even when the preview it arrived with is
832    /// stale: it is what lets the *next* preview of the same entry go straight to
833    /// the right provider instead of deferring to a worker again. Silently
834    /// ignores a path that is not in the current listing.
835    pub fn classify_entry(&mut self, path: &Path, is_text: bool) {
836        if let Some(entry) = self.entries.iter_mut().find(|e| e.path == path) {
837            entry.is_text = Some(is_text);
838        }
839    }
840
841    /// Records which entry is selected in `cwd`, for [`AppState::load_dir`] to
842    /// restore the next time this directory is listed.
843    ///
844    /// A directory with nothing in it records nothing, leaving any earlier
845    /// memory of it alone.
846    fn remember_selection(&mut self) {
847        if let Some(path) = self.selected_entry().map(|e| e.path.clone()) {
848            self.selection_memory.insert(self.cwd.clone(), path);
849        }
850    }
851
852    /// Returns the number of entries that are visible given `show_hidden`.
853    pub fn visible_count(&self) -> usize {
854        if self.show_hidden {
855            self.entries.len()
856        } else {
857            self.entries.iter().filter(|e| !e.is_hidden).count()
858        }
859    }
860
861    /// Returns a slice of entries visible given the current `show_hidden` flag.
862    ///
863    /// Returns an iterator rather than allocating a new `Vec`.
864    pub fn visible_entries(&self) -> impl Iterator<Item = &Entry> {
865        self.entries
866            .iter()
867            .filter(move |e| self.show_hidden || !e.is_hidden)
868    }
869
870    /// Returns the currently selected `Entry`, or `None` if the list is empty.
871    ///
872    /// In Search Mode, delegates to `selected_filtered_entry` so the
873    /// selection is resolved against the match list rather than the raw
874    /// listing. Outside Search Mode the raw visible listing is used.
875    pub fn selected_entry(&self) -> Option<&Entry> {
876        if self.filter.is_some() {
877            self.selected_filtered_entry()
878        } else {
879            self.visible_entries().nth(self.selected)
880        }
881    }
882
883    /// Navigates into the directory at `path`, pushing the current `cwd` onto
884    /// the history stack and loading the new listing.
885    ///
886    /// Saves the current selection in `selection_memory` before navigating.
887    ///
888    /// # Errors
889    ///
890    /// Propagates any `StateError` from `load_dir`.
891    pub fn enter_dir(&mut self, path: PathBuf) -> Result<(), StateError> {
892        // Not every caller hands over a path descended from `cwd`: `:jump`
893        // reads one out of the bookmark store, which may have been written by
894        // a build that saved the Windows verbatim form. Normalize here, the
895        // one place an outside path becomes `cwd`, so the prefix cannot come
896        // back in and spread to the entries, the yanks and the history.
897        let path = crate::pathfmt::simplified(&path);
898
899        // Remember where we were in the old directory.
900        self.remember_selection();
901        self.history.push(self.cwd.clone());
902        self.cwd = path.clone();
903
904        let res = self.load_dir(&self.cwd.clone());
905
906        self.recent_dirs.visit(path.clone());
907        if let Some(engine) = &self.plugin_engine {
908            engine.fire_on_enter_dir(&path);
909        }
910
911        res
912    }
913
914    /// Navigates to the parent directory, if one exists.
915    ///
916    /// Saves the current selection before navigating.
917    ///
918    /// # Errors
919    ///
920    /// Propagates any `StateError` from `load_dir`.
921    pub fn go_parent(&mut self) -> Result<(), StateError> {
922        if let Some(parent) = self.cwd.parent().map(|p| p.to_owned()) {
923            self.remember_selection();
924            self.history.push(self.cwd.clone());
925            self.cwd = parent;
926            self.load_dir(&self.cwd.clone())?;
927        }
928        Ok(())
929    }
930
931    /// Navigates backward in history (bound to `u`).
932    ///
933    /// # Errors
934    ///
935    /// Propagates any `StateError` from `load_dir`.
936    pub fn history_back(&mut self) -> Result<(), StateError> {
937        if let Some(prev) = self.history.back(self.cwd.clone()) {
938            self.remember_selection();
939            self.cwd = prev;
940            self.load_dir(&self.cwd.clone())?;
941        }
942        Ok(())
943    }
944
945    /// Navigates forward in history (bound to `Ctrl-r`).
946    ///
947    /// # Errors
948    ///
949    /// Propagates any `StateError` from `load_dir`.
950    pub fn history_forward(&mut self) -> Result<(), StateError> {
951        if let Some(next) = self.history.forward(self.cwd.clone()) {
952            self.remember_selection();
953            self.cwd = next;
954            self.load_dir(&self.cwd.clone())?;
955        }
956        Ok(())
957    }
958
959    /// Moves the selection down by one within the visible (or filtered) list,
960    /// clamping at the last entry.
961    ///
962    /// Sets `dirty = true` if the selection changed.
963    pub fn move_down(&mut self) {
964        let count = self.filtered_count();
965        if count == 0 {
966            return;
967        }
968        let new = (self.selected + 1).min(count - 1);
969        if new != self.selected {
970            self.selected = new;
971            self.dirty = true;
972        }
973    }
974
975    /// Moves the selection up by one, clamping at zero.
976    ///
977    /// Sets `dirty = true` if the selection changed.
978    pub fn move_up(&mut self) {
979        if self.selected > 0 {
980            self.selected -= 1;
981            self.dirty = true;
982        }
983    }
984
985    /// Jumps the selection to the first visible entry.
986    ///
987    /// Sets `dirty = true` if the selection changed.
988    pub fn jump_top(&mut self) {
989        if self.selected != 0 {
990            self.selected = 0;
991            self.dirty = true;
992        }
993    }
994
995    /// Jumps the selection to the last visible (or filtered) entry.
996    ///
997    /// Sets `dirty = true` if the selection changed.
998    pub fn jump_bottom(&mut self) {
999        let count = self.filtered_count();
1000        if count == 0 {
1001            return;
1002        }
1003        let last = count - 1;
1004        if self.selected != last {
1005            self.selected = last;
1006            self.dirty = true;
1007        }
1008    }
1009
1010    /// Scrolls the preview pane by `delta` logical lines, positive for down.
1011    ///
1012    /// Clamped to the loaded content: the first line never scrolls off the top
1013    /// and the last line never scrolls off the bottom. A no-op — including for
1014    /// `dirty` — when the content does not scroll (an image or a placeholder)
1015    /// or is already at the requested edge.
1016    pub fn scroll_preview_lines(&mut self, delta: isize) {
1017        self.set_preview_scroll(offset_by(self.preview.scroll, delta));
1018    }
1019
1020    /// Scrolls the preview pane by `pages` screenfuls, positive for down.
1021    ///
1022    /// A page is the lines on screen less a two-line overlap, so consecutive
1023    /// pages share context. Lines are counted whole: a wrapped line takes all
1024    /// its rows, so a page of long lines moves fewer of them.
1025    pub fn scroll_preview_pages(&mut self, pages: isize) {
1026        let mut target = self.preview.scroll;
1027        for _ in 0..pages.unsigned_abs() {
1028            target = if pages > 0 {
1029                self.preview.page_down_from(target)
1030            } else {
1031                self.preview.page_up_from(target)
1032            };
1033        }
1034        self.set_preview_scroll(target);
1035    }
1036
1037    /// Jumps the preview pane to the end of the loaded content (`to_end`) or
1038    /// back to its first line.
1039    ///
1040    /// "End of the loaded content" is not necessarily the end of the file — see
1041    /// [`PreviewSlot::truncated`].
1042    pub fn scroll_preview_to_edge(&mut self, to_end: bool) {
1043        let target = if to_end { self.preview.max_scroll() } else { 0 };
1044        self.set_preview_scroll(target);
1045    }
1046
1047    /// Applies a clamped preview scroll offset, marking the state dirty only if
1048    /// the offset actually moved.
1049    ///
1050    /// Every public scroll entry point funnels through here so the clamp and
1051    /// the `dirty` discipline live in exactly one place.
1052    fn set_preview_scroll(&mut self, target: usize) {
1053        if self.preview.content.scrollable_len().is_none() {
1054            return;
1055        }
1056        let new = target.min(self.preview.max_scroll());
1057        if new != self.preview.scroll {
1058            self.preview.scroll = new;
1059            self.dirty = true;
1060        }
1061    }
1062
1063    /// Toggles display of hidden files, keeping the selection on the same entry.
1064    ///
1065    /// When a filter is active, re-applies it so hidden-file visibility is
1066    /// reflected correctly in the match list.
1067    ///
1068    /// # Errors
1069    ///
1070    /// Propagates any `StateError` from `load_dir`.
1071    pub fn toggle_hidden(&mut self) -> Result<(), StateError> {
1072        // Hidden entries sort in among the visible ones, so revealing them
1073        // shifts every row below the first one — holding the index still would
1074        // hand the selection to a different file, which is what it used to do.
1075        let anchor = self.selected_entry().map(|e| e.path.clone());
1076
1077        self.show_hidden = !self.show_hidden;
1078        // Re-apply the filter if one is active so the match set is correct.
1079        if let Some(f) = self.filter.take() {
1080            let q = f.query.clone();
1081            self.apply_filter(q);
1082        }
1083        self.restore_selection(anchor.as_deref(), self.selected);
1084        self.dirty = true;
1085        Ok(())
1086    }
1087
1088    /// Re-orders the listing under `settings`, keeping the selection on the
1089    /// same entry, and records the new order on the active tab.
1090    ///
1091    /// Re-sorting is done in place rather than by re-listing the directory:
1092    /// every sort key comes from data the entries already carry, so there is
1093    /// nothing to re-read and no reason to pay for a `read_dir`.
1094    ///
1095    /// Two things have to move with the order, and both are silent corruption
1096    /// if they do not:
1097    ///
1098    /// - **The filter's match list** holds *indices* into `entries`
1099    ///   ([`FilterState::matches`]), so after a re-sort every index names a
1100    ///   different file. The filter is re-applied rather than re-indexed.
1101    /// - **The selection** is an index too, so it is resolved to a path first
1102    ///   and looked up again afterwards — the same rule as `toggle_hidden`.
1103    pub fn set_sort(&mut self, settings: crate::app::sort::SortSettings) {
1104        let anchor = self.selected_entry().map(|e| e.path.clone());
1105
1106        self.sort = settings;
1107        self.tab_manager.active_tab_mut().sort = settings;
1108        crate::app::sort::sort_entries(&mut self.entries, settings);
1109
1110        if let Some(f) = self.filter.take() {
1111            let q = f.query.clone();
1112            self.apply_filter(q);
1113        }
1114        self.restore_selection(anchor.as_deref(), self.selected);
1115        self.dirty = true;
1116    }
1117
1118    /// Reloads the current directory listing in place (e.g. after an external
1119    /// change or a self-initiated filesystem mutation).
1120    ///
1121    /// Unlike `enter_dir`, this keeps the selection on the entry the user was on
1122    /// so that `R` — or a filesystem-watch refresh — does not move the cursor.
1123    /// Falls back to the same row when that entry is gone, which is what a
1124    /// delete of the selected file wants.
1125    ///
1126    /// # Errors
1127    ///
1128    /// Propagates any `StateError` from `load_dir`.
1129    pub fn refresh(&mut self) -> Result<(), StateError> {
1130        // By path, not by index: a file created or deleted above the selection
1131        // shifts every row below it, and a refresh is usually something the
1132        // filesystem asked for rather than something the user did.
1133        let anchor = self.selected_entry().map(|e| e.path.clone());
1134        let saved = self.selected;
1135        self.load_dir(&self.cwd.clone())?;
1136        self.restore_selection(anchor.as_deref(), saved);
1137        self.dirty = true;
1138        Ok(())
1139    }
1140
1141    // -- Notices --------------------------------------------------------------
1142
1143    /// Reports that something happened, in the status bar and in the log.
1144    ///
1145    /// For outcomes worth seeing but not worth alarm: a yank, a bookmark, a
1146    /// count of files moved. Logged at `info`, which the default subscriber
1147    /// filter keeps, so the log file says what the user was told.
1148    ///
1149    /// Replaces any notice already showing — the status bar holds one line, and
1150    /// the newest outcome is the relevant one.
1151    pub fn notify(&mut self, text: impl Into<String>) {
1152        let text = text.into();
1153        tracing::info!(%text, "notice");
1154        self.notice = Some(Notice {
1155            text,
1156            level: NoticeLevel::Info,
1157        });
1158        self.dirty = true;
1159    }
1160
1161    /// Reports a failure, in the status bar and in the log.
1162    ///
1163    /// Logged at `warn` rather than `debug`: anything the user is shown as an
1164    /// error is by definition worth having in the log afterwards, and `debug`
1165    /// is below the default filter, so those messages used to reach the screen
1166    /// and nothing else.
1167    pub fn set_error(&mut self, text: impl Into<String>) {
1168        let text = text.into();
1169        tracing::warn!(%text, "error notice");
1170        self.notice = Some(Notice {
1171            text,
1172            level: NoticeLevel::Error,
1173        });
1174        self.dirty = true;
1175    }
1176
1177    /// Dismisses the current notice, if there is one.
1178    ///
1179    /// Called on each keystroke from the event loop, before the keystroke's own
1180    /// action runs — so a notice survives exactly until the user does the next
1181    /// thing, and an action is free to post a new one. Returns whether anything
1182    /// was actually dismissed, so the caller can avoid a needless redraw.
1183    pub fn clear_notice(&mut self) -> bool {
1184        if self.notice.is_none() {
1185            return false;
1186        }
1187        self.notice = None;
1188        self.dirty = true;
1189        true
1190    }
1191
1192    /// The text of the current notice when it is an error, else `None`.
1193    ///
1194    /// For callers that care specifically about failure — chiefly tests
1195    /// asserting that an operation did or did not fail.
1196    // clippy: dead_code — reached from tests/, which drive the library rather
1197    // than the binary; the binary matches on the level directly.
1198    #[allow(dead_code)]
1199    pub fn error_text(&self) -> Option<&str> {
1200        match &self.notice {
1201            Some(Notice {
1202                text,
1203                level: NoticeLevel::Error,
1204            }) => Some(text.as_str()),
1205            _ => None,
1206        }
1207    }
1208
1209    /// Updates the cached `StatusBarState` from current state.
1210    ///
1211    /// Called automatically by `load_dir`.
1212    fn update_status(&mut self) {
1213        self.status.cwd_display = crate::pathfmt::display(&self.cwd);
1214        self.status.entry_count = self.visible_count();
1215    }
1216
1217    // -- Tab management -------------------------------------------------------
1218
1219    /// Saves the current flat state into the active tab slot before switching.
1220    fn save_to_active_tab(&mut self) {
1221        let tab = self.tab_manager.active_tab_mut();
1222        tab.cwd = self.cwd.clone();
1223        tab.entries = self.entries.clone();
1224        tab.selected = self.selected;
1225        tab.sort = self.sort;
1226        std::mem::swap(&mut tab.history, &mut self.history);
1227    }
1228
1229    /// Restores the flat state from the active tab slot after switching.
1230    ///
1231    /// # Errors
1232    ///
1233    /// Returns [`StateError::ReadDir`] if the restored `cwd` can no longer be listed.
1234    pub fn restore_from_active_tab(&mut self) -> Result<(), StateError> {
1235        let tab = self.tab_manager.active_tab_mut();
1236        let new_cwd = tab.cwd.clone();
1237        let new_selected = tab.selected;
1238        // Restored before `load_dir` below, which sorts by it — a tab must come
1239        // back in the order it was left in, not the order the tab you came from
1240        // happened to be using.
1241        let new_sort = tab.sort;
1242        std::mem::swap(&mut tab.history, &mut self.history);
1243        self.cwd = new_cwd.clone();
1244        self.selected = new_selected;
1245        self.sort = new_sort;
1246        self.git = None;
1247        self.filter = None;
1248        self.preview = PreviewSlot::default();
1249        self.mode = Mode::Navigation;
1250        self.load_dir(&new_cwd)?;
1251        Ok(())
1252    }
1253
1254    /// Opens a new tab rooted at `cwd` (or the current directory when `None`).
1255    ///
1256    /// Saves the current tab's state before switching.
1257    ///
1258    /// # Errors
1259    ///
1260    /// Returns [`StateError::ReadDir`] if the new tab's directory cannot be listed.
1261    pub fn open_tab(&mut self, cwd: Option<PathBuf>) -> Result<(), StateError> {
1262        // Same reasoning as `enter_dir`: an explicit `cwd` comes from outside
1263        // and may carry a Windows verbatim prefix.
1264        let new_cwd = cwd
1265            .map(|p| crate::pathfmt::simplified(&p))
1266            .unwrap_or_else(|| self.cwd.clone());
1267        self.save_to_active_tab();
1268        // The new tab inherits this one's order: opening a second view of the
1269        // same work is not a request to go back to the configured default.
1270        self.tab_manager.open_tab(new_cwd.clone(), self.sort);
1271        self.cwd = new_cwd.clone();
1272        self.selected = 0;
1273        self.git = None;
1274        self.filter = None;
1275        self.preview = PreviewSlot::default();
1276        self.mode = Mode::Navigation;
1277        self.history = NavigationHistory::new();
1278        self.load_dir(&new_cwd)?;
1279        Ok(())
1280    }
1281
1282    /// Closes the active tab and restores the adjacent tab's state.
1283    ///
1284    /// Returns `false` when only one tab is open (close refused).
1285    ///
1286    /// # Errors
1287    ///
1288    /// Returns [`StateError::ReadDir`] if the revealed tab's directory cannot be listed.
1289    pub fn close_tab(&mut self) -> Result<bool, StateError> {
1290        if !self.tab_manager.close_active_tab() {
1291            return Ok(false);
1292        }
1293        let tab = self.tab_manager.active_tab_mut();
1294        let new_cwd = tab.cwd.clone();
1295        let new_selected = tab.selected;
1296        // As in `restore_from_active_tab`: the revealed tab keeps its own order.
1297        let new_sort = tab.sort;
1298        std::mem::swap(&mut tab.history, &mut self.history);
1299        self.cwd = new_cwd.clone();
1300        self.selected = new_selected;
1301        self.sort = new_sort;
1302        self.git = None;
1303        self.filter = None;
1304        self.preview = PreviewSlot::default();
1305        self.mode = Mode::Navigation;
1306        self.load_dir(&new_cwd)?;
1307        Ok(true)
1308    }
1309
1310    /// Switches to the next tab (wraps), restoring its state.
1311    ///
1312    /// No-op when only one tab is open.
1313    ///
1314    /// # Errors
1315    ///
1316    /// Returns [`StateError::ReadDir`] if the target tab's directory cannot be listed.
1317    pub fn switch_tab_next(&mut self) -> Result<(), StateError> {
1318        if self.tab_manager.is_single() {
1319            return Ok(());
1320        }
1321        self.save_to_active_tab();
1322        self.tab_manager.switch_next();
1323        self.restore_from_active_tab()
1324    }
1325
1326    /// Switches to the previous tab (wraps), restoring its state.
1327    ///
1328    /// No-op when only one tab is open.
1329    ///
1330    /// # Errors
1331    ///
1332    /// Returns [`StateError::ReadDir`] if the target tab's directory cannot be listed.
1333    pub fn switch_tab_prev(&mut self) -> Result<(), StateError> {
1334        if self.tab_manager.is_single() {
1335            return Ok(());
1336        }
1337        self.save_to_active_tab();
1338        self.tab_manager.switch_prev();
1339        self.restore_from_active_tab()
1340    }
1341
1342    // -- Bookmark management --------------------------------------------------
1343
1344    /// Adds a bookmark named `name` pointing to the current directory.
1345    ///
1346    /// No-op (logs at debug) when the bookmark store is not initialized.
1347    ///
1348    /// # Errors
1349    ///
1350    /// Returns [`BookmarkError`] if the store cannot be persisted.
1351    pub fn bookmark_add(&mut self, name: String) -> Result<(), BookmarkError> {
1352        let cwd = self.cwd.clone();
1353        if let Some(store) = &mut self.bookmark_store {
1354            store.add(name, cwd)?;
1355        } else {
1356            tracing::debug!("bookmark_add: store not initialized");
1357        }
1358        Ok(())
1359    }
1360
1361    /// Navigates to the bookmark named `name`.
1362    ///
1363    /// Returns `Ok(true)` if the bookmark existed and navigation succeeded,
1364    /// `Ok(false)` if no bookmark with that name exists.
1365    ///
1366    /// # Errors
1367    ///
1368    /// Returns [`StateError`] if the bookmarked directory cannot be listed.
1369    pub fn bookmark_jump(&mut self, name: &str) -> Result<bool, StateError> {
1370        let target = match &self.bookmark_store {
1371            Some(store) => store.get(name).map(|p| p.to_owned()),
1372            None => {
1373                tracing::debug!("bookmark_jump: store not initialized");
1374                return Ok(false);
1375            }
1376        };
1377        match target {
1378            Some(path) => {
1379                // Recorded for `U`, unless the jump goes nowhere: returning
1380                // from a jump to the directory you were already in would be a
1381                // `U` that does nothing.
1382                if crate::pathfmt::simplified(&path) != self.cwd {
1383                    self.history.push_jump(self.cwd.clone());
1384                }
1385                self.enter_dir(path)?;
1386                Ok(true)
1387            }
1388            None => Ok(false),
1389        }
1390    }
1391
1392    /// Returns to the directory the newest jump left (`U`).
1393    ///
1394    /// Walks past jumps whose directory has since disappeared, and returns
1395    /// how many it skipped, so the caller can say so. `Ok(None)` means there
1396    /// was no jump to return from. The return itself is an ordinary visit — `u`
1397    /// comes back from it — but not a jump, so `U` again goes further back
1398    /// rather than bouncing between two places.
1399    ///
1400    /// # Errors
1401    ///
1402    /// Returns [`StateError`] if the directory exists but cannot be listed.
1403    pub fn return_from_jump(&mut self) -> Result<Option<usize>, StateError> {
1404        let mut skipped = 0;
1405        while let Some(dir) = self.history.pop_jump() {
1406            if dir.is_dir() {
1407                self.enter_dir(dir)?;
1408                return Ok(Some(skipped));
1409            }
1410            skipped += 1;
1411        }
1412        Ok(if skipped > 0 { Some(skipped) } else { None })
1413    }
1414}
1415
1416#[cfg(test)]
1417mod tests {
1418    use super::*;
1419    use tempfile::TempDir;
1420
1421    /// Creates a temp directory with a known set of files for testing.
1422    fn make_test_dir() -> TempDir {
1423        let dir = tempfile::tempdir().expect("tempdir");
1424        let p = dir.path();
1425        fs::create_dir(p.join("alpha_dir")).unwrap();
1426        fs::create_dir(p.join("zeta_dir")).unwrap();
1427        fs::write(p.join("a_file.txt"), b"").unwrap();
1428        fs::write(p.join(".hidden_file"), b"").unwrap();
1429        dir
1430    }
1431
1432    #[test]
1433    fn dir_first_sort() {
1434        let dir = make_test_dir();
1435        let state = AppState::new(dir.path().to_owned()).unwrap();
1436
1437        // Directories should come first.
1438        let entries: Vec<_> = state.visible_entries().collect();
1439        assert_eq!(entries[0].kind, EntryKind::Dir);
1440        assert_eq!(entries[1].kind, EntryKind::Dir);
1441        // File comes after directories.
1442        assert_eq!(entries[2].kind, EntryKind::File);
1443    }
1444
1445    #[test]
1446    fn dir_sort_is_alphabetical_case_insensitive() {
1447        let dir = make_test_dir();
1448        let state = AppState::new(dir.path().to_owned()).unwrap();
1449        let dirs: Vec<_> = state
1450            .visible_entries()
1451            .filter(|e| e.kind == EntryKind::Dir)
1452            .map(|e| e.file_name.as_str())
1453            .collect();
1454        assert_eq!(dirs, vec!["alpha_dir", "zeta_dir"]);
1455    }
1456
1457    #[test]
1458    fn hidden_files_hidden_by_default() {
1459        let dir = make_test_dir();
1460        let state = AppState::new(dir.path().to_owned()).unwrap();
1461        assert!(
1462            state.visible_entries().all(|e| !e.is_hidden),
1463            "hidden files should not be visible by default"
1464        );
1465    }
1466
1467    #[test]
1468    fn toggle_hidden_reveals_hidden_files() {
1469        let dir = make_test_dir();
1470        let mut state = AppState::new(dir.path().to_owned()).unwrap();
1471        state.toggle_hidden().unwrap();
1472        assert!(
1473            state.visible_entries().any(|e| e.is_hidden),
1474            "hidden files should be visible after toggle"
1475        );
1476    }
1477
1478    #[test]
1479    fn move_down_clamps_at_bottom() {
1480        let dir = make_test_dir();
1481        let mut state = AppState::new(dir.path().to_owned()).unwrap();
1482        let count = state.visible_count();
1483        for _ in 0..count + 5 {
1484            state.move_down();
1485        }
1486        assert_eq!(state.selected, count - 1);
1487    }
1488
1489    #[test]
1490    fn move_up_clamps_at_top() {
1491        let dir = make_test_dir();
1492        let mut state = AppState::new(dir.path().to_owned()).unwrap();
1493        state.move_up(); // Already at 0.
1494        assert_eq!(state.selected, 0);
1495    }
1496
1497    #[test]
1498    fn jump_top_bottom() {
1499        let dir = make_test_dir();
1500        let mut state = AppState::new(dir.path().to_owned()).unwrap();
1501        state.jump_bottom();
1502        let count = state.visible_count();
1503        assert_eq!(state.selected, count - 1);
1504        state.jump_top();
1505        assert_eq!(state.selected, 0);
1506    }
1507
1508    #[test]
1509    fn dirty_set_on_mutation() {
1510        let dir = make_test_dir();
1511        let mut state = AppState::new(dir.path().to_owned()).unwrap();
1512        state.dirty = false; // simulate post-render clear
1513        state.move_down();
1514        assert!(state.dirty);
1515    }
1516
1517    /// The bug this guards: the selection was an index that was merely clamped,
1518    /// so revealing hidden files moved it onto whichever entry had taken that
1519    /// row. In this fixture `.hidden_file` sorts directly before `a_file.txt`,
1520    /// so the selected row is exactly the one that shifts.
1521    #[test]
1522    fn toggling_hidden_keeps_the_selection_on_the_same_entry() {
1523        let dir = make_test_dir();
1524        let mut state = AppState::new(dir.path().to_owned()).unwrap();
1525        state.jump_bottom(); // a_file.txt — the last visible entry
1526        let anchor = state.selected_entry().map(|e| e.path.clone()).unwrap();
1527        assert!(
1528            anchor.ends_with("a_file.txt"),
1529            "fixture changed: {anchor:?}"
1530        );
1531
1532        state.toggle_hidden().unwrap();
1533        assert_eq!(
1534            state.selected_entry().map(|e| e.path.clone()),
1535            Some(anchor.clone()),
1536            "revealing hidden files must not move the selection"
1537        );
1538        assert_eq!(
1539            state.selected, 3,
1540            "the row it sits on has to have moved, or the test proves nothing"
1541        );
1542
1543        state.toggle_hidden().unwrap();
1544        assert_eq!(
1545            state.selected_entry().map(|e| e.path.clone()),
1546            Some(anchor),
1547            "hiding them again must put it back"
1548        );
1549    }
1550
1551    /// A refresh is usually something the filesystem asked for rather than
1552    /// something the user did, so it must not move the cursor under them.
1553    #[test]
1554    fn a_refresh_keeps_the_selection_when_an_entry_appears_above_it() {
1555        let dir = make_test_dir();
1556        let mut state = AppState::new(dir.path().to_owned()).unwrap();
1557        state.jump_bottom();
1558        let anchor = state.selected_entry().map(|e| e.path.clone()).unwrap();
1559
1560        // `0` sorts before `a`, so this lands above the selection.
1561        fs::write(dir.path().join("0_first.txt"), b"").unwrap();
1562        state.refresh().unwrap();
1563
1564        assert_eq!(state.selected_entry().map(|e| e.path.clone()), Some(anchor));
1565    }
1566
1567    /// Returning to a directory restores the entry, not the row number — the
1568    /// listing may have changed while the user was away.
1569    #[test]
1570    fn returning_to_a_directory_reselects_the_same_entry() {
1571        let dir = make_test_dir();
1572        let mut state = AppState::new(dir.path().to_owned()).unwrap();
1573        state.jump_bottom();
1574        let anchor = state.selected_entry().map(|e| e.path.clone()).unwrap();
1575
1576        // Descend from `cwd`, which is canonicalized, so the parent Trail
1577        // returns to is the same path it recorded the selection under.
1578        let child = state.cwd.join("alpha_dir");
1579        state.enter_dir(child).unwrap();
1580        fs::write(dir.path().join("0_first.txt"), b"").unwrap();
1581        state.go_parent().unwrap();
1582
1583        assert_eq!(state.selected_entry().map(|e| e.path.clone()), Some(anchor));
1584    }
1585
1586    /// The bug this guards: an outcome and a failure shared one field, so a
1587    /// successful `:bookmark` was rendered as `Error: bookmark added: name`.
1588    #[test]
1589    fn an_outcome_is_not_an_error() {
1590        let dir = make_test_dir();
1591        let mut state = AppState::new(dir.path().to_owned()).unwrap();
1592
1593        state.notify("bookmark added: work");
1594        assert_eq!(
1595            state.notice.as_ref().map(|n| n.level),
1596            Some(NoticeLevel::Info)
1597        );
1598        assert!(
1599            state.error_text().is_none(),
1600            "an info notice must not read as a failure"
1601        );
1602
1603        state.set_error("mkdir: destination already exists");
1604        assert_eq!(
1605            state.notice.as_ref().map(|n| n.level),
1606            Some(NoticeLevel::Error)
1607        );
1608        assert_eq!(
1609            state.error_text(),
1610            Some("mkdir: destination already exists")
1611        );
1612    }
1613
1614    /// The bug this guards: `last_yank` was only ever assigned, so one `ya`
1615    /// kept announcing itself in every directory visited afterwards.
1616    #[test]
1617    fn clearing_a_notice_reports_whether_there_was_one() {
1618        let dir = make_test_dir();
1619        let mut state = AppState::new(dir.path().to_owned()).unwrap();
1620
1621        assert!(!state.clear_notice(), "nothing to clear on a fresh state");
1622
1623        state.notify("yanked: src/main.rs");
1624        state.dirty = false; // simulate post-render clear
1625        assert!(state.clear_notice(), "the notice was there to clear");
1626        assert!(state.notice.is_none());
1627        assert!(state.dirty, "dismissing a notice has to be drawn");
1628        assert!(!state.clear_notice(), "and it is gone for good");
1629    }
1630}