From 3afabc723bb0013fd59851dcb25a98a99d32b27e Mon Sep 17 00:00:00 2001 From: vikingowl Date: Mon, 26 Jan 2026 12:47:21 +0100 Subject: [PATCH] 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 --- src/cli.rs | 2 +- src/config.rs | 47 +- src/lib.rs | 3 + src/main.rs | 28 +- src/tui/app.rs | 1057 ++++++++++++++++++++++++++++++++++++ src/tui/event.rs | 119 ++++ src/tui/mod.rs | 927 +++++++++++++++++++++++++++++++ src/tui/ops.rs | 873 +++++++++++++++++++++++++++++ src/tui/theme.rs | 85 +++ src/tui/views/catalog.rs | 188 +++++++ src/tui/views/dashboard.rs | 165 ++++++ src/tui/views/mod.rs | 7 + src/tui/views/repos.rs | 259 +++++++++ src/tui/views/scripts.rs | 174 ++++++ src/tui/views/targets.rs | 190 +++++++ src/tui/widgets/help.rs | 177 ++++++ src/tui/widgets/list.rs | 190 +++++++ src/tui/widgets/mod.rs | 9 + src/tui/widgets/popup.rs | 330 +++++++++++ 19 files changed, 4814 insertions(+), 16 deletions(-) create mode 100644 src/tui/app.rs create mode 100644 src/tui/event.rs create mode 100644 src/tui/mod.rs create mode 100644 src/tui/ops.rs create mode 100644 src/tui/theme.rs create mode 100644 src/tui/views/catalog.rs create mode 100644 src/tui/views/dashboard.rs create mode 100644 src/tui/views/mod.rs create mode 100644 src/tui/views/repos.rs create mode 100644 src/tui/views/scripts.rs create mode 100644 src/tui/views/targets.rs create mode 100644 src/tui/widgets/help.rs create mode 100644 src/tui/widgets/list.rs create mode 100644 src/tui/widgets/mod.rs create mode 100644 src/tui/widgets/popup.rs diff --git a/src/cli.rs b/src/cli.rs index 6416eae..0354ea5 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -21,5 +21,5 @@ pub struct Cli { pub verbose: bool, #[command(subcommand)] - pub command: Commands, + pub command: Option, } diff --git a/src/config.rs b/src/config.rs index dd20bfa..c202803 100644 --- a/src/config.rs +++ b/src/config.rs @@ -27,32 +27,59 @@ impl TargetConfig { } } + /// Get the expanded path (handles ~ expansion) + pub fn expanded_path(&self) -> PathBuf { + let path_str = self.path.to_string_lossy(); + if let Some(stripped) = path_str.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(stripped); + } + } else if path_str == "~" { + if let Some(home) = dirs::home_dir() { + return home; + } + } + self.path.clone() + } + /// Get the scripts directory for this target pub fn scripts_dir(&self) -> PathBuf { - self.path.join("scripts") + self.expanded_path().join("scripts") } /// Get the script-opts directory for this target pub fn script_opts_dir(&self) -> PathBuf { - self.path.join("script-opts") + self.expanded_path().join("script-opts") } /// Get the fonts directory for this target pub fn fonts_dir(&self) -> PathBuf { - self.path.join("fonts") + self.expanded_path().join("fonts") } /// Get the shaders directory for this target pub fn shaders_dir(&self) -> PathBuf { - self.path.join("shaders") + self.expanded_path().join("shaders") } /// Ensure all asset directories exist for this target + /// + /// Removes broken symlinks and creates missing directories pub fn ensure_directories(&self) -> std::io::Result<()> { - std::fs::create_dir_all(self.scripts_dir())?; - std::fs::create_dir_all(self.script_opts_dir())?; - std::fs::create_dir_all(self.fonts_dir())?; - std::fs::create_dir_all(self.shaders_dir())?; + for dir in [self.scripts_dir(), self.script_opts_dir(), self.fonts_dir(), self.shaders_dir()] { + // Check if it's a broken symlink (symlink exists but target doesn't) + if let Ok(meta) = dir.symlink_metadata() { + if meta.file_type().is_symlink() && !dir.exists() { + // Broken symlink - remove it + std::fs::remove_file(&dir)?; + } else { + // Working symlink or actual directory - skip + continue; + } + } + // Create the directory + std::fs::create_dir_all(&dir)?; + } Ok(()) } @@ -66,7 +93,7 @@ impl TargetConfig { } /// Main configuration structure -#[derive(Debug, Serialize, Deserialize, Default)] +#[derive(Debug, Serialize, Deserialize, Default, Clone)] pub struct Config { /// General settings #[serde(default)] @@ -82,7 +109,7 @@ pub struct Config { } /// Global settings -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct Settings { /// mpv scripts directory (default: ~/.config/mpv/scripts) pub mpv_scripts_dir: Option, diff --git a/src/lib.rs b/src/lib.rs index e0d91e0..2b7df56 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,3 +8,6 @@ pub mod paths; pub mod repo; pub mod script; pub mod ui; + +#[cfg(feature = "tui")] +pub mod tui; diff --git a/src/main.rs b/src/main.rs index c9829fb..9c741d9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,10 +19,28 @@ fn main() { fn run() -> Result<()> { let cli = Cli::parse(); - // Check for first-run scenario before executing commands - check_first_run(&cli.command)?; + // 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(); + } - match cli.command { + #[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)?; } @@ -63,9 +81,9 @@ fn run() -> Result<()> { /// Check if this is the first run and set up targets fn check_first_run(command: &commands::Commands) -> Result<()> { - // Only check for certain commands + // Only check for certain commands that need config let should_check = matches!( - command, + *command, commands::Commands::Status { .. } | commands::Commands::List { .. } | commands::Commands::Install { .. } diff --git a/src/tui/app.rs b/src/tui/app.rs new file mode 100644 index 0000000..087d17a --- /dev/null +++ b/src/tui/app.rs @@ -0,0 +1,1057 @@ +//! Application state machine for the TUI + +use std::sync::mpsc::{self, Receiver, Sender}; +use std::thread; + +use ratatui::widgets::ListState; + +use crate::catalog::CatalogManager; +use crate::config::Config; +use crate::paths::Paths; + +/// Active view in the TUI +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum View { + #[default] + Dashboard, + Repos, + Scripts, + Catalog, + Targets, +} + +impl View { + pub fn label(&self) -> &'static str { + match self { + View::Dashboard => "Dashboard", + View::Repos => "Repos", + View::Scripts => "Scripts", + View::Catalog => "Catalog", + View::Targets => "Targets", + } + } + + pub fn key(&self) -> char { + match self { + View::Dashboard => 'D', + View::Repos => 'R', + View::Scripts => 'S', + View::Catalog => 'C', + View::Targets => 'T', + } + } + + pub fn all() -> &'static [View] { + &[View::Dashboard, View::Repos, View::Scripts, View::Catalog, View::Targets] + } +} + +/// Input mode +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Mode { + #[default] + Normal, + Filter, + Popup, +} + +/// Popup type for modal dialogs +#[derive(Debug, Clone)] +pub enum Popup { + Help, + Confirm { + title: String, + message: String, + on_confirm: PopupAction, + }, + Input { + title: String, + prompt: String, + value: String, + on_submit: PopupAction, + }, + Message { + title: String, + message: String, + is_error: bool, + }, + ScriptSelector { + repo_id: String, + scripts: Vec, + selected_index: usize, + }, +} + +/// Item in the script selector popup +#[derive(Debug, Clone)] +pub struct ScriptSelectItem { + pub name: String, + pub enabled: bool, +} + +/// Action to perform after popup confirmation +#[derive(Debug, Clone)] +pub enum PopupAction { + InstallRepo(String), + UpdateRepo(String), + RemoveRepo(String), + AddRepo(String), + PinRepo(String, String), // (repo_id, rev) + AddTarget(String, String), // (name, path) + RemoveTarget(String), + Clean, + ImportScript(String), // script name + ToggleScript(String, String), // (repo_id, script_name) + SaveScriptSelection(String, Vec), // (repo_id, enabled_scripts) + RemoveScript(String, String), // (script_name, target_name) + None, +} + +/// Focus panel in split views +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Focus { + #[default] + List, + Details, +} + +/// Background task types +#[derive(Debug, Clone)] +pub enum TaskKind { + InstallRepo(String), + InstallAll, + UpdateRepo(String), + UpdateAll, + RemoveRepo(String, bool), // (repo_id, purge) + Clean, + Lock, +} + +impl TaskKind { + pub fn description(&self) -> String { + match self { + TaskKind::InstallRepo(id) => format!("Installing {}", id), + TaskKind::InstallAll => "Installing all repos".to_string(), + TaskKind::UpdateRepo(id) => format!("Updating {}", id), + TaskKind::UpdateAll => "Updating all repos".to_string(), + TaskKind::RemoveRepo(id, _) => format!("Removing {}", id), + TaskKind::Clean => "Cleaning orphaned items".to_string(), + TaskKind::Lock => "Creating lockfile".to_string(), + } + } +} + +/// Result of a background task +#[derive(Debug)] +pub enum TaskResult { + InstallRepo { + repo_id: String, + scripts: usize, + assets: usize, + errors: Vec, + }, + InstallAll { + scripts: usize, + assets: usize, + errors: Vec, + }, + UpdateRepo { + repo_id: String, + was_updated: bool, + old_commit: Option, + new_commit: Option, + is_pinned: bool, + }, + UpdateAll { + updated: usize, + up_to_date: usize, + errors: Vec, + }, + RemoveRepo { + repo_id: String, + scripts_removed: usize, + repo_deleted: bool, + }, + Clean { + scripts_removed: usize, + repos_removed: usize, + errors: Vec, + }, + Lock { + locked_count: usize, + skipped_count: usize, + errors: Vec, + }, + Error { + task: String, + message: String, + }, + ConfigChanged, +} + +/// Cached script info for Scripts view +#[derive(Clone)] +pub struct CachedScript { + pub name: String, + pub repo: String, + pub target: String, +} + +/// Main application state +pub struct App { + pub view: View, + pub mode: Mode, + pub focus: Focus, + pub config: Config, + pub paths: Paths, + pub catalog: CatalogManager, + pub list_state: ListState, + pub filter: String, + pub popup: Option, + pub should_quit: bool, + pub status_message: Option<(String, bool)>, // (message, is_error) + pub running_task: Option, // Description of running task + task_sender: Sender, + task_receiver: Receiver, + spinner_frame: usize, + // Cached scripts list (refreshed on view switch) + cached_scripts: Vec, + scripts_cache_valid: bool, + // Target filter for Scripts view (None = all targets) + pub target_filter: Option, +} + +const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +impl App { + pub fn new(config: Config, paths: Paths) -> Self { + let catalog = CatalogManager::load(Some(&paths.catalogs_dir)) + .unwrap_or_else(|_| CatalogManager::bundled()); + + let mut list_state = ListState::default(); + if !config.repos.is_empty() { + list_state.select(Some(0)); + } + + let (task_sender, task_receiver) = mpsc::channel(); + + Self { + view: View::default(), + mode: Mode::default(), + focus: Focus::default(), + config, + paths, + catalog, + list_state, + filter: String::new(), + popup: None, + should_quit: false, + status_message: None, + running_task: None, + task_sender, + task_receiver, + spinner_frame: 0, + cached_scripts: Vec::new(), + scripts_cache_valid: false, + target_filter: None, + } + } + + /// Check for completed background tasks + pub fn poll_tasks(&mut self) { + // Advance spinner + self.spinner_frame = (self.spinner_frame + 1) % SPINNER_FRAMES.len(); + + // Check for task results (non-blocking) + while let Ok(result) = self.task_receiver.try_recv() { + self.handle_task_result(result); + } + } + + /// Handle a completed task result + fn handle_task_result(&mut self, result: TaskResult) { + self.running_task = None; + + match result { + TaskResult::InstallRepo { repo_id, scripts, assets, errors } => { + if errors.is_empty() { + self.set_status( + format!("✓ Installed {} ({} scripts, {} assets)", repo_id, scripts, assets), + false, + ); + } else { + self.set_status( + format!("⚠ Installed {} with {} errors", repo_id, errors.len()), + true, + ); + } + // Invalidate scripts cache since new scripts were installed + self.invalidate_scripts_cache(); + } + TaskResult::InstallAll { scripts, assets, errors } => { + if errors.is_empty() { + self.set_status( + format!("✓ Installed {} scripts, {} assets", scripts, assets), + false, + ); + } else { + self.set_status( + format!("⚠ Installed with {} errors", errors.len()), + true, + ); + } + // Reload config to reflect changes + let _ = self.reload_config(); + // Invalidate scripts cache since new scripts were installed + self.invalidate_scripts_cache(); + } + TaskResult::UpdateRepo { repo_id, was_updated, old_commit, new_commit, is_pinned } => { + if is_pinned { + self.set_status(format!("📌 {} is pinned", repo_id), false); + } else if was_updated { + let old = old_commit.as_deref().unwrap_or("?"); + let new = new_commit.as_deref().unwrap_or("?"); + self.set_status( + format!("✓ Updated {} ({} → {})", repo_id, &old[..7.min(old.len())], &new[..7.min(new.len())]), + false, + ); + } else { + self.set_status(format!("✓ {} already up to date", repo_id), false); + } + } + TaskResult::UpdateAll { updated, up_to_date, errors } => { + if errors.is_empty() { + self.set_status( + format!("✓ {} updated, {} up to date", updated, up_to_date), + false, + ); + } else { + self.set_status( + format!("⚠ {} updated, {} errors", updated, errors.len()), + true, + ); + } + } + TaskResult::RemoveRepo { repo_id, scripts_removed, repo_deleted } => { + let mut msg = format!("✓ Removed {}", repo_id); + if scripts_removed > 0 { + msg.push_str(&format!(", {} scripts uninstalled", scripts_removed)); + } + if repo_deleted { + msg.push_str(", repo deleted"); + } + self.set_status(msg, false); + // Reload config to reflect the removal + let _ = self.reload_config(); + self.reset_list_selection(); + // Invalidate scripts cache since scripts may have been removed + self.invalidate_scripts_cache(); + + // Check for orphaned scripts and prompt to clean + if let Ok(scan) = super::ops::scan_orphaned(&self.config, &self.paths) { + let orphan_count = scan.orphaned_scripts.len(); + if orphan_count > 0 { + self.show_confirm( + "Clean Orphaned Scripts", + format!("Found {} orphaned scripts. Remove them?", orphan_count), + PopupAction::Clean, + ); + } + } + } + TaskResult::Clean { scripts_removed, repos_removed, errors } => { + if errors.is_empty() { + self.set_status( + format!("✓ Cleaned {} scripts, {} repos", scripts_removed, repos_removed), + false, + ); + } else { + self.set_status( + format!("⚠ Cleaned with {} errors", errors.len()), + true, + ); + } + // Invalidate scripts cache since scripts were removed + self.invalidate_scripts_cache(); + } + TaskResult::Lock { locked_count, skipped_count, errors } => { + if errors.is_empty() { + self.set_status( + format!("✓ Locked {} repos ({} skipped)", locked_count, skipped_count), + false, + ); + } else { + self.set_status( + format!("⚠ Locked {} repos, {} errors", locked_count, errors.len()), + true, + ); + } + } + TaskResult::Error { task, message } => { + self.set_status(format!("✗ {} failed: {}", task, message), true); + } + TaskResult::ConfigChanged => { + let _ = self.reload_config(); + } + } + } + + /// Spawn a background task + pub fn spawn_task(&mut self, kind: TaskKind) { + if self.running_task.is_some() { + self.set_status("A task is already running", true); + return; + } + + let description = kind.description(); + self.running_task = Some(description.clone()); + self.set_status(format!("{} {}...", SPINNER_FRAMES[0], description), false); + + let sender = self.task_sender.clone(); + let config = self.config.clone(); + let paths = self.paths.clone(); + + thread::spawn(move || { + let result = execute_task(kind, &config, &paths); + let _ = sender.send(result); + }); + } + + /// Check if a task is currently running + pub fn is_busy(&self) -> bool { + self.running_task.is_some() + } + + /// Get spinner text for current frame + pub fn spinner(&self) -> &'static str { + SPINNER_FRAMES[self.spinner_frame] + } + + /// Reload config from disk + pub fn reload_config(&mut self) -> crate::error::Result<()> { + self.config = Config::load(&self.paths.config_file)?; + self.catalog = CatalogManager::load(Some(&self.paths.catalogs_dir)) + .unwrap_or_else(|_| CatalogManager::bundled()); + self.reset_list_selection(); + Ok(()) + } + + /// Save current config to disk + pub fn save_config(&mut self) -> crate::error::Result<()> { + self.config.save(&self.paths.config_file) + } + + /// Set status message + pub fn set_status(&mut self, message: impl Into, is_error: bool) { + self.status_message = Some((message.into(), is_error)); + } + + /// Clear status message + pub fn clear_status(&mut self) { + self.status_message = None; + } + + /// Switch to a different view + pub fn switch_view(&mut self, view: View) { + self.view = view; + self.focus = Focus::List; + self.filter.clear(); + self.mode = Mode::Normal; + + // Refresh scripts cache when switching to Scripts view + if view == View::Scripts { + self.refresh_scripts_cache(); + } + + self.reset_list_selection(); + } + + /// Reset list selection to first item + pub fn reset_list_selection(&mut self) { + let count = self.current_list_len(); + if count > 0 { + self.list_state.select(Some(0)); + } else { + self.list_state.select(None); + } + } + + /// Get number of items in current list + pub fn current_list_len(&self) -> usize { + match self.view { + View::Dashboard => 0, + View::Repos => self.filtered_repos().count(), + View::Scripts => self.script_count(), + View::Catalog => self.filtered_catalog().len(), + View::Targets => self.config.targets.len(), + } + } + + /// Get filtered repos based on current filter + pub fn filtered_repos(&self) -> impl Iterator { + let filter = self.filter.to_lowercase(); + self.config.repos.iter().filter(move |r| { + filter.is_empty() || r.repo.to_lowercase().contains(&filter) + }) + } + + /// Get filtered catalog entries + pub fn filtered_catalog(&self) -> Vec<&crate::catalog::CatalogEntry> { + let filter = self.filter.to_lowercase(); + self.catalog + .entries() + .iter() + .filter(|e| { + filter.is_empty() + || e.name.to_lowercase().contains(&filter) + || e.repo.to_lowercase().contains(&filter) + || e.description.to_lowercase().contains(&filter) + }) + .collect() + } + + /// Count total scripts (uses cache) + fn script_count(&self) -> usize { + if self.scripts_cache_valid { + // Apply filter to cached scripts + let filter = self.filter.to_lowercase(); + self.cached_scripts.iter() + .filter(|s| { + filter.is_empty() + || s.name.to_lowercase().contains(&filter) + || s.repo.to_lowercase().contains(&filter) + || s.target.to_lowercase().contains(&filter) + }) + .count() + } else { + 0 + } + } + + /// Refresh the scripts cache (call when switching to Scripts view) + pub fn refresh_scripts_cache(&mut self) { + self.cached_scripts.clear(); + + for target in self.config.enabled_targets() { + let scripts_dir = target.scripts_dir(); + if !scripts_dir.exists() { + continue; + } + + if let Ok(entries) = std::fs::read_dir(&scripts_dir) { + for entry in entries.filter_map(|e| e.ok()) { + let name = entry.file_name().to_string_lossy().to_string(); + + // Skip hidden files + if name.starts_with('.') { + continue; + } + + // Check if it's a script + let is_script = name.ends_with(".lua") + || name.ends_with(".js") + || entry.path().is_dir(); + + if !is_script { + continue; + } + + // Find repo by checking symlink target or config + let repo = self.find_script_repo_fast(&name, &entry.path()); + + self.cached_scripts.push(CachedScript { + name, + repo, + target: target.name.clone(), + }); + } + } + } + + // Sort by name + self.cached_scripts.sort_by(|a, b| a.name.cmp(&b.name)); + self.scripts_cache_valid = true; + } + + /// Fast repo lookup - check symlink target or config, no walkdir + fn find_script_repo_fast(&self, script_name: &str, script_path: &std::path::Path) -> String { + // First, check if any repo has this script in its filter list + for repo in &self.config.repos { + if let Some(scripts) = &repo.scripts { + if scripts.iter().any(|s| s == script_name || s == script_name.trim_end_matches(".lua") || s == script_name.trim_end_matches(".js")) { + return repo.repo.clone(); + } + } + } + + // Check if it's a symlink pointing to our repos dir + if script_path.is_symlink() { + if let Ok(target) = std::fs::read_link(script_path) { + if target.starts_with(&self.paths.repos_dir) { + // Extract repo name from path: repos_dir/user/repo/... + let relative = target.strip_prefix(&self.paths.repos_dir).ok(); + if let Some(rel) = relative { + let parts: Vec<_> = rel.components().take(2).collect(); + if parts.len() >= 2 { + return format!("{}/{}", + parts[0].as_os_str().to_string_lossy(), + parts[1].as_os_str().to_string_lossy()); + } + } + } + } + } + + "unknown".to_string() + } + + /// Get cached scripts (filtered by search and target) + pub fn filtered_scripts(&self) -> Vec<&CachedScript> { + let filter = self.filter.to_lowercase(); + self.cached_scripts.iter() + .filter(|s| { + // Target filter + if let Some(ref target_filter) = self.target_filter { + if &s.target != target_filter { + return false; + } + } + // Text filter + filter.is_empty() + || s.name.to_lowercase().contains(&filter) + || s.repo.to_lowercase().contains(&filter) + || s.target.to_lowercase().contains(&filter) + }) + .collect() + } + + /// Cycle through target filter options + pub fn cycle_target_filter(&mut self) { + // Get unique targets + let targets: Vec = self.config.targets.iter() + .map(|t| t.name.clone()) + .collect(); + + if targets.is_empty() { + return; + } + + self.target_filter = match &self.target_filter { + None => { + // Start with first target + Some(targets[0].clone()) + } + Some(current) => { + // Find current and move to next, or back to None + if let Some(pos) = targets.iter().position(|t| t == current) { + if pos + 1 < targets.len() { + Some(targets[pos + 1].clone()) + } else { + None // Wrap back to "all" + } + } else { + None + } + } + }; + + let filter_name = self.target_filter.as_deref().unwrap_or("all"); + self.set_status(format!("Target filter: {}", filter_name), false); + self.reset_list_selection(); + } + + /// Get current target filter display name + pub fn target_filter_display(&self) -> &str { + self.target_filter.as_deref().unwrap_or("all") + } + + /// Invalidate scripts cache + pub fn invalidate_scripts_cache(&mut self) { + self.scripts_cache_valid = false; + } + + /// Move selection up + pub fn select_prev(&mut self) { + let count = self.current_list_len(); + if count == 0 { + return; + } + + let i = match self.list_state.selected() { + Some(i) => { + if i == 0 { + count - 1 + } else { + i - 1 + } + } + None => 0, + }; + self.list_state.select(Some(i)); + } + + /// Move selection down + pub fn select_next(&mut self) { + let count = self.current_list_len(); + if count == 0 { + return; + } + + let i = match self.list_state.selected() { + Some(i) => { + if i >= count - 1 { + 0 + } else { + i + 1 + } + } + None => 0, + }; + self.list_state.select(Some(i)); + } + + /// Move selection to top + pub fn select_first(&mut self) { + if self.current_list_len() > 0 { + self.list_state.select(Some(0)); + } + } + + /// Move selection to bottom + pub fn select_last(&mut self) { + let count = self.current_list_len(); + if count > 0 { + self.list_state.select(Some(count - 1)); + } + } + + /// Page down (move 10 items) + pub fn page_down(&mut self) { + let count = self.current_list_len(); + if count == 0 { + return; + } + + let i = match self.list_state.selected() { + Some(i) => (i + 10).min(count - 1), + None => 0, + }; + self.list_state.select(Some(i)); + } + + /// Page up (move 10 items) + pub fn page_up(&mut self) { + if self.current_list_len() == 0 { + return; + } + + let i = match self.list_state.selected() { + Some(i) => i.saturating_sub(10), + None => 0, + }; + self.list_state.select(Some(i)); + } + + /// Toggle focus between list and details panel + pub fn toggle_focus(&mut self) { + self.focus = match self.focus { + Focus::List => Focus::Details, + Focus::Details => Focus::List, + }; + } + + /// Enter filter mode + pub fn enter_filter_mode(&mut self) { + self.mode = Mode::Filter; + self.filter.clear(); + } + + /// Exit filter mode + pub fn exit_filter_mode(&mut self) { + self.mode = Mode::Normal; + } + + /// Show help popup + pub fn show_help(&mut self) { + self.popup = Some(Popup::Help); + self.mode = Mode::Popup; + } + + /// Show confirmation popup + pub fn show_confirm(&mut self, title: impl Into, message: impl Into, action: PopupAction) { + self.popup = Some(Popup::Confirm { + title: title.into(), + message: message.into(), + on_confirm: action, + }); + self.mode = Mode::Popup; + } + + /// Show input popup + pub fn show_input(&mut self, title: impl Into, prompt: impl Into, action: PopupAction) { + self.popup = Some(Popup::Input { + title: title.into(), + prompt: prompt.into(), + value: String::new(), + on_submit: action, + }); + self.mode = Mode::Popup; + } + + /// Show message popup + pub fn show_message(&mut self, title: impl Into, message: impl Into, is_error: bool) { + self.popup = Some(Popup::Message { + title: title.into(), + message: message.into(), + is_error, + }); + self.mode = Mode::Popup; + } + + /// Show script selector popup for a repo + pub fn show_script_selector(&mut self, repo_id: &str) { + use crate::repo::ScriptDiscovery; + + let repo_path = self.paths.repo_path(repo_id); + if !repo_path.exists() { + self.set_status("Repo not installed - install first to see scripts", true); + return; + } + + // Discover available scripts + let discovered = ScriptDiscovery::discover(&repo_path); + if discovered.is_empty() { + self.set_status("No scripts found in this repository", true); + return; + } + + // Get currently enabled scripts from config + let enabled_scripts: Vec = self.config + .find_repo(repo_id) + .and_then(|r| r.scripts.clone()) + .unwrap_or_default(); + + // Build script items - if no filter is set, all scripts are enabled + let no_filter = enabled_scripts.is_empty(); + let scripts: Vec = discovered + .iter() + .map(|s| ScriptSelectItem { + name: s.name.clone(), + enabled: no_filter || enabled_scripts.iter().any(|e| e == &s.name), + }) + .collect(); + + self.popup = Some(Popup::ScriptSelector { + repo_id: repo_id.to_string(), + scripts, + selected_index: 0, + }); + self.mode = Mode::Popup; + } + + /// Toggle script in script selector + pub fn toggle_script_in_selector(&mut self) { + if let Some(Popup::ScriptSelector { scripts, selected_index, .. }) = &mut self.popup { + if let Some(script) = scripts.get_mut(*selected_index) { + script.enabled = !script.enabled; + } + } + } + + /// Move selection in script selector + pub fn script_selector_prev(&mut self) { + if let Some(Popup::ScriptSelector { scripts, selected_index, .. }) = &mut self.popup { + if !scripts.is_empty() { + *selected_index = if *selected_index == 0 { + scripts.len() - 1 + } else { + *selected_index - 1 + }; + } + } + } + + /// Move selection in script selector + pub fn script_selector_next(&mut self) { + if let Some(Popup::ScriptSelector { scripts, selected_index, .. }) = &mut self.popup { + if !scripts.is_empty() { + *selected_index = (*selected_index + 1) % scripts.len(); + } + } + } + + /// Apply script selection (save to config) + pub fn apply_script_selection(&mut self) -> crate::error::Result<()> { + if let Some(Popup::ScriptSelector { repo_id, scripts, .. }) = &self.popup { + let enabled: Vec = scripts + .iter() + .filter(|s| s.enabled) + .map(|s| s.name.clone()) + .collect(); + + let all_enabled = scripts.iter().all(|s| s.enabled); + let repo_id = repo_id.clone(); + + // Find and update repo entry + if let Some(repo) = self.config.repos.iter_mut().find(|r| r.repo == repo_id) { + // If all scripts enabled, remove the filter (None = all) + repo.scripts = if all_enabled { + None + } else { + Some(enabled) + }; + } + + // Save config + self.save_config()?; + self.invalidate_scripts_cache(); + self.set_status(format!("Script selection saved for {}", repo_id), false); + } + self.close_popup(); + Ok(()) + } + + /// Close popup + pub fn close_popup(&mut self) { + self.popup = None; + self.mode = Mode::Normal; + } + + /// Get currently selected repo (if in Repos view) + pub fn selected_repo(&self) -> Option<&crate::config::RepoEntry> { + if self.view != View::Repos { + return None; + } + let idx = self.list_state.selected()?; + self.filtered_repos().nth(idx) + } + + /// Get currently selected catalog entry (if in Catalog view) + pub fn selected_catalog_entry(&self) -> Option<&crate::catalog::CatalogEntry> { + if self.view != View::Catalog { + return None; + } + let idx = self.list_state.selected()?; + self.filtered_catalog().get(idx).copied() + } +} + +/// Execute a task synchronously (called from background thread) +fn execute_task(kind: TaskKind, config: &Config, paths: &Paths) -> TaskResult { + use super::ops; + + match kind { + TaskKind::InstallRepo(repo_id) => { + match ops::install_repo(&repo_id, config, paths) { + Ok(result) => TaskResult::InstallRepo { + repo_id, + scripts: result.scripts_installed, + assets: result.assets_installed, + errors: result.errors, + }, + Err(e) => TaskResult::Error { + task: format!("Install {}", repo_id), + message: e.to_string(), + }, + } + } + TaskKind::InstallAll => { + match ops::install_all(config, paths) { + Ok((scripts, assets, errors)) => TaskResult::InstallAll { + scripts, + assets, + errors, + }, + Err(e) => TaskResult::Error { + task: "Install all".to_string(), + message: e.to_string(), + }, + } + } + TaskKind::UpdateRepo(repo_id) => { + match ops::update_repo(&repo_id, config, paths) { + Ok(result) => TaskResult::UpdateRepo { + repo_id, + was_updated: result.was_updated, + old_commit: result.old_commit, + new_commit: result.new_commit, + is_pinned: result.is_pinned, + }, + Err(e) => TaskResult::Error { + task: format!("Update {}", repo_id), + message: e.to_string(), + }, + } + } + TaskKind::UpdateAll => { + match ops::update_all(config, paths) { + Ok((updated, up_to_date, errors)) => TaskResult::UpdateAll { + updated, + up_to_date, + errors, + }, + Err(e) => TaskResult::Error { + task: "Update all".to_string(), + message: e.to_string(), + }, + } + } + TaskKind::RemoveRepo(repo_id, purge) => { + // For remove, we need mutable config, so we handle it specially + // Load fresh config, modify, save + let mut config = match Config::load(&paths.config_file) { + Ok(c) => c, + Err(e) => return TaskResult::Error { + task: format!("Remove {}", repo_id), + message: e.to_string(), + }, + }; + + match ops::remove_repo(&repo_id, &mut config, paths, purge) { + Ok((scripts_removed, repo_deleted)) => { + if let Err(e) = config.save(&paths.config_file) { + return TaskResult::Error { + task: format!("Remove {}", repo_id), + message: format!("Failed to save config: {}", e), + }; + } + TaskResult::RemoveRepo { + repo_id, + scripts_removed, + repo_deleted, + } + } + Err(e) => TaskResult::Error { + task: format!("Remove {}", repo_id), + message: e.to_string(), + }, + } + } + TaskKind::Clean => { + match ops::clean(config, paths) { + Ok(result) => TaskResult::Clean { + scripts_removed: result.scripts_removed, + repos_removed: result.repos_removed, + errors: result.errors, + }, + Err(e) => TaskResult::Error { + task: "Clean".to_string(), + message: e.to_string(), + }, + } + } + TaskKind::Lock => { + match ops::lock(config, paths) { + Ok(result) => TaskResult::Lock { + locked_count: result.locked_count, + skipped_count: result.skipped_count, + errors: result.errors, + }, + Err(e) => TaskResult::Error { + task: "Lock".to_string(), + message: e.to_string(), + }, + } + } + } +} diff --git a/src/tui/event.rs b/src/tui/event.rs new file mode 100644 index 0000000..5d3e76b --- /dev/null +++ b/src/tui/event.rs @@ -0,0 +1,119 @@ +//! Event handling for the TUI + +use std::time::Duration; + +use crossterm::event::{self, Event as CrosstermEvent, KeyCode, KeyEvent, KeyModifiers}; + +/// Application events +#[derive(Debug)] +pub enum Event { + /// Key press event + Key(KeyEvent), + /// Terminal tick (for periodic updates) + Tick, + /// Terminal resize + Resize(u16, u16), +} + +/// Event handler configuration +pub struct EventHandler { + tick_rate: Duration, +} + +impl EventHandler { + pub fn new(tick_rate_ms: u64) -> Self { + Self { + tick_rate: Duration::from_millis(tick_rate_ms), + } + } + + /// Poll for the next event + pub fn next(&self) -> std::io::Result { + if event::poll(self.tick_rate)? { + match event::read()? { + CrosstermEvent::Key(key) => Ok(Event::Key(key)), + CrosstermEvent::Resize(w, h) => Ok(Event::Resize(w, h)), + _ => Ok(Event::Tick), + } + } else { + Ok(Event::Tick) + } + } +} + +impl Default for EventHandler { + fn default() -> Self { + Self::new(250) // 250ms tick rate + } +} + +/// Key input utilities +pub trait KeyEventExt { + fn is_quit(&self) -> bool; + fn is_char(&self, c: char) -> bool; + fn char(&self) -> Option; + fn is_ctrl(&self, c: char) -> bool; +} + +impl KeyEventExt for KeyEvent { + fn is_quit(&self) -> bool { + matches!( + self, + KeyEvent { code: KeyCode::Char('q'), modifiers: KeyModifiers::NONE, .. } + | KeyEvent { code: KeyCode::Char('c'), modifiers: KeyModifiers::CONTROL, .. } + ) + } + + fn is_char(&self, c: char) -> bool { + matches!(self, KeyEvent { code: KeyCode::Char(ch), modifiers: KeyModifiers::NONE, .. } if *ch == c) + || matches!(self, KeyEvent { code: KeyCode::Char(ch), modifiers: KeyModifiers::SHIFT, .. } if *ch == c) + } + + fn char(&self) -> Option { + match self { + KeyEvent { code: KeyCode::Char(c), .. } => Some(*c), + _ => None, + } + } + + fn is_ctrl(&self, c: char) -> bool { + matches!(self, KeyEvent { code: KeyCode::Char(ch), modifiers: KeyModifiers::CONTROL, .. } if *ch == c) + } +} + +/// Check if key matches navigation keys +pub fn is_up(key: &KeyEvent) -> bool { + matches!(key.code, KeyCode::Up | KeyCode::Char('k')) +} + +pub fn is_down(key: &KeyEvent) -> bool { + matches!(key.code, KeyCode::Down | KeyCode::Char('j')) +} + +pub fn is_page_up(key: &KeyEvent) -> bool { + key.code == KeyCode::PageUp || key.is_ctrl('u') +} + +pub fn is_page_down(key: &KeyEvent) -> bool { + key.code == KeyCode::PageDown || key.is_ctrl('d') +} + +pub fn is_home(key: &KeyEvent) -> bool { + key.code == KeyCode::Home || key.is_char('g') +} + +pub fn is_end(key: &KeyEvent) -> bool { + key.code == KeyCode::End || key.is_char('G') +} + +pub fn is_select(key: &KeyEvent) -> bool { + matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) +} + +pub fn is_back(key: &KeyEvent) -> bool { + key.code == KeyCode::Esc +} + +pub fn is_tab(key: &KeyEvent) -> bool { + key.code == KeyCode::Tab +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs new file mode 100644 index 0000000..0de6dfb --- /dev/null +++ b/src/tui/mod.rs @@ -0,0 +1,927 @@ +//! Terminal User Interface for empeve +//! +//! A modern, vim-style TUI inspired by lazygit, k9s, and gitui. + +pub mod app; +pub mod event; +pub mod ops; +pub mod theme; +pub mod views; +pub mod widgets; + +use std::io; +use std::panic; + +use crossterm::{ + event::KeyCode, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{ + backend::CrosstermBackend, + layout::{Constraint, Direction, Layout, Rect}, + text::{Line, Span}, + widgets::Paragraph, + Frame, Terminal, +}; + +use crate::config::{Config, RepoEntry}; +use crate::error::Result; +use crate::paths::Paths; + +use app::{App, Mode, Popup, PopupAction, TaskKind, View}; +use event::{is_back, is_down, is_end, is_home, is_page_down, is_page_up, is_select, is_tab, is_up, Event, EventHandler, KeyEventExt}; +use widgets::{render_confirm, render_help, render_input, render_message, render_script_selector}; + +/// Run the TUI application +pub fn run() -> Result<()> { + // Setup terminal + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + // Setup panic hook to restore terminal + let original_hook = panic::take_hook(); + panic::set_hook(Box::new(move |panic_info| { + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), LeaveAlternateScreen); + original_hook(panic_info); + })); + + // Initialize app state + let paths = Paths::new()?; + let config = Config::load(&paths.config_file)?; + let mut app = App::new(config, paths); + + // Event handler with faster tick rate for spinner animation + let events = EventHandler::new(100); // 100ms tick rate + + // Main loop + loop { + terminal.draw(|f| render(f, &mut app))?; + + match events.next()? { + Event::Key(key) => { + handle_input(&mut app, key); + } + Event::Resize(_, _) => { + // Terminal will redraw automatically + } + Event::Tick => { + // Poll for completed background tasks + app.poll_tasks(); + + // Update spinner in status if task is running + if let Some(ref task_desc) = app.running_task { + app.set_status(format!("{} {}...", app.spinner(), task_desc), false); + } + } + } + + if app.should_quit { + break; + } + } + + // Restore terminal + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + + Ok(()) +} + +/// Render the full UI +fn render(f: &mut Frame, app: &mut App) { + let size = f.area(); + + // Main layout: header, content, footer + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // Header + Constraint::Min(0), // Content + Constraint::Length(1), // Footer + ]) + .split(size); + + render_header(f, chunks[0], app); + render_content(f, chunks[1], app); + render_footer(f, chunks[2], app); + + // Render popup if any + if let Some(popup) = &app.popup { + render_popup(f, size, popup, app.view); + } +} + +/// Render the header bar +fn render_header(f: &mut Frame, area: Rect, app: &App) { + let mut spans = vec![ + Span::styled(" empeve ", theme::title()), + Span::styled("│ ", theme::border()), + ]; + + // View tabs - format as [D]ashboard [R]epos etc. + for view in View::all() { + let is_active = *view == app.view; + let label = view.label(); + // Skip the first character of the label (it's the key) + let label_rest = &label[1..]; + + if is_active { + spans.push(Span::styled("[", theme::highlight())); + spans.push(Span::styled(view.key().to_string(), theme::highlight())); + spans.push(Span::styled("]", theme::highlight())); + spans.push(Span::styled(format!("{} ", label_rest), theme::highlight())); + } else { + spans.push(Span::styled("[", theme::text_secondary())); + spans.push(Span::styled(view.key().to_string(), theme::keybind())); + spans.push(Span::styled("]", theme::text_secondary())); + spans.push(Span::styled(format!("{} ", label_rest), theme::text_secondary())); + } + } + + let header = Paragraph::new(Line::from(spans)) + .style(ratatui::style::Style::default().bg(theme::SURFACE0)); + + f.render_widget(header, area); +} + +/// Render the main content area +fn render_content(f: &mut Frame, area: Rect, app: &mut App) { + match app.view { + View::Dashboard => views::dashboard::render(f, area, app), + View::Repos => views::repos::render(f, area, app), + View::Scripts => views::scripts::render(f, area, app), + View::Catalog => views::catalog::render(f, area, app), + View::Targets => views::targets::render(f, area, app), + } +} + +/// Render the footer bar with keybindings hint +fn render_footer(f: &mut Frame, area: Rect, app: &App) { + let mode_hint = match app.mode { + Mode::Normal => "", + Mode::Filter => "FILTER: ", + Mode::Popup => "", + }; + + // Status message with spinner support + let status = if let Some((msg, is_error)) = &app.status_message { + if *is_error { + Span::styled(msg.as_str(), theme::error()) + } else if app.is_busy() { + Span::styled(msg.as_str(), theme::accent()) + } else { + Span::styled(msg.as_str(), theme::success()) + } + } else { + Span::styled("Ready", theme::text_muted()) + }; + + let hints = Line::from(vec![ + Span::styled(" ", theme::text()), + Span::styled(mode_hint, theme::warning()), + Span::styled("j/k", theme::keybind()), + Span::styled(":move ", theme::text_muted()), + Span::styled("/", theme::keybind()), + Span::styled(":filter ", theme::text_muted()), + Span::styled("?", theme::keybind()), + Span::styled(":help ", theme::text_muted()), + Span::styled("q", theme::keybind()), + Span::styled(":quit ", theme::text_muted()), + Span::styled("│ ", theme::border()), + status, + ]); + + let footer = Paragraph::new(hints) + .style(ratatui::style::Style::default().bg(theme::SURFACE0)); + + f.render_widget(footer, area); +} + +/// Render popup overlay +fn render_popup(f: &mut Frame, area: Rect, popup: &Popup, view: View) { + match popup { + Popup::Help => render_help(f, area, view), + Popup::Confirm { title, message, .. } => render_confirm(f, area, title, message), + Popup::Input { title, prompt, value, .. } => render_input(f, area, title, prompt, value), + Popup::Message { title, message, is_error } => render_message(f, area, title, message, *is_error), + Popup::ScriptSelector { repo_id, scripts, selected_index } => { + render_script_selector(f, area, repo_id, scripts, *selected_index); + } + } +} + +/// Handle keyboard input +fn handle_input(app: &mut App, key: crossterm::event::KeyEvent) { + // Handle popup mode first + if app.mode == Mode::Popup { + handle_popup_input(app, key); + return; + } + + // Handle filter mode + if app.mode == Mode::Filter { + handle_filter_input(app, key); + return; + } + + // Normal mode - check for view switching (uppercase only) + if let Some(c) = key.char() { + // Only switch views on uppercase letters + if c.is_ascii_uppercase() { + match c { + 'D' => { app.switch_view(View::Dashboard); return; } + 'R' => { app.switch_view(View::Repos); return; } + 'S' => { app.switch_view(View::Scripts); return; } + 'C' => { app.switch_view(View::Catalog); return; } + 'T' => { app.switch_view(View::Targets); return; } + _ => {} + } + } + } + + // Global keybindings + if key.is_quit() { + app.should_quit = true; + return; + } + + if key.is_char('?') { + app.show_help(); + return; + } + + if key.is_char('/') { + app.enter_filter_mode(); + return; + } + + if is_tab(&key) { + app.toggle_focus(); + return; + } + + // Navigation + if is_up(&key) { + app.select_prev(); + return; + } + + if is_down(&key) { + app.select_next(); + return; + } + + if is_home(&key) { + app.select_first(); + return; + } + + if is_end(&key) { + app.select_last(); + return; + } + + if is_page_up(&key) { + app.page_up(); + return; + } + + if is_page_down(&key) { + app.page_down(); + return; + } + + // View-specific keybindings + match app.view { + View::Dashboard => handle_dashboard_input(app, key), + View::Repos => handle_repos_input(app, key), + View::Scripts => handle_scripts_input(app, key), + View::Catalog => handle_catalog_input(app, key), + View::Targets => handle_targets_input(app, key), + } +} + +/// Handle popup input +fn handle_popup_input(app: &mut App, key: crossterm::event::KeyEvent) { + match &app.popup { + Some(Popup::Help) => { + // Any key closes help + if is_back(&key) || key.is_char('?') || is_select(&key) { + app.close_popup(); + } + } + Some(Popup::Confirm { on_confirm, .. }) => { + if key.is_char('y') || key.is_char('Y') { + let action = on_confirm.clone(); + app.close_popup(); + execute_popup_action(app, action); + } else if is_back(&key) || key.is_char('n') || key.is_char('N') { + app.close_popup(); + } + } + Some(Popup::Input { value, on_submit, .. }) => { + match key.code { + KeyCode::Enter => { + let action = on_submit.clone(); + let input_value = value.clone(); + app.close_popup(); + execute_input_action(app, action, input_value); + } + KeyCode::Esc => { + app.close_popup(); + } + KeyCode::Backspace => { + if let Some(Popup::Input { value, .. }) = &mut app.popup { + value.pop(); + } + } + KeyCode::Char(c) => { + if let Some(Popup::Input { value, .. }) = &mut app.popup { + value.push(c); + } + } + _ => {} + } + } + Some(Popup::Message { .. }) => { + if is_back(&key) || is_select(&key) { + app.close_popup(); + } + } + Some(Popup::ScriptSelector { scripts: _, .. }) => { + match key.code { + KeyCode::Esc => { + app.close_popup(); + } + KeyCode::Enter => { + let _ = app.apply_script_selection(); + } + KeyCode::Char(' ') => { + app.toggle_script_in_selector(); + } + KeyCode::Char('a') => { + // Select all + if let Some(Popup::ScriptSelector { scripts, .. }) = &mut app.popup { + for script in scripts.iter_mut() { + script.enabled = true; + } + } + } + KeyCode::Char('n') => { + // Select none + if let Some(Popup::ScriptSelector { scripts, .. }) = &mut app.popup { + for script in scripts.iter_mut() { + script.enabled = false; + } + } + } + KeyCode::Char('j') | KeyCode::Down => { + app.script_selector_next(); + } + KeyCode::Char('k') | KeyCode::Up => { + app.script_selector_prev(); + } + _ => {} + } + } + None => {} + } +} + +/// Handle filter mode input +fn handle_filter_input(app: &mut App, key: crossterm::event::KeyEvent) { + match key.code { + KeyCode::Esc => { + app.filter.clear(); + app.exit_filter_mode(); + app.reset_list_selection(); + } + KeyCode::Enter => { + app.exit_filter_mode(); + } + KeyCode::Backspace => { + app.filter.pop(); + app.reset_list_selection(); + } + KeyCode::Char(c) => { + app.filter.push(c); + app.reset_list_selection(); + } + _ => {} + } +} + +/// Handle dashboard-specific input +fn handle_dashboard_input(app: &mut App, key: crossterm::event::KeyEvent) { + // Quick actions from dashboard + if key.is_char('I') { + // Install all (non-blocking) + app.spawn_task(TaskKind::InstallAll); + } else if key.is_char('U') { + // Update all (non-blocking) + app.spawn_task(TaskKind::UpdateAll); + } else if key.is_char('L') { + // Lock versions + app.spawn_task(TaskKind::Lock); + } else if key.is_char('X') { + // Clean orphaned - show confirmation with count + let scan = ops::scan_orphaned(&app.config, &app.paths); + if let Ok(result) = scan { + let total = result.orphaned_scripts.len() + result.orphaned_repos.len(); + if total == 0 { + app.set_status("Nothing to clean", false); + } else { + app.show_confirm( + "Clean Orphaned", + format!("Remove {} orphaned scripts and {} orphaned repos?", + result.orphaned_scripts.len(), result.orphaned_repos.len()), + PopupAction::Clean, + ); + } + } else { + app.set_status("Failed to scan for orphaned items", true); + } + } else if key.is_char('M') { + // Import scripts - show scan results + let scan = ops::scan_importable(&app.config, &app.paths); + if scan.scripts.is_empty() { + app.set_status("No importable scripts found", false); + } else { + let importable: Vec<_> = scan.scripts.iter() + .filter(|s| s.git_remote.is_some() && !s.already_managed) + .collect(); + if importable.is_empty() { + app.set_status(format!("Found {} scripts, none importable (no git remote or already managed)", + scan.scripts.len()), false); + } else { + app.show_message( + "Import Scripts", + format!("Found {} importable scripts from git repos.\nGo to Scripts view and press 'm' to import.", importable.len()), + false, + ); + } + } + } else if key.is_char('H') { + // Run doctor + let result = ops::doctor(&app.config, &app.paths); + let msg = format!("{} passed, {} warnings, {} errors", + result.ok_count, result.warning_count, result.error_count); + let is_error = result.error_count > 0; + app.set_status(format!("Doctor: {}", msg), is_error); + } else if key.is_char('r') { + // Refresh - reload config + if app.reload_config().is_ok() { + app.set_status("Config reloaded", false); + } else { + app.set_status("Failed to reload config", true); + } + } +} + +/// Handle repos view input +fn handle_repos_input(app: &mut App, key: crossterm::event::KeyEvent) { + if key.is_char('a') { + app.show_input("Add Repository", "Enter repo (user/repo or URL):", PopupAction::AddRepo(String::new())); + } else if key.is_char('i') { + if let Some(repo) = app.selected_repo() { + let repo_id = repo.repo.clone(); + app.show_confirm( + "Install", + format!("Install {}?", repo_id), + PopupAction::InstallRepo(repo_id), + ); + } + } else if key.is_char('u') { + if let Some(repo) = app.selected_repo() { + let repo_id = repo.repo.clone(); + app.show_confirm( + "Update", + format!("Update {}?", repo_id), + PopupAction::UpdateRepo(repo_id), + ); + } + } else if key.is_char('r') { + if let Some(repo) = app.selected_repo() { + let repo_id = repo.repo.clone(); + app.show_confirm( + "Remove", + format!("Remove {} from config?", repo_id), + PopupAction::RemoveRepo(repo_id), + ); + } + } else if key.is_char('e') { + // Toggle enabled/disabled + if let Some(idx) = app.list_state.selected() { + // Gather filtered repo IDs first + let filter_lower = app.filter.to_lowercase(); + let repo_ids: Vec = app.config.repos + .iter() + .filter(|r| app.filter.is_empty() || r.repo.to_lowercase().contains(&filter_lower)) + .map(|r| r.repo.clone()) + .collect(); + + if let Some(repo_id) = repo_ids.get(idx).cloned() { + if let Some(config_repo) = app.config.repos.iter_mut().find(|r| r.repo == repo_id) { + config_repo.disabled = !config_repo.disabled; + } + let _ = app.save_config(); + let is_disabled = app.config.repos.iter().find(|r| r.repo == repo_id).map(|r| r.disabled).unwrap_or(false); + let status = if is_disabled { "disabled" } else { "enabled" }; + app.set_status(format!("{} {}", repo_id, status), false); + } + } + } else if key.is_char('p') { + // Pin version - show input for commit/tag + if let Some(repo) = app.selected_repo() { + let repo_id = repo.repo.clone(); + // Get current commit to show as default + let current = ops::get_current_commit(&repo_id, &app.config, &app.paths) + .ok() + .flatten() + .unwrap_or_default(); + app.show_input( + "Pin Version", + format!("Enter commit/tag (current: {}):", ¤t[..7.min(current.len())]), + PopupAction::PinRepo(repo_id, String::new()), + ); + } + } else if key.is_char('s') { + // Open script selector for this repo + if let Some(repo) = app.selected_repo() { + let repo_id = repo.repo.clone(); + app.show_script_selector(&repo_id); + } + } else if key.is_char('I') { + // Install all (non-blocking) + app.spawn_task(TaskKind::InstallAll); + } else if key.is_char('U') { + // Update all (non-blocking) + app.spawn_task(TaskKind::UpdateAll); + } +} + +/// Handle scripts view input +fn handle_scripts_input(app: &mut App, key: crossterm::event::KeyEvent) { + if key.is_char('e') { + // Toggle script enabled in repo config + let selected = app.list_state.selected(); + let filtered = app.filtered_scripts(); + if let Some(idx) = selected { + if let Some(script) = filtered.get(idx) { + let script_name = script.name.clone(); + let repo_id = script.repo.clone(); + + if repo_id == "unknown" { + app.set_status(format!("'{}' is not managed by empeve", script_name), true); + return; + } + + // Toggle in repo's scripts filter + if let Some(repo) = app.config.repos.iter_mut().find(|r| r.repo == repo_id) { + if let Some(ref mut scripts) = repo.scripts { + // Has filter - toggle membership + if scripts.contains(&script_name) { + scripts.retain(|s| s != &script_name); + app.set_status(format!("Disabled {} (reinstall to apply)", script_name), false); + } else { + scripts.push(script_name.clone()); + app.set_status(format!("Enabled {} (reinstall to apply)", script_name), false); + } + } else { + // No filter - add one excluding this script + use crate::repo::ScriptDiscovery; + let repo_path = app.paths.repo_path(&repo_id); + let all_scripts: Vec = ScriptDiscovery::discover(&repo_path) + .iter() + .map(|s| s.name.clone()) + .filter(|n| n != &script_name) + .collect(); + repo.scripts = Some(all_scripts); + app.set_status(format!("Disabled {} (reinstall to apply)", script_name), false); + } + let _ = app.save_config(); + } + } + } + } else if key.is_char('d') { + // View script in repo - open file browser or show path + let selected = app.list_state.selected(); + let filtered = app.filtered_scripts(); + if let Some(idx) = selected { + if let Some(script) = filtered.get(idx) { + let repo_id = script.repo.clone(); + if repo_id != "unknown" { + // Switch to Repos view and select the repo + app.switch_view(View::Repos); + app.filter.clear(); + // Find and select the repo + let repo_idx = app.config.repos.iter().position(|r| r.repo == repo_id); + if let Some(idx) = repo_idx { + app.list_state.select(Some(idx)); + } + app.set_status(format!("Switched to repo: {}", repo_id), false); + } else { + app.set_status("Script repo unknown", true); + } + } + } + } else if key.is_char('t') { + // Cycle target filter + app.cycle_target_filter(); + } else if key.is_char('r') { + // Remove script (unlink from target) + let selected = app.list_state.selected(); + let filtered = app.filtered_scripts(); + if let Some(idx) = selected { + if let Some(script) = filtered.get(idx) { + let script_name = script.name.clone(); + let target_name = script.target.clone(); + app.show_confirm( + "Remove Script", + format!("Remove '{}' from {}?", script_name, target_name), + PopupAction::RemoveScript(script_name, target_name), + ); + } + } + } +} + +/// Handle catalog view input +fn handle_catalog_input(app: &mut App, key: crossterm::event::KeyEvent) { + if key.is_char('a') { + // Just add to config without installing + if let Some(entry) = app.selected_catalog_entry() { + let repo_id = entry.repo.clone(); + + // Check if already added + if app.config.repos.iter().any(|r| r.repo == repo_id) { + app.set_status(format!("{} already in config", repo_id), false); + return; + } + + // Add to config + let repo_entry = RepoEntry::new(&repo_id); + if app.config.add_repo(repo_entry).is_ok() { + if app.save_config().is_ok() { + app.set_status(format!("Added {} (press Enter to install)", repo_id), false); + } else { + app.set_status("Failed to save config", true); + } + } + } + } else if is_select(&key) { + // Add AND install + if let Some(entry) = app.selected_catalog_entry() { + let repo_id = entry.repo.clone(); + + // Check if already added + let already_added = app.config.repos.iter().any(|r| r.repo == repo_id); + + if !already_added { + // Add to config first + let repo_entry = RepoEntry::new(&repo_id); + if app.config.add_repo(repo_entry).is_ok() { + if app.save_config().is_err() { + app.set_status("Failed to save config", true); + return; + } + } + } + + // Now install + app.spawn_task(TaskKind::InstallRepo(repo_id)); + } + } else if key.is_char('i') { + // Install (if already added) + if let Some(entry) = app.selected_catalog_entry() { + let repo_id = entry.repo.clone(); + if app.config.repos.iter().any(|r| r.repo == repo_id) { + app.spawn_task(TaskKind::InstallRepo(repo_id)); + } else { + app.set_status("Add to config first (press 'a')", true); + } + } + } else if key.is_char('r') { + if let Some(entry) = app.selected_catalog_entry() { + let repo_id = entry.repo.clone(); + app.show_confirm( + "Remove", + format!("Remove {} from config?", repo_id), + PopupAction::RemoveRepo(repo_id), + ); + } + } +} + +/// Handle targets view input +fn handle_targets_input(app: &mut App, key: crossterm::event::KeyEvent) { + if key.is_char('e') { + // Toggle enabled + if let Some(idx) = app.list_state.selected() { + if idx < app.config.targets.len() { + app.config.targets[idx].enabled = !app.config.targets[idx].enabled; + let is_enabled = app.config.targets[idx].enabled; + let _ = app.save_config(); + let status = if is_enabled { "enabled" } else { "disabled" }; + app.set_status(format!("Target {}", status), false); + } + } + } else if key.is_char('c') { + // Create directories + if let Some(idx) = app.list_state.selected() { + if let Some(target) = app.config.targets.get(idx) { + match target.ensure_directories() { + Ok(()) => { + app.set_status(format!("Created directories at {}", target.expanded_path().display()), false); + } + Err(e) => { + app.set_status(format!("Failed: {} (path: {})", e, target.expanded_path().display()), true); + } + } + } + } + } else if key.is_char('r') { + // Remove target + if let Some(idx) = app.list_state.selected() { + if idx < app.config.targets.len() { + let name = app.config.targets[idx].name.clone(); + app.show_confirm( + "Remove Target", + format!("Remove target '{}'?", name), + PopupAction::RemoveTarget(name), + ); + } + } + } else if key.is_char('a') { + // Add target - show input for path + app.show_input( + "Add Target", + "Enter mpv config path (e.g., ~/.config/mpv):", + PopupAction::AddTarget(String::new(), String::new()), + ); + } +} + +/// Execute popup confirmation action +fn execute_popup_action(app: &mut App, action: PopupAction) { + match action { + PopupAction::InstallRepo(repo_id) => { + // Non-blocking install + app.spawn_task(TaskKind::InstallRepo(repo_id)); + } + PopupAction::UpdateRepo(repo_id) => { + // Non-blocking update + app.spawn_task(TaskKind::UpdateRepo(repo_id)); + } + PopupAction::RemoveRepo(repo_id) => { + // Non-blocking remove (with purge) + app.spawn_task(TaskKind::RemoveRepo(repo_id, true)); + } + PopupAction::AddRepo(_) => { + // Handled by execute_input_action + } + PopupAction::PinRepo(repo_id, rev) => { + if ops::pin_repo(&repo_id, &rev, &mut app.config).is_ok() { + if app.save_config().is_ok() { + app.set_status(format!("Pinned {} to {}", repo_id, rev), false); + } else { + app.set_status("Failed to save config", true); + } + } else { + app.set_status(format!("Repo {} not found", repo_id), true); + } + } + PopupAction::AddTarget(_, _) => { + // Handled by execute_input_action + } + PopupAction::RemoveTarget(name) => { + match ops::remove_target(&name, &mut app.config) { + Ok(()) => { + if app.save_config().is_ok() { + app.set_status(format!("Removed target '{}'", name), false); + app.reset_list_selection(); + } else { + app.set_status("Failed to save config", true); + } + } + Err(e) => { + app.set_status(format!("Remove failed: {}", e), true); + } + } + } + PopupAction::Clean => { + // Non-blocking clean + app.spawn_task(TaskKind::Clean); + } + PopupAction::ImportScript(_) => { + // Handled by scripts view + } + PopupAction::ToggleScript(_, _) | PopupAction::SaveScriptSelection(_, _) => { + // Handled by script selector popup directly + } + PopupAction::RemoveScript(script_name, target_name) => { + // Find the target and remove the script + if let Some(target) = app.config.targets.iter().find(|t| t.name == target_name) { + let script_path = target.scripts_dir().join(&script_name); + if script_path.exists() || script_path.symlink_metadata().is_ok() { + match std::fs::remove_file(&script_path) { + Ok(()) => { + app.set_status(format!("Removed '{}' from {}", script_name, target_name), false); + app.invalidate_scripts_cache(); + app.refresh_scripts_cache(); + app.reset_list_selection(); + } + Err(e) => { + // Try removing as directory (for multi-file scripts) + if script_path.is_dir() { + match std::fs::remove_dir_all(&script_path) { + Ok(()) => { + app.set_status(format!("Removed '{}' from {}", script_name, target_name), false); + app.invalidate_scripts_cache(); + app.refresh_scripts_cache(); + app.reset_list_selection(); + } + Err(e) => { + app.set_status(format!("Failed to remove: {}", e), true); + } + } + } else { + app.set_status(format!("Failed to remove: {}", e), true); + } + } + } + } else { + app.set_status(format!("Script not found: {}", script_name), true); + } + } else { + app.set_status(format!("Target not found: {}", target_name), true); + } + } + PopupAction::None => {} + } +} + +/// Execute input popup action with value +fn execute_input_action(app: &mut App, action: PopupAction, value: String) { + match action { + PopupAction::AddRepo(_) => { + if value.is_empty() { + app.set_status("No repo specified", true); + return; + } + + // Validate and add + match RepoEntry::try_new(&value) { + Ok(entry) => { + if app.config.add_repo(entry).is_ok() { + if app.save_config().is_ok() { + app.set_status(format!("Added {}", value), false); + app.reset_list_selection(); + } else { + app.set_status("Failed to save config", true); + } + } else { + app.set_status(format!("{} already exists", value), true); + } + } + Err(e) => { + app.set_status(e, true); + } + } + } + PopupAction::PinRepo(repo_id, _) => { + if value.is_empty() { + app.set_status("No revision specified", true); + return; + } + execute_popup_action(app, PopupAction::PinRepo(repo_id, value)); + } + PopupAction::AddTarget(_, _) => { + if value.is_empty() { + app.set_status("No path specified", true); + return; + } + // Add target using the path (name is derived from path) + match ops::add_target(&value, &mut app.config) { + Ok(name) => { + if app.save_config().is_ok() { + app.set_status(format!("Added target '{}'", name), false); + app.reset_list_selection(); + } else { + app.set_status("Failed to save config", true); + } + } + Err(e) => { + app.set_status(format!("Add failed: {}", e), true); + } + } + } + _ => execute_popup_action(app, action), + } +} diff --git a/src/tui/ops.rs b/src/tui/ops.rs new file mode 100644 index 0000000..82ed623 --- /dev/null +++ b/src/tui/ops.rs @@ -0,0 +1,873 @@ +//! TUI operations - wrappers for repo/script operations without CLI output + +use crate::config::{Config, TargetConfig}; +use crate::error::Result; +use crate::paths::Paths; +use crate::repo::{Repository, ScriptDiscovery}; +use crate::script::ScriptInstaller; + +/// Result of an install operation +pub struct InstallResult { + pub scripts_installed: usize, + pub assets_installed: usize, + pub errors: Vec, +} + +/// Result of an update operation +pub struct UpdateResult { + pub was_updated: bool, + pub old_commit: Option, + pub new_commit: Option, + pub is_pinned: bool, +} + +/// Install a single repository +pub fn install_repo( + repo_id: &str, + config: &Config, + paths: &Paths, +) -> Result { + let entry = config.find_repo(repo_id) + .ok_or_else(|| crate::error::EmpveError::RepoNotFound(repo_id.to_string()))?; + + let mut repo = Repository::from_entry(entry.clone(), paths); + let mut result = InstallResult { + scripts_installed: 0, + assets_installed: 0, + errors: Vec::new(), + }; + + // Clone if needed + if !repo.is_cloned { + if entry.is_local() { + if !repo.local_path.exists() { + return Err(crate::error::EmpveError::Config( + "Local repository directory does not exist".into() + )); + } + } else { + repo.clone(config.settings.shallow_clone)?; + } + } + + // Discover scripts + let scripts = ScriptDiscovery::discover(&repo.local_path); + let scripts = if let Some(ref filter) = entry.scripts { + ScriptDiscovery::filter_scripts(scripts, filter) + } else { + scripts + }; + + if scripts.is_empty() { + return Ok(result); + } + + // Get targets for this repo + let targets: Vec<&TargetConfig> = config + .enabled_targets() + .filter(|t| entry.should_install_to(&t.name)) + .collect(); + + // Install to each target + for target in targets { + let installer = ScriptInstaller::new( + target.scripts_dir(), + target.script_opts_dir(), + target.fonts_dir(), + target.shaders_dir(), + paths.repos_dir.clone(), + config.settings.use_symlinks, + ); + + for script in &scripts { + match installer.install(script, entry.rename.as_deref()) { + Ok(install_result) => { + result.scripts_installed += 1; + result.assets_installed += install_result.script_opts_count + + install_result.fonts_count + + install_result.shaders_count; + } + Err(e) => { + result.errors.push(format!("{}: {}", script.name, e)); + } + } + } + } + + Ok(result) +} + +/// Update a single repository +pub fn update_repo( + repo_id: &str, + config: &Config, + paths: &Paths, +) -> Result { + let entry = config.find_repo(repo_id) + .ok_or_else(|| crate::error::EmpveError::RepoNotFound(repo_id.to_string()))?; + + let repo = Repository::from_entry(entry.clone(), paths); + + if !repo.is_cloned { + return Err(crate::error::EmpveError::Config( + "Repository not installed".into() + )); + } + + let old_commit = repo.current_commit()?.unwrap_or_default(); + let is_pinned = repo.is_pinned(); + + // Fetch updates + repo.fetch()?; + + // Update + match repo.update()? { + crate::repo::git_ops::UpdateResult::Updated(new_commit) => { + Ok(UpdateResult { + was_updated: true, + old_commit: Some(old_commit), + new_commit: Some(new_commit), + is_pinned, + }) + } + crate::repo::git_ops::UpdateResult::UpToDate => { + Ok(UpdateResult { + was_updated: false, + old_commit: Some(old_commit.clone()), + new_commit: Some(old_commit), + is_pinned, + }) + } + crate::repo::git_ops::UpdateResult::Pinned => { + Ok(UpdateResult { + was_updated: false, + old_commit: Some(old_commit.clone()), + new_commit: Some(old_commit), + is_pinned: true, + }) + } + } +} + +/// Remove a repository (config removal + optional purge) +pub fn remove_repo( + repo_id: &str, + config: &mut Config, + paths: &Paths, + purge: bool, +) -> Result<(usize, bool)> { + let entry = config.remove_repo(repo_id)?; + let mut scripts_removed = 0; + let mut repo_deleted = false; + + if purge { + let repository = Repository::from_entry(entry.clone(), paths); + + // Remove installed scripts from all targets + for target in config.enabled_targets() { + let installer = ScriptInstaller::new( + target.scripts_dir(), + target.script_opts_dir(), + target.fonts_dir(), + target.shaders_dir(), + paths.repos_dir.clone(), + true, + ); + + if let Ok(installed) = installer.find_installed() { + for script in installed { + if script.repo.as_ref().map(|r| r == repo_id).unwrap_or(false) { + let _ = installer.uninstall(&script.installed_path); + scripts_removed += 1; + } + } + } + } + + // Delete the cloned repository + if repository.is_cloned && repository.local_path.exists() { + std::fs::remove_dir_all(&repository.local_path)?; + repo_deleted = true; + } + } + + Ok((scripts_removed, repo_deleted)) +} + +/// Install all configured repositories +pub fn install_all( + config: &Config, + paths: &Paths, +) -> Result<(usize, usize, Vec)> { + let mut total_scripts = 0; + let mut total_assets = 0; + let mut errors = Vec::new(); + + for entry in config.enabled_repos() { + match install_repo(&entry.repo, config, paths) { + Ok(result) => { + total_scripts += result.scripts_installed; + total_assets += result.assets_installed; + errors.extend(result.errors); + } + Err(e) => { + errors.push(format!("{}: {}", entry.repo, e)); + } + } + } + + Ok((total_scripts, total_assets, errors)) +} + +/// Update all configured repositories +pub fn update_all( + config: &Config, + paths: &Paths, +) -> Result<(usize, usize, Vec)> { + let mut updated = 0; + let mut up_to_date = 0; + let mut errors = Vec::new(); + + for entry in config.enabled_repos() { + match update_repo(&entry.repo, config, paths) { + Ok(result) => { + if result.was_updated { + updated += 1; + } else { + up_to_date += 1; + } + } + Err(e) => { + errors.push(format!("{}: {}", entry.repo, e)); + } + } + } + + Ok((updated, up_to_date, errors)) +} + +/// Pin a repository to a specific commit/tag +pub fn pin_repo( + repo_id: &str, + rev: &str, + config: &mut Config, +) -> Result<()> { + let entry = config.repos.iter_mut() + .find(|r| r.repo == repo_id) + .ok_or_else(|| crate::error::EmpveError::RepoNotFound(repo_id.to_string()))?; + + entry.rev = Some(rev.to_string()); + Ok(()) +} + +/// Get current commit for a repository +pub fn get_current_commit( + repo_id: &str, + config: &Config, + paths: &Paths, +) -> Result> { + let entry = config.find_repo(repo_id) + .ok_or_else(|| crate::error::EmpveError::RepoNotFound(repo_id.to_string()))?; + + let repo = Repository::from_entry(entry.clone(), paths); + repo.current_commit() +} + +/// Add a target to the config +pub fn add_target( + path: &str, + config: &mut Config, +) -> Result { + use std::path::PathBuf; + use crate::config::TargetConfig; + + // Expand ~ to home directory + let expanded_path = if let Some(stripped) = path.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + home.join(stripped) + } else { + PathBuf::from(path) + } + } else { + PathBuf::from(path) + }; + + // Derive name from path (last component or "mpv" for standard path) + let name = expanded_path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "mpv".to_string()); + + // Check if target with same name already exists + if config.targets.iter().any(|t| t.name == name) { + return Err(crate::error::EmpveError::Config( + format!("Target '{}' already exists", name) + )); + } + + let target = TargetConfig::new(&name, &expanded_path); + + // Create directories + target.ensure_directories().map_err(|e| { + crate::error::EmpveError::Config(format!("Failed to create directories: {}", e)) + })?; + + config.add_target(target); + Ok(name) +} + +/// Remove a target from the config +pub fn remove_target( + name: &str, + config: &mut Config, +) -> Result<()> { + let pos = config.targets.iter().position(|t| t.name == name) + .ok_or_else(|| crate::error::EmpveError::Config( + format!("Target '{}' not found", name) + ))?; + + config.targets.remove(pos); + Ok(()) +} + +// ============================================================================ +// Clean operation +// ============================================================================ + +use std::collections::HashSet; +use std::path::PathBuf; +use crate::repo::repository::find_cloned_repos; + +/// Result of finding orphaned items +pub struct CleanScanResult { + pub orphaned_scripts: Vec, + pub orphaned_repos: Vec, +} + +/// An orphaned script with its location +pub struct OrphanedScript { + pub path: PathBuf, + pub name: String, + pub target: String, +} + +/// Scan for orphaned scripts and repos +pub fn scan_orphaned(config: &Config, paths: &Paths) -> Result { + let mut orphaned_scripts = Vec::new(); + + // Find orphaned scripts from all enabled targets + for target in config.enabled_targets() { + let installer = ScriptInstaller::new( + target.scripts_dir(), + target.script_opts_dir(), + target.fonts_dir(), + target.shaders_dir(), + paths.repos_dir.clone(), + true, + ); + + if let Ok(orphans) = installer.find_orphaned() { + for path in orphans { + let name = path.file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + orphaned_scripts.push(OrphanedScript { + path, + name, + target: target.name.clone(), + }); + } + } + } + + // Find orphaned repos (cloned but not in config) + let configured_repos: HashSet<_> = config + .repos + .iter() + .map(|r| paths.repo_path(&r.repo)) + .collect(); + + let cloned_repos = find_cloned_repos(&paths.repos_dir)?; + let orphaned_repos: Vec<_> = cloned_repos + .into_iter() + .filter(|p| !configured_repos.contains(p)) + .collect(); + + Ok(CleanScanResult { + orphaned_scripts, + orphaned_repos, + }) +} + +/// Result of clean operation +pub struct CleanResult { + pub scripts_removed: usize, + pub repos_removed: usize, + pub errors: Vec, +} + +/// Execute clean - remove orphaned scripts and repos +pub fn clean(config: &Config, paths: &Paths) -> Result { + let scan = scan_orphaned(config, paths)?; + let mut result = CleanResult { + scripts_removed: 0, + repos_removed: 0, + errors: Vec::new(), + }; + + // Remove orphaned scripts + for orphan in &scan.orphaned_scripts { + if let Err(e) = std::fs::remove_file(&orphan.path) { + result.errors.push(format!("{}: {}", orphan.name, e)); + } else { + result.scripts_removed += 1; + } + } + + // Remove orphaned repos + for repo_path in &scan.orphaned_repos { + if let Err(e) = std::fs::remove_dir_all(repo_path) { + let name = repo_path.file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + result.errors.push(format!("{}: {}", name, e)); + } else { + result.repos_removed += 1; + } + } + + Ok(result) +} + +// ============================================================================ +// Doctor operation +// ============================================================================ + +use crate::repo::GitOps; + +/// Diagnostic check status +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DiagnosticStatus { + Ok, + Warning, + Error, +} + +/// A single diagnostic result +#[derive(Debug, Clone)] +pub struct DiagnosticItem { + pub name: String, + pub status: DiagnosticStatus, + pub message: String, + pub fix_available: bool, +} + +/// Full doctor results +pub struct DoctorResult { + pub items: Vec, + pub ok_count: usize, + pub warning_count: usize, + pub error_count: usize, +} + +/// Run diagnostics +pub fn doctor(config: &Config, paths: &Paths) -> DoctorResult { + let mut items = Vec::new(); + + // Check config directory + items.push(check_dir_writable(&paths.config_dir, "Config directory")); + items.push(check_dir_writable(&paths.repos_dir, "Repos directory")); + + // Check each enabled target's directories + for target in config.enabled_targets() { + items.push(check_dir_writable(&target.scripts_dir(), &format!("{} scripts", target.name))); + } + + // Check symlink support + items.push(check_symlink_support(paths)); + + // Check repo health + for entry in &config.repos { + let repo = Repository::from_entry(entry.clone(), paths); + items.push(check_repo_health(&repo, &entry.repo)); + } + + // Check target health + for target in &config.targets { + items.push(check_target_health(target)); + } + + // Count results + let ok_count = items.iter().filter(|i| i.status == DiagnosticStatus::Ok).count(); + let warning_count = items.iter().filter(|i| i.status == DiagnosticStatus::Warning).count(); + let error_count = items.iter().filter(|i| i.status == DiagnosticStatus::Error).count(); + + DoctorResult { + items, + ok_count, + warning_count, + error_count, + } +} + +fn check_dir_writable(path: &std::path::Path, name: &str) -> DiagnosticItem { + if !path.exists() { + return DiagnosticItem { + name: name.to_string(), + status: DiagnosticStatus::Warning, + message: format!("Does not exist: {}", path.display()), + fix_available: true, + }; + } + + let test_file = path.join(".empeve-doctor-test"); + match std::fs::write(&test_file, "") { + Ok(_) => { + let _ = std::fs::remove_file(&test_file); + DiagnosticItem { + name: name.to_string(), + status: DiagnosticStatus::Ok, + message: "Writable".to_string(), + fix_available: false, + } + } + Err(e) => DiagnosticItem { + name: name.to_string(), + status: DiagnosticStatus::Error, + message: format!("Not writable: {}", e), + fix_available: false, + }, + } +} + +fn check_symlink_support(paths: &Paths) -> DiagnosticItem { + let test_source = paths.config_dir.join(".empeve-symlink-test-src"); + let test_link = paths.config_dir.join(".empeve-symlink-test-link"); + + if std::fs::write(&test_source, "test").is_err() { + return DiagnosticItem { + name: "Symlink support".to_string(), + status: DiagnosticStatus::Warning, + message: "Could not test".to_string(), + fix_available: false, + }; + } + + #[cfg(unix)] + let symlink_result = std::os::unix::fs::symlink(&test_source, &test_link); + #[cfg(windows)] + let symlink_result = std::os::windows::fs::symlink_file(&test_source, &test_link); + + let result = match symlink_result { + Ok(_) => { + let _ = std::fs::remove_file(&test_link); + DiagnosticItem { + name: "Symlink support".to_string(), + status: DiagnosticStatus::Ok, + message: "Supported".to_string(), + fix_available: false, + } + } + Err(_) => DiagnosticItem { + name: "Symlink support".to_string(), + status: DiagnosticStatus::Warning, + message: "Not supported (will copy files)".to_string(), + fix_available: false, + }, + }; + + let _ = std::fs::remove_file(&test_source); + result +} + +fn check_repo_health(repo: &Repository, name: &str) -> DiagnosticItem { + if !repo.is_cloned { + return DiagnosticItem { + name: format!("Repo: {}", name), + status: DiagnosticStatus::Warning, + message: "Not cloned".to_string(), + fix_available: true, + }; + } + + match repo.open() { + Ok(git_repo) => { + match git_repo.head() { + Ok(_) => DiagnosticItem { + name: format!("Repo: {}", name), + status: DiagnosticStatus::Ok, + message: "Healthy".to_string(), + fix_available: false, + }, + Err(e) => DiagnosticItem { + name: format!("Repo: {}", name), + status: DiagnosticStatus::Error, + message: format!("Broken HEAD: {}", e), + fix_available: true, + }, + } + } + Err(e) => DiagnosticItem { + name: format!("Repo: {}", name), + status: DiagnosticStatus::Error, + message: format!("Invalid: {}", e), + fix_available: true, + }, + } +} + +fn check_target_health(target: &TargetConfig) -> DiagnosticItem { + if !target.path.exists() { + DiagnosticItem { + name: format!("Target: {}", target.name), + status: DiagnosticStatus::Error, + message: format!("Path missing: {}", target.path.display()), + fix_available: true, + } + } else if !target.enabled { + DiagnosticItem { + name: format!("Target: {}", target.name), + status: DiagnosticStatus::Warning, + message: "Disabled".to_string(), + fix_available: false, + } + } else { + DiagnosticItem { + name: format!("Target: {}", target.name), + status: DiagnosticStatus::Ok, + message: "Healthy".to_string(), + fix_available: false, + } + } +} + +// ============================================================================ +// Lock operation +// ============================================================================ + +use crate::lockfile::{Lockfile, LockedRepo}; + +/// Result of lock operation +pub struct LockResult { + pub locked_count: usize, + pub skipped_count: usize, + pub errors: Vec, +} + +/// Create lockfile from current repo state +pub fn lock(config: &Config, paths: &Paths) -> Result { + let mut lockfile = Lockfile::new(); + let mut result = LockResult { + locked_count: 0, + skipped_count: 0, + errors: Vec::new(), + }; + + for entry in config.enabled_repos() { + let repo = Repository::from_entry(entry.clone(), paths); + + if !repo.is_cloned { + result.skipped_count += 1; + continue; + } + + match repo.open() { + Ok(git_repo) => { + match GitOps::head_commit(&git_repo) { + Ok(commit) => { + lockfile.lock_repo( + &entry.repo, + LockedRepo { + commit, + source: entry.git_url(), + rev: entry.rev.clone(), + }, + ); + result.locked_count += 1; + } + Err(e) => { + result.errors.push(format!("{}: {}", entry.repo, e)); + result.skipped_count += 1; + } + } + } + Err(e) => { + result.errors.push(format!("{}: {}", entry.repo, e)); + result.skipped_count += 1; + } + } + } + + if result.locked_count > 0 { + lockfile.save(&paths.lockfile)?; + } + + Ok(result) +} + +// ============================================================================ +// Import operation +// ============================================================================ + +/// An importable script detected in the mpv scripts directory +#[derive(Clone)] +pub struct ImportableScript { + pub name: String, + pub path: PathBuf, + pub git_remote: Option, + pub is_directory: bool, + pub already_managed: bool, +} + +/// Result of import scan +pub struct ImportScanResult { + pub scripts: Vec, + pub git_backed_count: usize, + pub local_count: usize, + pub already_managed_count: usize, +} + +/// Scan for importable scripts +pub fn scan_importable(config: &Config, paths: &Paths) -> ImportScanResult { + let mut scripts = Vec::new(); + let mut git_backed_count = 0; + let mut local_count = 0; + let mut already_managed_count = 0; + + if !paths.mpv_scripts_dir.exists() { + return ImportScanResult { + scripts, + git_backed_count, + local_count, + already_managed_count, + }; + } + + if let Ok(entries) = std::fs::read_dir(&paths.mpv_scripts_dir) { + for entry in entries.filter_map(|e| e.ok()) { + let path = entry.path(); + let name = entry.file_name().to_string_lossy().to_string(); + + // Skip hidden files + if name.starts_with('.') { + continue; + } + + // Check if already managed by empeve (symlink to repos_dir) + let already_managed = if path.is_symlink() { + if let Ok(target) = std::fs::read_link(&path) { + target.starts_with(&paths.repos_dir) + } else { + false + } + } else { + false + }; + + if already_managed { + already_managed_count += 1; + continue; + } + + let is_directory = path.is_dir(); + + // Try to find git remote + let source_path = if path.is_symlink() { + std::fs::read_link(&path).unwrap_or(path.clone()) + } else { + path.clone() + }; + + let git_remote = find_git_remote(&source_path); + + if git_remote.is_some() { + git_backed_count += 1; + } else { + local_count += 1; + } + + // Check if already in config + let repo_id = git_remote.as_ref().map(|r| extract_repo_id(r)); + let already_in_config = repo_id + .as_ref() + .map(|id| config.find_repo(id).is_some()) + .unwrap_or(false); + + scripts.push(ImportableScript { + name, + path: source_path, + git_remote, + is_directory, + already_managed: already_in_config, + }); + } + } + + ImportScanResult { + scripts, + git_backed_count, + local_count, + already_managed_count, + } +} + +/// Import a script by adding its repo to config +pub fn import_script( + script: &ImportableScript, + config: &mut Config, +) -> Result { + let remote = script.git_remote.as_ref() + .ok_or_else(|| crate::error::EmpveError::Config( + "Script is not from a git repository".to_string() + ))?; + + let repo_id = extract_repo_id(remote); + + // Check if already in config + if config.find_repo(&repo_id).is_some() { + return Err(crate::error::EmpveError::Config( + format!("{} is already in config", repo_id) + )); + } + + let entry = crate::config::RepoEntry::new(&repo_id); + config.add_repo(entry)?; + + Ok(repo_id) +} + +fn find_git_remote(path: &std::path::Path) -> Option { + let mut current = if path.is_file() { + path.parent()? + } else { + path + }; + + loop { + if GitOps::is_repo(current) { + if let Ok(repo) = GitOps::open(current) { + if let Ok(remote) = repo.find_remote("origin") { + return remote.url().map(|s| s.to_string()); + } + } + } + + current = current.parent()?; + } +} + +fn extract_repo_id(url: &str) -> String { + let url = url.trim_end_matches('/').trim_end_matches(".git"); + + if let Some(rest) = url.strip_prefix("https://github.com/") { + return rest.to_string(); + } + if let Some(rest) = url.strip_prefix("http://github.com/") { + return rest.to_string(); + } + if let Some(rest) = url.strip_prefix("git@github.com:") { + return rest.to_string(); + } + + url.to_string() +} diff --git a/src/tui/theme.rs b/src/tui/theme.rs new file mode 100644 index 0000000..bc88678 --- /dev/null +++ b/src/tui/theme.rs @@ -0,0 +1,85 @@ +//! Terminal color scheme for the TUI +//! +//! Uses the terminal's 16-color palette so colors respect user's terminal theme. + +use ratatui::style::{Color, Modifier, Style}; + +// Base terminal colors (will use terminal's configured colors) +pub const BASE: Color = Color::Reset; // Default background +pub const SURFACE0: Color = Color::DarkGray; // Elevated surface +pub const SURFACE1: Color = Color::Gray; // Border/divider (brighter) +pub const TEXT: Color = Color::Reset; // Main text (default fg) +pub const SUBTEXT0: Color = Color::Gray; // Secondary text +pub const OVERLAY0: Color = Color::Gray; // Muted text (brighter for visibility) + +// Accent colors (terminal palette) +pub const BLUE: Color = Color::Blue; // Primary accent +pub const GREEN: Color = Color::Green; // Success +pub const YELLOW: Color = Color::Yellow; // Warning +pub const RED: Color = Color::Red; // Error +pub const MAUVE: Color = Color::Magenta; // Purple accent +pub const TEAL: Color = Color::Cyan; // Teal accent + +// Pre-built styles +pub fn text() -> Style { + Style::default() +} + +pub fn text_muted() -> Style { + Style::default().fg(OVERLAY0) +} + +pub fn text_secondary() -> Style { + Style::default().fg(SUBTEXT0) +} + +pub fn accent() -> Style { + Style::default().fg(BLUE) +} + +pub fn success() -> Style { + Style::default().fg(GREEN) +} + +pub fn warning() -> Style { + Style::default().fg(YELLOW) +} + +pub fn error() -> Style { + Style::default().fg(RED) +} + +pub fn selected() -> Style { + Style::default() + .bg(SURFACE0) + .fg(BLUE) + .add_modifier(Modifier::BOLD) +} + +pub fn highlight() -> Style { + Style::default().fg(BLUE).add_modifier(Modifier::BOLD) +} + +pub fn border() -> Style { + Style::default().fg(SURFACE1) +} + +pub fn border_focused() -> Style { + Style::default().fg(BLUE) +} + +pub fn title() -> Style { + Style::default().add_modifier(Modifier::BOLD) +} + +pub fn header() -> Style { + Style::default().fg(MAUVE).add_modifier(Modifier::BOLD) +} + +pub fn keybind() -> Style { + Style::default().fg(TEAL) +} + +pub fn keybind_desc() -> Style { + Style::default().fg(SUBTEXT0) +} diff --git a/src/tui/views/catalog.rs b/src/tui/views/catalog.rs new file mode 100644 index 0000000..0e8bd05 --- /dev/null +++ b/src/tui/views/catalog.rs @@ -0,0 +1,188 @@ +//! Catalog view - browse and add scripts from catalog + +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, + Frame, +}; + +use crate::tui::app::{App, Focus, Mode}; +use crate::tui::theme; +use crate::tui::widgets::{render_empty_list, render_filter_input, render_status_list, ItemStatus, StatusListItem}; + +/// Render the catalog view +pub fn render(f: &mut Frame, area: Rect, app: &mut App) { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(area); + + render_catalog_list(f, chunks[0], app); + render_catalog_details(f, chunks[1], app); + + // Render filter input if in filter mode + if app.mode == Mode::Filter { + let filter_area = Rect { + x: chunks[0].x + 1, + y: chunks[0].y + chunks[0].height - 2, + width: chunks[0].width - 2, + height: 1, + }; + render_filter_input(f, filter_area, &app.filter); + } +} + +/// Render the catalog list +fn render_catalog_list(f: &mut Frame, area: Rect, app: &mut App) { + // Gather data without holding borrows across the render call + let filter_lower = app.filter.to_lowercase(); + let filter_empty = app.filter.is_empty(); + let in_filter_mode = app.mode == Mode::Filter; + let is_focused = app.focus == Focus::List; + + // Collect repos for checking if already added + let config_repos: Vec = app.config.repos.iter().map(|r| r.repo.clone()).collect(); + + // Collect catalog data we need + let catalog_data: Vec<(String, String, bool)> = app.catalog + .entries() + .iter() + .filter(|e| { + filter_empty + || e.name.to_lowercase().contains(&filter_lower) + || e.repo.to_lowercase().contains(&filter_lower) + || e.description.to_lowercase().contains(&filter_lower) + }) + .map(|entry| { + let is_added = config_repos.iter().any(|r| r == &entry.repo); + (entry.name.clone(), entry.category.clone(), is_added) + }) + .collect(); + + if catalog_data.is_empty() { + let message = if filter_empty { + "Catalog is empty" + } else { + "No matching entries" + }; + render_empty_list(f, area, "Catalog", message, is_focused); + return; + } + + let items: Vec = catalog_data + .iter() + .map(|(name, category, is_added)| StatusListItem { + text: name.as_str(), + status: if *is_added { + ItemStatus::Installed + } else { + ItemStatus::None + }, + detail: Some(category.as_str()), + }) + .collect(); + + let filter_display = if in_filter_mode || !filter_empty { + Some(app.filter.as_str()) + } else { + None + }; + + render_status_list( + f, + area, + "Catalog", + &items, + &mut app.list_state, + is_focused, + filter_display, + ); +} + +/// Render catalog entry details +fn render_catalog_details(f: &mut Frame, area: Rect, app: &App) { + let border_style = if app.focus == Focus::Details { + theme::border_focused() + } else { + theme::border() + }; + + let block = Block::default() + .title(" Details ") + .title_style(theme::title()) + .borders(Borders::ALL) + .border_style(border_style); + + let inner = block.inner(area); + f.render_widget(block, area); + + let Some(entry) = app.selected_catalog_entry() else { + let empty = Paragraph::new("Select an entry") + .style(theme::text_muted()) + .alignment(ratatui::layout::Alignment::Center); + f.render_widget(empty, inner); + return; + }; + + let is_added = app.config.repos.iter().any(|r| r.repo == entry.repo); + + let mut lines: Vec = vec![ + Line::from(Span::styled(&entry.name, theme::highlight())), + Line::from(""), + Line::from(vec![ + Span::styled("Repo: ", theme::text_secondary()), + Span::styled(&entry.repo, theme::accent()), + ]), + Line::from(vec![ + Span::styled("Category: ", theme::text_secondary()), + Span::styled(&entry.category, theme::text()), + ]), + Line::from(vec![ + Span::styled("Status: ", theme::text_secondary()), + if is_added { + Span::styled("✓ Added", theme::success()) + } else { + Span::styled("Not added", theme::text_muted()) + }, + ]), + Line::from(""), + Line::from(Span::styled("Description:", theme::header())), + Line::from(""), + Line::from(Span::styled(&entry.description, theme::text())), + Line::from(""), + Line::from(Span::styled("Actions:", theme::header())), + Line::from(""), + ]; + + if is_added { + lines.push(Line::from(vec![ + Span::styled("[r] ", theme::keybind()), + Span::styled("Remove from config", theme::keybind_desc()), + ])); + lines.push(Line::from(vec![ + Span::styled("[i] ", theme::keybind()), + Span::styled("Install", theme::keybind_desc()), + ])); + } else { + lines.push(Line::from(vec![ + Span::styled("[a] ", theme::keybind()), + Span::styled("Add to config", theme::keybind_desc()), + ])); + lines.push(Line::from(vec![ + Span::styled("[Enter]", theme::keybind()), + Span::styled(" Add and install", theme::keybind_desc()), + ])); + } + + let details = Paragraph::new(lines).wrap(Wrap { trim: true }); + + let details_area = Rect { + x: inner.x + 1, + y: inner.y + 1, + width: inner.width.saturating_sub(2), + height: inner.height.saturating_sub(2), + }; + + f.render_widget(details, details_area); +} diff --git a/src/tui/views/dashboard.rs b/src/tui/views/dashboard.rs new file mode 100644 index 0000000..d2b39a9 --- /dev/null +++ b/src/tui/views/dashboard.rs @@ -0,0 +1,165 @@ +//! Dashboard view - stats overview + +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; + +use crate::tui::app::App; +use crate::tui::theme; + +/// Render the dashboard view +pub fn render(f: &mut Frame, area: Rect, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(8), // Stats + Constraint::Length(10), // Targets + Constraint::Min(0), // Recent activity (placeholder) + ]) + .split(area); + + render_stats(f, chunks[0], app); + render_targets_summary(f, chunks[1], app); + render_recent_activity(f, chunks[2], app); +} + +/// Render stats summary +fn render_stats(f: &mut Frame, area: Rect, app: &App) { + let block = Block::default() + .title(" Overview ") + .title_style(theme::title()) + .borders(Borders::ALL) + .border_style(theme::border()); + + let inner = block.inner(area); + f.render_widget(block, area); + + let repo_count = app.config.repos.len(); + let enabled_repos = app.config.repos.iter().filter(|r| !r.disabled).count(); + let target_count = app.config.targets.len(); + let enabled_targets = app.config.targets.iter().filter(|t| t.enabled).count(); + + let lines = vec![ + Line::from(""), + Line::from(vec![ + Span::styled(" Repositories: ", theme::text_secondary()), + Span::styled(format!("{}", repo_count), theme::highlight()), + Span::styled(format!(" ({} enabled)", enabled_repos), theme::text_muted()), + ]), + Line::from(vec![ + Span::styled(" Targets: ", theme::text_secondary()), + Span::styled(format!("{}", target_count), theme::highlight()), + Span::styled(format!(" ({} enabled)", enabled_targets), theme::text_muted()), + ]), + Line::from(vec![ + Span::styled(" Catalog: ", theme::text_secondary()), + Span::styled(format!("{} scripts", app.catalog.entries().len()), theme::highlight()), + ]), + ]; + + let para = Paragraph::new(lines); + f.render_widget(para, inner); +} + +/// Render targets summary +fn render_targets_summary(f: &mut Frame, area: Rect, app: &App) { + let block = Block::default() + .title(" Targets ") + .title_style(theme::title()) + .borders(Borders::ALL) + .border_style(theme::border()); + + let inner = block.inner(area); + f.render_widget(block, area); + + if app.config.targets.is_empty() { + let empty = Paragraph::new("No targets configured") + .style(theme::text_muted()); + f.render_widget(empty, inner); + return; + } + + let mut lines: Vec = vec![Line::from("")]; + + for target in &app.config.targets { + let status_icon = if target.enabled { "✓" } else { "○" }; + let status_style = if target.enabled { + theme::success() + } else { + theme::text_muted() + }; + + lines.push(Line::from(vec![ + Span::styled(format!(" {} ", status_icon), status_style), + Span::styled(&target.name, if target.enabled { theme::text() } else { theme::text_muted() }), + Span::styled(format!(" {}", target.path.display()), theme::text_muted()), + ])); + } + + let para = Paragraph::new(lines); + f.render_widget(para, inner); +} + +/// Render quick actions +fn render_recent_activity(f: &mut Frame, area: Rect, _app: &App) { + let block = Block::default() + .title(" Quick Actions ") + .title_style(theme::title()) + .borders(Borders::ALL) + .border_style(theme::border()); + + let inner = block.inner(area); + f.render_widget(block, area); + + // Split into two columns + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(inner); + + let left_lines = vec![ + Line::from(""), + Line::from(vec![ + Span::styled(" [I] ", theme::keybind()), + Span::styled("Install all repos", theme::keybind_desc()), + ]), + Line::from(vec![ + Span::styled(" [U] ", theme::keybind()), + Span::styled("Update all repos", theme::keybind_desc()), + ]), + Line::from(vec![ + Span::styled(" [L] ", theme::keybind()), + Span::styled("Lock versions", theme::keybind_desc()), + ]), + Line::from(vec![ + Span::styled(" [X] ", theme::keybind()), + Span::styled("Clean orphaned", theme::keybind_desc()), + ]), + ]; + + let right_lines = vec![ + Line::from(""), + Line::from(vec![ + Span::styled(" [M] ", theme::keybind()), + Span::styled("Import scripts", theme::keybind_desc()), + ]), + Line::from(vec![ + Span::styled(" [H] ", theme::keybind()), + Span::styled("Run doctor", theme::keybind_desc()), + ]), + Line::from(vec![ + Span::styled(" [r] ", theme::keybind()), + Span::styled("Reload config", theme::keybind_desc()), + ]), + Line::from(vec![ + Span::styled(" [?] ", theme::keybind()), + Span::styled("Show help", theme::keybind_desc()), + ]), + ]; + + f.render_widget(Paragraph::new(left_lines), cols[0]); + f.render_widget(Paragraph::new(right_lines), cols[1]); +} diff --git a/src/tui/views/mod.rs b/src/tui/views/mod.rs new file mode 100644 index 0000000..3964812 --- /dev/null +++ b/src/tui/views/mod.rs @@ -0,0 +1,7 @@ +//! TUI views + +pub mod catalog; +pub mod dashboard; +pub mod repos; +pub mod scripts; +pub mod targets; diff --git a/src/tui/views/repos.rs b/src/tui/views/repos.rs new file mode 100644 index 0000000..9ac9ca6 --- /dev/null +++ b/src/tui/views/repos.rs @@ -0,0 +1,259 @@ +//! Repository list view - the primary TUI view + +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, + Frame, +}; + +use crate::config::RepoEntry; +use crate::paths::Paths; +use crate::repo::GitOps; +use crate::tui::app::{App, Focus, Mode}; +use crate::tui::theme; +use crate::tui::widgets::{render_empty_list, render_filter_input, render_status_list, ItemStatus, StatusListItem}; + +/// Render the repos view +pub fn render(f: &mut Frame, area: Rect, app: &mut App) { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(area); + + render_repo_list(f, chunks[0], app); + render_repo_details(f, chunks[1], app); + + // Render filter input if in filter mode + if app.mode == Mode::Filter { + let filter_area = Rect { + x: chunks[0].x + 1, + y: chunks[0].y + chunks[0].height - 2, + width: chunks[0].width - 2, + height: 1, + }; + render_filter_input(f, filter_area, &app.filter); + } +} + +/// Render the repository list +fn render_repo_list(f: &mut Frame, area: Rect, app: &mut App) { + // Gather data without holding borrows across the render call + let filter_lower = app.filter.to_lowercase(); + let filter_empty = app.filter.is_empty(); + let in_filter_mode = app.mode == Mode::Filter; + let is_focused = app.focus == Focus::List; + + // Collect repo data we need + let repo_data: Vec<(String, ItemStatus, Option)> = app.config.repos + .iter() + .filter(|r| filter_empty || r.repo.to_lowercase().contains(&filter_lower)) + .map(|repo| { + let status = get_repo_status(repo, &app.paths); + let detail = repo.rev.clone(); + (repo.repo.clone(), status, detail) + }) + .collect(); + + if repo_data.is_empty() { + let message = if filter_empty { + "No repos configured\nPress 'a' to add one" + } else { + "No matching repos" + }; + render_empty_list(f, area, "Repositories", message, is_focused); + return; + } + + let items: Vec = repo_data + .iter() + .map(|(repo, status, detail)| StatusListItem { + text: repo.as_str(), + status: *status, + detail: detail.as_deref(), + }) + .collect(); + + let filter = if in_filter_mode || !filter_empty { + Some(app.filter.as_str()) + } else { + None + }; + + render_status_list( + f, + area, + "Repositories", + &items, + &mut app.list_state, + is_focused, + filter, + ); +} + +/// Get the status of a repository +fn get_repo_status(repo: &RepoEntry, paths: &Paths) -> ItemStatus { + if repo.disabled { + return ItemStatus::Disabled; + } + + let repo_path = paths.repo_path(&repo.repo); + if !repo_path.exists() { + return ItemStatus::Pending; + } + + // Check if repo is cloned + if GitOps::is_repo(&repo_path) { + ItemStatus::Installed + } else { + ItemStatus::Error + } +} + +/// Render the repository details panel +fn render_repo_details(f: &mut Frame, area: Rect, app: &App) { + let border_style = if app.focus == Focus::Details { + theme::border_focused() + } else { + theme::border() + }; + + let block = Block::default() + .title(" Details ") + .title_style(theme::title()) + .borders(Borders::ALL) + .border_style(border_style); + + let inner = block.inner(area); + f.render_widget(block, area); + + let Some(repo) = app.selected_repo() else { + let empty = Paragraph::new("Select a repository") + .style(theme::text_muted()) + .alignment(ratatui::layout::Alignment::Center); + f.render_widget(empty, inner); + return; + }; + + // Build details content + let mut lines: Vec = Vec::new(); + + // Repository name + lines.push(Line::from(vec![ + Span::styled(&repo.repo, theme::highlight()), + ])); + lines.push(Line::from("")); + + // Status + let status = get_repo_status(repo, &app.paths); + let status_text = match status { + ItemStatus::Installed => ("Installed", theme::success()), + ItemStatus::Pending => ("Not cloned", theme::warning()), + ItemStatus::Disabled => ("Disabled", theme::text_muted()), + ItemStatus::Error => ("Error", theme::error()), + _ => ("Unknown", theme::text_muted()), + }; + + lines.push(Line::from(vec![ + Span::styled("Status: ", theme::text_secondary()), + Span::styled(format!("{} {}", status.icon(), status_text.0), status_text.1), + ])); + + // Revision + if let Some(rev) = &repo.rev { + lines.push(Line::from(vec![ + Span::styled("Rev: ", theme::text_secondary()), + Span::styled(rev, theme::text()), + ])); + } + + // Scripts info - show available and filtered + let repo_path = app.paths.repo_path(&repo.repo); + if repo_path.exists() { + use crate::repo::ScriptDiscovery; + let discovered = ScriptDiscovery::discover(&repo_path); + let total = discovered.len(); + let enabled = if let Some(ref filter) = repo.scripts { + filter.len() + } else { + total + }; + lines.push(Line::from(vec![ + Span::styled("Scripts: ", theme::text_secondary()), + Span::styled(format!("{}/{} enabled", enabled, total), theme::text()), + Span::styled(" [s] to select", theme::text_muted()), + ])); + } else if let Some(scripts) = &repo.scripts { + lines.push(Line::from(vec![ + Span::styled("Scripts: ", theme::text_secondary()), + Span::styled(scripts.join(", "), theme::text()), + ])); + } + + // Target filter + if let Some(targets) = &repo.targets { + lines.push(Line::from(vec![ + Span::styled("Targets: ", theme::text_secondary()), + Span::styled(targets.join(", "), theme::text()), + ])); + } + + // Local repo indicator + if repo.is_local() { + lines.push(Line::from(vec![ + Span::styled("Type: ", theme::text_secondary()), + Span::styled("Local", theme::accent()), + ])); + } + + lines.push(Line::from("")); + + // Actions hint + lines.push(Line::from(Span::styled("Actions:", theme::header()))); + lines.push(Line::from("")); + + let actions = if repo.disabled { + vec![ + ("[e]", "Enable"), + ("[r]", "Remove"), + ] + } else { + match status { + ItemStatus::Pending => vec![ + ("[i]", "Install"), + ("[e]", "Disable"), + ("[r]", "Remove"), + ], + ItemStatus::Installed => vec![ + ("[u]", "Update"), + ("[s]", "Select scripts"), + ("[p]", "Pin version"), + ("[e]", "Disable"), + ("[r]", "Remove"), + ], + _ => vec![ + ("[i]", "Install"), + ("[r]", "Remove"), + ], + } + }; + + for (key, desc) in actions { + lines.push(Line::from(vec![ + Span::styled(format!("{:6}", key), theme::keybind()), + Span::styled(desc, theme::keybind_desc()), + ])); + } + + let details = Paragraph::new(lines) + .wrap(Wrap { trim: true }); + + let details_area = Rect { + x: inner.x + 1, + y: inner.y + 1, + width: inner.width.saturating_sub(2), + height: inner.height.saturating_sub(2), + }; + + f.render_widget(details, details_area); +} diff --git a/src/tui/views/scripts.rs b/src/tui/views/scripts.rs new file mode 100644 index 0000000..6ad6c1b --- /dev/null +++ b/src/tui/views/scripts.rs @@ -0,0 +1,174 @@ +//! Scripts view - browse scripts by target + +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, + Frame, +}; + +use crate::tui::app::{App, Focus, Mode}; +use crate::tui::theme; +use crate::tui::widgets::{render_empty_list, render_filter_input, render_status_list, ItemStatus, StatusListItem}; + +/// Render the scripts view +pub fn render(f: &mut Frame, area: Rect, app: &mut App) { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(area); + + render_scripts_list(f, chunks[0], app); + render_script_details(f, chunks[1], app); + + // Render filter input if in filter mode + if app.mode == Mode::Filter { + let filter_area = Rect { + x: chunks[0].x + 1, + y: chunks[0].y + chunks[0].height - 2, + width: chunks[0].width - 2, + height: 1, + }; + render_filter_input(f, filter_area, &app.filter); + } +} + +/// Render the scripts list +fn render_scripts_list(f: &mut Frame, area: Rect, app: &mut App) { + // Collect filtered scripts data first to avoid borrow issues + let script_data: Vec<(String, String)> = app.filtered_scripts() + .iter() + .map(|s| (s.name.clone(), s.target.clone())) + .collect(); + + let is_empty = script_data.is_empty(); + let filter_empty = app.filter.is_empty(); + let is_focused = app.focus == Focus::List; + let in_filter_mode = app.mode == Mode::Filter; + let filter_str = app.filter.clone(); + + if is_empty { + let message = if filter_empty { + "No scripts installed\nGo to Repos view to install" + } else { + "No matching scripts" + }; + render_empty_list(f, area, "Scripts", message, is_focused); + return; + } + + let items: Vec = script_data + .iter() + .map(|(name, target)| StatusListItem { + text: name, + status: ItemStatus::Installed, + detail: Some(target), + }) + .collect(); + + let filter_display = if in_filter_mode || !filter_empty { + Some(filter_str.as_str()) + } else { + None + }; + + // Build title with target filter indicator + let title = if let Some(ref target) = app.target_filter { + format!("Scripts [{}]", target) + } else { + "Scripts".to_string() + }; + + render_status_list( + f, + area, + &title, + &items, + &mut app.list_state, + is_focused, + filter_display, + ); +} + +/// Render script details +fn render_script_details(f: &mut Frame, area: Rect, app: &App) { + let border_style = if app.focus == Focus::Details { + theme::border_focused() + } else { + theme::border() + }; + + let block = Block::default() + .title(" Script Details ") + .title_style(theme::title()) + .borders(Borders::ALL) + .border_style(border_style); + + let inner = block.inner(area); + f.render_widget(block, area); + + // Use cached scripts + let filtered = app.filtered_scripts(); + let selected = app.list_state.selected().and_then(|i| filtered.get(i)); + + let Some(script) = selected else { + let empty = Paragraph::new("Select a script") + .style(theme::text_muted()) + .alignment(ratatui::layout::Alignment::Center); + f.render_widget(empty, inner); + return; + }; + + let repo_display = if script.repo == "unknown" { + "not managed by empeve" + } else { + &script.repo + }; + + let lines: Vec = vec![ + Line::from(Span::styled(&script.name, theme::highlight())), + Line::from(""), + Line::from(vec![ + Span::styled("Repo: ", theme::text_secondary()), + Span::styled(repo_display, if script.repo == "unknown" { theme::text_muted() } else { theme::text() }), + ]), + Line::from(vec![ + Span::styled("Target: ", theme::text_secondary()), + Span::styled(&script.target, theme::text()), + ]), + Line::from(vec![ + Span::styled("Status: ", theme::text_secondary()), + Span::styled("✓ Installed", theme::success()), + ]), + Line::from(""), + Line::from(Span::styled("Actions:", theme::header())), + Line::from(""), + Line::from(vec![ + Span::styled("[e] ", theme::keybind()), + Span::styled("Enable/disable", theme::keybind_desc()), + ]), + Line::from(vec![ + Span::styled("[r] ", theme::keybind()), + Span::styled("Remove from target", theme::keybind_desc()), + ]), + Line::from(vec![ + Span::styled("[d] ", theme::keybind()), + Span::styled("View in repo", theme::keybind_desc()), + ]), + Line::from(vec![ + Span::styled("[t] ", theme::keybind()), + Span::styled("Filter by target", theme::keybind_desc()), + ]), + ]; + + let details = Paragraph::new(lines).wrap(Wrap { trim: true }); + + let details_area = Rect { + x: inner.x + 1, + y: inner.y + 1, + width: inner.width.saturating_sub(2), + height: inner.height.saturating_sub(2), + }; + + f.render_widget(details, details_area); +} diff --git a/src/tui/views/targets.rs b/src/tui/views/targets.rs new file mode 100644 index 0000000..1328c09 --- /dev/null +++ b/src/tui/views/targets.rs @@ -0,0 +1,190 @@ +//! Targets view - manage target mpv configurations + +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, + Frame, +}; + +use crate::tui::app::{App, Focus}; +use crate::tui::theme; +use crate::tui::widgets::{render_empty_list, render_status_list, ItemStatus, StatusListItem}; + +/// Render the targets view +pub fn render(f: &mut Frame, area: Rect, app: &mut App) { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(area); + + render_targets_list(f, chunks[0], app); + render_target_details(f, chunks[1], app); +} + +/// Render the targets list +fn render_targets_list(f: &mut Frame, area: Rect, app: &mut App) { + let targets = &app.config.targets; + + if targets.is_empty() { + render_empty_list( + f, + area, + "Targets", + "No targets configured\nPress 'a' to add one", + app.focus == Focus::List, + ); + return; + } + + let items: Vec = targets + .iter() + .map(|target| { + let status = if target.enabled { + if target.directories_exist() { + ItemStatus::Installed + } else { + ItemStatus::Error + } + } else { + ItemStatus::Disabled + }; + + StatusListItem { + text: &target.name, + status, + detail: None, + } + }) + .collect(); + + render_status_list( + f, + area, + "Targets", + &items, + &mut app.list_state, + app.focus == Focus::List, + None, + ); +} + +/// Render target details +fn render_target_details(f: &mut Frame, area: Rect, app: &App) { + let border_style = if app.focus == Focus::Details { + theme::border_focused() + } else { + theme::border() + }; + + let block = Block::default() + .title(" Target Details ") + .title_style(theme::title()) + .borders(Borders::ALL) + .border_style(border_style); + + let inner = block.inner(area); + f.render_widget(block, area); + + let targets = &app.config.targets; + let selected = app.list_state.selected().and_then(|i| targets.get(i)); + + let Some(target) = selected else { + let empty = Paragraph::new("Select a target") + .style(theme::text_muted()) + .alignment(ratatui::layout::Alignment::Center); + f.render_widget(empty, inner); + return; + }; + + let dirs_exist = target.directories_exist(); + + let expanded = target.expanded_path(); + + let mut lines: Vec = vec![ + Line::from(Span::styled(&target.name, theme::highlight())), + Line::from(""), + Line::from(vec![ + Span::styled("Path: ", theme::text_secondary()), + Span::styled(expanded.display().to_string(), theme::text()), + ]), + Line::from(vec![ + Span::styled("Status: ", theme::text_secondary()), + if target.enabled { + if dirs_exist { + Span::styled("✓ Enabled", theme::success()) + } else { + Span::styled("✗ Dirs missing", theme::error()) + } + } else { + Span::styled("○ Disabled", theme::text_muted()) + }, + ]), + Line::from(""), + Line::from(Span::styled("Directories:", theme::header())), + Line::from(""), + ]; + + // Show directory status + let dirs = [ + ("scripts", target.scripts_dir()), + ("script-opts", target.script_opts_dir()), + ("fonts", target.fonts_dir()), + ("shaders", target.shaders_dir()), + ]; + + for (name, path) in dirs { + // Check if it's a broken symlink (symlink exists but target doesn't) + let is_symlink = path.symlink_metadata().map(|m| m.file_type().is_symlink()).unwrap_or(false); + let exists = path.exists(); + + let (icon, style, status_text) = if exists { + ("✓", theme::success(), "") + } else if is_symlink { + ("⚠", theme::warning(), " (broken symlink)") + } else { + ("✗", theme::error(), "") + }; + + lines.push(Line::from(vec![ + Span::styled(format!(" {} ", icon), style), + Span::styled(name, if exists { theme::text() } else { theme::text_muted() }), + Span::styled(status_text, theme::warning()), + ])); + } + + lines.push(Line::from("")); + lines.push(Line::from(Span::styled("Actions:", theme::header()))); + lines.push(Line::from("")); + + if !dirs_exist { + lines.push(Line::from(vec![ + Span::styled("[c] ", theme::keybind()), + Span::styled("Create directories", theme::keybind_desc()), + ])); + } + + lines.push(Line::from(vec![ + Span::styled("[e] ", theme::keybind()), + Span::styled( + if target.enabled { "Disable" } else { "Enable" }, + theme::keybind_desc(), + ), + ])); + + lines.push(Line::from(vec![ + Span::styled("[r] ", theme::keybind()), + Span::styled("Remove target", theme::keybind_desc()), + ])); + + let details = Paragraph::new(lines).wrap(Wrap { trim: true }); + + let details_area = Rect { + x: inner.x + 1, + y: inner.y + 1, + width: inner.width.saturating_sub(2), + height: inner.height.saturating_sub(2), + }; + + f.render_widget(details, details_area); +} diff --git a/src/tui/widgets/help.rs b/src/tui/widgets/help.rs new file mode 100644 index 0000000..f337dd4 --- /dev/null +++ b/src/tui/widgets/help.rs @@ -0,0 +1,177 @@ +//! Help overlay widget showing keybindings + +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, + Frame, +}; + +use crate::tui::app::View; +use crate::tui::theme; +use crate::tui::widgets::popup::centered_rect; + +/// Keybinding entry +struct Keybind { + key: &'static str, + desc: &'static str, +} + +/// Render the help overlay +pub fn render_help(f: &mut Frame, area: Rect, current_view: View) { + let popup_area = centered_rect(70, 80, area); + + // Clear the background + f.render_widget(Clear, popup_area); + + let block = Block::default() + .title(" Help ") + .title_style(theme::title()) + .borders(Borders::ALL) + .border_style(theme::border_focused()) + .style(ratatui::style::Style::default().bg(theme::SURFACE0)); + + let inner = block.inner(popup_area); + f.render_widget(block, popup_area); + + // Split into columns + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(inner); + + // Left column: Navigation + let nav_bindings = [ + Keybind { key: "j/↓", desc: "Move down" }, + Keybind { key: "k/↑", desc: "Move up" }, + Keybind { key: "g/Home", desc: "Go to top" }, + Keybind { key: "G/End", desc: "Go to bottom" }, + Keybind { key: "Ctrl+d/PgDn", desc: "Page down" }, + Keybind { key: "Ctrl+u/PgUp", desc: "Page up" }, + Keybind { key: "Tab", desc: "Switch panel" }, + Keybind { key: "/", desc: "Filter mode" }, + Keybind { key: "Enter", desc: "Select" }, + Keybind { key: "Esc", desc: "Back/cancel" }, + Keybind { key: "q", desc: "Quit" }, + ]; + + let view_bindings = [ + Keybind { key: "D", desc: "Dashboard" }, + Keybind { key: "R", desc: "Repos" }, + Keybind { key: "S", desc: "Scripts" }, + Keybind { key: "C", desc: "Catalog" }, + Keybind { key: "T", desc: "Targets" }, + ]; + + let mut left_lines: Vec = vec![ + Line::from(Span::styled("Navigation", theme::header())), + Line::from(""), + ]; + + for kb in &nav_bindings { + left_lines.push(Line::from(vec![ + Span::styled(format!("{:14}", kb.key), theme::keybind()), + Span::styled(kb.desc, theme::keybind_desc()), + ])); + } + + left_lines.push(Line::from("")); + left_lines.push(Line::from(Span::styled("Views", theme::header()))); + left_lines.push(Line::from("")); + + for kb in &view_bindings { + left_lines.push(Line::from(vec![ + Span::styled(format!("{:14}", kb.key), theme::keybind()), + Span::styled(kb.desc, theme::keybind_desc()), + ])); + } + + let left_para = Paragraph::new(left_lines); + let left_area = Rect { + x: columns[0].x + 2, + y: columns[0].y + 1, + width: columns[0].width.saturating_sub(4), + height: columns[0].height.saturating_sub(2), + }; + f.render_widget(left_para, left_area); + + // Right column: View-specific bindings + let specific_bindings = match current_view { + View::Dashboard => vec![ + Keybind { key: "I", desc: "Install all" }, + Keybind { key: "U", desc: "Update all" }, + Keybind { key: "L", desc: "Lock versions" }, + Keybind { key: "X", desc: "Clean orphaned" }, + Keybind { key: "M", desc: "Import scripts" }, + Keybind { key: "H", desc: "Run doctor" }, + Keybind { key: "r", desc: "Reload config" }, + ], + View::Repos => vec![ + Keybind { key: "a", desc: "Add repo" }, + Keybind { key: "i", desc: "Install selected" }, + Keybind { key: "I", desc: "Install all" }, + Keybind { key: "u", desc: "Update selected" }, + Keybind { key: "U", desc: "Update all" }, + Keybind { key: "r", desc: "Remove selected" }, + Keybind { key: "p", desc: "Pin to commit" }, + Keybind { key: "e", desc: "Enable/disable" }, + Keybind { key: "s", desc: "Select scripts" }, + ], + View::Scripts => vec![ + Keybind { key: "e", desc: "Enable/disable" }, + Keybind { key: "r", desc: "Remove script" }, + Keybind { key: "d", desc: "View in repo" }, + Keybind { key: "t", desc: "Filter by target" }, + ], + View::Catalog => vec![ + Keybind { key: "a", desc: "Add to config" }, + Keybind { key: "Enter", desc: "View details" }, + ], + View::Targets => vec![ + Keybind { key: "a", desc: "Add target" }, + Keybind { key: "e", desc: "Enable/disable" }, + Keybind { key: "c", desc: "Create directories" }, + Keybind { key: "r", desc: "Remove target" }, + ], + }; + + let mut right_lines: Vec = vec![ + Line::from(Span::styled( + format!("{} Actions", current_view.label()), + theme::header(), + )), + Line::from(""), + ]; + + for kb in &specific_bindings { + right_lines.push(Line::from(vec![ + Span::styled(format!("{:14}", kb.key), theme::keybind()), + Span::styled(kb.desc, theme::keybind_desc()), + ])); + } + + let right_para = Paragraph::new(right_lines); + let right_area = Rect { + x: columns[1].x + 2, + y: columns[1].y + 1, + width: columns[1].width.saturating_sub(4), + height: columns[1].height.saturating_sub(2), + }; + f.render_widget(right_para, right_area); + + // Footer + let footer_area = Rect { + x: inner.x, + y: inner.y + inner.height - 2, + width: inner.width, + height: 1, + }; + + let footer = Line::from(vec![ + Span::styled("[?/Esc]", theme::keybind()), + Span::styled(" Close help", theme::keybind_desc()), + ]); + + let footer_widget = Paragraph::new(footer).alignment(Alignment::Center); + f.render_widget(footer_widget, footer_area); +} diff --git a/src/tui/widgets/list.rs b/src/tui/widgets/list.rs new file mode 100644 index 0000000..b891a14 --- /dev/null +++ b/src/tui/widgets/list.rs @@ -0,0 +1,190 @@ +//! Filterable list widget with vim-style navigation + +use ratatui::{ + layout::Rect, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, + Frame, +}; + +use crate::tui::theme; + +/// A list item with status indicator +pub struct StatusListItem<'a> { + pub text: &'a str, + pub status: ItemStatus, + pub detail: Option<&'a str>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ItemStatus { + None, + Installed, + Pending, + UpdateAvailable, + Error, + Disabled, +} + +impl ItemStatus { + pub fn icon(&self) -> &'static str { + match self { + ItemStatus::None => " ", + ItemStatus::Installed => "✓", + ItemStatus::Pending => "●", + ItemStatus::UpdateAvailable => "↑", + ItemStatus::Error => "✗", + ItemStatus::Disabled => "○", + } + } + + pub fn style(&self) -> Style { + match self { + ItemStatus::None => theme::text(), + ItemStatus::Installed => theme::success(), + ItemStatus::Pending => theme::accent(), + ItemStatus::UpdateAvailable => theme::warning(), + ItemStatus::Error => theme::error(), + ItemStatus::Disabled => theme::text_muted(), + } + } +} + +/// Render a filterable list with status indicators +pub fn render_status_list<'a>( + f: &mut Frame, + area: Rect, + title: &str, + items: &[StatusListItem<'a>], + state: &mut ListState, + focused: bool, + filter: Option<&str>, +) { + let border_style = if focused { + theme::border_focused() + } else { + theme::border() + }; + + let title_text = if let Some(filter) = filter { + if !filter.is_empty() { + format!(" {} [/{}] ", title, filter) + } else { + format!(" {} ", title) + } + } else { + format!(" {} ", title) + }; + + let block = Block::default() + .title(title_text) + .title_style(theme::title()) + .borders(Borders::ALL) + .border_style(border_style); + + let list_items: Vec = items + .iter() + .map(|item| { + let status_span = Span::styled( + format!("{} ", item.status.icon()), + item.status.style(), + ); + + let text_span = Span::styled(item.text, theme::text()); + + let detail_span = item.detail.map(|d| { + Span::styled(format!(" {}", d), theme::text_muted()) + }); + + let mut spans = vec![status_span, text_span]; + if let Some(detail) = detail_span { + spans.push(detail); + } + + ListItem::new(Line::from(spans)) + }) + .collect(); + + let list = List::new(list_items) + .block(block) + .highlight_style(theme::selected()) + .highlight_symbol("> "); + + f.render_stateful_widget(list, area, state); +} + +/// Render a simple text list (no status icons) +pub fn render_simple_list( + f: &mut Frame, + area: Rect, + title: &str, + items: &[String], + state: &mut ListState, + focused: bool, +) { + let border_style = if focused { + theme::border_focused() + } else { + theme::border() + }; + + let block = Block::default() + .title(format!(" {} ", title)) + .title_style(theme::title()) + .borders(Borders::ALL) + .border_style(border_style); + + let list_items: Vec = items + .iter() + .map(|item| ListItem::new(Line::from(Span::styled(item.as_str(), theme::text())))) + .collect(); + + let list = List::new(list_items) + .block(block) + .highlight_style(theme::selected()) + .highlight_symbol("> "); + + f.render_stateful_widget(list, area, state); +} + +/// Render an empty list placeholder +pub fn render_empty_list(f: &mut Frame, area: Rect, title: &str, message: &str, focused: bool) { + let border_style = if focused { + theme::border_focused() + } else { + theme::border() + }; + + let block = Block::default() + .title(format!(" {} ", title)) + .title_style(theme::title()) + .borders(Borders::ALL) + .border_style(border_style); + + let inner = block.inner(area); + f.render_widget(block, area); + + let text = Paragraph::new(message) + .style(theme::text_muted()) + .alignment(ratatui::layout::Alignment::Center); + + // Center vertically + let y_offset = inner.height / 2; + let centered_area = Rect { + x: inner.x, + y: inner.y + y_offset, + width: inner.width, + height: 1, + }; + + f.render_widget(text, centered_area); +} + +/// Render filter input at bottom of a list +pub fn render_filter_input(f: &mut Frame, area: Rect, filter: &str) { + let input = Paragraph::new(format!("/{}", filter)) + .style(theme::accent().add_modifier(Modifier::BOLD)); + + f.render_widget(input, area); +} diff --git a/src/tui/widgets/mod.rs b/src/tui/widgets/mod.rs new file mode 100644 index 0000000..e4bc08a --- /dev/null +++ b/src/tui/widgets/mod.rs @@ -0,0 +1,9 @@ +//! Reusable TUI widgets + +pub mod help; +pub mod list; +pub mod popup; + +pub use help::render_help; +pub use list::{render_empty_list, render_filter_input, render_simple_list, render_status_list, ItemStatus, StatusListItem}; +pub use popup::{centered_rect, centered_rect_fixed, render_confirm, render_input, render_message, render_script_selector}; diff --git a/src/tui/widgets/popup.rs b/src/tui/widgets/popup.rs new file mode 100644 index 0000000..fc138f5 --- /dev/null +++ b/src/tui/widgets/popup.rs @@ -0,0 +1,330 @@ +//! Modal popup widgets + +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::Modifier, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, + Frame, +}; + +use crate::tui::theme; + +/// Calculate centered popup area +pub fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(area); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] +} + +/// Calculate fixed-size centered popup area +pub fn centered_rect_fixed(width: u16, height: u16, area: Rect) -> Rect { + let x = area.x + (area.width.saturating_sub(width)) / 2; + let y = area.y + (area.height.saturating_sub(height)) / 2; + + Rect { + x, + y, + width: width.min(area.width), + height: height.min(area.height), + } +} + +/// Render a confirmation dialog +pub fn render_confirm(f: &mut Frame, area: Rect, title: &str, message: &str) { + let popup_area = centered_rect_fixed(50, 8, area); + + // Clear the background + f.render_widget(Clear, popup_area); + + let block = Block::default() + .title(format!(" {} ", title)) + .title_style(theme::title()) + .borders(Borders::ALL) + .border_style(theme::border_focused()) + .style(ratatui::style::Style::default().bg(theme::SURFACE0)); + + let inner = block.inner(popup_area); + f.render_widget(block, popup_area); + + // Message + let message_area = Rect { + x: inner.x + 1, + y: inner.y + 1, + width: inner.width.saturating_sub(2), + height: inner.height.saturating_sub(3), + }; + + let message_widget = Paragraph::new(message) + .style(theme::text()) + .wrap(Wrap { trim: true }); + + f.render_widget(message_widget, message_area); + + // Buttons + let button_area = Rect { + x: inner.x, + y: inner.y + inner.height - 2, + width: inner.width, + height: 1, + }; + + let buttons = Line::from(vec![ + Span::styled("[y]", theme::keybind()), + Span::styled(" Confirm ", theme::keybind_desc()), + Span::styled("[n/Esc]", theme::keybind()), + Span::styled(" Cancel", theme::keybind_desc()), + ]); + + let buttons_widget = Paragraph::new(buttons).alignment(Alignment::Center); + + f.render_widget(buttons_widget, button_area); +} + +/// Render an input dialog +pub fn render_input(f: &mut Frame, area: Rect, title: &str, prompt: &str, value: &str) { + let popup_area = centered_rect_fixed(60, 8, area); + + // Clear the background + f.render_widget(Clear, popup_area); + + let block = Block::default() + .title(format!(" {} ", title)) + .title_style(theme::title()) + .borders(Borders::ALL) + .border_style(theme::border_focused()) + .style(ratatui::style::Style::default().bg(theme::SURFACE0)); + + let inner = block.inner(popup_area); + f.render_widget(block, popup_area); + + // Prompt + let prompt_area = Rect { + x: inner.x + 1, + y: inner.y + 1, + width: inner.width.saturating_sub(2), + height: 1, + }; + + let prompt_widget = Paragraph::new(prompt).style(theme::text_secondary()); + f.render_widget(prompt_widget, prompt_area); + + // Input field + let input_area = Rect { + x: inner.x + 1, + y: inner.y + 2, + width: inner.width.saturating_sub(2), + height: 1, + }; + + let input_widget = Paragraph::new(format!("{}_", value)) + .style(theme::accent().add_modifier(Modifier::BOLD)); + f.render_widget(input_widget, input_area); + + // Buttons + let button_area = Rect { + x: inner.x, + y: inner.y + inner.height - 2, + width: inner.width, + height: 1, + }; + + let buttons = Line::from(vec![ + Span::styled("[Enter]", theme::keybind()), + Span::styled(" Submit ", theme::keybind_desc()), + Span::styled("[Esc]", theme::keybind()), + Span::styled(" Cancel", theme::keybind_desc()), + ]); + + let buttons_widget = Paragraph::new(buttons).alignment(Alignment::Center); + f.render_widget(buttons_widget, button_area); +} + +/// Render a message dialog (info or error) +pub fn render_message(f: &mut Frame, area: Rect, title: &str, message: &str, is_error: bool) { + let popup_area = centered_rect_fixed(50, 8, area); + + // Clear the background + f.render_widget(Clear, popup_area); + + let title_style = if is_error { + theme::error().add_modifier(Modifier::BOLD) + } else { + theme::title() + }; + + let block = Block::default() + .title(format!(" {} ", title)) + .title_style(title_style) + .borders(Borders::ALL) + .border_style(if is_error { theme::error() } else { theme::border_focused() }) + .style(ratatui::style::Style::default().bg(theme::SURFACE0)); + + let inner = block.inner(popup_area); + f.render_widget(block, popup_area); + + // Message + let message_area = Rect { + x: inner.x + 1, + y: inner.y + 1, + width: inner.width.saturating_sub(2), + height: inner.height.saturating_sub(3), + }; + + let message_widget = Paragraph::new(message) + .style(if is_error { theme::error() } else { theme::text() }) + .wrap(Wrap { trim: true }); + + f.render_widget(message_widget, message_area); + + // Button + let button_area = Rect { + x: inner.x, + y: inner.y + inner.height - 2, + width: inner.width, + height: 1, + }; + + let buttons = Line::from(vec![ + Span::styled("[Enter/Esc]", theme::keybind()), + Span::styled(" Close", theme::keybind_desc()), + ]); + + let buttons_widget = Paragraph::new(buttons).alignment(Alignment::Center); + f.render_widget(buttons_widget, button_area); +} + +/// Render a script selector dialog +pub fn render_script_selector( + f: &mut Frame, + area: Rect, + repo_id: &str, + scripts: &[crate::tui::app::ScriptSelectItem], + selected_index: usize, +) { + // Size based on script count (max 80% height) + let height = (scripts.len() as u16 + 6).min(area.height * 80 / 100).max(10); + let width = 60.min(area.width - 4); + let popup_area = centered_rect_fixed(width, height, area); + + // Clear the background + f.render_widget(Clear, popup_area); + + let block = Block::default() + .title(format!(" Scripts: {} ", repo_id)) + .title_style(theme::title()) + .borders(Borders::ALL) + .border_style(theme::border_focused()) + .style(ratatui::style::Style::default().bg(theme::SURFACE0)); + + let inner = block.inner(popup_area); + f.render_widget(block, popup_area); + + // Instructions at top + let header_area = Rect { + x: inner.x + 1, + y: inner.y, + width: inner.width.saturating_sub(2), + height: 1, + }; + + let header = Line::from(vec![ + Span::styled("Space", theme::keybind()), + Span::styled(" toggle ", theme::text_muted()), + Span::styled("a", theme::keybind()), + Span::styled(" all ", theme::text_muted()), + Span::styled("n", theme::keybind()), + Span::styled(" none", theme::text_muted()), + ]); + f.render_widget(Paragraph::new(header), header_area); + + // Script list + let list_area = Rect { + x: inner.x + 1, + y: inner.y + 2, + width: inner.width.saturating_sub(2), + height: inner.height.saturating_sub(5), + }; + + let visible_height = list_area.height as usize; + let scroll_offset = if selected_index >= visible_height { + selected_index - visible_height + 1 + } else { + 0 + }; + + let lines: Vec = scripts + .iter() + .enumerate() + .skip(scroll_offset) + .take(visible_height) + .map(|(i, script)| { + let is_selected = i == selected_index; + let checkbox = if script.enabled { "[✓]" } else { "[ ]" }; + let checkbox_style = if script.enabled { + theme::success() + } else { + theme::text_muted() + }; + + let name_style = if is_selected { + theme::highlight().add_modifier(Modifier::BOLD) + } else if script.enabled { + theme::text() + } else { + theme::text_muted() + }; + + let prefix = if is_selected { "▸ " } else { " " }; + let prefix_style = if is_selected { + theme::accent() + } else { + theme::text() + }; + + Line::from(vec![ + Span::styled(prefix, prefix_style), + Span::styled(checkbox, checkbox_style), + Span::styled(" ", theme::text()), + Span::styled(&script.name, name_style), + ]) + }) + .collect(); + + f.render_widget(Paragraph::new(lines), list_area); + + // Footer with buttons + let button_area = Rect { + x: inner.x, + y: inner.y + inner.height - 2, + width: inner.width, + height: 1, + }; + + let enabled_count = scripts.iter().filter(|s| s.enabled).count(); + let buttons = Line::from(vec![ + Span::styled(format!("{}/{}", enabled_count, scripts.len()), theme::text_muted()), + Span::styled(" ", theme::text()), + Span::styled("[Enter]", theme::keybind()), + Span::styled(" Save ", theme::keybind_desc()), + Span::styled("[Esc]", theme::keybind()), + Span::styled(" Cancel", theme::keybind_desc()), + ]); + + let buttons_widget = Paragraph::new(buttons).alignment(Alignment::Center); + f.render_widget(buttons_widget, button_area); +}