use super::super::*;
use super::{Ask, Cx, Read, ToolScreen, View};
#[derive(Default)]
pub(in crate::tui::app) struct Doctor {
pub(in crate::tui::app) diagnoses: Vec<doctor::Diagnosis>,
examining: Read<Vec<doctor::Diagnosis>>,
pub(in crate::tui::app) wanted: Option<&'static str>,
stale: bool,
}
fn reports(catalog: &Catalog) -> Vec<&Entry> {
catalog
.tabs
.iter()
.find(|tab| tab.id == "doctor")
.map(|tab| tab.entries())
.unwrap_or_default()
}
pub(in crate::tui::app) fn diagnosis_popup(
diagnosis: &doctor::Diagnosis,
catalog: &Catalog,
recovery: bool,
) -> Popup {
let mut text = format!("{}: {}\n", diagnosis.health.label(), diagnosis.summary);
if !diagnosis.evidence.is_empty() {
text.push_str("\nWhat was found:\n");
for line in &diagnosis.evidence {
text.push_str(&format!(" {line}\n"));
}
}
if let Some(advice) = &diagnosis.advice {
text.push_str(&format!("\nWhat to do: {advice}\n"));
}
if let Some(repair) = &diagnosis.repair {
text.push_str(&format!(
"\nRepair: {}. f runs it after its own confirmation:\n",
repair.label
));
for command in &repair.commands {
text.push_str(&format!(" {}\n", command.join(" ")));
}
} else if let Some(entry) = diagnosis.entry.as_deref().and_then(|id| catalog.find(id)) {
text.push_str(&format!(
"\nThe {} entry addresses it; f runs it.\n",
entry.name
));
}
if diagnosis.id == "snapshots" && recovery {
text.push_str(
"\nc lists the snapshots as root and looks inside the newest, reading only.\n",
);
}
Popup::Text(TextPopup::new(diagnosis.title.clone(), text, true))
}
impl Doctor {
fn examine(&mut self, cx: &Cx) {
let manager = cx.platform.manager;
self.examining
.start(cx.host, move |host| doctor::examine_on(&host, manager));
}
fn selected(&self, at: Option<usize>) -> Option<&doctor::Diagnosis> {
at.and_then(|index| self.diagnoses.get(index))
}
fn describe(&self, cx: &mut Cx) {
if let Some(diagnosis) = self.selected(cx.at()) {
*cx.popup = Some(diagnosis_popup(
diagnosis,
cx.catalog,
cx.screen == Screen::Recovery,
));
}
}
fn checks(&self) -> usize {
self.diagnoses.len().max(1)
}
fn selected_report<'a>(&self, catalog: &'a Catalog, at: Option<usize>) -> Option<&'a Entry> {
let index = at?.checked_sub(self.checks())?;
reports(catalog).into_iter().nth(index)
}
fn repair_selected(&self, cx: &mut Cx) {
let Some(diagnosis) = self.selected(cx.at()).cloned() else {
return;
};
if let Some(repair) = &diagnosis.repair {
cx.confirm(repair.plan(&diagnosis.id));
} else if let Some(entry) = diagnosis
.entry
.as_deref()
.and_then(|id| cx.catalog.find(id))
.cloned()
{
cx.ask(Ask::Run(vec![entry]));
}
}
pub(in crate::tui::app) fn want(&mut self, cx: &mut Cx, wanted: Option<&'static str>) {
if let Some(wanted) = wanted {
if self.diagnoses.is_empty() || self.examining.busy() {
self.wanted = Some(wanted);
} else {
self.show_check(cx, wanted);
}
} else if !self.diagnoses.is_empty() {
self.examine(cx);
}
}
pub(in crate::tui::app) fn show_check(&self, cx: &mut Cx, wanted: &str) {
let found = self
.diagnoses
.iter()
.position(|diagnosis| diagnosis.id == wanted)
.or_else(|| {
self.diagnoses
.iter()
.position(|diagnosis| diagnosis.id.starts_with(wanted))
});
if let Some(index) = found {
cx.selected.select(Some(index));
self.describe(cx);
}
}
}
impl ToolScreen for Doctor {
fn open(&mut self, cx: &mut Cx) {
if std::mem::take(&mut self.stale) || self.diagnoses.is_empty() {
self.examine(cx);
} else if let Some(wanted) = self.wanted.take() {
self.show_check(cx, wanted);
}
}
fn len(&self, view: &View) -> usize {
self.checks() + reports(view.catalog).len()
}
fn key(&mut self, cx: &mut Cx, code: KeyCode) {
match code {
KeyCode::Enter => match self.selected(cx.at()) {
Some(_) => self.describe(cx),
None => {
if let Some(entry) = self.selected_report(cx.catalog, cx.at()).cloned() {
cx.ask(Ask::Run(vec![entry]));
}
}
},
KeyCode::Char('f') => self.repair_selected(cx),
KeyCode::Char('r') => self.examine(cx),
KeyCode::Char('d') | KeyCode::Char('D') => {
if self.selected(cx.at()).is_some() {
self.describe(cx);
} else if self.selected_report(cx.catalog, cx.at()).is_some() {
cx.ask(Ask::Describe);
}
}
KeyCode::Char('p') | KeyCode::Char('P') => cx.ask(Ask::Preview),
KeyCode::Char('v') => {
if let Some(entry) = self.selected_report(cx.catalog, cx.at()) {
cx.ask(Ask::Inspect(entry.id.clone()));
}
}
KeyCode::Char(' ') => {
if let Some(entry) = self.selected_report(cx.catalog, cx.at()).cloned() {
cx.toggle_queued(entry);
}
}
KeyCode::Char('*') => cx.ask(Ask::Pin),
KeyCode::Char('h') | KeyCode::Left => cx.ask(Ask::Sidebar),
_ => {}
}
}
fn hints(&self, view: &View, at: Option<usize>, list: &mut Vec<Hint>) {
list.push(Hint::new(&["↑↓"], "Navigate"));
if let Some(diagnosis) = self.selected(at) {
list.push(Hint::new(&["Enter"], "Details"));
if diagnosis.repair.is_some() {
list.push(Hint::new(&["f"], "Repair"));
} else if diagnosis.entry.is_some() {
list.push(Hint::new(&["f"], "Run the entry"));
}
} else if self.selected_report(view.catalog, at).is_some() {
list.push(Hint::new(&["Enter"], "Run"));
list.push(Hint::new(&["Space"], "Plan"));
list.push(Hint::new(&["d"], "Details"));
}
list.push(Hint::new(&["r"], "Check again"));
}
fn poll(&mut self, cx: &mut Cx) -> bool {
let Some(answer) = self.examining.take() else {
return false;
};
if let Ok(diagnoses) = answer {
self.diagnoses = diagnoses;
}
if cx.screen == Screen::Doctor {
cx.clamp();
if let Some(wanted) = self.wanted.take() {
cx.ask(Ask::ShowCheck(wanted));
}
}
true
}
fn after_run(&mut self, cx: &mut Cx, reading: Reading) {
if reading == Reading::Tools {
self.examine(cx);
}
}
fn stale(&mut self, reading: Reading) {
if reading == Reading::Tools {
self.stale = true;
}
}
fn entry<'a>(&self, view: &View<'a>, at: Option<usize>) -> Option<&'a Entry> {
self.selected_report(view.catalog, at)
}
fn draw(&self, view: &View, frame: &mut Frame, area: Rect, state: &mut ListState) {
let inner = view.page(
frame,
area,
"Helix Doctor",
"The checks only read the system; a repair runs after its own confirmation.",
);
let focused = view.focused;
let mut items: Vec<Line> = Vec::new();
if self.diagnoses.is_empty() {
items.push(Line::styled(
if self.examining.busy() {
"Checking…"
} else if self.examining.failed() {
"The checks stopped without an answer. Press r to check again."
} else {
"Press r to check this machine."
},
Style::default().fg(view.theme.muted()),
));
}
let title_width = self
.diagnoses
.iter()
.map(|diagnosis| diagnosis.title.chars().count())
.max()
.unwrap_or(0)
+ 2;
for diagnosis in &self.diagnoses {
let color = match diagnosis.health {
doctor::Health::Good => view.theme.success(),
doctor::Health::Notice => view.theme.warning(),
doctor::Health::Problem => view.theme.failure(),
};
let offer = if diagnosis.repair.is_some() {
" · f repairs"
} else if diagnosis.entry.is_some() {
" · f runs the entry"
} else {
""
};
items.push(Line::from(vec![
Span::styled(
format!("{:<8}", diagnosis.health.label()),
Style::default().fg(color).bold(),
),
Span::styled(
format!("{:<title_width$}", diagnosis.title),
Style::default().fg(view.theme.entry()),
),
Span::styled(
diagnosis.summary.clone(),
Style::default().fg(view.theme.muted()),
),
Span::styled(offer, Style::default().fg(view.theme.focused())),
]));
}
for entry in reports(view.catalog) {
let queued = view.queue.iter().any(|other| other.id == entry.id);
let marks = format!(
"{}{}",
if queued { " *" } else { "" },
if view.favorites.contains(&entry.id) {
" ★"
} else {
""
}
);
let status = view
.statuses
.get(&entry.id)
.map(|status| format!(" {}", status.label()))
.unwrap_or_default();
items.push(Line::from(vec![
Span::styled("Report ", Style::default().fg(view.theme.group())),
Span::styled(
format!("{}{marks}", entry.name),
if queued {
Style::default().fg(view.theme.focused()).bold()
} else {
Style::default().fg(view.theme.entry())
},
),
Span::styled(status, Style::default().fg(view.theme.muted())),
]));
}
view.fill(frame, inner);
let title = if self.diagnoses.is_empty() {
"Checks".to_string()
} else {
let count = |health: doctor::Health| {
self.diagnoses
.iter()
.filter(|diagnosis| diagnosis.health == health)
.count()
};
let (problems, notices) = (
count(doctor::Health::Problem),
count(doctor::Health::Notice),
);
format!(
"Checks · {problems} {} · {notices} {} · {} OK{}",
if problems == 1 { "problem" } else { "problems" },
if notices == 1 { "notice" } else { "notices" },
count(doctor::Health::Good),
if self.examining.busy() {
" (checking again…)"
} else {
""
}
)
};
let list = List::new(view.measure(items))
.block(view.card(&title))
.highlight_style(view.highlight(focused))
.highlight_symbol(view.marker(focused));
bordered_list(frame, list, inner, state, view.hits);
}
}