use ratatui::style::Style;
use ratatui::text::{Line, Span};
use super::super::app::{
ActionState, App, Body, Control, Grouping, InputMode, Link, RowKey, Settings, TypingWhat,
retrying_sentence,
};
use super::super::pane::ConfigPane;
use super::super::pane_bleats::BleatsPane;
use super::cell;
use super::flock::fit;
use super::settings::field_label;
#[must_use]
pub fn banner_line(app: &App, width: u16) -> Option<Line<'static>> {
let palette = app.palette();
match app.link() {
Link::Live => None,
Link::Retrying { attempt } => Some(Line::from(Span::styled(
retrying_sentence(*attempt),
palette.attention(),
))),
Link::Lost { .. } => Some(Line::from(Span::styled(
fit(
&format!(
" shep lookout {} · the dashboard stays up so you can read what it had · it will not exit on its own",
app.home()
),
width,
),
palette.muted(),
))),
}
}
const FROZEN_HINT: &str = "q quit h keymap j/k g/G move \
nothing you press can reach the shepherd";
pub(super) const READ_ONLY_LABEL: &str = "read-only";
pub(super) const CONTROL_ENABLED_LABEL: &str = "control enabled";
#[must_use]
pub fn status_line(app: &App, width: u16) -> Line<'static> {
let palette = app.palette();
let (left, left_style) = if let Some(prompt) = app.settings().and_then(Settings::pending) {
let text = if prompt.sent {
format!("{} sent, waiting for the shepherd", prompt.text)
} else {
format!("{} enter confirms, any other key cancels", prompt.text)
};
(text, palette.attention())
} else if let Some(buffer) = app.bleats_pane().and_then(BleatsPane::match_editing) {
(
format!("match {buffer}\u{258f} enter applies esc cancels"),
palette.attention(),
)
} else if let Some(buffer) = app
.sheep_pane()
.and_then(|pane| pane.feed().match_editing())
{
(
format!("match {buffer}\u{258f} enter applies esc cancels"),
palette.attention(),
)
} else if let Some((label, buffer)) = app.config_pane().and_then(pane_editor) {
(
format!("{label} {buffer}\u{258f} enter applies esc cancels"),
palette.attention(),
)
} else if let Some(action) = app.action().filter(|a| !a.sent) {
(confirm_prompt(&action), palette.attention())
} else if let Some((field, buffer)) = app.settings().and_then(Settings::typing) {
(
format!(
"editing {} {buffer}\u{258f} enter applies esc cancels",
field_label(*field)
),
palette.attention(),
)
} else if let Some((label, buffer)) = secrets_typing(app) {
(
format!("{label} {buffer}\u{258f} enter applies esc cancels"),
palette.attention(),
)
} else if let Some(key) = secrets_armed(app) {
(
format!("delete {key}? enter confirms, any other key cancels"),
palette.attention(),
)
} else if app.mode() == InputMode::Text {
(
format!(
"filter {}\u{258f} enter applies esc cancels ctrl-c quits",
app.filter()
),
palette.attention(),
)
} else if let Some(notice) = app.notice() {
(
notice.to_string(),
if notice.is_grave() {
palette.refusal()
} else {
palette.attention()
},
)
} else if let Some(action) = app.action() {
let text = in_flight_text(&action);
(text, palette.attention())
} else if app.close_dialog().is_some() {
(CLOSE_DIALOG_HINT.to_string(), palette.muted())
} else if let Some(pane) = app.config_pane() {
(
pane_hint(app.control(), pane_screen(pane)).to_string(),
palette.attention(),
)
} else if app.bleats_pane().is_some() {
(BLEATS_HINT.to_string(), palette.attention())
} else if matches!(app.body(), Body::Secrets(_)) {
(secrets_hint(app.control()), palette.attention())
} else if app.sheep_pane().is_some() {
(
sheep_pane_hint(app.control()).to_string(),
palette.attention(),
)
} else if app.settings().is_none() && !app.filter().is_empty() {
(
format!("filter \"{}\" / edit esc clear", app.filter()),
palette.muted(),
)
} else if matches!(app.link(), Link::Lost { .. }) {
(FROZEN_HINT.to_string(), palette.attention())
} else {
(
hint_for(app.control(), app.settings().is_some(), app.grouping()),
palette.attention(),
)
};
let right = if matches!(app.link(), Link::Lost { .. }) {
"\u{2588} frozen"
} else if app.bleats_pane().is_some_and(BleatsPane::following) {
"\u{2588} following"
} else {
match app.control() {
Control::ReadOnly => READ_ONLY_LABEL,
Control::Allowed => CONTROL_ENABLED_LABEL,
}
};
let right_len = u16::try_from(right.chars().count()).unwrap_or(0);
let left_width = width.saturating_sub(right_len).saturating_sub(1);
let ground = palette.ground();
Line::from(vec![
Span::styled(fit(&left, left_width), left_style.patch(ground)),
Span::styled(format!(" {right}"), palette.muted().patch(ground)),
])
}
fn confirm_prompt(action: &ActionState<'_>) -> String {
match action.target {
RowKey::Sheep(id) => format!(
"{} {} (id {id})? enter confirms, any other key cancels",
action.verb.label(),
action.name
),
RowKey::Group(name) => {
let count = action.count;
format!(
"{} all {count} instances of {name}? enter confirms, any other key cancels",
action.verb.label()
)
}
RowKey::Fold(name) => format!(
"{} all {} sheep in fold {name}? enter confirms, any other key cancels",
action.verb.label(),
action.count
),
RowKey::Section(_) => unreachable!("a header is never an action target"),
}
}
fn in_flight_text(action: &ActionState<'_>) -> String {
match action.target {
RowKey::Sheep(id) => format!(
"{} {} (id {id}): sent, waiting for the shepherd",
action.verb.label(),
action.name
),
RowKey::Group(name) => format!(
"{} all {} instances of {name}: sent, waiting for the shepherd",
action.verb.label(),
action.count
),
RowKey::Fold(name) => format!(
"{} all {} sheep in fold {name}: sent, waiting for the shepherd",
action.verb.label(),
action.count
),
RowKey::Section(_) => unreachable!("a header is never an action target"),
}
}
fn pane_editor(pane: &ConfigPane) -> Option<(String, &str)> {
if let Some(list) = pane.list() {
return match list.typing()? {
(Some(index), buffer) => Some((format!("{} {index} =", list.key()), buffer)),
(None, buffer) => Some((format!("new {} element", list.key()), buffer)),
};
}
if let Some(env) = pane.env_typing() {
return match env.key() {
Some(key) => Some((format!("env {key} ="), env.buffer())),
None => Some(("new env KEY=value".to_owned(), env.buffer())),
};
}
let typing = pane.typing()?;
Some((format!("editing {}", typing.key), typing.buffer.as_str()))
}
fn secrets_typing(app: &App) -> Option<(String, &str)> {
let Body::Secrets(pane) = app.body() else {
return None;
};
let typing = pane.typing.as_ref()?;
let label = match &typing.what {
TypingWhat::NewKey => "new key".to_string(),
TypingWhat::ValueFor(key) => format!("value for {key}"),
};
Some((label, typing.buffer.as_str()))
}
fn secrets_armed(app: &App) -> Option<&str> {
let Body::Secrets(pane) = app.body() else {
return None;
};
pane.armed.as_ref().map(|a| a.key.as_str())
}
const BLEATS_HINT: &str = "esc back j/k line ctrl-d/u page G end \
/ search n/N match f follow w wrap o out/err/both m level";
const CLOSE_DIALOG_HINT: &str = "the dialog owns the keyboard until it is answered or it expires";
fn secrets_hint(control: Control) -> String {
match control {
Control::ReadOnly => {
"esc/S close \u{2190}/\u{2192} tab z collapse v reveal for 10s \
y copy q quit"
.to_string()
}
Control::Allowed => {
"esc/S close \u{2190}/\u{2192} tab z collapse v reveal for 10s \
\u{21b5} set a value D delete y copy q quit"
.to_string()
}
}
}
const fn pane_hint(control: Control, screen: PaneScreen) -> &'static str {
match (control, screen) {
(Control::ReadOnly, PaneScreen::Fields) => {
"esc close j/k select g/G first/last r refresh h help * yours ! parked q quit"
}
(Control::Allowed, PaneScreen::Fields) => {
"esc close j/k select g/G first/last r refresh space cycle e edit d back to default u undo h help * yours ! parked q quit"
}
(Control::ReadOnly, PaneScreen::List) => {
"esc back j/k select g/G first/last r refresh q quit"
}
(Control::Allowed, PaneScreen::List) => {
"esc back j/k select g/G first/last r refresh e edit d remove K/J move u undo q quit"
}
}
}
const fn sheep_pane_hint(control: Control) -> &'static str {
match control {
Control::ReadOnly => "esc flock e edit J/K next sheep b full log / filter",
Control::Allowed => {
"esc flock e edit J/K next sheep x stop R restart L reload b full log / filter"
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PaneScreen {
Fields,
List,
}
fn pane_screen(pane: &ConfigPane) -> PaneScreen {
if pane.list().is_some() {
PaneScreen::List
} else {
PaneScreen::Fields
}
}
fn hint_for(control: Control, settings_open: bool, grouping: Grouping) -> String {
if settings_open {
return match control {
Control::ReadOnly => "esc/s close j/k select g/G first/last r refresh q quit",
Control::Allowed => {
"esc/s close j/k select g/G first/last r refresh space cycle enter apply q quit"
}
}
.to_string();
}
let folds = match grouping {
Grouping::Flat => "F folds ",
Grouping::ByFold => "F flat z collapse ",
};
match control {
Control::ReadOnly => {
format!(
"q quit j/k select g/G first/last r refresh / filter {folds}s settings e edit * yours ! parked"
)
}
Control::Allowed => {
format!(
"q quit j/k select / filter {folds}x stop R restart L reload s settings e edit * yours ! parked"
)
}
}
}
#[must_use]
pub fn rule_line(style: Style, width: u16) -> Line<'static> {
Line::from(Span::styled(cell::rule(usize::from(width)), style))
}
#[cfg(test)]
mod tests {
use std::ffi::OsStr;
use std::time::Instant;
use shep_core::protocol::BusEvent;
use super::super::fixtures::{
acting_app, allowed_app, app_armed_to_delete_a_secret, app_in_settings, app_in_settings_on,
app_in_settings_with_control, armed_app, armed_app_with_a_filter_and_a_notice, editing_app,
filtered_app, rendered, with_selection,
};
use super::*;
use crate::commands::settings::SettingField;
use crate::lookout::app::{ActionVerb, App, KeyPress, Msg};
use crate::lookout::theme::Palette;
#[test]
fn the_frozen_hint_clauses_are_separated_by_exactly_three_spaces() {
assert!(
!FROZEN_HINT.contains(" "),
"a separator wider than three spaces: {FROZEN_HINT:?}"
);
assert_eq!(
FROZEN_HINT.matches(" ").count(),
3,
"three clause gaps, so three separators: {FROZEN_HINT:?}"
);
}
#[test]
fn the_frozen_hint_names_the_keymap_it_no_longer_refuses() {
assert!(FROZEN_HINT.contains("h keymap"), "{FROZEN_HINT}");
assert!(
FROZEN_HINT.contains("q quit") && FROZEN_HINT.contains("j/k g/G move"),
"{FROZEN_HINT}"
);
}
#[test]
fn the_frozen_hint_claims_no_blanket_refusal() {
assert!(
!FROZEN_HINT.contains("every other key"),
"the blanket-refusal claim is back, and it is false: {FROZEN_HINT}"
);
assert!(
FROZEN_HINT.contains("nothing you press can reach the shepherd"),
"{FROZEN_HINT}"
);
}
#[test]
fn the_legend_fits_wherever_the_cfg_column_is_drawn() {
let widest = [
hint_for(Control::ReadOnly, false, Grouping::Flat),
hint_for(Control::Allowed, false, Grouping::Flat),
]
.into_iter()
.map(|hint| hint.chars().count())
.max()
.expect("two hints");
let cfg_tier = usize::from(super::super::flock::cfg_tier_width());
assert!(
widest <= cfg_tier,
"the hint is {widest} wide and CFG draws from {cfg_tier}, so the \
legend truncates where the glyph still shows"
);
}
#[test]
fn a_truncated_hint_still_leaves_a_gap_before_the_control_label() {
let palette = Palette::detect(None, Some(OsStr::new("xterm-256color")), None);
let app = App::new(
palette,
Control::ReadOnly,
"/home/ada/.shep".to_string(),
Instant::now(),
);
let line = status_line(&app, 49);
let rendered: String = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
assert_eq!(rendered.chars().count(), 49, "must fill the full width");
assert!(
rendered.ends_with(" read-only"),
"expected a space before the label, got: {rendered:?}"
);
assert!(
!rendered.contains("…read-only"),
"the ellipsis must not butt straight against the label: {rendered:?}"
);
}
#[test]
fn the_key_hint_says_what_the_keys_now_do() {
let app = App::new(
Palette::detect(None, None, None),
Control::ReadOnly,
"/home/ada/.shep".to_string(),
Instant::now(),
);
let hint: String = status_line(&app, 200)
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
assert!(hint.contains("j/k select"), "got {hint:?}");
assert!(hint.contains("g/G first/last"), "got {hint:?}");
assert!(
!hint.contains("scroll"),
"the pane no longer scrolls: {hint:?}"
);
}
#[test]
fn a_wide_status_line_still_pads_out_to_the_full_width() {
let palette = Palette::detect(None, Some(OsStr::new("xterm-256color")), None);
let app = App::new(
palette,
Control::Allowed,
"/home/ada/.shep".to_string(),
Instant::now(),
);
let line = status_line(&app, 120);
let rendered: String = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
assert_eq!(rendered.chars().count(), 120);
assert!(rendered.ends_with(" control enabled"));
}
#[test]
fn the_bar_names_the_filter_keys_while_a_filter_is_applied() {
let app = filtered_app("web");
let bar = rendered(&status_line(&app, 120));
assert!(bar.contains("filter \"web\""), "the query, quoted: {bar:?}");
assert!(bar.contains("/ edit"), "got {bar:?}");
assert!(bar.contains("esc clear"), "got {bar:?}");
}
#[test]
fn the_bar_carries_the_query_and_a_cursor_while_editing() {
let app = editing_app("we");
let bar = rendered(&status_line(&app, 120));
assert!(
bar.contains("filter we\u{258f}"),
"query then cursor: {bar:?}"
);
assert!(bar.contains("enter applies"), "got {bar:?}");
assert!(bar.contains("esc cancels"), "got {bar:?}");
assert!(bar.contains("ctrl-c quits"), "got {bar:?}");
}
#[test]
fn the_bar_shows_the_settings_editor_rather_than_the_filter_box() {
let mut app = app_in_settings_on(SettingField::Socket);
let _ = app.update(Msg::Key(KeyPress::Confirm));
let bar = rendered(&status_line(&app, 120));
assert!(
bar.contains("editing socket "),
"names the field being typed: {bar:?}"
);
assert!(
bar.contains("/home/ada/.shep/run/shep.sock\u{258f}"),
"shows the buffer and the cursor, not the dashboard's own filter: {bar:?}"
);
assert!(
!bar.contains("filter "),
"must not read as the filter box: {bar:?}"
);
}
#[test]
fn a_notice_raised_while_typing_does_not_cover_the_box() {
let mut app = editing_app("we");
app.update(Msg::Event(BusEvent::Dropped { count: 3 }));
let bar = rendered(&status_line(&app, 120));
assert!(
bar.contains("filter we\u{258f}"),
"the box is still there: {bar:?}"
);
assert!(!bar.contains("dropped 3 events"), "got {bar:?}");
}
#[test]
fn closing_the_box_shows_the_notice_that_was_waiting() {
let mut app = editing_app("we");
app.update(Msg::Event(BusEvent::Dropped { count: 3 }));
app.update(Msg::Key(KeyPress::TextApply));
let bar = rendered(&status_line(&app, 120));
assert!(bar.contains("dropped 3 events"), "got {bar:?}");
}
#[test]
fn the_read_only_hint_advertises_the_filter_key() {
let app = filtered_app("");
let hint = rendered(&status_line(&app, 200));
assert!(hint.contains("/ filter"), "got {hint:?}");
}
#[test]
fn the_dashboard_hint_says_what_the_cfg_glyphs_mean() {
for control in [Control::ReadOnly, Control::Allowed] {
let hint = hint_for(control, false, Grouping::Flat);
assert!(hint.contains("* yours"), "{hint}");
assert!(hint.contains("! parked"), "{hint}");
}
}
#[test]
fn the_hint_names_the_fold_keys_and_only_names_z_in_fold_view() {
for control in [Control::ReadOnly, Control::Allowed] {
let flat = hint_for(control, false, Grouping::Flat);
assert!(flat.contains("F folds"), "{flat}");
assert!(!flat.contains("z collapse"), "z does nothing here: {flat}");
let folded = hint_for(control, false, Grouping::ByFold);
assert!(folded.contains("F flat"), "{folded}");
assert!(folded.contains("z collapse"), "{folded}");
}
}
#[test]
fn an_armed_confirm_names_the_verb_the_sheep_and_the_answer() {
let app = armed_app(ActionVerb::Restart);
let bar = rendered(&status_line(&app, 120));
assert!(bar.contains("restart api (id 2)?"), "got {bar:?}");
assert!(
bar.contains("enter confirms, any other key cancels"),
"got {bar:?}"
);
}
#[test]
fn an_armed_secret_delete_names_the_key_and_the_answer() {
let app = app_armed_to_delete_a_secret();
let bar = rendered(&status_line(&app, 120));
assert!(bar.contains("delete DB_PASSWORD"), "got {bar:?}");
assert!(
bar.contains("enter confirms, any other key cancels"),
"got {bar:?}"
);
}
#[test]
fn an_armed_secret_delete_stops_advertising_set_a_value() {
let app = app_armed_to_delete_a_secret();
let bar = rendered(&status_line(&app, 120));
assert!(
!bar.contains("set a value"),
"the arm changes what enter does: {bar:?}"
);
}
#[test]
fn an_in_flight_action_says_it_is_waiting() {
let app = acting_app(ActionVerb::Stop);
let bar = rendered(&status_line(&app, 120));
assert!(
bar.contains("stop api (id 2): sent, waiting for the shepherd"),
"got {bar:?}"
);
}
#[test]
fn the_confirm_outranks_a_notice_and_the_filter_line() {
let app = armed_app_with_a_filter_and_a_notice();
let bar = rendered(&status_line(&app, 120));
assert!(bar.contains("stop api (id 2)?"), "got {bar:?}");
assert!(
!bar.contains("filter \""),
"the filter line is below it: {bar:?}"
);
}
#[test]
fn a_refusal_while_an_action_is_in_flight_reaches_the_bar() {
let mut app = acting_app(ActionVerb::Stop);
app.update(Msg::Key(KeyPress::Action(ActionVerb::Restart)));
let bar = rendered(&status_line(&app, 120));
assert!(
bar.contains("one action is already in flight"),
"the refusal is on the bar, not only in the reducer: {bar:?}"
);
let mut app = acting_app(ActionVerb::Stop);
app.update(Msg::Event(BusEvent::DaemonShutdown));
let bar = rendered(&status_line(&app, 120));
assert!(bar.contains("the shepherd is shutting down"), "got {bar:?}");
}
#[test]
fn the_in_flight_line_comes_back_when_the_notice_clears() {
let mut app = acting_app(ActionVerb::Stop);
app.update(Msg::Key(KeyPress::Action(ActionVerb::Restart)));
app.update(Msg::Key(KeyPress::SelectDown));
let bar = rendered(&status_line(&app, 120));
assert!(
bar.contains("stop api (id 2): sent, waiting for the shepherd"),
"got {bar:?}"
);
}
#[test]
fn the_action_keys_are_advertised_only_when_the_gate_is_open() {
let closed = rendered(&status_line(&filtered_app(""), 200));
for key in ["x stop", "R restart", "L reload"] {
assert!(
!closed.contains(key),
"{key} advertised read-only: {closed:?}"
);
}
let open = rendered(&status_line(&allowed_app(), 200));
for key in ["x stop", "R restart", "L reload"] {
assert!(
open.contains(key),
"{key} missing when the gate is open: {open:?}"
);
}
assert!(
open.contains("/ filter"),
"and the filter key survives both forms"
);
}
#[test]
fn the_settings_edit_keys_are_advertised_only_when_the_gate_is_open() {
let closed = rendered(&status_line(&app_in_settings(), 200));
for key in ["space cycle", "enter apply"] {
assert!(
!closed.contains(key),
"{key} advertised read-only: {closed:?}"
);
}
let open = rendered(&status_line(&app_in_settings_with_control(), 200));
for key in ["space cycle", "enter apply"] {
assert!(
open.contains(key),
"{key} missing when the gate is open: {open:?}"
);
}
for both in [&closed, &open] {
assert!(both.contains("esc/s close"), "got {both:?}");
assert!(both.contains("r refresh"), "got {both:?}");
}
}
#[test]
fn q_quit_is_named_on_the_settings_screen_in_both_control_states() {
let closed = rendered(&status_line(&app_in_settings(), 200));
let open = rendered(&status_line(&app_in_settings_with_control(), 200));
for hint in [&closed, &open] {
assert!(hint.contains("q quit"), "got {hint:?}");
}
}
fn pane_to(app: &mut App, key: &str) {
super::super::fixtures::select_field(app, key);
}
#[test]
fn the_panes_edit_keys_are_advertised_only_when_the_gate_is_open() {
let closed = rendered(&status_line(
&super::super::fixtures::app_in_sheep_pane(),
200,
));
let open = rendered(&status_line(
&super::super::fixtures::app_in_sheep_pane_with_control(),
200,
));
for key in ["space cycle", "e edit"] {
assert!(
!closed.contains(key),
"{key} advertised read-only: {closed:?}"
);
assert!(
open.contains(key),
"{key} missing with the gate open: {open:?}"
);
}
assert!(closed.contains("esc close"), "got {closed:?}");
assert!(
open.contains("esc close"),
"the gate is open, but esc only asks or closes now: {open:?}"
);
for both in [&closed, &open] {
assert!(both.contains("h help"), "got {both:?}");
assert!(both.contains("* yours"), "got {both:?}");
assert!(both.contains("! parked"), "got {both:?}");
assert!(both.contains("q quit"), "got {both:?}");
assert!(!both.contains("x stop"), "got {both:?}");
}
}
#[test]
fn an_env_rows_status_bar_hint_is_the_field_lists_own() {
let mut app = super::super::fixtures::app_in_sheep_pane_with_control();
app.update(Msg::Key(KeyPress::SelectLast));
assert!(matches!(
app.config_pane().unwrap().cursor(),
Some(crate::lookout::pane::PaneRow::Env(_) | crate::lookout::pane::PaneRow::AddEnv)
));
let bar = rendered(&status_line(&app, 200));
assert!(bar.contains("esc close"), "got {bar:?}");
assert!(bar.contains("e edit"), "got {bar:?}");
}
#[test]
fn every_pane_hint_names_the_movement_and_refresh_keys_it_binds() {
for screen in [PaneScreen::Fields, PaneScreen::List] {
for control in [Control::ReadOnly, Control::Allowed] {
let hint = pane_hint(control, screen);
for key in ["j/k select", "g/G first/last", "r refresh", "q quit"] {
assert!(hint.contains(key), "{control:?} {screen:?}: {hint:?}");
}
}
}
}
#[test]
fn the_field_lists_hint_carries_a_legend_for_its_own_flag_glyphs() {
for control in [Control::ReadOnly, Control::Allowed] {
let hint = pane_hint(control, PaneScreen::Fields);
assert!(hint.contains("* yours"), "{control:?}: {hint:?}");
assert!(hint.contains("! parked"), "{control:?}: {hint:?}");
let sub = pane_hint(control, PaneScreen::List);
assert!(!sub.contains('*'), "{control:?}: {sub:?}");
assert!(!sub.contains('!'), "{control:?}: {sub:?}");
}
}
#[test]
fn the_env_rows_movement_and_refresh_keys_do_what_the_hint_says() {
let mut app = super::super::fixtures::app_in_sheep_pane_with_control();
app.update(Msg::Key(KeyPress::SelectLast));
assert_eq!(
app.config_pane().unwrap().cursor(),
Some(crate::lookout::pane::PaneRow::AddEnv)
);
app.update(Msg::Key(KeyPress::SelectFirst));
assert_eq!(
app.config_pane().unwrap().cursor(),
Some(crate::lookout::pane::PaneRow::Field(0))
);
assert!(matches!(
app.update(Msg::Key(KeyPress::Refresh)),
crate::lookout::app::Effect::Send(_)
));
}
#[test]
fn a_filed_pane_edit_leaves_the_status_bar_on_its_key_hint() {
let mut app = super::super::fixtures::app_in_sheep_pane_with_control();
pane_to(&mut app, "autorestart");
app.update(Msg::Key(KeyPress::Cycle));
let bar = rendered(&status_line(&app, 200));
assert!(!bar.contains("enter confirms"), "got {bar:?}");
assert!(!bar.contains("sent, waiting"), "got {bar:?}");
assert!(bar.contains("esc close"), "got {bar:?}");
}
#[test]
fn the_panes_editors_get_their_own_status_line_rather_than_the_filters() {
let mut app = super::super::fixtures::app_in_sheep_pane_with_control();
pane_to(&mut app, "cwd");
app.update(Msg::Key(KeyPress::Confirm));
app.update(Msg::Key(KeyPress::TextChar('x')));
let field = rendered(&status_line(&app, 200));
assert!(field.contains("editing cwd"), "got {field:?}");
assert!(!field.contains("filter"), "got {field:?}");
app.update(Msg::Key(KeyPress::TextAbandon));
app.update(Msg::Key(KeyPress::SelectLast));
app.update(Msg::Key(KeyPress::SelectUp));
app.update(Msg::Key(KeyPress::SelectUp));
assert_eq!(
app.config_pane().unwrap().cursor(),
Some(crate::lookout::pane::PaneRow::Env(0))
);
app.update(Msg::Key(KeyPress::Confirm));
app.update(Msg::Key(KeyPress::TextChar('y')));
let env = rendered(&status_line(&app, 200));
assert!(env.contains("env DB_HOST ="), "got {env:?}");
assert!(env.contains('y'), "got {env:?}");
assert!(!env.contains("filter"), "got {env:?}");
}
#[test]
fn the_config_pane_gets_its_own_key_hint() {
let app = super::super::fixtures::app_in_sheep_pane();
let bar = status_line(&app, 120).to_string();
assert!(bar.contains("esc close"), "got {bar:?}");
assert!(bar.contains("r refresh"), "got {bar:?}");
assert!(!bar.contains("x stop"), "got {bar:?}");
assert!(!bar.contains("s settings"), "got {bar:?}");
}
fn filed_args(app: &App) -> Option<serde_json::Value> {
use super::super::super::edits::EditKey;
use super::super::super::pane::PaneEdit;
match app
.config_pane()?
.edits()
.get(&EditKey::Field("args".to_owned()))?
.edit()
{
PaneEdit::Set { value, .. } => Some(value.as_value().clone()),
PaneEdit::SetEnv { .. } => None,
}
}
#[test]
fn the_list_sub_screens_own_keys_do_what_its_hint_says() {
let mut app = super::super::fixtures::app_in_sheep_pane_with_control();
pane_to(&mut app, "args");
app.update(Msg::Key(KeyPress::Confirm));
let bar = rendered(&status_line(&app, 200));
assert!(bar.contains("esc back"), "got {bar:?}");
assert!(bar.contains("d remove"), "got {bar:?}");
assert!(bar.contains("K/J move"), "got {bar:?}");
app.update(Msg::Key(KeyPress::StepDown));
assert_eq!(
filed_args(&app),
Some(serde_json::json!(["8080", "--port"])),
"J files the array with the element moved down"
);
app.update(Msg::Key(KeyPress::Remove));
assert_eq!(
filed_args(&app),
Some(serde_json::json!(["--port"])),
"d files the array without the element under the cursor"
);
}
#[test]
fn the_bleats_match_box_owns_the_status_bar_while_it_is_open() {
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
let mut app = with_selection(ProcessInfo::builder(9, "web", ProcStatus::Online).build());
let _ = app.update(Msg::Key(KeyPress::Bleats));
let _ = app.update(Msg::Key(KeyPress::FilterStart));
for typed in "pool".chars() {
let _ = app.update(Msg::Key(KeyPress::TextChar(typed)));
}
let bar = rendered(&status_line(&app, 160));
assert!(bar.contains("match pool"), "got {bar}");
assert!(!bar.contains("filter"), "not the dashboard's box: {bar}");
}
#[test]
fn the_bleats_pane_gets_its_own_status_line() {
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
let mut app = with_selection(ProcessInfo::builder(9, "web", ProcStatus::Online).build());
let _ = app.update(Msg::Key(KeyPress::Bleats));
let bar = rendered(&status_line(&app, 200));
for key in [
"esc back",
"j/k line",
"ctrl-d/u page",
"G end",
"/ search",
"n/N match",
"f follow",
"w wrap",
"o out/err/both",
"m level",
] {
assert!(bar.contains(key), "missing {key:?}: got {bar}");
}
}
#[test]
fn the_following_indicator_replaces_control_state_while_pinned_to_the_tail() {
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
let mut app = with_selection(ProcessInfo::builder(9, "web", ProcStatus::Online).build());
let _ = app.update(Msg::Key(KeyPress::Bleats));
let bar = rendered(&status_line(&app, 160));
assert!(bar.contains("\u{2588} following"), "got {bar}");
assert!(!bar.contains("read-only"), "got {bar}");
let _ = app.update(Msg::Key(KeyPress::SelectUp));
let scrolled = rendered(&status_line(&app, 160));
assert!(
!scrolled.contains("\u{2588} following"),
"scrolled back, no longer following: {scrolled}"
);
assert!(scrolled.contains("read-only"), "got {scrolled}");
}
#[test]
fn the_sheep_panes_hint_names_the_write_keys_and_the_feeds_own() {
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
let mut app = with_selection(ProcessInfo::builder(9, "web", ProcStatus::Online).build());
app.set_control_for_tests(Control::Allowed);
let _ = app.update(Msg::Key(KeyPress::Confirm));
let bar = rendered(&status_line(&app, 200));
for key in [
"esc flock",
"e edit",
"J/K next sheep",
"x stop",
"R restart",
"L reload",
"b full log",
"/ filter",
] {
assert!(bar.contains(key), "missing {key:?}: got {bar}");
}
}
#[test]
fn the_sheep_panes_hint_drops_the_write_keys_under_read_only() {
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
let mut app = with_selection(ProcessInfo::builder(9, "web", ProcStatus::Online).build());
let _ = app.update(Msg::Key(KeyPress::Confirm));
let bar = rendered(&status_line(&app, 200));
assert!(bar.contains("esc flock"), "got {bar}");
assert!(!bar.contains("x stop"), "got {bar}");
assert!(!bar.contains("R restart"), "got {bar}");
assert!(!bar.contains("L reload"), "got {bar}");
assert!(bar.contains("b full log"), "got {bar}");
assert!(bar.contains("/ filter"), "got {bar}");
}
}