409 lines
11 KiB
Rust
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));
|
|
}
|
|
}
|