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 |
| `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`:

View file

@ -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<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)]
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<CustomState>,
pub pending_command: Option<PendingCommand>,
pub message: Option<String>,
}
@ -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)
}

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::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,
&section.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,
&section.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
}

View file

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

View file

@ -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<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) {
let items: Vec<ListItem> = 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),