Initial commit

This commit is contained in:
SW 2026-06-15 10:46:15 +02:00
commit cc2d73154d
14 changed files with 2885 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

2039
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

11
Cargo.toml Normal file
View file

@ -0,0 +1,11 @@
[package]
name = "lines"
version = "0.1.0"
edition = "2024"
[dependencies]
clap = { version = "4.6.1", features = ["derive"] }
crossterm = "0.29.0"
directories = "6.0.0"
ratatui = "0.30.1"
walkdir = "2.5.0"

69
README.md Normal file
View file

@ -0,0 +1,69 @@
# lines
TUI aplikace pro rychlé spouštění často používaných příkazů — ideální pro SSH skripty, aktualizace a cokoliv, co pouštíš pravidelně z terminálu.
## Instalace
```bash
brew install janvete/tools/lines
```
## Použití
```bash
lines # otevře TUI
lines --dir /cesta/k/datům # použije jinou datovou složku
```
## Struktura dat
Data jsou běžné `.md` soubory ve složce `~/.lines`. Skupiny = podsložky.
```
~/.lines
├── Synology
│ └── dsmview.md
└── Scripts
└── update.md
```
### Formát `.md`
Každá sekce začíná nadpisem `# Název příkazu`. Pod ním následují řádky příkazů.
```markdown
# Sysel DSM
dsmview ssh root@192.168.40.15
# Johy DSM
dsmview ssh root@192.168.51.3
```
Sekce bez příkazů znamená "spustit všechny příkazy v tomto souboru".
```markdown
# Spustí vše
# Docker update
ssh root@192.168.53.3 /opt/mealie/update.sh
ssh root@192.168.53.5 /opt/yamtrack/update.sh
```
## Ovládání
| Klávesa | Akce |
|---------|------|
| `↑/↓` nebo `j/k` | pohyb v seznamu |
| `←/→` nebo `h/l` | přepínání panelů |
| `Enter` | spustí vybraný příkaz / všechny příkazy |
| `e` | otevře aktuální `.md` soubor v `$EDITOR` |
| `r` | znovu načte data ze složky |
| `q` nebo `Esc` | ukončí aplikaci |
## Zálohování
Celá datová složka `~/.lines` je obyčejný text — stačí ji zkopírovat na disk, do Gitu nebo iCloudu. Na novém Macu ji přeneseš a `lines` ji rovnou načte.
## Licence
MIT

View file

@ -0,0 +1,5 @@
# Spustí vše
# Docker update
ssh root@192.168.53.3 /opt/mealie/update.sh
ssh root@192.168.53.5 /opt/yamtrack/update.sh

View file

@ -0,0 +1,5 @@
# Sysel DSM
dsmview ssh root@192.168.40.15
# Johy DSM
dsmview ssh root@192.168.51.3

134
src/app.rs Normal file
View file

@ -0,0 +1,134 @@
use std::path::PathBuf;
use crate::parser::{load_groups, CommandFile, Group, Section};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Focus {
Groups,
Files,
Sections,
}
pub struct App {
pub data_dir: PathBuf,
pub groups: Vec<Group>,
pub group_index: usize,
pub file_index: usize,
pub section_index: usize,
pub focus: Focus,
pub message: Option<String>,
}
impl App {
pub fn new(data_dir: PathBuf) -> Self {
let groups = load_groups(&data_dir);
App {
data_dir,
groups,
group_index: 0,
file_index: 0,
section_index: 0,
focus: Focus::Groups,
message: None,
}
}
pub fn reload(&mut self) {
self.groups = load_groups(&self.data_dir);
self.group_index = self.group_index.min(self.groups.len().saturating_sub(1));
self.file_index = self.file_index.min(self.current_files().len().saturating_sub(1));
self.section_index = self.section_index.min(self.current_sections().len().saturating_sub(1));
}
pub fn current_group(&self) -> Option<&Group> {
self.groups.get(self.group_index)
}
pub fn current_file(&self) -> Option<&CommandFile> {
self.current_group()?.files.get(self.file_index)
}
pub fn current_section(&self) -> Option<&Section> {
self.current_file()?.sections.get(self.section_index)
}
pub fn current_files(&self) -> &[CommandFile] {
match self.current_group() {
Some(g) => &g.files,
None => &[],
}
}
pub fn current_sections(&self) -> &[Section] {
match self.current_file() {
Some(f) => &f.sections,
None => &[],
}
}
pub fn next(&mut self) {
match self.focus {
Focus::Groups => {
if !self.groups.is_empty() {
self.group_index = (self.group_index + 1) % self.groups.len();
self.file_index = 0;
self.section_index = 0;
}
}
Focus::Files => {
let len = self.current_files().len();
if len > 0 {
self.file_index = (self.file_index + 1) % len;
self.section_index = 0;
}
}
Focus::Sections => {
let len = self.current_sections().len();
if len > 0 {
self.section_index = (self.section_index + 1) % len;
}
}
}
}
pub fn previous(&mut self) {
match self.focus {
Focus::Groups => {
if !self.groups.is_empty() {
self.group_index = self.group_index.checked_sub(1).unwrap_or(self.groups.len() - 1);
self.file_index = 0;
self.section_index = 0;
}
}
Focus::Files => {
let len = self.current_files().len();
if len > 0 {
self.file_index = self.file_index.checked_sub(1).unwrap_or(len - 1);
self.section_index = 0;
}
}
Focus::Sections => {
let len = self.current_sections().len();
if len > 0 {
self.section_index = self.section_index.checked_sub(1).unwrap_or(len - 1);
}
}
}
}
pub fn focus_right(&mut self) {
self.focus = match self.focus {
Focus::Groups => Focus::Files,
Focus::Files => Focus::Sections,
Focus::Sections => Focus::Sections,
};
}
pub fn focus_left(&mut self) {
self.focus = match self.focus {
Focus::Groups => Focus::Groups,
Focus::Files => Focus::Groups,
Focus::Sections => Focus::Files,
};
}
}

