Add 'upload' instruction to upload a file to the host using scp command

main
kirbylife 2026-07-26 00:37:28 -06:00
parent 6bbd9fb5f8
commit 2333d85627
2 changed files with 132 additions and 17 deletions

View File

@ -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

View File

@ -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<String> {
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));
}