Skip to main content

tui_file_explorer/app/
mod.rs

1//! Application state for the `tfe` binary.
2//!
3//! This module owns all runtime state that is not part of the file-explorer
4//! widget itself:
5//!
6//! * `panes` / `active_idx` — the list of open panes and which one is focused.
7//! * [`ClipOp`]        — whether a yanked entry is being copied or cut.
8//! * [`ClipboardItem`] — what is currently in the clipboard.
9//! * [`Modal`]         — an optional blocking confirmation dialog.
10//! * [`Editor`]        — which editor to launch when `e` is pressed on a file.
11//! * [`App`]           — the top-level state struct that drives the event loop.
12
13use std::{
14    collections::VecDeque,
15    io::{self},
16    path::{Path, PathBuf},
17    time::{Duration, Instant},
18};
19
20// ── Editor ────────────────────────────────────────────────────────────────────
21
22/// The editor that is launched when the user presses `e` on a file.
23///
24/// # Persistence
25///
26/// Serialised to/from a short key string in the `tfe` state file:
27///
28/// | Variant            | Key string        |
29/// |--------------------|-------------------|
30/// | `None`             | `none`            |
31/// | `Helix`            | `helix`           |
32/// | `Neovim`           | `nvim`            |
33/// | `Vim`              | `vim`             |
34/// | `Nano`             | `nano`            |
35/// | `Micro`            | `micro`           |
36/// | `Emacs`            | `emacs`           |
37/// | `VSCode`           | `vscode`          |
38/// | `Zed`              | `zed`             |
39/// | `Xcode`            | `xcode`           |
40/// | `AndroidStudio`    | `android-studio`  |
41/// | `RustRover`        | `rustrover`       |
42/// | `IntelliJIdea`     | `intellij`        |
43/// | `WebStorm`         | `webstorm`        |
44/// | `PyCharm`          | `pycharm`         |
45/// | `GoLand`           | `goland`          |
46/// | `CLion`            | `clion`           |
47/// | `Fleet`            | `fleet`           |
48/// | `Sublime`          | `sublime`         |
49/// | `RubyMine`         | `rubymine`        |
50/// | `PHPStorm`         | `phpstorm`        |
51/// | `Rider`            | `rider`           |
52/// | `Eclipse`          | `eclipse`         |
53/// | `Custom(s)`        | `custom:<s>`      |
54#[derive(Debug, Clone, PartialEq, Eq, Default)]
55pub enum Editor {
56    /// No editor — pressing `e` on a file is a silent no-op.
57    #[default]
58    None,
59    /// [Helix](https://helix-editor.com/) — `hx`
60    Helix,
61    /// [Neovim](https://neovim.io/) — `nvim`
62    Neovim,
63    /// [Vim](https://www.vim.org/) — `vim`
64    Vim,
65    /// [Nano](https://www.nano-editor.org/) — `nano`
66    Nano,
67    /// [Micro](https://micro-editor.github.io/) — `micro`
68    Micro,
69    /// [Emacs](https://www.gnu.org/software/emacs/) — `emacs`
70    Emacs,
71    /// [Visual Studio Code](https://code.visualstudio.com/) — `code`
72    VSCode,
73    /// [Zed](https://zed.dev/) — `zed`
74    Zed,
75    /// [Xcode](https://developer.apple.com/xcode/) — `xed`
76    Xcode,
77    /// [Android Studio](https://developer.android.com/studio) — `studio`
78    AndroidStudio,
79    /// [RustRover](https://www.jetbrains.com/rust/) — `rustrover`
80    RustRover,
81    /// [IntelliJ IDEA](https://www.jetbrains.com/idea/) — `idea`
82    IntelliJIdea,
83    /// [WebStorm](https://www.jetbrains.com/webstorm/) — `webstorm`
84    WebStorm,
85    /// [PyCharm](https://www.jetbrains.com/pycharm/) — `pycharm`
86    PyCharm,
87    /// [GoLand](https://www.jetbrains.com/go/) — `goland`
88    GoLand,
89    /// [CLion](https://www.jetbrains.com/clion/) — `clion`
90    CLion,
91    /// [Fleet](https://www.jetbrains.com/fleet/) — `fleet`
92    Fleet,
93    /// [Sublime Text](https://www.sublimetext.com/) — `subl`
94    Sublime,
95    /// [RubyMine](https://www.jetbrains.com/ruby/) — `rubymine`
96    RubyMine,
97    /// [PHPStorm](https://www.jetbrains.com/phpstorm/) — `phpstorm`
98    PHPStorm,
99    /// [Rider](https://www.jetbrains.com/rider/) — `rider`
100    Rider,
101    /// [Eclipse](https://www.eclipse.org/) — `eclipse`
102    Eclipse,
103    /// A user-supplied binary name or path.
104    Custom(String),
105}
106
107impl Editor {
108    /// Return the launch binary (and optional arguments) for this editor.
109    ///
110    /// Returns `None` for `Editor::None` — the caller should skip the launch.
111    ///
112    /// For `Custom` variants the returned string may contain embedded
113    /// arguments (e.g. `"code --wait"`).  The caller is responsible for
114    /// splitting on whitespace to separate the binary from its arguments
115    /// before passing them to `std::process::Command`.
116    ///
117    /// For `Editor::Helix` the function probes `$PATH` at call time: it
118    /// tries `hx` first (the name used by the official release binaries and
119    /// Homebrew on macOS), then falls back to `helix` (the name used by most
120    /// Linux package managers such as pacman, apt, and dnf).  Whichever is
121    /// found first is returned; if neither is on `$PATH` the string `"hx"` is
122    /// returned as a best-effort fallback so the error message names a real
123    /// binary.
124    #[must_use]
125    pub fn binary(&self) -> Option<String> {
126        match self {
127            Editor::None => Option::None,
128            Editor::Helix => Some(Self::resolve_helix()),
129            Editor::Neovim => Some("nvim".to_string()),
130            Editor::Vim => Some("vim".to_string()),
131            Editor::Nano => Some("nano".to_string()),
132            Editor::Micro => Some("micro".to_string()),
133            Editor::Emacs => Some("emacs".to_string()),
134            Editor::VSCode => Some("code".to_string()),
135            Editor::Zed => Some("zed".to_string()),
136            Editor::Xcode => Some("xed".to_string()),
137            Editor::AndroidStudio => Some("studio".to_string()),
138            Editor::RustRover => Some("rustrover".to_string()),
139            Editor::IntelliJIdea => Some("idea".to_string()),
140            Editor::WebStorm => Some("webstorm".to_string()),
141            Editor::PyCharm => Some("pycharm".to_string()),
142            Editor::GoLand => Some("goland".to_string()),
143            Editor::CLion => Some("clion".to_string()),
144            Editor::Fleet => Some("fleet".to_string()),
145            Editor::Sublime => Some("subl".to_string()),
146            Editor::RubyMine => Some("rubymine".to_string()),
147            Editor::PHPStorm => Some("phpstorm".to_string()),
148            Editor::Rider => Some("rider".to_string()),
149            Editor::Eclipse => Some("eclipse".to_string()),
150            Editor::Custom(s) => Some(s.clone()),
151        }
152    }
153
154    /// Probe `$PATH` for the Helix binary name.
155    ///
156    /// Returns `"hx"` when found, then tries `"helix"`, and finally falls
157    /// back to `"hx"` so callers always get a non-empty string.
158    fn resolve_helix() -> String {
159        for candidate in &["hx", "helix"] {
160            if which_on_path(candidate) {
161                return candidate.to_string();
162            }
163        }
164        // Neither found — return "hx" so the error message is predictable.
165        "hx".to_string()
166    }
167
168    /// Return a short human-readable label (shown in the options panel).
169    #[must_use]
170    pub fn label(&self) -> &str {
171        match self {
172            Editor::None => "none",
173            Editor::Helix => "helix",
174            Editor::Neovim => "nvim",
175            Editor::Vim => "vim",
176            Editor::Nano => "nano",
177            Editor::Micro => "micro",
178            Editor::Emacs => "emacs",
179            Editor::VSCode => "vscode",
180            Editor::Zed => "zed",
181            Editor::Xcode => "xcode",
182            Editor::AndroidStudio => "android-studio",
183            Editor::RustRover => "rustrover",
184            Editor::IntelliJIdea => "intellij",
185            Editor::WebStorm => "webstorm",
186            Editor::PyCharm => "pycharm",
187            Editor::GoLand => "goland",
188            Editor::CLion => "clion",
189            Editor::Fleet => "fleet",
190            Editor::Sublime => "sublime",
191            Editor::RubyMine => "rubymine",
192            Editor::PHPStorm => "phpstorm",
193            Editor::Rider => "rider",
194            Editor::Eclipse => "eclipse",
195            Editor::Custom(s) => s.as_str(),
196        }
197    }
198
199    /// Cycle to the next editor in the fixed rotation.
200    ///
201    /// Order: None → Helix → Neovim → Vim → Nano → Micro → None → …
202    ///
203    /// `Custom` variants skip back to `None` — the user must set them via
204    /// `--editor` or direct persistence editing.
205    #[must_use]
206    pub fn cycle(&self) -> Editor {
207        match self {
208            Editor::None => Editor::Helix,
209            Editor::Helix => Editor::Neovim,
210            Editor::Neovim => Editor::Vim,
211            Editor::Vim => Editor::Nano,
212            Editor::Nano => Editor::Micro,
213            Editor::Micro => Editor::None,
214            // New GUI/IDE editors and Custom all fall back to None in the legacy
215            // cycle rotation.  The cycle() method is deprecated in favour of the
216            // editor-picker panel (Shift + E); this fallback keeps it exhaustive.
217            _ => Editor::None,
218        }
219    }
220
221    /// Serialise to the on-disk key string.
222    #[must_use]
223    pub fn to_key(&self) -> String {
224        match self {
225            Editor::None => "none".to_string(),
226            Editor::Helix => "helix".to_string(),
227            Editor::Neovim => "nvim".to_string(),
228            Editor::Vim => "vim".to_string(),
229            Editor::Nano => "nano".to_string(),
230            Editor::Micro => "micro".to_string(),
231            Editor::Emacs => "emacs".to_string(),
232            Editor::VSCode => "vscode".to_string(),
233            Editor::Zed => "zed".to_string(),
234            Editor::Xcode => "xcode".to_string(),
235            Editor::AndroidStudio => "android-studio".to_string(),
236            Editor::RustRover => "rustrover".to_string(),
237            Editor::IntelliJIdea => "intellij".to_string(),
238            Editor::WebStorm => "webstorm".to_string(),
239            Editor::PyCharm => "pycharm".to_string(),
240            Editor::GoLand => "goland".to_string(),
241            Editor::CLion => "clion".to_string(),
242            Editor::Fleet => "fleet".to_string(),
243            Editor::Sublime => "sublime".to_string(),
244            Editor::RubyMine => "rubymine".to_string(),
245            Editor::PHPStorm => "phpstorm".to_string(),
246            Editor::Rider => "rider".to_string(),
247            Editor::Eclipse => "eclipse".to_string(),
248            Editor::Custom(s) => format!("custom:{s}"),
249        }
250    }
251
252    /// Deserialise from the on-disk key string.
253    ///
254    /// Returns `None` (the Rust `Option`) for an empty string; unknown values
255    /// are treated as `Custom` so that third-party editors survive round-trips.
256    #[must_use]
257    pub fn from_key(s: &str) -> Option<Editor> {
258        if s.is_empty() {
259            return Option::None;
260        }
261        Some(match s {
262            "none" => Editor::None,
263            "helix" => Editor::Helix,
264            "nvim" => Editor::Neovim,
265            "vim" => Editor::Vim,
266            "nano" => Editor::Nano,
267            "micro" => Editor::Micro,
268            "emacs" => Editor::Emacs,
269            "vscode" => Editor::VSCode,
270            "zed" => Editor::Zed,
271            "xcode" => Editor::Xcode,
272            "android-studio" => Editor::AndroidStudio,
273            "rustrover" => Editor::RustRover,
274            "intellij" => Editor::IntelliJIdea,
275            "webstorm" => Editor::WebStorm,
276            "pycharm" => Editor::PyCharm,
277            "goland" => Editor::GoLand,
278            "clion" => Editor::CLion,
279            "fleet" => Editor::Fleet,
280            "sublime" => Editor::Sublime,
281            "rubymine" => Editor::RubyMine,
282            "phpstorm" => Editor::PHPStorm,
283            "rider" => Editor::Rider,
284            "eclipse" => Editor::Eclipse,
285            _ if s.starts_with("custom:") => Editor::Custom(s["custom:".len()..].to_string()),
286            other => Editor::Custom(other.to_string()),
287        })
288    }
289}
290
291// ── PATH probe helper ─────────────────────────────────────────────────────────
292
293/// Returns `true` when `name` resolves to an executable on `$PATH`.
294///
295/// This is intentionally minimal — it only walks `$PATH` entries and checks
296/// for a regular (or symlinked) file with execute permission.  It does not
297/// handle Windows `.cmd` shims or `PATHEXT`, but that is fine because Helix
298/// does not ship as a `.cmd` wrapper.
299fn which_on_path(name: &str) -> bool {
300    let path_var = std::env::var_os("PATH").unwrap_or_default();
301    std::env::split_paths(&path_var).any(|dir| {
302        let candidate = dir.join(name);
303        // `metadata` follows symlinks, so a symlink to an executable is OK.
304        candidate.metadata().is_ok_and(|m| {
305            #[cfg(unix)]
306            {
307                use std::os::unix::fs::PermissionsExt;
308                m.is_file() && (m.permissions().mode() & 0o111 != 0)
309            }
310            #[cfg(not(unix))]
311            {
312                m.is_file()
313            }
314        })
315    })
316}
317
318// ── AppOptions ────────────────────────────────────────────────────────────────
319
320/// Startup configuration passed to [`App::new`].
321///
322/// Grouping all constructor parameters into a single struct keeps the call
323/// sites readable and avoids the `clippy::too_many_arguments` limit.
324///
325/// # Example
326///
327/// ```
328/// use tui_file_explorer::{App, AppOptions};
329///
330/// let dir = std::env::temp_dir();
331/// let app = App::new(AppOptions {
332///     pane_dirs: vec![dir.clone(), dir],
333///     ..AppOptions::default()
334/// });
335/// # drop(app);
336/// ```
337#[derive(Debug, Clone)]
338pub struct AppOptions {
339    /// Starting directories, one per pane (at least one is required).
340    pub pane_dirs: Vec<PathBuf>,
341    /// File-extension filter (empty = show all).
342    pub extensions: Vec<String>,
343    /// Show hidden (dot-prefixed) entries on startup.
344    pub show_hidden: bool,
345    /// Show file/folder sizes on startup. Defaults to `true`; set to `false`
346    /// for the snappiest possible browsing of huge directory trees.
347    pub show_sizes: bool,
348    /// Compute recursive folder sizes on startup. When `false` folders show a
349    /// shallow item count instead. Defaults to `true`.
350    pub show_dir_sizes: bool,
351    /// Send deletions to the OS trash instead of unlinking them. Needs the
352    /// `trash` feature; ignored (permanent delete) without it. Defaults to
353    /// `false` for library users — the `tfe` binary turns it on.
354    pub use_trash: bool,
355    /// Show git change markers (`M`, `A`, `?`, …) next to entries.
356    pub show_git: bool,
357    /// Saved bookmark folders.
358    pub bookmarks: Vec<PathBuf>,
359    /// Read directories on a background thread so a slow disk never freezes
360    /// the UI (see [`FileExplorer::async_loading`]). Off by default; `tfe`
361    /// enables it.
362    pub async_loading: bool,
363    /// Index into the theme catalogue to use on startup.
364    pub theme_idx: usize,
365    /// Whether the theme-picker side-panel should be open on startup.
366    pub show_theme_panel: bool,
367    /// Whether to start in single-pane mode.
368    pub single_pane: bool,
369    /// Active sort mode.
370    pub sort_mode: SortMode,
371    /// Whether cd-on-exit is enabled.
372    pub cd_on_exit: bool,
373    /// Which editor to open when the user presses `e` on a file.
374    pub editor: Editor,
375    /// When `true`, show a debug log panel in the TUI and write logs to a
376    /// file.  Activated by `--verbose` / `-v`.
377    pub verbose: bool,
378    /// Pre-App log lines collected during startup (before the App existed).
379    /// These are drained into [`App::debug_log`] on construction.
380    pub startup_log: Vec<String>,
381}
382
383impl Default for AppOptions {
384    fn default() -> Self {
385        Self {
386            pane_dirs: vec![PathBuf::from(".")],
387            extensions: vec![],
388            show_hidden: false,
389            show_sizes: true,
390            show_dir_sizes: true,
391            use_trash: false,
392            show_git: false,
393            bookmarks: Vec::new(),
394            async_loading: false,
395            theme_idx: 0,
396            show_theme_panel: false,
397            single_pane: false,
398            sort_mode: SortMode::default(),
399            cd_on_exit: false,
400            editor: Editor::default(),
401            verbose: false,
402            startup_log: Vec::new(),
403        }
404    }
405}
406
407use crate::inline_editor::{EditorAction, InlineEditor};
408use crate::preview::PreviewState;
409
410use crate::picker::Picker;
411use crate::{ExplorerOutcome, FileExplorer, SortMode, Theme};
412use crossterm::event::{self, Event, KeyCode, KeyModifiers};
413
414// ── ClipOp ───────────────────────────────────────────────────────────────────
415
416/// Whether the clipboard item should be copied or moved on paste.
417#[derive(Debug, Clone, Copy, PartialEq, Eq)]
418pub enum ClipOp {
419    /// Copy the entries on paste; the originals stay.
420    Copy,
421    /// Move the entries on paste.
422    Cut,
423}
424
425// ── ClipboardItem ─────────────────────────────────────────────────────────────
426
427/// An entry (or entries) that have been yanked (copied or cut) and are waiting
428/// to be pasted.  When the user space-marks multiple files before pressing
429/// `p` (copy) or `x` (cut), all marked paths are stored here.
430#[derive(Debug, Clone)]
431pub struct ClipboardItem {
432    /// One or more source paths waiting to be pasted.
433    pub paths: Vec<PathBuf>,
434    /// Whether this is a copy or a cut operation.
435    pub op: ClipOp,
436}
437
438impl ClipboardItem {
439    /// A small emoji that visually distinguishes copy from cut in the action bar.
440    #[must_use]
441    pub fn icon(&self) -> &'static str {
442        match self.op {
443            ClipOp::Copy => "\u{1F4CB}", // 📋
444            ClipOp::Cut => "\u{2702} ",  // ✂
445        }
446    }
447
448    /// A short human-readable label for the current operation.
449    #[must_use]
450    pub fn label(&self) -> &'static str {
451        match self.op {
452            ClipOp::Copy => "Copy",
453            ClipOp::Cut => "Cut ",
454        }
455    }
456
457    /// Number of paths in the clipboard.
458    #[must_use]
459    pub fn count(&self) -> usize {
460        self.paths.len()
461    }
462
463    /// The first (or only) path — convenience accessor for single-item clipboard.
464    #[must_use]
465    pub fn first_path(&self) -> Option<&PathBuf> {
466        self.paths.first()
467    }
468}
469
470// ── Modal ─────────────────────────────────────────────────────────────────────
471
472/// A blocking confirmation dialog that intercepts all keyboard input until
473/// the user either confirms or cancels.
474#[derive(Debug)]
475pub enum Modal {
476    /// Asks the user to confirm deletion of a file or directory.
477    Delete {
478        /// Absolute path of the entry to delete.
479        path: PathBuf,
480    },
481    /// Asks the user to confirm deletion of multiple marked entries.
482    MultiDelete {
483        /// Absolute paths of all entries to delete.
484        paths: Vec<PathBuf>,
485    },
486    /// Asks the user whether to overwrite an existing destination during paste.
487    Overwrite {
488        /// Absolute path of the source being pasted.
489        src: PathBuf,
490        /// Absolute path of the destination that already exists.
491        dst: PathBuf,
492        /// `true` if the original operation was a cut (move).
493        is_cut: bool,
494    },
495}
496
497// ── App ───────────────────────────────────────────────────────────────────────
498
499// Top-level application state for the `tfe` binary.
500//
501// Owns both [`FileExplorer`] panes, the clipboard, the active modal, theme
502// state, and the final selected path (set when the user confirms a file).
503// ── Snackbar ──────────────────────────────────────────────────────────────────
504
505/// A short-lived notification that floats over the UI and auto-expires.
506#[derive(Debug)]
507pub struct Snackbar {
508    /// The message to display.
509    pub message: String,
510    /// When the snackbar should stop being shown.
511    pub expires_at: Instant,
512    /// Whether this is an error (affects colour).
513    pub is_error: bool,
514}
515
516impl Snackbar {
517    /// Create a new info snackbar that lasts 3 seconds.
518    pub fn info(message: impl Into<String>) -> Self {
519        Self {
520            message: message.into(),
521            expires_at: Instant::now() + Duration::from_secs(3),
522            is_error: false,
523        }
524    }
525
526    /// Create a new error snackbar that lasts 4 seconds.
527    pub fn error(message: impl Into<String>) -> Self {
528        Self {
529            message: message.into(),
530            expires_at: Instant::now() + Duration::from_secs(4),
531            is_error: true,
532        }
533    }
534
535    /// Returns `true` if the snackbar's display window has passed.
536    #[must_use]
537    pub fn is_expired(&self) -> bool {
538        Instant::now() >= self.expires_at
539    }
540}
541
542/// Tracks the progress of an in-progress copy/move operation.
543#[derive(Debug, Clone)]
544pub struct CopyProgress {
545    /// Human-readable label for the current operation (e.g. "Copying 3 items").
546    pub label: String,
547    /// Number of files/dirs successfully processed so far.
548    pub done: usize,
549    /// Total number of files/dirs to process.
550    pub total: usize,
551    /// Name of the item currently being copied.
552    pub current_item: String,
553}
554
555impl CopyProgress {
556    /// Create a new progress tracker.
557    pub fn new(label: impl Into<String>, total: usize) -> Self {
558        Self {
559            label: label.into(),
560            done: 0,
561            total,
562            current_item: String::new(),
563        }
564    }
565
566    /// Returns the fraction complete as a value in `0.0..=1.0`.
567    #[must_use]
568    pub fn fraction(&self) -> f64 {
569        if self.total == 0 {
570            1.0
571        } else {
572            self.done as f64 / self.total as f64
573        }
574    }
575
576    /// Returns `true` when all items have been processed.
577    #[must_use]
578    pub fn is_complete(&self) -> bool {
579        self.done >= self.total
580    }
581}
582
583/// The whole `tfe` application: panes, side panels, themes, clipboard and
584/// pending file operations. Build one with [`App::builder`].
585#[derive(Debug)]
586pub struct App {
587    /// All explorer panes, in left-to-right display order. Always has at
588    /// least one entry.
589    pub panes: Vec<FileExplorer>,
590    /// Whether deletions go to the OS trash (see [`AppOptions::use_trash`]).
591    pub use_trash: bool,
592    /// Index into `panes` of the pane that currently has keyboard focus.
593    pub active_idx: usize,
594    /// The most recently yanked entry, if any.
595    pub clipboard: Option<ClipboardItem>,
596    /// All available themes as `(name, Theme)` pairs (the `tui-nuances` catalogue).
597    pub themes: Vec<(&'static str, Theme)>,
598    /// Index into `themes` for the currently active theme.
599    pub theme_idx: usize,
600    /// Whether the theme-picker side-panel is visible.
601    pub show_theme_panel: bool,
602    /// Whether the options side-panel is visible.
603    pub show_options_panel: bool,
604    /// Whether only the active pane is shown (single-pane mode).
605    pub single_pane: bool,
606    /// The currently displayed confirmation modal, if any.
607    pub modal: Option<Modal>,
608    /// The path chosen by the user (set on `Enter` / `→` confirm).
609    pub selected: Option<PathBuf>,
610    /// One-line status text shown in the action bar.
611    pub status_msg: String,
612    /// Optional floating notification that auto-expires.
613    pub snackbar: Option<Snackbar>,
614    /// Progress of an ongoing copy/move operation, if any.
615    pub copy_progress: Option<CopyProgress>,
616    /// Whether cd-on-exit is enabled (dismiss prints cwd to stdout).
617    pub cd_on_exit: bool,
618    /// Which editor to open when the user presses `e` on a file.
619    pub editor: Editor,
620    /// When `Some`, the run-loop should suspend the TUI, open this path in
621    /// `self.editor`, then restore the TUI.  Set by the `e` key handler;
622    /// cleared by `run_loop` after the editor exits.
623    pub open_with_editor: Option<PathBuf>,
624    /// Whether the editor-picker side-panel is visible.
625    pub show_editor_panel: bool,
626    /// Highlighted row index in the editor-picker panel (cursor position).
627    pub editor_panel_idx: usize,
628    /// Whether the debug log panel is visible (`--verbose` / `-v`).
629    pub verbose: bool,
630    /// Accumulated debug log lines shown in the log panel.
631    pub debug_log: Vec<String>,
632    /// Scroll offset for the debug log panel (0 = pinned to bottom).
633    pub debug_scroll: usize,
634    /// Whether the preview panel is visible (toggled with `P`).
635    pub show_preview: bool,
636    /// Cached preview content for the currently highlighted file.
637    pub preview_state: PreviewState,
638    /// The active inline editor, if any (opened with `i`).
639    pub inline_editor: Option<InlineEditor>,
640    /// The open list overlay (bookmarks / fuzzy finder), if any.
641    pub picker: Option<Picker>,
642    /// Saved bookmark folders (`b` toggles, `B` opens the list).
643    pub bookmarks: Vec<PathBuf>,
644    /// Reversible actions, oldest first (`u` undoes the newest).
645    pub undo_stack: Vec<UndoEntry>,
646    /// Undone actions that `Ctrl+R` can re-apply, oldest first.
647    pub redo_stack: Vec<UndoEntry>,
648    /// The bulk rename awaiting the editor, if any (`R`).
649    pub bulk_rename: Option<BulkRenameSession>,
650    picker_task: Option<crate::worker::Task<features::WalkMsg>>,
651    #[cfg(feature = "archive")]
652    extract_task: Option<features::ExtractJob>,
653    /// Host-defined key remappings used by `dispatch_key`.
654    key_bindings: KeyBindings,
655    operation_mode: OperationMode,
656    next_operation_id: u64,
657    queued_operation: Option<OperationRequest>,
658    pending_operation: Option<OperationRequest>,
659    event_queue: VecDeque<AppEvent>,
660}
661
662impl App {
663    /// Construct a new `App` from an [`AppOptions`] config struct.
664    #[must_use]
665    pub fn new(opts: AppOptions) -> Self {
666        let dirs = if opts.pane_dirs.is_empty() {
667            vec![PathBuf::from(".")]
668        } else {
669            opts.pane_dirs
670        };
671        let panes: Vec<FileExplorer> = dirs
672            .into_iter()
673            .map(|dir| {
674                FileExplorer::builder(dir)
675                    .filesystem(crate::local_filesystem(opts.use_trash))
676                    .show_git(opts.show_git)
677                    .async_loading(opts.async_loading)
678                    .extension_filter(opts.extensions.clone())
679                    .show_hidden(opts.show_hidden)
680                    .show_sizes(opts.show_sizes)
681                    .show_dir_sizes(opts.show_dir_sizes)
682                    .sort_mode(opts.sort_mode)
683                    .build()
684            })
685            .collect();
686        let themes = Theme::presets();
687        let theme_idx = opts.theme_idx.min(themes.len().saturating_sub(1));
688        Self {
689            panes,
690            use_trash: opts.use_trash && crate::TRASH_SUPPORTED,
691            active_idx: 0,
692            clipboard: None,
693            themes,
694            theme_idx,
695            show_theme_panel: opts.show_theme_panel,
696            show_options_panel: false,
697            single_pane: opts.single_pane,
698            modal: None,
699            selected: None,
700            status_msg: String::new(),
701            snackbar: None,
702            copy_progress: None,
703            cd_on_exit: opts.cd_on_exit,
704            editor: opts.editor,
705            open_with_editor: None,
706            show_editor_panel: false,
707            editor_panel_idx: 0,
708            verbose: opts.verbose,
709            debug_log: opts.startup_log,
710            debug_scroll: 0,
711            show_preview: false,
712            preview_state: PreviewState::new(),
713            inline_editor: None,
714            picker: None,
715            bookmarks: opts.bookmarks,
716            undo_stack: Vec::new(),
717            redo_stack: Vec::new(),
718            bulk_rename: None,
719            picker_task: None,
720            #[cfg(feature = "archive")]
721            extract_task: None,
722            key_bindings: KeyBindings::default(),
723            operation_mode: OperationMode::Immediate,
724            next_operation_id: 1,
725            queued_operation: None,
726            pending_operation: None,
727            event_queue: VecDeque::new(),
728        }
729    }
730
731    /// Append a line to the debug log (visible in the log panel when
732    /// `--verbose` is active).
733    pub fn log(&mut self, msg: impl Into<String>) {
734        if self.verbose {
735            self.debug_log.push(msg.into());
736        }
737    }
738
739    /// Index of the first IDE/GUI editor in the [`Self::all_editors`] list.
740    ///
741    /// Everything before this index is a terminal editor; everything from
742    /// this index onward is a GUI editor or IDE.  Used by the editor panel
743    /// to render the two section headers.
744    #[must_use]
745    pub fn first_ide_idx() -> usize {
746        // None, Helix, Neovim, Vim, Nano, Micro, Emacs  →  7 terminal entries
747        7
748    }
749
750    /// Return every [`Editor`] variant in display order.
751    ///
752    /// Used by the editor-picker panel to populate the list and navigate it.
753    /// Terminal editors come first, then GUI editors/IDEs.
754    #[must_use]
755    pub fn all_editors() -> Vec<Editor> {
756        vec![
757            // ── Terminal editors ──────────────────────────────────────────────
758            Editor::None,
759            Editor::Helix,
760            Editor::Neovim,
761            Editor::Vim,
762            Editor::Nano,
763            Editor::Micro,
764            Editor::Emacs,
765            // ── IDEs & GUI editors ────────────────────────────────────────────
766            Editor::Sublime,
767            Editor::VSCode,
768            Editor::Zed,
769            Editor::Xcode,
770            Editor::AndroidStudio,
771            Editor::RustRover,
772            Editor::IntelliJIdea,
773            Editor::WebStorm,
774            Editor::PyCharm,
775            Editor::GoLand,
776            Editor::CLion,
777            Editor::Fleet,
778            Editor::RubyMine,
779            Editor::PHPStorm,
780            Editor::Rider,
781            Editor::Eclipse,
782        ]
783    }
784
785    /// Sync `editor_panel_idx` to point at the currently active `editor`.
786    ///
787    /// Called when the panel is opened so the cursor lands on the current
788    /// selection.  Defaults to index `0` (`Editor::None`) if not found.
789    pub fn sync_editor_panel_idx(&mut self) {
790        let editors = Self::all_editors();
791        self.editor_panel_idx = editors.iter().position(|e| e == &self.editor).unwrap_or(0);
792    }
793
794    // ── Snackbar helpers ──────────────────────────────────────────────────────
795
796    /// Show an info snackbar with the given message (auto-expires after 3 s).
797    pub fn notify(&mut self, msg: impl Into<String>) {
798        self.snackbar = Some(Snackbar::info(msg));
799    }
800
801    /// Reload both panes and show a notification with the entry name.
802    fn reload_and_notify(&mut self, path: &std::path::Path, verb: &str) {
803        for p in &mut self.panes {
804            p.reload();
805        }
806        let name = path
807            .file_name()
808            .unwrap_or_default()
809            .to_string_lossy()
810            .to_string();
811        self.notify(format!("{verb} '{name}'"));
812    }
813
814    /// Show an error snackbar with the given message (auto-expires after 4 s).
815    pub fn notify_error(&mut self, msg: impl Into<String>) {
816        self.snackbar = Some(Snackbar::error(msg));
817    }
818
819    // ── Theme helpers ─────────────────────────────────────────────────────────
820
821    /// Return a reference to the currently selected [`Theme`].
822    #[must_use]
823    pub fn theme(&self) -> &Theme {
824        &self.themes[self.theme_idx].1
825    }
826
827    /// Return the name of the currently selected theme.
828    #[must_use]
829    pub fn theme_name(&self) -> &str {
830        self.themes[self.theme_idx].0
831    }
832
833    /// Advance to the next theme, wrapping around at the end of the list.
834    pub fn next_theme(&mut self) {
835        self.theme_idx = (self.theme_idx + 1) % self.themes.len();
836    }
837
838    /// Retreat to the previous theme, wrapping around at the beginning.
839    pub fn prev_theme(&mut self) {
840        self.theme_idx = self
841            .theme_idx
842            .checked_sub(1)
843            .unwrap_or(self.themes.len() - 1);
844    }
845}
846
847mod api;
848mod clipboard;
849mod features;
850mod keys;
851mod operation;
852mod pane;
853
854pub use api::{
855    AppBuildError, AppBuilder, AppCommand, AppEvent, AppOutcome, KeyBinding, KeyBindings,
856    PaneOptions,
857};
858pub use features::{BulkRenameSession, UndoEntry};
859pub use operation::{
860    FileOperation, OperationApplyError, OperationFailure, OperationId, OperationMode,
861    OperationProgress, OperationRequest, OperationResult, execute_operation,
862    execute_operation_with, execute_operation_with_progress,
863};
864
865// ── Tests ─────────────────────────────────────────────────────────────────────
866
867#[cfg(test)]
868#[path = "tests.rs"]
869mod tests;