git_same/setup/
handler.rs1use 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
11pub async fn handle_key(state: &mut SetupState, key: KeyEvent) {
15 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 } 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 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 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 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 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(); }
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;