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:
teernisse
2026-02-18 23:40:30 -05:00
parent 418417b0f4
commit 146eb61623
45 changed files with 5216 additions and 207 deletions

View File

@@ -26,3 +26,18 @@ pub use filter_bar::{FilterBarColors, FilterBarState, render_filter_bar};
pub use help_overlay::render_help_overlay;
pub use loading::render_loading;
pub use status_bar::render_status_bar;
/// Truncate a string to at most `max_chars` display characters.
///
/// Uses Unicode ellipsis `…` for truncation. If `max_chars` is too small
/// for an ellipsis (<=1), just truncates without one.
pub fn truncate_str(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
s.to_string()
} else if max_chars <= 1 {
s.chars().take(max_chars).collect()
} else {
let truncated: String = s.chars().take(max_chars.saturating_sub(1)).collect();
format!("{truncated}\u{2026}")
}
}

View File

@@ -0,0 +1,289 @@
//! Doctor screen view — health check results.
//!
//! Renders a vertical list of health checks with colored status
//! indicators (green PASS, yellow WARN, red FAIL).
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::state::doctor::{DoctorState, HealthStatus};
use super::{TEXT, TEXT_MUTED};
/// Pass green.
const PASS_FG: PackedRgba = PackedRgba::rgb(0x87, 0x9A, 0x39);
/// Warning yellow.
const WARN_FG: PackedRgba = PackedRgba::rgb(0xD0, 0xA2, 0x15);
/// Fail red.
const FAIL_FG: PackedRgba = PackedRgba::rgb(0xD1, 0x4D, 0x41);
// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------
/// Render the doctor screen.
pub fn render_doctor(frame: &mut Frame<'_>, state: &DoctorState, area: Rect) {
if area.width < 10 || area.height < 3 {
return;
}
let max_x = area.right();
if !state.loaded {
// Not yet loaded — show centered prompt.
let msg = "Loading health checks...";
let x = area.x + area.width.saturating_sub(msg.len() as u16) / 2;
let y = area.y + area.height / 2;
frame.print_text_clipped(
x,
y,
msg,
Cell {
fg: TEXT_MUTED,
..Cell::default()
},
max_x,
);
return;
}
// Title.
let overall = state.overall_status();
let title_fg = status_color(overall);
let title = format!("Doctor — {}", overall.label());
frame.print_text_clipped(
area.x + 2,
area.y + 1,
&title,
Cell {
fg: title_fg,
..Cell::default()
},
max_x,
);
// Summary line.
let pass_count = state.count_by_status(HealthStatus::Pass);
let warn_count = state.count_by_status(HealthStatus::Warn);
let fail_count = state.count_by_status(HealthStatus::Fail);
let summary = format!(
"{} passed, {} warnings, {} failed",
pass_count, warn_count, fail_count
);
frame.print_text_clipped(
area.x + 2,
area.y + 2,
&summary,
Cell {
fg: TEXT_MUTED,
..Cell::default()
},
max_x,
);
// Health check rows.
let rows_start_y = area.y + 4;
let name_width = 16u16;
for (i, check) in state.checks.iter().enumerate() {
let y = rows_start_y + i as u16;
if y >= area.bottom().saturating_sub(2) {
break;
}
// Status badge.
let badge = format!("[{}]", check.status.label());
let badge_fg = status_color(check.status);
frame.print_text_clipped(
area.x + 2,
y,
&badge,
Cell {
fg: badge_fg,
..Cell::default()
},
max_x,
);
// Check name.
let name_x = area.x + 2 + 7; // "[PASS] " = 7 chars
let name = format!("{:<width$}", check.name, width = name_width as usize);
frame.print_text_clipped(
name_x,
y,
&name,
Cell {
fg: TEXT,
..Cell::default()
},
max_x,
);
// Detail text.
let detail_x = name_x + name_width;
let max_detail = area.right().saturating_sub(detail_x + 1) as usize;
let detail = if check.detail.len() > max_detail {
format!(
"{}...",
&check.detail[..check.detail.floor_char_boundary(max_detail.saturating_sub(3))]
)
} else {
check.detail.clone()
};
frame.print_text_clipped(
detail_x,
y,
&detail,
Cell {
fg: TEXT_MUTED,
..Cell::default()
},
max_x,
);
}
// Hint at bottom.
let hint_y = area.bottom().saturating_sub(1);
frame.print_text_clipped(
area.x + 2,
hint_y,
"Esc: back | lore doctor (full check)",
Cell {
fg: TEXT_MUTED,
..Cell::default()
},
max_x,
);
}
/// Map health status to a display color.
fn status_color(status: HealthStatus) -> PackedRgba {
match status {
HealthStatus::Pass => PASS_FG,
HealthStatus::Warn => WARN_FG,
HealthStatus::Fail => FAIL_FG,
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::state::doctor::HealthCheck;
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
}};
}
fn sample_checks() -> Vec<HealthCheck> {
vec![
HealthCheck {
name: "Config".into(),
status: HealthStatus::Pass,
detail: "/home/user/.config/lore/config.json".into(),
},
HealthCheck {
name: "Database".into(),
status: HealthStatus::Pass,
detail: "schema v12".into(),
},
HealthCheck {
name: "Projects".into(),
status: HealthStatus::Warn,
detail: "0 projects configured".into(),
},
HealthCheck {
name: "FTS Index".into(),
status: HealthStatus::Fail,
detail: "No documents indexed".into(),
},
]
}
#[test]
fn test_render_not_loaded() {
with_frame!(80, 24, |frame| {
let state = DoctorState::default();
let area = frame.bounds();
render_doctor(&mut frame, &state, area);
});
}
#[test]
fn test_render_with_checks() {
with_frame!(80, 24, |frame| {
let mut state = DoctorState::default();
state.apply_checks(sample_checks());
let area = frame.bounds();
render_doctor(&mut frame, &state, area);
});
}
#[test]
fn test_render_all_pass() {
with_frame!(80, 24, |frame| {
let mut state = DoctorState::default();
state.apply_checks(vec![HealthCheck {
name: "Config".into(),
status: HealthStatus::Pass,
detail: "ok".into(),
}]);
let area = frame.bounds();
render_doctor(&mut frame, &state, area);
});
}
#[test]
fn test_render_tiny_terminal() {
with_frame!(8, 2, |frame| {
let mut state = DoctorState::default();
state.apply_checks(sample_checks());
let area = frame.bounds();
render_doctor(&mut frame, &state, area);
// Should not panic.
});
}
#[test]
fn test_render_narrow_terminal_truncates() {
with_frame!(40, 20, |frame| {
let mut state = DoctorState::default();
state.apply_checks(vec![HealthCheck {
name: "Database".into(),
status: HealthStatus::Pass,
detail: "This is a very long detail string that should be truncated".into(),
}]);
let area = frame.bounds();
render_doctor(&mut frame, &state, area);
});
}
#[test]
fn test_render_many_checks_clips() {
with_frame!(80, 10, |frame| {
let mut state = DoctorState::default();
let mut checks = Vec::new();
for i in 0..20 {
checks.push(HealthCheck {
name: format!("Check {i}"),
status: HealthStatus::Pass,
detail: "ok".into(),
});
}
state.apply_checks(checks);
let area = frame.bounds();
render_doctor(&mut frame, &state, area);
// Should clip without panicking.
});
}
}

View File

@@ -22,6 +22,7 @@ use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::state::file_history::{FileHistoryResult, FileHistoryState};
use super::common::truncate_str;
// ---------------------------------------------------------------------------
// Colors (Flexoki palette)
@@ -136,7 +137,8 @@ fn render_path_input(frame: &mut Frame<'_>, state: &FileHistoryState, x: u16, y:
// Cursor indicator.
if state.path_focused {
let cursor_x = after_label + state.path_cursor as u16;
let cursor_col = state.path_input[..state.path_cursor].chars().count() as u16;
let cursor_x = after_label + cursor_col;
if cursor_x < max_x {
let cursor_cell = Cell {
fg: PackedRgba::rgb(0x10, 0x0F, 0x0F), // dark bg
@@ -446,16 +448,6 @@ fn render_hint_bar(frame: &mut Frame<'_>, x: u16, y: u16, max_x: u16) {
frame.print_text_clipped(x + 1, y, hints, style, max_x);
}
/// Truncate a string to at most `max_chars` display characters.
fn truncate_str(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
s.to_string()
} else {
let truncated: String = s.chars().take(max_chars.saturating_sub(1)).collect();
format!("{truncated}")
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

View File

@@ -10,6 +10,7 @@ pub mod bootstrap;
pub mod command_palette;
pub mod common;
pub mod dashboard;
pub mod doctor;
pub mod file_history;
pub mod issue_detail;
pub mod issue_list;
@@ -18,11 +19,13 @@ pub mod mr_list;
pub mod search;
pub mod timeline;
pub mod trace;
pub mod scope_picker;
pub mod stats;
pub mod sync;
pub mod who;
use ftui::layout::{Constraint, Flex};
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::cell::PackedRgba;
use ftui::render::frame::Frame;
use crate::app::LoreApp;
@@ -34,6 +37,7 @@ use common::{
render_breadcrumb, render_error_toast, render_help_overlay, render_loading, render_status_bar,
};
use dashboard::render_dashboard;
use doctor::render_doctor;
use file_history::render_file_history;
use issue_detail::render_issue_detail;
use issue_list::render_issue_list;
@@ -42,6 +46,9 @@ use mr_list::render_mr_list;
use search::render_search;
use timeline::render_timeline;
use trace::render_trace;
use scope_picker::render_scope_picker;
use stats::render_stats;
use sync::render_sync;
use who::render_who;
// ---------------------------------------------------------------------------
@@ -56,41 +63,6 @@ const ERROR_BG: PackedRgba = PackedRgba::rgb(0xAF, 0x3A, 0x29); // red
const ERROR_FG: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3); // tx
const BORDER: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2
fn render_sync_placeholder(frame: &mut Frame<'_>, area: ftui::core::geometry::Rect) {
if area.width < 10 || area.height < 5 {
return;
}
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 body = "Run `lore sync` in another terminal.";
let body_x = area.x + area.width.saturating_sub(body.len() as u16) / 2;
frame.print_text_clipped(
body_x,
center_y + 1,
body,
Cell {
fg: TEXT_MUTED,
..Cell::default()
},
max_x,
);
}
// ---------------------------------------------------------------------------
// render_screen
// ---------------------------------------------------------------------------
@@ -144,7 +116,7 @@ pub fn render_screen(frame: &mut Frame<'_>, app: &LoreApp) {
if screen == &Screen::Bootstrap {
render_bootstrap(frame, &app.state.bootstrap, content_area);
} else if screen == &Screen::Sync {
render_sync_placeholder(frame, content_area);
render_sync(frame, &app.state.sync, content_area);
} else if screen == &Screen::Dashboard {
render_dashboard(frame, &app.state.dashboard, content_area);
} else if screen == &Screen::IssueList {
@@ -165,6 +137,10 @@ pub fn render_screen(frame: &mut Frame<'_>, app: &LoreApp) {
render_file_history(frame, &app.state.file_history, content_area);
} else if screen == &Screen::Trace {
render_trace(frame, &app.state.trace, content_area);
} else if screen == &Screen::Doctor {
render_doctor(frame, &app.state.doctor, content_area);
} else if screen == &Screen::Stats {
render_stats(frame, &app.state.stats, content_area);
}
// --- Status bar ---
@@ -189,6 +165,14 @@ pub fn render_screen(frame: &mut Frame<'_>, app: &LoreApp) {
// Command palette overlay.
render_command_palette(frame, &app.state.command_palette, bounds);
// Scope picker overlay.
render_scope_picker(
frame,
&app.state.scope_picker,
&app.state.global_scope,
bounds,
);
// Help overlay.
if app.state.show_help {
render_help_overlay(
@@ -279,7 +263,7 @@ mod tests {
});
assert!(
has_content,
"Expected sync placeholder content in center area"
"Expected sync idle content in center area"
);
});
}

View File

@@ -0,0 +1,276 @@
//! Scope picker overlay — modal project filter selector.
//!
//! Renders a centered modal listing all available projects. The user
//! selects "All Projects" or a specific project to filter all screens.
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::{BorderChars, Draw};
use ftui::render::frame::Frame;
use crate::state::scope_picker::ScopePickerState;
use crate::state::ScopeContext;
use super::{ACCENT, BG_SURFACE, BORDER, TEXT, TEXT_MUTED};
/// Selection highlight background.
const SELECTION_BG: PackedRgba = PackedRgba::rgb(0x3A, 0x3A, 0x34);
// ---------------------------------------------------------------------------
// render_scope_picker
// ---------------------------------------------------------------------------
/// Render the scope picker overlay centered on the screen.
///
/// Only renders if `state.visible`. The modal is 50% width, up to 40x20.
pub fn render_scope_picker(
frame: &mut Frame<'_>,
state: &ScopePickerState,
current_scope: &ScopeContext,
area: Rect,
) {
if !state.visible {
return;
}
if area.height < 5 || area.width < 20 {
return;
}
// Modal dimensions.
let modal_width = (area.width / 2).clamp(25, 40);
let row_count = state.row_count();
// +3 for border top, title gap, border bottom.
let modal_height = ((row_count + 3) as u16).clamp(5, 20).min(area.height - 2);
let modal_x = area.x + (area.width.saturating_sub(modal_width)) / 2;
let modal_y = area.y + (area.height.saturating_sub(modal_height)) / 2;
let modal_rect = Rect::new(modal_x, modal_y, modal_width, modal_height);
// Clear background.
let bg_cell = Cell {
fg: TEXT,
bg: BG_SURFACE,
..Cell::default()
};
for y in modal_rect.y..modal_rect.bottom() {
for x in modal_rect.x..modal_rect.right() {
frame.buffer.set(x, y, bg_cell);
}
}
// Border.
let border_cell = Cell {
fg: BORDER,
bg: BG_SURFACE,
..Cell::default()
};
frame.draw_border(modal_rect, BorderChars::ROUNDED, border_cell);
// Title.
let title = " Project Scope ";
let title_x = modal_x + (modal_width.saturating_sub(title.len() as u16)) / 2;
let title_cell = Cell {
fg: ACCENT,
bg: BG_SURFACE,
..Cell::default()
};
frame.print_text_clipped(title_x, modal_y, title, title_cell, modal_rect.right());
// Content area (inside border).
let content_x = modal_x + 1;
let content_max_x = modal_rect.right().saturating_sub(1);
let content_width = content_max_x.saturating_sub(content_x);
let first_row_y = modal_y + 1;
let max_rows = (modal_height.saturating_sub(2)) as usize; // Inside borders.
// Render rows.
let visible_end = (state.scroll_offset + max_rows).min(row_count);
for vis_idx in 0..max_rows {
let row_idx = state.scroll_offset + vis_idx;
if row_idx >= row_count {
break;
}
let y = first_row_y + vis_idx as u16;
let selected = row_idx == state.selected_index;
let bg = if selected { SELECTION_BG } else { BG_SURFACE };
// Fill row background.
if selected {
let sel_cell = Cell {
fg: TEXT,
bg,
..Cell::default()
};
for x in content_x..content_max_x {
frame.buffer.set(x, y, sel_cell);
}
}
// Row content.
let (label, is_active) = if row_idx == 0 {
let active = current_scope.project_id.is_none();
("All Projects".to_string(), active)
} else {
let project = &state.projects[row_idx - 1];
let active = current_scope.project_id == Some(project.id);
(project.path.clone(), active)
};
// Active indicator.
let prefix = if is_active { "> " } else { " " };
let fg = if is_active { ACCENT } else { TEXT };
let cell = Cell {
fg,
bg,
..Cell::default()
};
// Truncate label to fit.
let max_label_len = content_width.saturating_sub(2) as usize; // 2 for prefix
let display = if label.len() > max_label_len {
format!("{prefix}{}...", &label[..label.floor_char_boundary(max_label_len.saturating_sub(3))])
} else {
format!("{prefix}{label}")
};
frame.print_text_clipped(content_x, y, &display, cell, content_max_x);
}
// Scroll indicators.
if state.scroll_offset > 0 {
let arrow_cell = Cell {
fg: TEXT_MUTED,
bg: BG_SURFACE,
..Cell::default()
};
frame.print_text_clipped(
content_max_x.saturating_sub(1),
first_row_y,
"^",
arrow_cell,
modal_rect.right(),
);
}
if visible_end < row_count {
let arrow_cell = Cell {
fg: TEXT_MUTED,
bg: BG_SURFACE,
..Cell::default()
};
let bottom_y = first_row_y + (max_rows as u16).saturating_sub(1);
frame.print_text_clipped(
content_max_x.saturating_sub(1),
bottom_y,
"v",
arrow_cell,
modal_rect.right(),
);
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::scope::ProjectInfo;
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
}};
}
fn sample_projects() -> Vec<ProjectInfo> {
vec![
ProjectInfo {
id: 1,
path: "alpha/repo".into(),
},
ProjectInfo {
id: 2,
path: "beta/repo".into(),
},
]
}
#[test]
fn test_render_hidden_noop() {
with_frame!(80, 24, |frame| {
let state = ScopePickerState::default();
let scope = ScopeContext::default();
let area = frame.bounds();
render_scope_picker(&mut frame, &state, &scope, area);
// Should not panic.
});
}
#[test]
fn test_render_visible_no_panic() {
with_frame!(80, 24, |frame| {
let mut state = ScopePickerState::default();
let scope = ScopeContext::default();
state.open(sample_projects(), &scope);
let area = frame.bounds();
render_scope_picker(&mut frame, &state, &scope, area);
});
}
#[test]
fn test_render_with_selection() {
with_frame!(80, 24, |frame| {
let mut state = ScopePickerState::default();
let scope = ScopeContext::default();
state.open(sample_projects(), &scope);
state.select_next(); // Move to first project
let area = frame.bounds();
render_scope_picker(&mut frame, &state, &scope, area);
});
}
#[test]
fn test_render_tiny_terminal_noop() {
with_frame!(15, 4, |frame| {
let mut state = ScopePickerState::default();
let scope = ScopeContext::default();
state.open(sample_projects(), &scope);
let area = frame.bounds();
render_scope_picker(&mut frame, &state, &scope, area);
// Should not panic on tiny terminals.
});
}
#[test]
fn test_render_active_scope_highlighted() {
with_frame!(80, 24, |frame| {
let mut state = ScopePickerState::default();
let scope = ScopeContext {
project_id: Some(2),
project_name: Some("beta/repo".into()),
};
state.open(sample_projects(), &scope);
let area = frame.bounds();
render_scope_picker(&mut frame, &state, &scope, area);
});
}
#[test]
fn test_render_empty_project_list() {
with_frame!(80, 24, |frame| {
let mut state = ScopePickerState::default();
let scope = ScopeContext::default();
state.open(vec![], &scope);
let area = frame.bounds();
render_scope_picker(&mut frame, &state, &scope, area);
// Only "All Projects" row, should not panic.
});
}
}

View File

@@ -0,0 +1,443 @@
//! Stats screen view — database and index statistics.
//!
//! Renders entity counts, FTS/embedding coverage, and queue health
//! as a simple table layout.
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::state::stats::StatsState;
use super::{ACCENT, TEXT, TEXT_MUTED};
/// Success green (for good coverage).
const GOOD_FG: PackedRgba = PackedRgba::rgb(0x87, 0x9A, 0x39);
/// Warning yellow (for partial coverage).
const WARN_FG: PackedRgba = PackedRgba::rgb(0xD0, 0xA2, 0x15);
// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------
/// Render the stats screen.
pub fn render_stats(frame: &mut Frame<'_>, state: &StatsState, area: Rect) {
if area.width < 10 || area.height < 3 {
return;
}
let max_x = area.right();
if !state.loaded {
let msg = "Loading statistics...";
let x = area.x + area.width.saturating_sub(msg.len() as u16) / 2;
let y = area.y + area.height / 2;
frame.print_text_clipped(
x,
y,
msg,
Cell {
fg: TEXT_MUTED,
..Cell::default()
},
max_x,
);
return;
}
let data = match &state.data {
Some(d) => d,
None => return,
};
// Title.
frame.print_text_clipped(
area.x + 2,
area.y + 1,
"Database Statistics",
Cell {
fg: ACCENT,
..Cell::default()
},
max_x,
);
let mut y = area.y + 3;
let label_width = 22u16;
let value_x = area.x + 2 + label_width;
// --- Entity Counts section ---
if y < area.bottom().saturating_sub(2) {
frame.print_text_clipped(
area.x + 2,
y,
"Entities",
Cell {
fg: TEXT,
..Cell::default()
},
max_x,
);
y += 1;
}
let entity_rows: [(&str, i64); 4] = [
(" Issues", data.issues),
(" Merge Requests", data.merge_requests),
(" Discussions", data.discussions),
(" Notes", data.notes),
];
for (label, count) in &entity_rows {
if y >= area.bottom().saturating_sub(2) {
break;
}
render_stat_row(frame, area.x + 2, y, label, &format_count(*count), label_width, max_x);
y += 1;
}
// Total.
if y < area.bottom().saturating_sub(2) {
let total = data.issues + data.merge_requests + data.discussions + data.notes;
render_stat_row(
frame,
area.x + 2,
y,
" Total",
&format_count(total),
label_width,
max_x,
);
y += 1;
}
y += 1; // Blank line.
// --- Index Coverage section ---
if y < area.bottom().saturating_sub(2) {
frame.print_text_clipped(
area.x + 2,
y,
"Index Coverage",
Cell {
fg: TEXT,
..Cell::default()
},
max_x,
);
y += 1;
}
// FTS.
if y < area.bottom().saturating_sub(2) {
let fts_pct = data.fts_coverage_pct();
let fts_text = format!("{} ({:.0}%)", format_count(data.fts_indexed), fts_pct);
let fg = coverage_color(fts_pct);
frame.print_text_clipped(
area.x + 2,
y,
&format!("{:<width$}", " FTS Indexed", width = label_width as usize),
Cell {
fg: TEXT_MUTED,
..Cell::default()
},
value_x,
);
frame.print_text_clipped(
value_x,
y,
&fts_text,
Cell {
fg,
..Cell::default()
},
max_x,
);
y += 1;
}
// Embeddings.
if y < area.bottom().saturating_sub(2) {
let embed_text = format!(
"{} ({:.0}%)",
format_count(data.embedded_documents),
data.coverage_pct
);
let fg = coverage_color(data.coverage_pct);
frame.print_text_clipped(
area.x + 2,
y,
&format!("{:<width$}", " Embeddings", width = label_width as usize),
Cell {
fg: TEXT_MUTED,
..Cell::default()
},
value_x,
);
frame.print_text_clipped(
value_x,
y,
&embed_text,
Cell {
fg,
..Cell::default()
},
max_x,
);
y += 1;
}
// Chunks.
if y < area.bottom().saturating_sub(2) {
render_stat_row(
frame,
area.x + 2,
y,
" Chunks",
&format_count(data.total_chunks),
label_width,
max_x,
);
y += 1;
}
y += 1; // Blank line.
// --- Queue section ---
if data.has_queue_work() && y < area.bottom().saturating_sub(2) {
frame.print_text_clipped(
area.x + 2,
y,
"Queue",
Cell {
fg: TEXT,
..Cell::default()
},
max_x,
);
y += 1;
if y < area.bottom().saturating_sub(2) {
render_stat_row(
frame,
area.x + 2,
y,
" Pending",
&format_count(data.queue_pending),
label_width,
max_x,
);
y += 1;
}
if data.queue_failed > 0 && y < area.bottom().saturating_sub(2) {
let failed_cell = Cell {
fg: WARN_FG,
..Cell::default()
};
frame.print_text_clipped(
area.x + 2,
y,
&format!("{:<width$}", " Failed", width = label_width as usize),
Cell {
fg: TEXT_MUTED,
..Cell::default()
},
value_x,
);
frame.print_text_clipped(
value_x,
y,
&format_count(data.queue_failed),
failed_cell,
max_x,
);
}
}
// Hint at bottom.
let hint_y = area.bottom().saturating_sub(1);
frame.print_text_clipped(
area.x + 2,
hint_y,
"Esc: back | lore stats (full report)",
Cell {
fg: TEXT_MUTED,
..Cell::default()
},
max_x,
);
}
/// Render a label + value row.
fn render_stat_row(
frame: &mut Frame<'_>,
x: u16,
y: u16,
label: &str,
value: &str,
label_width: u16,
max_x: u16,
) {
let value_x = x + label_width;
frame.print_text_clipped(
x,
y,
&format!("{label:<width$}", width = label_width as usize),
Cell {
fg: TEXT_MUTED,
..Cell::default()
},
value_x,
);
frame.print_text_clipped(
value_x,
y,
value,
Cell {
fg: TEXT,
..Cell::default()
},
max_x,
);
}
/// Color based on coverage percentage.
fn coverage_color(pct: f64) -> PackedRgba {
if pct >= 90.0 {
GOOD_FG
} else if pct >= 50.0 {
WARN_FG
} else {
TEXT
}
}
/// Format a count with comma separators for readability.
fn format_count(n: i64) -> String {
if n < 1_000 {
return n.to_string();
}
let s = n.to_string();
let mut result = String::with_capacity(s.len() + s.len() / 3);
for (i, c) in s.chars().enumerate() {
if i > 0 && (s.len() - i).is_multiple_of(3) {
result.push(',');
}
result.push(c);
}
result
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::state::stats::StatsData;
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
}};
}
fn sample_data() -> StatsData {
StatsData {
total_documents: 500,
issues: 200,
merge_requests: 150,
discussions: 100,
notes: 50,
fts_indexed: 450,
embedded_documents: 300,
total_chunks: 1200,
coverage_pct: 60.0,
queue_pending: 5,
queue_failed: 1,
}
}
#[test]
fn test_render_not_loaded() {
with_frame!(80, 24, |frame| {
let state = StatsState::default();
let area = frame.bounds();
render_stats(&mut frame, &state, area);
});
}
#[test]
fn test_render_with_data() {
with_frame!(80, 24, |frame| {
let mut state = StatsState::default();
state.apply_data(sample_data());
let area = frame.bounds();
render_stats(&mut frame, &state, area);
});
}
#[test]
fn test_render_no_queue_work() {
with_frame!(80, 24, |frame| {
let mut state = StatsState::default();
state.apply_data(StatsData {
queue_pending: 0,
queue_failed: 0,
..sample_data()
});
let area = frame.bounds();
render_stats(&mut frame, &state, area);
});
}
#[test]
fn test_render_tiny_terminal() {
with_frame!(8, 2, |frame| {
let mut state = StatsState::default();
state.apply_data(sample_data());
let area = frame.bounds();
render_stats(&mut frame, &state, area);
});
}
#[test]
fn test_render_short_terminal() {
with_frame!(80, 8, |frame| {
let mut state = StatsState::default();
state.apply_data(sample_data());
let area = frame.bounds();
render_stats(&mut frame, &state, area);
// Should clip without panicking.
});
}
#[test]
fn test_format_count_small() {
assert_eq!(format_count(0), "0");
assert_eq!(format_count(42), "42");
assert_eq!(format_count(999), "999");
}
#[test]
fn test_format_count_thousands() {
assert_eq!(format_count(1_000), "1,000");
assert_eq!(format_count(12_345), "12,345");
assert_eq!(format_count(1_234_567), "1,234,567");
}
#[test]
fn test_coverage_color_thresholds() {
assert_eq!(coverage_color(100.0), GOOD_FG);
assert_eq!(coverage_color(90.0), GOOD_FG);
assert_eq!(coverage_color(89.9), WARN_FG);
assert_eq!(coverage_color(50.0), WARN_FG);
assert_eq!(coverage_color(49.9), TEXT);
}
}

