Files
owlry/crates/owlry-core/src/providers/mod.rs
vikingowl 6113217f7b perf(core): sample Utc::now() once per search instead of per-item
get_score() called Utc::now() inside calculate_frecency() for every
item in the search loop. Added get_score_at() that accepts a pre-sampled
timestamp. Eliminates hundreds of unnecessary clock_gettime syscalls
per keystroke.
2026-03-28 08:45:21 +01:00

1082 lines
38 KiB
Rust

// Core providers (no plugin equivalents)
mod application;
mod command;
// Native plugin bridge
pub mod native_provider;
// Lua plugin bridge (optional)
#[cfg(feature = "lua")]
pub mod lua_provider;
// Re-exports for core providers
pub use application::ApplicationProvider;
pub use command::CommandProvider;
// Re-export native provider for plugin loading
pub use native_provider::NativeProvider;
use chrono::Utc;
use fuzzy_matcher::FuzzyMatcher;
use fuzzy_matcher::skim::SkimMatcherV2;
use log::info;
#[cfg(feature = "dev-logging")]
use log::debug;
use crate::config::Config;
use crate::data::FrecencyStore;
use crate::plugins::runtime_loader::LoadedRuntime;
/// Metadata descriptor for an available provider (used by IPC/daemon API)
#[derive(Debug, Clone)]
pub struct ProviderDescriptor {
pub id: String,
pub name: String,
pub prefix: Option<String>,
pub icon: String,
pub position: String,
}
/// Represents a single searchable/launchable item
#[derive(Debug, Clone)]
pub struct LaunchItem {
#[allow(dead_code)]
pub id: String,
pub name: String,
pub description: Option<String>,
pub icon: Option<String>,
pub provider: ProviderType,
pub command: String,
pub terminal: bool,
/// Tags/categories for filtering (e.g., from .desktop Categories)
pub tags: Vec<String>,
}
/// Provider type identifier for filtering and badge display
///
/// Core types are built-in providers. All native plugins use Plugin(type_id).
/// This keeps the core app free of plugin-specific knowledge.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ProviderType {
/// Built-in: Desktop applications from XDG directories
Application,
/// Built-in: Shell commands from PATH
Command,
/// Built-in: Pipe-based input (dmenu compatibility)
Dmenu,
/// Plugin-defined provider type with its type_id (e.g., "calc", "weather", "emoji")
Plugin(String),
}
impl std::str::FromStr for ProviderType {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
// Core built-in providers
"app" | "apps" | "application" | "applications" => Ok(ProviderType::Application),
"cmd" | "command" | "commands" => Ok(ProviderType::Command),
"dmenu" => Ok(ProviderType::Dmenu),
// Everything else is a plugin
other => Ok(ProviderType::Plugin(other.to_string())),
}
}
}
impl std::fmt::Display for ProviderType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ProviderType::Application => write!(f, "app"),
ProviderType::Command => write!(f, "cmd"),
ProviderType::Dmenu => write!(f, "dmenu"),
ProviderType::Plugin(type_id) => write!(f, "{}", type_id),
}
}
}
/// Trait for all search providers
pub trait Provider: Send + Sync {
#[allow(dead_code)]
fn name(&self) -> &str;
fn provider_type(&self) -> ProviderType;
fn refresh(&mut self);
fn items(&self) -> &[LaunchItem];
}
/// Manages all providers and handles searching
pub struct ProviderManager {
/// Core static providers (apps, commands, dmenu)
providers: Vec<Box<dyn Provider>>,
/// Static native plugin providers (need query() for submenu support)
static_native_providers: Vec<NativeProvider>,
/// Dynamic providers from native plugins (calculator, websearch, filesearch)
/// These are queried per-keystroke, not cached
dynamic_providers: Vec<NativeProvider>,
/// Widget providers from native plugins (weather, media, pomodoro)
/// These appear at the top of results
widget_providers: Vec<NativeProvider>,
/// Fuzzy matcher for search
matcher: SkimMatcherV2,
/// Loaded script runtimes (Lua, Rune) — must stay alive to keep Library handles
runtimes: Vec<LoadedRuntime>,
/// Type IDs of providers from script runtimes (for hot-reload removal)
runtime_type_ids: std::collections::HashSet<String>,
}
impl ProviderManager {
/// Create a new ProviderManager with core providers and native plugins.
///
/// Core providers (e.g., ApplicationProvider, CommandProvider, DmenuProvider) are
/// passed in by the caller. Native plugins are categorized based on their declared
/// ProviderKind and ProviderPosition.
pub fn new(
core_providers: Vec<Box<dyn Provider>>,
native_providers: Vec<NativeProvider>,
) -> Self {
let mut manager = Self {
providers: core_providers,
static_native_providers: Vec::new(),
dynamic_providers: Vec::new(),
widget_providers: Vec::new(),
matcher: SkimMatcherV2::default(),
runtimes: Vec::new(),
runtime_type_ids: std::collections::HashSet::new(),
};
// Categorize native plugins based on their declared ProviderKind and ProviderPosition
for provider in native_providers {
let type_id = provider.type_id();
if provider.is_dynamic() {
info!(
"Registered dynamic provider: {} ({})",
provider.name(),
type_id
);
manager.dynamic_providers.push(provider);
} else if provider.is_widget() {
info!(
"Registered widget provider: {} ({})",
provider.name(),
type_id
);
manager.widget_providers.push(provider);
} else {
info!(
"Registered static provider: {} ({})",
provider.name(),
type_id
);
manager.static_native_providers.push(provider);
}
}
// Initial refresh
manager.refresh_all();
manager
}
/// Create a self-contained ProviderManager from config.
///
/// Loads native plugins, creates core providers (Application + Command),
/// categorizes everything, and performs initial refresh. Used by the daemon
/// which doesn't have the UI-driven setup path from `app.rs`.
pub fn new_with_config(config: &Config) -> Self {
use crate::plugins::native_loader::NativePluginLoader;
use std::sync::Arc;
// Create core providers
let mut core_providers: Vec<Box<dyn Provider>> = vec![
Box::new(ApplicationProvider::new()),
Box::new(CommandProvider::new()),
];
// Load native plugins
let mut loader = NativePluginLoader::new();
loader.set_disabled(config.plugins.disabled_plugins.clone());
let native_providers = match loader.discover() {
Ok(count) => {
if count == 0 {
info!("No native plugins found");
Vec::new()
} else {
info!("Discovered {} native plugin(s)", count);
let plugins: Vec<Arc<crate::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());
info!(
"Created native provider: {} ({})",
provider.name(),
provider.type_id()
);
providers.push(provider);
}
}
providers
}
}
Err(e) => {
log::warn!("Failed to discover native plugins: {}", e);
Vec::new()
}
};
// Load script runtimes (Lua, Rune) for user plugins
let mut runtime_providers: Vec<Box<dyn Provider>> = Vec::new();
let mut runtimes: Vec<LoadedRuntime> = Vec::new();
let mut runtime_type_ids = std::collections::HashSet::new();
let owlry_version = env!("CARGO_PKG_VERSION");
let skip_runtimes = std::env::var("OWLRY_SKIP_RUNTIMES").is_ok();
if !skip_runtimes
&& let Some(plugins_dir) = crate::paths::plugins_dir()
{
// Try Lua runtime
match LoadedRuntime::load_lua(&plugins_dir, owlry_version) {
Ok(rt) => {
info!("Loaded Lua runtime with {} provider(s)", rt.providers().len());
for provider in rt.create_providers() {
let type_id = format!("{}", provider.provider_type());
runtime_type_ids.insert(type_id);
runtime_providers.push(provider);
}
runtimes.push(rt);
}
Err(e) => {
info!("Lua runtime not available: {}", e);
}
}
// Try Rune runtime
match LoadedRuntime::load_rune(&plugins_dir, owlry_version) {
Ok(rt) => {
info!("Loaded Rune runtime with {} provider(s)", rt.providers().len());
for provider in rt.create_providers() {
let type_id = format!("{}", provider.provider_type());
runtime_type_ids.insert(type_id);
runtime_providers.push(provider);
}
runtimes.push(rt);
}
Err(e) => {
info!("Rune runtime not available: {}", e);
}
}
} // skip_runtimes
// Merge runtime providers into core providers
for provider in runtime_providers {
info!("Registered runtime provider: {}", provider.name());
core_providers.push(provider);
}
let mut manager = Self::new(core_providers, native_providers);
manager.runtimes = runtimes;
manager.runtime_type_ids = runtime_type_ids;
manager
}
/// Reload all script runtime providers (called by filesystem watcher)
pub fn reload_runtimes(&mut self) {
use crate::plugins::runtime_loader::LoadedRuntime;
// Remove old runtime providers from the core providers list
self.providers.retain(|p| {
let type_str = format!("{}", p.provider_type());
!self.runtime_type_ids.contains(&type_str)
});
// Drop old runtimes (catch panics from runtime cleanup)
let old_runtimes = std::mem::take(&mut self.runtimes);
drop(std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
drop(old_runtimes);
})));
self.runtime_type_ids.clear();
let owlry_version = env!("CARGO_PKG_VERSION");
let plugins_dir = match crate::paths::plugins_dir() {
Some(d) => d,
None => return,
};
// Reload Lua runtime
match LoadedRuntime::load_lua(&plugins_dir, owlry_version) {
Ok(rt) => {
info!("Reloaded Lua runtime with {} provider(s)", rt.providers().len());
for provider in rt.create_providers() {
let type_id = format!("{}", provider.provider_type());
self.runtime_type_ids.insert(type_id);
self.providers.push(provider);
}
self.runtimes.push(rt);
}
Err(e) => {
info!("Lua runtime not available on reload: {}", e);
}
}
// Reload Rune runtime
match LoadedRuntime::load_rune(&plugins_dir, owlry_version) {
Ok(rt) => {
info!("Reloaded Rune runtime with {} provider(s)", rt.providers().len());
for provider in rt.create_providers() {
let type_id = format!("{}", provider.provider_type());
self.runtime_type_ids.insert(type_id);
self.providers.push(provider);
}
self.runtimes.push(rt);
}
Err(e) => {
info!("Rune runtime not available on reload: {}", e);
}
}
// Refresh the newly added providers
for provider in &mut self.providers {
provider.refresh();
}
info!("Runtime reload complete");
}
#[allow(dead_code)]
pub fn is_dmenu_mode(&self) -> bool {
self.providers
.iter()
.any(|p| p.provider_type() == ProviderType::Dmenu)
}
pub fn refresh_all(&mut self) {
// Refresh core providers (apps, commands)
for provider in &mut self.providers {
provider.refresh();
info!(
"Provider '{}' loaded {} items",
provider.name(),
provider.items().len()
);
}
// Refresh static native providers (clipboard, emoji, ssh, etc.)
for provider in &mut self.static_native_providers {
provider.refresh();
info!(
"Static provider '{}' loaded {} items",
provider.name(),
provider.items().len()
);
}
// Widget providers are refreshed separately to avoid blocking startup
// Call refresh_widgets() after window is shown
// Dynamic providers don't need refresh (they query on demand)
}
/// Refresh widget providers (weather, media, pomodoro)
/// Call this separately from refresh_all() to avoid blocking startup
/// since widgets may make network requests or spawn processes
pub fn refresh_widgets(&mut self) {
for provider in &mut self.widget_providers {
provider.refresh();
info!(
"Widget '{}' loaded {} items",
provider.name(),
provider.items().len()
);
}
}
/// Find a native provider by type ID
/// Searches in all native provider lists (static, dynamic, widget)
pub fn find_native_provider(&self, type_id: &str) -> Option<&NativeProvider> {
// Check static native providers first (clipboard, emoji, ssh, systemd, etc.)
if let Some(p) = self
.static_native_providers
.iter()
.find(|p| p.type_id() == type_id)
{
return Some(p);
}
// Check widget providers (pomodoro, weather, media)
if let Some(p) = self
.widget_providers
.iter()
.find(|p| p.type_id() == type_id)
{
return Some(p);
}
// Then dynamic providers (calc, websearch, filesearch)
self.dynamic_providers
.iter()
.find(|p| p.type_id() == type_id)
}
/// Execute a plugin action command
/// Command format: PLUGIN_ID:action_data (e.g., "POMODORO:start", "SYSTEMD:unit:restart")
/// Returns true if the command was handled by a plugin
pub fn execute_plugin_action(&self, command: &str) -> bool {
// Parse command format: PLUGIN_ID:action_data
if let Some(colon_pos) = command.find(':') {
let plugin_id = &command[..colon_pos];
let action = command; // Pass full command to plugin
// Find provider by type ID (case-insensitive for convenience)
let type_id = plugin_id.to_lowercase();
if let Some(provider) = self.find_native_provider(&type_id) {
provider.execute_action(action);
return true;
}
}
false
}
/// Add a dynamic provider (e.g., from a Lua plugin)
#[allow(dead_code)]
pub fn add_provider(&mut self, provider: Box<dyn Provider>) {
info!("Added plugin provider: {}", provider.name());
self.providers.push(provider);
}
/// Add multiple providers at once (for batch plugin loading)
#[allow(dead_code)]
pub fn add_providers(&mut self, providers: Vec<Box<dyn Provider>>) {
for provider in providers {
self.add_provider(provider);
}
}
/// Iterate over all static provider items (core + native static plugins)
fn all_static_items(&self) -> impl Iterator<Item = &LaunchItem> {
self.providers.iter().flat_map(|p| p.items().iter()).chain(
self.static_native_providers
.iter()
.flat_map(|p| p.items().iter()),
)
}
#[allow(dead_code)]
pub fn search(&self, query: &str, max_results: usize) -> Vec<(LaunchItem, i64)> {
if query.is_empty() {
// Return recent/popular items when query is empty
return self
.all_static_items()
.take(max_results)
.map(|item| (item.clone(), 0))
.collect();
}
let mut results: Vec<(LaunchItem, i64)> = self
.all_static_items()
.filter_map(|item| {
// Match against name and description
let name_score = self.matcher.fuzzy_match(&item.name, query);
let desc_score = item
.description
.as_ref()
.and_then(|d| self.matcher.fuzzy_match(d, query));
let score = match (name_score, desc_score) {
(Some(n), Some(d)) => Some(n.max(d)),
(Some(n), None) => Some(n),
(None, Some(d)) => Some(d / 2), // Lower weight for description matches
(None, None) => None,
};
score.map(|s| (item.clone(), s))
})
.collect();
// Sort by score (descending)
results.sort_by(|a, b| b.1.cmp(&a.1));
results.truncate(max_results);
results
}
/// Search with provider filtering
pub fn search_filtered(
&self,
query: &str,
max_results: usize,
filter: &crate::filter::ProviderFilter,
) -> Vec<(LaunchItem, i64)> {
// Collect items from core providers
let core_items = self
.providers
.iter()
.filter(|p| filter.is_active(p.provider_type()))
.flat_map(|p| p.items().iter().cloned());
// Collect items from static native providers
let native_items = self
.static_native_providers
.iter()
.filter(|p| filter.is_active(p.provider_type()))
.flat_map(|p| p.items().iter().cloned());
if query.is_empty() {
return core_items
.chain(native_items)
.take(max_results)
.map(|item| (item, 0))
.collect();
}
let mut results: Vec<(LaunchItem, i64)> = core_items
.chain(native_items)
.filter_map(|item| {
let name_score = self.matcher.fuzzy_match(&item.name, query);
let desc_score = item
.description
.as_ref()
.and_then(|d| self.matcher.fuzzy_match(d, query));
let score = match (name_score, desc_score) {
(Some(n), Some(d)) => Some(n.max(d)),
(Some(n), None) => Some(n),
(None, Some(d)) => Some(d / 2),
(None, None) => None,
};
score.map(|s| (item, s))
})
.collect();
results.sort_by(|a, b| b.1.cmp(&a.1));
results.truncate(max_results);
results
}
/// Search with frecency boosting, dynamic providers, and tag filtering
pub fn search_with_frecency(
&self,
query: &str,
max_results: usize,
filter: &crate::filter::ProviderFilter,
frecency: &FrecencyStore,
frecency_weight: f64,
tag_filter: Option<&str>,
) -> Vec<(LaunchItem, i64)> {
#[cfg(feature = "dev-logging")]
debug!(
"[Search] query={:?}, max={}, frecency_weight={}",
query, max_results, frecency_weight
);
let now = Utc::now();
let mut results: Vec<(LaunchItem, i64)> = Vec::new();
// Add widget items first (highest priority) - only when:
// 1. No specific filter prefix is active
// 2. Query is empty (user hasn't started searching)
// This keeps widgets visible on launch but hides them during active search
// Widgets are always visible regardless of filter settings (they declare position via API)
if filter.active_prefix().is_none() && query.is_empty() {
// Widget priority comes from plugin-declared priority field
for provider in &self.widget_providers {
let base_score = provider.priority() as i64;
for (idx, item) in provider.items().iter().enumerate() {
results.push((item.clone(), base_score - idx as i64));
}
}
}
// Query dynamic providers (calculator, websearch, filesearch)
// Only query if:
// 1. Their specific filter is active (e.g., :file prefix or Files tab selected), OR
// 2. No specific single-mode filter is active (showing all providers)
if !query.is_empty() {
for provider in &self.dynamic_providers {
// Skip if this provider type is explicitly filtered out
if !filter.is_active(provider.provider_type()) {
continue;
}
let dynamic_results = provider.query(query);
// Priority comes from plugin-declared priority field
let base_score = provider.priority() as i64;
for (idx, item) in dynamic_results.into_iter().enumerate() {
results.push((item, base_score - idx as i64));
}
}
}
// Empty query (after checking special providers) - return frecency-sorted items
if query.is_empty() {
// Collect items from core providers
let core_items = self
.providers
.iter()
.filter(|p| filter.is_active(p.provider_type()))
.flat_map(|p| p.items().iter().cloned());
// Collect items from static native providers
let native_items = self
.static_native_providers
.iter()
.filter(|p| filter.is_active(p.provider_type()))
.flat_map(|p| p.items().iter().cloned());
let items: Vec<(LaunchItem, i64)> = core_items
.chain(native_items)
.filter(|item| {
// Apply tag filter if present
if let Some(tag) = tag_filter {
item.tags.iter().any(|t| t.to_lowercase().contains(tag))
} else {
true
}
})
.map(|item| {
let frecency_score = frecency.get_score_at(&item.id, now);
let boosted = (frecency_score * frecency_weight * 100.0) as i64;
(item, boosted)
})
.collect();
// Combine widgets (already in results) with frecency items
results.extend(items);
results.sort_by(|a, b| b.1.cmp(&a.1));
results.truncate(max_results);
return results;
}
// Regular search with frecency boost and tag matching
// Helper closure for scoring items
let score_item = |item: &LaunchItem| -> Option<(LaunchItem, i64)> {
// Apply tag filter if present
if let Some(tag) = tag_filter
&& !item.tags.iter().any(|t| t.to_lowercase().contains(tag))
{
return None;
}
let name_score = self.matcher.fuzzy_match(&item.name, query);
let desc_score = item
.description
.as_ref()
.and_then(|d| self.matcher.fuzzy_match(d, query));
// Also match against tags (lower weight)
let tag_score = item
.tags
.iter()
.filter_map(|t| self.matcher.fuzzy_match(t, query))
.max()
.map(|s| s / 3); // Lower weight for tag matches
let base_score = match (name_score, desc_score, tag_score) {
(Some(n), Some(d), Some(t)) => Some(n.max(d).max(t)),
(Some(n), Some(d), None) => Some(n.max(d)),
(Some(n), None, Some(t)) => Some(n.max(t)),
(Some(n), None, None) => Some(n),
(None, Some(d), Some(t)) => Some((d / 2).max(t)),
(None, Some(d), None) => Some(d / 2),
(None, None, Some(t)) => Some(t),
(None, None, None) => None,
};
base_score.map(|s| {
let frecency_score = frecency.get_score_at(&item.id, now);
let frecency_boost = (frecency_score * frecency_weight * 10.0) as i64;
(item.clone(), s + frecency_boost)
})
};
// Search core providers
for provider in &self.providers {
if !filter.is_active(provider.provider_type()) {
continue;
}
for item in provider.items() {
if let Some(scored) = score_item(item) {
results.push(scored);
}
}
}
// Search static native providers
for provider in &self.static_native_providers {
if !filter.is_active(provider.provider_type()) {
continue;
}
for item in provider.items() {
if let Some(scored) = score_item(item) {
results.push(scored);
}
}
}
results.sort_by(|a, b| b.1.cmp(&a.1));
results.truncate(max_results);
#[cfg(feature = "dev-logging")]
{
debug!("[Search] Returning {} results", results.len());
for (i, (item, score)) in results.iter().take(5).enumerate() {
debug!(
"[Search] #{}: {} (score={}, provider={:?})",
i + 1,
item.name,
score,
item.provider
);
}
if results.len() > 5 {
debug!("[Search] ... and {} more", results.len() - 5);
}
}
results
}
/// Get all available provider types (for UI tabs)
#[allow(dead_code)]
pub fn available_provider_types(&self) -> Vec<ProviderType> {
self.providers
.iter()
.map(|p| p.provider_type())
.chain(
self.static_native_providers
.iter()
.map(|p| p.provider_type()),
)
.collect()
}
/// Get descriptors for all registered providers (core + native plugins).
///
/// Used by the IPC server to report what providers are available to clients.
pub fn available_providers(&self) -> Vec<ProviderDescriptor> {
let mut descs = Vec::new();
// Core providers
for provider in &self.providers {
let (id, prefix, icon) = match provider.provider_type() {
ProviderType::Application => (
"app".to_string(),
Some(":app".to_string()),
"application-x-executable".to_string(),
),
ProviderType::Command => (
"cmd".to_string(),
Some(":cmd".to_string()),
"utilities-terminal".to_string(),
),
ProviderType::Dmenu => {
("dmenu".to_string(), None, "view-list-symbolic".to_string())
}
ProviderType::Plugin(type_id) => (type_id, None, "application-x-addon".to_string()),
};
descs.push(ProviderDescriptor {
id,
name: provider.name().to_string(),
prefix,
icon,
position: "normal".to_string(),
});
}
// Static native plugin providers
for provider in &self.static_native_providers {
descs.push(ProviderDescriptor {
id: provider.type_id().to_string(),
name: provider.name().to_string(),
prefix: provider.prefix().map(String::from),
icon: provider.icon().to_string(),
position: provider.position_str().to_string(),
});
}
// Dynamic native plugin providers
for provider in &self.dynamic_providers {
descs.push(ProviderDescriptor {
id: provider.type_id().to_string(),
name: provider.name().to_string(),
prefix: provider.prefix().map(String::from),
icon: provider.icon().to_string(),
position: provider.position_str().to_string(),
});
}
// Widget native plugin providers
for provider in &self.widget_providers {
descs.push(ProviderDescriptor {
id: provider.type_id().to_string(),
name: provider.name().to_string(),
prefix: provider.prefix().map(String::from),
icon: provider.icon().to_string(),
position: provider.position_str().to_string(),
});
}
descs
}
/// Refresh a specific provider by its type_id.
///
/// Searches core providers (by ProviderType string), static native providers,
/// and widget providers. Dynamic providers are skipped (they query on demand).
pub fn refresh_provider(&mut self, provider_id: &str) {
// Check core providers
for provider in &mut self.providers {
let matches = match provider.provider_type() {
ProviderType::Application => provider_id == "app",
ProviderType::Command => provider_id == "cmd",
ProviderType::Dmenu => provider_id == "dmenu",
ProviderType::Plugin(ref id) => provider_id == id,
};
if matches {
provider.refresh();
info!("Refreshed core provider '{}'", provider.name());
return;
}
}
// Check static native providers
for provider in &mut self.static_native_providers {
if provider.type_id() == provider_id {
provider.refresh();
info!("Refreshed static provider '{}'", provider.name());
return;
}
}
// Check widget providers
for provider in &mut self.widget_providers {
if provider.type_id() == provider_id {
provider.refresh();
info!("Refreshed widget provider '{}'", provider.name());
return;
}
}
info!("Provider '{}' not found for refresh", provider_id);
}
/// Get a widget item by type_id (e.g., "pomodoro", "weather", "media")
/// Returns the first item from the widget provider, if any
pub fn get_widget_item(&self, type_id: &str) -> Option<LaunchItem> {
self.widget_providers
.iter()
.find(|p| p.type_id() == type_id)
.and_then(|p| p.items().first().cloned())
}
/// Get all loaded widget provider type_ids
/// Returns an iterator over the type_ids of currently loaded widget providers
pub fn widget_type_ids(&self) -> impl Iterator<Item = &str> {
self.widget_providers.iter().map(|p| p.type_id())
}
/// Query a plugin for submenu actions
///
/// This is used when a user selects a SUBMENU:plugin_id:data item.
/// The plugin is queried with "?SUBMENU:data" and returns action items.
///
/// Returns (display_name, actions) where display_name is the item name
/// and actions are the submenu items returned by the plugin.
pub fn query_submenu_actions(
&self,
plugin_id: &str,
data: &str,
display_name: &str,
) -> Option<(String, Vec<LaunchItem>)> {
// Build the submenu query
let submenu_query = format!("?SUBMENU:{}", data);
#[cfg(feature = "dev-logging")]
debug!(
"[Submenu] Querying plugin '{}' with: {}",
plugin_id, submenu_query
);
// Search in static native providers (clipboard, emoji, ssh, systemd, etc.)
for provider in &self.static_native_providers {
if provider.type_id() == plugin_id {
let actions = provider.query(&submenu_query);
if !actions.is_empty() {
return Some((display_name.to_string(), actions));
}
}
}
// Search in dynamic providers
for provider in &self.dynamic_providers {
if provider.type_id() == plugin_id {
let actions = provider.query(&submenu_query);
if !actions.is_empty() {
return Some((display_name.to_string(), actions));
}
}
}
// Search in widget providers
for provider in &self.widget_providers {
if provider.type_id() == plugin_id {
let actions = provider.query(&submenu_query);
if !actions.is_empty() {
return Some((display_name.to_string(), actions));
}
}
}
#[cfg(feature = "dev-logging")]
debug!(
"[Submenu] No submenu actions found for plugin '{}'",
plugin_id
);
None
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Minimal mock provider for testing ProviderManager
struct MockProvider {
name: String,
provider_type: ProviderType,
items: Vec<LaunchItem>,
refresh_count: usize,
}
impl MockProvider {
fn new(name: &str, provider_type: ProviderType) -> Self {
Self {
name: name.to_string(),
provider_type,
items: Vec::new(),
refresh_count: 0,
}
}
fn with_items(mut self, items: Vec<LaunchItem>) -> Self {
self.items = items;
self
}
}
impl Provider for MockProvider {
fn name(&self) -> &str {
&self.name
}
fn provider_type(&self) -> ProviderType {
self.provider_type.clone()
}
fn refresh(&mut self) {
self.refresh_count += 1;
}
fn items(&self) -> &[LaunchItem] {
&self.items
}
}
fn make_item(id: &str, name: &str, provider: ProviderType) -> LaunchItem {
LaunchItem {
id: id.to_string(),
name: name.to_string(),
description: None,
icon: None,
provider,
command: format!("run-{}", id),
terminal: false,
tags: Vec::new(),
}
}
#[test]
fn test_available_providers_core_only() {
let providers: Vec<Box<dyn Provider>> = vec![
Box::new(MockProvider::new("Applications", ProviderType::Application)),
Box::new(MockProvider::new("Commands", ProviderType::Command)),
];
let pm = ProviderManager::new(providers, Vec::new());
let descs = pm.available_providers();
assert_eq!(descs.len(), 2);
assert_eq!(descs[0].id, "app");
assert_eq!(descs[0].name, "Applications");
assert_eq!(descs[0].prefix, Some(":app".to_string()));
assert_eq!(descs[0].icon, "application-x-executable");
assert_eq!(descs[0].position, "normal");
assert_eq!(descs[1].id, "cmd");
assert_eq!(descs[1].name, "Commands");
}
#[test]
fn test_available_providers_dmenu() {
let providers: Vec<Box<dyn Provider>> =
vec![Box::new(MockProvider::new("dmenu", ProviderType::Dmenu))];
let pm = ProviderManager::new(providers, Vec::new());
let descs = pm.available_providers();
assert_eq!(descs.len(), 1);
assert_eq!(descs[0].id, "dmenu");
assert!(descs[0].prefix.is_none());
}
#[test]
fn test_available_provider_types() {
let providers: Vec<Box<dyn Provider>> = vec![
Box::new(MockProvider::new("Applications", ProviderType::Application)),
Box::new(MockProvider::new("Commands", ProviderType::Command)),
];
let pm = ProviderManager::new(providers, Vec::new());
let types = pm.available_provider_types();
assert_eq!(types.len(), 2);
assert!(types.contains(&ProviderType::Application));
assert!(types.contains(&ProviderType::Command));
}
#[test]
fn test_refresh_provider_core() {
let app = MockProvider::new("Applications", ProviderType::Application);
let cmd = MockProvider::new("Commands", ProviderType::Command);
let providers: Vec<Box<dyn Provider>> = vec![Box::new(app), Box::new(cmd)];
let mut pm = ProviderManager::new(providers, Vec::new());
// refresh_all was called during construction, now refresh individual
pm.refresh_provider("app");
pm.refresh_provider("cmd");
// Just verifying it doesn't panic; can't easily inspect refresh_count
// through Box<dyn Provider>
}
#[test]
fn test_refresh_provider_unknown_does_not_panic() {
let providers: Vec<Box<dyn Provider>> = vec![Box::new(MockProvider::new(
"Applications",
ProviderType::Application,
))];
let mut pm = ProviderManager::new(providers, Vec::new());
pm.refresh_provider("nonexistent");
// Should complete without panicking
}
#[test]
fn test_search_with_core_providers() {
let items = vec![
make_item("firefox", "Firefox", ProviderType::Application),
make_item("vim", "Vim", ProviderType::Application),
];
let provider =
MockProvider::new("Applications", ProviderType::Application).with_items(items);
let providers: Vec<Box<dyn Provider>> = vec![Box::new(provider)];
let pm = ProviderManager::new(providers, Vec::new());
let results = pm.search("fire", 10);
assert_eq!(results.len(), 1);
assert_eq!(results[0].0.name, "Firefox");
}
}