From 4549a8749b16f6e6bd4d323eee10349bee6e2180 Mon Sep 17 00:00:00 2001 From: kirbylife Date: Thu, 23 Jul 2026 22:08:23 -0600 Subject: [PATCH] New command to add a SSH connection --- src/main.rs | 275 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 264 insertions(+), 11 deletions(-) diff --git a/src/main.rs b/src/main.rs index aaa9f7a..61ccfac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,170 @@ use ratatui::{ - crossterm::event::{self, Event, KeyCode, KeyEventKind}, - widgets::{Block, Borders}, DefaultTerminal, Frame, + crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}, + layout::{Constraint, Flex, Layout, Rect}, + style::{Color, Style}, + widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}, }; +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(Clone, Copy)] +enum Field { + Alias, + Command, +} + +struct AddForm { + alias: String, + command: String, + field: Field, +} + +impl AddForm { + fn new() -> Self { + Self { + alias: String::new(), + command: String::from("ssh "), + field: Field::Alias, + } + } + + fn toggle_field(&mut self) { + self.field = match self.field { + Field::Alias => Field::Command, + Field::Command => Field::Alias, + }; + } + + fn current_mut(&mut self) -> &mut String { + match self.field { + Field::Alias => &mut self.alias, + Field::Command => &mut self.command, + } + } +} + +/// 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. + selected: usize, + /// Current input mode. + mode: Mode, + /// Whether the app should exit. + should_quit: bool, +} + +impl App { + fn new() -> Self { + 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"), + }, + ], + selected: 0, + mode: Mode::Normal, + should_quit: false, + } + } + + fn select_previous(&mut self) { + if !self.commands.is_empty() { + self.selected = if self.selected == 0 { + self.commands.len() - 1 + } else { + self.selected - 1 + }; + } + } + + fn select_next(&mut self) { + if !self.commands.is_empty() { + self.selected = (self.selected + 1) % self.commands.len(); + } + } + + fn on_key(&mut self, code: KeyCode) { + match self.mode { + Mode::Normal => self.on_key_normal(code), + Mode::Adding(_) => self.on_key_adding(code), + } + } + + 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::Adding(AddForm::new()), + KeyCode::Up => self.select_previous(), + KeyCode::Down => self.select_next(), + _ => {} + } + } + + fn on_key_adding(&mut self, code: KeyCode) { + match code { + KeyCode::Esc => self.mode = Mode::Normal, + KeyCode::Enter => self.confirm_adding(), + KeyCode::Tab | KeyCode::Up | KeyCode::Down => { + if let Mode::Adding(form) = &mut self.mode { + form.toggle_field(); + } + } + KeyCode::Backspace => { + if let Mode::Adding(form) = &mut self.mode { + form.current_mut().pop(); + } + } + 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(); + if !command.is_empty() { + self.commands.push(Command { alias, command }); + } + } + self.mode = Mode::Normal; + } +} + fn main() -> std::io::Result<()> { let terminal = ratatui::init(); let result = run(terminal); @@ -12,21 +173,113 @@ fn main() -> std::io::Result<()> { } fn run(mut terminal: DefaultTerminal) -> std::io::Result<()> { - loop { - terminal.draw(draw)?; + 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 && key.code == KeyCode::Char('q') { - break; + 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.code); + } } } } Ok(()) } -fn draw(frame: &mut Frame) { - let block = Block::default() - .title("sshmngrs") - .borders(Borders::ALL); - frame.render_widget(block, frame.area()); +fn draw(frame: &mut Frame, app: &App) { + draw_main(frame, app, frame.area()); + + if let Mode::Adding(form) = &app.mode { + draw_add_popup(frame, form); + } +} + +fn draw_main(frame: &mut Frame, app: &App, area: Rect) { + let instructions = " [a] Add connection [q/Esc] Quit "; + + let block = Block::default() + .title(" sshmngrs ") + .title_bottom(instructions) + .borders(Borders::ALL); + + let selected = app.selected.min(app.commands.len().saturating_sub(1)); + let items: Vec = app + .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 !app.commands.is_empty() { + state.select(Some(selected)); + } + + frame.render_stateful_widget(list, area, &mut state); +} + +fn draw_add_popup(frame: &mut Frame, form: &AddForm) { + 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, value: &str, focused: bool) { + let border_style = if focused { + Style::new().fg(Color::Yellow) + } else { + Style::new() + }; + + let paragraph = Paragraph::new(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 + value.chars().count() as u16; + let cursor_y = area.y + 1; + frame.set_cursor_position((cursor_x, cursor_y)); + } +} + +fn centered_rect(area: Rect, width: u16, height: u16) -> Rect { + let horizontal = Layout::horizontal([Constraint::Length(width)]).flex(Flex::Center); + let vertical = Layout::vertical([Constraint::Length(height)]).flex(Flex::Center); + let [area] = vertical.areas(area); + let [area] = horizontal.areas(area); + area }