Add custom command mode for running commands against selected lines

This commit is contained in:
SW 2026-06-15 19:55:37 +02:00
parent aa3b5e09df
commit 55db8dbbdc
6 changed files with 370 additions and 57 deletions

View file

@ -69,10 +69,41 @@ The default terminal is `Terminal` (macOS Terminal.app). The default shell is de
| `←/→` or `h/l` | switch panel | | `←/→` or `h/l` | switch panel |
| `Enter` | run selected command / all commands in the current terminal | | `Enter` | run selected command / all commands in the current terminal |
| `o` | open selected command / all commands in a new terminal window | | `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` | | `e` | open current `.md` file in `$EDITOR` |
| `r` | reload data | | `r` | reload data |
| `q` / `Esc` | quit | | `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 ## History
Every command run through `lines` is appended to `~/.lines/history.log`: Every command run through `lines` is appended to `~/.lines/history.log`:

View file

@ -3,6 +3,12 @@ use std::path::PathBuf;
use crate::config::Terminal; use crate::config::Terminal;
use crate::parser::{load_groups, CommandFile, Group, Section}; use crate::parser::{load_groups, CommandFile, Group, Section};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Screen {
Main,
Custom,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct PendingCommand { pub struct PendingCommand {
pub group: String, pub group: String,
@ -11,6 +17,62 @@ pub struct PendingCommand {
pub commands: Vec<String>, pub commands: Vec<String>,
} }
#[derive(Debug, Clone)]
pub struct CustomState {
pub lines: Vec<String>,
pub selected: Vec<bool>,
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 &section.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<String> {
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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Focus { pub enum Focus {
Groups, Groups,
@ -27,6 +89,8 @@ pub struct App {
pub file_index: usize, pub file_index: usize,
pub section_index: usize, pub section_index: usize,
pub focus: Focus, pub focus: Focus,
pub screen: Screen,
pub custom_state: Option<CustomState>,
pub pending_command: Option<PendingCommand>, pub pending_command: Option<PendingCommand>,
pub message: Option<String>, pub message: Option<String>,
} }
@ -43,6 +107,8 @@ impl App {
file_index: 0, file_index: 0,
section_index: 0, section_index: 0,
focus: Focus::Groups, focus: Focus::Groups,
screen: Screen::Main,
custom_state: None,
pending_command: None, pending_command: None,
message: None, message: None,
} }
@ -55,6 +121,18 @@ impl App {
self.section_index = self.section_index.min(self.current_sections().len().saturating_sub(1)); 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> { pub fn current_group(&self) -> Option<&Group> {
self.groups.get(self.group_index) self.groups.get(self.group_index)
} }

83
src/custom.rs Normal file
View file

@ -0,0 +1,83 @@
use crate::app::CustomState;
pub fn build_commands(state: &CustomState) -> Vec<String> {
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<String>, selected: Vec<bool>, 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());
}
}

View file

@ -2,8 +2,9 @@ use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use std::io; use std::io;
use std::time::Duration; use std::time::Duration;
use crate::app::{App, PendingCommand}; use crate::app::{App, PendingCommand, Screen};
use crate::commands::{edit_file, run_section}; use crate::commands::{edit_file, run_section};
use crate::custom::build_commands;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppEvent { pub enum AppEvent {
@ -29,6 +30,16 @@ pub fn handle_event(app: &mut App, event: AppEvent) -> bool {
if key.kind != KeyEventKind::Press { if key.kind != KeyEventKind::Press {
return true; return true;
} }
match app.screen {
Screen::Main => handle_main_event(app, key),
Screen::Custom => handle_custom_event(app, key),
}
}
}
}
fn handle_main_event(app: &mut App, key: event::KeyEvent) -> bool {
match key.code { match key.code {
KeyCode::Char('q') | KeyCode::Esc => return false, KeyCode::Char('q') | KeyCode::Esc => return false,
KeyCode::Char('j') | KeyCode::Down => app.next(), KeyCode::Char('j') | KeyCode::Down => app.next(),
@ -41,6 +52,9 @@ pub fn handle_event(app: &mut App, event: AppEvent) -> bool {
edit_file(&file.path, &app.terminal, &app.shell); edit_file(&file.path, &app.terminal, &app.shell);
} }
} }
KeyCode::Char('c') => {
app.enter_custom();
}
KeyCode::Char('o') => { KeyCode::Char('o') => {
if let (Some(section), Some(file), Some(group)) = if let (Some(section), Some(file), Some(group)) =
(app.current_section(), app.current_file(), app.current_group()) (app.current_section(), app.current_file(), app.current_group())
@ -85,5 +99,41 @@ pub fn handle_event(app: &mut App, event: AppEvent) -> bool {
} }
true 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
}

View file

@ -1,6 +1,7 @@
mod app; mod app;
mod commands; mod commands;
mod config; mod config;
mod custom;
mod events; mod events;
mod logger; mod logger;
mod parser; mod parser;

View file

@ -6,9 +6,16 @@ use ratatui::{
Frame, Frame,
}; };
use crate::app::{App, Focus}; use crate::app::{App, Focus, Screen};
pub fn draw(f: &mut Frame, app: &App) { 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() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.margin(1) .margin(1)
@ -35,6 +42,69 @@ pub fn draw(f: &mut Frame, app: &App) {
draw_status(f, app, chunks[2]); 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<ListItem> = 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) { fn draw_groups(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let items: Vec<ListItem> = app let items: Vec<ListItem> = app
.groups .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) { 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 { let text = match &app.message {
Some(msg) => Text::from(msg.as_str()), Some(msg) => Text::from(msg.as_str()),
None => Text::from(help), None => Text::from(help),