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

+51 -3
View File
@@ -1,12 +1,17 @@
//! Shared Drive-result activation policy for the GTK browser and quick prompt.
//!
//! Media should be handed to its mounted path so players can issue range reads
//! through FUSE. Other files retain the materialize-then-open behaviour because
//! many desktop applications expect a stable ordinary file.
//! through FUSE. Everything else opens through the mount too whenever the mount
//! exposes it, and only falls back to materialize-then-open when it does not —
//! see [`mounted_target`].
//!
//! Both binaries `#[path]`-include this file, so each compiles its own copy and
//! anything only one of them calls looks dead in the other. That is what the
//! `dead_code` allowances below are for — not code nobody uses.
use std::path::{Path, PathBuf};
use pdfs_core::control::PhotoKind;
use pdfs_core::control::{PhotoKind, SearchHit};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DriveActivation {
@@ -29,6 +34,49 @@ pub(crate) fn mounted_path(mountpoint: &Path, relative_path: &str) -> PathBuf {
mountpoint.join(relative_path)
}
/// Where a Drive hit should be opened from, when a mount exposes it.
///
/// Materialised content is a *copy*: it lives in the cache under its content
/// hash, so an editor saving into it writes somewhere Drive will never look,
/// and the cache is free to evict it afterwards. The mounted path is the real
/// file — reads pull 4 MiB blocks on demand and a write becomes a new revision
/// — so it wins for every kind of file, not just streamable media.
///
/// [`Request::OpenFile`](pdfs_core::control::Request::OpenFile) stays the
/// fallback for hits no mount exposes: the search index also covers nodes
/// outside the mounted tree (a mirror folder's contents, a stale row), and
/// those can still be read, just not edited in place.
///
/// The `stat` is why this returns an `Option` rather than a path: it is the
/// only way to tell those two cases apart, and it costs one syscall on a path
/// the caller is about to hand to an application anyway.
#[allow(dead_code)] // prompt only
pub(crate) fn mounted_target(mountpoint: &Path, hit: &SearchHit) -> Option<PathBuf> {
present(mounted_or_relative(mountpoint, hit))
}
/// [`mounted_target`] for callers holding a mountpoint-relative path rather
/// than a search hit — the browser's listing entries, which are by definition
/// paths in the primary mount's tree.
#[allow(dead_code)] // browser only
pub(crate) fn mounted_target_rel(mountpoint: &Path, relative_path: &str) -> Option<PathBuf> {
present(mounted_path(mountpoint, relative_path))
}
fn present(path: PathBuf) -> Option<PathBuf> {
path.symlink_metadata().is_ok().then_some(path)
}
/// The mount path for a hit, whether or not anything is there — for callers
/// that have no fallback (a folder cannot be materialised) or already know the
/// entry came from a mount listing.
#[allow(dead_code)] // prompt only
pub(crate) fn mounted_or_relative(mountpoint: &Path, hit: &SearchHit) -> PathBuf {
hit.mounted_path
.as_deref()
.map_or_else(|| mounted_path(mountpoint, &hit.path), PathBuf::from)
}
fn is_streamable_media(name: &str) -> bool {
if PhotoKind::classify(Some(name), None) == PhotoKind::Video {
return true;
+11 -4
View File
@@ -996,11 +996,18 @@ fn capitalize(s: &str) -> String {
}
}
/// Open a local path with the user's default handler.
/// Open a local path with the handler the user configured — `xdg-open` unless
/// `open_with` in `config.json` overrides it for this kind of file.
fn open_path(path: &str) {
if let Err(e) = Command::new("xdg-open").arg(path).spawn() {
tracing::error!("xdg-open {path} failed: {e}");
}
let path = Path::new(path);
pdfs_core::opener::open_default(path, path.is_dir());
}
/// [`open_path`] for a file the daemon materialised into the content cache,
/// where the on-disk name is a content hash and carries no extension for the
/// rules (or `xdg-open`) to key off. `name` is the Drive name it was opened as.
pub(crate) fn open_named_path(path: &str, name: &str) {
pdfs_core::opener::open_default_named(Path::new(path), name, false);
}
/// Format a byte count as a short binary-unit string (e.g. `1.2 GiB`).
+23 -7
View File
@@ -1,4 +1,4 @@
use crate::activation::{DriveActivation, drive_activation, mounted_path};
use crate::activation::{DriveActivation, drive_activation, mounted_path, mounted_target_rel};
use crate::*;
pub(crate) struct BrowserState {
@@ -659,21 +659,35 @@ pub(crate) fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) {
}
}
/// Download a file's full content into the cache and hand it to the user's
/// default application. The download-and-open path behind both a plain
/// double-click and the context menu's "Open" (including for a video, when the
/// user explicitly wants a local copy rather than to stream it).
/// Hand a file to the user's default application: through the mount when it is
/// there, otherwise downloaded into the cache first. The open path behind both
/// a plain double-click and the context menu's "Open" (including for a video,
/// when the user wants their default application rather than the player).
///
/// The mount comes first because the cache blob is a *copy* keyed by content
/// hash — an application saving into it writes where Drive will never look, and
/// the cache may evict it afterwards. See
/// [`mounted_target`](crate::activation::mounted_target).
pub(crate) fn download_and_open(ui: &Rc<Ui>, entry: &DirEntry) {
let rel = entry_rel(ui, entry);
let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config());
if let Some(path) = mounted_target_rel(&mountpoint, &rel) {
open_path(&path.to_string_lossy());
return;
}
// Ignore a repeat activation of a file already downloading, so an impatient
// double-click doesn't kick off a second round-trip.
if !ui.opening.borrow_mut().insert(rel.clone()) {
return;
}
ui.busy_begin();
let name = entry.name.clone();
let rx = spawn_request(
ui.dirs.control_socket(),
Request::OpenFile { path: rel.clone() },
Request::OpenFile {
path: rel.clone(),
uid: Some(entry.uid.clone()),
},
);
let ui = ui.clone();
glib::spawn_future_local(async move {
@@ -681,7 +695,9 @@ pub(crate) fn download_and_open(ui: &Rc<Ui>, entry: &DirEntry) {
ui.busy_end();
ui.opening.borrow_mut().remove(&rel);
match result {
Ok(Ok(Response::FilePath { path })) => open_path(&path),
// A cache blob is named by content hash: match the open rules
// against the Drive name the user clicked, not that path.
Ok(Ok(Response::FilePath { path })) => open_named_path(&path, &name),
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't open file", &message, kind)
}
+4 -1
View File
@@ -388,12 +388,15 @@ fn open_shared_file(ui: &Rc<Ui>, uid: &str, name: &str) {
);
let ui = ui.clone();
let uid = uid.to_string();
let name = name.to_string();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
ui.opening.borrow_mut().remove(&uid);
match result {
Ok(Ok(Response::FilePath { path })) => open_path(&path),
// A cache blob is named by content hash, so the open rules key off
// the shared node's name instead.
Ok(Ok(Response::FilePath { path })) => open_named_path(&path, &name),
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't open file", &message, kind)
}
+348
View File
@@ -0,0 +1,348 @@
//! `pdfs-prompt --dmenu`: the same search, presented in the user's own launcher.
//!
//! Everything here is blocking and GTK-free — the process exists for one
//! round-trip and then exits, so there is no main loop to keep off. It reuses
//! the GTK prompt's [`Hit`](crate::Hit) model and ranking, and its Drive
//! activation policy, so the two front ends cannot drift on what "open" means.
//!
//! Launchers filter a static list; they cannot call back for a new one per
//! keystroke. So the interaction is a loop: the first menu shows pinned files
//! and accepts free text, and any text that matches no entry becomes the next
//! search. Escape (an empty selection) ends it.
use std::path::{Path, PathBuf};
use pdfs_core::config::AppDirs;
use pdfs_core::control::{
Request, Response, SearchFilters, SearchHit, SearchKind, SearchSource, send,
};
use pdfs_core::menu::{self, MenuChoice, MenuItem, PromptConfig};
use pdfs_core::opener::{self, OpenWith};
use crate::activation::{DriveActivation, drive_activation, mounted_or_relative, mounted_target};
use crate::{Hit, file_name, is_document, is_image, is_media, rank_hits};
/// Guard against a launcher that keeps handing back text we cannot satisfy —
/// without it a scripted (non-interactive) menu could spin forever.
const MAX_ROUNDS: usize = 64;
pub(crate) struct Options {
/// Launcher argv from `--menu`, overriding the configured/detected one.
pub menu: Option<Vec<String>>,
/// Skip the pinned-files round and search for this immediately.
pub query: Option<String>,
}
/// Run the launcher flow. Returns an error only for conditions the user must
/// act on (no daemon, no launcher); a plain cancel is `Ok`.
pub(crate) fn run(options: Options) -> Result<(), String> {
let dirs = AppDirs::new().map_err(|e| format!("cannot resolve app dirs: {e}"))?;
let config = dirs.load_config();
let prompt: PromptConfig = config.resolved_prompt();
let policy: OpenWith = config.resolved_open_with();
let socket = dirs.control_socket();
let menu_argv =
menu::resolve_menu(options.menu.as_ref().or(prompt.menu.as_ref())).ok_or_else(|| {
"no launcher found — install fuzzel/rofi/wofi, or set prompt.menu in config.json"
.to_string()
})?;
let mountpoint = match request(&socket, Request::Status)? {
Response::Status { mountpoint, .. } => PathBuf::from(mountpoint),
_ => dirs.default_mountpoint(),
};
let mut query = options
.query
.map(|q| q.trim().to_string())
.filter(|q| !q.is_empty());
for _ in 0..MAX_ROUNDS {
let hits = match &query {
Some(text) => search(&socket, text, prompt.resolved_menu_limit())?,
None => pins(&socket)?,
};
let items: Vec<MenuItem> = if hits.is_empty() {
vec![MenuItem::new(placeholder(query.as_deref()), None)]
} else {
label_all(&hits)
};
match menu::run(&menu_argv, &title(query.as_deref()), &items).map_err(|e| e.to_string())? {
MenuChoice::Item(index) => {
let Some(hit) = hits.get(index) else {
// The placeholder row. Selecting it is not a cancel — the
// user pressed Enter on "type to search", so start over
// with the pinned list rather than quitting on them.
query = None;
continue;
};
return open(&socket, &mountpoint, &policy, hit, query.is_none());
}
// No entry matched what was typed: treat it as the next query.
MenuChoice::Custom(text) => query = Some(text),
MenuChoice::Cancelled => return Ok(()),
}
}
Ok(())
}
/// The launcher's prompt string.
///
/// A launcher renders this immediately left of the input, so it is the only
/// place to say what typing here does. "Drive" alone read as a label and left
/// the two-step nature of the flow — type, Enter, *then* results — invisible;
/// the verb and the chevron make it a search box, and echoing the current query
/// shows which round you are in.
fn title(query: Option<&str>) -> String {
match query {
Some(text) => format!("Drive: {text} › "),
None => "Search Drive › ".to_string(),
}
}
/// Text for the one row shown when there is nothing to list.
///
/// A launcher handed an empty list shows a bare prompt, and some refuse to
/// return anything at all from an empty menu — which would strand the user on
/// the very first round, since a fresh account has no pins. A single row keeps
/// the menu non-empty and says why it is empty.
///
/// A fuzzy launcher can match this row against what the user types, and the
/// selection comes back as a label with no way to tell the two apart — so the
/// wording is parenthesised and avoids words that read like a filename query.
fn placeholder(query: Option<&str>) -> String {
match query {
Some(text) => format!("(no matches for “{text}”)"),
None => "(no pinned files — type to search)".to_string(),
}
}
/// Pinned files — the same "you asked to keep these" set the GTK prompt opens
/// with, and the only listing the daemon offers without a query.
fn pins(socket: &Path) -> Result<Vec<Hit>, String> {
let Response::Pins { pins } = request(socket, Request::ListPins)? else {
return Ok(Vec::new());
};
Ok(pins
.into_iter()
.map(|pin| {
Hit::Drive(SearchHit {
name: file_name(&pin.path),
path: pin.path,
is_dir: pin.recursive,
size: 0,
modified: 0,
pinned: true,
uid: pin.uid,
mounted_path: None,
score: 0,
})
})
.collect())
}
fn search(socket: &Path, query: &str, limit: usize) -> Result<Vec<Hit>, String> {
let reply = request(
socket,
Request::SearchV2 {
query: query.to_string(),
limit,
filters: SearchFilters {
sources: vec![SearchSource::Drive, SearchSource::Local],
kind: SearchKind::All,
},
},
)?;
let Response::SearchResultsV2 {
drive_hits,
local_hits,
..
} = reply
else {
return Ok(Vec::new());
};
let mut hits: Vec<Hit> = drive_hits.into_iter().map(Hit::Drive).collect();
hits.extend(local_hits.into_iter().map(Hit::Local));
rank_hits(&mut hits);
hits.truncate(limit);
Ok(hits)
}
/// One launcher line per hit. Labels must be unique: they are how the selection
/// is matched back, and two files of the same name in the same folder listing
/// would otherwise be indistinguishable.
fn label_all(hits: &[Hit]) -> Vec<MenuItem> {
let mut items: Vec<MenuItem> = Vec::with_capacity(hits.len());
for hit in hits {
let mut label = format!("{} · {}", hit.name(), hit.location());
let mut suffix = 2;
while items.iter().any(|item| item.label == label) {
label = format!("{} · {} ({suffix})", hit.name(), hit.location());
suffix += 1;
}
items.push(MenuItem::new(label, Some(icon_name(hit))));
}
items
}
/// Icon theme name for a hit.
///
/// Coarse on purpose: `content_type_guess` would give an exact MIME name, but
/// only with file *contents* to sniff — a Drive hit is metadata, and passing it
/// an empty buffer answers `application-x-zerosize` for everything. The generic
/// names below exist in every icon theme.
fn icon_name(hit: &Hit) -> String {
if hit.is_dir() {
return "folder".to_string();
}
let name = hit.name();
if is_image(name) {
"image-x-generic"
} else if is_media(name) {
"video-x-generic"
} else if is_document(name) {
"text-x-generic"
} else {
"application-x-generic"
}
.to_string()
}
/// Open a chosen hit, mirroring the GTK prompt: local files are already on
/// disk, a Drive folder or streamable file goes through the mount, and anything
/// else is materialised by the daemon first.
fn open(
socket: &Path,
mountpoint: &Path,
policy: &OpenWith,
hit: &Hit,
from_pins: bool,
) -> Result<(), String> {
match hit {
Hit::Local(local) => {
opener::open(policy, Path::new(&local.path), local.is_dir);
Ok(())
}
Hit::Drive(drive) => {
// A folder has no materialised form, and a pin row's `is_dir` is pin
// policy rather than node kind, so both go to the mount unchecked.
let unconditional = from_pins
|| matches!(
drive_activation(&drive.name, drive.is_dir),
DriveActivation::Folder | DriveActivation::MountedMedia
);
if unconditional {
opener::open(
policy,
&mounted_or_relative(mountpoint, drive),
drive.is_dir,
);
return Ok(());
}
// Anything else the mount exposes opens there too, so an editor
// saves back into Drive instead of into a cache blob.
if let Some(path) = mounted_target(mountpoint, drive) {
opener::open(policy, &path, false);
return Ok(());
}
match request(
socket,
Request::OpenFile {
path: drive.path.clone(),
uid: Some(drive.uid.clone()),
},
)? {
Response::FilePath { path } => {
// The reply is a content-hash blob; the rules key off the
// Drive name, which is what the user actually opened.
opener::open_named(policy, Path::new(&path), &drive.name, false);
Ok(())
}
Response::Error { message, .. } => Err(format!("could not open: {message}")),
_ => Err("unexpected reply from the daemon".to_string()),
}
}
}
}
/// One control-socket round-trip, with the daemon-is-down case phrased for a
/// terminal rather than a status page.
fn request(socket: &Path, request: Request) -> Result<Response, String> {
send(socket, &request).map_err(|e| format!("cannot reach the Proton Drive daemon: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
use pdfs_core::control::LocalHit;
fn drive(name: &str, path: &str, is_dir: bool) -> Hit {
Hit::Drive(SearchHit {
name: name.into(),
path: path.into(),
is_dir,
size: 0,
modified: 0,
pinned: false,
uid: "uid".into(),
mounted_path: None,
score: 0,
})
}
#[test]
fn labels_are_unique_so_a_selection_maps_back_to_one_hit() {
let hits = vec![
drive("notes.md", "Docs/notes.md", false),
Hit::Local(LocalHit {
name: "notes.md".into(),
path: "/home/me/Docs/notes.md".into(),
is_dir: false,
size: 0,
modified: 0,
score: 0,
}),
drive("notes.md", "Docs/notes.md", false),
];
let items = label_all(&hits);
assert_eq!(items.len(), 3);
assert_ne!(items[0].label, items[2].label);
let unique: std::collections::HashSet<_> =
items.iter().map(|item| item.label.clone()).collect();
assert_eq!(unique.len(), 3);
}
#[test]
fn the_prompt_says_a_search_is_what_enter_does() {
let first = title(None);
assert!(first.to_lowercase().contains("search"), "{first}");
// The launcher renders the input immediately after the prompt, so the
// prompt has to carry its own separator or it runs into what is typed.
assert!(first.ends_with(' '), "{first:?}");
let refined = title(Some("md"));
assert!(refined.contains("md"), "{refined}");
assert!(refined.ends_with(' '), "{refined:?}");
}
#[test]
fn an_empty_round_still_has_a_row_explaining_itself() {
assert!(placeholder(None).contains("type to search"));
assert!(placeholder(Some("md")).contains("md"));
}
#[test]
fn icons_are_theme_names_the_launcher_can_look_up() {
assert_eq!(icon_name(&drive("Photos", "Photos", true)), "folder");
assert_eq!(
icon_name(&drive("a.png", "a.png", false)),
"image-x-generic"
);
assert_eq!(icon_name(&drive("a.md", "a.md", false)), "text-x-generic");
assert_eq!(
icon_name(&drive("a.bin", "a.bin", false)),
"application-x-generic"
);
}
}
+1 -3
View File
@@ -134,9 +134,7 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
}
fn open_folder(mountpoint: &Path) {
if let Err(e) = Command::new("xdg-open").arg(mountpoint).spawn() {
tracing::error!("failed to xdg-open {}: {e}", mountpoint.display());
}
pdfs_core::opener::open_default(mountpoint, true);
}
/// Launch (or, since it's `SingleMainWindow`, raise) the settings/management
+176 -23
View File
@@ -9,7 +9,6 @@
use std::cell::{Cell, RefCell};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::rc::Rc;
use std::time::Duration;
@@ -20,9 +19,12 @@ use pdfs_core::config::AppDirs;
use pdfs_core::control::{
LocalHit, Request, Response, SearchFilters, SearchHit, SearchKind, SearchSource, send,
};
use pdfs_core::menu::PromptMode;
use pdfs_core::opener::{self, OpenWith};
mod activation;
use activation::{DriveActivation, drive_activation, mounted_path};
mod dmenu;
use activation::{DriveActivation, drive_activation, mounted_or_relative, mounted_target};
const APP_ID: &str = "io.narl.proton-drive-linux-prompt";
@@ -220,6 +222,10 @@ fn rank_hits(hits: &mut [Hit]) {
struct Ui {
socket: PathBuf,
mountpoint: RefCell<PathBuf>,
/// How a chosen file is launched. Read once at window build: the prompt is
/// resident, but a config edit is rare enough that a restart is a fair
/// price for not re-reading the file on every Enter.
opener: OpenWith,
entry: gtk4::Entry,
spinner: gtk4::Spinner,
@@ -328,6 +334,71 @@ impl Section {
}
}
const USAGE: &str = "\
pdfs-prompt — search Proton Drive and this computer
Usage: pdfs-prompt [OPTIONS]
Options:
--dmenu Present the results in an external launcher (fuzzel,
rofi, …) instead of the built-in GTK window. Also
settable permanently as \"prompt\": { \"mode\": \"dmenu\" }
in config.json.
--gtk Force the built-in window, overriding that setting.
--menu <COMMAND> Launcher command line for --dmenu, e.g.
--menu 'fuzzel --dmenu --width 60'. Overrides
\"prompt\": { \"menu\": [...] }.
--query <TEXT> Search for TEXT immediately instead of opening on the
pinned-files list (--dmenu only).
-h, --help Show this help.
";
/// What the command line asked for, before any GTK setup happens: the launcher
/// path must not pay for — or fail on — a display connection it never uses.
struct Args {
mode: Option<PromptMode>,
menu: Option<Vec<String>>,
query: Option<String>,
}
fn parse_args(argv: impl Iterator<Item = String>) -> Result<Args, String> {
let mut args = Args {
mode: None,
menu: None,
query: None,
};
let mut argv = argv.peekable();
while let Some(arg) = argv.next() {
match arg.as_str() {
"--dmenu" => args.mode = Some(PromptMode::Dmenu),
"--gtk" => args.mode = Some(PromptMode::Gtk),
// A launcher command is one shell-ish string so it can live in a
// keybinding; splitting on whitespace is enough for flags, and a
// launcher argument needing spaces belongs in config.json.
"--menu" => {
let value = argv
.next()
.ok_or_else(|| "--menu needs a command".to_string())?;
let parts: Vec<String> = value.split_whitespace().map(str::to_string).collect();
if parts.is_empty() {
return Err("--menu needs a command".to_string());
}
args.menu = Some(parts);
// A launcher was named explicitly; that only makes sense here.
args.mode.get_or_insert(PromptMode::Dmenu);
}
"--query" => {
args.query = Some(
argv.next()
.ok_or_else(|| "--query needs a search term".to_string())?,
);
}
other => return Err(format!("unrecognised argument: {other}")),
}
}
Ok(args)
}
fn main() -> glib::ExitCode {
tracing_subscriber::fmt()
.with_env_filter(
@@ -335,6 +406,37 @@ fn main() -> glib::ExitCode {
)
.init();
let raw: Vec<String> = std::env::args().skip(1).collect();
if raw.iter().any(|arg| arg == "-h" || arg == "--help") {
print!("{USAGE}");
return glib::ExitCode::SUCCESS;
}
let args = match parse_args(raw.into_iter()) {
Ok(args) => args,
Err(message) => {
eprintln!("pdfs-prompt: {message}\n\n{USAGE}");
return glib::ExitCode::FAILURE;
}
};
// The stored mode makes an existing keybinding switch front ends without
// being re-bound; an explicit flag still wins.
let stored = AppDirs::new()
.map(|dirs| dirs.load_config().resolved_prompt().resolved_mode())
.unwrap_or_default();
if args.mode.unwrap_or(stored) == PromptMode::Dmenu {
return match dmenu::run(dmenu::Options {
menu: args.menu,
query: args.query,
}) {
Ok(()) => glib::ExitCode::SUCCESS,
Err(message) => {
eprintln!("pdfs-prompt: {message}");
glib::ExitCode::FAILURE
}
};
}
let app = adw::Application::builder().application_id(APP_ID).build();
// GtkApplication normally remains active while it owns a window, including
// when that window is hidden. Hold it explicitly as well: residency is a
@@ -354,7 +456,8 @@ fn main() -> glib::ExitCode {
};
ui.activate();
});
app.run()
// GTK must not try to parse our own flags; they were consumed above.
app.run_with_args::<&str>(&[])
}
fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
@@ -478,6 +581,7 @@ fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
let ui = Rc::new(Ui {
socket: dirs.control_socket(),
mountpoint: RefCell::new(dirs.default_mountpoint()),
opener: dirs.load_config().resolved_open_with(),
entry: entry.clone(),
spinner,
stack,
@@ -686,6 +790,20 @@ impl Ui {
self.entry.grab_focus();
}
/// Hand a resolved path to the configured opener — `xdg-open` unless
/// `open_with` in `config.json` says otherwise (a terminal editor for text
/// files, say).
fn open_path(&self, path: &Path, is_dir: bool) {
opener::open(&self.opener, path, is_dir);
}
/// Open a file the daemon just materialised into the content cache. The
/// blob is named after its content hash, so the open rules have to be
/// matched against the Drive name instead.
fn open_materialized(&self, path: &Path, name: &str) {
opener::open_named(&self.opener, path, name, false);
}
/// Dismiss without destroying the application window. GTK continues to
/// associate the hidden window with the single application instance, so a
/// later desktop shortcut invocation is a cheap `activate` round-trip.
@@ -1081,7 +1199,7 @@ impl Ui {
match hit {
Hit::Local(local) => {
xdg_open(Path::new(&local.path));
self.open_path(Path::new(&local.path), local.is_dir);
self.dismiss();
}
Hit::Drive(drive) => {
@@ -1091,25 +1209,28 @@ impl Ui {
// this handles non-recursively pinned folders without mistaking
// them for files and sending an invalid OpenFile request.
if self.rendered_query.borrow().is_empty() {
let path = drive.mounted_path.as_deref().map_or_else(
|| mounted_path(&self.mountpoint.borrow(), &drive.path),
PathBuf::from,
);
xdg_open(&path);
let path = mounted_or_relative(&self.mountpoint.borrow(), &drive);
self.open_path(&path, drive.is_dir);
self.dismiss();
return;
}
match drive_activation(&drive.name, drive.is_dir) {
DriveActivation::Folder | DriveActivation::MountedMedia => {
let path = drive.mounted_path.as_deref().map_or_else(
|| mounted_path(&self.mountpoint.borrow(), &drive.path),
PathBuf::from,
);
xdg_open(&path);
let path = mounted_or_relative(&self.mountpoint.borrow(), &drive);
self.open_path(&path, drive.is_dir);
self.dismiss();
return;
}
DriveActivation::Materialize => {}
// The mount holds the real file; the cache holds a copy an
// editor would save into for nothing. Prefer the mount, and
// materialise only what it does not expose.
DriveActivation::Materialize => {
if let Some(path) = mounted_target(&self.mountpoint.borrow(), &drive) {
self.open_path(&path, false);
self.dismiss();
return;
}
}
}
self.opening.set(true);
@@ -1121,6 +1242,7 @@ impl Ui {
self.socket.clone(),
Request::OpenFile {
path: drive.path.clone(),
uid: Some(drive.uid.clone()),
},
);
let ui = self.clone();
@@ -1131,7 +1253,7 @@ impl Ui {
ui.opening.set(false);
match reply {
Ok(Ok(Response::FilePath { path })) => {
xdg_open(Path::new(&path));
ui.open_materialized(Path::new(&path), &drive.name);
ui.dismiss();
}
Ok(Ok(Response::Error { message, .. })) => {
@@ -1224,13 +1346,6 @@ fn spawn_request(
rx
}
fn xdg_open(path: &Path) {
tracing::info!(path = %path.display(), "opening");
if let Err(e) = Command::new("xdg-open").arg(path).spawn() {
tracing::error!("xdg-open failed: {e}");
}
}
fn dirs_home() -> Option<PathBuf> {
AppDirs::new().ok().and_then(|dirs| dirs.home_dir())
}
@@ -1466,4 +1581,42 @@ mod tests {
assert!(matches!(&hits[0], Hit::Local(hit) if hit.name == "better.pdf"));
assert!(matches!(&hits[1], Hit::Drive(hit) if hit.name == "alpha.pdf"));
}
fn parse(args: &[&str]) -> Result<Args, String> {
parse_args(args.iter().map(|a| (*a).to_string()))
}
#[test]
fn no_arguments_defer_the_mode_to_config() {
let args = parse(&[]).unwrap();
assert_eq!(args.mode, None);
assert_eq!(args.menu, None);
assert_eq!(args.query, None);
}
#[test]
fn naming_a_launcher_implies_dmenu_but_an_explicit_mode_still_wins() {
let args = parse(&["--menu", "fuzzel --dmenu"]).unwrap();
assert_eq!(args.mode, Some(PromptMode::Dmenu));
assert_eq!(
args.menu,
Some(vec!["fuzzel".to_string(), "--dmenu".to_string()])
);
let args = parse(&["--gtk", "--menu", "rofi -dmenu"]).unwrap();
assert_eq!(args.mode, Some(PromptMode::Gtk));
}
#[test]
fn a_flag_without_its_value_is_an_error_not_a_default() {
assert!(parse(&["--menu"]).is_err());
assert!(parse(&["--query"]).is_err());
assert!(parse(&["--nonsense"]).is_err());
}
#[test]
fn a_query_is_taken_verbatim_including_spaces() {
let args = parse(&["--dmenu", "--query", "tax return 2024"]).unwrap();
assert_eq!(args.query.as_deref(), Some("tax return 2024"));
}
}