1use 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
22pub 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; app.status_loading = true;
35 start_operation(app, Operation::Status, backend_tx);
36 }
37 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 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 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 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, 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
222pub(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), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(4), Constraint::Min(1), Constraint::Length(2), ])
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 "┃" } else if x == tab_left {
571 "┛" } else if x == tab_right {
573 "┗" } else if x > tab_left && x < tab_right {
575 " " } else if x == x_start {
577 "┏" } else if x == x_end {
579 "┓" } else {
581 "━" };
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 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
875fn 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), Constraint::Length(1), ])
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 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 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;