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:
@@ -21,5 +21,5 @@ pub struct Cli {
|
||||
pub verbose: bool,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: Commands,
|
||||
pub command: Option<Commands>,
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -8,3 +8,6 @@ pub mod paths;
|
||||
pub mod repo;
|
||||
pub mod script;
|
||||
pub mod ui;
|
||||
|
||||
#[cfg(feature = "tui")]
|
||||
pub mod tui;
|
||||
|
||||
28
src/main.rs
28
src/main.rs
@@ -19,10 +19,28 @@ fn main() {
|
||||
fn run() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
// Check for first-run scenario before executing commands
|
||||
check_first_run(&cli.command)?;
|
||||
// If no subcommand, launch TUI (or show help if TUI not compiled)
|
||||
let Some(command) = cli.command else {
|
||||
#[cfg(feature = "tui")]
|
||||
{
|
||||
return empeve::tui::run();
|
||||
}
|
||||
|
||||
match cli.command {
|
||||
#[cfg(not(feature = "tui"))]
|
||||
{
|
||||
// Print help and exit
|
||||
use clap::CommandFactory;
|
||||
let mut cmd = Cli::command();
|
||||
cmd.print_help()?;
|
||||
println!();
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
// Check for first-run scenario before executing commands
|
||||
check_first_run(&command)?;
|
||||
|
||||
match command {
|
||||
commands::Commands::Add { repo, rev, scripts } => {
|
||||
commands::add::execute(&repo, rev, scripts)?;
|
||||
}
|
||||
@@ -63,9 +81,9 @@ fn run() -> Result<()> {
|
||||
|
||||
/// Check if this is the first run and set up targets
|
||||
fn check_first_run(command: &commands::Commands) -> Result<()> {
|
||||
// Only check for certain commands
|
||||
// Only check for certain commands that need config
|
||||
let should_check = matches!(
|
||||
command,
|
||||
*command,
|
||||
commands::Commands::Status { .. }
|
||||
| commands::Commands::List { .. }
|
||||
| commands::Commands::Install { .. }
|
||||
|
||||
1057
src/tui/app.rs
Normal file
1057
src/tui/app.rs
Normal file
File diff suppressed because it is too large
Load Diff
119
src/tui/event.rs
Normal file
119
src/tui/event.rs
Normal 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
927
src/tui/mod.rs
Normal 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: {}):", ¤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),
|
||||
}
|
||||
}
|
||||
873
src/tui/ops.rs
Normal file
873
src/tui/ops.rs
Normal 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
85
src/tui/theme.rs
Normal 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
188
src/tui/views/catalog.rs
Normal 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
165
src/tui/views/dashboard.rs
Normal 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
7
src/tui/views/mod.rs
Normal 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
259
src/tui/views/repos.rs
Normal 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
174
src/tui/views/scripts.rs
Normal 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
190
src/tui/views/targets.rs
Normal 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
177
src/tui/widgets/help.rs
Normal 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
190
src/tui/widgets/list.rs
Normal 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
9
src/tui/widgets/mod.rs
Normal 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
330
src/tui/widgets/popup.rs
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user