Files
gitlore/crates/lore-tui/src/view/doctor.rs
teernisse 026b3f0754 feat(tui): responsive breakpoints for detail views (bd-a6yb)
Apply breakpoint-aware layout to issue_detail and mr_detail views:
- Issue detail: hide labels on Xs, hide assignees on Xs/Sm, skip milestone row on Xs
- MR detail: hide branch names and merge status on Xs/Sm
- Issue detail allocate_sections gives description 60% on wide (Lg+) vs 40% narrow
- Add responsive tests for both detail views
- Close bd-a6yb: all TUI screens now adapt to terminal width

760 lib tests pass, clippy clean.
2026-02-19 00:10:43 -05:00

298 lines
8.4 KiB
Rust

//! 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::layout::classify_width;
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 — name column adapts to breakpoint.
let bp = classify_width(area.width);
let rows_start_y = area.y + 4;
let name_width = match bp {
ftui::layout::Breakpoint::Xs => 10u16,
ftui::layout::Breakpoint::Sm => 13,
_ => 16,
};
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.
});
}
}