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:<path>' id. Documents the open behavior in the README.
This commit is contained in:
@@ -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 <file>`. 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.
|
||||
|
||||
@@ -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:<path>` id). Returns `None` for every other
|
||||
/// provider, so callers fall through to the generic launch path.
|
||||
fn filesearch_open_path(item: &LaunchItem) -> Option<String> {
|
||||
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<std::process::Child> {
|
||||
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<String> {
|
||||
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<std::path::PathBuf> {
|
||||
use std::path::PathBuf;
|
||||
|
||||
let mut dirs: Vec<PathBuf> = 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<DesktopHandler> {
|
||||
let mut in_entry = false;
|
||||
let mut exec: Option<String> = 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'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user