use crate::collect::panes::Panes;
use crate::collect::run::RunFailure;
use crate::model::join::{AgentRef, BeadKey, Conflict};
use crate::model::snapshot::{ProviderState, Snapshot};
use crate::model::types::PaneKey;
use crate::view::forest::Forest;
use crate::view::lines::{Content, Item};
use crate::view::phrase;
pub const LINES: u16 = 6;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Tail {
Pane { pane: PaneKey, lines: Vec<String> },
Reading { pane: PaneKey },
Silent(&'static str),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Target<'a> {
Pane(&'a PaneKey),
NoAgent,
NotABead,
}
impl Target<'_> {
pub fn pane(&self) -> Option<&PaneKey> {
match self {
Target::Pane(pane) => Some(pane),
Target::NoAgent | Target::NotABead => None,
}
}
}
pub fn target(forest: &Forest) -> Target<'_> {
let Some(line) = forest.lines().get(forest.selected_line()) else {
return Target::NotABead;
};
match &line.content {
Content::Bead(_) => line
.bead()
.and_then(|key| agent(forest.snapshot(), key))
.map_or(Target::NoAgent, |agent| Target::Pane(&agent.pane)),
Content::Item(item) => named_pane(item).map_or(Target::NotABead, Target::Pane),
Content::Project(_)
| Content::Unread(_)
| Content::Elided { .. }
| Content::Note(_)
| Content::Group(_)
| Content::Scoped { .. } => Target::NotABead,
}
}
fn named_pane(item: &Item) -> Option<&PaneKey> {
match item {
Item::Loose(loose) => Some(&loose.pane),
Item::Unconfigured(unconfigured) => Some(&unconfigured.pane),
Item::Conflict(
Conflict::SeveralBeadsNameOnePane { pane, .. }
| Conflict::PaneInAnotherProject { pane, .. },
) => Some(pane),
Item::Conflict(
Conflict::BeadAndPaneDisagree { .. }
| Conflict::SeveralPanesNameOneBead { .. }
| Conflict::PaneIdInSeveralSessions { .. },
)
| Item::Failed(_) => None,
}
}
fn agent<'a>(snapshot: &'a Snapshot, key: &BeadKey) -> Option<&'a AgentRef> {
snapshot.node(key)?.agent.as_ref()
}
pub fn tail(forest: &Forest) -> Tail {
match forest.snapshot().agents.state {
ProviderState::NotAnswering => return Tail::Silent(phrase::no_session_to_tail()),
ProviderState::Absent => return Tail::Silent(phrase::no_provider_to_tail()),
ProviderState::Answering => {}
}
match target(forest) {
Target::NotABead => Tail::Silent(phrase::no_bead_to_tail()),
Target::NoAgent => Tail::Silent(phrase::no_agent_to_tail()),
Target::Pane(pane) => Tail::Reading { pane: pane.clone() },
}
}
pub fn read(pane: PaneKey, read: Result<Vec<String>, RunFailure>) -> Tail {
match read {
Ok(lines) => Tail::Pane { pane, lines },
Err(failure) => Tail::Silent(phrase::pane_unreadable(failure.kind)),
}
}
pub fn moved_on(forest: &Forest, showing: Option<&PaneKey>) -> bool {
match target(forest).pane() {
Some(pane) => showing != Some(pane),
None => true,
}
}
pub fn focus(forest: &Forest, panes: &dyn Panes) {
if let Some(pane) = target(forest).pane() {
panes.focus(pane);
}
}
pub fn focused(focused: Result<(), RunFailure>) -> Option<Tail> {
match focused {
Ok(()) => None,
Err(failure) => Some(Tail::Silent(phrase::pane_unreadable(failure.kind))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::collect::run::{FailureKind, RunFailure};
use crate::config::Scope;
use crate::model::join::JoinSource;
use crate::model::snapshot::{
a_provider, Counts, FailedProject, Filter, LoosePane, Node, TrackerFailure, TrackerState,
Tree, UnconfiguredPane,
};
use crate::model::tree::Link;
use crate::model::types::testing::key as pane_key;
use crate::model::types::{Edge, PaneStatus, Status};
use crate::view::forest;
use crate::view::lines::GroupKind;
use crate::view::tests::every_failure_kind;
use crate::view::walk::{self, Rows};
use crate::view::{Action, Motion};
use chrono::Utc;
use pretty_assertions::assert_eq;
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::sync::Arc;
#[derive(Default)]
struct Fake {
asked: RefCell<Vec<String>>,
focused: RefCell<Vec<String>>,
}
impl Panes for Fake {
fn read(&self, pane: &PaneKey, lines: u16) {
self.asked.borrow_mut().push(format!("{} {lines}", pane.id));
}
fn focus(&self, pane: &PaneKey) {
self.focused.borrow_mut().push(pane.id.clone());
}
}
fn failure(kind: FailureKind) -> RunFailure {
RunFailure {
kind,
program: "herdr".to_string(),
detail: "the test said so".to_string(),
unreadable: None,
}
}
fn agent_on(pane: &str) -> AgentRef {
AgentRef {
pane: pane_key(pane),
pane_status: PaneStatus::Working,
title: None,
source: JoinSource::AgentPane,
}
}
fn node(id: &str, agent: Option<AgentRef>) -> Node {
Node {
id: id.to_string(),
title: "a bead in the tree".to_string(),
status: Status::InProgress,
issue_type: "task".to_string(),
priority: 2,
ready: true,
blocked_by: Vec::new(),
started_at: None,
closed_at: None,
badges: Vec::new(),
agent,
anomalies: Vec::new(),
description: String::new(),
notes: String::new(),
owner: None,
parent: None,
depends_on: Vec::new(),
blocks: Vec::new(),
}
}
fn snapshot(agents: ProviderState) -> Snapshot {
let tree = Arc::new(Tree {
project: "orbital".to_string(),
root: "orb-7".to_string(),
title: "lift the ground station".to_string(),
counts: Counts {
total: 5,
closed: 0,
live_agents: 2,
anomalies: 0,
},
tracker: TrackerState::Ok,
beads: vec![
node("orb-7", None),
node("orb-7.1", Some(agent_on("w:p1"))),
node("orb-7.2", None),
node("orb-7.3", None),
node("orb-7.4", Some(agent_on("w:p1"))),
],
children: vec![
(1..5)
.map(|bead| Link {
bead,
edge: Edge::ParentChild,
first: true,
})
.collect(),
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
],
dangling: Vec::new(),
cycles: Vec::new(),
});
Snapshot {
generated_at: Utc::now(),
agents: a_provider(agents),
filter: Filter::All,
trees: vec![Arc::clone(&tree)],
hidden_trees: Vec::new(),
failed_projects: Vec::new(),
unattributed: Vec::new(),
unconfigured: Vec::new(),
conflicts: Vec::new(),
projects_named_without_git: Vec::new(),
read_at: BTreeMap::new(),
collected: vec![tree],
projects: vec!["orbital".to_string()],
scope: Scope::default(),
}
}
fn selecting(steps: usize, agents: ProviderState) -> Forest {
let mut forest = forest::flatten(snapshot(agents));
for _ in 0..steps {
forest.apply(Action::Move(Motion::NextRow));
}
forest
}
fn on_the_project(agents: ProviderState) -> Forest {
let mut forest = forest::flatten(snapshot(agents));
forest.apply(Action::Move(Motion::FirstRow));
forest
}
#[test]
fn the_tail_follows_the_selection() {
assert_eq!(
tail(&selecting(1, ProviderState::Answering)),
Tail::Reading {
pane: pane_key("w:p1")
}
);
assert_eq!(
tail(&selecting(2, ProviderState::Answering)),
Tail::Silent(phrase::no_agent_to_tail()),
"the row below is a bead nobody is working"
);
}
#[test]
fn what_herdr_read_is_the_band_under_the_forest() {
assert_eq!(
read(
pane_key("w:p1"),
Ok(vec!["rebuilt .#larkspur, generation 541".to_string()])
),
Tail::Pane {
pane: pane_key("w:p1"),
lines: vec!["rebuilt .#larkspur, generation 541".to_string()],
}
);
}
#[test]
fn a_project_line_has_no_pane_to_tail() {
assert_eq!(
tail(&on_the_project(ProviderState::Answering)),
Tail::Silent(phrase::no_bead_to_tail())
);
}
#[test]
fn a_provider_that_will_not_answer_means_no_pane_to_read() {
assert_eq!(
tail(&selecting(1, ProviderState::NotAnswering)),
Tail::Silent(phrase::no_session_to_tail()),
"with no session there is no pane on any row, whatever the row says"
);
}
#[test]
fn no_provider_at_all_is_said_in_its_own_words() {
assert_eq!(
tail(&selecting(1, ProviderState::Absent)),
Tail::Silent(phrase::no_provider_to_tail())
);
assert_ne!(
phrase::no_provider_to_tail(),
phrase::no_session_to_tail(),
"the two states share a sentence, so the band cannot tell them apart"
);
}
#[test]
fn a_pane_that_has_gone_degrades_to_a_phrase() {
assert_eq!(
read(pane_key("w:p1"), Err(failure(FailureKind::Gone))),
Tail::Silent(phrase::pane_unreadable(FailureKind::Gone))
);
}
#[test]
fn every_way_a_read_can_fail_is_said_rather_than_drawn_blank() {
for kind in every_failure_kind() {
let band = read(pane_key("w:p1"), Err(failure(kind)));
let Tail::Silent(said) = band else {
panic!("{kind:?} left the band drawing a pane rather than saying so")
};
assert_eq!(said, phrase::pane_unreadable(kind), "for {kind:?}");
assert!(!said.trim().is_empty(), "for {kind:?}");
}
}
#[test]
fn enter_focuses_the_pane_the_selection_is_on() {
let panes = Fake::default();
focus(&selecting(1, ProviderState::Answering), &panes);
assert_eq!(*panes.focused.borrow(), ["w:p1"]);
}
#[test]
fn enter_on_a_row_with_no_pane_is_a_no_op_and_not_an_error() {
let panes = Fake::default();
for steps in [0, 2] {
focus(&selecting(steps, ProviderState::Answering), &panes);
}
assert!(panes.focused.borrow().is_empty());
}
#[test]
fn a_pane_that_will_not_come_to_the_front_says_so_where_the_tail_is() {
assert_eq!(focused(Ok(())), None);
assert_eq!(
focused(Err(failure(FailureKind::Gone))),
Some(Tail::Silent(phrase::pane_unreadable(FailureKind::Gone)))
);
}
#[test]
fn the_tail_is_read_again_only_where_the_selection_has_left_the_pane() {
assert!(
!moved_on(
&selecting(1, ProviderState::Answering),
Some(&pane_key("w:p1"))
),
"the selection is still on the pane the tail is showing"
);
assert!(moved_on(
&selecting(2, ProviderState::Answering),
Some(&pane_key("w:p1"))
));
assert!(moved_on(&selecting(1, ProviderState::Answering), None));
}
#[test]
fn leaving_a_header_for_a_bead_nobody_is_working_is_a_move() {
assert!(moved_on(&selecting(2, ProviderState::Answering), None));
}
#[test]
fn moving_between_two_beads_nobody_is_working_is_a_move() {
assert!(moved_on(&selecting(3, ProviderState::Answering), None));
}
#[test]
fn two_beads_on_one_pane_do_not_read_it_twice() {
assert!(!moved_on(
&selecting(4, ProviderState::Answering),
Some(&pane_key("w:p1"))
));
}
#[test]
fn a_tail_that_stands_is_the_tail_the_new_row_calls_for() {
for from in 0..5 {
let was = selecting(from, ProviderState::Answering);
let showing = target(&was).pane().cloned();
let on_screen = tail(&was);
for onto in 0..5 {
let now = selecting(onto, ProviderState::Answering);
if !moved_on(&now, showing.as_ref()) {
assert_eq!(
tail(&now),
on_screen,
"the tail read on row {from} was left standing on row {onto}"
);
}
}
}
}
fn key(id: &str) -> BeadKey {
BeadKey {
project: "orbital".to_string(),
id: id.to_string(),
}
}
fn loose() -> LoosePane {
LoosePane {
pane: pane_key("w:p2"),
project: "orbital".to_string(),
cwd: "/tmp/bdi-ground/orbital".to_string(),
pane_status: PaneStatus::Working,
display_agent: None,
title: None,
claim_refused: false,
}
}
fn another_loose() -> LoosePane {
LoosePane {
pane: pane_key("w:p10"),
project: "orbital".to_string(),
cwd: "/tmp/bdi-ground/orbital".to_string(),
pane_status: PaneStatus::Idle,
display_agent: None,
title: None,
claim_refused: false,
}
}
fn unconfigured() -> UnconfiguredPane {
UnconfiguredPane {
pane: pane_key("w:p3"),
cwd: "/tmp/bdi-ground/lander".to_string(),
pane_status: PaneStatus::Working,
}
}
fn pane_in_another_project() -> Conflict {
Conflict::PaneInAnotherProject {
bead: key("orb-7.2"),
pane: pane_key("w:p4"),
pane_project: None,
}
}
fn several_beads_name_one_pane() -> Conflict {
Conflict::SeveralBeadsNameOnePane {
pane: pane_key("w:p5"),
caption: None,
beads: vec![key("orb-7.2"), key("orb-7.3")],
}
}
fn bead_and_pane_disagree() -> Conflict {
Conflict::BeadAndPaneDisagree {
bead: key("orb-7.2"),
named_by_bead: pane_key("w:p6"),
named_by_pane: pane_key("w:p7"),
}
}
fn several_panes_name_one_bead() -> Conflict {
Conflict::SeveralPanesNameOneBead {
bead: key("orb-7.3"),
panes: vec![pane_key("w:p8"), pane_key("w:p9")],
}
}
fn failed_project() -> FailedProject {
FailedProject {
project: "lander".to_string(),
tracker: TrackerFailure::Unavailable,
}
}
fn snapshot_with_groups(agents: ProviderState) -> Snapshot {
Snapshot {
failed_projects: vec![failed_project()],
unattributed: vec![loose(), another_loose()],
unconfigured: vec![unconfigured()],
conflicts: vec![
pane_in_another_project(),
several_beads_name_one_pane(),
bead_and_pane_disagree(),
several_panes_name_one_bead(),
],
..snapshot(agents)
}
}
fn shut_group(forest: &Forest) -> Option<GroupKind> {
forest.lines().iter().find_map(|line| match &line.content {
Content::Group(group) if line.folded == Some(false) => Some(group.kind),
_ => None,
})
}
fn with_groups_open(agents: ProviderState) -> Forest {
let mut forest = forest::flatten(snapshot_with_groups(agents));
walk::until(
&mut forest,
|forest| shut_group(forest).is_none(),
|forest| {
let shut = shut_group(forest).expect("the walk stops when none is shut");
step_onto(
forest,
|content| matches!(content, Content::Group(group) if group.kind == shut),
);
forest.apply(Action::ExpandOrChild);
},
|_| "a group was still shut after the key was pressed on it".to_string(),
);
forest
}
fn step_onto(forest: &mut Forest, wanted: impl Fn(&Content) -> bool) {
forest.apply(Action::Move(Motion::FirstRow));
walk::until(
forest,
|forest| wanted(&forest.lines()[forest.selected_line()].content),
|forest| {
forest.apply(Action::Move(Motion::NextRow));
},
|_| "pressing down from the top never reached the row".to_string(),
);
}
fn onto(wanted: &Item) -> impl Fn(&Content) -> bool + use<'_> {
move |content| matches!(content, Content::Item(item) if item == wanted)
}
fn stepping(steps: usize, agents: ProviderState) -> Forest {
let mut forest = with_groups_open(agents);
forest.apply(Action::Move(Motion::FirstRow));
for _ in 0..steps {
forest.apply(Action::Move(Motion::NextRow));
}
forest
}
fn rows(agents: ProviderState) -> usize {
let mut forest = with_groups_open(agents);
forest.apply(Action::Move(Motion::FirstRow));
to_the_last_row(&mut forest);
forest.rows()
}
fn to_the_last_row(forest: &mut Forest) {
walk::until(
forest,
|forest| forest.selected_line() + 1 == forest.rows(),
|forest| {
forest.apply(Action::Move(Motion::NextRow));
},
|forest| {
format!(
"pressing down from the top stopped at row {} of {}",
forest.selected_line(),
forest.rows()
)
},
);
}
fn pane_bearing() -> [(Item, &'static str); 4] {
[
(Item::Loose(loose()), "w:p2"),
(Item::Unconfigured(unconfigured()), "w:p3"),
(Item::Conflict(pane_in_another_project()), "w:p4"),
(Item::Conflict(several_beads_name_one_pane()), "w:p5"),
]
}
#[test]
fn a_pane_no_bead_claims_can_be_tailed() {
let mut forest = with_groups_open(ProviderState::Answering);
for (item, pane) in pane_bearing() {
step_onto(&mut forest, onto(&item));
assert_eq!(
tail(&forest),
Tail::Reading {
pane: pane_key(pane)
},
"on {item:?}"
);
}
}
#[test]
fn enter_focuses_a_pane_no_bead_claims() {
let panes = Fake::default();
let mut forest = with_groups_open(ProviderState::Answering);
for (item, _) in pane_bearing() {
step_onto(&mut forest, onto(&item));
focus(&forest, &panes);
}
assert_eq!(*panes.focused.borrow(), ["w:p2", "w:p3", "w:p4", "w:p5"]);
}
#[test]
fn a_line_in_a_group_that_turns_on_no_one_pane_is_unchanged() {
let panes = Fake::default();
let mut forest = with_groups_open(ProviderState::Answering);
for item in [
Item::Conflict(bead_and_pane_disagree()),
Item::Conflict(several_panes_name_one_bead()),
Item::Failed(failed_project()),
] {
step_onto(&mut forest, onto(&item));
assert_eq!(
tail(&forest),
Tail::Silent(phrase::no_bead_to_tail()),
"on {item:?}"
);
focus(&forest, &panes);
}
assert!(panes.focused.borrow().is_empty());
}
#[test]
fn a_groups_own_line_names_no_pane() {
let panes = Fake::default();
let mut forest = with_groups_open(ProviderState::Answering);
let kinds: Vec<GroupKind> = forest
.lines()
.iter()
.filter_map(|line| match &line.content {
Content::Group(group) => Some(group.kind),
_ => None,
})
.collect();
assert_eq!(kinds.len(), 4, "the fixture fills four of the five groups");
for kind in kinds {
step_onto(
&mut forest,
|content| matches!(content, Content::Group(group) if group.kind == kind),
);
assert_eq!(
tail(&forest),
Tail::Silent(phrase::no_bead_to_tail()),
"on the {kind:?} group"
);
focus(&forest, &panes);
}
assert!(panes.focused.borrow().is_empty());
}
#[test]
fn the_tail_follows_the_selection_onto_a_loose_pane_and_off_it() {
let mut forest = with_groups_open(ProviderState::Answering);
let pane = Item::Loose(loose());
step_onto(&mut forest, onto(&pane));
assert!(
moved_on(&forest, None),
"arriving on the pane, nothing on screen was read for it"
);
let on_arrival = tail(&forest);
assert_eq!(
on_arrival,
Tail::Reading {
pane: pane_key("w:p2")
}
);
assert!(
!moved_on(&forest, Some(&pane_key("w:p2"))),
"the selection has not left the pane, so its rows stand"
);
forest.apply(Action::Move(Motion::PreviousRow));
assert!(
moved_on(&forest, Some(&pane_key("w:p2"))),
"the row above is the group's own line and names no pane"
);
step_onto(&mut forest, onto(&pane));
assert_eq!(tail(&forest), on_arrival);
}
#[test]
fn a_tail_that_stands_holds_over_the_groups_too() {
let rows = rows(ProviderState::Answering);
for from in 0..rows {
let was = stepping(from, ProviderState::Answering);
let showing = target(&was).pane().cloned();
let on_screen = tail(&was);
for onto in 0..rows {
let now = stepping(onto, ProviderState::Answering);
if !moved_on(&now, showing.as_ref()) {
assert_eq!(
tail(&now),
on_screen,
"the tail read on row {from} was left standing on row {onto}"
);
}
}
}
}
#[test]
fn no_herdr_means_no_pane_on_a_loose_row_either() {
let mut forest = with_groups_open(ProviderState::NotAnswering);
step_onto(&mut forest, onto(&Item::Loose(loose())));
assert_eq!(tail(&forest), Tail::Silent(phrase::no_session_to_tail()));
}
#[test]
fn a_refresh_that_reorders_a_group_leaves_the_selection_on_the_same_pane() {
let mut forest = with_groups_open(ProviderState::Answering);
step_onto(&mut forest, onto(&Item::Loose(loose())));
assert_eq!(target(&forest).pane(), Some(&pane_key("w:p2")));
let mut reordered = snapshot_with_groups(ProviderState::Answering);
reordered.unattributed.reverse();
reordered.conflicts.reverse();
forest.refresh(reordered);
assert_eq!(
target(&forest).pane(),
Some(&pane_key("w:p2")),
"the group came back in a different order and took the selection with it"
);
assert!(
!moved_on(&forest, Some(&pane_key("w:p2"))),
"the pane selected is the pane on screen, so its rows stand"
);
}
}