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
This commit is contained in:
575
crates/lore-tui/src/view/sync.rs
Normal file
575
crates/lore-tui/src/view/sync.rs
Normal file
@@ -0,0 +1,575 @@
|
||||
//! 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user