Compare commits

..

3 Commits

Author SHA1 Message Date
2e773cdeaf style: format code for better readability in tests and function signatures
Some checks failed
CI / test (push) Failing after 4m59s
CI / clippy (push) Failing after 4m58s
CI / fmt (push) Successful in 23s
2026-03-18 13:59:53 +01:00
3d2bd5f9fe fix: update build_entries function signature to ignore frecency parameter
Some checks failed
CI / test (push) Failing after 5m6s
CI / clippy (push) Failing after 5m3s
CI / fmt (push) Failing after 23s
2026-03-18 13:48:02 +01:00
ff9b2b5712 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)
2026-03-18 13:45:48 +01:00
18 changed files with 197 additions and 136 deletions

2
Cargo.lock generated
View File

@@ -2082,7 +2082,6 @@ dependencies = [
name = "k-launcher-os-bridge" name = "k-launcher-os-bridge"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"k-launcher-config",
"k-launcher-kernel", "k-launcher-kernel",
"libc", "libc",
] ]
@@ -2118,7 +2117,6 @@ dependencies = [
"egui", "egui",
"k-launcher-config", "k-launcher-config",
"k-launcher-kernel", "k-launcher-kernel",
"k-launcher-os-bridge",
"tokio", "tokio",
] ]

View File

@@ -50,7 +50,6 @@ pub enum LaunchAction {
SpawnInTerminal(String), SpawnInTerminal(String),
OpenPath(String), OpenPath(String),
CopyToClipboard(String), CopyToClipboard(String),
Custom(Arc<dyn Fn() + Send + Sync>),
} }
// --- AppLauncher port trait --- // --- AppLauncher port trait ---
@@ -68,7 +67,6 @@ pub struct SearchResult {
pub icon: Option<String>, pub icon: Option<String>,
pub score: Score, pub score: Score,
pub action: LaunchAction, pub action: LaunchAction,
pub on_select: Option<Arc<dyn Fn() + Send + Sync>>,
} }
impl std::fmt::Debug for SearchResult { impl std::fmt::Debug for SearchResult {
@@ -88,6 +86,7 @@ impl std::fmt::Debug for SearchResult {
pub trait Plugin: Send + Sync { pub trait Plugin: Send + Sync {
fn name(&self) -> &str; fn name(&self) -> &str;
async fn search(&self, query: &str) -> Vec<SearchResult>; async fn search(&self, query: &str) -> Vec<SearchResult>;
fn on_selected(&self, _id: &ResultId) {}
} }
// --- SearchEngine port trait --- // --- SearchEngine port trait ---
@@ -95,6 +94,7 @@ pub trait Plugin: Send + Sync {
#[async_trait] #[async_trait]
pub trait SearchEngine: Send + Sync { pub trait SearchEngine: Send + Sync {
async fn search(&self, query: &str) -> Vec<SearchResult>; async fn search(&self, query: &str) -> Vec<SearchResult>;
fn on_selected(&self, id: &ResultId);
} }
// --- NullSearchEngine --- // --- NullSearchEngine ---
@@ -106,6 +106,7 @@ impl SearchEngine for NullSearchEngine {
async fn search(&self, _query: &str) -> Vec<SearchResult> { async fn search(&self, _query: &str) -> Vec<SearchResult> {
vec![] vec![]
} }
fn on_selected(&self, _id: &ResultId) {}
} }
// --- Kernel (Application use case) --- // --- Kernel (Application use case) ---
@@ -123,6 +124,12 @@ impl Kernel {
} }
} }
pub fn on_selected(&self, id: &ResultId) {
for plugin in &self.plugins {
plugin.on_selected(id);
}
}
pub async fn search(&self, query: &str) -> Vec<SearchResult> { pub async fn search(&self, query: &str) -> Vec<SearchResult> {
use futures::FutureExt; use futures::FutureExt;
use std::panic::AssertUnwindSafe; use std::panic::AssertUnwindSafe;
@@ -154,6 +161,9 @@ impl SearchEngine for Kernel {
async fn search(&self, query: &str) -> Vec<SearchResult> { async fn search(&self, query: &str) -> Vec<SearchResult> {
self.search(query).await self.search(query).await
} }
fn on_selected(&self, id: &ResultId) {
self.on_selected(id);
}
} }
// --- Tests --- // --- Tests ---
@@ -188,8 +198,7 @@ mod tests {
description: None, description: None,
icon: None, icon: None,
score: Score::new(*score), score: Score::new(*score),
action: LaunchAction::Custom(Arc::new(|| {})), action: LaunchAction::SpawnProcess("mock".to_string()),
on_select: None,
}) })
.collect() .collect()
} }

