Feat: Add a flag to bypass confirmations + Add a partial implementation of repo sync (#978)

* Added a start of a functional copy of the .git folder

Signed-off-by: alexis-opolka <53085471+alexis-opolka@users.noreply.github.com>

* Add the sync file

Signed-off-by: alexis-opolka <53085471+alexis-opolka@users.noreply.github.com>

* Bump version from beta1 to beta2 + modified sync subcommand entry

Signed-off-by: alexis-opolka <53085471+alexis-opolka@users.noreply.github.com>

* Made minor modifications

Signed-off-by: alexis-opolka <53085471+alexis-opolka@users.noreply.github.com>

* Update crossterm to last release

Signed-off-by: alexis-opolka <53085471+alexis-opolka@users.noreply.github.com>

* Introducing a new internal module - terminal

This module should be able to handle specific tasks with the terminal such as hyperlinks or others not handled by crates

Signed-off-by: alexis-opolka <53085471+alexis-opolka@users.noreply.github.com>

* Add the repo list subcommand

Signed-off-by: alexis-opolka <53085471+alexis-opolka@users.noreply.github.com>

* cargo fmt + minor improvements

Signed-off-by: alexis-opolka <53085471+alexis-opolka@users.noreply.github.com>

* Adds a basic sync function + cargo fmt + cargo clippy compliance + minor refactors

Signed-off-by: alexis-opolka <53085471+alexis-opolka@users.noreply.github.com>

* Add an option for confirmation

Signed-off-by: alexis-opolka <53085471+alexis-opolka@users.noreply.github.com>

* Updated the status of this branch to a stable state

Signed-off-by: alexis-opolka <53085471+alexis-opolka@users.noreply.github.com>

---------

Signed-off-by: alexis-opolka <53085471+alexis-opolka@users.noreply.github.com>
This commit is contained in:
Alexis Opolka 2025-05-03 20:42:44 +02:00 committed by GitHub
parent 138b7e0b00
commit 75eb558198
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 456 additions and 44 deletions

88
Cargo.lock generated
View file

@ -156,16 +156,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
[[package]]
name = "crossterm"
version = "0.28.1"
name = "convert_case"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "crossterm"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
dependencies = [
"bitflags",
"crossterm_winapi",
"derive_more",
"document-features",
"mio",
"parking_lot",
"rustix",
"rustix 1.0.7",
"signal-hook",
"signal-hook-mio",
"winapi",
@ -189,6 +200,36 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "derive_more"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3"
dependencies = [
"convert_case",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "document-features"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d"
dependencies = [
"litrs",
]
[[package]]
name = "dunce"
version = "1.0.5"
@ -330,6 +371,18 @@ version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]]
name = "linux-raw-sys"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12"
[[package]]
name = "litrs"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5"
[[package]]
name = "lock_api"
version = "0.4.12"
@ -375,7 +428,7 @@ dependencies = [
[[package]]
name = "navi"
version = "2.25.0-beta1"
version = "2.25.0-beta2"
dependencies = [
"anyhow",
"clap",
@ -563,7 +616,20 @@ dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"linux-raw-sys 0.4.15",
"windows-sys 0.59.0",
]
[[package]]
name = "rustix"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys 0.9.4",
"windows-sys 0.59.0",
]
@ -718,7 +784,7 @@ dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix",
"rustix 0.38.43",
"windows-sys 0.59.0",
]
@ -819,6 +885,12 @@ version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83"
[[package]]
name = "unicode-segmentation"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-width"
version = "0.2.0"
@ -877,7 +949,7 @@ dependencies = [
"either",
"home",
"once_cell",
"rustix",
"rustix 0.38.43",
]
[[package]]

View file

@ -1,6 +1,6 @@
[package]
name = "navi"
version = "2.25.0-beta1"
version = "2.25.0-beta2"
authors = ["Denis Isidoro <denis_isidoro@live.com>", "Alexis Opolka <alexis.opolka@protonmail.com>"]
edition = "2021"
description = "An interactive cheatsheet tool for the command-line"
@ -24,7 +24,7 @@ regex = { version = "1.7.3", default-features = false, features = [
"unicode-perl",
] }
clap = { version = "4.2.1", features = ["derive", "cargo"] }
crossterm = "0.28.0"
crossterm = "0.29.0"
lazy_static = "1.4.0"
etcetera = "0.10.0"
walkdir = "2.3.3"

View file

