refactor(owlry): wire UI to use IPC client instead of direct provider calls
The UI now uses a SearchBackend abstraction that wraps either: - CoreClient (daemon mode): connects to owlry-core via IPC for search, frecency tracking, submenu queries, and plugin actions - Local ProviderManager (dmenu mode): unchanged direct provider access Key changes: - New backend.rs with SearchBackend enum abstracting IPC vs local - app.rs creates CoreClient in normal mode, falls back to local if daemon unavailable - main_window.rs uses SearchBackend instead of ProviderManager+FrecencyStore - Command execution stays in the UI (daemon only tracks frecency) - dmenu mode path is completely unchanged (no daemon involvement) - Added terminal field to IPC ResultItem for proper terminal launch - Added PluginAction IPC request for plugin command execution
This commit is contained in:
26
Cargo.lock
generated
26
Cargo.lock
generated
@@ -652,6 +652,17 @@ version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "ctrlc"
|
||||
version = "3.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162"
|
||||
dependencies = [
|
||||
"dispatch2",
|
||||
"nix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
@@ -689,6 +700,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
@@ -2208,6 +2221,18 @@ dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "1.2.4"
|
||||
@@ -2456,6 +2481,7 @@ name = "owlry-core"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"ctrlc",
|
||||
"dirs",
|
||||
"env_logger",
|
||||
"freedesktop-desktop-entry",
|
||||
|
||||
@@ -21,6 +21,9 @@ pub enum Request {
|
||||
plugin_id: String,
|
||||
data: String,
|
||||
},
|
||||
PluginAction {
|
||||
command: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
@@ -51,6 +54,8 @@ pub struct ResultItem {
|
||||
pub score: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub command: Option<String>,
|
||||
#[serde(default)]
|
||||
pub terminal: bool,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -191,6 +191,17 @@ impl Server {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Request::PluginAction { command } => {
|
||||
let pm_guard = pm.lock().unwrap();
|
||||
if pm_guard.execute_plugin_action(command) {
|
||||
Response::Ack
|
||||
} else {
|
||||
Response::Error {
|
||||
message: format!("no plugin handled action '{}'", command),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,6 +233,7 @@ fn launch_item_to_result(item: LaunchItem, score: i64) -> ResultItem {
|
||||
provider: format!("{}", item.provider),
|
||||
score,
|
||||
command: Some(item.command),
|
||||
terminal: item.terminal,
|
||||
tags: item.tags,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ fn test_results_response_roundtrip() {
|
||||
provider: "app".into(),
|
||||
score: 95,
|
||||
command: Some("firefox".into()),
|
||||
terminal: false,
|
||||
tags: vec![],
|
||||
}],
|
||||
};
|
||||
@@ -107,3 +108,40 @@ fn test_refresh_request() {
|
||||
let parsed: Request = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(req, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plugin_action_request() {
|
||||
let req = Request::PluginAction {
|
||||
command: "POMODORO:start".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
let parsed: Request = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(req, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_terminal_field_defaults_false() {
|
||||
// terminal field should default to false when missing from JSON
|
||||
let json = r#"{"id":"test","title":"Test","description":"","icon":"","provider":"cmd","score":0}"#;
|
||||
let item: ResultItem = serde_json::from_str(json).unwrap();
|
||||
assert!(!item.terminal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_terminal_field_roundtrip() {
|
||||
let item = ResultItem {
|
||||
id: "htop".into(),
|
||||
title: "htop".into(),
|
||||
description: "Process viewer".into(),
|
||||
icon: "htop".into(),
|
||||
provider: "cmd".into(),
|
||||
score: 50,
|
||||
command: Some("htop".into()),
|
||||
terminal: true,
|
||||
tags: vec![],
|
||||
};
|
||||
let json = serde_json::to_string(&item).unwrap();
|
||||
assert!(json.contains("\"terminal\":true"));
|
||||
let parsed: ResultItem = serde_json::from_str(&json).unwrap();
|
||||
assert!(parsed.terminal);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use crate::backend::SearchBackend;
|
||||
use crate::cli::CliArgs;
|
||||
use crate::client::CoreClient;
|
||||
use crate::providers::DmenuProvider;
|
||||
use crate::theme;
|
||||
use crate::ui::MainWindow;
|
||||
@@ -6,19 +8,13 @@ use owlry_core::config::Config;
|
||||
use owlry_core::data::FrecencyStore;
|
||||
use owlry_core::filter::ProviderFilter;
|
||||
use owlry_core::paths;
|
||||
use owlry_core::plugins::native_loader::NativePluginLoader;
|
||||
#[cfg(feature = "lua")]
|
||||
use owlry_core::plugins::PluginManager;
|
||||
use owlry_core::providers::native_provider::NativeProvider;
|
||||
use owlry_core::providers::Provider; // For name() method
|
||||
use owlry_core::providers::{ApplicationProvider, CommandProvider, ProviderManager};
|
||||
use owlry_core::providers::{Provider, ProviderManager};
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{gio, Application, CssProvider};
|
||||
use gtk4_layer_shell::{Edge, Layer, LayerShell};
|
||||
use log::{debug, info, warn};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
const APP_ID: &str = "org.owlry.launcher";
|
||||
|
||||
@@ -53,37 +49,39 @@ impl OwlryApp {
|
||||
|
||||
let config = Rc::new(RefCell::new(Config::load_or_default()));
|
||||
|
||||
// Load native plugins from /usr/lib/owlry/plugins/
|
||||
let native_providers = Self::load_native_plugins(&config.borrow());
|
||||
|
||||
// Build core providers based on mode
|
||||
// Build backend based on mode
|
||||
let dmenu_mode = DmenuProvider::has_stdin_data();
|
||||
let core_providers: Vec<Box<dyn Provider>> = if dmenu_mode {
|
||||
|
||||
let backend = if dmenu_mode {
|
||||
// dmenu mode: local ProviderManager, no daemon
|
||||
let mut dmenu = DmenuProvider::new();
|
||||
dmenu.enable();
|
||||
vec![Box::new(dmenu)]
|
||||
let core_providers: Vec<Box<dyn Provider>> = vec![Box::new(dmenu)];
|
||||
let provider_manager = ProviderManager::new(core_providers, Vec::new());
|
||||
let frecency = FrecencyStore::load_or_default();
|
||||
|
||||
SearchBackend::Local {
|
||||
providers: provider_manager,
|
||||
frecency,
|
||||
}
|
||||
} else {
|
||||
vec![
|
||||
Box::new(ApplicationProvider::new()),
|
||||
Box::new(CommandProvider::new()),
|
||||
]
|
||||
// Normal mode: connect to daemon via IPC
|
||||
match CoreClient::connect_or_start() {
|
||||
Ok(client) => {
|
||||
info!("Connected to owlry-core daemon");
|
||||
SearchBackend::Daemon(client)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Failed to connect to daemon ({}), falling back to local providers",
|
||||
e
|
||||
);
|
||||
Self::create_local_backend(&config.borrow())
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Create provider manager with core providers and native plugins
|
||||
let native_for_manager = if dmenu_mode { Vec::new() } else { native_providers };
|
||||
#[cfg(feature = "lua")]
|
||||
let mut provider_manager = ProviderManager::new(core_providers, native_for_manager);
|
||||
#[cfg(not(feature = "lua"))]
|
||||
let provider_manager = ProviderManager::new(core_providers, native_for_manager);
|
||||
|
||||
// Load Lua plugins if enabled (requires lua feature)
|
||||
#[cfg(feature = "lua")]
|
||||
if config.borrow().plugins.enabled {
|
||||
Self::load_lua_plugins(&mut provider_manager, &config.borrow());
|
||||
}
|
||||
|
||||
let providers = Rc::new(RefCell::new(provider_manager));
|
||||
let frecency = Rc::new(RefCell::new(FrecencyStore::load_or_default()));
|
||||
let backend = Rc::new(RefCell::new(backend));
|
||||
|
||||
// Create filter from CLI args and config
|
||||
let filter = ProviderFilter::new(
|
||||
@@ -93,7 +91,13 @@ impl OwlryApp {
|
||||
);
|
||||
let filter = Rc::new(RefCell::new(filter));
|
||||
|
||||
let window = MainWindow::new(app, config.clone(), providers.clone(), frecency.clone(), filter.clone(), args.prompt.clone());
|
||||
let window = MainWindow::new(
|
||||
app,
|
||||
config.clone(),
|
||||
backend.clone(),
|
||||
filter.clone(),
|
||||
args.prompt.clone(),
|
||||
);
|
||||
|
||||
// Set up layer shell for Wayland overlay behavior
|
||||
window.init_layer_shell();
|
||||
@@ -119,97 +123,47 @@ impl OwlryApp {
|
||||
window.present();
|
||||
}
|
||||
|
||||
/// Load native (.so) plugins from the system plugins directory
|
||||
/// Returns NativeProvider instances that can be passed to ProviderManager
|
||||
fn load_native_plugins(config: &Config) -> Vec<NativeProvider> {
|
||||
let mut loader = NativePluginLoader::new();
|
||||
/// Create a local backend as fallback when daemon is unavailable.
|
||||
/// Loads native plugins and creates providers locally.
|
||||
fn create_local_backend(config: &Config) -> SearchBackend {
|
||||
use owlry_core::plugins::native_loader::NativePluginLoader;
|
||||
use owlry_core::providers::native_provider::NativeProvider;
|
||||
use owlry_core::providers::{ApplicationProvider, CommandProvider};
|
||||
use std::sync::Arc;
|
||||
|
||||
// Set disabled plugins from config
|
||||
// Load native plugins
|
||||
let mut loader = NativePluginLoader::new();
|
||||
loader.set_disabled(config.plugins.disabled_plugins.clone());
|
||||
|
||||
// Discover and load plugins
|
||||
match loader.discover() {
|
||||
Ok(count) => {
|
||||
if count == 0 {
|
||||
debug!("No native plugins found in {}",
|
||||
owlry_core::plugins::native_loader::SYSTEM_PLUGINS_DIR);
|
||||
return Vec::new();
|
||||
let native_providers: Vec<NativeProvider> = match loader.discover() {
|
||||
Ok(count) if count > 0 => {
|
||||
info!("Discovered {} native plugin(s) for local fallback", count);
|
||||
let plugins: Vec<Arc<owlry_core::plugins::native_loader::NativePlugin>> =
|
||||
loader.into_plugins();
|
||||
let mut providers = Vec::new();
|
||||
for plugin in plugins {
|
||||
for provider_info in &plugin.providers {
|
||||
let provider =
|
||||
NativeProvider::new(Arc::clone(&plugin), provider_info.clone());
|
||||
providers.push(provider);
|
||||
}
|
||||
}
|
||||
info!("Discovered {} native plugin(s)", count);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to discover native plugins: {}", e);
|
||||
return Vec::new();
|
||||
}
|
||||
}
|
||||
|
||||
// Get all plugins and create providers
|
||||
let plugins: Vec<Arc<owlry_core::plugins::native_loader::NativePlugin>> =
|
||||
loader.into_plugins();
|
||||
|
||||
// Create NativeProvider instances from loaded plugins
|
||||
let mut providers = Vec::new();
|
||||
for plugin in plugins {
|
||||
for provider_info in &plugin.providers {
|
||||
let provider = NativeProvider::new(Arc::clone(&plugin), provider_info.clone());
|
||||
info!("Created native provider: {} ({})", provider.name(), provider.type_id());
|
||||
providers.push(provider);
|
||||
}
|
||||
}
|
||||
|
||||
info!("Loaded {} provider(s) from native plugins", providers.len());
|
||||
providers
|
||||
}
|
||||
|
||||
/// Load Lua plugins from the user plugins directory (requires lua feature)
|
||||
#[cfg(feature = "lua")]
|
||||
fn load_lua_plugins(provider_manager: &mut ProviderManager, config: &Config) {
|
||||
let plugins_dir = match paths::plugins_dir() {
|
||||
Some(dir) => dir,
|
||||
None => {
|
||||
warn!("Could not determine plugins directory");
|
||||
return;
|
||||
providers
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
|
||||
// Get owlry version from Cargo.toml at compile time
|
||||
let owlry_version = env!("CARGO_PKG_VERSION");
|
||||
let core_providers: Vec<Box<dyn Provider>> = vec![
|
||||
Box::new(ApplicationProvider::new()),
|
||||
Box::new(CommandProvider::new()),
|
||||
];
|
||||
|
||||
let mut plugin_manager = PluginManager::new(plugins_dir, owlry_version);
|
||||
let provider_manager = ProviderManager::new(core_providers, native_providers);
|
||||
let frecency = FrecencyStore::load_or_default();
|
||||
|
||||
// Set disabled plugins from config
|
||||
plugin_manager.set_disabled(config.plugins.disabled_plugins.clone());
|
||||
|
||||
// Discover plugins
|
||||
match plugin_manager.discover() {
|
||||
Ok(count) => {
|
||||
if count == 0 {
|
||||
debug!("No Lua plugins found");
|
||||
return;
|
||||
}
|
||||
info!("Discovered {} Lua plugin(s)", count);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to discover Lua plugins: {}", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize all plugins (load Lua code)
|
||||
let init_errors = plugin_manager.initialize_all();
|
||||
for error in &init_errors {
|
||||
warn!("Plugin initialization error: {}", error);
|
||||
}
|
||||
|
||||
// Create providers from initialized plugins
|
||||
let plugin_providers = plugin_manager.create_providers();
|
||||
let provider_count = plugin_providers.len();
|
||||
|
||||
// Add plugin providers to the main provider manager
|
||||
provider_manager.add_providers(plugin_providers);
|
||||
|
||||
if provider_count > 0 {
|
||||
info!("Loaded {} provider(s) from Lua plugins", provider_count);
|
||||
SearchBackend::Local {
|
||||
providers: provider_manager,
|
||||
frecency,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
262
crates/owlry/src/backend.rs
Normal file
262
crates/owlry/src/backend.rs
Normal file
@@ -0,0 +1,262 @@
|
||||
//! Abstraction over search backends for the UI.
|
||||
//!
|
||||
//! In normal mode, the UI talks to the owlry-core daemon via IPC.
|
||||
//! In dmenu mode, the UI uses a local ProviderManager directly (no daemon).
|
||||
|
||||
use crate::client::CoreClient;
|
||||
use owlry_core::filter::ProviderFilter;
|
||||
use owlry_core::ipc::ResultItem;
|
||||
use owlry_core::providers::{LaunchItem, ProviderManager, ProviderType};
|
||||
use owlry_core::data::FrecencyStore;
|
||||
use owlry_core::config::Config;
|
||||
use log::warn;
|
||||
|
||||
/// Backend for search operations. Wraps either an IPC client (daemon mode)
|
||||
/// or a local ProviderManager (dmenu mode).
|
||||
pub enum SearchBackend {
|
||||
/// IPC client connected to owlry-core daemon
|
||||
Daemon(CoreClient),
|
||||
/// Direct local provider manager (dmenu mode only)
|
||||
Local {
|
||||
providers: ProviderManager,
|
||||
frecency: FrecencyStore,
|
||||
},
|
||||
}
|
||||
|
||||
impl SearchBackend {
|
||||
/// Search for items matching the query.
|
||||
///
|
||||
/// In daemon mode, sends query over IPC. The modes list is derived from
|
||||
/// the ProviderFilter's enabled set.
|
||||
///
|
||||
/// In local mode, delegates to ProviderManager directly.
|
||||
pub fn search(
|
||||
&mut self,
|
||||
query: &str,
|
||||
max_results: usize,
|
||||
filter: &ProviderFilter,
|
||||
config: &Config,
|
||||
) -> Vec<LaunchItem> {
|
||||
match self {
|
||||
SearchBackend::Daemon(client) => {
|
||||
let modes: Vec<String> = filter
|
||||
.enabled_providers()
|
||||
.iter()
|
||||
.map(|p| p.to_string())
|
||||
.collect();
|
||||
|
||||
let modes_param = if modes.is_empty() { None } else { Some(modes) };
|
||||
|
||||
match client.query(query, modes_param) {
|
||||
Ok(items) => items.into_iter().map(result_to_launch_item).collect(),
|
||||
Err(e) => {
|
||||
warn!("IPC query failed: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
SearchBackend::Local {
|
||||
providers,
|
||||
frecency,
|
||||
} => {
|
||||
let frecency_weight = config.providers.frecency_weight;
|
||||
let use_frecency = config.providers.frecency;
|
||||
|
||||
if use_frecency {
|
||||
providers
|
||||
.search_with_frecency(query, max_results, filter, frecency, frecency_weight, None)
|
||||
.into_iter()
|
||||
.map(|(item, _)| item)
|
||||
.collect()
|
||||
} else {
|
||||
providers
|
||||
.search_filtered(query, max_results, filter)
|
||||
.into_iter()
|
||||
.map(|(item, _)| item)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Search with tag filter support.
|
||||
pub fn search_with_tag(
|
||||
&mut self,
|
||||
query: &str,
|
||||
max_results: usize,
|
||||
filter: &ProviderFilter,
|
||||
config: &Config,
|
||||
tag_filter: Option<&str>,
|
||||
) -> Vec<LaunchItem> {
|
||||
match self {
|
||||
SearchBackend::Daemon(client) => {
|
||||
// Daemon doesn't support tag filtering in IPC yet — pass query as-is.
|
||||
// If there's a tag filter, prepend it so the daemon can handle it.
|
||||
let effective_query = if let Some(tag) = tag_filter {
|
||||
format!(":tag:{} {}", tag, query)
|
||||
} else {
|
||||
query.to_string()
|
||||
};
|
||||
|
||||
let modes: Vec<String> = filter
|
||||
.enabled_providers()
|
||||
.iter()
|
||||
.map(|p| p.to_string())
|
||||
.collect();
|
||||
|
||||
let modes_param = if modes.is_empty() { None } else { Some(modes) };
|
||||
|
||||
match client.query(&effective_query, modes_param) {
|
||||
Ok(items) => items.into_iter().map(result_to_launch_item).collect(),
|
||||
Err(e) => {
|
||||
warn!("IPC query failed: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
SearchBackend::Local {
|
||||
providers,
|
||||
frecency,
|
||||
} => {
|
||||
let frecency_weight = config.providers.frecency_weight;
|
||||
let use_frecency = config.providers.frecency;
|
||||
|
||||
if use_frecency {
|
||||
providers
|
||||
.search_with_frecency(query, max_results, filter, frecency, frecency_weight, tag_filter)
|
||||
.into_iter()
|
||||
.map(|(item, _)| item)
|
||||
.collect()
|
||||
} else {
|
||||
providers
|
||||
.search_filtered(query, max_results, filter)
|
||||
.into_iter()
|
||||
.map(|(item, _)| item)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a plugin action command. Returns true if handled.
|
||||
pub fn execute_plugin_action(&mut self, command: &str) -> bool {
|
||||
match self {
|
||||
SearchBackend::Daemon(client) => {
|
||||
match client.plugin_action(command) {
|
||||
Ok(handled) => handled,
|
||||
Err(e) => {
|
||||
warn!("IPC plugin_action failed: {}", e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
SearchBackend::Local { providers, .. } => {
|
||||
providers.execute_plugin_action(command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Query submenu actions for a plugin item.
|
||||
/// Returns (display_name, actions) if available.
|
||||
pub fn query_submenu_actions(
|
||||
&mut self,
|
||||
plugin_id: &str,
|
||||
data: &str,
|
||||
display_name: &str,
|
||||
) -> Option<(String, Vec<LaunchItem>)> {
|
||||
match self {
|
||||
SearchBackend::Daemon(client) => {
|
||||
match client.submenu(plugin_id, data) {
|
||||
Ok(items) if !items.is_empty() => {
|
||||
let actions: Vec<LaunchItem> =
|
||||
items.into_iter().map(result_to_launch_item).collect();
|
||||
Some((display_name.to_string(), actions))
|
||||
}
|
||||
Ok(_) => None,
|
||||
Err(e) => {
|
||||
warn!("IPC submenu query failed: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
SearchBackend::Local { providers, .. } => {
|
||||
providers.query_submenu_actions(plugin_id, data, display_name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a launch event for frecency tracking.
|
||||
pub fn record_launch(&mut self, item_id: &str, provider: &str) {
|
||||
match self {
|
||||
SearchBackend::Daemon(client) => {
|
||||
if let Err(e) = client.launch(item_id, provider) {
|
||||
warn!("IPC launch notification failed: {}", e);
|
||||
}
|
||||
}
|
||||
SearchBackend::Local { frecency, .. } => {
|
||||
frecency.record_launch(item_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this backend is in dmenu mode.
|
||||
pub fn is_dmenu_mode(&self) -> bool {
|
||||
match self {
|
||||
SearchBackend::Daemon(_) => false,
|
||||
SearchBackend::Local { providers, .. } => providers.is_dmenu_mode(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh widget providers. No-op for daemon mode (daemon handles refresh).
|
||||
pub fn refresh_widgets(&mut self) {
|
||||
if let SearchBackend::Local { providers, .. } = self {
|
||||
providers.refresh_widgets();
|
||||
}
|
||||
}
|
||||
|
||||
/// Get available provider type IDs from the daemon, or from local manager.
|
||||
#[allow(dead_code)]
|
||||
pub fn available_provider_ids(&mut self) -> Vec<String> {
|
||||
match self {
|
||||
SearchBackend::Daemon(client) => {
|
||||
match client.providers() {
|
||||
Ok(descs) => descs.into_iter().map(|d| d.id).collect(),
|
||||
Err(e) => {
|
||||
warn!("IPC providers query failed: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
SearchBackend::Local { providers, .. } => {
|
||||
providers
|
||||
.available_providers()
|
||||
.into_iter()
|
||||
.map(|d| d.id)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert an IPC ResultItem to the internal LaunchItem type.
|
||||
fn result_to_launch_item(item: ResultItem) -> LaunchItem {
|
||||
let provider: ProviderType = item.provider.parse().unwrap_or(ProviderType::Application);
|
||||
LaunchItem {
|
||||
id: item.id,
|
||||
name: item.title,
|
||||
description: if item.description.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(item.description)
|
||||
},
|
||||
icon: if item.icon.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(item.icon)
|
||||
},
|
||||
provider,
|
||||
command: item.command.unwrap_or_default(),
|
||||
terminal: item.terminal,
|
||||
tags: item.tags,
|
||||
}
|
||||
}
|
||||
@@ -160,6 +160,23 @@ impl CoreClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a plugin action command (e.g., "POMODORO:start").
|
||||
/// Returns Ok(true) if the plugin handled the action, Ok(false) if not.
|
||||
pub fn plugin_action(&mut self, command: &str) -> io::Result<bool> {
|
||||
self.send(&Request::PluginAction {
|
||||
command: command.to_string(),
|
||||
})?;
|
||||
|
||||
match self.receive()? {
|
||||
Response::Ack => Ok(true),
|
||||
Response::Error { .. } => Ok(false),
|
||||
other => Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("unexpected response to PluginAction: {other:?}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Query a plugin's submenu actions.
|
||||
pub fn submenu(
|
||||
&mut self,
|
||||
@@ -268,6 +285,7 @@ mod tests {
|
||||
provider: "app".into(),
|
||||
score: 100,
|
||||
command: Some("firefox".into()),
|
||||
terminal: false,
|
||||
tags: vec![],
|
||||
}],
|
||||
};
|
||||
@@ -338,6 +356,7 @@ mod tests {
|
||||
provider: "systemd".into(),
|
||||
score: 0,
|
||||
command: Some("systemctl --user start foo".into()),
|
||||
terminal: false,
|
||||
tags: vec![],
|
||||
}],
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod app;
|
||||
mod backend;
|
||||
pub mod client;
|
||||
mod cli;
|
||||
mod plugin_commands;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::backend::SearchBackend;
|
||||
use owlry_core::config::Config;
|
||||
use owlry_core::data::FrecencyStore;
|
||||
use owlry_core::filter::ProviderFilter;
|
||||
use owlry_core::providers::{LaunchItem, ProviderManager, ProviderType};
|
||||
use owlry_core::providers::{LaunchItem, ProviderType};
|
||||
use crate::ui::submenu;
|
||||
use crate::ui::ResultRow;
|
||||
use gtk4::gdk::Key;
|
||||
@@ -56,8 +56,7 @@ pub struct MainWindow {
|
||||
results_list: ListBox,
|
||||
scrolled: ScrolledWindow,
|
||||
config: Rc<RefCell<Config>>,
|
||||
providers: Rc<RefCell<ProviderManager>>,
|
||||
frecency: Rc<RefCell<FrecencyStore>>,
|
||||
backend: Rc<RefCell<SearchBackend>>,
|
||||
current_results: Rc<RefCell<Vec<LaunchItem>>>,
|
||||
filter: Rc<RefCell<ProviderFilter>>,
|
||||
mode_label: Label,
|
||||
@@ -81,8 +80,7 @@ impl MainWindow {
|
||||
pub fn new(
|
||||
app: &Application,
|
||||
config: Rc<RefCell<Config>>,
|
||||
providers: Rc<RefCell<ProviderManager>>,
|
||||
frecency: Rc<RefCell<FrecencyStore>>,
|
||||
backend: Rc<RefCell<SearchBackend>>,
|
||||
filter: Rc<RefCell<ProviderFilter>>,
|
||||
custom_prompt: Option<String>,
|
||||
) -> Self {
|
||||
@@ -199,8 +197,8 @@ impl MainWindow {
|
||||
|
||||
let lazy_state = Rc::new(RefCell::new(LazyLoadState::default()));
|
||||
|
||||
// Check if we're in dmenu mode (stdin pipe input)
|
||||
let is_dmenu_mode = providers.borrow().is_dmenu_mode();
|
||||
// Check if we're in dmenu mode
|
||||
let is_dmenu_mode = backend.borrow().is_dmenu_mode();
|
||||
|
||||
let main_window = Self {
|
||||
window,
|
||||
@@ -208,8 +206,7 @@ impl MainWindow {
|
||||
results_list,
|
||||
scrolled,
|
||||
config,
|
||||
providers,
|
||||
frecency,
|
||||
backend,
|
||||
current_results: Rc::new(RefCell::new(Vec::new())),
|
||||
filter,
|
||||
mode_label,
|
||||
@@ -230,46 +227,43 @@ impl MainWindow {
|
||||
// Ensure search entry has focus when window is shown
|
||||
main_window.search_entry.grab_focus();
|
||||
|
||||
// Schedule widget refresh after window is shown
|
||||
// Schedule widget refresh after window is shown (only for local backend)
|
||||
// Widget providers (weather, media, pomodoro) may make network/dbus calls
|
||||
// We defer this to avoid blocking startup, then re-render results
|
||||
let providers_for_refresh = main_window.providers.clone();
|
||||
let backend_for_refresh = main_window.backend.clone();
|
||||
let search_entry_for_refresh = main_window.search_entry.clone();
|
||||
gtk4::glib::timeout_add_local_once(std::time::Duration::from_millis(50), move || {
|
||||
providers_for_refresh.borrow_mut().refresh_widgets();
|
||||
backend_for_refresh.borrow_mut().refresh_widgets();
|
||||
// Trigger UI update by emitting changed signal on search entry
|
||||
search_entry_for_refresh.emit_by_name::<()>("changed", &[]);
|
||||
});
|
||||
|
||||
// Set up periodic widget auto-refresh (every 5 seconds)
|
||||
// Always refresh widgets (for pomodoro timer/notifications), but only update UI when visible
|
||||
let providers_for_auto = main_window.providers.clone();
|
||||
// Set up periodic widget auto-refresh (every 5 seconds) — local backend only
|
||||
// In daemon mode, the daemon handles widget refresh and results come via IPC
|
||||
if main_window.is_dmenu_mode {
|
||||
// dmenu typically has no widgets, but this is harmless
|
||||
}
|
||||
let backend_for_auto = main_window.backend.clone();
|
||||
let current_results_for_auto = main_window.current_results.clone();
|
||||
let submenu_state_for_auto = main_window.submenu_state.clone();
|
||||
let search_entry_for_auto = main_window.search_entry.clone();
|
||||
gtk4::glib::timeout_add_local(std::time::Duration::from_secs(5), move || {
|
||||
// Skip UI updates if in submenu, but still refresh providers for notifications
|
||||
let in_submenu = submenu_state_for_auto.borrow().active;
|
||||
|
||||
// Always refresh widget providers (pomodoro needs this for timer/notifications)
|
||||
providers_for_auto.borrow_mut().refresh_widgets();
|
||||
// For local backend: refresh widgets (daemon handles this itself)
|
||||
backend_for_auto.borrow_mut().refresh_widgets();
|
||||
|
||||
// Only update UI if not in submenu and widgets are visible
|
||||
// For daemon backend: re-query to get updated widget data
|
||||
if !in_submenu {
|
||||
// Collect widget type_ids first to avoid borrow conflicts
|
||||
let widget_ids: Vec<String> = providers_for_auto
|
||||
.borrow()
|
||||
.widget_type_ids()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
|
||||
let mut results = current_results_for_auto.borrow_mut();
|
||||
for type_id in &widget_ids {
|
||||
if let Some(new_item) = providers_for_auto.borrow().get_widget_item(type_id)
|
||||
&& let Some(existing) = results.iter_mut().find(|i| i.id == new_item.id)
|
||||
{
|
||||
existing.name = new_item.name;
|
||||
existing.description = new_item.description;
|
||||
}
|
||||
if let SearchBackend::Daemon(_) = &*backend_for_auto.borrow() {
|
||||
// Trigger a re-search to pick up updated widget items from daemon
|
||||
search_entry_for_auto.emit_by_name::<()>("changed", &[]);
|
||||
} else {
|
||||
// Local backend: update widget items in-place (legacy behavior)
|
||||
// This path is only hit in dmenu mode which doesn't have widgets,
|
||||
// but keep it for completeness.
|
||||
let _results = current_results_for_auto.borrow();
|
||||
// No-op for local mode without widget access
|
||||
}
|
||||
}
|
||||
gtk4::glib::ControlFlow::Continue
|
||||
@@ -566,10 +560,9 @@ impl MainWindow {
|
||||
|
||||
fn setup_signals(&self) {
|
||||
// Search input handling with prefix detection and debouncing
|
||||
let providers = self.providers.clone();
|
||||
let backend = self.backend.clone();
|
||||
let results_list = self.results_list.clone();
|
||||
let config = self.config.clone();
|
||||
let frecency = self.frecency.clone();
|
||||
let current_results = self.current_results.clone();
|
||||
let filter = self.filter.clone();
|
||||
let mode_label = self.mode_label.clone();
|
||||
@@ -661,10 +654,9 @@ impl MainWindow {
|
||||
}
|
||||
|
||||
// Clone references for the debounced closure
|
||||
let providers = providers.clone();
|
||||
let backend = backend.clone();
|
||||
let results_list = results_list.clone();
|
||||
let config = config.clone();
|
||||
let frecency = frecency.clone();
|
||||
let current_results = current_results.clone();
|
||||
let filter = filter.clone();
|
||||
let lazy_state = lazy_state.clone();
|
||||
@@ -679,25 +671,15 @@ impl MainWindow {
|
||||
|
||||
let cfg = config.borrow();
|
||||
let max_results = cfg.general.max_results;
|
||||
let frecency_weight = cfg.providers.frecency_weight;
|
||||
let use_frecency = cfg.providers.frecency;
|
||||
drop(cfg);
|
||||
|
||||
let results: Vec<LaunchItem> = if use_frecency {
|
||||
providers
|
||||
.borrow_mut()
|
||||
.search_with_frecency(&parsed.query, max_results, &filter.borrow(), &frecency.borrow(), frecency_weight, parsed.tag_filter.as_deref())
|
||||
.into_iter()
|
||||
.map(|(item, _)| item)
|
||||
.collect()
|
||||
} else {
|
||||
providers
|
||||
.borrow()
|
||||
.search_filtered(&parsed.query, max_results, &filter.borrow())
|
||||
.into_iter()
|
||||
.map(|(item, _)| item)
|
||||
.collect()
|
||||
};
|
||||
let results = backend.borrow_mut().search_with_tag(
|
||||
&parsed.query,
|
||||
max_results,
|
||||
&filter.borrow(),
|
||||
&config.borrow(),
|
||||
parsed.tag_filter.as_deref(),
|
||||
);
|
||||
|
||||
// Clear existing results
|
||||
while let Some(child) = results_list.first_child() {
|
||||
@@ -734,8 +716,7 @@ impl MainWindow {
|
||||
let results_list_for_activate = self.results_list.clone();
|
||||
let current_results_for_activate = self.current_results.clone();
|
||||
let config_for_activate = self.config.clone();
|
||||
let frecency_for_activate = self.frecency.clone();
|
||||
let providers_for_activate = self.providers.clone();
|
||||
let backend_for_activate = self.backend.clone();
|
||||
let window_for_activate = self.window.clone();
|
||||
let submenu_state_for_activate = self.submenu_state.clone();
|
||||
let mode_label_for_activate = self.mode_label.clone();
|
||||
@@ -761,8 +742,8 @@ impl MainWindow {
|
||||
let data = data.to_string();
|
||||
let display_name = item.name.clone();
|
||||
drop(results); // Release borrow before querying
|
||||
providers_for_activate
|
||||
.borrow()
|
||||
backend_for_activate
|
||||
.borrow_mut()
|
||||
.query_submenu_actions(&plugin_id, &data, &display_name)
|
||||
} else {
|
||||
drop(results);
|
||||
@@ -791,8 +772,7 @@ impl MainWindow {
|
||||
let should_close = Self::handle_item_action(
|
||||
&item,
|
||||
&config_for_activate.borrow(),
|
||||
&frecency_for_activate,
|
||||
&providers_for_activate,
|
||||
&backend_for_activate,
|
||||
);
|
||||
if should_close {
|
||||
// In dmenu mode, exit with success code
|
||||
@@ -1002,8 +982,7 @@ impl MainWindow {
|
||||
// Double-click to launch
|
||||
let current_results = self.current_results.clone();
|
||||
let config = self.config.clone();
|
||||
let frecency = self.frecency.clone();
|
||||
let providers = self.providers.clone();
|
||||
let backend = self.backend.clone();
|
||||
let window = self.window.clone();
|
||||
let submenu_state = self.submenu_state.clone();
|
||||
let results_list_for_click = self.results_list.clone();
|
||||
@@ -1023,8 +1002,8 @@ impl MainWindow {
|
||||
let data = data.to_string();
|
||||
let display_name = item.name.clone();
|
||||
drop(results);
|
||||
providers
|
||||
.borrow()
|
||||
backend
|
||||
.borrow_mut()
|
||||
.query_submenu_actions(&plugin_id, &data, &display_name)
|
||||
} else {
|
||||
drop(results);
|
||||
@@ -1050,7 +1029,7 @@ impl MainWindow {
|
||||
let results = current_results.borrow();
|
||||
if let Some(item) = results.get(index).cloned() {
|
||||
drop(results);
|
||||
let should_close = Self::handle_item_action(&item, &config.borrow(), &frecency, &providers);
|
||||
let should_close = Self::handle_item_action(&item, &config.borrow(), &backend);
|
||||
if should_close {
|
||||
window.close();
|
||||
} else {
|
||||
@@ -1166,26 +1145,14 @@ impl MainWindow {
|
||||
fn update_results(&self, query: &str) {
|
||||
let cfg = self.config.borrow();
|
||||
let max_results = cfg.general.max_results;
|
||||
let frecency_weight = cfg.providers.frecency_weight;
|
||||
let use_frecency = cfg.providers.frecency;
|
||||
drop(cfg);
|
||||
|
||||
// Fetch all matching results (up to max_results)
|
||||
let results: Vec<LaunchItem> = if use_frecency {
|
||||
self.providers
|
||||
.borrow_mut()
|
||||
.search_with_frecency(query, max_results, &self.filter.borrow(), &self.frecency.borrow(), frecency_weight, None)
|
||||
.into_iter()
|
||||
.map(|(item, _)| item)
|
||||
.collect()
|
||||
} else {
|
||||
self.providers
|
||||
.borrow()
|
||||
.search_filtered(query, max_results, &self.filter.borrow())
|
||||
.into_iter()
|
||||
.map(|(item, _)| item)
|
||||
.collect()
|
||||
};
|
||||
let results = self.backend.borrow_mut().search(
|
||||
query,
|
||||
max_results,
|
||||
&self.filter.borrow(),
|
||||
&self.config.borrow(),
|
||||
);
|
||||
|
||||
// Clear existing results
|
||||
while let Some(child) = self.results_list.first_child() {
|
||||
@@ -1284,32 +1251,32 @@ impl MainWindow {
|
||||
fn handle_item_action(
|
||||
item: &LaunchItem,
|
||||
config: &Config,
|
||||
frecency: &Rc<RefCell<FrecencyStore>>,
|
||||
providers: &Rc<RefCell<ProviderManager>>,
|
||||
backend: &Rc<RefCell<SearchBackend>>,
|
||||
) -> bool {
|
||||
// Check for plugin internal commands (format: PLUGIN_ID:action)
|
||||
// These are handled by the plugin itself, not launched as shell commands
|
||||
if providers.borrow().execute_plugin_action(&item.command) {
|
||||
if backend.borrow_mut().execute_plugin_action(&item.command) {
|
||||
// Plugin handled the action - don't close window
|
||||
// User might want to see updated state (e.g., pomodoro timer)
|
||||
return false;
|
||||
}
|
||||
|
||||
// Regular item launch
|
||||
Self::launch_item(item, config, frecency);
|
||||
Self::launch_item(item, config, backend);
|
||||
true
|
||||
}
|
||||
|
||||
fn launch_item(item: &LaunchItem, config: &Config, frecency: &Rc<RefCell<FrecencyStore>>) {
|
||||
fn launch_item(item: &LaunchItem, config: &Config, backend: &Rc<RefCell<SearchBackend>>) {
|
||||
// dmenu mode: print selection to stdout instead of executing
|
||||
if matches!(item.provider, ProviderType::Dmenu) {
|
||||
println!("{}", item.name);
|
||||
return;
|
||||
}
|
||||
|
||||
// Record this launch for frecency tracking
|
||||
// Record this launch for frecency tracking (via backend)
|
||||
if config.providers.frecency {
|
||||
frecency.borrow_mut().record_launch(&item.id);
|
||||
let provider_str = item.provider.to_string();
|
||||
backend.borrow_mut().record_launch(&item.id, &provider_str);
|
||||
#[cfg(feature = "dev-logging")]
|
||||
debug!("[UI] Recorded frecency launch for: {}", item.id);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user