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
576 lines
16 KiB
Rust
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);
|
|
});
|
|
}
|
|
}
|