From b95f16c5e019de1c9db9d99ef889985d1fbbe8b3 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Tue, 2 Jun 2026 09:28:35 +0200 Subject: [PATCH] feat(filesearch): honor Terminal=true handlers when opening files Opening a file from filesearch shelled out to 'xdg-open' unconditionally, which ignores the default handler's Terminal flag. Files whose default app is a terminal program (nvim, vim, less, ...) launched windowless and appeared to do nothing. Resolve the default handler at launch time via the path's MIME type: - terminal handlers (Terminal=true) run through the configured terminal, reconstructing the .desktop Exec line with freedesktop field-code expansion (%f/%F/%u/%U, %% literal, others dropped, path appended when absent); - GUI handlers and indeterminate resolutions keep using xdg-open; - types with no associated application surface a desktop notification instead of failing silently. Resolution happens once for the selected item (not per result), keyed off the item's 'file:' id. Documents the open behavior in the README. --- README.md | 10 + crates/owlry/src/ui/main_window.rs | 343 ++++++++++++++++++++++++++++- 2 files changed, 352 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7aa68b2..5ec5333 100644 --- a/README.md +++ b/README.md @@ -412,6 +412,16 @@ Typical latencies, measured against a warm daemon (single machine, informational File search (`:file` / `/`) is the one provider that shells out (`fd`/`locate`). It runs **only** on its explicit prefix and is bounded — depth limit, directory exclusions, result cap, and a wall-clock timeout — so it never blocks the rest of the result set. +### Opening files and folders + +Selecting a file or folder result opens it with the system's **default application** for that path's MIME type — the same association `xdg-open` uses. How it launches depends on the resolved handler: + +- **GUI handler** (Zed, Firefox, an image viewer, a file manager for folders): launched directly via `xdg-open`. +- **Terminal handler** (`Terminal=true` in the `.desktop` entry — `nvim`, `vim`, `less`, …): owlry reads the handler's `Exec` line and runs it inside your configured terminal (`general.terminal_command`), e.g. `kitty -e nvim `. Plain `xdg-open` would launch these windowless, so without this they'd silently appear to do nothing. +- **No associated application**: owlry shows a desktop notification (*"No application is associated with …"*) instead of failing silently. + +To change which app opens a given type, set its default the usual freedesktop way, e.g. `xdg-mime default dev.zed.Zed.desktop text/x-c` for Rust source files. The terminal used for terminal handlers follows the `-e` convention; terminals that need a different flag may not pick up the file argument. + ## Roadmap See [ROADMAP.md](ROADMAP.md) for feature ideas and [docs/RESTRUCTURE-V2.md](docs/RESTRUCTURE-V2.md) for the v2 rewrite story. diff --git a/crates/owlry/src/ui/main_window.rs b/crates/owlry/src/ui/main_window.rs index 6c607aa..37f6ab7 100644 --- a/crates/owlry/src/ui/main_window.rs +++ b/crates/owlry/src/ui/main_window.rs @@ -1399,7 +1399,13 @@ impl MainWindow { // We delegate to: uwsm (if configured), gio launch, or gtk-launch as fallback. // // Non-desktop items (commands, plugins) use sh -c for shell execution. - let result = if is_desktop_app { + // File-search results resolve their default handler first so that + // terminal-based handlers (nvim, vim, …) open in a real terminal + // instead of being launched windowless via xdg-open, and so that + // files with no associated application surface a notification. + let result = if let Some(path) = Self::filesearch_open_path(item) { + Self::launch_file_open(&path, config) + } else if is_desktop_app { Self::launch_desktop_file(&item.id, config) } else { Self::launch_command(&item.command, item.terminal, config) @@ -1499,6 +1505,231 @@ impl MainWindow { Command::new("sh").arg("-c").arg(&cmd).spawn() } } + + /// If `item` is a file-search result, return the absolute path it points + /// at (decoded from its `file:` id). Returns `None` for every other + /// provider, so callers fall through to the generic launch path. + fn filesearch_open_path(item: &LaunchItem) -> Option { + match &item.provider { + ProviderType::Plugin(id) if id == "filesearch" => { + item.id.strip_prefix("file:").map(str::to_string) + } + _ => None, + } + } + + /// Open a file via its default desktop handler. + /// + /// Plain `xdg-open` ignores the handler's `Terminal=true` flag, so a file + /// whose default app is a terminal program (nvim, vim, less, …) launches + /// windowless and appears to do nothing. When the resolved handler wants a + /// terminal we reconstruct its `Exec` line and run it through the + /// configured terminal (reusing [`Self::launch_command`]'s terminal + /// handling, including its `-e` convention). + /// + /// When the file's type has no associated application, the user is + /// notified instead of silently getting nothing. GUI handlers and + /// indeterminate resolutions fall back to plain `xdg-open`, preserving the + /// prior behavior (Zed, browsers, image viewers, …). + fn launch_file_open(path: &str, config: &Config) -> std::io::Result { + match Self::resolve_default_handler(path) { + HandlerResolution::Found(handler) if handler.terminal => { + let exec = expand_exec_for_file(&handler.exec, path); + info!( + "File handler is terminal-based; opening in terminal: {}", + exec + ); + Self::launch_command(&exec, true, config) + } + HandlerResolution::NoAssociation => { + let name = std::path::Path::new(path) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| path.to_string()); + let msg = format!("No application is associated with '{}'", name); + info!("{}", msg); + crate::notify::notify("Cannot open file", &msg); + // Best-effort: hand it to xdg-open anyway in case the desktop + // environment offers its own "open with…" fallback. + Self::launch_command(&format!("xdg-open {}", shell_quote(path)), false, config) + } + // GUI handler or indeterminate resolution: plain xdg-open. + _ => Self::launch_command(&format!("xdg-open {}", shell_quote(path)), false, config), + } + } + + /// Resolve the default desktop handler for `path` by MIME type. + /// + /// Distinguishes "no application is associated" (worth informing the user) + /// from "resolution tooling unavailable" (stay silent, fall back to + /// `xdg-open`). + fn resolve_default_handler(path: &str) -> HandlerResolution { + // MIME type — `xdg-mime` missing/erroring leaves us indeterminate. + let mime = match Self::xdg_mime_query(&["query", "filetype", path]) { + Some(m) => m, + None => return HandlerResolution::Indeterminate, + }; + if mime.is_empty() { + return HandlerResolution::NoAssociation; + } + + // Default application for that type — empty means nothing is associated. + let desktop_id = match Self::xdg_mime_query(&["query", "default", &mime]) { + Some(d) => d, + None => return HandlerResolution::Indeterminate, + }; + if desktop_id.is_empty() { + return HandlerResolution::NoAssociation; + } + + // Locate and parse the named .desktop file. + let desktop_path = match Self::locate_desktop_file(&desktop_id) { + Some(p) => p, + None => return HandlerResolution::Indeterminate, + }; + match std::fs::read_to_string(&desktop_path) + .ok() + .and_then(|c| parse_desktop_handler(&c)) + { + Some(handler) => HandlerResolution::Found(handler), + None => HandlerResolution::Indeterminate, + } + } + + /// Run `xdg-mime` with `args`, returning trimmed stdout on success. + fn xdg_mime_query(args: &[&str]) -> Option { + let out = Command::new("xdg-mime").args(args).output().ok()?; + if !out.status.success() { + return None; + } + Some(String::from_utf8_lossy(&out.stdout).trim().to_string()) + } + + /// Locate a `.desktop` file by id across the XDG application directories. + fn locate_desktop_file(desktop_id: &str) -> Option { + use std::path::PathBuf; + + let mut dirs: Vec = Vec::new(); + if let Some(data_home) = std::env::var_os("XDG_DATA_HOME") { + dirs.push(PathBuf::from(data_home).join("applications")); + } else if let Some(home) = dirs::home_dir() { + dirs.push(home.join(".local/share/applications")); + } + let data_dirs = std::env::var("XDG_DATA_DIRS") + .unwrap_or_else(|_| "/usr/local/share:/usr/share".to_string()); + for d in data_dirs.split(':').filter(|d| !d.is_empty()) { + dirs.push(PathBuf::from(d).join("applications")); + } + + // Direct match first; then the freedesktop nested-id convention where + // `subdir-name.desktop` lives at `subdir/name.desktop`. + for dir in &dirs { + let direct = dir.join(desktop_id); + if direct.is_file() { + return Some(direct); + } + } + if desktop_id.contains('-') { + let nested = desktop_id.replace('-', "/"); + for dir in &dirs { + let p = dir.join(&nested); + if p.is_file() { + return Some(p); + } + } + } + None + } +} + +/// A file's default desktop handler: its `Exec` line (field codes intact) and +/// whether it requests a terminal. +#[derive(Debug, Clone, PartialEq, Eq)] +struct DesktopHandler { + exec: String, + terminal: bool, +} + +/// Outcome of resolving a file's default application. +enum HandlerResolution { + /// A default handler was found and parsed. + Found(DesktopHandler), + /// The type has no associated default application (or no MIME type) — the + /// user should be told rather than getting a silent no-op. + NoAssociation, + /// Resolution couldn't run to completion (tooling absent, handler file + /// missing, …); stay silent and fall back to `xdg-open`. + Indeterminate, +} + +/// Shell-quote `s` for inclusion in a single-quoted `sh -c` argument. +fn shell_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) +} + +/// Parse the `[Desktop Entry]` group of a `.desktop` file for `Exec=` and +/// `Terminal=`. Only the first `Exec=` in the group is used, and only that +/// group is consulted (action groups are ignored). Returns `None` when there +/// is no `Exec` line — nothing to launch. +fn parse_desktop_handler(contents: &str) -> Option { + let mut in_entry = false; + let mut exec: Option = None; + let mut terminal = false; + + for line in contents.lines() { + let line = line.trim(); + if line.starts_with('[') && line.ends_with(']') { + in_entry = line == "[Desktop Entry]"; + continue; + } + if !in_entry { + continue; + } + if let Some(value) = line.strip_prefix("Exec=") { + if exec.is_none() { + exec = Some(value.trim().to_string()); + } + } else if let Some(value) = line.strip_prefix("Terminal=") { + terminal = value.trim().eq_ignore_ascii_case("true"); + } + } + + exec.map(|exec| DesktopHandler { exec, terminal }) +} + +/// Expand a desktop `Exec` line's field codes against a single `path`. +/// +/// `%f %F %u %U` are replaced with the shell-quoted path; `%%` becomes a +/// literal `%`; every other code (`%i %c %k`, …) is dropped per the +/// freedesktop spec. If the line carries no file field code, the path is +/// appended so the file is still passed to the handler. +fn expand_exec_for_file(exec: &str, path: &str) -> String { + let quoted = shell_quote(path); + let mut out = String::new(); + let mut substituted = false; + let mut chars = exec.chars(); + + while let Some(c) = chars.next() { + if c != '%' { + out.push(c); + continue; + } + match chars.next() { + Some('f') | Some('F') | Some('u') | Some('U') => { + out.push_str("ed); + substituted = true; + } + Some('%') => out.push('%'), + _ => {} // drop %i %c %k and unknown/trailing codes + } + } + + let mut out = out.trim().to_string(); + if !substituted { + out.push(' '); + out.push_str("ed); + } + out } impl std::ops::Deref for MainWindow { @@ -1508,3 +1739,113 @@ impl std::ops::Deref for MainWindow { &self.window } } + +#[cfg(test)] +mod tests { + use super::*; + + fn item(provider: ProviderType, id: &str) -> LaunchItem { + LaunchItem { + id: id.to_string(), + name: "x".into(), + description: None, + icon: None, + provider, + command: "xdg-open '/tmp/x'".into(), + terminal: false, + tags: Vec::new(), + source: ItemSource::Core, + } + } + + #[test] + fn filesearch_open_path_extracts_path_from_id() { + let it = item( + ProviderType::Plugin("filesearch".into()), + "file:/home/u/a.rs", + ); + assert_eq!( + MainWindow::filesearch_open_path(&it).as_deref(), + Some("/home/u/a.rs") + ); + } + + #[test] + fn filesearch_open_path_none_for_other_providers() { + let app = item(ProviderType::Application, "file:/home/u/a.rs"); + assert!(MainWindow::filesearch_open_path(&app).is_none()); + let calc = item(ProviderType::Plugin("calc".into()), "file:/home/u/a.rs"); + assert!(MainWindow::filesearch_open_path(&calc).is_none()); + } + + #[test] + fn parse_handler_reads_command_and_terminal_true() { + let contents = "[Desktop Entry]\nName=Neovim\nExec=nvim %F\nTerminal=true\n"; + let h = parse_desktop_handler(contents).expect("has command"); + assert_eq!(h.exec, "nvim %F"); + assert!(h.terminal); + } + + #[test] + fn parse_handler_absent_terminal_is_false() { + // Regression guard: GUI handlers (no Terminal= line) must NOT be + // treated as terminal apps, so they keep using xdg-open. + let contents = "[Desktop Entry]\nName=Zed\nExec=zeditor %U\n"; + let h = parse_desktop_handler(contents).expect("has command"); + assert!(!h.terminal); + } + + #[test] + fn parse_handler_terminal_value_is_case_insensitive() { + let contents = "[Desktop Entry]\nExec=nvim %F\nTerminal=True\n"; + assert!(parse_desktop_handler(contents).unwrap().terminal); + } + + #[test] + fn parse_handler_ignores_action_groups() { + // Terminal=true in an action group must not leak into the main entry. + let contents = "[Desktop Entry]\nExec=zeditor %U\nTerminal=false\n\n\ + [Desktop Action new]\nExec=zeditor --new\nTerminal=true\n"; + let h = parse_desktop_handler(contents).expect("has command"); + assert_eq!(h.exec, "zeditor %U"); + assert!(!h.terminal); + } + + #[test] + fn parse_handler_none_without_command_line() { + let contents = "[Desktop Entry]\nName=Broken\nTerminal=true\n"; + assert!(parse_desktop_handler(contents).is_none()); + } + + #[test] + fn expand_substitutes_file_field_codes() { + assert_eq!( + expand_exec_for_file("nvim %F", "/home/u/a.rs"), + "nvim '/home/u/a.rs'" + ); + assert_eq!(expand_exec_for_file("vim %f", "/tmp/x"), "vim '/tmp/x'"); + assert_eq!(expand_exec_for_file("app %u", "/tmp/x"), "app '/tmp/x'"); + assert_eq!(expand_exec_for_file("app %U", "/tmp/x"), "app '/tmp/x'"); + } + + #[test] + fn expand_drops_unknown_codes_and_keeps_literal_percent() { + assert_eq!( + expand_exec_for_file("app %i %c %F 100%%", "/tmp/x"), + "app '/tmp/x' 100%" + ); + } + + #[test] + fn expand_appends_path_when_no_field_code() { + assert_eq!(expand_exec_for_file("nvim", "/tmp/x"), "nvim '/tmp/x'"); + } + + #[test] + fn expand_quotes_embedded_single_quotes() { + assert_eq!( + expand_exec_for_file("nvim %F", "/tmp/it's a file"), + "nvim '/tmp/it'\\''s a file'" + ); + } +}