View File

@@ -4,6 +4,5 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
k-launcher-config = { path = "../k-launcher-config" }
k-launcher-kernel = { path = "../k-launcher-kernel" } k-launcher-kernel = { path = "../k-launcher-kernel" }
libc = "0.2" libc = "0.2"

View File

@@ -1,23 +1,2 @@
mod unix_launcher; mod unix_launcher;
pub use unix_launcher::UnixAppLauncher; pub use unix_launcher::UnixAppLauncher;
pub struct WindowConfig {
pub width: f32,
pub height: f32,
pub decorations: bool,
pub transparent: bool,
pub resizable: bool,
}
impl WindowConfig {
pub fn from_cfg(w: &k_launcher_config::WindowCfg) -> Self {
Self {
width: w.width,
height: w.height,
decorations: w.decorations,
transparent: w.transparent,
resizable: w.resizable,
}
}
}

View File

@@ -3,6 +3,29 @@ use std::process::{Command, Stdio};
use k_launcher_kernel::{AppLauncher, LaunchAction}; use k_launcher_kernel::{AppLauncher, LaunchAction};
fn shell_split(cmd: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut current = String::new();
let mut in_quotes = false;
for ch in cmd.chars() {
match ch {
'"' => in_quotes = !in_quotes,
' ' | '\t' if !in_quotes => {
if !current.is_empty() {
tokens.push(current.clone());
current.clear();
}
}
_ => current.push(ch),
}
}
if !current.is_empty() {
tokens.push(current);
}
tokens
}
fn parse_term_cmd(s: &str) -> (String, Vec<String>) { fn parse_term_cmd(s: &str) -> (String, Vec<String>) {
let mut parts = s.split_whitespace(); let mut parts = s.split_whitespace();
let bin = parts.next().unwrap_or("").to_string(); let bin = parts.next().unwrap_or("").to_string();
@@ -69,7 +92,7 @@ impl AppLauncher for UnixAppLauncher {
fn execute(&self, action: &LaunchAction) { fn execute(&self, action: &LaunchAction) {
match action { match action {
LaunchAction::SpawnProcess(cmd) => { LaunchAction::SpawnProcess(cmd) => {
let parts: Vec<&str> = cmd.split_whitespace().collect(); let parts = shell_split(cmd);
if let Some((bin, args)) = parts.split_first() { if let Some((bin, args)) = parts.split_first() {
let _ = unsafe { let _ = unsafe {
Command::new(bin) Command::new(bin)
@@ -121,7 +144,47 @@ impl AppLauncher for UnixAppLauncher {
} }
} }
} }
LaunchAction::Custom(f) => f(),
} }
} }
} }
#[cfg(test)]
mod tests {
use super::shell_split;
#[test]
fn split_simple() {
assert_eq!(shell_split("firefox"), vec!["firefox"]);
}
#[test]
fn split_with_args() {
assert_eq!(
shell_split("firefox --new-window"),
vec!["firefox", "--new-window"]
);
}
#[test]
fn split_quoted_path() {
assert_eq!(shell_split(r#""My App" --flag"#), vec!["My App", "--flag"]);
}
#[test]
fn split_quoted_with_spaces() {
assert_eq!(
shell_split(r#"env "FOO BAR" baz"#),
vec!["env", "FOO BAR", "baz"]
);
}
#[test]
fn split_empty() {
assert!(shell_split("").is_empty());
}
#[test]
fn split_extra_whitespace() {
assert_eq!(shell_split(" a b "), vec!["a", "b"]);
}
}

View File

@@ -12,5 +12,5 @@ async-trait = { workspace = true }
k-launcher-kernel = { path = "../k-launcher-kernel" } k-launcher-kernel = { path = "../k-launcher-kernel" }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
tokio = { workspace = true, features = ["process", "io-util", "sync"] } tokio = { workspace = true, features = ["process", "io-util", "sync", "time"] }
tracing = { workspace = true } tracing = { workspace = true }

View File

@@ -105,7 +105,14 @@ impl Plugin for ExternalPlugin {
} }
let result = match guard.as_mut() { let result = match guard.as_mut() {
Some(io) => do_search(io, query).await, Some(io) => {
tokio::time::timeout(std::time::Duration::from_secs(5), do_search(io, query))
.await
.unwrap_or_else(|_| {
tracing::warn!("plugin {} search timed out", self.name);
Err("timeout".into())
})
}
None => unreachable!(), None => unreachable!(),
}; };
@@ -125,7 +132,6 @@ impl Plugin for ExternalPlugin {
} }
ExternalAction::OpenPath { path } => LaunchAction::OpenPath(path), ExternalAction::OpenPath { path } => LaunchAction::OpenPath(path),
}, },
on_select: None,
}) })
.collect(), .collect(),
Err(e) => { Err(e) => {

View File

@@ -12,5 +12,4 @@ eframe = { version = "0.31", default-features = false, features = ["default_font
egui = "0.31" egui = "0.31"
k-launcher-config = { path = "../k-launcher-config" } k-launcher-config = { path = "../k-launcher-config" }
k-launcher-kernel = { path = "../k-launcher-kernel" } k-launcher-kernel = { path = "../k-launcher-kernel" }
k-launcher-os-bridge = { path = "../k-launcher-os-bridge" }
tokio = { workspace = true } tokio = { workspace = true }

View File

@@ -2,7 +2,6 @@ use std::sync::{Arc, mpsc};
use egui::{Color32, Key, ViewportCommand}; use egui::{Color32, Key, ViewportCommand};
use k_launcher_kernel::{AppLauncher, SearchEngine, SearchResult}; use k_launcher_kernel::{AppLauncher, SearchEngine, SearchResult};
use k_launcher_os_bridge::WindowConfig;
const BG: Color32 = Color32::from_rgba_premultiplied(20, 20, 30, 230); const BG: Color32 = Color32::from_rgba_premultiplied(20, 20, 30, 230);
const BORDER_COLOR: Color32 = Color32::from_rgb(229, 125, 33); const BORDER_COLOR: Color32 = Color32::from_rgb(229, 125, 33);
@@ -83,9 +82,7 @@ impl eframe::App for KLauncherApp {
if launch_selected { if launch_selected {
if let Some(result) = self.results.get(self.selected) { if let Some(result) = self.results.get(self.selected) {
if let Some(on_select) = &result.on_select { self.engine.on_selected(&result.id);
on_select();
}
self.launcher.execute(&result.action); self.launcher.execute(&result.action);
} }
ctx.send_viewport_cmd(ViewportCommand::Close); ctx.send_viewport_cmd(ViewportCommand::Close);
@@ -166,17 +163,17 @@ impl eframe::App for KLauncherApp {
pub fn run( pub fn run(
engine: Arc<dyn SearchEngine>, engine: Arc<dyn SearchEngine>,
launcher: Arc<dyn AppLauncher>, launcher: Arc<dyn AppLauncher>,
window_cfg: &k_launcher_config::WindowCfg,
) -> Result<(), eframe::Error> { ) -> Result<(), eframe::Error> {
let wc = WindowConfig::from_cfg(&k_launcher_config::WindowCfg::default());
let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
let handle = rt.handle().clone(); let handle = rt.handle().clone();
let options = eframe::NativeOptions { let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default() viewport: egui::ViewportBuilder::default()
.with_inner_size([wc.width, wc.height]) .with_inner_size([window_cfg.width, window_cfg.height])
.with_decorations(wc.decorations) .with_decorations(window_cfg.decorations)
.with_transparent(wc.transparent) .with_transparent(window_cfg.transparent)
.with_resizable(wc.resizable) .with_resizable(window_cfg.resizable)
.with_always_on_top(), .with_always_on_top(),
..Default::default() ..Default::default()
}; };

View File

@@ -7,6 +7,7 @@ use k_launcher_kernel::{AppLauncher, SearchEngine};
pub fn run( pub fn run(
engine: Arc<dyn SearchEngine>, engine: Arc<dyn SearchEngine>,
launcher: Arc<dyn AppLauncher>, launcher: Arc<dyn AppLauncher>,
window_cfg: &k_launcher_config::WindowCfg,
) -> Result<(), eframe::Error> { ) -> Result<(), eframe::Error> {
app::run(engine, launcher) app::run(engine, launcher, window_cfg)
} }

View File

@@ -9,7 +9,6 @@ use iced::{
use k_launcher_config::AppearanceCfg; use k_launcher_config::AppearanceCfg;
use k_launcher_kernel::{AppLauncher, NullSearchEngine, SearchEngine, SearchResult}; use k_launcher_kernel::{AppLauncher, NullSearchEngine, SearchEngine, SearchResult};
use k_launcher_os_bridge::WindowConfig;
static INPUT_ID: std::sync::LazyLock<iced::widget::Id> = static INPUT_ID: std::sync::LazyLock<iced::widget::Id> =
std::sync::LazyLock::new(|| iced::widget::Id::new("search")); std::sync::LazyLock::new(|| iced::widget::Id::new("search"));
@@ -63,6 +62,7 @@ pub enum Message {
ResultsReady(u64, Arc<Vec<SearchResult>>), ResultsReady(u64, Arc<Vec<SearchResult>>),
KeyPressed(KeyEvent), KeyPressed(KeyEvent),
EngineReady(EngineHandle), EngineReady(EngineHandle),
EngineInitFailed(String),
} }
fn update(state: &mut KLauncherApp, message: Message) -> Task<Message> { fn update(state: &mut KLauncherApp, message: Message) -> Task<Message> {
@@ -88,6 +88,10 @@ fn update(state: &mut KLauncherApp, message: Message) -> Task<Message> {
} }
Task::none() Task::none()
} }
Message::EngineInitFailed(msg) => {
state.error = Some(msg);
Task::none()
}
Message::EngineReady(handle) => { Message::EngineReady(handle) => {
state.engine = handle.0; state.engine = handle.0;
if !state.query.is_empty() { if !state.query.is_empty() {
@@ -121,9 +125,7 @@ fn update(state: &mut KLauncherApp, message: Message) -> Task<Message> {
} }
Named::Enter => { Named::Enter => {
if let Some(result) = state.results.get(state.selected) { if let Some(result) = state.results.get(state.selected) {
if let Some(on_select) = &result.on_select { state.engine.on_selected(&result.id);
on_select();
}
state.launcher.execute(&result.action); state.launcher.execute(&result.action);
} }
std::process::exit(0); std::process::exit(0);
@@ -277,7 +279,6 @@ pub fn run(
window_cfg: &k_launcher_config::WindowCfg, window_cfg: &k_launcher_config::WindowCfg,
appearance_cfg: AppearanceCfg, appearance_cfg: AppearanceCfg,
) -> iced::Result { ) -> iced::Result {
let wc = WindowConfig::from_cfg(window_cfg);
iced::application( iced::application(
move || { move || {
let app = KLauncherApp::new( let app = KLauncherApp::new(
@@ -288,8 +289,15 @@ pub fn run(
let focus = iced::widget::operation::focus(INPUT_ID.clone()); let focus = iced::widget::operation::focus(INPUT_ID.clone());
let ef = engine_factory.clone(); let ef = engine_factory.clone();
let init = Task::perform( let init = Task::perform(
async move { tokio::task::spawn_blocking(move || ef()).await.unwrap() }, async move {
|e| Message::EngineReady(EngineHandle(e)), tokio::task::spawn_blocking(move || ef())
.await
.map_err(|e| format!("Engine init failed: {e}"))
},
|result| match result {
Ok(e) => Message::EngineReady(EngineHandle(e)),
Err(msg) => Message::EngineInitFailed(msg),
},
); );
(app, Task::batch([focus, init])) (app, Task::batch([focus, init]))
}, },
@@ -299,11 +307,11 @@ pub fn run(
.title("K-Launcher") .title("K-Launcher")
.subscription(subscription) .subscription(subscription)
.window(window::Settings { .window(window::Settings {
size: Size::new(wc.width, wc.height), size: Size::new(window_cfg.width, window_cfg.height),
position: window::Position::Centered, position: window::Position::Centered,
decorations: wc.decorations, decorations: window_cfg.decorations,
transparent: wc.transparent, transparent: window_cfg.transparent,
resizable: wc.resizable, resizable: window_cfg.resizable,
..Default::default() ..Default::default()
}) })
.run() .run()

View File

@@ -10,6 +10,7 @@ use plugin_cmd::CmdPlugin;
use plugin_files::FilesPlugin; use plugin_files::FilesPlugin;
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
let cfg = k_launcher_config::load();
let launcher = Arc::new(UnixAppLauncher::new()); let launcher = Arc::new(UnixAppLauncher::new());
let frecency = FrecencyStore::load(); let frecency = FrecencyStore::load();
let kernel: Arc<dyn k_launcher_kernel::SearchEngine> = Arc::new(Kernel::new( let kernel: Arc<dyn k_launcher_kernel::SearchEngine> = Arc::new(Kernel::new(
@@ -21,6 +22,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
], ],
8, 8,
)); ));
k_launcher_ui_egui::run(kernel, launcher)?; k_launcher_ui_egui::run(kernel, launcher, &cfg.window)?;
Ok(()) Ok(())
} }

View File

@@ -55,6 +55,7 @@ impl FrecencyStore {
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.unwrap_or_default() .unwrap_or_default()
.as_secs(); .as_secs();
let json = {
let mut data = self.data.lock().unwrap(); let mut data = self.data.lock().unwrap();
let entry = data.entry(id.to_string()).or_insert(Entry { let entry = data.entry(id.to_string()).or_insert(Entry {
count: 0, count: 0,
@@ -62,10 +63,12 @@ impl FrecencyStore {
}); });
entry.count += 1; entry.count += 1;
entry.last_used = now; 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() { if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent); let _ = std::fs::create_dir_all(parent);
} }
if let Ok(json) = serde_json::to_string(&*data) {
let _ = std::fs::write(&self.path, json); let _ = std::fs::write(&self.path, json);
} }
} }
@@ -78,14 +81,7 @@ impl FrecencyStore {
.unwrap_or_default() .unwrap_or_default()
.as_secs(); .as_secs();
let age_secs = now.saturating_sub(entry.last_used); let age_secs = now.saturating_sub(entry.last_used);
let decay = if age_secs < 3600 { entry.count * decay_factor(age_secs)
4
} else if age_secs < 86400 {
2
} else {
1
};
entry.count * decay
} }
pub fn top_ids(&self, n: usize) -> Vec<String> { pub fn top_ids(&self, n: usize) -> Vec<String> {
@@ -98,18 +94,21 @@ impl FrecencyStore {
.iter() .iter()
.map(|(id, entry)| { .map(|(id, entry)| {
let age_secs = now.saturating_sub(entry.last_used); let age_secs = now.saturating_sub(entry.last_used);
let decay = if age_secs < 3600 { (id.clone(), 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()
}
}
fn decay_factor(age_secs: u64) -> u32 {
if age_secs < 3600 {
4 4
} else if age_secs < 86400 { } else if age_secs < 86400 {
2 2
} else { } else {
1 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()
} }
} }

View File

@@ -76,7 +76,6 @@ struct CachedEntry {
category: Option<String>, category: Option<String>,
icon: Option<String>, icon: Option<String>,
exec: String, exec: String,
on_select: Arc<dyn Fn() + Send + Sync>,
} }
// --- Serializable cache data (no closures) --- // --- Serializable cache data (no closures) ---
@@ -92,24 +91,16 @@ struct CachedEntryData {
} }
fn cache_path() -> Option<PathBuf> { fn cache_path() -> Option<PathBuf> {
#[cfg(test)]
return None;
#[cfg(not(test))]
dirs::cache_dir().map(|d| d.join("k-launcher/apps.bin")) dirs::cache_dir().map(|d| d.join("k-launcher/apps.bin"))
} }
fn load_from_path(path: &Path, frecency: &Arc<FrecencyStore>) -> Option<HashMap<String, CachedEntry>> { fn load_from_path(path: &Path) -> Option<HashMap<String, CachedEntry>> {
let data = std::fs::read(path).ok()?; let data = std::fs::read(path).ok()?;
let (entries_data, _): (Vec<CachedEntryData>, _) = let (entries_data, _): (Vec<CachedEntryData>, _) =
bincode::serde::decode_from_slice(&data, bincode::config::standard()).ok()?; bincode::serde::decode_from_slice(&data, bincode::config::standard()).ok()?;
let map = entries_data let map = entries_data
.into_iter() .into_iter()
.map(|e| { .map(|e| {
let store = Arc::clone(frecency);
let record_id = e.id.clone();
let on_select: Arc<dyn Fn() + Send + Sync> = Arc::new(move || {
store.record(&record_id);
});
let cached = CachedEntry { let cached = CachedEntry {
id: e.id.clone(), id: e.id.clone(),
name: AppName::new(e.name), name: AppName::new(e.name),
@@ -117,7 +108,6 @@ fn load_from_path(path: &Path, frecency: &Arc<FrecencyStore>) -> Option<HashMap<
category: e.category, category: e.category,
icon: e.icon, icon: e.icon,
exec: e.exec, exec: e.exec,
on_select,
}; };
(e.id, cached) (e.id, cached)
}) })
@@ -145,12 +135,15 @@ fn save_to_path(path: &Path, entries: &HashMap<String, CachedEntry>) {
} }
} }
fn build_entries(source: &impl DesktopEntrySource, frecency: &Arc<FrecencyStore>) -> HashMap<String, CachedEntry> { fn build_entries(
source: &impl DesktopEntrySource,
_frecency: &Arc<FrecencyStore>,
) -> HashMap<String, CachedEntry> {
source source
.entries() .entries()
.into_iter() .into_iter()
.map(|e| { .map(|e| {
let id = format!("app-{}", e.name.as_str()); let id = format!("app-{}:{}", e.name.as_str(), e.exec.as_str());
let keywords_lc = e.keywords.iter().map(|k| k.to_lowercase()).collect(); let keywords_lc = e.keywords.iter().map(|k| k.to_lowercase()).collect();
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
let icon = e let icon = e
@@ -160,18 +153,12 @@ fn build_entries(source: &impl DesktopEntrySource, frecency: &Arc<FrecencyStore>
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
let icon: Option<String> = None; let icon: Option<String> = None;
let exec = e.exec.as_str().to_string(); let exec = e.exec.as_str().to_string();
let store = Arc::clone(frecency);
let record_id = id.clone();
let on_select: Arc<dyn Fn() + Send + Sync> = Arc::new(move || {
store.record(&record_id);
});
let cached = CachedEntry { let cached = CachedEntry {
id: id.clone(), id: id.clone(),
keywords_lc, keywords_lc,
category: e.category, category: e.category,
icon, icon,
exec, exec,
on_select,
name: e.name, name: e.name,
}; };
(id, cached) (id, cached)
@@ -188,16 +175,25 @@ pub struct AppsPlugin {
impl AppsPlugin { impl AppsPlugin {
pub fn new(source: impl DesktopEntrySource + 'static, frecency: Arc<FrecencyStore>) -> Self { pub fn new(source: impl DesktopEntrySource + 'static, frecency: Arc<FrecencyStore>) -> Self {
let cached = cache_path().and_then(|p| load_from_path(&p, &frecency)); 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 { let entries = if let Some(from_cache) = cached {
// Serve cache immediately; refresh in background. // Serve cache immediately; refresh in background.
let map = Arc::new(RwLock::new(from_cache)); let map = Arc::new(RwLock::new(from_cache));
let entries_bg = Arc::clone(&map); let entries_bg = Arc::clone(&map);
let frecency_bg = Arc::clone(&frecency); let frecency_bg = Arc::clone(&frecency);
let cp_bg = cp.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
let fresh = build_entries(&source, &frecency_bg); let fresh = build_entries(&source, &frecency_bg);
if let Some(path) = cache_path() { if let Some(path) = cp_bg {
save_to_path(&path, &fresh); save_to_path(&path, &fresh);
} }
*entries_bg.write().unwrap() = fresh; *entries_bg.write().unwrap() = fresh;
@@ -206,14 +202,22 @@ impl AppsPlugin {
} else { } else {
// No cache: build synchronously, then persist. // No cache: build synchronously, then persist.
let initial = build_entries(&source, &frecency); let initial = build_entries(&source, &frecency);
if let Some(path) = cache_path() { if let Some(path) = &cp {
save_to_path(&path, &initial); save_to_path(path, &initial);
} }
Arc::new(RwLock::new(initial)) Arc::new(RwLock::new(initial))
}; };
Self { entries, frecency } 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 { fn initials(name_lc: &str) -> String {
@@ -267,6 +271,10 @@ impl Plugin for AppsPlugin {
"apps" "apps"
} }
fn on_selected(&self, id: &ResultId) {
self.frecency.record(id.as_str());
}
async fn search(&self, query: &str) -> Vec<SearchResult> { async fn search(&self, query: &str) -> Vec<SearchResult> {
let entries = self.entries.read().unwrap(); let entries = self.entries.read().unwrap();
if query.is_empty() { if query.is_empty() {
@@ -284,7 +292,6 @@ impl Plugin for AppsPlugin {
icon: e.icon.clone(), icon: e.icon.clone(),
score: Score::new(score), score: Score::new(score),
action: LaunchAction::SpawnProcess(e.exec.clone()), action: LaunchAction::SpawnProcess(e.exec.clone()),
on_select: Some(Arc::clone(&e.on_select)),
}) })
}) })
.collect(); .collect();
@@ -307,7 +314,6 @@ impl Plugin for AppsPlugin {
icon: e.icon.clone(), icon: e.icon.clone(),
score: Score::new(score), score: Score::new(score),
action: LaunchAction::SpawnProcess(e.exec.clone()), action: LaunchAction::SpawnProcess(e.exec.clone()),
on_select: Some(Arc::clone(&e.on_select)),
}) })
}) })
.collect() .collect()
@@ -381,7 +387,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn apps_prefix_match() { async fn apps_prefix_match() {
let p = AppsPlugin::new( let p = AppsPlugin::new_for_test(
MockSource::with(vec![("Firefox", "firefox")]), MockSource::with(vec![("Firefox", "firefox")]),
ephemeral_frecency(), ephemeral_frecency(),
); );
@@ -391,7 +397,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn apps_no_match_returns_empty() { async fn apps_no_match_returns_empty() {
let p = AppsPlugin::new( let p = AppsPlugin::new_for_test(
MockSource::with(vec![("Firefox", "firefox")]), MockSource::with(vec![("Firefox", "firefox")]),
ephemeral_frecency(), ephemeral_frecency(),
); );
@@ -400,7 +406,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn apps_empty_query_no_frecency_returns_empty() { async fn apps_empty_query_no_frecency_returns_empty() {
let p = AppsPlugin::new( let p = AppsPlugin::new_for_test(
MockSource::with(vec![("Firefox", "firefox")]), MockSource::with(vec![("Firefox", "firefox")]),
ephemeral_frecency(), ephemeral_frecency(),
); );
@@ -414,7 +420,7 @@ mod tests {
} }
#[test] #[test]
fn score_match_exact_beats_prefix_beats_abbrev_beats_substr() { fn score_match_exact_beats_prefix() {
let exact = score_match("firefox", "firefox"); let exact = score_match("firefox", "firefox");
let prefix = score_match("firefox", "fire"); let prefix = score_match("firefox", "fire");
let abbrev = score_match("gnu firefox", "gf"); let abbrev = score_match("gnu firefox", "gf");
@@ -428,7 +434,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn apps_abbreviation_match() { async fn apps_abbreviation_match() {
let p = AppsPlugin::new( let p = AppsPlugin::new_for_test(
MockSource::with(vec![("Visual Studio Code", "code")]), MockSource::with(vec![("Visual Studio Code", "code")]),
ephemeral_frecency(), ephemeral_frecency(),
); );
@@ -440,7 +446,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn apps_keyword_match() { async fn apps_keyword_match() {
let p = AppsPlugin::new( let p = AppsPlugin::new_for_test(
MockSource::with_keywords(vec![("Code", "code", vec!["editor", "ide"])]), MockSource::with_keywords(vec![("Code", "code", vec!["editor", "ide"])]),
ephemeral_frecency(), ephemeral_frecency(),
); );
@@ -451,7 +457,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn apps_fuzzy_typo_match() { async fn apps_fuzzy_typo_match() {
let p = AppsPlugin::new( let p = AppsPlugin::new_for_test(
MockSource::with(vec![("Firefox", "firefox")]), MockSource::with(vec![("Firefox", "firefox")]),
ephemeral_frecency(), ephemeral_frecency(),
); );
@@ -472,7 +478,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn apps_category_appears_in_description() { async fn apps_category_appears_in_description() {
let p = AppsPlugin::new( let p = AppsPlugin::new_for_test(
MockSource::with_categories(vec![("Code", "code", "Text Editor")]), MockSource::with_categories(vec![("Code", "code", "Text Editor")]),
ephemeral_frecency(), ephemeral_frecency(),
); );
@@ -483,10 +489,10 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn apps_empty_query_returns_top_frecent() { async fn apps_empty_query_returns_top_frecent() {
let frecency = ephemeral_frecency(); let frecency = ephemeral_frecency();
frecency.record("app-Code"); frecency.record("app-Code:code");
frecency.record("app-Code"); frecency.record("app-Code:code");
frecency.record("app-Firefox"); frecency.record("app-Firefox:firefox");
let p = AppsPlugin::new( let p = AppsPlugin::new_for_test(
MockSource::with(vec![("Firefox", "firefox"), ("Code", "code")]), MockSource::with(vec![("Firefox", "firefox"), ("Code", "code")]),
frecency, frecency,
); );
@@ -498,8 +504,8 @@ mod tests {
#[test] #[test]
fn apps_loads_from_cache_when_source_is_empty() { fn apps_loads_from_cache_when_source_is_empty() {
let frecency = ephemeral_frecency(); let frecency = ephemeral_frecency();
let cache_file = std::env::temp_dir() let cache_file =
.join(format!("k-launcher-test-{}.bin", std::process::id())); std::env::temp_dir().join(format!("k-launcher-test-{}.bin", std::process::id()));
// Build entries from a real source and save to temp path // Build entries from a real source and save to temp path
let source = MockSource::with(vec![("Firefox", "firefox")]); let source = MockSource::with(vec![("Firefox", "firefox")]);
@@ -507,8 +513,8 @@ mod tests {
save_to_path(&cache_file, &entries); save_to_path(&cache_file, &entries);
// Load from temp path — should contain Firefox // Load from temp path — should contain Firefox
let loaded = load_from_path(&cache_file, &frecency).unwrap(); let loaded = load_from_path(&cache_file).unwrap();
assert!(loaded.contains_key("app-Firefox")); assert!(loaded.contains_key("app-Firefox:firefox"));
std::fs::remove_file(&cache_file).ok(); std::fs::remove_file(&cache_file).ok();
} }

View File

@@ -90,7 +90,6 @@ impl Plugin for CalcPlugin {
icon: None, icon: None,
score: Score::new(90), score: Score::new(90),
action: LaunchAction::CopyToClipboard(value_str), action: LaunchAction::CopyToClipboard(value_str),
on_select: None,
}] }]
} }
_ => vec![], _ => vec![],

View File

@@ -36,7 +36,6 @@ impl Plugin for CmdPlugin {
icon: None, icon: None,
score: Score::new(95), score: Score::new(95),
action: LaunchAction::SpawnInTerminal(cmd.to_string()), action: LaunchAction::SpawnInTerminal(cmd.to_string()),
on_select: None,
}] }]
} }
} }

View File

@@ -71,21 +71,19 @@ impl Plugin for FilesPlugin {
.unwrap_or(false) .unwrap_or(false)
}) })
.take(20) .take(20)
.enumerate() .map(|entry| {
.map(|(i, entry)| {
let full_path = entry.path(); let full_path = entry.path();
let name = entry.file_name().to_string_lossy().to_string(); let name = entry.file_name().to_string_lossy().to_string();
let is_dir = full_path.is_dir(); let is_dir = full_path.is_dir();
let title = if is_dir { format!("{name}/") } else { name }; let title = if is_dir { format!("{name}/") } else { name };
let path_str = full_path.to_string_lossy().to_string(); let path_str = full_path.to_string_lossy().to_string();
SearchResult { SearchResult {
id: ResultId::new(format!("file-{i}")), id: ResultId::new(&path_str),
title: ResultTitle::new(title), title: ResultTitle::new(title),
description: Some(path_str.clone()), description: Some(path_str.clone()),
icon: None, icon: None,
score: Score::new(50), score: Score::new(50),
action: LaunchAction::OpenPath(path_str), action: LaunchAction::OpenPath(path_str),
on_select: None,
} }
}) })
.collect() .collect()

View File

@@ -10,7 +10,7 @@ struct Query {
#[derive(Serialize)] #[derive(Serialize)]
struct Action { struct Action {
r#type: &'static str, r#type: &'static str,
cmd: String, path: String,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -45,8 +45,8 @@ fn search(query: &str) -> Vec<Result> {
description: url.clone(), description: url.clone(),
score: 95, score: 95,
action: Action { action: Action {
r#type: "SpawnProcess", r#type: "OpenPath",
cmd: format!("xdg-open {url}"), path: url.clone(),
}, },
}] }]
} }
@@ -112,7 +112,7 @@ mod tests {
fn search_returns_result() { fn search_returns_result() {
let results = search("https://example.com"); let results = search("https://example.com");
assert_eq!(results.len(), 1); assert_eq!(results.len(), 1);
assert_eq!(results[0].action.cmd, "xdg-open https://example.com"); assert_eq!(results[0].action.path, "https://example.com");
} }
#[test] #[test]
@@ -124,7 +124,7 @@ mod tests {
fn result_serializes() { fn result_serializes() {
let results = search("https://example.com"); let results = search("https://example.com");
let json = serde_json::to_string(&results).unwrap(); let json = serde_json::to_string(&results).unwrap();
assert!(json.contains("SpawnProcess")); assert!(json.contains("OpenPath"));
assert!(json.contains("xdg-open")); assert!(json.contains("https://example.com"));
} }
} }