purple-ssh 3.10.1

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
use crossterm::event::{KeyCode, KeyEvent};
use log::{debug, info};

use crate::app::{App, Screen, TopPage};

/// Where the tunnel form returns to on submit, cancel, or discard. When the
/// user opened the form from the Tunnels-tab overview we hop back to that
/// overview (Screen::HostList — the overview shares the screen variant with
/// the host list and is selected by `top_page`); otherwise we return to the
/// per-host TunnelList overlay.
fn tunnel_form_return_screen(app: &App, alias: &str) -> Screen {
    if matches!(app.top_page, TopPage::Tunnels) {
        Screen::HostList
    } else {
        Screen::TunnelList {
            alias: alias.to_string(),
        }
    }
}

pub(super) fn handle_tunnel_list(app: &mut App, key: KeyEvent) {
    let alias = match &app.screen {
        Screen::TunnelList { alias } => alias.clone(),
        _ => return,
    };

    // Handle pending tunnel delete confirmation first
    if app.tunnels.pending_delete.is_some() && key.code != KeyCode::Char('?') {
        match key.code {
            KeyCode::Char('y') | KeyCode::Char('Y') => {
                let Some(sel) = app.tunnels.pending_delete.take() else {
                    return;
                };
                if let Some(rule) = app.tunnels.list.get(sel) {
                    let key = rule.tunnel_type.directive_key().to_string();
                    let value = rule.to_directive_value();
                    let config_backup = app.hosts_state.ssh_config.clone();
                    if !app
                        .hosts_state
                        .ssh_config
                        .remove_forward(&alias, &key, &value)
                    {
                        app.notify_warning(crate::messages::TUNNEL_NOT_FOUND);
                        return;
                    }
                    if let Err(e) = app.hosts_state.ssh_config.write() {
                        app.hosts_state.ssh_config = config_backup;
                        app.notify_error(crate::messages::failed_to_save(&e));
                    } else {
                        app.update_last_modified();
                        app.refresh_tunnel_list(&alias);
                        app.reload_hosts();
                        if app.tunnels.list.is_empty() {
                            app.ui.tunnel_list_state.select(None);
                        } else if sel >= app.tunnels.list.len() {
                            app.ui
                                .tunnel_list_state
                                .select(Some(app.tunnels.list.len() - 1));
                        }
                        app.notify(crate::messages::TUNNEL_REMOVED);
                    }
                }
            }
            KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
                app.tunnels.pending_delete = None;
            }
            _ => {}
        }
        return;
    }

    match key.code {
        KeyCode::Esc | KeyCode::Char('q') => {
            app.set_screen(Screen::HostList);
        }
        KeyCode::Char('j') | KeyCode::Down => {
            app.select_next_tunnel();
        }
        KeyCode::Char('k') | KeyCode::Up => {
            app.select_prev_tunnel();
        }
        KeyCode::PageDown => {
            crate::app::page_down(&mut app.ui.tunnel_list_state, app.tunnels.list.len(), 10);
        }
        KeyCode::PageUp => {
            crate::app::page_up(&mut app.ui.tunnel_list_state, app.tunnels.list.len(), 10);
        }
        KeyCode::Char('a') => {
            // Check if host is from an included file (read-only)
            if let Some(host) = app.hosts_state.list.iter().find(|h| h.alias == alias) {
                if host.source_file.is_some() {
                    app.notify_warning(crate::messages::TUNNEL_INCLUDED_READ_ONLY);
                    return;
                }
            }
            app.tunnels.form = crate::app::TunnelForm::new();
            app.set_screen(Screen::TunnelForm {
                alias: alias.clone(),
                editing: None,
            });
            app.capture_form_mtime();
            app.capture_tunnel_form_baseline();
        }
        KeyCode::Char('e') => {
            // Check if host is from an included file (read-only)
            if let Some(host) = app.hosts_state.list.iter().find(|h| h.alias == alias) {
                if host.source_file.is_some() {
                    app.notify_warning(crate::messages::TUNNEL_INCLUDED_READ_ONLY);
                    return;
                }
            }
            if let Some(sel) = app.ui.tunnel_list_state.selected() {
                if let Some(rule) = app.tunnels.list.get(sel) {
                    app.tunnels.form = crate::app::TunnelForm::from_rule(rule);
                    app.set_screen(Screen::TunnelForm {
                        alias: alias.clone(),
                        editing: Some(sel),
                    });
                    app.capture_form_mtime();
                    app.capture_tunnel_form_baseline();
                }
            }
        }
        KeyCode::Char('d') => {
            // Check if host is from an included file (read-only)
            if let Some(host) = app.hosts_state.list.iter().find(|h| h.alias == alias) {
                if host.source_file.is_some() {
                    app.notify_warning(crate::messages::TUNNEL_INCLUDED_READ_ONLY);
                    return;
                }
            }
            if let Some(sel) = app.ui.tunnel_list_state.selected() {
                if sel < app.tunnels.list.len() {
                    app.tunnels.pending_delete = Some(sel);
                }
            }
        }
        KeyCode::Enter => {
            // Start/stop tunnel
            if app.tunnels.active.contains_key(&alias) {
                // Stop
                if let Some(mut tunnel) = app.tunnels.active.remove(&alias) {
                    if let Err(e) = tunnel.child.kill() {
                        debug!("[external] Failed to kill tunnel process for {alias}: {e}");
                    }
                    let _ = tunnel.child.wait();
                    drop(tunnel);
                    app.refresh_tunnel_bind_ports();
                    app.notify(crate::messages::tunnel_stopped(&alias));
                }
            } else if !app.tunnels.list.is_empty() {
                // Start
                if app.demo_mode {
                    app.notify_warning(crate::messages::DEMO_TUNNELS_DISABLED);
                    return;
                }
                let askpass = app
                    .hosts_state
                    .list
                    .iter()
                    .find(|h| h.alias == alias)
                    .and_then(|h| h.askpass.clone());
                match crate::tunnel::start_tunnel(
                    &alias,
                    &app.reload.config_path,
                    askpass.as_deref(),
                    app.bw_session.as_deref(),
                ) {
                    Ok(child) => {
                        for rule in &app.tunnels.list {
                            info!(
                                "Tunnel started: type={} local={} remote={}:{} alias={alias}",
                                rule.tunnel_type.label(),
                                rule.bind_port,
                                rule.remote_host,
                                rule.remote_port
                            );
                        }
                        app.tunnels.ensure_lsof_poller();
                        let parser_tx = app.tunnels.parser_tx.clone();
                        let active = crate::tunnel::ActiveTunnel::spawn(child, &alias, parser_tx);
                        app.tunnels.active.insert(alias.clone(), active);
                        app.refresh_tunnel_bind_ports();
                        // Tunnel start spawns a real ssh session, same as a
                        // shell connect, so record it in connection history.
                        app.history.record(&alias);
                        app.apply_sort();
                        app.notify(crate::messages::tunnel_started(&alias));
                    }
                    Err(e) => {
                        app.notify_error(crate::messages::tunnel_start_failed(&e));
                    }
                }
            }
        }
        KeyCode::Char('?') => {
            let old = std::mem::replace(&mut app.screen, Screen::HostList);
            app.set_screen(Screen::Help {
                return_screen: Box::new(old),
            });
        }
        _ => {}
    }
}

