feat(providers): convert remaining 6 plugins from C-ABI to native impls

Closes the v2 plugin conversion. Six providers ported from the
owlry-plugins sibling repo into the single owlry crate as feature-
gated modules. Each follows the same pattern established by systemd:
drop extern "C"/PluginItem/ProviderHandle/owlry_plugin! scaffolding,
implement Provider or DynamicProvider directly on a regular struct.

Static providers (Provider trait, populate via refresh):
- providers/bookmarks.rs — Firefox + Chromium bookmarks via rusqlite,
  favicon cache preserved. dep: rusqlite (bundled), feature: bookmarks
- providers/clipboard.rs — cliphist history. feature: clipboard
- providers/emoji.rs — bundled emoji list with keyword tags.
  feature: emoji
- providers/ssh.rs — ~/.ssh/config host extraction. feature: ssh

Dynamic providers (DynamicProvider trait, generate per query):
- providers/filesearch.rs — fd / mlocate shellout with extract_search_term
  for ':file' and '/' triggers. feature: filesearch
- providers/websearch.rs — URL builder with DuckDuckGo/Google/custom
  engines. TODO: plumb engine through constructor once Lua config lands
  (Phase 3). feature: websearch

Wiring:
- Cargo.toml: 7 per-provider features + 'full' meta-feature. rusqlite
  added as optional dep (only pulled in with feature 'bookmarks').
- config/mod.rs: ProvidersConfig gains 6 new bool fields (defaults true)
- providers/mod.rs: gated module declarations + new_with_config takes a
  config snapshot and registers each provider behind its feature flag

Verification across feature axes:
- --no-default-features: 178 tests pass (feature-gated modules excluded)
- default (systemd only): 186 tests pass
- --features full: 233 tests pass (+55 from the 6 new conversions)

