diff --git a/Cargo.lock b/Cargo.lock index b2251114..7ff8ecb0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -584,6 +584,27 @@ dependencies = [ "serde", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1529,6 +1550,8 @@ checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" dependencies = [ "equivalent", "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -1620,6 +1643,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.10.0", + "libc", +] + [[package]] name = "litemap" version = "0.8.1" @@ -1882,6 +1915,12 @@ dependencies = [ "windows-sys 0.42.0", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "pango" version = "0.21.5" @@ -2147,6 +2186,17 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror", +] + [[package]] name = "regex" version = "1.12.2" @@ -2512,15 +2562,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] @@ -2561,8 +2611,12 @@ version = "25.12.0" dependencies = [ "abi_stable", "anyrun-plugin", + "dirs", + "fuzzy-matcher", + "indexmap", "ron 0.8.1", "serde", + "serde_json", ] [[package]] @@ -3777,3 +3831,9 @@ dependencies = [ "quote", "syn 2.0.111", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/plugins/shell/Cargo.toml b/plugins/shell/Cargo.toml index 09a9c71b..72f8cb65 100644 --- a/plugins/shell/Cargo.toml +++ b/plugins/shell/Cargo.toml @@ -11,5 +11,9 @@ crate-type = [ "cdylib" ] [dependencies] abi_stable = "0.11.1" anyrun-plugin = { path = "../../anyrun-plugin" } +dirs = "6.0.0" +fuzzy-matcher = "0.3.7" +indexmap = { features = [ "serde" ], version = "2.12.1" } ron = "0.8.0" serde = { features = [ "derive" ], version = "1.0.228" } +serde_json = "1.0.149" diff --git a/plugins/shell/README.md b/plugins/shell/README.md index 9c5a45cb..c489f359 100644 --- a/plugins/shell/README.md +++ b/plugins/shell/README.md @@ -14,5 +14,11 @@ Config( prefix: ":sh", // Override the shell used to launch the command shell: None, + max_entries: 10, + // to enable history: + // Some(( + // capacity: 100, + // )), + history: None, ) -``` \ No newline at end of file +``` diff --git a/plugins/shell/src/history.rs b/plugins/shell/src/history.rs new file mode 100644 index 00000000..15275709 --- /dev/null +++ b/plugins/shell/src/history.rs @@ -0,0 +1,99 @@ +use std::fs::File; +use std::io::{BufReader, BufWriter, Seek, SeekFrom, Write}; + +use indexmap::IndexSet; +use serde::{Deserialize, Serialize}; + +use crate::HistoryConfig; + +#[derive(Serialize, Deserialize, Default)] +struct PersistedHistory { + elements: T, +} +type PersistedHistoryOwned = PersistedHistory>; +type PersistedHistoryBorrowed<'a> = PersistedHistory<&'a IndexSet>; + +#[derive(Serialize, Deserialize, PartialEq, Eq, Hash, Debug)] +pub struct HistoryItem { + pub command: String, +} + +impl HistoryItem { + fn new(command: String) -> Self { + Self { command } + } +} + +#[derive(Debug)] +pub struct History { + store: File, + elements: IndexSet, + pub cap: usize, +} + +impl History { + pub fn new(history_config: &HistoryConfig) -> Result { + let maybe_history_path = + dirs::state_dir().map(|s| s.join("anyrun").join("shell").join("history.json")); + + if let Some(history_path) = maybe_history_path { + if let Some(dir) = history_path.parent() { + std::fs::create_dir_all(dir)?; + } + + let file = File::options() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&history_path)?; + + let reader = BufReader::new(&file); + let persisted_history: PersistedHistoryOwned = match serde_json::from_reader(reader) { + Ok(val) => val, + Err(e) if e.is_eof() => PersistedHistory::default(), + Err(e) => return Err(e.into()), + }; + + Ok(History { + store: file, + elements: persisted_history.elements, + cap: history_config.capacity, + }) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "failed to get the user state directory", + )) + } + } + + pub fn push(&mut self, value: String) -> Result<(), std::io::Error> { + // insert_before ensures new usages of existing commands bubble up to the top of the history, simple `insert` does not + self.elements + .insert_before(self.elements.len(), HistoryItem::new(value)); + + if self.elements.len() > self.cap { + let remove_count = self.elements.len().saturating_sub(self.cap); + self.elements.drain(0..remove_count); + } + + self.store.set_len(0)?; + self.store.seek(SeekFrom::Start(0))?; + + let mut writer = BufWriter::new(&self.store); + serde_json::to_writer( + &mut writer, + &PersistedHistoryBorrowed { + elements: &self.elements, + }, + )?; + writer.flush()?; + + Ok(()) + } + + pub fn elements(&self) -> impl Iterator { + self.elements.iter() + } +} diff --git a/plugins/shell/src/lib.rs b/plugins/shell/src/lib.rs index 3620b77f..6a9640e3 100644 --- a/plugins/shell/src/lib.rs +++ b/plugins/shell/src/lib.rs @@ -2,12 +2,31 @@ use std::{env, fs, process::Command}; use abi_stable::std_types::{ROption, RString, RVec}; use anyrun_plugin::*; +use fuzzy_matcher::FuzzyMatcher; use serde::Deserialize; -#[derive(Deserialize)] +use self::history::History; + +mod history; + +#[derive(Deserialize, Debug)] +struct HistoryConfig { + capacity: usize, +} + +#[derive(Deserialize, Debug)] struct Config { prefix: String, shell: Option, + #[serde(default = "Config::default_max_entries")] + max_entries: usize, + history: Option, +} + +impl Config { + fn default_max_entries() -> usize { + 10 + } } impl Default for Config { @@ -15,15 +34,49 @@ impl Default for Config { Config { prefix: ":sh".to_string(), shell: None, + max_entries: Config::default_max_entries(), + history: None, } } } +#[derive(Default, Debug)] +struct State { + config: Config, + history: Option, +} + #[init] -fn init(config_dir: RString) -> Config { - match fs::read_to_string(format!("{}/shell.ron", config_dir)) { - Ok(content) => ron::from_str(&content).unwrap_or_default(), - Err(_) => Config::default(), +fn init(config_dir: RString) -> State { + let config_dir = format!("{}/shell.ron", config_dir); + match fs::read_to_string(&config_dir) { + Ok(content) => { + let config: Config = ron::from_str(&content).unwrap_or_else(|err| { + let def = Config::default(); + eprintln!("[shell] Failed to parse configuration: {:?}", err); + eprintln!("[shell] Proceeding with fallback configuration: {:?}", def); + def + }); + + let history = config.history.as_ref().and_then(|h| match History::new(h) { + Ok(history) => Some(history), + Err(err) => { + eprintln!("[shell] Failed to initialize history: {}", err); + None + } + }); + + State { config, history } + } + Err(err) => { + let def = State::default(); + eprintln!( + "[shell] Failed to read configuration from '{}': {}", + &config_dir, err + ); + eprintln!("[shell] Proceeding with fallback configuration: {:?})", def); + def + } } } @@ -36,28 +89,57 @@ fn info() -> PluginInfo { } #[get_matches] -fn get_matches(input: RString, config: &Config) -> RVec { +fn get_matches(input: RString, state: &State) -> RVec { + let config = &state.config; if input.starts_with(&config.prefix) { - let (_, command) = input.split_once(&config.prefix).unwrap(); - if !command.is_empty() { - vec![Match { - title: command.trim().into(), - description: ROption::RSome( - config - .shell - .clone() - .unwrap_or_else(|| { - env::var("SHELL").unwrap_or_else(|_| { - "The shell could not be determined!".to_string() - }) + let (_, input) = input.split_once(&config.prefix).unwrap(); + let input = input.trim(); + if !input.is_empty() { + let history_matches = state + .history + .as_ref() + .map(|history| { + let matcher = fuzzy_matcher::skim::SkimMatcherV2::default().ignore_case(); + let mut matches = history + .elements() + .filter_map(|s| { + matcher + .fuzzy_match(&s.command, input) + .map(|score| (s, score)) }) - .into(), - ), - use_pango: false, - icon: ROption::RNone, - id: ROption::RNone, - }] - .into() + .collect::>(); + + matches.sort_by(|(_, score_a), (_, score_b)| score_b.cmp(score_a)); + + matches + .iter() + .map(|(hist_item, _)| hist_item.command.as_str()) + .collect::>() + }) + .unwrap_or_default(); + + std::iter::once(input) + .chain(history_matches.into_iter()) + .take(config.max_entries) + .map(|cmd| Match { + title: cmd.into(), + description: ROption::RSome( + config + .shell + .clone() + .unwrap_or_else(|| { + env::var("SHELL").unwrap_or_else(|_| { + "The shell could not be determined!".to_string() + }) + }) + .into(), + ), + use_pango: false, + icon: ROption::RNone, + id: ROption::RNone, + }) + .collect::>() + .into() } else { RVec::new() } @@ -67,7 +149,13 @@ fn get_matches(input: RString, config: &Config) -> RVec { } #[handler] -fn handler(selection: Match) -> HandleResult { +fn handler(selection: Match, state: &mut State) -> HandleResult { + if let Some(history) = state.history.as_mut() { + if let Err(err) = history.push(selection.title.clone().into_string()) { + eprintln!("[shell] Failed to push command to history: {:?}", err); + } + } + if let Err(why) = Command::new(selection.description.unwrap().as_str()) .arg("-c") .arg(selection.title.as_str())