- 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>
214 lines
6.8 KiB
Rust
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'"
|
|
);
|
|
}
|
|
}
|