diff --git a/README.md b/README.md index 7c89f45..55460d3 100644 --- a/README.md +++ b/README.md @@ -69,10 +69,41 @@ The default terminal is `Terminal` (macOS Terminal.app). The default shell is de | `←/→` or `h/l` | switch panel | | `Enter` | run selected command / all commands in the current terminal | | `o` | open selected command / all commands in a new terminal window | +| `c` | open custom command mode for the current file | | `e` | open current `.md` file in `$EDITOR` | | `r` | reload data | | `q` / `Esc` | quit | +## Custom command mode + +Press `c` on a selected file to run a custom command against specific lines from that file. + +For a file containing: + +```markdown +# PVE1 +ssh root@host1.example.com + +# PVE2 +ssh root@host2.example.com + +# PVE3 +ssh root@host3.example.com +``` + +Press `c`, select lines with `Space`, type `lsblk`, and press `Enter`. `lines` will run: + +```bash +ssh root@host2.example.com lsblk +ssh root@host3.example.com lsblk +``` + +Use `{}` as a placeholder if your lines are just addresses: + +```bash +ssh {} lsblk +``` + ## History Every command run through `lines` is appended to `~/.lines/history.log`: diff --git a/src/app.rs b/src/app.rs index b4e9e68..7d8e558 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,6 +3,12 @@ use std::path::PathBuf; use crate::config::Terminal; use crate::parser::{load_groups, CommandFile, Group, Section}; +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Screen { + Main, + Custom, +} + #[derive(Debug, Clone)] pub struct PendingCommand { pub group: String, @@ -11,6 +17,62 @@ pub struct PendingCommand { pub commands: Vec, } +#[derive(Debug, Clone)] +pub struct CustomState { + pub lines: Vec, + pub selected: Vec, + pub cursor: usize, + pub command: String, +} + +impl CustomState { + pub fn from_file(file: &CommandFile) -> Self { + let mut lines = Vec::new(); + for section in &file.sections { + if section.is_run_all() { + continue; + } + for cmd in §ion.commands { + lines.push(cmd.clone()); + } + } + let selected = vec![false; lines.len()]; + CustomState { + lines, + selected, + cursor: 0, + command: String::new(), + } + } + + pub fn toggle(&mut self) { + if let Some(s) = self.selected.get_mut(self.cursor) { + *s = !*s; + } + } + + pub fn next(&mut self) { + if !self.lines.is_empty() { + self.cursor = (self.cursor + 1) % self.lines.len(); + } + } + + pub fn previous(&mut self) { + if !self.lines.is_empty() { + self.cursor = self.cursor.checked_sub(1).unwrap_or(self.lines.len() - 1); + } + } + + pub fn selected_lines(&self) -> Vec { + self.lines + .iter() + .enumerate() + .filter(|(i, _)| self.selected.get(*i).copied().unwrap_or(false)) + .map(|(_, line)| line.clone()) + .collect() + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Focus { Groups, @@ -27,6 +89,8 @@ pub struct App { pub file_index: usize, pub section_index: usize, pub focus: Focus, + pub screen: Screen, + pub custom_state: Option, pub pending_command: Option, pub message: Option, } @@ -43,6 +107,8 @@ impl App { file_index: 0, section_index: 0, focus: Focus::Groups, + screen: Screen::Main, + custom_state: None, pending_command: None, message: None, } @@ -55,6 +121,18 @@ impl App { self.section_index = self.section_index.min(self.current_sections().len().saturating_sub(1)); } + pub fn enter_custom(&mut self) { + if let Some(file) = self.current_file() { + self.custom_state = Some(CustomState::from_file(file)); + self.screen = Screen::Custom; + } + } + + pub fn exit_custom(&mut self) { + self.screen = Screen::Main; + self.custom_state = None; + } + pub fn current_group(&self) -> Option<&Group> { self.groups.get(self.group_index) } diff --git a/src/custom.rs b/src/custom.rs new file mode 100644 index 0000000..71fccd4 --- /dev/null +++ b/src/custom.rs @@ -0,0 +1,83 @@ +use crate::app::CustomState; + +pub fn build_commands(state: &CustomState) -> Vec { + let command = state.command.trim(); + if command.is_empty() { + return Vec::new(); + } + + let selected = state.selected_lines(); + if selected.is_empty() { + return Vec::new(); + } + + selected + .iter() + .map(|line| { + let line = line.trim(); + if command.contains("{}") { + command.replace("{}", line) + } else { + format!("{} {}", line, command) + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::CustomState; + + fn make_state(lines: Vec, selected: Vec, command: &str) -> CustomState { + CustomState { + lines, + selected, + cursor: 0, + command: command.to_string(), + } + } + + #[test] + fn test_build_commands_append() { + let state = make_state( + vec![ + "ssh root@ip1".to_string(), + "ssh root@ip2".to_string(), + "ssh root@ip3".to_string(), + ], + vec![false, true, true], + "lsblk", + ); + assert_eq!( + build_commands(&state), + vec!["ssh root@ip2 lsblk", "ssh root@ip3 lsblk"] + ); + } + + #[test] + fn test_build_commands_placeholder() { + let state = make_state( + vec![ + "root@ip1".to_string(), + "root@ip2".to_string(), + ], + vec![true, true], + "ssh {} lsblk", + ); + assert_eq!( + build_commands(&state), + vec!["ssh root@ip1 lsblk", "ssh root@ip2 lsblk"] + ); + } + + #[test] + fn test_build_commands_empty_selection() { + let state = make_state( + vec!["ssh root@ip1".to_string()], + vec![false], + "lsblk", + ); + assert!(build_commands(&state).is_empty()); + } +} diff --git a/src/events.rs b/src/events.rs index 2eaef12..5649e38 100644 --- a/src/events.rs +++ b/src/events.rs @@ -2,8 +2,9 @@ use crossterm::event::{self, Event, KeyCode, KeyEventKind}; use std::io; use std::time::Duration; -use crate::app::{App, PendingCommand}; +use crate::app::{App, PendingCommand, Screen}; use crate::commands::{edit_file, run_section}; +use crate::custom::build_commands; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AppEvent { @@ -29,61 +30,110 @@ pub fn handle_event(app: &mut App, event: AppEvent) -> bool { if key.kind != KeyEventKind::Press { return true; } - match key.code { - KeyCode::Char('q') | KeyCode::Esc => return false, - KeyCode::Char('j') | KeyCode::Down => app.next(), - KeyCode::Char('k') | KeyCode::Up => app.previous(), - KeyCode::Char('h') | KeyCode::Left => app.focus_left(), - KeyCode::Char('l') | KeyCode::Right => app.focus_right(), - KeyCode::Char('r') => app.reload(), - KeyCode::Char('e') => { - if let Some(file) = app.current_file() { - edit_file(&file.path, &app.terminal, &app.shell); - } - } - KeyCode::Char('o') => { - if let (Some(section), Some(file), Some(group)) = - (app.current_section(), app.current_file(), app.current_group()) - { - run_section( - section, - file, - &app.terminal, - &app.shell, - &app.data_dir, - &group.name, - &file.name, - §ion.title, - ); - } - } - KeyCode::Enter => { - if let (Some(section), Some(file), Some(group)) = - (app.current_section(), app.current_file(), app.current_group()) - { - let commands = if section.is_run_all() { - file.sections - .iter() - .filter(|s| !s.is_run_all()) - .flat_map(|s| s.commands.clone()) - .collect() - } else { - section.commands.clone() - }; - if !commands.is_empty() { - app.pending_command = Some(PendingCommand { - group: group.name.clone(), - file: file.name.clone(), - section: section.title.clone(), - commands, - }); - return false; - } - } - } - _ => {} + + match app.screen { + Screen::Main => handle_main_event(app, key), + Screen::Custom => handle_custom_event(app, key), } - true } } } + +fn handle_main_event(app: &mut App, key: event::KeyEvent) -> bool { + match key.code { + KeyCode::Char('q') | KeyCode::Esc => return false, + KeyCode::Char('j') | KeyCode::Down => app.next(), + KeyCode::Char('k') | KeyCode::Up => app.previous(), + KeyCode::Char('h') | KeyCode::Left => app.focus_left(), + KeyCode::Char('l') | KeyCode::Right => app.focus_right(), + KeyCode::Char('r') => app.reload(), + KeyCode::Char('e') => { + if let Some(file) = app.current_file() { + edit_file(&file.path, &app.terminal, &app.shell); + } + } + KeyCode::Char('c') => { + app.enter_custom(); + } + KeyCode::Char('o') => { + if let (Some(section), Some(file), Some(group)) = + (app.current_section(), app.current_file(), app.current_group()) + { + run_section( + section, + file, + &app.terminal, + &app.shell, + &app.data_dir, + &group.name, + &file.name, + §ion.title, + ); + } + } + KeyCode::Enter => { + if let (Some(section), Some(file), Some(group)) = + (app.current_section(), app.current_file(), app.current_group()) + { + let commands = if section.is_run_all() { + file.sections + .iter() + .filter(|s| !s.is_run_all()) + .flat_map(|s| s.commands.clone()) + .collect() + } else { + section.commands.clone() + }; + if !commands.is_empty() { + app.pending_command = Some(PendingCommand { + group: group.name.clone(), + file: file.name.clone(), + section: section.title.clone(), + commands, + }); + return false; + } + } + } + _ => {} + } + true +} + +fn handle_custom_event(app: &mut App, key: event::KeyEvent) -> bool { + let Some(state) = app.custom_state.as_mut() else { + app.exit_custom(); + return true; + }; + + match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + app.exit_custom(); + } + KeyCode::Char('j') | KeyCode::Down => state.next(), + KeyCode::Char('k') | KeyCode::Up => state.previous(), + KeyCode::Char(' ') => state.toggle(), + KeyCode::Backspace => { + state.command.pop(); + } + KeyCode::Enter => { + let commands = build_commands(state); + if !commands.is_empty() + && let (Some(file), Some(group)) = (app.current_file(), app.current_group()) + { + app.pending_command = Some(PendingCommand { + group: group.name.clone(), + file: file.name.clone(), + section: "custom".to_string(), + commands, + }); + return false; + } + } + KeyCode::Char(c) => { + state.command.push(c); + } + _ => {} + } + true +} diff --git a/src/main.rs b/src/main.rs index 8afa8d5..b8d1dff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod app; mod commands; mod config; +mod custom; mod events; mod logger; mod parser; diff --git a/src/ui.rs b/src/ui.rs index 9b4c3d1..401339d 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -6,9 +6,16 @@ use ratatui::{ Frame, }; -use crate::app::{App, Focus}; +use crate::app::{App, Focus, Screen}; pub fn draw(f: &mut Frame, app: &App) { + match app.screen { + Screen::Main => draw_main(f, app), + Screen::Custom => draw_custom(f, app), + } +} + +fn draw_main(f: &mut Frame, app: &App) { let chunks = Layout::default() .direction(Direction::Vertical) .margin(1) @@ -35,6 +42,69 @@ pub fn draw(f: &mut Frame, app: &App) { draw_status(f, app, chunks[2]); } +fn draw_custom(f: &mut Frame, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([ + Constraint::Min(5), + Constraint::Length(3), + Constraint::Length(1), + ]) + .split(f.area()); + + let Some(state) = app.custom_state.as_ref() else { + return; + }; + + let items: Vec = state + .lines + .iter() + .enumerate() + .map(|(i, line)| { + let checked = if state.selected.get(i).copied().unwrap_or(false) { + "[x]" + } else { + "[ ]" + }; + let style = if i == state.cursor { + Style::default() + .bg(Color::Blue) + .fg(Color::Black) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + ListItem::new(format!("{} {}", checked, line)).style(style) + }) + .collect(); + + let list = List::new(items).block( + Block::default() + .title(" Custom command — select lines with Space ") + .borders(Borders::ALL), + ); + f.render_widget(list, chunks[0]); + + let input = Paragraph::new(state.command.as_str()) + .block( + Block::default() + .title(" Command (use {} for selected line) ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)), + ) + .wrap(Wrap { trim: true }); + f.render_widget(input, chunks[1]); + + let help = "Space: toggle | j/k: move | Enter: run | Esc: back"; + let text = match &app.message { + Some(msg) => Text::from(msg.as_str()), + None => Text::from(help), + }; + let paragraph = Paragraph::new(text).style(Style::default().fg(Color::Gray)); + f.render_widget(paragraph, chunks[2]); +} + fn draw_groups(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { let items: Vec = app .groups @@ -173,7 +243,7 @@ fn draw_preview(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { } fn draw_status(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { - let help = "↑/↓ or j/k: move | →/← or h/l: panel | Enter: run | o: open in new window | e: edit | r: reload | q: quit"; + let help = "↑/↓ or j/k: move | →/← or h/l: panel | Enter: run | o: new window | c: custom | e: edit | r: reload | q: quit"; let text = match &app.message { Some(msg) => Text::from(msg.as_str()), None => Text::from(help),