From 03f2691d8b71dda8cc0bb63716adc3eb2f028934 Mon Sep 17 00:00:00 2001 From: Nikola Hristov Date: Wed, 22 Jul 2026 12:27:00 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(tui):=20Batch=201=20=E2=80=94=20event?= =?UTF-8?q?=20bus,=20AppState,=20panel=20layout,=20-T=20flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the full scaffold for `Run -T`: - Source/Struct/Event.rs — typed event enum (JobStarted, Line, JobFinished, AllDone) - Source/Struct/Tui.rs — AppState: panel list, log buffers, status, selection, tick - Source/Fn/Tui/mod.rs — terminal init/restore guard + main app loop (crossterm + ratatui) - Source/Fn/Tui/Render.rs — two-panel layout: dir list (left) + log viewer (right) - Source/Fn/Tui/Input.rs — keyboard + mouse click dispatch - Source/Fn/Binary/Command/Sequential.rs — inject Sender, remove direct println/eprintln - Source/Fn/Binary/Command/Parallel.rs — inject Sender, remove direct println/eprintln - Source/Fn/Binary/Command.rs — add -T / --Tui flag - Source/Struct/Binary/Command.rs — wire Tui flag through Option and dispatch to Tui::Fn - Source/Struct/Binary/Command/Option.rs — add Tui: bool field - Source/Fn/mod.rs — pub mod Tui - Source/Struct/mod.rs — pub mod Event + Tui - Cargo.toml — add ratatui, crossterm, unicode-width under [tui] feature --- Cargo.toml | 4 +- Source/Fn/Binary/Command.rs | 26 ++-- Source/Fn/Binary/Command/Parallel.rs | 131 ++++++++----------- Source/Fn/Binary/Command/Sequential.rs | 113 +++++++++------- Source/Fn/Tui/Input.rs | 79 ++++++++++++ Source/Fn/Tui/Render.rs | 170 +++++++++++++++++++++++++ Source/Fn/Tui/mod.rs | 114 +++++++++++++++++ Source/Fn/mod.rs | 1 + Source/Struct/Binary/Command.rs | 70 +++++++--- Source/Struct/Binary/Command/Option.rs | 17 +-- Source/Struct/Event.rs | 36 ++++++ Source/Struct/Tui.rs | 126 ++++++++++++++++++ Source/Struct/mod.rs | 2 + 13 files changed, 726 insertions(+), 163 deletions(-) create mode 100644 Source/Fn/Tui/Input.rs create mode 100644 Source/Fn/Tui/Render.rs create mode 100644 Source/Fn/Tui/mod.rs create mode 100644 Source/Struct/Event.rs create mode 100644 Source/Struct/Tui.rs 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..7713f5a 100644 --- a/Source/Fn/Binary/Command.rs +++ b/Source/Fn/Binary/Command.rs @@ -7,17 +7,11 @@ 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. +/// Adds `-T` / `--Tui` to launch the interactive TUI panel mode. 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 +29,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 +51,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 +68,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..5c3b77a 100644 --- a/Source/Fn/Binary/Command/Parallel.rs +++ b/Source/Fn/Binary/Command/Parallel.rs @@ -6,25 +6,21 @@ 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(())); -/// 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, @@ -33,20 +29,11 @@ struct ProcessedCommand { /// 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. +/// Identical logic to the previous implementation; the only change is that all +/// output is routed through `Tx` (typed `Event`) instead of `println!`. +pub async fn Fn(Option:ExecutionOption, Tx:Sender) { + let TotalCommands = Option.Command.len(); + let ProcessedCommands:Arc> = Arc::new( Option .Command @@ -59,7 +46,6 @@ pub async fn Fn(Option:ExecutionOption) { .collect(), ); - // 2. Identify target directories based on the pattern. let TargetDirs:Vec = Option .Entry .into_par_iter() @@ -73,30 +59,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 +78,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 +105,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..82e22ad 100644 --- a/Source/Fn/Binary/Command/Sequential.rs +++ b/Source/Fn/Binary/Command/Sequential.rs @@ -2,29 +2,23 @@ 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`. +/// 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() @@ -34,7 +28,6 @@ pub async fn Fn(Option:ExecutionOption) { }) .collect(); - // Identify target directories where commands will be executed. let TargetDirs:Vec = Option .Entry .into_iter() @@ -48,50 +41,62 @@ 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. + // Stream stdout line-by-line. 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. + // Capture stderr. let mut StderrBuf = String::new(); { let StderrReader = Child.stderr.take().unwrap(); @@ -103,26 +108,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, + }).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..390081b --- /dev/null +++ b/Source/Fn/Tui/Input.rs @@ -0,0 +1,79 @@ +use crossterm::event::{ + Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, +}; + +use crate::Struct::Tui::AppState; + +/// Process one crossterm event. Returns `true` if the app should quit. +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 { + // Quit + KeyCode::Char('q') | KeyCode::Char('Q') => { + State.ForceQuit = true; + true + } + KeyCode::Char('c') if Mods.contains(KeyModifiers::CONTROL) => { + State.ForceQuit = true; + true + } + + // Directory navigation + KeyCode::Up | KeyCode::Char('k') => { + State.select_up(); + false + } + KeyCode::Down | KeyCode::Char('j') => { + State.select_down(); + false + } + + // Log scroll + KeyCode::PageUp => { + State.scroll_up(); + false + } + KeyCode::PageDown => { + State.scroll_down(); + false + } + + // Toggle auto-scroll for the selected directory + KeyCode::Char('s') | KeyCode::Char('S') => { + State.toggle_autoscroll(); + false + } + + _ => false, + } +} + +/// Handle mouse clicks: clicking a row in the left panel selects that directory. +/// +/// Left panel is the first 30 % of the terminal width; we subtract the 1-char +/// border and the header row to compute the list row index. +fn handle_mouse(State:&mut AppState, Kind:MouseEventKind, Column:u16, Row:u16) { + if !matches!(Kind, MouseEventKind::Down(MouseButton::Left)) { + return; + } + + // Approximate: terminal width is not available here without passing it in, + // so we use the column heuristic: left panel spans columns 0 .. (width*0.3). + // We instead use a fixed threshold of 40 columns which covers most terminals. + // This is replaced by exact bounds in a future batch once we thread area + // coordinates through Input. + if Column < 40 && Row >= 2 { + // Row 0 = border, Row 1 = title, Row 2+ = list items. + 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..fe52b0b --- /dev/null +++ b/Source/Fn/Tui/Render.rs @@ -0,0 +1,170 @@ +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}; + +/// Top-level render function — called once per tick. +/// +/// Layout (horizontal split): +/// ┌────────────────┬──────────────────────────────────────┐ +/// │ Directories │ Log │ +/// │ (30 % width) │ (70 % width) │ +/// └────────────────┴──────────────────────────────────────┘ +/// Status bar spans the full width at the bottom (1 line). +pub fn Fn(Frame:&mut Frame, State:&AppState) { + let Size = Frame.area(); + + // Reserve 1 row at the bottom for the status bar. + 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]); +} + +// ─── Left panel: directory list ────────────────────────────────────────────── + +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)), + }; + + // Shorten long paths: keep the last two path components only. + 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); +} + +// ─── Right panel: log viewer ───────────────────────────────────────────────── + +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); +} + +// ─── Bottom status bar ──────────────────────────────────────────────────────── + +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); +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/// Keep at most the last two path components to fit narrow panels. +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..ec94fd0 --- /dev/null +++ b/Source/Fn/Tui/mod.rs @@ -0,0 +1,114 @@ +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}; + +/// RAII guard that restores the terminal unconditionally on drop. +struct TerminalGuard; + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = disable_raw_mode(); + let _ = execute!(stdout(), LeaveAlternateScreen, DisableMouseCapture); + } +} + +/// Main TUI entry point. Owns the terminal and drives the event loop. +/// +/// * `Rx` — receives execution events from the Sequential / Parallel engine. +/// +/// The loop ticks at 100 ms so the spinner animates smoothly without burning +/// CPU. Keyboard and mouse events are handled surgically between ticks. +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 { + // 1. Drain all pending execution events (non-blocking). + 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, // channel empty or closed + } + } + + // 2. Render the current frame. + Term.draw(|Frame| Render::Fn(Frame, &State)) + .expect("Failed to draw frame"); + + // 3. Poll for input with a timeout equal to the tick rate. + if event::poll(TickRate).unwrap_or(false) { + if let Ok(Ev) = event::read() { + if Input::Fn(&mut State, Ev) { + break; // quit requested + } + } + } + + State.Tick = State.Tick.wrapping_add(1); + + // 4. If done and force-quit requested, exit. + 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..e7d32a3 100644 --- a/Source/Struct/Binary/Command.rs +++ b/Source/Struct/Binary/Command.rs @@ -1,38 +1,78 @@ pub mod Entry; pub mod Option; -/// The main command configuration struct that holds the execution logic. +use tokio::sync::mpsc; + +/// The main command configuration struct. /// -/// This struct's primary role is to create and hold a boxed closure (`Fn`) that -/// encapsulates the entire application workflow. +/// The `Fn` closure now creates an `mpsc` channel and routes its sender into +/// the execution engine. In TUI mode the receiver is handed to `Tui::Fn`; in +/// CLI mode a lightweight printer task drains it with `println!`. pub struct Struct { pub Separator:Option::Separator, pub Fn:Box std::pin::Pin + Send + 'static>> + Send + 'static>, } impl Struct { - /// Constructs the command struct and its main execution closure. - /// - /// The returned `Fn` closure, when called, will handle everything from - /// parsing CLI arguments to selecting the parallel or sequential execution - /// strategy. pub fn Fn() -> Self { Self { 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 - // parsing. let OptionsConfig = Self::Fn(); let CommandLineOptions = Option::Struct::Fn(OptionsConfig); let ExecutionOptions = Entry::Struct::Fn(&CommandLineOptions); + let IsTui = CommandLineOptions.Tui; + let IsParallel = ExecutionOptions.Parallel; + + // Unbounded channel — execution engines are never back-pressured + // by a slow consumer. + let (Tx, Rx) = mpsc::unbounded_channel::(); - if ExecutionOptions.Parallel { - crate::Fn::Binary::Command::Parallel::Fn(ExecutionOptions).await; + if IsTui { + // Spawn execution in the background; TUI runs on this task. + 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; - }; + // CLI mode: plain printer task drains the channel. + 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..a53d7a0 100644 --- a/Source/Struct/Binary/Command/Option.rs +++ b/Source/Struct/Binary/Command/Option.rs @@ -3,22 +3,16 @@ use once_cell::sync::Lazy; use crate::{Fn::Binary::Command::Fn as ParseClap, Struct::Binary::Command::Struct as CommandStruct}; -/// A type alias for a list of command strings. pub type Command = Vec; -/// A type alias for the boolean `Parallel` flag. pub type Parallel = bool; -/// A type alias for the `Pattern` string. pub type Pattern = String; -/// A type alias for the path separator character. pub type Separator = char; -/// Caches the parsed command-line arguments in a thread-safe, static variable. -/// -/// 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); -/// A struct that holds the raw, parsed options from the command line. +/// Raw parsed options from the command line. +/// +/// `Tui` is `true` when `-T` / `--Tui` was passed. pub struct Struct { pub Command:Command, pub Exclude:Vec, @@ -27,15 +21,15 @@ pub struct Struct { 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 { Self { 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") @@ -52,7 +46,6 @@ impl Struct { .expect("Command argument is required.") .cloned() .collect(), - // The separator is passed through from the initial config. Separator:_Option.Separator, } } diff --git a/Source/Struct/Event.rs b/Source/Struct/Event.rs new file mode 100644 index 0000000..b8ffeed --- /dev/null +++ b/Source/Struct/Event.rs @@ -0,0 +1,36 @@ +/// A single typed event emitted by an execution engine (Sequential or +/// Parallel) and consumed by either the plain CLI printer or the TUI app. +/// +/// Both engines emit these instead of calling `println!`/`eprintln!` directly, +/// keeping execution logic completely I/O-free and making the TUI a zero-cost +/// opt-in. +#[derive(Debug, Clone)] +pub enum Struct { + /// A directory has been picked up and its first command is about to run. + JobStarted { + Directory:String, + /// Total number of commands queued for this directory. + Total:usize, + }, + /// One line of stdout or stderr arrived from a running command. + Line { + Directory:String, + Text:String, + /// `true` → stderr (rendered in red in the TUI). + IsStderr:bool, + }, + /// A single command inside a directory finished. + JobProgress { + Directory:String, + /// Index of the command that just finished (0-based). + Done:usize, + Total:usize, + Success:bool, + }, + /// All commands inside a directory have finished. + JobFinished { Directory:String, Success:bool }, + /// The index-lock timed out; remaining commands for this directory skipped. + IndexLockTimeout { Directory:String }, + /// Every directory has been processed — engines send this last. + AllDone, +} diff --git a/Source/Struct/Tui.rs b/Source/Struct/Tui.rs new file mode 100644 index 0000000..9c5099c --- /dev/null +++ b/Source/Struct/Tui.rs @@ -0,0 +1,126 @@ +use std::collections::HashMap; + +/// Status of one directory entry shown in the left panel. +#[derive(Debug, Clone, PartialEq)] +pub enum Status { + Pending, + Running { Done:usize, Total:usize }, + Done, + Failed, + Timeout, +} + +/// Per-directory state kept by the TUI app. +#[derive(Debug, Clone)] +pub struct DirState { + pub Directory:String, + pub Status:Status, + /// Accumulated output lines (stdout + stderr) for the log panel. + pub Lines:Vec<(String, bool)>, // (text, is_stderr) + /// Whether the log panel should auto-scroll to the bottom. + pub AutoScroll:bool, + /// Current scroll offset in the log panel for this directory. + 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, + } + } +} + +/// Spinner frames cycled on each tick for running jobs. +pub const SPINNER:&[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +/// The complete application state owned by the TUI event loop. +pub struct AppState { + /// Ordered directory keys (insertion order = discovery order). + pub Order:Vec, + /// Per-directory state, keyed by directory path string. + pub Map:HashMap, + /// Currently selected row in the left panel (0-based). + pub Selected:usize, + /// Whether execution is fully complete. + pub Done:bool, + /// Monotonic tick counter — used to animate the spinner. + pub Tick:usize, + /// If `true`, `q` / Ctrl-C quits immediately even while running. + pub ForceQuit:bool, +} + +impl AppState { + pub fn new() -> Self { + Self { + Order:Vec::new(), + Map:HashMap::new(), + Selected:0, + Done:false, + Tick:0, + ForceQuit:false, + } + } + + /// Returns the currently selected directory key, if any. + pub fn selected_dir(&self) -> Option<&str> { + self.Order.get(self.Selected).map(String::as_str) + } + + /// Move selection up by 1, clamping at 0. + pub fn select_up(&mut self) { + if self.Selected > 0 { + self.Selected -= 1; + } + } + + /// Move selection down by 1, clamping at last entry. + pub fn select_down(&mut self) { + if self.Selected + 1 < self.Order.len() { + self.Selected += 1; + } + } + + /// Scroll the log panel of the selected directory up. + 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); + } + } + } + + /// Scroll the log panel of the selected directory down. + 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; + } + } + } + } + + /// Toggle auto-scroll for the selected directory. + 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; + } + } + } + + /// Click on a row in the left panel (0-based row index inside the list). + 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; From 60b1559e44a405bd7fa531e630006a676f667496 Mon Sep 17 00:00:00 2001 From: Nikola Hristov Date: Wed, 22 Jul 2026 12:32:24 +0300 Subject: [PATCH 2/2] style: restore em quad (U+3000) spacing after colons in all modified files --- Source/Fn/Binary/Command.rs | 3 -- Source/Fn/Binary/Command/Parallel.rs | 50 +++++++++--------- Source/Fn/Binary/Command/Sequential.rs | 44 ++++++++-------- Source/Fn/Tui/Input.rs | 26 ++-------- Source/Fn/Tui/Render.rs | 39 ++++---------- Source/Fn/Tui/mod.rs | 20 ++------ Source/Struct/Binary/Command.rs | 27 +++++----- Source/Struct/Binary/Command/Option.rs | 51 +++++++++++-------- Source/Struct/Event.rs | 37 ++++---------- Source/Struct/Tui.rs | 70 +++++++++----------------- 10 files changed, 142 insertions(+), 225 deletions(-) diff --git a/Source/Fn/Binary/Command.rs b/Source/Fn/Binary/Command.rs index 7713f5a..724448f 100644 --- a/Source/Fn/Binary/Command.rs +++ b/Source/Fn/Binary/Command.rs @@ -5,9 +5,6 @@ pub mod Index; pub mod Parallel; pub mod Sequential; -/// Defines and configures the command-line interface for the "Run" utility. -/// -/// Adds `-T` / `--Tui` to launch the interactive TUI panel mode. pub fn Fn() -> ArgMatches { ClapCommand::new("Run") .version(env!("CARGO_PKG_VERSION")) diff --git a/Source/Fn/Binary/Command/Parallel.rs b/Source/Fn/Binary/Command/Parallel.rs index 5c3b77a..840d3e0 100644 --- a/Source/Fn/Binary/Command/Parallel.rs +++ b/Source/Fn/Binary/Command/Parallel.rs @@ -19,34 +19,30 @@ use crate::{ pub mod GPG; pub mod Process; -static GPG_MUTEX:Lazy> = Lazy::new(|| Mutex::new(())); +static GPG_MUTEX: Lazy> = Lazy::new(|| Mutex::new(())); struct ProcessedCommand { - Command:String, - RequiresGpgLock:bool, - RequiresIndexLock:bool, + Command: String, + RequiresGpgLock: bool, + RequiresIndexLock: bool, } -/// Executes commands in parallel across multiple directories. -/// -/// Identical logic to the previous implementation; the only change is that all -/// output is routed through `Tx` (typed `Event`) instead of `println!`. -pub async fn Fn(Option:ExecutionOption, Tx:Sender) { +pub async fn Fn(Option: ExecutionOption, Tx: Sender) { let TotalCommands = Option.Command.len(); - let ProcessedCommands:Arc> = Arc::new( + 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(), ); - let TargetDirs:Vec = Option + let TargetDirs: Vec = Option .Entry .into_par_iter() .filter_map(|CandidatePath| { @@ -81,8 +77,8 @@ pub async fn Fn(Option:ExecutionOption, Tx:Sender) { let DirectoryString = Directory.to_string_lossy().to_string(); let _ = Producer.send(Event::JobStarted { - Directory:DirectoryString.clone(), - Total:TotalCommands, + Directory: DirectoryString.clone(), + Total: TotalCommands, }).await; let mut AllSuccess = true; @@ -92,7 +88,7 @@ pub async fn Fn(Option:ExecutionOption, Tx:Sender) { && !Index::Lock::Fn(&DirectoryString).await { let _ = Producer.send(Event::IndexLockTimeout { - Directory:DirectoryString.clone(), + Directory: DirectoryString.clone(), }).await; break 'commands; } @@ -109,34 +105,34 @@ pub async fn Fn(Option:ExecutionOption, Tx:Sender) { for Line in Output.lines() { if !Line.trim().is_empty() { let _ = Producer.send(Event::Line { - Directory:DirectoryString.clone(), - Text:Line.to_owned(), - IsStderr:false, + Directory: DirectoryString.clone(), + Text: Line.to_owned(), + IsStderr: false, }).await; } } } Err(Error) => { let _ = Producer.send(Event::Line { - Directory:DirectoryString.clone(), - Text:format!("Error: {}", Error), - IsStderr:true, + Directory: DirectoryString.clone(), + Text: format!("Error: {}", Error), + IsStderr: true, }).await; AllSuccess = false; } } let _ = Producer.send(Event::JobProgress { - Directory:DirectoryString.clone(), - Done:CmdIdx + 1, - Total:TotalCommands, - Success:AllSuccess, + Directory: DirectoryString.clone(), + Done: CmdIdx + 1, + Total: TotalCommands, + Success: AllSuccess, }).await; } let _ = Producer.send(Event::JobFinished { - Directory:DirectoryString.clone(), - Success:AllSuccess, + Directory: DirectoryString.clone(), + Success: AllSuccess, }).await; } }); diff --git a/Source/Fn/Binary/Command/Sequential.rs b/Source/Fn/Binary/Command/Sequential.rs index 82e22ad..3cd428a 100644 --- a/Source/Fn/Binary/Command/Sequential.rs +++ b/Source/Fn/Binary/Command/Sequential.rs @@ -16,10 +16,10 @@ use crate::{ /// /// 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) { +pub async fn Fn(Option: ExecutionOption, Tx: Sender) { let TotalCommands = Option.Command.len(); - let ProcessedCommands:Vec<(String, bool)> = Option + let ProcessedCommands: Vec<(String, bool)> = Option .Command .iter() .map(|CommandString| { @@ -28,7 +28,7 @@ pub async fn Fn(Option:ExecutionOption, Tx:Sender) { }) .collect(); - let TargetDirs:Vec = Option + let TargetDirs: Vec = Option .Entry .into_iter() .filter_map(|CandidatePath| { @@ -44,8 +44,8 @@ pub async fn Fn(Option:ExecutionOption, Tx:Sender) { let DirectoryString = Directory.to_string_lossy().to_string(); let _ = Tx.send(Event::JobStarted { - Directory:DirectoryString.clone(), - Total:TotalCommands, + Directory: DirectoryString.clone(), + Total: TotalCommands, }).await; let mut AllSuccess = true; @@ -57,7 +57,7 @@ pub async fn Fn(Option:ExecutionOption, Tx:Sender) { if *RequiresIndexLock && !Index::Lock::Fn(&DirectoryString).await { let _ = Tx.send(Event::IndexLockTimeout { - Directory:DirectoryString.clone(), + Directory: DirectoryString.clone(), }).await; continue 'directories; } @@ -72,31 +72,29 @@ pub async fn Fn(Option:ExecutionOption, Tx:Sender) { Ok(Child) => Child, Err(Error) => { let _ = Tx.send(Event::Line { - Directory:DirectoryString.clone(), - Text:format!("Failed to spawn: {}", Error), - IsStderr:true, + Directory: DirectoryString.clone(), + Text: format!("Failed to spawn: {}", Error), + IsStderr: true, }).await; AllSuccess = false; continue; } }; - // Stream stdout line-by-line. 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() { let _ = Tx.send(Event::Line { - Directory:DirectoryString.clone(), - Text:Line, - IsStderr:false, + Directory: DirectoryString.clone(), + Text: Line, + IsStderr: false, }).await; } } } - // Capture stderr. let mut StderrBuf = String::new(); { let StderrReader = Child.stderr.take().unwrap(); @@ -115,9 +113,9 @@ pub async fn Fn(Option:ExecutionOption, Tx:Sender) { for Line in StderrBuf.lines() { if !Line.trim().is_empty() { let _ = Tx.send(Event::Line { - Directory:DirectoryString.clone(), - Text:Line.to_owned(), - IsStderr:true, + Directory: DirectoryString.clone(), + Text: Line.to_owned(), + IsStderr: true, }).await; } } @@ -128,16 +126,16 @@ pub async fn Fn(Option:ExecutionOption, Tx:Sender) { } let _ = Tx.send(Event::JobProgress { - Directory:DirectoryString.clone(), - Done:CmdIdx + 1, - Total:TotalCommands, - Success, + Directory: DirectoryString.clone(), + Done: CmdIdx + 1, + Total: TotalCommands, + Success: Success, }).await; } let _ = Tx.send(Event::JobFinished { - Directory:DirectoryString.clone(), - Success:AllSuccess, + Directory: DirectoryString.clone(), + Success: AllSuccess, }).await; } diff --git a/Source/Fn/Tui/Input.rs b/Source/Fn/Tui/Input.rs index 390081b..02aeab1 100644 --- a/Source/Fn/Tui/Input.rs +++ b/Source/Fn/Tui/Input.rs @@ -4,8 +4,7 @@ use crossterm::event::{ use crate::Struct::Tui::AppState; -/// Process one crossterm event. Returns `true` if the app should quit. -pub fn Fn(State:&mut AppState, Ev:Event) -> bool { +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, .. }) => { @@ -16,9 +15,8 @@ pub fn Fn(State:&mut AppState, Ev:Event) -> bool { } } -fn handle_key(State:&mut AppState, Code:KeyCode, Mods:KeyModifiers) -> bool { +fn handle_key(State: &mut AppState, Code: KeyCode, Mods: KeyModifiers) -> bool { match Code { - // Quit KeyCode::Char('q') | KeyCode::Char('Q') => { State.ForceQuit = true; true @@ -27,8 +25,6 @@ fn handle_key(State:&mut AppState, Code:KeyCode, Mods:KeyModifiers) -> bool { State.ForceQuit = true; true } - - // Directory navigation KeyCode::Up | KeyCode::Char('k') => { State.select_up(); false @@ -37,8 +33,6 @@ fn handle_key(State:&mut AppState, Code:KeyCode, Mods:KeyModifiers) -> bool { State.select_down(); false } - - // Log scroll KeyCode::PageUp => { State.scroll_up(); false @@ -47,33 +41,19 @@ fn handle_key(State:&mut AppState, Code:KeyCode, Mods:KeyModifiers) -> bool { State.scroll_down(); false } - - // Toggle auto-scroll for the selected directory KeyCode::Char('s') | KeyCode::Char('S') => { State.toggle_autoscroll(); false } - _ => false, } } -/// Handle mouse clicks: clicking a row in the left panel selects that directory. -/// -/// Left panel is the first 30 % of the terminal width; we subtract the 1-char -/// border and the header row to compute the list row index. -fn handle_mouse(State:&mut AppState, Kind:MouseEventKind, Column:u16, Row:u16) { +fn handle_mouse(State: &mut AppState, Kind: MouseEventKind, Column: u16, Row: u16) { if !matches!(Kind, MouseEventKind::Down(MouseButton::Left)) { return; } - - // Approximate: terminal width is not available here without passing it in, - // so we use the column heuristic: left panel spans columns 0 .. (width*0.3). - // We instead use a fixed threshold of 40 columns which covers most terminals. - // This is replaced by exact bounds in a future batch once we thread area - // coordinates through Input. if Column < 40 && Row >= 2 { - // Row 0 = border, Row 1 = title, Row 2+ = list items. State.click_row((Row - 2) as usize); } } diff --git a/Source/Fn/Tui/Render.rs b/Source/Fn/Tui/Render.rs index fe52b0b..6631a5e 100644 --- a/Source/Fn/Tui/Render.rs +++ b/Source/Fn/Tui/Render.rs @@ -8,18 +8,9 @@ use ratatui::{ use crate::Struct::Tui::{AppState, SPINNER, Status}; -/// Top-level render function — called once per tick. -/// -/// Layout (horizontal split): -/// ┌────────────────┬──────────────────────────────────────┐ -/// │ Directories │ Log │ -/// │ (30 % width) │ (70 % width) │ -/// └────────────────┴──────────────────────────────────────┘ -/// Status bar spans the full width at the bottom (1 line). -pub fn Fn(Frame:&mut Frame, State:&AppState) { +pub fn Fn(Frame: &mut Frame, State: &AppState) { let Size = Frame.area(); - // Reserve 1 row at the bottom for the status bar. let Outer = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(3), Constraint::Length(1)]) @@ -35,12 +26,10 @@ pub fn Fn(Frame:&mut Frame, State:&AppState) { render_status_bar(Frame, State, Outer[1]); } -// ─── Left panel: directory list ────────────────────────────────────────────── - -fn render_dir_list(Frame:&mut Frame, State:&AppState, Area:Rect) { +fn render_dir_list(Frame: &mut Frame, State: &AppState, Area: Rect) { let Spinner = SPINNER[State.Tick % SPINNER.len()]; - let Items:Vec = State + let Items: Vec = State .Order .iter() .enumerate() @@ -58,7 +47,6 @@ fn render_dir_list(Frame:&mut Frame, State:&AppState, Area:Rect) { Status::Timeout => ("⚠ ".to_owned(), Style::default().fg(Color::Magenta)), }; - // Shorten long paths: keep the last two path components only. let Label = shorten(Key); let Row = TLine::from(vec![ @@ -87,15 +75,13 @@ fn render_dir_list(Frame:&mut Frame, State:&AppState, Area:Rect) { Frame.render_stateful_widget(List, Area, &mut ListSt); } -// ─── Right panel: log viewer ───────────────────────────────────────────────── - -fn render_log(Frame:&mut Frame, State:&AppState, Area:Rect) { +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 + let Lines: Vec = DS .Lines .iter() .map(|(Text, IsStderr)| { @@ -123,15 +109,13 @@ fn render_log(Frame:&mut Frame, State:&AppState, Area:Rect) { let Para = Paragraph::new(Lines) .block(Block) - .wrap(Wrap { trim:false }) + .wrap(Wrap { trim: false }) .scroll((Scroll as u16, 0)); Frame.render_widget(Para, Area); } -// ─── Bottom status bar ──────────────────────────────────────────────────────── - -fn render_status_bar(Frame:&mut Frame, State:&AppState, Area:Rect) { +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(); @@ -153,18 +137,15 @@ fn render_status_bar(Frame:&mut Frame, State:&AppState, Area:Rect) { Frame.render_widget(Paragraph::new(Status).style(Style), Area); } -// ─── Helpers ────────────────────────────────────────────────────────────────── - -/// Keep at most the last two path components to fit narrow panels. -fn shorten(Path:&str) -> String { - let Parts:Vec<&str> = Path.trim_end_matches('/').rsplit('/').take(2).collect(); +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 { +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 index ec94fd0..22673f1 100644 --- a/Source/Fn/Tui/mod.rs +++ b/Source/Fn/Tui/mod.rs @@ -14,7 +14,6 @@ use tokio::sync::mpsc::Receiver; use crate::Struct::{Event::Struct as Event, Tui::AppState}; -/// RAII guard that restores the terminal unconditionally on drop. struct TerminalGuard; impl Drop for TerminalGuard { @@ -24,13 +23,7 @@ impl Drop for TerminalGuard { } } -/// Main TUI entry point. Owns the terminal and drives the event loop. -/// -/// * `Rx` — receives execution events from the Sequential / Parallel engine. -/// -/// The loop ticks at 100 ms so the spinner animates smoothly without burning -/// CPU. Keyboard and mouse events are handled surgically between ticks. -pub async fn Fn(mut Rx:Receiver) { +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"); @@ -44,7 +37,6 @@ pub async fn Fn(mut Rx:Receiver) { let TickRate = Duration::from_millis(100); loop { - // 1. Drain all pending execution events (non-blocking). loop { match Rx.try_recv() { Ok(Event::JobStarted { Directory, Total }) => { @@ -80,33 +72,29 @@ pub async fn Fn(mut Rx:Receiver) { 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)); + DS.Lines.push(("⚠ git index lock timed out".to_owned(), true)); } } Ok(Event::AllDone) => { State.Done = true; } - Err(_) => break, // channel empty or closed + Err(_) => break, } } - // 2. Render the current frame. Term.draw(|Frame| Render::Fn(Frame, &State)) .expect("Failed to draw frame"); - // 3. Poll for input with a timeout equal to the tick rate. if event::poll(TickRate).unwrap_or(false) { if let Ok(Ev) = event::read() { if Input::Fn(&mut State, Ev) { - break; // quit requested + break; } } } State.Tick = State.Tick.wrapping_add(1); - // 4. If done and force-quit requested, exit. if State.ForceQuit { break; } diff --git a/Source/Struct/Binary/Command.rs b/Source/Struct/Binary/Command.rs index e7d32a3..3d6a6b9 100644 --- a/Source/Struct/Binary/Command.rs +++ b/Source/Struct/Binary/Command.rs @@ -3,34 +3,38 @@ pub mod Option; use tokio::sync::mpsc; -/// The main command configuration struct. +/// The main command configuration struct that holds the execution logic. /// -/// The `Fn` closure now creates an `mpsc` channel and routes its sender into -/// the execution engine. In TUI mode the receiver is handed to `Tui::Fn`; in -/// CLI mode a lightweight printer task drains it with `println!`. +/// 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 { + /// Constructs the command struct and its main execution closure. + /// + /// The returned `Fn` closure, when called, will handle everything from + /// parsing CLI arguments to selecting the parallel or sequential execution + /// 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 + // parsing. let OptionsConfig = Self::Fn(); let CommandLineOptions = Option::Struct::Fn(OptionsConfig); let ExecutionOptions = Entry::Struct::Fn(&CommandLineOptions); let IsTui = CommandLineOptions.Tui; let IsParallel = ExecutionOptions.Parallel; - // Unbounded channel — execution engines are never back-pressured - // by a slow consumer. let (Tx, Rx) = mpsc::unbounded_channel::(); if IsTui { - // Spawn execution in the background; TUI runs on this task. if IsParallel { tokio::spawn( crate::Fn::Binary::Command::Parallel::Fn(ExecutionOptions, Tx), @@ -42,7 +46,6 @@ impl Struct { } crate::Fn::Tui::Fn(Rx).await; } else { - // CLI mode: plain printer task drains the channel. use crate::Struct::Event::Struct as Event; let PrintTask = tokio::spawn(async move { let mut Rx = Rx; diff --git a/Source/Struct/Binary/Command/Option.rs b/Source/Struct/Binary/Command/Option.rs index a53d7a0..f2fc08a 100644 --- a/Source/Struct/Binary/Command/Option.rs +++ b/Source/Struct/Binary/Command/Option.rs @@ -3,50 +3,59 @@ use once_cell::sync::Lazy; use crate::{Fn::Binary::Command::Fn as ParseClap, Struct::Binary::Command::Struct as CommandStruct}; +/// A type alias for a list of command strings. pub type Command = Vec; +/// A type alias for the boolean `Parallel` flag. pub type Parallel = bool; +/// A type alias for the `Pattern` string. pub type Pattern = String; +/// A type alias for the path separator character. pub type Separator = char; -static ARGS:Lazy = Lazy::new(ParseClap); - -/// Raw parsed options from the command line. +/// Caches the parsed command-line arguments in a thread-safe, static variable. /// -/// `Tui` is `true` when `-T` / `--Tui` was passed. +/// 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); + +/// 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 Tui:bool, + 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 { - pub fn Fn(_Option:CommandStruct) -> Self { + /// Creates a new `Struct` instance from the statically parsed `clap` + /// arguments. + pub fn Fn(_Option: CommandStruct) -> Self { Self { - 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 + 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(), - Separator:_Option.Separator, + // The separator is passed through from the initial config. + Separator: _Option.Separator, } } } diff --git a/Source/Struct/Event.rs b/Source/Struct/Event.rs index b8ffeed..f168099 100644 --- a/Source/Struct/Event.rs +++ b/Source/Struct/Event.rs @@ -1,36 +1,21 @@ -/// A single typed event emitted by an execution engine (Sequential or -/// Parallel) and consumed by either the plain CLI printer or the TUI app. -/// -/// Both engines emit these instead of calling `println!`/`eprintln!` directly, -/// keeping execution logic completely I/O-free and making the TUI a zero-cost -/// opt-in. #[derive(Debug, Clone)] pub enum Struct { - /// A directory has been picked up and its first command is about to run. JobStarted { - Directory:String, - /// Total number of commands queued for this directory. - Total:usize, + Directory: String, + Total: usize, }, - /// One line of stdout or stderr arrived from a running command. Line { - Directory:String, - Text:String, - /// `true` → stderr (rendered in red in the TUI). - IsStderr:bool, + Directory: String, + Text: String, + IsStderr: bool, }, - /// A single command inside a directory finished. JobProgress { - Directory:String, - /// Index of the command that just finished (0-based). - Done:usize, - Total:usize, - Success:bool, + Directory: String, + Done: usize, + Total: usize, + Success: bool, }, - /// All commands inside a directory have finished. - JobFinished { Directory:String, Success:bool }, - /// The index-lock timed out; remaining commands for this directory skipped. - IndexLockTimeout { Directory:String }, - /// Every directory has been processed — engines send this last. + JobFinished { Directory: String, Success: bool }, + IndexLockTimeout { Directory: String }, AllDone, } diff --git a/Source/Struct/Tui.rs b/Source/Struct/Tui.rs index 9c5099c..edea314 100644 --- a/Source/Struct/Tui.rs +++ b/Source/Struct/Tui.rs @@ -1,91 +1,74 @@ use std::collections::HashMap; -/// Status of one directory entry shown in the left panel. #[derive(Debug, Clone, PartialEq)] pub enum Status { Pending, - Running { Done:usize, Total:usize }, + Running { Done: usize, Total: usize }, Done, Failed, Timeout, } -/// Per-directory state kept by the TUI app. #[derive(Debug, Clone)] pub struct DirState { - pub Directory:String, - pub Status:Status, - /// Accumulated output lines (stdout + stderr) for the log panel. - pub Lines:Vec<(String, bool)>, // (text, is_stderr) - /// Whether the log panel should auto-scroll to the bottom. - pub AutoScroll:bool, - /// Current scroll offset in the log panel for this directory. - pub Scroll:usize, + 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 { + pub fn new(Directory: String, Total: usize) -> Self { Self { Directory, - Status:Status::Running { Done:0, Total }, - Lines:Vec::new(), - AutoScroll:true, - Scroll:0, + Status: Status::Running { Done: 0, Total }, + Lines: Vec::new(), + AutoScroll: true, + Scroll: 0, } } } -/// Spinner frames cycled on each tick for running jobs. -pub const SPINNER:&[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +pub const SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; -/// The complete application state owned by the TUI event loop. pub struct AppState { - /// Ordered directory keys (insertion order = discovery order). - pub Order:Vec, - /// Per-directory state, keyed by directory path string. - pub Map:HashMap, - /// Currently selected row in the left panel (0-based). - pub Selected:usize, - /// Whether execution is fully complete. - pub Done:bool, - /// Monotonic tick counter — used to animate the spinner. - pub Tick:usize, - /// If `true`, `q` / Ctrl-C quits immediately even while running. - pub ForceQuit:bool, + 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, + Order: Vec::new(), + Map: HashMap::new(), + Selected: 0, + Done: false, + Tick: 0, + ForceQuit: false, } } - /// Returns the currently selected directory key, if any. pub fn selected_dir(&self) -> Option<&str> { self.Order.get(self.Selected).map(String::as_str) } - /// Move selection up by 1, clamping at 0. pub fn select_up(&mut self) { if self.Selected > 0 { self.Selected -= 1; } } - /// Move selection down by 1, clamping at last entry. pub fn select_down(&mut self) { if self.Selected + 1 < self.Order.len() { self.Selected += 1; } } - /// Scroll the log panel of the selected directory up. pub fn scroll_up(&mut self) { if let Some(Key) = self.selected_dir() { if let Some(State) = self.Map.get_mut(Key) { @@ -95,7 +78,6 @@ impl AppState { } } - /// Scroll the log panel of the selected directory down. 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) { @@ -108,7 +90,6 @@ impl AppState { } } - /// Toggle auto-scroll for the selected directory. 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) { @@ -117,8 +98,7 @@ impl AppState { } } - /// Click on a row in the left panel (0-based row index inside the list). - pub fn click_row(&mut self, Row:usize) { + pub fn click_row(&mut self, Row: usize) { if Row < self.Order.len() { self.Selected = Row; }