fix(prompt) cached file open instead of actualy files

This commit is contained in:
nvrl committed 2026-08-11 14:22:13 +02:00
1 parent 4d76462e62
commit 14b6df7bce
18 files changed
+1863 -60

No files matched your search

+29
View File
@@ -6,6 +6,8 @@ use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
use crate::menu::PromptConfig;
use crate::opener::OpenWith;
use crate::syncignore::DEFAULT_IGNORE_PATTERNS;
/// Proton requires `external-drive-{name}@{semver}-{channel}` (channel ∈
@@ -106,6 +108,17 @@ pub struct AppConfig {
/// the field, and overridable at runtime via [`CONFLICT_SWEEP_ENV`].
#[serde(default)]
pub conflict_sweep: Option<SweepMode>,
/// How files opened from the prompt/browser are launched. `None` means
/// "hand everything to `xdg-open`", which is what the client did before
/// this existed. See [`crate::opener`]. Defaulted for configs predating the
/// field.
#[serde(default)]
pub open_with: Option<OpenWith>,
/// `pdfs-prompt` behaviour, including the external launcher used by
/// `--dmenu`. See [`crate::menu`]. Defaulted for configs predating the
/// field.
#[serde(default)]
pub prompt: Option<PromptConfig>,
}
impl Default for AppConfig {
@@ -118,6 +131,8 @@ impl Default for AppConfig {
ignore_patterns: None,
device_uid: None,
conflict_sweep: None,
open_with: None,
prompt: None,
}
}
}
@@ -129,6 +144,18 @@ impl AppConfig {
self.cache_budget.unwrap_or(DEFAULT_CACHE_BUDGET_BYTES)
}
/// The effective opener policy: the user's rules, or the all-`xdg-open`
/// default. Cloned rather than borrowed because callers hold it for the
/// life of a window, long after the config value is gone.
pub fn resolved_open_with(&self) -> OpenWith {
self.open_with.clone().unwrap_or_default()
}
/// The effective prompt settings, defaulted when the section is absent.
pub fn resolved_prompt(&self) -> PromptConfig {
self.prompt.clone().unwrap_or_default()
}
/// The effective conflict-sweep mode: the [`CONFLICT_SWEEP_ENV`] override if
/// it parses, then the user's stored choice, then [`SweepMode::default`].
///
@@ -448,6 +475,8 @@ mod tests {
ignore_patterns: Some(vec!["build/".to_string()]),
device_uid: Some("dev-uid".to_string()),
conflict_sweep: Some(SweepMode::Enforce),
open_with: None,
prompt: None,
};
let json = serde_json::to_string(&config).unwrap();
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
+30 -1
View File
@@ -120,7 +120,18 @@ pub enum Request {
/// Download a Drive file's full content into the cache; replies with the
/// on-disk path so the front-end can open it with the default app. `path`
/// is mountpoint-relative.
OpenFile { path: String },
///
/// `uid` addresses the same node directly and is preferred when present: a
/// search hit's path comes from the metadata index, which also indexes
/// nodes the primary mount's tree does not expose (an on-demand sync
/// folder, a mirror), and walking such a path from the mount root fails
/// with "no such file or folder". Defaulted for wire-compat with clients
/// predating the field; the daemon falls back to `path` without it.
OpenFile {
path: String,
#[serde(default)]
uid: Option<String>,
},
/// Full-text search node names against the daemon's local metadata index.
/// `limit` caps the number of hits returned. Replies with [`Response::SearchResults`].
Search { query: String, limit: usize },
@@ -2173,6 +2184,24 @@ mod tests {
}
}
#[test]
fn open_file_accepts_a_request_from_a_client_predating_the_uid_field() {
let legacy = r#"{"OpenFile":{"path":"Documents/ONBOARDING.md"}}"#;
let Request::OpenFile { path, uid } = serde_json::from_str::<Request>(legacy).unwrap()
else {
panic!("decoded into the wrong variant");
};
assert_eq!(path, "Documents/ONBOARDING.md");
assert_eq!(uid, None);
let with_uid = serde_json::to_string(&Request::OpenFile {
path: "Documents/ONBOARDING.md".into(),
uid: Some("vol~link".into()),
})
.unwrap();
assert!(with_uid.contains("vol~link"));
}
#[test]
fn legacy_daemons_reject_new_by_uid_variants() {
#[allow(dead_code)]
+2
View File
@@ -11,7 +11,9 @@ pub mod control;
pub mod db;
pub mod error;
pub mod localindex;
pub mod menu;
pub mod mounts;
pub mod opener;
pub mod profile;
pub mod sdkcache;
pub mod search;
+422
View File
@@ -0,0 +1,422 @@
//! Driving an external dmenu-style launcher (fuzzel, rofi, wofi, …).
//!
//! `pdfs-prompt` ships its own GTK HUD, but on a tiling-WM desktop the user
//! already has a launcher bound to a key, themed to match everything else. This
//! module lets the prompt borrow it: entries go out on the child's stdin, the
//! chosen line comes back on stdout.
//!
//! Two things vary between launchers and are the only reasons this is not four
//! lines of `Command`:
//!
//! * the flag that turns on filter mode and the one that sets the prompt text;
//! * icon support, which fuzzel and rofi both spell `text\0icon\x1fNAME` and
//! everything else would render as literal garbage.
//!
//! Selection is matched back by label rather than by index, because the one
//! feature no launcher agrees on is index output. A line that matches nothing is
//! the user's own typing — [`MenuChoice::Custom`] — which is what makes
//! type-then-search work at all.
use std::io::{BufRead, BufReader, Write};
use std::path::Path;
use std::process::{Command, Stdio};
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
/// Separates the label from its icon name in the fuzzel/rofi protocol.
const ICON_SEPARATOR: &str = "\0icon\x1f";
/// Launchers we know how to invoke, most-preferred first:
/// `(program, filter-mode args, prompt flag, icon protocol)`.
///
/// The prompt flag is written as it is passed: fuzzel wants `--prompt=TEXT` as
/// one argument, the rest take the text as a separate one.
const KNOWN_MENUS: [MenuFlavor; 6] = [
MenuFlavor {
program: "fuzzel",
mode: &["--dmenu"],
prompt: PromptFlag::Joined("--prompt="),
icons: true,
},
MenuFlavor {
program: "rofi",
mode: &["-dmenu"],
prompt: PromptFlag::Separate("-p"),
icons: true,
},
MenuFlavor {
program: "wofi",
mode: &["--dmenu"],
prompt: PromptFlag::Separate("--prompt"),
icons: false,
},
MenuFlavor {
program: "tofi",
mode: &[],
prompt: PromptFlag::Joined("--prompt-text="),
icons: false,
},
MenuFlavor {
program: "bemenu",
mode: &[],
prompt: PromptFlag::Separate("-p"),
icons: false,
},
MenuFlavor {
program: "dmenu",
mode: &[],
prompt: PromptFlag::Separate("-p"),
icons: false,
},
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PromptFlag {
/// `--prompt=TEXT`
Joined(&'static str),
/// `-p TEXT`
Separate(&'static str),
}
#[derive(Debug, Clone, Copy)]
struct MenuFlavor {
program: &'static str,
mode: &'static [&'static str],
prompt: PromptFlag,
icons: bool,
}
/// Prompt behaviour from `config.json`, under `prompt`.
///
/// ```json
/// "prompt": { "menu": ["fuzzel", "--dmenu", "--width=60"], "menu_limit": 40 }
/// ```
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PromptConfig {
/// Which front end `pdfs-prompt` uses when invoked with no mode flag, so a
/// desktop shortcut does not have to be re-bound to switch. `None` is
/// [`PromptMode::Gtk`], the built-in HUD.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<PromptMode>,
/// Launcher argv for `pdfs-prompt --dmenu`. Unset means "find one" — see
/// [`resolve_menu`]. A `{prompt}` token anywhere in the argv is replaced by
/// the prompt text; without one the launcher's own prompt flag is appended.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub menu: Option<Vec<String>>,
/// How many results to feed the launcher. Unset means
/// [`DEFAULT_MENU_LIMIT`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub menu_limit: Option<usize>,
/// Show icons when the launcher understands them. Defaults to true; set
/// false for a launcher that advertises support but is themed without them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub menu_icons: Option<bool>,
}
/// Which front end the prompt presents.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PromptMode {
/// The built-in GTK HUD.
#[default]
Gtk,
/// The user's own launcher, driven over stdin/stdout.
Dmenu,
}
/// Rows an external launcher can show without becoming a scroll exercise. Larger
/// than the GTK prompt's cap because filtering happens launcher-side.
pub const DEFAULT_MENU_LIMIT: usize = 50;
impl PromptConfig {
pub fn resolved_mode(&self) -> PromptMode {
self.mode.unwrap_or_default()
}
pub fn resolved_menu_limit(&self) -> usize {
self.menu_limit.unwrap_or(DEFAULT_MENU_LIMIT)
}
pub fn resolved_menu_icons(&self) -> bool {
self.menu_icons.unwrap_or(true)
}
}
/// One line offered to the launcher.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MenuItem {
/// What the user sees, and what identifies the choice on the way back.
pub label: String,
/// Icon theme name, used only by launchers that support the protocol.
pub icon: Option<String>,
}
impl MenuItem {
pub fn new(label: impl Into<String>, icon: Option<String>) -> Self {
Self {
label: label.into(),
icon,
}
}
}
/// What came back from the launcher.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MenuChoice {
/// Index into the items that were passed in.
Item(usize),
/// Text the user typed that matched no item — treat it as a query.
Custom(String),
/// Escape, or an empty selection.
Cancelled,
}
/// The launcher to run: the configured one, or the first [`KNOWN_MENUS`] entry
/// on `PATH`. Returns the argv as it should be spawned, filter-mode flags
/// included.
pub fn resolve_menu(configured: Option<&Vec<String>>) -> Option<Vec<String>> {
if let Some(argv) = configured.filter(|argv| !argv.is_empty()) {
return Some(argv.clone());
}
let flavor = KNOWN_MENUS.iter().find(|f| on_path(f.program))?;
let mut argv = vec![flavor.program.to_string()];
argv.extend(flavor.mode.iter().map(|f| (*f).to_string()));
Some(argv)
}
/// Show `items` in the launcher named by `argv` and wait for a choice.
///
/// Blocking by design: the caller is a one-shot CLI invocation whose entire job
/// is this round-trip.
pub fn run(argv: &[String], prompt: &str, items: &[MenuItem]) -> Result<MenuChoice> {
let argv = with_prompt(argv, prompt);
let Some((program, args)) = argv.split_first() else {
return Err(Error::Other("no launcher command configured".into()));
};
let icons = supports_icons(program);
let mut child = Command::new(program)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.map_err(|e| Error::Other(format!("cannot run launcher {program}: {e}")))?;
// Write on a worker: a launcher that starts reading only after it maps its
// window would otherwise deadlock against a full pipe buffer.
let payload = render(items, icons);
let mut stdin = child
.stdin
.take()
.ok_or_else(|| Error::Other("launcher stdin unavailable".into()))?;
let writer = std::thread::spawn(move || {
let _ = stdin.write_all(payload.as_bytes());
let _ = stdin.flush();
});
let mut selected = String::new();
if let Some(stdout) = child.stdout.take() {
// Only the first line matters; multi-select is not a thing here, and a
// launcher that emits more must not leave us blocked on a full pipe.
let mut reader = BufReader::new(stdout);
let mut line = String::new();
while reader.read_line(&mut line).unwrap_or(0) > 0 {
if selected.is_empty() {
selected = line.trim_end_matches('\n').to_string();
}
line.clear();
}
}
let _ = child.wait();
let _ = writer.join();
Ok(interpret(&selected, items))
}
/// Map the launcher's output back to a choice. Exposed for tests; the ambiguity
/// it resolves (label vs. free text) is the whole contract with the launcher.
fn interpret(selected: &str, items: &[MenuItem]) -> MenuChoice {
// Some launchers echo the icon protocol back verbatim.
let selected = selected
.split(ICON_SEPARATOR)
.next()
.unwrap_or(selected)
.trim_end();
if selected.is_empty() {
return MenuChoice::Cancelled;
}
match items.iter().position(|item| item.label == selected) {
Some(index) => MenuChoice::Item(index),
None => MenuChoice::Custom(selected.to_string()),
}
}
/// The stdin payload. Labels are stripped of newlines rather than escaped —
/// there is no escape in this protocol, and a file name with a newline in it
/// would otherwise become two entries.
fn render(items: &[MenuItem], icons: bool) -> String {
let mut out = String::new();
for item in items {
let label = item.label.replace(['\n', '\r'], " ");
out.push_str(&label);
if icons && let Some(icon) = &item.icon {
out.push_str(ICON_SEPARATOR);
out.push_str(icon);
}
out.push('\n');
}
out
}
/// Add the prompt text: substituted for `{prompt}` if the user placed one,
/// otherwise appended using the launcher's own flag. An unrecognised launcher
/// gets no prompt rather than a guessed flag that might not parse.
fn with_prompt(argv: &[String], prompt: &str) -> Vec<String> {
if argv.iter().any(|arg| arg.contains("{prompt}")) {
return argv
.iter()
.map(|arg| arg.replace("{prompt}", prompt))
.collect();
}
let mut argv = argv.to_vec();
if prompt.is_empty() {
return argv;
}
if let Some(flavor) = argv.first().and_then(|program| flavor_of(program)) {
match flavor.prompt {
PromptFlag::Joined(flag) => argv.push(format!("{flag}{prompt}")),
PromptFlag::Separate(flag) => {
argv.push(flag.to_string());
argv.push(prompt.to_string());
}
}
}
argv
}
fn supports_icons(program: &str) -> bool {
flavor_of(program).is_some_and(|flavor| flavor.icons)
}
fn flavor_of(program: &str) -> Option<&'static MenuFlavor> {
let base = Path::new(program).file_name()?.to_str()?;
KNOWN_MENUS.iter().find(|flavor| flavor.program == base)
}
fn on_path(program: &str) -> bool {
let Some(path) = std::env::var_os("PATH") else {
return false;
};
std::env::split_paths(&path).any(|dir| dir.join(program).is_file())
}
#[cfg(test)]
mod tests {
use super::*;
fn items() -> Vec<MenuItem> {
vec![
MenuItem::new(
"notes.md · My files / Docs",
Some("text-x-generic".into()),
),
MenuItem::new("photo.png · Home / Pictures", None),
]
}
#[test]
fn a_matching_line_is_an_item_and_anything_else_is_a_query() {
let items = items();
assert_eq!(
interpret("notes.md · My files / Docs", &items),
MenuChoice::Item(0)
);
assert_eq!(
interpret("invoice", &items),
MenuChoice::Custom("invoice".into())
);
assert_eq!(interpret("", &items), MenuChoice::Cancelled);
}
#[test]
fn an_echoed_icon_field_does_not_break_the_match() {
let items = items();
assert_eq!(
interpret(
"notes.md · My files / Docs\0icon\x1ftext-x-generic",
&items
),
MenuChoice::Item(0)
);
}
#[test]
fn icons_are_only_rendered_for_launchers_that_understand_them() {
let items = items();
assert_eq!(
render(&items, true),
"notes.md · My files / Docs\0icon\x1ftext-x-generic\nphoto.png · Home / Pictures\n"
);
assert_eq!(
render(&items, false),
"notes.md · My files / Docs\nphoto.png · Home / Pictures\n"
);
assert!(supports_icons("fuzzel"));
assert!(supports_icons("/usr/bin/rofi"));
assert!(!supports_icons("wofi"));
assert!(!supports_icons("mystery-menu"));
}
#[test]
fn a_label_with_a_newline_stays_one_entry() {
let sneaky = vec![MenuItem::new("two\nlines.txt", None)];
assert_eq!(render(&sneaky, false), "two lines.txt\n");
}
#[test]
fn the_prompt_uses_each_launchers_own_flag() {
assert_eq!(
with_prompt(&["fuzzel".into(), "--dmenu".into()], "Drive"),
vec!["fuzzel", "--dmenu", "--prompt=Drive"]
);
assert_eq!(
with_prompt(&["rofi".into(), "-dmenu".into()], "Drive"),
vec!["rofi", "-dmenu", "-p", "Drive"]
);
// Unknown launcher: no guessed flag.
assert_eq!(
with_prompt(&["mystery-menu".into()], "Drive"),
vec!["mystery-menu"]
);
}
#[test]
fn an_explicit_placeholder_wins_over_the_flag() {
assert_eq!(
with_prompt(
&[
"rofi".into(),
"-dmenu".into(),
"-p".into(),
"{prompt} >".into()
],
"Drive"
),
vec!["rofi", "-dmenu", "-p", "Drive >"]
);
}
#[test]
fn a_configured_launcher_is_used_verbatim() {
let configured = vec!["my-menu".to_string(), "--pick".to_string()];
assert_eq!(resolve_menu(Some(&configured)), Some(configured.clone()));
// An empty list is "not configured", not "run nothing".
assert_eq!(
resolve_menu(Some(&Vec::new())),
resolve_menu(None),
"an empty menu list must fall back to detection"
);
}
}
+599
View File
@@ -0,0 +1,599 @@
//! How a file chosen in the prompt (or the browser) is actually opened.
//!
//! The default is `xdg-open`, which routes through the desktop's MIME
//! associations. That is the right answer for a GNOME session and the wrong one
//! for a tiling-WM setup where "open a text file" means "run `nvim` inside my
//! terminal" — there is no desktop file for that, and pointing `xdg-open` at a
//! terminal editor globally breaks every graphical caller.
//!
//! So the rules live in `config.json` under `open_with`: an ordered list of
//! name/class patterns mapped to argv, each optionally wrapped in a terminal.
//! First match wins; anything unmatched falls through to `default` (itself
//! `xdg-open` unless overridden). Nothing here is required — an absent
//! `open_with` reproduces the previous behaviour exactly.
//!
//! ```json
//! "open_with": {
//! "terminal": ["alacritty", "-e"],
//! "rules": [
//! { "match": ["@text", "*.rs", "*.toml"], "command": ["nvim"], "terminal": true },
//! { "match": ["*.png"], "command": ["imv"] }
//! ]
//! }
//! ```
use std::ffi::OsStr;
use std::path::Path;
use std::process::Command;
use serde::{Deserialize, Serialize};
/// Environment override for the terminal used by `"terminal": true` rules.
/// Checked before `$TERMINAL` so a support case can redirect it without editing
/// config.
pub const TERMINAL_ENV: &str = "PDFS_TERMINAL";
/// Terminals we know how to hand a command line to, most-preferred first, with
/// the argument that means "run this, don't parse it as options". `kitty` and
/// `wezterm start --` take the command positionally.
const KNOWN_TERMINALS: [(&str, &[&str]); 11] = [
("alacritty", &["-e"]),
("foot", &["-e"]),
("kitty", &[]),
("ghostty", &["-e"]),
("wezterm", &["start", "--"]),
("gnome-terminal", &["--"]),
("konsole", &["-e"]),
("xfce4-terminal", &["-x"]),
("urxvt", &["-e"]),
("st", &["-e"]),
("xterm", &["-e"]),
];
/// The opener policy as it appears in `config.json`.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct OpenWith {
/// Terminal argv used by rules with `"terminal": true`, e.g.
/// `["alacritty", "-e"]`. Unset means "resolve one" — see
/// [`resolve_terminal`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub terminal: Option<Vec<String>>,
/// What to run when no rule matches. Unset means `xdg-open`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<Vec<String>>,
/// Ordered; first match wins.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rules: Vec<OpenRule>,
}
/// One `pattern set -> command` mapping.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct OpenRule {
/// Patterns this rule answers to. Either a shell-style glob against the file
/// name (`*.md`, `notes-*.txt`) or one of the classes `@dir`, `@text`,
/// `@document`, `@image`, `@media`, `@any`. Matching is case-insensitive.
#[serde(rename = "match", default)]
pub patterns: Vec<String>,
/// argv to run. A `{}` token is replaced by the path; without one the path
/// is appended. `$VAR`/`${VAR}` tokens expand from the environment, so
/// `["$EDITOR"]` follows the user's editor.
#[serde(default)]
pub command: Vec<String>,
/// Wrap [`command`](Self::command) in the terminal emulator.
#[serde(default)]
pub terminal: bool,
}
impl OpenWith {
/// The argv that opens `path`, terminal wrapping included. Never fails: an
/// unmatched path — or a rule whose terminal cannot be resolved — degrades
/// to the default opener rather than doing nothing.
pub fn command_for(&self, path: &Path, is_dir: bool) -> Vec<String> {
let name = path
.file_name()
.and_then(OsStr::to_str)
.unwrap_or_default()
.to_string();
self.command_for_named(path, &name, is_dir)
}
/// [`command_for`](Self::command_for) with the rules matched against `name`
/// instead of the last component of `path`.
///
/// A materialised Drive file lives in the content cache under its content
/// hash — no extension, no original name — so matching the on-disk path
/// classifies every download as "unknown" and hands it to `xdg-open`, which
/// sniffs the bytes and picks whatever claims `text/plain` (LibreOffice, for
/// a Markdown file). The rules are about *what the user opened*, so the
/// caller passes that name and the argv still gets the real path.
pub fn command_for_named(&self, path: &Path, name: &str, is_dir: bool) -> Vec<String> {
for rule in &self.rules {
if rule.command.is_empty() {
continue;
}
if !rule.patterns.iter().any(|p| matches(p, name, is_dir)) {
continue;
}
let argv = expand(&rule.command, path);
if !rule.terminal {
return argv;
}
match self.terminal_argv() {
Some(mut terminal) => {
terminal.extend(argv);
return terminal;
}
// A terminal rule with no terminal to run it in would silently
// launch a TUI with no tty. Fall through to the desktop instead.
None => {
tracing::warn!(
"no terminal found for a terminal open rule; using the default opener"
);
break;
}
}
}
let fallback = self
.default
.clone()
.unwrap_or_else(|| vec!["xdg-open".to_string()]);
expand(&fallback, path)
}
/// The configured terminal, or a resolved one.
fn terminal_argv(&self) -> Option<Vec<String>> {
match &self.terminal {
Some(argv) if !argv.is_empty() => Some(argv.clone()),
_ => resolve_terminal(),
}
}
}
/// Launch `path` under `policy`. Fire-and-forget: the child outlives us, and a
/// spawn failure is logged rather than propagated — every caller is a UI action
/// with nowhere useful to report to.
pub fn open(policy: &OpenWith, path: &Path, is_dir: bool) {
let argv = policy.command_for(path, is_dir);
spawn(&argv, path);
}
/// [`open`] for a path whose own file name says nothing about its type — see
/// [`OpenWith::command_for_named`].
pub fn open_named(policy: &OpenWith, path: &Path, name: &str, is_dir: bool) {
let argv = policy.command_for_named(path, name, is_dir);
spawn(&argv, path);
}
fn spawn(argv: &[String], path: &Path) {
let Some((program, args)) = argv.split_first() else {
return;
};
tracing::info!(path = %path.display(), command = ?argv, "opening");
if let Err(e) = Command::new(program).args(args).spawn() {
tracing::error!(command = ?argv, "open failed: {e}");
}
}
/// [`open`] with the policy from the user's `config.json`, read once per
/// process. For callers that open the occasional path and have no reason to
/// carry a policy around; long-lived windows should hold their own copy.
pub fn open_default(path: &Path, is_dir: bool) {
open(default_policy(), path, is_dir);
}
/// [`open_default`] matching the rules against `name` — see
/// [`OpenWith::command_for_named`].
pub fn open_default_named(path: &Path, name: &str, is_dir: bool) {
open_named(default_policy(), path, name, is_dir);
}
fn default_policy() -> &'static OpenWith {
static POLICY: std::sync::OnceLock<OpenWith> = std::sync::OnceLock::new();
POLICY.get_or_init(|| {
crate::config::AppDirs::new()
.map(|dirs| dirs.load_config().resolved_open_with())
.unwrap_or_default()
})
}
/// `$PDFS_TERMINAL`, then `$TERMINAL`, then the first [`KNOWN_TERMINALS`] entry
/// on `PATH`. An env value may be a full command line (`"foot -e"`), in which
/// case it is used verbatim; a bare program name gains that terminal's "run
/// this" argument so `TERMINAL=alacritty` does the obvious thing.
pub fn resolve_terminal() -> Option<Vec<String>> {
for key in [TERMINAL_ENV, "TERMINAL"] {
let Ok(value) = std::env::var(key) else {
continue;
};
let argv: Vec<String> = value.split_whitespace().map(str::to_string).collect();
match argv.len() {
0 => continue,
1 => return Some(with_exec_flag(&argv[0])),
_ => return Some(argv),
}
}
KNOWN_TERMINALS
.iter()
.find(|(program, _)| on_path(program))
.map(|(program, _)| with_exec_flag(program))
}
/// A bare terminal name plus whatever it needs before a command.
fn with_exec_flag(program: &str) -> Vec<String> {
let base = Path::new(program)
.file_name()
.and_then(OsStr::to_str)
.unwrap_or(program);
let mut argv = vec![program.to_string()];
let flags = KNOWN_TERMINALS
.iter()
.find(|(known, _)| *known == base)
.map_or(&["-e"][..], |(_, flags)| *flags);
argv.extend(flags.iter().map(|f| (*f).to_string()));
argv
}
fn on_path(program: &str) -> bool {
let Some(path) = std::env::var_os("PATH") else {
return false;
};
std::env::split_paths(&path).any(|dir| dir.join(program).is_file())
}
/// Substitute `{}` (or append) the path, and expand `$VAR` tokens. Tokens that
/// expand to nothing are dropped, so `["$EDITOR"]` with no `EDITOR` set leaves
/// an empty argv and falls back at the call site.
fn expand(argv: &[String], path: &Path) -> Vec<String> {
let target = path.display().to_string();
let mut out = Vec::with_capacity(argv.len() + 1);
let mut substituted = false;
for token in argv {
if token.contains("{}") {
out.push(token.replace("{}", &target));
substituted = true;
continue;
}
match expand_env(token) {
Some(value) => out.push(value),
None => continue,
}
}
if out.is_empty() {
return vec!["xdg-open".to_string(), target];
}
if !substituted {
out.push(target);
}
out
}
/// `$VAR` / `${VAR}` for a whole token, `None` when the variable is unset or
/// empty so the token drops out. A token that is not a variable reference is
/// returned unchanged; this is deliberately not a shell — no word splitting, no
/// quoting rules.
fn expand_env(token: &str) -> Option<String> {
let Some(name) = token
.strip_prefix("${")
.and_then(|rest| rest.strip_suffix('}'))
.or_else(|| token.strip_prefix('$'))
else {
return Some(token.to_string());
};
if name.is_empty() {
return Some(token.to_string());
}
// Follow `$EDITOR`'s convention of preferring the graphical-capable one.
let value = if name == "EDITOR" {
std::env::var("VISUAL")
.ok()
.filter(|v| !v.is_empty())
.or_else(|| std::env::var("EDITOR").ok())
} else {
std::env::var(name).ok()
};
value.filter(|v| !v.is_empty())
}
/// Whether `pattern` — a glob or an `@class` — matches this name.
fn matches(pattern: &str, name: &str, is_dir: bool) -> bool {
let name_lower = name.to_lowercase();
match pattern.to_lowercase().as_str() {
"@any" => true,
"@dir" => is_dir,
"@text" => !is_dir && class_text(&name_lower),
"@document" => !is_dir && class_document(&name_lower),
"@image" => !is_dir && class_image(&name_lower),
"@media" => !is_dir && class_media(&name_lower),
other if other.starts_with('@') => false,
other => glob_match(other, &name_lower),
}
}
fn extension(name: &str) -> &str {
Path::new(name)
.extension()
.and_then(OsStr::to_str)
.unwrap_or_default()
}
/// Plain-text-ish files a terminal editor is a sensible default for. Extensions
/// only: the daemon's hits may name files that are not on this machine yet, so
/// there is nothing to sniff.
fn class_text(name: &str) -> bool {
matches!(
extension(name),
"txt"
| "md"
| "markdown"
| "rst"
| "org"
| "log"
| "conf"
| "cfg"
| "ini"
| "toml"
| "yaml"
| "yml"
| "json"
| "xml"
| "csv"
| "tsv"
| "env"
| "sh"
| "bash"
| "zsh"
| "fish"
| "rs"
| "py"
| "go"
| "c"
| "h"
| "cpp"
| "hpp"
| "js"
| "ts"
| "tsx"
| "jsx"
| "css"
| "html"
| "sql"
| "lua"
| "vim"
| "nix"
| "patch"
| "diff"
) || name == "makefile"
|| name == "dockerfile"
}
fn class_document(name: &str) -> bool {
matches!(
extension(name),
"pdf"
| "doc"
| "docx"
| "odt"
| "rtf"
| "xls"
| "xlsx"
| "ods"
| "ppt"
| "pptx"
| "odp"
| "epub"
) || class_text(name)
}
fn class_image(name: &str) -> bool {
matches!(
extension(name),
"png" | "jpg" | "jpeg" | "webp" | "gif" | "bmp" | "svg" | "avif" | "heic" | "tiff"
)
}
fn class_media(name: &str) -> bool {
matches!(
extension(name),
"mp4"
| "mkv"
| "webm"
| "mov"
| "avi"
| "mp3"
| "flac"
| "wav"
| "ogg"
| "opus"
| "m4a"
| "aac"
)
}
/// `*` and `?` globbing, no character classes and no path semantics — patterns
/// are matched against a bare file name. Iterative with backtracking so a
/// pathological pattern cannot blow the stack.
fn glob_match(pattern: &str, name: &str) -> bool {
let pattern: Vec<char> = pattern.chars().collect();
let name: Vec<char> = name.chars().collect();
let (mut p, mut n) = (0usize, 0usize);
// Where to resume if the current `*` turns out to have matched too little.
let (mut star, mut resume) = (None, 0usize);
while n < name.len() {
match pattern.get(p) {
Some('*') => {
star = Some(p);
resume = n;
p += 1;
}
Some('?') => {
p += 1;
n += 1;
}
Some(c) if *c == name[n] => {
p += 1;
n += 1;
}
_ => match star {
Some(s) => {
p = s + 1;
resume += 1;
n = resume;
}
None => return false,
},
}
}
pattern[p..].iter().all(|c| *c == '*')
}
#[cfg(test)]
mod tests {
use super::*;
fn rule(patterns: &[&str], command: &[&str], terminal: bool) -> OpenRule {
OpenRule {
patterns: patterns.iter().map(|p| (*p).to_string()).collect(),
command: command.iter().map(|c| (*c).to_string()).collect(),
terminal,
}
}
#[test]
fn a_cache_blob_is_classified_by_the_name_it_was_opened_as() {
let policy = OpenWith {
terminal: Some(vec!["alacritty".into(), "-e".into()]),
default: None,
rules: vec![rule(&["@text"], &["nvim"], true)],
};
// What the daemon hands back: content-hash name, no extension. Matched
// on its own it is "unknown" and would land in xdg-open's lap.
let blob = Path::new("/home/me/.cache/proton-drive-linux/content/364d1d8cc8e6");
assert_eq!(
policy.command_for(blob, false),
vec![
"xdg-open",
"/home/me/.cache/proton-drive-linux/content/364d1d8cc8e6"
]
);
assert_eq!(
policy.command_for_named(blob, "ONBOARDING.md", false),
vec![
"alacritty",
"-e",
"nvim",
"/home/me/.cache/proton-drive-linux/content/364d1d8cc8e6"
]
);
}
#[test]
fn an_empty_policy_is_xdg_open() {
let policy = OpenWith::default();
assert_eq!(
policy.command_for(Path::new("/tmp/a.md"), false),
vec!["xdg-open".to_string(), "/tmp/a.md".to_string()]
);
}
#[test]
fn a_terminal_rule_wraps_the_command() {
let policy = OpenWith {
terminal: Some(vec!["alacritty".into(), "-e".into()]),
default: None,
rules: vec![rule(&["@text"], &["nvim"], true)],
};
assert_eq!(
policy.command_for(Path::new("/tmp/notes.md"), false),
vec!["alacritty", "-e", "nvim", "/tmp/notes.md"]
);
}
#[test]
fn the_first_matching_rule_wins_and_globs_are_case_insensitive() {
let policy = OpenWith {
terminal: None,
default: None,
rules: vec![
rule(&["*.PNG"], &["imv"], false),
rule(&["@image"], &["eog"], false),
],
};
assert_eq!(
policy.command_for(Path::new("/tmp/shot.png"), false),
vec!["imv", "/tmp/shot.png"]
);
}
#[test]
fn a_placeholder_positions_the_path_instead_of_appending_it() {
let policy = OpenWith {
terminal: None,
default: None,
rules: vec![rule(&["@any"], &["sh", "-c", "cat {} | less"], false)],
};
assert_eq!(
policy.command_for(Path::new("/tmp/a.log"), false),
vec!["sh", "-c", "cat /tmp/a.log | less"]
);
}
#[test]
fn directories_only_match_the_dir_class() {
let policy = OpenWith {
terminal: None,
default: None,
rules: vec![
rule(&["@text"], &["nvim"], false),
rule(&["@dir"], &["nautilus"], false),
],
};
assert_eq!(
policy.command_for(Path::new("/tmp/notes.md"), true),
vec!["nautilus", "/tmp/notes.md"]
);
}
#[test]
fn a_rule_that_cannot_find_a_terminal_falls_back_to_the_default() {
// A terminal that is spelled out in config is always honoured; this is
// the other path — resolution must not invent one from the test host.
let policy = OpenWith {
terminal: Some(Vec::new()),
default: Some(vec!["gio".into(), "open".into()]),
rules: vec![rule(&["@text"], &["nvim"], true)],
};
let argv = policy.command_for(Path::new("/tmp/a.md"), false);
// Either a terminal exists on this machine (wrapped nvim) or it does
// not (the default). Both are correct; what must not happen is a bare
// `nvim` with no tty.
assert!(
argv == vec!["gio", "open", "/tmp/a.md"]
|| argv.ends_with(&["nvim".to_string(), "/tmp/a.md".to_string()])
);
}
#[test]
fn globs_backtrack() {
assert!(glob_match("*.md", "notes.md"));
assert!(glob_match("*notes*.md", "my-notes-2024.md"));
assert!(glob_match("a?c.txt", "abc.txt"));
assert!(!glob_match("*.md", "notes.mdx"));
assert!(!glob_match("a?c.txt", "ac.txt"));
assert!(glob_match("*", "anything"));
}
#[test]
fn a_bare_terminal_name_gains_its_exec_flag() {
assert_eq!(with_exec_flag("alacritty"), vec!["alacritty", "-e"]);
assert_eq!(with_exec_flag("kitty"), vec!["kitty"]);
assert_eq!(
with_exec_flag("/usr/bin/wezterm"),
vec!["/usr/bin/wezterm", "start", "--"]
);
// Unknown terminals get the near-universal `-e`.
assert_eq!(with_exec_flag("mystery-term"), vec!["mystery-term", "-e"]);
}
}