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
265 lines
7.7 KiB
Rust
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
|
|
}
|