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;