purple-ssh 3.15.18

Open-source terminal SSH manager that keeps ~/.ssh/config in sync with your cloud infra. Spin up a VM on AWS, GCP, Azure, Hetzner or 12 other cloud providers and it appears in your host list. Destroy it and the entry dims. Search hundreds of hosts, transfer files, manage Docker and Podman over SSH, sign Vault SSH certs. Rust TUI, MIT licensed.
Documentation
//! Containers overlay state.

use crate::app::{ContainerActionRequest, ContainerExecRequest, ContainerLogsRequest};

/// Per-host overlay session state; only valid while the containers overlay is open.
///
/// No `Default` impl: construction always requires an alias and runtime
/// metadata, so a default-constructed value would be meaningless.
pub struct ContainerSession {
    pub alias: String,
    pub askpass: Option<String>,
    pub runtime: Option<crate::containers::ContainerRuntime>,
    pub containers: Vec<crate::containers::ContainerInfo>,
    pub list_state: ratatui::widgets::ListState,
    pub loading: bool,
    pub error: Option<String>,
    pub action_in_progress: Option<String>,
    /// Pending confirmation for stop/restart actions: (action, container_name, container_id).
    pub confirm_action: Option<(crate::containers::ContainerAction, String, String)>,
}

/// Always-present container-domain state: cache and cross-host pending operations.
/// Separate from `ContainerSession`, which is the per-host overlay session state.
#[derive(Debug, Default)]
pub struct ContainerState {
    pub(in crate::app) pending_exec: Option<ContainerExecRequest>,
    pub(in crate::app) pending_logs: Option<ContainerLogsRequest>,
    pub(in crate::app) pending_actions: std::collections::VecDeque<ContainerActionRequest>,
    pub(in crate::app) pending_fetch_aliases: Vec<String>,
    pub(in crate::app) cache:
        std::collections::HashMap<String, crate::containers::ContainerCacheEntry>,
}

impl ContainerState {
    pub fn cache(
        &self,
    ) -> &std::collections::HashMap<String, crate::containers::ContainerCacheEntry> {
        &self.cache
    }

    pub fn set_cache(
        &mut self,
        cache: std::collections::HashMap<String, crate::containers::ContainerCacheEntry>,
    ) {
        self.cache = cache;
    }

    pub fn cache_entry(&self, alias: &str) -> Option<&crate::containers::ContainerCacheEntry> {
        self.cache.get(alias)
    }

    pub fn cache_entry_mut(
        &mut self,
        alias: &str,
    ) -> Option<&mut crate::containers::ContainerCacheEntry> {
        self.cache.get_mut(alias)
    }

    pub fn cache_contains(&self, alias: &str) -> bool {
        self.cache.contains_key(alias)
    }

    pub fn cache_len(&self) -> usize {
        self.cache.len()
    }

    pub fn insert_cache_entry(
        &mut self,
        alias: String,
        entry: crate::containers::ContainerCacheEntry,
    ) {
        self.cache.insert(alias, entry);
    }

    pub fn remove_cache_entry(&mut self, alias: &str) {
        self.cache.remove(alias);
    }

    pub fn clear_cache(&mut self) {
        self.cache.clear();
    }

    pub fn pending_exec_request(&self) -> Option<&ContainerExecRequest> {
        self.pending_exec.as_ref()
    }

    pub fn pending_logs_request(&self) -> Option<&ContainerLogsRequest> {
        self.pending_logs.as_ref()
    }

    pub fn has_pending_fetches(&self) -> bool {
        !self.pending_fetch_aliases.is_empty()
    }

    pub fn pending_actions_len(&self) -> usize {
        self.pending_actions.len()
    }

    pub fn take_pending_exec(&mut self) -> Option<ContainerExecRequest> {
        self.pending_exec.take()
    }

    pub fn take_pending_logs(&mut self) -> Option<ContainerLogsRequest> {
        self.pending_logs.take()
    }

    pub fn pop_next_action(&mut self) -> Option<ContainerActionRequest> {
        self.pending_actions.pop_front()
    }

    pub fn pending_actions_iter(&self) -> impl Iterator<Item = &ContainerActionRequest> {
        self.pending_actions.iter()
    }

    pub fn pending_actions_at(&self, idx: usize) -> Option<&ContainerActionRequest> {
        self.pending_actions.get(idx)
    }

    pub fn pending_fetch_aliases(&self) -> &[String] {
        &self.pending_fetch_aliases
    }

    pub fn extend_pending_fetches<I: IntoIterator<Item = String>>(&mut self, iter: I) {
        self.pending_fetch_aliases.extend(iter);
    }

    /// Queue a logs request for the main loop to drain. Replaces any
    /// previous pending logs request and logs the displaced alias so a
    /// dropped request is traceable.
    pub fn queue_logs(&mut self, req: ContainerLogsRequest) {
        if let Some(prev) = self.pending_logs.as_ref() {
            log::debug!(
                "[purple] queue_logs replaced pending request for alias={} id={}",
                prev.alias,
                prev.container_id,
            );
        }
        self.pending_logs = Some(req);
    }

    /// Queue an exec request for the main loop to drain. Same replace
    /// and log semantics as `queue_logs`.
    pub fn queue_exec(&mut self, req: ContainerExecRequest) {
        if let Some(prev) = self.pending_exec.as_ref() {
            log::debug!(
                "[purple] queue_exec replaced pending request for alias={} id={}",
                prev.alias,
                prev.container_id,
            );
        }
        self.pending_exec = Some(req);
    }

