Skip to main content

loom_core/tui/
app.rs

1use std::collections::HashSet;
2use std::path::PathBuf;
3use std::time::Instant;
4
5use crate::config::Config;
6use crate::groups::GroupEntry;
7use crate::registry::RepoEntry;
8use crate::workspace::list::{WorkspaceHealth, WorkspaceSummary};
9
10/// Which screen the TUI is showing.
11#[derive(Debug, Clone)]
12pub enum Screen {
13    WorkspaceList,
14    WorkspaceDetail {
15        name: String,
16        path: PathBuf,
17    },
18    NewWizard {
19        step: WizardStep,
20        name: String,
21        available_repos: Vec<RepoEntry>,
22        /// Config groups and org groups for the selection step.
23        groups: Vec<GroupEntry>,
24        /// Indices into `groups` that are selected.
25        selected_groups: HashSet<usize>,
26        /// Indices into `available_repos` that are selected.
27        selected: HashSet<usize>,
28        focused: usize,
29    },
30    ConfirmDialog {
31        message: String,
32        action: PendingAction,
33    },
34}
35
36/// Steps in the new-workspace wizard.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum WizardStep {
39    EnterName,
40    SelectGroups,
41    SelectRepos,
42    Confirm,
43}
44
45/// Action pending confirmation.
46#[derive(Debug, Clone)]
47pub enum PendingAction {
48    TeardownWorkspace { name: String },
49}
50
51/// Messages that drive state transitions (TEA pattern).
52#[derive(Debug)]
53pub enum Message {
54    // Navigation
55    SelectNext,
56    SelectPrev,
57    Confirm,
58    Cancel,
59    Quit,
60
61    // Workspace list
62    OpenDetail,
63    StartNewWizard,
64    RefreshList,
65
66    // Workspace detail
67    TeardownWorkspace,
68
69    // Wizard
70    WizardCharInput(char),
71    WizardBackspace,
72    WizardNextStep,
73    ToggleRepo(usize),
74
75    // Confirm dialog
76    ConfirmYes,
77    ConfirmNo,
78
79    // Status
80    DismissStatus,
81}
82
83/// Severity of status bar messages.
84#[derive(Debug, Clone)]
85pub enum StatusLevel {
86    Info,
87    Error,
88}
89
90/// A status bar message with auto-dismiss.
91#[derive(Debug, Clone)]
92pub struct StatusMessage {
93    pub text: String,
94    pub level: StatusLevel,
95    pub created: Instant,
96}
97
98/// Top-level application state.
99pub struct App {
100    pub screen: Screen,
101    pub workspaces: Vec<WorkspaceSummary>,
102    pub selected: usize,
103    pub status: Option<StatusMessage>,
104    pub should_quit: bool,
105    pub config: Config,
106}
107
108impl App {
109    pub fn new(config: Config) -> Self {
110        Self {
111            screen: Screen::WorkspaceList,
112            workspaces: Vec::new(),
113            selected: 0,
114            status: None,
115            should_quit: false,
116            config,
117        }
118    }
119
120    /// Load workspace list from disk.
121    pub fn refresh_workspaces(&mut self) {
122        match crate::workspace::list::list_workspaces(&self.config) {
123            Ok(ws) => self.workspaces = ws,
124            Err(e) => self.set_status(
125                format!("Failed to load workspaces: {e}"),
126                StatusLevel::Error,
127            ),
128        }
129    }
130
131    fn set_status(&mut self, text: String, level: StatusLevel) {
132        self.status = Some(StatusMessage {
133            text,
134            level,
135            created: Instant::now(),
136        });
137    }
138
139    /// Auto-dismiss status messages after 5 seconds.
140    pub fn tick(&mut self) {
141        if let Some(ref status) = self.status
142            && status.created.elapsed().as_secs() >= 5
143        {
144            self.status = None;
145        }
146    }
147
148    /// Process a message and update state (TEA update function).
149    pub fn update(&mut self, msg: Message) {
150        match msg {
151            Message::Quit => {
152                self.should_quit = true;
153            }
154            Message::DismissStatus => {
155                self.status = None;
156            }
157            Message::RefreshList => {
158                self.refresh_workspaces();
159            }
160
161            // --- Workspace List ---
162            Message::SelectNext => match &mut self.screen {
163                Screen::WorkspaceList if !self.workspaces.is_empty() => {
164                    self.selected = (self.selected + 1) % self.workspaces.len();
165                }
166                Screen::WorkspaceDetail { .. } => {
167                    // Could scroll repo list in future
168                }
169                Screen::NewWizard {
170                    step: WizardStep::SelectGroups,
171                    groups,
172                    focused,
173                    ..
174                } if !groups.is_empty() => {
175                    *focused = (*focused + 1) % groups.len();
176                }
177                Screen::NewWizard {
178                    step: WizardStep::SelectRepos,
179                    available_repos,
180                    selected_groups,
181                    groups,
182                    focused,
183                    ..
184                } => {
185                    let visible_count =
186                        Self::filtered_repo_count(available_repos, selected_groups, groups);
187                    if visible_count > 0 {
188                        *focused = (*focused + 1) % visible_count;
189                    }
190                }
191                _ => {}
192            },
193            Message::SelectPrev => match &mut self.screen {
194                Screen::WorkspaceList if !self.workspaces.is_empty() => {
195                    self.selected = self
196                        .selected
197                        .checked_sub(1)
198                        .unwrap_or(self.workspaces.len() - 1);
199                }
200                Screen::NewWizard {
201                    step: WizardStep::SelectGroups,
202                    groups,
203                    focused,
204                    ..
205                } if !groups.is_empty() => {
206                    *focused = focused.checked_sub(1).unwrap_or(groups.len() - 1);
207                }
208                Screen::NewWizard {
209                    step: WizardStep::SelectRepos,
210                    available_repos,
211                    selected_groups,
212                    groups,
213                    focused,
214                    ..
215                } => {
216                    let visible_count =
217                        Self::filtered_repo_count(available_repos, selected_groups, groups);
218                    if visible_count > 0 {
219                        *focused = focused.checked_sub(1).unwrap_or(visible_count - 1);
220                    }
221                }
222                _ => {}
223            },
224            Message::OpenDetail | Message::Confirm => match &self.screen {
225                Screen::WorkspaceList => {
226                    if let Some(ws) = self.workspaces.get(self.selected) {
227                        self.screen = Screen::WorkspaceDetail {
228                            name: ws.name.clone(),
229                            path: ws.path.clone(),
230                        };
231                    }
232                }
233                Screen::ConfirmDialog { .. } => {
234                    self.update(Message::ConfirmYes);
235                }
236                _ => {}
237            },
238            Message::Cancel => {
239                let screen = std::mem::replace(&mut self.screen, Screen::WorkspaceList);
240                match screen {
241                    Screen::WorkspaceDetail { .. } => {
242                        self.refresh_workspaces();
243                    }
244                    Screen::NewWizard {
245                        step: WizardStep::EnterName,
246                        ..
247                    } => {
248                        // Already replaced with WorkspaceList
249                    }
250                    Screen::NewWizard {
251                        step: WizardStep::SelectGroups,
252                        name,
253                        available_repos,
254                        groups,
255                        ..
256                    } => {
257                        self.screen = Screen::NewWizard {
258                            step: WizardStep::EnterName,
259                            name,
260                            available_repos,
261                            groups,
262                            selected_groups: HashSet::new(),
263                            selected: HashSet::new(),
264                            focused: 0,
265                        };
266                    }
267                    Screen::NewWizard {
268                        step: WizardStep::SelectRepos,
269                        name,
270                        available_repos,
271                        groups,
272                        selected_groups,
273                        ..
274                    } => {
275                        self.screen = Screen::NewWizard {
276                            step: WizardStep::SelectGroups,
277                            name,
278                            available_repos,
279                            groups,
280                            selected_groups,
281                            selected: HashSet::new(),
282                            focused: 0,
283                        };
284                    }
285                    Screen::NewWizard {
286                        step: WizardStep::Confirm,
287                        name,
288                        available_repos,
289                        groups,
290                        selected_groups,
291                        selected,
292                        focused,
293                    } => {
294                        self.screen = Screen::NewWizard {
295                            step: WizardStep::SelectRepos,
296                            name,
297                            available_repos,
298                            groups,
299                            selected_groups,
300                            selected,
301                            focused,
302                        };
303                    }
304                    Screen::ConfirmDialog { .. } => {
305                        self.refresh_workspaces();
306                    }
307                    Screen::WorkspaceList => { /* already home */ }
308                }
309            }
310
311            // --- Start new workspace wizard ---
312            Message::StartNewWizard => {
313                let repos = crate::registry::discover_repos(
314                    &self.config.registry.scan_roots,
315                    Some(&self.config.workspace.root),
316                    self.config.registry.scan_depth,
317                );
318
319                // Build combined group list: config groups + org groups.
320                // Config groups appear in BTreeMap (alphabetical) order, not
321                // config.toml source order — TOML key order is not preserved.
322                let mut groups: Vec<GroupEntry> = Vec::new();
323                for (name, repo_names) in &self.config.groups {
324                    groups.push(GroupEntry::ConfigGroup {
325                        name: name.clone(),
326                        repo_names: repo_names.clone(),
327                    });
328                }
329                let mut orgs: Vec<String> = repos.iter().map(|r| r.org.clone()).collect();
330                orgs.dedup();
331                for org in orgs {
332                    groups.push(GroupEntry::OrgGroup { name: org });
333                }
334
335                self.screen = Screen::NewWizard {
336                    step: WizardStep::EnterName,
337                    name: String::new(),
338                    available_repos: repos,
339                    groups,
340                    selected_groups: HashSet::new(),
341                    selected: HashSet::new(),
342                    focused: 0,
343                };
344            }
345
346            // --- Wizard input ---
347            Message::WizardCharInput(ch) => {
348                if let Screen::NewWizard {
349                    step: WizardStep::EnterName,
350                    name,
351                    ..
352                } = &mut self.screen
353                {
354                    // Only allow valid workspace name chars
355                    if ch.is_ascii_alphanumeric() || ch == '-' {
356                        name.push(ch);
357                    }
358                }
359            }
360            Message::WizardBackspace => {
361                if let Screen::NewWizard {
362                    step: WizardStep::EnterName,
363                    name,
364                    ..
365                } = &mut self.screen
366                {
367                    name.pop();
368                }
369            }
370            Message::WizardNextStep => {
371                let screen = std::mem::replace(&mut self.screen, Screen::WorkspaceList);
372                match screen {
373                    Screen::NewWizard {
374                        step: WizardStep::EnterName,
375                        name,
376                        available_repos,
377                        groups,
378                        selected_groups,
379                        selected,
380                        focused,
381                    } => {
382                        // Generate random name if empty
383                        let name = if name.is_empty() {
384                            match crate::names::generate_unique_workspace_name(
385                                &self.config.workspace.root,
386                                crate::names::MAX_NAME_RETRIES,
387                            ) {
388                                Ok(generated) => generated,
389                                Err(e) => {
390                                    self.set_status(
391                                        format!("Failed to generate name: {e}"),
392                                        StatusLevel::Error,
393                                    );
394                                    // Restore wizard with the original (empty) name so the user can retry
395                                    self.screen = Screen::NewWizard {
396                                        step: WizardStep::EnterName,
397                                        name,
398                                        available_repos,
399                                        groups,
400                                        selected_groups,
401                                        selected,
402                                        focused,
403                                    };
404                                    return;
405                                }
406                            }
407                        } else {
408                            name
409                        };
410
411                        let has_config_groups = groups
412                            .iter()
413                            .any(|g| matches!(g, GroupEntry::ConfigGroup { .. }));
414                        let org_count = groups
415                            .iter()
416                            .filter(|g| matches!(g, GroupEntry::OrgGroup { .. }))
417                            .count();
418
419                        if groups.is_empty() {
420                            // No orgs discovered — cannot proceed
421                            self.set_status(
422                                "No repositories found. Check scan_roots in config.".to_string(),
423                                StatusLevel::Error,
424                            );
425                            self.screen = Screen::NewWizard {
426                                step: WizardStep::EnterName,
427                                name,
428                                available_repos,
429                                groups,
430                                selected_groups: HashSet::new(),
431                                selected,
432                                focused: 0,
433                            };
434                        } else if !has_config_groups && org_count <= 1 {
435                            // No config groups and at most one org — skip group selection
436                            let all_selected: HashSet<usize> = (0..groups.len()).collect();
437                            self.screen = Screen::NewWizard {
438                                step: WizardStep::SelectRepos,
439                                name,
440                                available_repos,
441                                groups,
442                                selected_groups: all_selected,
443                                selected,
444                                focused: 0,
445                            };
446                        } else {
447                            self.screen = Screen::NewWizard {
448                                step: WizardStep::SelectGroups,
449                                name,
450                                available_repos,
451                                groups,
452                                selected_groups,
453                                selected,
454                                focused: 0,
455                            };
456                        }
457                    }
458                    Screen::NewWizard {
459                        step: WizardStep::SelectGroups,
460                        name,
461                        available_repos,
462                        groups,
463                        selected_groups,
464                        ..
465                    } => {
466                        if selected_groups.is_empty() {
467                            self.set_status(
468                                "Select at least one group".to_string(),
469                                StatusLevel::Error,
470                            );
471                            self.screen = Screen::NewWizard {
472                                step: WizardStep::SelectGroups,
473                                name,
474                                available_repos,
475                                groups,
476                                selected_groups,
477                                selected: HashSet::new(),
478                                focused: 0,
479                            };
480                        } else {
481                            // Pre-select repos from selected config groups
482                            let filtered = Self::filtered_repo_indices(
483                                &available_repos,
484                                &selected_groups,
485                                &groups,
486                            );
487                            let mut pre_selected = HashSet::new();
488                            let mut warnings: Vec<String> = Vec::new();
489                            for &gi in &selected_groups {
490                                if let Some(GroupEntry::ConfigGroup {
491                                    name: gname,
492                                    repo_names,
493                                }) = groups.get(gi)
494                                {
495                                    let mut matched_count = 0;
496                                    for rn in repo_names {
497                                        if let Some(pos) = filtered.iter().position(|&ri| {
498                                            let r = &available_repos[ri];
499                                            r.matches_name(rn)
500                                        }) {
501                                            pre_selected.insert(filtered[pos]);
502                                            matched_count += 1;
503                                        }
504                                    }
505                                    if matched_count < repo_names.len() {
506                                        warnings.push(format!(
507                                            "Group '{}' matched {} of {} repos",
508                                            gname,
509                                            matched_count,
510                                            repo_names.len()
511                                        ));
512                                    }
513                                }
514                            }
515                            if !warnings.is_empty() {
516                                self.set_status(warnings.join("; "), StatusLevel::Info);
517                            }
518                            self.screen = Screen::NewWizard {
519                                step: WizardStep::SelectRepos,
520                                name,
521                                available_repos,
522                                groups,
523                                selected_groups,
524                                selected: pre_selected,
525                                focused: 0,
526                            };
527                        }
528                    }
529                    Screen::NewWizard {
530                        step: WizardStep::SelectRepos,
531                        name,
532                        available_repos,
533                        groups,
534                        selected_groups,
535                        selected,
536                        focused,
537                    } => {
538                        if selected.is_empty() {
539                            self.set_status(
540                                "Select at least one repo".to_string(),
541                                StatusLevel::Error,
542                            );
543                            self.screen = Screen::NewWizard {
544                                step: WizardStep::SelectRepos,
545                                name,
546                                available_repos,
547                                groups,
548                                selected_groups,
549                                selected,
550                                focused,
551                            };
552                        } else {
553                            self.screen = Screen::NewWizard {
554                                step: WizardStep::Confirm,
555                                name,
556                                available_repos,
557                                groups,
558                                selected_groups,
559                                selected,
560                                focused,
561                            };
562                        }
563                    }
564                    Screen::NewWizard {
565                        step: WizardStep::Confirm,
566                        name,
567                        available_repos,
568                        selected,
569                        ..
570                    } => {
571                        // Execute workspace creation
572                        let repos: Vec<RepoEntry> = selected
573                            .iter()
574                            .filter_map(|&i| available_repos.get(i).cloned())
575                            .collect();
576                        match crate::workspace::new::create_workspace(
577                            &self.config,
578                            crate::workspace::new::NewWorkspaceOpts {
579                                name: name.clone(),
580                                branch: None,
581                                random_branch: false,
582                                repos,
583                                base_branch: None,
584                                preset: None,
585                            },
586                            |_| {}, // TUI doesn't show progress bar
587                        ) {
588                            Ok(result) => {
589                                self.set_status(
590                                    format!(
591                                        "Created '{}' with {} repo(s)",
592                                        result.name, result.repos_added
593                                    ),
594                                    StatusLevel::Info,
595                                );
596                                self.screen = Screen::WorkspaceList;
597                                self.refresh_workspaces();
598                            }
599                            Err(e) => {
600                                self.set_status(
601                                    format!("Failed to create workspace: {e}"),
602                                    StatusLevel::Error,
603                                );
604                                self.screen = Screen::WorkspaceList;
605                            }
606                        }
607                    }
608                    other => {
609                        self.screen = other;
610                    }
611                }
612            }
613            Message::ToggleRepo(idx) => {
614                match &mut self.screen {
615                    Screen::NewWizard {
616                        step: WizardStep::SelectGroups,
617                        selected_groups,
618                        ..
619                    } => {
620                        if selected_groups.contains(&idx) {
621                            selected_groups.remove(&idx);
622                        } else {
623                            selected_groups.insert(idx);
624                        }
625                    }
626                    // Only applies to SelectRepos — SelectGroups uses Enter-to-select
627                    Screen::NewWizard {
628                        step: WizardStep::SelectRepos,
629                        available_repos,
630                        groups,
631                        selected_groups,
632                        selected,
633                        ..
634                    } => {
635                        let visible =
636                            Self::filtered_repo_indices(available_repos, selected_groups, groups);
637                        if let Some(&repo_idx) = visible.get(idx) {
638                            if selected.contains(&repo_idx) {
639                                selected.remove(&repo_idx);
640                            } else {
641                                selected.insert(repo_idx);
642                            }
643                        }
644                    }
645                    _ => {}
646                }
647            }
648
649            // --- Workspace detail actions ---
650            Message::TeardownWorkspace => {
651                if let Screen::WorkspaceDetail { name, .. } = &self.screen {
652                    let ws_name = name.clone();
653                    self.screen = Screen::ConfirmDialog {
654                        message: format!(
655                            "Tear down workspace '{ws_name}'? This removes all worktrees."
656                        ),
657                        action: PendingAction::TeardownWorkspace { name: ws_name },
658                    };
659                }
660            }
661
662            // --- Confirm dialog ---
663            Message::ConfirmYes => {
664                let screen = std::mem::replace(&mut self.screen, Screen::WorkspaceList);
665                if let Screen::ConfirmDialog { action, .. } = screen {
666                    match action {
667                        PendingAction::TeardownWorkspace { name } => {
668                            self.execute_teardown(&name);
669                        }
670                    }
671                }
672                self.refresh_workspaces();
673            }
674            Message::ConfirmNo => {
675                // Go back to where we came from
676                self.screen = Screen::WorkspaceList;
677                self.refresh_workspaces();
678            }
679        }
680    }
681
682    /// Count repos visible after group filtering.
683    fn filtered_repo_count(
684        repos: &[RepoEntry],
685        selected_groups: &HashSet<usize>,
686        groups: &[GroupEntry],
687    ) -> usize {
688        Self::filtered_repo_indices(repos, selected_groups, groups).len()
689    }
690
691    /// Get the original indices of repos that belong to selected groups.
692    pub fn filtered_repo_indices(
693        repos: &[RepoEntry],
694        selected_groups: &HashSet<usize>,
695        groups: &[GroupEntry],
696    ) -> Vec<usize> {
697        // Collect selected org names from OrgGroup entries
698        let selected_org_names: HashSet<&str> = selected_groups
699            .iter()
700            .filter_map(|&i| match groups.get(i) {
701                Some(GroupEntry::OrgGroup { name }) => Some(name.as_str()),
702                _ => None,
703            })
704            .collect();
705
706        // Collect repo names from selected ConfigGroup entries
707        let config_repo_names: HashSet<&str> = selected_groups
708            .iter()
709            .filter_map(|&i| match groups.get(i) {
710                Some(GroupEntry::ConfigGroup { repo_names, .. }) => Some(repo_names),
711                _ => None,
712            })
713            .flat_map(|names| names.iter().map(|s| s.as_str()))
714            .collect();
715
716        let mut seen = HashSet::new();
717        repos
718            .iter()
719            .enumerate()
720            .filter(|(_, r)| {
721                // Match by org group
722                let by_org = selected_org_names.contains(r.org.as_str());
723                // Match by config group repo names (bare name or org/name)
724                let by_config = config_repo_names.contains(r.name.as_str())
725                    || config_repo_names.contains(r.display_name().as_str());
726                by_org || by_config
727            })
728            .filter(|(i, _)| seen.insert(*i))
729            .map(|(i, _)| i)
730            .collect()
731    }
732
733    fn execute_teardown(&mut self, name: &str) {
734        let cwd = match std::env::current_dir() {
735            Ok(c) => c,
736            Err(e) => {
737                self.set_status(format!("Cannot get cwd: {e}"), StatusLevel::Error);
738                return;
739            }
740        };
741
742        let (ws_path, mut manifest) =
743            match crate::workspace::resolve_workspace(Some(name), &cwd, &self.config) {
744                Ok(r) => r,
745                Err(e) => {
746                    self.set_status(format!("Cannot find workspace: {e}"), StatusLevel::Error);
747                    return;
748                }
749            };
750
751        let all_repos: Vec<String> = manifest.repos.iter().map(|r| r.name.clone()).collect();
752        match crate::workspace::down::teardown_workspace(
753            &self.config,
754            &ws_path,
755            &mut manifest,
756            &all_repos,
757            false,
758        ) {
759            Ok(result) => {
760                let msg = format!(
761                    "Torn down '{}': {} removed, {} failed",
762                    name,
763                    result.removed.len(),
764                    result.failed.len()
765                );
766                self.set_status(msg, StatusLevel::Info);
767            }
768            Err(e) => {
769                self.set_status(format!("Teardown failed: {e}"), StatusLevel::Error);
770            }
771        }
772    }
773
774    /// Get detail info for the currently viewed workspace.
775    pub fn workspace_detail_status(&self) -> Option<crate::workspace::status::WorkspaceStatus> {
776        if let Screen::WorkspaceDetail { name, .. } = &self.screen {
777            let cwd = std::env::current_dir().ok()?;
778            let (ws_path, manifest) =
779                crate::workspace::resolve_workspace(Some(name), &cwd, &self.config).ok()?;
780            crate::workspace::status::workspace_status(&manifest, &ws_path, false).ok()
781        } else {
782            None
783        }
784    }
785
786    /// Color for workspace health status.
787    pub fn health_color(health: &WorkspaceHealth) -> ratatui::style::Color {
788        use ratatui::style::Color;
789        match health {
790            WorkspaceHealth::Clean => Color::Green,
791            WorkspaceHealth::Dirty(_) => Color::Yellow,
792            WorkspaceHealth::Broken(_) => Color::Red,
793        }
794    }
795}
796
797#[cfg(test)]
798mod tests {
799    use super::*;
800    use crate::config::{
801        AgentsConfig, DefaultsConfig, RegistryConfig, UpdateConfig, WorkspaceConfig,
802    };
803    use std::collections::BTreeMap;
804    use std::path::PathBuf;
805
806    fn test_config(dir: &std::path::Path) -> Config {
807        let ws_root = dir.join("loom");
808        std::fs::create_dir_all(ws_root.join(".loom")).unwrap();
809        Config {
810            registry: RegistryConfig {
811                scan_roots: vec![],
812                scan_depth: 2,
813            },
814            workspace: WorkspaceConfig { root: ws_root },
815            sync: None,
816            terminal: None,
817            editor: None,
818            defaults: DefaultsConfig::default(),
819            groups: BTreeMap::new(),
820            repos: BTreeMap::new(),
821            specs: None,
822            agents: AgentsConfig::default(),
823            update: UpdateConfig::default(),
824        }
825    }
826
827    #[test]
828    fn test_app_initial_state() {
829        let dir = tempfile::tempdir().unwrap();
830        let config = test_config(dir.path());
831        let app = App::new(config);
832
833        assert!(!app.should_quit);
834        assert!(app.workspaces.is_empty());
835        assert_eq!(app.selected, 0);
836        assert!(matches!(app.screen, Screen::WorkspaceList));
837    }
838
839    #[test]
840    fn test_quit_message() {
841        let dir = tempfile::tempdir().unwrap();
842        let config = test_config(dir.path());
843        let mut app = App::new(config);
844
845        app.update(Message::Quit);
846        assert!(app.should_quit);
847    }
848
849    #[test]
850    fn test_select_navigation_empty() {
851        let dir = tempfile::tempdir().unwrap();
852        let config = test_config(dir.path());
853        let mut app = App::new(config);
854
855        // Should not panic on empty list
856        app.update(Message::SelectNext);
857        assert_eq!(app.selected, 0);
858        app.update(Message::SelectPrev);
859        assert_eq!(app.selected, 0);
860    }
861
862    #[test]
863    fn test_status_dismiss() {
864        let dir = tempfile::tempdir().unwrap();
865        let config = test_config(dir.path());
866        let mut app = App::new(config);
867
868        app.set_status("test".to_string(), StatusLevel::Info);
869        assert!(app.status.is_some());
870
871        app.update(Message::DismissStatus);
872        assert!(app.status.is_none());
873    }
874
875    #[test]
876    fn test_wizard_name_input() {
877        let dir = tempfile::tempdir().unwrap();
878        let config = test_config(dir.path());
879        let mut app = App::new(config);
880
881        app.screen = Screen::NewWizard {
882            step: WizardStep::EnterName,
883            name: String::new(),
884            available_repos: vec![],
885            groups: vec![],
886            selected_groups: HashSet::new(),
887            selected: HashSet::new(),
888            focused: 0,
889        };
890
891        app.update(Message::WizardCharInput('m'));
892        app.update(Message::WizardCharInput('y'));
893        app.update(Message::WizardCharInput('-'));
894        app.update(Message::WizardCharInput('w'));
895        app.update(Message::WizardCharInput('s'));
896
897        if let Screen::NewWizard { name, .. } = &app.screen {
898            assert_eq!(name, "my-ws");
899        } else {
900            panic!("Expected NewWizard screen");
901        }
902
903        app.update(Message::WizardBackspace);
904        if let Screen::NewWizard { name, .. } = &app.screen {
905            assert_eq!(name, "my-w");
906        } else {
907            panic!("Expected NewWizard screen");
908        }
909    }
910
911    #[test]
912    fn test_wizard_generates_random_name_when_empty() {
913        let dir = tempfile::tempdir().unwrap();
914        let config = test_config(dir.path());
915        let mut app = App::new(config);
916
917        app.screen = Screen::NewWizard {
918            step: WizardStep::EnterName,
919            name: String::new(),
920            available_repos: vec![],
921            groups: vec![GroupEntry::OrgGroup {
922                name: "test-org".to_string(),
923            }],
924            selected_groups: HashSet::new(),
925            selected: HashSet::new(),
926            focused: 0,
927        };
928
929        app.update(Message::WizardNextStep);
930
931        // With one org group, should auto-skip to SelectRepos with a generated name
932        if let Screen::NewWizard { name, step, .. } = &app.screen {
933            assert!(!name.is_empty(), "Name should be generated");
934            assert_eq!(name.split('-').count(), 3, "Name should be adj-mod-noun");
935            assert_eq!(*step, WizardStep::SelectRepos);
936        } else {
937            panic!("Expected NewWizard screen");
938        }
939    }
940
941    #[test]
942    fn test_wizard_empty_groups_shows_error() {
943        let dir = tempfile::tempdir().unwrap();
944        let config = test_config(dir.path());
945        let mut app = App::new(config);
946
947        app.screen = Screen::NewWizard {
948            step: WizardStep::EnterName,
949            name: "test-ws".to_string(),
950            available_repos: vec![],
951            groups: vec![],
952            selected_groups: HashSet::new(),
953            selected: HashSet::new(),
954            focused: 0,
955        };
956
957        app.update(Message::WizardNextStep);
958
959        // With no groups, should stay on EnterName and show error
960        if let Screen::NewWizard { step, .. } = &app.screen {
961            assert_eq!(*step, WizardStep::EnterName);
962        } else {
963            panic!("Expected NewWizard screen");
964        }
965        assert!(app.status.is_some(), "Should show error status");
966    }
967
968    #[test]
969    fn test_cancel_from_detail_returns_to_list() {
970        let dir = tempfile::tempdir().unwrap();
971        let config = test_config(dir.path());
972        let mut app = App::new(config);
973
974        app.screen = Screen::WorkspaceDetail {
975            name: "test".to_string(),
976            path: PathBuf::from("/tmp/test"),
977        };
978
979        app.update(Message::Cancel);
980        assert!(matches!(app.screen, Screen::WorkspaceList));
981    }
982
983    fn make_repo(name: &str, org: &str) -> RepoEntry {
984        RepoEntry {
985            name: name.to_string(),
986            org: org.to_string(),
987            path: PathBuf::from(format!("/code/{}/{}", org, name)),
988            remote_url: None,
989        }
990    }
991
992    #[test]
993    fn test_filtered_repo_indices_org_groups() {
994        let repos = vec![
995            make_repo("api", "dasch"),
996            make_repo("das", "dasch"),
997            make_repo("tools", "acme"),
998        ];
999        let groups = vec![
1000            GroupEntry::OrgGroup {
1001                name: "dasch".to_string(),
1002            },
1003            GroupEntry::OrgGroup {
1004                name: "acme".to_string(),
1005            },
1006        ];
1007        let selected = HashSet::from([0]); // select "dasch"
1008        let indices = App::filtered_repo_indices(&repos, &selected, &groups);
1009        assert_eq!(indices, vec![0, 1]); // api and das
1010    }
1011
1012    #[test]
1013    fn test_filtered_repo_indices_config_groups() {
1014        let repos = vec![
1015            make_repo("api", "dasch"),
1016            make_repo("das", "dasch"),
1017            make_repo("sipi", "dasch"),
1018        ];
1019        let groups = vec![
1020            GroupEntry::ConfigGroup {
1021                name: "stack".to_string(),
1022                repo_names: vec!["api".to_string(), "sipi".to_string()],
1023            },
1024            GroupEntry::OrgGroup {
1025                name: "dasch".to_string(),
1026            },
1027        ];
1028        let selected = HashSet::from([0]); // select config group "stack"
1029        let indices = App::filtered_repo_indices(&repos, &selected, &groups);
1030        assert_eq!(indices, vec![0, 2]); // api and sipi
1031    }
1032
1033    #[test]
1034    fn test_filtered_repo_indices_mixed_groups() {
1035        let repos = vec![
1036            make_repo("api", "dasch"),
1037            make_repo("das", "dasch"),
1038            make_repo("tools", "acme"),
1039        ];
1040        let groups = vec![
1041            GroupEntry::ConfigGroup {
1042                name: "stack".to_string(),
1043                repo_names: vec!["api".to_string(), "tools".to_string()],
1044            },
1045            GroupEntry::OrgGroup {
1046                name: "dasch".to_string(),
1047            },
1048        ];
1049        let selected = HashSet::from([0, 1]); // select both
1050        let indices = App::filtered_repo_indices(&repos, &selected, &groups);
1051        // api (config+org), das (org), tools (config) — deduplicated
1052        assert_eq!(indices, vec![0, 1, 2]);
1053    }
1054
1055    #[test]
1056    fn test_skip_logic_with_config_groups() {
1057        let dir = tempfile::tempdir().unwrap();
1058        let mut config = test_config(dir.path());
1059        config
1060            .groups
1061            .insert("my-stack".to_string(), vec!["api".to_string()]);
1062        let mut app = App::new(config);
1063
1064        // Set up wizard with 1 config group + 1 org group
1065        app.screen = Screen::NewWizard {
1066            step: WizardStep::EnterName,
1067            name: "test-ws".to_string(),
1068            available_repos: vec![make_repo("api", "dasch")],
1069            groups: vec![
1070                GroupEntry::ConfigGroup {
1071                    name: "my-stack".to_string(),
1072                    repo_names: vec!["api".to_string()],
1073                },
1074                GroupEntry::OrgGroup {
1075                    name: "dasch".to_string(),
1076                },
1077            ],
1078            selected_groups: HashSet::new(),
1079            selected: HashSet::new(),
1080            focused: 0,
1081        };
1082
1083        app.update(Message::WizardNextStep);
1084
1085        // Should NOT skip SelectGroups because config groups exist
1086        if let Screen::NewWizard { step, .. } = &app.screen {
1087            assert_eq!(*step, WizardStep::SelectGroups);
1088        } else {
1089            panic!("Expected NewWizard screen");
1090        }
1091    }
1092
1093    #[test]
1094    fn test_skip_logic_without_config_groups_single_org() {
1095        let dir = tempfile::tempdir().unwrap();
1096        let config = test_config(dir.path());
1097        let mut app = App::new(config);
1098
1099        // Set up wizard with 1 org group only
1100        app.screen = Screen::NewWizard {
1101            step: WizardStep::EnterName,
1102            name: "test-ws".to_string(),
1103            available_repos: vec![make_repo("api", "dasch")],
1104            groups: vec![GroupEntry::OrgGroup {
1105                name: "dasch".to_string(),
1106            }],
1107            selected_groups: HashSet::new(),
1108            selected: HashSet::new(),
1109            focused: 0,
1110        };
1111
1112        app.update(Message::WizardNextStep);
1113
1114        // Should skip to SelectRepos (no config groups, single org)
1115        if let Screen::NewWizard { step, .. } = &app.screen {
1116            assert_eq!(*step, WizardStep::SelectRepos);
1117        } else {
1118            panic!("Expected NewWizard screen");
1119        }
1120    }
1121
1122    #[test]
1123    fn test_config_group_preselects_repos() {
1124        let dir = tempfile::tempdir().unwrap();
1125        let config = test_config(dir.path());
1126        let mut app = App::new(config);
1127
1128        let repos = vec![
1129            make_repo("api", "dasch"),
1130            make_repo("das", "dasch"),
1131            make_repo("sipi", "dasch"),
1132        ];
1133        let groups = vec![
1134            GroupEntry::ConfigGroup {
1135                name: "stack".to_string(),
1136                repo_names: vec!["api".to_string(), "sipi".to_string()],
1137            },
1138            GroupEntry::OrgGroup {
1139                name: "dasch".to_string(),
1140            },
1141        ];
1142
1143        // Start at SelectGroups with both groups selected
1144        app.screen = Screen::NewWizard {
1145            step: WizardStep::SelectGroups,
1146            name: "test-ws".to_string(),
1147            available_repos: repos,
1148            groups,
1149            selected_groups: HashSet::from([0, 1]),
1150            selected: HashSet::new(),
1151            focused: 0,
1152        };
1153
1154        app.update(Message::WizardNextStep);
1155
1156        // Should advance to SelectRepos with api(0) and sipi(2) pre-selected
1157        if let Screen::NewWizard { step, selected, .. } = &app.screen {
1158            assert_eq!(*step, WizardStep::SelectRepos);
1159            assert!(selected.contains(&0), "api should be pre-selected");
1160            assert!(!selected.contains(&1), "das should NOT be pre-selected");
1161            assert!(selected.contains(&2), "sipi should be pre-selected");
1162        } else {
1163            panic!("Expected NewWizard screen");
1164        }
1165    }
1166}