purple-ssh 3.15.17

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
//! Screen enum: tags the currently-displayed overlay or view.

/// Top-level page selected via the top navigation bar.
///
/// Orthogonal to [`Screen`]. `Screen` tracks overlays and modal forms,
/// `TopPage` tracks which base view (hosts, tunnels, containers, keys)
/// renders behind them. Tab/Shift+Tab cycles through the variants when
/// no overlay is active.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum TopPage {
    #[default]
    Hosts,
    Tunnels,
    Containers,
    Keys,
}

impl TopPage {
    /// Cycle to the next page
    /// (Hosts -> Tunnels -> Containers -> Keys -> Hosts).
    pub fn next(self) -> Self {
        match self {
            TopPage::Hosts => TopPage::Tunnels,
            TopPage::Tunnels => TopPage::Containers,
            TopPage::Containers => TopPage::Keys,
            TopPage::Keys => TopPage::Hosts,
        }
    }

    /// Cycle to the previous page
    /// (Hosts -> Keys -> Containers -> Tunnels -> Hosts).
    pub fn prev(self) -> Self {
        match self {
            TopPage::Hosts => TopPage::Keys,
            TopPage::Tunnels => TopPage::Hosts,
            TopPage::Containers => TopPage::Tunnels,
            TopPage::Keys => TopPage::Containers,
        }
    }
}

/// State for the What's New overlay.
#[derive(Debug, Default, Clone, PartialEq)]
pub struct WhatsNewState {
    pub scroll: u16,
}

/// Search state for the container logs viewer. `None` on
/// `Screen::ContainerLogs.search` means no search is active.
///
/// Modeless: while the struct is `Some`, every keystroke either edits
/// the query (chars / cursor / delete) or navigates matches
/// (Tab / Shift+Tab). There is no "confirm" step: matches are
/// recomputed live and `Esc` exits search outright.
///
/// `matches` are line indices into the rendered body; `current`
/// indexes into `matches`. Smart case is decided by the query at
/// match time: any uppercase rune flips to case-sensitive (vim's
/// `'smartcase'`).
///
/// `cursor_pos` is a char index into `query` (0..=chars().count()).
/// Mirrors the host_form pattern so Left/Right/Home/End/Delete edit
/// mid-string instead of forcing append-only behaviour.
#[derive(Debug, Default, Clone, PartialEq)]
pub struct ContainerLogsSearch {
    pub query: String,
    pub matches: Vec<usize>,
    pub current: usize,
    pub cursor_pos: usize,
}

impl ContainerLogsSearch {
    pub fn insert_char(&mut self, c: char) {
        let byte_pos = super::forms::char_to_byte_pos(&self.query, self.cursor_pos);
        self.query.insert(byte_pos, c);
        self.cursor_pos += 1;
    }

    pub fn delete_char_before_cursor(&mut self) {
        if self.cursor_pos == 0 {
            return;
        }
        let byte_pos = super::forms::char_to_byte_pos(&self.query, self.cursor_pos);
        let prev = super::forms::char_to_byte_pos(&self.query, self.cursor_pos - 1);
        self.query.drain(prev..byte_pos);
        self.cursor_pos -= 1;
    }

    pub fn delete_char_at_cursor(&mut self) {
        let len = self.query.chars().count();
        if self.cursor_pos >= len {
            return;
        }
        let byte_pos = super::forms::char_to_byte_pos(&self.query, self.cursor_pos);
        let next = super::forms::char_to_byte_pos(&self.query, self.cursor_pos + 1);
        self.query.drain(byte_pos..next);
    }

    pub fn move_left(&mut self) {
        if self.cursor_pos > 0 {
            self.cursor_pos -= 1;
        }
    }

    pub fn move_right(&mut self) {
        let len = self.query.chars().count();
        if self.cursor_pos < len {
            self.cursor_pos += 1;
        }
    }

    pub fn move_home(&mut self) {
        self.cursor_pos = 0;
    }

    pub fn move_end(&mut self) {
        self.cursor_pos = self.query.chars().count();
    }
}

/// One running compose-stack member surfaced in the stack-restart
/// confirm dialog. Carried so the confirm body can list every
/// container that will be cycled, identity-and-state-clear.
#[derive(Debug, Clone, PartialEq)]
pub struct StackMember {
    pub container_id: String,
    pub container_name: String,
    pub uptime: Option<String>,
}

