Skip to main content

git_same/tui/screens/
dashboard.rs

1//! Dashboard screen — home view with summary stats and quick-action hotkeys.
2
3use std::collections::{HashMap, HashSet};
4
5use ratatui::{
6    layout::{Constraint, Layout, Position, Rect},
7    style::{Color, Modifier, Style},
8    text::{Line, Span},
9    widgets::{Block, BorderType, Borders, Paragraph, Row, Table, TableState},
10    Frame,
11};
12
13use chrono::DateTime;
14
15use crossterm::event::{KeyCode, KeyEvent};
16use tokio::sync::mpsc::UnboundedSender;
17
18use crate::banner::{render_animated_banner, render_banner};
19use crate::tui::app::{App, Operation, OperationState, RepoEntry, Screen};
20use crate::tui::event::AppEvent;
21
22// ── Key handler ─────────────────────────────────────────────────────────────
23
24pub async fn handle_key(app: &mut App, key: KeyEvent, backend_tx: &UnboundedSender<AppEvent>) {
25    match key.code {
26        KeyCode::Char('s') => {
27            start_sync_operation(app, backend_tx);
28        }
29        KeyCode::Char('p') => {
30            show_sync_progress(app);
31        }
32        KeyCode::Char('t') => {
33            app.last_status_scan = None; // Force immediate refresh
34            app.status_loading = true;
35            start_operation(app, Operation::Status, backend_tx);
36        }
37        // Tab shortcuts
38        KeyCode::Char('o') => {
39            app.stat_index = 0;
40            app.dashboard_table_state.select(Some(0));
41        }
42        KeyCode::Char('r') => {
43            app.stat_index = 1;
44            app.dashboard_table_state.select(Some(0));
45        }
46        KeyCode::Char('c') => {
47            app.stat_index = 2;
48            app.dashboard_table_state.select(Some(0));
49        }
50        KeyCode::Char('b') => {
51            app.stat_index = 3;
52            app.dashboard_table_state.select(Some(0));
53        }
54        KeyCode::Char('a') => {
55            app.stat_index = 4;
56            app.dashboard_table_state.select(Some(0));
57        }
58        KeyCode::Char('u') => {
59            app.stat_index = 5;
60            app.dashboard_table_state.select(Some(0));
61        }
62        KeyCode::Char('e') => {
63            app.navigate_to(Screen::Settings);
64        }
65        KeyCode::Char('w') => {
66            app.navigate_to(Screen::Workspaces);
67        }
68        KeyCode::Char('i') => {
69            app.navigate_to(Screen::Settings);
70        }
71        KeyCode::Char('/') => {
72            app.filter_active = true;
73            app.filter_text.clear();
74            app.stat_index = 1;
75            app.dashboard_table_state.select(Some(0));
76        }
77        // Tab navigation (left/right between stat boxes)
78        KeyCode::Left => {
79            app.stat_index = app.stat_index.saturating_sub(1);
80            app.dashboard_table_state.select(Some(0));
81        }
82        KeyCode::Right => {
83            if app.stat_index < 5 {
84                app.stat_index += 1;
85                app.dashboard_table_state.select(Some(0));
86            }
87        }
88        // List navigation (up/down within tab content)
89        KeyCode::Down => {
90            let count = tab_item_count(app);
91            if count > 0 {
92                let current = app.dashboard_table_state.selected().unwrap_or(0);
93                if current + 1 < count {
94                    app.dashboard_table_state.select(Some(current + 1));
95                }
96            }
97        }
98        KeyCode::Up => {
99            let count = tab_item_count(app);
100            if count > 0 {
101                let current = app.dashboard_table_state.selected().unwrap_or(0);
102                app.dashboard_table_state
103                    .select(Some(current.saturating_sub(1)));
104            }
105        }
106        KeyCode::Enter => {
107            // Open the selected repo's folder
108            if let Some(path) = selected_repo_path(app) {
109                let _ = std::process::Command::new("open").arg(&path).spawn();
110            }
111        }
112        _ => {}
113    }
114}
115
116fn start_operation(app: &mut App, operation: Operation, backend_tx: &UnboundedSender<AppEvent>) {
117    if matches!(
118        app.operation_state,
119        OperationState::Discovering { .. } | OperationState::Running { .. }
120    ) {
121        app.error_message = Some("An operation is already running".to_string());
122        return;
123    }
124
125    app.tick_count = 0;
126    app.operation_state = OperationState::Discovering {
127        operation,
128        message: format!("Starting {}...", operation),
129    };
130    app.log_lines.clear();
131    app.scroll_offset = 0;
132
133    crate::tui::backend::spawn_operation(operation, app, backend_tx.clone());
134}
135
136pub(crate) fn start_sync_operation(app: &mut App, backend_tx: &UnboundedSender<AppEvent>) {
137    start_operation(app, Operation::Sync, backend_tx);
138}
139
140pub(crate) fn show_sync_progress(app: &mut App) {
141    if !matches!(app.screen, Screen::Sync) {
142        app.screen_stack.push(app.screen);
143        app.screen = Screen::Sync;
144    }
145}
146
147pub(crate) fn hide_sync_progress(app: &mut App) {
148    if !matches!(app.screen, Screen::Sync) {
149        return;
150    }
151
152    if app.screen_stack.is_empty() {
153        app.screen = Screen::Dashboard;
154    } else {
155        app.go_back();
156    }
157}
158
159fn tab_item_count(app: &App) -> usize {
160    match app.stat_index {
161        0 => app
162            .local_repos
163            .iter()
164            .map(|r| r.owner.as_str())
165            .collect::<HashSet<_>>()
166            .len(),
167        1 => {
168            if app.filter_text.is_empty() {
169                app.local_repos.len()
170            } else {
171                let ft = app.filter_text.to_lowercase();
172                app.local_repos
173                    .iter()
174                    .filter(|r| r.full_name.to_lowercase().contains(&ft))
175                    .count()
176            }
177        }
178        2 => app
179            .local_repos
180            .iter()
181            .filter(|r| !r.is_uncommitted && r.behind == 0 && r.ahead == 0)
182            .count(),
183        3 => app.local_repos.iter().filter(|r| r.behind > 0).count(),
184        4 => app.local_repos.iter().filter(|r| r.ahead > 0).count(),
185        5 => app.local_repos.iter().filter(|r| r.is_uncommitted).count(),
186        _ => 0,
187    }
188}
189
190fn selected_repo_path(app: &App) -> Option<std::path::PathBuf> {
191    let selected = app.dashboard_table_state.selected()?;
192    let repos: Vec<&RepoEntry> = match app.stat_index {
193        0 => return None, // Owners tab — no single repo
194        1 => {
195            if app.filter_text.is_empty() {
196                app.local_repos.iter().collect()
197            } else {
198                let ft = app.filter_text.to_lowercase();
199                app.local_repos
200                    .iter()
201                    .filter(|r| r.full_name.to_lowercase().contains(&ft))
202                    .collect()
203            }
204        }
205        2 => app
206            .local_repos
207            .iter()
208            .filter(|r| !r.is_uncommitted && r.behind == 0 && r.ahead == 0)
209            .collect(),
210        3 => app.local_repos.iter().filter(|r| r.behind > 0).collect(),
211        4 => app.local_repos.iter().filter(|r| r.ahead > 0).collect(),
212        5 => app
213            .local_repos
214            .iter()
215            .filter(|r| r.is_uncommitted)
216            .collect(),
217        _ => return None,
218    };
219    repos.get(selected).map(|r| r.path.clone())
220}
221
222// ── Render ──────────────────────────────────────────────────────────────────
223
224pub(crate) fn format_timestamp(raw: &str) -> String {
225    use chrono::Utc;
226
227    let parsed = DateTime::parse_from_rfc3339(raw);
228    match parsed {
229        Ok(dt) => {
230            let absolute = dt.format("%Y-%m-%d %H:%M:%S").to_string();
231            let duration = Utc::now().signed_duration_since(dt);
232            let relative = if duration.num_days() > 30 {
233                format!("about {}mo ago", duration.num_days() / 30)
234            } else if duration.num_days() > 0 {
235                format!("about {}d ago", duration.num_days())
236            } else if duration.num_hours() > 0 {
237                format!("about {}h ago", duration.num_hours())
238            } else if duration.num_minutes() > 0 {
239                format!("about {} min ago", duration.num_minutes())
240            } else {
241                "just now".to_string()
242            };
243            format!("{} at {}", relative, absolute)
244        }
245        Err(_) => raw.to_string(),
246    }
247}
248
249fn sync_banner_phase(app: &App) -> Option<f64> {
250    match &app.operation_state {
251        OperationState::Discovering {
252            operation: Operation::Sync,
253            ..
254        }
255        | OperationState::Running {
256            operation: Operation::Sync,
257            ..
258        } => Some((app.tick_count as f64 / 50.0).fract()),
259        _ => None,
260    }
261}
262
263pub fn render(app: &mut App, frame: &mut Frame) {
264    let chunks = Layout::vertical([
265        Constraint::Length(6), // Banner
266        Constraint::Length(1), // Tagline
267        Constraint::Length(1), // Requirements
268        Constraint::Length(1), // Workspace
269        Constraint::Length(4), // Stats
270        Constraint::Min(1),    // Spacer
271        Constraint::Length(2), // Bottom actions
272    ])
273    .split(frame.area());
274
275    if let Some(phase) = sync_banner_phase(app) {
276        render_animated_banner(frame, chunks[0], phase);
277    } else {
278        render_banner(frame, chunks[0]);
279    }
280    render_tagline(frame, chunks[1]);
281    render_config_reqs(app, frame, chunks[2]);
282    render_workspace_info(app, frame, chunks[3]);
283    let stat_cols = render_stats(app, frame, chunks[4]);
284    let table_area = Rect {
285        y: chunks[5].y + 1,
286        height: chunks[5].height.saturating_sub(1),
287        ..chunks[5]
288    };
289    render_tab_content(app, frame, table_area);
290    render_tab_connector(frame, &stat_cols, chunks[5], app.stat_index);
291    render_bottom_actions(app, frame, chunks[6]);
292}
293
294fn render_tagline(frame: &mut Frame, area: Rect) {
295    let description = crate::banner::subheadline();
296
297    let line = Line::from(Span::styled(
298        description,
299        Style::default()
300            .fg(Color::DarkGray)
301            .add_modifier(Modifier::BOLD),
302    ));
303    let p = Paragraph::new(vec![line]).centered();
304    frame.render_widget(p, area);
305}
306
307fn render_info_line(frame: &mut Frame, area: Rect, left: Vec<Span>, right: Vec<Span>) {
308    let cols =
309        Layout::horizontal([Constraint::Percentage(41), Constraint::Percentage(59)]).split(area);
310    frame.render_widget(Paragraph::new(Line::from(left)).right_aligned(), cols[0]);
311    frame.render_widget(Paragraph::new(Line::from(right)), cols[1]);
312}
313
314fn render_config_reqs(app: &App, frame: &mut Frame, area: Rect) {
315    let dim = Style::default().fg(Color::DarkGray);
316
317    let key_style = Style::default()
318        .fg(Color::Rgb(37, 99, 235))
319        .add_modifier(Modifier::BOLD);
320    let left = vec![
321        Span::styled("[e]", key_style),
322        Span::styled(" Settings    ", dim),
323    ];
324
325    let right = if app.checks_loading || app.check_results.is_empty() {
326        vec![
327            Span::styled(" Checking...", Style::default().fg(Color::Yellow)),
328            Span::raw("  "),
329            Span::styled("[t]", key_style),
330            Span::styled(" Refresh", dim),
331        ]
332    } else {
333        let all_passed = app.check_results.iter().all(|c| c.passed);
334        if all_passed {
335            vec![
336                Span::styled(" [✓]", Style::default().fg(Color::Rgb(21, 128, 61))),
337                Span::styled(" Requirements Satisfied", dim),
338                Span::raw("  "),
339                Span::styled("[t]", key_style),
340                Span::styled(" Refresh", dim),
341            ]
342        } else {
343            vec![
344                Span::styled(" [✗]", Style::default().fg(Color::Red)),
345                Span::styled(" Requirements Not Met", dim),
346                Span::raw("  "),
347                Span::styled("[t]", key_style),
348                Span::styled(" Refresh", dim),
349            ]
350        }
351    };
352
353    render_info_line(frame, area, left, right);
354}
355
356fn render_workspace_info(app: &App, frame: &mut Frame, area: Rect) {
357    let dim = Style::default().fg(Color::DarkGray);
358    let key_style = Style::default()
359        .fg(Color::Rgb(37, 99, 235))
360        .add_modifier(Modifier::BOLD);
361    match &app.active_workspace {
362        Some(ws) => {
363            let folder_name = ws
364                .root_path
365                .file_name()
366                .and_then(|n| n.to_str())
367                .unwrap_or_else(|| ws.root_path.to_str().unwrap_or("workspace"))
368                .to_string();
369
370            render_info_line(
371                frame,
372                area,
373                vec![
374                    Span::styled("[w]", key_style),
375                    Span::styled(" Workspace   ", dim),
376                ],
377                vec![
378                    Span::styled(" [✓]", Style::default().fg(Color::Rgb(21, 128, 61))),
379                    Span::styled(" Folder ", dim),
380                    Span::styled(
381                        folder_name,
382                        Style::default()
383                            .fg(Color::Rgb(21, 128, 61))
384                            .add_modifier(Modifier::BOLD),
385                    ),
386                    Span::raw("  "),
387                    Span::styled("[/]", key_style),
388                    Span::styled(" Search Repositories", dim),
389                ],
390            );
391        }
392        None => {
393            let p = Paragraph::new(Line::from(Span::styled(
394                "No workspace selected",
395                Style::default().fg(Color::Yellow),
396            )))
397            .centered();
398            frame.render_widget(p, area);
399        }
400    }
401}
402
403fn render_stats(app: &App, frame: &mut Frame, area: Rect) -> [Rect; 6] {
404    let cols = Layout::horizontal([
405        Constraint::Ratio(1, 6),
406        Constraint::Ratio(1, 6),
407        Constraint::Ratio(1, 6),
408        Constraint::Ratio(1, 6),
409        Constraint::Ratio(1, 6),
410        Constraint::Ratio(1, 6),
411    ])
412    .split(area);
413
414    let completed_repos = app.local_repos.len();
415    let completed_owners = app
416        .local_repos
417        .iter()
418        .map(|r| r.owner.as_str())
419        .collect::<HashSet<_>>()
420        .len();
421    let discovered_repos = app.all_repos.len();
422    let discovered_owners = app
423        .all_repos
424        .iter()
425        .map(|r| r.owner.as_str())
426        .collect::<HashSet<_>>()
427        .len();
428    let total_repos = discovered_repos.max(completed_repos);
429    let total_owners = discovered_owners.max(completed_owners);
430    let owners_progress = format!("{}/{}", completed_owners, total_owners);
431    let repos_progress = total_repos.to_string();
432    let uncommitted = app.local_repos.iter().filter(|r| r.is_uncommitted).count();
433    let behind = app.local_repos.iter().filter(|r| r.behind > 0).count();
434    let ahead = app.local_repos.iter().filter(|r| r.ahead > 0).count();
435    let clean = app
436        .local_repos
437        .iter()
438        .filter(|r| !r.is_uncommitted && r.behind == 0 && r.ahead == 0)
439        .count();
440
441    let selected = app.stat_index;
442    render_stat_box(
443        frame,
444        cols[0],
445        &owners_progress,
446        "o",
447        "Owners",
448        Color::Rgb(21, 128, 61),
449        selected == 0,
450    );
451    render_stat_box(
452        frame,
453        cols[1],
454        &repos_progress,
455        "r",
456        "Repositories",
457        Color::Rgb(21, 128, 61),
458        selected == 1,
459    );
460    render_stat_box(
461        frame,
462        cols[2],
463        &clean.to_string(),
464        "c",
465        "Clean",
466        Color::Rgb(21, 128, 61),
467        selected == 2,
468    );
469    render_stat_box(
470        frame,
471        cols[3],
472        &behind.to_string(),
473        "b",
474        "Behind",
475        Color::Rgb(21, 128, 61),
476        selected == 3,
477    );
478    render_stat_box(
479        frame,
480        cols[4],
481        &ahead.to_string(),
482        "a",
483        "Ahead",
484        Color::Rgb(21, 128, 61),
485        selected == 4,
486    );
487    render_stat_box(
488        frame,
489        cols[5],
490        &uncommitted.to_string(),
491        "u",
492        "Uncommitted",
493        Color::Rgb(21, 128, 61),
494        selected == 5,
495    );
496
497    [cols[0], cols[1], cols[2], cols[3], cols[4], cols[5]]
498}
499
500fn render_stat_box(
501    frame: &mut Frame,
502    area: Rect,
503    value: &str,
504    key: &str,
505    label: &str,
506    color: Color,
507    selected: bool,
508) {
509    let (border_style, borders, border_type) = if selected {
510        (
511            Style::default().fg(color).add_modifier(Modifier::BOLD),
512            Borders::TOP | Borders::LEFT | Borders::RIGHT,
513            BorderType::Thick,
514        )
515    } else {
516        (
517            Style::default().fg(Color::DarkGray),
518            Borders::ALL,
519            BorderType::Plain,
520        )
521    };
522    let block = Block::default()
523        .borders(borders)
524        .border_type(border_type)
525        .border_style(border_style);
526    let content = Paragraph::new(vec![
527        Line::from(Span::styled(
528            value,
529            Style::default().fg(color).add_modifier(Modifier::BOLD),
530        )),
531        Line::from(vec![
532            Span::styled(
533                format!("[{}]", key),
534                Style::default()
535                    .fg(Color::Rgb(37, 99, 235))
536                    .add_modifier(Modifier::BOLD),
537            ),
538            Span::raw(" "),
539            Span::styled(label, Style::default().fg(Color::DarkGray)),
540        ]),
541    ])
542    .centered()
543    .block(block);
544    frame.render_widget(content, area);
545}
546
547fn tab_color(_stat_index: usize) -> Color {
548    Color::Rgb(21, 128, 61)
549}
550
551fn render_tab_connector(
552    frame: &mut Frame,
553    stat_cols: &[Rect; 6],
554    content_area: Rect,
555    selected: usize,
556) {
557    let color = tab_color(selected);
558    let style = Style::default().fg(color).add_modifier(Modifier::BOLD);
559    let y = content_area.y;
560    let x_start = content_area.x;
561    let x_end = content_area.x + content_area.width.saturating_sub(1);
562    let tab_left = stat_cols[selected].x;
563    let tab_right = stat_cols[selected].x + stat_cols[selected].width.saturating_sub(1);
564
565    let buf = frame.buffer_mut();
566
567    for x in x_start..=x_end {
568        let symbol = if (x == tab_left && x == x_start) || (x == tab_right && x == x_end) {
569            "┃" // tab edge aligns with content edge: vertical continues
570        } else if x == tab_left {
571            "┛" // horizontal from left meets tab's left border going up
572        } else if x == tab_right {
573            "┗" // tab's right border going up meets horizontal going right
574        } else if x > tab_left && x < tab_right {
575            " " // gap under the selected tab
576        } else if x == x_start {
577            "┏" // content top-left corner
578        } else if x == x_end {
579            "┓" // content top-right corner
580        } else {
581            "━" // thick horizontal line
582        };
583
584        if let Some(cell) = buf.cell_mut(Position::new(x, y)) {
585            cell.set_symbol(symbol);
586            cell.set_style(style);
587        }
588    }
589}
590
591fn render_tab_content(app: &mut App, frame: &mut Frame, area: Rect) {
592    if area.height < 2 {
593        return;
594    }
595
596    let color = tab_color(app.stat_index);
597    let mut table_state = app.dashboard_table_state;
598    match app.stat_index {
599        0 => render_owners_tab(app, frame, area, color, &mut table_state),
600        1 => render_repos_tab(app, frame, area, color, &mut table_state),
601        2 => render_clean_tab(app, frame, area, color, &mut table_state),
602        3 => render_behind_tab(app, frame, area, color, &mut table_state),
603        4 => render_ahead_tab(app, frame, area, color, &mut table_state),
604        5 => render_uncommitted_tab(app, frame, area, color, &mut table_state),
605        _ => {}
606    }
607    app.dashboard_table_state = table_state;
608}
609
610fn render_owners_tab(
611    app: &App,
612    frame: &mut Frame,
613    area: Rect,
614    color: Color,
615    table_state: &mut TableState,
616) {
617    // (total, behind, ahead, uncommitted)
618    let mut owner_stats: HashMap<&str, (usize, usize, usize, usize)> = HashMap::new();
619    for r in &app.local_repos {
620        let entry = owner_stats.entry(r.owner.as_str()).or_insert((0, 0, 0, 0));
621        entry.0 += 1;
622        if r.behind > 0 {
623            entry.1 += 1;
624        }
625        if r.ahead > 0 {
626            entry.2 += 1;
627        }
628        if r.is_uncommitted {
629            entry.3 += 1;
630        }
631    }
632
633    let mut owners: Vec<(&str, usize, usize, usize, usize)> = owner_stats
634        .into_iter()
635        .map(|(name, (total, behind, ahead, uncommitted))| {
636            (name, total, behind, ahead, uncommitted)
637        })
638        .collect();
639    owners.sort_by_key(|(name, _, _, _, _)| name.to_lowercase());
640
641    let header_cols = vec!["#", "Owner", "Repos", "Behind", "Ahead", "Uncommitted"];
642    let widths = [
643        Constraint::Length(4),
644        Constraint::Percentage(35),
645        Constraint::Percentage(15),
646        Constraint::Percentage(15),
647        Constraint::Percentage(15),
648        Constraint::Percentage(20),
649    ];
650
651    let rows: Vec<Row> = owners
652        .iter()
653        .enumerate()
654        .map(|(i, (name, total, behind, ahead, uncommitted))| {
655            let fmt = |n: &usize| {
656                if *n > 0 {
657                    n.to_string()
658                } else {
659                    ".".to_string()
660                }
661            };
662            Row::new(vec![
663                format!("{:>4}", i + 1),
664                name.to_string(),
665                total.to_string(),
666                fmt(behind),
667                fmt(ahead),
668                fmt(uncommitted),
669            ])
670        })
671        .collect();
672
673    render_table_block(frame, area, &header_cols, rows, &widths, color, table_state);
674}
675
676fn render_repos_tab(
677    app: &App,
678    frame: &mut Frame,
679    area: Rect,
680    color: Color,
681    table_state: &mut TableState,
682) {
683    let repos: Vec<&RepoEntry> = if app.filter_text.is_empty() {
684        app.local_repos.iter().collect()
685    } else {
686        let ft = app.filter_text.to_lowercase();
687        app.local_repos
688            .iter()
689            .filter(|r| r.full_name.to_lowercase().contains(&ft))
690            .collect()
691    };
692
693    let header_cols = vec!["#", "Org/Repo", "Branch", "Uncommitted", "Ahead", "Behind"];
694    let widths = [
695        Constraint::Length(4),
696        Constraint::Percentage(35),
697        Constraint::Percentage(20),
698        Constraint::Percentage(15),
699        Constraint::Percentage(15),
700        Constraint::Percentage(15),
701    ];
702
703    let rows: Vec<Row> = repos
704        .iter()
705        .enumerate()
706        .map(|(i, entry)| {
707            let branch = entry.branch.as_deref().unwrap_or("-");
708            Row::new(vec![
709                format!("{:>4}", i + 1),
710                entry.full_name.clone(),
711                branch.to_string(),
712                fmt_flag(entry.is_uncommitted),
713                fmt_count_plus(entry.ahead),
714                fmt_count_minus(entry.behind),
715            ])
716        })
717        .collect();
718
719    render_table_block(frame, area, &header_cols, rows, &widths, color, table_state);
720}
721
722fn render_clean_tab(
723    app: &App,
724    frame: &mut Frame,
725    area: Rect,
726    color: Color,
727    table_state: &mut TableState,
728) {
729    let repos: Vec<&RepoEntry> = app
730        .local_repos
731        .iter()
732        .filter(|r| !r.is_uncommitted && r.behind == 0 && r.ahead == 0)
733        .collect();
734
735    let header_cols = vec!["#", "Org/Repo", "Branch"];
736    let widths = [
737        Constraint::Length(4),
738        Constraint::Percentage(60),
739        Constraint::Percentage(40),
740    ];
741
742    let rows: Vec<Row> = repos
743        .iter()
744        .enumerate()
745        .map(|(i, entry)| {
746            let branch = entry.branch.as_deref().unwrap_or("-");
747            Row::new(vec![
748                format!("{:>4}", i + 1),
749                entry.full_name.clone(),
750                branch.to_string(),
751            ])
752        })
753        .collect();
754
755    render_table_block(frame, area, &header_cols, rows, &widths, color, table_state);
756}
757
758fn render_behind_tab(
759    app: &App,
760    frame: &mut Frame,
761    area: Rect,
762    color: Color,
763    table_state: &mut TableState,
764) {
765    let repos: Vec<&RepoEntry> = app.local_repos.iter().filter(|r| r.behind > 0).collect();
766
767    let header_cols = vec!["#", "Org/Repo", "Branch", "Behind"];
768    let widths = [
769        Constraint::Length(4),
770        Constraint::Percentage(45),
771        Constraint::Percentage(30),
772        Constraint::Percentage(25),
773    ];
774
775    let rows: Vec<Row> = repos
776        .iter()
777        .enumerate()
778        .map(|(i, entry)| {
779            let branch = entry.branch.as_deref().unwrap_or("-");
780            Row::new(vec![
781                format!("{:>4}", i + 1),
782                entry.full_name.clone(),
783                branch.to_string(),
784                fmt_count_minus(entry.behind),
785            ])
786        })
787        .collect();
788
789    render_table_block(frame, area, &header_cols, rows, &widths, color, table_state);
790}
791
792fn render_ahead_tab(
793    app: &App,
794    frame: &mut Frame,
795    area: Rect,
796    color: Color,
797    table_state: &mut TableState,
798) {
799    let repos: Vec<&RepoEntry> = app.local_repos.iter().filter(|r| r.ahead > 0).collect();
800
801    let header_cols = vec!["#", "Org/Repo", "Branch", "Ahead"];
802    let widths = [
803        Constraint::Length(4),
804        Constraint::Percentage(45),
805        Constraint::Percentage(30),
806        Constraint::Percentage(25),
807    ];
808
809    let rows: Vec<Row> = repos
810        .iter()
811        .enumerate()
812        .map(|(i, entry)| {
813            let branch = entry.branch.as_deref().unwrap_or("-");
814            Row::new(vec![
815                format!("{:>4}", i + 1),
816                entry.full_name.clone(),
817                branch.to_string(),
818                fmt_count_plus(entry.ahead),
819            ])
820        })
821        .collect();
822
823    render_table_block(frame, area, &header_cols, rows, &widths, color, table_state);
824}
825
826fn render_uncommitted_tab(
827    app: &App,
828    frame: &mut Frame,
829    area: Rect,
830    color: Color,
831    table_state: &mut TableState,
832) {
833    let repos: Vec<&RepoEntry> = app
834        .local_repos
835        .iter()
836        .filter(|r| r.is_uncommitted)
837        .collect();
838
839    let header_cols = vec!["#", "Org/Repo", "Branch", "Staged", "Unstaged", "Untracked"];
840    let widths = [
841        Constraint::Length(4),
842        Constraint::Percentage(30),
843        Constraint::Percentage(22),
844        Constraint::Percentage(16),
845        Constraint::Percentage(16),
846        Constraint::Percentage(16),
847    ];
848
849    let rows: Vec<Row> = repos
850        .iter()
851        .enumerate()
852        .map(|(i, entry)| {
853            let branch = entry.branch.as_deref().unwrap_or("-");
854            let fmt_n = |n: usize| {
855                if n > 0 {
856                    n.to_string()
857                } else {
858                    ".".to_string()
859                }
860            };
861            Row::new(vec![
862                format!("{:>4}", i + 1),
863                entry.full_name.clone(),
864                branch.to_string(),
865                fmt_n(entry.staged_count),
866                fmt_n(entry.unstaged_count),
867                fmt_n(entry.untracked_count),
868            ])
869        })
870        .collect();
871
872    render_table_block(frame, area, &header_cols, rows, &widths, color, table_state);
873}
874
875// -- Shared helpers --
876
877fn fmt_flag(flag: bool) -> String {
878    if flag {
879        "*".to_string()
880    } else {
881        ".".to_string()
882    }
883}
884
885fn fmt_count_plus(n: usize) -> String {
886    if n > 0 {
887        format!("+{}", n)
888    } else {
889        ".".to_string()
890    }
891}
892
893fn fmt_count_minus(n: usize) -> String {
894    if n > 0 {
895        format!("-{}", n)
896    } else {
897        ".".to_string()
898    }
899}
900
901fn render_table_block(
902    frame: &mut Frame,
903    area: Rect,
904    header_cols: &[&str],
905    rows: Vec<Row>,
906    widths: &[Constraint],
907    color: Color,
908    table_state: &mut TableState,
909) {
910    let border_style = Style::default().fg(color).add_modifier(Modifier::BOLD);
911    let block = Block::default()
912        .borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM)
913        .border_type(BorderType::Thick)
914        .border_style(border_style);
915
916    if rows.is_empty() {
917        let msg = Paragraph::new(Line::from(Span::styled(
918            "  No repositories in this category.",
919            Style::default().fg(Color::DarkGray),
920        )))
921        .block(block);
922        frame.render_widget(msg, area);
923        return;
924    }
925
926    let header = Row::new(
927        header_cols
928            .iter()
929            .map(|s| s.to_string())
930            .collect::<Vec<_>>(),
931    )
932    .style(
933        Style::default()
934            .fg(Color::Rgb(21, 128, 61))
935            .add_modifier(Modifier::BOLD),
936    )
937    .bottom_margin(1);
938
939    let table = Table::new(rows, widths)
940        .header(header)
941        .row_highlight_style(
942            Style::default()
943                .fg(Color::Rgb(21, 128, 61))
944                .add_modifier(Modifier::BOLD),
945        )
946        .block(block);
947    frame.render_stateful_widget(table, area, table_state);
948}
949
950fn render_bottom_actions(app: &App, frame: &mut Frame, area: Rect) {
951    let rows = Layout::vertical([
952        Constraint::Length(1), // Actions + sync info
953        Constraint::Length(1), // Navigation
954    ])
955    .split(area);
956
957    let dim = Style::default().fg(Color::DarkGray);
958    let key_style = Style::default()
959        .fg(Color::Rgb(37, 99, 235))
960        .add_modifier(Modifier::BOLD);
961
962    // Line 1: live sync status (centered full-width) + action hints (right overlay)
963    let sync_line = match &app.operation_state {
964        OperationState::Discovering {
965            operation: Operation::Sync,
966            message,
967        } => Some(Line::from(vec![
968            Span::styled(
969                "Sync ",
970                Style::default()
971                    .fg(Color::Cyan)
972                    .add_modifier(Modifier::BOLD),
973            ),
974            Span::styled("discovering in background", dim),
975            Span::styled(": ", dim),
976            Span::styled(message.clone(), dim),
977        ])),
978        OperationState::Running {
979            operation: Operation::Sync,
980            completed,
981            total,
982            started_at,
983            throughput_samples,
984            active_repos,
985            ..
986        } => {
987            let pct = if *total > 0 {
988                ((*completed as f64 / *total as f64) * 100.0).round() as u64
989            } else {
990                0
991            };
992            let elapsed_secs = started_at.elapsed().as_secs_f64();
993            let sample_count = throughput_samples.len().min(10);
994            let sample_rate = if sample_count > 0 {
995                throughput_samples
996                    .iter()
997                    .rev()
998                    .take(sample_count)
999                    .copied()
1000                    .sum::<u64>() as f64
1001                    / sample_count as f64
1002            } else {
1003                0.0
1004            };
1005            let repos_per_sec = if sample_rate > 0.0 {
1006                sample_rate
1007            } else if elapsed_secs > 1.0 {
1008                *completed as f64 / elapsed_secs
1009            } else {
1010                0.0
1011            };
1012            let remaining = total.saturating_sub(*completed);
1013            let has_eta_data = throughput_samples.iter().any(|&n| n > 0);
1014            let eta_secs = if has_eta_data && repos_per_sec > 0.1 {
1015                Some((remaining as f64 / repos_per_sec).ceil() as u64)
1016            } else {
1017                None
1018            };
1019            let concurrency = app
1020                .active_workspace
1021                .as_ref()
1022                .and_then(|ws| ws.concurrency)
1023                .unwrap_or(app.config.concurrency);
1024
1025            let mut spans = vec![
1026                Span::styled(
1027                    "Sync ",
1028                    Style::default()
1029                        .fg(Color::Cyan)
1030                        .add_modifier(Modifier::BOLD),
1031                ),
1032                Span::styled("running in background ", dim),
1033                Span::styled(format!("{}%", pct), Style::default().fg(Color::Cyan)),
1034                Span::styled(format!(" ({}/{})", completed, total), dim),
1035            ];
1036
1037            if repos_per_sec > 0.0 {
1038                spans.push(Span::styled(
1039                    format!("  |  {:.1} repo/s", repos_per_sec),
1040                    Style::default().fg(Color::DarkGray),
1041                ));
1042            }
1043            if let Some(eta_secs) = eta_secs.filter(|_| remaining > 0) {
1044                spans.push(Span::styled(
1045                    format!("  |  ETA {}", format_duration_secs(eta_secs)),
1046                    Style::default().fg(Color::Cyan),
1047                ));
1048            }
1049            spans.push(Span::styled(
1050                format!("  |  workers {}/{}", active_repos.len(), concurrency),
1051                Style::default().fg(Color::DarkGray),
1052            ));
1053            spans.push(Span::styled("  |  show ", dim));
1054            spans.push(Span::styled("[p]", key_style));
1055            spans.push(Span::styled(" progress", dim));
1056            Some(Line::from(spans))
1057        }
1058        OperationState::Finished {
1059            operation: Operation::Sync,
1060            summary,
1061            with_updates,
1062            duration_secs,
1063            ..
1064        } => {
1065            let total = summary.success + summary.failed + summary.skipped;
1066            Some(Line::from(vec![
1067                Span::styled(
1068                    "Last Sync ",
1069                    Style::default()
1070                        .fg(Color::Rgb(21, 128, 61))
1071                        .add_modifier(Modifier::BOLD),
1072                ),
1073                Span::styled(
1074                    format!("{} repos", total),
1075                    Style::default().fg(Color::Rgb(21, 128, 61)),
1076                ),
1077                Span::styled(
1078                    format!("  |  {} updated", with_updates),
1079                    Style::default().fg(Color::Yellow),
1080                ),
1081                Span::styled(
1082                    format!("  |  {} failed", summary.failed),
1083                    if summary.failed > 0 {
1084                        Style::default().fg(Color::Red)
1085                    } else {
1086                        Style::default().fg(Color::DarkGray)
1087                    },
1088                ),
1089                Span::styled(
1090                    format!("  |  {:.1}s", duration_secs),
1091                    Style::default().fg(Color::DarkGray),
1092                ),
1093                Span::styled("  |  details ", dim),
1094                Span::styled("[p]", key_style),
1095            ]))
1096        }
1097        _ => app.active_workspace.as_ref().and_then(|ws| {
1098            ws.last_synced.as_ref().map(|ts| {
1099                let folder_name_owned = ws
1100                    .root_path
1101                    .file_name()
1102                    .and_then(|n| n.to_str())
1103                    .unwrap_or_else(|| ws.root_path.to_str().unwrap_or("workspace"))
1104                    .to_string();
1105                let folder_name = folder_name_owned.as_str();
1106                let formatted = format_timestamp(ts);
1107                Line::from(vec![
1108                    Span::styled("Synced ", dim),
1109                    Span::styled(
1110                        folder_name.to_string(),
1111                        Style::default()
1112                            .fg(Color::Rgb(21, 128, 61))
1113                            .add_modifier(Modifier::BOLD),
1114                    ),
1115                    Span::styled(" with GitHub ", dim),
1116                    Span::styled(formatted, dim),
1117                ])
1118            })
1119        }),
1120    };
1121    if let Some(sync_line) = sync_line {
1122        frame.render_widget(Paragraph::new(vec![sync_line]).centered(), rows[0]);
1123    }
1124
1125    let actions_right = Line::from(vec![
1126        Span::styled("[s]", key_style),
1127        Span::styled(" Start Sync", dim),
1128        Span::raw("   "),
1129        Span::styled("[p]", key_style),
1130        Span::styled(" Show Sync Progress", dim),
1131        Span::raw(" "),
1132    ]);
1133    frame.render_widget(Paragraph::new(vec![actions_right]).right_aligned(), rows[0]);
1134
1135    // Line 2: Navigation — left-aligned (Quit, Back) and right-aligned (Left, Right, Select)
1136    let nav_cols =
1137        Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).split(rows[1]);
1138
1139    let left_spans = vec![
1140        Span::raw(" "),
1141        Span::styled("[q]", key_style),
1142        Span::styled(" Quit", dim),
1143        Span::raw("   "),
1144        Span::styled("[Esc]", key_style),
1145        Span::styled(" Back", dim),
1146    ];
1147
1148    let right_spans = vec![
1149        Span::styled("[←]", key_style),
1150        Span::raw(" "),
1151        Span::styled("[↑]", key_style),
1152        Span::raw(" "),
1153        Span::styled("[↓]", key_style),
1154        Span::raw(" "),
1155        Span::styled("[→]", key_style),
1156        Span::styled(" Move", dim),
1157        Span::raw("   "),
1158        Span::styled("[Enter]", key_style),
1159        Span::styled(" Select", dim),
1160        Span::raw(" "),
1161    ];
1162
1163    let nav_left = Paragraph::new(vec![Line::from(left_spans)]);
1164    let nav_right = Paragraph::new(vec![Line::from(right_spans)]).right_aligned();
1165
1166    frame.render_widget(nav_left, nav_cols[0]);
1167    frame.render_widget(nav_right, nav_cols[1]);
1168}
1169
1170fn format_duration_secs(secs: u64) -> String {
1171    if secs >= 60 {
1172        format!("{}m{}s", secs / 60, secs % 60)
1173    } else {
1174        format!("{}s", secs)
1175    }
1176}
1177
1178#[cfg(test)]
1179#[path = "dashboard_tests.rs"]
1180mod tests;