v0.2.0
clean architecture refactor, performance, resilience, DX/UX architecture: - 13 crates with proper domain/application/infrastructure layers - domain crate: newtypes, ports (Plugin, AppLauncher), constants - kernel: pure orchestrator - shared UI state machine (k-launcher-ui-core) - merged plugin-api into domain as ports module - granular file structure (no monolithic lib.rs) - all tests extracted to tests/ directories features: - frecency boost in search results - empty query shows top frecent apps - append-only frecency log with configurable compaction - config-driven styling (all colors, sizes, debounce) - configurable terminal emulator, external plugin timeout - log rotation with max_log_files - loading indicator, descriptive placeholder text - graceful shutdown via iced::exit() + Plugin::shutdown() - --version flag, panic hook, signal handling (SIGINT/SIGTERM) - SpawnInTerminal in external plugin protocol performance: - ~1500 -> ~50 heap allocs per keystroke - reused Matcher, Pattern, char buffer across entries - Arc<str> for shared result fields - pre-filter before fuzzy matching - partial sort for top frecent IDs - cached lowercase names in entries resilience: - parking_lot (no mutex poisoning) - thiserror hierarchy (PluginError, ConfigError, AppError) - all silent error swallowing replaced with tracing::warn - config parse errors logged quality: - named constants (no magic strings/numbers) - named types (no anonymous tuples) - Rgba newtype with validation - domain newtype validation (debug_assert non-empty) - man page, LICENSE (MIT), PKGBUILD, example config - plugin development guide updated - make check (fmt + clippy + test), make dev (RUST_LOG=debug) style: format code for better readability in tests and function signatures fix: update build_entries function signature to ignore frecency parameter fix(review): bugs, arch violations, design smells P1 bugs: - unix_launcher: shell_split respects quoted args (was split_whitespace) - plugin-host: 5s timeout on external plugin search - ui: handle engine init panic, wire error state - ui-egui: read window config instead of always using defaults - plugin-url: use OpenPath action instead of SpawnProcess+xdg-open Architecture: - remove WindowConfig (mirror of WindowCfg); use WindowCfg directly - remove on_select closure from SearchResult (domain leakage) - remove LaunchAction::Custom; add Plugin::on_selected + SearchEngine::on_selected - apps: record frecency via on_selected instead of embedded closure Design smells: - frecency: extract decay_factor helper, write outside mutex - apps: remove cfg(test) cache_path hack; add new_for_test ctor - apps: stable ResultId using name+exec to prevent collision - files: stable ResultId using full path instead of index - plugin-host: remove k-launcher-os-bridge dep (WindowConfig gone) Update iced dependency in Cargo.toml to disable default features and add additional ones feat(app): enhance engine initialization with EngineHandle and update run function signature feat: production hardening (panic isolation, file logging, apps cache) - Kernel::search wraps each plugin in catch_unwind; panics are logged and return [] - init_logging() adds daily rolling file at ~/.local/share/k-launcher/logs/ - AppsPlugin caches entries to ~/.cache/k-launcher/apps.bin via bincode; stale-while-revalidate on subsequent launches - 57 tests pass refactor: remove client module and associated show command logic fix(app): format code for clarity in update function chore: update .gitignore and enhance README with compositor setup instructions chore(docs): remove unused screenshot file feature/prod-ready (#1) Reviewed-on: #1 fix(calc): remove ambiguous log alias, use ln/log2/log10 explicitly fix(calc): fix log/ln naming, cache math context, strengthen sin(pi) test feat(calc): add math functions (sqrt, sin, cos, etc.) and pi/e constants refactor(calc): rename preprocess, extend underscore test assertions feat(calc): strip underscore digit separators feat: update dependencies for improved compatibility and performance feat: add plugin-url for URL handling and open in browser functionality feat: add support for external plugins and enhance plugin management feat: add Makefile for build, run, and installation commands feat: add required features for k-launcher-egui and update dependencies feat: update README and add documentation for installation, configuration, usage, and plugin development feat: enhance configuration management and UI styling, remove unused theme module feat: add k-launcher-config crate for configuration management and integrate with existing components feat: add k-launcher-ui-egui crate for enhanced UI - Introduced a new crate `k-launcher-ui-egui` to provide a graphical user interface using eframe and egui. - Updated the workspace configuration in `Cargo.toml` to include the new crate. - Implemented the main application logic in `src/app.rs`, handling search functionality and user interactions. - Created a library entry point in `src/lib.rs` to expose the `run` function for launching the UI. - Modified the `k-launcher` crate to include a new binary target for the egui-based launcher. - Added a new main file `src/main_egui.rs` to initialize and run the egui UI with the existing kernel and launcher components. feat: implement OS bridge and enhance app launcher functionality feat: add FilesPlugin for file searching and integrate into KLauncher feat: implement frecency tracking for app usage and enhance search functionality feat: add CmdPlugin for executing terminal commands and update workspace configuration refactor: update dependencies and improve keyboard event handling in KLauncherApp refactor: simplify theme usage and enhance AppsPlugin structure feat: restructure k-launcher workspace and add core functionality - Updated Cargo.toml to include a new k-launcher crate and reorganized workspace members. - Introduced a README.md file detailing the project philosophy, architecture, and technical specifications. - Implemented a new Kernel struct in k-launcher-kernel for managing plugins and search functionality. - Created a Plugin trait for plugins to implement, allowing for asynchronous search operations. - Developed k-launcher-ui with an Iced-based UI for user interaction, including search input and result display. - Added AppsPlugin and CalcPlugin to handle application launching and basic calculations, respectively. - Established a theme module for UI styling, focusing on an Aero aesthetic. - Removed unnecessary main.rs files from plugin crates, streamlining the project structure. Initialize k-launcher project structure with multiple crates and basic configurations
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "plugin-apps"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
@@ -9,10 +9,11 @@ path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
async-trait = { workspace = true }
|
||||
bincode = { workspace = true }
|
||||
bincode = { version = "2", features = ["serde"] }
|
||||
dirs = { workspace = true }
|
||||
k-launcher-kernel = { path = "../../k-launcher-kernel" }
|
||||
k-launcher-domain = { workspace = true }
|
||||
nucleo-matcher = "0.3"
|
||||
parking_lot = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = "1.0"
|
||||
tokio = { workspace = true }
|
||||
@@ -21,3 +22,7 @@ tracing = { workspace = true }
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
linicon = "2.3.0"
|
||||
xdg = "3"
|
||||
|
||||
[dev-dependencies]
|
||||
k-launcher-domain = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
110
crates/plugins/plugin-apps/src/cache.rs
Normal file
110
crates/plugins/plugin-apps/src/cache.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use crate::frecency::FrecencyStore;
|
||||
use crate::types::{AppName, DesktopEntrySource};
|
||||
|
||||
pub struct CachedEntry {
|
||||
pub(crate) id: String,
|
||||
pub(crate) name: AppName,
|
||||
pub(crate) name_lowercase: String,
|
||||
pub(crate) keywords_lowercase: Vec<String>,
|
||||
pub(crate) category: Option<Arc<str>>,
|
||||
pub(crate) icon: Option<Arc<str>>,
|
||||
pub(crate) exec: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct CachedEntryData {
|
||||
id: String,
|
||||
name: String,
|
||||
name_lowercase: String,
|
||||
keywords_lowercase: Vec<String>,
|
||||
category: Option<String>,
|
||||
icon: Option<String>,
|
||||
exec: String,
|
||||
}
|
||||
|
||||
pub fn cache_path() -> Option<PathBuf> {
|
||||
dirs::cache_dir().map(|d| d.join("k-launcher/apps.bin"))
|
||||
}
|
||||
|
||||
pub fn load_from_path(path: &Path) -> Option<HashMap<String, CachedEntry>> {
|
||||
let data = std::fs::read(path).ok()?;
|
||||
let (entries_data, _): (Vec<CachedEntryData>, _) =
|
||||
bincode::serde::decode_from_slice(&data, bincode::config::standard()).ok()?;
|
||||
let map = entries_data
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
let cached = CachedEntry {
|
||||
id: e.id.clone(),
|
||||
name: AppName::new(e.name),
|
||||
name_lowercase: e.name_lowercase,
|
||||
keywords_lowercase: e.keywords_lowercase,
|
||||
category: e.category.map(Arc::from),
|
||||
icon: e.icon.map(Arc::from),
|
||||
exec: e.exec,
|
||||
};
|
||||
(e.id, cached)
|
||||
})
|
||||
.collect();
|
||||
Some(map)
|
||||
}
|
||||
|
||||
pub fn save_to_path(path: &Path, entries: &HashMap<String, CachedEntry>) {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir).ok();
|
||||
}
|
||||
let data: Vec<CachedEntryData> = entries
|
||||
.values()
|
||||
.map(|e| CachedEntryData {
|
||||
id: e.id.clone(),
|
||||
name: e.name.as_str().to_string(),
|
||||
name_lowercase: e.name_lowercase.clone(),
|
||||
keywords_lowercase: e.keywords_lowercase.clone(),
|
||||
category: e.category.as_deref().map(str::to_string),
|
||||
icon: e.icon.as_deref().map(str::to_string),
|
||||
exec: e.exec.clone(),
|
||||
})
|
||||
.collect();
|
||||
if let Ok(encoded) = bincode::serde::encode_to_vec(&data, bincode::config::standard()) {
|
||||
std::fs::write(path, encoded).ok();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_entries(
|
||||
source: &impl DesktopEntrySource,
|
||||
_frecency: &Arc<FrecencyStore>,
|
||||
) -> HashMap<String, CachedEntry> {
|
||||
source
|
||||
.entries()
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
let id = format!("app-{}:{}", e.name.as_str(), e.exec.as_str());
|
||||
let name_lowercase = e.name.as_str().to_lowercase();
|
||||
let keywords_lowercase = e.keywords.iter().map(|k| k.to_lowercase()).collect();
|
||||
#[cfg(target_os = "linux")]
|
||||
let icon: Option<Arc<str>> = e
|
||||
.icon
|
||||
.as_ref()
|
||||
.and_then(|p| crate::linux::resolve_icon_path(p.as_str()))
|
||||
.map(Arc::from);
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let icon: Option<Arc<str>> = None;
|
||||
let exec = e.exec.as_str().to_string();
|
||||
let cached = CachedEntry {
|
||||
id: id.clone(),
|
||||
name_lowercase,
|
||||
keywords_lowercase,
|
||||
category: e.category.map(Arc::from),
|
||||
icon,
|
||||
exec,
|
||||
name: e.name,
|
||||
};
|
||||
(id, cached)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs::{File, OpenOptions},
|
||||
io::{BufRead, BufReader, Write},
|
||||
path::PathBuf,
|
||||
sync::{Arc, Mutex},
|
||||
sync::Arc,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -13,41 +16,64 @@ struct Entry {
|
||||
last_used: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct LogRecord {
|
||||
id: String,
|
||||
ts: u64,
|
||||
}
|
||||
|
||||
pub struct FrecencyStore {
|
||||
path: PathBuf,
|
||||
snapshot_path: PathBuf,
|
||||
log_path: PathBuf,
|
||||
data: Mutex<HashMap<String, Entry>>,
|
||||
log_count: Mutex<usize>,
|
||||
compact_threshold: usize,
|
||||
}
|
||||
|
||||
impl FrecencyStore {
|
||||
pub fn new(path: PathBuf) -> Arc<Self> {
|
||||
let data = std::fs::read_to_string(&path)
|
||||
pub fn new(snapshot_path: PathBuf, compact_threshold: usize) -> Arc<Self> {
|
||||
let mut data: HashMap<String, Entry> = std::fs::read_to_string(&snapshot_path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
Arc::new(Self {
|
||||
path,
|
||||
|
||||
let log_path = snapshot_path.with_extension("log");
|
||||
let log_count = replay_log_into(&log_path, &mut data);
|
||||
|
||||
let store = Arc::new(Self {
|
||||
snapshot_path,
|
||||
log_path,
|
||||
data: Mutex::new(data),
|
||||
})
|
||||
log_count: Mutex::new(log_count),
|
||||
compact_threshold,
|
||||
});
|
||||
|
||||
if log_count >= compact_threshold {
|
||||
store.compact();
|
||||
}
|
||||
|
||||
store
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn new_for_test() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
path: PathBuf::from("/dev/null"),
|
||||
snapshot_path: PathBuf::from("/dev/null"),
|
||||
log_path: PathBuf::from("/dev/null"),
|
||||
data: Mutex::new(HashMap::new()),
|
||||
log_count: Mutex::new(0),
|
||||
compact_threshold: usize::MAX,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load() -> Arc<Self> {
|
||||
pub fn load(compact_threshold: usize) -> Arc<Self> {
|
||||
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()),
|
||||
});
|
||||
return Self::new_for_test();
|
||||
};
|
||||
let path = data_home.join("k-launcher").join("frecency.json");
|
||||
Self::new(path)
|
||||
let path = data_home
|
||||
.join(k_launcher_domain::constants::APP_NAME)
|
||||
.join(k_launcher_domain::constants::FRECENCY_SNAPSHOT_FILENAME);
|
||||
Self::new(path, compact_threshold)
|
||||
}
|
||||
|
||||
pub fn record(&self, id: &str) {
|
||||
@@ -55,26 +81,72 @@ impl FrecencyStore {
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let json = {
|
||||
let mut data = self.data.lock().unwrap();
|
||||
{
|
||||
let mut data = self.data.lock();
|
||||
let entry = data.entry(id.to_string()).or_insert(Entry {
|
||||
count: 0,
|
||||
last_used: 0,
|
||||
});
|
||||
entry.count += 1;
|
||||
entry.last_used = now;
|
||||
serde_json::to_string(&*data).ok()
|
||||
}; // lock released here
|
||||
if let Some(json) = json {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
self.append_log(id, now);
|
||||
}
|
||||
|
||||
fn append_log(&self, id: &str, ts: u64) {
|
||||
let record = LogRecord {
|
||||
id: id.to_string(),
|
||||
ts,
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&record) {
|
||||
if let Some(parent) = self.log_path.parent()
|
||||
&& let Err(e) = std::fs::create_dir_all(parent)
|
||||
{
|
||||
tracing::warn!("failed to create frecency dir: {e}");
|
||||
}
|
||||
let _ = std::fs::write(&self.path, json);
|
||||
if let Ok(mut file) = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&self.log_path)
|
||||
&& let Err(e) = writeln!(file, "{json}")
|
||||
{
|
||||
tracing::warn!("failed to write frecency log: {e}");
|
||||
}
|
||||
}
|
||||
let mut count = self.log_count.lock();
|
||||
*count += 1;
|
||||
if *count >= self.compact_threshold {
|
||||
drop(count);
|
||||
self.compact();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compact(&self) {
|
||||
let json = {
|
||||
let data = self.data.lock();
|
||||
serde_json::to_string(&*data).ok()
|
||||
};
|
||||
if let Some(json) = json {
|
||||
if let Some(parent) = self.snapshot_path.parent()
|
||||
&& let Err(e) = std::fs::create_dir_all(parent)
|
||||
{
|
||||
tracing::warn!("failed to create frecency dir: {e}");
|
||||
}
|
||||
if std::fs::write(&self.snapshot_path, json).is_ok() {
|
||||
if let Err(e) = File::create(&self.log_path) {
|
||||
tracing::warn!("failed to truncate frecency log: {e}");
|
||||
}
|
||||
*self.log_count.lock() = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) {
|
||||
self.compact();
|
||||
}
|
||||
|
||||
pub fn frecency_score(&self, id: &str) -> u32 {
|
||||
let data = self.data.lock().unwrap();
|
||||
let data = self.data.lock();
|
||||
let Some(entry) = data.get(id) else { return 0 };
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -85,70 +157,73 @@ impl FrecencyStore {
|
||||
}
|
||||
|
||||
pub fn top_ids(&self, n: usize) -> Vec<String> {
|
||||
let data = self.data.lock().unwrap();
|
||||
struct ScoredId {
|
||||
id: String,
|
||||
score: u32,
|
||||
}
|
||||
|
||||
let data = self.data.lock();
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let mut scored: Vec<(String, u32)> = data
|
||||
let mut scored: Vec<ScoredId> = data
|
||||
.iter()
|
||||
.map(|(id, entry)| {
|
||||
let age_secs = now.saturating_sub(entry.last_used);
|
||||
(id.clone(), entry.count * decay_factor(age_secs))
|
||||
ScoredId {
|
||||
id: id.clone(),
|
||||
score: entry.count * decay_factor(age_secs),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
scored.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
scored.into_iter().take(n).map(|(id, _)| id).collect()
|
||||
|
||||
if scored.len() <= n {
|
||||
scored.sort_by_key(|s| std::cmp::Reverse(s.score));
|
||||
return scored.into_iter().map(|s| s.id).collect();
|
||||
}
|
||||
|
||||
scored.select_nth_unstable_by_key(n, |s| std::cmp::Reverse(s.score));
|
||||
scored.truncate(n);
|
||||
scored.sort_by_key(|s| std::cmp::Reverse(s.score));
|
||||
scored.into_iter().map(|s| s.id).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn replay_log_into(log_path: &PathBuf, data: &mut HashMap<String, Entry>) -> usize {
|
||||
let file = match File::open(log_path) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return 0,
|
||||
};
|
||||
let mut count = 0;
|
||||
for line in BufReader::new(file).lines() {
|
||||
let Ok(line) = line else { continue };
|
||||
let Ok(record) = serde_json::from_str::<LogRecord>(&line) else {
|
||||
continue;
|
||||
};
|
||||
let entry = data.entry(record.id).or_insert(Entry {
|
||||
count: 0,
|
||||
last_used: 0,
|
||||
});
|
||||
entry.count += 1;
|
||||
entry.last_used = record.ts;
|
||||
count += 1;
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
const ONE_HOUR: u64 = 3600;
|
||||
const ONE_DAY: u64 = 86400;
|
||||
const DECAY_RECENT: u32 = 4;
|
||||
const DECAY_TODAY: u32 = 2;
|
||||
const DECAY_OLD: u32 = 1;
|
||||
|
||||
fn decay_factor(age_secs: u64) -> u32 {
|
||||
if age_secs < 3600 {
|
||||
4
|
||||
} else if age_secs < 86400 {
|
||||
2
|
||||
if age_secs < ONE_HOUR {
|
||||
DECAY_RECENT
|
||||
} else if age_secs < ONE_DAY {
|
||||
DECAY_TODAY
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
#[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");
|
||||
DECAY_OLD
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,521 +1,12 @@
|
||||
mod cache;
|
||||
pub mod frecency;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod linux;
|
||||
mod plugin;
|
||||
mod scoring;
|
||||
mod types;
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use k_launcher_kernel::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
|
||||
|
||||
use crate::frecency::FrecencyStore;
|
||||
|
||||
// --- Domain newtypes ---
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppName(String);
|
||||
|
||||
impl AppName {
|
||||
pub fn new(s: impl Into<String>) -> Self {
|
||||
Self(s.into())
|
||||
}
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExecCommand(String);
|
||||
|
||||
impl ExecCommand {
|
||||
pub fn new(s: impl Into<String>) -> Self {
|
||||
Self(s.into())
|
||||
}
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IconPath(String);
|
||||
|
||||
impl IconPath {
|
||||
pub fn new(s: impl Into<String>) -> Self {
|
||||
Self(s.into())
|
||||
}
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
// --- Desktop entry ---
|
||||
|
||||
pub struct DesktopEntry {
|
||||
pub name: AppName,
|
||||
pub exec: ExecCommand,
|
||||
pub icon: Option<IconPath>,
|
||||
pub category: Option<String>,
|
||||
pub keywords: Vec<String>,
|
||||
}
|
||||
|
||||
// --- Swappable source trait (Application layer principle) ---
|
||||
|
||||
pub trait DesktopEntrySource: Send + Sync {
|
||||
fn entries(&self) -> Vec<DesktopEntry>;
|
||||
}
|
||||
|
||||
// --- Cached entry (pre-computed at construction) ---
|
||||
|
||||
struct CachedEntry {
|
||||
id: String,
|
||||
name: AppName,
|
||||
keywords_lc: Vec<String>,
|
||||
category: Option<String>,
|
||||
icon: Option<String>,
|
||||
exec: String,
|
||||
}
|
||||
|
||||
// --- Serializable cache data (no closures) ---
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct CachedEntryData {
|
||||
id: String,
|
||||
name: String,
|
||||
keywords_lc: Vec<String>,
|
||||
category: Option<String>,
|
||||
icon: Option<String>,
|
||||
exec: String,
|
||||
}
|
||||
|
||||
fn cache_path() -> Option<PathBuf> {
|
||||
dirs::cache_dir().map(|d| d.join("k-launcher/apps.bin"))
|
||||
}
|
||||
|
||||
fn load_from_path(path: &Path) -> Option<HashMap<String, CachedEntry>> {
|
||||
let data = std::fs::read(path).ok()?;
|
||||
let (entries_data, _): (Vec<CachedEntryData>, _) =
|
||||
bincode::serde::decode_from_slice(&data, bincode::config::standard()).ok()?;
|
||||
let map = entries_data
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
let cached = CachedEntry {
|
||||
id: e.id.clone(),
|
||||
name: AppName::new(e.name),
|
||||
keywords_lc: e.keywords_lc,
|
||||
category: e.category,
|
||||
icon: e.icon,
|
||||
exec: e.exec,
|
||||
};
|
||||
(e.id, cached)
|
||||
})
|
||||
.collect();
|
||||
Some(map)
|
||||
}
|
||||
|
||||
fn save_to_path(path: &Path, entries: &HashMap<String, CachedEntry>) {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir).ok();
|
||||
}
|
||||
let data: Vec<CachedEntryData> = entries
|
||||
.values()
|
||||
.map(|e| CachedEntryData {
|
||||
id: e.id.clone(),
|
||||
name: e.name.as_str().to_string(),
|
||||
keywords_lc: e.keywords_lc.clone(),
|
||||
category: e.category.clone(),
|
||||
icon: e.icon.clone(),
|
||||
exec: e.exec.clone(),
|
||||
})
|
||||
.collect();
|
||||
if let Ok(encoded) = bincode::serde::encode_to_vec(&data, bincode::config::standard()) {
|
||||
std::fs::write(path, encoded).ok();
|
||||
}
|
||||
}
|
||||
|
||||
fn build_entries(
|
||||
source: &impl DesktopEntrySource,
|
||||
_frecency: &Arc<FrecencyStore>,
|
||||
) -> HashMap<String, CachedEntry> {
|
||||
source
|
||||
.entries()
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
let id = format!("app-{}:{}", e.name.as_str(), e.exec.as_str());
|
||||
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()));
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let icon: Option<String> = None;
|
||||
let exec = e.exec.as_str().to_string();
|
||||
let cached = CachedEntry {
|
||||
id: id.clone(),
|
||||
keywords_lc,
|
||||
category: e.category,
|
||||
icon,
|
||||
exec,
|
||||
name: e.name,
|
||||
};
|
||||
(id, cached)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// --- Plugin ---
|
||||
|
||||
pub struct AppsPlugin {
|
||||
entries: Arc<RwLock<HashMap<String, CachedEntry>>>,
|
||||
frecency: Arc<FrecencyStore>,
|
||||
}
|
||||
|
||||
impl AppsPlugin {
|
||||
pub fn new(source: impl DesktopEntrySource + 'static, frecency: Arc<FrecencyStore>) -> Self {
|
||||
Self::new_impl(source, frecency, cache_path())
|
||||
}
|
||||
|
||||
fn new_impl(
|
||||
source: impl DesktopEntrySource + 'static,
|
||||
frecency: Arc<FrecencyStore>,
|
||||
cp: Option<PathBuf>,
|
||||
) -> Self {
|
||||
let cached = cp.as_deref().and_then(load_from_path);
|
||||
|
||||
let entries = if let Some(from_cache) = cached {
|
||||
// Serve cache immediately; refresh in background.
|
||||
let map = Arc::new(RwLock::new(from_cache));
|
||||
let entries_bg = Arc::clone(&map);
|
||||
let frecency_bg = Arc::clone(&frecency);
|
||||
let cp_bg = cp.clone();
|
||||
std::thread::spawn(move || {
|
||||
let fresh = build_entries(&source, &frecency_bg);
|
||||
if let Some(path) = cp_bg {
|
||||
save_to_path(&path, &fresh);
|
||||
}
|
||||
*entries_bg.write().unwrap() = fresh;
|
||||
});
|
||||
map
|
||||
} else {
|
||||
// No cache: build synchronously, then persist.
|
||||
let initial = build_entries(&source, &frecency);
|
||||
if let Some(path) = &cp {
|
||||
save_to_path(path, &initial);
|
||||
}
|
||||
Arc::new(RwLock::new(initial))
|
||||
};
|
||||
|
||||
Self { entries, frecency }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn new_for_test(
|
||||
source: impl DesktopEntrySource + 'static,
|
||||
frecency: Arc<FrecencyStore>,
|
||||
) -> Self {
|
||||
Self::new_impl(source, frecency, None)
|
||||
}
|
||||
}
|
||||
|
||||
fn initials(name_lc: &str) -> String {
|
||||
name_lc
|
||||
.split_whitespace()
|
||||
.filter_map(|w| w.chars().next())
|
||||
.collect()
|
||||
}
|
||||
|
||||
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 {
|
||||
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]
|
||||
impl Plugin for AppsPlugin {
|
||||
fn name(&self) -> &str {
|
||||
"apps"
|
||||
}
|
||||
|
||||
fn on_selected(&self, id: &ResultId) {
|
||||
self.frecency.record(id.as_str());
|
||||
}
|
||||
|
||||
async fn search(&self, query: &str) -> Vec<SearchResult> {
|
||||
let entries = self.entries.read().unwrap();
|
||||
if query.is_empty() {
|
||||
return self
|
||||
.frecency
|
||||
.top_ids(5)
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
let e = 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),
|
||||
action: LaunchAction::SpawnProcess(e.exec.clone()),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
let query_lc = query.to_lowercase();
|
||||
entries
|
||||
.values()
|
||||
.filter_map(|e| {
|
||||
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),
|
||||
title: ResultTitle::new(e.name.as_str()),
|
||||
description: e.category.clone(),
|
||||
icon: e.icon.clone(),
|
||||
score: Score::new(score),
|
||||
action: LaunchAction::SpawnProcess(e.exec.clone()),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ephemeral_frecency() -> Arc<FrecencyStore> {
|
||||
FrecencyStore::new_for_test()
|
||||
}
|
||||
|
||||
struct MockSource {
|
||||
entries: Vec<(String, String, Option<String>, Vec<String>)>, // (name, exec, category, keywords)
|
||||
}
|
||||
|
||||
impl MockSource {
|
||||
fn with(entries: Vec<(&str, &str)>) -> Self {
|
||||
Self {
|
||||
entries: entries
|
||||
.into_iter()
|
||||
.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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DesktopEntrySource for MockSource {
|
||||
fn entries(&self) -> Vec<DesktopEntry> {
|
||||
self.entries
|
||||
.iter()
|
||||
.map(|(name, exec, category, keywords)| DesktopEntry {
|
||||
name: AppName::new(name.clone()),
|
||||
exec: ExecCommand::new(exec.clone()),
|
||||
icon: None,
|
||||
category: category.clone(),
|
||||
keywords: keywords.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_prefix_match() {
|
||||
let p = AppsPlugin::new_for_test(
|
||||
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_for_test(
|
||||
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_for_test(
|
||||
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!(score_match("visual studio code", "vsc").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_match_exact_beats_prefix() {
|
||||
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]
|
||||
async fn apps_abbreviation_match() {
|
||||
let p = AppsPlugin::new_for_test(
|
||||
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!(results[0].score.value() > 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_keyword_match() {
|
||||
let p = AppsPlugin::new_for_test(
|
||||
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);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_fuzzy_typo_match() {
|
||||
let p = AppsPlugin::new_for_test(
|
||||
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");
|
||||
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_for_test(
|
||||
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:code");
|
||||
frecency.record("app-Code:code");
|
||||
frecency.record("app-Firefox:firefox");
|
||||
let p = AppsPlugin::new_for_test(
|
||||
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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apps_loads_from_cache_when_source_is_empty() {
|
||||
let frecency = ephemeral_frecency();
|
||||
let cache_file =
|
||||
std::env::temp_dir().join(format!("k-launcher-test-{}.bin", std::process::id()));
|
||||
|
||||
// Build entries from a real source and save to temp path
|
||||
let source = MockSource::with(vec![("Firefox", "firefox")]);
|
||||
let entries = build_entries(&source, &frecency);
|
||||
save_to_path(&cache_file, &entries);
|
||||
|
||||
// Load from temp path — should contain Firefox
|
||||
let loaded = load_from_path(&cache_file).unwrap();
|
||||
assert!(loaded.contains_key("app-Firefox:firefox"));
|
||||
|
||||
std::fs::remove_file(&cache_file).ok();
|
||||
}
|
||||
}
|
||||
pub use cache::{CachedEntry, build_entries, load_from_path, save_to_path};
|
||||
pub use plugin::*;
|
||||
pub use scoring::{humanize_category, new_matcher, parse_pattern, score_match};
|
||||
pub use types::*;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::path::Path;
|
||||
|
||||
use crate::humanize_category;
|
||||
use crate::scoring::humanize_category;
|
||||
use crate::{AppName, DesktopEntry, DesktopEntrySource, ExecCommand, IconPath};
|
||||
|
||||
pub struct FsDesktopEntrySource;
|
||||
@@ -45,7 +45,7 @@ impl DesktopEntrySource for FsDesktopEntrySource {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn clean_exec(exec: &str) -> String {
|
||||
pub 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();
|
||||
@@ -99,16 +99,18 @@ fn is_field_code(s: &str) -> bool {
|
||||
b.len() == 2 && b[0] == b'%' && b[1].is_ascii_alphabetic()
|
||||
}
|
||||
|
||||
const ICON_LOOKUP_SIZE: u16 = 48;
|
||||
const ICON_THEMES: &[&str] = &["hicolor", "Adwaita", "breeze", "Papirus"];
|
||||
const PIXMAPS_DIR: &str = "/usr/share/pixmaps";
|
||||
|
||||
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 {
|
||||
for theme in ICON_THEMES {
|
||||
if let Some(icon_path) = linicon::lookup_icon(name)
|
||||
.from_theme(theme)
|
||||
.with_size(48)
|
||||
.with_size(ICON_LOOKUP_SIZE)
|
||||
.find_map(|r| r.ok())
|
||||
{
|
||||
return Some(icon_path.path.to_string_lossy().into_owned());
|
||||
@@ -116,8 +118,8 @@ pub fn resolve_icon_path(name: &str) -> Option<String> {
|
||||
}
|
||||
// Fallback to pixmaps
|
||||
let candidates = [
|
||||
format!("/usr/share/pixmaps/{name}.png"),
|
||||
format!("/usr/share/pixmaps/{name}.svg"),
|
||||
format!("{PIXMAPS_DIR}/{name}.png"),
|
||||
format!("{PIXMAPS_DIR}/{name}.svg"),
|
||||
];
|
||||
candidates.into_iter().find(|p| Path::new(p).exists())
|
||||
}
|
||||
@@ -187,31 +189,3 @@ 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");
|
||||
}
|
||||
}
|
||||
|
||||
142
crates/plugins/plugin-apps/src/plugin.rs
Normal file
142
crates/plugins/plugin-apps/src/plugin.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
use std::{collections::HashMap, path::PathBuf, sync::Arc};
|
||||
|
||||
use parking_lot::RwLock;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use k_launcher_domain::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
|
||||
|
||||
use crate::cache::{CachedEntry, build_entries, cache_path, load_from_path, save_to_path};
|
||||
use crate::frecency::FrecencyStore;
|
||||
use crate::scoring::{new_matcher, parse_pattern, score_match};
|
||||
use crate::types::DesktopEntrySource;
|
||||
|
||||
const FRECENT_RESULTS_COUNT: usize = 5;
|
||||
const KEYWORD_MATCH_SCORE: u32 = 50;
|
||||
|
||||
pub struct AppsPlugin {
|
||||
entries: Arc<RwLock<HashMap<String, CachedEntry>>>,
|
||||
frecency: Arc<FrecencyStore>,
|
||||
}
|
||||
|
||||
impl AppsPlugin {
|
||||
pub fn new(source: impl DesktopEntrySource + 'static, frecency: Arc<FrecencyStore>) -> Self {
|
||||
Self::new_impl(source, frecency, cache_path())
|
||||
}
|
||||
|
||||
fn new_impl(
|
||||
source: impl DesktopEntrySource + 'static,
|
||||
frecency: Arc<FrecencyStore>,
|
||||
cp: Option<PathBuf>,
|
||||
) -> Self {
|
||||
let cached = cp.as_deref().and_then(load_from_path);
|
||||
|
||||
let entries = if let Some(from_cache) = cached {
|
||||
// Serve cache immediately; refresh in background.
|
||||
let map = Arc::new(RwLock::new(from_cache));
|
||||
let entries_bg = Arc::clone(&map);
|
||||
let frecency_bg = Arc::clone(&frecency);
|
||||
let cp_bg = cp.clone();
|
||||
std::thread::spawn(move || {
|
||||
let fresh = build_entries(&source, &frecency_bg);
|
||||
if let Some(path) = cp_bg {
|
||||
save_to_path(&path, &fresh);
|
||||
}
|
||||
*entries_bg.write() = fresh;
|
||||
});
|
||||
map
|
||||
} else {
|
||||
// No cache: build synchronously, then persist.
|
||||
let initial = build_entries(&source, &frecency);
|
||||
if let Some(path) = &cp {
|
||||
save_to_path(path, &initial);
|
||||
}
|
||||
Arc::new(RwLock::new(initial))
|
||||
};
|
||||
|
||||
Self { entries, frecency }
|
||||
}
|
||||
|
||||
pub fn new_for_test(
|
||||
source: impl DesktopEntrySource + 'static,
|
||||
frecency: Arc<FrecencyStore>,
|
||||
) -> Self {
|
||||
Self::new_impl(source, frecency, None)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Plugin for AppsPlugin {
|
||||
fn name(&self) -> &str {
|
||||
"apps"
|
||||
}
|
||||
|
||||
fn on_selected(&self, id: &ResultId) {
|
||||
self.frecency.record(id.as_str());
|
||||
}
|
||||
|
||||
fn shutdown(&self) {
|
||||
self.frecency.shutdown();
|
||||
}
|
||||
|
||||
async fn search(&self, query: &str) -> Vec<SearchResult> {
|
||||
let entries = self.entries.read();
|
||||
if query.is_empty() {
|
||||
return self
|
||||
.frecency
|
||||
.top_ids(FRECENT_RESULTS_COUNT)
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
let e = 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),
|
||||
action: LaunchAction::SpawnProcess(e.exec.clone()),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
let query_lowercase = query.to_lowercase();
|
||||
let first_char = query_lowercase.chars().next().unwrap_or_default();
|
||||
let mut matcher = new_matcher();
|
||||
let pattern = parse_pattern(query);
|
||||
let mut char_buf: Vec<char> = Vec::with_capacity(64);
|
||||
|
||||
entries
|
||||
.values()
|
||||
.filter(|e| {
|
||||
e.name_lowercase.contains(first_char)
|
||||
|| e.keywords_lowercase.iter().any(|k| k.contains(first_char))
|
||||
})
|
||||
.filter_map(|e| {
|
||||
let match_score = score_match(
|
||||
&mut matcher,
|
||||
&pattern,
|
||||
e.name.as_str(),
|
||||
&mut char_buf,
|
||||
&e.name_lowercase,
|
||||
&query_lowercase,
|
||||
)
|
||||
.or_else(|| {
|
||||
e.keywords_lowercase
|
||||
.iter()
|
||||
.any(|k| k.contains(query_lowercase.as_str()))
|
||||
.then_some(KEYWORD_MATCH_SCORE)
|
||||
})?;
|
||||
let frecency_boost = self.frecency.frecency_score(&e.id);
|
||||
Some(SearchResult {
|
||||
id: ResultId::new(&e.id),
|
||||
title: ResultTitle::new(e.name.as_str()),
|
||||
description: e.category.clone(),
|
||||
icon: e.icon.clone(),
|
||||
score: Score::new(match_score.saturating_add(frecency_boost)),
|
||||
action: LaunchAction::SpawnProcess(e.exec.clone()),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
60
crates/plugins/plugin-apps/src/scoring.rs
Normal file
60
crates/plugins/plugin-apps/src/scoring.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
pub use nucleo_matcher::Matcher;
|
||||
use nucleo_matcher::{
|
||||
Config, Utf32Str,
|
||||
pattern::{CaseMatching, Normalization, Pattern},
|
||||
};
|
||||
|
||||
pub fn new_matcher() -> Matcher {
|
||||
Matcher::new(Config::DEFAULT)
|
||||
}
|
||||
|
||||
pub fn parse_pattern(query: &str) -> Pattern {
|
||||
Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart)
|
||||
}
|
||||
|
||||
fn matches_initials(name_lowercase: &str, query_lowercase: &str) -> bool {
|
||||
let mut initials = name_lowercase
|
||||
.split_whitespace()
|
||||
.filter_map(|w| w.chars().next());
|
||||
let mut query_chars = query_lowercase.chars();
|
||||
|
||||
for expected in &mut query_chars {
|
||||
match initials.next() {
|
||||
Some(initial) if initial == expected => continue,
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
const INITIALS_BONUS: u32 = 20;
|
||||
|
||||
pub fn score_match(
|
||||
matcher: &mut Matcher,
|
||||
pattern: &Pattern,
|
||||
name: &str,
|
||||
char_buf: &mut Vec<char>,
|
||||
name_lowercase: &str,
|
||||
query_lowercase: &str,
|
||||
) -> Option<u32> {
|
||||
let haystack = Utf32Str::new(name, char_buf);
|
||||
let score = pattern.score(haystack, matcher)?;
|
||||
|
||||
let bonus = if matches_initials(name_lowercase, query_lowercase) {
|
||||
INITIALS_BONUS
|
||||
} else {
|
||||
0
|
||||
};
|
||||
Some(score.saturating_add(bonus))
|
||||
}
|
||||
|
||||
pub 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
|
||||
}
|
||||
53
crates/plugins/plugin-apps/src/types.rs
Normal file
53
crates/plugins/plugin-apps/src/types.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
// --- Domain newtypes ---
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppName(String);
|
||||
|
||||
impl AppName {
|
||||
pub fn new(s: impl Into<String>) -> Self {
|
||||
Self(s.into())
|
||||
}
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExecCommand(String);
|
||||
|
||||
impl ExecCommand {
|
||||
pub fn new(s: impl Into<String>) -> Self {
|
||||
Self(s.into())
|
||||
}
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IconPath(String);
|
||||
|
||||
impl IconPath {
|
||||
pub fn new(s: impl Into<String>) -> Self {
|
||||
Self(s.into())
|
||||
}
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
// --- Desktop entry ---
|
||||
|
||||
pub struct DesktopEntry {
|
||||
pub name: AppName,
|
||||
pub exec: ExecCommand,
|
||||
pub icon: Option<IconPath>,
|
||||
pub category: Option<String>,
|
||||
pub keywords: Vec<String>,
|
||||
}
|
||||
|
||||
// --- Swappable source trait (Application layer principle) ---
|
||||
|
||||
pub trait DesktopEntrySource: Send + Sync {
|
||||
fn entries(&self) -> Vec<DesktopEntry>;
|
||||
}
|
||||
21
crates/plugins/plugin-apps/tests/frecency.rs
Normal file
21
crates/plugins/plugin-apps/tests/frecency.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use plugin_apps::frecency::FrecencyStore;
|
||||
|
||||
#[test]
|
||||
fn record_increments_count() {
|
||||
let store = FrecencyStore::new_for_test();
|
||||
store.record("app-firefox");
|
||||
store.record("app-firefox");
|
||||
assert!(store.frecency_score("app-firefox") > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_ids_returns_sorted_order() {
|
||||
let store = FrecencyStore::new_for_test();
|
||||
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");
|
||||
}
|
||||
27
crates/plugins/plugin-apps/tests/linux.rs
Normal file
27
crates/plugins/plugin-apps/tests/linux.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux_tests {
|
||||
use plugin_apps::linux::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");
|
||||
}
|
||||
}
|
||||
260
crates/plugins/plugin-apps/tests/plugin.rs
Normal file
260
crates/plugins/plugin-apps/tests/plugin.rs
Normal file
@@ -0,0 +1,260 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use k_launcher_domain::Plugin;
|
||||
use plugin_apps::frecency::FrecencyStore;
|
||||
use plugin_apps::{
|
||||
AppName, AppsPlugin, DesktopEntry, DesktopEntrySource, ExecCommand, build_entries,
|
||||
humanize_category, load_from_path, new_matcher, parse_pattern, save_to_path, score_match,
|
||||
};
|
||||
|
||||
fn ephemeral_frecency() -> Arc<FrecencyStore> {
|
||||
FrecencyStore::new_for_test()
|
||||
}
|
||||
|
||||
struct MockEntry {
|
||||
name: String,
|
||||
exec: String,
|
||||
category: Option<String>,
|
||||
keywords: Vec<String>,
|
||||
}
|
||||
|
||||
struct MockSource {
|
||||
entries: Vec<MockEntry>,
|
||||
}
|
||||
|
||||
impl MockSource {
|
||||
fn with(entries: Vec<(&str, &str)>) -> Self {
|
||||
Self {
|
||||
entries: entries
|
||||
.into_iter()
|
||||
.map(|(n, e)| MockEntry {
|
||||
name: n.to_string(),
|
||||
exec: e.to_string(),
|
||||
category: None,
|
||||
keywords: vec![],
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_categories(entries: Vec<(&str, &str, &str)>) -> Self {
|
||||
Self {
|
||||
entries: entries
|
||||
.into_iter()
|
||||
.map(|(n, e, c)| MockEntry {
|
||||
name: n.to_string(),
|
||||
exec: e.to_string(),
|
||||
category: Some(c.to_string()),
|
||||
keywords: vec![],
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_keywords(entries: Vec<(&str, &str, Vec<&str>)>) -> Self {
|
||||
Self {
|
||||
entries: entries
|
||||
.into_iter()
|
||||
.map(|(n, e, kw)| MockEntry {
|
||||
name: n.to_string(),
|
||||
exec: e.to_string(),
|
||||
category: None,
|
||||
keywords: kw.into_iter().map(|s| s.to_string()).collect(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DesktopEntrySource for MockSource {
|
||||
fn entries(&self) -> Vec<DesktopEntry> {
|
||||
self.entries
|
||||
.iter()
|
||||
.map(|e| DesktopEntry {
|
||||
name: AppName::new(e.name.clone()),
|
||||
exec: ExecCommand::new(e.exec.clone()),
|
||||
icon: None,
|
||||
category: e.category.clone(),
|
||||
keywords: e.keywords.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_prefix_match() {
|
||||
let p = AppsPlugin::new_for_test(
|
||||
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_for_test(
|
||||
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_for_test(
|
||||
MockSource::with(vec![("Firefox", "firefox")]),
|
||||
ephemeral_frecency(),
|
||||
);
|
||||
assert!(p.search("").await.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_match_abbreviation() {
|
||||
let mut matcher = new_matcher();
|
||||
let pattern = parse_pattern("vsc");
|
||||
let mut buf = Vec::new();
|
||||
assert!(
|
||||
score_match(
|
||||
&mut matcher,
|
||||
&pattern,
|
||||
"visual studio code",
|
||||
&mut buf,
|
||||
"visual studio code",
|
||||
"vsc"
|
||||
)
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_match_exact_beats_prefix() {
|
||||
let mut matcher = new_matcher();
|
||||
let mut buf = Vec::new();
|
||||
let exact_pattern = parse_pattern("firefox");
|
||||
let fire_pattern = parse_pattern("fire");
|
||||
let gf_pattern = parse_pattern("gf");
|
||||
|
||||
let exact = score_match(
|
||||
&mut matcher,
|
||||
&exact_pattern,
|
||||
"firefox",
|
||||
&mut buf,
|
||||
"firefox",
|
||||
"firefox",
|
||||
);
|
||||
let prefix = score_match(
|
||||
&mut matcher,
|
||||
&fire_pattern,
|
||||
"firefox",
|
||||
&mut buf,
|
||||
"firefox",
|
||||
"fire",
|
||||
);
|
||||
let abbrev = score_match(
|
||||
&mut matcher,
|
||||
&gf_pattern,
|
||||
"gnu firefox",
|
||||
&mut buf,
|
||||
"gnu firefox",
|
||||
"gf",
|
||||
);
|
||||
let substr = score_match(
|
||||
&mut matcher,
|
||||
&fire_pattern,
|
||||
"ice firefox",
|
||||
&mut buf,
|
||||
"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]
|
||||
async fn apps_abbreviation_match() {
|
||||
let p = AppsPlugin::new_for_test(
|
||||
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!(results[0].score.value() > 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_keyword_match() {
|
||||
let p = AppsPlugin::new_for_test(
|
||||
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);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_fuzzy_typo_match() {
|
||||
let p = AppsPlugin::new_for_test(
|
||||
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");
|
||||
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_for_test(
|
||||
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:code");
|
||||
frecency.record("app-Code:code");
|
||||
frecency.record("app-Firefox:firefox");
|
||||
let p = AppsPlugin::new_for_test(
|
||||
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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apps_loads_from_cache_when_source_is_empty() {
|
||||
let frecency = ephemeral_frecency();
|
||||
let cache_file =
|
||||
std::env::temp_dir().join(format!("k-launcher-test-{}.bin", std::process::id()));
|
||||
|
||||
let source = MockSource::with(vec![("Firefox", "firefox")]);
|
||||
let entries = build_entries(&source, &frecency);
|
||||
save_to_path(&cache_file, &entries);
|
||||
|
||||
let loaded = load_from_path(&cache_file).unwrap();
|
||||
assert!(loaded.contains_key("app-Firefox:firefox"));
|
||||
|
||||
std::fs::remove_file(&cache_file).ok();
|
||||
}
|
||||
Reference in New Issue
Block a user