/// Which screen is currently displayed.
#[derive(Debug, Clone, PartialEq)]
pub enum Screen {
    HostList,
    AddHost,
    EditHost {
        alias: String,
    },
    ConfirmDelete {
        alias: String,
    },
    Help {
        return_screen: Box<Screen>,
    },
    KeyList,
    KeyDetail {
        index: usize,
    },
    /// Multi-host picker reached from the Keys tab by pressing `p`.
    /// `key_index` points into `app.keys.list` for the key to push. The picker
    /// shows hosts with checkbox selection; hosts whose `vault_ssh` role
    /// is configured are dimmed and not selectable (Vault SSH workflow
    /// uses signed certs, not authorized_keys appends).
    KeyPushPicker {
        key_index: usize,
    },
    /// Destructive confirm shown after the picker commits. Footer renders
    /// action verbs both sides via `design::confirm_footer_destructive`.
    /// On y the worker thread is spawned and the screen returns to
    /// HostList; on n/Esc returns to the picker with selection intact.
    ///
    /// The frozen alias list lives on `app.keys.push.committed` instead
    /// of inside the Screen variant: keeping the vec out of the enum
    /// prevents per-frame clones during overlay redraws and keeps the
    /// `Screen` payload uniformly small.
    ConfirmKeyPush {
        key_index: usize,
    },
    HostDetail {
        index: usize,
    },
    TagPicker,
    ThemePicker,
    Providers,
    ProviderForm {
        id: crate::providers::config::ProviderConfigId,
    },
    /// Step 1 of the lazy add-second-config flow: ask the user to pick a
    /// label for the existing (bare) config of `provider` before opening
    /// the new-config form. The chosen label lives on
    /// `app.providers.pending_label_migration` until step 2 saves both
    /// configs together.
    ProviderLabelMigration {
        provider: String,
    },
    TunnelList {
        alias: String,
    },
    TunnelForm {
        alias: String,
        editing: Option<usize>,
    },
    /// Host picker reached from the Tunnels overview when adding a new
    /// tunnel: the user must choose a host before the tunnel form opens.
    /// On confirm, transitions to `TunnelForm { alias, editing: None }`.
    TunnelHostPicker,
    SnippetPicker {
        target_aliases: Vec<String>,
    },
    SnippetForm {
        target_aliases: Vec<String>,
        editing: Option<usize>,
    },
    SnippetOutput {
        snippet_name: String,
        target_aliases: Vec<String>,
    },
    SnippetParamForm {
        snippet: crate::snippet::Snippet,
        target_aliases: Vec<String>,
    },
    ConfirmHostKeyReset {
        alias: String,
        hostname: String,
        known_hosts_path: String,
        askpass: Option<String>,
    },
    FileBrowser {
        alias: String,
    },
    Containers {
        alias: String,
    },
    /// Picker reached from the containers overview when adding a host
    /// to the cache (`a`). Lists hosts that have no cache entry yet;
    /// on Enter, spawns a `docker ps` listing for the chosen host and
    /// returns to the overview.
    ContainerHostPicker,
    /// One-shot logs viewer for a single container. `body` is empty
    /// while the SSH `docker logs --tail 200` call is in flight; once
    /// the result lands the lines populate it. `scroll` is line-based,
    /// `0` is the top. `last_render_height` is written by the renderer
    /// each frame so the logs-arrival path and `G` can compute the
    /// tail-anchored scroll without guessing the visible-area size.
    ContainerLogs {
        alias: String,
        container_id: String,
        container_name: String,
        body: Vec<String>,
        fetched_at: u64,
        error: Option<String>,
        scroll: u16,
        last_render_height: u16,
        /// `/` search state. `None` when no search is active.
        search: Option<ContainerLogsSearch>,
    },
    /// Confirm dialog for `K` (kick). restart a single running
    /// container. Reuses `route_confirm_key` so y/n/Esc are the only
    /// effective inputs; stake-test footer phrases the verb on both
    /// sides.
    ConfirmContainerRestart {
        alias: String,
        container_id: String,
        container_name: String,
        project: Option<String>,
        uptime: Option<String>,
    },
    /// Confirm dialog for `S` (stop). stop a single running container.
    /// Same key contract as ConfirmContainerRestart.
    ConfirmContainerStop {
        alias: String,
        container_id: String,
        container_name: String,
        project: Option<String>,
        uptime: Option<String>,
    },
    /// Single-line prompt for an arbitrary command to run inside the
    /// container via `docker exec -it`. Submit hits the existing
    /// `pending_container_exec` flow with the typed command in place
    /// of the default `bash || sh`.
    ContainerExecPrompt {
        alias: String,
        container_id: String,
        container_name: String,
        query: String,
    },
    /// Confirm dialog for `Ctrl-K` (stack kick). Restarts every running
    /// member of a compose stack on a single host, sequentially. The
    /// drain queue + 30s cache TTL pace the work; we accept the trivial
    /// per-tick interleaving.
    ConfirmStackRestart {
        alias: String,
        project: String,
        members: Vec<StackMember>,
    },
    /// Confirm dialog for `K` pressed on a host-divider row in the
    /// containers overview. Restarts every running container on the
    /// host, ignoring compose-project boundaries. Shares the bulk
    /// confirm shape with `ConfirmStackRestart`.
    ConfirmHostRestartAll {
        alias: String,
        members: Vec<StackMember>,
    },
    /// Confirm dialog for `S` pressed on a host-divider row. Stops
    /// every running container on the host, sequentially.
    ConfirmHostStopAll {
        alias: String,
        members: Vec<StackMember>,
    },
    ConfirmImport {
        count: usize,
    },
    ConfirmPurgeStale {
        aliases: Vec<String>,
        provider: Option<String>,
    },
    ConfirmVaultSign {
        /// Precomputed list of hosts that resolve to a Vault SSH role. Computed
        /// when the user presses `V`. `certificate_file` is the host's existing
        /// `CertificateFile` directive (empty when unset) so the background
        /// worker checks renewal against the configured cert path.
        signable: Vec<crate::vault_ssh::VaultSignTarget>,
    },
    Welcome {
        has_backup: bool,
        host_count: usize,
        known_hosts_count: usize,
    },
    /// Bulk tag editor: tri-state checkbox picker that edits tags across
    /// all hosts in `multi_select` in one go. Opened via `t` when a
    /// multi-host selection is active.
    BulkTagEditor,
    /// What's New overlay: shows recent changelog sections to the user
    /// after an upgrade. Opened via the upgrade toast or `n` key.
    WhatsNew(WhatsNewState),
}