90
src/commands.rs Normal file
View file

@ -0,0 +1,90 @@
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::Command;
use crate::parser::{CommandFile, Section};
pub fn run_section(section: &Section, file: &CommandFile) {
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() {
return;
}
let script = build_script(&commands);
execute_in_terminal(&script);
}
fn build_script(commands: &[String]) -> String {
commands.join("\n")
}
fn execute_in_terminal(script: &str) {
let tmp_dir = std::env::temp_dir();
let script_path = tmp_dir.join(format!("lines_run_{}.sh", random_suffix()));
let mut file = match std::fs::File::create(&script_path) {
Ok(f) => f,
Err(_) => return,
};
if file.write_all(script.as_bytes()).is_err() {
return;
}
// Best-effort attempt to make executable; Terminal.app can run `bash script.sh` anyway.
let _ = std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755));
let path_str = script_path.to_string_lossy();
// Open a new Terminal.app window and run the script.
let applescript = format!(
r#"tell application "Terminal"
do script "clear; bash {}; rm {}"
activate
end tell"#,
escape_applescript(&path_str),
escape_applescript(&path_str)
);
let _ = Command::new("osascript")
.arg("-e")
.arg(applescript)
.spawn();
}
fn escape_applescript(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
fn random_suffix() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn edit_file(path: &Path) {
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".to_string());
let _ = Command::new("osascript")
.arg("-e")
.arg(format!(
r#"tell application "Terminal"
do script "cd {}; {} {}; exit"
activate
end tell"#,
escape_applescript(&std::env::current_dir().unwrap_or_default().to_string_lossy()),
escape_applescript(&editor),
escape_applescript(&path.to_string_lossy())
))
.spawn();
}

18
src/config.rs Normal file
View file

@ -0,0 +1,18 @@
use std::path::PathBuf;
pub struct Config {
pub data_dir: PathBuf,
}
impl Config {
pub fn new(data_dir: Option<PathBuf>) -> Self {
let data_dir = data_dir.unwrap_or_else(default_data_dir);
Config { data_dir }
}
}
fn default_data_dir() -> PathBuf {
directories::UserDirs::new()
.map(|d| d.home_dir().join(".lines"))
.unwrap_or_else(|| PathBuf::from(".lines"))
}

54
src/events.rs Normal file
View file

@ -0,0 +1,54 @@
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use std::io;
use std::time::Duration;
use crate::app::App;
use crate::commands::{edit_file, run_section};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppEvent {
Tick,
Key(event::KeyEvent),
}
pub fn poll_event(timeout: Duration) -> io::Result<Option<AppEvent>> {
if event::poll(timeout)? {
match event::read()? {
Event::Key(key) => Ok(Some(AppEvent::Key(key))),
_ => Ok(Some(AppEvent::Tick)),
}
} else {
Ok(Some(AppEvent::Tick))
}
}
pub fn handle_event(app: &mut App, event: AppEvent) -> bool {
match event {
AppEvent::Tick => true,
AppEvent::Key(key) => {
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);
}
}
KeyCode::Enter => {
if let (Some(section), Some(file)) = (app.current_section(), app.current_file()) {
run_section(section, file);
}
}
_ => {}
}
true
}
}
}

