diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..0b16aaf --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,152 @@ +name: CD # Continuous Deployment + +on: + push: + tags: + - '[v]?[0-9]+.[0-9]+.[0-9]+' + +jobs: + publish: + + name: Publishing for ${{ matrix.os }} + runs-on: ${{ matrix.os }} + + strategy: + matrix: + include: + - os: macos-latest + os-name: macos + target: x86_64-apple-darwin + architecture: x86_64 + binary-postfix: "" + binary-name: crates-tui + use-cross: false + - os: macos-latest + os-name: macos + target: aarch64-apple-darwin + architecture: arm64 + binary-postfix: "" + use-cross: false + binary-name: crates-tui + - os: ubuntu-latest + os-name: linux + target: x86_64-unknown-linux-gnu + architecture: x86_64 + binary-postfix: "" + use-cross: false + binary-name: crates-tui + - os: windows-latest + os-name: windows + target: x86_64-pc-windows-msvc + architecture: x86_64 + binary-postfix: ".exe" + use-cross: false + binary-name: crates-tui + - os: ubuntu-latest + os-name: linux + target: aarch64-unknown-linux-gnu + architecture: arm64 + binary-postfix: "" + use-cross: true + binary-name: crates-tui + - os: ubuntu-latest + os-name: linux + target: i686-unknown-linux-gnu + architecture: i686 + binary-postfix: "" + use-cross: true + binary-name: crates-tui + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install Rust toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + + target: ${{ matrix.target }} + + profile: minimal + override: true + - uses: Swatinem/rust-cache@v2 + - name: Cargo build + uses: actions-rs/cargo@v1 + with: + command: build + + use-cross: ${{ matrix.use-cross }} + + toolchain: stable + + args: --release --target ${{ matrix.target }} + + + - name: install strip command + shell: bash + run: | + + if [[ ${{ matrix.target }} == aarch64-unknown-linux-gnu ]]; then + + sudo apt update + sudo apt-get install -y binutils-aarch64-linux-gnu + fi + - name: Packaging final binary + shell: bash + run: | + + cd target/${{ matrix.target }}/release + + + ####### reduce binary size by removing debug symbols ####### + + BINARY_NAME=${{ matrix.binary-name }}${{ matrix.binary-postfix }} + if [[ ${{ matrix.target }} == aarch64-unknown-linux-gnu ]]; then + + GCC_PREFIX="aarch64-linux-gnu-" + else + GCC_PREFIX="" + fi + "$GCC_PREFIX"strip $BINARY_NAME + + ########## create tar.gz ########## + + RELEASE_NAME=${{ matrix.binary-name }}-${GITHUB_REF/refs\/tags\//}-${{ matrix.os-name }}-${{ matrix.architecture }} + + tar czvf $RELEASE_NAME.tar.gz $BINARY_NAME + + ########## create sha256 ########## + + if [[ ${{ runner.os }} == 'Windows' ]]; then + + certutil -hashfile $RELEASE_NAME.tar.gz sha256 | grep -E [A-Fa-f0-9]{64} > $RELEASE_NAME.sha256 + else + shasum -a 256 $RELEASE_NAME.tar.gz > $RELEASE_NAME.sha256 + fi + - name: Releasing assets + uses: softprops/action-gh-release@v1 + with: + files: | + + target/${{ matrix.target }}/release/${{ matrix.binary-name }}-*.tar.gz + target/${{ matrix.target }}/release/${{ matrix.binary-name }}-*.sha256 + + env: + + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + + publish-cargo: + name: Publishing to Cargo + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo publish + env: + + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c64fbee --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI # Continuous Integration + +on: + push: + branches: + - main + pull_request: + +jobs: + + test: + name: Test Suite + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@nightly + - uses: Swatinem/rust-cache@v2 + - name: Run tests + run: cargo test --all-features --workspace + + rustfmt: + name: Rustfmt + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@nightly + with: + components: rustfmt + - uses: Swatinem/rust-cache@v2 + - name: Check formatting + run: cargo fmt --all --check + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@nightly + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - name: Clippy check + run: cargo clippy --all-targets --all-features --workspace -- -D warnings + + docs: + name: Docs + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@nightly + - uses: Swatinem/rust-cache@v2 + - name: Check documentation + env: + RUSTDOCFLAGS: -D warnings + run: cargo doc --no-deps --document-private-items --all-features --workspace --examples diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e6b3901 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +.data/ diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 0000000..1cf4c15 --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1,16 @@ +max_width = 120 +use_small_heuristics = "Max" +empty_item_single_line = false +force_multiline_blocks = true +format_code_in_doc_comments = true +match_block_trailing_comma = true +imports_granularity = "Crate" +normalize_comments = true +normalize_doc_attributes = true +overflow_delimited_expr = true +reorder_impl_items = true +reorder_imports = true +group_imports = "StdExternalCrate" +tab_spaces = 2 +use_field_init_shorthand = true +use_try_shorthand = true diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..03174c0 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,51 @@ +[package] +name = "crates-tui" +version = "0.1.0" +edition = "2021" +description = "A TUI for crates.io" +repository = "https://github.com/kdheepak/crates-tui" +authors = ["Dheepak Krishnamurthy "] +build = "build.rs" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +better-panic = "0.3.0" +chrono = "0.4.31" +clap = { version = "4.4.11", features = ["derive", "cargo", "wrap_help", "unicode", "string", "unstable-styles", "color"] } +color-eyre = "0.6.2" +crates_io_api = "0.9.0" +crossterm = { version = "0.27.0", features = ["serde", "event-stream"] } +derive_deref = "1.1.1" +directories = "5.0.1" +figment = { version = "0.10.14", features = ["env", "toml"] } +futures = "0.3.28" +human-panic = "1.2.0" +itertools = "0.12.0" +json5 = "0.4.1" +lazy_static = "1.4.0" +libc = "0.2.148" +log = "0.4.20" +num-format = "0.4.4" +pretty_assertions = "1.4.0" +ratatui = { version = "0.26.0", features = ["serde", "macros"] } +ratatui-macros = "0.2.3" +serde = { version = "1.0.188", features = ["derive"] } +serde_json = "1.0.107" +serde_with = "3.5.0" +signal-hook = "0.3.17" +strip-ansi-escapes = "0.2.0" +strum = { version = "0.26.1", features = ["derive"] } +tokio = { version = "1.32.0", features = ["full"] } +tokio-util = "0.7.10" +toml = "0.8.8" +tracing = "0.1.40" +tracing-appender = "0.2.3" +tracing-error = "0.2.0" +tracing-log = "0.2.0" +tracing-subscriber = { version = "0.3.18", features = ["env-filter", "serde", "serde_json"] } +tui-input = "0.8.0" + + +[build-dependencies] +vergen = { version = "8.2.6", features = [ "build", "git", "gitoxide", "cargo" ]} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e6d680a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Dheepak Krishnamurthy + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..03cfb2e --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# crates-tui + +[![CI](https://github.com//crates-tui/workflows/CI/badge.svg)](https://github.com//crates-tui/actions) + +A TUI for crates.io diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..52484c2 --- /dev/null +++ b/build.rs @@ -0,0 +1,4 @@ +fn main() -> Result<(), Box> { + vergen::EmitBuilder::builder().all_build().all_git().emit()?; + Ok(()) +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..5d56faf --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "nightly" diff --git a/src/action.rs b/src/action.rs new file mode 100644 index 0000000..69dae85 --- /dev/null +++ b/src/action.rs @@ -0,0 +1,33 @@ +use std::{fmt, string::ToString}; + +use serde::{ + de::{self, Deserializer, Visitor}, + Deserialize, Serialize, +}; +use strum::Display; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Display, Deserialize)] +pub enum Action { + Tick, + Render, + Resize(u16, u16), + Suspend, + Resume, + Quit, + Refresh, + Error(String), + Help, + GetCrates, + EnterSearchQueryInsert, + EnterFilterInsert, + EnterNormal, + MoveSelectionBottom, + MoveSelectionTop, + MoveSelectionNext, + MoveSelectionPrevious, + SubmitSearchQuery, + GetInfo, + ShowPicker, + ReloadData, + ToggleShowHelp, +} diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..4945366 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,88 @@ +use std::{cell::RefCell, rc::Rc}; + +use color_eyre::eyre::Result; +use crossterm::event::KeyEvent; +use ratatui::prelude::Rect; +use serde::{Deserialize, Serialize}; +use tokio::sync::mpsc; + +use crate::{ + action::Action, + config::Config, + mode::Mode, + picker::Picker, + tui::{self, Tui}, +}; + +pub struct App { + pub should_quit: bool, + pub mode: Rc>, + pub picker: Picker, +} + +impl App { + pub fn new() -> Result { + let mode = Rc::new(RefCell::new(Mode::PickerSearchQueryEditing)); + let picker = Picker::new(mode.clone()); + Ok(Self { should_quit: Default::default(), mode, picker }) + } + + pub async fn run(&mut self, tui: &mut Tui) -> Result<()> { + let (action_tx, mut action_rx) = mpsc::unbounded_channel(); + + tui.enter()?; + + loop { + if let Some(e) = tui.next().await { + match e { + tui::Event::Quit => action_tx.send(Action::Quit)?, + tui::Event::Tick => action_tx.send(Action::Tick)?, + tui::Event::Render => action_tx.send(Action::Render)?, + tui::Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?, + tui::Event::Key(key) => { + log::debug!("Received key {:?}", key); + if let Some(action) = self.picker.handle_events(e.clone())? { + action_tx.send(action)?; + } + }, + _ => {}, + } + } + + while let Ok(action) = action_rx.try_recv() { + if action != Action::Tick && action != Action::Render { + log::debug!("{action:?}"); + } + match action { + Action::Tick => {}, + Action::Quit => self.should_quit = true, + Action::Resize(w, h) => { + tui.resize(Rect::new(0, 0, w, h))?; + tui.draw(|f| { + let r = self.picker.draw(f, f.size()); + r.unwrap(); + })?; + }, + Action::Render => { + tui.draw(|f| { + let r = self.picker.draw(f, f.size()); + if let Err(e) = r { + action_tx.send(Action::Error(format!("Failed to draw: {:?}", e))).unwrap(); + } + })?; + }, + _ => {}, + } + if let Some(action) = self.picker.update(action.clone())? { + action_tx.send(action)? + }; + } + if self.should_quit { + tui.stop()?; + break; + } + } + tui.exit()?; + Ok(()) + } +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..8a516ad --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,21 @@ +use std::path::PathBuf; + +use clap::Parser; + +use crate::utils::version; + +#[derive(Parser, Debug)] +#[command(author, version = version(), about)] +pub struct Cli { + #[arg(short, long, value_name = "FLOAT", help = "Tick rate, i.e. number of ticks per second", default_value_t = 1.0)] + pub tick_rate: f64, + + #[arg( + short, + long, + value_name = "FLOAT", + help = "Frame rate, i.e. number of frames per second", + default_value_t = 20.0 + )] + pub frame_rate: f64, +} diff --git a/src/color.rs b/src/color.rs new file mode 100644 index 0000000..075796a --- /dev/null +++ b/src/color.rs @@ -0,0 +1,67 @@ +use std::{collections::BTreeMap, str::FromStr}; + +use ratatui::prelude::*; +use ratatui_macros::palette; + +palette!(pub SLATE); +palette!(pub GRAY); +palette!(pub ZINC); +palette!(pub NEUTRAL); +palette!(pub STONE); +palette!(pub RED); +palette!(pub ORANGE); +palette!(pub AMBER); +palette!(pub YELLOW); +palette!(pub LIME); +palette!(pub GREEN); +palette!(pub EMERALD); +palette!(pub TEAL); +palette!(pub CYAN); +palette!(pub SKY); +palette!(pub BLUE); +palette!(pub INDIGO); +palette!(pub VIOLET); +palette!(pub PURPLE); +palette!(pub FUCHSIA); +palette!(pub PINK); +palette!(pub ROSE); + +struct Palette { + base: Color, + surface: Color, + overlay: Color, + muted: Color, + subtle: Color, + text: Color, + love: Color, + gold: Color, + rose: Color, + pine: Color, + foam: Color, + iris: Color, + highlightlow: Color, + highlightmed: Color, + highlighthigh: Color, +} + +impl Default for Palette { + fn default() -> Self { + Self { + base: Color::from_str("#191724").unwrap(), + surface: Color::from_str("#1F1D2E").unwrap(), + overlay: Color::from_str("#26233A").unwrap(), + muted: Color::from_str("#6E6A86").unwrap(), + subtle: Color::from_str("#908CAA").unwrap(), + text: Color::from_str("#E0DEF4").unwrap(), + love: Color::from_str("#EB6F92").unwrap(), + gold: Color::from_str("#F6C177").unwrap(), + rose: Color::from_str("#EBBCBA").unwrap(), + pine: Color::from_str("#31748F").unwrap(), + foam: Color::from_str("#9CCFD8").unwrap(), + iris: Color::from_str("#C4A7E7").unwrap(), + highlightlow: Color::from_str("#21202E").unwrap(), + highlightmed: Color::from_str("#403D52").unwrap(), + highlighthigh: Color::from_str("#524F67").unwrap(), + } + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..793af85 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,130 @@ +use std::{path::PathBuf, sync::OnceLock}; + +use clap::Parser; +use color_eyre::eyre::{eyre, Result}; +use directories::ProjectDirs; +use figment::{ + providers::{Env, Format, Serialized, Toml}, + Figment, +}; +use serde::{Deserialize, Serialize}; +use serde_with::{serde_as, skip_serializing_none, DisplayFromStr, NoneAsEmptyString}; +use tracing::level_filters::LevelFilter; + +use crate::utils::version; + +static CONFIG: OnceLock = OnceLock::new(); + +/// Command line arguments. +/// +/// Implements Serialize so that we can use it as a source for Figment configuration. +#[serde_as] +#[skip_serializing_none] +#[derive(Debug, Default, Parser, Serialize)] +#[command(author, version = version(), about, long_about = None)] +pub struct Cli { + #[arg( + short, + long, + value_name = "FLOAT", + help = "Tick rate, i.e. number of ticks per second", + default_value_t = 10.0 + )] + pub tick_rate: f64, + + /// A path to a crates-tui configuration file. + #[arg(short, long, value_name = "FILE")] + config: Option, + + #[arg( + short, + long, + value_name = "FLOAT", + help = "Frame rate, i.e. number of frames per second", + default_value_t = 15.0 + )] + pub frame_rate: f64, + + /// The directory to use for storing application data. + #[arg(long, value_name = "DIR")] + pub data_dir: Option, + + /// The log level to use. + /// + /// Valid values are: error, warn, info, debug, trace, off. The default is info. + #[arg(long, value_name = "LEVEL", default_value = "info", alias = "log")] + #[serde_as(as = "NoneAsEmptyString")] + pub log_level: Option, +} + +/// Application configuration. +/// +/// This is the main configuration struct for the application. +#[serde_as] +#[derive(Debug, Deserialize, Serialize)] +pub struct Config { + /// The directory to use for storing application data (logs etc.). + pub data_dir: PathBuf, + + /// The log level to use. Valid values are: error, warn, info, debug, trace, off. The default is + /// info. + #[serde_as(as = "DisplayFromStr")] + pub log_level: LevelFilter, + + pub tick_rate: f64, + + pub frame_rate: f64, +} + +impl Default for Config { + fn default() -> Self { + let data_dir = default_data_dir(); + Self { data_dir, log_level: LevelFilter::INFO, tick_rate: 1.0, frame_rate: 4.0 } + } +} + +/// Returns the directory to use for storing data files. +fn default_data_dir() -> PathBuf { + project_dirs().map(|dirs| dirs.data_local_dir().to_path_buf()).unwrap() +} + +/// Returns the path to the default configuration file. +fn default_config_file() -> PathBuf { + project_dirs().map(|dirs| dirs.config_local_dir().join("config.toml")).unwrap() +} + +/// Returns the project directories. +fn project_dirs() -> Result { + ProjectDirs::from("com", "kdheepak", env!("CARGO_PKG_NAME")).ok_or_else(|| eyre!("user home directory not found")) +} + +/// Initialize the application configuration. +/// +/// This function should be called before any other function in the application. +/// It will initialize the application config from the following sources: +/// - default values +/// - a configuration file +/// - environment variables +/// - command line arguments +pub fn initialize_config() -> Result<()> { + let cli = Cli::parse(); + let config_file = cli.config.clone().unwrap_or_else(default_config_file); + let config = Figment::new() + .merge(Serialized::defaults(Config::default())) + .merge(Toml::file(config_file)) + .merge(Env::prefixed("CRATES_TUI_")) + .merge(Serialized::defaults(cli)) + .extract::()?; + CONFIG.set(config).map_err(|config| eyre!("failed to set config {config:?}")) +} + +/// Get the application configuration. +/// +/// This function should only be called after [`init()`] has been called. +/// +/// # Panics +/// +/// This function will panic if [`init()`] has not been called. +pub fn get() -> &'static Config { + CONFIG.get().expect("config not initialized") +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..e96f5ae --- /dev/null +++ b/src/main.rs @@ -0,0 +1,46 @@ +#![allow(dead_code)] +#![allow(unused_imports)] +#![allow(unused_variables)] +#![feature(iter_intersperse)] + +pub mod action; +pub mod app; +pub mod cli; +pub mod color; +pub mod config; +pub mod mode; +pub mod picker; +pub mod tui; +pub mod utils; + +use clap::Parser; +use cli::Cli; +use color_eyre::eyre::Result; +use config::initialize_config; + +use crate::{ + app::App, + utils::{initialize_logging, initialize_panic_handler, version}, +}; + +async fn tokio_main() -> Result<()> { + initialize_config()?; + initialize_logging()?; + initialize_panic_handler()?; + + let mut tui = tui::Tui::new()?.tick_rate(config::get().tick_rate).frame_rate(config::get().frame_rate); + let mut app = App::new()?; + app.run(&mut tui).await?; + + Ok(()) +} + +#[tokio::main] +async fn main() -> Result<()> { + if let Err(e) = tokio_main().await { + eprintln!("{} error: Something went wrong", env!("CARGO_PKG_NAME")); + Err(e) + } else { + Ok(()) + } +} diff --git a/src/mode.rs b/src/mode.rs new file mode 100644 index 0000000..273d501 --- /dev/null +++ b/src/mode.rs @@ -0,0 +1,10 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Mode { + #[default] + Picker, + PickerSearchQueryEditing, + PickerFilterEditing, + Info, +} diff --git a/src/picker.rs b/src/picker.rs new file mode 100644 index 0000000..c6615c0 --- /dev/null +++ b/src/picker.rs @@ -0,0 +1,459 @@ +use std::{ + cell::RefCell, + collections::HashMap, + rc::Rc, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, + }, + time::Duration, +}; + +use color_eyre::eyre::Result; +use crossterm::event::{KeyCode, KeyEvent}; +use num_format::{Locale, ToFormattedString}; +use ratatui::{prelude::*, widgets::*}; +use serde::{Deserialize, Serialize}; +use tokio::sync::mpsc::UnboundedSender; +use tui_input::backend::crossterm::EventHandler; + +use crate::{action::Action, color, config::Config, mode::Mode, tui::Event}; + +#[derive(Default)] +pub struct Picker { + command_tx: Option>, + action_tx: Option>, + config: Config, + mode: Rc>, + last_events: Vec, + loading_status: Arc, + search: String, + search_horizontal_scroll: usize, + filter: String, + filter_horizontal_scroll: usize, + filtered_crates: Vec, + crates: Arc>>, + crate_info: Arc>>, + state: TableState, + input: tui_input::Input, + page: u64, + row_height: usize, +} + +impl Picker { + pub fn new(mode: Rc>) -> Self { + Self { page: 1, row_height: 1, mode, ..Self::default() } + } + + pub fn render_info_widget(&mut self, f: &mut Frame, area: Rect) { + let crate_info = self.crate_info.lock().unwrap().clone(); + let crate_info = if let Some(ci) = crate_info { + ci + } else { + f.render_widget(Block::default().borders(Borders::ALL).title("crates.io info"), area); + return; + }; + let name = crate_info.name.clone(); + + let mut rows = vec![]; + + rows.push(Row::new(vec![Cell::from("Name"), Cell::from(name.clone())])); + if let Some(description) = crate_info.description { + rows.push(Row::new(vec![Cell::from("Description"), Cell::from(description)])); + } + if let Some(homepage) = crate_info.homepage { + rows.push(Row::new(vec![Cell::from("Homepage"), Cell::from(homepage)])); + } + if let Some(repository) = crate_info.repository { + rows.push(Row::new(vec![Cell::from("Repository"), Cell::from(repository)])); + } + if let Some(recent_downloads) = crate_info.recent_downloads { + rows.push(Row::new(vec![Cell::from("Recent Downloads"), Cell::from(recent_downloads.to_string())])); + } + rows.push(Row::new(vec![Cell::from("Max Version"), Cell::from(crate_info.max_version)])); + if let Some(max_stable_version) = crate_info.max_stable_version { + rows.push(Row::new(vec![Cell::from("Max Stable Version"), Cell::from(max_stable_version)])); + } + rows.push(Row::new(vec![ + Cell::from("Created At"), + Cell::from(crate_info.created_at.format("%Y-%m-%d %H:%M:%S").to_string()), + ])); + rows.push(Row::new(vec![ + Cell::from("Updated At"), + Cell::from(crate_info.created_at.format("%Y-%m-%d %H:%M:%S").to_string()), + ])); + + let widths = [Constraint::Min(20), Constraint::Percentage(100)]; + let table_widget = + Table::new(rows, widths).block(Block::default().borders(Borders::ALL).title(format!("crates.io info - {name}"))); + f.render_widget(table_widget, area); + } + + pub fn render_table_widget(&mut self, f: &mut Frame, area: Rect) { + let selected_style = Style::default(); + let normal_style = Style::default().bg(Color::White).fg(Color::Black); + let ncrates = self.filtered_crates.len(); + let header = Row::new( + ["Name", "Description", "Downloads", "Last Updated"] + .iter() + .map(|h| Text::from(vec![Line::from(""), Line::from(h.bold()), Line::from("")])), + ) + .bg(color::GRAY_900) + .height(3); + let highlight_symbol = if *self.mode.borrow() == Mode::Picker { " \u{2022} " } else { " " }; + let loading_status = if self.loading_status.load(Ordering::SeqCst) { + format!("Loaded {}...", ncrates) + } else { + format!("{}/{}", self.state.selected().map_or(0, |n| n + 1), ncrates) + }; + + let crates = self.filtered_crates.clone(); + let rows = crates.iter().enumerate().map(|(i, item)| { + Row::new([ + Text::from(vec![Line::from(""), Line::from(item.name.clone()), Line::from("")]), + Text::from(vec![Line::from(""), Line::from(item.description.clone().unwrap_or_default()), Line::from("")]), + Text::from(vec![Line::from(""), Line::from(item.downloads.to_formatted_string(&Locale::en)), Line::from("")]), + Text::from(vec![ + Line::from(""), + Line::from(item.updated_at.format("%Y-%m-%d %H:%M:%S").to_string()), + Line::from(""), + ]), + ]) + .bg(match i % 2 { + 0 => color::GRAY_900, + 1 => color::GRAY_800, + _ => unreachable!("Cannot reach this line"), + }) + .height(3) + }); + let widths = [Constraint::Min(20), Constraint::Percentage(100), Constraint::Min(10), Constraint::Min(15)]; + let table_widget = Table::new(rows, widths) + .header(header) + .column_spacing(1) + .highlight_style(selected_style) + .highlight_symbol(Text::from(vec!["".into(), " █ ".into(), "".into()])) + .highlight_spacing(HighlightSpacing::Always); + f.render_stateful_widget(table_widget, area, &mut self.state); + } + + pub fn next(&mut self) { + if self.filtered_crates.len() == 0 { + self.state.select(None) + } else { + let i = match self.state.selected() { + Some(i) => { + if i >= self.filtered_crates.len() - 1 { + self.row_height / 2 + } else { + i + self.row_height + } + }, + None => self.row_height / 2, + }; + self.state.select(Some(i)); + } + } + + pub fn previous(&mut self) { + if self.filtered_crates.len() == 0 { + self.state.select(None) + } else { + let i = match self.state.selected() { + Some(i) => { + if i == (self.row_height / 2) { + self.filtered_crates.len() - 1 + } else { + i - self.row_height + } + }, + None => 0, + }; + self.state.select(Some(i)); + } + } + + pub fn top(&mut self) { + if self.filtered_crates.len() == 0 { + self.state.select(None) + } else { + self.state.select(Some(0)) + } + } + + pub fn bottom(&mut self) { + if self.filtered_crates.len() == 0 { + self.state.select(None) + } else { + self.state.select(Some(self.filtered_crates.len() - 1)); + } + } + + fn render_filter_widget(&mut self, f: &mut Frame, area: Rect) { + let scroll = if *self.mode.borrow() == Mode::PickerSearchQueryEditing { + self.search_horizontal_scroll + } else if *self.mode.borrow() == Mode::PickerFilterEditing { + self.filter_horizontal_scroll + } else { + 0 + }; + + let block = Block::default() + .borders(Borders::ALL) + .title( + block::Title::from(Line::from(vec![ + "Query ".into(), + "(Press ".into(), + "?".bold(), + " to search, ".into(), + "/".bold(), + " to filter, ".into(), + "Enter".bold(), + " to submit)".into(), + ])) + .alignment(Alignment::Left), + ) + .border_style(match *self.mode.borrow() { + Mode::PickerSearchQueryEditing => Style::default().fg(color::GREEN_400), + Mode::PickerFilterEditing => Style::default().fg(color::RED_400), + _ => Style::default().add_modifier(Modifier::DIM), + }); + f.render_widget(block, area); + + let paragraph = Paragraph::new(self.input.value()).scroll((0, scroll as u16)); + f.render_widget(paragraph, area.inner(&Margin { horizontal: 2, vertical: 2 })); + } + + fn mode_mut(&mut self) -> std::cell::RefMut<'_, Mode> { + std::cell::RefCell::<_>::borrow_mut(&self.mode) + } + + fn reload_data(&mut self) { + self.state.select(None); + *self.crate_info.lock().unwrap() = None; + let crates = self.crates.clone(); + let search = self.search.clone(); + let loading_status = self.loading_status.clone(); + let action_tx = self.action_tx.clone(); + let page = self.page.clamp(1, u64::MAX); + tokio::spawn(async move { + crates.lock().unwrap().drain(0..); + loading_status.store(true, Ordering::SeqCst); + let client = + crates_io_api::AsyncClient::new("crates-tui (crates-tui@kdheepak.com)", std::time::Duration::from_millis(1000)) + .unwrap(); + let mut query = crates_io_api::CratesQueryBuilder::default(); + query = query.search(search); + query = query.page(page); + query = query.page_size(100); + query = query.sort(crates_io_api::Sort::Relevance); + let query = query.build(); + let page = client.crates(query).await.unwrap(); + let mut all_crates = vec![]; + for _crate in page.crates.iter() { + all_crates.push(_crate.clone()) + } + all_crates.sort_by(|a, b| b.downloads.cmp(&a.downloads)); + *crates.lock().unwrap() = all_crates; + if let Some(action_tx) = action_tx { + action_tx.send(Action::Tick).unwrap_or_default(); + action_tx.send(Action::MoveSelectionNext).unwrap_or_default(); + } + loading_status.store(false, Ordering::SeqCst); + }); + } + + fn get_info(&mut self) { + let name = if let Some(index) = self.state.selected() { + if self.filtered_crates.len() > 0 { + self.filtered_crates[index].name.clone() + } else { + return; + } + } else if self.filtered_crates.len() > 0 { + self.state.select(Some(0)); + self.filtered_crates[0].name.clone() + } else { + return; + }; + if !name.is_empty() { + let crate_info = self.crate_info.clone(); + tokio::spawn(async move { + let client = crates_io_api::AsyncClient::new( + "crates-tui (crates-tui@kdheepak.com)", + std::time::Duration::from_millis(1000), + ) + .unwrap(); + match client.get_crate(&name).await { + Ok(_crate_info) => *crate_info.lock().unwrap() = Some(_crate_info.crate_data), + Err(err) => {}, + } + }); + } + } + + fn tick(&mut self) { + self.last_events.drain(..); + let filter = self.filter.clone(); + let filter_words = filter.split_whitespace().collect::>(); + self.filtered_crates = self + .crates + .lock() + .unwrap() + .iter() + .filter(|c| { + filter_words.iter().all(|word| { + c.name.to_lowercase().contains(word) + || c.description.clone().unwrap_or_default().to_lowercase().contains(word) + }) + }) + .map(|c| c.clone()) + .collect(); + } +} + +impl Picker { + pub fn draw(&mut self, f: &mut Frame<'_>, area: Rect) -> Result<()> { + f.render_widget(Block::default().bg(color::GRAY_900), area); + + let [table_rect, filter_rect] = Layout::default() + .constraints([Constraint::Percentage(100), Constraint::Min(5)]) + .split(area) + .to_vec() + .try_into() + .unwrap(); + + self.render_table_widget(f, table_rect); + self.render_filter_widget(f, filter_rect); + + if *self.mode.borrow() == Mode::PickerSearchQueryEditing || *self.mode.borrow() == Mode::PickerFilterEditing { + f.set_cursor( + (filter_rect.x + 2 + self.input.cursor() as u16).min(filter_rect.x + filter_rect.width - 2), + filter_rect.y + 2, + ) + } + + Ok(()) + } + + pub fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { + self.action_tx = Some(tx); + Ok(()) + } + + pub fn register_config_handler(&mut self, config: Config) -> Result<()> { + self.config = config; + Ok(()) + } + + pub fn update(&mut self, action: Action) -> Result> { + match action { + Action::Tick => { + self.tick(); + }, + Action::MoveSelectionNext => { + self.next(); + return Ok(Some(Action::GetInfo)); + }, + Action::MoveSelectionPrevious => { + self.previous(); + return Ok(Some(Action::GetInfo)); + }, + Action::MoveSelectionTop => { + self.top(); + return Ok(Some(Action::GetInfo)); + }, + Action::MoveSelectionBottom => { + self.bottom(); + return Ok(Some(Action::GetInfo)); + }, + Action::EnterSearchQueryInsert => { + *self.mode_mut() = Mode::PickerSearchQueryEditing; + self.input = self.input.clone().with_value(self.search.clone()); + }, + Action::EnterFilterInsert => { + *self.mode_mut() = Mode::PickerFilterEditing; + self.input = self.input.clone().with_value(self.filter.clone()); + }, + Action::EnterNormal => { + *self.mode_mut() = Mode::Picker; + if self.filtered_crates.len() > 0 && self.state.selected().is_none() { + self.state.select(Some(0)) + } + }, + Action::SubmitSearchQuery => { + *self.mode_mut() = Mode::Picker; + self.filter.clear(); + return Ok(Some(Action::ReloadData)); + }, + Action::ReloadData => { + self.reload_data(); + }, + Action::GetInfo => { + self.get_info(); + }, + _ => {}, + } + Ok(None) + } + + pub fn handle_events(&mut self, evt: Event) -> Result> { + if let Event::Key(key) = evt { + return self.handle_key_events(key); + } + Ok(None) + } + + pub fn handle_key_events(&mut self, key: KeyEvent) -> Result> { + let cmd = match *self.mode.borrow() { + Mode::Picker => { + match key.code { + KeyCode::Char('q') => Action::Quit, + KeyCode::Char('?') => Action::EnterSearchQueryInsert, + KeyCode::Char('/') => Action::EnterFilterInsert, + KeyCode::Char('j') | KeyCode::Down => Action::MoveSelectionNext, + KeyCode::Char('k') | KeyCode::Up => Action::MoveSelectionPrevious, + KeyCode::Char('g') => { + if let Some(KeyEvent { code: KeyCode::Char('g'), .. }) = self.last_events.last() { + Action::MoveSelectionTop + } else { + self.last_events.push(key.clone()); + return Ok(None); + } + }, + KeyCode::PageUp => Action::MoveSelectionTop, + KeyCode::Char('G') | KeyCode::PageDown => Action::MoveSelectionBottom, + KeyCode::Char('r') => Action::ReloadData, + KeyCode::Home => Action::MoveSelectionTop, + KeyCode::End => Action::MoveSelectionBottom, + KeyCode::Esc => Action::Quit, + _ => return Ok(None), + } + }, + Mode::PickerSearchQueryEditing => { + match key.code { + KeyCode::Esc => Action::EnterNormal, + KeyCode::Enter => Action::SubmitSearchQuery, + _ => { + self.input.handle_event(&crossterm::event::Event::Key(key)); + self.search = self.input.value().into(); + return Ok(None); + }, + } + }, + Mode::PickerFilterEditing => { + match key.code { + KeyCode::Esc => Action::EnterNormal, + KeyCode::Enter => Action::EnterNormal, + _ => { + self.input.handle_event(&crossterm::event::Event::Key(key)); + self.filter = self.input.value().into(); + self.state.select(None); + Action::GetInfo + }, + } + }, + _ => return Ok(None), + }; + Ok(Some(cmd)) + } +} diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..6a0589b --- /dev/null +++ b/src/tui.rs @@ -0,0 +1,239 @@ +use std::{ + ops::{Deref, DerefMut}, + time::Duration, +}; + +use color_eyre::eyre::Result; +use crossterm::{ + cursor, + event::{ + DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, Event as CrosstermEvent, + KeyEvent, KeyEventKind, MouseEvent, + }, + terminal::{EnterAlternateScreen, LeaveAlternateScreen}, +}; +use futures::{FutureExt, StreamExt}; +use ratatui::backend::CrosstermBackend as Backend; +use serde::{Deserialize, Serialize}; +use tokio::{ + sync::mpsc::{self, UnboundedReceiver, UnboundedSender}, + task::JoinHandle, +}; +use tokio_util::sync::CancellationToken; + +pub type IO = std::io::Stdout; +pub fn io() -> IO { + std::io::stdout() +} +pub type Frame<'a> = ratatui::Frame<'a>; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum Event { + Init, + Quit, + Error, + Closed, + Tick, + Render, + FocusGained, + FocusLost, + Paste(String), + Key(KeyEvent), + Mouse(MouseEvent), + Resize(u16, u16), +} + +pub struct Tui { + pub terminal: ratatui::Terminal>, + pub task: JoinHandle<()>, + pub cancellation_token: CancellationToken, + pub event_rx: UnboundedReceiver, + pub event_tx: UnboundedSender, + pub frame_rate: f64, + pub tick_rate: f64, + pub mouse: bool, + pub paste: bool, +} + +impl Tui { + pub fn new() -> Result { + let tick_rate = 4.0; + let frame_rate = 60.0; + let terminal = ratatui::Terminal::new(Backend::new(io()))?; + let (event_tx, event_rx) = mpsc::unbounded_channel(); + let cancellation_token = CancellationToken::new(); + let task = tokio::spawn(async {}); + let mouse = false; + let paste = false; + Ok(Self { terminal, task, cancellation_token, event_rx, event_tx, frame_rate, tick_rate, mouse, paste }) + } + + pub fn tick_rate(mut self, tick_rate: f64) -> Self { + self.tick_rate = tick_rate; + self + } + + pub fn frame_rate(mut self, frame_rate: f64) -> Self { + self.frame_rate = frame_rate; + self + } + + pub fn mouse(mut self, mouse: bool) -> Self { + self.mouse = mouse; + self + } + + pub fn paste(mut self, paste: bool) -> Self { + self.paste = paste; + self + } + + pub fn start(&mut self) { + let tick_delay = std::time::Duration::from_secs_f64(1.0 / self.tick_rate); + let render_delay = std::time::Duration::from_secs_f64(1.0 / self.frame_rate); + self.cancel(); + self.cancellation_token = CancellationToken::new(); + let _cancellation_token = self.cancellation_token.clone(); + let _event_tx = self.event_tx.clone(); + self.task = tokio::spawn(async move { + let mut reader = crossterm::event::EventStream::new(); + let mut tick_interval = tokio::time::interval(tick_delay); + let mut render_interval = tokio::time::interval(render_delay); + _event_tx.send(Event::Init).unwrap(); + loop { + let tick_delay = tick_interval.tick(); + let render_delay = render_interval.tick(); + let crossterm_event = reader.next().fuse(); + tokio::select! { + _ = _cancellation_token.cancelled() => { + break; + } + maybe_event = crossterm_event => { + match maybe_event { + Some(Ok(evt)) => { + match evt { + CrosstermEvent::Key(key) => { + if key.kind == KeyEventKind::Press { + _event_tx.send(Event::Key(key)).unwrap(); + } + }, + CrosstermEvent::Mouse(mouse) => { + _event_tx.send(Event::Mouse(mouse)).unwrap(); + }, + CrosstermEvent::Resize(x, y) => { + _event_tx.send(Event::Resize(x, y)).unwrap(); + }, + CrosstermEvent::FocusLost => { + _event_tx.send(Event::FocusLost).unwrap(); + }, + CrosstermEvent::FocusGained => { + _event_tx.send(Event::FocusGained).unwrap(); + }, + CrosstermEvent::Paste(s) => { + _event_tx.send(Event::Paste(s)).unwrap(); + }, + } + } + Some(Err(_)) => { + _event_tx.send(Event::Error).unwrap(); + } + None => {}, + } + }, + _ = tick_delay => { + _event_tx.send(Event::Tick).unwrap(); + }, + _ = render_delay => { + _event_tx.send(Event::Render).unwrap(); + }, + } + } + }); + } + + pub fn stop(&self) -> Result<()> { + self.cancel(); + let mut counter = 0; + while !self.task.is_finished() { + std::thread::sleep(Duration::from_millis(1)); + counter += 1; + if counter > 50 { + self.task.abort(); + } + if counter > 100 { + log::error!("Failed to abort task in 100 milliseconds for unknown reason"); + break; + } + } + Ok(()) + } + + pub fn enter(&mut self) -> Result<()> { + crossterm::terminal::enable_raw_mode()?; + crossterm::execute!(io(), EnterAlternateScreen, cursor::Hide)?; + if self.mouse { + crossterm::execute!(io(), EnableMouseCapture)?; + } + if self.paste { + crossterm::execute!(io(), EnableBracketedPaste)?; + } + self.start(); + Ok(()) + } + + pub fn exit(&mut self) -> Result<()> { + self.stop()?; + if crossterm::terminal::is_raw_mode_enabled()? { + self.flush()?; + if self.paste { + crossterm::execute!(io(), DisableBracketedPaste)?; + } + if self.mouse { + crossterm::execute!(io(), DisableMouseCapture)?; + } + crossterm::execute!(io(), LeaveAlternateScreen, cursor::Show)?; + crossterm::terminal::disable_raw_mode()?; + } + Ok(()) + } + + pub fn cancel(&self) { + self.cancellation_token.cancel(); + } + + pub fn suspend(&mut self) -> Result<()> { + self.exit()?; + #[cfg(not(windows))] + signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP)?; + Ok(()) + } + + pub fn resume(&mut self) -> Result<()> { + self.enter()?; + Ok(()) + } + + pub async fn next(&mut self) -> Option { + self.event_rx.recv().await + } +} + +impl Deref for Tui { + type Target = ratatui::Terminal>; + + fn deref(&self) -> &Self::Target { + &self.terminal + } +} + +impl DerefMut for Tui { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.terminal + } +} + +impl Drop for Tui { + fn drop(&mut self) { + self.exit().unwrap(); + } +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..7ad7387 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,135 @@ +use std::{path::PathBuf, sync::OnceLock}; + +use clap::Parser; +use color_eyre::eyre::{eyre, Result}; +use directories::ProjectDirs; +use figment::{ + providers::{Env, Format, Serialized, Toml}, + Figment, +}; +use serde::{Deserialize, Serialize}; +use serde_with::{serde_as, skip_serializing_none, DisplayFromStr, NoneAsEmptyString}; +use tracing::{error, level_filters::LevelFilter}; +use tracing_error::ErrorLayer; +use tracing_subscriber::{self, prelude::__tracing_subscriber_SubscriberExt, util::SubscriberInitExt}; + +use crate::config; + +const VERSION_MESSAGE: &str = + concat!(env!("CARGO_PKG_VERSION"), "-", env!("VERGEN_GIT_DESCRIBE"), " (", env!("VERGEN_BUILD_DATE"), ")"); + +pub fn initialize_panic_handler() -> Result<()> { + let (panic_hook, eyre_hook) = color_eyre::config::HookBuilder::default() + .panic_section(format!("This is a bug. Consider reporting it at {}", env!("CARGO_PKG_REPOSITORY"))) + .capture_span_trace_by_default(false) + .display_location_section(false) + .display_env_section(false) + .into_hooks(); + eyre_hook.install()?; + std::panic::set_hook(Box::new(move |panic_info| { + if let Ok(mut t) = crate::tui::Tui::new() { + if let Err(r) = t.exit() { + error!("Unable to exit Terminal: {:?}", r); + } + } + + #[cfg(not(debug_assertions))] + { + use human_panic::{handle_dump, print_msg, Metadata}; + let meta = Metadata { + version: env!("CARGO_PKG_VERSION").into(), + name: env!("CARGO_PKG_NAME").into(), + authors: env!("CARGO_PKG_AUTHORS").replace(':', ", ").into(), + homepage: env!("CARGO_PKG_HOMEPAGE").into(), + }; + + let file_path = handle_dump(&meta, panic_info); + // prints human-panic message + print_msg(file_path, &meta).expect("human-panic: printing error message to console failed"); + eprintln!("{}", panic_hook.panic_report(panic_info)); // prints color-eyre stack trace to stderr + } + let msg = format!("{}", panic_hook.panic_report(panic_info)); + log::error!("Error: {}", strip_ansi_escapes::strip_str(msg)); + + #[cfg(debug_assertions)] + { + // Better Panic stacktrace that is only enabled when debugging. + better_panic::Settings::auto() + .most_recent_first(false) + .lineno_suffix(true) + .verbosity(better_panic::Verbosity::Full) + .create_panic_handler()(panic_info); + } + + std::process::exit(libc::EXIT_FAILURE); + })); + Ok(()) +} + +pub fn initialize_logging() -> Result<()> { + let config = config::get(); + let directory = config.data_dir.clone(); + std::fs::create_dir_all(directory.clone())?; + let project_name = env!("CARGO_CRATE_NAME").to_uppercase().to_string(); + let log_file = format!("{}.log", env!("CARGO_PKG_NAME")); + let log_path = directory.join(log_file); + let log_file = std::fs::File::create(log_path)?; + let log_env = format!("{}_LOGLEVEL", project_name); + // std::env::set_var( + // "RUST_LOG", + // std::env::var("RUST_LOG") + // .or_else(|_| std::env::var(log_env)) + // .unwrap_or_else(|_| format!("{}=info", env!("CARGO_CRATE_NAME"))), + // ); + let file_subscriber = tracing_subscriber::fmt::layer() + .with_file(true) + .with_line_number(true) + .with_writer(log_file) + .with_target(false) + .with_ansi(false); + tracing_subscriber::registry() + .with(file_subscriber) + .with(ErrorLayer::default()) + .with(tracing_subscriber::filter::EnvFilter::from_default_env().add_directive(config.log_level.into())) + .init(); + Ok(()) +} + +/// Similar to the `std::dbg!` macro, but generates `tracing` events rather +/// than printing to stdout. +/// +/// By default, the verbosity level for the generated events is `DEBUG`, but +/// this can be customized. +#[macro_export] +macro_rules! trace_dbg { + (target: $target:expr, level: $level:expr, $ex:expr) => {{ + match $ex { + value => { + tracing::event!(target: $target, $level, ?value, stringify!($ex)); + value + } + } + }}; + (level: $level:expr, $ex:expr) => { + trace_dbg!(target: module_path!(), level: $level, $ex) + }; + (target: $target:expr, $ex:expr) => { + trace_dbg!(target: $target, level: tracing::Level::DEBUG, $ex) + }; + ($ex:expr) => { + trace_dbg!(level: tracing::Level::DEBUG, $ex) + }; +} + +pub fn version() -> String { + let author = clap::crate_authors!(); + + format!( + "\ +{VERSION_MESSAGE} + +Authors: {author} + +" + ) +}