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

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

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

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

928 lines
33 KiB
Rust

//! 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),
}
}