impl Screen {
    /// Stable short variant name used in state-transition logs.
    /// Omits inner fields so log lines never leak host aliases, paths or
    /// tokens.
    pub fn variant_name(&self) -> &'static str {
        match self {
            Screen::HostList => "HostList",
            Screen::AddHost => "AddHost",
            Screen::EditHost { .. } => "EditHost",
            Screen::ConfirmDelete { .. } => "ConfirmDelete",
            Screen::Help { .. } => "Help",
            Screen::KeyList => "KeyList",
            Screen::KeyDetail { .. } => "KeyDetail",
            Screen::KeyPushPicker { .. } => "KeyPushPicker",
            Screen::ConfirmKeyPush { .. } => "ConfirmKeyPush",
            Screen::HostDetail { .. } => "HostDetail",
            Screen::TagPicker => "TagPicker",
            Screen::ThemePicker => "ThemePicker",
            Screen::Providers => "Providers",
            Screen::ProviderForm { .. } => "ProviderForm",
            Screen::ProviderLabelMigration { .. } => "ProviderLabelMigration",
            Screen::TunnelList { .. } => "TunnelList",
            Screen::TunnelForm { .. } => "TunnelForm",
            Screen::TunnelHostPicker => "TunnelHostPicker",
            Screen::SnippetPicker { .. } => "SnippetPicker",
            Screen::SnippetForm { .. } => "SnippetForm",
            Screen::SnippetOutput { .. } => "SnippetOutput",
            Screen::SnippetParamForm { .. } => "SnippetParamForm",
            Screen::ConfirmHostKeyReset { .. } => "ConfirmHostKeyReset",
            Screen::FileBrowser { .. } => "FileBrowser",
            Screen::Containers { .. } => "Containers",
            Screen::ContainerHostPicker => "ContainerHostPicker",
            Screen::ContainerLogs { .. } => "ContainerLogs",
            Screen::ConfirmContainerRestart { .. } => "ConfirmContainerRestart",
            Screen::ConfirmContainerStop { .. } => "ConfirmContainerStop",
            Screen::ContainerExecPrompt { .. } => "ContainerExecPrompt",
            Screen::ConfirmStackRestart { .. } => "ConfirmStackRestart",
            Screen::ConfirmHostRestartAll { .. } => "ConfirmHostRestartAll",
            Screen::ConfirmHostStopAll { .. } => "ConfirmHostStopAll",
            Screen::ConfirmImport { .. } => "ConfirmImport",
            Screen::ConfirmPurgeStale { .. } => "ConfirmPurgeStale",
            Screen::ConfirmVaultSign { .. } => "ConfirmVaultSign",
            Screen::Welcome { .. } => "Welcome",
            Screen::BulkTagEditor => "BulkTagEditor",
            Screen::WhatsNew(_) => "WhatsNew",
        }
    }
}