diff --git a/Cargo.toml b/Cargo.toml index 7bf326b..dba2491 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,11 +21,13 @@ toml = { version = "1.1.3" } [dependencies] clap = { features = ["derive"], version = "4.6.4" } crossbeam-queue = "0.3.13" +crossterm = { version = "0.28" } futures = { version = "0.3.33" } globset = "0.4.19" num_cpus = { version = "1.17.0" } once_cell = { version = "1.21.4" } rayon = "1.12.0" +ratatui = { version = "0.29" } tokio = { version = "1.53.1", features = ["full"] } walkdir = { version = "2.5.0" } @@ -43,7 +45,7 @@ autobins = false autoexamples = false autotests = false default-run = "Run" -description = "Run 🍺" +description = "Run 🍺" edition = "2024" include = [ "Source/**/*", diff --git a/Source/Fn/Binary/Command.rs b/Source/Fn/Binary/Command.rs index 0cd24ee..724448f 100644 --- a/Source/Fn/Binary/Command.rs +++ b/Source/Fn/Binary/Command.rs @@ -5,19 +5,10 @@ pub mod Index; pub mod Parallel; pub mod Sequential; -/// Defines and configures the command-line interface for the "Run" utility. -/// -/// This function uses the `clap` crate to specify all possible arguments, -/// flags, and options, including their help messages, default values, and -/// relationships. -/// -/// # Returns -/// -/// An `ArgMatches` object containing the parsed values from the command line. pub fn Fn() -> ArgMatches { ClapCommand::new("Run") .version(env!("CARGO_PKG_VERSION")) - .author("Source ✍🏻 Open 👐🏻 ") + .author("Source ✍🏻 Open 👐🏻 ") .about("A utility to run commands in directories matching a pattern.") .arg( Arg::new("File") @@ -35,11 +26,19 @@ pub fn Fn() -> ArgMatches { .display_order(2) .help("Execute commands in parallel across all found directories."), ) + .arg( + Arg::new("Tui") + .short('T') + .long("Tui") + .action(ArgAction::SetTrue) + .display_order(3) + .help("Launch the interactive TUI panel dashboard."), + ) .arg( Arg::new("Root") .short('R') .long("Root") - .display_order(3) + .display_order(4) .value_name("DIRECTORY") .help("The root directory to start the search from.") .default_value("."), @@ -49,14 +48,14 @@ pub fn Fn() -> ArgMatches { .short('E') .long("Exclude") .action(ArgAction::Append) - .display_order(4) + .display_order(5) .value_name("PATTERNS") .help("A space-separated list of glob patterns to exclude from the search.") .default_value("**/{node_modules,.git,target,dist,vendor}/**/*"), ) .arg( Arg::new("Pattern") - .display_order(5) + .display_order(6) .value_name("PATTERN") .required(true) .help("The file or directory name that identifies a target directory."), @@ -66,7 +65,7 @@ pub fn Fn() -> ArgMatches { .short('C') .long("Command") .action(ArgAction::Append) - .display_order(6) + .display_order(7) .value_name("COMMAND") .required(true) .allow_hyphen_values(true) diff --git a/Source/Fn/Binary/Command/Parallel.rs b/Source/Fn/Binary/Command/Parallel.rs index ad17f13..840d3e0 100644 --- a/Source/Fn/Binary/Command/Parallel.rs +++ b/Source/Fn/Binary/Command/Parallel.rs @@ -6,61 +6,43 @@ use std::{ use crossbeam_queue::ArrayQueue; use once_cell::sync::Lazy; use rayon::prelude::*; -use tokio::sync::{Mutex, mpsc}; +use tokio::sync::{Mutex, mpsc::Sender}; use crate::{ Fn::Binary::Command::Index, - Struct::Binary::Command::Entry::Struct as ExecutionOption, + Struct::{ + Binary::Command::Entry::Struct as ExecutionOption, + Event::Struct as Event, + }, }; pub mod GPG; pub mod Process; -/// A global, asynchronous mutex to ensure that only one GPG-related git command -/// runs at any given time, preventing conflicts with the GPG agent. -static GPG_MUTEX:Lazy> = Lazy::new(|| Mutex::new(())); +static GPG_MUTEX: Lazy> = Lazy::new(|| Mutex::new(())); -/// Represents a command that has been pre-processed for efficient execution. -/// -/// This struct holds the command string and booleans indicating whether the -/// command requires a GPG lock or an index-lock wait, preventing redundant -/// classification work inside the main execution loop. struct ProcessedCommand { - Command:String, - RequiresGpgLock:bool, - RequiresIndexLock:bool, + Command: String, + RequiresGpgLock: bool, + RequiresIndexLock: bool, } -/// Executes commands in parallel across multiple directories. -/// -/// This function orchestrates a complex workflow: -/// 1. Pre-classifies all user-provided commands for lock requirements. -/// 2. Filters the candidate paths to identify target execution directories. -/// 3. Sets up a multi-producer, single-consumer channel for work distribution. -/// 4. Spawns a pool of Tokio worker tasks. -/// 5. Each worker pulls a directory from the queue and executes all commands -/// **sequentially** within it via `sh -c`, preserving order and preventing -/// index-lock conflicts between chained git commands. -/// 6. Before any index-modifying git command the worker waits for -/// `.git/index.lock` to be released, handling both active locks from other -/// processes and stale locks left by previously killed processes. -/// 7. A dedicated output task prints results to stdout as they arrive. -pub async fn Fn(Option:ExecutionOption) { - // 1. Pre-process commands: classify lock requirements once. - let ProcessedCommands:Arc> = Arc::new( +pub async fn Fn(Option: ExecutionOption, Tx: Sender) { + let TotalCommands = Option.Command.len(); + + let ProcessedCommands: Arc> = Arc::new( Option .Command .par_iter() .map(|CommandString| { let RequiresGpgLock = GPG::Fn(CommandString); let RequiresIndexLock = Index::Fn(CommandString); - ProcessedCommand { Command:CommandString.clone(), RequiresGpgLock, RequiresIndexLock } + ProcessedCommand { Command: CommandString.clone(), RequiresGpgLock, RequiresIndexLock } }) .collect(), ); - // 2. Identify target directories based on the pattern. - let TargetDirs:Vec = Option + let TargetDirs: Vec = Option .Entry .into_par_iter() .filter_map(|CandidatePath| { @@ -73,30 +55,18 @@ pub async fn Fn(Option:ExecutionOption) { .collect(); if TargetDirs.is_empty() { + let _ = Tx.send(Event::AllDone).await; return; } - // 3. Set up the work queue and the results channel. - let (Tx, mut Rx) = mpsc::unbounded_channel::(); let WorkQueue = Arc::new(ArrayQueue::new(TargetDirs.len())); for Dir in TargetDirs { - WorkQueue - .push(Dir) - .expect("Queue should have enough capacity for all target directories."); + WorkQueue.push(Dir).expect("Queue capacity pre-allocated"); } - // 4. Spawn the output task to print results from the channel. - let OutputTask = tokio::spawn(async move { - while let Some(Output) = Rx.recv().await { - if !Output.trim().is_empty() { - println!("{}", Output); - } - } - }); - - // 5. Spawn worker tasks, one for each available CPU core. let WorkerCount = rayon::current_num_threads(); let mut WorkerHandles = Vec::with_capacity(WorkerCount); + for _ in 0..WorkerCount { let Queue = Arc::clone(&WorkQueue); let Commands = Arc::clone(&ProcessedCommands); @@ -104,32 +74,25 @@ pub async fn Fn(Option:ExecutionOption) { let WorkerHandle = tokio::spawn(async move { while let Some(Directory) = Queue.pop() { - let DirectoryString = Directory.to_string_lossy(); - - // Commands are executed sequentially within each directory. - // This preserves the user-supplied order (e.g. `git add` before - // `git commit`) and ensures only one command at a time holds - // the git index lock for this repository. - // - // All output for this directory is collected into a single - // buffer and sent atomically through the channel so that - // results from different directories do not interleave. - let mut DirectoryOutput = String::new(); - - 'commands: for Cmd in Commands.iter() { - // Wait for any in-flight index lock before writing to the index. + let DirectoryString = Directory.to_string_lossy().to_string(); + + let _ = Producer.send(Event::JobStarted { + Directory: DirectoryString.clone(), + Total: TotalCommands, + }).await; + + let mut AllSuccess = true; + + 'commands: for (CmdIdx, Cmd) in Commands.iter().enumerate() { if Cmd.RequiresIndexLock && !Index::Lock::Fn(&DirectoryString).await { - eprintln!( - "Skipping remaining commands in '{}': git index lock timed out.", - DirectoryString - ); + let _ = Producer.send(Event::IndexLockTimeout { + Directory: DirectoryString.clone(), + }).await; break 'commands; } - // Hold the GPG mutex for the entire duration of the process - // so the GPG agent is not shared concurrently. let Result = if Cmd.RequiresGpgLock { let _GpgLock = GPG_MUTEX.lock().await; Process::Fn(&Cmd.Command, &DirectoryString).await @@ -138,33 +101,47 @@ pub async fn Fn(Option:ExecutionOption) { }; match Result { - Ok(Output) => DirectoryOutput.push_str(&Output), + Ok(Output) => { + for Line in Output.lines() { + if !Line.trim().is_empty() { + let _ = Producer.send(Event::Line { + Directory: DirectoryString.clone(), + Text: Line.to_owned(), + IsStderr: false, + }).await; + } + } + } Err(Error) => { - eprintln!( - "Error executing command in '{}': {}", - DirectoryString, Error - ) - }, + let _ = Producer.send(Event::Line { + Directory: DirectoryString.clone(), + Text: format!("Error: {}", Error), + IsStderr: true, + }).await; + AllSuccess = false; + } } - } - if !DirectoryOutput.trim().is_empty() - && Producer.send(DirectoryOutput).is_err() - { - // Receiver dropped - stop processing entirely. - break; + let _ = Producer.send(Event::JobProgress { + Directory: DirectoryString.clone(), + Done: CmdIdx + 1, + Total: TotalCommands, + Success: AllSuccess, + }).await; } + + let _ = Producer.send(Event::JobFinished { + Directory: DirectoryString.clone(), + Success: AllSuccess, + }).await; } }); WorkerHandles.push(WorkerHandle); } - // Wait for all workers to complete their tasks. for Handle in WorkerHandles { Handle.await.expect("Worker task panicked."); } - // Drop the original producer to signal the output task to terminate. - drop(Tx); - OutputTask.await.expect("Output task panicked."); + let _ = Tx.send(Event::AllDone).await; } diff --git a/Source/Fn/Binary/Command/Sequential.rs b/Source/Fn/Binary/Command/Sequential.rs index d98a0fc..3cd428a 100644 --- a/Source/Fn/Binary/Command/Sequential.rs +++ b/Source/Fn/Binary/Command/Sequential.rs @@ -2,30 +2,24 @@ use std::path::{Path, PathBuf}; use tokio::io::AsyncBufReadExt; use tokio::process::Command as TokioCommand; +use tokio::sync::mpsc::Sender; use crate::{ Fn::Binary::Command::Index, - Struct::Binary::Command::Entry::Struct as ExecutionOption, + Struct::{ + Binary::Command::Entry::Struct as ExecutionOption, + Event::Struct as Event, + }, }; /// Executes commands sequentially, one directory at a time. /// -/// This function provides a non-parallel execution strategy. It iterates -/// through each target directory and runs all specified commands within it -/// before moving to the next. Commands are executed via `sh -c` so shell -/// features like `~`, `$HOME`, pipes, and redirects work. Before any -/// index-modifying git command it waits for `.git/index.lock` to be released, -/// handling both active locks held by other processes and stale locks left by -/// previously killed processes. -/// -/// # Arguments -/// -/// * `Option`: An `ExecutionOption` struct containing the commands, paths, and -/// pattern. -pub async fn Fn(Option:ExecutionOption) { - // Pre-classify commands for index-lock requirements once. - // Commands are kept as full strings so they can pass through `sh -c`. - let ProcessedCommands:Vec<(String, bool)> = Option +/// All output is emitted through `Tx` as typed `Event` variants so the caller +/// (CLI printer or TUI) can render it however it likes. No I/O happens here. +pub async fn Fn(Option: ExecutionOption, Tx: Sender) { + let TotalCommands = Option.Command.len(); + + let ProcessedCommands: Vec<(String, bool)> = Option .Command .iter() .map(|CommandString| { @@ -34,8 +28,7 @@ pub async fn Fn(Option:ExecutionOption) { }) .collect(); - // Identify target directories where commands will be executed. - let TargetDirs:Vec = Option + let TargetDirs: Vec = Option .Entry .into_iter() .filter_map(|CandidatePath| { @@ -48,50 +41,60 @@ pub async fn Fn(Option:ExecutionOption) { .collect(); 'directories: for Directory in TargetDirs { - let DirectoryString = Directory.to_string_lossy(); + let DirectoryString = Directory.to_string_lossy().to_string(); + + let _ = Tx.send(Event::JobStarted { + Directory: DirectoryString.clone(), + Total: TotalCommands, + }).await; - for (CommandString, RequiresIndexLock) in &ProcessedCommands { + let mut AllSuccess = true; + + for (CmdIdx, (CommandString, RequiresIndexLock)) in ProcessedCommands.iter().enumerate() { if CommandString.trim().is_empty() { continue; } - // Wait for any in-flight index lock before writing to the index. if *RequiresIndexLock && !Index::Lock::Fn(&DirectoryString).await { - eprintln!( - "Skipping remaining commands in '{}': git index lock timed out.", - DirectoryString - ); + let _ = Tx.send(Event::IndexLockTimeout { + Directory: DirectoryString.clone(), + }).await; continue 'directories; } - // Execute via `sh -c` so shell features ( ~ , $HOME , pipes , - // redirects) work. `sh` is available on every Unix. let mut Child = match TokioCommand::new("sh") .args(["-c", CommandString]) - .current_dir(DirectoryString.as_ref()) + .current_dir(&DirectoryString) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() { Ok(Child) => Child, Err(Error) => { - eprintln!("Failed to spawn command in '{}': {}", DirectoryString, Error); + let _ = Tx.send(Event::Line { + Directory: DirectoryString.clone(), + Text: format!("Failed to spawn: {}", Error), + IsStderr: true, + }).await; + AllSuccess = false; continue; } }; - // Stream stdout to the terminal line by line as it's produced. let StdoutReader = Child.stdout.take().unwrap(); { let mut Lines = tokio::io::BufReader::new(StdoutReader).lines(); while let Ok(Some(Line)) = Lines.next_line().await { if !Line.trim().is_empty() { - println!("{}", Line); + let _ = Tx.send(Event::Line { + Directory: DirectoryString.clone(), + Text: Line, + IsStderr: false, + }).await; } } } - // Capture stderr for error reporting. let mut StderrBuf = String::new(); { let StderrReader = Child.stderr.take().unwrap(); @@ -103,26 +106,38 @@ pub async fn Fn(Option:ExecutionOption) { .unwrap_or(0); } - let Status = Child.wait().await; + let ExitStatus = Child.wait().await; + let Success = matches!(ExitStatus, Ok(S) if S.success()); - match Status { - Ok(ExitStatus) if !ExitStatus.success() => { - eprintln!( - "Command failed in '{}' with status {}. Stderr: {}", - DirectoryString, - ExitStatus, - StderrBuf.trim() - ); - } - Err(Error) => { - eprintln!( - "Command in '{}' was terminated: {}", - DirectoryString, - Error - ); + if !StderrBuf.trim().is_empty() { + for Line in StderrBuf.lines() { + if !Line.trim().is_empty() { + let _ = Tx.send(Event::Line { + Directory: DirectoryString.clone(), + Text: Line.to_owned(), + IsStderr: true, + }).await; + } } - _ => {} } + + if !Success { + AllSuccess = false; + } + + let _ = Tx.send(Event::JobProgress { + Directory: DirectoryString.clone(), + Done: CmdIdx + 1, + Total: TotalCommands, + Success: Success, + }).await; } + + let _ = Tx.send(Event::JobFinished { + Directory: DirectoryString.clone(), + Success: AllSuccess, + }).await; } + + let _ = Tx.send(Event::AllDone).await; } diff --git a/Source/Fn/Tui/Input.rs b/Source/Fn/Tui/Input.rs new file mode 100644 index 0000000..02aeab1 --- /dev/null +++ b/Source/Fn/Tui/Input.rs @@ -0,0 +1,59 @@ +use crossterm::event::{ + Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, +}; + +use crate::Struct::Tui::AppState; + +pub fn Fn(State: &mut AppState, Ev: Event) -> bool { + match Ev { + Event::Key(KeyEvent { code, modifiers, .. }) => handle_key(State, code, modifiers), + Event::Mouse(MouseEvent { kind, column, row, .. }) => { + handle_mouse(State, kind, column, row); + false + } + _ => false, + } +} + +fn handle_key(State: &mut AppState, Code: KeyCode, Mods: KeyModifiers) -> bool { + match Code { + KeyCode::Char('q') | KeyCode::Char('Q') => { + State.ForceQuit = true; + true + } + KeyCode::Char('c') if Mods.contains(KeyModifiers::CONTROL) => { + State.ForceQuit = true; + true + } + KeyCode::Up | KeyCode::Char('k') => { + State.select_up(); + false + } + KeyCode::Down | KeyCode::Char('j') => { + State.select_down(); + false + } + KeyCode::PageUp => { + State.scroll_up(); + false + } + KeyCode::PageDown => { + State.scroll_down(); + false + } + KeyCode::Char('s') | KeyCode::Char('S') => { + State.toggle_autoscroll(); + false + } + _ => false, + } +} + +fn handle_mouse(State: &mut AppState, Kind: MouseEventKind, Column: u16, Row: u16) { + if !matches!(Kind, MouseEventKind::Down(MouseButton::Left)) { + return; + } + if Column < 40 && Row >= 2 { + State.click_row((Row - 2) as usize); + } +} diff --git a/Source/Fn/Tui/Render.rs b/Source/Fn/Tui/Render.rs new file mode 100644 index 0000000..6631a5e --- /dev/null +++ b/Source/Fn/Tui/Render.rs @@ -0,0 +1,151 @@ +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line as TLine, Span}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}, +}; + +use crate::Struct::Tui::{AppState, SPINNER, Status}; + +pub fn Fn(Frame: &mut Frame, State: &AppState) { + let Size = Frame.area(); + + let Outer = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(3), Constraint::Length(1)]) + .split(Size); + + let Panels = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(30), Constraint::Percentage(70)]) + .split(Outer[0]); + + render_dir_list(Frame, State, Panels[0]); + render_log(Frame, State, Panels[1]); + render_status_bar(Frame, State, Outer[1]); +} + +fn render_dir_list(Frame: &mut Frame, State: &AppState, Area: Rect) { + let Spinner = SPINNER[State.Tick % SPINNER.len()]; + + let Items: Vec = State + .Order + .iter() + .enumerate() + .map(|(I, Key)| { + let DS = &State.Map[Key]; + + let (Icon, IconStyle) = match &DS.Status { + Status::Pending => ("○ ".to_owned(), Style::default().fg(Color::DarkGray)), + Status::Running { Done, Total } => ( + format!("{} {}/{} ", Spinner, Done, Total), + Style::default().fg(Color::Yellow), + ), + Status::Done => ("✓ ".to_owned(), Style::default().fg(Color::Green)), + Status::Failed => ("✗ ".to_owned(), Style::default().fg(Color::Red)), + Status::Timeout => ("⚠ ".to_owned(), Style::default().fg(Color::Magenta)), + }; + + let Label = shorten(Key); + + let Row = TLine::from(vec![ + Span::styled(Icon, IconStyle), + Span::raw(Label), + ]); + + let Style = if I == State.Selected { + Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + + ListItem::new(Row).style(Style) + }) + .collect(); + + let Block = Block::default() + .title(" ◈ Directories ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + + let List = List::new(Items).block(Block); + let mut ListSt = ListState::default(); + ListSt.select(Some(State.Selected)); + Frame.render_stateful_widget(List, Area, &mut ListSt); +} + +fn render_log(Frame: &mut Frame, State: &AppState, Area: Rect) { + let (Title, Lines, Scroll) = match State.selected_dir() { + None => (" ◈ Log ".to_owned(), vec![], 0usize), + Some(Key) => { + let DS = &State.Map[Key]; + let Title = format!(" ◈ {} ", shorten(Key)); + let Lines: Vec = DS + .Lines + .iter() + .map(|(Text, IsStderr)| { + let Style = if *IsStderr { + Style::default().fg(Color::Red) + } else { + Style::default() + }; + TLine::from(Span::styled(Text.clone(), Style)) + }) + .collect(); + let Scroll = if DS.AutoScroll { + Lines.len().saturating_sub(Area.height as usize - 2) + } else { + DS.Scroll + }; + (Title, Lines, Scroll) + } + }; + + let Block = Block::default() + .title(Title) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + + let Para = Paragraph::new(Lines) + .block(Block) + .wrap(Wrap { trim: false }) + .scroll((Scroll as u16, 0)); + + Frame.render_widget(Para, Area); +} + +fn render_status_bar(Frame: &mut Frame, State: &AppState, Area: Rect) { + let Done = State.Order.iter().filter(|K| { + matches!(State.Map[*K].Status, Status::Done | Status::Failed | Status::Timeout) + }).count(); + let Total = State.Order.len(); + + let Status = if State.Done { + format!(" ✓ All done ({}/{}) — q quit", Done, Total) + } else { + format!(" {} Running {}/{} — ↑↓ select PgUp/PgDn scroll s auto-scroll q quit", + SPINNER[State.Tick % SPINNER.len()], Done, Total) + }; + + let Style = if State.Done { + Style::default().fg(Color::Green) + } else { + Style::default().fg(Color::DarkGray) + }; + + Frame.render_widget(Paragraph::new(Status).style(Style), Area); +} + +fn shorten(Path: &str) -> String { + let Parts: Vec<&str> = Path.trim_end_matches('/').rsplit('/').take(2).collect(); + if Parts.is_empty() { + return Path.to_owned(); + } + parts_join(Parts) +} + +fn parts_join(mut Parts: Vec<&str>) -> String { + Parts.reverse(); + Parts.join("/") +} diff --git a/Source/Fn/Tui/mod.rs b/Source/Fn/Tui/mod.rs new file mode 100644 index 0000000..22673f1 --- /dev/null +++ b/Source/Fn/Tui/mod.rs @@ -0,0 +1,102 @@ +pub mod Input; +pub mod Render; + +use std::io::stdout; +use std::time::Duration; + +use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture}, + execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, +}; +use ratatui::{Terminal, backend::CrosstermBackend}; +use tokio::sync::mpsc::Receiver; + +use crate::Struct::{Event::Struct as Event, Tui::AppState}; + +struct TerminalGuard; + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = disable_raw_mode(); + let _ = execute!(stdout(), LeaveAlternateScreen, DisableMouseCapture); + } +} + +pub async fn Fn(mut Rx: Receiver) { + enable_raw_mode().expect("Failed to enable raw mode"); + execute!(stdout(), EnterAlternateScreen, EnableMouseCapture) + .expect("Failed to enter alternate screen"); + + let _Guard = TerminalGuard; + + let Backend = CrosstermBackend::new(stdout()); + let mut Term = Terminal::new(Backend).expect("Failed to create terminal"); + + let mut State = AppState::new(); + let TickRate = Duration::from_millis(100); + + loop { + loop { + match Rx.try_recv() { + Ok(Event::JobStarted { Directory, Total }) => { + if !State.Map.contains_key(&Directory) { + State.Order.push(Directory.clone()); + State + .Map + .insert(Directory.clone(), crate::Struct::Tui::DirState::new(Directory, Total)); + } + } + Ok(Event::Line { Directory, Text, IsStderr }) => { + if let Some(DS) = State.Map.get_mut(&Directory) { + DS.Lines.push((Text, IsStderr)); + if DS.AutoScroll { + DS.Scroll = DS.Lines.len().saturating_sub(1); + } + } + } + Ok(Event::JobProgress { Directory, Done, Total, .. }) => { + if let Some(DS) = State.Map.get_mut(&Directory) { + DS.Status = crate::Struct::Tui::Status::Running { Done, Total }; + } + } + Ok(Event::JobFinished { Directory, Success }) => { + if let Some(DS) = State.Map.get_mut(&Directory) { + DS.Status = if Success { + crate::Struct::Tui::Status::Done + } else { + crate::Struct::Tui::Status::Failed + }; + } + } + Ok(Event::IndexLockTimeout { Directory }) => { + if let Some(DS) = State.Map.get_mut(&Directory) { + DS.Status = crate::Struct::Tui::Status::Timeout; + DS.Lines.push(("⚠ git index lock timed out".to_owned(), true)); + } + } + Ok(Event::AllDone) => { + State.Done = true; + } + Err(_) => break, + } + } + + Term.draw(|Frame| Render::Fn(Frame, &State)) + .expect("Failed to draw frame"); + + if event::poll(TickRate).unwrap_or(false) { + if let Ok(Ev) = event::read() { + if Input::Fn(&mut State, Ev) { + break; + } + } + } + + State.Tick = State.Tick.wrapping_add(1); + + if State.ForceQuit { + break; + } + } +} diff --git a/Source/Fn/mod.rs b/Source/Fn/mod.rs index a56e8ce..c962f5d 100644 --- a/Source/Fn/mod.rs +++ b/Source/Fn/mod.rs @@ -1 +1,2 @@ pub mod Binary; +pub mod Tui; diff --git a/Source/Struct/Binary/Command.rs b/Source/Struct/Binary/Command.rs index 6d51e45..3d6a6b9 100644 --- a/Source/Struct/Binary/Command.rs +++ b/Source/Struct/Binary/Command.rs @@ -1,13 +1,15 @@ pub mod Entry; pub mod Option; +use tokio::sync::mpsc; + /// The main command configuration struct that holds the execution logic. /// /// This struct's primary role is to create and hold a boxed closure (`Fn`) that /// encapsulates the entire application workflow. pub struct Struct { - pub Separator:Option::Separator, - pub Fn:Box std::pin::Pin + Send + 'static>> + Send + 'static>, + pub Separator: Option::Separator, + pub Fn: Box std::pin::Pin + Send + 'static>> + Send + 'static>, } impl Struct { @@ -18,8 +20,8 @@ impl Struct { /// strategy. pub fn Fn() -> Self { Self { - Separator:std::path::MAIN_SEPARATOR, - Fn:Box::new(|| { + Separator: std::path::MAIN_SEPARATOR, + Fn: Box::new(|| { Box::pin(async move { // This initialization pattern allows `Option::Fn` to access the `Separator` // from the `options_config` while still using the static `ARGS` for CLI @@ -27,12 +29,53 @@ impl Struct { let OptionsConfig = Self::Fn(); let CommandLineOptions = Option::Struct::Fn(OptionsConfig); let ExecutionOptions = Entry::Struct::Fn(&CommandLineOptions); + let IsTui = CommandLineOptions.Tui; + let IsParallel = ExecutionOptions.Parallel; + + let (Tx, Rx) = mpsc::unbounded_channel::(); - if ExecutionOptions.Parallel { - crate::Fn::Binary::Command::Parallel::Fn(ExecutionOptions).await; + if IsTui { + if IsParallel { + tokio::spawn( + crate::Fn::Binary::Command::Parallel::Fn(ExecutionOptions, Tx), + ); + } else { + tokio::spawn( + crate::Fn::Binary::Command::Sequential::Fn(ExecutionOptions, Tx), + ); + } + crate::Fn::Tui::Fn(Rx).await; } else { - crate::Fn::Binary::Command::Sequential::Fn(ExecutionOptions).await; - }; + use crate::Struct::Event::Struct as Event; + let PrintTask = tokio::spawn(async move { + let mut Rx = Rx; + while let Some(Ev) = Rx.recv().await { + match Ev { + Event::Line { Text, IsStderr, .. } => { + if IsStderr { + eprintln!("{}", Text); + } else { + println!("{}", Text); + } + } + Event::IndexLockTimeout { Directory } => { + eprintln!("Skipping '{}': git index lock timed out.", Directory); + } + Event::JobFinished { Directory, Success } if !Success => { + eprintln!("✗ Failed: {}", Directory); + } + _ => {} + } + } + }); + + if IsParallel { + crate::Fn::Binary::Command::Parallel::Fn(ExecutionOptions, Tx).await; + } else { + crate::Fn::Binary::Command::Sequential::Fn(ExecutionOptions, Tx).await; + } + PrintTask.await.ok(); + } }) }), } diff --git a/Source/Struct/Binary/Command/Option.rs b/Source/Struct/Binary/Command/Option.rs index da6d498..f2fc08a 100644 --- a/Source/Struct/Binary/Command/Option.rs +++ b/Source/Struct/Binary/Command/Option.rs @@ -16,44 +16,46 @@ pub type Separator = char; /// /// This ensures that `clap` argument parsing logic is executed only once, /// no matter how many times the configuration is accessed. -static ARGS:Lazy = Lazy::new(ParseClap); +static ARGS: Lazy = Lazy::new(ParseClap); /// A struct that holds the raw, parsed options from the command line. pub struct Struct { - pub Command:Command, - pub Exclude:Vec, - pub File:bool, - pub Parallel:Parallel, - pub Pattern:Pattern, - pub Root:String, - pub Separator:Separator, + pub Command: Command, + pub Exclude: Vec, + pub File: bool, + pub Parallel: Parallel, + pub Pattern: Pattern, + pub Root: String, + pub Separator: Separator, + pub Tui: bool, } impl Struct { /// Creates a new `Struct` instance from the statically parsed `clap` /// arguments. - pub fn Fn(_Option:CommandStruct) -> Self { + pub fn Fn(_Option: CommandStruct) -> Self { Self { - File:ARGS.get_flag("File"), - Parallel:ARGS.get_flag("Parallel"), - Root:ARGS.get_one::("Root").expect("Root argument is required.").to_owned(), - Exclude:ARGS + File: ARGS.get_flag("File"), + Parallel: ARGS.get_flag("Parallel"), + Tui: ARGS.get_flag("Tui"), + Root: ARGS.get_one::("Root").expect("Root argument is required.").to_owned(), + Exclude: ARGS .get_many::("Exclude") .unwrap_or_default() .flat_map(|Value| Value.split_whitespace()) .map(String::from) .collect::>(), - Pattern:ARGS + Pattern: ARGS .get_one::("Pattern") .expect("Pattern argument is required.") .to_owned(), - Command:ARGS + Command: ARGS .get_many::("Command") .expect("Command argument is required.") .cloned() .collect(), // The separator is passed through from the initial config. - Separator:_Option.Separator, + Separator: _Option.Separator, } } } diff --git a/Source/Struct/Event.rs b/Source/Struct/Event.rs new file mode 100644 index 0000000..f168099 --- /dev/null +++ b/Source/Struct/Event.rs @@ -0,0 +1,21 @@ +#[derive(Debug, Clone)] +pub enum Struct { + JobStarted { + Directory: String, + Total: usize, + }, + Line { + Directory: String, + Text: String, + IsStderr: bool, + }, + JobProgress { + Directory: String, + Done: usize, + Total: usize, + Success: bool, + }, + JobFinished { Directory: String, Success: bool }, + IndexLockTimeout { Directory: String }, + AllDone, +} diff --git a/Source/Struct/Tui.rs b/Source/Struct/Tui.rs new file mode 100644 index 0000000..edea314 --- /dev/null +++ b/Source/Struct/Tui.rs @@ -0,0 +1,106 @@ +use std::collections::HashMap; + +#[derive(Debug, Clone, PartialEq)] +pub enum Status { + Pending, + Running { Done: usize, Total: usize }, + Done, + Failed, + Timeout, +} + +#[derive(Debug, Clone)] +pub struct DirState { + pub Directory: String, + pub Status: Status, + pub Lines: Vec<(String, bool)>, + pub AutoScroll: bool, + pub Scroll: usize, +} + +impl DirState { + pub fn new(Directory: String, Total: usize) -> Self { + Self { + Directory, + Status: Status::Running { Done: 0, Total }, + Lines: Vec::new(), + AutoScroll: true, + Scroll: 0, + } + } +} + +pub const SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +pub struct AppState { + pub Order: Vec, + pub Map: HashMap, + pub Selected: usize, + pub Done: bool, + pub Tick: usize, + pub ForceQuit: bool, +} + +impl AppState { + pub fn new() -> Self { + Self { + Order: Vec::new(), + Map: HashMap::new(), + Selected: 0, + Done: false, + Tick: 0, + ForceQuit: false, + } + } + + pub fn selected_dir(&self) -> Option<&str> { + self.Order.get(self.Selected).map(String::as_str) + } + + pub fn select_up(&mut self) { + if self.Selected > 0 { + self.Selected -= 1; + } + } + + pub fn select_down(&mut self) { + if self.Selected + 1 < self.Order.len() { + self.Selected += 1; + } + } + + pub fn scroll_up(&mut self) { + if let Some(Key) = self.selected_dir() { + if let Some(State) = self.Map.get_mut(Key) { + State.AutoScroll = false; + State.Scroll = State.Scroll.saturating_sub(3); + } + } + } + + pub fn scroll_down(&mut self) { + if let Some(Key) = self.selected_dir().map(str::to_owned) { + if let Some(State) = self.Map.get_mut(&Key) { + let Max = State.Lines.len().saturating_sub(1); + State.Scroll = (State.Scroll + 3).min(Max); + if State.Scroll >= Max { + State.AutoScroll = true; + } + } + } + } + + pub fn toggle_autoscroll(&mut self) { + if let Some(Key) = self.selected_dir().map(str::to_owned) { + if let Some(State) = self.Map.get_mut(&Key) { + State.AutoScroll = !State.AutoScroll; + } + } + } + + pub fn click_row(&mut self, Row: usize) { + if Row < self.Order.len() { + self.Selected = Row; + } + } +} diff --git a/Source/Struct/mod.rs b/Source/Struct/mod.rs index a56e8ce..ae904ec 100644 --- a/Source/Struct/mod.rs +++ b/Source/Struct/mod.rs @@ -1 +1,3 @@ pub mod Binary; +pub mod Event; +pub mod Tui;