From 0a8a831f956a3343a29a65fc1b95cd9da2236278 Mon Sep 17 00:00:00 2001 From: kirbylife Date: Fri, 24 Jul 2026 01:43:06 -0600 Subject: [PATCH] Add configuration save on disk, navigation with Vim keys and improve the input handle with tui-input crate --- Cargo.lock | 78 ++++++++++++++++++++++++++++++ Cargo.toml | 3 ++ src/main.rs | 134 ++++++++++++++++++++++++++++++---------------------- 3 files changed, 159 insertions(+), 56 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 40da4f0..7704610 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -479,6 +479,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + [[package]] name = "indoc" version = "2.0.7" @@ -1148,6 +1158,15 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1213,6 +1232,9 @@ name = "sshmngrs" version = "0.1.0" dependencies = [ "ratatui", + "serde", + "toml", + "tui-input", ] [[package]] @@ -1418,6 +1440,56 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +[[package]] +name = "toml" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tui-input" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bd014a652e31cf25ea68d11b10a7b09549863449b19387505c9933f11eb05fa" +dependencies = [ + "ratatui", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "typenum" version = "1.20.1" @@ -1661,6 +1733,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/Cargo.toml b/Cargo.toml index fc964bc..1803fe0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,3 +6,6 @@ description = "A fast and powerful SSH connection manager" [dependencies] ratatui = "0.30.2" +serde = { version = "1.0.229", features = ["derive"] } +toml = "1.1.3" +tui-input = "0.15.3" diff --git a/src/main.rs b/src/main.rs index 61ccfac..d64be19 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,15 @@ use ratatui::{ DefaultTerminal, Frame, - crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}, + crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}, layout::{Constraint, Flex, 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, @@ -21,6 +25,45 @@ impl 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, @@ -28,16 +71,16 @@ enum Field { } struct AddForm { - alias: String, - command: String, + alias: Input, + command: Input, field: Field, } impl AddForm { fn new() -> Self { Self { - alias: String::new(), - command: String::from("ssh "), + alias: Input::default(), + command: Input::new(String::from("ssh ")), field: Field::Alias, } } @@ -49,7 +92,7 @@ impl AddForm { }; } - fn current_mut(&mut self) -> &mut String { + fn current_mut(&mut self) -> &mut Input { match self.field { Field::Alias => &mut self.alias, Field::Command => &mut self.command, @@ -57,40 +100,24 @@ impl AddForm { } } -/// Which input mode the app is currently in. enum Mode { - /// Browsing the list of commands. Normal, - /// Filling in the popup to add a new command. Adding(AddForm), } -/// Application state. struct App { - /// The list of commands (SSH connections). - commands: Vec, - /// Index of the currently selected command in the list. + config: Config, selected: usize, - /// Current input mode. mode: Mode, - /// Whether the app should exit. should_quit: bool, } impl App { fn new() -> Self { + let mut config = Config::default(); + config.load(); Self { - // Dummy connections for testing. - commands: vec![ - Command { - alias: String::from("kirbylife-lab"), - command: String::from("ssh root@localhost"), - }, - Command { - alias: String::from("kirbylife-server"), - command: String::from("ssh root@192.168.100.1"), - }, - ], + config, selected: 0, mode: Mode::Normal, should_quit: false, @@ -98,9 +125,9 @@ impl App { } fn select_previous(&mut self) { - if !self.commands.is_empty() { + if !self.config.commands.is_empty() { self.selected = if self.selected == 0 { - self.commands.len() - 1 + self.config.commands.len() - 1 } else { self.selected - 1 }; @@ -108,15 +135,15 @@ impl App { } fn select_next(&mut self) { - if !self.commands.is_empty() { - self.selected = (self.selected + 1) % self.commands.len(); + if !self.config.commands.is_empty() { + self.selected = (self.selected + 1) % self.config.commands.len(); } } - fn on_key(&mut self, code: KeyCode) { + fn on_key(&mut self, key: KeyEvent) { match self.mode { - Mode::Normal => self.on_key_normal(code), - Mode::Adding(_) => self.on_key_adding(code), + Mode::Normal => self.on_key_normal(key.code), + Mode::Adding(_) => self.on_key_adding(key), } } @@ -124,14 +151,14 @@ impl App { match code { KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true, KeyCode::Char('a') => self.mode = Mode::Adding(AddForm::new()), - KeyCode::Up => self.select_previous(), - KeyCode::Down => self.select_next(), + KeyCode::Up | KeyCode::Char('k') => self.select_previous(), + KeyCode::Down | KeyCode::Char('j') => self.select_next(), _ => {} } } - fn on_key_adding(&mut self, code: KeyCode) { - match code { + fn on_key_adding(&mut self, key: KeyEvent) { + match key.code { KeyCode::Esc => self.mode = Mode::Normal, KeyCode::Enter => self.confirm_adding(), KeyCode::Tab | KeyCode::Up | KeyCode::Down => { @@ -139,26 +166,21 @@ impl App { form.toggle_field(); } } - KeyCode::Backspace => { + _ => { if let Mode::Adding(form) = &mut self.mode { - form.current_mut().pop(); + form.current_mut().handle_event(&Event::Key(key)); } } - KeyCode::Char(c) => { - if let Mode::Adding(form) = &mut self.mode { - form.current_mut().push(c); - } - } - _ => {} } } fn confirm_adding(&mut self) { if let Mode::Adding(form) = &self.mode { - let alias = form.alias.trim().to_string(); - let command = form.command.trim().to_string(); + let alias = form.alias.value().trim().to_string(); + let command = form.command.value().trim().to_string(); if !command.is_empty() { - self.commands.push(Command { alias, command }); + self.config.commands.push(Command { alias, command }); + let _ = self.config.save(); } } self.mode = Mode::Normal; @@ -183,7 +205,7 @@ fn run(mut terminal: DefaultTerminal) -> std::io::Result<()> { if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { app.should_quit = true; } else { - app.on_key(key.code); + app.on_key(key); } } } @@ -207,9 +229,9 @@ fn draw_main(frame: &mut Frame, app: &App, area: Rect) { .title_bottom(instructions) .borders(Borders::ALL); - let selected = app.selected.min(app.commands.len().saturating_sub(1)); - let items: Vec = app - .commands + let commands = &app.config.commands; + let selected = app.selected.min(commands.len().saturating_sub(1)); + let items: Vec = commands .iter() .enumerate() .map(|(index, command)| { @@ -223,7 +245,7 @@ fn draw_main(frame: &mut Frame, app: &App, area: Rect) { .highlight_style(Style::new().fg(Color::Black).bg(Color::Cyan)); let mut state = ListState::default(); - if !app.commands.is_empty() { + if !commands.is_empty() { state.select(Some(selected)); } @@ -253,14 +275,14 @@ fn draw_add_popup(frame: &mut Frame, form: &AddForm) { ); } -fn draw_field(frame: &mut Frame, area: Rect, title: &str, value: &str, focused: bool) { +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(value).block( + let paragraph = Paragraph::new(input.value()).block( Block::default() .title(title) .borders(Borders::ALL) @@ -270,7 +292,7 @@ fn draw_field(frame: &mut Frame, area: Rect, title: &str, value: &str, focused: frame.render_widget(paragraph, area); if focused { - let cursor_x = area.x + 1 + value.chars().count() as u16; + 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)); }