1use std::collections::HashSet;
4use std::path::PathBuf;
5
6use crate::config::{Config, SortMode};
7use crate::history::History;
8use crate::package::{Runner, Script, Scripts, Workspace};
9
10const MIN_COLUMN_WIDTH: u16 = 28;
12
13#[derive(Debug, Clone, PartialEq, Default)]
15pub enum AppMode {
16 #[default]
18 Normal,
19 Filter { query: String },
21 MultiSelect { selected: HashSet<usize> },
23 Help,
25 Error { message: String },
27 Args { script_index: usize, input: String },
29 WorkspaceSelect,
31}
32
33#[derive(Debug, Clone, PartialEq)]
35pub enum WorkspaceContext {
36 Root,
38 Workspace(usize),
40}
41
42#[derive(Debug, Clone)]
44pub struct ScriptRun {
45 pub script: Script,
47 pub args: Option<String>,
49 pub workspace: Option<String>,
51 pub workspace_path: Option<PathBuf>,
53}
54
55impl std::fmt::Display for ScriptRun {
56 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 let prefix = if let Some(ws) = &self.workspace {
58 format!("{} > ", ws)
59 } else {
60 String::new()
61 };
62
63 if let Some(args) = &self.args {
64 write!(f, "{}{} {}", prefix, self.script.name(), args)
65 } else {
66 write!(f, "{}{}", prefix, self.script.name())
67 }
68 }
69}
70
71pub struct App {
73 scripts: Scripts,
76 config: Config,
78 history: History,
80 runner: Runner,
82 project_name: String,
84 project_path: PathBuf,
86
87 is_monorepo: bool,
90 workspaces: Vec<Workspace>,
92 workspace_context: WorkspaceContext,
94 workspace_selected: usize,
96
97 mode: AppMode,
100 selected: usize,
102 scroll_offset: usize,
104 filter_text: String,
106 sort_mode: SortMode,
108
109 visible_indices: Vec<usize>,
112 columns: usize,
114 should_quit: bool,
116 script_to_run: Option<ScriptRun>,
118}
119
120impl App {
121 pub fn new(
123 scripts: Scripts,
124 config: Config,
125 history: History,
126 project_name: String,
127 project_path: PathBuf,
128 runner: Runner,
129 ) -> Self {
130 Self::with_workspaces(
131 scripts,
132 config,
133 history,
134 project_name,
135 project_path,
136 runner,
137 Vec::new(),
138 )
139 }
140
141 pub fn with_workspaces(
143 scripts: Scripts,
144 config: Config,
145 history: History,
146 project_name: String,
147 project_path: PathBuf,
148 runner: Runner,
149 workspaces: Vec<Workspace>,
150 ) -> Self {
151 let sort_mode = config.general.default_sort;
152 let visible_indices: Vec<usize> = (0..scripts.len()).collect();
153 let is_monorepo = !workspaces.is_empty();
154
155 let initial_mode = if is_monorepo {
157 AppMode::WorkspaceSelect
158 } else {
159 AppMode::Normal
160 };
161
162 let mut app = Self {
163 scripts,
164 config,
165 history,
166 runner,
167 project_name,
168 project_path,
169 is_monorepo,
170 workspaces,
171 workspace_context: WorkspaceContext::Root,
172 workspace_selected: 0,
173 mode: initial_mode,
174 selected: 0,
175 scroll_offset: 0,
176 filter_text: String::new(),
177 sort_mode,
178 visible_indices,
179 columns: 1,
180 should_quit: false,
181 script_to_run: None,
182 };
183
184 app.update_visible_scripts();
186 app
187 }
188
189 pub fn mode(&self) -> &AppMode {
193 &self.mode
194 }
195
196 pub fn should_quit(&self) -> bool {
198 self.should_quit
199 }
200
201 pub fn script_to_run(&self) -> Option<&ScriptRun> {
203 self.script_to_run.as_ref()
204 }
205
206 pub fn project_name(&self) -> &str {
208 &self.project_name
209 }
210
211 pub fn project_path(&self) -> &PathBuf {
213 &self.project_path
214 }
215
216 pub fn runner(&self) -> Runner {
218 self.runner
219 }
220
221 pub fn scripts(&self) -> &Scripts {
223 &self.scripts
224 }
225
226 pub fn filter_text(&self) -> &str {
228 &self.filter_text
229 }
230
231 pub fn sort_mode(&self) -> SortMode {
233 self.sort_mode
234 }
235
236 pub fn columns(&self) -> usize {
238 self.columns
239 }
240
241 pub fn selected_index(&self) -> usize {
243 self.selected
244 }
245
246 pub fn scroll_offset(&self) -> usize {
248 self.scroll_offset
249 }
250
251 pub fn visible_count(&self) -> usize {
253 self.visible_indices.len()
254 }
255
256 pub fn config(&self) -> &Config {
258 &self.config
259 }
260
261 pub fn is_monorepo(&self) -> bool {
265 self.is_monorepo
266 }
267
268 pub fn workspaces(&self) -> &[Workspace] {
270 &self.workspaces
271 }
272
273 pub fn workspace_context(&self) -> &WorkspaceContext {
275 &self.workspace_context
276 }
277
278 pub fn workspace_selected(&self) -> usize {
280 self.workspace_selected
281 }
282
283 pub fn current_workspace(&self) -> Option<&Workspace> {
285 match &self.workspace_context {
286 WorkspaceContext::Root => None,
287 WorkspaceContext::Workspace(idx) => self.workspaces.get(*idx),
288 }
289 }
290
291 pub fn breadcrumb(&self) -> String {
294 match &self.workspace_context {
295 WorkspaceContext::Root => self.project_name.clone(),
296 WorkspaceContext::Workspace(idx) => {
297 if let Some(ws) = self.workspaces.get(*idx) {
298 format!("{} > {}", self.project_name, ws.name())
299 } else {
300 self.project_name.clone()
301 }
302 }
303 }
304 }
305
306 pub fn visible_scripts(&self) -> Vec<&Script> {
310 self.visible_indices
311 .iter()
312 .filter_map(|&i| self.scripts.iter().nth(i))
313 .collect()
314 }
315
316 pub fn selected_script(&self) -> Option<&Script> {
318 self.visible_indices
319 .get(self.selected)
320 .and_then(|&i| self.scripts.iter().nth(i))
321 }
322
323 pub fn get_visible_script(&self, index: usize) -> Option<&Script> {
325 self.visible_indices
326 .get(index)
327 .and_then(|&i| self.scripts.iter().nth(i))
328 }
329
330 pub fn set_mode(&mut self, mode: AppMode) {
334 self.mode = mode;
335 }
336
337 pub fn toggle_filter_mode(&mut self) {
339 match &self.mode {
340 AppMode::Filter { .. } => {
341 self.mode = AppMode::Normal;
342 self.filter_text.clear();
343 self.update_visible_scripts();
344 }
345 AppMode::Normal => {
346 self.mode = AppMode::Filter {
347 query: String::new(),
348 };
349 }
350 _ => {}
351 }
352 }
353
354 pub fn enter_args_mode(&mut self) {
356 if self.selected < self.visible_indices.len() {
357 self.mode = AppMode::Args {
358 script_index: self.selected,
359 input: String::new(),
360 };
361 }
362 }
363
364 pub fn toggle_multi_select(&mut self) {
366 match &self.mode {
367 AppMode::MultiSelect { .. } => {
368 self.mode = AppMode::Normal;
369 }
370 AppMode::Normal => {
371 self.mode = AppMode::MultiSelect {
372 selected: HashSet::new(),
373 };
374 }
375 _ => {}
376 }
377 }
378
379 pub fn toggle_help(&mut self) {
381 match self.mode {
382 AppMode::Help => {
383 self.mode = AppMode::Normal;
384 }
385 _ => {
386 self.mode = AppMode::Help;
387 }
388 }
389 }
390
391 pub fn enter_workspace_select(&mut self) {
395 if self.is_monorepo {
396 self.mode = AppMode::WorkspaceSelect;
397 self.workspace_selected = 0;
398 }
399 }
400
401 pub fn exit_workspace_select(&mut self) {
403 self.mode = AppMode::Normal;
404 self.selected = 0;
405 self.update_visible_scripts();
406 }
407
408 pub fn select_workspace(&mut self, index: usize) {
410 if index == 0 {
412 self.workspace_context = WorkspaceContext::Root;
413 } else if let Some(workspace) = self.workspaces.get(index - 1) {
415 self.workspace_context = WorkspaceContext::Workspace(index - 1);
416 self.scripts = Scripts::from_vec(workspace.scripts().to_vec());
418 }
419
420 self.selected = 0;
421 self.mode = AppMode::Normal;
422 self.update_visible_scripts();
423 }
424
425 pub fn select_current_workspace(&mut self) {
427 self.select_workspace(self.workspace_selected);
428 }
429
430 pub fn back_to_workspace_select(&mut self) {
432 if self.is_monorepo {
433 self.mode = AppMode::WorkspaceSelect;
434 }
435 }
436
437 pub fn workspace_move_up(&mut self) {
439 if self.workspace_selected > 0 {
440 self.workspace_selected -= 1;
441 }
442 }
443
444 pub fn workspace_move_down(&mut self) {
446 let max_index = self.workspaces.len();
448 if self.workspace_selected < max_index {
449 self.workspace_selected += 1;
450 }
451 }
452
453 pub fn workspace_move_left(&mut self) {
455 if self.workspace_selected > 0 {
456 self.workspace_selected -= 1;
457 }
458 }
459
460 pub fn workspace_move_right(&mut self) {
462 let max_index = self.workspaces.len();
463 if self.workspace_selected < max_index {
464 self.workspace_selected += 1;
465 }
466 }
467
468 pub fn select_workspace_by_number(&mut self, num: usize) {
470 if num > 0 && num <= self.workspaces.len() + 1 {
472 self.select_workspace(num - 1);
473 }
474 }
475
476 pub fn workspace_count(&self) -> usize {
478 self.workspaces.len() + 1 }
480
481 pub fn set_filter(&mut self, text: String) {
485 self.filter_text = text.clone();
486 self.mode = AppMode::Filter { query: text };
487 self.update_visible_scripts();
488 }
489
490 pub fn push_filter_char(&mut self, c: char) {
492 self.filter_text.push(c);
493 self.update_visible_scripts();
494 }
495
496 pub fn pop_filter_char(&mut self) {
498 self.filter_text.pop();
499 self.update_visible_scripts();
500 }
501
502 pub fn clear_filter(&mut self) {
504 self.filter_text.clear();
505 self.update_visible_scripts();
506 }
507
508 pub fn cycle_sort_mode(&mut self) {
512 self.sort_mode = match self.sort_mode {
513 SortMode::Recent => SortMode::Alpha,
514 SortMode::Alpha => SortMode::Category,
515 SortMode::Category => SortMode::Recent,
516 };
517 self.update_visible_scripts();
518 }
519
520 pub fn set_sort_mode(&mut self, mode: SortMode) {
522 self.sort_mode = mode;
523 self.update_visible_scripts();
524 }
525
526 pub fn update_visible_scripts(&mut self) {
530 let filtered_indices: Vec<usize> = if self.filter_text.is_empty() {
532 (0..self.scripts.len()).collect()
533 } else {
534 let matches = crate::filter::filter_scripts(
536 &self.filter_text,
537 self.scripts.as_slice(),
538 self.config.filter.search_descriptions,
539 );
540
541 matches.into_iter().map(|(idx, _score)| idx).collect()
542 };
543
544 self.visible_indices = self.sort_indices(filtered_indices);
546
547 if self.selected >= self.visible_indices.len() {
549 self.selected = self.visible_indices.len().saturating_sub(1);
550 }
551 }
552
553 fn sort_indices(&self, mut indices: Vec<usize>) -> Vec<usize> {
555 match self.sort_mode {
556 SortMode::Recent => {
557 let scripts_owned: Vec<Script> = indices
560 .iter()
561 .filter_map(|&i| self.scripts.iter().nth(i).cloned())
562 .collect();
563
564 let sorted = self
565 .history
566 .get_sorted_by_recent(&self.project_path, &scripts_owned);
567
568 sorted
570 .iter()
571 .filter_map(|s| {
572 self.scripts
573 .iter()
574 .position(|script| script.name() == s.name())
575 })
576 .filter(|i| indices.contains(i))
577 .collect()
578 }
579 SortMode::Alpha => {
580 indices.sort_by(|&a, &b| {
582 let name_a = self.scripts.iter().nth(a).map(|s| s.name()).unwrap_or("");
583 let name_b = self.scripts.iter().nth(b).map(|s| s.name()).unwrap_or("");
584 name_a.cmp(name_b)
585 });
586 indices
587 }
588 SortMode::Category => {
589 indices.sort_by(|&a, &b| {
591 let name_a = self.scripts.iter().nth(a).map(|s| s.name()).unwrap_or("");
592 let name_b = self.scripts.iter().nth(b).map(|s| s.name()).unwrap_or("");
593
594 let category_a = name_a.split(':').next().unwrap_or(name_a);
595 let category_b = name_b.split(':').next().unwrap_or(name_b);
596
597 category_a.cmp(category_b).then_with(|| name_a.cmp(name_b))
598 });
599 indices
600 }
601 }
602 }
603
604 pub fn update_columns(&mut self, width: u16) {
608 self.columns = calculate_columns(width);
609 }
610
611 fn row_count(&self) -> usize {
615 let count = self.visible_indices.len();
616 if count == 0 || self.columns == 0 {
617 return 0;
618 }
619 (count + self.columns - 1) / self.columns
620 }
621
622 fn current_position(&self) -> (usize, usize) {
624 let row = self.selected / self.columns;
625 let col = self.selected % self.columns;
626 (row, col)
627 }
628
629 pub fn move_up(&mut self) {
631 if self.visible_indices.is_empty() || self.columns == 0 {
632 return;
633 }
634
635 let (row, col) = self.current_position();
636 if row > 0 {
637 let new_index = (row - 1) * self.columns + col;
638 if new_index < self.visible_indices.len() {
639 self.selected = new_index;
640 }
641 }
642 }
643
644 pub fn move_down(&mut self) {
646 if self.visible_indices.is_empty() || self.columns == 0 {
647 return;
648 }
649
650 let (row, col) = self.current_position();
651 let new_index = (row + 1) * self.columns + col;
652
653 if new_index < self.visible_indices.len() {
654 self.selected = new_index;
655 } else {
656 let last_row = self.row_count().saturating_sub(1);
658 if row < last_row {
659 self.selected = self.visible_indices.len().saturating_sub(1);
661 }
662 }
663 }
664
665 pub fn move_left(&mut self) {
667 if self.selected > 0 {
668 self.selected -= 1;
669 }
670 }
671
672 pub fn move_right(&mut self) {
674 if self.selected < self.visible_indices.len().saturating_sub(1) {
675 self.selected += 1;
676 }
677 }
678
679 pub fn move_to_first(&mut self) {
681 self.selected = 0;
682 }
683
684 pub fn move_to_last(&mut self) {
686 self.selected = self.visible_indices.len().saturating_sub(1);
687 }
688
689 pub fn select_by_number(&mut self, num: usize) {
691 if num > 0 && num <= self.visible_indices.len() {
692 self.selected = num - 1;
693 }
694 }
695
696 pub fn select_prev(&mut self) {
700 self.move_up();
701 }
702
703 pub fn select_next(&mut self) {
705 self.move_down();
706 }
707
708 pub fn select_first(&mut self) {
710 self.move_to_first();
711 }
712
713 pub fn select_last(&mut self) {
715 self.move_to_last();
716 }
717
718 pub fn quit(&mut self) {
722 self.should_quit = true;
723 }
724
725 pub fn run_selected(&mut self) -> Option<ScriptRun> {
727 if let Some(script) = self.selected_script() {
728 let (workspace, workspace_path) = self.get_workspace_info();
729 let run = ScriptRun {
730 script: script.clone(),
731 args: None,
732 workspace,
733 workspace_path,
734 };
735 self.script_to_run = Some(run.clone());
736 self.should_quit = true;
737 Some(run)
738 } else {
739 None
740 }
741 }
742
743 fn get_workspace_info(&self) -> (Option<String>, Option<PathBuf>) {
745 match &self.workspace_context {
746 WorkspaceContext::Root => (None, None),
747 WorkspaceContext::Workspace(idx) => {
748 if let Some(ws) = self.workspaces.get(*idx) {
749 (Some(ws.name().to_string()), Some(ws.path().to_path_buf()))
750 } else {
751 (None, None)
752 }
753 }
754 }
755 }
756
757 pub fn run_numbered(&mut self, num: usize) -> Option<ScriptRun> {
759 if num > 0 && num <= self.visible_indices.len() {
760 self.selected = num - 1;
761 self.run_selected()
762 } else {
763 None
764 }
765 }
766
767 pub fn run_by_number(&mut self, num: usize) {
769 self.run_numbered(num);
770 }
771
772 pub fn run_with_args(&mut self, args: String) -> Option<ScriptRun> {
774 if let Some(script) = self.selected_script() {
775 let (workspace, workspace_path) = self.get_workspace_info();
776 let run = ScriptRun {
777 script: script.clone(),
778 args: if args.is_empty() { None } else { Some(args) },
779 workspace,
780 workspace_path,
781 };
782 self.script_to_run = Some(run.clone());
783 self.should_quit = true;
784 Some(run)
785 } else {
786 None
787 }
788 }
789
790 pub fn toggle_current_selection(&mut self) {
792 if let AppMode::MultiSelect { ref mut selected } = self.mode {
793 if selected.contains(&self.selected) {
794 selected.remove(&self.selected);
795 } else {
796 selected.insert(self.selected);
797 }
798 }
799 }
800
801 pub fn multi_selected_indices(&self) -> Option<&HashSet<usize>> {
803 if let AppMode::MultiSelect { ref selected } = self.mode {
804 Some(selected)
805 } else {
806 None
807 }
808 }
809
810 pub fn run_multi_selected(&mut self) -> Vec<ScriptRun> {
812 let (workspace, workspace_path) = self.get_workspace_info();
813 let runs: Vec<ScriptRun> = if let AppMode::MultiSelect { ref selected } = self.mode {
814 selected
815 .iter()
816 .filter_map(|&idx| {
817 self.get_visible_script(idx).map(|script| ScriptRun {
818 script: script.clone(),
819 args: None,
820 workspace: workspace.clone(),
821 workspace_path: workspace_path.clone(),
822 })
823 })
824 .collect()
825 } else {
826 vec![]
827 };
828
829 if !runs.is_empty() {
830 self.script_to_run = runs.first().cloned();
832 self.should_quit = true;
833 }
834
835 runs
836 }
837}
838
839pub fn calculate_columns(width: u16) -> usize {
841 if width < 60 {
842 1
843 } else if width < 90 {
844 2
845 } else if width < 120 {
846 3
847 } else if width < 160 {
848 4
849 } else {
850 5
851 }
852}
853
854pub fn calculate_column_width(total_width: u16, columns: usize) -> u16 {
856 if columns == 0 {
857 return total_width;
858 }
859 let padding = 2; let available = total_width.saturating_sub(padding * 2);
861 (available / columns as u16).max(MIN_COLUMN_WIDTH)
862}
863
864#[cfg(test)]
865mod tests {
866 use super::*;
867
868 fn create_test_scripts() -> Scripts {
869 let mut scripts = Scripts::new();
870 scripts.add(Script::new("dev", "vite"));
871 scripts.add(Script::new("build", "vite build"));
872 scripts.add(Script::new("test", "vitest"));
873 scripts.add(Script::new("lint", "eslint ."));
874 scripts.add(Script::new("format", "prettier --write ."));
875 scripts.add(Script::new("typecheck", "tsc --noEmit"));
876 scripts.add(Script::new("build:prod", "vite build --mode production"));
877 scripts.add(Script::new("build:dev", "vite build --mode development"));
878 scripts.add(Script::new("test:unit", "vitest unit"));
879 scripts
880 }
881
882 fn create_test_app() -> App {
883 let scripts = create_test_scripts();
884 let config = Config::default();
885 let history = History::new();
886 App::new(
887 scripts,
888 config,
889 history,
890 "test-project".to_string(),
891 PathBuf::from("/test/project"),
892 Runner::Npm,
893 )
894 }
895
896 #[test]
899 fn test_app_new() {
900 let app = create_test_app();
901 assert_eq!(app.project_name(), "test-project");
902 assert_eq!(app.runner(), Runner::Npm);
903 assert!(!app.should_quit());
904 assert!(app.script_to_run().is_none());
905 assert_eq!(app.mode(), &AppMode::Normal);
906 }
907
908 #[test]
909 fn test_visible_scripts() {
910 let app = create_test_app();
911 let visible = app.visible_scripts();
912 assert_eq!(visible.len(), 9);
913 }
914
915 #[test]
916 fn test_selected_script() {
917 let mut app = create_test_app();
918 app.set_sort_mode(SortMode::Alpha); let script = app.selected_script().unwrap();
920 assert_eq!(script.name(), "build");
922 }
923
924 #[test]
927 fn test_move_left_right() {
928 let mut app = create_test_app();
929 app.update_columns(100); app.set_sort_mode(SortMode::Alpha);
931
932 assert_eq!(app.selected_index(), 0);
933
934 app.move_right();
935 assert_eq!(app.selected_index(), 1);
936
937 app.move_right();
938 assert_eq!(app.selected_index(), 2);
939
940 app.move_left();
941 assert_eq!(app.selected_index(), 1);
942
943 app.move_left();
944 assert_eq!(app.selected_index(), 0);
945
946 app.move_left();
948 assert_eq!(app.selected_index(), 0);
949 }
950
951 #[test]
952 fn test_move_up_down_single_column() {
953 let mut app = create_test_app();
954 app.update_columns(50); app.set_sort_mode(SortMode::Alpha);
956
957 assert_eq!(app.selected_index(), 0);
958
959 app.move_down();
960 assert_eq!(app.selected_index(), 1);
961
962 app.move_down();
963 assert_eq!(app.selected_index(), 2);
964
965 app.move_up();
966 assert_eq!(app.selected_index(), 1);
967
968 app.move_up();
969 assert_eq!(app.selected_index(), 0);
970
971 app.move_up();
973 assert_eq!(app.selected_index(), 0);
974 }
975
976 #[test]
977 fn test_move_up_down_multi_column() {
978 let mut app = create_test_app();
979 app.update_columns(100); app.set_sort_mode(SortMode::Alpha);
981
982 assert_eq!(app.selected_index(), 0);
988
989 app.move_down(); assert_eq!(app.selected_index(), 3);
991
992 app.move_down(); assert_eq!(app.selected_index(), 6);
994
995 app.move_right(); assert_eq!(app.selected_index(), 7);
997
998 app.move_up(); assert_eq!(app.selected_index(), 4);
1000
1001 app.move_up(); assert_eq!(app.selected_index(), 1);
1003 }
1004
1005 #[test]
1006 fn test_move_to_first_last() {
1007 let mut app = create_test_app();
1008 app.set_sort_mode(SortMode::Alpha);
1009
1010 app.move_to_last();
1011 assert_eq!(app.selected_index(), 8); app.move_to_first();
1014 assert_eq!(app.selected_index(), 0);
1015 }
1016
1017 #[test]
1018 fn test_select_by_number() {
1019 let mut app = create_test_app();
1020 app.set_sort_mode(SortMode::Alpha);
1021
1022 app.select_by_number(5);
1023 assert_eq!(app.selected_index(), 4); app.select_by_number(1);
1026 assert_eq!(app.selected_index(), 0);
1027
1028 app.select_by_number(9);
1029 assert_eq!(app.selected_index(), 8);
1030
1031 app.select_by_number(0);
1033 assert_eq!(app.selected_index(), 8);
1034
1035 app.select_by_number(100);
1036 assert_eq!(app.selected_index(), 8);
1037 }
1038
1039 #[test]
1042 fn test_filter_updates_visible() {
1043 let mut app = create_test_app();
1044
1045 app.set_filter("build".to_string());
1046 let visible = app.visible_scripts();
1047
1048 assert_eq!(visible.len(), 3);
1050 assert!(visible.iter().all(|s| s.name().contains("build")));
1051 }
1052
1053 #[test]
1054 fn test_filter_adjusts_selection() {
1055 let mut app = create_test_app();
1056 app.set_sort_mode(SortMode::Alpha);
1057
1058 app.move_to_last();
1060 assert_eq!(app.selected_index(), 8);
1061
1062 app.set_filter("test".to_string());
1064
1065 assert!(app.selected_index() < app.visible_count());
1067 }
1068
1069 #[test]
1070 fn test_filter_clear() {
1071 let mut app = create_test_app();
1072
1073 app.set_filter("dev".to_string());
1074 assert!(app.visible_count() < 9);
1075
1076 app.clear_filter();
1077 assert_eq!(app.visible_count(), 9);
1078 }
1079
1080 #[test]
1081 fn test_filter_char_operations() {
1082 let mut app = create_test_app();
1083
1084 app.push_filter_char('t');
1085 assert_eq!(app.filter_text(), "t");
1086
1087 app.push_filter_char('e');
1088 assert_eq!(app.filter_text(), "te");
1089
1090 app.pop_filter_char();
1091 assert_eq!(app.filter_text(), "t");
1092
1093 app.pop_filter_char();
1094 assert_eq!(app.filter_text(), "");
1095 }
1096
1097 #[test]
1100 fn test_cycle_sort_mode() {
1101 let mut app = create_test_app();
1102
1103 assert_eq!(app.sort_mode(), SortMode::Recent); app.cycle_sort_mode();
1106 assert_eq!(app.sort_mode(), SortMode::Alpha);
1107
1108 app.cycle_sort_mode();
1109 assert_eq!(app.sort_mode(), SortMode::Category);
1110
1111 app.cycle_sort_mode();
1112 assert_eq!(app.sort_mode(), SortMode::Recent);
1113 }
1114
1115 #[test]
1116 fn test_sort_mode_alpha() {
1117 let mut app = create_test_app();
1118 app.set_sort_mode(SortMode::Alpha);
1119
1120 let visible = app.visible_scripts();
1121 let names: Vec<&str> = visible.iter().map(|s| s.name()).collect();
1122
1123 let mut sorted_names = names.clone();
1125 sorted_names.sort();
1126 assert_eq!(names, sorted_names);
1127 }
1128
1129 #[test]
1130 fn test_sort_mode_category() {
1131 let mut app = create_test_app();
1132 app.set_sort_mode(SortMode::Category);
1133
1134 let visible = app.visible_scripts();
1135 let names: Vec<&str> = visible.iter().map(|s| s.name()).collect();
1136
1137 let build_indices: Vec<usize> = names
1139 .iter()
1140 .enumerate()
1141 .filter(|(_, n)| n.starts_with("build"))
1142 .map(|(i, _)| i)
1143 .collect();
1144
1145 if build_indices.len() > 1 {
1147 for i in 1..build_indices.len() {
1148 assert!(build_indices[i] - build_indices[i - 1] <= 1);
1149 }
1150 }
1151 }
1152
1153 #[test]
1156 fn test_run_selected() {
1157 let mut app = create_test_app();
1158 app.set_sort_mode(SortMode::Alpha);
1159
1160 let run = app.run_selected();
1161 assert!(run.is_some());
1162
1163 let run = run.unwrap();
1164 assert_eq!(run.script.name(), "build"); assert!(run.args.is_none());
1166 assert!(app.should_quit());
1167 }
1168
1169 #[test]
1170 fn test_run_numbered() {
1171 let mut app = create_test_app();
1172 app.set_sort_mode(SortMode::Alpha);
1173
1174 let run = app.run_numbered(3);
1175 assert!(run.is_some());
1176
1177 assert!(run.unwrap().script.name().len() > 0);
1179 assert_eq!(app.selected_index(), 2);
1180 assert!(app.should_quit());
1181 }
1182
1183 #[test]
1184 fn test_run_with_args() {
1185 let mut app = create_test_app();
1186 app.set_sort_mode(SortMode::Alpha);
1187
1188 let run = app.run_with_args("--watch".to_string());
1189 assert!(run.is_some());
1190
1191 let run = run.unwrap();
1192 assert_eq!(run.args, Some("--watch".to_string()));
1193 assert!(app.should_quit());
1194 }
1195
1196 #[test]
1197 fn test_quit() {
1198 let mut app = create_test_app();
1199 assert!(!app.should_quit());
1200
1201 app.quit();
1202 assert!(app.should_quit());
1203 }
1204
1205 #[test]
1208 fn test_toggle_filter_mode() {
1209 let mut app = create_test_app();
1210 assert_eq!(app.mode(), &AppMode::Normal);
1211
1212 app.toggle_filter_mode();
1213 assert!(matches!(app.mode(), &AppMode::Filter { .. }));
1214
1215 app.toggle_filter_mode();
1216 assert_eq!(app.mode(), &AppMode::Normal);
1217 }
1218
1219 #[test]
1220 fn test_toggle_multi_select() {
1221 let mut app = create_test_app();
1222 assert_eq!(app.mode(), &AppMode::Normal);
1223
1224 app.toggle_multi_select();
1225 assert!(matches!(app.mode(), &AppMode::MultiSelect { .. }));
1226
1227 app.toggle_multi_select();
1228 assert_eq!(app.mode(), &AppMode::Normal);
1229 }
1230
1231 #[test]
1232 fn test_toggle_help() {
1233 let mut app = create_test_app();
1234 assert_eq!(app.mode(), &AppMode::Normal);
1235
1236 app.toggle_help();
1237 assert_eq!(app.mode(), &AppMode::Help);
1238
1239 app.toggle_help();
1240 assert_eq!(app.mode(), &AppMode::Normal);
1241 }
1242
1243 #[test]
1244 fn test_enter_args_mode() {
1245 let mut app = create_test_app();
1246 app.enter_args_mode();
1247
1248 assert!(matches!(
1249 app.mode(),
1250 &AppMode::Args {
1251 script_index: 0,
1252 ..
1253 }
1254 ));
1255 }
1256
1257 #[test]
1258 fn test_multi_select_toggle_selection() {
1259 let mut app = create_test_app();
1260 app.toggle_multi_select();
1261
1262 app.toggle_current_selection();
1263 let selected = app.multi_selected_indices().unwrap();
1264 assert!(selected.contains(&0));
1265
1266 app.move_right();
1267 app.toggle_current_selection();
1268 let selected = app.multi_selected_indices().unwrap();
1269 assert!(selected.contains(&0));
1270 assert!(selected.contains(&1));
1271
1272 app.toggle_current_selection();
1274 let selected = app.multi_selected_indices().unwrap();
1275 assert!(!selected.contains(&1));
1276 }
1277
1278 #[test]
1281 fn test_calculate_columns() {
1282 assert_eq!(calculate_columns(50), 1);
1283 assert_eq!(calculate_columns(59), 1);
1284 assert_eq!(calculate_columns(60), 2);
1285 assert_eq!(calculate_columns(89), 2);
1286 assert_eq!(calculate_columns(90), 3);
1287 assert_eq!(calculate_columns(119), 3);
1288 assert_eq!(calculate_columns(120), 4);
1289 assert_eq!(calculate_columns(159), 4);
1290 assert_eq!(calculate_columns(160), 5);
1291 assert_eq!(calculate_columns(200), 5);
1292 }
1293
1294 #[test]
1295 fn test_update_columns() {
1296 let mut app = create_test_app();
1297
1298 app.update_columns(100);
1299 assert_eq!(app.columns(), 3);
1300
1301 app.update_columns(50);
1302 assert_eq!(app.columns(), 1);
1303
1304 app.update_columns(160);
1305 assert_eq!(app.columns(), 5);
1306 }
1307
1308 #[test]
1309 fn test_calculate_column_width() {
1310 assert_eq!(calculate_column_width(100, 3), 32);
1311 assert_eq!(calculate_column_width(80, 2), 38);
1312 assert_eq!(calculate_column_width(60, 1), 56);
1313 assert_eq!(calculate_column_width(50, 0), 50); }
1315
1316 #[test]
1319 fn test_empty_scripts() {
1320 let scripts = Scripts::new();
1321 let config = Config::default();
1322 let history = History::new();
1323 let app = App::new(
1324 scripts,
1325 config,
1326 history,
1327 "empty-project".to_string(),
1328 PathBuf::from("/test/empty"),
1329 Runner::Npm,
1330 );
1331
1332 assert_eq!(app.visible_count(), 0);
1333 assert!(app.selected_script().is_none());
1334 }
1335
1336 #[test]
1337 fn test_navigation_with_empty_scripts() {
1338 let scripts = Scripts::new();
1339 let config = Config::default();
1340 let history = History::new();
1341 let mut app = App::new(
1342 scripts,
1343 config,
1344 history,
1345 "empty-project".to_string(),
1346 PathBuf::from("/test/empty"),
1347 Runner::Npm,
1348 );
1349
1350 app.move_up();
1352 app.move_down();
1353 app.move_left();
1354 app.move_right();
1355 app.move_to_first();
1356 app.move_to_last();
1357
1358 assert_eq!(app.selected_index(), 0);
1359 }
1360
1361 #[test]
1362 fn test_filter_no_matches() {
1363 let mut app = create_test_app();
1364
1365 app.set_filter("nonexistent_script_xyz".to_string());
1366 assert_eq!(app.visible_count(), 0);
1367 assert!(app.selected_script().is_none());
1368 }
1369
1370 #[test]
1371 fn test_navigation_last_row_partial() {
1372 let mut scripts = Scripts::new();
1374 for i in 0..7 {
1375 scripts.add(Script::new(format!("script{}", i), format!("cmd{}", i)));
1376 }
1377
1378 let config = Config::default();
1379 let history = History::new();
1380 let mut app = App::new(
1381 scripts,
1382 config,
1383 history,
1384 "test".to_string(),
1385 PathBuf::from("/test"),
1386 Runner::Npm,
1387 );
1388
1389 app.update_columns(100); app.set_sort_mode(SortMode::Alpha);
1391
1392 app.select_by_number(3);
1399 assert_eq!(app.selected_index(), 2);
1400
1401 app.move_down();
1403 assert_eq!(app.selected_index(), 5);
1404
1405 app.move_down();
1407 assert_eq!(app.selected_index(), 6);
1408 }
1409}