@ -1,10 +1,14 @@
use crate::common::git;
use crate::filesystem;
use crate::filesystem::{
all_cheat_files, all_git_files, create_dir, default_cheat_pathbuf, remove_dir, tmp_pathbuf,
};
use crate::finder::questions::finder_yes_no_question;
use crate::finder::structures::{Opts as FinderOpts, SuggestionType};
use crate::finder::FinderChoice;
use crate::prelude::*;
use std::fs;
use std::path;
use std::path::{MAIN_SEPARATOR, MAIN_SEPARATOR_STR};
fn ask_if_should_import_all(finder: &FinderChoice) -> Result<bool> {
let opts = FinderOpts {
@ -13,37 +17,66 @@ fn ask_if_should_import_all(finder: &FinderChoice) -> Result<bool> {
..Default::default()
};
let (response, _) = finder
.call(opts, |stdin| {
stdin
.write_all(b"Yes\nNo")
.context("Unable to writer alternatives")?;
Ok(())
})
.context("Unable to get response")?;
finder_yes_no_question(finder, opts)
}
fn ask_folder_present_question(finder: &FinderChoice) -> Result<bool> {
let opts = FinderOpts {
column: Some(1),
header: Some(
"It seems this cheatsheet repository has been previously added, do you still want to continue?"
.to_string(),
),
..Default::default()
};
Ok(response.to_lowercase().starts_with('y'))
finder_yes_no_question(finder, opts)
}
pub fn main(uri: String) -> Result<()> {
pub fn main(uri: String, yes_flag: bool) -> Result<()> {
let finder = CONFIG.finder();
let should_import_all = ask_if_should_import_all(&finder).unwrap_or(false);
// If the user has set the yes flag, we don't ask a confirmation
let should_import_all = if yes_flag {
true
} else {
ask_if_should_import_all(&finder).unwrap_or(false)
};
let (actual_uri, user, repo) = git::meta(uri.as_str());
let cheat_pathbuf = filesystem::default_cheat_pathbuf()?;
let tmp_pathbuf = filesystem::tmp_pathbuf()?;
// TODO: Using the default cheat pathbuf will send the downloaded cheatsheets
// into the path without taking into account the user-defined paths.
let cheat_pathbuf = default_cheat_pathbuf()?;
let tmp_pathbuf = tmp_pathbuf()?;
let tmp_path_str = &tmp_pathbuf.to_string();
let to_folder = {
let mut p = cheat_pathbuf;
p.push(format!("{user}__{repo}"));
p
};
let _ = filesystem::remove_dir(&tmp_pathbuf);
filesystem::create_dir(&tmp_pathbuf)?;
// Before anything else, we check to see if the folder exists
// if it exists -> ask confirmation if we continue
if fs::exists(&to_folder)? {
// When the yes_flag has been raised => follow through and removes the existing directory
// When the yes_flag has not been raised => ask for confirmation
if yes_flag || ask_folder_present_question(&finder).unwrap_or(false) {
fs::remove_dir_all(&to_folder)?;
} else {
return Ok(());
}
}
let _ = remove_dir(&tmp_pathbuf);
create_dir(&tmp_pathbuf)?;
eprintln!("Cloning {} into {}...\n", &actual_uri, &tmp_path_str);
git::shallow_clone(actual_uri.as_str(), tmp_path_str)
.with_context(|| format!("Failed to clone `{actual_uri}`"))?;
let all_files = filesystem::all_cheat_files(&tmp_pathbuf).join("\n");
let all_files = all_cheat_files(&tmp_pathbuf).join("\n");
let git_files = all_git_files(&tmp_pathbuf).join("\n");
let opts = FinderOpts {
suggestion_type: SuggestionType::MultipleSelections,
@ -67,12 +100,6 @@ pub fn main(uri: String) -> Result<()> {
files
};
let to_folder = {
let mut p = cheat_pathbuf;
p.push(format!("{user}__{repo}"));
p
};
for file in files.split('\n') {
let from = {
let mut p = tmp_pathbuf.clone();
@ -92,7 +119,48 @@ pub fn main(uri: String) -> Result<()> {
.with_context(|| format!("Failed to copy `{}` to `{}`", &from.to_string(), &to.to_string()))?;
}
filesystem::remove_dir(&tmp_pathbuf)?;
// We are copying the .git folder along with the cheat files
// For more details, see: (https://github.com/denisidoro/navi/issues/733)
for file in git_files.split('\n') {
let filename = format!("{}{}", &tmp_path_str, &file);
let from = {
let mut p = tmp_pathbuf.clone();
p.push(&filename);
p
};
let to = {
let mut p = to_folder.clone();
p.push(file);
p
};
let path_str = &PathBuf::clone(&to).to_string();
let local_collection = &path_str.split(MAIN_SEPARATOR).collect::<Vec<&str>>();
let local_to_folder = format!(
"{}{}",
&to_folder.to_string(),
local_collection[0..&local_collection.len() - 1].join(MAIN_SEPARATOR_STR)
);
let complete_local_path = format!(
"{}{}",
&to_folder.clone().to_str().unwrap(),
&to.clone().to_str().unwrap()
);
eprintln!("=> {}", &complete_local_path);
fs::create_dir_all(&local_to_folder).unwrap_or(());
fs::copy(&from, &complete_local_path).with_context(|| {
format!(
"Failed to copy `{}` to `{}`",
&from.to_string(),
&complete_local_path
)
})?;
}
remove_dir(&tmp_pathbuf)?;
eprintln!(
"The following .cheat files were imported successfully:\n{}\n\nThey are now located at {}",

34
src/commands/repo/list.rs Normal file
View file

@ -0,0 +1,34 @@
use crate::filesystem::local_cheatsheet_repositories;
use crate::libs::terminal::hyperlink;
pub fn main() {
let (cheats_repos, _) = local_cheatsheet_repositories();
// Now that we have our list of cheatsheet repositories, we loop through them
// Two behaviours:
// We do have entries -> We show them
// We do not have entries -> We put a message for the user to add one
if cheats_repos.is_empty() {
eprintln!("Uh Oh! It seems you haven't downloaded a cheatsheet repository yet.");
eprintln!("What you can do: \n\n- `navi repo add` to add a cheatsheet repository\n- `navi repo browse` to browse recommended cheatsheet repositories");
// We quit this function
return;
}
// The list shouldn't be empty
eprintln!("You have locally available the following cheatsheet repositories: \n");
for cheat_repo in cheats_repos {
let content = if cheat_repo.starts_with("https://") {
// If the URL is using the HTTPS scheme, we use a hyperlink
// instead of printing its raw value.
hyperlink::new(&cheat_repo, &cheat_repo)
} else {
cheat_repo.to_owned()
};
eprintln!("- {}", content);
}
}

View file

@ -4,16 +4,28 @@ use clap::{Args, Subcommand};
pub mod add;
pub mod browse;
mod list;
mod sync;
#[derive(Debug, Clone, Subcommand)]
pub enum RepoCommand {
/// Browses for featured cheatsheet repos
Browse,
/// Imports cheatsheets from a repo
Add {
/// A URI to a git repository containing .cheat files ("user/repo" will download cheats from github.com/user/repo)
uri: String,
/// Assumes yes for all confirmations
#[clap(short = 'y', long = "yes")]
yes_flag: bool,
},
/// Browses for featured cheatsheet repos
Browse,
/// Synchronize either all cheatsheet repositories or a given one.
Sync {
/// The name of the cheatsheet repository to sync.
name: Option<String>,
},
/// List all downloaded repositories
List,
}
#[derive(Debug, Clone, Args)]
@ -25,17 +37,29 @@ pub struct Input {
impl Runnable for Input {
fn run(&self) -> Result<()> {
match &self.cmd {
RepoCommand::Add { uri } => {
add::main(uri.clone())
RepoCommand::Add { uri, yes_flag } => {
add::main(uri.clone(), *yes_flag)
.with_context(|| format!("Failed to import cheatsheets from `{uri}`"))?;
commands::core::main()
}
RepoCommand::Browse => {
let repo = browse::main().context("Failed to browse featured cheatsheets")?;
add::main(repo.clone())
add::main(repo.clone(), false)
.with_context(|| format!("Failed to import cheatsheets from `{repo}`"))?;
commands::core::main()
}
RepoCommand::Sync { name } => {
sync::main(name.clone())
// TODO: Remove the debug extension later on
.with_context(|| format!("Failed to synchronize cheatsheets from `{:?}`", name))
}
RepoCommand::List => {
list::main();
Ok(())
}
}
}
}

35
src/commands/repo/sync.rs Normal file
View file

@ -0,0 +1,35 @@
use crate::common::git;
use crate::filesystem::local_cheatsheet_repositories;
use crate::prelude::*;
pub fn main(name: Option<String>) -> Result<()> {
let (cheats_repo_uris, cheats_repo_paths) = local_cheatsheet_repositories();
if name.clone().is_some() {
let name = name.clone().unwrap();
// We have been given a repository uri to check
if cheats_repo_uris.contains(&name) {
let folder_index = cheats_repo_uris.iter().position(|r| r == &name).unwrap();
let repo_path = cheats_repo_paths[folder_index].clone();
git::pull(&repo_path)?;
} else {
eprintln!("I don't find {} locally, are you sure you downloaded it?", &name);
}
return Ok(());
}
// We haven't been given a name -> We synchronize every cheatsheet repository we've found
for cheat_repo in cheats_repo_paths {
eprintln!("Pulling the latest version of {}", cheat_repo);
git::pull(&cheat_repo)?;
}
// TODO: Sanitize the cheatsheet folder of any file that is not a cheat file
// Ref: https://github.com/denisidoro/navi/issues/733
Ok(())
}

View file

@ -27,6 +27,55 @@ pub fn meta(uri: &str) -> (String, String, String) {
(actual_uri, user.to_string(), repo)
}
/// Retrieves the remote URI of a git repository
/// Works best with a repository containing only one remote.
pub fn get_remote(uri: &str) -> Result<String> {
// We consider the repository having only one remote
// In case of multiple occurrences, we return the first one and discard the others
let git_path = format!("{}/.git/", &uri);
let mut remotes_uri: Vec<String> = Vec::new();
if std::fs::exists(&git_path)? {
// If the path exists, retrieve the remotes
let remotes = Command::new("git")
.current_dir(&git_path)
.args(["remote"])
.output()
.context("Unable to git remote")?;
// This is the name of the remote, not its URI
let current_remote = String::from_utf8_lossy(&remotes.stdout).trim().to_string();
let remote_uri = Command::new("git")
.current_dir(&git_path)
.args(["config", format!("remote.{}.url", current_remote).as_str()])
.output()
.context(format!(
"Unable to git config remote <remote>.url for {}",
&current_remote
))?;
// This is the URI of the remote
let current_remote_uri = String::from_utf8_lossy(&remote_uri.stdout).trim().to_string();
remotes_uri.push(current_remote_uri);
}
Ok(remotes_uri[0].clone())
}
/// Pulls the latest version of a git repository
pub fn pull(uri: &str) -> Result<()> {
Command::new("git")
.current_dir(uri)
.args(["pull", "origin"])
.spawn()?
.wait()
.expect("Unable to git pull");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -26,3 +26,11 @@ impl EnvConfig {
}
}
}
/// A default implementation for EnvConfig
/// to satisfy cargo clippy.
impl Default for EnvConfig {
fn default() -> Self {
Self::new()
}
}

View file

@ -103,6 +103,10 @@ impl Config {
})
}
pub fn yaml(&self) -> &YamlConfig {
&self.yaml
}
pub fn finder(&self) -> FinderChoice {
self.clap
.finder

View file

@ -10,6 +10,7 @@ use regex::Regex;
use std::cell::RefCell;
use std::path::MAIN_SEPARATOR;
use crate::common::git;
use walkdir::WalkDir;
/// Multiple paths are joint by a platform-specific separator.
@ -29,6 +30,32 @@ pub fn all_cheat_files(path: &Path) -> Vec<String> {
.collect::<Vec<String>>()
}
pub fn all_git_files(path: &Path) -> Vec<String> {
let mut path_str = path.to_str().unwrap().to_owned();
if path_str.ends_with("/") {
// We're removing the trailing '/' at the end, if it exists
path_str.push('/');
}
WalkDir::new(path)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
.map(|e| {
return if e.path().is_file() {
e.path()
.to_str()
.unwrap()
.replace(path_str.as_str(), "")
.to_string()
} else {
"".to_string()
};
})
.filter(|e| e.contains("/.git/"))
.collect::<Vec<String>>()
}
fn paths_from_path_param(env_var: &str) -> impl Iterator<Item = &str> {
env_var.split(JOIN_SEPARATOR).filter(|folder| folder != &"")
}
@ -88,6 +115,16 @@ pub fn cheat_paths(path: Option<String>) -> Result<String> {
}
}
/// Returns the cheats path defined at run time
pub fn running_cheats_path() -> String {
CONFIG.path().unwrap_or_else(|| {
// if we don't have a path, use the default value
let _cheats = default_cheat_pathbuf().unwrap();
_cheats.to_string()
})
}
////////////////////////////////////////////////////////////////////////////////////////////////////
//
// Here are other functions, unrelated to CLI commands (or at least not directly related)
@ -124,6 +161,54 @@ fn get_config_dir_by_platform() -> Result<PathBuf> {
}
}
/// Goes through the cheats path(s) defined within the configuration
/// and sends back any cheatsheet repository remote URI it finds.
pub fn local_cheatsheet_repositories() -> (Vec<String>, Vec<String>) {
let mut cheats_repos_uri: Vec<String> = Vec::new();
let mut cheats_repos_paths: Vec<String> = Vec::new();
let cheats = running_cheats_path();
// We're checking each given paths possible
for cheat_path in cheats.split(':') {
// If the path doesn't exist, continue to the next one
if !std::fs::exists(cheat_path).unwrap() {
continue;
}
let curr_dir = std::fs::read_dir(cheat_path).unwrap();
// We're checking subfolders -> they should contain at least one .cheat files
for entry in curr_dir {
let entry = entry.unwrap();
if entry.file_type().unwrap().is_dir() {
// If the directory doesn't have at least one cheat file -> ignore it and continue
if all_cheat_files(&entry.path()).is_empty() {
continue;
};
// If the directory have at least one cheat file -> add it to the list
// Note: for the list, we are registering the git remote name and not the
// folder name since we modify it internally.
let git_path = format!("{}/{}", &entry.path().display(), ".git");
let folder_path = entry.path().display().to_string();
if std::fs::exists(&git_path).unwrap() {
let remote_uri = git::get_remote(&entry.path().to_string()).unwrap();
cheats_repos_uri.push(remote_uri);
} else {
cheats_repos_uri.push(folder_path.clone());
}
cheats_repos_paths.push(folder_path);
}
}
}
(cheats_repos_uri, cheats_repos_paths)
}
pub fn tmp_pathbuf() -> Result<PathBuf> {
let mut root = default_cheat_pathbuf()?;
root.push("tmp");

View file

@ -1,20 +1,22 @@
use crate::deser;
use crate::prelude::*;
use clap::ValueEnum;
pub use post::process;
use std::io::Write;
use std::process::{self, Output};
use std::process::{Command, Stdio};
pub mod structures;
use clap::ValueEnum;
pub use post::process;
use structures::Opts;
use structures::SuggestionType;
pub mod questions;
pub mod structures;
mod post;
const MIN_FZF_VERSION_MAJOR: u32 = 0;
const MIN_FZF_VERSION_MINOR: u32 = 23;
const MIN_FZF_VERSION_PATCH: u32 = 1;
mod post;
#[derive(Debug, Clone, Copy, Deserialize, ValueEnum)]
pub enum FinderChoice {
Fzf,

16
src/finder/questions.rs Normal file
View file

@ -0,0 +1,16 @@
use crate::finder::structures::Opts;
use crate::finder::FinderChoice;
use crate::prelude::*;
pub fn finder_yes_no_question(finder: &FinderChoice, opts: Opts) -> anyhow::Result<bool> {
let (response, _) = finder
.call(opts, |stdin| {
stdin
.write_all(b"Yes\nNo")
.context("Unable to writer alternatives")?;
Ok(())
})
.context("Unable to get response")?;
Ok(response.to_lowercase().starts_with('y'))
}

View file

@ -16,6 +16,7 @@ mod welcome;
mod libs {
pub mod dns_common;
pub mod terminal;
}
pub use {commands::handle, filesystem::default_config_pathbuf};

View file

@ -0,0 +1,12 @@
/// Takes a URI and a label, returns a UNIX-compliant hyperlink
/// See the definition of this function for more details.
pub fn new(uri: &String, label: &String) -> String {
// This is a temporary way of creating a hyperlink until we find a suitable crate
// to handle this kind of use cases -> A maintained crated specialized in this use case will be
// safer to use than inserting ourselves the escape sequences.
// For more details, see:
// - Terminal hyperlink -> https://askubuntu.com/questions/1391071/creating-a-hyperlink-from-command-line-output-on-a-terminal
// - Rust Hexadecimal escape characters -> https://stackoverflow.com/a/33139393/13025136
format!("\x1b]8;;{}\x1b\\{}\x1b]8;;\x1b\\\n", uri, label)
}

2
src/libs/terminal/mod.rs Normal file
View file

@ -0,0 +1,2 @@
/// This module exposes functions for use with the terminal.
pub mod hyperlink;