pub(super) fn handle_tunnel_form(app: &mut App, key: KeyEvent) {
    let (alias, editing) = match &app.screen {
        Screen::TunnelForm { alias, editing } => (alias.clone(), *editing),
        _ => return,
    };

    // Handle discard confirmation dialog
    if app.forms.pending_discard_confirm {
        match key.code {
            KeyCode::Char('y') | KeyCode::Char('Y') => {
                app.forms.pending_discard_confirm = false;
                app.clear_form_mtime();
                app.tunnels.form_baseline = None;
                let return_to = tunnel_form_return_screen(app, &alias);
                app.set_screen(return_to);
            }
            KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
                app.forms.pending_discard_confirm = false;
            }
            _ => {}
        }
        return;
    }

    match key.code {
        KeyCode::Esc => {
            if app.tunnel_form_is_dirty() {
                app.forms.pending_discard_confirm = true;
            } else {
                app.clear_form_mtime();
                app.tunnels.form_baseline = None;
                let return_to = tunnel_form_return_screen(app, &alias);
                app.set_screen(return_to);
            }
        }
        KeyCode::Tab | KeyCode::Down => {
            app.tunnels.form.focused_field = app
                .tunnels
                .form
                .focused_field
                .next(app.tunnels.form.tunnel_type);
            app.tunnels.form.sync_cursor_to_end();
        }
        KeyCode::BackTab | KeyCode::Up => {
            app.tunnels.form.focused_field = app
                .tunnels
                .form
                .focused_field
                .prev(app.tunnels.form.tunnel_type);
            app.tunnels.form.sync_cursor_to_end();
        }
        KeyCode::Left if app.tunnels.form.cursor_pos > 0 => {
            app.tunnels.form.cursor_pos -= 1;
        }
        KeyCode::Right => {
            let len = app
                .tunnels
                .form
                .focused_value()
                .map(|v| v.chars().count())
                .unwrap_or(0);
            if app.tunnels.form.cursor_pos < len {
                app.tunnels.form.cursor_pos += 1;
            }
        }
        KeyCode::Home => {
            app.tunnels.form.cursor_pos = 0;
        }
        KeyCode::End => {
            app.tunnels.form.sync_cursor_to_end();
        }
        KeyCode::Enter => {
            submit_tunnel_form(app, &alias, editing);
        }
        KeyCode::Char(' ')
            if app.tunnels.form.focused_field == crate::app::TunnelFormField::Type =>
        {
            app.tunnels.form.tunnel_type = app.tunnels.form.tunnel_type.next();
        }
        KeyCode::Char(c) => {
            app.tunnels.form.insert_char(c);
        }
        KeyCode::Backspace => {
            app.tunnels.form.delete_char_before_cursor();
        }
        _ => {}
    }
}