85
src/main.rs Normal file
View file

@ -0,0 +1,85 @@
mod app;
mod commands;
mod config;
mod events;
mod parser;
mod ui;
use std::io;
use std::path::PathBuf;
use std::time::Duration;
use clap::Parser;
use crossterm::{
event::{DisableMouseCapture, EnableMouseCapture},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::{Backend, CrosstermBackend},
Terminal,
};
use crate::app::App;
use crate::config::Config;
use crate::events::{handle_event, poll_event};
#[derive(Parser, Debug)]
#[command(name = "lines")]
#[command(about = "TUI pro rychlé spouštění často používaných příkazů")]
struct Args {
/// Cesta k datové složce (výchozí: ~/.lines)
#[arg(short, long)]
dir: Option<PathBuf>,
}
fn main() -> io::Result<()> {
let args = Args::parse();
let config = Config::new(args.dir);
// Ensure data directory exists
if let Err(e) = std::fs::create_dir_all(&config.data_dir) {
eprintln!("Nepodařilo se vytvořit datovou složku {}: {}", config.data_dir.display(), e);
}
let app = App::new(config.data_dir);
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let res = run_app(&mut terminal, app);
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = res {
eprintln!("{}", err);
}
Ok(())
}
fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut app: App) -> io::Result<()>
where
io::Error: From<<B as Backend>::Error>,
{
let tick_rate = Duration::from_millis(100);
loop {
terminal.draw(|f| ui::draw(f, &app))?;
if let Some(event) = poll_event(tick_rate)?
&& !handle_event(&mut app, event)
{
return Ok(());
}
}
}

175
src/parser.rs Normal file
View file

@ -0,0 +1,175 @@
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct Section {
pub title: String,
pub commands: Vec<String>,
}
impl Section {
pub fn is_run_all(&self) -> bool {
self.commands.is_empty()
}
}
#[derive(Debug, Clone)]
pub struct CommandFile {
pub name: String,
pub path: PathBuf,
pub sections: Vec<Section>,
}
#[derive(Debug, Clone)]
pub struct Group {
pub name: String,
pub files: Vec<CommandFile>,
}
pub fn load_groups(data_dir: &Path) -> Vec<Group> {
let mut groups = Vec::new();
if !data_dir.exists() {
return groups;
}
let entries = match fs::read_dir(data_dir) {
Ok(e) => e,
Err(_) => return groups,
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
let files = load_files_in_group(&path);
if !files.is_empty() {
groups.push(Group { name, files });
}
}
groups.sort_by_key(|a| a.name.to_lowercase());
groups
}
fn load_files_in_group(group_path: &Path) -> Vec<CommandFile> {
let mut files = Vec::new();
let entries = match fs::read_dir(group_path) {
Ok(e) => e,
Err(_) => return files,
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let name = path
.file_stem()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
let sections = parse_file(&path);
if !sections.is_empty() {
files.push(CommandFile {
name,
path,
sections,
});
}
}
files.sort_by_key(|a| a.name.to_lowercase());
files
}
pub fn parse_file(path: &Path) -> Vec<Section> {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
parse_markdown(&content)
}
fn parse_markdown(content: &str) -> Vec<Section> {
let mut sections = Vec::new();
let mut current_title = String::new();
let mut current_commands = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if let Some(title) = trimmed.strip_prefix("# ") {
if !current_title.is_empty() {
sections.push(Section {
title: current_title,
commands: current_commands,
});
}
current_title = title.trim().to_string();
current_commands = Vec::new();
} else if trimmed.starts_with('#') || trimmed.is_empty() {
// lower-level markdown headings, commented command lines and empty lines are ignored
continue;
} else {
current_commands.push(trimmed.to_string());
}
}
if !current_title.is_empty() {
sections.push(Section {
title: current_title,
commands: current_commands,
});
}
sections
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_markdown_basic() {
let input = "# Sysel DSM\ndsmview ssh root@192.168.40.15\n\n# Johy DSM\ndsmview ssh root@192.168.51.3\n";
let sections = parse_markdown(input);
assert_eq!(sections.len(), 2);
assert_eq!(sections[0].title, "Sysel DSM");
assert_eq!(sections[0].commands, vec!["dsmview ssh root@192.168.40.15"]);
assert_eq!(sections[1].title, "Johy DSM");
assert_eq!(sections[1].commands, vec!["dsmview ssh root@192.168.51.3"]);
}
#[test]
fn test_parse_markdown_run_all() {
let input = "# Spustí vše\n\n# Docker update\nssh root@192.168.53.3 /opt/mealie/update.sh\nssh root@192.168.53.5 /opt/yamtrack/update.sh\n";
let sections = parse_markdown(input);
assert_eq!(sections.len(), 2);
assert_eq!(sections[0].title, "Spustí vše");
assert!(sections[0].commands.is_empty());
assert_eq!(sections[1].commands.len(), 2);
}
#[test]
fn test_parse_markdown_ignores_comments_and_empty() {
let input = "# Section\n\n## Subsection\ncommand1\n\ncommand2\n";
let sections = parse_markdown(input);
assert_eq!(sections.len(), 1);
assert_eq!(sections[0].commands, vec!["command1", "command2"]);
}
}

