sshmngrs/src/main.rs

409 lines
11 KiB
Rust

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<Command>,
}
impl Config {
/// Path to the configuration file: <home>/.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::<Config>(&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<String>,
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<ListItem> = 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));
}
}