diff --git a/README.md b/README.md index fe1fb8b..062b196 100644 --- a/README.md +++ b/README.md @@ -39,15 +39,16 @@ sshmngrs ### Keybindings -| Key | Action | -| ---------------------- | ------------------------------- | -| `Enter` | Connect to the selected command | -| `a` | Add a new command | -| `e` | Edit the selected command | -| `d` | Delete the selected command | -| `↑` / `k` | Move selection up | -| `↓` / `j` | Move selection down | -| `q` / `Esc` / `Ctrl+C` | Quit | +| Key | Action | +| ---------------------- | --------------------------------------- | +| `Enter` | Connect to the selected command | +| `a` | Add a new command | +| `e` | Edit the selected command | +| `d` | Delete the selected command | +| `u` | Upload a file to the host through `scp` | +| `↑` / `k` | Move selection up | +| `↓` / `j` | Move selection down | +| `q` / `Esc` / `Ctrl+C` | Quit | ## Configuration diff --git a/src/main.rs b/src/main.rs index 1cb96f7..6ecd55e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,6 +29,17 @@ impl Command { format!("{} [{}]", self.alias, self.command) } } + + /// Best-effort SSH destination ("user@host") used as the scp target. + fn destination(&self) -> Option { + let tokens: Vec<&str> = self.command.split_whitespace().collect(); + tokens + .iter() + .rev() + .find(|token| token.contains('@')) + .or_else(|| tokens.last()) + .map(|token| token.to_string()) + } } #[derive(Serialize, Deserialize, Default)] @@ -123,9 +134,46 @@ impl CommandForm { } } +#[derive(Clone, Copy)] +enum UploadField { + Local, + Remote, +} + +struct UploadForm { + local: Input, + remote: Input, + field: UploadField, +} + +impl UploadForm { + fn new(destination: &str) -> Self { + Self { + local: Input::default(), + remote: Input::new(format!("{destination}:")), + field: UploadField::Local, + } + } + + fn toggle_field(&mut self) { + self.field = match self.field { + UploadField::Local => UploadField::Remote, + UploadField::Remote => UploadField::Local, + }; + } + + fn current_mut(&mut self) -> &mut Input { + match self.field { + UploadField::Local => &mut self.local, + UploadField::Remote => &mut self.remote, + } + } +} + enum Mode { Normal, Editing(CommandForm), + Uploading(UploadForm), } struct App { @@ -169,6 +217,7 @@ impl App { match self.mode { Mode::Normal => self.on_key_normal(key.code), Mode::Editing(_) => self.on_key_editing(key), + Mode::Uploading(_) => self.on_key_uploading(key), } } @@ -178,6 +227,7 @@ impl App { KeyCode::Char('a') => self.mode = Mode::Editing(CommandForm::new()), KeyCode::Char('e') => self.start_editing(), KeyCode::Char('d') => self.delete_selected(), + KeyCode::Char('u') => self.start_uploading(), KeyCode::Enter => self.connect_selected(), KeyCode::Up | KeyCode::Char('k') => self.select_previous(), KeyCode::Down | KeyCode::Char('j') => self.select_next(), @@ -207,6 +257,41 @@ impl App { } } + fn start_uploading(&mut self) { + if let Some(command) = self.config.commands.get(self.selected) { + let destination = command.destination().unwrap_or_default(); + self.mode = Mode::Uploading(UploadForm::new(&destination)); + } + } + + fn on_key_uploading(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Esc => self.mode = Mode::Normal, + KeyCode::Enter => self.confirm_uploading(), + KeyCode::Tab | KeyCode::Up | KeyCode::Down => { + if let Mode::Uploading(form) = &mut self.mode { + form.toggle_field(); + } + } + _ => { + if let Mode::Uploading(form) = &mut self.mode { + form.current_mut().handle_event(&Event::Key(key)); + } + } + } + } + + fn confirm_uploading(&mut self) { + if let Mode::Uploading(form) = &self.mode { + let local = form.local.value().trim().to_string(); + let remote = form.remote.value().trim().to_string(); + if !local.is_empty() && !remote.is_empty() { + self.pending_command = Some(format!("scp \"{local}\" \"{remote}\"")); + } + } + self.mode = Mode::Normal; + } + fn on_key_editing(&mut self, key: KeyEvent) { match key.code { KeyCode::Esc => self.mode = Mode::Normal, @@ -327,11 +412,12 @@ fn draw(frame: &mut Frame, app: &App) { match &app.mode { Mode::Normal => {} Mode::Editing(form) => draw_edit_popup(frame, form), + Mode::Uploading(form) => draw_upload_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 instructions = " [a] Add [e] Edit [d] Delete [u] Upload [q/Esc] Quit "; let block = Block::default() .title(" sshmngrs ") @@ -384,6 +470,29 @@ fn draw_edit_popup(frame: &mut Frame, form: &CommandForm) { ); } +fn draw_upload_popup(frame: &mut Frame, form: &UploadForm) { + let area = centered_rect(frame.area(), 60, 6); + frame.render_widget(Clear, area); + + let [local_area, remote_area] = + Layout::vertical([Constraint::Length(3), Constraint::Length(3)]).areas(area); + + draw_field( + frame, + local_area, + "Local file", + &form.local, + matches!(form.field, UploadField::Local), + ); + draw_field( + frame, + remote_area, + "Remote destination", + &form.remote, + matches!(form.field, UploadField::Remote), + ); +} + fn draw_field(frame: &mut Frame, area: Rect, title: &str, input: &Input, focused: bool) { let border_style = if focused { Style::new().fg(Color::Yellow) @@ -391,17 +500,22 @@ fn draw_field(frame: &mut Frame, area: Rect, title: &str, input: &Input, focused Style::new() }; - let paragraph = Paragraph::new(input.value()).block( - Block::default() - .title(title) - .borders(Borders::ALL) - .border_style(border_style), - ); + let inner_width = area.width.saturating_sub(3) as usize; + let scroll = input.visual_scroll(inner_width); + + let paragraph = Paragraph::new(input.value()) + .scroll((0, scroll as u16)) + .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_x = area.x + 1 + (input.visual_cursor().max(scroll) - scroll) as u16; let cursor_y = area.y + 1; frame.set_cursor_position((cursor_x, cursor_y)); }