191
src/ui.rs Normal file
View file

@ -0,0 +1,191 @@
use ratatui::{
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
Frame,
};
use crate::app::{App, Focus};
pub fn draw(f: &mut Frame, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints([
Constraint::Min(8),
Constraint::Percentage(40),
Constraint::Length(1),
])
.split(f.area());
let top_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(25),
Constraint::Percentage(25),
Constraint::Percentage(50),
])
.split(chunks[0]);
draw_groups(f, app, top_chunks[0]);
draw_files(f, app, top_chunks[1]);
draw_sections(f, app, top_chunks[2]);
draw_preview(f, app, chunks[1]);
draw_status(f, app, chunks[2]);
}
fn draw_groups(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let items: Vec<ListItem> = app
.groups
.iter()
.enumerate()
.map(|(i, g)| {
let style = if i == app.group_index {
Style::default()
.bg(Color::Blue)
.fg(Color::Black)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
ListItem::new(g.name.clone()).style(style)
})
.collect();
let block = Block::default()
.title(" Skupiny ")
.borders(Borders::ALL)
.border_style(border_style(app.focus == Focus::Groups));
let list = List::new(items).block(block);
f.render_widget(list, area);
}
fn draw_files(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let items: Vec<ListItem> = app
.current_files()
.iter()
.enumerate()
.map(|(i, file)| {
let style = if i == app.file_index {
Style::default()
.bg(Color::Blue)
.fg(Color::Black)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
ListItem::new(file.name.clone()).style(style)
})
.collect();
let block = Block::default()
.title(" Soubory ")
.borders(Borders::ALL)
.border_style(border_style(app.focus == Focus::Files));
let list = List::new(items).block(block);
f.render_widget(list, area);
}
fn draw_sections(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let items: Vec<ListItem> = app
.current_sections()
.iter()
.enumerate()
.map(|(i, section)| {
let label = if section.is_run_all() {
format!("{} (vše)", section.title)
} else {
section.title.clone()
};
let style = if i == app.section_index {
Style::default()
.bg(Color::Blue)
.fg(Color::Black)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
ListItem::new(label).style(style)
})
.collect();
let block = Block::default()
.title(" Příkazy ")
.borders(Borders::ALL)
.border_style(border_style(app.focus == Focus::Sections));
let list = List::new(items).block(block);
f.render_widget(list, area);
}
fn draw_preview(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let text = match app.current_section() {
Some(section) => {
let title = if section.is_run_all() {
format!("{} — spustí všechny příkazy v souboru", section.title)
} else {
section.title.clone()
};
let mut lines = vec![Line::from(Span::styled(
title,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
))];
if section.is_run_all() {
if let Some(file) = app.current_file() {
for sec in &file.sections {
if sec.is_run_all() {
continue;
}
lines.push(Line::from(Span::styled(
format!("# {}", sec.title),
Style::default().fg(Color::Cyan),
)));
for cmd in &sec.commands {
lines.push(Line::from(cmd.as_str()));
}
lines.push(Line::from(""));
}
}
} else {
for cmd in &section.commands {
lines.push(Line::from(cmd.as_str()));
}
}
Text::from(lines)
}
None => Text::from("Žádný příkaz k zobrazení."),
};
let block = Block::default()
.title(" Náhled ")
.borders(Borders::ALL);
let paragraph = Paragraph::new(text).block(block).wrap(Wrap { trim: true });
f.render_widget(paragraph, area);
}
fn draw_status(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let help = "↑/↓ nebo j/k: pohyb | →/←: panel | Enter: spustit | e: editovat | r: reload | q: konec";
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, area);
}
fn border_style(focused: bool) -> Style {
if focused {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::DarkGray)
}
}

8
tests/load_examples.rs Normal file
View file

@ -0,0 +1,8 @@
use std::path::PathBuf;
#[test]
fn test_example_files_exist() {
let examples = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples");
assert!(examples.join("Synology/dsmview.md").exists());
assert!(examples.join("Scripts/update.md").exists());
}