use crate::cli::DashboardArgs;
use crate::color::ColorProfile;
use crate::commands::watch::{SessionArgs, run_registry};
use crate::core::dashboard_file::load;
use crate::core::registry::Registry;
use crate::exit::AppResult;
use crate::theme::Palette;
pub fn run(args: DashboardArgs, profile: ColorProfile, palette: Palette) -> AppResult {
let registry = load(&args.file, profile != ColorProfile::Ascii)?;
let once_timeout = args
.once_timeout
.as_deref()
.map(crate::core::duration::parse_interval)
.transpose()
.map_err(|err| anyhow::anyhow!("--once-timeout: {err:#}"))?;
let session = SessionArgs {
once: args.once,
tab_title: Some(
args.file
.file_stem()
.map(|stem| stem.to_string_lossy().into_owned())
.unwrap_or_else(|| "dashboard".to_string()),
),
once_timeout,
clear: args.clear,
fullscreen: args.fullscreen,
mouse: args.mouse,
no_hide_cursor: args.no_hide_cursor,
no_sync: args.no_sync,
wrap: false,
max_height: args.max_height,
snapshot_dir: args.snapshot_dir.clone(),
snapshot_ansi: args.snapshot_ansi,
live_tail: dashboard_suffix(args.once, registry.len()),
help_heading: "rat dashboard — keys",
help_extra: pane_help(®istry),
resize_respawn: true,
append: false,
};
run_registry(registry, session, profile, palette)
}
fn dashboard_suffix(once: bool, sources: usize) -> String {
if once {
return String::new();
}
match sources {
1 => " · 1 source · ? help".to_string(),
n => format!(" · {n} sources · ? help"),
}
}
fn pane_help(registry: &Registry) -> Vec<String> {
let mut lines = vec![String::new(), " panes:".to_string()];
for id in registry.ids() {
let spec = registry.spec(id);
lines.push(format!(
" {} {}",
spec.id,
crate::commands::watch::cadence_label(spec)
));
for trigger in &spec.triggers {
lines.push(format!(" {trigger}"));
}
}
if registry.ids().any(|id| registry.spec(id).live) {
lines.extend(LIVE_HELP.iter().map(|l| (*l).to_string()));
}
if registry
.ids()
.any(|id| !registry.spec(id).triggers.is_empty())
{
lines.extend(LOOPING_HELP.iter().map(|l| (*l).to_string()));
}
if !registry.diagnostics().is_empty() {
lines.push(String::new());
lines.push(" diagnostics:".to_string());
for diagnostic in registry.diagnostics() {
lines.push(crate::core::measure::truncate_display(
&format!(" {diagnostic}"),
74,
crate::core::measure::ELLIPSIS,
));
}
}
lines
}
const LIVE_HELP: &[&str] = &[
"",
" live panes:",
" A pane marked `live` runs one long-lived command, spawned once",
" and painted as it prints — it is not on a cadence. Its",
" `interval` is how soon a replacement spawns if the child exits,",
" not how often it runs. `interval \"never\"` means no replacement:",
" the pane keeps its exit badge. A `trigger` on a live pane",
" restarts the child: the running one is killed and a replacement",
" spawned in its place.",
];
const LOOPING_HELP: &[&str] = &[
"",
" looping panes:",
" A pane marked `· looping` is still running — nothing has been",
" stopped. rat cannot see who writes a file, only that a watched",
" path changes while the dashboard is busy and never while it is",
" idle, which is what a pane whose own command touches another",
" pane's trigger looks like.",
"",
" The fix is in the declaration: give the command a guard so it",
" writes only when the content changed, or point the trigger at a",
" path no pane writes. A trigger fires on mtime, not on content,",
" so writing identical bytes still fires — the guard has to skip",
" the write, not just the change.",
"",
" The absence of the badge is weaker than its presence. rat stays",
" silent whenever it cannot tell: when a write cannot be placed",
" against the commands that were running, when a reader's evidence",
" was lost, or when the dashboard was too busy to judge. No badge",
" means no loop was proved, not that there is none.",
];
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::*;
use crate::core::box_model::{BorderPreset, Sides};
use crate::core::registry::{
LayoutNode, Overflow, PaneBox, PaneWidth, ShellMode, SourceId, SourceSpec,
};
use crate::core::trigger::TriggerSpec;
fn registry(triggers: bool) -> Registry {
let spec = |id: &str, path: &str| SourceSpec {
id: id.to_string(),
command: vec!["true".to_string()],
shell: ShellMode::Direct,
interval: (!triggers).then(|| Duration::from_secs(5)),
triggers: if triggers {
vec![TriggerSpec::File(std::path::PathBuf::from(path))]
} else {
Vec::new()
},
debounce: Duration::from_millis(250),
live: false,
};
let pane = || PaneBox {
height: 5,
width: PaneWidth::Weight(1),
overflow: Overflow::KeepTop,
border: BorderPreset::Rounded,
padding: Sides::default(),
title: None,
chrome: true,
};
Registry::panes(
vec![spec("a", "./sa"), spec("b", "./sb")],
vec![pane(), pane()],
LayoutNode::Row(vec![
LayoutNode::Pane(SourceId(0)),
LayoutNode::Pane(SourceId(1)),
]),
1,
0,
)
.expect("a valid two-pane registry")
}
fn live_registry() -> Registry {
let batch = SourceSpec {
id: "a".to_string(),
command: vec!["true".to_string()],
shell: ShellMode::Direct,
interval: Some(Duration::from_secs(5)),
triggers: Vec::new(),
debounce: Duration::from_millis(250),
live: false,
};
let follow = SourceSpec {
id: "follow".to_string(),
command: vec!["true".to_string()],
shell: ShellMode::Direct,
interval: Some(Duration::from_secs(2)),
triggers: Vec::new(),
debounce: Duration::from_millis(250),
live: true,
};
let pane = |overflow| PaneBox {
height: 5,
width: PaneWidth::Weight(1),
overflow,
border: BorderPreset::Rounded,
padding: Sides::default(),
title: None,
chrome: true,
};
Registry::panes(
vec![batch, follow],
vec![pane(Overflow::KeepTop), pane(Overflow::KeepBottom)],
LayoutNode::Row(vec![
LayoutNode::Pane(SourceId(0)),
LayoutNode::Pane(SourceId(1)),
]),
1,
0,
)
.expect("a valid two-pane registry")
}
#[test]
fn the_help_explains_what_interval_means_on_a_live_pane() {
let lines = pane_help(&live_registry());
assert!(
lines.contains(&" follow live".to_string()),
"the pane list must carry the live label: {lines:?}"
);
let text = lines.iter().map(|l| l.trim()).collect::<Vec<_>>().join(" ");
assert!(text.contains("live panes:"), "got {text}");
assert!(text.contains("how soon a replacement spawns"), "got {text}");
assert!(text.contains("not how often it runs"), "got {text}");
assert!(text.contains("no replacement"), "got {text}");
assert!(text.contains("restarts the child"), "got {text}");
}
#[test]
fn the_live_help_is_absent_when_no_pane_is_live() {
let text = pane_help(®istry(true)).join(" ");
assert!(!text.contains("live panes:"), "got {text}");
}
#[test]
fn the_help_explains_the_badge_and_names_the_fix() {
let lines = pane_help(®istry(true));
let text = lines.iter().map(|l| l.trim()).collect::<Vec<_>>().join(" ");
assert!(text.contains("looping"), "got {text}");
assert!(text.contains("nothing has been stopped"), "got {text}");
assert!(text.contains("mtime, not on content"), "got {text}");
assert!(text.contains("no loop was proved"), "got {text}");
}
#[test]
fn the_help_stays_inside_the_width_the_key_table_already_sets() {
for line in pane_help(®istry(true))
.into_iter()
.chain(pane_help(&live_registry()))
{
assert!(
line.chars().count() <= 74,
"{} cells: {line:?}",
line.chars().count()
);
}
}
#[test]
fn load_diagnostics_get_their_own_help_section_only_when_present() {
let noisy = registry(false).with_diagnostics(vec![
"duplicate id \"a\" — refs bind to the first declaration".to_string(),
]);
let lines = pane_help(&noisy);
assert!(
lines.contains(&" diagnostics:".to_string()),
"the section header appears: {lines:?}"
);
assert!(
lines
.iter()
.any(|l| l.contains("duplicate id \"a\"") && l.starts_with(" ")),
"the diagnostic is listed, indented: {lines:?}"
);
let quiet = pane_help(®istry(false));
assert!(
!quiet.iter().any(|l| l.contains("diagnostics")),
"no section when there is nothing to say: {quiet:?}"
);
}
#[test]
fn the_help_is_unchanged_when_no_pane_has_a_trigger() {
assert_eq!(
pane_help(®istry(false)),
vec![
String::new(),
" panes:".to_string(),
" a every 5s".to_string(),
" b every 5s".to_string(),
]
);
}
}