Skip to main content

git_same/setup/
handler.rs

1//! Setup wizard event handling.
2
3use super::state::{
4    tilde_collapse, AuthStatus, OrgEntry, PathBrowseEntry, SetupOutcome, SetupState, SetupStep,
5};
6use crate::auth::{get_auth_for_provider, gh_cli};
7use crate::config::{WorkspaceConfig, WorkspaceManager};
8use crate::provider::create_provider;
9use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
10
11/// Handle a key event in the setup wizard.
12///
13/// Returns true if the event triggered an async operation that should be awaited.
14pub async fn handle_key(state: &mut SetupState, key: KeyEvent) {
15    // Global quit shortcuts
16    if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
17        state.outcome = Some(SetupOutcome::Cancelled);
18        state.should_quit = true;
19        return;
20    }
21    let path_popup_active = state.step == SetupStep::SelectPath && state.path_browse_mode;
22    if path_popup_active && key.modifiers == KeyModifiers::NONE {
23        match key.code {
24            KeyCode::Up
25            | KeyCode::Down
26            | KeyCode::Left
27            | KeyCode::Right
28            | KeyCode::Enter
29            | KeyCode::Esc => {
30                handle_path(state, key);
31                return;
32            }
33            _ => {}
34        }
35    }
36    if key.modifiers == KeyModifiers::NONE
37        && key.code == KeyCode::Char('q')
38        && !matches!(state.step, SetupStep::SelectPath | SetupStep::Requirements)
39    {
40        state.outcome = Some(SetupOutcome::Cancelled);
41        state.should_quit = true;
42        return;
43    }
44    if !path_popup_active
45        && state.step != SetupStep::SelectPath
46        && state.step != SetupStep::Requirements
47        && key.modifiers == KeyModifiers::NONE
48        && key.code == KeyCode::Esc
49    {
50        state.outcome = Some(SetupOutcome::Cancelled);
51        state.should_quit = true;
52        return;
53    }
54    if !path_popup_active
55        && state.step != SetupStep::SelectPath
56        && key.modifiers == KeyModifiers::NONE
57    {
58        match key.code {
59            KeyCode::Left => {
60                state.prev_step();
61                return;
62            }
63            KeyCode::Right => {
64                handle_step_forward(state).await;
65                return;
66            }
67            _ => {}
68        }
69    }
70
71    match state.step {
72        SetupStep::Requirements => handle_requirements(state, key),
73        SetupStep::SelectProvider => handle_provider(state, key),
74        SetupStep::Authenticate => handle_auth(state, key).await,
75        SetupStep::SelectPath => handle_path(state, key),
76        SetupStep::SelectOrgs => handle_orgs(state, key).await,
77        SetupStep::Confirm => handle_confirm(state, key),
78        SetupStep::Complete => handle_complete(state, key),
79    }
80}
81
82async fn handle_step_forward(state: &mut SetupState) {
83    match state.step {
84        SetupStep::Requirements => {
85            if !state.checks_loading && state.requirements_passed() {
86                state.next_step();
87            }
88        }
89        SetupStep::SelectProvider => {
90            if state.provider_choices[state.provider_index].available {
91                state.auth_status = AuthStatus::Pending;
92                state.next_step();
93            }
94        }
95        SetupStep::Authenticate => match state.auth_status.clone() {
96            AuthStatus::Pending | AuthStatus::Failed(_) => {
97                state.auth_status = AuthStatus::Checking;
98                do_authenticate(state).await;
99            }
100            AuthStatus::Success => {
101                state.next_step();
102            }
103            AuthStatus::Checking => {}
104        },
105        SetupStep::SelectOrgs => {
106            if state.org_loading {
107                // Discovery is driven externally while loading.
108            } else if state.org_error.is_some() {
109                state.org_loading = true;
110                state.org_discovery_in_progress = false;
111                state.org_error = None;
112            } else {
113                state.next_step();
114            }
115        }
116        SetupStep::SelectPath => {
117            if state.path_browse_mode {
118                if !state.path_browse_current_dir.is_empty() {
119                    state.base_path = state.path_browse_current_dir.clone();
120                    state.path_cursor = state.base_path.len();
121                }
122                close_path_browse_to_input(state);
123            }
124            confirm_path(state);
125        }
126        SetupStep::Confirm => match save_workspace(state) {
127            Ok(()) => {
128                state.next_step();
129            }
130            Err(e) => {
131                state.error_message = Some(e.to_string());
132            }
133        },
134        SetupStep::Complete => {
135            state.next_step();
136        }
137    }
138}
139
140fn handle_requirements(state: &mut SetupState, key: KeyEvent) {
141    match key.code {
142        KeyCode::Enter => {
143            if !state.checks_loading && state.requirements_passed() {
144                state.next_step();
145            }
146        }
147        KeyCode::Esc => {
148            state.prev_step();
149        }
150        _ => {}
151    }
152}
153
154fn handle_provider(state: &mut SetupState, key: KeyEvent) {
155    match key.code {
156        KeyCode::Up => {
157            if state.provider_index > 0 {
158                state.provider_index -= 1;
159            }
160        }
161        KeyCode::Down => {
162            if state.provider_index + 1 < state.provider_choices.len() {
163                state.provider_index += 1;
164            }
165        }
166        KeyCode::Enter => {
167            if state.provider_choices[state.provider_index].available {
168                state.auth_status = AuthStatus::Pending;
169                state.next_step();
170            }
171        }
172        KeyCode::Esc => {
173            state.prev_step();
174        }
175        _ => {}
176    }
177}
178
179async fn handle_auth(state: &mut SetupState, key: KeyEvent) {
180    match key.code {
181        KeyCode::Enter => {
182            match &state.auth_status {
183                AuthStatus::Pending | AuthStatus::Failed(_) => {
184                    // Attempt authentication
185                    state.auth_status = AuthStatus::Checking;
186                    do_authenticate(state).await;
187                }
188                AuthStatus::Success => {
189                    state.next_step();
190                }
191                AuthStatus::Checking => {}
192            }
193        }
194        KeyCode::Esc => {
195            state.prev_step();
196        }
197        _ => {}
198    }
199}
200
201async fn do_authenticate(state: &mut SetupState) {
202    let ws_provider = state.build_workspace_provider();
203    match get_auth_for_provider(&ws_provider) {
204        Ok(auth) => {
205            let username = auth.username.or_else(|| gh_cli::get_username().ok());
206            state.username = username;
207            state.auth_token = Some(auth.token);
208            state.auth_status = AuthStatus::Success;
209        }
210        Err(e) => {
211            state.auth_status = AuthStatus::Failed(e.to_string());
212        }
213    }
214}
215
216fn handle_path(state: &mut SetupState, key: KeyEvent) {
217    if state.path_browse_mode {
218        handle_path_browse(state, key);
219    } else if state.path_suggestions_mode {
220        handle_path_suggestions(state, key);
221    } else {
222        handle_path_input(state, key);
223    }
224}
225
226fn confirm_path(state: &mut SetupState) {
227    if state.base_path.is_empty() {
228        state.error_message = Some("Base path cannot be empty".to_string());
229    } else {
230        state.error_message = None;
231        state.next_step();
232    }
233}
234
235fn open_path_browse_mode(state: &mut SetupState) {
236    let dir = resolve_browse_root();
237    state.path_browse_info = None;
238    set_browse_root(state, dir);
239    state.path_suggestions_mode = false;
240    state.path_browse_mode = true;
241}
242
243fn resolve_browse_root() -> std::path::PathBuf {
244    std::env::current_dir()
245        .or_else(|_| std::env::var("HOME").map(std::path::PathBuf::from))
246        .unwrap_or_else(|_| std::path::PathBuf::from("/"))
247}
248
249fn set_browse_root(state: &mut SetupState, dir: std::path::PathBuf) {
250    let root_path = tilde_collapse(&dir.to_string_lossy());
251    let (children, browse_error) = read_child_directories(&dir, 1);
252    let root = PathBrowseEntry {
253        label: browse_label_for_path(&dir),
254        path: root_path.clone(),
255        depth: 0,
256        expanded: true,
257        has_children: !children.is_empty(),
258    };
259
260    let mut entries = Vec::with_capacity(children.len() + 1);
261    entries.push(root);
262    entries.extend(children);
263
264    state.path_browse_current_dir = root_path;
265    state.path_browse_entries = entries;
266    state.path_browse_error = browse_error;
267    state.path_browse_index = 0;
268}
269
270fn browse_label_for_path(path: &std::path::Path) -> String {
271    if path.parent().is_none() {
272        "/".to_string()
273    } else {
274        let name = path
275            .file_name()
276            .map(|part| part.to_string_lossy().to_string())
277            .unwrap_or_else(|| path.to_string_lossy().to_string());
278        format!("{name}/")
279    }
280}
281
282fn has_visible_child_directory(dir: &std::path::Path) -> bool {
283    match std::fs::read_dir(dir) {
284        Ok(entries) => entries.flatten().any(|entry| {
285            let path = entry.path();
286            if !path.is_dir() {
287                return false;
288            }
289            let name = entry.file_name().to_string_lossy().to_string();
290            !name.starts_with('.')
291        }),
292        Err(_) => false,
293    }
294}
295
296fn read_child_directories(
297    dir: &std::path::Path,
298    depth: u16,
299) -> (Vec<PathBrowseEntry>, Option<String>) {
300    let mut children = Vec::new();
301    let mut browse_error = None;
302
303    match std::fs::read_dir(dir) {
304        Ok(dir_entries) => {
305            for entry_result in dir_entries {
306                match entry_result {
307                    Ok(entry) => {
308                        let path = entry.path();
309                        if !path.is_dir() {
310                            continue;
311                        }
312                        let name = entry.file_name().to_string_lossy().to_string();
313                        if name.starts_with('.') {
314                            continue;
315                        }
316                        children.push(PathBrowseEntry {
317                            label: format!("{name}/"),
318                            path: tilde_collapse(&path.to_string_lossy()),
319                            depth,
320                            expanded: false,
321                            has_children: has_visible_child_directory(&path),
322                        });
323                    }
324                    Err(e) => {
325                        if browse_error.is_none() {
326                            browse_error = Some(format!("Some entries could not be read: {e}"));
327                        }
328                    }
329                }
330            }
331        }
332        Err(e) => {
333            browse_error = Some(format!(
334                "Cannot read '{}': {e}",
335                tilde_collapse(&dir.to_string_lossy())
336            ));
337        }
338    }
339
340    children.sort_by_key(|entry| entry.label.to_lowercase());
341    (children, browse_error)
342}
343
344fn close_path_browse_to_input(state: &mut SetupState) {
345    state.path_browse_mode = false;
346    state.path_suggestions_mode = false;
347    state.path_browse_error = None;
348    state.path_browse_info = None;
349    state.path_cursor = state.base_path.len();
350    state.path_completions.clear();
351    state.path_completion_index = 0;
352}
353
354fn sync_browse_current_dir(state: &mut SetupState) {
355    if let Some(entry) = state.path_browse_entries.get(state.path_browse_index) {
356        state.path_browse_current_dir = entry.path.clone();
357    }
358}
359
360fn selected_browse_dir(state: &SetupState) -> Option<std::path::PathBuf> {
361    state
362        .path_browse_entries
363        .get(state.path_browse_index)
364        .map(|entry| std::path::PathBuf::from(shellexpand::tilde(&entry.path).as_ref()))
365}
366
367fn collapse_selected_entry(state: &mut SetupState) {
368    let Some(entry) = state
369        .path_browse_entries
370        .get(state.path_browse_index)
371        .cloned()
372    else {
373        return;
374    };
375    if !entry.expanded {
376        return;
377    }
378    let start = state.path_browse_index + 1;
379    let mut end = start;
380    while end < state.path_browse_entries.len()
381        && state.path_browse_entries[end].depth > entry.depth
382    {
383        end += 1;
384    }
385    if start < end {
386        state.path_browse_entries.drain(start..end);
387    }
388    if let Some(selected) = state.path_browse_entries.get_mut(state.path_browse_index) {
389        selected.expanded = false;
390    }
391}
392
393fn expand_selected_entry(state: &mut SetupState) {
394    let index = state.path_browse_index;
395    let Some(dir) = selected_browse_dir(state) else {
396        return;
397    };
398    let Some(selected) = state.path_browse_entries.get(index) else {
399        return;
400    };
401    let depth = selected.depth;
402
403    let (children, browse_error) = read_child_directories(&dir, depth + 1);
404    state.path_browse_error = browse_error;
405    if children.is_empty() {
406        if let Some(entry) = state.path_browse_entries.get_mut(index) {
407            entry.has_children = false;
408            entry.expanded = false;
409        }
410        return;
411    }
412
413    if let Some(entry) = state.path_browse_entries.get_mut(index) {
414        entry.expanded = true;
415        entry.has_children = true;
416    }
417    state
418        .path_browse_entries
419        .splice(index + 1..index + 1, children);
420}
421
422fn open_selected_browse_entry(state: &mut SetupState) {
423    let Some(selected) = state
424        .path_browse_entries
425        .get(state.path_browse_index)
426        .cloned()
427    else {
428        return;
429    };
430    if !selected.has_children {
431        return;
432    }
433    if selected.expanded {
434        let child_index = state.path_browse_index + 1;
435        if child_index < state.path_browse_entries.len()
436            && state.path_browse_entries[child_index].depth == selected.depth + 1
437        {
438            state.path_browse_index = child_index;
439        }
440    } else {
441        expand_selected_entry(state);
442    }
443    sync_browse_current_dir(state);
444}
445
446fn move_to_parent_or_collapse_selected_entry(state: &mut SetupState) {
447    let Some(selected) = state
448        .path_browse_entries
449        .get(state.path_browse_index)
450        .cloned()
451    else {
452        return;
453    };
454    if selected.depth == 0 {
455        let root_dir = std::path::PathBuf::from(shellexpand::tilde(&selected.path).as_ref());
456        if let Some(parent) = root_dir.parent() {
457            let parent = parent.to_path_buf();
458            set_browse_root(state, parent.clone());
459            state.path_browse_info = Some(format!(
460                "Moved to parent: {}",
461                tilde_collapse(&parent.to_string_lossy())
462            ));
463        } else {
464            state.path_browse_info = Some("Already at filesystem root".to_string());
465        }
466        return;
467    }
468    if selected.expanded {
469        collapse_selected_entry(state);
470        sync_browse_current_dir(state);
471        return;
472    }
473    for idx in (0..state.path_browse_index).rev() {
474        if state.path_browse_entries[idx].depth + 1 == selected.depth {
475            state.path_browse_index = idx;
476            sync_browse_current_dir(state);
477            return;
478        }
479    }
480}
481
482fn select_current_browse_folder(state: &mut SetupState) {
483    if let Some(entry) = state.path_browse_entries.get(state.path_browse_index) {
484        state.base_path = entry.path.clone();
485        state.path_cursor = state.base_path.len();
486    }
487    close_path_browse_to_input(state);
488}
489
490fn handle_path_browse(state: &mut SetupState, key: KeyEvent) {
491    match key.code {
492        KeyCode::Up => {
493            if state.path_browse_index > 0 {
494                state.path_browse_index -= 1;
495                sync_browse_current_dir(state);
496            }
497        }
498        KeyCode::Down => {
499            if state.path_browse_index + 1 < state.path_browse_entries.len() {
500                state.path_browse_index += 1;
501                sync_browse_current_dir(state);
502            }
503        }
504        KeyCode::Right => {
505            open_selected_browse_entry(state);
506        }
507        KeyCode::Left => {
508            move_to_parent_or_collapse_selected_entry(state);
509        }
510        KeyCode::Enter => {
511            select_current_browse_folder(state);
512        }
513        KeyCode::Esc => {
514            close_path_browse_to_input(state);
515        }
516        _ => {}
517    }
518}
519
520fn handle_path_suggestions(state: &mut SetupState, key: KeyEvent) {
521    match key.code {
522        KeyCode::Left => {
523            state.prev_step();
524        }
525        KeyCode::Enter => {
526            confirm_path(state);
527        }
528        KeyCode::Char('b') => {
529            open_path_browse_mode(state);
530        }
531        KeyCode::Esc => {
532            state.prev_step();
533        }
534        KeyCode::Tab => open_path_browse_mode(state),
535        KeyCode::Up | KeyCode::Down | KeyCode::Right => {}
536        KeyCode::Backspace | KeyCode::Delete => {}
537        KeyCode::Home | KeyCode::End => {}
538        KeyCode::Char(_) => {}
539        _ => {}
540    }
541}
542
543fn handle_path_input(state: &mut SetupState, key: KeyEvent) {
544    if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('b') {
545        open_path_browse_mode(state);
546        return;
547    }
548
549    match key.code {
550        KeyCode::Left => {
551            state.prev_step();
552        }
553        KeyCode::Enter => {
554            confirm_path(state);
555        }
556        KeyCode::Char('b') => {
557            open_path_browse_mode(state);
558        }
559        KeyCode::Esc => {
560            state.prev_step();
561        }
562        KeyCode::Up | KeyCode::Down | KeyCode::Right => {}
563        KeyCode::Tab => {}
564        KeyCode::Backspace | KeyCode::Delete => {}
565        KeyCode::Home | KeyCode::End => {}
566        KeyCode::Char(_) => {}
567        _ => {}
568    }
569}
570
571async fn handle_orgs(state: &mut SetupState, key: KeyEvent) {
572    if state.org_loading {
573        // Discovery is triggered by a synthetic Null key in setup CLI mode.
574        if key.code == KeyCode::Null {
575            do_discover_orgs(state).await;
576        }
577        return;
578    }
579
580    match key.code {
581        KeyCode::Up => {
582            if state.org_index > 0 {
583                state.org_index -= 1;
584            }
585        }
586        KeyCode::Down => {
587            if state.org_index + 1 < state.orgs.len() {
588                state.org_index += 1;
589            }
590        }
591        KeyCode::Char(' ') => {
592            if !state.orgs.is_empty() {
593                state.orgs[state.org_index].selected = !state.orgs[state.org_index].selected;
594            }
595        }
596        KeyCode::Char('a') => {
597            for org in &mut state.orgs {
598                org.selected = true;
599            }
600        }
601        KeyCode::Char('n') => {
602            for org in &mut state.orgs {
603                org.selected = false;
604            }
605        }
606        KeyCode::Enter => {
607            if state.org_error.is_some() {
608                // Retry
609                state.org_loading = true;
610                state.org_discovery_in_progress = false;
611                state.org_error = None;
612            } else {
613                state.next_step();
614            }
615        }
616        KeyCode::Esc => {
617            state.prev_step();
618        }
619        _ => {}
620    }
621}
622
623async fn do_discover_orgs(state: &mut SetupState) {
624    let Some(ref token) = state.auth_token else {
625        state.org_error = Some("Not authenticated".to_string());
626        state.org_loading = false;
627        state.org_discovery_in_progress = false;
628        return;
629    };
630
631    let ws_provider = state.build_workspace_provider();
632    match discover_org_entries(ws_provider, token.clone()).await {
633        Ok(org_entries) => {
634            state.orgs = org_entries;
635            state.org_index = 0;
636            state.org_loading = false;
637            state.org_discovery_in_progress = false;
638        }
639        Err(e) => {
640            state.org_error = Some(e);
641            state.org_loading = false;
642            state.org_discovery_in_progress = false;
643        }
644    }
645}
646
647pub(crate) async fn discover_org_entries(
648    ws_provider: crate::config::WorkspaceProvider,
649    token: String,
650) -> Result<Vec<OrgEntry>, String> {
651    match create_provider(&ws_provider, &token) {
652        Ok(provider) => match provider.get_organizations().await {
653            Ok(orgs) => {
654                let mut org_entries: Vec<OrgEntry> = Vec::new();
655                for org in &orgs {
656                    let repo_count = provider
657                        .get_org_repos(&org.login)
658                        .await
659                        .map(|r| r.len())
660                        .unwrap_or(0);
661                    org_entries.push(OrgEntry {
662                        name: org.login.clone(),
663                        repo_count,
664                        selected: true,
665                    });
666                }
667                org_entries.sort_by(|a, b| a.name.cmp(&b.name));
668                Ok(org_entries)
669            }
670            Err(e) => Err(e.to_string()),
671        },
672        Err(e) => Err(e.to_string()),
673    }
674}
675
676fn handle_confirm(state: &mut SetupState, key: KeyEvent) {
677    match key.code {
678        KeyCode::Enter => {
679            // Save workspace config and advance to Complete screen
680            match save_workspace(state) {
681                Ok(()) => {
682                    state.next_step();
683                }
684                Err(e) => {
685                    state.error_message = Some(e.to_string());
686                }
687            }
688        }
689        KeyCode::Esc => {
690            state.prev_step();
691        }
692        _ => {}
693    }
694}
695
696fn handle_complete(state: &mut SetupState, key: KeyEvent) {
697    match key.code {
698        KeyCode::Enter | KeyCode::Char('s') => {
699            state.next_step(); // Triggers Completed + should_quit
700        }
701        KeyCode::Esc => {
702            state.prev_step();
703        }
704        _ => {}
705    }
706}
707
708fn save_workspace(state: &SetupState) -> Result<(), crate::errors::AppError> {
709    let expanded = shellexpand::tilde(&state.base_path);
710    let root = std::path::Path::new(expanded.as_ref());
711    std::fs::create_dir_all(root).map_err(|e| {
712        crate::errors::AppError::config(format!(
713            "Failed to create workspace directory '{}': {}",
714            root.display(),
715            e
716        ))
717    })?;
718    let root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
719
720    let mut ws = WorkspaceConfig::new_from_root(&root);
721    ws.provider = state.build_workspace_provider();
722    ws.username = state.username.clone().unwrap_or_default();
723    ws.orgs = state.selected_orgs();
724
725    WorkspaceManager::save(&ws)?;
726    Ok(())
727}
728
729#[cfg(test)]
730#[path = "handler_tests.rs"]
731mod tests;