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
This commit is contained in:
2026-01-26 12:47:21 +01:00
parent 6be61df8a0
commit 3afabc723b
19 changed files with 4814 additions and 16 deletions

View File

@@ -21,5 +21,5 @@ pub struct Cli {
pub verbose: bool,
#[command(subcommand)]
pub command: Commands,
pub command: Option<Commands>,
}

View File

@@ -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<PathBuf>,

View File

@@ -8,3 +8,6 @@ pub mod paths;
pub mod repo;
pub mod script;
pub mod ui;
#[cfg(feature = "tui")]
pub mod tui;

View File

@@ -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 { .. }

1057
src/tui/app.rs Normal file

File diff suppressed because it is too large Load Diff

119
src/tui/event.rs Normal file
View File

@@ -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<Event> {
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<char>;
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<char> {
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
}

927
src/tui/mod.rs Normal file
View File

@@ -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<String> = 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: {}):", &current[..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<String> = 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),
}
}

873
src/tui/ops.rs Normal file
View File

@@ -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<String>,
}
/// Result of an update operation
pub struct UpdateResult {
pub was_updated: bool,
pub old_commit: Option<String>,
pub new_commit: Option<String>,
pub is_pinned: bool,
}
/// Install a single repository
pub fn install_repo(
repo_id: &str,
config: &Config,
paths: &Paths,
) -> Result<InstallResult> {
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<UpdateResult> {
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<String>)> {
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<String>)> {
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<Option<String>> {
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<String> {
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<OrphanedScript>,
pub orphaned_repos: Vec<PathBuf>,
}
/// 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<CleanScanResult> {
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<String>,
}
/// Execute clean - remove orphaned scripts and repos
pub fn clean(config: &Config, paths: &Paths) -> Result<CleanResult> {
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<DiagnosticItem>,
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<String>,
}
/// Create lockfile from current repo state
pub fn lock(config: &Config, paths: &Paths) -> Result<LockResult> {
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<String>,
pub is_directory: bool,
pub already_managed: bool,
}
/// Result of import scan
pub struct ImportScanResult {
pub scripts: Vec<ImportableScript>,
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<String> {
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<String> {
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()
}

85
src/tui/theme.rs Normal file
View File

@@ -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)
}

188
src/tui/views/catalog.rs Normal file
View File

@@ -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<String> = 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<StatusListItem> = 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<Line> = 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);
}

165
src/tui/views/dashboard.rs Normal file
View File

@@ -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<Line> = 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]);
}

7
src/tui/views/mod.rs Normal file
View File

@@ -0,0 +1,7 @@
//! TUI views
pub mod catalog;
pub mod dashboard;
pub mod repos;
pub mod scripts;
pub mod targets;

259
src/tui/views/repos.rs Normal file
View File

@@ -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<String>)> = 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<StatusListItem> = 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<Line> = 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);
}

174
src/tui/views/scripts.rs Normal file
View File

@@ -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<StatusListItem> = 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<Line> = 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);
}

190
src/tui/views/targets.rs Normal file
View File

@@ -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<StatusListItem> = 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<Line> = 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);
}

177
src/tui/widgets/help.rs Normal file
View File

@@ -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<Line> = 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<Line> = 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);
}

190
src/tui/widgets/list.rs Normal file
View File

@@ -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<ListItem> = 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<ListItem> = 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);
}

9
src/tui/widgets/mod.rs Normal file
View File

@@ -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};

330
src/tui/widgets/popup.rs Normal file
View File

@@ -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<Line> = 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);
}