Tasks #6 and #7 complete.
This commit is contained in:
2026-05-13 02:17:42 +02:00
parent eb8a65f1fd
commit cb2ea5973b
11 changed files with 2211 additions and 16 deletions
Generated
+80 -1
View File
@@ -580,6 +580,18 @@ dependencies = [
"unicode-width",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.3.0"
@@ -608,6 +620,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "foreign-types"
version = "0.3.2"
@@ -1192,7 +1210,7 @@ version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
"foldhash 0.1.5",
]
[[package]]
@@ -1200,6 +1218,18 @@ name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"foldhash 0.2.0",
]
[[package]]
name = "hashlink"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230"
dependencies = [
"hashbrown 0.16.1",
]
[[package]]
name = "heck"
@@ -1557,6 +1587,17 @@ dependencies = [
"libc",
]
[[package]]
name = "libsqlite3-sys"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@@ -1841,6 +1882,7 @@ dependencies = [
"log",
"notify-rust",
"reqwest",
"rusqlite",
"serde",
"serde_json",
"signal-hook",
@@ -2087,6 +2129,31 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rsqlite-vfs"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d"
dependencies = [
"hashbrown 0.16.1",
"thiserror 2.0.18",
]
[[package]]
name = "rusqlite"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e"
dependencies = [
"bitflags",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
"sqlite-wasm-rs",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -2282,6 +2349,18 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "sqlite-wasm-rs"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b2c760607300407ddeaee518acf28c795661b7108c75421303dbefb237d3a36"
dependencies = [
"cc",
"js-sys",
"rsqlite-vfs",
"wasm-bindgen",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
+18 -1
View File
@@ -60,6 +60,9 @@ signal-hook = "0.3"
expr-solver-lib = "1"
reqwest = { version = "0.13", default-features = false, features = ["native-tls", "json", "blocking"] }
# Optional providers (gated by cargo features below)
rusqlite = { version = "0.39", features = ["bundled"], optional = true }
# Async oneshot channel (background thread -> main loop)
futures-channel = "0.3"
@@ -78,6 +81,20 @@ dev-logging = []
# Optional providers (compiled in when enabled).
# The AUR PKGBUILD builds with --features "full" to ship everything.
bookmarks = ["dep:rusqlite"]
clipboard = []
emoji = []
filesearch = []
ssh = []
systemd = []
websearch = []
full = ["systemd"]
full = [
"bookmarks",
"clipboard",
"emoji",
"filesearch",
"ssh",
"systemd",
"websearch",
]
+24
View File
@@ -174,6 +174,24 @@ pub struct ProvidersConfig {
/// Enable systemd user units provider (alias: uuctl)
#[serde(default = "default_true", alias = "uuctl")]
pub systemd: bool,
/// Enable bookmarks provider (Firefox + Chromium)
#[serde(default = "default_true")]
pub bookmarks: bool,
/// Enable clipboard history provider (requires cliphist)
#[serde(default = "default_true")]
pub clipboard: bool,
/// Enable emoji picker provider
#[serde(default = "default_true")]
pub emoji: bool,
/// Enable filesystem search provider (uses fd or mlocate)
#[serde(default = "default_true")]
pub filesearch: bool,
/// Enable SSH host provider (parses ~/.ssh/config)
#[serde(default = "default_true")]
pub ssh: bool,
/// Enable web search provider (DuckDuckGo / configurable)
#[serde(default = "default_true")]
pub websearch: bool,
/// Enable frecency-based result ranking
#[serde(default = "default_true")]
pub frecency: bool,
@@ -196,6 +214,12 @@ impl Default for ProvidersConfig {
converter: true,
system: true,
systemd: true,
bookmarks: true,
clipboard: true,
emoji: true,
filesearch: true,
ssh: true,
websearch: true,
frecency: true,
frecency_weight: 0.3,
search_engine: "duckduckgo".to_string(),
+469
View File
@@ -0,0 +1,469 @@
//! Browser bookmarks provider.
//!
//! Reads bookmarks from Firefox (`places.sqlite` via `rusqlite`) and
//! Chromium-family browsers (Chrome, Chromium, Brave, Edge — JSON
//! `Bookmarks` files). Items launch via `xdg-open <url>`.
//!
//! This is a static provider: `refresh()` populates `self.items`, and the
//! core fuzzy-matches against the cached list.
use super::{ItemSource, LaunchItem, Provider, ProviderType};
use rusqlite::{Connection, OpenFlags};
use serde::Deserialize;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
const TYPE_ID: &str = "bookmarks";
const ICON: &str = "user-bookmarks-symbolic";
pub struct BookmarksProvider {
items: Vec<LaunchItem>,
}
impl Default for BookmarksProvider {
fn default() -> Self {
Self::new()
}
}
impl BookmarksProvider {
pub fn new() -> Self {
Self { items: Vec::new() }
}
/// Cache directory for downloaded Firefox favicons.
fn favicon_cache_dir() -> Option<PathBuf> {
dirs::cache_dir().map(|d| d.join("owlry/favicons"))
}
fn ensure_favicon_cache_dir() -> Option<PathBuf> {
Self::favicon_cache_dir().and_then(|dir| {
fs::create_dir_all(&dir).ok()?;
Some(dir)
})
}
/// Hash a URL into a stable cache filename. Collisions are acceptable —
/// the worst case is a favicon shown for the wrong bookmark.
fn url_to_cache_filename(url: &str) -> String {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
url.hash(&mut hasher);
format!("{:016x}.png", hasher.finish())
}
fn chromium_bookmark_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Some(config_dir) = dirs::config_dir() {
// Chrome
paths.push(config_dir.join("google-chrome/Default/Bookmarks"));
paths.push(config_dir.join("google-chrome-stable/Default/Bookmarks"));
// Chromium
paths.push(config_dir.join("chromium/Default/Bookmarks"));
// Brave
paths.push(config_dir.join("BraveSoftware/Brave-Browser/Default/Bookmarks"));
// Edge
paths.push(config_dir.join("microsoft-edge/Default/Bookmarks"));
}
paths
}
fn firefox_places_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Some(home) = dirs::home_dir() {
let firefox_dir = home.join(".mozilla/firefox");
if firefox_dir.exists()
&& let Ok(entries) = fs::read_dir(&firefox_dir)
{
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let places = path.join("places.sqlite");
if places.exists() {
paths.push(places);
}
}
}
}
}
paths
}
/// Sibling `favicons.sqlite` next to a given `places.sqlite`, if present.
fn firefox_favicons_path(places_path: &Path) -> Option<PathBuf> {
let favicons = places_path.parent()?.join("favicons.sqlite");
if favicons.exists() {
Some(favicons)
} else {
None
}
}
fn read_chrome_bookmarks(path: &Path, items: &mut Vec<LaunchItem>) {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return,
};
let bookmarks: ChromeBookmarks = match serde_json::from_str(&content) {
Ok(b) => b,
Err(_) => return,
};
if let Some(roots) = bookmarks.roots {
if let Some(bar) = roots.bookmark_bar {
Self::process_chrome_folder(&bar, items);
}
if let Some(other) = roots.other {
Self::process_chrome_folder(&other, items);
}
if let Some(synced) = roots.synced {
Self::process_chrome_folder(&synced, items);
}
}
}
fn process_chrome_folder(folder: &ChromeBookmarkNode, items: &mut Vec<LaunchItem>) {
if let Some(ref children) = folder.children {
for child in children {
match child.node_type.as_deref() {
Some("url") => {
if let Some(ref url) = child.url {
let name = child.name.clone().unwrap_or_else(|| url.clone());
items.push(LaunchItem {
id: format!("bookmark:{}", url),
name,
description: Some(url.clone()),
icon: Some(ICON.to_string()),
provider: ProviderType::Plugin(TYPE_ID.into()),
command: format!("xdg-open '{}'", url.replace('\'', "'\\''")),
terminal: false,
tags: vec!["bookmark".to_string(), "chrome".to_string()],
source: ItemSource::Core,
});
}
}
Some("folder") => {
Self::process_chrome_folder(child, items);
}
_ => {}
}
}
}
}
/// Read Firefox bookmarks via `rusqlite`. Copies the DB (+ WAL) into a
/// temp file first so we can open in read-only mode without contesting
/// the lock Firefox holds while running.
fn read_firefox_bookmarks(places_path: &Path, items: &mut Vec<LaunchItem>) {
let temp_dir = std::env::temp_dir();
let pid = std::process::id();
let temp_db = temp_dir.join(format!("owlry_places_{}.sqlite", pid));
if fs::copy(places_path, &temp_db).is_err() {
return;
}
let wal_path = places_path.with_extension("sqlite-wal");
if wal_path.exists() {
let temp_wal = temp_db.with_extension("sqlite-wal");
let _ = fs::copy(&wal_path, &temp_wal);
}
let favicons_path = Self::firefox_favicons_path(places_path);
let temp_favicons = temp_dir.join(format!("owlry_favicons_{}.sqlite", pid));
if let Some(ref fp) = favicons_path {
let _ = fs::copy(fp, &temp_favicons);
let fav_wal = fp.with_extension("sqlite-wal");
if fav_wal.exists() {
let _ = fs::copy(&fav_wal, temp_favicons.with_extension("sqlite-wal"));
}
}
let cache_dir = Self::ensure_favicon_cache_dir();
let bookmarks = Self::fetch_firefox_bookmarks(&temp_db, &temp_favicons, cache_dir.as_ref());
let _ = fs::remove_file(&temp_db);
let _ = fs::remove_file(temp_db.with_extension("sqlite-wal"));
let _ = fs::remove_file(&temp_favicons);
let _ = fs::remove_file(temp_favicons.with_extension("sqlite-wal"));
for (title, url, favicon_path) in bookmarks {
let icon = favicon_path.unwrap_or_else(|| ICON.to_string());
items.push(LaunchItem {
id: format!("bookmark:firefox:{}", url),
name: title,
description: Some(url.clone()),
icon: Some(icon),
provider: ProviderType::Plugin(TYPE_ID.into()),
command: format!("xdg-open '{}'", url.replace('\'', "'\\''")),
terminal: false,
tags: vec!["bookmark".to_string(), "firefox".to_string()],
source: ItemSource::Core,
});
}
}
/// Run the bookmark SELECT against `places.sqlite` and, if available,
/// fetch + cache favicons from `favicons.sqlite`.
fn fetch_firefox_bookmarks(
places_path: &Path,
favicons_path: &Path,
cache_dir: Option<&PathBuf>,
) -> Vec<(String, String, Option<String>)> {
let conn = match Connection::open_with_flags(
places_path,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
// type=1 means URL bookmarks (not folders, separators, etc.). We
// skip Firefox-internal `place:` and `about:` URLs.
let query = r#"
SELECT b.title, p.url
FROM moz_bookmarks b
JOIN moz_places p ON b.fk = p.id
WHERE b.type = 1
AND p.url NOT LIKE 'place:%'
AND p.url NOT LIKE 'about:%'
AND b.title IS NOT NULL
AND b.title != ''
ORDER BY b.dateAdded DESC
LIMIT 500
"#;
let mut stmt = match conn.prepare(query) {
Ok(s) => s,
Err(_) => return Vec::new(),
};
let bookmarks: Vec<(String, String)> = stmt
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.ok()
.map(|rows| rows.filter_map(|r| r.ok()).collect())
.unwrap_or_default();
let cache_dir = match cache_dir {
Some(c) => c,
None => return bookmarks.into_iter().map(|(t, u)| (t, u, None)).collect(),
};
let fav_conn = match Connection::open_with_flags(
favicons_path,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
) {
Ok(c) => c,
Err(_) => return bookmarks.into_iter().map(|(t, u)| (t, u, None)).collect(),
};
let mut results = Vec::new();
for (title, url) in bookmarks {
let favicon_path = Self::get_favicon_for_url(&fav_conn, &url, cache_dir);
results.push((title, url, favicon_path));
}
results
}
/// Look up a favicon for `page_url` in Firefox's `favicons.sqlite`, writing
/// the blob to the cache directory on first hit. Returns the cached path
/// (or None if no favicon / write failed).
fn get_favicon_for_url(conn: &Connection, page_url: &str, cache_dir: &Path) -> Option<String> {
let cache_filename = Self::url_to_cache_filename(page_url);
let cache_path = cache_dir.join(&cache_filename);
if cache_path.exists() {
return Some(cache_path.to_string_lossy().to_string());
}
// Prefer the 32px-ish icon if multiple sizes exist.
let query = r#"
SELECT i.data
FROM moz_pages_w_icons p
JOIN moz_icons_to_pages ip ON p.id = ip.page_id
JOIN moz_icons i ON ip.icon_id = i.id
WHERE p.page_url = ?
AND i.data IS NOT NULL
ORDER BY ABS(i.width - 32) ASC
LIMIT 1
"#;
let data: Option<Vec<u8>> = conn.query_row(query, [page_url], |row| row.get(0)).ok();
let data = data?;
if data.is_empty() {
return None;
}
let mut file = fs::File::create(&cache_path).ok()?;
file.write_all(&data).ok()?;
Some(cache_path.to_string_lossy().to_string())
}
}
impl Provider for BookmarksProvider {
fn name(&self) -> &str {
"Bookmarks"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Plugin(TYPE_ID.into())
}
fn refresh(&mut self) {
self.items.clear();
for path in Self::chromium_bookmark_paths() {
if path.exists() {
Self::read_chrome_bookmarks(&path, &mut self.items);
}
}
for path in Self::firefox_places_paths() {
Self::read_firefox_bookmarks(&path, &mut self.items);
}
}
fn items(&self) -> &[LaunchItem] {
&self.items
}
fn prefix(&self) -> Option<&str> {
Some(":bm")
}
fn icon(&self) -> &str {
ICON
}
fn tab_label(&self) -> Option<&str> {
Some("Bookmarks")
}
fn search_noun(&self) -> Option<&str> {
Some("bookmarks")
}
}
// Chrome's `Bookmarks` JSON layout.
#[derive(Debug, Deserialize)]
struct ChromeBookmarks {
roots: Option<ChromeBookmarkRoots>,
}
#[derive(Debug, Deserialize)]
struct ChromeBookmarkRoots {
bookmark_bar: Option<ChromeBookmarkNode>,
other: Option<ChromeBookmarkNode>,
synced: Option<ChromeBookmarkNode>,
}
#[derive(Debug, Deserialize)]
struct ChromeBookmarkNode {
name: Option<String>,
url: Option<String>,
#[serde(rename = "type")]
node_type: Option<String>,
children: Option<Vec<ChromeBookmarkNode>>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bookmarks_state_new() {
let p = BookmarksProvider::new();
assert!(p.items().is_empty());
}
#[test]
fn test_chromium_paths() {
let paths = BookmarksProvider::chromium_bookmark_paths();
// Should have at least some paths configured (5 known browsers).
assert!(!paths.is_empty());
}
#[test]
fn test_firefox_paths() {
// Path detection should not panic, even if Firefox isn't installed.
let paths = BookmarksProvider::firefox_places_paths();
let _ = paths.len();
}
#[test]
fn test_parse_chrome_bookmarks() {
let json = r#"{
"roots": {
"bookmark_bar": {
"type": "folder",
"children": [
{
"type": "url",
"name": "Example",
"url": "https://example.com"
}
]
}
}
}"#;
let bookmarks: ChromeBookmarks = serde_json::from_str(json).unwrap();
assert!(bookmarks.roots.is_some());
let roots = bookmarks.roots.unwrap();
assert!(roots.bookmark_bar.is_some());
let bar = roots.bookmark_bar.unwrap();
assert!(bar.children.is_some());
assert_eq!(bar.children.unwrap().len(), 1);
}
#[test]
fn test_process_folder() {
let mut items = Vec::new();
let folder = ChromeBookmarkNode {
name: Some("Test Folder".to_string()),
url: None,
node_type: Some("folder".to_string()),
children: Some(vec![ChromeBookmarkNode {
name: Some("Test Bookmark".to_string()),
url: Some("https://test.com".to_string()),
node_type: Some("url".to_string()),
children: None,
}]),
};
BookmarksProvider::process_chrome_folder(&folder, &mut items);
assert_eq!(items.len(), 1);
assert_eq!(items[0].name, "Test Bookmark");
assert_eq!(items[0].provider, ProviderType::Plugin("bookmarks".into()));
assert_eq!(items[0].source, ItemSource::Core);
assert!(items[0].command.starts_with("xdg-open '"));
}
#[test]
fn test_url_escaping() {
let url = "https://example.com/path?query='test'";
let command = format!("xdg-open '{}'", url.replace('\'', "'\\''"));
assert!(command.contains("'\\''"));
}
#[test]
fn provider_type_is_bookmarks_plugin() {
let p = BookmarksProvider::new();
assert_eq!(p.provider_type(), ProviderType::Plugin("bookmarks".into()));
}
#[test]
fn provider_prefix_is_bm() {
let p = BookmarksProvider::new();
assert_eq!(p.prefix(), Some(":bm"));
}
}
+249
View File
@@ -0,0 +1,249 @@
//! Clipboard history provider.
//!
//! Static provider that surfaces `cliphist` entries as launch items. Selecting
//! an item runs `cliphist decode | wl-copy` to put the entry back on the
//! clipboard. Requires `cliphist` and `wl-clipboard` to be installed.
use super::{ItemSource, LaunchItem, Provider, ProviderType};
use std::process::Command;
const TYPE_ID: &str = "clipboard";
const PROVIDER_ICON: &str = "edit-paste";
const DEFAULT_MAX_ENTRIES: usize = 50;
pub struct ClipboardProvider {
items: Vec<LaunchItem>,
max_entries: usize,
}
impl Default for ClipboardProvider {
fn default() -> Self {
Self::new()
}
}
impl ClipboardProvider {
pub fn new() -> Self {
Self {
items: Vec::new(),
max_entries: DEFAULT_MAX_ENTRIES,
}
}
fn has_cliphist() -> bool {
Command::new("which")
.arg("cliphist")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn parse_cliphist_output(output: &str, max_entries: usize) -> Vec<LaunchItem> {
let mut items = Vec::new();
for (idx, line) in output.lines().take(max_entries).enumerate() {
// cliphist format: "id\tpreview"
let parts: Vec<&str> = line.splitn(2, '\t').collect();
if parts.is_empty() {
continue;
}
let clip_id = parts[0];
let preview = if parts.len() > 1 {
// Truncate long previews (char-safe for UTF-8)
let p = parts[1];
if p.chars().count() > 80 {
let truncated: String = p.chars().take(77).collect();
format!("{}...", truncated)
} else {
p.to_string()
}
} else {
"[binary data]".to_string()
};
// Clean up preview - replace newlines/tabs with spaces, strip CR.
let preview_clean = preview
.replace('\n', " ")
.replace('\r', "")
.replace('\t', " ");
// Command to paste this entry: echo "id" | cliphist decode | wl-copy
let command = format!(
"echo '{}' | cliphist decode | wl-copy",
clip_id.replace('\'', "'\\''")
);
items.push(LaunchItem {
id: format!("clipboard:{}", idx),
name: preview_clean,
description: Some("Copy to clipboard".to_string()),
icon: Some(PROVIDER_ICON.to_string()),
provider: ProviderType::Plugin(TYPE_ID.into()),
command,
terminal: false,
tags: vec!["clipboard".to_string()],
source: ItemSource::Core,
});
}
items
}
}
impl Provider for ClipboardProvider {
fn name(&self) -> &str {
"Clipboard"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Plugin(TYPE_ID.into())
}
fn refresh(&mut self) {
self.items.clear();
if !Self::has_cliphist() {
return;
}
let output = match Command::new("cliphist").arg("list").output() {
Ok(o) if o.status.success() => o,
_ => return,
};
let stdout = String::from_utf8_lossy(&output.stdout);
self.items = Self::parse_cliphist_output(&stdout, self.max_entries);
}
fn items(&self) -> &[LaunchItem] {
&self.items
}
fn prefix(&self) -> Option<&str> {
Some(":clip")
}
fn icon(&self) -> &str {
PROVIDER_ICON
}
fn tab_label(&self) -> Option<&str> {
Some("Clipboard")
}
fn search_noun(&self) -> Option<&str> {
Some("clipboard entries")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_clipboard_provider_new() {
let p = ClipboardProvider::new();
assert!(p.items.is_empty());
assert_eq!(p.max_entries, DEFAULT_MAX_ENTRIES);
}
#[test]
fn test_preview_truncation() {
// Long ASCII strings get truncated char-safely to 80 chars with "..." suffix.
let long_text = "a".repeat(100);
let truncated = if long_text.chars().count() > 80 {
let t: String = long_text.chars().take(77).collect();
format!("{}...", t)
} else {
long_text.clone()
};
assert_eq!(truncated.chars().count(), 80);
assert!(truncated.ends_with("..."));
}
#[test]
fn test_preview_truncation_utf8() {
// Multi-byte UTF-8 chars must not split mid-codepoint.
let utf8_text = "├── ".repeat(30);
let truncated = if utf8_text.chars().count() > 80 {
let t: String = utf8_text.chars().take(77).collect();
format!("{}...", t)
} else {
utf8_text.clone()
};
assert_eq!(truncated.chars().count(), 80);
assert!(truncated.ends_with("..."));
}
#[test]
fn test_preview_cleaning() {
let dirty = "line1\nline2\tcolumn\rend";
let clean = dirty
.replace('\n', " ")
.replace('\r', "")
.replace('\t', " ");
assert_eq!(clean, "line1 line2 columnend");
}
#[test]
fn test_command_escaping() {
let clip_id = "test'id";
let command = format!(
"echo '{}' | cliphist decode | wl-copy",
clip_id.replace('\'', "'\\''")
);
assert!(command.contains("test'\\''id"));
}
#[test]
fn test_has_cliphist_runs() {
// Just ensure it doesn't panic — cliphist may or may not be installed.
let _ = ClipboardProvider::has_cliphist();
}
#[test]
fn parse_cliphist_output_builds_items_with_plugin_provider_type() {
let output = "1\thello world\n2\tanother entry\n";
let items = ClipboardProvider::parse_cliphist_output(output, DEFAULT_MAX_ENTRIES);
assert_eq!(items.len(), 2);
assert_eq!(items[0].name, "hello world");
assert_eq!(items[0].provider, ProviderType::Plugin("clipboard".into()));
assert_eq!(items[0].source, ItemSource::Core);
assert!(items[0].command.contains("cliphist decode"));
assert!(items[0].command.contains("wl-copy"));
assert!(!items[0].terminal);
}
#[test]
fn parse_cliphist_output_respects_max_entries() {
let output = "1\ta\n2\tb\n3\tc\n4\td\n";
let items = ClipboardProvider::parse_cliphist_output(output, 2);
assert_eq!(items.len(), 2);
}
#[test]
fn parse_cliphist_output_handles_missing_preview_as_binary() {
let output = "42\n";
let items = ClipboardProvider::parse_cliphist_output(output, DEFAULT_MAX_ENTRIES);
assert_eq!(items.len(), 1);
assert_eq!(items[0].name, "[binary data]");
}
#[test]
fn provider_type_is_clipboard_plugin() {
// CLI back-compat: `-m clip` / `:clip` resolve to this type_id.
let p = ClipboardProvider::new();
assert_eq!(p.provider_type(), ProviderType::Plugin("clipboard".into()));
}
#[test]
fn provider_metadata_exposes_clip_prefix_and_labels() {
let p = ClipboardProvider::new();
assert_eq!(p.prefix(), Some(":clip"));
assert_eq!(p.icon(), "edit-paste");
assert_eq!(p.tab_label(), Some("Clipboard"));
assert_eq!(p.search_noun(), Some("clipboard entries"));
}
}
+518
View File
@@ -0,0 +1,518 @@
//! Emoji provider.
//!
//! Static provider exposing a curated set of emojis. Selecting an item copies
//! the emoji glyph to the Wayland clipboard via `wl-copy`. Search matches the
//! human-readable name and the keyword bag.
use super::{ItemSource, LaunchItem, Provider, ProviderType};
const TYPE_ID: &str = "emoji";
/// (emoji, name, space-separated keywords)
const EMOJIS: &[(&str, &str, &str)] = &[
// Smileys & Emotion
("😀", "grinning face", "smile happy"),
("😃", "grinning face with big eyes", "smile happy"),
("😄", "grinning face with smiling eyes", "smile happy laugh"),
("😁", "beaming face with smiling eyes", "smile happy grin"),
("😅", "grinning face with sweat", "smile nervous"),
("🤣", "rolling on the floor laughing", "lol rofl funny"),
("😂", "face with tears of joy", "laugh cry funny lol"),
("🙂", "slightly smiling face", "smile"),
("😊", "smiling face with smiling eyes", "blush happy"),
("😇", "smiling face with halo", "angel innocent"),
("🥰", "smiling face with hearts", "love adore"),
("😍", "smiling face with heart-eyes", "love crush"),
("🤩", "star-struck", "excited wow amazing"),
("😘", "face blowing a kiss", "kiss love"),
("😜", "winking face with tongue", "playful silly"),
("🤪", "zany face", "crazy silly wild"),
("😎", "smiling face with sunglasses", "cool"),
("🤓", "nerd face", "geek glasses"),
("🧐", "face with monocle", "thinking inspect"),
("😏", "smirking face", "smug"),
("😒", "unamused face", "meh annoyed"),
("🙄", "face with rolling eyes", "whatever annoyed"),
("😬", "grimacing face", "awkward nervous"),
("😮‍💨", "face exhaling", "sigh relief"),
("🤥", "lying face", "pinocchio lie"),
("😌", "relieved face", "relaxed peaceful"),
("😔", "pensive face", "sad thoughtful"),
("😪", "sleepy face", "tired"),
("🤤", "drooling face", "hungry yummy"),
("😴", "sleeping face", "zzz tired"),
("😷", "face with medical mask", "sick covid"),
("🤒", "face with thermometer", "sick fever"),
("🤕", "face with head-bandage", "hurt injured"),
("🤢", "nauseated face", "sick gross"),
("🤮", "face vomiting", "sick puke"),
("🤧", "sneezing face", "achoo sick"),
("🥵", "hot face", "sweating heat"),
("🥶", "cold face", "freezing"),
("😵", "face with crossed-out eyes", "dizzy dead"),
("🤯", "exploding head", "mind blown wow"),
("🤠", "cowboy hat face", "yeehaw western"),
("🥳", "partying face", "celebration party"),
("🥸", "disguised face", "incognito"),
("🤡", "clown face", "circus"),
("👻", "ghost", "halloween spooky"),
("💀", "skull", "dead death"),
("☠️", "skull and crossbones", "danger death"),
("👽", "alien", "ufo extraterrestrial"),
("🤖", "robot", "bot android"),
("💩", "pile of poo", "poop"),
("😈", "smiling face with horns", "devil evil"),
("👿", "angry face with horns", "devil evil"),
// Gestures & People
("👋", "waving hand", "hello hi bye wave"),
("🤚", "raised back of hand", "stop"),
("🖐️", "hand with fingers splayed", "five high"),
("", "raised hand", "stop high five"),
("🖖", "vulcan salute", "spock trek"),
("👌", "ok hand", "okay perfect"),
("🤌", "pinched fingers", "italian"),
("🤏", "pinching hand", "small tiny"),
("✌️", "victory hand", "peace two"),
("🤞", "crossed fingers", "luck hope"),
("🤟", "love-you gesture", "ily rock"),
("🤘", "sign of the horns", "rock metal"),
("🤙", "call me hand", "shaka hang loose"),
("👈", "backhand index pointing left", "left point"),
("👉", "backhand index pointing right", "right point"),
("👆", "backhand index pointing up", "up point"),
("👇", "backhand index pointing down", "down point"),
("☝️", "index pointing up", "one point"),
("👍", "thumbs up", "like yes good approve"),
("👎", "thumbs down", "dislike no bad"),
("", "raised fist", "power solidarity"),
("👊", "oncoming fist", "punch bump"),
("🤛", "left-facing fist", "fist bump"),
("🤜", "right-facing fist", "fist bump"),
("👏", "clapping hands", "applause bravo"),
("🙌", "raising hands", "hooray celebrate"),
("👐", "open hands", "hug"),
("🤲", "palms up together", "prayer"),
("🤝", "handshake", "agreement deal"),
("🙏", "folded hands", "prayer please thanks"),
("✍️", "writing hand", "write"),
("💪", "flexed biceps", "strong muscle"),
("🦾", "mechanical arm", "robot prosthetic"),
("🦵", "leg", "kick"),
("🦶", "foot", "kick"),
("👂", "ear", "listen hear"),
("👃", "nose", "smell"),
("🧠", "brain", "smart think"),
("👀", "eyes", "look see watch"),
("👁️", "eye", "see look"),
("👅", "tongue", "taste lick"),
("👄", "mouth", "lips kiss"),
// Hearts & Love
("❤️", "red heart", "love"),
("🧡", "orange heart", "love"),
("💛", "yellow heart", "love friendship"),
("💚", "green heart", "love"),
("💙", "blue heart", "love"),
("💜", "purple heart", "love"),
("🖤", "black heart", "love dark"),
("🤍", "white heart", "love pure"),
("🤎", "brown heart", "love"),
("💔", "broken heart", "heartbreak sad"),
("❤️‍🔥", "heart on fire", "passion love"),
("❤️‍🩹", "mending heart", "healing recovery"),
("💕", "two hearts", "love"),
("💞", "revolving hearts", "love"),
("💓", "beating heart", "love"),
("💗", "growing heart", "love"),
("💖", "sparkling heart", "love"),
("💘", "heart with arrow", "love cupid"),
("💝", "heart with ribbon", "love gift"),
("💟", "heart decoration", "love"),
// Animals
("🐶", "dog face", "puppy"),
("🐱", "cat face", "kitty"),
("🐭", "mouse face", ""),
("🐹", "hamster", ""),
("🐰", "rabbit face", "bunny"),
("🦊", "fox", ""),
("🐻", "bear", ""),
("🐼", "panda", ""),
("🐨", "koala", ""),
("🐯", "tiger face", ""),
("🦁", "lion", ""),
("🐮", "cow face", ""),
("🐷", "pig face", ""),
("🐸", "frog", ""),
("🐵", "monkey face", ""),
("🦄", "unicorn", "magic"),
("🐝", "bee", "honeybee"),
("🦋", "butterfly", ""),
("🐌", "snail", "slow"),
("🐛", "bug", "caterpillar"),
("🦀", "crab", ""),
("🐙", "octopus", ""),
("🐠", "tropical fish", ""),
("🐟", "fish", ""),
("🐬", "dolphin", ""),
("🐳", "whale", ""),
("🦈", "shark", ""),
("🐊", "crocodile", "alligator"),
("🐢", "turtle", ""),
("🦎", "lizard", ""),
("🐍", "snake", ""),
("🦖", "t-rex", "dinosaur"),
("🦕", "sauropod", "dinosaur"),
("🐔", "chicken", ""),
("🐧", "penguin", ""),
("🦅", "eagle", "bird"),
("🦆", "duck", ""),
("🦉", "owl", ""),
// Food & Drink
("🍎", "red apple", "fruit"),
("🍐", "pear", "fruit"),
("🍊", "orange", "tangerine fruit"),
("🍋", "lemon", "fruit"),
("🍌", "banana", "fruit"),
("🍉", "watermelon", "fruit"),
("🍇", "grapes", "fruit"),
("🍓", "strawberry", "fruit"),
("🍒", "cherries", "fruit"),
("🍑", "peach", "fruit"),
("🥭", "mango", "fruit"),
("🍍", "pineapple", "fruit"),
("🥥", "coconut", "fruit"),
("🥝", "kiwi", "fruit"),
("🍅", "tomato", "vegetable"),
("🥑", "avocado", ""),
("🥦", "broccoli", "vegetable"),
("🥬", "leafy green", "vegetable salad"),
("🥒", "cucumber", "vegetable"),
("🌶️", "hot pepper", "spicy chili"),
("🌽", "corn", ""),
("🥕", "carrot", "vegetable"),
("🧄", "garlic", ""),
("🧅", "onion", ""),
("🥔", "potato", ""),
("🍞", "bread", ""),
("🥐", "croissant", ""),
("🥖", "baguette", "bread french"),
("🥨", "pretzel", ""),
("🧀", "cheese", ""),
("🥚", "egg", ""),
("🍳", "cooking", "frying pan egg"),
("🥞", "pancakes", "breakfast"),
("🧇", "waffle", "breakfast"),
("🥓", "bacon", "breakfast"),
("🍔", "hamburger", "burger"),
("🍟", "french fries", ""),
("🍕", "pizza", ""),
("🌭", "hot dog", ""),
("🥪", "sandwich", ""),
("🌮", "taco", "mexican"),
("🌯", "burrito", "mexican"),
("🍜", "steaming bowl", "ramen noodles"),
("🍝", "spaghetti", "pasta"),
("🍣", "sushi", "japanese"),
("🍱", "bento box", "japanese"),
("🍩", "doughnut", "donut dessert"),
("🍪", "cookie", "dessert"),
("🎂", "birthday cake", "dessert"),
("🍰", "shortcake", "dessert"),
("🧁", "cupcake", "dessert"),
("🍫", "chocolate bar", "dessert"),
("🍬", "candy", "sweet"),
("🍭", "lollipop", "candy sweet"),
("🍦", "soft ice cream", "dessert"),
("🍨", "ice cream", "dessert"),
("", "hot beverage", "coffee tea"),
("🍵", "teacup", "tea"),
("🧃", "juice box", ""),
("🥤", "cup with straw", "soda drink"),
("🍺", "beer mug", "drink alcohol"),
("🍻", "clinking beer mugs", "cheers drink"),
("🥂", "clinking glasses", "champagne cheers"),
("🍷", "wine glass", "drink alcohol"),
("🥃", "tumbler glass", "whiskey drink"),
("🍸", "cocktail glass", "martini drink"),
// Objects & Symbols
("💻", "laptop", "computer"),
("🖥️", "desktop computer", "pc"),
("⌨️", "keyboard", ""),
("🖱️", "computer mouse", ""),
("💾", "floppy disk", "save"),
("💿", "optical disk", "cd"),
("📱", "mobile phone", "smartphone"),
("☎️", "telephone", "phone"),
("📧", "email", "mail"),
("📨", "incoming envelope", "email"),
("📩", "envelope with arrow", "email send"),
("📝", "memo", "note write"),
("📄", "page facing up", "document"),
("📃", "page with curl", "document"),
("📑", "bookmark tabs", ""),
("📚", "books", "library read"),
("📖", "open book", "read"),
("🔗", "link", "chain url"),
("📎", "paperclip", "attachment"),
("🔒", "locked", "security"),
("🔓", "unlocked", "security open"),
("🔑", "key", "password"),
("🔧", "wrench", "tool fix"),
("🔨", "hammer", "tool"),
("⚙️", "gear", "settings"),
("🧲", "magnet", ""),
("💡", "light bulb", "idea"),
("🔦", "flashlight", ""),
("🔋", "battery", "power"),
("🔌", "electric plug", "power"),
("💰", "money bag", ""),
("💵", "dollar", "money cash"),
("💳", "credit card", "payment"),
("", "alarm clock", "time"),
("⏱️", "stopwatch", "timer"),
("📅", "calendar", "date"),
("📆", "tear-off calendar", "date"),
("", "check mark", "done yes"),
("", "cross mark", "no wrong delete"),
("", "question mark", "help"),
("", "exclamation mark", "important warning"),
("⚠️", "warning", "caution alert"),
("🚫", "prohibited", "no ban forbidden"),
("", "hollow circle", ""),
("🔴", "red circle", ""),
("🟠", "orange circle", ""),
("🟡", "yellow circle", ""),
("🟢", "green circle", ""),
("🔵", "blue circle", ""),
("🟣", "purple circle", ""),
("", "black circle", ""),
("", "white circle", ""),
("🟤", "brown circle", ""),
("", "black square", ""),
("", "white square", ""),
("🔶", "large orange diamond", ""),
("🔷", "large blue diamond", ""),
("", "star", "favorite"),
("🌟", "glowing star", "sparkle"),
("", "sparkles", "magic shine"),
("💫", "dizzy", "star"),
("🔥", "fire", "hot lit"),
("💧", "droplet", "water"),
("🌊", "wave", "water ocean"),
("🎵", "musical note", "music"),
("🎶", "musical notes", "music"),
("🎤", "microphone", "sing karaoke"),
("🎧", "headphones", "music"),
("🎮", "video game", "gaming controller"),
("🕹️", "joystick", "gaming"),
("🎯", "direct hit", "target bullseye"),
("🏆", "trophy", "winner award"),
("🥇", "1st place medal", "gold winner"),
("🥈", "2nd place medal", "silver"),
("🥉", "3rd place medal", "bronze"),
("🎁", "wrapped gift", "present"),
("🎈", "balloon", "party"),
("🎉", "party popper", "celebration tada"),
("🎊", "confetti ball", "celebration"),
// Arrows & Misc
("➡️", "right arrow", ""),
("⬅️", "left arrow", ""),
("⬆️", "up arrow", ""),
("⬇️", "down arrow", ""),
("↗️", "up-right arrow", ""),
("↘️", "down-right arrow", ""),
("↙️", "down-left arrow", ""),
("↖️", "up-left arrow", ""),
("↕️", "up-down arrow", ""),
("↔️", "left-right arrow", ""),
("🔄", "counterclockwise arrows", "refresh reload"),
("🔃", "clockwise arrows", "refresh reload"),
("", "plus", "add"),
("", "minus", "subtract"),
("", "division", "divide"),
("✖️", "multiply", "times"),
("♾️", "infinity", "forever"),
("💯", "hundred points", "100 perfect"),
("🆗", "ok button", "okay"),
("🆕", "new button", ""),
("🆓", "free button", ""),
("", "information", "info"),
("🅿️", "parking", ""),
("🚀", "rocket", "launch startup"),
("✈️", "airplane", "travel flight"),
("🚗", "car", "automobile"),
("🚕", "taxi", "cab"),
("🚌", "bus", ""),
("🚂", "locomotive", "train"),
("🏠", "house", "home"),
("🏢", "office building", "work"),
("🏥", "hospital", ""),
("🏫", "school", ""),
("🏛️", "classical building", ""),
("", "church", ""),
("🕌", "mosque", ""),
("🕍", "synagogue", ""),
("🗽", "statue of liberty", "usa america"),
("🗼", "tokyo tower", "japan"),
("🗾", "map of japan", ""),
("🌍", "globe europe-africa", "earth world"),
("🌎", "globe americas", "earth world"),
("🌏", "globe asia-australia", "earth world"),
("🌑", "new moon", ""),
("🌕", "full moon", ""),
("☀️", "sun", "sunny"),
("🌙", "crescent moon", "night"),
("☁️", "cloud", ""),
("🌧️", "cloud with rain", "rainy"),
("⛈️", "cloud with lightning", "storm thunder"),
("🌈", "rainbow", ""),
("❄️", "snowflake", "cold winter"),
("☃️", "snowman", "winter"),
("🎄", "christmas tree", "xmas holiday"),
("🎃", "jack-o-lantern", "halloween pumpkin"),
("🐚", "shell", "beach"),
("🌸", "cherry blossom", "flower spring"),
("🌺", "hibiscus", "flower"),
("🌻", "sunflower", "flower"),
("🌹", "rose", "flower love"),
("🌷", "tulip", "flower"),
("🌱", "seedling", "plant grow"),
("🌲", "evergreen tree", ""),
("🌳", "deciduous tree", ""),
("🌴", "palm tree", "tropical"),
("🌵", "cactus", "desert"),
("🍀", "four leaf clover", "luck irish"),
("🍁", "maple leaf", "fall autumn canada"),
("🍂", "fallen leaf", "fall autumn"),
];
pub struct EmojiProvider {
items: Vec<LaunchItem>,
}
impl Default for EmojiProvider {
fn default() -> Self {
Self::new()
}
}
impl EmojiProvider {
pub fn new() -> Self {
Self { items: Vec::new() }
}
fn build_items() -> Vec<LaunchItem> {
EMOJIS
.iter()
.map(|(emoji, name, keywords)| {
let mut tags = vec![name.to_string()];
if !keywords.is_empty() {
tags.push(keywords.to_string());
}
LaunchItem {
id: format!("emoji:{}", emoji),
name: name.to_string(),
description: Some(format!("{} {}", emoji, keywords)),
icon: Some((*emoji).to_string()),
provider: ProviderType::Plugin(TYPE_ID.into()),
command: format!("printf '%s' '{}' | wl-copy", emoji),
terminal: false,
tags,
source: ItemSource::Core,
}
})
.collect()
}
}
impl Provider for EmojiProvider {
fn name(&self) -> &str {
"Emoji"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Plugin(TYPE_ID.into())
}
fn refresh(&mut self) {
self.items = Self::build_items();
}
fn items(&self) -> &[LaunchItem] {
&self.items
}
fn prefix(&self) -> Option<&str> {
Some(":emoji")
}
fn icon(&self) -> &str {
"face-smile"
}
fn tab_label(&self) -> Option<&str> {
Some("Emoji")
}
fn search_noun(&self) -> Option<&str> {
Some("emoji")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_emoji_state_new() {
let mut provider = EmojiProvider::new();
provider.refresh();
assert!(
provider.items().len() > 100,
"Should have more than 100 emojis loaded after refresh"
);
}
#[test]
fn test_emoji_has_grinning_face() {
let mut provider = EmojiProvider::new();
provider.refresh();
let grinning = provider
.items()
.iter()
.find(|i| i.name == "grinning face");
assert!(grinning.is_some());
let item = grinning.unwrap();
assert!(item.description.as_ref().unwrap().contains("😀"));
}
#[test]
fn test_emoji_command_format() {
let mut provider = EmojiProvider::new();
provider.refresh();
let item = &provider.items()[0];
assert!(item.command.contains("wl-copy"));
assert!(item.command.contains("printf"));
}
#[test]
fn test_emojis_have_keywords() {
let mut provider = EmojiProvider::new();
provider.refresh();
let heart = provider.items().iter().find(|i| i.name == "red heart");
assert!(heart.is_some());
}
#[test]
fn provider_type_is_emoji_plugin() {
let provider = EmojiProvider::new();
assert_eq!(
provider.provider_type(),
ProviderType::Plugin("emoji".into())
);
}
}
+267
View File
@@ -0,0 +1,267 @@
//! File search provider.
//!
//! Dynamic provider that searches for files using `fd` (preferred) or
//! `locate`. Triggered by:
//! - `/ name` / `/name` (slash prefix)
//! - `file name` / `find name` (word prefix)
//!
//! External dependencies:
//! - `fd` (preferred) or `locate`
use super::{DynamicProvider, ItemSource, LaunchItem, ProviderType};
use std::path::Path;
use std::process::Command;
const TYPE_ID: &str = "filesearch";
const MAX_RESULTS: usize = 20;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SearchTool {
Fd,
Locate,
None,
}
/// Dynamic file search provider — shells out to `fd` or `locate` per keystroke.
pub(crate) struct FileSearchProvider {
search_tool: SearchTool,
// TODO(v2.x): plumb via constructor (search roots, extra flags).
home: String,
}
impl Default for FileSearchProvider {
fn default() -> Self {
Self::new()
}
}
impl FileSearchProvider {
pub fn new() -> Self {
let search_tool = Self::detect_search_tool();
// TODO(v2.x): plumb via constructor.
let home = dirs::home_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| "/".to_string());
Self { search_tool, home }
}
fn detect_search_tool() -> SearchTool {
// Prefer fd (faster, respects .gitignore).
if Self::command_exists("fd") {
return SearchTool::Fd;
}
// Fall back to locate (requires updatedb).
if Self::command_exists("locate") {
return SearchTool::Locate;
}
SearchTool::None
}
fn command_exists(cmd: &str) -> bool {
Command::new("which")
.arg(cmd)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
/// Extract the search term from the query.
fn extract_search_term(query: &str) -> Option<&str> {
let trimmed = query.trim();
if let Some(rest) = trimmed.strip_prefix("/ ") {
Some(rest.trim())
} else if let Some(rest) = trimmed.strip_prefix('/') {
Some(rest.trim())
} else {
// Handle "file " and "find " prefixes (case-insensitive), or raw
// query in filter mode.
let lower = trimmed.to_lowercase();
if lower.starts_with("file ") || lower.starts_with("find ") {
Some(trimmed[5..].trim())
} else {
Some(trimmed)
}
}
}
fn evaluate(&self, query: &str) -> Vec<LaunchItem> {
let search_term = match Self::extract_search_term(query) {
Some(t) if !t.is_empty() => t,
_ => return Vec::new(),
};
self.search_files(search_term)
}
fn search_files(&self, pattern: &str) -> Vec<LaunchItem> {
match self.search_tool {
SearchTool::Fd => self.search_with_fd(pattern),
SearchTool::Locate => self.search_with_locate(pattern),
SearchTool::None => Vec::new(),
}
}
fn search_with_fd(&self, pattern: &str) -> Vec<LaunchItem> {
let output = match Command::new("fd")
.args([
"--max-results",
&MAX_RESULTS.to_string(),
"--type",
"f", // Files only
"--type",
"d", // And directories
pattern,
])
.current_dir(&self.home)
.output()
{
Ok(o) => o,
Err(_) => return Vec::new(),
};
self.parse_file_results(&String::from_utf8_lossy(&output.stdout))
}
fn search_with_locate(&self, pattern: &str) -> Vec<LaunchItem> {
let output = match Command::new("locate")
.args([
"--limit",
&MAX_RESULTS.to_string(),
"--ignore-case",
pattern,
])
.output()
{
Ok(o) => o,
Err(_) => return Vec::new(),
};
self.parse_file_results(&String::from_utf8_lossy(&output.stdout))
}
fn parse_file_results(&self, output: &str) -> Vec<LaunchItem> {
output
.lines()
.filter(|line| !line.is_empty())
.map(|path| {
let path = path.trim();
let full_path = if path.starts_with('/') {
path.to_string()
} else {
format!("{}/{}", self.home, path)
};
let filename = Path::new(&full_path)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| full_path.clone());
let is_dir = Path::new(&full_path).is_dir();
let icon = if is_dir { "folder" } else { "text-x-generic" };
let command = format!("xdg-open '{}'", full_path.replace('\'', "'\\''"));
LaunchItem {
id: format!("file:{}", full_path),
name: filename,
description: Some(full_path.clone()),
icon: Some(icon.to_string()),
provider: ProviderType::Plugin(TYPE_ID.into()),
command,
terminal: false,
tags: vec!["file".to_string()],
source: ItemSource::Core,
}
})
.collect()
}
}
impl DynamicProvider for FileSearchProvider {
fn name(&self) -> &str {
"Files"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Plugin(TYPE_ID.into())
}
fn priority(&self) -> u32 {
8_000
}
fn query(&self, query: &str) -> Vec<LaunchItem> {
self.evaluate(query)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_search_term() {
assert_eq!(
FileSearchProvider::extract_search_term("/ config.toml"),
Some("config.toml")
);
assert_eq!(
FileSearchProvider::extract_search_term("/config"),
Some("config")
);
assert_eq!(
FileSearchProvider::extract_search_term("file bashrc"),
Some("bashrc")
);
assert_eq!(
FileSearchProvider::extract_search_term("find readme"),
Some("readme")
);
}
#[test]
fn test_extract_search_term_empty() {
assert_eq!(FileSearchProvider::extract_search_term("/"), Some(""));
assert_eq!(FileSearchProvider::extract_search_term("/ "), Some(""));
}
#[test]
fn test_command_exists() {
// 'which' should exist on any Unix system.
assert!(FileSearchProvider::command_exists("which"));
// This should not exist.
assert!(!FileSearchProvider::command_exists(
"nonexistent-command-12345"
));
}
#[test]
fn test_detect_search_tool() {
// Just ensure it doesn't panic.
let _ = FileSearchProvider::detect_search_tool();
}
#[test]
fn test_state_new() {
let provider = FileSearchProvider::new();
assert!(!provider.home.is_empty());
}
#[test]
fn test_evaluate_empty() {
let provider = FileSearchProvider::new();
let results = provider.evaluate("/");
assert!(results.is_empty());
let results = provider.evaluate("/ ");
assert!(results.is_empty());
}
#[test]
fn provider_type_is_filesearch_plugin() {
let p = FileSearchProvider::new();
assert_eq!(p.provider_type(), ProviderType::Plugin("filesearch".into()));
}
}
+49 -13
View File
@@ -7,8 +7,20 @@ pub(crate) mod converter;
pub(crate) mod system;
// Optional feature-gated providers
#[cfg(feature = "bookmarks")]
pub(crate) mod bookmarks;
#[cfg(feature = "clipboard")]
pub(crate) mod clipboard;
#[cfg(feature = "emoji")]
pub(crate) mod emoji;
#[cfg(feature = "filesearch")]
pub(crate) mod filesearch;
#[cfg(feature = "ssh")]
pub(crate) mod ssh;
#[cfg(feature = "systemd")]
pub(crate) mod systemd;
#[cfg(feature = "websearch")]
pub(crate) mod websearch;
// Re-exports for core providers
pub use application::ApplicationProvider;
@@ -243,45 +255,69 @@ impl ProviderManager {
/// Only built-in / compiled-in providers are registered here. Future Lua-defined
/// providers (Phase 3+) are added via [`Self::add_provider`] after construction.
pub fn new_with_config(config: Arc<RwLock<Config>>) -> Self {
let (calc_enabled, conv_enabled, sys_enabled, systemd_enabled) = match config.read() {
Ok(cfg) => (
cfg.providers.calculator,
cfg.providers.converter,
cfg.providers.system,
cfg.providers.systemd,
),
let cfg_snapshot = match config.read() {
Ok(cfg) => cfg.providers.clone(),
Err(_) => {
log::warn!("Config lock poisoned during provider init; using defaults");
(true, true, true, true)
crate::config::ProvidersConfig::default()
}
};
let _ = systemd_enabled; // referenced only behind cfg(feature)
let mut core_providers: Vec<Box<dyn Provider>> = vec![
Box::new(ApplicationProvider::new()),
Box::new(CommandProvider::new()),
];
if sys_enabled {
if cfg_snapshot.system {
core_providers.push(Box::new(system::SystemProvider::new()));
info!("Registered built-in system provider");
}
#[cfg(feature = "bookmarks")]
if cfg_snapshot.bookmarks {
core_providers.push(Box::new(bookmarks::BookmarksProvider::new()));
info!("Registered bookmarks provider");
}
#[cfg(feature = "clipboard")]
if cfg_snapshot.clipboard {
core_providers.push(Box::new(clipboard::ClipboardProvider::new()));
info!("Registered clipboard provider");
}
#[cfg(feature = "emoji")]
if cfg_snapshot.emoji {
core_providers.push(Box::new(emoji::EmojiProvider::new()));
info!("Registered emoji provider");
}
#[cfg(feature = "ssh")]
if cfg_snapshot.ssh {
core_providers.push(Box::new(ssh::SshProvider::new()));
info!("Registered ssh provider");
}
#[cfg(feature = "systemd")]
if systemd_enabled {
if cfg_snapshot.systemd {
core_providers.push(Box::new(systemd::SystemdProvider::new()));
info!("Registered systemd provider (type_id: uuctl)");
}
let mut builtin_dynamic: Vec<Box<dyn DynamicProvider>> = Vec::new();
if calc_enabled {
if cfg_snapshot.calculator {
builtin_dynamic.push(Box::new(calculator::CalculatorProvider));
info!("Registered built-in calculator provider");
}
if conv_enabled {
if cfg_snapshot.converter {
builtin_dynamic.push(Box::new(converter::ConverterProvider::new()));
info!("Registered built-in converter provider");
}
#[cfg(feature = "filesearch")]
if cfg_snapshot.filesearch {
builtin_dynamic.push(Box::new(filesearch::FileSearchProvider::new()));
info!("Registered filesearch provider");
}
#[cfg(feature = "websearch")]
if cfg_snapshot.websearch {
builtin_dynamic.push(Box::new(websearch::WebSearchProvider::new()));
info!("Registered websearch provider");
}
Self::new(core_providers, builtin_dynamic)
}
+290
View File
@@ -0,0 +1,290 @@
//! SSH hosts provider.
//!
//! Parses `~/.ssh/config` and exposes each non-wildcard `Host` entry as a
//! launchable item that opens an `ssh <host>` session in the user's terminal.
use super::{ItemSource, LaunchItem, Provider, ProviderType};
use std::fs;
use std::path::PathBuf;
const TYPE_ID: &str = "ssh";
const ICON: &str = "utilities-terminal";
pub struct SshProvider {
items: Vec<LaunchItem>,
terminal_command: String,
}
impl Default for SshProvider {
fn default() -> Self {
Self::new()
}
}
impl SshProvider {
pub fn new() -> Self {
Self {
items: Vec::new(),
terminal_command: Self::load_terminal_from_config(),
}
}
fn load_terminal_from_config() -> String {
// Try [plugins.ssh] in config.toml
let config_path = dirs::config_dir().map(|d| d.join("owlry").join("config.toml"));
if let Some(content) = config_path.and_then(|p| fs::read_to_string(p).ok())
&& let Ok(toml) = content.parse::<toml::Table>()
&& let Some(plugins) = toml.get("plugins").and_then(|v| v.as_table())
&& let Some(ssh) = plugins.get("ssh").and_then(|v| v.as_table())
&& let Some(terminal) = ssh.get("terminal").and_then(|v| v.as_str())
{
return terminal.to_string();
}
// Fall back to $TERMINAL env var
if let Ok(terminal) = std::env::var("TERMINAL") {
return terminal;
}
// Last resort
"xdg-terminal-exec".to_string()
}
fn ssh_config_path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".ssh").join("config"))
}
fn parse_ssh_config(&mut self) {
self.items.clear();
let config_path = match Self::ssh_config_path() {
Some(p) => p,
None => return,
};
if !config_path.exists() {
return;
}
let content = match fs::read_to_string(&config_path) {
Ok(c) => c,
Err(_) => return,
};
let mut current_host: Option<String> = None;
let mut current_hostname: Option<String> = None;
let mut current_user: Option<String> = None;
let mut current_port: Option<String> = None;
for line in content.lines() {
let line = line.trim();
// Skip comments and empty lines
if line.is_empty() || line.starts_with('#') {
continue;
}
// Split on whitespace or '='
let parts: Vec<&str> = line
.splitn(2, |c: char| c.is_whitespace() || c == '=')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
if parts.len() < 2 {
continue;
}
let key = parts[0].to_lowercase();
let value = parts[1];
match key.as_str() {
"host" => {
// Save previous host if exists
if let Some(host) = current_host.take() {
self.add_host_item(
&host,
current_hostname.take(),
current_user.take(),
current_port.take(),
);
}
// Skip wildcards and patterns
if !value.contains('*') && !value.contains('?') && value != "*" {
current_host = Some(value.to_string());
}
current_hostname = None;
current_user = None;
current_port = None;
}
"hostname" => {
current_hostname = Some(value.to_string());
}
"user" => {
current_user = Some(value.to_string());
}
"port" => {
current_port = Some(value.to_string());
}
_ => {}
}
}
// Don't forget the last host
if let Some(host) = current_host.take() {
self.add_host_item(&host, current_hostname, current_user, current_port);
}
}
fn add_host_item(
&mut self,
host: &str,
hostname: Option<String>,
user: Option<String>,
port: Option<String>,
) {
// Build description
let mut desc_parts = Vec::new();
if let Some(ref h) = hostname {
desc_parts.push(h.clone());
}
if let Some(ref u) = user {
desc_parts.push(format!("user: {}", u));
}
if let Some(ref p) = port {
desc_parts.push(format!("port: {}", p));
}
let description = if desc_parts.is_empty() {
None
} else {
Some(desc_parts.join(", "))
};
// Build SSH command - just use the host alias, SSH will resolve the rest
let ssh_command = format!("ssh {}", host);
// Wrap in terminal
let command = format!("{} -e {}", self.terminal_command, ssh_command);
self.items.push(LaunchItem {
id: format!("ssh:{}", host),
name: format!("SSH: {}", host),
description,
icon: Some(ICON.to_string()),
provider: ProviderType::Plugin(TYPE_ID.into()),
command,
terminal: false,
tags: vec!["ssh".to_string(), "remote".to_string()],
source: ItemSource::Core,
});
}
}
impl Provider for SshProvider {
fn name(&self) -> &str {
"SSH"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Plugin(TYPE_ID.into())
}
fn refresh(&mut self) {
self.parse_ssh_config();
}
fn items(&self) -> &[LaunchItem] {
&self.items
}
fn prefix(&self) -> Option<&str> {
Some(":ssh")
}
fn icon(&self) -> &str {
ICON
}
fn tab_label(&self) -> Option<&str> {
Some("SSH")
}
fn search_noun(&self) -> Option<&str> {
Some("SSH hosts")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ssh_provider_new() {
let p = SshProvider::new();
assert!(p.items.is_empty());
}
#[test]
fn test_parse_simple_config() {
let mut p = SshProvider::new();
// We can't easily test the full flow without mocking file paths,
// but we can test the add_host_item method
p.add_host_item(
"myserver",
Some("192.168.1.100".to_string()),
Some("admin".to_string()),
Some("2222".to_string()),
);
assert_eq!(p.items.len(), 1);
assert_eq!(p.items[0].name, "SSH: myserver");
assert!(p.items[0].command.contains("ssh myserver"));
}
#[test]
fn test_add_host_without_details() {
let mut p = SshProvider::new();
p.add_host_item("simple-host", None, None, None);
assert_eq!(p.items.len(), 1);
assert_eq!(p.items[0].name, "SSH: simple-host");
assert!(p.items[0].description.is_none());
}
#[test]
fn test_add_host_with_partial_details() {
let mut p = SshProvider::new();
p.add_host_item("partial", Some("example.com".to_string()), None, None);
assert_eq!(p.items.len(), 1);
let desc = p.items[0].description.as_ref().unwrap();
assert_eq!(desc, "example.com");
}
#[test]
fn test_items_have_icons() {
let mut p = SshProvider::new();
p.add_host_item("test", None, None, None);
assert!(p.items[0].icon.is_some());
assert_eq!(p.items[0].icon.as_ref().unwrap(), ICON);
}
#[test]
fn test_items_have_keywords() {
let mut p = SshProvider::new();
p.add_host_item("test", None, None, None);
assert!(!p.items[0].tags.is_empty());
assert!(p.items[0].tags.iter().any(|t| t == "ssh"));
}
#[test]
fn provider_type_is_ssh_plugin() {
let p = SshProvider::new();
assert_eq!(p.provider_type(), ProviderType::Plugin("ssh".into()));
}
}
+244
View File
@@ -0,0 +1,244 @@
use super::{DynamicProvider, ItemSource, LaunchItem, ProviderType};
/// Built-in web search provider. Opens a search URL in the user's browser via
/// `xdg-open`.
///
/// Triggered by:
/// - `? query` / `?query` (explicit prefix)
/// - `web query` / `search query` (word prefixes)
///
/// The CLI prefix routing for `:web` and `?` is handled by core; this provider
/// only needs to return matching items when `query()` is called.
pub(crate) struct WebSearchProvider {
/// URL template containing a `{query}` placeholder.
url_template: String,
}
/// Common search engine URL templates. `{query}` is replaced with the
/// URL-encoded search term.
const SEARCH_ENGINES: &[(&str, &str)] = &[
("google", "https://www.google.com/search?q={query}"),
("duckduckgo", "https://duckduckgo.com/?q={query}"),
("bing", "https://www.bing.com/search?q={query}"),
("startpage", "https://www.startpage.com/search?q={query}"),
("searxng", "https://searx.be/search?q={query}"),
("brave", "https://search.brave.com/search?q={query}"),
("ecosia", "https://www.ecosia.org/search?q={query}"),
];
/// Default search engine when no configuration is provided.
const DEFAULT_ENGINE: &str = "duckduckgo";
const PROVIDER_ICON: &str = "web-browser";
impl Default for WebSearchProvider {
fn default() -> Self {
// TODO(v2.x): plumb search_engine via constructor argument from Lua
// config. The C-ABI host config lookup was removed; for now we
// hardcode the default engine.
Self::with_engine(DEFAULT_ENGINE)
}
}
impl WebSearchProvider {
#[allow(dead_code)]
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn with_engine(engine_name: &str) -> Self {
let lower = engine_name.to_lowercase();
let url_template = SEARCH_ENGINES
.iter()
.find(|(name, _)| *name == lower)
.map(|(_, url)| (*url).to_string())
.unwrap_or_else(|| {
// Not a known engine; treat as custom URL template if it
// contains a {query} placeholder, else fall back to default.
if engine_name.contains("{query}") {
engine_name.to_string()
} else {
SEARCH_ENGINES
.iter()
.find(|(name, _)| *name == DEFAULT_ENGINE)
.map(|(_, url)| (*url).to_string())
.expect("default engine must exist in SEARCH_ENGINES")
}
});
Self { url_template }
}
/// Extract the search term from a raw query string.
fn extract_search_term(query: &str) -> Option<&str> {
let trimmed = query.trim();
if let Some(rest) = trimmed.strip_prefix("? ") {
Some(rest.trim())
} else if let Some(rest) = trimmed.strip_prefix('?') {
Some(rest.trim())
} else if trimmed.to_lowercase().starts_with("web ") {
Some(trimmed[4..].trim())
} else if trimmed.to_lowercase().starts_with("search ") {
Some(trimmed[7..].trim())
} else {
// Filter mode: accept the raw query.
Some(trimmed)
}
}
/// URL-encode a search query using a small percent-encoding scheme suitable
/// for query parameters (spaces become `+`).
fn url_encode(query: &str) -> String {
query
.chars()
.map(|c| match c {
' ' => "+".to_string(),
'&' => "%26".to_string(),
'=' => "%3D".to_string(),
'?' => "%3F".to_string(),
'#' => "%23".to_string(),
'+' => "%2B".to_string(),
'%' => "%25".to_string(),
c if c.is_ascii_alphanumeric() || "-_.~".contains(c) => c.to_string(),
c => format!("%{:02X}", c as u32),
})
.collect()
}
/// Build the final search URL for a given search term.
fn build_search_url(&self, search_term: &str) -> String {
let encoded = Self::url_encode(search_term);
self.url_template.replace("{query}", &encoded)
}
/// Evaluate a query and produce a single `LaunchItem` if the query yields a
/// non-empty search term.
fn evaluate(&self, query: &str) -> Option<LaunchItem> {
let search_term = Self::extract_search_term(query)?;
if search_term.is_empty() {
return None;
}
let url = self.build_search_url(search_term);
let command = format!("xdg-open '{}'", url.replace('\'', "'\\''"));
Some(LaunchItem {
id: format!("websearch:{}", search_term),
name: format!("Search: {}", search_term),
description: Some("Open in browser".to_string()),
icon: Some(PROVIDER_ICON.to_string()),
provider: ProviderType::Plugin("websearch".into()),
command,
terminal: false,
tags: vec!["web".into(), "search".into()],
source: ItemSource::Core,
})
}
}
impl DynamicProvider for WebSearchProvider {
fn name(&self) -> &str {
"Web Search"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Plugin("websearch".into())
}
fn priority(&self) -> u32 {
9_000
}
fn query(&self, query: &str) -> Vec<LaunchItem> {
match self.evaluate(query) {
Some(item) => vec![item],
None => Vec::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_search_term() {
assert_eq!(
WebSearchProvider::extract_search_term("? rust programming"),
Some("rust programming")
);
assert_eq!(
WebSearchProvider::extract_search_term("?rust"),
Some("rust")
);
assert_eq!(
WebSearchProvider::extract_search_term("web rust docs"),
Some("rust docs")
);
assert_eq!(
WebSearchProvider::extract_search_term("search how to rust"),
Some("how to rust")
);
}
#[test]
fn test_url_encode() {
assert_eq!(WebSearchProvider::url_encode("hello world"), "hello+world");
assert_eq!(WebSearchProvider::url_encode("foo&bar"), "foo%26bar");
assert_eq!(WebSearchProvider::url_encode("a=b"), "a%3Db");
assert_eq!(WebSearchProvider::url_encode("test?query"), "test%3Fquery");
}
#[test]
fn test_build_search_url() {
let provider = WebSearchProvider::with_engine("duckduckgo");
let url = provider.build_search_url("rust programming");
assert_eq!(url, "https://duckduckgo.com/?q=rust+programming");
}
#[test]
fn test_build_search_url_google() {
let provider = WebSearchProvider::with_engine("google");
let url = provider.build_search_url("rust");
assert_eq!(url, "https://www.google.com/search?q=rust");
}
#[test]
fn test_evaluate() {
let provider = WebSearchProvider::new();
let item = provider.evaluate("? rust docs").unwrap();
assert_eq!(item.name, "Search: rust docs");
assert!(item.command.contains("xdg-open"));
assert!(item.command.contains("duckduckgo"));
}
#[test]
fn test_evaluate_empty() {
let provider = WebSearchProvider::new();
assert!(provider.evaluate("?").is_none());
assert!(provider.evaluate("? ").is_none());
}
#[test]
fn test_custom_url_template() {
let provider = WebSearchProvider::with_engine("https://custom.search/q={query}");
let url = provider.build_search_url("test");
assert_eq!(url, "https://custom.search/q=test");
}
#[test]
fn test_fallback_to_default() {
let provider = WebSearchProvider::with_engine("nonexistent");
let url = provider.build_search_url("test");
assert!(url.contains("duckduckgo"));
}
#[test]
fn provider_type_is_websearch_plugin() {
assert_eq!(
WebSearchProvider::default().provider_type(),
ProviderType::Plugin("websearch".into())
);
}
}
+3 -1
View File
@@ -452,7 +452,9 @@ This section captures in-progress state. Update freely as work proceeds.
- `2fc976b` — D15D21 resolutions
- `ae4a903` — C-ABI demolition: tasks #3/#4/#5 done in one commit
- `1d20754` — TDD characterization pass (+36 tests)
- (next) — Workspace collapse: owlry-core merged into owlry (task #2)
- `0a4a090` — Workspace collapse: owlry-core merged into owlry (task #2)
- `eb8a65f` — systemd provider converted (issue #5 functional fix in v2)
- (next) — Remaining 6 plugins converted in parallel: bookmarks, clipboard, emoji, filesearch, ssh, websearch (tasks #6 + #7)
- **Tasks done:** #1 inventory, #3 delete C-ABI, #4 delete Rune+Lua crates, #5 delete config_editor (scripts never lived in this repo)
- **Tasks remaining (Phase 1):** #2 workspace collapse, #6 convert 8 plugins, #7 cargo features, #8 sys→power rename, #9 CLI subcommands, #10 auto-mode test, #11 final build+smoke
- **Stray processes from inventory phase:**