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}