use super::*;
use crate::loader::Stats;
use ratatui::crossterm::event::{KeyCode, KeyEvent};
impl App {
pub fn conflict_footer(&self) -> Option<String> {
use std::collections::BTreeSet;
let mut agents = 0;
let mut files: BTreeSet<&str> = BTreeSet::new();
for c in self.collisions.values() {
if c.level != crate::collide::Overlap::File {
continue;
}
agents += 1;
files.extend(c.files.iter().map(String::as_str));
}
let first = files.iter().next()?;
let more = match files.len() {
1 => String::new(),
n => format!(" +{} more", n - 1),
};
Some(format!(
"Conflict: ⚠ {}{more} — {agents} agents have written it",
crate::util::path_tail(first, 2)
))
}
pub fn merge_remotes(&mut self) {
if self.remotes.is_empty() {
return;
}
self.sessions.retain(|s| s.remote.is_none());
for (host, rows) in &self.remotes {
let skew = self.skew_of(host);
self.sessions.extend(rows.iter().cloned().map(|mut s| {
if let Some(r) = s.remote.as_mut() {
r.skew = skew.clone();
}
s
}));
}
self.stats = crate::loader::compute_stats(&self.sessions);
self.refilter();
}
pub(super) fn adopt_stats(&mut self, stats: Stats) {
self.stats = match self.remotes.is_empty() {
true => stats,
false => crate::loader::compute_stats(&self.sessions),
};
}
pub fn remote_refusal(session: &Session) -> Option<String> {
let r = session.remote.as_ref()?;
Some(format!(
"{} is on {} — cctop reads other machines but only acts on this one",
session.display_label(),
r.host
))
}
pub fn remotes_up_footer(&self) -> Option<String> {
let up: Vec<&str> = self
.remotes
.keys()
.filter(|host| !self.remote_errors.contains_key(*host))
.map(String::as_str)
.collect();
match up.len() {
0 => None,
1 => Some(format!("{} up", up[0])),
n => Some(format!("{n} remotes up")),
}
}
pub fn remote_footer(&self) -> Option<String> {
let mut hosts: Vec<&str> = self.remote_errors.keys().map(String::as_str).collect();
hosts.sort();
let first = hosts.first()?;
Some(match hosts.len() {
1 => format!("{first}: {}", self.remote_errors[*first]),
n => format!(
"{n} hosts unreachable ({first}: {})",
self.remote_errors[*first]
),
})
}
pub fn clash_of(&self, session: &Session) -> Option<panels::Clash> {
let c = self.collisions.get(&session.key())?;
let peers = c
.peers
.iter()
.filter_map(|key| self.sessions.iter().find(|s| &s.key() == key))
.map(|s| s.display_label().to_string())
.collect();
Some(panels::Clash {
level: c.level,
peers,
files: c.files.clone(),
})
}
pub fn selected_is_remote(&self) -> bool {
self.selected_session().is_some_and(|s| s.remote.is_some())
}
pub fn skew_of(&self, host: &str) -> Option<crate::fleet::Skew> {
crate::fleet::skew(
self.remote_versions.get(host)?,
crate::update::current_version(),
)
}
pub fn got_remote_version(&mut self, host: String, probe: crate::fleet::Probe) {
use crate::fleet::Skew;
self.remote_versions.insert(host.clone(), probe);
let Some(skew) = self.skew_of(&host) else {
return;
};
if !self.remote_skew_told.insert(host.clone()) {
return;
}
let local = crate::update::current_version();
self.set_status(match skew {
Skew::Older(v) => format!(
"{host} runs cctop {v}, older than this {local} — Enter on one of its rows \
offers to update it"
),
Skew::Newer(v) => format!(
"{host} runs cctop {v}, newer than this {local} — `cctop --update` here \
to catch up"
),
Skew::Missing => format!(
"{host} has no cctop where ssh looks — install it there, or name the binary \
with --host {host}:/path/to/cctop"
),
});
}
pub fn remote_update_refusal(&self, host: &str) -> Option<String> {
use crate::fleet::Skew;
if self.remote_updating.contains(host) {
return Some(format!("an update of {host} is already under way"));
}
match self.skew_of(host) {
Some(Skew::Older(_)) => None,
Some(Skew::Newer(v)) => Some(format!(
"{host} runs {v}, newer than this cctop — update this one instead"
)),
Some(Skew::Missing) => Some(format!("{host} has no cctop to update")),
None => Some(match self.remote_versions.get(host) {
Some(crate::fleet::Probe::Version(v)) => format!("{host} already runs {v}"),
_ => format!("{host}'s cctop version is not known yet"),
}),
}
}
pub(super) fn confirm_remote_update(&mut self) {
let Some(host) = self
.selected_session()
.and_then(|s| s.remote.as_ref())
.map(|r| r.host.clone())
else {
return;
};
if let Some(why) = self.remote_update_refusal(&host) {
self.set_status(why);
return;
}
self.remote_update = Some(host);
self.mode = Mode::RemoteUpdateConfirm;
}
pub fn remote_host(&self, target: &str) -> Option<&crate::fleet::Host> {
self.remote_hosts.iter().find(|h| h.target == target)
}
pub(super) fn on_key_remote_update(&mut self, key: KeyEvent) {
self.mode = Mode::List;
let Some(target) = self.remote_update.take() else {
return;
};
if key.code != KeyCode::Char('y') {
return;
}
let Some(host) = self.remote_host(&target).cloned() else {
return;
};
self.remote_updating.insert(target.clone());
let _ = self.tx.send(Request::UpdateRemote(host));
self.set_status(format!("Updating cctop on {target}…"));
}
pub fn remote_updated(
&mut self,
host: String,
result: Result<String, crate::fleet::UpdateFailure>,
) {
use crate::fleet::UpdateFailure;
self.remote_updating.remove(&host);
self.set_status(match result {
Ok(said) => format!("{host}: {said}"),
Err(UpdateFailure::NeedsRoot(manual)) => format!(
"Could not update {host}: its cctop is in a directory only root can write. \
Run it yourself: {manual}"
),
Err(UpdateFailure::Other(why)) => format!("Could not update {host}: {why}"),
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui::tests::{session, test_app};
use std::sync::mpsc::channel;
#[test]
fn a_contested_file_reaches_the_footer_and_the_info_panel() {
let repo = std::env::temp_dir().join(format!("cctop-ui-clash-{}", std::process::id()));
std::fs::create_dir_all(repo.join(".git")).expect("checkout");
let dir = repo.to_string_lossy().into_owned();
let contested = crate::collide::normalise("src/ui/mod.rs", &dir);
let mut app = test_app();
app.sessions = vec![session("a", true, &dir), session("b", true, &dir)];
for s in app.sessions.iter_mut() {
s.recent_writes = vec![contested.clone()];
}
app.collisions = crate::collide::apply(&mut app.sessions);
for s in &app.sessions {
assert_eq!(s.conflict, Some(crate::collide::Overlap::File));
}
let footer = app.conflict_footer().expect("a warning");
assert!(footer.contains("ui/mod.rs"), "{footer}");
assert!(footer.contains("2 agents"), "{footer}");
let clash = app.clash_of(&app.sessions[0]).expect("a clash");
assert_eq!(clash.peers, vec![app.sessions[1].display_label()]);
assert_eq!(clash.files, vec![contested]);
app.sessions[1].recent_writes = vec![crate::collide::normalise("other.rs", &dir)];
app.collisions = crate::collide::apply(&mut app.sessions);
assert_eq!(
app.sessions[0].conflict,
Some(crate::collide::Overlap::Directory)
);
assert!(app.conflict_footer().is_none());
std::fs::remove_dir_all(&repo).ok();
}
#[test]
fn remote_rows_outlive_a_walk_and_stay_read_only() {
let mut app = test_app();
app.sessions = vec![session("local", true, "/here")];
let mut away = session("away", true, "/srv/work");
away.remote = Some(crate::session::Remote {
host: "box".into(),
branch: Some("main".into()),
..Default::default()
});
away.total_cost = Some(3.0);
app.remotes.insert("box".into(), vec![away]);
app.merge_remotes();
assert_eq!(app.sessions.len(), 2);
assert!(
(app.stats.spend_claude - 3.0).abs() < 1e-9,
"totals span hosts"
);
app.sessions = vec![session("local", true, "/here")];
app.merge_remotes();
assert_eq!(app.sessions.len(), 2);
assert_eq!(
app.sessions.iter().filter(|s| s.remote.is_some()).count(),
1
);
let remote = app
.sessions
.iter()
.find(|s| s.remote.is_some())
.expect("the remote row");
let why = App::remote_refusal(remote).expect("a refusal");
assert!(why.contains("box"), "the refusal has to name the machine");
assert!(App::remote_refusal(&app.sessions[0]).is_none());
assert_eq!(
crate::ui::columns::branch_of(remote).as_deref(),
Some("main")
);
app.remote_errors
.insert("box".into(), "Permission denied".into());
let footer = app.remote_footer().expect("a warning");
assert!(footer.contains("box"), "{footer}");
assert!(footer.contains("Permission denied"), "{footer}");
assert!(app.remotes_up_footer().is_none());
}
#[test]
fn the_footer_counts_the_hosts_that_answer() {
let mut app = test_app();
assert!(app.remotes_up_footer().is_none());
let mut away = session("away", true, "/srv/work");
away.remote = Some(crate::session::Remote {
host: "box".into(),
..Default::default()
});
app.remotes.insert("box".into(), vec![away]);
assert_eq!(app.remotes_up_footer().as_deref(), Some("box up"));
let mut far = session("far", true, "/srv/other");
far.remote = Some(crate::session::Remote {
host: "bench".into(),
..Default::default()
});
app.remotes.insert("bench".into(), vec![far]);
assert_eq!(app.remotes_up_footer().as_deref(), Some("2 remotes up"));
app.remote_errors.insert("box".into(), "timeout".into());
assert_eq!(app.remotes_up_footer().as_deref(), Some("bench up"));
}
#[test]
fn an_older_remote_is_said_once_marked_and_offered_an_update() {
use crate::fleet::{Host, Probe, UpdateFailure};
let (tx, rx) = channel();
let mut app = App::new(Plan::Retail, tx);
let host = Host::parse("box").expect("a host");
app.remote_hosts = vec![host.clone()];
let mut s = session("away", true, "/srv/work");
s.remote = Some(crate::session::Remote {
host: "box".into(),
..Default::default()
});
app.remotes.insert("box".into(), vec![s]);
app.merge_remotes();
let refusal = app.remote_update_refusal("box").expect("not yet");
assert!(refusal.contains("not known"), "{refusal}");
app.got_remote_version("box".into(), Probe::Version("0.0.1".into()));
app.merge_remotes();
let said: Vec<String> = app.toasts.iter().map(|t| t.text.clone()).collect();
assert_eq!(said.len(), 1, "{said:?}");
assert!(said[0].contains("box runs cctop 0.0.1"), "{}", said[0]);
app.got_remote_version("box".into(), Probe::Version("0.0.1".into()));
assert_eq!(app.toasts.iter().count(), 1);
app.selected = 0;
let row = app.selected_session().expect("the row").clone();
let cell = columns::render_cell(ColumnId::Host, &row, &chrono::Utc::now());
assert_eq!(cell, "↑box");
let items = menu::items(&app);
let entry = items
.iter()
.find(|i| i.action == menu::Action::UpdateRemote)
.expect("an update entry");
assert!(entry.enabled(), "{:?}", entry.blocked);
app.confirm_remote_update();
assert_eq!(app.mode, Mode::RemoteUpdateConfirm);
app.on_key_remote_update(KeyEvent::from(KeyCode::Char('n')));
assert_eq!(app.mode, Mode::List);
assert!(rx.try_recv().is_err(), "a no must not reach the worker");
app.confirm_remote_update();
app.on_key_remote_update(KeyEvent::from(KeyCode::Char('y')));
let sent = std::iter::from_fn(|| rx.try_recv().ok())
.find_map(|r| match r {
Request::UpdateRemote(h) => Some(h),
_ => None,
})
.expect("the update went to the worker");
assert_eq!(sent, host);
assert!(app.remote_update_refusal("box").is_some());
app.remote_updated(
"box".into(),
Err(UpdateFailure::NeedsRoot(host.sudo_update_command())),
);
let last = app.toasts.iter().next().expect("a toast").text.clone();
assert!(last.contains("only root can write"), "{last}");
assert!(last.contains("ssh -t box sudo cctop --update"), "{last}");
}
#[test]
fn a_newer_remote_points_at_this_machine() {
let mut app = test_app();
app.got_remote_version("box".into(), crate::fleet::Probe::Version("999.0.0".into()));
let said = app.toasts.iter().next().expect("a toast").text.clone();
assert!(said.contains("cctop --update` here"), "{said}");
let why = app.remote_update_refusal("box").expect("refused");
assert!(why.contains("update this one"), "{why}");
app.got_remote_version("bare".into(), crate::fleet::Probe::Missing);
let said = app.toasts.iter().next().expect("a toast").text.clone();
assert!(said.contains("--host bare:/path/to/cctop"), "{said}");
}
#[test]
fn the_host_column_stays_off_a_single_machine() {
let ids = |hidden: &[ColumnId]| -> Vec<ColumnId> {
columns::visible_columns(300, hidden, &[])
.iter()
.map(|c| c.id)
.collect()
};
assert!(ids(&[]).contains(&ColumnId::Host));
assert!(!ids(&[ColumnId::Host]).contains(&ColumnId::Host));
}
#[test]
fn the_menu_refuses_a_remote_row_the_way_the_keys_do() {
let mut app = App::new(Plan::Retail, channel().0);
let mut s = session("a", true, "/repo");
s.remote = Some(crate::session::Remote {
host: "devbox".into(),
branch: None,
..Default::default()
});
app.sessions = vec![s];
app.refilter();
app.selected = 0;
let items = menu::items(&app);
assert!(!items.is_empty(), "a selected row has a menu");
for item in &items {
match item.action {
menu::Action::Read | menu::Action::Expand | menu::Action::Mark => {
assert!(item.enabled(), "{} works on a remote row", item.label);
}
_ => {
let why = item.blocked.as_deref().unwrap_or("");
assert!(
why.contains("devbox"),
"{} must name the host, said {why:?}",
item.label
);
}
}
}
assert!(items[menu::first_enabled(&items)].enabled());
}
}