mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
fix(prompt) cached file open instead of actualy files
This commit is contained in:
1 parent
4d76462e62
commit
14b6df7bce
18 files changed
+1863
-60
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user