1use std::{
14 fs,
15 io::{self},
16 path::{Path, PathBuf},
17 time::{Duration, Instant},
18};
19
20#[derive(Debug, Clone, PartialEq, Eq, Default)]
55pub enum Editor {
56 #[default]
58 None,
59 Helix,
61 Neovim,
63 Vim,
65 Nano,
67 Micro,
69 Emacs,
71 VSCode,
73 Zed,
75 Xcode,
77 AndroidStudio,
79 RustRover,
81 IntelliJIdea,
83 WebStorm,
85 PyCharm,
87 GoLand,
89 CLion,
91 Fleet,
93 Sublime,
95 RubyMine,
97 PHPStorm,
99 Rider,
101 Eclipse,
103 Custom(String),
105}
106
107impl Editor {
108 pub fn binary(&self) -> Option<String> {
125 match self {
126 Editor::None => Option::None,
127 Editor::Helix => Some(Self::resolve_helix()),
128 Editor::Neovim => Some("nvim".to_string()),
129 Editor::Vim => Some("vim".to_string()),
130 Editor::Nano => Some("nano".to_string()),
131 Editor::Micro => Some("micro".to_string()),
132 Editor::Emacs => Some("emacs".to_string()),
133 Editor::VSCode => Some("code".to_string()),
134 Editor::Zed => Some("zed".to_string()),
135 Editor::Xcode => Some("xed".to_string()),
136 Editor::AndroidStudio => Some("studio".to_string()),
137 Editor::RustRover => Some("rustrover".to_string()),
138 Editor::IntelliJIdea => Some("idea".to_string()),
139 Editor::WebStorm => Some("webstorm".to_string()),
140 Editor::PyCharm => Some("pycharm".to_string()),
141 Editor::GoLand => Some("goland".to_string()),
142 Editor::CLion => Some("clion".to_string()),
143 Editor::Fleet => Some("fleet".to_string()),
144 Editor::Sublime => Some("subl".to_string()),
145 Editor::RubyMine => Some("rubymine".to_string()),
146 Editor::PHPStorm => Some("phpstorm".to_string()),
147 Editor::Rider => Some("rider".to_string()),
148 Editor::Eclipse => Some("eclipse".to_string()),
149 Editor::Custom(s) => Some(s.clone()),
150 }
151 }
152
153 fn resolve_helix() -> String {
158 for candidate in &["hx", "helix"] {
159 if which_on_path(candidate) {
160 return candidate.to_string();
161 }
162 }
163 "hx".to_string()
165 }
166
167 pub fn label(&self) -> &str {
169 match self {
170 Editor::None => "none",
171 Editor::Helix => "helix",
172 Editor::Neovim => "nvim",
173 Editor::Vim => "vim",
174 Editor::Nano => "nano",
175 Editor::Micro => "micro",
176 Editor::Emacs => "emacs",
177 Editor::VSCode => "vscode",
178 Editor::Zed => "zed",
179 Editor::Xcode => "xcode",
180 Editor::AndroidStudio => "android-studio",
181 Editor::RustRover => "rustrover",
182 Editor::IntelliJIdea => "intellij",
183 Editor::WebStorm => "webstorm",
184 Editor::PyCharm => "pycharm",
185 Editor::GoLand => "goland",
186 Editor::CLion => "clion",
187 Editor::Fleet => "fleet",
188 Editor::Sublime => "sublime",
189 Editor::RubyMine => "rubymine",
190 Editor::PHPStorm => "phpstorm",
191 Editor::Rider => "rider",
192 Editor::Eclipse => "eclipse",
193 Editor::Custom(s) => s.as_str(),
194 }
195 }
196
197 #[allow(dead_code)]
204 pub fn cycle(&self) -> Editor {
205 match self {
206 Editor::None => Editor::Helix,
207 Editor::Helix => Editor::Neovim,
208 Editor::Neovim => Editor::Vim,
209 Editor::Vim => Editor::Nano,
210 Editor::Nano => Editor::Micro,
211 Editor::Micro => Editor::None,
212 _ => Editor::None,
216 }
217 }
218
219 pub fn to_key(&self) -> String {
221 match self {
222 Editor::None => "none".to_string(),
223 Editor::Helix => "helix".to_string(),
224 Editor::Neovim => "nvim".to_string(),
225 Editor::Vim => "vim".to_string(),
226 Editor::Nano => "nano".to_string(),
227 Editor::Micro => "micro".to_string(),
228 Editor::Emacs => "emacs".to_string(),
229 Editor::VSCode => "vscode".to_string(),
230 Editor::Zed => "zed".to_string(),
231 Editor::Xcode => "xcode".to_string(),
232 Editor::AndroidStudio => "android-studio".to_string(),
233 Editor::RustRover => "rustrover".to_string(),
234 Editor::IntelliJIdea => "intellij".to_string(),
235 Editor::WebStorm => "webstorm".to_string(),
236 Editor::PyCharm => "pycharm".to_string(),
237 Editor::GoLand => "goland".to_string(),
238 Editor::CLion => "clion".to_string(),
239 Editor::Fleet => "fleet".to_string(),
240 Editor::Sublime => "sublime".to_string(),
241 Editor::RubyMine => "rubymine".to_string(),
242 Editor::PHPStorm => "phpstorm".to_string(),
243 Editor::Rider => "rider".to_string(),
244 Editor::Eclipse => "eclipse".to_string(),
245 Editor::Custom(s) => format!("custom:{s}"),
246 }
247 }
248
249 pub fn from_key(s: &str) -> Option<Editor> {
254 if s.is_empty() {
255 return Option::None;
256 }
257 Some(match s {
258 "none" => Editor::None,
259 "helix" => Editor::Helix,
260 "nvim" => Editor::Neovim,
261 "vim" => Editor::Vim,
262 "nano" => Editor::Nano,
263 "micro" => Editor::Micro,
264 "emacs" => Editor::Emacs,
265 "vscode" => Editor::VSCode,
266 "zed" => Editor::Zed,
267 "xcode" => Editor::Xcode,
268 "android-studio" => Editor::AndroidStudio,
269 "rustrover" => Editor::RustRover,
270 "intellij" => Editor::IntelliJIdea,
271 "webstorm" => Editor::WebStorm,
272 "pycharm" => Editor::PyCharm,
273 "goland" => Editor::GoLand,
274 "clion" => Editor::CLion,
275 "fleet" => Editor::Fleet,
276 "sublime" => Editor::Sublime,
277 "rubymine" => Editor::RubyMine,
278 "phpstorm" => Editor::PHPStorm,
279 "rider" => Editor::Rider,
280 "eclipse" => Editor::Eclipse,
281 _ if s.starts_with("custom:") => Editor::Custom(s["custom:".len()..].to_string()),
282 other => Editor::Custom(other.to_string()),
283 })
284 }
285}
286
287fn which_on_path(name: &str) -> bool {
296 let path_var = std::env::var_os("PATH").unwrap_or_default();
297 std::env::split_paths(&path_var).any(|dir| {
298 let candidate = dir.join(name);
299 candidate
301 .metadata()
302 .map(|m| {
303 #[cfg(unix)]
304 {
305 use std::os::unix::fs::PermissionsExt;
306 m.is_file() && (m.permissions().mode() & 0o111 != 0)
307 }
308 #[cfg(not(unix))]
309 {
310 m.is_file()
311 }
312 })
313 .unwrap_or(false)
314 })
315}
316
317#[derive(Debug, Clone)]
334pub struct AppOptions {
335 pub left_dir: PathBuf,
337 pub right_dir: PathBuf,
339 pub extensions: Vec<String>,
341 pub show_hidden: bool,
343 pub theme_idx: usize,
345 pub show_theme_panel: bool,
347 pub single_pane: bool,
349 pub sort_mode: SortMode,
351 pub cd_on_exit: bool,
353 pub editor: Editor,
355 pub verbose: bool,
358 pub startup_log: Vec<String>,
361}
362
363impl Default for AppOptions {
364 fn default() -> Self {
365 Self {
366 left_dir: PathBuf::from("."),
367 right_dir: PathBuf::from("."),
368 extensions: vec![],
369 show_hidden: false,
370 theme_idx: 0,
371 show_theme_panel: false,
372 single_pane: false,
373 sort_mode: SortMode::default(),
374 cd_on_exit: false,
375 editor: Editor::default(),
376 verbose: false,
377 startup_log: Vec::new(),
378 }
379 }
380}
381
382use crate::fs::copy_dir_all;
383
384use crate::{ExplorerOutcome, FileExplorer, SortMode, Theme};
385use crossterm::event::{self, Event, KeyCode, KeyModifiers};
386
387#[derive(Debug, Clone, Copy, PartialEq, Eq)]
391pub enum Pane {
392 Left,
393 Right,
394}
395
396impl Pane {
397 pub fn other(self) -> Self {
399 match self {
400 Pane::Left => Pane::Right,
401 Pane::Right => Pane::Left,
402 }
403 }
404}
405
406#[derive(Debug, Clone, Copy, PartialEq, Eq)]
410pub enum ClipOp {
411 Copy,
412 Cut,
413}
414
415#[derive(Debug, Clone)]
421pub struct ClipboardItem {
422 pub paths: Vec<PathBuf>,
424 pub op: ClipOp,
426}
427
428impl ClipboardItem {
429 pub fn icon(&self) -> &'static str {
431 match self.op {
432 ClipOp::Copy => "\u{1F4CB}", ClipOp::Cut => "\u{2702} ", }
435 }
436
437 pub fn label(&self) -> &'static str {
439 match self.op {
440 ClipOp::Copy => "Copy",
441 ClipOp::Cut => "Cut ",
442 }
443 }
444
445 pub fn count(&self) -> usize {
447 self.paths.len()
448 }
449
450 pub fn first_path(&self) -> Option<&PathBuf> {
452 self.paths.first()
453 }
454}
455
456#[derive(Debug)]
461pub enum Modal {
462 Delete {
464 path: PathBuf,
466 },
467 MultiDelete {
469 paths: Vec<PathBuf>,
471 },
472 Overwrite {
474 src: PathBuf,
476 dst: PathBuf,
478 is_cut: bool,
480 },
481}
482
483pub struct Snackbar {
493 pub message: String,
495 pub expires_at: Instant,
497 pub is_error: bool,
499}
500
501impl Snackbar {
502 #[allow(dead_code)]
504 pub fn info(message: impl Into<String>) -> Self {
505 Self {
506 message: message.into(),
507 expires_at: Instant::now() + Duration::from_secs(3),
508 is_error: false,
509 }
510 }
511
512 pub fn error(message: impl Into<String>) -> Self {
514 Self {
515 message: message.into(),
516 expires_at: Instant::now() + Duration::from_secs(4),
517 is_error: true,
518 }
519 }
520
521 pub fn is_expired(&self) -> bool {
523 Instant::now() >= self.expires_at
524 }
525}
526
527#[derive(Debug, Clone)]
529pub struct CopyProgress {
530 pub label: String,
532 pub done: usize,
534 pub total: usize,
536 pub current_item: String,
538}
539
540impl CopyProgress {
541 pub fn new(label: impl Into<String>, total: usize) -> Self {
543 Self {
544 label: label.into(),
545 done: 0,
546 total,
547 current_item: String::new(),
548 }
549 }
550
551 pub fn fraction(&self) -> f64 {
553 if self.total == 0 {
554 1.0
555 } else {
556 self.done as f64 / self.total as f64
557 }
558 }
559
560 pub fn is_complete(&self) -> bool {
562 self.done >= self.total
563 }
564}
565
566pub struct App {
567 pub left: FileExplorer,
569 pub right: FileExplorer,
571 pub active: Pane,
573 pub clipboard: Option<ClipboardItem>,
575 pub themes: Vec<(&'static str, &'static str, Theme)>,
577 pub theme_idx: usize,
579 pub show_theme_panel: bool,
581 pub show_options_panel: bool,
583 pub single_pane: bool,
585 pub modal: Option<Modal>,
587 pub selected: Option<PathBuf>,
589 pub status_msg: String,
591 pub snackbar: Option<Snackbar>,
593 pub copy_progress: Option<CopyProgress>,
595 pub cd_on_exit: bool,
597 pub editor: Editor,
599 pub open_with_editor: Option<PathBuf>,
603 pub show_editor_panel: bool,
605 pub editor_panel_idx: usize,
607 pub verbose: bool,
609 pub debug_log: Vec<String>,
611 pub debug_scroll: usize,
613}
614
615impl App {
616 pub fn new(opts: AppOptions) -> Self {
618 let left = FileExplorer::builder(opts.left_dir)
619 .extension_filter(opts.extensions.clone())
620 .show_hidden(opts.show_hidden)
621 .sort_mode(opts.sort_mode)
622 .build();
623 let right = FileExplorer::builder(opts.right_dir)
624 .extension_filter(opts.extensions)
625 .show_hidden(opts.show_hidden)
626 .sort_mode(opts.sort_mode)
627 .build();
628 Self {
629 left,
630 right,
631 active: Pane::Left,
632 clipboard: None,
633 themes: Theme::all_presets(),
634 theme_idx: opts.theme_idx,
635 show_theme_panel: opts.show_theme_panel,
636 show_options_panel: false,
637 single_pane: opts.single_pane,
638 modal: None,
639 selected: None,
640 status_msg: String::new(),
641 snackbar: None,
642 copy_progress: None,
643 cd_on_exit: opts.cd_on_exit,
644 editor: opts.editor,
645 open_with_editor: None,
646 show_editor_panel: false,
647 editor_panel_idx: 0,
648 verbose: opts.verbose,
649 debug_log: opts.startup_log,
650 debug_scroll: 0,
651 }
652 }
653
654 pub fn log(&mut self, msg: impl Into<String>) {
657 if self.verbose {
658 self.debug_log.push(msg.into());
659 }
660 }
661
662 pub fn first_ide_idx() -> usize {
668 7
670 }
671
672 pub fn all_editors() -> Vec<Editor> {
677 vec![
678 Editor::None,
680 Editor::Helix,
681 Editor::Neovim,
682 Editor::Vim,
683 Editor::Nano,
684 Editor::Micro,
685 Editor::Emacs,
686 Editor::Sublime,
688 Editor::VSCode,
689 Editor::Zed,
690 Editor::Xcode,
691 Editor::AndroidStudio,
692 Editor::RustRover,
693 Editor::IntelliJIdea,
694 Editor::WebStorm,
695 Editor::PyCharm,
696 Editor::GoLand,
697 Editor::CLion,
698 Editor::Fleet,
699 Editor::RubyMine,
700 Editor::PHPStorm,
701 Editor::Rider,
702 Editor::Eclipse,
703 ]
704 }
705
706 pub fn sync_editor_panel_idx(&mut self) {
711 let editors = Self::all_editors();
712 self.editor_panel_idx = editors.iter().position(|e| e == &self.editor).unwrap_or(0);
713 }
714
715 #[allow(dead_code)]
719 pub fn notify(&mut self, msg: impl Into<String>) {
720 self.snackbar = Some(Snackbar::info(msg));
721 }
722
723 pub fn notify_error(&mut self, msg: impl Into<String>) {
725 self.snackbar = Some(Snackbar::error(msg));
726 }
727
728 pub fn active_pane(&self) -> &FileExplorer {
731 match self.active {
732 Pane::Left => &self.left,
733 Pane::Right => &self.right,
734 }
735 }
736
737 pub fn active_pane_mut(&mut self) -> &mut FileExplorer {
739 match self.active {
740 Pane::Left => &mut self.left,
741 Pane::Right => &mut self.right,
742 }
743 }
744
745 pub fn theme(&self) -> &Theme {
749 &self.themes[self.theme_idx].2
750 }
751
752 pub fn theme_name(&self) -> &str {
754 self.themes[self.theme_idx].0
755 }
756
757 pub fn theme_desc(&self) -> &str {
759 self.themes[self.theme_idx].1
760 }
761
762 pub fn next_theme(&mut self) {
764 self.theme_idx = (self.theme_idx + 1) % self.themes.len();
765 }
766
767 pub fn prev_theme(&mut self) {
769 self.theme_idx = self
770 .theme_idx
771 .checked_sub(1)
772 .unwrap_or(self.themes.len() - 1);
773 }
774
775 pub fn yank(&mut self, op: ClipOp) {
788 let active_marks: Vec<PathBuf> = self.active_pane().marked.iter().cloned().collect();
789 let inactive_marks: Vec<PathBuf> = match self.active {
790 Pane::Left => self.right.marked.iter().cloned().collect(),
791 Pane::Right => self.left.marked.iter().cloned().collect(),
792 };
793
794 enum Source {
796 ActiveMarks,
797 InactiveMarks,
798 Cursor,
799 }
800
801 let source = if !active_marks.is_empty() {
802 Source::ActiveMarks
803 } else if !inactive_marks.is_empty() {
804 Source::InactiveMarks
805 } else {
806 Source::Cursor
807 };
808
809 let paths: Vec<PathBuf> = match source {
810 Source::ActiveMarks => {
811 let mut sorted = active_marks;
812 sorted.sort();
813 sorted
814 }
815 Source::InactiveMarks => {
816 let mut sorted = inactive_marks;
817 sorted.sort();
818 sorted
819 }
820 Source::Cursor => {
821 if let Some(entry) = self.active_pane().current_entry() {
822 vec![entry.path.clone()]
823 } else {
824 return;
825 }
826 }
827 };
828
829 let count = paths.len();
830 let (verb, hint) = if op == ClipOp::Copy {
831 ("Copied", "paste a copy")
832 } else {
833 ("Cut", "move")
834 };
835
836 let label = if count == 1 {
837 format!(
838 "'{}'",
839 paths[0].file_name().unwrap_or_default().to_string_lossy()
840 )
841 } else {
842 format!("{count} items")
843 };
844
845 self.clipboard = Some(ClipboardItem { paths, op });
846
847 match source {
849 Source::ActiveMarks | Source::Cursor => self.active_pane_mut().clear_marks(),
850 Source::InactiveMarks => match self.active {
851 Pane::Left => self.right.clear_marks(),
852 Pane::Right => self.left.clear_marks(),
853 },
854 }
855
856 self.status_msg = format!("{verb} {label} — press p to {hint}");
857 }
858
859 pub fn paste(&mut self) {
864 let Some(clip) = self.clipboard.clone() else {
865 self.status_msg = "Nothing in clipboard.".into();
866 return;
867 };
868
869 let dst_dir = self.active_pane().current_dir.clone();
870
871 if clip.paths.len() == 1 {
873 let src = &clip.paths[0];
874 let file_name = match src.file_name() {
875 Some(n) => n.to_owned(),
876 None => {
877 self.status_msg = "Cannot paste: clipboard path has no filename.".into();
878 return;
879 }
880 };
881 let dst = dst_dir.join(&file_name);
882
883 if clip.op == ClipOp::Cut && src.parent() == Some(&dst_dir) {
884 self.status_msg = "Source and destination are the same — skipped.".into();
885 return;
886 }
887
888 if dst.exists() {
889 self.modal = Some(Modal::Overwrite {
890 src: src.clone(),
891 dst,
892 is_cut: clip.op == ClipOp::Cut,
893 });
894 return;
895 }
896 }
897
898 self.do_paste_all(&clip.paths.clone(), &dst_dir, clip.op == ClipOp::Cut);
900 }
901
902 pub fn do_paste(&mut self, src: &Path, dst: &Path, is_cut: bool) {
907 let result = if src.is_dir() {
908 copy_dir_all(src, dst)
909 } else {
910 fs::copy(src, dst).map(|_| ())
911 };
912
913 match result {
914 Ok(()) => {
915 if is_cut {
916 let _ = if src.is_dir() {
917 fs::remove_dir_all(src)
918 } else {
919 fs::remove_file(src)
920 };
921 self.clipboard = None;
922 }
923 self.left.reload();
924 self.right.reload();
925 let msg = format!(
926 "{} '{}'",
927 if is_cut { "Moved" } else { "Pasted" },
928 dst.file_name().unwrap_or_default().to_string_lossy()
929 );
930 self.status_msg = msg.clone();
931 self.notify(msg);
932 }
933 Err(e) => {
934 let msg = format!("Paste failed: {e}");
935 self.status_msg = format!("Error: {msg}");
936 self.notify_error(msg);
937 }
938 }
939 }
940
941 pub fn do_paste_all(&mut self, srcs: &[PathBuf], dst_dir: &Path, is_cut: bool) {
946 let mut errors: Vec<String> = Vec::new();
947 let mut succeeded: usize = 0;
948 let total = srcs.len();
949 let verb_label = if is_cut { "Moving" } else { "Copying" };
950
951 self.copy_progress = Some(CopyProgress::new(
953 format!("{verb_label} {total} item(s)…"),
954 total,
955 ));
956
957 for src in srcs {
958 let file_name = match src.file_name() {
959 Some(n) => n,
960 None => {
961 errors.push(format!("skipped (no filename): {}", src.display()));
962 if let Some(p) = &mut self.copy_progress {
963 p.done += 1;
964 }
965 continue;
966 }
967 };
968
969 if let Some(p) = &mut self.copy_progress {
972 p.current_item = file_name.to_string_lossy().into_owned();
973 }
974
975 let dst = dst_dir.join(file_name);
976
977 if is_cut && src.parent() == Some(dst_dir) {
979 if let Some(p) = &mut self.copy_progress {
980 p.done += 1;
981 }
982 continue;
983 }
984
985 let result = if src.is_dir() {
986 copy_dir_all(src, &dst)
987 } else {
988 fs::copy(src, &dst).map(|_| ())
989 };
990
991 match result {
992 Ok(()) => {
993 if is_cut {
994 let _ = if src.is_dir() {
995 fs::remove_dir_all(src)
996 } else {
997 fs::remove_file(src)
998 };
999 }
1000 succeeded += 1;
1001 }
1002 Err(e) => {
1003 errors.push(format!(
1004 "'{}': {e}",
1005 src.file_name().unwrap_or_default().to_string_lossy()
1006 ));
1007 }
1008 }
1009
1010 if let Some(p) = &mut self.copy_progress {
1011 p.done += 1;
1012 }
1013 }
1014
1015 self.copy_progress = None;
1017
1018 if is_cut && errors.is_empty() {
1019 self.clipboard = None;
1020 }
1021
1022 self.left.reload();
1023 self.right.reload();
1024
1025 if errors.is_empty() {
1026 let verb = if is_cut { "Moved" } else { "Pasted" };
1027 let msg = format!("{verb} {succeeded} item(s).");
1028 self.status_msg = msg.clone();
1029 self.notify(msg);
1030 } else {
1031 let verb = if is_cut { "Moved" } else { "Pasted" };
1032 let msg = format!(
1033 "{verb} {succeeded}, {} error(s): {}",
1034 errors.len(),
1035 errors.join("; ")
1036 );
1037 self.status_msg = format!("Error: {msg}");
1038 self.notify_error(msg);
1039 }
1040 }
1041
1042 pub fn prompt_delete(&mut self) {
1046 let marked: Vec<PathBuf> = self.active_pane().marked.iter().cloned().collect();
1047 if !marked.is_empty() {
1048 let mut sorted = marked;
1049 sorted.sort();
1050 self.modal = Some(Modal::MultiDelete { paths: sorted });
1051 } else if let Some(entry) = self.active_pane().current_entry() {
1052 self.modal = Some(Modal::Delete {
1053 path: entry.path.clone(),
1054 });
1055 }
1056 }
1057
1058 pub fn confirm_delete_many(&mut self, paths: &[PathBuf]) {
1060 let mut errors: Vec<String> = Vec::new();
1061 let mut deleted: usize = 0;
1062
1063 for path in paths {
1064 let result = if path.is_dir() {
1065 std::fs::remove_dir_all(path)
1066 } else {
1067 std::fs::remove_file(path)
1068 };
1069 match result {
1070 Ok(()) => deleted += 1,
1071 Err(e) => errors.push(format!(
1072 "'{}': {e}",
1073 path.file_name().unwrap_or_default().to_string_lossy()
1074 )),
1075 }
1076 }
1077
1078 self.left.clear_marks();
1079 self.right.clear_marks();
1080 self.left.reload();
1081 self.right.reload();
1082
1083 if errors.is_empty() {
1084 self.status_msg = format!("Deleted {deleted} item(s).");
1085 } else {
1086 self.status_msg = format!(
1087 "Deleted {deleted}, {} error(s): {}",
1088 errors.len(),
1089 errors.join("; ")
1090 );
1091 }
1092 }
1093
1094 pub fn confirm_delete(&mut self, path: &Path) {
1096 let name = path
1097 .file_name()
1098 .unwrap_or_default()
1099 .to_string_lossy()
1100 .to_string();
1101 let result = if path.is_dir() {
1102 fs::remove_dir_all(path)
1103 } else {
1104 fs::remove_file(path)
1105 };
1106 match result {
1107 Ok(()) => {
1108 self.left.reload();
1109 self.right.reload();
1110 self.status_msg = format!("Deleted '{name}'");
1111 }
1112 Err(e) => {
1113 self.status_msg = format!("Delete failed: {e}");
1114 }
1115 }
1116 }
1117
1118 pub fn handle_key(&mut self, key: crossterm::event::KeyEvent) -> io::Result<bool> {
1143 if key.kind != crossterm::event::KeyEventKind::Press {
1151 return Ok(false);
1152 }
1153
1154 if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
1156 return Ok(true);
1157 }
1158
1159 if let Some(modal) = self.modal.take() {
1161 match &modal {
1162 Modal::Delete { path } => match key.code {
1163 KeyCode::Char('y') | KeyCode::Char('Y') => {
1164 let p = path.clone();
1165 self.confirm_delete(&p);
1166 }
1167 _ => self.status_msg = "Delete cancelled.".into(),
1168 },
1169 Modal::MultiDelete { paths } => match key.code {
1170 KeyCode::Char('y') | KeyCode::Char('Y') => {
1171 let ps = paths.clone();
1172 self.confirm_delete_many(&ps);
1173 }
1174 _ => self.status_msg = "Multi-delete cancelled.".into(),
1175 },
1176 Modal::Overwrite { src, dst, is_cut } => match key.code {
1177 KeyCode::Char('y') | KeyCode::Char('Y') => {
1178 let (s, d, cut) = (src.clone(), dst.clone(), *is_cut);
1179 self.do_paste(&s, &d, cut);
1180 }
1181 _ => self.status_msg = "Paste cancelled.".into(),
1182 },
1183 }
1184 return Ok(false);
1185 }
1186
1187 if self.verbose {
1189 match key.code {
1190 KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
1191 let max = self.debug_log.len().saturating_sub(1);
1192 self.debug_scroll = (self.debug_scroll + 1).min(max);
1193 return Ok(false);
1194 }
1195 KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
1196 self.debug_scroll = self.debug_scroll.saturating_sub(1);
1197 return Ok(false);
1198 }
1199 _ => {}
1200 }
1201 }
1202
1203 if self.show_editor_panel {
1206 match key.code {
1207 KeyCode::Down | KeyCode::Char('j') if key.modifiers.is_empty() => {
1208 let editors = App::all_editors();
1209 self.editor_panel_idx = (self.editor_panel_idx + 1) % editors.len();
1210 return Ok(false);
1211 }
1212 KeyCode::Up | KeyCode::Char('k') if key.modifiers.is_empty() => {
1213 let editors = App::all_editors();
1214 self.editor_panel_idx = self
1215 .editor_panel_idx
1216 .checked_sub(1)
1217 .unwrap_or(editors.len() - 1);
1218 return Ok(false);
1219 }
1220 KeyCode::Enter => {
1221 let editors = App::all_editors();
1222 self.editor = editors[self.editor_panel_idx].clone();
1223 self.show_editor_panel = false;
1224 return Ok(false);
1225 }
1226 KeyCode::Esc => {
1227 self.show_editor_panel = false;
1228 return Ok(false);
1229 }
1230 _ => {}
1231 }
1232 }
1233
1234 if self.show_theme_panel {
1236 match key.code {
1237 KeyCode::Down | KeyCode::Char('j') if key.modifiers.is_empty() => {
1238 self.next_theme();
1239 return Ok(false);
1240 }
1241 KeyCode::Up | KeyCode::Char('k') if key.modifiers.is_empty() => {
1242 self.prev_theme();
1243 return Ok(false);
1244 }
1245 _ => {}
1246 }
1247 }
1248
1249 match key.code {
1250 KeyCode::Char('t') if key.modifiers.is_empty() => {
1252 self.next_theme();
1253 return Ok(false);
1254 }
1255 KeyCode::Char('[') => {
1257 self.prev_theme();
1258 return Ok(false);
1259 }
1260 KeyCode::Char('T') => {
1262 self.show_theme_panel = !self.show_theme_panel;
1263 if self.show_theme_panel {
1264 self.show_options_panel = false;
1265 self.show_editor_panel = false;
1266 }
1267 return Ok(false);
1268 }
1269 KeyCode::Char('O') => {
1271 self.show_options_panel = !self.show_options_panel;
1272 if self.show_options_panel {
1273 self.show_theme_panel = false;
1274 self.show_editor_panel = false;
1275 }
1276 return Ok(false);
1277 }
1278 KeyCode::Char('E') => {
1280 self.show_editor_panel = !self.show_editor_panel;
1281 if self.show_editor_panel {
1282 self.show_options_panel = false;
1283 self.show_theme_panel = false;
1284 self.sync_editor_panel_idx();
1285 }
1286 return Ok(false);
1287 }
1288 KeyCode::Char('C') => {
1290 self.cd_on_exit = !self.cd_on_exit;
1291 let state = if self.cd_on_exit { "on" } else { "off" };
1292 self.status_msg = format!("cd-on-exit: {state}");
1293 return Ok(false);
1294 }
1295 KeyCode::Tab => {
1297 self.active = self.active.other();
1298 return Ok(false);
1299 }
1300 KeyCode::Char('w') if key.modifiers.is_empty() => {
1302 self.single_pane = !self.single_pane;
1303 return Ok(false);
1304 }
1305 KeyCode::Char('y') if key.modifiers.is_empty() => {
1307 self.yank(ClipOp::Copy);
1308 return Ok(false);
1309 }
1310 KeyCode::Char('x') if key.modifiers.is_empty() => {
1312 self.yank(ClipOp::Cut);
1313 return Ok(false);
1314 }
1315 KeyCode::Char('p') if key.modifiers.is_empty() => {
1317 self.paste();
1318 return Ok(false);
1319 }
1320 KeyCode::Char('d') if key.modifiers.is_empty() => {
1322 self.prompt_delete();
1323 return Ok(false);
1324 }
1325 KeyCode::Char('e') if key.modifiers.is_empty() => {
1327 if self.editor != Editor::None {
1328 if let Some(entry) = self.active_pane().current_entry() {
1329 if !entry.path.is_dir() {
1330 self.open_with_editor = Some(entry.path.clone());
1331 }
1332 }
1334 } else {
1335 self.notify_error("No editor set — open Editor picker (Shift + E) to pick one");
1337 }
1338 return Ok(false);
1339 }
1340 _ => {}
1341 }
1342
1343 let outcome = self.active_pane_mut().handle_key(key);
1346 match outcome {
1347 ExplorerOutcome::Selected(path) => {
1348 if path.is_dir() {
1349 self.selected = Some(path);
1351 return Ok(true);
1352 }
1353 if self.editor != Editor::None {
1355 self.open_with_editor = Some(path);
1356 return Ok(false);
1357 }
1358 self.notify_error("No editor set — open Editor picker (Shift + E) to pick one");
1360 return Ok(false);
1361 }
1362 ExplorerOutcome::Dismissed => return Ok(true),
1363 ExplorerOutcome::MkdirCreated(path) => {
1364 self.left.reload();
1365 self.right.reload();
1366 let name = path
1367 .file_name()
1368 .unwrap_or_default()
1369 .to_string_lossy()
1370 .to_string();
1371 self.notify(format!("Created folder '{name}'"));
1372 }
1373 ExplorerOutcome::TouchCreated(path) => {
1374 self.left.reload();
1375 self.right.reload();
1376 let name = path
1377 .file_name()
1378 .unwrap_or_default()
1379 .to_string_lossy()
1380 .to_string();
1381 self.notify(format!("Created file '{name}'"));
1382 }
1383 ExplorerOutcome::RenameCompleted(path) => {
1384 self.left.reload();
1385 self.right.reload();
1386 let name = path
1387 .file_name()
1388 .unwrap_or_default()
1389 .to_string_lossy()
1390 .to_string();
1391 self.notify(format!("Renamed to '{name}'"));
1392 }
1393 ExplorerOutcome::Pending => {
1394 if self.status_msg.starts_with("Error") || self.status_msg.starts_with("Delete") {
1395 } else {
1397 self.status_msg.clear();
1398 }
1399 }
1400 ExplorerOutcome::Unhandled => {}
1401 }
1402
1403 Ok(false)
1404 }
1405
1406 pub fn handle_event(&mut self) -> io::Result<bool> {
1414 let Event::Key(key) = event::read()? else {
1415 return Ok(false);
1416 };
1417 self.handle_key(key)
1418 }
1419}
1420
1421#[cfg(test)]
1424mod tests {
1425 use super::*;
1426 use std::fs;
1427 use tempfile::tempdir;
1428
1429 #[test]
1432 fn editor_default_is_none() {
1433 assert_eq!(Editor::default(), Editor::None);
1434 }
1435
1436 #[test]
1437 fn editor_binary_none_returns_option_none() {
1438 assert_eq!(Editor::None.binary(), Option::None);
1439 }
1440
1441 #[test]
1442 fn editor_binary_names() {
1443 let helix_bin = Editor::Helix.binary();
1446 assert!(helix_bin.is_some(), "Helix binary should be Some");
1447 assert!(
1448 !helix_bin.unwrap().is_empty(),
1449 "Helix binary string should not be empty"
1450 );
1451 assert_eq!(Editor::Neovim.binary(), Some("nvim".to_string()));
1452 assert_eq!(Editor::Vim.binary(), Some("vim".to_string()));
1453 assert_eq!(Editor::Nano.binary(), Some("nano".to_string()));
1454 assert_eq!(Editor::Micro.binary(), Some("micro".to_string()));
1455 assert_eq!(
1456 Editor::Custom("code".into()).binary(),
1457 Some("code".to_string())
1458 );
1459 }
1460
1461 #[test]
1462 fn which_on_path_finds_existing_binary() {
1463 #[cfg(unix)]
1465 assert!(
1466 which_on_path("sh"),
1467 "which_on_path should find 'sh' on Unix"
1468 );
1469 #[cfg(not(unix))]
1471 let _ = which_on_path("cmd");
1472 }
1473
1474 #[test]
1475 fn which_on_path_returns_false_for_nonexistent_binary() {
1476 assert!(
1477 !which_on_path("__tfe_definitely_does_not_exist__"),
1478 "which_on_path should return false for a binary that doesn't exist"
1479 );
1480 }
1481
1482 #[test]
1483 fn helix_binary_returns_hx_or_helix() {
1484 let bin = Editor::Helix.binary().expect("Helix binary should be Some");
1485 assert!(
1486 bin == "hx" || bin == "helix",
1487 "Helix binary should be 'hx' or 'helix', got '{bin}'"
1488 );
1489 }
1490
1491 #[test]
1492 fn helix_binary_matches_what_is_on_path() {
1493 let bin = Editor::Helix.binary().expect("Helix binary should be Some");
1494 if which_on_path("hx") || which_on_path("helix") {
1496 assert!(
1497 which_on_path(&bin),
1498 "resolved helix binary '{bin}' should be found on $PATH"
1499 );
1500 }
1501 }
1502
1503 #[test]
1504 fn editor_label_names() {
1505 assert_eq!(Editor::None.label(), "none");
1506 assert_eq!(Editor::Helix.label(), "helix");
1507 assert_eq!(Editor::Neovim.label(), "nvim");
1508 assert_eq!(Editor::Vim.label(), "vim");
1509 assert_eq!(Editor::Nano.label(), "nano");
1510 assert_eq!(Editor::Micro.label(), "micro");
1511 assert_eq!(Editor::Custom("code".into()).label(), "code");
1512 }
1513
1514 #[test]
1515 fn editor_cycle_order() {
1516 assert_eq!(Editor::None.cycle(), Editor::Helix);
1517 assert_eq!(Editor::Helix.cycle(), Editor::Neovim);
1518 assert_eq!(Editor::Neovim.cycle(), Editor::Vim);
1519 assert_eq!(Editor::Vim.cycle(), Editor::Nano);
1520 assert_eq!(Editor::Nano.cycle(), Editor::Micro);
1521 assert_eq!(Editor::Micro.cycle(), Editor::None);
1522 }
1523
1524 #[test]
1525 fn editor_custom_cycle_resets_to_none() {
1526 assert_eq!(Editor::Custom("code".into()).cycle(), Editor::None);
1527 }
1528
1529 #[test]
1530 fn editor_cycle_full_loop_returns_to_start() {
1531 let mut e = Editor::None;
1532 for _ in 0..6 {
1534 e = e.cycle();
1535 }
1536 assert_eq!(e, Editor::None);
1537 }
1538
1539 #[test]
1540 fn editor_to_key_round_trips() {
1541 for e in [
1542 Editor::None,
1543 Editor::Helix,
1544 Editor::Neovim,
1545 Editor::Vim,
1546 Editor::Nano,
1547 Editor::Micro,
1548 Editor::Custom("code".into()),
1549 ] {
1550 let key = e.to_key();
1551 assert_eq!(Editor::from_key(&key), Some(e));
1552 }
1553 }
1554
1555 #[test]
1556 fn editor_none_serialises_as_none_key() {
1557 assert_eq!(Editor::None.to_key(), "none");
1558 assert_eq!(Editor::from_key("none"), Some(Editor::None));
1559 }
1560
1561 #[test]
1562 fn editor_from_key_empty_returns_none() {
1563 assert_eq!(Editor::from_key(""), None);
1564 }
1565
1566 #[test]
1567 fn editor_from_key_unknown_is_custom() {
1568 assert_eq!(
1570 Editor::from_key("some-unknown-editor"),
1571 Some(Editor::Custom("some-unknown-editor".into()))
1572 );
1573 }
1574
1575 #[test]
1576 fn editor_from_key_custom_prefix_strips_prefix() {
1577 assert_eq!(
1578 Editor::from_key("custom:code"),
1579 Some(Editor::Custom("code".into()))
1580 );
1581 }
1582
1583 #[test]
1584 fn app_options_default_editor_is_none() {
1585 assert_eq!(AppOptions::default().editor, Editor::None);
1586 }
1587
1588 #[test]
1589 fn app_new_editor_field_is_from_options() {
1590 let dir = tempdir().unwrap();
1591 let app = make_app(dir.path().to_path_buf());
1592 assert_eq!(app.editor, Editor::None);
1593 }
1594
1595 #[test]
1596 fn app_new_open_with_editor_is_none() {
1597 let dir = tempdir().unwrap();
1598 let app = make_app(dir.path().to_path_buf());
1599 assert!(app.open_with_editor.is_none());
1600 }
1601
1602 #[test]
1603 fn enter_on_file_with_editor_sets_open_with_editor_not_selected() {
1604 let dir = tempdir().unwrap();
1605 let file = dir.path().join("test.txt");
1606 fs::write(&file, b"hello").unwrap();
1607
1608 let mut app = App::new(AppOptions {
1609 left_dir: dir.path().to_path_buf(),
1610 right_dir: dir.path().to_path_buf(),
1611 editor: Editor::Helix,
1612 ..AppOptions::default()
1613 });
1614
1615 let outcome = ExplorerOutcome::Selected(file.clone());
1618 if let ExplorerOutcome::Selected(path) = outcome {
1619 if app.editor != Editor::None && !path.is_dir() {
1620 app.open_with_editor = Some(path);
1621 } else {
1622 app.selected = Some(path);
1623 }
1624 }
1625
1626 assert_eq!(
1627 app.open_with_editor,
1628 Some(file),
1629 "open_with_editor must be set"
1630 );
1631 assert!(
1632 app.selected.is_none(),
1633 "selected must remain None — TUI must not exit"
1634 );
1635 }
1636
1637 #[test]
1638 fn enter_on_file_with_editor_none_sets_selected_and_exits() {
1639 let dir = tempdir().unwrap();
1640 let file = dir.path().join("test.txt");
1641 fs::write(&file, b"hello").unwrap();
1642
1643 let mut app = make_app(dir.path().to_path_buf());
1644 assert_eq!(app.editor, Editor::None);
1646
1647 let outcome = ExplorerOutcome::Selected(file.clone());
1648 if let ExplorerOutcome::Selected(path) = outcome {
1649 if app.editor != Editor::None && !path.is_dir() {
1650 app.open_with_editor = Some(path);
1651 } else {
1652 app.selected = Some(path);
1653 }
1654 }
1655
1656 assert_eq!(
1657 app.selected,
1658 Some(file),
1659 "selected must be set so TUI exits"
1660 );
1661 assert!(
1662 app.open_with_editor.is_none(),
1663 "open_with_editor must remain None"
1664 );
1665 }
1666
1667 #[test]
1668 fn enter_on_dir_always_navigates_not_opens_editor() {
1669 let dir = tempdir().unwrap();
1670 let subdir = dir.path().join("subdir");
1671 fs::create_dir(&subdir).unwrap();
1672
1673 let mut app = App::new(AppOptions {
1674 left_dir: dir.path().to_path_buf(),
1675 right_dir: dir.path().to_path_buf(),
1676 editor: Editor::Helix,
1677 ..AppOptions::default()
1678 });
1679
1680 let outcome = ExplorerOutcome::Selected(subdir.clone());
1682 if let ExplorerOutcome::Selected(path) = outcome {
1683 if app.editor != Editor::None && !path.is_dir() {
1684 app.open_with_editor = Some(path);
1685 } else {
1686 app.selected = Some(path);
1687 }
1688 }
1689
1690 assert!(
1691 app.open_with_editor.is_none(),
1692 "dirs must never go to open_with_editor"
1693 );
1694 assert_eq!(app.selected, Some(subdir));
1695 }
1696
1697 fn make_app(dir: PathBuf) -> App {
1701 App::new(AppOptions {
1702 left_dir: dir.clone(),
1703 right_dir: dir,
1704 ..AppOptions::default()
1705 })
1706 }
1707
1708 #[test]
1711 fn pane_other_left_returns_right() {
1712 assert_eq!(Pane::Left.other(), Pane::Right);
1713 }
1714
1715 #[test]
1716 fn pane_other_right_returns_left() {
1717 assert_eq!(Pane::Right.other(), Pane::Left);
1718 }
1719
1720 #[test]
1723 fn clipboard_item_copy_icon_and_label() {
1724 let item = ClipboardItem {
1725 paths: vec![PathBuf::from("/tmp/foo")],
1726 op: ClipOp::Copy,
1727 };
1728 assert_eq!(item.icon(), "\u{1F4CB}");
1729 assert_eq!(item.label(), "Copy");
1730 }
1731
1732 #[test]
1733 fn clipboard_item_cut_icon_and_label() {
1734 let item = ClipboardItem {
1735 paths: vec![PathBuf::from("/tmp/foo")],
1736 op: ClipOp::Cut,
1737 };
1738 assert_eq!(item.icon(), "\u{2702} ");
1739 assert_eq!(item.label(), "Cut ");
1740 }
1741
1742 #[test]
1743 fn clipboard_item_count_single() {
1744 let item = ClipboardItem {
1745 paths: vec![PathBuf::from("/tmp/foo")],
1746 op: ClipOp::Copy,
1747 };
1748 assert_eq!(item.count(), 1);
1749 }
1750
1751 #[test]
1752 fn clipboard_item_count_multi() {
1753 let item = ClipboardItem {
1754 paths: vec![PathBuf::from("/tmp/a"), PathBuf::from("/tmp/b")],
1755 op: ClipOp::Copy,
1756 };
1757 assert_eq!(item.count(), 2);
1758 }
1759
1760 #[test]
1763 fn new_sets_default_active_pane_to_left() {
1764 let dir = tempdir().expect("tempdir");
1765 let app = make_app(dir.path().to_path_buf());
1766 assert_eq!(app.active, Pane::Left);
1767 }
1768
1769 #[test]
1770 fn new_clipboard_is_empty() {
1771 let dir = tempdir().expect("tempdir");
1772 let app = make_app(dir.path().to_path_buf());
1773 assert!(app.clipboard.is_none());
1774 }
1775
1776 #[test]
1777 fn new_modal_is_none() {
1778 let dir = tempdir().expect("tempdir");
1779 let app = make_app(dir.path().to_path_buf());
1780 assert!(app.modal.is_none());
1781 }
1782
1783 #[test]
1784 fn new_selected_is_none() {
1785 let dir = tempdir().expect("tempdir");
1786 let app = make_app(dir.path().to_path_buf());
1787 assert!(app.selected.is_none());
1788 }
1789
1790 #[test]
1791 fn new_status_msg_is_empty() {
1792 let dir = tempdir().expect("tempdir");
1793 let app = make_app(dir.path().to_path_buf());
1794 assert!(app.status_msg.is_empty());
1795 }
1796
1797 #[test]
1798 fn new_snackbar_is_none() {
1799 let dir = tempdir().expect("tempdir");
1800 let app = make_app(dir.path().to_path_buf());
1801 assert!(app.snackbar.is_none());
1802 }
1803
1804 #[test]
1807 fn notify_sets_info_snackbar() {
1808 let dir = tempdir().expect("tempdir");
1809 let mut app = make_app(dir.path().to_path_buf());
1810 app.notify("hello");
1811 let sb = app.snackbar.as_ref().expect("snackbar should be set");
1812 assert_eq!(sb.message, "hello");
1813 assert!(!sb.is_error, "notify should produce a non-error snackbar");
1814 }
1815
1816 #[test]
1817 fn notify_error_sets_error_snackbar() {
1818 let dir = tempdir().expect("tempdir");
1819 let mut app = make_app(dir.path().to_path_buf());
1820 app.notify_error("something went wrong");
1821 let sb = app.snackbar.as_ref().expect("snackbar should be set");
1822 assert_eq!(sb.message, "something went wrong");
1823 assert!(sb.is_error, "notify_error should produce an error snackbar");
1824 }
1825
1826 #[test]
1827 fn notify_replaces_previous_snackbar() {
1828 let dir = tempdir().expect("tempdir");
1829 let mut app = make_app(dir.path().to_path_buf());
1830 app.notify("first");
1831 app.notify("second");
1832 let sb = app.snackbar.as_ref().expect("snackbar should be set");
1833 assert_eq!(sb.message, "second");
1834 }
1835
1836 #[test]
1837 fn snackbar_info_is_not_expired_immediately() {
1838 let sb = Snackbar::info("test");
1839 assert!(!sb.is_expired(), "fresh snackbar must not be expired");
1840 }
1841
1842 #[test]
1843 fn snackbar_error_is_not_expired_immediately() {
1844 let sb = Snackbar::error("test");
1845 assert!(!sb.is_expired(), "fresh error snackbar must not be expired");
1846 }
1847
1848 #[test]
1849 fn snackbar_is_expired_when_past_deadline() {
1850 use std::time::{Duration, Instant};
1851 let sb = Snackbar {
1853 message: "stale".into(),
1854 expires_at: Instant::now() - Duration::from_secs(1),
1855 is_error: false,
1856 };
1857 assert!(
1858 sb.is_expired(),
1859 "snackbar past its deadline must be expired"
1860 );
1861 }
1862
1863 #[test]
1864 fn e_key_with_no_editor_sets_error_snackbar() {
1865 use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
1866 let dir = tempdir().expect("tempdir");
1867 let file = dir.path().join("note.txt");
1869 std::fs::write(&file, b"hi").unwrap();
1870
1871 let mut app = make_app(dir.path().to_path_buf());
1872 assert_eq!(app.editor, Editor::None);
1873
1874 let key = KeyEvent {
1875 code: KeyCode::Char('e'),
1876 modifiers: KeyModifiers::empty(),
1877 kind: KeyEventKind::Press,
1878 state: KeyEventState::empty(),
1879 };
1880 if app.editor == Editor::None {
1884 app.notify_error("No editor set — open Options (Shift + O) and press e to pick one");
1885 }
1886 let _ = key; let sb = app.snackbar.as_ref().expect("snackbar must be set");
1889 assert!(sb.is_error);
1890 assert!(
1891 sb.message.contains("No editor set"),
1892 "message should mention missing editor"
1893 );
1894 }
1895
1896 #[test]
1897 fn e_key_with_editor_does_not_set_snackbar() {
1898 let dir = tempdir().expect("tempdir");
1899 let file = dir.path().join("note.txt");
1900 std::fs::write(&file, b"hi").unwrap();
1901
1902 let mut app = App::new(AppOptions {
1903 left_dir: dir.path().to_path_buf(),
1904 right_dir: dir.path().to_path_buf(),
1905 editor: Editor::Helix,
1906 ..AppOptions::default()
1907 });
1908
1909 if app.editor != Editor::None {
1911 if let Some(entry) = app.active_pane().current_entry() {
1912 if !entry.path.is_dir() {
1913 app.open_with_editor = Some(entry.path.clone());
1914 }
1915 }
1916 } else {
1917 app.notify_error("No editor set — open Options (Shift + O) and press e to pick one");
1918 }
1919
1920 assert!(
1921 app.snackbar.is_none(),
1922 "no snackbar when an editor is configured"
1923 );
1924 assert!(
1925 app.open_with_editor.is_some(),
1926 "open_with_editor must be set"
1927 );
1928 }
1929
1930 #[test]
1933 fn theme_name_returns_str_for_idx_zero() {
1934 let dir = tempdir().expect("tempdir");
1935 let app = make_app(dir.path().to_path_buf());
1936 assert!(!app.theme_name().is_empty());
1938 }
1939
1940 #[test]
1941 fn theme_name_matches_preset_catalogue() {
1942 let dir = tempdir().expect("tempdir");
1943 let app = make_app(dir.path().to_path_buf());
1944 let expected = app.themes[app.theme_idx].0;
1945 assert_eq!(app.theme_name(), expected);
1946 }
1947
1948 #[test]
1949 fn theme_desc_returns_non_empty_string() {
1950 let dir = tempdir().expect("tempdir");
1951 let app = make_app(dir.path().to_path_buf());
1952 assert!(!app.theme_desc().is_empty());
1953 }
1954
1955 #[test]
1956 fn theme_desc_matches_preset_catalogue() {
1957 let dir = tempdir().expect("tempdir");
1958 let app = make_app(dir.path().to_path_buf());
1959 let expected = app.themes[app.theme_idx].1;
1960 assert_eq!(app.theme_desc(), expected);
1961 }
1962
1963 #[test]
1964 fn theme_returns_correct_preset_object() {
1965 let dir = tempdir().expect("tempdir");
1966 let mut app = make_app(dir.path().to_path_buf());
1967 app.theme_idx = 2;
1969 let expected = &app.themes[2].2;
1970 assert_eq!(app.theme(), expected);
1971 }
1972
1973 #[test]
1974 fn theme_name_and_desc_change_together_with_idx() {
1975 let dir = tempdir().expect("tempdir");
1976 let mut app = make_app(dir.path().to_path_buf());
1977 app.theme_idx = 1;
1978 assert_eq!(app.theme_name(), app.themes[1].0);
1979 assert_eq!(app.theme_desc(), app.themes[1].1);
1980 }
1981
1982 #[test]
1983 fn next_theme_increments_idx() {
1984 let dir = tempdir().expect("tempdir");
1985 let mut app = make_app(dir.path().to_path_buf());
1986 let initial = app.theme_idx;
1987 app.next_theme();
1988 assert_eq!(app.theme_idx, initial + 1);
1989 }
1990
1991 #[test]
1992 fn next_theme_wraps_around() {
1993 let dir = tempdir().expect("tempdir");
1994 let mut app = make_app(dir.path().to_path_buf());
1995 let total = app.themes.len();
1996 app.theme_idx = total - 1;
1997 app.next_theme();
1998 assert_eq!(app.theme_idx, 0);
1999 }
2000
2001 #[test]
2002 fn prev_theme_decrements_idx() {
2003 let dir = tempdir().expect("tempdir");
2004 let mut app = make_app(dir.path().to_path_buf());
2005 app.theme_idx = 3;
2006 app.prev_theme();
2007 assert_eq!(app.theme_idx, 2);
2008 }
2009
2010 #[test]
2011 fn prev_theme_wraps_around() {
2012 let dir = tempdir().expect("tempdir");
2013 let mut app = make_app(dir.path().to_path_buf());
2014 app.theme_idx = 0;
2015 app.prev_theme();
2016 assert_eq!(app.theme_idx, app.themes.len() - 1);
2017 }
2018
2019 #[test]
2022 fn new_single_pane_false_by_default() {
2023 let dir = tempdir().expect("tempdir");
2024 let app = make_app(dir.path().to_path_buf());
2025 assert!(!app.single_pane);
2026 }
2027
2028 #[test]
2029 fn new_show_theme_panel_false_by_default() {
2030 let dir = tempdir().expect("tempdir");
2031 let app = make_app(dir.path().to_path_buf());
2032 assert!(!app.show_theme_panel);
2033 }
2034
2035 #[test]
2036 fn new_single_pane_true_when_requested() {
2037 let dir = tempdir().expect("tempdir");
2038 let app = App::new(AppOptions {
2039 left_dir: dir.path().to_path_buf(),
2040 right_dir: dir.path().to_path_buf(),
2041 single_pane: true,
2042 ..AppOptions::default()
2043 });
2044 assert!(app.single_pane);
2045 }
2046
2047 #[test]
2048 fn new_show_theme_panel_true_when_requested() {
2049 let dir = tempdir().expect("tempdir");
2050 let app = App::new(AppOptions {
2051 left_dir: dir.path().to_path_buf(),
2052 right_dir: dir.path().to_path_buf(),
2053 show_theme_panel: true,
2054 ..AppOptions::default()
2055 });
2056 assert!(app.show_theme_panel);
2057 }
2058
2059 #[test]
2060 fn new_show_options_panel_false_by_default() {
2061 let dir = tempdir().expect("tempdir");
2062 let app = make_app(dir.path().to_path_buf());
2063 assert!(!app.show_options_panel);
2064 }
2065
2066 #[test]
2067 fn new_cd_on_exit_false_by_default() {
2068 let dir = tempdir().expect("tempdir");
2069 let app = make_app(dir.path().to_path_buf());
2070 assert!(!app.cd_on_exit);
2071 }
2072
2073 #[test]
2074 fn new_cd_on_exit_true_when_requested() {
2075 let dir = tempdir().expect("tempdir");
2076 let app = App::new(AppOptions {
2077 left_dir: dir.path().to_path_buf(),
2078 right_dir: dir.path().to_path_buf(),
2079 cd_on_exit: true,
2080 ..AppOptions::default()
2081 });
2082 assert!(app.cd_on_exit);
2083 }
2084
2085 #[test]
2088 fn capital_o_opens_options_panel() {
2089 let dir = tempdir().expect("tempdir");
2090 let mut app = make_app(dir.path().to_path_buf());
2091 assert!(!app.show_options_panel);
2092 app.show_options_panel = true;
2093 assert!(app.show_options_panel);
2094 }
2095
2096 #[test]
2097 fn capital_o_closes_options_panel_when_already_open() {
2098 let dir = tempdir().expect("tempdir");
2099 let mut app = make_app(dir.path().to_path_buf());
2100 app.show_options_panel = true;
2101 app.show_options_panel = !app.show_options_panel;
2102 assert!(!app.show_options_panel);
2103 }
2104
2105 #[test]
2106 fn opening_options_panel_closes_theme_panel() {
2107 let dir = tempdir().expect("tempdir");
2108 let mut app = make_app(dir.path().to_path_buf());
2109 app.show_theme_panel = true;
2110 app.show_options_panel = !app.show_options_panel;
2112 if app.show_options_panel {
2113 app.show_theme_panel = false;
2114 }
2115 assert!(app.show_options_panel);
2116 assert!(!app.show_theme_panel);
2117 }
2118
2119 #[test]
2120 fn opening_theme_panel_closes_options_panel() {
2121 let dir = tempdir().expect("tempdir");
2122 let mut app = make_app(dir.path().to_path_buf());
2123 app.show_options_panel = true;
2124 app.show_theme_panel = !app.show_theme_panel;
2126 if app.show_theme_panel {
2127 app.show_options_panel = false;
2128 }
2129 assert!(app.show_theme_panel);
2130 assert!(!app.show_options_panel);
2131 }
2132
2133 #[test]
2134 fn capital_c_toggles_cd_on_exit_on() {
2135 let dir = tempdir().expect("tempdir");
2136 let mut app = make_app(dir.path().to_path_buf());
2137 assert!(!app.cd_on_exit);
2138 app.cd_on_exit = !app.cd_on_exit;
2139 assert!(app.cd_on_exit);
2140 }
2141
2142 #[test]
2143 fn capital_c_toggles_cd_on_exit_off() {
2144 let dir = tempdir().expect("tempdir");
2145 let mut app = App::new(AppOptions {
2146 left_dir: dir.path().to_path_buf(),
2147 right_dir: dir.path().to_path_buf(),
2148 cd_on_exit: true,
2149 ..AppOptions::default()
2150 });
2151 app.cd_on_exit = !app.cd_on_exit;
2152 assert!(!app.cd_on_exit);
2153 }
2154
2155 #[test]
2156 fn capital_c_sets_status_message_on() {
2157 let dir = tempdir().expect("tempdir");
2158 let mut app = make_app(dir.path().to_path_buf());
2159 app.cd_on_exit = !app.cd_on_exit;
2161 let state = if app.cd_on_exit { "on" } else { "off" };
2162 app.status_msg = format!("cd-on-exit: {state}");
2163 assert_eq!(app.status_msg, "cd-on-exit: on");
2164 }
2165
2166 #[test]
2167 fn capital_c_sets_status_message_off() {
2168 let dir = tempdir().expect("tempdir");
2169 let mut app = App::new(AppOptions {
2170 left_dir: dir.path().to_path_buf(),
2171 right_dir: dir.path().to_path_buf(),
2172 cd_on_exit: true,
2173 ..AppOptions::default()
2174 });
2175 app.cd_on_exit = !app.cd_on_exit;
2176 let state = if app.cd_on_exit { "on" } else { "off" };
2177 app.status_msg = format!("cd-on-exit: {state}");
2178 assert_eq!(app.status_msg, "cd-on-exit: off");
2179 }
2180
2181 #[test]
2184 fn active_pane_returns_left_by_default() {
2185 let dir = tempdir().expect("tempdir");
2186 let app = make_app(dir.path().to_path_buf());
2187 assert_eq!(app.active_pane().current_dir, app.left.current_dir);
2189 }
2190
2191 #[test]
2192 fn active_pane_returns_right_when_switched() {
2193 let dir = tempdir().expect("tempdir");
2194 let mut app = make_app(dir.path().to_path_buf());
2195 app.active = Pane::Right;
2196 assert_eq!(app.active_pane().current_dir, app.right.current_dir);
2197 }
2198
2199 #[test]
2202 fn yank_copy_populates_clipboard_with_copy_op() {
2203 let dir = tempdir().expect("tempdir");
2204 fs::write(dir.path().join("file.txt"), b"hi").expect("write");
2205 let mut app = make_app(dir.path().to_path_buf());
2206 app.yank(ClipOp::Copy);
2207 let clip = app.clipboard.expect("clipboard should be set");
2208 assert_eq!(clip.op, ClipOp::Copy);
2209 assert_eq!(clip.paths.len(), 1);
2210 }
2211
2212 #[test]
2213 fn yank_cut_populates_clipboard_with_cut_op() {
2214 let dir = tempdir().expect("tempdir");
2215 fs::write(dir.path().join("file.txt"), b"hi").expect("write");
2216 let mut app = make_app(dir.path().to_path_buf());
2217 app.yank(ClipOp::Cut);
2218 let clip = app.clipboard.expect("clipboard should be set");
2219 assert_eq!(clip.op, ClipOp::Cut);
2220 assert_eq!(clip.paths.len(), 1);
2221 }
2222
2223 #[test]
2224 fn yank_sets_status_message() {
2225 let dir = tempdir().expect("tempdir");
2226 fs::write(dir.path().join("file.txt"), b"hi").expect("write");
2227 let mut app = make_app(dir.path().to_path_buf());
2228 app.yank(ClipOp::Copy);
2229 assert!(!app.status_msg.is_empty());
2230 }
2231
2232 #[test]
2233 fn yank_copy_status_mentions_copied_and_filename() {
2234 let dir = tempdir().expect("tempdir");
2235 fs::write(dir.path().join("report.txt"), b"data").expect("write");
2236 let mut app = make_app(dir.path().to_path_buf());
2237 app.yank(ClipOp::Copy);
2238 assert!(
2239 app.status_msg.contains("Copied"),
2240 "status should mention 'Copied', got: {}",
2241 app.status_msg
2242 );
2243 assert!(
2244 app.status_msg.contains("report.txt"),
2245 "status should mention the filename, got: {}",
2246 app.status_msg
2247 );
2248 }
2249
2250 #[test]
2251 fn yank_cut_status_mentions_cut_and_filename() {
2252 let dir = tempdir().expect("tempdir");
2253 fs::write(dir.path().join("move_me.txt"), b"data").expect("write");
2254 let mut app = make_app(dir.path().to_path_buf());
2255 app.yank(ClipOp::Cut);
2256 assert!(
2257 app.status_msg.contains("Cut"),
2258 "status should mention 'Cut', got: {}",
2259 app.status_msg
2260 );
2261 assert!(
2262 app.status_msg.contains("move_me.txt"),
2263 "status should mention the filename, got: {}",
2264 app.status_msg
2265 );
2266 }
2267
2268 #[test]
2269 fn yank_with_marks_yanks_all_marked_files() {
2270 let dir = tempdir().expect("tempdir");
2271 fs::write(dir.path().join("a.txt"), b"a").expect("write");
2272 fs::write(dir.path().join("b.txt"), b"b").expect("write");
2273 fs::write(dir.path().join("c.txt"), b"c").expect("write");
2274 let mut app = make_app(dir.path().to_path_buf());
2275 app.left.toggle_mark();
2277 app.left.toggle_mark(); app.yank(ClipOp::Copy);
2279 let clip = app.clipboard.expect("clipboard should be set");
2280 assert_eq!(clip.paths.len(), 2, "should have 2 paths in clipboard");
2281 assert_eq!(clip.op, ClipOp::Copy);
2282 }
2283
2284 #[test]
2285 fn yank_with_marks_clears_marks_after_yank() {
2286 let dir = tempdir().expect("tempdir");
2287 fs::write(dir.path().join("a.txt"), b"a").expect("write");
2288 fs::write(dir.path().join("b.txt"), b"b").expect("write");
2289 let mut app = make_app(dir.path().to_path_buf());
2290 app.left.toggle_mark();
2291 app.yank(ClipOp::Copy);
2292 assert!(
2293 app.left.marked.is_empty(),
2294 "marks should be cleared after yank"
2295 );
2296 }
2297
2298 #[test]
2299 fn yank_with_marks_status_mentions_count() {
2300 let dir = tempdir().expect("tempdir");
2301 fs::write(dir.path().join("a.txt"), b"a").expect("write");
2302 fs::write(dir.path().join("b.txt"), b"b").expect("write");
2303 let mut app = make_app(dir.path().to_path_buf());
2304 app.left.toggle_mark();
2305 app.left.toggle_mark();
2306 app.yank(ClipOp::Copy);
2307 assert!(
2308 app.status_msg.contains("2 items"),
2309 "status should mention item count, got: {}",
2310 app.status_msg
2311 );
2312 }
2313
2314 #[test]
2315 fn yank_uses_inactive_pane_marks_when_active_pane_has_none() {
2316 let src_dir = tempdir().expect("src tempdir");
2318 let dst_dir = tempdir().expect("dst tempdir");
2319 fs::write(src_dir.path().join("a.txt"), b"a").expect("write");
2320 fs::write(src_dir.path().join("b.txt"), b"b").expect("write");
2321
2322 let mut app = App::new(AppOptions {
2323 left_dir: src_dir.path().to_path_buf(),
2324 right_dir: dst_dir.path().to_path_buf(),
2325 ..AppOptions::default()
2326 });
2327
2328 app.left.toggle_mark(); app.left.toggle_mark(); app.active = Pane::Right;
2334
2335 app.yank(ClipOp::Copy);
2337
2338 let clip = app.clipboard.expect("clipboard should be set");
2339 assert_eq!(
2340 clip.paths.len(),
2341 2,
2342 "both marked files should be in clipboard"
2343 );
2344 assert_eq!(clip.op, ClipOp::Copy);
2345 }
2346
2347 #[test]
2348 fn yank_inactive_pane_marks_clears_inactive_pane_marks() {
2349 let src_dir = tempdir().expect("src tempdir");
2350 let dst_dir = tempdir().expect("dst tempdir");
2351 fs::write(src_dir.path().join("a.txt"), b"a").expect("write");
2352
2353 let mut app = App::new(AppOptions {
2354 left_dir: src_dir.path().to_path_buf(),
2355 right_dir: dst_dir.path().to_path_buf(),
2356 ..AppOptions::default()
2357 });
2358
2359 app.left.toggle_mark();
2361 app.active = Pane::Right;
2362 app.yank(ClipOp::Copy);
2363
2364 assert!(
2365 app.left.marked.is_empty(),
2366 "marks on the inactive (source) pane should be cleared after yank"
2367 );
2368 assert!(
2369 app.right.marked.is_empty(),
2370 "right pane should have no marks"
2371 );
2372 }
2373
2374 #[test]
2375 fn yank_inactive_pane_marks_does_not_clear_active_pane_marks() {
2376 let src_dir = tempdir().expect("src tempdir");
2379 let dst_dir = tempdir().expect("dst tempdir");
2380 fs::write(src_dir.path().join("x.txt"), b"x").expect("write");
2381 fs::write(dst_dir.path().join("y.txt"), b"y").expect("write");
2382
2383 let mut app = App::new(AppOptions {
2384 left_dir: src_dir.path().to_path_buf(),
2385 right_dir: dst_dir.path().to_path_buf(),
2386 ..AppOptions::default()
2387 });
2388
2389 app.left.toggle_mark(); app.active = Pane::Right;
2392
2393 app.yank(ClipOp::Copy);
2394
2395 assert!(app.left.marked.is_empty(), "left marks should be cleared");
2397 assert!(
2399 app.right.marked.is_empty(),
2400 "right marks should remain empty"
2401 );
2402 }
2403
2404 #[test]
2405 fn yank_active_pane_marks_take_priority_over_inactive_pane_marks() {
2406 let src_dir = tempdir().expect("src tempdir");
2408 let dst_dir = tempdir().expect("dst tempdir");
2409 fs::write(src_dir.path().join("left.txt"), b"l").expect("write");
2410 fs::write(dst_dir.path().join("right.txt"), b"r").expect("write");
2411
2412 let mut app = App::new(AppOptions {
2413 left_dir: src_dir.path().to_path_buf(),
2414 right_dir: dst_dir.path().to_path_buf(),
2415 ..AppOptions::default()
2416 });
2417
2418 app.left.toggle_mark(); app.right.toggle_mark(); app.yank(ClipOp::Copy);
2426
2427 let clip = app.clipboard.expect("clipboard should be set");
2428 assert_eq!(
2429 clip.paths.len(),
2430 1,
2431 "only active pane's mark should be used"
2432 );
2433 assert!(
2434 clip.paths[0].ends_with("left.txt"),
2435 "should have yanked the active (left) pane's marked file"
2436 );
2437 }
2438
2439 #[test]
2440 fn yank_inactive_marks_from_right_pane_when_active_is_left_with_no_marks() {
2441 let src_dir = tempdir().expect("src tempdir");
2443 let dst_dir = tempdir().expect("dst tempdir");
2444 fs::write(dst_dir.path().join("c.txt"), b"c").expect("write");
2445 fs::write(dst_dir.path().join("d.txt"), b"d").expect("write");
2446
2447 let mut app = App::new(AppOptions {
2448 left_dir: src_dir.path().to_path_buf(),
2449 right_dir: dst_dir.path().to_path_buf(),
2450 ..AppOptions::default()
2451 });
2452
2453 app.right.toggle_mark(); app.right.toggle_mark(); assert_eq!(app.active, Pane::Left);
2459
2460 app.yank(ClipOp::Copy);
2461
2462 let clip = app.clipboard.expect("clipboard should be set");
2463 assert_eq!(clip.paths.len(), 2, "right pane marks should be used");
2464 assert!(
2465 app.right.marked.is_empty(),
2466 "right marks cleared after yank"
2467 );
2468 }
2469
2470 #[test]
2471 fn yank_falls_back_to_cursor_when_no_marks_in_either_pane() {
2472 let dir = tempdir().expect("tempdir");
2473 fs::write(dir.path().join("only.txt"), b"x").expect("write");
2474
2475 let mut app = make_app(dir.path().to_path_buf());
2476 assert!(app.left.marked.is_empty());
2478 assert!(app.right.marked.is_empty());
2479
2480 app.yank(ClipOp::Copy);
2481
2482 let clip = app.clipboard.expect("clipboard should be set");
2483 assert_eq!(clip.paths.len(), 1, "should fall back to cursor entry");
2484 assert!(clip.paths[0].ends_with("only.txt"));
2485 }
2486
2487 #[test]
2488 fn paste_success_sets_snackbar_notification() {
2489 let src_dir = tempdir().expect("src tempdir");
2490 let dst_dir = tempdir().expect("dst tempdir");
2491 fs::write(src_dir.path().join("hello.txt"), b"world").expect("write");
2492
2493 let mut app = App::new(AppOptions {
2494 left_dir: src_dir.path().to_path_buf(),
2495 right_dir: dst_dir.path().to_path_buf(),
2496 ..AppOptions::default()
2497 });
2498 app.yank(ClipOp::Copy);
2499 app.active = Pane::Right;
2500 app.paste();
2501
2502 assert!(
2503 app.snackbar.is_some(),
2504 "paste success should set a snackbar notification"
2505 );
2506 let sb = app.snackbar.as_ref().unwrap();
2507 assert!(
2508 !sb.is_error,
2509 "success paste snackbar should not be an error"
2510 );
2511 assert!(
2512 sb.message.contains("Pasted") || sb.message.contains("Moved"),
2513 "snackbar message should mention paste result, got: {}",
2514 sb.message
2515 );
2516 }
2517
2518 #[test]
2519 fn paste_error_sets_error_snackbar_notification() {
2520 let dir = tempdir().expect("tempdir");
2521 let mut app = make_app(dir.path().to_path_buf());
2522 app.clipboard = Some(ClipboardItem {
2524 paths: vec![dir.path().join("does_not_exist.txt")],
2525 op: ClipOp::Copy,
2526 });
2527 app.paste();
2528
2529 assert!(
2530 app.snackbar.is_some(),
2531 "paste failure should set a snackbar notification"
2532 );
2533 let sb = app.snackbar.as_ref().unwrap();
2534 assert!(
2535 sb.is_error,
2536 "error paste snackbar should be flagged as error"
2537 );
2538 }
2539
2540 #[test]
2541 fn paste_error_status_starts_with_error_prefix() {
2542 let dir = tempdir().expect("tempdir");
2543 let mut app = make_app(dir.path().to_path_buf());
2544 app.clipboard = Some(ClipboardItem {
2545 paths: vec![dir.path().join("ghost.txt")],
2546 op: ClipOp::Copy,
2547 });
2548 app.paste();
2549
2550 assert!(
2551 app.status_msg.starts_with("Error"),
2552 "error status should start with 'Error' so it persists on navigation, got: {}",
2553 app.status_msg
2554 );
2555 }
2556
2557 #[test]
2558 fn paste_multi_success_sets_snackbar() {
2559 let src_dir = tempdir().expect("src tempdir");
2560 let dst_dir = tempdir().expect("dst tempdir");
2561 fs::write(src_dir.path().join("a.txt"), b"a").expect("write");
2562 fs::write(src_dir.path().join("b.txt"), b"b").expect("write");
2563
2564 let mut app = App::new(AppOptions {
2565 left_dir: src_dir.path().to_path_buf(),
2566 right_dir: dst_dir.path().to_path_buf(),
2567 ..AppOptions::default()
2568 });
2569 app.left.toggle_mark();
2570 app.left.toggle_mark();
2571 app.yank(ClipOp::Copy);
2572 app.active = Pane::Right;
2573 app.paste();
2574
2575 assert!(
2576 app.snackbar.is_some(),
2577 "multi-file paste should set a snackbar"
2578 );
2579 let sb = app.snackbar.as_ref().unwrap();
2580 assert!(
2581 !sb.is_error,
2582 "successful paste snackbar should not be error"
2583 );
2584 assert!(
2585 sb.message.contains("2"),
2586 "snackbar should mention item count, got: {}",
2587 sb.message
2588 );
2589 }
2590
2591 #[test]
2592 fn copy_dir_skips_symlinks_without_failing() {
2593 use std::os::unix::fs::symlink;
2594
2595 let src_dir = tempdir().expect("src tempdir");
2596 let dst_dir = tempdir().expect("dst tempdir");
2597
2598 fs::write(src_dir.path().join("real.txt"), b"content").expect("write real");
2600 symlink("/nonexistent/path", src_dir.path().join("broken_link")).expect("create symlink");
2601
2602 let result = crate::fs::copy_dir_all(src_dir.path(), dst_dir.path());
2604 assert!(
2605 result.is_ok(),
2606 "copy_dir_all should not fail on symlinks, got: {:?}",
2607 result
2608 );
2609
2610 assert!(
2612 dst_dir.path().join("real.txt").exists(),
2613 "real.txt should be copied"
2614 );
2615 assert!(
2617 !dst_dir.path().join("broken_link").exists(),
2618 "broken symlink should be skipped, not copied"
2619 );
2620 }
2621
2622 #[test]
2623 fn copy_dir_skips_valid_symlink_to_file() {
2624 use std::os::unix::fs::symlink;
2625
2626 let src_dir = tempdir().expect("src tempdir");
2627 let dst_dir = tempdir().expect("dst tempdir");
2628 let target = src_dir.path().join("target.txt");
2629
2630 fs::write(&target, b"target content").expect("write target");
2631 fs::write(src_dir.path().join("normal.txt"), b"normal").expect("write normal");
2632 symlink(&target, src_dir.path().join("link_to_target")).expect("create symlink");
2633
2634 let result = crate::fs::copy_dir_all(src_dir.path(), dst_dir.path());
2635 assert!(result.is_ok(), "should succeed skipping symlinks");
2636
2637 assert!(dst_dir.path().join("normal.txt").exists());
2639 assert!(!dst_dir.path().join("link_to_target").exists());
2641 }
2642
2643 #[test]
2644 fn yank_on_empty_dir_does_not_set_clipboard() {
2645 let dir = tempdir().expect("tempdir");
2646 let mut app = make_app(dir.path().to_path_buf());
2647 app.yank(ClipOp::Copy);
2648 assert!(app.clipboard.is_none());
2649 }
2650
2651 #[test]
2660 fn key_release_after_yank_does_not_clobber_clipboard() {
2661 use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
2662
2663 let dir = tempdir().expect("tempdir");
2664 fs::write(dir.path().join("a.txt"), b"a").expect("write");
2665 fs::write(dir.path().join("b.txt"), b"b").expect("write");
2666 fs::write(dir.path().join("c.txt"), b"c").expect("write");
2667 let mut app = make_app(dir.path().to_path_buf());
2668
2669 app.left.toggle_mark();
2671 app.left.toggle_mark();
2672 app.left.toggle_mark();
2673 assert_eq!(app.left.marked.len(), 3);
2674
2675 let press = KeyEvent {
2677 code: KeyCode::Char('y'),
2678 modifiers: KeyModifiers::empty(),
2679 kind: KeyEventKind::Press,
2680 state: KeyEventState::empty(),
2681 };
2682 app.handle_key(press).unwrap();
2683
2684 let clip = app
2685 .clipboard
2686 .as_ref()
2687 .expect("clipboard should be set after press");
2688 assert_eq!(clip.paths.len(), 3, "press should yank all 3 marked items");
2689
2690 let release = KeyEvent {
2692 code: KeyCode::Char('y'),
2693 modifiers: KeyModifiers::empty(),
2694 kind: KeyEventKind::Release,
2695 state: KeyEventState::empty(),
2696 };
2697 app.handle_key(release).unwrap();
2698
2699 let clip = app
2700 .clipboard
2701 .as_ref()
2702 .expect("clipboard should still be set after release");
2703 assert_eq!(
2704 clip.paths.len(),
2705 3,
2706 "release event must not clobber the multi-item clipboard"
2707 );
2708 }
2709
2710 #[test]
2711 fn key_repeat_after_yank_does_not_clobber_clipboard() {
2712 use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
2713
2714 let dir = tempdir().expect("tempdir");
2715 fs::write(dir.path().join("a.txt"), b"a").expect("write");
2716 fs::write(dir.path().join("b.txt"), b"b").expect("write");
2717 let mut app = make_app(dir.path().to_path_buf());
2718
2719 app.left.toggle_mark();
2720 app.left.toggle_mark();
2721
2722 let press = KeyEvent {
2724 code: KeyCode::Char('y'),
2725 modifiers: KeyModifiers::empty(),
2726 kind: KeyEventKind::Press,
2727 state: KeyEventState::empty(),
2728 };
2729 app.handle_key(press).unwrap();
2730 assert_eq!(app.clipboard.as_ref().unwrap().paths.len(), 2);
2731
2732 let repeat = KeyEvent {
2734 code: KeyCode::Char('y'),
2735 modifiers: KeyModifiers::empty(),
2736 kind: KeyEventKind::Repeat,
2737 state: KeyEventState::empty(),
2738 };
2739 app.handle_key(repeat).unwrap();
2740 assert_eq!(
2741 app.clipboard.as_ref().unwrap().paths.len(),
2742 2,
2743 "repeat event must not clobber the multi-item clipboard"
2744 );
2745 }
2746
2747 #[test]
2748 fn space_release_does_not_double_toggle_mark() {
2749 use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
2750
2751 let dir = tempdir().expect("tempdir");
2752 fs::write(dir.path().join("a.txt"), b"a").expect("write");
2753 fs::write(dir.path().join("b.txt"), b"b").expect("write");
2754 let mut app = make_app(dir.path().to_path_buf());
2755
2756 let press = KeyEvent {
2758 code: KeyCode::Char(' '),
2759 modifiers: KeyModifiers::empty(),
2760 kind: KeyEventKind::Press,
2761 state: KeyEventState::empty(),
2762 };
2763 app.handle_key(press).unwrap();
2764 assert_eq!(app.left.marked.len(), 1, "press should mark one entry");
2765
2766 let release = KeyEvent {
2768 code: KeyCode::Char(' '),
2769 modifiers: KeyModifiers::empty(),
2770 kind: KeyEventKind::Release,
2771 state: KeyEventState::empty(),
2772 };
2773 app.handle_key(release).unwrap();
2774 assert_eq!(
2775 app.left.marked.len(),
2776 1,
2777 "release event must not toggle an additional mark"
2778 );
2779 }
2780
2781 #[test]
2784 fn paste_with_empty_clipboard_sets_status() {
2785 let dir = tempdir().expect("tempdir");
2786 let mut app = make_app(dir.path().to_path_buf());
2787 app.paste();
2788 assert_eq!(app.status_msg, "Nothing in clipboard.");
2789 }
2790
2791 #[test]
2792 fn paste_copy_creates_file_in_destination() {
2793 let src_dir = tempdir().expect("src tempdir");
2794 let dst_dir = tempdir().expect("dst tempdir");
2795 fs::write(src_dir.path().join("hello.txt"), b"world").expect("write");
2796
2797 let mut app = App::new(AppOptions {
2798 left_dir: src_dir.path().to_path_buf(),
2799 right_dir: src_dir.path().to_path_buf(),
2800 ..AppOptions::default()
2801 });
2802 app.yank(ClipOp::Copy);
2803
2804 app.active = Pane::Right;
2806 app.right.navigate_to(dst_dir.path().to_path_buf());
2807
2808 app.paste();
2809
2810 assert!(dst_dir.path().join("hello.txt").exists());
2811 assert!(src_dir.path().join("hello.txt").exists());
2813 }
2814
2815 #[test]
2816 fn paste_multi_copy_creates_all_files_in_destination() {
2817 let src_dir = tempdir().expect("src tempdir");
2818 let dst_dir = tempdir().expect("dst tempdir");
2819 fs::write(src_dir.path().join("a.txt"), b"a").expect("write");
2820 fs::write(src_dir.path().join("b.txt"), b"b").expect("write");
2821
2822 let mut app = App::new(AppOptions {
2823 left_dir: src_dir.path().to_path_buf(),
2824 right_dir: dst_dir.path().to_path_buf(),
2825 ..AppOptions::default()
2826 });
2827
2828 app.left.toggle_mark();
2830 app.left.toggle_mark();
2831 app.yank(ClipOp::Copy);
2832
2833 app.active = Pane::Right;
2834 app.paste();
2835
2836 assert!(
2837 dst_dir.path().join("a.txt").exists(),
2838 "a.txt should be copied"
2839 );
2840 assert!(
2841 dst_dir.path().join("b.txt").exists(),
2842 "b.txt should be copied"
2843 );
2844 assert!(src_dir.path().join("a.txt").exists());
2846 assert!(src_dir.path().join("b.txt").exists());
2847 }
2848
2849 #[test]
2850 fn paste_multi_cut_moves_all_files_and_clears_clipboard() {
2851 let src_dir = tempdir().expect("src tempdir");
2852 let dst_dir = tempdir().expect("dst tempdir");
2853 fs::write(src_dir.path().join("a.txt"), b"a").expect("write");
2854 fs::write(src_dir.path().join("b.txt"), b"b").expect("write");
2855
2856 let mut app = App::new(AppOptions {
2857 left_dir: src_dir.path().to_path_buf(),
2858 right_dir: dst_dir.path().to_path_buf(),
2859 ..AppOptions::default()
2860 });
2861
2862 app.left.toggle_mark();
2863 app.left.toggle_mark();
2864 app.yank(ClipOp::Cut);
2865
2866 app.active = Pane::Right;
2867 app.paste();
2868
2869 assert!(
2870 dst_dir.path().join("a.txt").exists(),
2871 "a.txt should be moved"
2872 );
2873 assert!(
2874 dst_dir.path().join("b.txt").exists(),
2875 "b.txt should be moved"
2876 );
2877 assert!(
2878 !src_dir.path().join("a.txt").exists(),
2879 "a.txt should be gone from src"
2880 );
2881 assert!(
2882 !src_dir.path().join("b.txt").exists(),
2883 "b.txt should be gone from src"
2884 );
2885 assert!(app.clipboard.is_none(), "clipboard cleared after cut-paste");
2886 }
2887
2888 #[test]
2889 fn paste_cut_moves_file_and_clears_clipboard() {
2890 let src_dir = tempdir().expect("src tempdir");
2891 let dst_dir = tempdir().expect("dst tempdir");
2892 fs::write(src_dir.path().join("move_me.txt"), b"data").expect("write");
2893
2894 let mut app = App::new(AppOptions {
2895 left_dir: src_dir.path().to_path_buf(),
2896 right_dir: src_dir.path().to_path_buf(),
2897 ..AppOptions::default()
2898 });
2899 app.yank(ClipOp::Cut);
2900
2901 app.active = Pane::Right;
2902 app.right.navigate_to(dst_dir.path().to_path_buf());
2903
2904 app.paste();
2905
2906 assert!(dst_dir.path().join("move_me.txt").exists());
2907 assert!(!src_dir.path().join("move_me.txt").exists());
2908 assert!(
2909 app.clipboard.is_none(),
2910 "clipboard should be cleared after cut-paste"
2911 );
2912 }
2913
2914 #[test]
2915 fn paste_same_dir_cut_is_skipped() {
2916 let dir = tempdir().expect("tempdir");
2917 fs::write(dir.path().join("same.txt"), b"x").expect("write");
2918
2919 let mut app = make_app(dir.path().to_path_buf());
2920 app.yank(ClipOp::Cut);
2921 app.paste();
2923
2924 assert_eq!(
2925 app.status_msg,
2926 "Source and destination are the same — skipped."
2927 );
2928 }
2929
2930 #[test]
2931 fn paste_existing_dst_raises_overwrite_modal() {
2932 let src_dir = tempdir().expect("src tempdir");
2933 let dst_dir = tempdir().expect("dst tempdir");
2934 fs::write(src_dir.path().join("clash.txt"), b"src").expect("write src");
2935 fs::write(dst_dir.path().join("clash.txt"), b"dst").expect("write dst");
2936
2937 let mut app = App::new(AppOptions {
2938 left_dir: src_dir.path().to_path_buf(),
2939 right_dir: src_dir.path().to_path_buf(),
2940 ..AppOptions::default()
2941 });
2942 app.yank(ClipOp::Copy);
2943 app.active = Pane::Right;
2944 app.right.navigate_to(dst_dir.path().to_path_buf());
2945 app.paste();
2946
2947 assert!(
2948 matches!(app.modal, Some(Modal::Overwrite { .. })),
2949 "expected Overwrite modal"
2950 );
2951 }
2952
2953 #[test]
2956 fn do_paste_copy_file_succeeds() {
2957 let dir = tempdir().expect("tempdir");
2958 let src = dir.path().join("orig.txt");
2959 let dst = dir.path().join("copy.txt");
2960 fs::write(&src, b"content").expect("write");
2961
2962 let mut app = make_app(dir.path().to_path_buf());
2963 app.do_paste(&src, &dst, false);
2964
2965 assert!(dst.exists());
2966 assert!(src.exists());
2967 assert!(app.status_msg.contains("Pasted"));
2968 }
2969
2970 #[test]
2971 fn do_paste_cut_file_removes_source() {
2972 let dir = tempdir().expect("tempdir");
2973 let src = dir.path().join("src.txt");
2974 let dst = dir.path().join("dst.txt");
2975 fs::write(&src, b"content").expect("write");
2976
2977 let mut app = make_app(dir.path().to_path_buf());
2978 app.clipboard = Some(ClipboardItem {
2980 paths: vec![src.clone()],
2981 op: ClipOp::Cut,
2982 });
2983 app.do_paste(&src, &dst, true);
2984
2985 assert!(dst.exists());
2986 assert!(!src.exists());
2987 assert!(app.clipboard.is_none());
2988 assert!(app.status_msg.contains("Moved"));
2989 }
2990
2991 #[test]
2992 fn do_paste_copy_dir_recursively() {
2993 let dir = tempdir().expect("tempdir");
2994 let src = dir.path().join("src_dir");
2995 fs::create_dir(&src).expect("mkdir src");
2996 fs::write(src.join("nested.txt"), b"hello").expect("write nested");
2997
2998 let dst = dir.path().join("dst_dir");
2999 let mut app = make_app(dir.path().to_path_buf());
3000 app.do_paste(&src, &dst, false);
3001
3002 assert!(dst.join("nested.txt").exists());
3003 assert!(src.exists(), "source dir should survive a copy");
3004 }
3005
3006 #[test]
3007 fn do_paste_error_sets_error_status() {
3008 let dir = tempdir().expect("tempdir");
3009 let src = dir.path().join("ghost.txt");
3011 let dst = dir.path().join("out.txt");
3012
3013 let mut app = make_app(dir.path().to_path_buf());
3014 app.do_paste(&src, &dst, false);
3015
3016 assert!(app.status_msg.starts_with("Error"));
3017 }
3018
3019 #[test]
3022 fn prompt_delete_raises_modal_when_entry_exists() {
3023 let dir = tempdir().expect("tempdir");
3024 fs::write(dir.path().join("del.txt"), b"bye").expect("write");
3025
3026 let mut app = make_app(dir.path().to_path_buf());
3027 app.prompt_delete();
3028
3029 assert!(
3030 matches!(app.modal, Some(Modal::Delete { .. })),
3031 "expected Delete modal"
3032 );
3033 }
3034
3035 #[test]
3036 fn prompt_delete_on_empty_dir_does_not_set_modal() {
3037 let dir = tempdir().expect("tempdir");
3038 let mut app = make_app(dir.path().to_path_buf());
3039 app.prompt_delete();
3040 assert!(app.modal.is_none());
3041 }
3042
3043 #[test]
3044 fn confirm_delete_removes_file_and_updates_status() {
3045 let dir = tempdir().expect("tempdir");
3046 let path = dir.path().join("gone.txt");
3047 fs::write(&path, b"delete me").expect("write");
3048
3049 let mut app = make_app(dir.path().to_path_buf());
3050 app.confirm_delete(&path);
3051
3052 assert!(!path.exists());
3053 assert!(app.status_msg.contains("Deleted"));
3054 }
3055
3056 #[test]
3057 fn confirm_delete_removes_directory_recursively() {
3058 let dir = tempdir().expect("tempdir");
3059 let sub = dir.path().join("subdir");
3060 fs::create_dir(&sub).expect("mkdir");
3061 fs::write(sub.join("inner.txt"), b"x").expect("write");
3062
3063 let mut app = make_app(dir.path().to_path_buf());
3064 app.confirm_delete(&sub);
3065
3066 assert!(!sub.exists());
3067 }
3068
3069 #[test]
3070 fn confirm_delete_nonexistent_path_sets_error_status() {
3071 let dir = tempdir().expect("tempdir");
3072 let path = dir.path().join("not_here.txt");
3073
3074 let mut app = make_app(dir.path().to_path_buf());
3075 app.confirm_delete(&path);
3076
3077 assert!(app.status_msg.starts_with("Delete failed"));
3078 }
3079
3080 #[test]
3083 fn status_msg_is_cleared_by_do_paste_on_success() {
3084 let src_dir = tempdir().expect("src tempdir");
3085 let dst_dir = tempdir().expect("dst tempdir");
3086 fs::write(src_dir.path().join("a.txt"), b"x").expect("write");
3087
3088 let mut app = App::new(AppOptions {
3089 left_dir: src_dir.path().to_path_buf(),
3090 right_dir: src_dir.path().to_path_buf(),
3091 ..AppOptions::default()
3092 });
3093 app.status_msg = "old message".into();
3095
3096 let src = src_dir.path().join("a.txt");
3097 let dst = dst_dir.path().join("a.txt");
3098 app.do_paste(&src, &dst, false);
3099
3100 assert_ne!(app.status_msg, "old message");
3101 assert!(app.status_msg.contains("Pasted"));
3102 }
3103
3104 #[test]
3105 fn status_msg_starts_with_error_on_failed_paste() {
3106 let dir = tempdir().expect("tempdir");
3107 let src = dir.path().join("ghost.txt"); let dst = dir.path().join("out.txt");
3109
3110 let mut app = make_app(dir.path().to_path_buf());
3111 app.do_paste(&src, &dst, false);
3112
3113 assert!(
3114 app.status_msg.starts_with("Error"),
3115 "expected error prefix, got: {}",
3116 app.status_msg
3117 );
3118 }
3119
3120 #[test]
3123 fn paste_clipboard_path_with_no_filename_sets_status() {
3124 let dir = tempdir().expect("tempdir");
3125 let mut app = make_app(dir.path().to_path_buf());
3126 app.clipboard = Some(ClipboardItem {
3128 paths: vec![PathBuf::from("/")],
3129 op: ClipOp::Copy,
3130 });
3131 app.paste();
3132 assert_eq!(
3133 app.status_msg,
3134 "Cannot paste: clipboard path has no filename."
3135 );
3136 }
3137
3138 #[test]
3141 fn confirm_delete_reloads_both_panes() {
3142 let dir = tempdir().expect("tempdir");
3143 let file = dir.path().join("vanish.txt");
3144 fs::write(&file, b"bye").expect("write");
3145
3146 let mut app = make_app(dir.path().to_path_buf());
3147 app.confirm_delete(&file);
3150
3151 let in_left = app.left.entries.iter().any(|e| e.name == "vanish.txt");
3152 let in_right = app.right.entries.iter().any(|e| e.name == "vanish.txt");
3153 assert!(!in_left, "file still appears in left pane after delete");
3154 assert!(!in_right, "file still appears in right pane after delete");
3155 }
3156
3157 #[test]
3158 fn do_paste_reloads_both_panes() {
3159 let src_dir = tempdir().expect("src tempdir");
3160 let dst_dir = tempdir().expect("dst tempdir");
3161 fs::write(src_dir.path().join("appear.txt"), b"hi").expect("write");
3162
3163 let mut app = App::new(AppOptions {
3164 left_dir: dst_dir.path().to_path_buf(),
3165 right_dir: dst_dir.path().to_path_buf(),
3166 ..AppOptions::default()
3167 });
3168 let src = src_dir.path().join("appear.txt");
3169 let dst = dst_dir.path().join("appear.txt");
3170 app.do_paste(&src, &dst, false);
3171
3172 let in_left = app.left.entries.iter().any(|e| e.name == "appear.txt");
3173 let in_right = app.right.entries.iter().any(|e| e.name == "appear.txt");
3174 assert!(in_left, "pasted file should appear in left pane");
3175 assert!(in_right, "pasted file should appear in right pane");
3176 }
3177
3178 #[test]
3181 fn space_mark_adds_entry_to_marked_set() {
3182 let dir = tempdir().expect("tempdir");
3183 fs::write(dir.path().join("a.txt"), b"a").unwrap();
3184 fs::write(dir.path().join("b.txt"), b"b").unwrap();
3185 let mut app = make_app(dir.path().to_path_buf());
3186
3187 app.left.toggle_mark();
3189 assert_eq!(app.left.marked.len(), 1);
3190 }
3191
3192 #[test]
3193 fn space_mark_toggles_off_when_already_marked() {
3194 let dir = tempdir().expect("tempdir");
3195 fs::write(dir.path().join("a.txt"), b"a").unwrap();
3196 let mut app = make_app(dir.path().to_path_buf());
3197
3198 app.left.toggle_mark(); app.left.cursor = 0; app.left.toggle_mark(); assert!(app.left.marked.is_empty(), "second toggle should unmark");
3202 }
3203
3204 #[test]
3205 fn space_mark_advances_cursor_down() {
3206 let dir = tempdir().expect("tempdir");
3207 fs::write(dir.path().join("a.txt"), b"a").unwrap();
3208 fs::write(dir.path().join("b.txt"), b"b").unwrap();
3209 let mut app = make_app(dir.path().to_path_buf());
3210
3211 let before = app.left.cursor;
3212 app.left.toggle_mark();
3213 assert!(
3214 app.left.cursor > before || app.left.entries.len() == 1,
3215 "cursor should advance after marking"
3216 );
3217 }
3218
3219 #[test]
3220 fn prompt_delete_with_marks_raises_multi_delete_modal() {
3221 let dir = tempdir().expect("tempdir");
3222 fs::write(dir.path().join("a.txt"), b"a").unwrap();
3223 fs::write(dir.path().join("b.txt"), b"b").unwrap();
3224 let mut app = make_app(dir.path().to_path_buf());
3225
3226 app.left.toggle_mark();
3228 app.left.toggle_mark();
3229 assert_eq!(app.left.marked.len(), 2, "both files should be marked");
3230
3231 app.prompt_delete();
3232
3233 match &app.modal {
3234 Some(Modal::MultiDelete { paths }) => {
3235 assert_eq!(paths.len(), 2, "modal should list 2 paths");
3236 }
3237 other => panic!("expected MultiDelete, got {other:?}"),
3238 }
3239 }
3240
3241 #[test]
3242 fn prompt_delete_without_marks_raises_single_delete_modal() {
3243 let dir = tempdir().expect("tempdir");
3244 fs::write(dir.path().join("a.txt"), b"a").unwrap();
3245 let mut app = make_app(dir.path().to_path_buf());
3246
3247 app.prompt_delete();
3249
3250 assert!(
3251 matches!(app.modal, Some(Modal::Delete { .. })),
3252 "expected Delete when nothing is marked"
3253 );
3254 }
3255
3256 #[test]
3257 fn confirm_delete_many_removes_all_files() {
3258 let dir = tempdir().expect("tempdir");
3259 let a = dir.path().join("a.txt");
3260 let b = dir.path().join("b.txt");
3261 fs::write(&a, b"a").unwrap();
3262 fs::write(&b, b"b").unwrap();
3263
3264 let mut app = make_app(dir.path().to_path_buf());
3265 app.confirm_delete_many(&[a.clone(), b.clone()]);
3266
3267 assert!(!a.exists(), "a.txt should be deleted");
3268 assert!(!b.exists(), "b.txt should be deleted");
3269 }
3270
3271 #[test]
3272 fn confirm_delete_many_sets_success_status() {
3273 let dir = tempdir().expect("tempdir");
3274 fs::write(dir.path().join("x.txt"), b"x").unwrap();
3275 fs::write(dir.path().join("y.txt"), b"y").unwrap();
3276 let x = dir.path().join("x.txt");
3277 let y = dir.path().join("y.txt");
3278
3279 let mut app = make_app(dir.path().to_path_buf());
3280 app.confirm_delete_many(&[x, y]);
3281
3282 assert!(
3283 app.status_msg.contains('2'),
3284 "status should mention the count: {}",
3285 app.status_msg
3286 );
3287 }
3288
3289 #[test]
3290 fn confirm_delete_many_reloads_both_panes() {
3291 let dir = tempdir().expect("tempdir");
3292 let f = dir.path().join("gone.txt");
3293 fs::write(&f, b"bye").unwrap();
3294
3295 let mut app = make_app(dir.path().to_path_buf());
3296 let before_left = app.left.entries.iter().any(|e| e.name == "gone.txt");
3297 assert!(before_left, "file should be visible before delete");
3298
3299 app.confirm_delete_many(&[f]);
3300
3301 let in_left = app.left.entries.iter().any(|e| e.name == "gone.txt");
3302 let in_right = app.right.entries.iter().any(|e| e.name == "gone.txt");
3303 assert!(!in_left, "deleted file should not appear in left pane");
3304 assert!(!in_right, "deleted file should not appear in right pane");
3305 }
3306
3307 #[test]
3308 fn confirm_delete_many_clears_marks_on_both_panes() {
3309 let dir = tempdir().expect("tempdir");
3310 let f = dir.path().join("marked.txt");
3311 fs::write(&f, b"data").unwrap();
3312
3313 let mut app = make_app(dir.path().to_path_buf());
3314 app.left.toggle_mark();
3315 app.right.toggle_mark();
3316 assert!(!app.left.marked.is_empty(), "left pane should have a mark");
3317 assert!(
3318 !app.right.marked.is_empty(),
3319 "right pane should have a mark"
3320 );
3321
3322 app.confirm_delete_many(&[f]);
3323
3324 assert!(
3325 app.left.marked.is_empty(),
3326 "left marks should be cleared after multi-delete"
3327 );
3328 assert!(
3329 app.right.marked.is_empty(),
3330 "right marks should be cleared after multi-delete"
3331 );
3332 }
3333
3334 #[test]
3335 fn confirm_delete_many_partial_error_reports_both_counts() {
3336 let dir = tempdir().expect("tempdir");
3337 let real = dir.path().join("real.txt");
3338 fs::write(&real, b"exists").unwrap();
3339 let ghost = dir.path().join("ghost.txt"); let mut app = make_app(dir.path().to_path_buf());
3342 app.confirm_delete_many(&[real, ghost]);
3343
3344 assert!(
3346 app.status_msg.contains('1'),
3347 "should report 1 deleted: {}",
3348 app.status_msg
3349 );
3350 assert!(
3351 app.status_msg.contains("error"),
3352 "should report an error: {}",
3353 app.status_msg
3354 );
3355 }
3356
3357 #[test]
3358 fn confirm_delete_many_removes_directory_recursively() {
3359 let dir = tempdir().expect("tempdir");
3360 let sub = dir.path().join("subdir");
3361 fs::create_dir(&sub).unwrap();
3362 fs::write(sub.join("inner.txt"), b"inner").unwrap();
3363
3364 let mut app = make_app(dir.path().to_path_buf());
3365 app.confirm_delete_many(std::slice::from_ref(&sub));
3366
3367 assert!(!sub.exists(), "subdirectory should be removed recursively");
3368 }
3369
3370 #[test]
3371 fn multi_delete_cancelled_sets_status_and_no_files_deleted() {
3372 let dir = tempdir().expect("tempdir");
3373 let f = dir.path().join("keep.txt");
3374 fs::write(&f, b"keep").unwrap();
3375
3376 let mut app = make_app(dir.path().to_path_buf());
3377 app.modal = Some(Modal::MultiDelete {
3379 paths: vec![f.clone()],
3380 });
3381 app.modal = None;
3382 app.status_msg = "Multi-delete cancelled.".into();
3383
3384 assert!(f.exists(), "file should still exist after cancellation");
3385 assert_eq!(app.status_msg, "Multi-delete cancelled.");
3386 }
3387
3388 #[test]
3389 fn marks_cleared_on_ascend() {
3390 let dir = tempdir().expect("tempdir");
3391 let sub = dir.path().join("sub");
3392 fs::create_dir(&sub).unwrap();
3393 fs::write(sub.join("file.txt"), b"x").unwrap();
3394
3395 let mut app = make_app(dir.path().to_path_buf());
3396 app.left.navigate_to(sub.clone());
3398 app.left.toggle_mark();
3399 assert!(
3400 !app.left.marked.is_empty(),
3401 "should have a mark before ascend"
3402 );
3403
3404 app.left.navigate_to(dir.path().to_path_buf());
3405 app.left.clear_marks();
3410 assert!(
3411 app.left.marked.is_empty(),
3412 "marks should be clear after clear_marks"
3413 );
3414 }
3415
3416 #[test]
3417 fn marks_cleared_on_directory_descend() {
3418 let dir = tempdir().expect("tempdir");
3419 let sub = dir.path().join("sub");
3420 fs::create_dir(&sub).unwrap();
3421
3422 let mut app = make_app(dir.path().to_path_buf());
3423 if let Some(idx) = app.left.entries.iter().position(|e| e.name == "sub") {
3425 app.left.cursor = idx;
3426 }
3427 app.left.toggle_mark();
3428 assert!(
3429 !app.left.marked.is_empty(),
3430 "should have a mark before descend"
3431 );
3432
3433 app.left.navigate_to(sub);
3435 app.left.clear_marks();
3438 assert!(
3439 app.left.marked.is_empty(),
3440 "marks should be cleared on descent"
3441 );
3442 }
3443
3444 #[test]
3445 fn prompt_delete_with_marks_paths_are_sorted() {
3446 let dir = tempdir().expect("tempdir");
3447 fs::write(dir.path().join("z.txt"), b"z").unwrap();
3448 fs::write(dir.path().join("a.txt"), b"a").unwrap();
3449 fs::write(dir.path().join("m.txt"), b"m").unwrap();
3450 let mut app = make_app(dir.path().to_path_buf());
3451
3452 for _ in 0..app.left.entries.len() {
3454 app.left.toggle_mark();
3455 }
3456
3457 app.prompt_delete();
3458
3459 if let Some(Modal::MultiDelete { paths }) = &app.modal {
3460 let names: Vec<_> = paths
3461 .iter()
3462 .map(|p| p.file_name().unwrap().to_string_lossy().to_string())
3463 .collect();
3464 let mut sorted = names.clone();
3465 sorted.sort();
3466 assert_eq!(names, sorted, "paths in modal should be sorted");
3467 } else {
3468 panic!("expected MultiDelete modal");
3469 }
3470 }
3471
3472 #[test]
3475 fn tab_key_switches_active_pane_from_left_to_right() {
3476 let dir = tempdir().expect("tempdir");
3477 let mut app = make_app(dir.path().to_path_buf());
3478 assert_eq!(app.active, Pane::Left);
3479 app.active = app.active.other();
3481 assert_eq!(app.active, Pane::Right);
3482 }
3483
3484 #[test]
3485 fn tab_key_switches_active_pane_from_right_to_left() {
3486 let dir = tempdir().expect("tempdir");
3487 let mut app = make_app(dir.path().to_path_buf());
3488 app.active = Pane::Right;
3489 app.active = app.active.other();
3490 assert_eq!(app.active, Pane::Left);
3491 }
3492
3493 #[test]
3494 fn tab_key_two_switches_return_to_original() {
3495 let dir = tempdir().expect("tempdir");
3496 let mut app = make_app(dir.path().to_path_buf());
3497 let original = app.active;
3498 app.active = app.active.other();
3499 app.active = app.active.other();
3500 assert_eq!(app.active, original);
3501 }
3502
3503 #[test]
3506 fn new_themes_list_is_non_empty() {
3507 let dir = tempdir().expect("tempdir");
3508 let app = make_app(dir.path().to_path_buf());
3509 assert!(!app.themes.is_empty(), "themes list must not be empty");
3510 }
3511
3512 #[test]
3513 fn new_theme_idx_is_zero() {
3514 let dir = tempdir().expect("tempdir");
3515 let app = make_app(dir.path().to_path_buf());
3516 assert_eq!(app.theme_idx, 0);
3517 }
3518
3519 #[test]
3520 fn new_theme_idx_from_options_is_respected() {
3521 let dir = tempdir().expect("tempdir");
3522 let app = App::new(AppOptions {
3523 left_dir: dir.path().to_path_buf(),
3524 right_dir: dir.path().to_path_buf(),
3525 theme_idx: 2,
3526 ..AppOptions::default()
3527 });
3528 assert_eq!(app.theme_idx, 2);
3529 }
3530
3531 #[test]
3534 fn next_theme_never_exceeds_themes_len() {
3535 let dir = tempdir().expect("tempdir");
3536 let mut app = make_app(dir.path().to_path_buf());
3537 let total = app.themes.len();
3538 for _ in 0..total * 2 {
3539 app.next_theme();
3540 assert!(
3541 app.theme_idx < total,
3542 "theme_idx {} out of bounds (len {})",
3543 app.theme_idx,
3544 total
3545 );
3546 }
3547 }
3548
3549 #[test]
3550 fn prev_theme_never_exceeds_themes_len() {
3551 let dir = tempdir().expect("tempdir");
3552 let mut app = make_app(dir.path().to_path_buf());
3553 let total = app.themes.len();
3554 for _ in 0..total * 2 {
3555 app.prev_theme();
3556 assert!(
3557 app.theme_idx < total,
3558 "theme_idx {} out of bounds (len {})",
3559 app.theme_idx,
3560 total
3561 );
3562 }
3563 }
3564
3565 #[test]
3568 fn do_paste_copy_clears_previous_error_status() {
3569 let dir = tempdir().expect("tempdir");
3570 let src_file = dir.path().join("src.txt");
3571 let dst_file = dir.path().join("dst.txt");
3572 fs::write(&src_file, b"content").unwrap();
3573
3574 let mut app = make_app(dir.path().to_path_buf());
3575 app.status_msg = "Error: something bad".into();
3576
3577 app.do_paste(&src_file, &dst_file, false);
3578
3579 assert!(
3580 !app.status_msg.starts_with("Error"),
3581 "successful paste must replace error status, got: {}",
3582 app.status_msg
3583 );
3584 }
3585
3586 #[test]
3587 fn do_paste_success_status_mentions_filename() {
3588 let dir = tempdir().expect("tempdir");
3589 let src_file = dir.path().join("report.txt");
3590 let dst_file = dir.path().join("report_copy.txt");
3591 fs::write(&src_file, b"data").unwrap();
3592
3593 let mut app = make_app(dir.path().to_path_buf());
3594 app.do_paste(&src_file, &dst_file, false);
3595
3596 assert!(
3597 app.status_msg.contains("report_copy.txt"),
3598 "status should mention destination filename, got: {}",
3599 app.status_msg
3600 );
3601 }
3602
3603 #[test]
3606 fn inactive_pane_is_right_when_left_is_active() {
3607 let dir = tempdir().expect("tempdir");
3608 let app = make_app(dir.path().to_path_buf());
3609 assert_eq!(app.active, Pane::Left);
3610 assert_eq!(app.active.other(), Pane::Right);
3613 }
3614
3615 #[test]
3616 fn inactive_pane_is_left_when_right_is_active() {
3617 let dir = tempdir().expect("tempdir");
3618 let mut app = make_app(dir.path().to_path_buf());
3619 app.active = Pane::Right;
3620 assert_eq!(app.active.other(), Pane::Left);
3621 }
3622
3623 #[test]
3626 fn active_pane_mut_returns_right_when_right_is_active() {
3627 let dir = tempdir().expect("tempdir");
3628 let mut app = make_app(dir.path().to_path_buf());
3629 app.active = Pane::Right;
3630 let right_dir = app.right.current_dir.clone();
3631 assert_eq!(app.active_pane_mut().current_dir, right_dir);
3632 }
3633
3634 #[test]
3635 fn active_pane_mut_returns_left_when_left_is_active() {
3636 let dir = tempdir().expect("tempdir");
3637 let mut app = make_app(dir.path().to_path_buf());
3638 app.active = Pane::Left;
3639 let left_dir = app.left.current_dir.clone();
3640 assert_eq!(app.active_pane_mut().current_dir, left_dir);
3641 }
3642
3643 #[test]
3646 fn single_pane_toggle_via_field() {
3647 let dir = tempdir().expect("tempdir");
3648 let mut app = make_app(dir.path().to_path_buf());
3649 assert!(!app.single_pane);
3650 app.single_pane = !app.single_pane;
3651 assert!(app.single_pane);
3652 app.single_pane = !app.single_pane;
3653 assert!(!app.single_pane);
3654 }
3655
3656 #[test]
3659 fn app_options_default_show_hidden_false() {
3660 assert!(!AppOptions::default().show_hidden);
3661 }
3662
3663 #[test]
3664 fn app_options_default_theme_idx_zero() {
3665 assert_eq!(AppOptions::default().theme_idx, 0);
3666 }
3667
3668 #[test]
3669 fn app_options_default_sort_mode_is_name() {
3670 assert_eq!(AppOptions::default().sort_mode, SortMode::Name);
3671 }
3672
3673 #[test]
3674 fn app_options_default_extensions_empty() {
3675 assert!(AppOptions::default().extensions.is_empty());
3676 }
3677
3678 #[test]
3679 fn app_options_default_single_pane_false() {
3680 assert!(!AppOptions::default().single_pane);
3681 }
3682
3683 #[test]
3684 fn app_options_default_show_theme_panel_false() {
3685 assert!(!AppOptions::default().show_theme_panel);
3686 }
3687
3688 #[test]
3689 fn app_options_default_cd_on_exit_false() {
3690 assert!(!AppOptions::default().cd_on_exit);
3691 }
3692
3693 #[test]
3696 fn app_options_default_verbose_is_false() {
3697 assert!(!AppOptions::default().verbose);
3698 }
3699
3700 #[test]
3701 fn app_options_default_startup_log_is_empty() {
3702 assert!(AppOptions::default().startup_log.is_empty());
3703 }
3704
3705 #[test]
3706 fn app_new_verbose_false_by_default() {
3707 let app = make_app(std::env::temp_dir());
3708 assert!(!app.verbose);
3709 }
3710
3711 #[test]
3712 fn app_new_debug_log_empty_by_default() {
3713 let app = make_app(std::env::temp_dir());
3714 assert!(app.debug_log.is_empty());
3715 }
3716
3717 #[test]
3718 fn app_new_debug_scroll_zero_by_default() {
3719 let app = make_app(std::env::temp_dir());
3720 assert_eq!(app.debug_scroll, 0);
3721 }
3722
3723 #[test]
3724 fn app_new_inherits_verbose_from_options() {
3725 let app = App::new(AppOptions {
3726 left_dir: std::env::temp_dir(),
3727 right_dir: std::env::temp_dir(),
3728 verbose: true,
3729 ..AppOptions::default()
3730 });
3731 assert!(app.verbose);
3732 }
3733
3734 #[test]
3735 fn app_new_drains_startup_log_into_debug_log() {
3736 let startup = vec!["line 1".to_string(), "line 2".to_string()];
3737 let app = App::new(AppOptions {
3738 left_dir: std::env::temp_dir(),
3739 right_dir: std::env::temp_dir(),
3740 startup_log: startup.clone(),
3741 ..AppOptions::default()
3742 });
3743 assert_eq!(app.debug_log, startup);
3744 }
3745
3746 #[test]
3747 fn app_log_appends_when_verbose() {
3748 let mut app = App::new(AppOptions {
3749 left_dir: std::env::temp_dir(),
3750 right_dir: std::env::temp_dir(),
3751 verbose: true,
3752 ..AppOptions::default()
3753 });
3754 app.log("hello");
3755 app.log("world");
3756 assert_eq!(app.debug_log.len(), 2);
3757 assert_eq!(app.debug_log[0], "hello");
3758 assert_eq!(app.debug_log[1], "world");
3759 }
3760
3761 #[test]
3762 fn app_log_does_nothing_when_not_verbose() {
3763 let mut app = make_app(std::env::temp_dir());
3764 assert!(!app.verbose);
3765 app.log("should be ignored");
3766 assert!(app.debug_log.is_empty());
3767 }
3768
3769 #[test]
3770 fn app_log_accepts_string_and_str() {
3771 let mut app = App::new(AppOptions {
3772 left_dir: std::env::temp_dir(),
3773 right_dir: std::env::temp_dir(),
3774 verbose: true,
3775 ..AppOptions::default()
3776 });
3777 app.log("static str");
3778 app.log(String::from("owned string"));
3779 app.log(format!("formatted {}", 42));
3780 assert_eq!(app.debug_log.len(), 3);
3781 }
3782
3783 #[test]
3784 fn app_log_preserves_startup_log_order() {
3785 let mut app = App::new(AppOptions {
3786 left_dir: std::env::temp_dir(),
3787 right_dir: std::env::temp_dir(),
3788 verbose: true,
3789 startup_log: vec!["startup".to_string()],
3790 ..AppOptions::default()
3791 });
3792 app.log("runtime");
3793 assert_eq!(app.debug_log.len(), 2);
3794 assert_eq!(app.debug_log[0], "startup");
3795 assert_eq!(app.debug_log[1], "runtime");
3796 }
3797
3798 fn make_verbose_app_with_logs(n: usize) -> App {
3801 let mut app = App::new(AppOptions {
3802 left_dir: std::env::temp_dir(),
3803 right_dir: std::env::temp_dir(),
3804 verbose: true,
3805 ..AppOptions::default()
3806 });
3807 for i in 0..n {
3808 app.debug_log.push(format!("log line {i}"));
3809 }
3810 app
3811 }
3812
3813 fn ctrl_up() -> crossterm::event::KeyEvent {
3814 use crossterm::event::{KeyEvent, KeyEventKind, KeyEventState};
3815 KeyEvent {
3816 code: KeyCode::Up,
3817 modifiers: KeyModifiers::CONTROL,
3818 kind: KeyEventKind::Press,
3819 state: KeyEventState::NONE,
3820 }
3821 }
3822
3823 fn ctrl_down() -> crossterm::event::KeyEvent {
3824 use crossterm::event::{KeyEvent, KeyEventKind, KeyEventState};
3825 KeyEvent {
3826 code: KeyCode::Down,
3827 modifiers: KeyModifiers::CONTROL,
3828 kind: KeyEventKind::Press,
3829 state: KeyEventState::NONE,
3830 }
3831 }
3832
3833 #[test]
3834 fn debug_scroll_up_increments() {
3835 let mut app = make_verbose_app_with_logs(10);
3836 assert_eq!(app.debug_scroll, 0);
3837 app.handle_key(ctrl_up()).unwrap();
3838 assert_eq!(app.debug_scroll, 1);
3839 app.handle_key(ctrl_up()).unwrap();
3840 assert_eq!(app.debug_scroll, 2);
3841 }
3842
3843 #[test]
3844 fn debug_scroll_down_decrements() {
3845 let mut app = make_verbose_app_with_logs(10);
3846 app.debug_scroll = 5;
3847 app.handle_key(ctrl_down()).unwrap();
3848 assert_eq!(app.debug_scroll, 4);
3849 app.handle_key(ctrl_down()).unwrap();
3850 assert_eq!(app.debug_scroll, 3);
3851 }
3852
3853 #[test]
3854 fn debug_scroll_down_clamps_at_zero() {
3855 let mut app = make_verbose_app_with_logs(10);
3856 assert_eq!(app.debug_scroll, 0);
3857 app.handle_key(ctrl_down()).unwrap();
3858 assert_eq!(app.debug_scroll, 0);
3859 }
3860
3861 #[test]
3862 fn debug_scroll_up_clamps_at_log_length() {
3863 let mut app = make_verbose_app_with_logs(5);
3864 for _ in 0..20 {
3866 app.handle_key(ctrl_up()).unwrap();
3867 }
3868 assert_eq!(app.debug_scroll, 4);
3869 }
3870
3871 #[test]
3872 fn debug_scroll_ignored_when_not_verbose() {
3873 let mut app = make_app(std::env::temp_dir());
3874 assert!(!app.verbose);
3875 app.debug_log.push("line".to_string());
3877 app.debug_log.push("line".to_string());
3878 app.handle_key(ctrl_up()).unwrap();
3879 assert_eq!(app.debug_scroll, 0);
3880 app.handle_key(ctrl_down()).unwrap();
3881 assert_eq!(app.debug_scroll, 0);
3882 }
3883}