use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use crossterm::{
cursor, event, execute, queue,
style::{self, Attribute},
terminal,
};
use crate::check::CheckReport;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Row {
pub scope: String,
pub label: String,
pub depth: usize,
pub stale: bool,
pub tokens: String,
}
pub fn rows(report: &CheckReport, filter: &str) -> Vec<Row> {
let maps = &report.maps;
let mut kids: std::collections::BTreeMap<&str, Vec<&str>> = Default::default();
for scope in maps.keys() {
if scope.is_empty() {
continue;
}
let mut cur = scope.as_str();
let parent = loop {
let p = match cur.rfind('/') {
Some(i) => &cur[..i],
None => "",
};
if maps.contains_key(p) {
break p;
}
if p.is_empty() {
break "";
}
cur = p;
};
kids.entry(parent).or_default().push(scope);
}
let mut out = Vec::new();
fn walk(
scope: &str,
depth: usize,
report: &CheckReport,
kids: &std::collections::BTreeMap<&str, Vec<&str>>,
out: &mut Vec<Row>,
) {
if let Some(map) = report.maps.get(scope) {
out.push(Row {
scope: scope.to_string(),
label: if scope.is_empty() {
".".to_string()
} else {
scope.rsplit('/').next().unwrap_or(scope).to_string()
},
depth,
stale: report.stale.get(scope).copied().unwrap_or(false),
tokens: map.tokens.clone().unwrap_or_else(|| "~?".into()),
});
}
for child in kids.get(scope).cloned().unwrap_or_default() {
walk(child, depth + 1, report, kids, out);
}
}
if report.maps.contains_key("") {
walk("", 0, report, &kids, &mut out);
}
if filter.is_empty() {
out
} else {
let f = filter.to_lowercase();
out.into_iter()
.filter(|r| r.scope.to_lowercase().contains(&f) || r.label.to_lowercase().contains(&f))
.collect()
}
}
fn clip(s: &str, max: usize) -> String {
s.chars().take(max).collect()
}
struct Restore;
impl Drop for Restore {
fn drop(&mut self) {
let _ = terminal::disable_raw_mode();
let _ = execute!(
std::io::stderr(),
terminal::LeaveAlternateScreen,
cursor::Show
);
}
}
type PanicHook = Box<dyn Fn(&std::panic::PanicHookInfo<'_>) + Send + Sync + 'static>;
struct PanicHookRestore {
previous: Arc<Mutex<Option<PanicHook>>>,
}
impl Drop for PanicHookRestore {
fn drop(&mut self) {
if std::thread::panicking() {
return;
}
let _installed = std::panic::take_hook();
if let Ok(mut previous) = self.previous.lock()
&& let Some(hook) = previous.take()
{
std::panic::set_hook(hook);
}
}
}
pub fn run(root: &Path, report: &CheckReport) -> std::io::Result<()> {
use std::io::IsTerminal;
if !std::io::stderr().is_terminal() {
return Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"not a terminal - use `radar tree` instead",
));
}
let previous = Arc::new(Mutex::new(Some(std::panic::take_hook())));
let hook_previous = Arc::clone(&previous);
std::panic::set_hook(Box::new(move |info| {
let _ = terminal::disable_raw_mode();
let _ = execute!(
std::io::stderr(),
terminal::LeaveAlternateScreen,
cursor::Show
);
if let Ok(previous) = hook_previous.lock()
&& let Some(hook) = previous.as_ref()
{
hook(info);
}
}));
let _hook_restore = PanicHookRestore { previous };
terminal::enable_raw_mode()?;
execute!(
std::io::stderr(),
terminal::EnterAlternateScreen,
cursor::Hide
)?;
let _restore = Restore;
let mut selected = 0usize;
let mut scroll = 0usize;
let mut filter = String::new();
let mut filtering = false;
let mut body_cache: Option<(String, Vec<String>)> = None;
loop {
let list = rows(report, &filter);
if selected >= list.len() {
selected = list.len().saturating_sub(1);
}
let (w, h) = terminal::size()?;
let width = w as usize;
let left_w = (width / 3)
.clamp(20, 46)
.min(width.saturating_sub(2).max(1));
let body_h = (h as usize).saturating_sub(2);
let scope = list
.get(selected)
.map(|r| r.scope.clone())
.unwrap_or_default();
if body_cache.as_ref().map(|(s, _)| s.as_str()) != Some(scope.as_str()) {
let path = crate::mapfile::map_path(root, &scope);
let text = std::fs::read_to_string(&path).unwrap_or_else(|_| "no map".into());
let lines: Vec<String> = text.lines().map(str::to_string).collect();
body_cache = Some((scope.clone(), lines));
scroll = 0;
}
let mut err = std::io::stderr().lock();
queue!(
err,
terminal::Clear(terminal::ClearType::All),
cursor::MoveTo(0, 0)
)?;
let title = if filtering || !filter.is_empty() {
format!(" radar browse - filter: {filter}▏(Esc clears)")
} else {
" radar browse - j/k select · J/K scroll · o editor · / filter · q quit".to_string()
};
queue!(err, style::SetAttribute(Attribute::Reverse))?;
let header = clip(&title, w as usize);
queue!(
err,
style::Print(format!("{header:<width$}", width = w as usize))
)?;
queue!(err, style::SetAttribute(Attribute::Reset))?;
for i in 0..body_h {
queue!(err, cursor::MoveTo(0, (i + 1) as u16))?;
if let Some(row) = list.get(i) {
let marker = if row.stale { "✗" } else { " " };
let line = clip(
&format!(
"{}{} {} {}",
" ".repeat(row.depth),
marker,
row.label,
row.tokens
),
left_w.saturating_sub(1),
);
if i == selected {
queue!(err, style::SetAttribute(Attribute::Reverse))?;
queue!(err, style::Print(format!("{line:<left_w$}")))?;
queue!(err, style::SetAttribute(Attribute::Reset))?;
} else {
queue!(err, style::Print(format!("{line:<left_w$}")))?;
}
} else {
queue!(err, style::Print(" ".repeat(left_w)))?;
}
queue!(err, cursor::MoveTo(left_w as u16, (i + 1) as u16))?;
queue!(err, style::Print("│ "))?;
if let Some((_, lines)) = &body_cache
&& let Some(line) = lines.get(scroll + i)
{
let avail = (w as usize).saturating_sub(left_w + 2);
let line = clip(line, avail);
if line.starts_with('#') || line.starts_with("## ") {
queue!(err, style::SetAttribute(Attribute::Bold))?;
queue!(err, style::Print(&line))?;
queue!(err, style::SetAttribute(Attribute::Reset))?;
} else {
queue!(err, style::Print(&line))?;
}
}
}
err.flush()?;
drop(err);
match event::read()? {
event::Event::Key(key) if key.kind != event::KeyEventKind::Release => {
use event::KeyCode::*;
if filtering {
match key.code {
Esc => {
filter.clear();
filtering = false;
}
Enter => filtering = false,
Backspace => {
filter.pop();
}
Char(c) => filter.push(c),
_ => {}
}
continue;
}
match key.code {
Char('q') | Esc => break,
Char('j') | Down => selected = (selected + 1).min(list.len().saturating_sub(1)),
Char('k') | Up => selected = selected.saturating_sub(1),
Char('J') | PageDown => scroll += 10,
Char('K') | PageUp => scroll = scroll.saturating_sub(10),
Char('/') => {
filtering = true;
filter.clear();
}
Char('o') => {
if let Some(row) = list.get(selected) {
open_in_editor(root, &row.scope)?;
}
}
_ => {}
}
}
event::Event::Resize(..) => {}
_ => {}
}
}
Ok(())
}
fn open_in_editor(root: &Path, scope: &str) -> std::io::Result<()> {
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
let mut editor_parts = editor.split_whitespace();
let program = editor_parts
.next()
.filter(|part| !part.is_empty())
.unwrap_or("vi");
let path: PathBuf = crate::mapfile::map_path(root, scope);
terminal::disable_raw_mode()?;
execute!(
std::io::stderr(),
terminal::LeaveAlternateScreen,
cursor::Show
)?;
let _ = std::process::Command::new(program)
.args(editor_parts)
.arg(&path)
.status();
terminal::enable_raw_mode()?;
execute!(
std::io::stderr(),
terminal::EnterAlternateScreen,
cursor::Hide
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::check::DiskMap;
fn report() -> CheckReport {
let mut r = CheckReport::default();
for (scope, stale) in [
("", false),
("auth", false),
("auth/jwt", true),
("web", false),
] {
r.maps.insert(
scope.to_string(),
DiskMap {
parent_link: None,
children_links: vec![],
api_hash: None,
bytes_actual: 100,
tokens: Some("~25".into()),
fidelity: "syntax".into(),
slot_filled: true,
},
);
r.stale.insert(scope.to_string(), stale);
}
r
}
#[test]
fn rows_are_depth_ordered_and_labeled() {
let rows = rows(&report(), "");
let labels: Vec<(&str, usize, bool)> = rows
.iter()
.map(|r| (r.label.as_str(), r.depth, r.stale))
.collect();
assert_eq!(
labels,
vec![
(".", 0, false),
("auth", 1, false),
("jwt", 2, true),
("web", 1, false)
]
);
}
#[test]
fn filter_matches_scope_paths() {
let rows = rows(&report(), "jwt");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].scope, "auth/jwt");
}
}