View 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);
});
}
}

View File

@@ -24,16 +24,16 @@ use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::state::trace::TraceState;
use crate::text_width::cursor_cell_offset;
use lore::core::trace::TraceResult;
use super::common::truncate_str;
use super::{ACCENT, BG_SURFACE, TEXT, TEXT_MUTED};
// ---------------------------------------------------------------------------
// Colors (Flexoki palette)
// Colors (Flexoki palette — extras not in parent module)
// ---------------------------------------------------------------------------
const TEXT: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3); // tx
const TEXT_MUTED: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2
const BG_SURFACE: PackedRgba = PackedRgba::rgb(0x28, 0x28, 0x24); // bg-2
const ACCENT: PackedRgba = PackedRgba::rgb(0xDA, 0x70, 0x2C); // orange
const GREEN: PackedRgba = PackedRgba::rgb(0x87, 0x9A, 0x39); // green
const CYAN: PackedRgba = PackedRgba::rgb(0x3A, 0xA9, 0x9F); // cyan
const YELLOW: PackedRgba = PackedRgba::rgb(0xD0, 0xA2, 0x15); // yellow
@@ -135,7 +135,8 @@ fn render_path_input(frame: &mut Frame<'_>, state: &TraceState, x: u16, y: u16,
// Cursor.
if state.path_focused {
let cursor_x = after_label + state.path_cursor as u16;
let cursor_col = state.path_input[..state.path_cursor].chars().count() as u16;
let cursor_x = after_label + cursor_col;
if cursor_x < max_x {
let cursor_cell = Cell {
fg: PackedRgba::rgb(0x10, 0x0F, 0x0F),
@@ -144,8 +145,8 @@ fn render_path_input(frame: &mut Frame<'_>, state: &TraceState, x: u16, y: u16,
};
let ch = state
.path_input
.chars()
.nth(state.path_cursor)
.get(state.path_cursor..)
.and_then(|s| s.chars().next())
.unwrap_or(' ');
frame.print_text_clipped(cursor_x, y, &ch.to_string(), cursor_cell, max_x);
}
@@ -457,16 +458,6 @@ fn render_hint_bar(frame: &mut Frame<'_>, x: u16, y: u16, max_x: u16) {
frame.print_text_clipped(x + 1, y, hints, style, max_x);
}
/// Truncate a string to at most `max_chars` display characters.
fn truncate_str(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
s.to_string()
} else {
let truncated: String = s.chars().take(max_chars.saturating_sub(1)).collect();
format!("{truncated}")
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

View File

@@ -25,6 +25,7 @@ use lore::core::who_types::{
use crate::state::who::{WhoMode, WhoState};
use super::common::truncate_str;
use super::{ACCENT, BG_SURFACE, BORDER, TEXT, TEXT_MUTED};
/// Muted accent for inactive mode tabs.
@@ -915,20 +916,6 @@ fn render_truncation_footer(
frame.print_text_clipped(footer_x, footer_y, &footer, cell, max_x);
}
/// Truncate a string to at most `max_chars` display characters.
fn truncate_str(s: &str, max_chars: usize) -> String {
let chars: Vec<char> = s.chars().collect();
if chars.len() <= max_chars {
s.to_string()
} else if max_chars <= 3 {
chars[..max_chars].iter().collect()
} else {
let mut result: String = chars[..max_chars - 3].iter().collect();
result.push_str("...");
result
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -1029,7 +1016,7 @@ mod tests {
#[test]
fn test_truncate_str() {
assert_eq!(truncate_str("hello", 10), "hello");
assert_eq!(truncate_str("hello world", 8), "hello...");
assert_eq!(truncate_str("hello world", 8), "hello w\u{2026}");
assert_eq!(truncate_str("hi", 2), "hi");
assert_eq!(truncate_str("abc", 3), "abc");
}