1use ratatui::{
4 layout::{Alignment, Constraint, Layout, Position, Rect},
5 style::{Color, Modifier, Style},
6 text::{Line, Span},
7 widgets::{Block, BorderType, Borders, Clear, Gauge, List, ListItem, Paragraph},
8 Frame,
9};
10
11use crossterm::event::{KeyCode, KeyEvent};
12use tokio::sync::mpsc::UnboundedSender;
13
14use crate::tui::app::{App, LogFilter, OperationState, SyncLogEntry, SyncLogStatus};
15use crate::tui::event::AppEvent;
16use crate::tui::screens::dashboard::{hide_sync_progress, start_sync_operation};
17
18pub fn handle_key(app: &mut App, key: KeyEvent, backend_tx: &UnboundedSender<AppEvent>) {
21 let is_finished = matches!(app.operation_state, OperationState::Finished { .. });
22
23 match key.code {
24 KeyCode::Char('s') => {
25 start_sync_operation(app, backend_tx);
26 }
27 KeyCode::Char('p') => {
28 hide_sync_progress(app);
29 }
30 KeyCode::Down => {
32 if is_finished {
33 if app.log_filter == LogFilter::Changelog {
34 app.changelog_scroll += 1;
35 } else {
36 let count = filtered_log_count(app);
37 if count > 0 && app.sync_log_index < count.saturating_sub(1) {
38 app.sync_log_index += 1;
39 }
40 }
41 } else if app.scroll_offset < app.log_lines.len().saturating_sub(1) {
42 app.scroll_offset += 1;
43 }
44 }
45 KeyCode::Up => {
46 if is_finished {
47 if app.log_filter == LogFilter::Changelog {
48 app.changelog_scroll = app.changelog_scroll.saturating_sub(1);
49 } else {
50 app.sync_log_index = app.sync_log_index.saturating_sub(1);
51 }
52 } else {
53 app.scroll_offset = app.scroll_offset.saturating_sub(1);
54 }
55 }
56 KeyCode::Left => {
57 if is_finished {
58 cycle_filter(app, backend_tx, -1);
59 } else {
60 app.scroll_offset = app.scroll_offset.saturating_sub(1);
61 }
62 }
63 KeyCode::Right => {
64 if is_finished {
65 cycle_filter(app, backend_tx, 1);
66 } else if app.scroll_offset < app.log_lines.len().saturating_sub(1) {
67 app.scroll_offset += 1;
68 }
69 }
70 KeyCode::Enter if is_finished => {
72 let selected = filtered_log_entries(app)
74 .get(app.sync_log_index)
75 .map(|e| (e.repo_name.clone(), e.path.clone()));
76
77 if let Some((repo_name, path)) = selected {
78 if app.expanded_repo.as_deref() == Some(&repo_name) {
79 app.expanded_repo = None;
81 app.repo_commits.clear();
82 } else if let Some(path) = path {
83 app.expanded_repo = Some(repo_name.clone());
85 app.repo_commits.clear();
86 crate::tui::backend::spawn_commit_fetch(path, repo_name, backend_tx.clone());
87 }
88 }
89 }
90 KeyCode::Char('a') if is_finished => {
92 apply_log_filter(app, backend_tx, LogFilter::All);
93 }
94 KeyCode::Char('u') if is_finished => {
95 apply_log_filter(app, backend_tx, LogFilter::Updated);
96 }
97 KeyCode::Char('f') if is_finished => {
98 apply_log_filter(app, backend_tx, LogFilter::Failed);
99 }
100 KeyCode::Char('x') if is_finished => {
101 apply_log_filter(app, backend_tx, LogFilter::Skipped);
102 }
103 KeyCode::Char('c') if is_finished => {
104 apply_log_filter(app, backend_tx, LogFilter::Changelog);
105 }
106 KeyCode::Char('h') if is_finished => {
108 app.show_sync_history = !app.show_sync_history;
109 }
110 _ => {}
111 }
112}
113
114fn apply_log_filter(app: &mut App, backend_tx: &UnboundedSender<AppEvent>, filter: LogFilter) {
115 app.log_filter = filter;
116 app.sync_log_index = 0;
117 app.expanded_repo = None;
118 app.repo_commits.clear();
119 app.changelog_scroll = 0;
120
121 if filter != LogFilter::Changelog {
122 return;
123 }
124
125 let updated_repos: Vec<(String, std::path::PathBuf)> = app
127 .sync_log_entries
128 .iter()
129 .filter(|e| e.had_updates)
130 .filter_map(|e| e.path.clone().map(|p| (e.repo_name.clone(), p)))
131 .collect();
132 app.changelog_total = updated_repos.len();
133 app.changelog_loaded = 0;
134 app.changelog_commits.clear();
135
136 if !updated_repos.is_empty() {
137 crate::tui::backend::spawn_changelog_fetch(updated_repos, backend_tx.clone());
138 }
139}
140
141fn cycle_filter(app: &mut App, backend_tx: &UnboundedSender<AppEvent>, direction: i8) {
142 const FILTERS: [LogFilter; 5] = [
143 LogFilter::All,
144 LogFilter::Updated,
145 LogFilter::Failed,
146 LogFilter::Skipped,
147 LogFilter::Changelog,
148 ];
149
150 let idx = FILTERS
151 .iter()
152 .position(|f| *f == app.log_filter)
153 .unwrap_or(0) as i8;
154 let next = (idx + direction).rem_euclid(FILTERS.len() as i8) as usize;
155 apply_log_filter(app, backend_tx, FILTERS[next]);
156}
157
158fn filtered_log_count(app: &App) -> usize {
160 match app.log_filter {
161 LogFilter::All => app.sync_log_entries.len(),
162 LogFilter::Updated => app
163 .sync_log_entries
164 .iter()
165 .filter(|e| e.had_updates || e.is_clone)
166 .count(),
167 LogFilter::Failed => app
168 .sync_log_entries
169 .iter()
170 .filter(|e| e.status == SyncLogStatus::Failed)
171 .count(),
172 LogFilter::Skipped => app
173 .sync_log_entries
174 .iter()
175 .filter(|e| e.status == SyncLogStatus::Skipped)
176 .count(),
177 LogFilter::Changelog => app
178 .sync_log_entries
179 .iter()
180 .filter(|e| e.had_updates)
181 .count(),
182 }
183}
184
185fn filtered_log_entries(app: &App) -> Vec<&SyncLogEntry> {
187 match app.log_filter {
188 LogFilter::All => app.sync_log_entries.iter().collect(),
189 LogFilter::Updated => app
190 .sync_log_entries
191 .iter()
192 .filter(|e| e.had_updates || e.is_clone)
193 .collect(),
194 LogFilter::Failed => app
195 .sync_log_entries
196 .iter()
197 .filter(|e| e.status == SyncLogStatus::Failed)
198 .collect(),
199 LogFilter::Skipped => app
200 .sync_log_entries
201 .iter()
202 .filter(|e| e.status == SyncLogStatus::Skipped)
203 .collect(),
204 LogFilter::Changelog => app
205 .sync_log_entries
206 .iter()
207 .filter(|e| e.had_updates)
208 .collect(),
209 }
210}
211
212const POPUP_WIDTH_PERCENT: u16 = 80;
215const POPUP_HEIGHT_PERCENT: u16 = 80;
216
217pub fn render(app: &App, frame: &mut Frame) {
218 let is_finished = matches!(&app.operation_state, OperationState::Finished { .. });
219
220 let popup_area = centered_rect(frame.area(), POPUP_WIDTH_PERCENT, POPUP_HEIGHT_PERCENT);
221 dim_outside_popup(frame, popup_area);
222 frame.render_widget(Clear, popup_area);
223
224 let block = Block::default()
225 .title(" Sync Progress ")
226 .borders(Borders::ALL)
227 .border_type(BorderType::Thick)
228 .border_style(Style::default().fg(Color::Cyan));
229 let inner = block.inner(popup_area);
230 frame.render_widget(block, popup_area);
231
232 render_running_layout(app, frame, inner);
233
234 if app.show_sync_history && is_finished {
236 render_sync_history_overlay(app, frame, inner);
237 }
238}
239
240fn render_running_layout(app: &App, frame: &mut Frame, area: Rect) {
243 let chunks = Layout::vertical([
244 Constraint::Length(3), Constraint::Length(3), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(5), Constraint::Length(2), ])
253 .split(area);
254
255 render_title(app, frame, chunks[0]);
256 render_progress_bar(app, frame, chunks[1]);
257 render_enriched_counters(app, frame, chunks[2]);
258 render_throughput(app, frame, chunks[3]);
259 render_phase_indicator(app, frame, chunks[4]);
260 render_worker_slots(app, frame, chunks[5]);
261 render_main_log(app, frame, chunks[6]);
262 render_bottom_actions(app, frame, chunks[7]);
263}
264
265fn render_main_log(app: &App, frame: &mut Frame, area: Rect) {
266 if matches!(app.operation_state, OperationState::Finished { .. }) {
267 render_filterable_log(app, frame, area);
268 } else {
269 render_running_log(app, frame, area);
270 }
271}
272
273fn render_bottom_actions(app: &App, frame: &mut Frame, area: Rect) {
274 let rows = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(area);
275
276 let dim = Style::default().fg(Color::DarkGray);
277 let key_style = Style::default()
278 .fg(Color::Rgb(37, 99, 235))
279 .add_modifier(Modifier::BOLD);
280
281 let mut action_spans = vec![
282 Span::styled("[s]", key_style),
283 Span::styled(" Start Sync", dim),
284 Span::raw(" "),
285 Span::styled("[p]", key_style),
286 Span::styled(" Hide Sync Progress", dim),
287 ];
288
289 if matches!(app.operation_state, OperationState::Finished { .. }) {
290 action_spans.extend([
291 Span::raw(" "),
292 Span::styled("[a]", key_style),
293 Span::styled(" All", dim),
294 Span::raw(" "),
295 Span::styled("[u]", key_style),
296 Span::styled(" Updated", dim),
297 Span::raw(" "),
298 Span::styled("[f]", key_style),
299 Span::styled(" Failed", dim),
300 Span::raw(" "),
301 Span::styled("[x]", key_style),
302 Span::styled(" Skipped", dim),
303 Span::raw(" "),
304 Span::styled("[c]", key_style),
305 Span::styled(" Changelog", dim),
306 Span::raw(" "),
307 Span::styled("[h]", key_style),
308 Span::styled(" History", dim),
309 ]);
310 }
311 frame.render_widget(
312 Paragraph::new(vec![Line::from(action_spans)]).centered(),
313 rows[0],
314 );
315
316 let nav_cols =
317 Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).split(rows[1]);
318
319 let left_spans = vec![
320 Span::raw(" "),
321 Span::styled("[q]", key_style),
322 Span::styled(" Quit", dim),
323 Span::raw(" "),
324 Span::styled("[Esc]", key_style),
325 Span::styled(" Back", dim),
326 ];
327 let right_spans = vec![
328 Span::styled("[←]", key_style),
329 Span::raw(" "),
330 Span::styled("[↑]", key_style),
331 Span::raw(" "),
332 Span::styled("[↓]", key_style),
333 Span::raw(" "),
334 Span::styled("[→]", key_style),
335 Span::styled(" Move", dim),
336 Span::raw(" "),
337 Span::styled("[Enter]", key_style),
338 Span::styled(" Select", dim),
339 Span::raw(" "),
340 ];
341
342 frame.render_widget(Paragraph::new(vec![Line::from(left_spans)]), nav_cols[0]);
343 frame.render_widget(
344 Paragraph::new(vec![Line::from(right_spans)]).right_aligned(),
345 nav_cols[1],
346 );
347}
348
349fn centered_rect(area: Rect, width_percent: u16, height_percent: u16) -> Rect {
350 let width = (area.width.saturating_mul(width_percent) / 100).max(1);
351 let height = (area.height.saturating_mul(height_percent) / 100).max(1);
352 let x = area.x + (area.width.saturating_sub(width) / 2);
353 let y = area.y + (area.height.saturating_sub(height) / 2);
354 Rect::new(x, y, width, height)
355}
356
357fn dim_outside_popup(frame: &mut Frame, popup: Rect) {
358 let area = frame.area();
359 let popup_right = popup.x.saturating_add(popup.width);
360 let popup_bottom = popup.y.saturating_add(popup.height);
361
362 let buf = frame.buffer_mut();
363 for y in area.y..area.y.saturating_add(area.height) {
364 for x in area.x..area.x.saturating_add(area.width) {
365 let inside_popup = x >= popup.x && x < popup_right && y >= popup.y && y < popup_bottom;
366 if inside_popup {
367 continue;
368 }
369 if let Some(cell) = buf.cell_mut(Position::new(x, y)) {
370 cell.set_style(
371 Style::default()
372 .fg(Color::DarkGray)
373 .add_modifier(Modifier::DIM),
374 );
375 }
376 }
377 }
378}
379
380fn render_title(app: &App, frame: &mut Frame, area: Rect) {
383 let title_text = match &app.operation_state {
384 OperationState::Idle => "Sync Progress".to_string(),
385 OperationState::Discovering { .. } | OperationState::Running { .. } => {
386 "Sync Running".to_string()
387 }
388 OperationState::Finished { .. } => "Sync Completed".to_string(),
389 };
390
391 let style = match &app.operation_state {
392 OperationState::Finished { .. } => Style::default().fg(Color::Rgb(21, 128, 61)),
393 OperationState::Running { .. } => Style::default().fg(Color::Cyan),
394 _ => Style::default().fg(Color::Yellow),
395 };
396
397 let title = Paragraph::new(Line::from(Span::styled(
398 title_text,
399 style.add_modifier(Modifier::BOLD),
400 )))
401 .centered()
402 .block(
403 Block::default()
404 .borders(Borders::BOTTOM)
405 .border_style(Style::default().fg(Color::DarkGray)),
406 );
407 frame.render_widget(title, area);
408}
409
410fn render_progress_bar(app: &App, frame: &mut Frame, area: Rect) {
411 let (ratio, label) = match &app.operation_state {
412 OperationState::Running {
413 total, completed, ..
414 } => {
415 let r = if *total > 0 {
416 *completed as f64 / *total as f64
417 } else {
418 0.0
419 };
420 let pct = (r * 100.0) as u32;
421 (r, format!("{}/{} ({}%)", completed, total, pct))
422 }
423 OperationState::Finished { .. } => (1.0, "Done".to_string()),
424 OperationState::Discovering { .. } => (0.0, "Discovering repositories...".to_string()),
425 OperationState::Idle => (0.0, "Press [s] to start sync".to_string()),
426 };
427
428 let gauge = Gauge::default()
429 .block(
430 Block::default()
431 .borders(Borders::ALL)
432 .border_style(Style::default().fg(Color::DarkGray)),
433 )
434 .gauge_style(Style::default().fg(Color::Cyan))
435 .ratio(ratio.clamp(0.0, 1.0))
436 .label(label);
437 frame.render_widget(gauge, area);
438}
439
440fn render_enriched_counters(app: &App, frame: &mut Frame, area: Rect) {
443 match &app.operation_state {
444 OperationState::Running {
445 completed,
446 failed,
447 skipped,
448 with_updates,
449 cloned,
450 current_repo,
451 ..
452 } => {
453 let up_to_date = completed
454 .saturating_sub(*failed)
455 .saturating_sub(*skipped)
456 .saturating_sub(*with_updates)
457 .saturating_sub(*cloned);
458
459 let mut spans = vec![
460 Span::raw(" "),
461 Span::styled("Updated: ", Style::default().fg(Color::Yellow)),
462 Span::styled(
463 with_updates.to_string(),
464 Style::default()
465 .fg(Color::Yellow)
466 .add_modifier(Modifier::BOLD),
467 ),
468 Span::raw(" "),
469 Span::styled("Current: ", Style::default().fg(Color::Rgb(21, 128, 61))),
470 Span::styled(
471 up_to_date.to_string(),
472 Style::default().fg(Color::Rgb(21, 128, 61)),
473 ),
474 Span::raw(" "),
475 Span::styled("Cloned: ", Style::default().fg(Color::Cyan)),
476 Span::styled(cloned.to_string(), Style::default().fg(Color::Cyan)),
477 ];
478
479 if *failed > 0 {
480 spans.push(Span::raw(" "));
481 spans.push(Span::styled("Failed: ", Style::default().fg(Color::Red)));
482 spans.push(Span::styled(
483 failed.to_string(),
484 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
485 ));
486 }
487
488 if *skipped > 0 {
489 spans.push(Span::raw(" "));
490 spans.push(Span::styled(
491 "Skipped: ",
492 Style::default().fg(Color::DarkGray),
493 ));
494 spans.push(Span::styled(
495 skipped.to_string(),
496 Style::default().fg(Color::DarkGray),
497 ));
498 }
499
500 if !current_repo.is_empty() {
501 spans.push(Span::raw(" "));
502 spans.push(Span::styled(
503 current_repo.as_str(),
504 Style::default().fg(Color::DarkGray),
505 ));
506 }
507
508 frame.render_widget(Paragraph::new(Line::from(spans)), area);
509 }
510 OperationState::Finished {
511 summary,
512 with_updates,
513 cloned,
514 ..
515 } => {
516 let current = summary
517 .success
518 .saturating_sub(*with_updates)
519 .saturating_sub(*cloned);
520
521 let spans = vec![
522 Span::raw(" "),
523 Span::styled("Updated: ", Style::default().fg(Color::Yellow)),
524 Span::styled(
525 with_updates.to_string(),
526 Style::default()
527 .fg(Color::Yellow)
528 .add_modifier(Modifier::BOLD),
529 ),
530 Span::raw(" "),
531 Span::styled("Current: ", Style::default().fg(Color::Rgb(21, 128, 61))),
532 Span::styled(
533 current.to_string(),
534 Style::default().fg(Color::Rgb(21, 128, 61)),
535 ),
536 Span::raw(" "),
537 Span::styled("Cloned: ", Style::default().fg(Color::Cyan)),
538 Span::styled(cloned.to_string(), Style::default().fg(Color::Cyan)),
539 Span::raw(" "),
540 Span::styled("Failed: ", Style::default().fg(Color::Red)),
541 Span::styled(
542 summary.failed.to_string(),
543 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
544 ),
545 Span::raw(" "),
546 Span::styled("Skipped: ", Style::default().fg(Color::DarkGray)),
547 Span::styled(
548 summary.skipped.to_string(),
549 Style::default().fg(Color::DarkGray),
550 ),
551 ];
552 frame.render_widget(Paragraph::new(Line::from(spans)), area);
553 }
554 OperationState::Discovering { message, .. } => {
555 frame.render_widget(
556 Paragraph::new(Line::from(vec![
557 Span::raw(" "),
558 Span::styled("Discovering: ", Style::default().fg(Color::Yellow)),
559 Span::styled(message.as_str(), Style::default().fg(Color::DarkGray)),
560 ])),
561 area,
562 );
563 }
564 OperationState::Idle => {
565 frame.render_widget(
566 Paragraph::new(Line::from(vec![
567 Span::raw(" "),
568 Span::styled(
569 "No sync activity yet.",
570 Style::default().fg(Color::DarkGray),
571 ),
572 ])),
573 area,
574 );
575 }
576 }
577}
578
579fn render_throughput(app: &App, frame: &mut Frame, area: Rect) {
580 match &app.operation_state {
581 OperationState::Running {
582 completed,
583 total,
584 started_at,
585 throughput_samples,
586 ..
587 } => {
588 let elapsed = started_at.elapsed();
589 let elapsed_secs = elapsed.as_secs_f64();
590 let repos_per_sec = if elapsed_secs > 1.0 {
591 *completed as f64 / elapsed_secs
592 } else {
593 0.0
594 };
595 let remaining = total.saturating_sub(*completed);
596 let eta_secs = if repos_per_sec > 0.1 {
597 (remaining as f64 / repos_per_sec).ceil() as u64
598 } else {
599 0
600 };
601
602 let mut spans = vec![
603 Span::raw(" "),
604 Span::styled("Elapsed: ", Style::default().fg(Color::DarkGray)),
605 Span::styled(format_duration(elapsed), Style::default().fg(Color::Cyan)),
606 ];
607
608 if repos_per_sec > 0.0 {
609 spans.push(Span::raw(" "));
610 spans.push(Span::styled(
611 format!("~{:.1} repos/sec", repos_per_sec),
612 Style::default().fg(Color::DarkGray),
613 ));
614 }
615
616 let has_eta_data = throughput_samples.iter().any(|&sample| sample > 0);
617 if has_eta_data && eta_secs > 0 && *completed > 0 {
618 spans.push(Span::raw(" "));
619 spans.push(Span::styled("ETA: ", Style::default().fg(Color::DarkGray)));
620 spans.push(Span::styled(
621 format!("~{}s", eta_secs),
622 Style::default().fg(Color::Cyan),
623 ));
624 }
625
626 if !throughput_samples.is_empty() {
628 spans.push(Span::raw(" "));
629 let max_val = throughput_samples.iter().copied().max().unwrap_or(1).max(1);
630 let bars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
631 let spark_str: String = throughput_samples
632 .iter()
633 .rev()
634 .take(20)
635 .collect::<Vec<_>>()
636 .iter()
637 .rev()
638 .map(|&v| {
639 let idx = ((*v as f64 / max_val as f64) * 7.0) as usize;
640 bars[idx.min(7)]
641 })
642 .collect();
643 spans.push(Span::styled(spark_str, Style::default().fg(Color::Cyan)));
644 }
645
646 frame.render_widget(Paragraph::new(Line::from(spans)), area);
647 }
648 OperationState::Finished { .. } => {
649 render_performance_line(app, frame, area);
650 }
651 OperationState::Discovering { .. } => {
652 frame.render_widget(
653 Paragraph::new(Line::from(vec![
654 Span::raw(" "),
655 Span::styled(
656 "Building sync plan...",
657 Style::default().fg(Color::DarkGray),
658 ),
659 ])),
660 area,
661 );
662 }
663 OperationState::Idle => {
664 frame.render_widget(
665 Paragraph::new(Line::from(vec![
666 Span::raw(" "),
667 Span::styled(
668 "Press [p] to hide, [s] to start.",
669 Style::default().fg(Color::DarkGray),
670 ),
671 ])),
672 area,
673 );
674 }
675 }
676}
677
678fn render_phase_indicator(app: &App, frame: &mut Frame, area: Rect) {
679 match &app.operation_state {
680 OperationState::Running {
681 to_clone,
682 to_sync,
683 cloned,
684 synced,
685 ..
686 } => {
687 if *to_clone == 0 && *to_sync == 0 {
688 return;
689 }
690
691 let mut spans = vec![Span::raw(" Phase: ")];
692
693 if *to_clone > 0 {
694 let clone_pct = if *to_clone > 0 {
695 *cloned as f64 / *to_clone as f64
696 } else {
697 0.0
698 };
699 let bar_width: usize = 8;
700 let filled = (clone_pct * bar_width as f64).round() as usize;
701 spans.push(Span::styled(
702 "\u{2588}".repeat(filled),
703 Style::default().fg(Color::Cyan),
704 ));
705 spans.push(Span::styled(
706 "\u{2591}".repeat(bar_width.saturating_sub(filled)),
707 Style::default().fg(Color::DarkGray),
708 ));
709 spans.push(Span::styled(
710 format!(" Clone {}/{}", cloned, to_clone),
711 Style::default().fg(Color::Cyan),
712 ));
713 spans.push(Span::raw(" "));
714 }
715
716 if *to_sync > 0 {
717 let sync_pct = if *to_sync > 0 {
718 *synced as f64 / *to_sync as f64
719 } else {
720 0.0
721 };
722 let bar_width: usize = 12;
723 let filled = (sync_pct * bar_width as f64).round() as usize;
724 spans.push(Span::styled(
725 "\u{2588}".repeat(filled),
726 Style::default().fg(Color::Rgb(21, 128, 61)),
727 ));
728 spans.push(Span::styled(
729 "\u{2591}".repeat(bar_width.saturating_sub(filled)),
730 Style::default().fg(Color::DarkGray),
731 ));
732 spans.push(Span::styled(
733 format!(" Sync {}/{}", synced, to_sync),
734 Style::default().fg(Color::Rgb(21, 128, 61)),
735 ));
736 }
737
738 frame.render_widget(Paragraph::new(Line::from(spans)), area);
739 }
740 OperationState::Finished { .. } => {
741 let label = match app.log_filter {
742 LogFilter::All => "All",
743 LogFilter::Updated => "Updated",
744 LogFilter::Failed => "Failed",
745 LogFilter::Skipped => "Skipped",
746 LogFilter::Changelog => "Changelog",
747 };
748
749 let spans = vec![
750 Span::raw(" "),
751 Span::styled("Filter: ", Style::default().fg(Color::DarkGray)),
752 Span::styled(label, Style::default().fg(Color::Cyan)),
753 Span::styled(" | ", Style::default().fg(Color::DarkGray)),
754 Span::styled(
755 format!("{} entries", filtered_log_count(app)),
756 Style::default().fg(Color::DarkGray),
757 ),
758 Span::styled(" | ", Style::default().fg(Color::DarkGray)),
759 Span::styled("[←]/[→]", Style::default().fg(Color::Rgb(37, 99, 235))),
760 Span::styled(" filter", Style::default().fg(Color::DarkGray)),
761 ];
762 frame.render_widget(Paragraph::new(Line::from(spans)), area);
763 }
764 _ => {}
765 }
766}
767
768fn render_worker_slots(app: &App, frame: &mut Frame, area: Rect) {
769 match &app.operation_state {
770 OperationState::Running { active_repos, .. } => {
771 if active_repos.is_empty() {
772 frame.render_widget(
773 Paragraph::new(Line::from(vec![
774 Span::raw(" "),
775 Span::styled("Workers idle", Style::default().fg(Color::DarkGray)),
776 ])),
777 area,
778 );
779 return;
780 }
781
782 let mut spans = vec![Span::raw(" ")];
783 for (i, repo) in active_repos.iter().enumerate() {
784 if i > 0 {
785 spans.push(Span::raw(" "));
786 }
787 spans.push(Span::styled(
788 format!("[{}]", i + 1),
789 Style::default()
790 .fg(Color::DarkGray)
791 .add_modifier(Modifier::BOLD),
792 ));
793 spans.push(Span::raw(" "));
794 let short = repo.split('/').next_back().unwrap_or(repo);
796 spans.push(Span::styled(short, Style::default().fg(Color::Cyan)));
797 }
798
799 frame.render_widget(Paragraph::new(Line::from(spans)), area);
800 }
801 OperationState::Finished {
802 total_new_commits, ..
803 } => {
804 let mut spans = vec![
805 Span::raw(" "),
806 Span::styled(
807 "Completed. ",
808 Style::default()
809 .fg(Color::Rgb(21, 128, 61))
810 .add_modifier(Modifier::BOLD),
811 ),
812 Span::styled("[↑]/[↓] move", Style::default().fg(Color::Rgb(37, 99, 235))),
813 Span::styled(" ", Style::default().fg(Color::DarkGray)),
814 Span::styled(
815 "[Enter] commit details",
816 Style::default().fg(Color::Rgb(37, 99, 235)),
817 ),
818 ];
819
820 if *total_new_commits > 0 {
821 spans.push(Span::styled(
822 format!(" | {} new commits", total_new_commits),
823 Style::default().fg(Color::Yellow),
824 ));
825 }
826
827 frame.render_widget(Paragraph::new(Line::from(spans)), area);
828 }
829 OperationState::Discovering { .. } => {
830 frame.render_widget(
831 Paragraph::new(Line::from(vec![
832 Span::raw(" "),
833 Span::styled(
834 "Waiting for workers...",
835 Style::default().fg(Color::DarkGray),
836 ),
837 ])),
838 area,
839 );
840 }
841 OperationState::Idle => {
842 frame.render_widget(
843 Paragraph::new(Line::from(vec![
844 Span::raw(" "),
845 Span::styled(
846 "Use [p] to close this popup.",
847 Style::default().fg(Color::DarkGray),
848 ),
849 ])),
850 area,
851 );
852 }
853 }
854}
855
856fn render_running_log(app: &App, frame: &mut Frame, area: Rect) {
857 if app.log_lines.is_empty() {
858 let message = match app.operation_state {
859 OperationState::Idle => " No sync activity yet. Press [s] to start sync.",
860 OperationState::Discovering { .. } => " Discovering repositories...",
861 _ => " Waiting for log output...",
862 };
863 let empty = Paragraph::new(Line::from(Span::styled(
864 message,
865 Style::default().fg(Color::DarkGray),
866 )))
867 .block(
868 Block::default()
869 .title(" Log ")
870 .borders(Borders::ALL)
871 .border_style(Style::default().fg(Color::DarkGray)),
872 );
873 frame.render_widget(empty, area);
874 return;
875 }
876
877 let visible_height = area.height.saturating_sub(2) as usize;
878 let total = app.log_lines.len();
879 let max_start = total.saturating_sub(visible_height);
880 let start = app.scroll_offset.min(max_start);
881 let end = (start + visible_height).min(total);
882
883 let items: Vec<ListItem> = app.log_lines[start..end]
884 .iter()
885 .map(|line| {
886 let style = if line.starts_with("[**]") {
887 Style::default().fg(Color::Yellow)
888 } else if line.starts_with("[++]") {
889 Style::default().fg(Color::Cyan)
890 } else if line.starts_with("[ok]") {
891 Style::default().fg(Color::Rgb(21, 128, 61))
892 } else if line.starts_with("[!!]") {
893 Style::default().fg(Color::Red)
894 } else if line.starts_with("[--]") {
895 Style::default().fg(Color::DarkGray)
896 } else {
897 Style::default()
898 };
899 ListItem::new(Line::from(Span::styled(format!(" {}", line), style)))
900 })
901 .collect();
902
903 let log = List::new(items).block(
904 Block::default()
905 .title(" Log ")
906 .borders(Borders::ALL)
907 .border_style(Style::default().fg(Color::DarkGray)),
908 );
909 frame.render_widget(log, area);
910}
911
912fn render_performance_line(app: &App, frame: &mut Frame, area: Rect) {
915 if let OperationState::Finished {
916 summary,
917 duration_secs,
918 total_new_commits,
919 cloned,
920 ..
921 } = &app.operation_state
922 {
923 let total = summary.success + summary.failed + summary.skipped;
924 let repos_per_sec = if *duration_secs > 0.0 {
925 total as f64 / duration_secs
926 } else {
927 0.0
928 };
929
930 let mut spans = vec![
931 Span::raw(" "),
932 Span::styled(
933 format!("{} repos", total),
934 Style::default().fg(Color::DarkGray),
935 ),
936 Span::styled(" in ", Style::default().fg(Color::DarkGray)),
937 Span::styled(
938 format!("{:.1}s", duration_secs),
939 Style::default().fg(Color::Cyan),
940 ),
941 Span::styled(
942 format!(" ({:.1} repos/sec)", repos_per_sec),
943 Style::default().fg(Color::DarkGray),
944 ),
945 ];
946
947 if *total_new_commits > 0 {
948 spans.push(Span::styled(
949 format!(" \u{00b7} {} new commits", total_new_commits),
950 Style::default().fg(Color::Yellow),
951 ));
952 }
953
954 if *cloned > 0 {
955 spans.push(Span::styled(
956 format!(" \u{00b7} {} cloned", cloned),
957 Style::default().fg(Color::Cyan),
958 ));
959 }
960
961 frame.render_widget(Paragraph::new(Line::from(spans)), area);
962 }
963}
964
965fn render_filterable_log(app: &App, frame: &mut Frame, area: Rect) {
966 if app.log_filter == LogFilter::Changelog {
968 render_changelog(app, frame, area);
969 return;
970 }
971
972 let entries: Vec<&crate::tui::app::SyncLogEntry> = match app.log_filter {
973 LogFilter::All => app.sync_log_entries.iter().collect(),
974 LogFilter::Updated => app
975 .sync_log_entries
976 .iter()
977 .filter(|e| e.had_updates || e.is_clone)
978 .collect(),
979 LogFilter::Failed => app
980 .sync_log_entries
981 .iter()
982 .filter(|e| e.status == SyncLogStatus::Failed)
983 .collect(),
984 LogFilter::Skipped => app
985 .sync_log_entries
986 .iter()
987 .filter(|e| e.status == SyncLogStatus::Skipped)
988 .collect(),
989 LogFilter::Changelog => app
990 .sync_log_entries
991 .iter()
992 .filter(|e| e.had_updates)
993 .collect(),
994 };
995
996 let visible_height = area.height.saturating_sub(2) as usize;
997 let total_entries = entries.len();
998
999 let scroll_start = if total_entries > visible_height {
1001 let max_start = total_entries.saturating_sub(visible_height);
1002 app.sync_log_index.min(max_start)
1003 } else {
1004 0
1005 };
1006
1007 let mut items: Vec<ListItem> = Vec::new();
1008 let is_expanded = app.expanded_repo.is_some();
1009
1010 for (i, entry) in entries
1011 .iter()
1012 .skip(scroll_start)
1013 .take(visible_height)
1014 .enumerate()
1015 {
1016 let (prefix, color) = match entry.status {
1017 SyncLogStatus::Updated => ("[**]", Color::Yellow),
1018 SyncLogStatus::Cloned => ("[++]", Color::Cyan),
1019 SyncLogStatus::Success => ("[ok]", Color::Rgb(21, 128, 61)),
1020 SyncLogStatus::Failed => ("[!!]", Color::Red),
1021 SyncLogStatus::Skipped => ("[--]", Color::DarkGray),
1022 };
1023
1024 let is_selected = i + scroll_start == app.sync_log_index;
1025 let this_expanded = is_expanded && app.expanded_repo.as_deref() == Some(&entry.repo_name);
1026 let style = if is_selected {
1027 Style::default().fg(color).add_modifier(Modifier::BOLD)
1028 } else {
1029 Style::default().fg(color)
1030 };
1031
1032 let indicator = if this_expanded {
1033 " v "
1034 } else if is_selected {
1035 " > "
1036 } else {
1037 " "
1038 };
1039
1040 let mut spans = vec![
1041 Span::styled(indicator, style),
1042 Span::styled(prefix, style),
1043 Span::raw(" "),
1044 Span::styled(&entry.repo_name, style),
1045 ];
1046
1047 match entry.status {
1049 SyncLogStatus::Updated | SyncLogStatus::Cloned => {
1050 spans.push(Span::styled(
1051 format!(" - {}", entry.message),
1052 Style::default().fg(Color::DarkGray),
1053 ));
1054 if let Some(n) = entry.new_commits {
1055 if n > 0 {
1056 spans.push(Span::styled(
1057 format!(" ({} new commits)", n),
1058 Style::default().fg(Color::DarkGray),
1059 ));
1060 }
1061 }
1062 }
1063 _ => {
1064 spans.push(Span::styled(
1065 format!(" - {}", entry.message),
1066 Style::default().fg(Color::DarkGray),
1067 ));
1068 }
1069 }
1070
1071 items.push(ListItem::new(Line::from(spans)));
1072
1073 if this_expanded {
1075 if app.repo_commits.is_empty() {
1076 items.push(ListItem::new(Line::from(vec![
1077 Span::raw(" "),
1078 Span::styled(
1079 "Loading...",
1080 Style::default()
1081 .fg(Color::DarkGray)
1082 .add_modifier(Modifier::ITALIC),
1083 ),
1084 ])));
1085 } else {
1086 let max_commits = visible_height.saturating_sub(items.len()).max(3);
1087 for commit in app.repo_commits.iter().take(max_commits) {
1088 items.push(ListItem::new(Line::from(vec![
1089 Span::raw(" "),
1090 Span::styled(commit, Style::default().fg(Color::DarkGray)),
1091 ])));
1092 }
1093 if app.repo_commits.len() > max_commits {
1094 items.push(ListItem::new(Line::from(vec![
1095 Span::raw(" "),
1096 Span::styled(
1097 format!("... and {} more", app.repo_commits.len() - max_commits),
1098 Style::default().fg(Color::DarkGray),
1099 ),
1100 ])));
1101 }
1102 }
1103 }
1104 }
1105
1106 let filter_label = match app.log_filter {
1107 LogFilter::All => "All",
1108 LogFilter::Updated => "Updated",
1109 LogFilter::Failed => "Failed",
1110 LogFilter::Skipped => "Skipped",
1111 LogFilter::Changelog => "Changelog",
1112 };
1113
1114 let title = format!(" Log [{}] ({}) ", filter_label, total_entries);
1115
1116 let log = List::new(items).block(
1117 Block::default()
1118 .title(title)
1119 .borders(Borders::ALL)
1120 .border_style(Style::default().fg(Color::DarkGray)),
1121 );
1122 frame.render_widget(log, area);
1123}
1124
1125const REPO_COLORS: [Color; 4] = [Color::Yellow, Color::Cyan, Color::Green, Color::Magenta];
1128
1129fn render_changelog(app: &App, frame: &mut Frame, area: Rect) {
1130 let updated_repos: Vec<&crate::tui::app::SyncLogEntry> = app
1131 .sync_log_entries
1132 .iter()
1133 .filter(|e| e.had_updates)
1134 .collect();
1135
1136 if app.changelog_loaded < app.changelog_total && app.changelog_total > 0 {
1138 let loading = format!(
1139 "Fetching commits from {} updated repositories... {}/{}",
1140 app.changelog_total, app.changelog_loaded, app.changelog_total
1141 );
1142 let block = Block::default()
1143 .title(" Log [Changelog] ")
1144 .borders(Borders::ALL)
1145 .border_style(Style::default().fg(Color::DarkGray));
1146 let paragraph = Paragraph::new(loading)
1147 .alignment(Alignment::Center)
1148 .block(block);
1149 frame.render_widget(paragraph, area);
1150 return;
1151 }
1152
1153 if updated_repos.is_empty() {
1155 let block = Block::default()
1156 .title(" Log [Changelog] ")
1157 .borders(Borders::ALL)
1158 .border_style(Style::default().fg(Color::DarkGray));
1159 let paragraph = Paragraph::new("No updated repositories")
1160 .alignment(Alignment::Center)
1161 .block(block);
1162 frame.render_widget(paragraph, area);
1163 return;
1164 }
1165
1166 let mut items: Vec<ListItem> = Vec::new();
1168 let total_commits: usize = app.changelog_commits.values().map(|v| v.len()).sum();
1169
1170 for (i, entry) in updated_repos.iter().enumerate() {
1171 let color = REPO_COLORS[i % REPO_COLORS.len()];
1172 let commits = app.changelog_commits.get(&entry.repo_name);
1173 let count = commits.map(|c| c.len()).unwrap_or(0);
1174
1175 let header_right = format!("{} commits ", count);
1177 let used: u16 = 6 + entry.repo_name.len() as u16 + header_right.len() as u16;
1178 let padding = area.width.saturating_sub(used + 2) as usize;
1179 let dots = "·".repeat(padding);
1180
1181 items.push(ListItem::new(Line::from(vec![
1182 Span::styled(
1183 " ● ",
1184 Style::default().fg(color).add_modifier(Modifier::BOLD),
1185 ),
1186 Span::styled(
1187 entry.repo_name.as_str(),
1188 Style::default().fg(color).add_modifier(Modifier::BOLD),
1189 ),
1190 Span::styled(format!(" {} ", dots), Style::default().fg(Color::DarkGray)),
1191 Span::styled(header_right, Style::default().fg(Color::DarkGray)),
1192 ])));
1193
1194 if let Some(commits) = commits {
1196 for (j, commit) in commits.iter().enumerate() {
1197 let connector = if j < commits.len() - 1 { "│" } else { " " };
1198 items.push(ListItem::new(Line::from(vec![
1199 Span::styled(format!(" {connector} "), Style::default().fg(color)),
1200 Span::styled(commit.as_str(), Style::default().fg(Color::DarkGray)),
1201 ])));
1202 }
1203 }
1204
1205 if i < updated_repos.len() - 1 {
1207 items.push(ListItem::new(Line::from("")));
1208 }
1209 }
1210
1211 let visible_height = area.height.saturating_sub(2) as usize;
1212 let total_lines = items.len();
1213 let max_scroll = total_lines.saturating_sub(visible_height);
1214 let scroll = app.changelog_scroll.min(max_scroll);
1215
1216 let title = format!(
1217 " Log [Changelog] ({} commits across {} repos) ",
1218 total_commits,
1219 updated_repos.len()
1220 );
1221
1222 let items: Vec<ListItem> = items
1223 .into_iter()
1224 .skip(scroll)
1225 .take(visible_height)
1226 .collect();
1227
1228 let list = List::new(items).block(
1229 Block::default()
1230 .title(title)
1231 .borders(Borders::ALL)
1232 .border_style(Style::default().fg(Color::DarkGray)),
1233 );
1234 frame.render_widget(list, area);
1235}
1236
1237fn render_sync_history_overlay(app: &App, frame: &mut Frame, area: Rect) {
1240 if app.sync_history.is_empty() {
1241 return;
1242 }
1243
1244 let overlay_height = (app.sync_history.len() as u16 + 2).min(14);
1245 let overlay_width = 60u16.min(area.width.saturating_sub(4));
1246
1247 let x = area.x + area.width.saturating_sub(overlay_width) / 2;
1248 let y = area.y + area.height.saturating_sub(overlay_height) / 2;
1249 let overlay_area = Rect::new(x, y, overlay_width, overlay_height);
1250
1251 frame.render_widget(Clear, overlay_area);
1252
1253 let items: Vec<ListItem> = app
1254 .sync_history
1255 .iter()
1256 .rev()
1257 .map(|entry| {
1258 let time_str = if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&entry.timestamp) {
1260 dt.format("%b %d, %H:%M").to_string()
1261 } else {
1262 "unknown".to_string()
1263 };
1264
1265 let total = entry.success + entry.failed + entry.skipped;
1266 let mut spans = vec![
1267 Span::raw(" "),
1268 Span::styled(
1269 format!("{:<14}", time_str),
1270 Style::default().fg(Color::DarkGray),
1271 ),
1272 Span::styled(
1273 format!("{:>3} repos", total),
1274 Style::default().fg(Color::Cyan),
1275 ),
1276 Span::raw(" "),
1277 ];
1278
1279 if entry.with_updates > 0 {
1280 spans.push(Span::styled(
1281 format!("{} updated", entry.with_updates),
1282 Style::default().fg(Color::Yellow),
1283 ));
1284 } else if entry.cloned > 0 {
1285 spans.push(Span::styled(
1286 format!("{} cloned", entry.cloned),
1287 Style::default().fg(Color::Cyan),
1288 ));
1289 } else {
1290 spans.push(Span::styled(
1291 "no changes",
1292 Style::default().fg(Color::DarkGray),
1293 ));
1294 }
1295
1296 spans.push(Span::raw(" "));
1297 spans.push(Span::styled(
1298 format!("{:.1}s", entry.duration_secs),
1299 Style::default().fg(Color::DarkGray),
1300 ));
1301
1302 ListItem::new(Line::from(spans))
1303 })
1304 .collect();
1305
1306 let list = List::new(items).block(
1307 Block::default()
1308 .title(" Sync History ")
1309 .borders(Borders::ALL)
1310 .border_type(BorderType::Thick)
1311 .border_style(Style::default().fg(Color::Cyan)),
1312 );
1313 frame.render_widget(list, overlay_area);
1314}
1315
1316fn format_duration(d: std::time::Duration) -> String {
1319 let secs = d.as_secs();
1320 if secs >= 60 {
1321 format!("{}m{}s", secs / 60, secs % 60)
1322 } else {
1323 format!("{}s", secs)
1324 }
1325}
1326
1327#[cfg(test)]
1328#[path = "sync_tests.rs"]
1329mod tests;