Add custom command mode for running commands against selected lines
This commit is contained in:
parent
aa3b5e09df
commit
55db8dbbdc
6 changed files with 370 additions and 57 deletions
31
README.md
31
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`:
|
||||
|
|
|
|||
78
src/app.rs
78
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<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 §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<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
83
src/custom.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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,6 +30,16 @@ pub fn handle_event(app: &mut App, event: AppEvent) -> bool {
|
|||
if key.kind != KeyEventKind::Press {
|
||||
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 {
|
||||
KeyCode::Char('q') | KeyCode::Esc => return false,
|
||||
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);
|
||||
}
|
||||
}
|
||||
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())
|
||||
|
|
@ -85,5 +99,41 @@ pub fn handle_event(app: &mut App, event: AppEvent) -> bool {
|
|||
}
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
mod app;
|
||||
mod commands;
|
||||
mod config;
|
||||
mod custom;
|
||||
mod events;
|
||||
mod logger;
|
||||
mod parser;
|
||||
|
|
|
|||
74
src/ui.rs
74
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<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),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue