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#[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 groups: Vec<GroupEntry>,
24 selected_groups: HashSet<usize>,
26 selected: HashSet<usize>,
28 focused: usize,
29 },
30 ConfirmDialog {
31 message: String,
32 action: PendingAction,
33 },
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum WizardStep {
39 EnterName,
40 SelectGroups,
41 SelectRepos,
42 Confirm,
43}
44
45#[derive(Debug, Clone)]
47pub enum PendingAction {
48 TeardownWorkspace { name: String },
49}
50
51#[derive(Debug)]
53pub enum Message {
54 SelectNext,
56 SelectPrev,
57 Confirm,
58 Cancel,
59 Quit,
60
61 OpenDetail,
63 StartNewWizard,
64 RefreshList,
65
66 TeardownWorkspace,
68
69 WizardCharInput(char),
71 WizardBackspace,
72 WizardNextStep,
73 ToggleRepo(usize),
74
75 ConfirmYes,
77 ConfirmNo,
78
79 DismissStatus,
81}
82
83#[derive(Debug, Clone)]
85pub enum StatusLevel {
86 Info,
87 Error,
88}
89
90#[derive(Debug, Clone)]
92pub struct StatusMessage {
93 pub text: String,
94 pub level: StatusLevel,
95 pub created: Instant,
96}
97
98pub 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 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 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 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 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 }
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 }
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 => { }
308 }
309 }
310
311 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 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 Message::WizardCharInput(ch) => {
348 if let Screen::NewWizard {
349 step: WizardStep::EnterName,
350 name,
351 ..
352 } = &mut self.screen
353 {
354 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 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 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 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 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 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 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 |_| {}, ) {
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 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 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 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 self.screen = Screen::WorkspaceList;
677 self.refresh_workspaces();
678 }
679 }
680 }
681
682 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 pub fn filtered_repo_indices(
693 repos: &[RepoEntry],
694 selected_groups: &HashSet<usize>,
695 groups: &[GroupEntry],
696 ) -> Vec<usize> {
697 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 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 let by_org = selected_org_names.contains(r.org.as_str());
723 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 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 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 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 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 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]); let indices = App::filtered_repo_indices(&repos, &selected, &groups);
1009 assert_eq!(indices, vec![0, 1]); }
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]); let indices = App::filtered_repo_indices(&repos, &selected, &groups);
1030 assert_eq!(indices, vec![0, 2]); }
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]); let indices = App::filtered_repo_indices(&repos, &selected, &groups);
1051 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 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 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 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 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 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 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}