//! Sync screen view — progress bars, summary table, and log. //! //! Renders the sync screen in different phases: //! - **Idle**: prompt to start sync //! - **Running**: per-lane progress bars with throughput stats //! - **Complete**: summary table with change counts //! - **Cancelled/Failed**: status message with retry hint use ftui::core::geometry::Rect; use ftui::render::cell::{Cell, PackedRgba}; use ftui::render::drawing::Draw; use ftui::render::frame::Frame; use crate::state::sync::{SyncLane, SyncPhase, SyncState}; use super::{ACCENT, TEXT, TEXT_MUTED}; /// Progress bar fill color. const PROGRESS_FG: PackedRgba = PackedRgba::rgb(0xDA, 0x70, 0x2C); // orange /// Progress bar background. const PROGRESS_BG: PackedRgba = PackedRgba::rgb(0x34, 0x34, 0x30); /// Success green. const SUCCESS_FG: PackedRgba = PackedRgba::rgb(0x87, 0x9A, 0x39); /// Error red. const ERROR_FG: PackedRgba = PackedRgba::rgb(0xD1, 0x4D, 0x41); // --------------------------------------------------------------------------- // Public entry point // --------------------------------------------------------------------------- /// Render the sync screen. pub fn render_sync(frame: &mut Frame<'_>, state: &SyncState, area: Rect) { if area.width < 10 || area.height < 3 { return; } match &state.phase { SyncPhase::Idle => render_idle(frame, area), SyncPhase::Running => render_running(frame, state, area), SyncPhase::Complete => render_summary(frame, state, area), SyncPhase::Cancelled => render_cancelled(frame, area), SyncPhase::Failed(err) => render_failed(frame, area, err), } } // --------------------------------------------------------------------------- // Idle view // --------------------------------------------------------------------------- fn render_idle(frame: &mut Frame<'_>, area: Rect) { let max_x = area.right(); let center_y = area.y + area.height / 2; let title = "Sync"; let title_x = area.x + area.width.saturating_sub(title.len() as u16) / 2; frame.print_text_clipped( title_x, center_y.saturating_sub(1), title, Cell { fg: ACCENT, ..Cell::default() }, max_x, ); let hint = "Press Enter to start sync, or run `lore sync` externally."; let hint_x = area.x + area.width.saturating_sub(hint.len() as u16) / 2; frame.print_text_clipped( hint_x, center_y + 1, hint, Cell { fg: TEXT_MUTED, ..Cell::default() }, max_x, ); } // --------------------------------------------------------------------------- // Running view — per-lane progress bars // --------------------------------------------------------------------------- fn render_running(frame: &mut Frame<'_>, state: &SyncState, area: Rect) { let max_x = area.right(); // Title. let title = "Syncing..."; let title_x = area.x + 2; frame.print_text_clipped( title_x, area.y + 1, title, Cell { fg: ACCENT, ..Cell::default() }, max_x, ); // Stage label. if !state.stage.is_empty() { let stage_cell = Cell { fg: TEXT_MUTED, ..Cell::default() }; frame.print_text_clipped(title_x, area.y + 2, &state.stage, stage_cell, max_x); } // Per-lane progress bars. let bar_start_y = area.y + 4; let label_width = 14u16; // "Discussions " is the longest let bar_x = area.x + 2 + label_width; let bar_width = area.width.saturating_sub(4 + label_width + 12); // 12 for count text for (i, lane) in SyncLane::ALL.iter().enumerate() { let y = bar_start_y + i as u16; if y >= area.bottom().saturating_sub(3) { break; } let lane_progress = &state.lanes[i]; // Lane label. let label = format!("{:<12}", lane.label()); frame.print_text_clipped( area.x + 2, y, &label, Cell { fg: TEXT, ..Cell::default() }, bar_x, ); // Progress bar. if bar_width > 2 { render_progress_bar(frame, bar_x, y, bar_width, lane_progress.fraction()); } // Count text (e.g., "50/100"). let count_x = bar_x + bar_width + 1; let count_text = if lane_progress.total > 0 { format!("{}/{}", lane_progress.current, lane_progress.total) } else if lane_progress.current > 0 { format!("{}", lane_progress.current) } else { "--".to_string() }; frame.print_text_clipped( count_x, y, &count_text, Cell { fg: TEXT_MUTED, ..Cell::default() }, max_x, ); } // Throughput stats. let stats_y = bar_start_y + SyncLane::ALL.len() as u16 + 1; if stats_y < area.bottom().saturating_sub(2) && state.items_synced > 0 { let stats = format!( "{} items synced ({:.0} items/sec)", state.items_synced, state.items_per_sec ); frame.print_text_clipped( area.x + 2, stats_y, &stats, Cell { fg: TEXT_MUTED, ..Cell::default() }, max_x, ); } // Cancel hint at bottom. let hint_y = area.bottom().saturating_sub(1); frame.print_text_clipped( area.x + 2, hint_y, "Esc: cancel sync", Cell { fg: TEXT_MUTED, ..Cell::default() }, max_x, ); } /// Render a horizontal progress bar. fn render_progress_bar(frame: &mut Frame<'_>, x: u16, y: u16, width: u16, fraction: f64) { let filled = ((width as f64) * fraction).round() as u16; let max_x = x + width; for col in x..max_x { let is_filled = col < x + filled; let cell = Cell { fg: if is_filled { PROGRESS_FG } else { PROGRESS_BG }, bg: if is_filled { PROGRESS_FG } else { PROGRESS_BG }, ..Cell::default() }; frame.buffer.set(col, y, cell); } } // --------------------------------------------------------------------------- // Summary view // --------------------------------------------------------------------------- fn render_summary(frame: &mut Frame<'_>, state: &SyncState, area: Rect) { let max_x = area.right(); // Title. let title = "Sync Complete"; let title_x = area.x + 2; frame.print_text_clipped( title_x, area.y + 1, title, Cell { fg: SUCCESS_FG, ..Cell::default() }, max_x, ); if let Some(ref summary) = state.summary { // Duration. let duration = format_duration(summary.elapsed_ms); frame.print_text_clipped( title_x, area.y + 2, &format!("Duration: {duration}"), Cell { fg: TEXT_MUTED, ..Cell::default() }, max_x, ); // Summary table header. let table_y = area.y + 4; let header = format!("{:<16} {:>6} {:>8}", "Entity", "New", "Updated"); frame.print_text_clipped( area.x + 2, table_y, &header, Cell { fg: TEXT_MUTED, ..Cell::default() }, max_x, ); // Summary rows. let rows = [ ("Issues", summary.issues.new, summary.issues.updated), ("MRs", summary.merge_requests.new, summary.merge_requests.updated), ("Discussions", summary.discussions.new, summary.discussions.updated), ("Notes", summary.notes.new, summary.notes.updated), ]; for (i, (label, new, updated)) in rows.iter().enumerate() { let row_y = table_y + 1 + i as u16; if row_y >= area.bottom().saturating_sub(3) { break; } let row = format!("{label:<16} {new:>6} {updated:>8}"); let fg = if *new > 0 || *updated > 0 { TEXT } else { TEXT_MUTED }; frame.print_text_clipped( area.x + 2, row_y, &row, Cell { fg, ..Cell::default() }, max_x, ); } // Total. let total_y = table_y + 1 + rows.len() as u16; if total_y < area.bottom().saturating_sub(2) { let total = format!("Total changes: {}", summary.total_changes()); frame.print_text_clipped( area.x + 2, total_y, &total, Cell { fg: ACCENT, ..Cell::default() }, max_x, ); } // Per-project errors. if summary.has_errors() { let err_y = total_y + 2; if err_y < area.bottom().saturating_sub(1) { frame.print_text_clipped( area.x + 2, err_y, "Errors:", Cell { fg: ERROR_FG, ..Cell::default() }, max_x, ); for (i, (project, err)) in summary.project_errors.iter().enumerate() { let y = err_y + 1 + i as u16; if y >= area.bottom().saturating_sub(1) { break; } let line = format!(" {project}: {err}"); frame.print_text_clipped( area.x + 2, y, &line, Cell { fg: ERROR_FG, ..Cell::default() }, max_x, ); } } } } // Navigation hint at bottom. let hint_y = area.bottom().saturating_sub(1); frame.print_text_clipped( area.x + 2, hint_y, "Esc: back | Enter: sync again", Cell { fg: TEXT_MUTED, ..Cell::default() }, max_x, ); } // --------------------------------------------------------------------------- // Cancelled / Failed views // --------------------------------------------------------------------------- fn render_cancelled(frame: &mut Frame<'_>, area: Rect) { let max_x = area.right(); let center_y = area.y + area.height / 2; frame.print_text_clipped( area.x + 2, center_y.saturating_sub(1), "Sync Cancelled", Cell { fg: ACCENT, ..Cell::default() }, max_x, ); frame.print_text_clipped( area.x + 2, center_y + 1, "Press Enter to retry, or Esc to go back.", Cell { fg: TEXT_MUTED, ..Cell::default() }, max_x, ); } fn render_failed(frame: &mut Frame<'_>, area: Rect, error: &str) { let max_x = area.right(); let center_y = area.y + area.height / 2; frame.print_text_clipped( area.x + 2, center_y.saturating_sub(2), "Sync Failed", Cell { fg: ERROR_FG, ..Cell::default() }, max_x, ); // Truncate error to fit screen. let max_len = area.width.saturating_sub(4) as usize; let display_err = if error.len() > max_len { format!("{}...", &error[..error.floor_char_boundary(max_len.saturating_sub(3))]) } else { error.to_string() }; frame.print_text_clipped( area.x + 2, center_y, &display_err, Cell { fg: TEXT, ..Cell::default() }, max_x, ); frame.print_text_clipped( area.x + 2, center_y + 2, "Press Enter to retry, or Esc to go back.", Cell { fg: TEXT_MUTED, ..Cell::default() }, max_x, ); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- fn format_duration(ms: u64) -> String { let secs = ms / 1000; let mins = secs / 60; let remaining_secs = secs % 60; if mins > 0 { format!("{mins}m {remaining_secs}s") } else { format!("{secs}s") } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::state::sync::{EntityChangeCounts, SyncSummary}; use ftui::render::grapheme_pool::GraphemePool; macro_rules! with_frame { ($width:expr, $height:expr, |$frame:ident| $body:block) => {{ let mut pool = GraphemePool::new(); let mut $frame = Frame::new($width, $height, &mut pool); $body }}; } #[test] fn test_render_idle_no_panic() { with_frame!(80, 24, |frame| { let state = SyncState::default(); let area = frame.bounds(); render_sync(&mut frame, &state, area); }); } #[test] fn test_render_running_no_panic() { with_frame!(80, 24, |frame| { let mut state = SyncState::default(); state.start(); state.update_progress("issues", 25, 100); let area = frame.bounds(); render_sync(&mut frame, &state, area); }); } #[test] fn test_render_complete_no_panic() { with_frame!(80, 24, |frame| { let mut state = SyncState::default(); state.start(); state.complete(5000); state.summary = Some(SyncSummary { issues: EntityChangeCounts { new: 5, updated: 3 }, merge_requests: EntityChangeCounts { new: 2, updated: 1 }, elapsed_ms: 5000, ..Default::default() }); let area = frame.bounds(); render_sync(&mut frame, &state, area); }); } #[test] fn test_render_cancelled_no_panic() { with_frame!(80, 24, |frame| { let mut state = SyncState::default(); state.start(); state.cancel(); let area = frame.bounds(); render_sync(&mut frame, &state, area); }); } #[test] fn test_render_failed_no_panic() { with_frame!(80, 24, |frame| { let mut state = SyncState::default(); state.start(); state.fail("network timeout".into()); let area = frame.bounds(); render_sync(&mut frame, &state, area); }); } #[test] fn test_render_tiny_terminal() { with_frame!(8, 2, |frame| { let state = SyncState::default(); let area = frame.bounds(); render_sync(&mut frame, &state, area); // Should not panic. }); } #[test] fn test_render_complete_with_errors() { with_frame!(80, 24, |frame| { let mut state = SyncState::default(); state.start(); state.complete(3000); state.summary = Some(SyncSummary { elapsed_ms: 3000, project_errors: vec![ ("grp/repo".into(), "timeout".into()), ], ..Default::default() }); let area = frame.bounds(); render_sync(&mut frame, &state, area); }); } #[test] fn test_format_duration_seconds() { assert_eq!(format_duration(3500), "3s"); } #[test] fn test_format_duration_minutes() { assert_eq!(format_duration(125_000), "2m 5s"); } #[test] fn test_render_running_with_stats() { with_frame!(80, 24, |frame| { let mut state = SyncState::default(); state.start(); state.update_progress("issues", 50, 200); state.update_stream_stats(1024, 50); let area = frame.bounds(); render_sync(&mut frame, &state, area); }); } }