Initial commit
This commit is contained in:
commit
cc2d73154d
14 changed files with 2885 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/target
|
||||
2039
Cargo.lock
generated
Normal file
2039
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
11
Cargo.toml
Normal file
11
Cargo.toml
Normal 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
69
README.md
Normal 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
|
||||
5
examples/Scripts/update.md
Normal file
5
examples/Scripts/update.md
Normal 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
|
||||
5
examples/Synology/dsmview.md
Normal file
5
examples/Synology/dsmview.md
Normal 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
134
src/app.rs
Normal 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
90
src/commands.rs
Normal 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
18
src/config.rs
Normal 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
54
src/events.rs
Normal 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
85
src/main.rs
Normal 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
175
src/parser.rs
Normal 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
191
src/ui.rs
Normal 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 §ion.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
8
tests/load_examples.rs
Normal 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());
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue