Compare commits
3 Commits
58d0739cea
...
38860762c0
| Author | SHA1 | Date | |
|---|---|---|---|
| 38860762c0 | |||
| 248094f442 | |||
| bd356f27d1 |
1049
Cargo.lock
generated
1049
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
16
Cargo.toml
16
Cargo.toml
@@ -13,13 +13,27 @@ members = [
|
|||||||
"crates/k-launcher-ui-egui",
|
"crates/k-launcher-ui-egui",
|
||||||
"crates/plugins/plugin-url",
|
"crates/plugins/plugin-url",
|
||||||
]
|
]
|
||||||
|
default-members = [
|
||||||
|
"crates/k-launcher",
|
||||||
|
"crates/k-launcher-config",
|
||||||
|
"crates/k-launcher-kernel",
|
||||||
|
"crates/k-launcher-os-bridge",
|
||||||
|
"crates/k-launcher-plugin-host",
|
||||||
|
"crates/k-launcher-ui",
|
||||||
|
"crates/plugins/plugin-apps",
|
||||||
|
"crates/plugins/plugin-calc",
|
||||||
|
"crates/plugins/plugin-cmd",
|
||||||
|
"crates/plugins/plugin-files",
|
||||||
|
"crates/plugins/plugin-url",
|
||||||
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
bincode = { version = "2", features = ["serde"] }
|
||||||
dirs = "6.0"
|
dirs = "6.0"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
iced = { version = "0.14", features = ["image", "svg", "tokio", "tiny-skia"] }
|
iced = { version = "0.14", default-features = false, features = ["image", "svg", "tokio", "tiny-skia", "wayland", "x11", "crisp", "web-colors", "thread-pool"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tokio = { version = "1.35", features = ["rt-multi-thread", "macros"] }
|
tokio = { version = "1.35", features = ["rt-multi-thread", "macros"] }
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ async-trait = { workspace = true }
|
|||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
|||||||
@@ -97,6 +97,17 @@ pub trait SearchEngine: Send + Sync {
|
|||||||
async fn search(&self, query: &str) -> Vec<SearchResult>;
|
async fn search(&self, query: &str) -> Vec<SearchResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- NullSearchEngine ---
|
||||||
|
|
||||||
|
pub struct NullSearchEngine;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl SearchEngine for NullSearchEngine {
|
||||||
|
async fn search(&self, _query: &str) -> Vec<SearchResult> {
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Kernel (Application use case) ---
|
// --- Kernel (Application use case) ---
|
||||||
|
|
||||||
pub struct Kernel {
|
pub struct Kernel {
|
||||||
@@ -113,9 +124,25 @@ impl Kernel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn search(&self, query: &str) -> Vec<SearchResult> {
|
pub async fn search(&self, query: &str) -> Vec<SearchResult> {
|
||||||
let futures = self.plugins.iter().map(|p| p.search(query));
|
use futures::FutureExt;
|
||||||
let nested: Vec<Vec<SearchResult>> = join_all(futures).await;
|
use std::panic::AssertUnwindSafe;
|
||||||
let mut flat: Vec<SearchResult> = nested.into_iter().flatten().collect();
|
|
||||||
|
let futures = self
|
||||||
|
.plugins
|
||||||
|
.iter()
|
||||||
|
.map(|p| AssertUnwindSafe(p.search(query)).catch_unwind());
|
||||||
|
let outcomes = join_all(futures).await;
|
||||||
|
let mut flat: Vec<SearchResult> = outcomes
|
||||||
|
.into_iter()
|
||||||
|
.zip(self.plugins.iter())
|
||||||
|
.flat_map(|(outcome, plugin)| match outcome {
|
||||||
|
Ok(results) => results,
|
||||||
|
Err(_) => {
|
||||||
|
tracing::error!(plugin = plugin.name(), "plugin panicked during search");
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
flat.sort_by(|a, b| b.score.cmp(&a.score));
|
flat.sort_by(|a, b| b.score.cmp(&a.score));
|
||||||
flat.truncate(self.max_results);
|
flat.truncate(self.max_results);
|
||||||
flat
|
flat
|
||||||
@@ -203,6 +230,29 @@ mod tests {
|
|||||||
assert_eq!(results[2].score.value(), 5);
|
assert_eq!(results[2].score.value(), 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct PanicPlugin;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Plugin for PanicPlugin {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"panic-plugin"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn search(&self, _query: &str) -> Vec<SearchResult> {
|
||||||
|
panic!("test panic");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn kernel_continues_after_plugin_panic() {
|
||||||
|
let panic_plugin = Arc::new(PanicPlugin);
|
||||||
|
let normal_plugin = Arc::new(MockPlugin::returns(vec![("survivor", 5)]));
|
||||||
|
let k = Kernel::new(vec![panic_plugin, normal_plugin], 8);
|
||||||
|
let results = k.search("q").await;
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
assert_eq!(results[0].title.as_str(), "survivor");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn kernel_truncates_at_max_results() {
|
async fn kernel_truncates_at_max_results() {
|
||||||
let plugin = Arc::new(MockPlugin::returns(vec![
|
let plugin = Arc::new(MockPlugin::returns(vec![
|
||||||
|
|||||||
@@ -8,12 +8,21 @@ use iced::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use k_launcher_config::AppearanceCfg;
|
use k_launcher_config::AppearanceCfg;
|
||||||
use k_launcher_kernel::{AppLauncher, SearchEngine, SearchResult};
|
use k_launcher_kernel::{AppLauncher, NullSearchEngine, SearchEngine, SearchResult};
|
||||||
use k_launcher_os_bridge::WindowConfig;
|
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"));
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct EngineHandle(Arc<dyn SearchEngine>);
|
||||||
|
|
||||||
|
impl std::fmt::Debug for EngineHandle {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str("EngineHandle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn rgba(c: &[f32; 4]) -> Color {
|
fn rgba(c: &[f32; 4]) -> Color {
|
||||||
Color::from_rgba8(c[0] as u8, c[1] as u8, c[2] as u8, c[3])
|
Color::from_rgba8(c[0] as u8, c[1] as u8, c[2] as u8, c[3])
|
||||||
}
|
}
|
||||||
@@ -53,6 +62,7 @@ pub enum Message {
|
|||||||
QueryChanged(String),
|
QueryChanged(String),
|
||||||
ResultsReady(u64, Arc<Vec<SearchResult>>),
|
ResultsReady(u64, Arc<Vec<SearchResult>>),
|
||||||
KeyPressed(KeyEvent),
|
KeyPressed(KeyEvent),
|
||||||
|
EngineReady(EngineHandle),
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(state: &mut KLauncherApp, message: Message) -> Task<Message> {
|
fn update(state: &mut KLauncherApp, message: Message) -> Task<Message> {
|
||||||
@@ -78,6 +88,14 @@ fn update(state: &mut KLauncherApp, message: Message) -> Task<Message> {
|
|||||||
}
|
}
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
|
Message::EngineReady(handle) => {
|
||||||
|
state.engine = handle.0;
|
||||||
|
if !state.query.is_empty() {
|
||||||
|
let q = state.query.clone();
|
||||||
|
return Task::done(Message::QueryChanged(q));
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
Message::KeyPressed(event) => {
|
Message::KeyPressed(event) => {
|
||||||
let key = match event {
|
let key = match event {
|
||||||
KeyEvent::KeyPressed { key, .. } => key,
|
KeyEvent::KeyPressed { key, .. } => key,
|
||||||
@@ -254,7 +272,7 @@ fn subscription(_state: &KLauncherApp) -> Subscription<Message> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(
|
pub fn run(
|
||||||
engine: Arc<dyn SearchEngine>,
|
engine_factory: Arc<dyn Fn() -> Arc<dyn SearchEngine> + Send + Sync>,
|
||||||
launcher: Arc<dyn AppLauncher>,
|
launcher: Arc<dyn AppLauncher>,
|
||||||
window_cfg: &k_launcher_config::WindowCfg,
|
window_cfg: &k_launcher_config::WindowCfg,
|
||||||
appearance_cfg: AppearanceCfg,
|
appearance_cfg: AppearanceCfg,
|
||||||
@@ -262,9 +280,18 @@ pub fn run(
|
|||||||
let wc = WindowConfig::from_cfg(window_cfg);
|
let wc = WindowConfig::from_cfg(window_cfg);
|
||||||
iced::application(
|
iced::application(
|
||||||
move || {
|
move || {
|
||||||
let app = KLauncherApp::new(engine.clone(), launcher.clone(), appearance_cfg.clone());
|
let app = KLauncherApp::new(
|
||||||
|
Arc::new(NullSearchEngine),
|
||||||
|
launcher.clone(),
|
||||||
|
appearance_cfg.clone(),
|
||||||
|
);
|
||||||
let focus = iced::widget::operation::focus(INPUT_ID.clone());
|
let focus = iced::widget::operation::focus(INPUT_ID.clone());
|
||||||
(app, focus)
|
let ef = engine_factory.clone();
|
||||||
|
let init = Task::perform(
|
||||||
|
async move { tokio::task::spawn_blocking(move || ef()).await.unwrap() },
|
||||||
|
|e| Message::EngineReady(EngineHandle(e)),
|
||||||
|
);
|
||||||
|
(app, Task::batch([focus, init]))
|
||||||
},
|
},
|
||||||
update,
|
update,
|
||||||
view,
|
view,
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ use k_launcher_config::{AppearanceCfg, WindowCfg};
|
|||||||
use k_launcher_kernel::{AppLauncher, SearchEngine};
|
use k_launcher_kernel::{AppLauncher, SearchEngine};
|
||||||
|
|
||||||
pub fn run(
|
pub fn run(
|
||||||
engine: Arc<dyn SearchEngine>,
|
engine_factory: Arc<dyn Fn() -> Arc<dyn SearchEngine> + Send + Sync>,
|
||||||
launcher: Arc<dyn AppLauncher>,
|
launcher: Arc<dyn AppLauncher>,
|
||||||
window_cfg: &WindowCfg,
|
window_cfg: &WindowCfg,
|
||||||
appearance_cfg: AppearanceCfg,
|
appearance_cfg: AppearanceCfg,
|
||||||
) -> iced::Result {
|
) -> iced::Result {
|
||||||
app::run(engine, launcher, window_cfg, appearance_cfg)
|
app::run(engine_factory, launcher, window_cfg, appearance_cfg)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,5 +34,8 @@ plugin-apps = { path = "../plugins/plugin-apps" }
|
|||||||
plugin-calc = { path = "../plugins/plugin-calc" }
|
plugin-calc = { path = "../plugins/plugin-calc" }
|
||||||
plugin-cmd = { path = "../plugins/plugin-cmd" }
|
plugin-cmd = { path = "../plugins/plugin-cmd" }
|
||||||
plugin-files = { path = "../plugins/plugin-files" }
|
plugin-files = { path = "../plugins/plugin-files" }
|
||||||
|
dirs = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
tracing-appender = "0.2"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|||||||
@@ -10,8 +10,30 @@ use plugin_calc::CalcPlugin;
|
|||||||
use plugin_cmd::CmdPlugin;
|
use plugin_cmd::CmdPlugin;
|
||||||
use plugin_files::FilesPlugin;
|
use plugin_files::FilesPlugin;
|
||||||
|
|
||||||
|
fn init_logging() -> tracing_appender::non_blocking::WorkerGuard {
|
||||||
|
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
|
let log_dir = dirs::data_local_dir()
|
||||||
|
.map(|d| d.join("k-launcher/logs"))
|
||||||
|
.unwrap_or_else(|| std::path::PathBuf::from("/tmp/k-launcher/logs"));
|
||||||
|
std::fs::create_dir_all(&log_dir).ok();
|
||||||
|
|
||||||
|
let file_appender = tracing_appender::rolling::daily(&log_dir, "k-launcher.log");
|
||||||
|
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
|
||||||
|
|
||||||
|
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||||
|
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(env_filter)
|
||||||
|
.with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
|
||||||
|
.with(tracing_subscriber::fmt::layer().with_writer(non_blocking))
|
||||||
|
.init();
|
||||||
|
|
||||||
|
guard
|
||||||
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
tracing_subscriber::fmt::init();
|
let _guard = init_logging();
|
||||||
|
|
||||||
if let Err(e) = run_ui() {
|
if let Err(e) = run_ui() {
|
||||||
eprintln!("error: UI: {e}");
|
eprintln!("error: UI: {e}");
|
||||||
@@ -19,11 +41,8 @@ fn main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_ui() -> iced::Result {
|
fn build_engine(cfg: Arc<k_launcher_config::Config>) -> Arc<dyn k_launcher_kernel::SearchEngine> {
|
||||||
let cfg = k_launcher_config::load();
|
|
||||||
let launcher = Arc::new(UnixAppLauncher::new());
|
|
||||||
let frecency = FrecencyStore::load();
|
let frecency = FrecencyStore::load();
|
||||||
|
|
||||||
let mut plugins: Vec<Arc<dyn k_launcher_kernel::Plugin>> = vec![];
|
let mut plugins: Vec<Arc<dyn k_launcher_kernel::Plugin>> = vec![];
|
||||||
if cfg.plugins.cmd {
|
if cfg.plugins.cmd {
|
||||||
plugins.push(Arc::new(CmdPlugin::new()));
|
plugins.push(Arc::new(CmdPlugin::new()));
|
||||||
@@ -47,9 +66,14 @@ fn run_ui() -> iced::Result {
|
|||||||
ext.args.clone(),
|
ext.args.clone(),
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
Arc::new(Kernel::new(plugins, cfg.search.max_results))
|
||||||
let kernel: Arc<dyn k_launcher_kernel::SearchEngine> =
|
}
|
||||||
Arc::new(Kernel::new(plugins, cfg.search.max_results));
|
|
||||||
|
fn run_ui() -> iced::Result {
|
||||||
k_launcher_ui::run(kernel, launcher, &cfg.window, cfg.appearance)
|
let cfg = Arc::new(k_launcher_config::load());
|
||||||
|
let launcher = Arc::new(UnixAppLauncher::new());
|
||||||
|
let factory_cfg = cfg.clone();
|
||||||
|
let factory: Arc<dyn Fn() -> Arc<dyn k_launcher_kernel::SearchEngine> + Send + Sync> =
|
||||||
|
Arc::new(move || build_engine(factory_cfg.clone()));
|
||||||
|
k_launcher_ui::run(factory, launcher, &cfg.window, cfg.appearance.clone())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ path = "src/lib.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
|
bincode = { workspace = true }
|
||||||
|
dirs = { workspace = true }
|
||||||
k-launcher-kernel = { path = "../../k-launcher-kernel" }
|
k-launcher-kernel = { path = "../../k-launcher-kernel" }
|
||||||
nucleo-matcher = "0.3"
|
nucleo-matcher = "0.3"
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ pub mod frecency;
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub mod linux;
|
pub mod linux;
|
||||||
|
|
||||||
use std::{collections::HashMap, sync::Arc};
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
sync::{Arc, RwLock},
|
||||||
|
};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use k_launcher_kernel::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
|
use k_launcher_kernel::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
|
||||||
@@ -75,16 +79,74 @@ struct CachedEntry {
|
|||||||
on_select: Arc<dyn Fn() + Send + Sync>,
|
on_select: Arc<dyn Fn() + Send + Sync>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Plugin ---
|
// --- Serializable cache data (no closures) ---
|
||||||
|
|
||||||
pub struct AppsPlugin {
|
#[derive(serde::Serialize, serde::Deserialize)]
|
||||||
entries: HashMap<String, CachedEntry>,
|
struct CachedEntryData {
|
||||||
frecency: Arc<FrecencyStore>,
|
id: String,
|
||||||
|
name: String,
|
||||||
|
keywords_lc: Vec<String>,
|
||||||
|
category: Option<String>,
|
||||||
|
icon: Option<String>,
|
||||||
|
exec: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppsPlugin {
|
fn cache_path() -> Option<PathBuf> {
|
||||||
pub fn new(source: impl DesktopEntrySource, frecency: Arc<FrecencyStore>) -> Self {
|
#[cfg(test)]
|
||||||
let entries = source
|
return None;
|
||||||
|
#[cfg(not(test))]
|
||||||
|
dirs::cache_dir().map(|d| d.join("k-launcher/apps.bin"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_from_path(path: &Path, frecency: &Arc<FrecencyStore>) -> 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 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 {
|
||||||
|
id: e.id.clone(),
|
||||||
|
name: AppName::new(e.name),
|
||||||
|
keywords_lc: e.keywords_lc,
|
||||||
|
category: e.category,
|
||||||
|
icon: e.icon,
|
||||||
|
exec: e.exec,
|
||||||
|
on_select,
|
||||||
|
};
|
||||||
|
(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()
|
.entries()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|e| {
|
.map(|e| {
|
||||||
@@ -98,7 +160,7 @@ impl AppsPlugin {
|
|||||||
#[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 store = Arc::clone(frecency);
|
||||||
let record_id = id.clone();
|
let record_id = id.clone();
|
||||||
let on_select: Arc<dyn Fn() + Send + Sync> = Arc::new(move || {
|
let on_select: Arc<dyn Fn() + Send + Sync> = Arc::new(move || {
|
||||||
store.record(&record_id);
|
store.record(&record_id);
|
||||||
@@ -114,7 +176,42 @@ impl AppsPlugin {
|
|||||||
};
|
};
|
||||||
(id, cached)
|
(id, cached)
|
||||||
})
|
})
|
||||||
.collect();
|
.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 {
|
||||||
|
let cached = cache_path().and_then(|p| load_from_path(&p, &frecency));
|
||||||
|
|
||||||
|
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);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let fresh = build_entries(&source, &frecency_bg);
|
||||||
|
if let Some(path) = cache_path() {
|
||||||
|
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) = cache_path() {
|
||||||
|
save_to_path(&path, &initial);
|
||||||
|
}
|
||||||
|
Arc::new(RwLock::new(initial))
|
||||||
|
};
|
||||||
|
|
||||||
Self { entries, frecency }
|
Self { entries, frecency }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,13 +268,14 @@ impl Plugin for AppsPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn search(&self, query: &str) -> Vec<SearchResult> {
|
async fn search(&self, query: &str) -> Vec<SearchResult> {
|
||||||
|
let entries = self.entries.read().unwrap();
|
||||||
if query.is_empty() {
|
if query.is_empty() {
|
||||||
return self
|
return self
|
||||||
.frecency
|
.frecency
|
||||||
.top_ids(5)
|
.top_ids(5)
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|id| {
|
.filter_map(|id| {
|
||||||
let e = self.entries.get(id)?;
|
let e = entries.get(id)?;
|
||||||
let score = self.frecency.frecency_score(id).max(1);
|
let score = self.frecency.frecency_score(id).max(1);
|
||||||
Some(SearchResult {
|
Some(SearchResult {
|
||||||
id: ResultId::new(id),
|
id: ResultId::new(id),
|
||||||
@@ -193,7 +291,7 @@ impl Plugin for AppsPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let query_lc = query.to_lowercase();
|
let query_lc = query.to_lowercase();
|
||||||
self.entries
|
entries
|
||||||
.values()
|
.values()
|
||||||
.filter_map(|e| {
|
.filter_map(|e| {
|
||||||
let score = score_match(e.name.as_str(), query).or_else(|| {
|
let score = score_match(e.name.as_str(), query).or_else(|| {
|
||||||
@@ -396,4 +494,22 @@ mod tests {
|
|||||||
assert_eq!(results.len(), 2);
|
assert_eq!(results.len(), 2);
|
||||||
assert_eq!(results[0].title.as_str(), "Code");
|
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, &frecency).unwrap();
|
||||||
|
assert!(loaded.contains_key("app-Firefox"));
|
||||||
|
|
||||||
|
std::fs::remove_file(&cache_file).ok();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user