Skip to main content

git_same/tui/
handler.rs

1//! Input handler: keyboard events → state mutations (the "Update").
2
3use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
4use tokio::sync::mpsc::UnboundedSender;
5
6use super::app::{
7    App, CheckEntry, LogFilter, Operation, OperationState, Screen, SyncHistoryEntry, SyncLogEntry,
8    SyncLogStatus,
9};
10use super::event::{AppEvent, BackendMessage};
11use super::screens;
12use crate::cache::SyncHistoryManager;
13use crate::config::WorkspaceManager;
14use crate::domain::RepoPathTemplate;
15use crate::setup::state::{SetupOutcome, SetupStep};
16
17const MAX_THROUGHPUT_SAMPLES: usize = 240;
18const MAX_LOG_LINES: usize = 5_000;
19
20/// Handle an incoming event, updating app state and optionally spawning backend work.
21pub async fn handle_event(app: &mut App, event: AppEvent, backend_tx: &UnboundedSender<AppEvent>) {
22    match event {
23        AppEvent::Terminal(key) => handle_key(app, key, backend_tx).await,
24        AppEvent::Backend(msg) => handle_backend_message(app, msg, backend_tx),
25        AppEvent::Tick => {
26            let sync_in_progress = matches!(
27                &app.operation_state,
28                OperationState::Discovering {
29                    operation: Operation::Sync,
30                    ..
31                } | OperationState::Running {
32                    operation: Operation::Sync,
33                    ..
34                }
35            );
36
37            // Keep sync animation/throughput sampling active even when progress popup is hidden.
38            if sync_in_progress {
39                app.tick_count = app.tick_count.wrapping_add(1);
40
41                // Sample throughput every 10 ticks (1 second at 100ms tick rate)
42                if app.tick_count.is_multiple_of(10) {
43                    if let OperationState::Running {
44                        operation: Operation::Sync,
45                        completed,
46                        ref mut throughput_samples,
47                        ref mut last_sample_completed,
48                        ..
49                    } = app.operation_state
50                    {
51                        let delta = completed.saturating_sub(*last_sample_completed) as u64;
52                        throughput_samples.push(delta);
53                        if throughput_samples.len() > MAX_THROUGHPUT_SAMPLES {
54                            let drop_count = throughput_samples.len() - MAX_THROUGHPUT_SAMPLES;
55                            throughput_samples.drain(0..drop_count);
56                        }
57                        *last_sample_completed = completed;
58                    }
59                }
60            }
61            // Drive setup wizard tick and org discovery on tick
62            if app.screen == Screen::WorkspaceSetup {
63                if let Some(ref mut setup) = app.setup_state {
64                    setup.tick_count = setup.tick_count.wrapping_add(1);
65                    // Auto-trigger requirement checks on first tick
66                    if crate::setup::maybe_start_requirements_checks(setup) {
67                        let tx = backend_tx.clone();
68                        tokio::spawn(async move {
69                            let results = crate::checks::check_requirements().await;
70                            let entries: Vec<CheckEntry> = results
71                                .into_iter()
72                                .map(|r| CheckEntry {
73                                    name: r.name,
74                                    passed: r.passed,
75                                    message: r.message,
76                                    suggestion: r.suggestion,
77                                    critical: r.critical,
78                                })
79                                .collect();
80                            let _ = tx.send(AppEvent::Backend(BackendMessage::SetupCheckResults(
81                                entries,
82                            )));
83                        });
84                    }
85                    if setup.step == SetupStep::SelectOrgs
86                        && setup.org_loading
87                        && !setup.org_discovery_in_progress
88                    {
89                        if let Some(token) = setup.auth_token.clone() {
90                            setup.org_discovery_in_progress = true;
91                            let ws_provider = setup.build_workspace_provider();
92                            super::backend::spawn_setup_org_discovery(
93                                ws_provider,
94                                token,
95                                backend_tx.clone(),
96                            );
97                        } else {
98                            setup.org_error = Some("Not authenticated".to_string());
99                            setup.org_loading = false;
100                            setup.org_discovery_in_progress = false;
101                        }
102                    }
103                }
104            }
105            // Run background requirement checks when on Dashboard
106            if app.screen == Screen::Dashboard
107                && app.check_results.is_empty()
108                && !app.checks_loading
109            {
110                app.checks_loading = true;
111                let tx = backend_tx.clone();
112                tokio::spawn(async move {
113                    let results = crate::checks::check_requirements().await;
114                    let entries: Vec<CheckEntry> = results
115                        .into_iter()
116                        .map(|r| CheckEntry {
117                            name: r.name,
118                            passed: r.passed,
119                            message: r.message,
120                            suggestion: r.suggestion,
121                            critical: r.critical,
122                        })
123                        .collect();
124                    let _ = tx.send(AppEvent::Backend(BackendMessage::CheckResults(entries)));
125                });
126            }
127            // Auto-trigger status scan when data is stale or missing
128            let refresh_interval = app
129                .active_workspace
130                .as_ref()
131                .and_then(|ws| ws.refresh_interval)
132                .unwrap_or(app.config.refresh_interval);
133            if app.screen == Screen::Dashboard
134                && app.active_workspace.is_some()
135                && !app.status_loading
136                && !sync_in_progress
137                && app
138                    .last_status_scan
139                    .is_none_or(|t| t.elapsed().as_secs() >= refresh_interval)
140            {
141                app.status_loading = true;
142                super::backend::spawn_operation(Operation::Status, app, backend_tx.clone());
143            }
144        }
145        AppEvent::Resize(_, _) => {} // ratatui handles resize
146    }
147}
148
149async fn handle_key(app: &mut App, key: KeyEvent, backend_tx: &UnboundedSender<AppEvent>) {
150    // Clear error message on any keypress
151    app.error_message = None;
152
153    // If filter input is active, handle text input
154    if app.filter_active {
155        match key.code {
156            KeyCode::Esc => {
157                app.filter_active = false;
158                app.filter_text.clear();
159            }
160            KeyCode::Enter => {
161                app.filter_active = false;
162            }
163            KeyCode::Backspace => {
164                app.filter_text.pop();
165            }
166            KeyCode::Char(c) => {
167                app.filter_text.push(c);
168            }
169            _ => {}
170        }
171        return;
172    }
173
174    // Global keybindings
175    if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
176        app.should_quit = true;
177        return;
178    }
179
180    if key.code == KeyCode::Char('q') {
181        app.should_quit = true;
182        return;
183    }
184
185    // WorkspaceSetup handles its own screen-specific keys.
186    if app.screen == Screen::WorkspaceSetup {
187        handle_setup_wizard_key(app, key).await;
188        return;
189    }
190
191    if key.code == KeyCode::Esc {
192        // On Sync screen, collapse expanded entry before navigating back
193        if app.screen == Screen::Sync && app.expanded_repo.is_some() {
194            app.expanded_repo = None;
195            app.repo_commits.clear();
196            return;
197        }
198        // Ensure Sync can always minimize back to Dashboard.
199        if app.screen == Screen::Sync && app.screen_stack.is_empty() {
200            app.screen = Screen::Dashboard;
201            return;
202        }
203        // Workspace screen: only go back if navigated to (has screen stack)
204        if app.screen == Screen::Workspaces && app.screen_stack.is_empty() {
205            return;
206        }
207        app.go_back();
208        return;
209    }
210
211    // Screen-specific keybindings
212    match app.screen {
213        Screen::WorkspaceSetup => unreachable!(), // handled above
214        Screen::Workspaces => screens::workspaces::handle_key(app, key, backend_tx).await,
215        Screen::Dashboard => screens::dashboard::handle_key(app, key, backend_tx).await,
216        Screen::Sync => screens::sync::handle_key(app, key, backend_tx),
217        Screen::Settings => screens::settings::handle_key(app, key),
218    }
219}
220
221async fn handle_setup_wizard_key(app: &mut App, key: KeyEvent) {
222    let Some(ref mut setup) = app.setup_state else {
223        return;
224    };
225
226    crate::setup::handler::handle_key(setup, key).await;
227
228    if setup.should_quit {
229        if matches!(setup.outcome, Some(SetupOutcome::Completed)) {
230            // Reload workspaces and go to dashboard
231            match WorkspaceManager::list() {
232                Ok(workspaces) => {
233                    app.workspaces = workspaces;
234                    if let Some(ws) = app.workspaces.first().cloned() {
235                        app.base_path = Some(ws.expanded_base_path());
236                        app.sync_history = SyncHistoryManager::for_workspace(&ws.root_path)
237                            .and_then(|m| m.load())
238                            .unwrap_or_default();
239                        app.active_workspace = Some(ws);
240                    }
241                }
242                Err(e) => {
243                    app.error_message = Some(format!("Failed to load workspaces: {}", e));
244                    app.workspaces.clear();
245                    app.base_path = None;
246                    app.active_workspace = None;
247                    app.sync_history.clear();
248                }
249            }
250            app.setup_state = None;
251            app.screen = Screen::Dashboard;
252            app.screen_stack.clear();
253        } else {
254            // Cancelled — return to previous screen when available, else quit.
255            app.setup_state = None;
256            if app.screen_stack.is_empty() {
257                app.should_quit = true;
258            } else {
259                app.go_back();
260            }
261        }
262    }
263}
264
265/// Compute the filesystem path for a repo from its full name (e.g. "org/repo").
266/// Mirrors `DiscoveryOrchestrator::compute_path()` logic using workspace config.
267fn compute_repo_path(app: &App, repo_name: &str) -> Option<std::path::PathBuf> {
268    let ws = app.active_workspace.as_ref()?;
269    let base_path = ws.expanded_base_path();
270    let template = ws
271        .structure
272        .clone()
273        .unwrap_or_else(|| app.config.structure.clone());
274    let provider_name = ws.provider.kind.slug().to_string();
275
276    RepoPathTemplate::new(template).render_full_name(&base_path, &provider_name, repo_name)
277}
278
279fn handle_backend_message(
280    app: &mut App,
281    msg: BackendMessage,
282    backend_tx: &UnboundedSender<AppEvent>,
283) {
284    match msg {
285        BackendMessage::OrgsDiscovered(count) => {
286            app.operation_state = OperationState::Discovering {
287                operation: Operation::Sync,
288                message: format!("Found {} organizations", count),
289            };
290        }
291        BackendMessage::OrgStarted(name) => {
292            app.operation_state = OperationState::Discovering {
293                operation: Operation::Sync,
294                message: format!("Discovering: {}", name),
295            };
296        }
297        BackendMessage::OrgComplete(name, count) => {
298            app.log_lines
299                .push(format!("[ok] {} ({} repos)", name, count));
300        }
301        BackendMessage::DiscoveryComplete(repos) => {
302            // Populate org data
303            let mut by_org: std::collections::HashMap<String, Vec<_>> =
304                std::collections::HashMap::new();
305            for repo in &repos {
306                by_org
307                    .entry(repo.owner.clone())
308                    .or_default()
309                    .push(repo.clone());
310            }
311            let mut org_names: Vec<String> = by_org.keys().cloned().collect();
312            org_names.sort();
313            app.orgs = org_names;
314            app.repos_by_org = by_org;
315            app.all_repos = repos;
316        }
317        BackendMessage::DiscoveryError(msg) => {
318            app.operation_state = OperationState::Idle;
319            app.error_message = Some(msg);
320        }
321        BackendMessage::SetupOrgsDiscovered(orgs) => {
322            if let Some(setup) = app.setup_state.as_mut() {
323                setup.org_discovery_in_progress = false;
324                if setup.step == SetupStep::SelectOrgs {
325                    setup.orgs = orgs;
326                    setup.org_index = 0;
327                    setup.org_loading = false;
328                    setup.org_error = None;
329                }
330            }
331        }
332        BackendMessage::SetupOrgsError(msg) => {
333            if let Some(setup) = app.setup_state.as_mut() {
334                setup.org_discovery_in_progress = false;
335                if setup.step == SetupStep::SelectOrgs {
336                    setup.org_loading = false;
337                    setup.org_error = Some(msg);
338                }
339            }
340        }
341        BackendMessage::OperationStarted {
342            operation,
343            total,
344            to_clone,
345            to_sync,
346        } => {
347            app.log_lines.clear();
348            app.sync_log_entries.clear();
349            app.log_filter = LogFilter::All;
350            app.sync_log_index = 0;
351            app.expanded_repo = None;
352            app.repo_commits.clear();
353            app.show_sync_history = false;
354            app.operation_state = OperationState::Running {
355                operation,
356                total,
357                completed: 0,
358                failed: 0,
359                skipped: 0,
360                current_repo: String::new(),
361                with_updates: 0,
362                cloned: 0,
363                synced: 0,
364                to_clone,
365                to_sync,
366                total_new_commits: 0,
367                started_at: std::time::Instant::now(),
368                active_repos: Vec::new(),
369                throughput_samples: Vec::new(),
370                last_sample_completed: 0,
371            };
372        }
373        BackendMessage::RepoStarted { repo_name } => {
374            if let OperationState::Running {
375                ref mut active_repos,
376                ..
377            } = app.operation_state
378            {
379                active_repos.push(repo_name);
380            }
381        }
382        BackendMessage::RepoProgress {
383            repo_name,
384            success,
385            skipped,
386            message,
387            had_updates,
388            is_clone,
389            new_commits,
390            skip_reason: _,
391        } => {
392            if let OperationState::Running {
393                ref mut completed,
394                ref mut failed,
395                skipped: ref mut skip_count,
396                ref mut current_repo,
397                ref mut with_updates,
398                ref mut cloned,
399                ref mut synced,
400                ref mut total_new_commits,
401                ref mut active_repos,
402                ..
403            } = app.operation_state
404            {
405                *completed += 1;
406                *current_repo = repo_name.clone();
407
408                // Remove from active workers
409                active_repos.retain(|r| r != &repo_name);
410
411                if skipped {
412                    *skip_count += 1;
413                } else if !success {
414                    *failed += 1;
415                } else {
416                    if is_clone {
417                        *cloned += 1;
418                    } else {
419                        *synced += 1;
420                    }
421                    if had_updates {
422                        *with_updates += 1;
423                        if let Some(n) = new_commits {
424                            *total_new_commits += n;
425                        }
426                    }
427                }
428            }
429
430            // Build structured log entry
431            let log_status = if !success {
432                SyncLogStatus::Failed
433            } else if skipped {
434                SyncLogStatus::Skipped
435            } else if is_clone {
436                SyncLogStatus::Cloned
437            } else if had_updates {
438                SyncLogStatus::Updated
439            } else {
440                SyncLogStatus::Success
441            };
442
443            app.sync_log_entries.push(SyncLogEntry {
444                repo_name: repo_name.clone(),
445                status: log_status,
446                message: message.clone(),
447                had_updates,
448                is_clone,
449                new_commits,
450                path: compute_repo_path(app, &repo_name),
451            });
452
453            // Build legacy log line with enriched prefixes
454            let prefix = match log_status {
455                SyncLogStatus::Failed => "[!!]",
456                SyncLogStatus::Skipped => "[--]",
457                SyncLogStatus::Cloned => "[++]",
458                SyncLogStatus::Updated => "[**]",
459                SyncLogStatus::Success => "[ok]",
460            };
461
462            let commit_info = if had_updates {
463                if let Some(n) = new_commits {
464                    if n > 0 {
465                        format!(" ({} new commits)", n)
466                    } else {
467                        String::new()
468                    }
469                } else {
470                    String::new()
471                }
472            } else {
473                String::new()
474            };
475
476            if app.log_lines.len() >= MAX_LOG_LINES {
477                let drop_count = app.log_lines.len() + 1 - MAX_LOG_LINES;
478                app.log_lines.drain(0..drop_count);
479                app.scroll_offset = app.scroll_offset.saturating_sub(drop_count);
480            }
481            app.log_lines.push(format!(
482                "{} {} - {}{}",
483                prefix, repo_name, message, commit_info
484            ));
485            // Auto-scroll to bottom
486            app.scroll_offset = app.log_lines.len().saturating_sub(1);
487        }
488        BackendMessage::OperationComplete(summary) => {
489            // Extract accumulated metrics from Running state before transitioning
490            let (op, wu, cl, sy, tnc, dur) = match &app.operation_state {
491                OperationState::Running {
492                    operation,
493                    with_updates,
494                    cloned,
495                    synced,
496                    total_new_commits,
497                    started_at,
498                    ..
499                } => (
500                    *operation,
501                    *with_updates,
502                    *cloned,
503                    *synced,
504                    *total_new_commits,
505                    started_at.elapsed().as_secs_f64(),
506                ),
507                _ => (Operation::Sync, 0, 0, 0, 0, 0.0),
508            };
509
510            // Update last_synced after a successful sync
511            if op == Operation::Sync {
512                let now = chrono::Utc::now().to_rfc3339();
513                if let Some(ref mut ws) = app.active_workspace {
514                    ws.last_synced = Some(now.clone());
515                    let _ = WorkspaceManager::save(ws);
516                    if let Some(entry) = app
517                        .workspaces
518                        .iter_mut()
519                        .find(|w| w.root_path == ws.root_path)
520                    {
521                        entry.last_synced = Some(now.clone());
522                    }
523                }
524
525                // Save to sync history
526                app.sync_history.push(SyncHistoryEntry {
527                    timestamp: now,
528                    duration_secs: dur,
529                    success: summary.success,
530                    failed: summary.failed,
531                    skipped: summary.skipped,
532                    with_updates: wu,
533                    cloned: cl,
534                    total_new_commits: tnc,
535                });
536                // Cap in-memory history
537                if app.sync_history.len() > 50 {
538                    app.sync_history.remove(0);
539                }
540
541                // Persist history to disk
542                if let Some(ref ws) = app.active_workspace {
543                    if let Ok(manager) = SyncHistoryManager::for_workspace(&ws.root_path) {
544                        let _ = manager.save(&app.sync_history);
545                    }
546                }
547
548                // Auto-trigger status scan so dashboard is fresh
549                super::backend::spawn_operation(Operation::Status, app, backend_tx.clone());
550            }
551
552            // Default to Updated filter if there were updates, else All
553            app.log_filter = if wu > 0 || cl > 0 {
554                LogFilter::Updated
555            } else {
556                LogFilter::All
557            };
558            app.sync_log_index = 0;
559
560            app.operation_state = OperationState::Finished {
561                operation: op,
562                summary,
563                with_updates: wu,
564                cloned: cl,
565                synced: sy,
566                total_new_commits: tnc,
567                duration_secs: dur,
568            };
569        }
570        BackendMessage::OperationError(msg) => {
571            app.operation_state = OperationState::Idle;
572            app.error_message = Some(msg);
573        }
574        BackendMessage::StatusResults(entries) => {
575            app.local_repos = entries;
576            if matches!(
577                app.operation_state,
578                OperationState::Running {
579                    operation: Operation::Status,
580                    ..
581                }
582            ) {
583                app.operation_state = OperationState::Idle;
584            }
585            app.status_loading = false;
586            app.last_status_scan = Some(std::time::Instant::now());
587        }
588        BackendMessage::RepoCommitLog { repo_name, commits } => {
589            // Single repo deep dive (Enter key)
590            if app.expanded_repo.as_deref() == Some(&repo_name) {
591                app.repo_commits = commits.clone();
592            }
593            // Changelog aggregation (c key)
594            if app.log_filter == LogFilter::Changelog {
595                app.changelog_commits.insert(repo_name, commits);
596                app.changelog_loaded += 1;
597            }
598        }
599        BackendMessage::SetupCheckResults(entries) => {
600            // Populate app-level check_results (for Settings screen)
601            app.check_results = entries.clone();
602            app.checks_loading = false;
603            // Also populate setup state if on Requirements step
604            if let Some(ref mut setup) = app.setup_state {
605                // Map CheckEntry back to CheckResult for setup state storage
606                let results = entries
607                    .iter()
608                    .map(|e| crate::checks::CheckResult {
609                        name: e.name.clone(),
610                        passed: e.passed,
611                        message: e.message.clone(),
612                        suggestion: e.suggestion.clone(),
613                        critical: e.critical,
614                    })
615                    .collect();
616                crate::setup::apply_requirements_check_results(setup, results);
617            }
618        }
619        BackendMessage::DefaultWorkspaceUpdated(name) => {
620            app.config.default_workspace = name;
621        }
622        BackendMessage::DefaultWorkspaceError(msg) => {
623            app.error_message = Some(msg);
624        }
625        BackendMessage::CheckResults(entries) => {
626            app.check_results = entries;
627            app.checks_loading = false;
628        }
629    }
630}
631
632#[cfg(test)]
633#[path = "handler_tests.rs"]
634mod tests;