npm_run_scripts/tui/
app.rs

1//! Application state for the TUI.
2
3use 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
10/// Minimum column width for script items.
11const MIN_COLUMN_WIDTH: u16 = 28;
12
13/// Application mode/state.
14#[derive(Debug, Clone, PartialEq, Default)]
15pub enum AppMode {
16    /// Normal navigation mode.
17    #[default]
18    Normal,
19    /// Filter/search mode.
20    Filter { query: String },
21    /// Multi-select mode.
22    MultiSelect { selected: HashSet<usize> },
23    /// Help overlay.
24    Help,
25    /// Error display.
26    Error { message: String },
27    /// Arguments input mode.
28    Args { script_index: usize, input: String },
29    /// Workspace selection mode (for monorepos).
30    WorkspaceSelect,
31}
32
33/// Currently selected workspace context.
34#[derive(Debug, Clone, PartialEq)]
35pub enum WorkspaceContext {
36    /// Root scripts (no specific workspace selected).
37    Root,
38    /// A specific workspace is selected.
39    Workspace(usize),
40}
41
42/// Information about a script to run after TUI exits.
43#[derive(Debug, Clone)]
44pub struct ScriptRun {
45    /// The script to run.
46    pub script: Script,
47    /// Optional arguments to pass to the script.
48    pub args: Option<String>,
49    /// Workspace name if running from a specific workspace.
50    pub workspace: Option<String>,
51    /// Workspace path if running from a specific workspace.
52    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
71/// Main application state.
72pub struct App {
73    // Data
74    /// All available scripts.
75    scripts: Scripts,
76    /// Current configuration.
77    config: Config,
78    /// Execution history.
79    history: History,
80    /// Detected package manager.
81    runner: Runner,
82    /// Project name.
83    project_name: String,
84    /// Project path.
85    project_path: PathBuf,
86
87    // Workspace data
88    /// Whether this is a monorepo.
89    is_monorepo: bool,
90    /// Available workspaces (if monorepo).
91    workspaces: Vec<Workspace>,
92    /// Currently selected workspace context.
93    workspace_context: WorkspaceContext,
94    /// Selected workspace index (for workspace selector).
95    workspace_selected: usize,
96
97    // UI State
98    /// Current application mode.
99    mode: AppMode,
100    /// Currently selected script index (within visible_indices).
101    selected: usize,
102    /// Scroll offset for the scripts list.
103    scroll_offset: usize,
104    /// Current filter text.
105    filter_text: String,
106    /// Current sort mode.
107    sort_mode: SortMode,
108
109    // Computed (cached)
110    /// Indices of visible scripts (after filtering and sorting).
111    visible_indices: Vec<usize>,
112    /// Number of columns in the grid.
113    columns: usize,
114    /// Should the app quit.
115    should_quit: bool,
116    /// Script to run after exit.
117    script_to_run: Option<ScriptRun>,
118}
119
120impl App {
121    /// Create a new application.
122    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    /// Create a new application with workspace support.
142    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        // Start in workspace select mode if this is a monorepo with workspaces
156        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        // Initial sort based on default sort mode
185        app.update_visible_scripts();
186        app
187    }
188
189    // ==================== Getters ====================
190
191    /// Get the current mode.
192    pub fn mode(&self) -> &AppMode {
193        &self.mode
194    }
195
196    /// Check if the app should quit.
197    pub fn should_quit(&self) -> bool {
198        self.should_quit
199    }
200
201    /// Get the script to run after exit.
202    pub fn script_to_run(&self) -> Option<&ScriptRun> {
203        self.script_to_run.as_ref()
204    }
205
206    /// Get the project name.
207    pub fn project_name(&self) -> &str {
208        &self.project_name
209    }
210
211    /// Get the project path.
212    pub fn project_path(&self) -> &PathBuf {
213        &self.project_path
214    }
215
216    /// Get the runner.
217    pub fn runner(&self) -> Runner {
218        self.runner
219    }
220
221    /// Get all scripts.
222    pub fn scripts(&self) -> &Scripts {
223        &self.scripts
224    }
225
226    /// Get the current filter text.
227    pub fn filter_text(&self) -> &str {
228        &self.filter_text
229    }
230
231    /// Get the current sort mode.
232    pub fn sort_mode(&self) -> SortMode {
233        self.sort_mode
234    }
235
236    /// Get the number of columns.
237    pub fn columns(&self) -> usize {
238        self.columns
239    }
240
241    /// Get the selected index.
242    pub fn selected_index(&self) -> usize {
243        self.selected
244    }
245
246    /// Get the scroll offset.
247    pub fn scroll_offset(&self) -> usize {
248        self.scroll_offset
249    }
250
251    /// Get the number of visible scripts.
252    pub fn visible_count(&self) -> usize {
253        self.visible_indices.len()
254    }
255
256    /// Get the config.
257    pub fn config(&self) -> &Config {
258        &self.config
259    }
260
261    // ==================== Workspace Getters ====================
262
263    /// Check if this is a monorepo.
264    pub fn is_monorepo(&self) -> bool {
265        self.is_monorepo
266    }
267
268    /// Get the available workspaces.
269    pub fn workspaces(&self) -> &[Workspace] {
270        &self.workspaces
271    }
272
273    /// Get the current workspace context.
274    pub fn workspace_context(&self) -> &WorkspaceContext {
275        &self.workspace_context
276    }
277
278    /// Get the selected workspace index in the workspace selector.
279    pub fn workspace_selected(&self) -> usize {
280        self.workspace_selected
281    }
282
283    /// Get the currently selected workspace (if any).
284    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    /// Get the breadcrumb path for display.
292    /// Returns something like "monorepo > packages/web > scripts"
293    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    // ==================== Script Access ====================
307
308    /// Get visible scripts after filtering and sorting.
309    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    /// Get the currently selected script.
317    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    /// Get a visible script by its display index (0-based).
324    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    // ==================== Mode Management ====================
331
332    /// Set the application mode.
333    pub fn set_mode(&mut self, mode: AppMode) {
334        self.mode = mode;
335    }
336
337    /// Toggle filter mode.
338    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    /// Enter args input mode for the selected script.
355    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    /// Toggle multi-select mode.
365    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    /// Toggle help overlay.
380    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    // ==================== Workspace Management ====================
392
393    /// Enter workspace selection mode.
394    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    /// Exit workspace selection and go to scripts.
402    pub fn exit_workspace_select(&mut self) {
403        self.mode = AppMode::Normal;
404        self.selected = 0;
405        self.update_visible_scripts();
406    }
407
408    /// Select a workspace and show its scripts.
409    pub fn select_workspace(&mut self, index: usize) {
410        // Index 0 is "root", indices 1+ are workspaces
411        if index == 0 {
412            self.workspace_context = WorkspaceContext::Root;
413            // Keep root scripts
414        } else if let Some(workspace) = self.workspaces.get(index - 1) {
415            self.workspace_context = WorkspaceContext::Workspace(index - 1);
416            // Load workspace scripts
417            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    /// Select the currently highlighted workspace.
426    pub fn select_current_workspace(&mut self) {
427        self.select_workspace(self.workspace_selected);
428    }
429
430    /// Go back to workspace selection from script view.
431    pub fn back_to_workspace_select(&mut self) {
432        if self.is_monorepo {
433            self.mode = AppMode::WorkspaceSelect;
434        }
435    }
436
437    /// Move workspace selection up.
438    pub fn workspace_move_up(&mut self) {
439        if self.workspace_selected > 0 {
440            self.workspace_selected -= 1;
441        }
442    }
443
444    /// Move workspace selection down.
445    pub fn workspace_move_down(&mut self) {
446        // +1 for the "root" option
447        let max_index = self.workspaces.len();
448        if self.workspace_selected < max_index {
449            self.workspace_selected += 1;
450        }
451    }
452
453    /// Move workspace selection left (in grid).
454    pub fn workspace_move_left(&mut self) {
455        if self.workspace_selected > 0 {
456            self.workspace_selected -= 1;
457        }
458    }
459
460    /// Move workspace selection right (in grid).
461    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    /// Select workspace by number (1-9).
469    pub fn select_workspace_by_number(&mut self, num: usize) {
470        // num 1 = root (index 0), num 2 = first workspace (index 1), etc.
471        if num > 0 && num <= self.workspaces.len() + 1 {
472            self.select_workspace(num - 1);
473        }
474    }
475
476    /// Get the count of items in workspace selector (root + workspaces).
477    pub fn workspace_count(&self) -> usize {
478        self.workspaces.len() + 1 // +1 for root
479    }
480
481    // ==================== Filter Management ====================
482
483    /// Set the filter text.
484    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    /// Append a character to the filter text.
491    pub fn push_filter_char(&mut self, c: char) {
492        self.filter_text.push(c);
493        self.update_visible_scripts();
494    }
495
496    /// Remove the last character from the filter text.
497    pub fn pop_filter_char(&mut self) {
498        self.filter_text.pop();
499        self.update_visible_scripts();
500    }
501
502    /// Clear the filter text.
503    pub fn clear_filter(&mut self) {
504        self.filter_text.clear();
505        self.update_visible_scripts();
506    }
507
508    // ==================== Sort Management ====================
509
510    /// Cycle through sort modes: Recent -> Alphabetical -> Category -> Recent.
511    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    /// Set the sort mode.
521    pub fn set_sort_mode(&mut self, mode: SortMode) {
522        self.sort_mode = mode;
523        self.update_visible_scripts();
524    }
525
526    // ==================== Visibility Update ====================
527
528    /// Update the visible scripts based on current filter and sort mode.
529    pub fn update_visible_scripts(&mut self) {
530        // Step 1: Filter
531        let filtered_indices: Vec<usize> = if self.filter_text.is_empty() {
532            (0..self.scripts.len()).collect()
533        } else {
534            // Use the optimized filter_scripts that returns (index, score) pairs
535            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        // Step 2: Sort
545        self.visible_indices = self.sort_indices(filtered_indices);
546
547        // Adjust selection if needed
548        if self.selected >= self.visible_indices.len() {
549            self.selected = self.visible_indices.len().saturating_sub(1);
550        }
551    }
552
553    /// Sort indices based on current sort mode.
554    fn sort_indices(&self, mut indices: Vec<usize>) -> Vec<usize> {
555        match self.sort_mode {
556            SortMode::Recent => {
557                // Sort by history score (recent/frequent first)
558                // Clone scripts so we can pass them to get_sorted_by_recent
559                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                // Map back to indices
569                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                // Sort alphabetically by name
581                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                // Sort by category (prefix before colon) then alphabetically
590                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    // ==================== Column Management ====================
605
606    /// Update the number of columns based on terminal width.
607    pub fn update_columns(&mut self, width: u16) {
608        self.columns = calculate_columns(width);
609    }
610
611    // ==================== Navigation ====================
612
613    /// Calculate the number of rows based on visible items and columns.
614    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    /// Get the current row and column from the selected index.
623    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    /// Move selection up by one row.
630    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    /// Move selection down by one row.
645    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            // Try to go to last row, same column or last item
657            let last_row = self.row_count().saturating_sub(1);
658            if row < last_row {
659                // Go to last item if the target column doesn't exist in last row
660                self.selected = self.visible_indices.len().saturating_sub(1);
661            }
662        }
663    }
664
665    /// Move selection left by one column.
666    pub fn move_left(&mut self) {
667        if self.selected > 0 {
668            self.selected -= 1;
669        }
670    }
671
672    /// Move selection right by one column.
673    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    /// Move selection to the first item.
680    pub fn move_to_first(&mut self) {
681        self.selected = 0;
682    }
683
684    /// Move selection to the last item.
685    pub fn move_to_last(&mut self) {
686        self.selected = self.visible_indices.len().saturating_sub(1);
687    }
688
689    /// Select a script by number (1-9).
690    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    // ==================== Navigation Aliases (for compatibility) ====================
697
698    /// Move selection up (alias for move_up).
699    pub fn select_prev(&mut self) {
700        self.move_up();
701    }
702
703    /// Move selection down (alias for move_down).
704    pub fn select_next(&mut self) {
705        self.move_down();
706    }
707
708    /// Move to first item (alias for move_to_first).
709    pub fn select_first(&mut self) {
710        self.move_to_first();
711    }
712
713    /// Move to last item (alias for move_to_last).
714    pub fn select_last(&mut self) {
715        self.move_to_last();
716    }
717
718    // ==================== Actions ====================
719
720    /// Request the app to quit.
721    pub fn quit(&mut self) {
722        self.should_quit = true;
723    }
724
725    /// Run the currently selected script.
726    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    /// Get workspace info for script execution.
744    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    /// Run a script by number (1-9).
758    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    /// Run a script by number (alias for run_numbered, for compatibility).
768    pub fn run_by_number(&mut self, num: usize) {
769        self.run_numbered(num);
770    }
771
772    /// Run the selected script with arguments.
773    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    /// Toggle selection of current item in multi-select mode.
791    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    /// Get selected indices in multi-select mode.
802    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    /// Run all selected scripts in multi-select mode.
811    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            // Set first script to run, the rest would need to be handled by the caller
831            self.script_to_run = runs.first().cloned();
832            self.should_quit = true;
833        }
834
835        runs
836    }
837}
838
839/// Calculate grid columns based on terminal width.
840pub 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
854/// Calculate column width for the scripts grid.
855pub fn calculate_column_width(total_width: u16, columns: usize) -> u16 {
856    if columns == 0 {
857        return total_width;
858    }
859    let padding = 2; // Left and right padding
860    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    // ==================== Basic Tests ====================
897
898    #[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); // Ensure predictable order
919        let script = app.selected_script().unwrap();
920        // First alphabetically should be "build"
921        assert_eq!(script.name(), "build");
922    }
923
924    // ==================== Navigation Tests ====================
925
926    #[test]
927    fn test_move_left_right() {
928        let mut app = create_test_app();
929        app.update_columns(100); // 3 columns
930        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        // Should not go below 0
947        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); // 1 column
955        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        // Should not go above 0
972        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); // 3 columns
980        app.set_sort_mode(SortMode::Alpha);
981
982        // Grid layout (9 items, 3 columns):
983        // 0 1 2
984        // 3 4 5
985        // 6 7 8
986
987        assert_eq!(app.selected_index(), 0);
988
989        app.move_down(); // 0 -> 3
990        assert_eq!(app.selected_index(), 3);
991
992        app.move_down(); // 3 -> 6
993        assert_eq!(app.selected_index(), 6);
994
995        app.move_right(); // 6 -> 7
996        assert_eq!(app.selected_index(), 7);
997
998        app.move_up(); // 7 -> 4
999        assert_eq!(app.selected_index(), 4);
1000
1001        app.move_up(); // 4 -> 1
1002        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); // 9 items, 0-indexed
1012
1013        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); // 5 -> index 4
1024
1025        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        // Invalid numbers should not change selection
1032        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    // ==================== Filter Tests ====================
1040
1041    #[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        // Should match: build, build:prod, build:dev
1049        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        // Select last item
1059        app.move_to_last();
1060        assert_eq!(app.selected_index(), 8);
1061
1062        // Filter to fewer items
1063        app.set_filter("test".to_string());
1064
1065        // Selection should be adjusted to be within bounds
1066        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    // ==================== Sort Mode Tests ====================
1098
1099    #[test]
1100    fn test_cycle_sort_mode() {
1101        let mut app = create_test_app();
1102
1103        assert_eq!(app.sort_mode(), SortMode::Recent); // Default
1104
1105        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        // Should be alphabetically sorted
1124        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        // Scripts with "build" prefix should be together
1138        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        // Build scripts should be consecutive
1146        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    // ==================== Action Tests ====================
1154
1155    #[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"); // First alphabetically
1165        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        // Verify the run was successful
1178        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    // ==================== Mode Tests ====================
1206
1207    #[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        // Toggle off
1273        app.toggle_current_selection();
1274        let selected = app.multi_selected_indices().unwrap();
1275        assert!(!selected.contains(&1));
1276    }
1277
1278    // ==================== Column Tests ====================
1279
1280    #[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); // Edge case
1314    }
1315
1316    // ==================== Edge Case Tests ====================
1317
1318    #[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        // These should not panic
1351        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        // Test navigation when last row has fewer items than columns
1373        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); // 3 columns
1390        app.set_sort_mode(SortMode::Alpha);
1391
1392        // Grid layout (7 items, 3 columns):
1393        // 0 1 2
1394        // 3 4 5
1395        // 6
1396
1397        // Navigate to position 2 (top right)
1398        app.select_by_number(3);
1399        assert_eq!(app.selected_index(), 2);
1400
1401        // Move down should go to 5
1402        app.move_down();
1403        assert_eq!(app.selected_index(), 5);
1404
1405        // Move down again - should go to last item (6) since column 2 doesn't exist in row 2
1406        app.move_down();
1407        assert_eq!(app.selected_index(), 6);
1408    }
1409}