├── .github └── workflows │ ├── release.yml │ └── test-cli.yml ├── .gitignore ├── Cargo.toml ├── LICENSE ├── README.md ├── cli ├── .gitignore ├── Cargo.lock ├── Cargo.toml └── src │ ├── args.rs │ ├── handlers │ ├── add.rs │ ├── cli_prompter.rs │ ├── config.rs │ ├── delete.rs │ ├── export.rs │ ├── import.rs │ ├── mod.rs │ ├── search.rs │ └── update.rs │ ├── main.rs │ ├── outputs.rs │ └── utils.rs ├── data ├── .gitignore ├── Cargo.toml └── src │ ├── dal │ ├── mod.rs │ ├── sqlite.rs │ └── sqlite_dal.rs │ ├── lib.rs │ └── models.rs ├── logic ├── .gitignore ├── Cargo.toml └── src │ ├── command.rs │ ├── config.rs │ ├── import_export.rs │ ├── lib.rs │ └── parameters │ ├── blank.rs │ ├── boolean.rs │ ├── int.rs │ ├── mod.rs │ ├── parser.rs │ ├── populator.rs │ ├── string.rs │ └── uuid.rs ├── resources ├── Logo.svg ├── cmd-stack-icon.svg ├── cmdstack-add-cli-cmd.gif ├── cmdstack-add-cli-form.gif ├── cmdstack-delete-cli.gif ├── cmdstack-search-cli-cmd.gif ├── cmdstack-search-cli-form.gif └── cmdstack-update-cli.gif ├── sample_stacks └── demo.json └── ui ├── .gitignore ├── .prettierrc ├── .vscode └── extensions.json ├── README.md ├── components.json ├── index.html ├── package.json ├── postcss.config.js ├── public ├── tauri.svg └── vite.svg ├── src-tauri ├── .gitignore ├── Cargo.toml ├── build.rs ├── capabilities │ └── default.json ├── icons │ ├── 128x128.png │ ├── 128x128@2x.png │ ├── 32x32.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ ├── Square30x30Logo.png │ ├── Square310x310Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ ├── Square89x89Logo.png │ ├── StoreLogo.png │ ├── android │ │ ├── mipmap-hdpi │ │ │ ├── ic_launcher.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ │ ├── ic_launcher.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ │ ├── ic_launcher.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ │ ├── ic_launcher.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_round.png │ │ └── mipmap-xxxhdpi │ │ │ ├── ic_launcher.png │ │ │ ├── ic_launcher_foreground.png │ │ │ └── ic_launcher_round.png │ ├── icon.icns │ ├── icon.ico │ ├── icon.png │ └── ios │ │ ├── AppIcon-20x20@1x.png │ │ ├── AppIcon-20x20@2x-1.png │ │ ├── AppIcon-20x20@2x.png │ │ ├── AppIcon-20x20@3x.png │ │ ├── AppIcon-29x29@1x.png │ │ ├── AppIcon-29x29@2x-1.png │ │ ├── AppIcon-29x29@2x.png │ │ ├── AppIcon-29x29@3x.png │ │ ├── AppIcon-40x40@1x.png │ │ ├── AppIcon-40x40@2x-1.png │ │ ├── AppIcon-40x40@2x.png │ │ ├── AppIcon-40x40@3x.png │ │ ├── AppIcon-512@2x.png │ │ ├── AppIcon-60x60@2x.png │ │ ├── AppIcon-60x60@3x.png │ │ ├── AppIcon-76x76@1x.png │ │ ├── AppIcon-76x76@2x.png │ │ └── AppIcon-83.5x83.5@2x.png ├── src │ ├── lib.rs │ └── main.rs └── tauri.conf.json ├── src ├── App.tsx ├── assets │ └── react.svg ├── components │ ├── add-dialog.tsx │ ├── add-form.tsx │ ├── cmdStackIcon.tsx │ ├── command-display │ │ ├── command-display.tsx │ │ ├── param-viewer.tsx │ │ └── use-command-box.tsx │ ├── command-list.tsx │ ├── command.tsx │ ├── nav.tsx │ ├── remove-dialog.tsx │ ├── search-form.tsx │ ├── settings │ │ ├── settings-dialog.tsx │ │ ├── terminal-toggle.tsx │ │ └── theme-toggle.tsx │ ├── tag-tree.tsx │ ├── theme-provider.tsx │ └── ui │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── checkbox.tsx │ │ ├── dialog.tsx │ │ ├── dropdown-menu.tsx │ │ ├── form.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── popover.tsx │ │ ├── resizable.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── switch.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toast.tsx │ │ ├── toaster.tsx │ │ ├── tooltip.tsx │ │ └── tree-view.tsx ├── hooks │ └── use-toast.ts ├── index.css ├── lib │ └── utils.ts ├── main.tsx ├── page.tsx ├── types │ ├── command.tsx │ ├── config.tsx │ └── parameter.tsx ├── use-command.ts └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── yarn.lock /.github/workflows/test-cli.yml: -------------------------------------------------------------------------------- 1 | name: Build & Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test-cli: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v4 17 | 18 | - name: Cache Cargo registry and build artifacts 19 | uses: actions/cache@v4 20 | with: 21 | path: | 22 | ~/.cargo/registry 23 | ~/.cargo/git 24 | target 25 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 26 | 27 | - name: Set up Rust toolchain 28 | uses: dtolnay/rust-toolchain@stable 29 | 30 | - name: Build 31 | run: cargo build --workspace --exclude ui --verbose 32 | 33 | - name: Clippy 34 | run: cargo clippy --workspace --exclude ui --verbose -- -D warnings 35 | 36 | - name: Formatter 37 | run: cargo fmt --all -- --check 38 | 39 | - name: Test 40 | run: cargo test --workspace --exclude ui --verbose 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | 6 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 7 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 8 | Cargo.lock 9 | 10 | # These are backup files generated by rustfmt 11 | **/*.rs.bk 12 | 13 | # MSVC Windows builds of rustc generate these, which store debugging information 14 | *.pdb 15 | .env 16 | 17 | /security 18 | .DS_Store 19 | # RustRover 20 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 21 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 22 | # and can be added to the global gitignore or merged into this file. For a more nuclear 23 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 24 | #.idea/ 25 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "cli", 4 | "data", 5 | "logic", 6 | "ui/src-tauri" 7 | ] 8 | resolver="2" 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Danyal Khan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /cli/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | 6 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 7 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 8 | Cargo.lock 9 | 10 | # These are backup files generated by rustfmt 11 | **/*.rs.bk 12 | 13 | # MSVC Windows builds of rustc generate these, which store debugging information 14 | *.pdb 15 | 16 | # RustRover 17 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 18 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 19 | # and can be added to the global gitignore or merged into this file. For a more nuclear 20 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 21 | #.idea/ 22 | -------------------------------------------------------------------------------- /cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cmdstack" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | logic = { path = "../logic" } 8 | data = { path = "../data" } 9 | clap = { version = "4.5.7", features = ["derive"] } 10 | inquire = "0.7.5" 11 | thiserror = "1.0.61" 12 | cli-clipboard = "0.4.0" 13 | prettytable = "0.10.0" 14 | rand_regex = "0.17.0" 15 | regex-syntax = "0.8.4" 16 | log = "0.4.22" 17 | log4rs = "1.3.0" 18 | env_logger = "0.11.5" 19 | dirs = "5.0.1" 20 | term_size = "0.3.2" 21 | termion = "4.0.2" 22 | lazy_static = "1.5.0" 23 | validator = { version = "0.20.0", features = ["derive"] } 24 | serde = "1.0.217" 25 | itertools = "0.14.0" 26 | -------------------------------------------------------------------------------- /cli/src/args.rs: -------------------------------------------------------------------------------- 1 | use crate::handlers::config::ConfigArgs; 2 | use clap::{Args, Parser, Subcommand}; 3 | 4 | #[derive(Debug, Parser)] 5 | #[clap(version, about)] 6 | pub struct CmdStackArgs { 7 | #[clap(subcommand)] 8 | pub command: Command, 9 | } 10 | 11 | #[derive(Debug, Subcommand)] 12 | pub enum Command { 13 | /// Add a command to your stack 14 | Add(AddArgs), 15 | 16 | /// Update a command in your stack 17 | Update(SearchArgs), 18 | 19 | /// Delete a command in your stack 20 | Delete(SearchArgs), 21 | 22 | /// Search for a command in your stack 23 | Search(SearchArgs), 24 | 25 | /// Export stack to a JSON file 26 | Export(ImportExportArgs), 27 | 28 | /// Import stack from a JSON file 29 | Import(ImportExportArgs), 30 | 31 | #[clap(subcommand)] 32 | /// Modify the config values 33 | Config(ConfigArgs), 34 | } 35 | 36 | /// Arguments for adding a command 37 | #[derive(Debug, Args)] 38 | pub struct AddArgs { 39 | /// The command to add to your stack 40 | pub command: Option, 41 | 42 | /// Notes relating to the command 43 | #[clap(long = "note", short = 'n')] 44 | pub note: Option, 45 | 46 | /// The tag for the command 47 | #[clap(long = "tag", short = 't')] 48 | pub tag: Option, 49 | 50 | /// Mark the command as favourite 51 | #[clap(long = "favourite", short = 'f', action)] 52 | pub favourite: bool, 53 | } 54 | 55 | /// Arguments for searching and printing commands 56 | #[derive(Debug, Args, Clone)] 57 | pub struct SearchArgs { 58 | /// The text used to filter by command when searching 59 | pub command: Option, 60 | 61 | /// The text used to filter by tag when searching 62 | #[clap(long = "tag", short = 't')] 63 | pub tag: Option, 64 | 65 | /// Display commands in order of most recent use 66 | #[clap(long = "recent", short = 'r', action)] 67 | pub order_by_recently_used: bool, 68 | 69 | /// Only display favourite commands 70 | #[clap(long = "favourite", short = 'f', action)] 71 | pub favourite: bool, 72 | } 73 | 74 | /// Arguments for importing/exporting commands 75 | #[derive(Debug, Args)] 76 | pub struct ImportExportArgs { 77 | /// The relative path of the file 78 | pub file: String, 79 | } 80 | -------------------------------------------------------------------------------- /cli/src/handlers/add.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | args::AddArgs, 3 | handlers::CommandInputValidator, 4 | outputs::{format_output, print_internal_command_table, spacing}, 5 | utils::none_if_empty, 6 | Cli, 7 | }; 8 | use data::models::InternalCommand; 9 | use inquire::error::InquireError; 10 | use inquire::{Select, Text}; 11 | use log::error; 12 | use thiserror::Error; 13 | 14 | #[derive(Error, Debug)] 15 | pub enum HandleAddError { 16 | #[error("Failed to get user input: {0}")] 17 | Inquire(#[from] InquireError), 18 | #[error("Missing field: {0}")] 19 | MissingField(String), 20 | #[error("Failed to initialize logic: {0}")] 21 | LogicAdd(#[from] logic::command::AddCommandError), 22 | } 23 | 24 | impl Cli { 25 | /// CLI handler for the add command 26 | pub fn handle_add_command(&self, args: AddArgs) -> Result<(), HandleAddError> { 27 | let add_args_exist = args.command.is_some(); 28 | 29 | let user_input = if !add_args_exist { 30 | prompt_user_for_add_args(args)? 31 | } else { 32 | InternalCommand::try_from(args)? 33 | }; 34 | 35 | self.logic.add_command(user_input.clone())?; 36 | 37 | if add_args_exist { 38 | // If the user added the command via CLI arguments, we need to 39 | // display the information so they can confirm the validity 40 | print_internal_command_table(&user_input); 41 | } 42 | 43 | Ok(()) 44 | } 45 | } 46 | 47 | /// Generates a wizard to set the properties of a command 48 | fn prompt_user_for_add_args(args: AddArgs) -> Result { 49 | spacing(); 50 | // No check needed since wizard is only displayed if the command field is not present 51 | let command = Text::new(&format_output("Command:")) 52 | .with_validator(CommandInputValidator) 53 | .prompt()?; 54 | 55 | let tag = Text::new(&format_output( 56 | "Tag (Leave blank to skip):", 57 | )) 58 | .with_initial_value(&args.tag.unwrap_or_default()) 59 | .prompt()?; 60 | 61 | let note = Text::new(&format_output( 62 | "Note (Leave blank to skip):", 63 | )) 64 | .with_initial_value(&args.note.unwrap_or_default()) 65 | .prompt()?; 66 | 67 | let favourite = Select::new(&format_output("Favourite:"), vec!["Yes", "No"]) 68 | .with_starting_cursor(!args.favourite as usize) 69 | .prompt()? 70 | == "Yes"; 71 | 72 | Ok(InternalCommand { 73 | command, 74 | tag: none_if_empty(tag), 75 | note: none_if_empty(note), 76 | favourite, 77 | }) 78 | } 79 | 80 | impl TryFrom for InternalCommand { 81 | type Error = HandleAddError; 82 | 83 | fn try_from(args: AddArgs) -> Result { 84 | if let Some(command) = args.command { 85 | Ok(InternalCommand { 86 | command, 87 | tag: args.tag, 88 | note: args.note, 89 | favourite: args.favourite, 90 | }) 91 | } else { 92 | Err(HandleAddError::MissingField("command".to_string())) 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /cli/src/handlers/delete.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | args::SearchArgs, 3 | handlers::cli_prompter::{ 4 | check_search_args_exist, PromptUserForCommandSelectionError, SearchArgsUserInput, 5 | }, 6 | Cli, 7 | }; 8 | use inquire::InquireError; 9 | use log::error; 10 | use logic::command::{SearchCommandArgs, SearchCommandError}; 11 | use thiserror::Error; 12 | 13 | #[derive(Error, Debug)] 14 | pub enum HandleDeleteError { 15 | #[error("Failed to get user input: {0}")] 16 | Inquire(#[from] InquireError), 17 | #[error("No commands found matching query")] 18 | NoCommandsFound, 19 | #[error("Failed to search for command: {0}")] 20 | Search(#[from] SearchCommandError), 21 | #[error("Failed to select a command: {0}")] 22 | SelectCommand(#[from] PromptUserForCommandSelectionError), 23 | #[error("Failed to delete command: {0}")] 24 | LogicDelete(#[from] logic::command::DeleteCommandError), 25 | } 26 | 27 | impl Cli { 28 | /// CLI handler for the delete command 29 | pub fn handle_delete_command(&self, args: SearchArgs) -> Result<(), HandleDeleteError> { 30 | // Get the arguments used for search 31 | let search_user_input = if !check_search_args_exist(&args.command, &args.tag) { 32 | self.prompt_user_for_search_args()? 33 | } else { 34 | SearchArgsUserInput::from(args.clone()) 35 | }; 36 | 37 | let search_results = self.logic.search_command(SearchCommandArgs { 38 | command: search_user_input.command, 39 | tag: search_user_input.tag, 40 | order_by_recently_used: args.order_by_recently_used, 41 | favourites_only: args.favourite, 42 | })?; 43 | 44 | if search_results.is_empty() { 45 | return Err(HandleDeleteError::NoCommandsFound); 46 | } 47 | 48 | let selected_command = self.prompt_user_for_command_selection(search_results)?; 49 | Ok(self.logic.delete_command(selected_command.id)?) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /cli/src/handlers/export.rs: -------------------------------------------------------------------------------- 1 | use crate::{args::ImportExportArgs, Cli}; 2 | use log::error; 3 | use std::path::{Path, PathBuf}; 4 | use thiserror::Error; 5 | 6 | #[derive(Error, Debug)] 7 | pub enum HandleExportError { 8 | #[error("Failed to export commands: {0}")] 9 | LogicExport(#[from] logic::import_export::ExportError), 10 | } 11 | 12 | impl Cli { 13 | /// UI handler for export command 14 | pub fn handle_export_command( 15 | &self, 16 | args: ImportExportArgs, 17 | ) -> Result { 18 | let file_path = Path::new(&args.file).to_path_buf(); 19 | self.logic.create_export_json(&file_path)?; 20 | Ok(file_path) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /cli/src/handlers/import.rs: -------------------------------------------------------------------------------- 1 | use crate::{args::ImportExportArgs, Cli}; 2 | use log::error; 3 | use std::path::{Path, PathBuf}; 4 | use thiserror::Error; 5 | 6 | #[derive(Error, Debug)] 7 | pub enum HandleImportError { 8 | #[error("Failed to import commands: {0}")] 9 | LogicImport(#[from] logic::import_export::ImportError), 10 | } 11 | 12 | impl Cli { 13 | /// UI handler for import command 14 | pub fn handle_import_command( 15 | &self, 16 | args: ImportExportArgs, 17 | ) -> Result<(u64, PathBuf), HandleImportError> { 18 | let file_path = Path::new(&args.file).to_path_buf(); 19 | let num = self.logic.import_data(&file_path)?; 20 | Ok((num, file_path)) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /cli/src/handlers/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod add; 2 | pub mod cli_prompter; 3 | pub mod config; 4 | pub mod delete; 5 | pub mod export; 6 | pub mod import; 7 | pub mod search; 8 | pub mod update; 9 | 10 | use inquire::{ 11 | validator::{StringValidator, Validation}, 12 | CustomUserError, 13 | }; 14 | 15 | #[derive(Clone)] 16 | pub struct CommandInputValidator; 17 | 18 | impl StringValidator for CommandInputValidator { 19 | fn validate(&self, input: &str) -> Result { 20 | if !input.trim().is_empty() { 21 | Ok(Validation::Valid) 22 | } else { 23 | Ok(Validation::Invalid("Command must not be empty".into())) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /cli/src/handlers/search.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | args::SearchArgs, 3 | handlers::cli_prompter::{ 4 | check_search_args_exist, copy_to_clipboard, CopyTextError, 5 | PromptUserForCommandSelectionError, SearchArgsUserInput, 6 | }, 7 | outputs::spacing, 8 | Cli, 9 | }; 10 | use inquire::InquireError; 11 | use log::error; 12 | use logic::{ 13 | command::{SearchCommandArgs, SearchCommandError}, 14 | parameters::parser::SerializableParameter, 15 | }; 16 | use std::{os::unix::process::CommandExt, process::Command}; 17 | use thiserror::Error; 18 | 19 | #[derive(Error, Debug)] 20 | pub enum HandleSearchError { 21 | #[error("Failed to get user input: {0}")] 22 | Inquire(#[from] InquireError), 23 | #[error("No command found")] 24 | NoCommandFound, 25 | #[error("Failed to search for command: {0}")] 26 | Search(#[from] SearchCommandError), 27 | #[error("Failed to select a command: {0}")] 28 | SelectCommand(#[from] PromptUserForCommandSelectionError), 29 | #[error("Failed to copy command: {0}")] 30 | Copy(#[from] CopyTextError), 31 | #[error("Failed to generate parameters: {0}")] 32 | LogicParam(#[from] logic::parameters::ParameterError), 33 | #[error("Failed to update command: {0}")] 34 | LogicUpdate(#[from] logic::command::UpdateCommandError), 35 | #[error("Failed to execute command in terminal: {0}")] 36 | ExecuteCommandInTerminal(String), 37 | } 38 | 39 | impl Cli { 40 | /// UI handler for the search command 41 | pub fn handle_search_command(&self, args: SearchArgs) -> Result<(), HandleSearchError> { 42 | // Get the arguments used for search 43 | let search_user_input = if !check_search_args_exist(&args.command, &args.tag) { 44 | self.prompt_user_for_search_args()? 45 | } else { 46 | SearchArgsUserInput::from(args.clone()) 47 | }; 48 | let search_results = self.logic.search_command(SearchCommandArgs { 49 | command: search_user_input.command, 50 | tag: search_user_input.tag, 51 | order_by_recently_used: args.order_by_recently_used, 52 | favourites_only: args.favourite, 53 | })?; 54 | if search_results.is_empty() { 55 | return Err(HandleSearchError::NoCommandFound); 56 | } 57 | 58 | let user_selection = self.prompt_user_for_command_selection(search_results)?; 59 | 60 | let (non_param_strings, parsed_params) = self 61 | .logic 62 | .parse_parameters(user_selection.internal_command.command.clone())?; 63 | 64 | let has_blank_params = parsed_params 65 | .iter() 66 | .any(|item| matches!(item, SerializableParameter::Blank)); 67 | let blank_param_values = if has_blank_params { 68 | self.fill_blank_params(&parsed_params)? 69 | } else { 70 | spacing(); 71 | Vec::new() 72 | }; 73 | 74 | let (text_to_copy, _) = self.logic.populate_parameters( 75 | non_param_strings, 76 | parsed_params, 77 | blank_param_values, 78 | None, 79 | )?; 80 | 81 | // Prompt the user to edit the generated command 82 | let user_edited_cmd = self.prompt_user_for_command_edit(&text_to_copy)?; 83 | 84 | // Prompt the user for command action 85 | let action = self.prompt_user_for_action()?; 86 | 87 | if action == "Execute" { 88 | let _ = self.logic.update_command_last_used_prop(user_selection.id); 89 | // Note: using `.exec()` will shutdown our app and execute the command if successful. 90 | return Err(HandleSearchError::ExecuteCommandInTerminal( 91 | Command::new("sh") 92 | .args(["-c", &user_edited_cmd]) 93 | .exec() 94 | .to_string(), 95 | )); 96 | } else { 97 | copy_to_clipboard(user_edited_cmd)?; 98 | } 99 | 100 | Ok(self 101 | .logic 102 | .update_command_last_used_prop(user_selection.id)?) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /cli/src/handlers/update.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | args::SearchArgs, 3 | handlers::{ 4 | cli_prompter::{ 5 | check_search_args_exist, PromptUserForCommandSelectionError, SearchArgsUserInput, 6 | }, 7 | CommandInputValidator, 8 | }, 9 | outputs::{format_output, Output}, 10 | utils::none_if_empty, 11 | Cli, 12 | }; 13 | use data::models::InternalCommand; 14 | use inquire::{InquireError, Select, Text}; 15 | use log::error; 16 | use logic::command::{SearchCommandArgs, SearchCommandError}; 17 | use thiserror::Error; 18 | 19 | #[derive(Error, Debug)] 20 | pub enum HandleUpdateError { 21 | #[error("Failed to get user input: {0}")] 22 | Inquire(#[from] InquireError), 23 | #[error("No command found")] 24 | NoCommandFound, 25 | #[error("Failed to search for command: {0}")] 26 | Search(#[from] SearchCommandError), 27 | #[error("Failed to select a command: {0}")] 28 | SelectCommand(#[from] PromptUserForCommandSelectionError), 29 | #[error("Failed to update command: {0}")] 30 | LogicUpdate(#[from] logic::command::UpdateCommandError), 31 | } 32 | 33 | /// Generates a wizard to set the properties of a command 34 | /// 35 | /// Arguments: 36 | /// - cur_command: String - The current command text 37 | /// - cur_note: Option - The current note of the command 38 | /// - cur_tag: Option - The current tag of the command 39 | /// - cur_favourite: bool - The current favourite status of the command 40 | pub fn prompt_user_for_command( 41 | cur_command: InternalCommand, 42 | ) -> Result { 43 | let command = Text::new(&format_output("Command:")) 44 | .with_initial_value(&cur_command.command) 45 | .with_validator(CommandInputValidator) 46 | .prompt()?; 47 | 48 | let tag = Text::new(&format_output( 49 | "Tag (Leave blank to skip):", 50 | )) 51 | .with_initial_value(&cur_command.tag.unwrap_or(String::from(""))) 52 | .prompt()?; 53 | 54 | let note = Text::new(&format_output( 55 | "Note (Leave blank to skip):", 56 | )) 57 | .with_initial_value(&cur_command.note.unwrap_or(String::from(""))) 58 | .prompt()?; 59 | 60 | let favourite = Select::new(&format_output("Favourite:"), vec!["Yes", "No"]) 61 | .with_starting_cursor(if cur_command.favourite { 0 } else { 1 }) 62 | .prompt()? 63 | == "Yes"; 64 | 65 | Ok(InternalCommand { 66 | command, 67 | tag: none_if_empty(tag), 68 | note: none_if_empty(note), 69 | favourite, 70 | }) 71 | } 72 | 73 | impl Cli { 74 | /// UI handler for the update command 75 | pub fn handle_update_command(&self, args: SearchArgs) -> Result<(), HandleUpdateError> { 76 | // Get the arguments used for search 77 | let search_user_input = if !check_search_args_exist(&args.command, &args.tag) { 78 | self.prompt_user_for_search_args()? 79 | } else { 80 | SearchArgsUserInput::from(args.clone()) 81 | }; 82 | 83 | let search_results = self.logic.search_command(SearchCommandArgs { 84 | command: search_user_input.command, 85 | tag: search_user_input.tag, 86 | order_by_recently_used: args.order_by_recently_used, 87 | favourites_only: args.favourite, 88 | })?; 89 | if search_results.is_empty() { 90 | return Err(HandleUpdateError::NoCommandFound); 91 | } 92 | 93 | let user_selection = self.prompt_user_for_command_selection(search_results)?; 94 | 95 | // Get the new command properties from the user 96 | Output::UpdateCommandSectionTitle.print(); 97 | let new_internal_command = prompt_user_for_command(user_selection.internal_command)?; 98 | 99 | Ok(self 100 | .logic 101 | .update_command(user_selection.id, new_internal_command)?) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /cli/src/main.rs: -------------------------------------------------------------------------------- 1 | mod args; 2 | mod handlers; 3 | pub mod outputs; 4 | pub mod utils; 5 | 6 | use args::{CmdStackArgs, Command}; 7 | use clap::Parser; 8 | use handlers::add::HandleAddError; 9 | use handlers::delete::HandleDeleteError; 10 | use handlers::search::HandleSearchError; 11 | use handlers::update::HandleUpdateError; 12 | use log::{error, LevelFilter, SetLoggerError}; 13 | use log4rs::append::file::FileAppender; 14 | use log4rs::config::runtime::ConfigErrors; 15 | use log4rs::config::{Appender, Config, Root}; 16 | use logic::Logic; 17 | use outputs::{ErrorOutput, Output}; 18 | use thiserror::Error; 19 | 20 | #[derive(Error, Debug)] 21 | enum LoggerInitializationError { 22 | #[error("Could not get the log file path: {0}")] 23 | LogPath(String), 24 | 25 | #[error("Could not create the log file: {0}")] 26 | CreatingLogFile(#[from] std::io::Error), 27 | 28 | #[error("Could not create config: {0}")] 29 | CreatingLogConfig(#[from] ConfigErrors), 30 | 31 | #[error("Could not initialize logger: {0}")] 32 | InitializingLogger(#[from] SetLoggerError), 33 | } 34 | 35 | /// Set up logging for CLI 36 | /// 37 | /// If we are doing local development, set up environment logger. 38 | /// Otherwise (if the app was built with the `--release` flag), send logs to a file in the user's file statem. 39 | fn initialize_logger() -> Result<(), LoggerInitializationError> { 40 | if cfg!(debug_assertions) { 41 | // Set up environment logger 42 | env_logger::Builder::new() 43 | .filter(None, LevelFilter::Info) 44 | .init(); 45 | } else { 46 | // Log file path: $HOME/.config/cmdstack/cmdstack.log 47 | let config_dir = match dirs::config_dir() { 48 | Some(dir) => match dir.to_str() { 49 | Some(path) => path.to_string(), 50 | None => { 51 | return Err(LoggerInitializationError::LogPath( 52 | "Could not convert config directory to string".to_string(), 53 | )); 54 | } 55 | }, 56 | None => { 57 | return Err(LoggerInitializationError::LogPath( 58 | "Could not get config directory".to_string(), 59 | )); 60 | } 61 | }; 62 | let logfile_path = config_dir + "/cmdstack/cmdstack.log"; 63 | 64 | let logfile = FileAppender::builder().build(logfile_path)?; 65 | 66 | let config = Config::builder() 67 | .appender(Appender::builder().build("logfile", Box::new(logfile))) 68 | .build(Root::builder().appender("logfile").build(LevelFilter::Info))?; 69 | 70 | log4rs::init_config(config)?; 71 | } 72 | 73 | Ok(()) 74 | } 75 | 76 | pub struct Cli { 77 | logic: Logic, 78 | } 79 | 80 | fn main() { 81 | let _ = initialize_logger().map_err(|e| { 82 | ErrorOutput::Logger.print(); 83 | println!("{:?}", e); 84 | std::process::exit(1); 85 | }); 86 | 87 | let logic = Logic::try_default().unwrap_or_else(|e| { 88 | ErrorOutput::Logic.print(); 89 | println!("{:?}", e); 90 | std::process::exit(1); 91 | }); 92 | let mut cli = Cli { logic }; 93 | 94 | // Configure inquire 95 | inquire::set_global_render_config(inquire::ui::RenderConfig { 96 | prompt: inquire::ui::StyleSheet::default().with_fg(inquire::ui::Color::LightBlue), 97 | ..Default::default() 98 | }); 99 | 100 | let args = CmdStackArgs::parse(); 101 | 102 | match args.command { 103 | Command::Add(add_args) => match cli.handle_add_command(add_args) { 104 | Ok(_) => Output::AddCommandSuccess.print(), 105 | Err(e) => { 106 | match e { 107 | HandleAddError::Inquire(_) => ErrorOutput::UserInput.print(), 108 | _ => ErrorOutput::AddCommand.print(), 109 | }; 110 | error!("Error occurred while adding command: {:?}", e); 111 | } 112 | }, 113 | Command::Update(update_args) => match cli.handle_update_command(update_args) { 114 | Ok(_) => Output::UpdateCommandSuccess.print(), 115 | Err(e) => { 116 | match e { 117 | HandleUpdateError::NoCommandFound => Output::NoCommandsFound.print(), 118 | HandleUpdateError::Inquire(_) => ErrorOutput::UserInput.print(), 119 | HandleUpdateError::SelectCommand(_) => ErrorOutput::UserInput.print(), 120 | _ => ErrorOutput::UpdateCommand.print(), 121 | }; 122 | error!("Error occurred while updating command: {:?}", e); 123 | } 124 | }, 125 | Command::Delete(delete_args) => match cli.handle_delete_command(delete_args) { 126 | Ok(_) => Output::DeleteCommandSuccess.print(), 127 | Err(e) => { 128 | match e { 129 | HandleDeleteError::NoCommandsFound => Output::NoCommandsFound.print(), 130 | HandleDeleteError::Inquire(_) => ErrorOutput::UserInput.print(), 131 | HandleDeleteError::SelectCommand(_) => ErrorOutput::UserInput.print(), 132 | _ => ErrorOutput::DeleteCommand.print(), 133 | }; 134 | error!("Error occurred while deleting command: {:?}", e); 135 | } 136 | }, 137 | Command::Search(search_args) => match cli.handle_search_command(search_args) { 138 | Ok(_) => Output::CommandCopiedToClipboard.print(), 139 | Err(e) => { 140 | match e { 141 | HandleSearchError::NoCommandFound => Output::NoCommandsFound.print(), 142 | HandleSearchError::Inquire(_) => ErrorOutput::UserInput.print(), 143 | HandleSearchError::SelectCommand(_) => ErrorOutput::UserInput.print(), 144 | _ => ErrorOutput::SearchCommand.print(), 145 | }; 146 | error!("Error occurred while searching commands: {:?}", e); 147 | } 148 | }, 149 | Command::Export(export_args) => match cli.handle_export_command(export_args) { 150 | Ok(file_path) => Output::ExportCommandsSuccess(&file_path).print(), 151 | Err(e) => { 152 | ErrorOutput::Export.print(); 153 | error!("Error occurred while exporting commands: {:?}", e); 154 | } 155 | }, 156 | Command::Import(import_args) => match cli.handle_import_command(import_args) { 157 | Ok((num, file_path)) => Output::ImportCommandsSuccess(num, &file_path).print(), 158 | Err(e) => { 159 | ErrorOutput::Import.print(); 160 | error!("Error occurred while importing commands: {:?}", e); 161 | } 162 | }, 163 | Command::Config(config_args) => match cli.handle_config_command(config_args) { 164 | Ok(()) => Output::ConfigUpdate.print(), 165 | Err(e) => { 166 | ErrorOutput::Config.print(); 167 | error!("Error occurred while updating config: {:?}", e); 168 | } 169 | }, 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /cli/src/outputs.rs: -------------------------------------------------------------------------------- 1 | use data::models::InternalCommand; 2 | use lazy_static::lazy_static; 3 | use prettytable::{format, Attr, Cell, Row, Table}; 4 | use std::collections::HashMap; 5 | use std::fmt; 6 | use std::path::Path; 7 | 8 | lazy_static! { 9 | /// To see how colours are rendered refer to this Wikipedia page: 10 | /// 11 | /// https://en.wikipedia.org/wiki/ANSI_escape_code#3-bit_and_4-bit 12 | static ref MACRO_REPLACEMENTS: HashMap<&'static str, &'static str> = { 13 | HashMap::from([ 14 | ("", "\x1b[1m"), // Bold 15 | ("", "\x1b[22m"), // Unbold 16 | ("", "\x1b[3m"), // Italicize 17 | ("", "\x1b[23m"), // Un-italicize 18 | ("
", "\x1b[1m\x1b[4m"), // Bold + Underline 19 | ("
", "\x1b[22m\x1b[24m"), // Unbold + remove underline 20 | ]) 21 | }; 22 | } 23 | 24 | /// Converts the given coded text into ANSI escape codes for printing to the CLI: 25 | /// 26 | /// https://en.wikipedia.org/wiki/ANSI_escape_code 27 | pub fn format_output(text: &str) -> String { 28 | MACRO_REPLACEMENTS 29 | .iter() 30 | .fold(text.to_string(), |acc, (key, val)| acc.replace(key, val)) 31 | } 32 | 33 | /// Prints an command using the `prettytable` crate 34 | pub fn print_internal_command_table(internal_command: &InternalCommand) { 35 | spacing(); 36 | 37 | let mut table = Table::new(); 38 | table.set_format(*format::consts::FORMAT_CLEAN); 39 | 40 | table.add_row(Row::new(vec![ 41 | Cell::new("Command:").with_style(Attr::Bold), 42 | Cell::new(&internal_command.command), 43 | ])); 44 | if let Some(tag) = &internal_command.tag { 45 | table.add_row(Row::new(vec![ 46 | Cell::new("Tag:").with_style(Attr::Bold), 47 | Cell::new(tag), 48 | ])); 49 | } 50 | if let Some(note) = &internal_command.note { 51 | table.add_row(Row::new(vec![ 52 | Cell::new("Note:").with_style(Attr::Bold), 53 | Cell::new(note), 54 | ])); 55 | } 56 | let favourite_status = if internal_command.favourite { 57 | "Yes" 58 | } else { 59 | "No" 60 | }; 61 | table.add_row(Row::new(vec![ 62 | Cell::new("Favourite:").with_style(Attr::Bold), 63 | Cell::new(favourite_status), 64 | ])); 65 | 66 | table.printstd(); 67 | } 68 | 69 | /// Printing vertical space 70 | pub fn spacing() { 71 | println!(); 72 | } 73 | 74 | pub enum Output<'a> { 75 | NoCommandsFound, 76 | UpdateCommandSectionTitle, 77 | UpdateCommandSuccess, 78 | AddCommandSuccess, 79 | DeleteCommandSuccess, 80 | ExportCommandsSuccess(&'a Path), 81 | ImportCommandsSuccess(u64, &'a Path), 82 | CommandCopiedToClipboard, 83 | ConfigUpdate, 84 | BlankParameter, 85 | } 86 | 87 | impl fmt::Display for Output<'_> { 88 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 89 | let message = match self { 90 | Output::NoCommandsFound => "No commands found\n".to_string(), 91 | Output::UpdateCommandSectionTitle => "
Update Command:
".to_string(), 92 | Output::UpdateCommandSuccess => "✅ Command updated\n".to_string(), 93 | Output::AddCommandSuccess => "✅ Command added\n".to_string(), 94 | Output::DeleteCommandSuccess => "✅ Command deleted\n".to_string(), 95 | Output::ExportCommandsSuccess(file) => { 96 | format!("✅ Commands exported to {:?}\n", file) 97 | } 98 | Output::ImportCommandsSuccess(num_cmds, file) => { 99 | format!( 100 | "✅ {} commands imported from {:?}\n", 101 | num_cmds, file 102 | ) 103 | } 104 | Output::CommandCopiedToClipboard => { 105 | "✅ Command copied to clipboard\n".to_string() 106 | } 107 | Output::ConfigUpdate => "✅ Config updated\n".to_string(), 108 | Output::BlankParameter => "Fill in blank parameters:".to_string(), 109 | }; 110 | 111 | write!(f, "{}", format_output(&message)) 112 | } 113 | } 114 | 115 | impl Output<'_> { 116 | pub fn print(&self) { 117 | spacing(); 118 | println!("{}", self); 119 | } 120 | } 121 | 122 | pub enum ErrorOutput { 123 | UserInput, 124 | AddCommand, 125 | UpdateCommand, 126 | DeleteCommand, 127 | SearchCommand, 128 | Export, 129 | Import, 130 | Logger, 131 | Logic, 132 | Config, 133 | } 134 | 135 | impl fmt::Display for ErrorOutput { 136 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 137 | let message = match self { 138 | ErrorOutput::UserInput => "Failed to get input", 139 | ErrorOutput::AddCommand => "Failed to add command", 140 | ErrorOutput::UpdateCommand => "Failed to update command", 141 | ErrorOutput::DeleteCommand => "Failed to delete command", 142 | ErrorOutput::SearchCommand => "Failed to search command", 143 | ErrorOutput::Export => "Failed to export stack", 144 | ErrorOutput::Import => "Failed to import stack", 145 | ErrorOutput::Logger => "Failed to initialize the logger", 146 | ErrorOutput::Logic => "Failed to initialize the logic crate", 147 | ErrorOutput::Config => "Failed to update the config", 148 | }; 149 | 150 | write!( 151 | f, 152 | "{}", 153 | format_output(&format!("❌ {}", message)) 154 | ) 155 | } 156 | } 157 | 158 | impl ErrorOutput { 159 | pub fn print(&self) { 160 | spacing(); 161 | println!("{}", self); 162 | spacing(); 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /cli/src/utils.rs: -------------------------------------------------------------------------------- 1 | pub fn truncate_string(s: &str, width: usize) -> String { 2 | if s.chars().count() > width { 3 | if width < 3 { 4 | "...".to_string() 5 | } else { 6 | format!("{}...", &s.chars().take(width - 3).collect::()) 7 | } 8 | } else { 9 | s.to_string() 10 | } 11 | } 12 | 13 | /// Returns None if the provided string is empty. 14 | /// If a string only contains whitespace, None is returned. 15 | pub fn none_if_empty(s: String) -> Option { 16 | if !s.trim().is_empty() { 17 | Some(s) 18 | } else { 19 | None 20 | } 21 | } 22 | 23 | #[cfg(test)] 24 | mod tests { 25 | use super::*; 26 | 27 | #[test] 28 | fn must_truncate() { 29 | assert_eq!("...", truncate_string("abc", 2)); 30 | assert_eq!("...", truncate_string("abcd", 3)); 31 | assert_eq!("a...", truncate_string("abcde", 4)) 32 | } 33 | 34 | #[test] 35 | fn fits() { 36 | assert_eq!("ab", truncate_string("ab", 2)); 37 | assert_eq!("ab", truncate_string("ab", 3)); 38 | assert_eq!("abc", truncate_string("abc", 3)); 39 | assert_eq!("abcd", truncate_string("abcd", 7)); 40 | } 41 | 42 | #[test] 43 | fn test_none_if_empty() { 44 | assert_eq!(none_if_empty("".to_string()), None); 45 | assert_eq!(none_if_empty(" ".to_string()), None); 46 | assert_eq!( 47 | none_if_empty("non-empty".to_string()), 48 | Some("non-empty".to_string()) 49 | ); 50 | assert_eq!( 51 | none_if_empty(" non-empty ".to_string()), 52 | Some(" non-empty ".to_string()) 53 | ); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /data/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | 6 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 7 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 8 | Cargo.lock 9 | 10 | # These are backup files generated by rustfmt 11 | **/*.rs.bk 12 | 13 | # MSVC Windows builds of rustc generate these, which store debugging information 14 | *.pdb 15 | 16 | # RustRover 17 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 18 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 19 | # and can be added to the global gitignore or merged into this file. For a more nuclear 20 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 21 | #.idea/ 22 | 23 | **/cmdstack_db.db 24 | -------------------------------------------------------------------------------- /data/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "data" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | async-trait = "0.1.80" 8 | sea-query = { version = "0.30.7", features = ["thread-safe", "backend-sqlite"] } 9 | sqlx = { version = "0.7", features = ["sqlite", "runtime-tokio-native-tls"] } 10 | thiserror = "1.0.61" 11 | serde = "1.0.204" 12 | dirs = "5.0.1" 13 | tokio = { version = "1", features = ["full"] } 14 | -------------------------------------------------------------------------------- /data/src/dal/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod sqlite; 2 | pub mod sqlite_dal; 3 | use thiserror::Error; 4 | 5 | #[derive(Error, Debug)] 6 | pub enum InsertCommandError { 7 | #[error("Failed to get the unix timestamp: {0}")] 8 | UnixTimestamp(#[from] std::time::SystemTimeError), 9 | #[error("Expected rows to be affected after insertion but none were affected")] 10 | NoRowsAffected, 11 | #[error("Failed to execute SQL query: {0}")] 12 | Query(#[from] sqlx::Error), 13 | #[error("Failed to build SQL query to insert: {0}")] 14 | QueryBuilder(#[from] sea_query::error::Error), 15 | } 16 | 17 | #[derive(Error, Debug)] 18 | pub enum UpdateCommandError { 19 | #[error("Failed to get the unix timestamp: {0}")] 20 | UnixTimestamp(#[from] std::time::SystemTimeError), 21 | #[error("Expected rows to be affected after update but none were affected")] 22 | NoRowsAffected, 23 | #[error("Failed to execute SQL query: {0}")] 24 | Query(#[from] sqlx::Error), 25 | } 26 | 27 | #[derive(Error, Debug)] 28 | pub enum DeleteCommandError { 29 | #[error("Expected rows to be affected after deletion but none were affected")] 30 | NoRowsAffected, 31 | #[error("Failed to execute SQL query: {0}")] 32 | Query(#[from] sqlx::Error), 33 | } 34 | 35 | #[derive(Error, Debug)] 36 | pub enum SelectAllCommandsError { 37 | #[error("Failed to execute SQL query: {0}")] 38 | Query(#[from] sqlx::Error), 39 | } 40 | 41 | #[derive(Error, Debug)] 42 | pub enum SqlTxError { 43 | #[error("Failed to begin transaction: {0}")] 44 | TxBegin(#[source] sqlx::Error), 45 | 46 | #[error("Failed to commit transaction: {0}")] 47 | TxCommit(#[source] sqlx::Error), 48 | 49 | #[error("Failed to rollback transaction: {0}")] 50 | TxRollback(#[source] sqlx::Error), 51 | } 52 | -------------------------------------------------------------------------------- /data/src/dal/sqlite.rs: -------------------------------------------------------------------------------- 1 | use sea_query::{ColumnDef, Iden, SqliteQueryBuilder, Table}; 2 | use sqlx::sqlite::SqliteConnectOptions; 3 | use sqlx::SqlitePool; 4 | use std::fs; 5 | use std::str::FromStr; 6 | use thiserror::Error; 7 | 8 | #[derive(Error, Debug)] 9 | pub enum SqliteDbConnectionError { 10 | #[error("Could not get the database path: {0}")] 11 | DbPath(String), 12 | 13 | #[error("Could not create sqlite options: {0}")] 14 | SqliteOptionsInitialization(#[source] sqlx::Error), 15 | 16 | #[error("Could not connect to the file: {0}")] 17 | PoolInitialization(#[source] sqlx::Error), 18 | 19 | #[error("Could not create command table: {0}")] 20 | Command(#[source] sqlx::Error), 21 | } 22 | 23 | pub(crate) struct SqliteConnectionPool { 24 | pub(crate) pool: SqlitePool, 25 | } 26 | 27 | impl SqliteConnectionPool { 28 | pub async fn new(db_path: Option) -> Result { 29 | let db_path = match db_path { 30 | Some(path) => path, 31 | None => Self::default_db_path()?, 32 | }; 33 | 34 | let pool = Self::create_connection_pool(db_path).await?; 35 | 36 | Self::create_tables(&pool).await?; 37 | 38 | Ok(SqliteConnectionPool { pool }) 39 | } 40 | 41 | /// Returns the default path to the database file. The default location for the database 42 | /// file is in the `cmdstack` directory which is located in the OS config directory. 43 | /// 44 | /// If the `cmdstack` directory does not exist, it is created 45 | fn default_db_path() -> Result { 46 | let mut path = dirs::config_dir().ok_or_else(|| { 47 | SqliteDbConnectionError::DbPath("Could not get config directory".to_string()) 48 | })?; 49 | path.push("cmdstack"); 50 | 51 | // Create the config directory if it does not exist 52 | fs::create_dir_all(path.as_path()).map_err(|_| { 53 | SqliteDbConnectionError::DbPath(format!( 54 | "Could not create config directory: {:?}", 55 | path.to_str() 56 | )) 57 | })?; 58 | 59 | path.push("database.sqlite"); // Add the database file to the path 60 | path.to_str().map(|s| s.to_string()).ok_or_else(|| { 61 | SqliteDbConnectionError::DbPath("Could not generate the default db path".to_string()) 62 | }) 63 | } 64 | 65 | async fn create_connection_pool( 66 | db_path: String, 67 | ) -> Result { 68 | let connect_options = SqliteConnectOptions::from_str(&db_path) 69 | .map_err(SqliteDbConnectionError::SqliteOptionsInitialization)? 70 | .create_if_missing(true); 71 | 72 | SqlitePool::connect_with(connect_options) 73 | .await 74 | .map_err(SqliteDbConnectionError::PoolInitialization) 75 | } 76 | 77 | /// Initializes the tables in the Sqlite database if they do not exist 78 | async fn create_tables(pool: &SqlitePool) -> Result<(), SqliteDbConnectionError> { 79 | let command_table_sql = Table::create() 80 | .table(Command::Table) 81 | .if_not_exists() 82 | .col( 83 | ColumnDef::new(Command::Id) 84 | .integer() 85 | .not_null() 86 | .primary_key() 87 | .auto_increment(), 88 | ) 89 | .col(ColumnDef::new(Command::Command).string().not_null()) 90 | .col(ColumnDef::new(Command::Tag).string()) 91 | .col(ColumnDef::new(Command::Note).string()) 92 | .col(ColumnDef::new(Command::LastUsed).integer().default(0)) 93 | .col(ColumnDef::new(Command::Favourite).boolean().default(false)) 94 | .build(SqliteQueryBuilder); 95 | 96 | sqlx::query(&command_table_sql) 97 | .execute(pool) 98 | .await 99 | .map(|_| ()) 100 | .map_err(SqliteDbConnectionError::Command) 101 | } 102 | } 103 | 104 | #[derive(Iden)] 105 | /// Command Table Schema 106 | pub enum Command { 107 | Table, 108 | Id, 109 | Command, 110 | Tag, 111 | Note, 112 | LastUsed, 113 | Favourite, 114 | } 115 | -------------------------------------------------------------------------------- /data/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! # Data 2 | //! 3 | //! This crate is responsible for accessing the local SQLite database 4 | 5 | pub mod dal; 6 | pub mod models; 7 | -------------------------------------------------------------------------------- /data/src/models.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 4 | /// Represents the properties of a command that the user will 5 | /// have knowledge about 6 | pub struct InternalCommand { 7 | pub command: String, 8 | pub tag: Option, 9 | pub note: Option, 10 | pub favourite: bool, 11 | } 12 | 13 | #[derive(Debug, Clone, Serialize, Deserialize)] 14 | /// Stores all properties of a command in the database 15 | pub struct Command { 16 | pub id: i64, 17 | pub last_used: i64, 18 | pub internal_command: InternalCommand, 19 | } 20 | -------------------------------------------------------------------------------- /logic/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | 6 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 7 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 8 | Cargo.lock 9 | 10 | # These are backup files generated by rustfmt 11 | **/*.rs.bk 12 | 13 | # MSVC Windows builds of rustc generate these, which store debugging information 14 | *.pdb 15 | 16 | # RustRover 17 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 18 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 19 | # and can be added to the global gitignore or merged into this file. For a more nuclear 20 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 21 | #.idea/ 22 | -------------------------------------------------------------------------------- /logic/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "logic" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | data = { path = "../data" } 8 | fuzzy-matcher = "0.3.7" 9 | log = "0.4.22" 10 | rand = "0.8.5" 11 | rand_regex = "0.17.0" 12 | regex-syntax = "0.8.4" 13 | serde = "1.0.204" 14 | serde_json = "1.0.120" 15 | tempfile = "3.10.1" 16 | thiserror = "1.0" 17 | tokio = { version = "1", features = ["full"] } 18 | sqlx = { version = "0.7", features = ["sqlite", "runtime-tokio-native-tls"] } 19 | regex = "1.11.1" 20 | dirs = "6.0.0" 21 | itertools = "0.14.0" 22 | uuid = { version = "1", features = ["v4", "serde"] } 23 | -------------------------------------------------------------------------------- /logic/src/config.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::path::PathBuf; 3 | use std::{fs, io}; 4 | use thiserror::Error; 5 | 6 | #[derive(Debug, Error)] 7 | pub enum ConfigWriteError { 8 | #[error("Invalid value provided: {0}")] 9 | InvalidValue(String), 10 | #[error("Failed to locate default config directory")] 11 | DefaultConfigDirectory, 12 | #[error("Failed to write config to file: {0}")] 13 | Io(#[from] io::Error), 14 | #[error("Failed to deserialize config file: {0}")] 15 | Deserialize(#[from] serde_json::Error), 16 | } 17 | 18 | #[derive(Debug, Error)] 19 | pub enum ConfigReadError { 20 | #[error("Invalid value provided: {0}")] 21 | InvalidValue(String), 22 | #[error("Failed to locate default config directory")] 23 | DefaultConfigDirectory, 24 | #[error("Failed to read from config file: {0}")] 25 | Io(#[from] io::Error), 26 | #[error("Failed to serialize config file: {0}")] 27 | Serialize(#[from] serde_json::Error), 28 | } 29 | 30 | /// Configuration structure for reading/writing JSON 31 | #[derive(Debug, Serialize, Deserialize, Clone, Copy)] 32 | #[serde(default)] 33 | pub struct Config { 34 | pub cli_print_style: CliPrintStyle, 35 | pub cli_display_limit: u32, 36 | pub param_string_length_min: u32, 37 | pub param_string_length_max: u32, 38 | pub param_int_range_min: i32, 39 | pub param_int_range_max: i32, 40 | pub application_theme: ApplicationTheme, 41 | pub default_terminal: UiDefaultTerminal, 42 | } 43 | 44 | impl Default for Config { 45 | fn default() -> Self { 46 | Self { 47 | cli_print_style: CliPrintStyle::default(), 48 | cli_display_limit: 10, 49 | param_string_length_min: 5, 50 | param_string_length_max: 10, 51 | param_int_range_min: 5, 52 | param_int_range_max: 10, 53 | application_theme: ApplicationTheme::default(), 54 | default_terminal: UiDefaultTerminal::default(), 55 | } 56 | } 57 | } 58 | 59 | #[derive(Debug, Clone, Serialize, Deserialize, Default, Copy)] 60 | pub enum ApplicationTheme { 61 | #[default] 62 | System, 63 | Dark, 64 | Light, 65 | } 66 | 67 | #[derive(Debug, Clone, Serialize, Deserialize, Default, Copy)] 68 | pub enum CliPrintStyle { 69 | #[default] 70 | All, 71 | CommandsOnly, 72 | } 73 | 74 | #[derive(Debug, Clone, Serialize, Deserialize, Default, Copy, PartialEq)] 75 | pub enum UiDefaultTerminal { 76 | Iterm, 77 | #[default] 78 | Terminal, 79 | } 80 | 81 | impl Config { 82 | pub fn read() -> Result { 83 | let config_path = 84 | Config::default_config_file_path()?.ok_or(ConfigReadError::DefaultConfigDirectory)?; 85 | let config_content = fs::read_to_string(&config_path).unwrap_or_else(|_| "{}".to_string()); 86 | let config: Config = 87 | serde_json::from_str(&config_content).map_err(ConfigReadError::Serialize)?; 88 | 89 | Ok(config) 90 | } 91 | 92 | pub fn write(&self) -> Result<(), ConfigWriteError> { 93 | let config_path = 94 | Config::default_config_file_path()?.ok_or(ConfigWriteError::DefaultConfigDirectory)?; 95 | let config_file_content = 96 | serde_json::to_string_pretty(self).map_err(ConfigWriteError::Deserialize)?; 97 | 98 | Ok(fs::write(config_path, config_file_content)?) 99 | } 100 | 101 | fn default_config_file_path() -> Result, io::Error> { 102 | if let Some(mut path) = dirs::config_dir() { 103 | path.push("cmdstack"); 104 | // If we fail to create the directory, return an error with the path 105 | if let Err(e) = fs::create_dir_all(&path) { 106 | return Err(io::Error::new( 107 | e.kind(), 108 | format!("Failed to create config directory: {}", path.display()), 109 | )); 110 | } 111 | path.push("config.json"); 112 | 113 | return Ok(Some(path)); 114 | } 115 | Ok(None) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /logic/src/import_export.rs: -------------------------------------------------------------------------------- 1 | use data::{ 2 | dal::{InsertCommandError, SelectAllCommandsError}, 3 | models::InternalCommand, 4 | }; 5 | use serde::{Deserialize, Serialize}; 6 | use serde_json; 7 | use std::{ 8 | fs::{self}, 9 | path::Path, 10 | }; 11 | use thiserror::Error; 12 | 13 | use crate::Logic; 14 | 15 | #[derive(Debug, Serialize, Deserialize)] 16 | struct ImportExportFormat { 17 | commands: Vec, 18 | } 19 | 20 | #[derive(Error, Debug)] 21 | pub enum ExportError { 22 | #[error("Failed to serialize commands: {0}")] 23 | Deserialize(#[from] serde_json::Error), 24 | #[error("Failed to write commands to file: {0}")] 25 | Write(String), 26 | #[error("Failed to fetch commands from the database: {0}")] 27 | Database(#[from] SelectAllCommandsError), 28 | } 29 | 30 | #[derive(Error, Debug)] 31 | pub enum ImportError { 32 | #[error("Failed to deserialize commands: {0}")] 33 | Serialize(#[from] serde_json::Error), 34 | #[error("Failed to read commands from file: {0}")] 35 | Read(String), 36 | #[error("Failed to insert commands to the database: {0}")] 37 | Database(#[from] InsertCommandError), 38 | } 39 | 40 | impl Logic { 41 | #[tokio::main] 42 | /// Handle the export request by writing all data in the database to the requested JSON file 43 | pub async fn create_export_json(&self, export_file_path: &Path) -> Result<(), ExportError> { 44 | let commands = self.dal.get_all_commands(false, false).await?; 45 | let export_data = ImportExportFormat { 46 | commands: commands 47 | .into_iter() 48 | .map(|command| command.internal_command) 49 | .collect(), 50 | }; 51 | let json_string = serde_json::to_string(&export_data)?; 52 | fs::write(export_file_path, json_string).map_err(|e| ExportError::Write(e.to_string()))?; 53 | 54 | Ok(()) 55 | } 56 | 57 | #[tokio::main] 58 | /// Handle the import request by importing all data in the given JSON file 59 | pub async fn import_data(&self, import_file_path: &Path) -> Result { 60 | let json_string = 61 | fs::read_to_string(import_file_path).map_err(|e| ImportError::Read(e.to_string()))?; 62 | let import_data: ImportExportFormat = serde_json::from_str(&json_string)?; 63 | 64 | let num_commands = self 65 | .dal 66 | .insert_mulitple_commands(import_data.commands) 67 | .await?; 68 | 69 | Ok(num_commands) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /logic/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! # Logic 2 | //! 3 | //! This crate handles the business logic of the application 4 | 5 | pub mod command; 6 | pub mod config; 7 | pub mod import_export; 8 | pub mod parameters; 9 | 10 | use config::{Config, ConfigReadError}; 11 | use data::dal::{sqlite::SqliteDbConnectionError, sqlite_dal::SqliteDal}; 12 | use thiserror::Error; 13 | 14 | #[derive(Debug, Error)] 15 | pub enum LogicInitError { 16 | #[error("Failed to initalize the database connection: {0}")] 17 | Database(#[from] SqliteDbConnectionError), 18 | #[error("Failed to read from config file: {0}")] 19 | Config(#[from] ConfigReadError), 20 | } 21 | 22 | pub struct Logic { 23 | dal: SqliteDal, 24 | pub config: Config, 25 | } 26 | 27 | impl Logic { 28 | pub fn new(dal: SqliteDal) -> Result { 29 | Ok(Logic { 30 | dal, 31 | config: Config::read()?, 32 | }) 33 | } 34 | 35 | pub fn try_default() -> Result { 36 | Ok(Self { 37 | dal: SqliteDal::new()?, 38 | config: Config::read()?, 39 | }) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /logic/src/parameters/blank.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | use super::ParameterError; 4 | use regex::Regex; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | #[derive(Debug, Serialize, Deserialize)] 8 | pub struct BlankParameter; 9 | 10 | impl FromStr for BlankParameter { 11 | type Err = ParameterError; 12 | 13 | fn from_str(s: &str) -> Result { 14 | let blank_param_regex = r"@\{\s*\}"; 15 | let re = Regex::new(blank_param_regex).map_err(|e| { 16 | ParameterError::InvalidRegex(blank_param_regex.to_string(), e.to_string()) 17 | })?; 18 | 19 | if re.is_match(s) { 20 | return Ok(BlankParameter); 21 | } 22 | Err(ParameterError::InvalidParameter) 23 | } 24 | } 25 | 26 | #[cfg(test)] 27 | mod tests { 28 | use crate::parameters::blank::BlankParameter; 29 | use std::str::FromStr; 30 | 31 | #[test] 32 | fn test_from_str() { 33 | let ret = BlankParameter::from_str("@{}"); 34 | assert!(ret.is_ok()); 35 | 36 | let ret = BlankParameter::from_str("@{ }"); 37 | assert!(ret.is_ok()); 38 | } 39 | 40 | #[test] 41 | fn test_from_str_errors() { 42 | // Wrong type 43 | let ret = BlankParameter::from_str("@{int}"); 44 | assert!(ret.is_err()); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /logic/src/parameters/boolean.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::str::FromStr; 3 | 4 | use super::{populator::RandomNumberGenerator, GenerateRandomValues, ParameterError}; 5 | 6 | #[derive(Serialize, Deserialize, Debug, Default)] 7 | pub struct BooleanParameter {} 8 | 9 | impl FromStr for BooleanParameter { 10 | type Err = ParameterError; 11 | 12 | fn from_str(s: &str) -> Result { 13 | match s { 14 | "@{boolean}" => Ok(Self {}), 15 | _ => Err(ParameterError::InvalidParameter), 16 | } 17 | } 18 | } 19 | 20 | impl GenerateRandomValues for BooleanParameter { 21 | fn generate_random_value(&self, rng: &mut dyn RandomNumberGenerator) -> String { 22 | if rng.generate_range(0, 1) == 0 { 23 | "false".to_string() 24 | } else { 25 | "true".to_string() 26 | } 27 | } 28 | } 29 | 30 | #[cfg(test)] 31 | mod tests { 32 | use crate::parameters::boolean::BooleanParameter; 33 | use std::str::FromStr; 34 | 35 | #[test] 36 | fn test_from_str() { 37 | let ret = BooleanParameter::from_str("@{boolean}"); 38 | assert!(ret.is_ok()); 39 | } 40 | 41 | #[test] 42 | fn test_from_str_errors() { 43 | // Parameters provided 44 | let ret = BooleanParameter::from_str("@{boolean[0, 1]}"); 45 | assert!(ret.is_err()); 46 | 47 | // Wrong type 48 | let ret = BooleanParameter::from_str("@{int}"); 49 | assert!(ret.is_err()); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /logic/src/parameters/int.rs: -------------------------------------------------------------------------------- 1 | use regex::Regex; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use crate::config::Config; 5 | 6 | use super::{ 7 | populator::RandomNumberGenerator, FromStrWithConfig, GenerateRandomValues, ParameterError, 8 | }; 9 | 10 | #[derive(Serialize, Deserialize, Debug)] 11 | pub struct IntParameter { 12 | min: i32, 13 | max: i32, 14 | } 15 | 16 | impl Default for IntParameter { 17 | fn default() -> Self { 18 | IntParameter { min: 5, max: 10 } 19 | } 20 | } 21 | 22 | impl FromStrWithConfig for IntParameter { 23 | fn from_str(s: &str, config: &Config) -> Result { 24 | let int_param_regex = r"@\{int(?:\[(?P-?\d+),\s*(?P-?\d+)\])?\}"; 25 | let re = Regex::new(int_param_regex).map_err(|e| { 26 | ParameterError::InvalidRegex(int_param_regex.to_string(), e.to_string()) 27 | })?; 28 | 29 | if let Some(caps) = re.captures(s) { 30 | let min: i32 = if let Some(min) = caps.name("min") { 31 | min.as_str().parse::().map_err(|_| { 32 | ParameterError::TypeParsing( 33 | std::any::type_name::().to_string(), 34 | min.as_str().to_owned(), 35 | ) 36 | })? 37 | } else { 38 | config.param_int_range_min 39 | }; 40 | 41 | let max: i32 = if let Some(max) = caps.name("max") { 42 | max.as_str().parse::().map_err(|_| { 43 | ParameterError::TypeParsing( 44 | std::any::type_name::().to_string(), 45 | max.as_str().to_owned(), 46 | ) 47 | })? 48 | } else { 49 | config.param_int_range_max 50 | }; 51 | 52 | if min > max { 53 | return Err(ParameterError::InvalidMinMax( 54 | min.to_string(), 55 | max.to_string(), 56 | )); 57 | } 58 | 59 | return Ok(Self { min, max }); 60 | } 61 | Err(ParameterError::InvalidParameter) 62 | } 63 | } 64 | 65 | impl GenerateRandomValues for IntParameter { 66 | fn generate_random_value(&self, rng: &mut dyn RandomNumberGenerator) -> String { 67 | rng.generate_range(self.min, self.max).to_string() 68 | } 69 | } 70 | 71 | #[cfg(test)] 72 | mod tests { 73 | use crate::{ 74 | parameters::{int::IntParameter, FromStrWithConfig}, 75 | Config, 76 | }; 77 | 78 | #[test] 79 | fn test_from_str_no_params() { 80 | let ret = IntParameter::from_str("@{int}", &Config::default()); 81 | assert!(ret.is_ok()); 82 | let param = ret.unwrap(); 83 | assert_eq!(param.min, 5); 84 | assert_eq!(param.max, 10); 85 | } 86 | 87 | #[test] 88 | fn test_from_str_params() { 89 | let ret = IntParameter::from_str("@{int[-100, -99]}", &Config::default()); 90 | assert!(ret.is_ok()); 91 | let param = ret.unwrap(); 92 | assert_eq!(param.min, -100); 93 | assert_eq!(param.max, -99); 94 | 95 | let ret = IntParameter::from_str("@{int[-100, 100]}", &Config::default()); 96 | assert!(ret.is_ok()); 97 | let param = ret.unwrap(); 98 | assert_eq!(param.min, -100); 99 | assert_eq!(param.max, 100); 100 | 101 | let ret = IntParameter::from_str("@{int[99, 100]}", &Config::default()); 102 | assert!(ret.is_ok()); 103 | let param = ret.unwrap(); 104 | assert_eq!(param.min, 99); 105 | assert_eq!(param.max, 100); 106 | 107 | let ret = IntParameter::from_str("@{int[0, 0]}", &Config::default()); 108 | assert!(ret.is_ok()); 109 | let param = ret.unwrap(); 110 | assert_eq!(param.min, 0); 111 | assert_eq!(param.max, 0); 112 | } 113 | 114 | #[test] 115 | fn test_from_str_errors() { 116 | // Min and max swapped 117 | let ret = IntParameter::from_str("@{int[1, 0]}", &Config::default()); 118 | assert!(ret.is_err()); 119 | 120 | // Max missing 121 | let ret = IntParameter::from_str("@{int[1, ]}", &Config::default()); 122 | assert!(ret.is_err()); 123 | 124 | // Min missing 125 | let ret = IntParameter::from_str("@{int[, 1]}", &Config::default()); 126 | assert!(ret.is_err()); 127 | 128 | // Min and max missing 129 | let ret = IntParameter::from_str("@{int[, ]}", &Config::default()); 130 | assert!(ret.is_err()); 131 | 132 | // Bracket missing 133 | let ret = IntParameter::from_str("@{int[0, 1}", &Config::default()); 134 | assert!(ret.is_err()); 135 | 136 | // Wrong type 137 | let ret = IntParameter::from_str("@{string[0, 1]}", &Config::default()); 138 | assert!(ret.is_err()); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /logic/src/parameters/mod.rs: -------------------------------------------------------------------------------- 1 | use populator::RandomNumberGenerator; 2 | use thiserror::Error; 3 | 4 | use crate::config::Config; 5 | 6 | pub mod blank; 7 | pub mod boolean; 8 | pub mod int; 9 | pub mod parser; 10 | pub mod populator; 11 | pub mod string; 12 | pub mod uuid; 13 | 14 | pub trait FromStrWithConfig: Sized { 15 | fn from_str(s: &str, config: &Config) -> Result; 16 | } 17 | 18 | pub trait GenerateRandomValues { 19 | fn generate_random_value(&self, rng: &mut dyn RandomNumberGenerator) -> String; 20 | } 21 | 22 | #[derive(Error, Debug)] 23 | pub enum ParameterError { 24 | #[error("Failed to parse into {0} type from string value {1}")] 25 | TypeParsing(String, String), 26 | #[error("Invalid Parameter")] 27 | InvalidParameter, 28 | #[error("Invalid regex pattern: {0} Error: {1}")] 29 | InvalidRegex(String, String), 30 | #[error("Invalid (min,max): ({0},{1}) provided")] 31 | InvalidMinMax(String, String), 32 | #[error("Failed to fill in blank parameters: {0} value(s) provided, needed {1} value(s)")] 33 | MissingBlankParamValues(String, String), 34 | #[error("Failed to fill in parameters: {0} value(s) provided, needed {1} value(s)")] 35 | MissingParamValues(String, String), 36 | } 37 | -------------------------------------------------------------------------------- /logic/src/parameters/parser.rs: -------------------------------------------------------------------------------- 1 | use regex::Regex; 2 | use serde::{Deserialize, Serialize}; 3 | use std::str::FromStr; 4 | 5 | use super::{ 6 | blank::BlankParameter, boolean::BooleanParameter, int::IntParameter, 7 | populator::RandomNumberGenerator, string::StringParameter, uuid::UuidParameter, 8 | FromStrWithConfig, GenerateRandomValues, ParameterError, 9 | }; 10 | use crate::Logic; 11 | 12 | #[derive(Serialize, Deserialize, Debug)] 13 | #[serde(tag = "type", content = "data")] 14 | pub enum SerializableParameter { 15 | Int(IntParameter), 16 | String(StringParameter), 17 | Boolean(BooleanParameter), 18 | Blank, 19 | Uuid(UuidParameter), 20 | } 21 | 22 | impl GenerateRandomValues for SerializableParameter { 23 | fn generate_random_value(&self, rng: &mut dyn RandomNumberGenerator) -> String { 24 | match self { 25 | SerializableParameter::Int(param) => param.generate_random_value(rng), 26 | SerializableParameter::String(param) => param.generate_random_value(rng), 27 | SerializableParameter::Boolean(param) => param.generate_random_value(rng), 28 | SerializableParameter::Uuid(param) => param.generate_random_value(), 29 | SerializableParameter::Blank => String::new(), 30 | } 31 | } 32 | } 33 | 34 | impl Logic { 35 | pub fn parse_parameters( 36 | &self, 37 | command: String, 38 | ) -> Result<(Vec, Vec), ParameterError> { 39 | let regex_string = r"\@\{(?P([^}]*))\}"; 40 | let re = Regex::new(regex_string) 41 | .map_err(|e| ParameterError::InvalidRegex(regex_string.to_string(), e.to_string()))?; 42 | 43 | let mut parameters = Vec::new(); 44 | let mut non_parameter_strs = Vec::new(); 45 | let mut last_end = 0; 46 | 47 | for mat in re.find_iter(&command) { 48 | let param = self.parse_parameter(mat.as_str().to_owned())?; 49 | parameters.push(param); 50 | 51 | non_parameter_strs.push(command[last_end..mat.start()].to_string()); 52 | last_end = mat.end(); 53 | } 54 | 55 | if last_end < command.len() { 56 | non_parameter_strs.push(command[last_end..].to_string()); 57 | } else { 58 | non_parameter_strs.push("".to_string()); 59 | } 60 | 61 | // There should be a parameter for each "gap" between strings 62 | assert_eq!(non_parameter_strs.len() - 1, parameters.len()); 63 | 64 | Ok((non_parameter_strs, parameters)) 65 | } 66 | 67 | fn parse_parameter(&self, s: String) -> Result { 68 | if BlankParameter::from_str(&s).is_ok() { 69 | return Ok(SerializableParameter::Blank); 70 | } 71 | 72 | if let Ok(string_param) = StringParameter::from_str(&s, &self.config) { 73 | return Ok(SerializableParameter::String(string_param)); 74 | } 75 | 76 | if let Ok(int_param) = IntParameter::from_str(&s, &self.config) { 77 | return Ok(SerializableParameter::Int(int_param)); 78 | } 79 | 80 | if let Ok(bool_param) = BooleanParameter::from_str(&s) { 81 | return Ok(SerializableParameter::Boolean(bool_param)); 82 | } 83 | 84 | if let Ok(uuid_param) = UuidParameter::from_str(&s) { 85 | return Ok(SerializableParameter::Uuid(uuid_param)); 86 | } 87 | 88 | Err(ParameterError::InvalidParameter) 89 | } 90 | } 91 | 92 | #[cfg(test)] 93 | mod tests { 94 | use crate::{parameters::parser::SerializableParameter, Logic}; 95 | 96 | #[test] 97 | fn test_parse_parameters_no_parameter() { 98 | let logic = Logic::try_default().unwrap(); 99 | 100 | let ret = logic.parse_parameters("cmd @ @email @wadsf @test {} @".to_string()); 101 | assert!(ret.is_ok()); 102 | let (non_parameter_strings, parameters) = ret.unwrap(); 103 | assert_eq!(parameters.len(), 0); 104 | assert_eq!( 105 | non_parameter_strings, 106 | vec!["cmd @ @email @wadsf @test {} @".to_string()] 107 | ); 108 | } 109 | 110 | #[test] 111 | fn test_parse_parameters() { 112 | let logic = Logic::try_default().unwrap(); 113 | 114 | let ret = logic.parse_parameters("cmd @{boolean} @{int} @{string} @{}".to_string()); 115 | assert!(ret.is_ok()); 116 | let (non_parameter_strings, parameters) = ret.unwrap(); 117 | assert_eq!(parameters.len(), 4); 118 | matches!( 119 | parameters.get(0).unwrap(), 120 | SerializableParameter::Boolean(_) 121 | ); 122 | matches!(parameters.get(1).unwrap(), SerializableParameter::Int(_)); 123 | matches!(parameters.get(2).unwrap(), SerializableParameter::String(_)); 124 | matches!(parameters.get(3).unwrap(), SerializableParameter::Blank); 125 | assert_eq!( 126 | non_parameter_strings, 127 | vec![ 128 | "cmd ".to_string(), 129 | " ".to_string(), 130 | " ".to_string(), 131 | " ".to_string(), 132 | "".to_string() 133 | ] 134 | ); 135 | } 136 | 137 | #[test] 138 | fn test_parse_parameter_blank() { 139 | let logic = Logic::try_default().unwrap(); 140 | 141 | let ret = logic.parse_parameter("@{}".to_string()); 142 | assert!(ret.is_ok()); 143 | matches!(ret.unwrap(), SerializableParameter::Blank); 144 | } 145 | 146 | #[test] 147 | fn test_parse_parameter_int() { 148 | let logic = Logic::try_default().unwrap(); 149 | 150 | let ret = logic.parse_parameter("@{int}".to_string()); 151 | assert!(ret.is_ok()); 152 | matches!(ret.unwrap(), SerializableParameter::Int(_)); 153 | } 154 | 155 | #[test] 156 | fn test_parse_parameter_string() { 157 | let logic = Logic::try_default().unwrap(); 158 | 159 | let ret = logic.parse_parameter("@{string}".to_string()); 160 | assert!(ret.is_ok()); 161 | matches!(ret.unwrap(), SerializableParameter::String(_)); 162 | } 163 | 164 | #[test] 165 | fn test_parse_parameter_boolean() { 166 | let logic = Logic::try_default().unwrap(); 167 | 168 | let ret = logic.parse_parameter("@{boolean}".to_string()); 169 | assert!(ret.is_ok()); 170 | matches!(ret.unwrap(), SerializableParameter::Boolean(_)); 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /logic/src/parameters/string.rs: -------------------------------------------------------------------------------- 1 | use regex::Regex; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use crate::config::Config; 5 | 6 | use super::{ 7 | populator::RandomNumberGenerator, FromStrWithConfig, GenerateRandomValues, ParameterError, 8 | }; 9 | 10 | #[derive(Serialize, Deserialize, Debug)] 11 | pub struct StringParameter { 12 | min: u32, 13 | max: u32, 14 | } 15 | 16 | impl Default for StringParameter { 17 | fn default() -> Self { 18 | StringParameter { min: 5, max: 10 } 19 | } 20 | } 21 | 22 | impl FromStrWithConfig for StringParameter { 23 | fn from_str(s: &str, config: &Config) -> Result { 24 | let string_param_regex = r"@\{string(?:\[(?P(\d+)),\s*(?P(\d+))\])?\}"; 25 | let re = Regex::new(string_param_regex).map_err(|e| { 26 | ParameterError::InvalidRegex(string_param_regex.to_string(), e.to_string()) 27 | })?; 28 | 29 | if let Some(caps) = re.captures(s) { 30 | let min: u32 = if let Some(min) = caps.name("min") { 31 | min.as_str().parse::().map_err(|_| { 32 | ParameterError::TypeParsing( 33 | std::any::type_name::().to_string(), 34 | min.as_str().to_owned(), 35 | ) 36 | })? 37 | } else { 38 | config.param_string_length_min 39 | }; 40 | 41 | let max: u32 = if let Some(max) = caps.name("max") { 42 | max.as_str().parse::().map_err(|_| { 43 | ParameterError::TypeParsing( 44 | std::any::type_name::().to_string(), 45 | max.as_str().to_owned(), 46 | ) 47 | })? 48 | } else { 49 | config.param_string_length_max 50 | }; 51 | 52 | if min > max { 53 | return Err(ParameterError::InvalidMinMax( 54 | min.to_string(), 55 | max.to_string(), 56 | )); 57 | } 58 | 59 | return Ok(Self { min, max }); 60 | } 61 | Err(ParameterError::InvalidParameter) 62 | } 63 | } 64 | 65 | impl GenerateRandomValues for StringParameter { 66 | fn generate_random_value(&self, rng: &mut dyn RandomNumberGenerator) -> String { 67 | let charset: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; 68 | 69 | let length = rng.generate_range(self.min as i32, self.max as i32) as usize; 70 | 71 | assert!(!charset.is_empty()); 72 | 73 | let random_string: String = (0..length) 74 | .map(|_| { 75 | let idx = rng.generate_range(0, (charset.len() - 1) as i32); 76 | charset[idx as usize] as char 77 | }) 78 | .collect(); 79 | random_string 80 | } 81 | } 82 | 83 | #[cfg(test)] 84 | mod tests { 85 | use crate::{ 86 | parameters::{string::StringParameter, FromStrWithConfig}, 87 | Config, 88 | }; 89 | 90 | #[test] 91 | fn test_from_str_no_params() { 92 | let ret = StringParameter::from_str("@{string}", &Config::default()); 93 | assert!(ret.is_ok()); 94 | let param = ret.unwrap(); 95 | assert_eq!(param.min, 5); 96 | assert_eq!(param.max, 10); 97 | } 98 | 99 | #[test] 100 | fn test_from_str_params() { 101 | let ret = StringParameter::from_str("@{string[99, 100]}", &Config::default()); 102 | assert!(ret.is_ok()); 103 | let param = ret.unwrap(); 104 | assert_eq!(param.min, 99); 105 | assert_eq!(param.max, 100); 106 | 107 | let ret = StringParameter::from_str("@{string[0, 0]}", &Config::default()); 108 | assert!(ret.is_ok()); 109 | let param = ret.unwrap(); 110 | assert_eq!(param.min, 0); 111 | assert_eq!(param.max, 0); 112 | } 113 | 114 | #[test] 115 | fn test_from_str_errors() { 116 | // Min and max swapped 117 | let ret = StringParameter::from_str("@{string[1, 0]}", &Config::default()); 118 | assert!(ret.is_err()); 119 | 120 | // Max missing 121 | let ret = StringParameter::from_str("@{string[1, ]}", &Config::default()); 122 | assert!(ret.is_err()); 123 | 124 | // Min missing 125 | let ret = StringParameter::from_str("@{string[, 1]}", &Config::default()); 126 | assert!(ret.is_err()); 127 | 128 | // Min and max missing 129 | let ret = StringParameter::from_str("@{string[, ]}", &Config::default()); 130 | assert!(ret.is_err()); 131 | 132 | // Bracket missing 133 | let ret = StringParameter::from_str("@{string[0, 1}", &Config::default()); 134 | assert!(ret.is_err()); 135 | 136 | // Incorrect type 137 | let ret = StringParameter::from_str("@{int[0, 1]}", &Config::default()); 138 | assert!(ret.is_err()); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /logic/src/parameters/uuid.rs: -------------------------------------------------------------------------------- 1 | use regex::Regex; 2 | use serde::{Deserialize, Serialize}; 3 | use std::str::FromStr; 4 | use uuid::Uuid; 5 | 6 | use super::ParameterError; 7 | 8 | #[derive(Serialize, Deserialize, Debug)] 9 | pub struct UuidParameter; 10 | 11 | impl FromStr for UuidParameter { 12 | type Err = ParameterError; 13 | 14 | fn from_str(s: &str) -> Result { 15 | let uuid_param_regex = r"@\{uuid\}"; 16 | let re = Regex::new(uuid_param_regex).map_err(|e| { 17 | ParameterError::InvalidRegex(uuid_param_regex.to_string(), e.to_string()) 18 | })?; 19 | if re.is_match(s) { 20 | Ok(UuidParameter) 21 | } else { 22 | Err(ParameterError::InvalidParameter) 23 | } 24 | } 25 | } 26 | 27 | impl UuidParameter { 28 | pub fn generate_random_value(&self) -> String { 29 | Uuid::new_v4().to_string() 30 | } 31 | } 32 | 33 | #[cfg(test)] 34 | mod tests { 35 | use super::*; 36 | use std::str::FromStr; 37 | 38 | #[test] 39 | fn test_parse_uuid() { 40 | let result = UuidParameter::from_str("@{uuid}"); 41 | assert!(result.is_ok()); 42 | } 43 | 44 | #[test] 45 | fn test_invalid_uuid() { 46 | // This should fail because the format doesn't match exactly. 47 | let result = UuidParameter::from_str("@{uuid[3,4]}"); 48 | assert!(result.is_err()); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /resources/cmd-stack-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /resources/cmdstack-add-cli-cmd.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/resources/cmdstack-add-cli-cmd.gif -------------------------------------------------------------------------------- /resources/cmdstack-add-cli-form.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/resources/cmdstack-add-cli-form.gif -------------------------------------------------------------------------------- /resources/cmdstack-delete-cli.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/resources/cmdstack-delete-cli.gif -------------------------------------------------------------------------------- /resources/cmdstack-search-cli-cmd.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/resources/cmdstack-search-cli-cmd.gif -------------------------------------------------------------------------------- /resources/cmdstack-search-cli-form.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/resources/cmdstack-search-cli-form.gif -------------------------------------------------------------------------------- /resources/cmdstack-update-cli.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/resources/cmdstack-update-cli.gif -------------------------------------------------------------------------------- /ui/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | build.sh 27 | .env -------------------------------------------------------------------------------- /ui/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "trailingComma": "all" 5 | } 6 | -------------------------------------------------------------------------------- /ui/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"] 3 | } 4 | -------------------------------------------------------------------------------- /ui/README.md: -------------------------------------------------------------------------------- 1 | # Tauri + React + Typescript 2 | 3 | This template should help get you started developing with Tauri, React and Typescript in Vite. 4 | 5 | ## Recommended IDE Setup 6 | 7 | - [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer) 8 | -------------------------------------------------------------------------------- /ui/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/index.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | } 20 | } -------------------------------------------------------------------------------- /ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Tauri + React + Typescript 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "CmdStack", 3 | "private": true, 4 | "version": "0.1.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview", 10 | "tauri": "tauri" 11 | }, 12 | "dependencies": { 13 | "@hookform/resolvers": "^3.9.1", 14 | "@radix-ui/react-accordion": "^1.2.2", 15 | "@radix-ui/react-avatar": "^1.1.1", 16 | "@radix-ui/react-checkbox": "^1.1.3", 17 | "@radix-ui/react-collapsible": "^1.1.2", 18 | "@radix-ui/react-dialog": "^1.1.4", 19 | "@radix-ui/react-dropdown-menu": "^2.1.2", 20 | "@radix-ui/react-icons": "^1.3.1", 21 | "@radix-ui/react-label": "^2.1.1", 22 | "@radix-ui/react-popover": "^1.1.2", 23 | "@radix-ui/react-scroll-area": "^1.2.0", 24 | "@radix-ui/react-select": "^2.1.2", 25 | "@radix-ui/react-separator": "^1.1.1", 26 | "@radix-ui/react-slot": "^1.1.1", 27 | "@radix-ui/react-switch": "^1.1.1", 28 | "@radix-ui/react-tabs": "^1.1.1", 29 | "@radix-ui/react-toast": "^1.2.4", 30 | "@radix-ui/react-tooltip": "^1.1.6", 31 | "@tauri-apps/api": "^2", 32 | "@tauri-apps/plugin-shell": "^2", 33 | "class-variance-authority": "^0.7.1", 34 | "clsx": "^2.1.1", 35 | "date-fns": "^2.28.0", 36 | "jotai": "^2.10.1", 37 | "lucide-react": "^0.471.1", 38 | "react": "^18.2.0", 39 | "react-day-picker": "8.10.1", 40 | "react-dom": "^18.2.0", 41 | "react-hook-form": "^7.54.2", 42 | "react-resizable-panels": "^2.1.6", 43 | "tailwind-merge": "^2.5.4", 44 | "tailwindcss-animate": "^1.0.7", 45 | "use-resize-observer": "^9.1.0", 46 | "zod": "^3.24.1" 47 | }, 48 | "devDependencies": { 49 | "@tauri-apps/cli": "^2", 50 | "@types/node": "^22.8.6", 51 | "@types/react": "^18.2.15", 52 | "@types/react-dom": "^18.2.7", 53 | "@vitejs/plugin-react": "^4.2.1", 54 | "autoprefixer": "^10.4.20", 55 | "postcss": "^8.4.47", 56 | "tailwindcss": "^3.4.14", 57 | "typescript": "^5.2.2", 58 | "vite": "^5.3.1" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /ui/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /ui/public/tauri.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /ui/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Generated by Tauri 6 | # will have schema files for capabilities auto-completion 7 | /gen/schemas 8 | -------------------------------------------------------------------------------- /ui/src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ui" 3 | version = "0.1.0" 4 | description = "A Tauri App" 5 | authors = ["you"] 6 | edition = "2021" 7 | 8 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 9 | 10 | [lib] 11 | # The `_lib` suffix may seem redundant but it is necessary 12 | # to make the lib name unique and wouldn't conflict with the bin name. 13 | # This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 14 | name = "ui_lib" 15 | crate-type = ["staticlib", "cdylib", "rlib"] 16 | 17 | [build-dependencies] 18 | tauri-build = { version = "2", features = [] } 19 | 20 | [dependencies] 21 | tauri = { version = "2", features = [] } 22 | tauri-plugin-shell = "2" 23 | serde = { version = "1", features = ["derive"] } 24 | rustls-pemfile = { version = "2.2.0" } 25 | serde_json = "1" 26 | logic = { path = "../../logic" } 27 | data = { path = "../../data" } 28 | thiserror = "1.0" 29 | itertools = "0.14.0" 30 | deranged = "=0.4.0" 31 | -------------------------------------------------------------------------------- /ui/src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /ui/src-tauri/capabilities/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../gen/schemas/desktop-schema.json", 3 | "identifier": "default", 4 | "description": "Capability for the main window", 5 | "windows": ["main"], 6 | "permissions": [ 7 | "core:default", 8 | "shell:allow-open" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /ui/src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /ui/src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /ui/src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/ios/AppIcon-20x20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/ios/AppIcon-20x20@1x.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/ios/AppIcon-20x20@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/ios/AppIcon-20x20@2x-1.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/ios/AppIcon-20x20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/ios/AppIcon-20x20@2x.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/ios/AppIcon-20x20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/ios/AppIcon-20x20@3x.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/ios/AppIcon-29x29@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/ios/AppIcon-29x29@1x.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/ios/AppIcon-29x29@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/ios/AppIcon-29x29@2x-1.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/ios/AppIcon-29x29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/ios/AppIcon-29x29@2x.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/ios/AppIcon-29x29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/ios/AppIcon-29x29@3x.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/ios/AppIcon-40x40@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/ios/AppIcon-40x40@1x.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/ios/AppIcon-40x40@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/ios/AppIcon-40x40@2x-1.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/ios/AppIcon-40x40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/ios/AppIcon-40x40@2x.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/ios/AppIcon-40x40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/ios/AppIcon-40x40@3x.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/ios/AppIcon-512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/ios/AppIcon-512@2x.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/ios/AppIcon-60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/ios/AppIcon-60x60@2x.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/ios/AppIcon-60x60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/ios/AppIcon-60x60@3x.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/ios/AppIcon-76x76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/ios/AppIcon-76x76@1x.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/ios/AppIcon-76x76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/ios/AppIcon-76x76@2x.png -------------------------------------------------------------------------------- /ui/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danyal002/cmd-stack/507242a7957159423af5ac14c103690c6480a048/ui/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png -------------------------------------------------------------------------------- /ui/src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | // Prevents additional console window on Windows in release, DO NOT REMOVE!! 2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 3 | 4 | fn main() { 5 | ui_lib::run() 6 | } 7 | -------------------------------------------------------------------------------- /ui/src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schema.tauri.app/config/2", 3 | "productName": "CmdStack", 4 | "version": "0.1.0", 5 | "identifier": "com.ui.app", 6 | "build": { 7 | "beforeDevCommand": "yarn dev", 8 | "devUrl": "http://localhost:1420", 9 | "beforeBuildCommand": "yarn build", 10 | "frontendDist": "../dist" 11 | }, 12 | "app": { 13 | "windows": [ 14 | { 15 | "title": "CmdStack", 16 | "width": 1440, 17 | "height": 810, 18 | "minWidth": 850, 19 | "minHeight": 600 20 | } 21 | ], 22 | "security": { 23 | "csp": null 24 | } 25 | }, 26 | "bundle": { 27 | "active": true, 28 | "targets": "all", 29 | "icon": [ 30 | "icons/32x32.png", 31 | "icons/128x128.png", 32 | "icons/128x128@2x.png", 33 | "icons/icon.icns", 34 | "icons/icon.ico" 35 | ] 36 | } 37 | } -------------------------------------------------------------------------------- /ui/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { ThemeProvider } from './components/theme-provider'; 2 | import './index.css'; 3 | import CommandPage from './page'; 4 | 5 | function App() { 6 | return ( 7 |
8 | 9 | 10 | 11 |
12 | ); 13 | } 14 | 15 | export default App; 16 | -------------------------------------------------------------------------------- /ui/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/src/components/add-dialog.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Dialog, 3 | DialogContent, 4 | DialogHeader, 5 | DialogTrigger, 6 | } from '@/components/ui/dialog'; 7 | import { AddForm } from './add-form'; 8 | import { Button } from '@/components/ui/button'; 9 | import { Plus } from 'lucide-react'; 10 | import { useState } from 'react'; 11 | import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; 12 | 13 | interface AddDialogProps {} 14 | 15 | export function AddDialog({}: AddDialogProps) { 16 | const [open, setOpen] = useState(false); 17 | 18 | return ( 19 | 20 | 21 | 22 | 23 | 27 | 28 | Add command 29 | 30 | 31 | 32 | 33 | setOpen(false)} /> 34 | 35 | 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /ui/src/components/add-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { zodResolver } from '@hookform/resolvers/zod'; 4 | import { invoke } from '@tauri-apps/api/core'; 5 | import { useForm } from 'react-hook-form'; 6 | import { z } from 'zod'; 7 | 8 | import { Button } from '@/components/ui/button'; 9 | import { 10 | Form, 11 | FormControl, 12 | FormDescription, 13 | FormField, 14 | FormItem, 15 | FormLabel, 16 | FormMessage, 17 | } from '@/components/ui/form'; 18 | import { Input } from '@/components/ui/input'; 19 | import { toast } from '@/hooks/use-toast'; 20 | import { useCommands } from '@/use-command'; 21 | import { Plus } from 'lucide-react'; 22 | import { Checkbox } from './ui/checkbox'; 23 | import { Separator } from './ui/separator'; 24 | 25 | const FormSchema = z.object({ 26 | command: z.string().min(1, { 27 | message: 'Command must be at least 1 character.', 28 | }), 29 | tag: z.string(), 30 | note: z.string(), 31 | favourite: z.boolean(), 32 | }); 33 | 34 | interface AddFormProps { 35 | onSuccess: () => void; 36 | } 37 | 38 | export function AddForm({ onSuccess }: AddFormProps) { 39 | const [, refreshCommands] = useCommands(); 40 | 41 | const form = useForm>({ 42 | resolver: zodResolver(FormSchema), 43 | defaultValues: { 44 | command: '', 45 | tag: '', 46 | note: '', 47 | favourite: false, 48 | }, 49 | }); 50 | 51 | function onSubmit(data: z.infer) { 52 | invoke('add_command', { command: data }) 53 | .then((res) => { 54 | console.log(res); 55 | toast({ 56 | title: 'Command added ✅ ', 57 | }); 58 | 59 | refreshCommands(); 60 | 61 | onSuccess(); 62 | }) 63 | .catch((error) => { 64 | console.log(error); 65 | toast({ 66 | title: `${error} ❌`, 67 | }); 68 | }); 69 | } 70 | 71 | return ( 72 |
73 | 74 | ( 78 | 79 | Command 80 | 81 | 88 | 89 | This is your command. 90 | 91 | 92 | )} 93 | /> 94 | ( 98 | 99 | Tag 100 | 101 | 107 | 108 | 109 | This is your tag for the command. 110 | 111 | 112 | 113 | )} 114 | /> 115 | ( 119 | 120 | Note 121 | 122 | 126 | 127 | 128 | This is your note for the command. 129 | 130 | 131 | 132 | )} 133 | /> 134 | ( 138 | 139 |
140 | Favourite 141 | 142 | 146 | 147 | 148 |
149 | Add this command to favourites. 150 |
151 | )} 152 | /> 153 | 154 |
155 | 159 |
160 | 161 | 162 | ); 163 | } 164 | -------------------------------------------------------------------------------- /ui/src/components/cmdStackIcon.tsx: -------------------------------------------------------------------------------- 1 | const cmdStackIcon = ( 2 | 9 | 10 | 14 | 15 | 16 | ); 17 | 18 | const cmdStackIconWithText = ( 19 | 26 | 30 | 34 | 35 | 39 | 40 | 41 | ); 42 | 43 | export { cmdStackIcon, cmdStackIconWithText }; 44 | -------------------------------------------------------------------------------- /ui/src/components/command-display/param-viewer.tsx: -------------------------------------------------------------------------------- 1 | import { Parameter, ParameterType } from '@/types/parameter'; 2 | import { Label } from '../ui/label'; 3 | import { Input } from '../ui/input'; 4 | 5 | interface ParamViewerProps { 6 | parameters: Parameter[]; 7 | generatedValues: string[]; 8 | blankParamValues: string[]; 9 | setBlankParam: (index: number, value: string) => void; 10 | } 11 | 12 | export function ParamViewer({ 13 | parameters, 14 | generatedValues, 15 | blankParamValues, 16 | setBlankParam, 17 | }: ParamViewerProps) { 18 | return ( 19 |
20 | {parameters.map((parameter, index) => { 21 | if (parameter.type == ParameterType.Blank) { 22 | let blankIndex = parameters 23 | .slice(0, index) 24 | .filter((p) => p.type == ParameterType.Blank).length; 25 | 26 | return ( 27 | 33 | ); 34 | } else { 35 | return ( 36 | 41 | ); 42 | } 43 | })} 44 |
45 | ); 46 | } 47 | 48 | interface ParamProps { 49 | parameter: Parameter; 50 | generatedValue: string; 51 | } 52 | 53 | function Param({ parameter, generatedValue }: ParamProps) { 54 | return ( 55 |
56 | 62 | 65 |
66 | ); 67 | } 68 | 69 | interface BlankParamProps { 70 | blankIndex: number; 71 | blankParamValue: string; 72 | setBlankParam: (index: number, value: string) => void; 73 | } 74 | 75 | function BlankParam({ 76 | blankIndex, 77 | blankParamValue, 78 | setBlankParam, 79 | }: BlankParamProps) { 80 | function onChange(e: React.ChangeEvent): void { 81 | const input = e.target.value; 82 | setBlankParam(blankIndex, input); 83 | } 84 | 85 | return ( 86 |
87 | 90 | 98 |
99 | ); 100 | } 101 | -------------------------------------------------------------------------------- /ui/src/components/command-display/use-command-box.tsx: -------------------------------------------------------------------------------- 1 | import { Copy, SquareTerminal } from 'lucide-react'; 2 | import { Button } from '../ui/button'; 3 | import { Textarea } from '../ui/textarea'; 4 | import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'; 5 | import { invoke } from '@tauri-apps/api/core'; 6 | import { toast } from '@/hooks/use-toast'; 7 | import { useSettings } from '@/use-command'; 8 | 9 | interface UseCommandBoxProps { 10 | command: string; 11 | commandId: string; 12 | onChangeCommand: (e: React.ChangeEvent) => void; 13 | } 14 | 15 | export function UseCommandBox({ 16 | command, 17 | commandId, 18 | onChangeCommand, 19 | }: UseCommandBoxProps) { 20 | const [settings] = useSettings(); 21 | 22 | function onUseCommand() { 23 | invoke('update_command_last_used', { 24 | commandId, 25 | }).catch((error) => { 26 | console.error(error); 27 | toast({ 28 | title: `An error occurred whilst updating metadata. Please refer to logs. ❌`, 29 | }); 30 | }); 31 | } 32 | 33 | function onCopy() { 34 | navigator.clipboard.writeText(command); 35 | toast({ 36 | title: 'Copied to clipboard ✅', 37 | }); 38 | 39 | onUseCommand(); 40 | } 41 | 42 | function onExecuteInTerminal() { 43 | invoke('execute_in_terminal', { 44 | command, 45 | }) 46 | .then(() => { 47 | onUseCommand(); 48 | }) 49 | .catch((error) => { 50 | console.error(error); 51 | toast({ 52 | title: `${error} ❌`, 53 | }); 54 | }); 55 | } 56 | 57 | return ( 58 |
59 |