feature/prod-ready (#1)
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -10,9 +10,12 @@ path = "src/lib.rs"
|
||||
[dependencies]
|
||||
async-trait = { workspace = true }
|
||||
k-launcher-kernel = { path = "../../k-launcher-kernel" }
|
||||
nucleo-matcher = "0.3"
|
||||
serde = { workspace = true }
|
||||
serde_json = "1.0"
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
linicon = "2.3.0"
|
||||
xdg = "3"
|
||||
|
||||
@@ -24,7 +24,10 @@ impl FrecencyStore {
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
Arc::new(Self { path, data: Mutex::new(data) })
|
||||
Arc::new(Self {
|
||||
path,
|
||||
data: Mutex::new(data),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -36,11 +39,14 @@ impl FrecencyStore {
|
||||
}
|
||||
|
||||
pub fn load() -> Arc<Self> {
|
||||
let path = xdg::BaseDirectories::new()
|
||||
.get_data_home()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("k-launcher")
|
||||
.join("frecency.json");
|
||||
let Some(data_home) = xdg::BaseDirectories::new().get_data_home() else {
|
||||
tracing::warn!("XDG_DATA_HOME unavailable; frecency disabled (in-memory only)");
|
||||
return Arc::new(Self {
|
||||
path: PathBuf::from("/dev/null"),
|
||||
data: Mutex::new(HashMap::new()),
|
||||
});
|
||||
};
|
||||
let path = data_home.join("k-launcher").join("frecency.json");
|
||||
Self::new(path)
|
||||
}
|
||||
|
||||
@@ -50,7 +56,10 @@ impl FrecencyStore {
|
||||
.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 });
|
||||
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() {
|
||||
@@ -69,7 +78,13 @@ impl FrecencyStore {
|
||||
.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 };
|
||||
let decay = if age_secs < 3600 {
|
||||
4
|
||||
} else if age_secs < 86400 {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
};
|
||||
entry.count * decay
|
||||
}
|
||||
|
||||
@@ -83,7 +98,13 @@ impl FrecencyStore {
|
||||
.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 };
|
||||
let decay = if age_secs < 3600 {
|
||||
4
|
||||
} else if age_secs < 86400 {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
};
|
||||
(id.clone(), entry.count * decay)
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -68,7 +68,6 @@ pub trait DesktopEntrySource: Send + Sync {
|
||||
struct CachedEntry {
|
||||
id: String,
|
||||
name: AppName,
|
||||
name_lc: String,
|
||||
keywords_lc: Vec<String>,
|
||||
category: Option<String>,
|
||||
icon: Option<String>,
|
||||
@@ -90,10 +89,12 @@ impl AppsPlugin {
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
let id = format!("app-{}", e.name.as_str());
|
||||
let name_lc = e.name.as_str().to_lowercase();
|
||||
let keywords_lc = e.keywords.iter().map(|k| k.to_lowercase()).collect();
|
||||
#[cfg(target_os = "linux")]
|
||||
let icon = e.icon.as_ref().and_then(|p| linux::resolve_icon_path(p.as_str()));
|
||||
let icon = e
|
||||
.icon
|
||||
.as_ref()
|
||||
.and_then(|p| linux::resolve_icon_path(p.as_str()));
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let icon: Option<String> = None;
|
||||
let exec = e.exec.as_str().to_string();
|
||||
@@ -104,7 +105,6 @@ impl AppsPlugin {
|
||||
});
|
||||
let cached = CachedEntry {
|
||||
id: id.clone(),
|
||||
name_lc,
|
||||
keywords_lc,
|
||||
category: e.category,
|
||||
icon,
|
||||
@@ -120,15 +120,37 @@ impl AppsPlugin {
|
||||
}
|
||||
|
||||
fn initials(name_lc: &str) -> String {
|
||||
name_lc.split_whitespace().filter_map(|w| w.chars().next()).collect()
|
||||
name_lc
|
||||
.split_whitespace()
|
||||
.filter_map(|w| w.chars().next())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn score_match(name_lc: &str, query_lc: &str) -> Option<u32> {
|
||||
if name_lc == query_lc { return Some(100); }
|
||||
if name_lc.starts_with(query_lc) { return Some(80); }
|
||||
if name_lc.contains(query_lc) { return Some(60); }
|
||||
if initials(name_lc).starts_with(query_lc) { return Some(70); }
|
||||
None
|
||||
fn score_match(name: &str, query: &str) -> Option<u32> {
|
||||
use nucleo_matcher::{
|
||||
Config, Matcher, Utf32Str,
|
||||
pattern::{CaseMatching, Normalization, Pattern},
|
||||
};
|
||||
|
||||
let mut matcher = Matcher::new(Config::DEFAULT);
|
||||
let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
|
||||
|
||||
let mut name_chars: Vec<char> = name.chars().collect();
|
||||
let haystack = Utf32Str::new(name, &mut name_chars);
|
||||
let score = pattern.score(haystack, &mut matcher);
|
||||
|
||||
if let Some(s) = score {
|
||||
let name_lc = name.to_lowercase();
|
||||
let query_lc = query.to_lowercase();
|
||||
let bonus: u32 = if initials(&name_lc).starts_with(&query_lc) {
|
||||
20
|
||||
} else {
|
||||
0
|
||||
};
|
||||
Some(s.saturating_add(bonus))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn humanize_category(s: &str) -> String {
|
||||
@@ -150,7 +172,9 @@ impl Plugin for AppsPlugin {
|
||||
|
||||
async fn search(&self, query: &str) -> Vec<SearchResult> {
|
||||
if query.is_empty() {
|
||||
return self.frecency.top_ids(5)
|
||||
return self
|
||||
.frecency
|
||||
.top_ids(5)
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
let e = self.entries.get(id)?;
|
||||
@@ -172,8 +196,11 @@ impl Plugin for AppsPlugin {
|
||||
self.entries
|
||||
.values()
|
||||
.filter_map(|e| {
|
||||
let score = score_match(&e.name_lc, &query_lc).or_else(|| {
|
||||
e.keywords_lc.iter().any(|k| k.contains(&query_lc)).then_some(50)
|
||||
let score = score_match(e.name.as_str(), query).or_else(|| {
|
||||
e.keywords_lc
|
||||
.iter()
|
||||
.any(|k| k.contains(&query_lc))
|
||||
.then_some(50)
|
||||
})?;
|
||||
Some(SearchResult {
|
||||
id: ResultId::new(&e.id),
|
||||
@@ -226,7 +253,14 @@ mod tests {
|
||||
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()))
|
||||
.map(|(n, e, kw)| {
|
||||
(
|
||||
n.to_string(),
|
||||
e.to_string(),
|
||||
None,
|
||||
kw.into_iter().map(|s| s.to_string()).collect(),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
@@ -249,35 +283,49 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_prefix_match() {
|
||||
let p = AppsPlugin::new(MockSource::with(vec![("Firefox", "firefox")]), ephemeral_frecency());
|
||||
let p = AppsPlugin::new(
|
||||
MockSource::with(vec![("Firefox", "firefox")]),
|
||||
ephemeral_frecency(),
|
||||
);
|
||||
let results = p.search("fire").await;
|
||||
assert_eq!(results[0].title.as_str(), "Firefox");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_no_match_returns_empty() {
|
||||
let p = AppsPlugin::new(MockSource::with(vec![("Firefox", "firefox")]), ephemeral_frecency());
|
||||
let p = AppsPlugin::new(
|
||||
MockSource::with(vec![("Firefox", "firefox")]),
|
||||
ephemeral_frecency(),
|
||||
);
|
||||
assert!(p.search("zz").await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_empty_query_no_frecency_returns_empty() {
|
||||
let p = AppsPlugin::new(MockSource::with(vec![("Firefox", "firefox")]), ephemeral_frecency());
|
||||
let p = AppsPlugin::new(
|
||||
MockSource::with(vec![("Firefox", "firefox")]),
|
||||
ephemeral_frecency(),
|
||||
);
|
||||
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));
|
||||
assert!(score_match("visual studio code", "vsc").is_some());
|
||||
}
|
||||
|
||||
#[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));
|
||||
let exact = score_match("firefox", "firefox");
|
||||
let prefix = score_match("firefox", "fire");
|
||||
let abbrev = score_match("gnu firefox", "gf");
|
||||
let substr = score_match("ice firefox", "fire");
|
||||
assert!(exact.is_some());
|
||||
assert!(prefix.is_some());
|
||||
assert!(abbrev.is_some());
|
||||
assert!(substr.is_some());
|
||||
assert!(exact.unwrap() > prefix.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -289,7 +337,7 @@ mod tests {
|
||||
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);
|
||||
assert!(results[0].score.value() > 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -303,6 +351,20 @@ mod tests {
|
||||
assert_eq!(results[0].score.value(), 50);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_fuzzy_typo_match() {
|
||||
let p = AppsPlugin::new(
|
||||
MockSource::with(vec![("Firefox", "firefox")]),
|
||||
ephemeral_frecency(),
|
||||
);
|
||||
let results = p.search("frefox").await;
|
||||
assert!(
|
||||
!results.is_empty(),
|
||||
"nucleo should fuzzy-match 'frefox' to 'Firefox'"
|
||||
);
|
||||
assert!(results[0].score.value() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn humanize_category_splits_camel_case() {
|
||||
assert_eq!(humanize_category("TextEditor"), "Text Editor");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::path::Path;
|
||||
|
||||
use crate::{AppName, DesktopEntry, DesktopEntrySource, ExecCommand, IconPath};
|
||||
use crate::humanize_category;
|
||||
use crate::{AppName, DesktopEntry, DesktopEntrySource, ExecCommand, IconPath};
|
||||
|
||||
pub struct FsDesktopEntrySource;
|
||||
|
||||
@@ -45,15 +45,79 @@ impl DesktopEntrySource for FsDesktopEntrySource {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn clean_exec(exec: &str) -> String {
|
||||
// Tokenize respecting double-quoted strings, then filter field codes.
|
||||
let mut tokens: Vec<String> = Vec::new();
|
||||
let mut chars = exec.chars().peekable();
|
||||
|
||||
while let Some(&ch) = chars.peek() {
|
||||
if ch.is_whitespace() {
|
||||
chars.next();
|
||||
continue;
|
||||
}
|
||||
if ch == '"' {
|
||||
// Consume opening quote
|
||||
chars.next();
|
||||
let mut token = String::from('"');
|
||||
while let Some(&c) = chars.peek() {
|
||||
chars.next();
|
||||
if c == '"' {
|
||||
token.push('"');
|
||||
break;
|
||||
}
|
||||
token.push(c);
|
||||
}
|
||||
// Strip embedded field codes like %f inside the quoted string
|
||||
// (between the quotes, before re-assembling)
|
||||
let inner = &token[1..token.len().saturating_sub(1)];
|
||||
let cleaned_inner: String = inner
|
||||
.split_whitespace()
|
||||
.filter(|s| !is_field_code(s))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
tokens.push(format!("\"{cleaned_inner}\""));
|
||||
} else {
|
||||
let mut token = String::new();
|
||||
while let Some(&c) = chars.peek() {
|
||||
if c.is_whitespace() {
|
||||
break;
|
||||
}
|
||||
chars.next();
|
||||
token.push(c);
|
||||
}
|
||||
if !is_field_code(&token) {
|
||||
tokens.push(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tokens.join(" ")
|
||||
}
|
||||
|
||||
fn is_field_code(s: &str) -> bool {
|
||||
let b = s.as_bytes();
|
||||
b.len() == 2 && b[0] == b'%' && b[1].is_ascii_alphabetic()
|
||||
}
|
||||
|
||||
pub fn resolve_icon_path(name: &str) -> Option<String> {
|
||||
if name.starts_with('/') && Path::new(name).exists() {
|
||||
return Some(name.to_string());
|
||||
}
|
||||
// Try linicon freedesktop theme traversal
|
||||
let themes = ["hicolor", "Adwaita", "breeze", "Papirus"];
|
||||
for theme in &themes {
|
||||
if let Some(icon_path) = linicon::lookup_icon(name)
|
||||
.from_theme(theme)
|
||||
.with_size(48)
|
||||
.find_map(|r| r.ok())
|
||||
{
|
||||
return Some(icon_path.path.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
// Fallback to pixmaps
|
||||
let candidates = [
|
||||
format!("/usr/share/pixmaps/{name}.png"),
|
||||
format!("/usr/share/pixmaps/{name}.svg"),
|
||||
format!("/usr/share/icons/hicolor/48x48/apps/{name}.png"),
|
||||
format!("/usr/share/icons/hicolor/scalable/apps/{name}.svg"),
|
||||
];
|
||||
candidates.into_iter().find(|p| Path::new(p).exists())
|
||||
}
|
||||
@@ -90,13 +154,15 @@ fn parse_desktop_file(path: &Path) -> Option<DesktopEntry> {
|
||||
"Type" if !is_application => is_application = value.trim() == "Application",
|
||||
"NoDisplay" => no_display = value.trim().eq_ignore_ascii_case("true"),
|
||||
"Categories" if category.is_none() => {
|
||||
category = value.trim()
|
||||
category = value
|
||||
.trim()
|
||||
.split(';')
|
||||
.find(|s| !s.is_empty())
|
||||
.map(|s| humanize_category(s.trim()));
|
||||
}
|
||||
"Keywords" if keywords.is_empty() => {
|
||||
keywords = value.trim()
|
||||
keywords = value
|
||||
.trim()
|
||||
.split(';')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.trim().to_string())
|
||||
@@ -111,16 +177,7 @@ fn parse_desktop_file(path: &Path) -> Option<DesktopEntry> {
|
||||
return None;
|
||||
}
|
||||
|
||||
let exec_clean: String = exec?
|
||||
.split_whitespace()
|
||||
.filter(|s| !s.starts_with('%'))
|
||||
.fold(String::new(), |mut acc, s| {
|
||||
if !acc.is_empty() {
|
||||
acc.push(' ');
|
||||
}
|
||||
acc.push_str(s);
|
||||
acc
|
||||
});
|
||||
let exec_clean: String = clean_exec(&exec?);
|
||||
|
||||
Some(DesktopEntry {
|
||||
name: AppName::new(name?),
|
||||
@@ -130,3 +187,31 @@ fn parse_desktop_file(path: &Path) -> Option<DesktopEntry> {
|
||||
keywords,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod exec_tests {
|
||||
use super::clean_exec;
|
||||
|
||||
#[test]
|
||||
fn strips_bare_field_code() {
|
||||
assert_eq!(clean_exec("app --file %f"), "app --file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_multiple_field_codes() {
|
||||
assert_eq!(clean_exec("app %U --flag"), "app --flag");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_quoted_value() {
|
||||
assert_eq!(
|
||||
clean_exec(r#"app --arg="value" %U"#),
|
||||
r#"app --arg="value""#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_plain_exec() {
|
||||
assert_eq!(clean_exec("firefox"), "firefox");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user