1use std::{fmt, io, path::PathBuf, time::Instant};
2
3use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
4
5use super::{App, AppOptions, Editor, OperationRequest};
6use crate::{AppState, FileExplorer, SelectionMode, SortMode, Theme};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum AppOutcome {
11 Ignored,
13 Continue,
15 Changed(Vec<AppEvent>),
17 Selected(PathBuf),
19 Dismissed,
21 OpenEditor {
23 path: PathBuf,
25 editor: Editor,
27 },
28 OperationRequested(OperationRequest),
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum AppEvent {
35 PaneAdded {
37 pane_count: usize,
39 },
40 PaneClosed {
42 pane_count: usize,
44 },
45 ActivePaneChanged {
47 index: usize,
49 },
50 DirectoryChanged {
52 pane: usize,
54 path: PathBuf,
56 },
57 ThemeChanged {
59 index: usize,
61 },
62 LayoutChanged {
64 single_pane: bool,
66 },
67 PreviewChanged {
69 visible: bool,
71 },
72 OperationProgress {
74 id: super::OperationId,
76 completed: usize,
78 total: usize,
80 },
81 OperationCompleted {
83 id: super::OperationId,
85 succeeded: usize,
87 failed: usize,
89 },
90 OperationCancelled {
92 id: super::OperationId,
94 },
95}
96
97command_enum! {
98 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
100 pub enum AppCommand {
101 MoveUp => "move_up",
103 MoveDown => "move_down",
105 PageUp => "page_up",
107 PageDown => "page_down",
109 Top => "top",
111 Bottom => "bottom",
113 Ascend => "ascend",
115 Open => "open",
117 Confirm => "confirm",
119 NextPane => "next_pane",
121 PreviousPane => "previous_pane",
123 OpenPane => "open_pane",
125 ClosePane => "close_pane",
127 ToggleSinglePane => "toggle_single_pane",
129 Search => "search",
131 CycleSort => "cycle_sort",
133 ToggleHidden => "toggle_hidden",
135 ToggleSizes => "toggle_sizes",
137 ToggleDirSizes => "toggle_dir_sizes",
139 ToggleMark => "toggle_mark",
141 Cut => "cut",
143 Paste => "paste",
145 Delete => "delete",
147 NewDirectory => "new_directory",
149 NewFile => "new_file",
151 Rename => "rename",
153 TogglePreview => "toggle_preview",
155 PreviewDown => "preview_down",
157 PreviewUp => "preview_up",
159 OpenExternalEditor => "open_external_editor",
161 OpenInlineEditor => "open_inline_editor",
163 ToggleThemePanel => "toggle_theme_panel",
165 ToggleOptionsPanel => "toggle_options_panel",
167 ToggleEditorPanel => "toggle_editor_panel",
169 NextTheme => "next_theme",
171 PreviousTheme => "previous_theme",
173 ToggleCdOnExit => "toggle_cd_on_exit",
175 Dismiss => "dismiss",
177 Quit => "quit",
179 Undo => "undo",
181 Redo => "redo",
183 BulkRename => "bulk_rename",
185 ToggleBookmark => "toggle_bookmark",
187 OpenBookmarks => "open_bookmarks",
189 FuzzyFind => "fuzzy_find",
191 ToggleGit => "toggle_git",
193 ExtractArchive => "extract_archive",
195 }
196}
197
198impl AppCommand {
199 #[must_use]
201 pub fn key_event(self) -> KeyEvent {
202 let (code, modifiers) = match self {
203 Self::MoveUp => (KeyCode::Up, KeyModifiers::NONE),
204 Self::MoveDown => (KeyCode::Down, KeyModifiers::NONE),
205 Self::PageUp => (KeyCode::PageUp, KeyModifiers::NONE),
206 Self::PageDown => (KeyCode::PageDown, KeyModifiers::NONE),
207 Self::Top => (KeyCode::Home, KeyModifiers::NONE),
208 Self::Bottom => (KeyCode::End, KeyModifiers::NONE),
209 Self::Ascend => (KeyCode::Left, KeyModifiers::NONE),
210 Self::Open => (KeyCode::Right, KeyModifiers::NONE),
211 Self::Confirm => (KeyCode::Enter, KeyModifiers::NONE),
212 Self::NextPane => (KeyCode::Tab, KeyModifiers::NONE),
213 Self::PreviousPane => (KeyCode::BackTab, KeyModifiers::SHIFT),
214 Self::OpenPane => (KeyCode::Char('t'), KeyModifiers::CONTROL),
215 Self::ClosePane => (KeyCode::Char('w'), KeyModifiers::CONTROL),
216 Self::ToggleSinglePane => (KeyCode::Char('w'), KeyModifiers::NONE),
217 Self::Search => (KeyCode::Char('/'), KeyModifiers::NONE),
218 Self::CycleSort => (KeyCode::Char('s'), KeyModifiers::NONE),
219 Self::ToggleHidden => (KeyCode::Char('.'), KeyModifiers::NONE),
220 Self::ToggleSizes => (KeyCode::Char('z'), KeyModifiers::NONE),
221 Self::ToggleDirSizes => (KeyCode::Char('Z'), KeyModifiers::SHIFT),
222 Self::ToggleMark => (KeyCode::Char(' '), KeyModifiers::NONE),
223 Self::Cut => (KeyCode::Char('x'), KeyModifiers::NONE),
224 Self::Paste => (KeyCode::Char('p'), KeyModifiers::NONE),
225 Self::Delete => (KeyCode::Char('d'), KeyModifiers::NONE),
226 Self::NewDirectory => (KeyCode::Char('n'), KeyModifiers::NONE),
227 Self::NewFile => (KeyCode::Char('N'), KeyModifiers::NONE),
228 Self::Rename => (KeyCode::Char('r'), KeyModifiers::NONE),
229 Self::TogglePreview => (KeyCode::Char('P'), KeyModifiers::SHIFT),
230 Self::PreviewDown => (KeyCode::Char('j'), KeyModifiers::CONTROL),
231 Self::PreviewUp => (KeyCode::Char('k'), KeyModifiers::CONTROL),
232 Self::OpenExternalEditor => (KeyCode::Char('e'), KeyModifiers::NONE),
233 Self::OpenInlineEditor => (KeyCode::Char('i'), KeyModifiers::NONE),
234 Self::ToggleThemePanel => (KeyCode::Char('T'), KeyModifiers::SHIFT),
235 Self::ToggleOptionsPanel => (KeyCode::Char('O'), KeyModifiers::SHIFT),
236 Self::ToggleEditorPanel => (KeyCode::Char('E'), KeyModifiers::SHIFT),
237 Self::NextTheme => (KeyCode::Char('t'), KeyModifiers::NONE),
238 Self::PreviousTheme => (KeyCode::Char('['), KeyModifiers::NONE),
239 Self::ToggleCdOnExit => (KeyCode::Char('C'), KeyModifiers::SHIFT),
240 Self::Dismiss => (KeyCode::Esc, KeyModifiers::NONE),
241 Self::Quit => (KeyCode::Char('q'), KeyModifiers::NONE),
242 Self::Undo => (KeyCode::Char('u'), KeyModifiers::NONE),
243 Self::Redo => (KeyCode::Char('r'), KeyModifiers::CONTROL),
244 Self::BulkRename => (KeyCode::Char('R'), KeyModifiers::SHIFT),
245 Self::ToggleBookmark => (KeyCode::Char('b'), KeyModifiers::NONE),
246 Self::OpenBookmarks => (KeyCode::Char('B'), KeyModifiers::SHIFT),
247 Self::FuzzyFind => (KeyCode::Char('f'), KeyModifiers::NONE),
248 Self::ToggleGit => (KeyCode::Char('v'), KeyModifiers::NONE),
249 Self::ExtractArchive => (KeyCode::Char('X'), KeyModifiers::SHIFT),
250 };
251 KeyEvent::new(code, modifiers)
252 }
253}
254
255#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257pub struct KeyBinding {
258 pub code: KeyCode,
260 pub modifiers: KeyModifiers,
262 pub command: AppCommand,
264}
265
266#[derive(Debug, Clone, Default, PartialEq, Eq)]
268pub struct KeyBindings {
269 bindings: Vec<KeyBinding>,
270 remapped: Vec<AppCommand>,
272}
273
274impl KeyBindings {
275 #[must_use]
277 pub fn new() -> Self {
278 Self::default()
279 }
280
281 #[must_use]
283 pub fn bind(mut self, code: KeyCode, modifiers: KeyModifiers, command: AppCommand) -> Self {
284 self.bindings
285 .retain(|binding| binding.code != code || binding.modifiers != modifiers);
286 self.bindings.push(KeyBinding {
287 code,
288 modifiers,
289 command,
290 });
291 self
292 }
293
294 #[must_use]
296 pub fn unbind(mut self, code: KeyCode, modifiers: KeyModifiers) -> Self {
297 self.bindings
298 .retain(|binding| binding.code != code || binding.modifiers != modifiers);
299 self
300 }
301
302 #[must_use]
308 pub fn remap(mut self, command: AppCommand, chords: &[crate::KeyChord]) -> Self {
309 self.bindings.retain(|b| b.command != command);
310 if !self.remapped.contains(&command) {
311 self.remapped.push(command);
312 }
313 for chord in chords {
314 let event = chord.to_event();
315 let mut mods = vec![event.modifiers];
318 if matches!(chord.mods, crate::ChordMods::Plain | crate::ChordMods::Any)
319 && let KeyCode::Char(c) = event.code
320 && c.is_uppercase()
321 {
322 mods.push(KeyModifiers::NONE);
323 }
324 for m in mods {
325 self = self.bind(event.code, m, command);
326 }
327 }
328 self
329 }
330
331 #[must_use]
337 pub fn translate(&self, key: KeyEvent) -> Option<KeyEvent> {
338 if let Some(command) = self.resolve(key) {
339 return Some(command.key_event());
340 }
341 let swallowed = self.remapped.iter().any(|c| {
342 let built_in = c.key_event();
343 built_in.code == key.code && built_in.modifiers == key.modifiers
344 });
345 (!swallowed).then_some(key)
346 }
347
348 pub fn bindings(&self) -> &[KeyBinding] {
350 &self.bindings
351 }
352
353 #[must_use]
355 pub fn resolve(&self, key: KeyEvent) -> Option<AppCommand> {
356 self.bindings
357 .iter()
358 .find(|binding| binding.code == key.code && binding.modifiers == key.modifiers)
359 .map(|binding| binding.command)
360 }
361
362 #[must_use]
364 pub fn is_empty(&self) -> bool {
365 self.bindings.is_empty() && self.remapped.is_empty()
366 }
367}
368
369#[derive(Debug, Clone)]
371pub struct PaneOptions {
372 pub directory: PathBuf,
374 pub filesystem: crate::SharedFileSystem,
376 pub extensions: Vec<String>,
378 pub show_hidden: bool,
380 pub show_sizes: bool,
382 pub show_dir_sizes: bool,
384 pub keymap: crate::Keymap,
386 pub async_loading: bool,
388 pub sort_mode: SortMode,
390 pub page_size: usize,
392 pub selection_mode: SelectionMode,
394}
395
396impl PaneOptions {
397 pub fn new(directory: impl Into<PathBuf>) -> Self {
400 Self {
401 directory: directory.into(),
402 filesystem: crate::std_filesystem(),
403 extensions: Vec::new(),
404 show_hidden: false,
405 show_sizes: true,
406 show_dir_sizes: true,
407 keymap: crate::Keymap::default(),
408 async_loading: false,
409 sort_mode: SortMode::default(),
410 page_size: crate::PAGE_SIZE,
411 selection_mode: SelectionMode::default(),
412 }
413 }
414
415 #[must_use]
417 pub fn filesystem(mut self, filesystem: crate::SharedFileSystem) -> Self {
418 self.filesystem = filesystem;
419 self
420 }
421
422 #[must_use]
424 pub fn extension(mut self, extension: impl Into<String>) -> Self {
425 self.extensions.push(extension.into());
426 self
427 }
428
429 #[must_use]
431 pub fn show_hidden(mut self, show: bool) -> Self {
432 self.show_hidden = show;
433 self
434 }
435
436 #[must_use]
438 pub fn show_sizes(mut self, show: bool) -> Self {
439 self.show_sizes = show;
440 self
441 }
442
443 #[must_use]
445 pub fn show_dir_sizes(mut self, show: bool) -> Self {
446 self.show_dir_sizes = show;
447 self
448 }
449
450 #[must_use]
452 pub fn keymap(mut self, keymap: crate::Keymap) -> Self {
453 self.keymap = keymap;
454 self
455 }
456
457 pub fn async_loading(mut self, enabled: bool) -> Self {
459 self.async_loading = enabled;
460 self
461 }
462
463 pub fn sort_mode(mut self, sort_mode: SortMode) -> Self {
465 self.sort_mode = sort_mode;
466 self
467 }
468
469 #[must_use]
471 pub fn page_size(mut self, page_size: usize) -> Self {
472 self.page_size = page_size.max(1);
473 self
474 }
475
476 #[must_use]
478 pub fn selection_mode(mut self, selection_mode: SelectionMode) -> Self {
479 self.selection_mode = selection_mode;
480 self
481 }
482
483 fn build(self) -> FileExplorer {
484 FileExplorer::builder(self.directory)
485 .filesystem(self.filesystem)
486 .extension_filter(self.extensions)
487 .show_hidden(self.show_hidden)
488 .show_sizes(self.show_sizes)
489 .show_dir_sizes(self.show_dir_sizes)
490 .keymap(self.keymap)
491 .async_loading(self.async_loading)
492 .sort_mode(self.sort_mode)
493 .page_size(self.page_size)
494 .selection_mode(self.selection_mode)
495 .build()
496 }
497}
498
499#[derive(Debug, Clone, PartialEq, Eq)]
501pub enum AppBuildError {
502 NoPanes,
504 InvalidThemeIndex {
506 index: usize,
508 theme_count: usize,
510 },
511 InvalidPaneIndex {
513 index: usize,
515 pane_count: usize,
517 },
518}
519
520impl fmt::Display for AppBuildError {
521 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
522 match self {
523 Self::NoPanes => write!(f, "an application must contain at least one pane"),
524 Self::InvalidThemeIndex { index, theme_count } => {
525 write!(f, "theme index {index} is outside 0..{theme_count}")
526 }
527 Self::InvalidPaneIndex { index, pane_count } => {
528 write!(f, "pane index {index} is outside 0..{pane_count}")
529 }
530 }
531 }
532}
533
534impl std::error::Error for AppBuildError {}
535
536#[derive(Debug, Clone)]
538pub struct AppBuilder {
539 panes: Vec<PaneOptions>,
540 options: AppOptions,
541 key_bindings: KeyBindings,
542 theme_override: Option<Theme>,
543 operation_mode: super::OperationMode,
544}
545
546impl Default for AppBuilder {
547 fn default() -> Self {
548 Self {
549 panes: Vec::new(),
550 options: AppOptions::default(),
551 key_bindings: KeyBindings::default(),
552 theme_override: None,
553 operation_mode: super::OperationMode::Immediate,
554 }
555 }
556}
557
558impl AppBuilder {
559 #[must_use]
561 pub fn new() -> Self {
562 Self::default()
563 }
564
565 #[must_use]
567 pub fn pane(mut self, pane: PaneOptions) -> Self {
568 self.panes.push(pane);
569 self
570 }
571
572 #[must_use]
574 pub fn theme_index(mut self, theme_index: usize) -> Self {
575 self.options.theme_idx = theme_index;
576 self
577 }
578
579 #[must_use]
581 pub fn theme(mut self, theme: impl Into<Theme>) -> Self {
582 self.theme_override = Some(theme.into());
583 self
584 }
585
586 #[must_use]
588 pub fn editor(mut self, editor: Editor) -> Self {
589 self.options.editor = editor;
590 self
591 }
592
593 #[must_use]
595 pub fn single_pane(mut self, enabled: bool) -> Self {
596 self.options.single_pane = enabled;
597 self
598 }
599
600 #[must_use]
602 pub fn cd_on_exit(mut self, enabled: bool) -> Self {
603 self.options.cd_on_exit = enabled;
604 self
605 }
606
607 #[must_use]
609 pub fn verbose(mut self, enabled: bool) -> Self {
610 self.options.verbose = enabled;
611 self
612 }
613
614 #[must_use]
616 pub fn key_bindings(mut self, key_bindings: KeyBindings) -> Self {
617 self.key_bindings = key_bindings;
618 self
619 }
620
621 #[must_use]
623 pub fn operation_mode(mut self, operation_mode: super::OperationMode) -> Self {
624 self.operation_mode = operation_mode;
625 self
626 }
627
628 pub fn build(mut self) -> Result<App, AppBuildError> {
634 let theme_count = crate::Theme::presets().len();
635 if self.options.theme_idx >= theme_count {
636 return Err(AppBuildError::InvalidThemeIndex {
637 index: self.options.theme_idx,
638 theme_count,
639 });
640 }
641
642 if self.panes.is_empty() {
643 self.panes.push(PaneOptions::new("."));
644 }
645 self.options.pane_dirs = self
646 .panes
647 .iter()
648 .map(|pane| pane.directory.clone())
649 .collect();
650 let pane_options = self.panes;
651 let mut app = App::new(self.options);
652 app.panes = pane_options.into_iter().map(PaneOptions::build).collect();
653 app.key_bindings = self.key_bindings;
654 app.operation_mode = self.operation_mode;
655 if let Some(theme) = self.theme_override {
656 app.themes[app.theme_idx].1 = theme;
657 }
658 Ok(app)
659 }
660}
661
662impl App {
663 #[must_use]
665 pub fn builder() -> AppBuilder {
666 AppBuilder::new()
667 }
668
669 #[must_use]
671 pub fn panes(&self) -> &[FileExplorer] {
672 &self.panes
673 }
674
675 pub fn panes_mut(&mut self) -> &mut [FileExplorer] {
677 &mut self.panes
678 }
679
680 pub fn replace_panes(&mut self, panes: Vec<FileExplorer>) -> Result<(), AppBuildError> {
686 if panes.is_empty() {
687 return Err(AppBuildError::NoPanes);
688 }
689 self.panes = panes;
690 self.active_idx = self.active_idx.min(self.panes.len() - 1);
691 Ok(())
692 }
693
694 #[must_use]
696 pub fn active_pane_index(&self) -> usize {
697 self.active_idx
698 }
699
700 pub fn set_active_pane_index(&mut self, index: usize) -> Result<(), AppBuildError> {
706 if index >= self.panes.len() {
707 return Err(AppBuildError::InvalidPaneIndex {
708 index,
709 pane_count: self.panes.len(),
710 });
711 }
712 self.active_idx = index;
713 Ok(())
714 }
715
716 pub fn set_theme_index(&mut self, index: usize) -> Result<(), AppBuildError> {
722 if index >= self.themes.len() {
723 return Err(AppBuildError::InvalidThemeIndex {
724 index,
725 theme_count: self.themes.len(),
726 });
727 }
728 self.theme_idx = index;
729 Ok(())
730 }
731
732 pub fn set_keymap(&mut self, keymap: &crate::Keymap) {
734 for pane in &mut self.panes {
735 pane.keymap = keymap.clone();
736 }
737 }
738
739 pub fn apply_keymap_config(&mut self, config: &crate::KeymapConfig) {
742 self.set_keymap(&config.explorer);
743 self.key_bindings = config.app.clone();
744 }
745
746 #[must_use]
748 pub fn key_bindings(&self) -> &KeyBindings {
749 &self.key_bindings
750 }
751
752 pub fn set_key_bindings(&mut self, key_bindings: KeyBindings) {
754 self.key_bindings = key_bindings;
755 }
756
757 pub fn dispatch_event(&mut self, event: Event) -> io::Result<AppOutcome> {
759 match event {
760 Event::Key(key) => self.dispatch_key(key),
761 Event::Resize(_, _) => Ok(AppOutcome::Continue),
762 Event::Mouse(_) | Event::FocusGained | Event::FocusLost | Event::Paste(_) => {
763 Ok(AppOutcome::Ignored)
764 }
765 }
766 }
767
768 pub fn dispatch_command(&mut self, command: AppCommand) -> io::Result<AppOutcome> {
770 self.dispatch_key_event(command.key_event())
771 }
772
773 pub fn dispatch_key(&mut self, key: KeyEvent) -> io::Result<AppOutcome> {
775 press_only!(key, Ok(AppOutcome::Ignored));
776 let pane_captures_text =
777 self.active_pane().search_active || self.active_pane().has_prompt();
778 let mapped = if self.inline_editor.is_some() || self.modal.is_some() || pane_captures_text {
779 Some(key)
780 } else {
781 self.key_bindings.translate(key)
782 };
783 match mapped {
784 Some(mapped) => self.dispatch_key_event(mapped),
785 None => Ok(AppOutcome::Continue),
786 }
787 }
788
789 fn dispatch_key_event(&mut self, key: KeyEvent) -> io::Result<AppOutcome> {
790 let previous_editor_request = self.open_with_editor.clone();
791 let previous_pane_count = self.panes.len();
792 let previous_active = self.active_idx;
793 let previous_directories: Vec<PathBuf> = self
794 .panes
795 .iter()
796 .map(|pane| pane.current_dir.clone())
797 .collect();
798 let previous_theme = self.theme_idx;
799 let previous_single_pane = self.single_pane;
800 let previous_preview = self.show_preview;
801 let should_exit = self.handle_key(key)?;
802
803 let mut events = Vec::new();
804 match self.panes.len().cmp(&previous_pane_count) {
805 std::cmp::Ordering::Greater => events.push(AppEvent::PaneAdded {
806 pane_count: self.panes.len(),
807 }),
808 std::cmp::Ordering::Less => events.push(AppEvent::PaneClosed {
809 pane_count: self.panes.len(),
810 }),
811 std::cmp::Ordering::Equal => {}
812 }
813 if self.active_idx != previous_active {
814 events.push(AppEvent::ActivePaneChanged {
815 index: self.active_idx,
816 });
817 }
818 for (index, pane) in self.panes.iter().enumerate() {
819 if previous_directories.get(index) != Some(&pane.current_dir) {
820 events.push(AppEvent::DirectoryChanged {
821 pane: index,
822 path: pane.current_dir.clone(),
823 });
824 }
825 }
826 if self.theme_idx != previous_theme {
827 events.push(AppEvent::ThemeChanged {
828 index: self.theme_idx,
829 });
830 }
831 if self.single_pane != previous_single_pane {
832 events.push(AppEvent::LayoutChanged {
833 single_pane: self.single_pane,
834 });
835 }
836 if self.show_preview != previous_preview {
837 events.push(AppEvent::PreviewChanged {
838 visible: self.show_preview,
839 });
840 }
841 self.event_queue.extend(events.iter().cloned());
842
843 if let Some(request) = self.queued_operation.take() {
844 return Ok(AppOutcome::OperationRequested(request));
845 }
846 if should_exit {
847 return Ok(self
848 .selected
849 .clone()
850 .map_or(AppOutcome::Dismissed, AppOutcome::Selected));
851 }
852 if self.open_with_editor != previous_editor_request
853 && let Some(path) = self.open_with_editor.clone()
854 {
855 return Ok(AppOutcome::OpenEditor {
856 path,
857 editor: self.editor.clone(),
858 });
859 }
860 if events.is_empty() {
861 Ok(AppOutcome::Continue)
862 } else {
863 Ok(AppOutcome::Changed(events))
864 }
865 }
866
867 pub fn drain_events(&mut self) -> impl Iterator<Item = AppEvent> + '_ {
869 self.event_queue.drain(..)
870 }
871
872 pub fn tick(&mut self, now: Instant) {
874 if self
875 .snackbar
876 .as_ref()
877 .is_some_and(|snackbar| now >= snackbar.expires_at)
878 {
879 self.snackbar = None;
880 }
881 }
882
883 pub fn update_preview(&mut self, width: u16, height: u16) {
885 if !self.show_preview {
886 return;
887 }
888 let current_path = self
889 .active_pane()
890 .current_entry()
891 .map(|entry| entry.path.clone());
892 self.preview_state
893 .update(current_path.as_deref(), width, height);
894 }
895
896 pub fn take_editor_request(&mut self) -> Option<(PathBuf, Editor)> {
898 self.open_with_editor
899 .take()
900 .map(|path| (path, self.editor.clone()))
901 }
902
903 #[must_use]
905 pub fn snapshot(&self) -> AppState {
906 let active = self.active_pane();
907 AppState {
908 theme: Some(self.theme_name().to_string()),
909 last_dir: self.panes.first().map(|pane| pane.current_dir.clone()),
910 last_dir_right: self.panes.get(1).map(|pane| pane.current_dir.clone()),
911 sort_mode: Some(active.sort_mode),
912 show_hidden: Some(active.show_hidden),
913 show_sizes: Some(active.show_sizes),
914 show_dir_sizes: Some(active.show_dir_sizes),
915 use_trash: Some(self.use_trash),
916 show_git: Some(active.show_git),
917 bookmarks: Some(self.bookmarks.clone()),
918 single_pane: Some(self.single_pane),
919 cd_on_exit: Some(self.cd_on_exit),
920 editor: Some(self.editor.to_key()),
921 active_pane: Some(
922 if self.active_idx == 1 {
923 "right"
924 } else {
925 "left"
926 }
927 .into(),
928 ),
929 pane_dirs: Some(
930 self.panes
931 .iter()
932 .map(|pane| pane.current_dir.clone())
933 .collect(),
934 ),
935 active_pane_idx: Some(self.active_idx),
936 }
937 }
938
939 pub fn restore(&mut self, state: &AppState) {
941 let pane_dirs = state
942 .pane_dirs
943 .clone()
944 .filter(|dirs| !dirs.is_empty())
945 .or_else(|| {
946 state.last_dir.clone().map(|left| {
947 let mut dirs = vec![left];
948 if let Some(right) = state.last_dir_right.clone() {
949 dirs.push(right);
950 }
951 dirs
952 })
953 });
954 if let Some(dirs) = pane_dirs {
955 let show_hidden = state.show_hidden.unwrap_or(false);
956 let show_sizes = state.show_sizes.unwrap_or(true);
957 let show_dir_sizes = state.show_dir_sizes.unwrap_or(true);
958 let show_git = state.show_git.unwrap_or(false);
959 let sort_mode = state.sort_mode.unwrap_or_default();
960 let filesystem = self
961 .panes
962 .first()
963 .map_or_else(crate::std_filesystem, |pane| pane.filesystem().clone());
964 let keymap = self
965 .panes
966 .first()
967 .map(|pane| pane.keymap.clone())
968 .unwrap_or_default();
969 let async_loading = self.panes.first().is_some_and(|pane| pane.async_loading);
970 self.panes = dirs
971 .into_iter()
972 .map(|directory| {
973 FileExplorer::builder(directory)
974 .filesystem(filesystem.clone())
975 .show_hidden(show_hidden)
976 .show_sizes(show_sizes)
977 .show_dir_sizes(show_dir_sizes)
978 .show_git(show_git)
979 .async_loading(async_loading)
980 .keymap(keymap.clone())
981 .sort_mode(sort_mode)
982 .build()
983 })
984 .collect();
985 }
986 if let Some(bookmarks) = &state.bookmarks {
987 self.bookmarks.clone_from(bookmarks);
988 }
989 if let Some(index) = state.active_pane_idx {
990 self.active_idx = index.min(self.panes.len().saturating_sub(1));
991 }
992 if let Some(theme) = &state.theme {
993 self.theme_idx = crate::resolve_theme_idx(theme, &self.themes);
994 }
995 if let Some(single_pane) = state.single_pane {
996 self.single_pane = single_pane;
997 }
998 if let Some(cd_on_exit) = state.cd_on_exit {
999 self.cd_on_exit = cd_on_exit;
1000 }
1001 if let Some(editor) = state.editor.as_deref().and_then(Editor::from_key) {
1002 self.editor = editor;
1003 }
1004 }
1005}
1006
1007#[cfg(feature = "theme")]
1008impl crate::nuances::Themed for AppBuilder {
1009 fn apply_theme(self, theme: &crate::nuances::NuancesTheme) -> Self {
1010 self.theme(theme)
1011 }
1012}
1013
1014#[cfg(test)]
1015mod tests {
1016 use super::*;
1017 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1018 use tempfile::tempdir;
1019
1020 #[test]
1021 fn builder_supports_per_pane_options() {
1022 let left = tempdir().unwrap();
1023 let right = tempdir().unwrap();
1024 let app = App::builder()
1025 .pane(PaneOptions::new(left.path()).show_hidden(true))
1026 .pane(PaneOptions::new(right.path()).show_sizes(false))
1027 .build()
1028 .unwrap();
1029 assert_eq!(app.panes().len(), 2);
1030 assert!(app.panes()[0].show_hidden);
1031 assert!(!app.panes()[1].show_sizes);
1032 }
1033
1034 #[test]
1035 fn builder_rejects_invalid_theme() {
1036 let result = App::builder().theme_index(usize::MAX).build();
1037 assert!(matches!(
1038 result,
1039 Err(AppBuildError::InvalidThemeIndex { .. })
1040 ));
1041 }
1042
1043 #[test]
1044 fn custom_binding_dispatches_command() {
1045 let dir = tempdir().unwrap();
1046 let mut app = App::builder()
1047 .pane(PaneOptions::new(dir.path()))
1048 .key_bindings(KeyBindings::new().bind(
1049 KeyCode::F(2),
1050 KeyModifiers::NONE,
1051 AppCommand::ToggleSinglePane,
1052 ))
1053 .build()
1054 .unwrap();
1055 assert!(!app.single_pane);
1056 let outcome = app
1057 .dispatch_key(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE))
1058 .unwrap();
1059 assert_eq!(
1060 outcome,
1061 AppOutcome::Changed(vec![AppEvent::LayoutChanged { single_pane: true }])
1062 );
1063 assert!(app.single_pane);
1064 assert_eq!(
1065 app.drain_events().collect::<Vec<_>>(),
1066 vec![AppEvent::LayoutChanged { single_pane: true }]
1067 );
1068 }
1069
1070 #[test]
1071 fn custom_bindings_do_not_override_inline_editor_input() {
1072 let dir = tempdir().unwrap();
1073 std::fs::write(dir.path().join("note.txt"), b"").unwrap();
1074 let mut app = App::builder()
1075 .pane(PaneOptions::new(dir.path()))
1076 .key_bindings(KeyBindings::new().bind(
1077 KeyCode::Char('p'),
1078 KeyModifiers::NONE,
1079 AppCommand::ToggleSinglePane,
1080 ))
1081 .build()
1082 .unwrap();
1083 app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))
1084 .unwrap();
1085 app.dispatch_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE))
1086 .unwrap();
1087 assert!(!app.single_pane);
1088 assert_eq!(app.inline_editor.as_ref().unwrap().lines(), &["p"]);
1089 }
1090
1091 #[test]
1092 fn custom_bindings_do_not_override_search_input() {
1093 let dir = tempdir().unwrap();
1094 std::fs::write(dir.path().join("paper.txt"), b"").unwrap();
1095 let mut app = App::builder()
1096 .pane(PaneOptions::new(dir.path()))
1097 .key_bindings(KeyBindings::new().bind(
1098 KeyCode::Char('p'),
1099 KeyModifiers::NONE,
1100 AppCommand::ToggleSinglePane,
1101 ))
1102 .build()
1103 .unwrap();
1104 app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE))
1105 .unwrap();
1106 app.dispatch_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE))
1107 .unwrap();
1108 assert!(!app.single_pane);
1109 assert_eq!(app.active_pane().search_query(), "p");
1110 }
1111
1112 #[test]
1113 fn snapshot_round_trips_host_owned_state() {
1114 let left = tempdir().unwrap();
1115 let right = tempdir().unwrap();
1116 let mut app = App::builder()
1117 .pane(PaneOptions::new(left.path()))
1118 .pane(PaneOptions::new(right.path()))
1119 .build()
1120 .unwrap();
1121 app.set_active_pane_index(1).unwrap();
1122 app.single_pane = true;
1123 let snapshot = app.snapshot();
1124
1125 let mut restored = App::new(AppOptions::default());
1126 restored.restore(&snapshot);
1127 assert_eq!(restored.panes().len(), 2);
1128 assert_eq!(restored.active_pane_index(), 1);
1129 assert!(restored.single_pane);
1130 }
1131}