Compare commits
3 Commits
38860762c0
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e773cdeaf | |||
| 3d2bd5f9fe | |||
| ff9b2b5712 |
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,17 +55,20 @@ impl FrecencyStore {
|
|||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.as_secs();
|
.as_secs();
|
||||||
let mut data = self.data.lock().unwrap();
|
let json = {
|
||||||
let entry = data.entry(id.to_string()).or_insert(Entry {
|
let mut data = self.data.lock().unwrap();
|
||||||
count: 0,
|
let entry = data.entry(id.to_string()).or_insert(Entry {
|
||||||
last_used: 0,
|
count: 0,
|
||||||
});
|
last_used: 0,
|
||||||
entry.count += 1;
|
});
|
||||||
entry.last_used = now;
|
entry.count += 1;
|
||||||
if let Some(parent) = self.path.parent() {
|
entry.last_used = now;
|
||||||
let _ = std::fs::create_dir_all(parent);
|
serde_json::to_string(&*data).ok()
|
||||||
}
|
}; // lock released here
|
||||||
if let Ok(json) = serde_json::to_string(&*data) {
|
if let Some(json) = json {
|
||||||
|
if let Some(parent) = self.path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
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,14 +94,7 @@ 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))
|
||||||
4
|
|
||||||
} else if age_secs < 86400 {
|
|
||||||
2
|
|
||||||
} else {
|
|
||||||
1
|
|
||||||
};
|
|
||||||
(id.clone(), entry.count * decay)
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
scored.sort_by(|a, b| b.1.cmp(&a.1));
|
scored.sort_by(|a, b| b.1.cmp(&a.1));
|
||||||
@@ -113,6 +102,16 @@ impl FrecencyStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn decay_factor(age_secs: u64) -> u32 {
|
||||||
|
if age_secs < 3600 {
|
||||||
|
4
|
||||||
|
} else if age_secs < 86400 {
|
||||||
|
2
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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![],
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user