1use 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
20pub 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 if sync_in_progress {
39 app.tick_count = app.tick_count.wrapping_add(1);
40
41 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 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 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 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 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(_, _) => {} }
147}
148
149async fn handle_key(app: &mut App, key: KeyEvent, backend_tx: &UnboundedSender<AppEvent>) {
150 app.error_message = None;
152
153 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 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 if app.screen == Screen::WorkspaceSetup {
187 handle_setup_wizard_key(app, key).await;
188 return;
189 }
190
191 if key.code == KeyCode::Esc {
192 if app.screen == Screen::Sync && app.expanded_repo.is_some() {
194 app.expanded_repo = None;
195 app.repo_commits.clear();
196 return;
197 }
198 if app.screen == Screen::Sync && app.screen_stack.is_empty() {
200 app.screen = Screen::Dashboard;
201 return;
202 }
203 if app.screen == Screen::Workspaces && app.screen_stack.is_empty() {
205 return;
206 }
207 app.go_back();
208 return;
209 }
210
211 match app.screen {
213 Screen::WorkspaceSetup => unreachable!(), 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 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 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
265fn 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 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 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 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 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 app.scroll_offset = app.log_lines.len().saturating_sub(1);
487 }
488 BackendMessage::OperationComplete(summary) => {
489 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 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 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 if app.sync_history.len() > 50 {
538 app.sync_history.remove(0);
539 }
540
541 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 super::backend::spawn_operation(Operation::Status, app, backend_tx.clone());
550 }
551
552 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 if app.expanded_repo.as_deref() == Some(&repo_name) {
591 app.repo_commits = commits.clone();
592 }
593 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 app.check_results = entries.clone();
602 app.checks_loading = false;
603 if let Some(ref mut setup) = app.setup_state {
605 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;