mod misc; use misc::{ centered_rect, ignore_interrupt_signals, reset_child_interrupt_signals, restore_interrupt_signals, wait_for_enter, }; use ratatui::{ DefaultTerminal, Frame, crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}, layout::{Constraint, Layout, Rect}, style::{Color, Style}, widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}, }; use serde::{Deserialize, Serialize}; use std::path::PathBuf; use tui_input::{Input, backend::crossterm::EventHandler}; #[derive(Serialize, Deserialize, Clone)] struct Command { alias: String, command: String, } impl Command { fn label(&self) -> String { if self.alias.is_empty() { self.command.clone() } else { format!("{} [{}]", self.alias, self.command) } } } #[derive(Serialize, Deserialize, Default)] struct Config { #[serde(default)] commands: Vec, } impl Config { /// Path to the configuration file: /.config/sshmngrs.conf. fn path() -> PathBuf { let home = std::env::home_dir().unwrap_or_else(|| PathBuf::from(".")); home.join(".config").join("sshmngrs.conf") } fn load(&mut self) { match std::fs::read_to_string(Self::path()) { Ok(contents) => { if let Ok(config) = toml::from_str::(&contents) { *self = config; } } // No config file yet: create an empty one and start fresh. Err(_) => { let _ = self.save(); } } } fn save(&self) -> std::io::Result<()> { let contents = toml::to_string_pretty(self) .map_err(|error| std::io::Error::new(std::io::ErrorKind::Other, error))?; let path = Self::path(); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } std::fs::write(path, contents) } } #[derive(Clone, Copy)] enum Field { Alias, Command, } #[derive(Clone, Copy)] enum CommandFormTarget { New, Existing(usize), } struct CommandForm { alias: Input, command: Input, field: Field, target: CommandFormTarget, } impl CommandForm { fn new() -> Self { Self { alias: Input::default(), command: Input::new(String::from("ssh ")), field: Field::Alias, target: CommandFormTarget::New, } } fn edit(index: usize, command: &Command) -> Self { Self { alias: Input::new(command.alias.clone()), command: Input::new(command.command.clone()), field: Field::Alias, target: CommandFormTarget::Existing(index), } } fn toggle_field(&mut self) { self.field = match self.field { Field::Alias => Field::Command, Field::Command => Field::Alias, }; } fn current_mut(&mut self) -> &mut Input { match self.field { Field::Alias => &mut self.alias, Field::Command => &mut self.command, } } } enum Mode { Normal, Editing(CommandForm), } struct App { config: Config, selected: usize, mode: Mode, pending_command: Option, should_quit: bool, } impl App { fn new() -> Self { let mut config = Config::default(); config.load(); Self { config, selected: 0, mode: Mode::Normal, pending_command: None, should_quit: false, } } fn select_previous(&mut self) { if !self.config.commands.is_empty() { self.selected = if self.selected == 0 { self.config.commands.len() - 1 } else { self.selected - 1 }; } } fn select_next(&mut self) { if !self.config.commands.is_empty() { self.selected = (self.selected + 1) % self.config.commands.len(); } } fn on_key(&mut self, key: KeyEvent) { match self.mode { Mode::Normal => self.on_key_normal(key.code), Mode::Editing(_) => self.on_key_editing(key), } } fn on_key_normal(&mut self, code: KeyCode) { match code { KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true, KeyCode::Char('a') => self.mode = Mode::Editing(CommandForm::new()), KeyCode::Char('e') => self.start_editing(), KeyCode::Char('d') => self.delete_selected(), KeyCode::Enter => self.connect_selected(), KeyCode::Up | KeyCode::Char('k') => self.select_previous(), KeyCode::Down | KeyCode::Char('j') => self.select_next(), _ => {} } } fn start_editing(&mut self) { if let Some(command) = self.config.commands.get(self.selected) { self.mode = Mode::Editing(CommandForm::edit(self.selected, command)); } } fn delete_selected(&mut self) { if self.selected < self.config.commands.len() { self.config.commands.remove(self.selected); self.selected = self .selected .min(self.config.commands.len().saturating_sub(1)); let _ = self.config.save(); } } fn connect_selected(&mut self) { if let Some(command) = self.config.commands.get(self.selected) { self.pending_command = Some(command.command.clone()); } } fn on_key_editing(&mut self, key: KeyEvent) { match key.code { KeyCode::Esc => self.mode = Mode::Normal, KeyCode::Enter => self.confirm_editing(), KeyCode::Tab | KeyCode::Up | KeyCode::Down => { if let Mode::Editing(form) = &mut self.mode { form.toggle_field(); } } _ => { if let Mode::Editing(form) = &mut self.mode { form.current_mut().handle_event(&Event::Key(key)); } } } } fn confirm_editing(&mut self) { if let Mode::Editing(form) = &self.mode { let alias = form.alias.value().trim().to_string(); let command = form.command.value().trim().to_string(); let target = form.target; if !command.is_empty() { match target { CommandFormTarget::New => self.config.commands.push(Command { alias, command }), CommandFormTarget::Existing(index) => { if let Some(existing) = self.config.commands.get_mut(index) { existing.alias = alias; existing.command = command; } } } let _ = self.config.save(); } } self.mode = Mode::Normal; } } fn main() -> std::io::Result<()> { let terminal = ratatui::init(); let result = run(terminal); ratatui::restore(); result } fn run(mut terminal: DefaultTerminal) -> std::io::Result<()> { let mut app = App::new(); while !app.should_quit { terminal.draw(|frame| draw(frame, &app))?; if let Event::Key(key) = event::read()? { if key.kind == KeyEventKind::Press { // Ctrl+C always quits, regardless of the current mode. if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { app.should_quit = true; } else { app.on_key(key); } } } if let Some(command) = app.pending_command.take() { launch_command(&mut terminal, &command)?; } } Ok(()) } fn launch_command(terminal: &mut DefaultTerminal, command: &str) -> std::io::Result<()> { ratatui::restore(); println!("Running: {command}\n"); // In Unix, while the child runs the command is in cooked mode, so Ctrl+C sends // SIGINT to the whole foreground process group (sshmngrs included). Ignore it in // the parent so interrupting the child, for example, at an ssh password prompt, // returns to sshmngrs instead of killing it. The child resets its own handlers to // default below so it can still be interrupted. ignore_interrupt_signals(); let mut shell = shell_command(command); reset_child_interrupt_signals(&mut shell); let status = shell.status(); match &status { Ok(code) if !code.success() => eprintln!("\nCommand exited with {code}."), Ok(code) => println!("\nCommand exited with {code}."), Err(error) => eprintln!("\nCould not run command: {error}"), } wait_for_enter("Press Enter to restore sshmngrs..."); restore_interrupt_signals(); *terminal = ratatui::init(); terminal.clear()?; Ok(()) } fn shell_command(command: &str) -> std::process::Command { let mut shell = if cfg!(target_os = "windows") { let mut shell = std::process::Command::new("cmd"); shell.arg("/C"); shell } else { let mut shell = std::process::Command::new("sh"); shell.arg("-c"); shell }; shell.arg(command); shell } fn draw(frame: &mut Frame, app: &App) { draw_main(frame, app, frame.area()); match &app.mode { Mode::Normal => {} Mode::Editing(form) => draw_edit_popup(frame, form), } } fn draw_main(frame: &mut Frame, app: &App, area: Rect) { let instructions = " [a] Add [e] Edit [d] Delete [q/Esc] Quit "; let block = Block::default() .title(" sshmngrs ") .title_bottom(instructions) .borders(Borders::ALL); let commands = &app.config.commands; let selected = app.selected.min(commands.len().saturating_sub(1)); let items: Vec = commands .iter() .enumerate() .map(|(index, command)| { let marker = if index == selected { "[*] " } else { "[ ] " }; ListItem::new(format!("{}{}", marker, command.label())) }) .collect(); let list = List::new(items) .block(block) .highlight_style(Style::new().fg(Color::Black).bg(Color::Cyan)); let mut state = ListState::default(); if !commands.is_empty() { state.select(Some(selected)); } frame.render_stateful_widget(list, area, &mut state); } fn draw_edit_popup(frame: &mut Frame, form: &CommandForm) { let area = centered_rect(frame.area(), 60, 6); frame.render_widget(Clear, area); let [alias_area, command_area] = Layout::vertical([Constraint::Length(3), Constraint::Length(3)]).areas(area); draw_field( frame, alias_area, "Alias", &form.alias, matches!(form.field, Field::Alias), ); draw_field( frame, command_area, "Command", &form.command, matches!(form.field, Field::Command), ); } fn draw_field(frame: &mut Frame, area: Rect, title: &str, input: &Input, focused: bool) { let border_style = if focused { Style::new().fg(Color::Yellow) } else { Style::new() }; let paragraph = Paragraph::new(input.value()).block( Block::default() .title(title) .borders(Borders::ALL) .border_style(border_style), ); frame.render_widget(paragraph, area); if focused { let cursor_x = area.x + 1 + input.visual_cursor() as u16; let cursor_y = area.y + 1; frame.set_cursor_position((cursor_x, cursor_y)); } }