Files
owlry/src/providers/application.rs
vikingowl 7ca8a1f443 feat: add tags, configurable tabs, and tag-based filtering
- Add `tags` field to LaunchItem for categorization
- Extract .desktop Categories as tags for applications
- Add semantic tags to providers (systemd, ssh, script, etc.)
- Display tag badges in result rows (max 3 tags)
- Add `tabs` config option for customizable header tabs
- Dynamic Ctrl+1-9 shortcuts based on tab config
- Add `:tag:XXX` prefix for tag-based filtering
- Include tags in fuzzy search with lower weight
- Update config.example.toml with tabs documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 17:30:47 +01:00

214 lines
6.8 KiB
Rust

use super::{LaunchItem, Provider, ProviderType};
use crate::paths;
use freedesktop_desktop_entry::{DesktopEntry, Iter};
use log::{debug, warn};
/// Clean desktop file field codes from command string.
/// Removes %f, %F, %u, %U, %d, %D, %n, %N, %i, %c, %k, %v, %m field codes
/// while preserving quoted arguments and %% (literal percent).
/// See: https://specifications.freedesktop.org/desktop-entry-spec/latest/exec-variables.html
fn clean_desktop_exec_field(cmd: &str) -> String {
let mut result = String::with_capacity(cmd.len());
let mut chars = cmd.chars().peekable();
let mut in_single_quote = false;
let mut in_double_quote = false;
while let Some(c) = chars.next() {
match c {
'\'' if !in_double_quote => {
in_single_quote = !in_single_quote;
result.push(c);
}
'"' if !in_single_quote => {
in_double_quote = !in_double_quote;
result.push(c);
}
'%' if !in_single_quote => {
// Check the next character for field code
if let Some(&next) = chars.peek() {
match next {
// Standard field codes to remove (with following space if present)
'f' | 'F' | 'u' | 'U' | 'd' | 'D' | 'n' | 'N' | 'i' | 'c' | 'k' | 'v'
| 'm' => {
chars.next(); // consume the field code letter
// Skip trailing whitespace after the field code
while chars.peek() == Some(&' ') {
chars.next();
}
}
// %% is escaped percent, output single %
'%' => {
chars.next();
result.push('%');
}
// Unknown % sequence, keep as-is
_ => {
result.push('%');
}
}
} else {
// % at end of string, keep it
result.push('%');
}
}
_ => {
result.push(c);
}
}
}
// Clean up any double spaces that may have resulted from removing field codes
let mut cleaned = result.trim().to_string();
while cleaned.contains(" ") {
cleaned = cleaned.replace(" ", " ");
}
cleaned
}
pub struct ApplicationProvider {
items: Vec<LaunchItem>,
}
impl ApplicationProvider {
pub fn new() -> Self {
Self { items: Vec::new() }
}
fn get_application_dirs() -> Vec<std::path::PathBuf> {
paths::system_data_dirs()
}
}
impl Provider for ApplicationProvider {
fn name(&self) -> &str {
"Applications"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Application
}
fn refresh(&mut self) {
self.items.clear();
let dirs = Self::get_application_dirs();
debug!("Scanning application directories: {:?}", dirs);
// Empty locale list for default locale
let locales: &[&str] = &[];
for path in Iter::new(dirs.into_iter()) {
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
warn!("Failed to read {:?}: {}", path, e);
continue;
}
};
let desktop_entry = match DesktopEntry::from_str(&path, &content, Some(locales)) {
Ok(e) => e,
Err(e) => {
warn!("Failed to parse {:?}: {}", path, e);
continue;
}
};
// Skip entries marked as hidden or no-display
if desktop_entry.no_display() || desktop_entry.hidden() {
continue;
}
// Only include Application type entries
if desktop_entry.type_() != Some("Application") {
continue;
}
let name = match desktop_entry.name(locales) {
Some(n) => n.to_string(),
None => continue,
};
let run_cmd = match desktop_entry.exec() {
Some(e) => clean_desktop_exec_field(e),
None => continue,
};
// Extract categories as tags (lowercase for consistency)
let tags: Vec<String> = desktop_entry
.categories()
.map(|cats| cats.into_iter().map(|s| s.to_lowercase()).collect())
.unwrap_or_default();
let item = LaunchItem {
id: path.to_string_lossy().to_string(),
name,
description: desktop_entry.comment(locales).map(|s| s.to_string()),
icon: desktop_entry.icon().map(|s| s.to_string()),
provider: ProviderType::Application,
command: run_cmd,
terminal: desktop_entry.terminal(),
tags,
};
self.items.push(item);
}
debug!("Found {} applications", self.items.len());
// Sort alphabetically by name
self.items.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
}
fn items(&self) -> &[LaunchItem] {
&self.items
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_clean_desktop_exec_simple() {
assert_eq!(clean_desktop_exec_field("firefox"), "firefox");
assert_eq!(clean_desktop_exec_field("firefox %u"), "firefox");
assert_eq!(clean_desktop_exec_field("code %F"), "code");
}
#[test]
fn test_clean_desktop_exec_multiple_placeholders() {
assert_eq!(clean_desktop_exec_field("app %f %u %U"), "app");
assert_eq!(clean_desktop_exec_field("app --flag %u --other"), "app --flag --other");
}
#[test]
fn test_clean_desktop_exec_preserves_quotes() {
// Double quotes preserve spacing but field codes are still processed
assert_eq!(
clean_desktop_exec_field(r#"bash -c "echo hello""#),
r#"bash -c "echo hello""#
);
// Field codes in double quotes are stripped (per FreeDesktop spec: undefined behavior,
// but practical implementations strip them)
assert_eq!(
clean_desktop_exec_field(r#"bash -c "test %u value""#),
r#"bash -c "test value""#
);
}
#[test]
fn test_clean_desktop_exec_escaped_percent() {
assert_eq!(clean_desktop_exec_field("echo 100%%"), "echo 100%");
}
#[test]
fn test_clean_desktop_exec_single_quotes() {
assert_eq!(
clean_desktop_exec_field("bash -c 'echo %u'"),
"bash -c 'echo %u'"
);
}
}