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 |
|
| `←/→` 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`:
|
||||||
|
|
|
||||||
78
src/app.rs
78
src/app.rs
|
|
@ -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 §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)]
|
#[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
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::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())
|
||||||
|
|
@ -84,6 +98,42 @@ 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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
74
src/ui.rs
74
src/ui.rs
|
|
@ -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),
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue