Skip to main content

git_same/tui/screens/
sync.rs

1//! Sync progress screen — real-time metrics during sync, enriched summary after.
2
3use 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
18// ── Key handler ─────────────────────────────────────────────────────────────
19
20pub 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        // Scroll log
31        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        // Expand/collapse commit deep dive
71        KeyCode::Enter if is_finished => {
72            // Extract data we need before mutating app
73            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                    // Toggle off: collapse
80                    app.expanded_repo = None;
81                    app.repo_commits.clear();
82                } else if let Some(path) = path {
83                    // Expand: fetch commits
84                    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        // Post-sync log filters
91        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        // Sync history overlay toggle
107        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    // Collect updated repos with paths for batch commit fetch.
126    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
158/// Count of log entries matching the current filter.
159fn 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
185/// Returns filtered log entries matching the current filter.
186fn 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
212// ── Render ──────────────────────────────────────────────────────────────────
213
214const 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    // Sync history overlay (on top of popup)
235    if app.show_sync_history && is_finished {
236        render_sync_history_overlay(app, frame, inner);
237    }
238}
239
240// ── Popup layout ────────────────────────────────────────────────────────────
241
242fn render_running_layout(app: &App, frame: &mut Frame, area: Rect) {
243    let chunks = Layout::vertical([
244        Constraint::Length(3), // Title
245        Constraint::Length(3), // Progress bar
246        Constraint::Length(1), // Enriched counters / summary
247        Constraint::Length(1), // Throughput / performance
248        Constraint::Length(1), // Phase / filter
249        Constraint::Length(1), // Worker slots / status
250        Constraint::Min(5),    // Log (running or completed)
251        Constraint::Length(2), // Bottom actions + nav
252    ])
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
380// ── Shared render functions ─────────────────────────────────────────────────
381
382fn 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
440// ── During-sync specific renders ────────────────────────────────────────────
441
442fn 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            // Add sparkline inline if we have samples.
627            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                // Show just the repo name (not org/) to save space.
795                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
912// ── Post-sync specific renders ──────────────────────────────────────────────
913
914fn 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    // Changelog mode has its own renderer
967    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    // Ensure scroll index is in bounds
1000    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        // Add detail based on status
1048        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        // Render expanded commits inline below this entry
1074        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
1125// ── Aggregate changelog ─────────────────────────────────────────────────────
1126
1127const 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    // Loading state
1137    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    // Empty state
1154    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    // Build grouped timeline items
1167    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        // Repo header: ● repo/name ··················· N commits
1176        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        // Commit lines with │ connector
1195        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        // Blank separator between repos (except last)
1206        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
1237// ── Sync history overlay ────────────────────────────────────────────────────
1238
1239fn 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            // Parse and format timestamp
1259            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
1316// ── Utilities ───────────────────────────────────────────────────────────────
1317
1318fn 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;