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:
Generated
+80
-1
@@ -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
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -452,7 +452,9 @@ This section captures in-progress state. Update freely as work proceeds.
|
||||
- `2fc976b` — D15–D21 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:**
|
||||
|
||||
Reference in New Issue
Block a user