Files
empeve/src/main.rs
vikingowl 3afabc723b feat: add comprehensive TUI with vim-style navigation
Implement a modern terminal UI inspired by lazygit/k9s with full
feature parity to the CLI:

Views:
- Dashboard: stats overview, quick actions (Install/Update/Lock/Clean)
- Repos: list/add/remove/install/update repos, script selector popup
- Scripts: browse by target, filter by target, enable/disable/remove
- Catalog: browse and install from curated script catalog
- Targets: manage mpv config targets, create directories

Features:
- Vim-style navigation (j/k, g/G, Ctrl+d/u, /)
- Non-blocking background tasks with spinner animation
- Script selector popup for granular per-repo script management
- Target filtering in Scripts view
- Orphan cleanup prompt after repo removal
- Broken symlink detection and repair in Targets view
- Path expansion for ~ in target configs

Technical:
- Feature-gated module (#[cfg(feature = "tui")])
- mpsc channels for async task communication
- Scripts caching to avoid filesystem I/O on every render
- Terminal 16-color ANSI palette for theme compatibility
2026-01-26 12:47:21 +01:00

265 lines
7.7 KiB
Rust

use clap::Parser;
use colored::Colorize;
use empeve::{cli::Cli, commands, config::Config, error::Result, paths::{detect_mpv_configs, Paths}};
use std::io::{self, Write};
fn main() {
if let Err(error) = run() {
eprintln!("{}: {}", "error".red().bold(), error);
// Show hint if available
if let Some(hint) = error.hint() {
eprintln!("{}: {}", "hint".yellow(), hint);
}
std::process::exit(1);
}
}
fn run() -> Result<()> {
let cli = Cli::parse();
// If no subcommand, launch TUI (or show help if TUI not compiled)
let Some(command) = cli.command else {
#[cfg(feature = "tui")]
{
return empeve::tui::run();
}
#[cfg(not(feature = "tui"))]
{
// Print help and exit
use clap::CommandFactory;
let mut cmd = Cli::command();
cmd.print_help()?;
println!();
return Ok(());
}
};
// Check for first-run scenario before executing commands
check_first_run(&command)?;
match command {
commands::Commands::Add { repo, rev, scripts } => {
commands::add::execute(&repo, rev, scripts)?;
}
commands::Commands::Remove { repo, purge } => {
commands::remove::execute(&repo, purge)?;
}
commands::Commands::Install { force, repos, locked } => {
commands::install::execute(force, repos, cli.target, locked)?;
}
commands::Commands::Update { repos } => {
commands::update::execute(repos)?;
}
commands::Commands::Clean { yes } => {
commands::clean::execute(yes, cli.target)?;
}
commands::Commands::Status { verbose } => {
commands::status::execute(verbose)?;
}
commands::Commands::List { detailed } => {
commands::list::execute(detailed, cli.target)?;
}
commands::Commands::Import { convert_local, script } => {
commands::import::execute(convert_local, script)?;
}
commands::Commands::Browse { category, interactive } => {
commands::browse::execute(category, interactive)?;
}
commands::Commands::Doctor { fix } => {
commands::doctor::execute(fix)?;
}
commands::Commands::Lock => {
commands::lock::execute()?;
}
}
Ok(())
}
/// Check if this is the first run and set up targets
fn check_first_run(command: &commands::Commands) -> Result<()> {
// Only check for certain commands that need config
let should_check = matches!(
*command,
commands::Commands::Status { .. }
| commands::Commands::List { .. }
| commands::Commands::Install { .. }
| commands::Commands::Browse { .. }
| commands::Commands::Doctor { .. }
);
if !should_check {
return Ok(());
}
let paths = Paths::new()?;
// If config already exists, skip first-run check
if paths.config_file.exists() {
return Ok(());
}
// Detect mpv config folders
let detected = detect_mpv_configs();
if detected.is_empty() {
// No mpv configs found - create default config
println!("{}", "Welcome to empeve!".green().bold());
println!();
println!("{}", "No mpv configuration folders detected.".yellow());
println!("Creating default configuration...");
paths.ensure_directories()?;
Config::default().save(&paths.config_file)?;
return Ok(());
}
// Show welcome and detected targets
println!("{}", "Welcome to empeve!".green().bold());
println!();
println!("{}", "Detected mpv configuration folders:".bold());
println!();
for (i, target) in detected.iter().enumerate() {
let script_info = if target.has_scripts {
format!("({} scripts)", target.script_count).cyan().to_string()
} else {
"(empty)".dimmed().to_string()
};
println!(
" {:2}. {} {} {}",
i + 1,
target.name.cyan(),
target.path.display().to_string().dimmed(),
script_info
);
}
println!();
println!("{}", "Select targets to manage (e.g., 1 2 3 or 1-3 or 'all'):".bold());
print!("> ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let input = input.trim();
// Parse selection
let selected_indices = if input.eq_ignore_ascii_case("all") || input.is_empty() {
(0..detected.len()).collect::<Vec<_>>()
} else {
parse_target_selection(input, detected.len())
};
if selected_indices.is_empty() {
println!("{}", "No targets selected. Using all detected targets.".yellow());
}
// Create config with selected targets
let mut config = Config::default();
let indices_to_use = if selected_indices.is_empty() {
(0..detected.len()).collect::<Vec<_>>()
} else {
selected_indices
};
let mut success_count = 0;
let mut failed_targets: Vec<String> = Vec::new();
for i in indices_to_use {
if let Some(target) = detected.get(i) {
let target_config = target.to_target_config();
if let Err(e) = target_config.ensure_directories() {
eprintln!(
" {} {} - could not create directories: {}",
"".red(),
target.name.cyan(),
e.to_string().dimmed()
);
failed_targets.push(target.name.clone());
continue;
}
config.add_target(target_config);
println!(" {} {}", "".green(), target.name.cyan());
success_count += 1;
}
}
// Fail if no targets could be configured
if success_count == 0 {
eprintln!();
eprintln!(
"{} Could not configure any targets. Check directory permissions.",
"Error:".red().bold()
);
return Err(empeve::error::EmpveError::Config(
"no targets could be configured".to_string(),
));
}
// Save config
paths.ensure_directories()?;
config.save(&paths.config_file)?;
println!();
// Show warning if some targets failed
if !failed_targets.is_empty() {
println!(
"{} {} target(s) could not be configured: {}",
"".yellow(),
failed_targets.len(),
failed_targets.join(", ").dimmed()
);
println!();
}
println!(
"{} Configuration saved. Run {} to browse and add scripts.",
"Done!".green().bold(),
"empeve browse -i".cyan()
);
println!();
Ok(())
}
/// Parse target selection input (e.g., "1 2 3", "1-3", "all")
fn parse_target_selection(input: &str, max: usize) -> Vec<usize> {
let mut result = Vec::new();
for part in input.split([',', ' ']) {
let part = part.trim();
if part.is_empty() {
continue;
}
if let Some((start, end)) = part.split_once('-') {
if let (Ok(s), Ok(e)) = (start.trim().parse::<usize>(), end.trim().parse::<usize>()) {
for i in s..=e {
if i >= 1 && i <= max {
let idx = i - 1; // Convert to 0-based
if !result.contains(&idx) {
result.push(idx);
}
}
}
}
} else if let Ok(n) = part.parse::<usize>() {
if n >= 1 && n <= max {
let idx = n - 1; // Convert to 0-based
if !result.contains(&idx) {
result.push(idx);
}
}
}
}
result
}