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
928 lines
33 KiB
Rust
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: {}):", ¤t[..7.min(current.len())]),
|
|
PopupAction::PinRepo(repo_id, String::new()),
|
|
);
|
|
}
|
|
} else if key.is_char('s') {
|
|
// Open script selector for this repo
|
|
if let Some(repo) = app.selected_repo() {
|
|
let repo_id = repo.repo.clone();
|
|
app.show_script_selector(&repo_id);
|
|
}
|
|
} else if key.is_char('I') {
|
|
// Install all (non-blocking)
|
|
app.spawn_task(TaskKind::InstallAll);
|
|
} else if key.is_char('U') {
|
|
// Update all (non-blocking)
|
|
app.spawn_task(TaskKind::UpdateAll);
|
|
}
|
|
}
|
|
|
|
/// Handle scripts view input
|
|
fn handle_scripts_input(app: &mut App, key: crossterm::event::KeyEvent) {
|
|
if key.is_char('e') {
|
|
// Toggle script enabled in repo config
|
|
let selected = app.list_state.selected();
|
|
let filtered = app.filtered_scripts();
|
|
if let Some(idx) = selected {
|
|
if let Some(script) = filtered.get(idx) {
|
|
let script_name = script.name.clone();
|
|
let repo_id = script.repo.clone();
|
|
|
|
if repo_id == "unknown" {
|
|
app.set_status(format!("'{}' is not managed by empeve", script_name), true);
|
|
return;
|
|
}
|
|
|
|
// Toggle in repo's scripts filter
|
|
if let Some(repo) = app.config.repos.iter_mut().find(|r| r.repo == repo_id) {
|
|
if let Some(ref mut scripts) = repo.scripts {
|
|
// Has filter - toggle membership
|
|
if scripts.contains(&script_name) {
|
|
scripts.retain(|s| s != &script_name);
|
|
app.set_status(format!("Disabled {} (reinstall to apply)", script_name), false);
|
|
} else {
|
|
scripts.push(script_name.clone());
|
|
app.set_status(format!("Enabled {} (reinstall to apply)", script_name), false);
|
|
}
|
|
} else {
|
|
// No filter - add one excluding this script
|
|
use crate::repo::ScriptDiscovery;
|
|
let repo_path = app.paths.repo_path(&repo_id);
|
|
let all_scripts: Vec<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),
|
|
}
|
|
}
|