fn submit_tunnel_form(app: &mut App, alias: &str, editing: Option<usize>) {
    // Check for external config changes since form was opened
    if app.config_changed_since_form_open() {
        app.notify_warning(crate::messages::CONFIG_CHANGED_EXTERNALLY);
        return;
    }

    if let Err(msg) = app.tunnels.form.validate() {
        app.notify_error(msg);
        return;
    }

    let (directive_key, directive_value) = app.tunnels.form.to_directive();
    let config_backup = app.hosts_state.ssh_config.clone();

    // If editing, remove the old directive first
    if let Some(idx) = editing {
        if let Some(old_rule) = app.tunnels.list.get(idx) {
            let old_key = old_rule.tunnel_type.directive_key().to_string();
            let old_value = old_rule.to_directive_value();
            if !app
                .hosts_state
                .ssh_config
                .remove_forward(alias, &old_key, &old_value)
            {
                app.hosts_state.ssh_config = config_backup;
                app.notify_warning(crate::messages::TUNNEL_ORIGINAL_NOT_FOUND);
                return;
            }
        } else {
            // Index out of bounds (external config change) — abort
            app.notify_warning(crate::messages::TUNNEL_LIST_CHANGED);
            return;
        }
    }

    // Duplicate detection (runs after old directive removal for edits)
    if app
        .hosts_state
        .ssh_config
        .has_forward(alias, directive_key, &directive_value)
    {
        app.hosts_state.ssh_config = config_backup;
        app.notify_warning(crate::messages::TUNNEL_DUPLICATE);
        return;
    }

    app.hosts_state
        .ssh_config
        .add_forward(alias, directive_key, &directive_value);
    if let Err(e) = app.hosts_state.ssh_config.write() {
        app.hosts_state.ssh_config = config_backup;
        app.notify_error(crate::messages::failed_to_save(&e));
        return;
    }

    app.hosts_state.undo_stack.clear(); // Clear undo buffer — positions may have shifted
    app.update_last_modified();
    app.refresh_tunnel_list(alias);
    app.reload_hosts();
    // Fix selection after list change
    if app.tunnels.list.is_empty() {
        app.ui.tunnel_list_state.select(None);
    } else if let Some(sel) = app.ui.tunnel_list_state.selected() {
        if sel >= app.tunnels.list.len() {
            app.ui
                .tunnel_list_state
                .select(Some(app.tunnels.list.len() - 1));
        }
    } else {
        // First tunnel added to empty list — initialize selection
        app.ui.tunnel_list_state.select(Some(0));
    }
    app.clear_form_mtime();
    app.tunnels.form_baseline = None;
    app.notify(crate::messages::TUNNEL_SAVED);
    let return_to = tunnel_form_return_screen(app, alias);
    app.set_screen(return_to);
}