feat: implement frecency tracking for app usage and enhance search functionality
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -2737,6 +2737,8 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"k-launcher-kernel",
|
"k-launcher-kernel",
|
||||||
"libc",
|
"libc",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
"xdg",
|
"xdg",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -116,8 +116,17 @@ fn view(state: &KLauncherApp) -> Element<'_, Message> {
|
|||||||
image(image::Handle::from_path(p)).width(24).height(24).into(),
|
image(image::Handle::from_path(p)).width(24).height(24).into(),
|
||||||
None => Space::new().width(24).height(24).into(),
|
None => Space::new().width(24).height(24).into(),
|
||||||
};
|
};
|
||||||
|
let title_col: Element<'_, Message> = if let Some(desc) = &result.description {
|
||||||
|
column![
|
||||||
|
text(result.title.as_str()).size(15),
|
||||||
|
text(desc).size(11).color(Color::from_rgba8(180, 180, 200, 0.8)),
|
||||||
|
]
|
||||||
|
.into()
|
||||||
|
} else {
|
||||||
|
text(result.title.as_str()).size(15).into()
|
||||||
|
};
|
||||||
container(
|
container(
|
||||||
row![icon_el, text(result.title.as_str()).size(15)]
|
row![icon_el, title_col]
|
||||||
.spacing(8)
|
.spacing(8)
|
||||||
.align_y(iced::Center),
|
.align_y(iced::Center),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use k_launcher_kernel::Kernel;
|
use k_launcher_kernel::Kernel;
|
||||||
use plugin_apps::{AppsPlugin, FsDesktopEntrySource};
|
use plugin_apps::{AppsPlugin, FsDesktopEntrySource, frecency::FrecencyStore};
|
||||||
use plugin_calc::CalcPlugin;
|
use plugin_calc::CalcPlugin;
|
||||||
use plugin_cmd::CmdPlugin;
|
use plugin_cmd::CmdPlugin;
|
||||||
|
|
||||||
fn main() -> iced::Result {
|
fn main() -> iced::Result {
|
||||||
|
let frecency = FrecencyStore::load();
|
||||||
let kernel = Arc::new(Kernel::new(vec![
|
let kernel = Arc::new(Kernel::new(vec![
|
||||||
Arc::new(CmdPlugin::new()),
|
Arc::new(CmdPlugin::new()),
|
||||||
Arc::new(CalcPlugin::new()),
|
Arc::new(CalcPlugin::new()),
|
||||||
Arc::new(AppsPlugin::new(FsDesktopEntrySource::new())),
|
Arc::new(AppsPlugin::new(FsDesktopEntrySource::new(), frecency)),
|
||||||
]));
|
]));
|
||||||
k_launcher_ui::run(kernel)
|
k_launcher_ui::run(kernel)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,5 +15,7 @@ path = "src/main.rs"
|
|||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
k-launcher-kernel = { path = "../../k-launcher-kernel" }
|
k-launcher-kernel = { path = "../../k-launcher-kernel" }
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = "1.0"
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
xdg = "2"
|
xdg = "2"
|
||||||
|
|||||||
134
crates/plugins/plugin-apps/src/frecency.rs
Normal file
134
crates/plugins/plugin-apps/src/frecency.rs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
path::PathBuf,
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct Entry {
|
||||||
|
count: u32,
|
||||||
|
last_used: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FrecencyStore {
|
||||||
|
path: PathBuf,
|
||||||
|
data: Mutex<HashMap<String, Entry>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FrecencyStore {
|
||||||
|
pub fn new(path: PathBuf) -> Arc<Self> {
|
||||||
|
let data = std::fs::read_to_string(&path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| serde_json::from_str(&s).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
Arc::new(Self { path, data: Mutex::new(data) })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn new_for_test() -> Arc<Self> {
|
||||||
|
Arc::new(Self {
|
||||||
|
path: PathBuf::from("/dev/null"),
|
||||||
|
data: Mutex::new(HashMap::new()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load() -> Arc<Self> {
|
||||||
|
let path = xdg::BaseDirectories::new()
|
||||||
|
.map(|xdg| xdg.get_data_home())
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("."))
|
||||||
|
.join("k-launcher")
|
||||||
|
.join("frecency.json");
|
||||||
|
Self::new(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record(&self, id: &str) {
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
let mut data = self.data.lock().unwrap();
|
||||||
|
let entry = data.entry(id.to_string()).or_insert(Entry { count: 0, last_used: 0 });
|
||||||
|
entry.count += 1;
|
||||||
|
entry.last_used = now;
|
||||||
|
if let Some(parent) = self.path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
if let Ok(json) = serde_json::to_string(&*data) {
|
||||||
|
let _ = std::fs::write(&self.path, json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn frecency_score(&self, id: &str) -> u32 {
|
||||||
|
let data = self.data.lock().unwrap();
|
||||||
|
let Some(entry) = data.get(id) else { return 0 };
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
let age_secs = now.saturating_sub(entry.last_used);
|
||||||
|
let decay = if age_secs < 3600 { 4 } else if age_secs < 86400 { 2 } else { 1 };
|
||||||
|
entry.count * decay
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn top_ids(&self, n: usize) -> Vec<String> {
|
||||||
|
let data = self.data.lock().unwrap();
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
let mut scored: Vec<(String, u32)> = data
|
||||||
|
.iter()
|
||||||
|
.map(|(id, entry)| {
|
||||||
|
let age_secs = now.saturating_sub(entry.last_used);
|
||||||
|
let decay = if age_secs < 3600 { 4 } else if age_secs < 86400 { 2 } else { 1 };
|
||||||
|
(id.clone(), entry.count * decay)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
scored.sort_by(|a, b| b.1.cmp(&a.1));
|
||||||
|
scored.into_iter().take(n).map(|(id, _)| id).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn make_store() -> Arc<FrecencyStore> {
|
||||||
|
Arc::new(FrecencyStore {
|
||||||
|
path: PathBuf::from("/dev/null"),
|
||||||
|
data: Mutex::new(HashMap::new()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn record_increments_count() {
|
||||||
|
let store = make_store();
|
||||||
|
store.record("app-firefox");
|
||||||
|
store.record("app-firefox");
|
||||||
|
let data = store.data.lock().unwrap();
|
||||||
|
assert_eq!(data["app-firefox"].count, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn record_updates_last_used() {
|
||||||
|
let store = make_store();
|
||||||
|
store.record("app-firefox");
|
||||||
|
let data = store.data.lock().unwrap();
|
||||||
|
assert!(data["app-firefox"].last_used > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn top_ids_returns_sorted_order() {
|
||||||
|
let store = make_store();
|
||||||
|
store.record("app-firefox");
|
||||||
|
store.record("app-code");
|
||||||
|
store.record("app-code");
|
||||||
|
store.record("app-code");
|
||||||
|
let top = store.top_ids(2);
|
||||||
|
assert_eq!(top[0], "app-code");
|
||||||
|
assert_eq!(top[1], "app-firefox");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
use std::{path::Path, process::{Command, Stdio}, sync::Arc};
|
pub mod frecency;
|
||||||
|
|
||||||
|
use std::{collections::HashMap, path::Path, process::{Command, Stdio}, sync::Arc};
|
||||||
use std::os::unix::process::CommandExt;
|
use std::os::unix::process::CommandExt;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use k_launcher_kernel::{Plugin, PluginName, ResultId, ResultTitle, Score, SearchResult};
|
use k_launcher_kernel::{Plugin, PluginName, ResultId, ResultTitle, Score, SearchResult};
|
||||||
|
|
||||||
|
use crate::frecency::FrecencyStore;
|
||||||
|
|
||||||
// --- Domain newtypes ---
|
// --- Domain newtypes ---
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -48,6 +52,8 @@ pub struct DesktopEntry {
|
|||||||
pub name: AppName,
|
pub name: AppName,
|
||||||
pub exec: ExecCommand,
|
pub exec: ExecCommand,
|
||||||
pub icon: Option<IconPath>,
|
pub icon: Option<IconPath>,
|
||||||
|
pub category: Option<String>,
|
||||||
|
pub keywords: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Swappable source trait (Application layer principle) ---
|
// --- Swappable source trait (Application layer principle) ---
|
||||||
@@ -59,8 +65,11 @@ pub trait DesktopEntrySource: Send + Sync {
|
|||||||
// --- Cached entry (pre-computed at construction) ---
|
// --- Cached entry (pre-computed at construction) ---
|
||||||
|
|
||||||
struct CachedEntry {
|
struct CachedEntry {
|
||||||
|
id: String,
|
||||||
name: AppName,
|
name: AppName,
|
||||||
name_lc: String,
|
name_lc: String,
|
||||||
|
keywords_lc: Vec<String>,
|
||||||
|
category: Option<String>,
|
||||||
icon: Option<String>,
|
icon: Option<String>,
|
||||||
on_execute: Arc<dyn Fn() + Send + Sync>,
|
on_execute: Arc<dyn Fn() + Send + Sync>,
|
||||||
}
|
}
|
||||||
@@ -68,43 +77,54 @@ struct CachedEntry {
|
|||||||
// --- Plugin ---
|
// --- Plugin ---
|
||||||
|
|
||||||
pub struct AppsPlugin {
|
pub struct AppsPlugin {
|
||||||
entries: Vec<CachedEntry>,
|
entries: HashMap<String, CachedEntry>,
|
||||||
|
frecency: Arc<FrecencyStore>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppsPlugin {
|
impl AppsPlugin {
|
||||||
pub fn new(source: impl DesktopEntrySource) -> Self {
|
pub fn new(source: impl DesktopEntrySource, frecency: Arc<FrecencyStore>) -> Self {
|
||||||
let entries = source
|
let entries = source
|
||||||
.entries()
|
.entries()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|e| {
|
.map(|e| {
|
||||||
|
let id = format!("app-{}", e.name.as_str());
|
||||||
let name_lc = e.name.as_str().to_lowercase();
|
let name_lc = e.name.as_str().to_lowercase();
|
||||||
|
let keywords_lc = e.keywords.iter().map(|k| k.to_lowercase()).collect();
|
||||||
let icon = e.icon.as_ref().and_then(|p| resolve_icon_path(p.as_str()));
|
let icon = e.icon.as_ref().and_then(|p| resolve_icon_path(p.as_str()));
|
||||||
let exec = e.exec.clone();
|
let exec = e.exec.clone();
|
||||||
CachedEntry {
|
let store = Arc::clone(&frecency);
|
||||||
|
let record_id = id.clone();
|
||||||
|
let on_execute: Arc<dyn Fn() + Send + Sync> = Arc::new(move || {
|
||||||
|
store.record(&record_id);
|
||||||
|
let parts: Vec<&str> = exec.as_str().split_whitespace().collect();
|
||||||
|
if let Some((cmd, args)) = parts.split_first() {
|
||||||
|
let _ = unsafe {
|
||||||
|
Command::new(cmd)
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.pre_exec(|| {
|
||||||
|
libc::setsid();
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.spawn()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let cached = CachedEntry {
|
||||||
|
id: id.clone(),
|
||||||
name_lc,
|
name_lc,
|
||||||
|
keywords_lc,
|
||||||
|
category: e.category,
|
||||||
icon,
|
icon,
|
||||||
on_execute: Arc::new(move || {
|
on_execute,
|
||||||
let parts: Vec<&str> = exec.as_str().split_whitespace().collect();
|
|
||||||
if let Some((cmd, args)) = parts.split_first() {
|
|
||||||
let _ = unsafe {
|
|
||||||
Command::new(cmd)
|
|
||||||
.args(args)
|
|
||||||
.stdin(Stdio::null())
|
|
||||||
.stdout(Stdio::null())
|
|
||||||
.stderr(Stdio::null())
|
|
||||||
.pre_exec(|| {
|
|
||||||
libc::setsid();
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.spawn()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
name: e.name,
|
name: e.name,
|
||||||
}
|
};
|
||||||
|
(id, cached)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Self { entries }
|
Self { entries, frecency }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,16 +141,27 @@ fn resolve_icon_path(name: &str) -> Option<String> {
|
|||||||
candidates.into_iter().find(|p| Path::new(p).exists())
|
candidates.into_iter().find(|p| Path::new(p).exists())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn initials(name_lc: &str) -> String {
|
||||||
|
name_lc.split_whitespace().filter_map(|w| w.chars().next()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn score_match(name_lc: &str, query_lc: &str) -> Option<u32> {
|
fn score_match(name_lc: &str, query_lc: &str) -> Option<u32> {
|
||||||
if name_lc == query_lc {
|
if name_lc == query_lc { return Some(100); }
|
||||||
Some(100)
|
if name_lc.starts_with(query_lc) { return Some(80); }
|
||||||
} else if name_lc.starts_with(query_lc) {
|
if name_lc.contains(query_lc) { return Some(60); }
|
||||||
Some(80)
|
if initials(name_lc).starts_with(query_lc) { return Some(70); }
|
||||||
} else if name_lc.contains(query_lc) {
|
None
|
||||||
Some(60)
|
}
|
||||||
} else {
|
|
||||||
None
|
fn humanize_category(s: &str) -> String {
|
||||||
|
let mut result = String::new();
|
||||||
|
for ch in s.chars() {
|
||||||
|
if ch.is_uppercase() && !result.is_empty() {
|
||||||
|
result.push(' ');
|
||||||
|
}
|
||||||
|
result.push(ch);
|
||||||
}
|
}
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -141,16 +172,34 @@ impl Plugin for AppsPlugin {
|
|||||||
|
|
||||||
async fn search(&self, query: &str) -> Vec<SearchResult> {
|
async fn search(&self, query: &str) -> Vec<SearchResult> {
|
||||||
if query.is_empty() {
|
if query.is_empty() {
|
||||||
return vec![];
|
return self.frecency.top_ids(5)
|
||||||
|
.iter()
|
||||||
|
.filter_map(|id| {
|
||||||
|
let e = self.entries.get(id)?;
|
||||||
|
let score = self.frecency.frecency_score(id).max(1);
|
||||||
|
Some(SearchResult {
|
||||||
|
id: ResultId::new(id),
|
||||||
|
title: ResultTitle::new(e.name.as_str()),
|
||||||
|
description: e.category.clone(),
|
||||||
|
icon: e.icon.clone(),
|
||||||
|
score: Score::new(score),
|
||||||
|
on_execute: Arc::clone(&e.on_execute),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
}
|
}
|
||||||
|
|
||||||
let query_lc = query.to_lowercase();
|
let query_lc = query.to_lowercase();
|
||||||
self.entries
|
self.entries
|
||||||
.iter()
|
.values()
|
||||||
.filter_map(|e| {
|
.filter_map(|e| {
|
||||||
score_match(&e.name_lc, &query_lc).map(|score| SearchResult {
|
let score = score_match(&e.name_lc, &query_lc).or_else(|| {
|
||||||
id: ResultId::new(format!("app-{}", e.name.as_str())),
|
e.keywords_lc.iter().any(|k| k.contains(&query_lc)).then_some(50)
|
||||||
|
})?;
|
||||||
|
Some(SearchResult {
|
||||||
|
id: ResultId::new(&e.id),
|
||||||
title: ResultTitle::new(e.name.as_str()),
|
title: ResultTitle::new(e.name.as_str()),
|
||||||
description: None,
|
description: e.category.clone(),
|
||||||
icon: e.icon.clone(),
|
icon: e.icon.clone(),
|
||||||
score: Score::new(score),
|
score: Score::new(score),
|
||||||
on_execute: Arc::clone(&e.on_execute),
|
on_execute: Arc::clone(&e.on_execute),
|
||||||
@@ -209,6 +258,8 @@ fn parse_desktop_file(path: &Path) -> Option<DesktopEntry> {
|
|||||||
let mut name: Option<String> = None;
|
let mut name: Option<String> = None;
|
||||||
let mut exec: Option<String> = None;
|
let mut exec: Option<String> = None;
|
||||||
let mut icon: Option<String> = None;
|
let mut icon: Option<String> = None;
|
||||||
|
let mut category: Option<String> = None;
|
||||||
|
let mut keywords: Vec<String> = Vec::new();
|
||||||
let mut is_application = false;
|
let mut is_application = false;
|
||||||
let mut no_display = false;
|
let mut no_display = false;
|
||||||
|
|
||||||
@@ -232,6 +283,19 @@ fn parse_desktop_file(path: &Path) -> Option<DesktopEntry> {
|
|||||||
"Icon" if icon.is_none() => icon = Some(value.trim().to_string()),
|
"Icon" if icon.is_none() => icon = Some(value.trim().to_string()),
|
||||||
"Type" if !is_application => is_application = value.trim() == "Application",
|
"Type" if !is_application => is_application = value.trim() == "Application",
|
||||||
"NoDisplay" => no_display = value.trim().eq_ignore_ascii_case("true"),
|
"NoDisplay" => no_display = value.trim().eq_ignore_ascii_case("true"),
|
||||||
|
"Categories" if category.is_none() => {
|
||||||
|
category = value.trim()
|
||||||
|
.split(';')
|
||||||
|
.find(|s| !s.is_empty())
|
||||||
|
.map(|s| humanize_category(s.trim()));
|
||||||
|
}
|
||||||
|
"Keywords" if keywords.is_empty() => {
|
||||||
|
keywords = value.trim()
|
||||||
|
.split(';')
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -256,6 +320,8 @@ fn parse_desktop_file(path: &Path) -> Option<DesktopEntry> {
|
|||||||
name: AppName::new(name?),
|
name: AppName::new(name?),
|
||||||
exec: ExecCommand::new(exec_clean),
|
exec: ExecCommand::new(exec_clean),
|
||||||
icon: icon.map(IconPath::new),
|
icon: icon.map(IconPath::new),
|
||||||
|
category,
|
||||||
|
keywords,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,8 +331,12 @@ fn parse_desktop_file(path: &Path) -> Option<DesktopEntry> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn ephemeral_frecency() -> Arc<FrecencyStore> {
|
||||||
|
FrecencyStore::new_for_test()
|
||||||
|
}
|
||||||
|
|
||||||
struct MockSource {
|
struct MockSource {
|
||||||
entries: Vec<(String, String)>, // (name, exec)
|
entries: Vec<(String, String, Option<String>, Vec<String>)>, // (name, exec, category, keywords)
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MockSource {
|
impl MockSource {
|
||||||
@@ -274,7 +344,25 @@ mod tests {
|
|||||||
Self {
|
Self {
|
||||||
entries: entries
|
entries: entries
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(n, e)| (n.to_string(), e.to_string()))
|
.map(|(n, e)| (n.to_string(), e.to_string(), None, vec![]))
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_categories(entries: Vec<(&str, &str, &str)>) -> Self {
|
||||||
|
Self {
|
||||||
|
entries: entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|(n, e, c)| (n.to_string(), e.to_string(), Some(c.to_string()), vec![]))
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_keywords(entries: Vec<(&str, &str, Vec<&str>)>) -> Self {
|
||||||
|
Self {
|
||||||
|
entries: entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|(n, e, kw)| (n.to_string(), e.to_string(), None, kw.into_iter().map(|s| s.to_string()).collect()))
|
||||||
.collect(),
|
.collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -284,10 +372,12 @@ mod tests {
|
|||||||
fn entries(&self) -> Vec<DesktopEntry> {
|
fn entries(&self) -> Vec<DesktopEntry> {
|
||||||
self.entries
|
self.entries
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(name, exec)| DesktopEntry {
|
.map(|(name, exec, category, keywords)| DesktopEntry {
|
||||||
name: AppName::new(name.clone()),
|
name: AppName::new(name.clone()),
|
||||||
exec: ExecCommand::new(exec.clone()),
|
exec: ExecCommand::new(exec.clone()),
|
||||||
icon: None,
|
icon: None,
|
||||||
|
category: category.clone(),
|
||||||
|
keywords: keywords.clone(),
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -295,23 +385,89 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn apps_prefix_match() {
|
async fn apps_prefix_match() {
|
||||||
let source = MockSource::with(vec![("Firefox", "firefox")]);
|
let p = AppsPlugin::new(MockSource::with(vec![("Firefox", "firefox")]), ephemeral_frecency());
|
||||||
let p = AppsPlugin::new(source);
|
|
||||||
let results = p.search("fire").await;
|
let results = p.search("fire").await;
|
||||||
assert_eq!(results[0].title.as_str(), "Firefox");
|
assert_eq!(results[0].title.as_str(), "Firefox");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn apps_no_match_returns_empty() {
|
async fn apps_no_match_returns_empty() {
|
||||||
let source = MockSource::with(vec![("Firefox", "firefox")]);
|
let p = AppsPlugin::new(MockSource::with(vec![("Firefox", "firefox")]), ephemeral_frecency());
|
||||||
let p = AppsPlugin::new(source);
|
|
||||||
assert!(p.search("zz").await.is_empty());
|
assert!(p.search("zz").await.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn apps_empty_query_returns_empty() {
|
async fn apps_empty_query_no_frecency_returns_empty() {
|
||||||
let source = MockSource::with(vec![("Firefox", "firefox")]);
|
let p = AppsPlugin::new(MockSource::with(vec![("Firefox", "firefox")]), ephemeral_frecency());
|
||||||
let p = AppsPlugin::new(source);
|
|
||||||
assert!(p.search("").await.is_empty());
|
assert!(p.search("").await.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn score_match_abbreviation() {
|
||||||
|
assert_eq!(initials("visual studio code"), "vsc");
|
||||||
|
assert_eq!(score_match("visual studio code", "vsc"), Some(70));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn score_match_exact_beats_prefix_beats_abbrev_beats_substr() {
|
||||||
|
assert_eq!(score_match("firefox", "firefox"), Some(100));
|
||||||
|
assert_eq!(score_match("firefox", "fire"), Some(80));
|
||||||
|
assert_eq!(score_match("gnu firefox", "gf"), Some(70));
|
||||||
|
assert_eq!(score_match("ice firefox", "fire"), Some(60));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn apps_abbreviation_match() {
|
||||||
|
let p = AppsPlugin::new(
|
||||||
|
MockSource::with(vec![("Visual Studio Code", "code")]),
|
||||||
|
ephemeral_frecency(),
|
||||||
|
);
|
||||||
|
let results = p.search("vsc").await;
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
assert_eq!(results[0].title.as_str(), "Visual Studio Code");
|
||||||
|
assert_eq!(results[0].score.value(), 70);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn apps_keyword_match() {
|
||||||
|
let p = AppsPlugin::new(
|
||||||
|
MockSource::with_keywords(vec![("Code", "code", vec!["editor", "ide"])]),
|
||||||
|
ephemeral_frecency(),
|
||||||
|
);
|
||||||
|
let results = p.search("editor").await;
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
assert_eq!(results[0].score.value(), 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn humanize_category_splits_camel_case() {
|
||||||
|
assert_eq!(humanize_category("TextEditor"), "Text Editor");
|
||||||
|
assert_eq!(humanize_category("WebBrowser"), "Web Browser");
|
||||||
|
assert_eq!(humanize_category("Development"), "Development");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn apps_category_appears_in_description() {
|
||||||
|
let p = AppsPlugin::new(
|
||||||
|
MockSource::with_categories(vec![("Code", "code", "Text Editor")]),
|
||||||
|
ephemeral_frecency(),
|
||||||
|
);
|
||||||
|
let results = p.search("code").await;
|
||||||
|
assert_eq!(results[0].description.as_deref(), Some("Text Editor"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn apps_empty_query_returns_top_frecent() {
|
||||||
|
let frecency = ephemeral_frecency();
|
||||||
|
frecency.record("app-Code");
|
||||||
|
frecency.record("app-Code");
|
||||||
|
frecency.record("app-Firefox");
|
||||||
|
let p = AppsPlugin::new(
|
||||||
|
MockSource::with(vec![("Firefox", "firefox"), ("Code", "code")]),
|
||||||
|
frecency,
|
||||||
|
);
|
||||||
|
let results = p.search("").await;
|
||||||
|
assert_eq!(results.len(), 2);
|
||||||
|
assert_eq!(results[0].title.as_str(), "Code");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,6 @@ edition = "2024"
|
|||||||
name = "plugin_calc"
|
name = "plugin_calc"
|
||||||
path = "src/lib.rs"
|
path = "src/lib.rs"
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "plugin-calc"
|
|
||||||
path = "src/main.rs"
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
evalexpr = "11"
|
evalexpr = "11"
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
fn main() {}
|
|
||||||
@@ -3,4 +3,8 @@ name = "plugin-files"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "plugin_files"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
0
crates/plugins/plugin-files/src/lib.rs
Normal file
0
crates/plugins/plugin-files/src/lib.rs
Normal file
@@ -1,3 +0,0 @@
|
|||||||
fn main() {
|
|
||||||
println!("Hello, world!");
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user