Detects installed apps (editors, terminals, file managers, Markdown apps) and opens paths with them. Cross-platform.
Hand it a (path, app_id) and it figures out the rest -- including app-specific quirks like Obsidian's obsidian:// URI scheme and vault lookup. Works on macOS, Linux, and Windows.
[dependencies]
path-opener = "0.5"use std::path::Path;
use path_opener::{detect_installed_apps, open, open_default};
# fn main() -> std::io::Result<()> {
// Primary entry point: hand it a path and an app id.
open(Path::new("/Users/me/notes"), "obsidian")?;
// Or look at what's installed first.
for app in detect_installed_apps() {
if app.is_available {
println!("{} ({})", app.name, app.app_id);
}
}
// Or fall back to the OS default ("just open it" / double-click).
open_default("/Users/me/notes")?;
# Ok(())
# }Out of the box it looks for:
- File managers -- Finder, Explorer, xdg-open
- Terminals -- Terminal.app, iTerm, Alacritty, Kitty, GNOME Terminal, Konsole, Windows Terminal, PowerShell
- Editors -- VS Code, Cursor, Sublime Text, Zed, Neovim, WebStorm, IntelliJ
- Markdown -- Obsidian (with internal vault-aware launching, see below)
On macOS it checks for .app bundles in /Applications and ~/Applications. Elsewhere it checks PATH.
Each PathOpener declares two flat metadata fields the caller uses to build "what can I open this with?" UIs. Neither is consulted by open() itself.
accepts_directories: bool-- can this opener open a directory path?file_support: FileSupport-- which files it accepts.
FileSupport has three variants:
| Variant | Used by | Meaning |
|---|---|---|
FileSupport::Any |
Editors (VS Code, Cursor, Zed, Sublime, Neovim, WebStorm, IntelliJ); file managers (Finder, Explorer, xdg-open) | Accepts any file -- no extension restriction. |
FileSupport::NotSupported |
Terminals (Terminal.app, iTerm, Alacritty, Kitty, GNOME Terminal, Konsole, Windows Terminal, PowerShell) | Accepts a directory (to cd into) but does not open files. |
FileSupport::Extensions(Vec<String>) |
Obsidian (["md", "markdown", "canvas"]); future specialized apps (Bear, Logseq, Typora) slot in here |
Accepts only the listed extensions. |
The "what can I open this with?" filter for a given path looks like this:
use std::path::Path;
use path_opener::{detect_installed_apps, PathOpener};
fn openers_for(path: &Path) -> Vec<PathOpener> {
let is_dir = path.is_dir();
let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("");
detect_installed_apps()
.into_iter()
.filter(|app| app.is_available)
.filter(|app| {
if is_dir {
app.accepts_directories
} else {
app.file_support.accepts_extension(ext)
}
})
.collect()
}FileSupport::accepts_extension(ext) matches case-insensitively and treats Any as a yes for everything, NotSupported as a no for everything.
The public surface is small on purpose:
open(path, app_id)-- primary dispatch; resolves the built-in opener and launches.open_at(path, app_id, &Target)-- likeopen, but navigates to a location inside the file (see Targets).open_default(path)-- system default ("just open it"), like a double-click.open_with(opener, path)-- lower-level form when you already hold aPathOpener.preview_command(path, app_id)/preview_command_at(path, app_id, &Target)-- whatopen/open_atwould spawn, without spawning it.detect_installed_apps() -> Vec<PathOpener>-- registry walk.PathOpener { app_id, name, command, is_available, accepts_directories, file_support, accepts_target, is_default, is_hidden, sort_order }.enum FileSupport { Any, NotSupported, Extensions(Vec<String>) }.struct Target { line: Option<u32>, column: Option<u32> }withTarget::line(n)/Target::at(line, col).
(open_path(command, path) from 0.1.x is still present as a primitive that takes a raw command string; prefer open(path, app_id) for new code.)
URI schemes, vault metadata, CLI-shim resolution, and per-app launch strategies are implementation details -- they do not appear on the public API.
open_at(path, app_id, &Target) opens a file and navigates to a location inside it. A Target is a small bundle of "sub-application markers":
use std::path::Path;
use path_opener::{open_at, Target};
# fn main() -> std::io::Result<()> {
open_at(Path::new("/src/main.rs"), "vscode", &Target::line(42))?;
open_at(Path::new("/src/main.rs"), "zed", &Target::at(42, 8))?; // line + column
# Ok(())
# }Targets are honored by the GUI editors that can jump to a spot inside a file -- VS Code, Cursor, Sublime Text, Zed -- via their CLI (--goto file:line:col or a file:line:col suffix). Check PathOpener::accepts_target to know which detected openers qualify, instead of hardcoding a list:
use path_opener::detect_installed_apps;
let jump_capable: Vec<_> =
detect_installed_apps().into_iter().filter(|a| a.is_available && a.accepts_target).collect();Any opener that doesn't understand a marker (a terminal, a file manager, Obsidian) ignores it and just opens the path -- so open_at is always safe to call. Target is the extension point for future markers: new coordinates become new fields, not a new function per coordinate.
On macOS the GUI editors are detected by their .app bundle but ship a CLI shim (code, subl, …) that is often not symlinked onto PATH -- and a GUI-launched process inherits a stripped PATH anyway. So a plain open/open_with launches these editors through open -a "<App Name>" (LaunchServices, PATH-independent) rather than the bare shim. open_at still needs the shim to pass the line, so it resolves the shim from inside the app bundle first, then PATH, and falls back to a marker-less open -a if neither resolves.
Obsidian doesn't take a CLI path argument -- it speaks the obsidian:// URI scheme. When you call open(path, "obsidian"), path-opener internally:
- reads Obsidian's own
obsidian.jsonto discover the directories Obsidian has registered as vaults; - picks a URI based on where
pathfalls:pathis a registered vault root ->obsidian://open?vault=<Name>pathis inside a registered vault ->obsidian://open?vault=<Name>&file=<relative>- otherwise ->
obsidian://open?path=<absolute>(Obsidian decides)
- invokes the platform URI launcher (
openon macOS,xdg-openon Linux,starton Windows).
Vault discovery reads:
- macOS:
~/Library/Application Support/obsidian/obsidian.json - Linux:
~/.config/obsidian/obsidian.json - Windows:
%APPDATA%\obsidian\obsidian.json
If obsidian.json is missing or unreadable, the URI falls through to ?path=<absolute> and Obsidian decides what to do.
Note: the vault-discovery routing is experimental. The shape and exact URI strategy may evolve before 1.0. "Vault" is Obsidian-specific terminology used inside this opener -- it is not part of path-opener's general vocabulary, and never appears on the public API. Pin a minor version if you depend on the specifics.
On macOS, availability is determined by the presence of Obsidian.app in /Applications or ~/Applications. On Linux and Windows, is_available currently returns false -- detection (.desktop files on Linux, registry/AppData lookup on Windows) is a planned follow-up. The opener still appears in detect_installed_apps(); it just reports as unavailable on those platforms.
Each detected app comes back as a PathOpener:
use path_opener::{FileSupport, PathOpener};
let opener = PathOpener {
app_id: "vscode".into(),
name: "Visual Studio Code".into(),
command: "code".into(),
is_available: true,
accepts_directories: true,
file_support: FileSupport::Any,
accepts_target: true, // honors a Target (line/column) — see below
is_default: false, // for your UI to manage
is_hidden: false, // for your UI to manage
sort_order: None, // for your UI to manage
};The is_default, is_hidden, and sort_order fields are always initialized to false/None -- they're there so you can layer user preferences on top without a wrapper type.
specta-- Derivesspecta::TypeonPathOpenerandFileSupportfor TypeScript binding generation. Off by default.
PathOpenergainedaccepts_target: bool. Code that constructsPathOpenerliterals by hand needs to fill it in (trueonly for VS Code, Cursor, Sublime Text, Zed).- macOS launch behavior changed:
open/open_withnow launch the GUI editors viaopen -a "<App Name>"instead of their CLI shim. This fixes spuriousNotFound(os error 2) failures when the shim isn't on PATH.preview_commandreflects the new argv. - New, additive:
open_at,preview_command_at, and theTargettype. Existing calls are unaffected.
PathOpenergainedaccepts_directories: boolandfile_support: FileSupport. Code that constructedPathOpenerliterals by hand needs to fill them in.FileSupport::Extensionscarries aVec<String>(not&'static [&'static str]) -- this is the owned shape that round-trips through serde.obsidian::discover_vaults()is no longer a public API. Useopen(path, "obsidian")and let path-opener route internally. Vault metadata never crosses the public boundary.- New entry point
open(path, app_id)is preferred overopen_with(opener, path)for callers that only have an app id.
MIT