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:
@@ -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}")
|
||||
}
|
||||
}
|
||||
|
||||
289
crates/lore-tui/src/view/doctor.rs
Normal file
289
crates/lore-tui/src/view/doctor.rs
Normal 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.
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
276
crates/lore-tui/src/view/scope_picker.rs
Normal file
276
crates/lore-tui/src/view/scope_picker.rs
Normal 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.
|
||||
});
|
||||
}
|
||||
}
|
||||
443
crates/lore-tui/src/view/stats.rs
Normal file
443
crates/lore-tui/src/view/stats.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user