diff --git a/src/main.rs b/src/main.rs index 224495c..65b9637 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ pub mod searchable; pub mod ssh; pub mod ssh_config; pub mod ui; +pub mod window; use anyhow::Result; use clap::Parser; diff --git a/src/searchable.rs b/src/searchable.rs index a0d8753..cbea1b1 100644 --- a/src/searchable.rs +++ b/src/searchable.rs @@ -60,6 +60,10 @@ where pub fn iter(&self) -> std::slice::Iter { self.filtered.iter() } + + pub fn items(&self) -> Vec { + self.vec.clone() + } } impl<'a, T> IntoIterator for &'a Searchable diff --git a/src/ssh.rs b/src/ssh.rs index cc79745..255a10a 100644 --- a/src/ssh.rs +++ b/src/ssh.rs @@ -48,6 +48,32 @@ impl Host { } } +impl std::fmt::Display for Host { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.aliases.is_empty() { + writeln!(f, "Host {}", self.name)?; + } else { + writeln!(f, "Host {} {}", self.name, self.aliases)?; + } + + writeln!(f, " HostName {}", self.destination)?; + + if let Some(user) = &self.user { + writeln!(f, " User {}", user)?; + } + + if let Some(port) = &self.port { + writeln!(f, " Port {}", port)?; + } + + if let Some(proxy) = &self.proxy_command { + writeln!(f, " ProxyCommand {}", proxy)?; + } + + Ok(()) + } +} + #[derive(Debug)] pub enum ParseConfigError { Io(std::io::Error), diff --git a/src/ssh_config/parser.rs b/src/ssh_config/parser.rs index b455ef5..d58a88e 100644 --- a/src/ssh_config/parser.rs +++ b/src/ssh_config/parser.rs @@ -1,10 +1,13 @@ +use anyhow::Result; use glob::glob; use std::fs::File; -use std::io::BufRead; -use std::io::BufReader; +use std::fs::OpenOptions; +use std::io::{BufRead, BufReader, Write}; use std::path::Path; use std::str::FromStr; +use crate::ssh; + use super::host::Entry; use super::parser_error::InvalidIncludeError; use super::parser_error::InvalidIncludeErrorDetails; @@ -57,6 +60,20 @@ impl Parser { Ok(hosts) } + pub fn save_into_file(hosts: Vec, path: &str) -> Result<()> { + let normalized_path = shellexpand::tilde(&path).to_string(); + let path = std::fs::canonicalize(normalized_path)?; + + let mut file = OpenOptions::new().write(true).truncate(true).open(path)?; + + for host in hosts { + writeln!(file, "{}", host)?; + writeln!(file)?; + } + + Ok(()) + } + fn parse_raw(&self, reader: &mut impl BufRead) -> Result<(Host, Vec), ParseError> { let mut parent_host = Host::new(Vec::new()); let mut hosts = Vec::new(); diff --git a/src/ui.rs b/src/ui.rs index b473366..95080e1 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -22,9 +22,16 @@ use tui_input::backend::crossterm::EventHandler; use tui_input::Input; use unicode_width::UnicodeWidthStr; -use crate::{searchable::Searchable, ssh}; +use crate::{ + searchable::Searchable, + ssh::{self}, + window::{ + delete::OnKeyPressData as DeletePopupWindowOnKeyPressData, + delete::ShowData as DeletePopupWindowShowData, DeletePopupWindow, PopupWindow, + }, +}; -const INFO_TEXT: &str = "(Esc) quit | (↑) move up | (↓) move down | (enter) select"; +const INFO_TEXT: &str = "(Esc) quit | (↑) move up | (↓) move down | (enter) select | (Del) delete"; #[derive(Clone)] pub struct AppConfig { @@ -46,14 +53,15 @@ pub struct App { search: Input, table_state: TableState, - hosts: Searchable, table_columns_constraints: Vec, - palette: tailwind::Palette, + pub(crate) hosts: Searchable, + pub(crate) palette: tailwind::Palette, + pub(crate) delete_popup_window: DeletePopupWindow, } #[derive(PartialEq)] -enum AppKeyAction { +pub enum AppKeyAction { Ok, Stop, Continue, @@ -103,7 +111,7 @@ impl App { palette: tailwind::BLUE, hosts: Searchable::new( - hosts, + hosts.clone(), &search_input, move |host: &&ssh::Host, search_value: &str| -> bool { search_value.is_empty() @@ -114,6 +122,8 @@ impl App { || matcher.fuzzy_match(&host.aliases, search_value).is_some() }, ), + + delete_popup_window: DeletePopupWindow::default(), }; app.calculate_table_columns_constraints(); @@ -161,8 +171,10 @@ impl App { } } - self.search.handle_event(&ev); - self.hosts.search(self.search.value()); + if !self.delete_popup_window.is_active() { + self.search.handle_event(&ev); + self.hosts.search(self.search.value()); + } let selected = self.table_state.selected().unwrap_or(0); if selected >= self.hosts.len() { @@ -188,6 +200,22 @@ impl App { #[allow(clippy::enum_glob_use)] use KeyCode::*; + // If Popup Window is active `consume` key events + if self.delete_popup_window.is_active() { + let mut on_key_press_data = DeletePopupWindowOnKeyPressData::new( + self.config.config_paths.clone(), + self.hosts.items(), + ); + + let res = self + .delete_popup_window + .on_key_press(key, &mut on_key_press_data); + + self.hosts = Searchable::new(on_key_press_data.hosts, "", |_, _| false); + + return res; + } + let is_ctrl_pressed = key.modifiers.contains(KeyModifiers::CONTROL); if is_ctrl_pressed { @@ -241,6 +269,16 @@ impl App { return Ok(AppKeyAction::Stop); } } + Delete => { + let host_to_delete_index = self.table_state.selected().unwrap_or(0); + let host_to_delete = self.hosts[host_to_delete_index].clone(); + self.delete_popup_window + .show(DeletePopupWindowShowData::new( + self.hosts.items(), + host_to_delete_index, + host_to_delete, + )); + } _ => return Ok(AppKeyAction::Continue), } @@ -422,11 +460,13 @@ fn ui(f: &mut Frame, app: &mut App) { .split(f.area()); render_searchbar(f, app, rects[0]); - render_table(f, app, rects[1]); - render_footer(f, app, rects[2]); + if app.delete_popup_window.is_active() { + app.delete_popup_window.render(f); + } + let mut cursor_position = rects[0].as_position(); cursor_position.x += u16::try_from(app.search.cursor()).unwrap_or_default() + 4; cursor_position.y += 1; diff --git a/src/window/delete.rs b/src/window/delete.rs new file mode 100644 index 0000000..73df02b --- /dev/null +++ b/src/window/delete.rs @@ -0,0 +1,192 @@ +use anyhow::Result; +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout}, + style::{Color, Modifier, Style, Stylize}, + text::{Line, Span, Text}, + widgets::{Block, BorderType, Clear, Paragraph}, + Frame, +}; + +use crate::{ssh::Host, ssh_config, ui::AppKeyAction}; + +use super::{centered_rect, PopupWindow}; + +pub struct ShowData { + hosts: Vec, + host_to_delete_index: usize, + host_to_delete: Host, +} + +impl ShowData { + pub fn new(hosts: Vec, host_to_delete_index: usize, host_to_delete: Host) -> Self { + Self { + hosts, + host_to_delete_index, + host_to_delete, + } + } +} + +pub struct OnKeyPressData { + pub config_paths: Vec, + pub hosts: Vec, +} + +impl OnKeyPressData { + pub fn new(config_paths: Vec, hosts: Vec) -> Self { + Self { + config_paths, + hosts, + } + } +} + +#[derive(Default)] +pub struct DeletePopupWindow { + is_active: bool, + + selected_button_index: usize, + show_data: Option, +} + +impl PopupWindow for DeletePopupWindow { + type ShowData = ShowData; + type OnKeyPressData = OnKeyPressData; + + fn on_key_press( + &mut self, + key: KeyEvent, + data: &mut Self::OnKeyPressData, + ) -> Result { + #[allow(clippy::enum_glob_use)] + use KeyCode::*; + + match key.code { + Enter => { + if self.selected_button_index == 1 { + self.hide(); + return Ok(AppKeyAction::Continue); + } + + // Select first path from the config + // let path = &data.config_paths.get(0).unwrap(); + let path = "~/.ssh/config"; + let show_data = self.show_data.as_ref().unwrap(); + + let new_hosts = show_data + .hosts + .iter() + .enumerate() + .filter(|(index, _)| *index != show_data.host_to_delete_index) + .map(|(_, host)| host.clone()) + .collect::>(); + + data.hosts = new_hosts.clone(); + + ssh_config::Parser::save_into_file(new_hosts, path)?; + self.hide(); + } + Esc => self.hide(), + Left => { + if self.is_active() { + self.previous(); + } + } + Right => { + if self.is_active() { + self.next(); + } + } + _ => return Ok(AppKeyAction::Continue), + } + + Ok(AppKeyAction::Ok) + } + + fn is_active(&self) -> bool { + self.is_active + } + + fn show(&mut self, data: Self::ShowData) { + self.show_data = Some(data); + self.is_active = true; + } + + fn hide(&mut self) { + self.is_active = false; + } + + fn toggle(&mut self) { + self.is_active = !self.is_active; + } + + fn render(&mut self, f: &mut Frame) { + if let Some(show_data) = &self.show_data { + let popup_area = centered_rect(50, 20, f.area()); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(1), + Constraint::Length(3), + ]) + .margin(1) + .split(popup_area); + + let block = Block::bordered().border_type(BorderType::Rounded); + // .border_style(Style::new().fg(Palette:)); + + f.render_widget(Clear, popup_area); + f.render_widget(block, popup_area); + + let question = Paragraph::new(Text::from(vec![Line::from(vec![Span::raw(format!( + "Delete `{}` record?", + show_data.host_to_delete.name + ))]) + .bold()])) + .alignment(Alignment::Center); + + f.render_widget(question, chunks[0]); + + let yes_style = if self.selected_button_index == 0 { + Style::default() + .fg(Color::Black) + .bg(Color::Green) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Green) + }; + + let no_style = if self.selected_button_index == 1 { + Style::default() + .fg(Color::Black) + .bg(Color::Red) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Red) + }; + + let buttons_line = Line::from(vec![ + Span::styled(" Yes ", yes_style), + Span::raw(" "), + Span::styled(" No ", no_style), + ]); + + let buttons = Paragraph::new(Text::from(buttons_line)).alignment(Alignment::Center); + + f.render_widget(buttons, chunks[2]); + } + } +} + +impl DeletePopupWindow { + pub fn next(&mut self) { + self.selected_button_index = 1; + } + + pub fn previous(&mut self) { + self.selected_button_index = 0; + } +} diff --git a/src/window/mod.rs b/src/window/mod.rs new file mode 100644 index 0000000..7b53364 --- /dev/null +++ b/src/window/mod.rs @@ -0,0 +1,50 @@ +pub mod delete; + +use anyhow::Result; +use crossterm::event::KeyEvent; +pub use delete::DeletePopupWindow; +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + Frame, +}; + +use crate::ui::AppKeyAction; + +pub trait PopupWindow { + type ShowData; + type OnKeyPressData; + + fn on_key_press( + &mut self, + key: KeyEvent, + data: &mut Self::OnKeyPressData, + ) -> Result; + + fn is_active(&self) -> bool; + fn show(&mut self, data: Self::ShowData); + fn hide(&mut self); + fn toggle(&mut self); + fn render(&mut self, f: &mut Frame); +} + +pub fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + + let horizontal = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(vertical[1]); + + horizontal[1] +}