Files
gitlore/crates/lore-tui/src/view/sync.rs
teernisse 146eb61623 feat(tui): Phase 4 completion + Phase 5 session/lock/text-width
Phase 4 (bd-1df9) — all 5 acceptance criteria met:
- Sync screen with delta ledger (bd-2x2h, bd-y095)
- Doctor screen with health checks (bd-2iqk)
- Stats screen with document counts (bd-2iqk)
- CLI integration: lore tui subcommand (bd-26lp)
- CLI integration: lore sync --tui flag (bd-3l56)

Phase 5 (bd-3h00) — session persistence + instance lock + text width:
- text_width.rs: Unicode-aware measurement, truncation, padding (16 tests)
- instance_lock.rs: Advisory PID lock with stale recovery (6 tests)
- session.rs: Atomic write + CRC32 checksum + quarantine (9 tests)

Closes: bd-26lp, bd-3h00, bd-3l56, bd-1df9, bd-y095
2026-02-18 23:51:54 -05:00

576 lines
16 KiB
Rust

//! 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);
});
}
}