diff --git a/Cargo.lock b/Cargo.lock index e467b03..e7a26e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,24 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "ahash" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77c3a9648d43b9cd48db467b3f87fdd6e146bcc88ab0180006cef2179fe11d01" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "allocator-api2" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" + [[package]] name = "anstream" version = "0.6.11" @@ -50,6 +68,24 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf" + [[package]] name = "block-buffer" version = "0.10.4" @@ -59,6 +95,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + [[package]] name = "cfg-if" version = "1.0.0" @@ -96,7 +138,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.48", ] [[package]] @@ -130,6 +172,31 @@ dependencies = [ "libc", ] +[[package]] +name = "crossterm" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +dependencies = [ + "bitflags 2.4.2", + "crossterm_winapi", + "libc", + "mio", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -150,6 +217,12 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "either" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" + [[package]] name = "generic-array" version = "0.14.7" @@ -160,12 +233,37 @@ dependencies = [ "version_check", ] +[[package]] +name = "hashbrown" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" +dependencies = [ + "ahash", + "allocator-api2", +] + [[package]] name = "heck" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +[[package]] +name = "indoc" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e186cfbae8084e513daff4240b4797e342f988cecda4fb6c939150f96315fd8" + +[[package]] +name = "itertools" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25db6b064527c5d482d0423354fcd07a89a2dfe07b67892e62411946db7f07b0" +dependencies = [ + "either", +] + [[package]] name = "lazy_static" version = "1.4.0" @@ -178,18 +276,84 @@ version = "0.2.152" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13e3bf6590cbc649f4d1a3eefc9d5d6eb746f5200ffb04e5e142700b8faa56e7" +[[package]] +name = "lock_api" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" + +[[package]] +name = "lru" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2994eeba8ed550fd9b47a0b38f0242bc3344e496483c6180b69139cc2fa5d1d7" +dependencies = [ + "hashbrown", +] + [[package]] name = "memchr" version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" +[[package]] +name = "mio" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + [[package]] name = "once_cell" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.48.5", +] + +[[package]] +name = "paste" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" + [[package]] name = "pest" version = "2.7.6" @@ -221,7 +385,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn", + "syn 2.0.48", ] [[package]] @@ -253,6 +417,40 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "ratatui" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5659e52e4ba6e07b2dad9f1158f578ef84a73762625ddb51536019f34d180eb" +dependencies = [ + "bitflags 2.4.2", + "cassowary", + "crossterm", + "indoc", + "itertools", + "lru", + "paste", + "stability", + "strum", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "rustversion" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4" + [[package]] name = "same-file" version = "1.0.6" @@ -262,6 +460,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "sha2" version = "0.10.8" @@ -273,18 +477,97 @@ dependencies = [ "digest", ] +[[package]] +name = "signal-hook" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +dependencies = [ + "libc", +] + +[[package]] +name = "smallvec" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7" + [[package]] name = "smawk" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" +[[package]] +name = "stability" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebd1b177894da2a2d9120208c3386066af06a488255caabc5de8ddca22dbc3ce" +dependencies = [ + "quote", + "syn 1.0.109", +] + [[package]] name = "strsim" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "strum" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.48", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.48" @@ -302,10 +585,14 @@ version = "0.2.0" dependencies = [ "clap", "colored", + "crossterm", + "itertools", "lazy_static", "pest", "pest_derive", + "ratatui", "textwrap", + "tui-textarea", "walkdir", ] @@ -337,7 +624,18 @@ checksum = "fa0faa943b50f3db30a20aa7e265dbc66076993efed8463e8de414e5d06d3471" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.48", +] + +[[package]] +name = "tui-textarea" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e38ced1f941a9cfc923fbf2fe6858443c42cc5220bfd35bdd3648371e7bd8e" +dependencies = [ + "crossterm", + "ratatui", + "unicode-width", ] [[package]] @@ -364,6 +662,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-segmentation" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" + [[package]] name = "unicode-width" version = "0.1.11" @@ -392,6 +696,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + [[package]] name = "winapi" version = "0.3.9" @@ -554,3 +864,23 @@ name = "windows_x86_64_msvc" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" + +[[package]] +name = "zerocopy" +version = "0.7.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74d4d3961e53fa4c9a25a8637fc2bfaf2595b3d3ae34875568a5cf64787716be" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] diff --git a/Cargo.toml b/Cargo.toml index d777220..38156ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,10 +15,14 @@ categories = ["command-line-utilities", "filesystem"] [dependencies] clap = { version = "4.4.18", features = ["derive"] } colored = "2.1.0" +crossterm = "0.27.0" +itertools = "0.12.0" lazy_static = "1.4.0" pest = "2.7.6" pest_derive = "2.7.6" +ratatui = "0.25.0" textwrap = "0.16.0" +tui-textarea = "0.4.0" walkdir = "2.4.0" [lints.clippy] diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..3bccb85 --- /dev/null +++ b/src/commands.rs @@ -0,0 +1,69 @@ +use std::{path::Path, process::Command}; + +use colored::Colorize; + +/// `execute_command_on_file` executes a command on a given #FILE#. +/// +/// # Panics +/// +/// This function panics if it fails to convert the given path to a `&str`. +#[must_use] +pub fn execute_command_on_file(path: &Path, command: &str) -> String { + let command = command.replace("#FILE#", path.to_str().unwrap()); + + let output = if cfg!(target_os = "windows") { + Command::new("cmd").arg("/C").arg(command.clone()).output() + } else { + Command::new("bash").arg("-c").arg(command.clone()).output() + }; + + if let Err(e) = &output { + eprintln!( + "{} Wasn't able to execute command {}: {}", + "[ERROR]".red().bold(), + command.blue().underline(), + e.to_string().red() + ); + } + + let output = output.unwrap(); + let output_string = std::str::from_utf8(output.stdout.as_slice()); + + if let Err(e) = &output_string { + eprintln!( + "{} Failed to get output from command {}: {}", + "[ERROR]".red().bold(), + command.blue().underline(), + e.to_string().red() + ); + } + + output_string.unwrap().to_string() +} + +/// `execute_filter_command_on_file` executes a command on a given #FILE# and returns +/// +/// # Panics +/// +/// This function panics if it fails to convert the given path to a `&str`. +#[must_use] +pub fn execute_filter_command_on_file(path: &Path, command: &str) -> bool { + let command = command.replace("#FILE#", path.to_str().unwrap()); + + let output = if cfg!(target_os = "windows") { + Command::new("cmd").arg("/C").arg(command.clone()).output() + } else { + Command::new("bash").arg("-c").arg(command.clone()).output() + }; + + if let Err(e) = &output { + eprintln!( + "{} Wasn't able to execute command {}: {}", + "[ERROR]".red().bold(), + command.blue().underline(), + e.to_string().red() + ); + } + + output.unwrap().status.success() +} diff --git a/src/inspect.rs b/src/inspect.rs new file mode 100644 index 0000000..3774d6a --- /dev/null +++ b/src/inspect.rs @@ -0,0 +1,297 @@ +use crossterm::event; +use crossterm::event::{Event, KeyCode}; +use itertools::Itertools; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; +use ratatui::style::{Style, Stylize}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph, Tabs, Wrap}; +use ratatui::{symbols, Frame, Terminal}; +use std::io::{self, stdout}; +use std::rc::Rc; +use tui_textarea::{Input, Key, TextArea}; + +use crate::search::TaggedFile; + +use crate::commands::execute_command_on_file; + +/// `InteractiveInputs` contains possible inputs for interactive mode. +#[derive(Default)] +struct InteractiveInputs { + pub tab_index: usize, + pub file_index: usize, + pub scroll_index: u16, + pub command_mode: bool, + pub quit: bool, +} + +/// `interactive_output` handles the interactive UI of the inspect mode. +/// +/// # Errors +/// +/// This function returns an error if rendering or handling inputs fails. +pub fn interactive_output(files: &[TaggedFile], command_outputs: &[String]) -> io::Result<()> { + let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?; + + // the command textarea + let mut textarea = TextArea::default(); + textarea.set_cursor_line_style(Style::default()); + textarea.set_placeholder_text("Enter a command"); + textarea.set_block( + Block::new() + .title("command") + .borders(Borders::all()) + .border_style(Style::default().red().on_black()) + .style(Style::default().black().on_white()), + ); + + let mut interactive_inputs = InteractiveInputs::default(); + while !interactive_inputs.quit { + let file = &files[interactive_inputs.file_index]; + let command_output = command_outputs[interactive_inputs.file_index].clone(); + + terminal.draw(|frame| { + interactive_output_ui( + file, + command_output.as_str(), + &mut interactive_inputs, + &mut textarea, + frame, + ); + })?; + interactive_inputs = handle_events(&interactive_inputs)?; + + // prevent an overflow of the index + // and also handle wrapping + interactive_inputs.tab_index %= 3; + interactive_inputs.file_index %= files.len(); + } + + Ok(()) +} + +/// `interactive_output_ui` renders the UI. +fn interactive_output_ui( + file: &TaggedFile, + command_output: &str, + interactive_inputs: &mut InteractiveInputs, + text_area: &mut TextArea, + frame: &mut Frame, +) { + let area = layout(frame.size(), Direction::Vertical, &[1, 0, 1]); + + render_tabs(area[0], frame, interactive_inputs); + + render_tab_content( + file, + command_output, + interactive_inputs.tab_index, + interactive_inputs.scroll_index, + area[1], + frame, + ); + + render_help_menu(area[2], frame); + + if interactive_inputs.command_mode { + interactive_inputs.command_mode = command_mode_input(file, text_area).unwrap(); + command_mode_render(text_area, frame); + } +} + +/// `render_tabs` renders the tabs at the top of the screen. +fn render_tabs(area: Rect, frame: &mut Frame, interactive_inputs: &InteractiveInputs) { + let tabs = Tabs::new(vec!["File Content", "Command Output", "Tags"]) + .style(Style::default().white()) + .highlight_style(Style::default().blue()) + .select(interactive_inputs.tab_index) + .divider(symbols::DOT); + + frame.render_widget(tabs, area); +} + +/// `render_tab_content` renders the main content of the current tab. +fn render_tab_content( + file: &TaggedFile, + command_output: &str, + tab_index: usize, + scroll_index: u16, + area: Rect, + frame: &mut Frame, +) { + let content = match tab_index { + 0 => std::fs::read_to_string(&file.path).unwrap(), + 1 => command_output.to_string(), + 2 => { + let mut out_string = String::new(); + for tag in &file.tags { + out_string += tag.as_str(); + out_string.push('\n'); + } + out_string + } + _ => unreachable!(), // tabs are constrained to be between 0 and 2 + }; + + #[allow(clippy::cast_possible_truncation)] + let scroll_index = if content.is_empty() { + 0 + } else { + scroll_index % content.lines().collect_vec().len() as u16 + }; + + let paragraph = Paragraph::new(content) + .block( + Block::new() + .title(file.path.to_str().unwrap()) + .borders(Borders::all()), + ) + .style(Style::new()) + .alignment(Alignment::Left) + .wrap(Wrap { trim: false }) + .scroll((scroll_index, 0)); + + frame.render_widget(paragraph, area); +} + +/// `render_help_menu` renders the help menu at the bottom of the screen. +fn render_help_menu(area: Rect, frame: &mut Frame) { + let keys = [ + ("q", "Quit"), + ("Up-Arrow/k", "Scroll Up"), + ("Down-Arrow/j", "Scroll Down"), + ("n", "Next File"), + ("p", "Previous File"), + ("Tab/Right-Arrow/l", "Next Tab"), + ("Shift+Tab/Left-Arrow/h", "Previous Tab"), + ("c", "Execute a command"), + ]; + + let spans = keys + .iter() + .flat_map(|(key, desc)| { + let key = Span::styled(format!("| {key} "), Style::new().green()); + let desc = Span::styled(format!(" {desc} | "), Style::new().green()); + [key, desc] + }) + .collect_vec(); + + let paragraph = Paragraph::new(Line::from(spans)).alignment(Alignment::Center); + + frame.render_widget(paragraph, area); +} + +/// simple helper method to split an area into multiple sub-areas +/// copied and slightly modified from +/// [here](https://docs.rs/ratatui/latest/src/demo2/root.rs.html#34) +fn layout(area: Rect, direction: Direction, heights: &[u16]) -> Rc<[Rect]> { + let constraints = heights + .iter() + .map(|&h| { + if h > 0 { + Constraint::Length(h) + } else { + Constraint::Min(0) + } + }) + .collect_vec(); + Layout::default() + .direction(direction) + .constraints(constraints) + .split(area) +} + +/// `handle_events` handles inputs in normal mode. +fn handle_events(previous_inputs: &InteractiveInputs) -> io::Result { + let mut interactive_inputs = InteractiveInputs { + tab_index: previous_inputs.tab_index, + file_index: previous_inputs.file_index, + scroll_index: previous_inputs.scroll_index, + command_mode: previous_inputs.command_mode, + ..Default::default() + }; + + if event::poll(std::time::Duration::from_millis(50))? { + if let Event::Key(key) = event::read()? { + if key.kind != event::KeyEventKind::Press { + return Ok(interactive_inputs); + } + + match key.code { + KeyCode::Char('n') => interactive_inputs.file_index += 1, + KeyCode::Char('p') => { + if interactive_inputs.file_index == 0 { + interactive_inputs.file_index = usize::MAX; + } else { + interactive_inputs.file_index -= 1; + } + } + KeyCode::Char('l') | KeyCode::Right | KeyCode::Tab => { + interactive_inputs.tab_index += 1; + } + KeyCode::Char('h') | KeyCode::Left | KeyCode::BackTab => { + if interactive_inputs.tab_index != 0 { + interactive_inputs.tab_index -= 1; + } else { + interactive_inputs.tab_index = 2; // 2 = last tab + } + } + KeyCode::Char('k') | KeyCode::Up => { + if interactive_inputs.scroll_index == 0 { + interactive_inputs.scroll_index = u16::MAX; + } else { + interactive_inputs.scroll_index -= 1; + } + } + KeyCode::Char('j') | KeyCode::Down => { + if interactive_inputs.scroll_index == u16::MAX { + interactive_inputs.scroll_index = u16::MIN; + } else { + interactive_inputs.scroll_index += 1; + } + } + KeyCode::Char('c') => interactive_inputs.command_mode = true, + KeyCode::Char('q') => interactive_inputs.quit = true, + _ => return Ok(interactive_inputs), + } + } + } + + Ok(interactive_inputs) +} + +/// `command_mode_render` renders the popup for a command in the middle of the screen. +fn command_mode_render(text_area: &mut TextArea, frame: &mut Frame) { + let layout = + Layout::default().constraints([Constraint::Length(3), Constraint::Min(1)].as_slice()); + + let area = Rect::new(0, frame.size().height / 2, frame.size().width, 10); + + frame.render_widget(Clear, layout.split(area)[0]); + frame.render_widget(text_area.widget(), layout.split(area)[0]); +} + +/// `command_mode_input` handles inputs in command mode. +fn command_mode_input(file: &TaggedFile, text_area: &mut TextArea) -> Result { + match crossterm::event::read()?.into() { + Input { key: Key::Esc, .. } => { + return Ok(false); + } + Input { + key: Key::Enter, .. + } => { + let _ = execute_command_on_file(&file.path, &text_area.lines()[0]); + return Ok(false); + } + Input { + key: Key::Char('m'), + ctrl: true, + .. + } => {} + input => { + text_area.input(input); + } + } + + Ok(true) +} diff --git a/src/lib.rs b/src/lib.rs index ed14c8a..f247f4d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,3 +3,9 @@ pub mod parsers; /// search contains functions for searching files and tags. pub mod search; + +/// interactive inspection ui +pub mod inspect; + +/// system commands on files +pub mod commands; diff --git a/src/main.rs b/src/main.rs index 0a2128b..c5fe8c5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,15 @@ -use std::io::{BufRead, IsTerminal}; -use std::{path::Path, process::Command}; +use std::io::{stdout, BufRead, IsTerminal}; use colored::Colorize; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use crossterm::ExecutableCommand; use pest::Parser; + +use tag::commands::{execute_command_on_file, execute_filter_command_on_file}; +use tag::inspect; +use tag::search::TaggedFile; use tag::{ parsers::searchquery::{construct_query_ast, evaluate_ast, QueryParser, Rule}, search::get_tags_from_files, @@ -13,6 +20,7 @@ mod cli { #[derive(Parser)] #[command(author, version, about, long_about = None)] + #[allow(clippy::struct_excessive_bools)] pub struct Cli { #[clap(value_name = "PATH")] /// The path that will be searched. @@ -22,7 +30,7 @@ mod cli { /// Search query for the tags. pub query: Option, - #[arg(short, long)] + #[arg(short, long, group = "output")] /// Only print the paths of matched files. pub silent: bool, @@ -41,6 +49,10 @@ mod cli { #[arg(short, long, group = "q-input")] /// Receive a query from the standard input. pub query_stdin: bool, + + #[arg(short, long, group = "output")] + /// Enter an interactive inspection mode to view each file individually. + pub inspect: bool, } impl Cli { @@ -50,65 +62,22 @@ mod cli { } } -fn execute_command_on_file(path: &Path, command: &str) -> String { - let command = command.replace("#FILE#", path.to_str().unwrap()); - - let output = if cfg!(target_os = "windows") { - Command::new("cmd").arg("/C").arg(command.clone()).output() - } else { - Command::new("bash").arg("-c").arg(command.clone()).output() - }; - - if let Err(e) = &output { - eprintln!( - "{} Wasn't able to execute command {}: {}", - "[ERROR]".red().bold(), - command.blue().underline(), - e.to_string().red() - ); - } - - let output = output.unwrap(); - let output_string = std::str::from_utf8(output.stdout.as_slice()); - - if let Err(e) = &output_string { - eprintln!( - "{} Failed to get output from command {}: {}", - "[ERROR]".red().bold(), - command.blue().underline(), - e.to_string().red() - ); - } - - output_string.unwrap().to_string() -} - -fn execute_filter_command_on_file(path: &Path, command: &str) -> bool { - let command = command.replace("#FILE#", path.to_str().unwrap()); - - let output = if cfg!(target_os = "windows") { - Command::new("cmd").arg("/C").arg(command.clone()).output() - } else { - Command::new("bash").arg("-c").arg(command.clone()).output() - }; +fn non_interactive_output(file: &TaggedFile, command_output: &str) { + println!("\t{}", format!("tags: {:?}", file.tags).blue()); - if let Err(e) = &output { - eprintln!( - "{} Wasn't able to execute command {}: {}", - "[ERROR]".red().bold(), - command.blue().underline(), - e.to_string().red() + if !command_output.is_empty() { + println!( + "\tOutput of command:\n{}", + textwrap::indent(command_output, "\t\t") ); } - - output.unwrap().status.success() } fn main() -> Result<(), Box> { let mut args = cli::Cli::new_and_parse(); // detect if output is in a terminal or not - if !std::io::stdout().is_terminal() { + if !stdout().is_terminal() { args.silent = true; args.no_color = true; } @@ -150,6 +119,14 @@ fn main() -> Result<(), Box> { let query = query.unwrap(); + if args.inspect { + enable_raw_mode()?; + stdout().execute(EnterAlternateScreen)?; + } + + let mut file_matched_index = Vec::new(); + let mut command_outputs = Vec::new(); + for file in file_index { let ast = construct_query_ast( query.clone().next().unwrap().into_inner(), @@ -168,7 +145,9 @@ fn main() -> Result<(), Box> { continue; } - println!("{}", file.path.display().to_string().green()); + if !args.inspect { + println!("{}", file.path.display().to_string().green()); + } let output = if args.command.is_some() { execute_command_on_file(&file.path, &args.command.clone().unwrap()) @@ -176,16 +155,24 @@ fn main() -> Result<(), Box> { String::new() }; - if !args.silent { - println!("\t{}", format!("tags: {:?}", file.tags).blue()); + // don't print any more information in silent mode + if args.silent { + continue; + } - if !output.is_empty() { - println!( - "\tOutput of command:\n{}", - textwrap::indent(output.as_str(), "\t\t") - ); - } + if !args.inspect { + non_interactive_output(&file, output.as_str()); } + + file_matched_index.push(file); + command_outputs.push(output); + } + + if args.inspect { + inspect::interactive_output(&file_matched_index, &command_outputs)?; + + disable_raw_mode()?; + stdout().execute(LeaveAlternateScreen)?; } Ok(())