purple-ssh 3.4.0

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 ratatui::Frame;
use ratatui::layout::{Constraint, Layout};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Clear, List, ListItem, ListState, Paragraph};

use super::design;
use super::theme;
use crate::app::App;

pub fn render(frame: &mut Frame, app: &mut App) {
    let palette = match app.palette.as_ref() {
        Some(p) => p,
        None => return,
    };

    let filtered = palette.filtered_commands();
    let max_visible: u16 = 16;
    let list_height = (filtered.len() as u16).min(max_visible).max(1);
    // border(2) + input(1) + separator(1) + list. Footer below the block.
    let total_height = 2 + 1 + 1 + list_height;

    // Dynamic width: max(48, 60% of terminal), capped at terminal - 4
    let dynamic_width = 48u16.max(frame.area().width * 60 / 100);
    let overlay_width = dynamic_width.min(frame.area().width.saturating_sub(4));
    let height = total_height.min(frame.area().height.saturating_sub(3));
    let area = super::centered_rect_fixed(overlay_width, height, frame.area());

    frame.render_widget(Clear, area);

    let block = design::overlay_block("Commands");
    let inner = block.inner(area);
    frame.render_widget(block, area);

    let rows = Layout::vertical([
        Constraint::Length(1), // input line
        Constraint::Length(1), // separator
        Constraint::Min(1),    // command list
    ])
    .split(inner);

    // Input line with cursor
    let input_line = if palette.query.is_empty() {
        Line::from(Span::styled(
            "  type to filter, Enter to run...",
            theme::muted(),
        ))
    } else {
        Line::from(vec![
            Span::styled("  /", theme::accent_bold()),
            Span::styled(palette.query.clone(), theme::brand()),
            Span::styled("\u{2588}", theme::accent_bold()), // block cursor
        ])
    };
    frame.render_widget(Paragraph::new(input_line), rows[0]);

    // Separator using box-drawing char
    let sep_width = (inner.width as usize).saturating_sub(1);
    let sep = Line::from(Span::styled(
        format!(" {}", "\u{2500}".repeat(sep_width)),
        theme::muted(),
    ));
    frame.render_widget(Paragraph::new(sep), rows[1]);

    // Command list or empty state
    if filtered.is_empty() {
        design::render_empty(frame, rows[2], "no matching commands");
    } else {
        let items: Vec<ListItem> = filtered
            .iter()
            .map(|cmd| {
                let line = Line::from(vec![
                    Span::styled(format!("  {:>1}  ", cmd.key), theme::accent_bold()),
                    Span::styled(cmd.label, theme::muted()),
                ]);
                ListItem::new(line)
            })
            .collect();

        let list = List::new(items).highlight_style(theme::selected_row());

        let mut list_state = ListState::default();
        let clamped = palette.selected.min(filtered.len().saturating_sub(1));
        list_state.select(Some(clamped));
        frame.render_stateful_widget(list, rows[2], &mut list_state);
    }

    // Footer below the block
    let footer_area = design::render_overlay_footer(frame, area);
    design::Footer::new()
        .action("Enter", " run ")
        .action("\u{2191}\u{2193}", " select ")
        .action("Esc", " close")
        .render_with_status(frame, footer_area, app);
}

#[cfg(test)]
mod tests {
    use super::*;

    fn test_app() -> App {
        let config = crate::ssh_config::model::SshConfigFile {
            elements: Vec::new(),
            path: tempfile::tempdir()
                .expect("tempdir")
                .keep()
                .join("purple_palette_test"),
            crlf: false,
            bom: false,
        };
        let mut app = App::new(config);
        app.palette = Some(crate::app::CommandPaletteState::default());
        app
    }

    #[test]
    fn palette_renders_without_panic() {
        let mut app = test_app();
        let backend = ratatui::backend::TestBackend::new(80, 30);
        let mut terminal = ratatui::Terminal::new(backend).unwrap();
        terminal.draw(|frame| render(frame, &mut app)).unwrap();
    }

    #[test]
    fn palette_renders_all_commands_when_no_filter() {
        let mut app = test_app();
        let backend = ratatui::backend::TestBackend::new(80, 30);
        let mut terminal = ratatui::Terminal::new(backend).unwrap();
        terminal.draw(|frame| render(frame, &mut app)).unwrap();
        let buf = terminal.backend().buffer().clone();
        let text: String = buf.content.iter().map(|c| c.symbol().to_string()).collect();
        assert!(text.contains("file explorer"), "should show file explorer");
        assert!(text.contains("tunnels"), "should show tunnels");
    }

    #[test]
    fn palette_includes_whats_new() {
        let commands = crate::app::PaletteCommand::all();
        assert!(
            commands
                .iter()
                .any(|c| c.key == 'n' && c.label == "what's new"),
            "palette must include what's new command"
        );
    }

    #[test]
    fn palette_renders_filtered_commands() {
        let mut app = test_app();
        app.palette.as_mut().unwrap().push_query('t');
        let backend = ratatui::backend::TestBackend::new(80, 30);
        let mut terminal = ratatui::Terminal::new(backend).unwrap();
        terminal.draw(|frame| render(frame, &mut app)).unwrap();
        let buf = terminal.backend().buffer().clone();
        let text: String = buf.content.iter().map(|c| c.symbol().to_string()).collect();
        assert!(text.contains("tunnels"), "tunnels contains 't'");
    }

    #[test]
    fn palette_renders_empty_state() {
        let mut app = test_app();
        app.palette.as_mut().unwrap().push_query('z');
        app.palette.as_mut().unwrap().push_query('z');
        app.palette.as_mut().unwrap().push_query('z');
        let backend = ratatui::backend::TestBackend::new(80, 30);
        let mut terminal = ratatui::Terminal::new(backend).unwrap();
        terminal.draw(|frame| render(frame, &mut app)).unwrap();
        let buf = terminal.backend().buffer().clone();
        let text: String = buf.content.iter().map(|c| c.symbol().to_string()).collect();
        assert!(text.contains("no matching"), "should show empty state");
    }

    #[test]
    fn palette_renders_cursor_when_filtering() {
        let mut app = test_app();
        app.palette.as_mut().unwrap().push_query('t');
        let backend = ratatui::backend::TestBackend::new(80, 30);
        let mut terminal = ratatui::Terminal::new(backend).unwrap();
        terminal.draw(|frame| render(frame, &mut app)).unwrap();
        let buf = terminal.backend().buffer().clone();
        let text: String = buf.content.iter().map(|c| c.symbol().to_string()).collect();
        assert!(text.contains("\u{2588}"), "should show block cursor");
    }

    #[test]
    fn palette_on_narrow_terminal() {
        let mut app = test_app();
        let backend = ratatui::backend::TestBackend::new(50, 15);
        let mut terminal = ratatui::Terminal::new(backend).unwrap();
        terminal.draw(|frame| render(frame, &mut app)).unwrap();
    }
}