    /// Queue a non-interactive container action for the worker thread.
    /// Actions are FIFO via `VecDeque::push_back`; multiple actions
    /// against the same alias process in order.
    pub fn queue_action(&mut self, req: ContainerActionRequest) {
        self.pending_actions.push_back(req);
    }

    /// Enqueue an alias for the initial container-cache fetch. Drained by
    /// the main loop on the next tick via `drain_pending_fetches`.
    pub fn queue_fetch(&mut self, alias: String) {
        self.pending_fetch_aliases.push(alias);
    }

    /// Take the full fetch queue, leaving it empty.
    pub fn drain_pending_fetches(&mut self) -> Vec<String> {
        std::mem::take(&mut self.pending_fetch_aliases)
    }

    /// Move a cache entry from `old` to `new` on host rename. Returns
    /// `true` when the cache changed so the caller can persist. No-op
    /// (returns `false`) when `old == new` or no entry exists under `old`.
    pub fn migrate_alias(&mut self, old: &str, new: &str) -> bool {
        if old == new {
            return false;
        }
        if let Some(v) = self.cache.remove(old) {
            debug_assert!(
                !self.cache.contains_key(new),
                "container_state.cache collision on rename {old} -> {new}"
            );
            self.cache.insert(new.to_string(), v);
            true
        } else {
            false
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::containers::{ContainerCacheEntry, ContainerRuntime};

    fn make_logs_request(alias: &str) -> ContainerLogsRequest {
        ContainerLogsRequest {
            alias: alias.to_string(),
            askpass: None,
            runtime: ContainerRuntime::Docker,
            container_id: "abc123".to_string(),
            container_name: "nginx".to_string(),
        }
    }

    fn make_cache_entry() -> ContainerCacheEntry {
        ContainerCacheEntry {
            timestamp: 1700000000,
            runtime: ContainerRuntime::Docker,
            engine_version: Some("28.0.0".to_string()),
            containers: vec![],
        }
    }

    #[test]
    fn queue_logs_sets_pending() {
        let mut state = ContainerState::default();
        assert!(state.pending_logs.is_none());
        state.queue_logs(make_logs_request("host-a"));
        assert!(state.pending_logs.is_some());
        assert_eq!(state.pending_logs.as_ref().unwrap().alias, "host-a");
    }

    #[test]
    fn queue_logs_replaces_previous() {
        let mut state = ContainerState::default();
        state.queue_logs(make_logs_request("host-a"));
        state.queue_logs(make_logs_request("host-b"));
        assert_eq!(state.pending_logs.as_ref().unwrap().alias, "host-b");
    }

    #[test]
    fn queue_exec_sets_pending() {
        let mut state = ContainerState::default();
        assert!(state.pending_exec.is_none());
        state.queue_exec(ContainerExecRequest {
            alias: "host-a".to_string(),
            askpass: None,
            runtime: ContainerRuntime::Docker,
            container_id: "abc".to_string(),
            container_name: "nginx".to_string(),
            command: Some("echo hi".to_string()),
        });
        assert!(state.pending_exec.is_some());
        assert_eq!(state.pending_exec.as_ref().unwrap().alias, "host-a");
    }

    #[test]
    fn queue_fetch_pushes_alias() {
        let mut state = ContainerState::default();
        state.queue_fetch("host-a".to_string());
        state.queue_fetch("host-b".to_string());
        assert_eq!(state.pending_fetch_aliases, vec!["host-a", "host-b"]);
    }

    #[test]
    fn drain_pending_fetches_returns_and_clears() {
        let mut state = ContainerState::default();
        state.queue_fetch("host-a".to_string());
        state.queue_fetch("host-b".to_string());
        let drained = state.drain_pending_fetches();
        assert_eq!(drained, vec!["host-a", "host-b"]);
        assert!(state.pending_fetch_aliases.is_empty());
    }

    #[test]
    fn drain_pending_fetches_empty_when_no_aliases() {
        let mut state = ContainerState::default();
        let drained = state.drain_pending_fetches();
        assert!(drained.is_empty());
        assert!(state.pending_fetch_aliases.is_empty());
    }

    #[test]
    fn migrate_alias_renames_cache_entry() {
        let mut state = ContainerState::default();
        state.cache.insert("old".to_string(), make_cache_entry());
        assert!(state.migrate_alias("old", "new"));
        assert!(state.cache.contains_key("new"));
        assert!(!state.cache.contains_key("old"));
    }

    #[test]
    fn migrate_alias_returns_false_when_no_entry() {
        let mut state = ContainerState::default();
        assert!(!state.migrate_alias("missing", "new"));
        assert!(state.cache.is_empty());
    }

    #[test]
    fn migrate_alias_self_rename_is_noop() {
        let mut state = ContainerState::default();
        state.cache.insert("same".to_string(), make_cache_entry());
        assert!(!state.migrate_alias("same", "same"));
        assert!(state.cache.contains_key("same"));
    }

    #[test]
    fn queue_action_pushes_back_in_order() {
        let mut state = ContainerState::default();
        for id in ["a", "b", "c"] {
            state.queue_action(ContainerActionRequest {
                alias: "host".to_string(),
                askpass: None,
                runtime: ContainerRuntime::Docker,
                container_id: id.to_string(),
                container_name: id.to_string(),
                action: crate::containers::ContainerAction::Restart,
            });
        }
        assert_eq!(state.pending_actions.len(), 3);
        let ids: Vec<String> = state
            .pending_actions
            .iter()
            .map(|r| r.container_id.clone())
            .collect();
        assert_eq!(ids, vec!["a", "b", "c"]);
    }
}