mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
fix(prompt) cached file open instead of actualy files
This commit is contained in:
1 parent
4d76462e62
commit
14b6df7bce
18 files changed
+1862
-59
No files matched your search
Generated
+4
-4
@@ -3518,7 +3518,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-cli"
|
name = "pdfs-cli"
|
||||||
version = "1.4.0"
|
version = "1.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap 4.6.4",
|
"clap 4.6.4",
|
||||||
@@ -3533,7 +3533,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-core"
|
name = "pdfs-core"
|
||||||
version = "1.4.0"
|
version = "1.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"directories",
|
"directories",
|
||||||
@@ -3554,7 +3554,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-fuse"
|
name = "pdfs-fuse"
|
||||||
version = "1.4.0"
|
version = "1.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fuser",
|
"fuser",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -3571,7 +3571,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-gui"
|
name = "pdfs-gui"
|
||||||
version = "1.4.0"
|
version = "1.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "2"
|
|||||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "1.4.0"
|
version = "1.5.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||||
|
|||||||
@@ -27,6 +27,80 @@ A fast, unofficial Proton Drive client for Linux. This client features an advanc
|
|||||||
|
|
||||||
The prompt stays resident after its first launch, so invoking the shortcut again reuses and resets the existing window. Drive folders open from the mount. Audio and video results also open through FUSE, allowing media players to seek and request ranges without downloading the entire file first; ordinary Drive files are materialized into the local cache before opening.
|
The prompt stays resident after its first launch, so invoking the shortcut again reuses and resets the existing window. Drive folders open from the mount. Audio and video results also open through FUSE, allowing media players to seek and request ranges without downloading the entire file first; ordinary Drive files are materialized into the local cache before opening.
|
||||||
|
|
||||||
|
### Using your own launcher (fuzzel, rofi, wofi, …)
|
||||||
|
|
||||||
|
`pdfs-prompt --dmenu` runs the same search through an external dmenu-style
|
||||||
|
launcher instead of the built-in window, so the HUD matches the rest of a
|
||||||
|
tiling-WM desktop:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pdfs-prompt --dmenu # fuzzel/rofi/wofi/tofi/bemenu/dmenu, whichever is installed
|
||||||
|
pdfs-prompt --dmenu --menu 'fuzzel --dmenu --width 60'
|
||||||
|
pdfs-prompt --dmenu --query invoice # skip straight to a search
|
||||||
|
```
|
||||||
|
|
||||||
|
A launcher filters a fixed list — it cannot ask for a new one per keystroke — so
|
||||||
|
searching takes two steps, and the prompt says which step you are on:
|
||||||
|
|
||||||
|
- `Search Drive ›` lists your pinned files. Type anything that isn't one of them
|
||||||
|
and press Enter to search for it.
|
||||||
|
- `Drive: invoice ›` lists the results. Enter opens one; typing something else
|
||||||
|
and pressing Enter searches again. Escape closes.
|
||||||
|
|
||||||
|
`--query` skips the first step. fuzzel and rofi also get file-type icons.
|
||||||
|
|
||||||
|
To make an existing keybinding use it without re-binding, and to pin the
|
||||||
|
launcher command, set them in `config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"prompt": {
|
||||||
|
"mode": "dmenu",
|
||||||
|
"menu": ["fuzzel", "--dmenu", "--width", "60"],
|
||||||
|
"menu_limit": 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`--gtk` overrides `"mode": "dmenu"` for one invocation. A `{prompt}` token
|
||||||
|
anywhere in `menu` is replaced by the prompt text; without one, the launcher's
|
||||||
|
own prompt flag is appended.
|
||||||
|
|
||||||
|
## Choosing How Files Open
|
||||||
|
|
||||||
|
By default every result is handed to `xdg-open`. An `open_with` block in
|
||||||
|
`config.json` overrides that per file type — for instance opening text in
|
||||||
|
Neovim inside Alacritty, while everything else still goes to the desktop:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"open_with": {
|
||||||
|
"terminal": ["alacritty", "-e"],
|
||||||
|
"rules": [
|
||||||
|
{ "match": ["@text"], "command": ["nvim"], "terminal": true },
|
||||||
|
{ "match": ["*.png", "*.jpg"], "command": ["imv"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `match` takes file-name globs (`*.md`, `notes-*.txt`) or the classes `@dir`,
|
||||||
|
`@text`, `@document`, `@image`, `@media`, `@any`. First matching rule wins.
|
||||||
|
- `command` is argv, not a shell line. A `{}` token is replaced by the path;
|
||||||
|
without one the path is appended. `$VAR` tokens expand from the environment,
|
||||||
|
so `["$EDITOR"]` follows your editor.
|
||||||
|
- `"terminal": true` wraps the command in `terminal`. When that is unset, the
|
||||||
|
terminal comes from `$PDFS_TERMINAL`, then `$TERMINAL`, then the first known
|
||||||
|
emulator on `PATH`; a bare name like `TERMINAL=alacritty` gains the right
|
||||||
|
"run this" flag automatically.
|
||||||
|
- `"default": [...]` replaces `xdg-open` for everything unmatched.
|
||||||
|
|
||||||
|
The rules apply to the prompt (both front ends) and to the GTK browser, so a
|
||||||
|
file opens the same way wherever it was picked. Patterns are matched against the
|
||||||
|
Drive name — a downloaded file is stored in the cache under its content hash,
|
||||||
|
which says nothing about its type, so matching that would send everything to
|
||||||
|
`xdg-open`.
|
||||||
|
|
||||||
## Selective Sync (`.pdfsignore`)
|
## Selective Sync (`.pdfsignore`)
|
||||||
|
|
||||||
Two-way synced folders skip paths matched by ignore rules, so syncing a project
|
Two-way synced folders skip paths matched by ignore rules, so syncing a project
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use directories::ProjectDirs;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
|
use crate::menu::PromptConfig;
|
||||||
|
use crate::opener::OpenWith;
|
||||||
use crate::syncignore::DEFAULT_IGNORE_PATTERNS;
|
use crate::syncignore::DEFAULT_IGNORE_PATTERNS;
|
||||||
|
|
||||||
/// Proton requires `external-drive-{name}@{semver}-{channel}` (channel ∈
|
/// 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`].
|
/// the field, and overridable at runtime via [`CONFLICT_SWEEP_ENV`].
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub conflict_sweep: Option<SweepMode>,
|
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 {
|
impl Default for AppConfig {
|
||||||
@@ -118,6 +131,8 @@ impl Default for AppConfig {
|
|||||||
ignore_patterns: None,
|
ignore_patterns: None,
|
||||||
device_uid: None,
|
device_uid: None,
|
||||||
conflict_sweep: None,
|
conflict_sweep: None,
|
||||||
|
open_with: None,
|
||||||
|
prompt: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,6 +144,18 @@ impl AppConfig {
|
|||||||
self.cache_budget.unwrap_or(DEFAULT_CACHE_BUDGET_BYTES)
|
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
|
/// The effective conflict-sweep mode: the [`CONFLICT_SWEEP_ENV`] override if
|
||||||
/// it parses, then the user's stored choice, then [`SweepMode::default`].
|
/// it parses, then the user's stored choice, then [`SweepMode::default`].
|
||||||
///
|
///
|
||||||
@@ -448,6 +475,8 @@ mod tests {
|
|||||||
ignore_patterns: Some(vec!["build/".to_string()]),
|
ignore_patterns: Some(vec!["build/".to_string()]),
|
||||||
device_uid: Some("dev-uid".to_string()),
|
device_uid: Some("dev-uid".to_string()),
|
||||||
conflict_sweep: Some(SweepMode::Enforce),
|
conflict_sweep: Some(SweepMode::Enforce),
|
||||||
|
open_with: None,
|
||||||
|
prompt: None,
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&config).unwrap();
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
||||||
|
|||||||
@@ -120,7 +120,18 @@ pub enum Request {
|
|||||||
/// Download a Drive file's full content into the cache; replies with the
|
/// 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`
|
/// on-disk path so the front-end can open it with the default app. `path`
|
||||||
/// is mountpoint-relative.
|
/// 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.
|
/// Full-text search node names against the daemon's local metadata index.
|
||||||
/// `limit` caps the number of hits returned. Replies with [`Response::SearchResults`].
|
/// `limit` caps the number of hits returned. Replies with [`Response::SearchResults`].
|
||||||
Search { query: String, limit: usize },
|
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]
|
#[test]
|
||||||
fn legacy_daemons_reject_new_by_uid_variants() {
|
fn legacy_daemons_reject_new_by_uid_variants() {
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ pub mod control;
|
|||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod localindex;
|
pub mod localindex;
|
||||||
|
pub mod menu;
|
||||||
pub mod mounts;
|
pub mod mounts;
|
||||||
|
pub mod opener;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod sdkcache;
|
pub mod sdkcache;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -332,15 +332,20 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(CtlRequest::OpenFile { path }) => match rel_to_mount(mountpoint, &path) {
|
// A uid addresses the node wherever it lives; the path only works for
|
||||||
Ok(rel) => match core.open_file(&rel) {
|
// the primary mount's tree. Prefer the uid when the client sent one.
|
||||||
|
Ok(CtlRequest::OpenFile { path, uid }) => {
|
||||||
|
let opened = match uid.as_deref() {
|
||||||
|
Some(uid) => core.open_file_uid(uid),
|
||||||
|
None => rel_to_mount(mountpoint, &path).and_then(|rel| core.open_file(&rel)),
|
||||||
|
};
|
||||||
|
match opened {
|
||||||
Ok(p) => CtlResponse::FilePath {
|
Ok(p) => CtlResponse::FilePath {
|
||||||
path: p.display().to_string(),
|
path: p.display().to_string(),
|
||||||
},
|
},
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
}
|
||||||
Err(e) => CtlResponse::error(e),
|
}
|
||||||
},
|
|
||||||
Ok(CtlRequest::Search { query, limit }) => match core.search(&query, limit) {
|
Ok(CtlRequest::Search { query, limit }) => match core.search(&query, limit) {
|
||||||
Ok(hits) => CtlResponse::SearchResults { hits },
|
Ok(hits) => CtlResponse::SearchResults { hits },
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
|
|||||||
@@ -2974,15 +2974,24 @@ impl Core {
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return an absolute path through the most specific live on-demand mount
|
/// Return an absolute path through the most specific local location
|
||||||
/// covering `uid`. A node may be below several configured roots, so prefer
|
/// covering `uid`. A node may be below several configured roots, so prefer
|
||||||
/// the root leaving the shortest descendant path.
|
/// the root leaving the shortest descendant path.
|
||||||
|
///
|
||||||
|
/// An on-demand folder only answers while its mount is live — without the
|
||||||
|
/// mount its `local_path` is an ordinary empty directory. A mirror folder
|
||||||
|
/// needs no mount: its files are real local copies the sync loop pushes
|
||||||
|
/// back, so it is a valid open target whenever it is configured.
|
||||||
fn mounted_search_path(&self, uid: &str) -> Option<String> {
|
fn mounted_search_path(&self, uid: &str) -> Option<String> {
|
||||||
let live_mounts = self.mounts.lock();
|
let live_mounts = self.mounts.lock();
|
||||||
let folders = self.db.sync_folder_list().ok()?;
|
let folders = self.db.sync_folder_list().ok()?;
|
||||||
folders
|
folders
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|folder| folder.mode == "ondemand" && live_mounts.contains_key(&folder.id))
|
.filter(|folder| match folder.mode.as_str() {
|
||||||
|
"ondemand" => live_mounts.contains_key(&folder.id),
|
||||||
|
"mirror" => true,
|
||||||
|
_ => false,
|
||||||
|
})
|
||||||
.filter_map(|folder| {
|
.filter_map(|folder| {
|
||||||
let relative = self.db.path_relative_to(&folder.remote_uid, uid).ok()??;
|
let relative = self.db.path_relative_to(&folder.remote_uid, uid).ok()??;
|
||||||
let depth = relative.split('/').count();
|
let depth = relative.split('/').count();
|
||||||
@@ -3164,7 +3173,68 @@ impl Core {
|
|||||||
node_size(&e.node),
|
node_size(&e.node),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
if let Some(p) = self.cache.cached_content_path(&uid, mtime, size) {
|
self.fetch_content(&uid, &name, mtime, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`open_file`](Self::open_file) addressed by uid instead of by path.
|
||||||
|
///
|
||||||
|
/// A search hit is a metadata-index row, and the index covers every
|
||||||
|
/// location this daemon knows — including on-demand sync folders and
|
||||||
|
/// mirrors, whose nodes have no path inside the primary mount's tree.
|
||||||
|
/// Walking such a hit's path from the mount root answers ENOENT even though
|
||||||
|
/// the node is perfectly reachable, so front-ends address hits by uid.
|
||||||
|
///
|
||||||
|
/// Metadata comes from the live tree, then the index, then the API — the
|
||||||
|
/// last covers a node the index knows but no location holds. Unlike the
|
||||||
|
/// mutating by-uid handlers this does not demand a
|
||||||
|
/// [`resolve_anywhere`](Self::resolve_anywhere) residency proof: reading
|
||||||
|
/// one's own file is what the API authorizes anyway, and requiring
|
||||||
|
/// residency is exactly what turned an openable search hit into "no such
|
||||||
|
/// file or folder". [`open_shared_file`](Self::open_shared_file) already
|
||||||
|
/// works this way. Reserved (local/virtual) uids name no remote node and
|
||||||
|
/// are still rejected.
|
||||||
|
fn open_file_uid(&self, raw_uid: &str) -> CoreResult<PathBuf> {
|
||||||
|
let uid = parse_uid(raw_uid)
|
||||||
|
.filter(|uid| !is_local_uid(uid) && !is_virtual_uid(uid))
|
||||||
|
.ok_or_else(|| CoreError::invalid(format!("invalid uid: {raw_uid}")))?;
|
||||||
|
let live = self
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.entries
|
||||||
|
.values()
|
||||||
|
.find(|e| e.uid == uid)
|
||||||
|
.map(|e| e.node.clone());
|
||||||
|
let node = match live {
|
||||||
|
Some(node) => node,
|
||||||
|
None => match self
|
||||||
|
.db
|
||||||
|
.node_by_uid(&uid.to_string())
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "load node"))?
|
||||||
|
{
|
||||||
|
Some(node) => node,
|
||||||
|
None => self
|
||||||
|
.rt
|
||||||
|
.block_on(self.client.get_node(&uid))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "get node"))?
|
||||||
|
.ok_or_else(|| CoreError::not_found("node vanished"))?,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if !node.is_file() {
|
||||||
|
return Err(CoreError::invalid("not a regular file"));
|
||||||
|
}
|
||||||
|
self.fetch_content(&uid, &node.name, node.modification_time, node_size(&node))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Materialise a file's full content into the content cache, returning its
|
||||||
|
/// on-disk path. Serves a fresh cached blob without touching the network.
|
||||||
|
fn fetch_content(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
name: &str,
|
||||||
|
mtime: i64,
|
||||||
|
size: u64,
|
||||||
|
) -> CoreResult<PathBuf> {
|
||||||
|
if let Some(p) = self.cache.cached_content_path(uid, mtime, size) {
|
||||||
return Ok(p);
|
return Ok(p);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3175,7 +3245,7 @@ impl Core {
|
|||||||
// from sharing a partially written staging file.
|
// from sharing a partially written staging file.
|
||||||
static OPEN_TEMP_SEQ: AtomicU64 = AtomicU64::new(0);
|
static OPEN_TEMP_SEQ: AtomicU64 = AtomicU64::new(0);
|
||||||
let seq = OPEN_TEMP_SEQ.fetch_add(1, Ordering::Relaxed);
|
let seq = OPEN_TEMP_SEQ.fetch_add(1, Ordering::Relaxed);
|
||||||
let tmp = self.cache.content_path(&uid).with_extension(format!(
|
let tmp = self.cache.content_path(uid).with_extension(format!(
|
||||||
"open-{}-{}-{seq}.tmp",
|
"open-{}-{}-{seq}.tmp",
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
SystemTime::now()
|
SystemTime::now()
|
||||||
@@ -3188,7 +3258,7 @@ impl Core {
|
|||||||
.create_new(true)
|
.create_new(true)
|
||||||
.open(&tmp)
|
.open(&tmp)
|
||||||
.map_err(|e| CoreError::internal(format!("create download temp: {e}")))?;
|
.map_err(|e| CoreError::internal(format!("create download temp: {e}")))?;
|
||||||
let file = match self.download_file_tracked_to(&uid, &name, size, file) {
|
let file = match self.download_file_tracked_to(uid, name, size, file) {
|
||||||
Ok(file) => file,
|
Ok(file) => file,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = std::fs::remove_file(&tmp);
|
let _ = std::fs::remove_file(&tmp);
|
||||||
@@ -3199,12 +3269,12 @@ impl Core {
|
|||||||
let _ = std::fs::remove_file(&tmp);
|
let _ = std::fs::remove_file(&tmp);
|
||||||
return Err(CoreError::internal(format!("sync download temp: {e}")));
|
return Err(CoreError::internal(format!("sync download temp: {e}")));
|
||||||
}
|
}
|
||||||
if let Err(e) = self.cache.store_file(&uid, mtime, size, &tmp) {
|
if let Err(e) = self.cache.store_file(uid, mtime, size, &tmp) {
|
||||||
let _ = std::fs::remove_file(&tmp);
|
let _ = std::fs::remove_file(&tmp);
|
||||||
return Err(CoreError::internal(format!("cache store: {e}")));
|
return Err(CoreError::internal(format!("cache store: {e}")));
|
||||||
}
|
}
|
||||||
let _ = std::fs::remove_file(&tmp);
|
let _ = std::fs::remove_file(&tmp);
|
||||||
Ok(self.cache.content_path(&uid))
|
Ok(self.cache.content_path(uid))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drop the cached child listing of `rel`'s parent directory so the next
|
/// Drop the cached child listing of `rel`'s parent directory so the next
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
//! Shared Drive-result activation policy for the GTK browser and quick prompt.
|
//! 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
|
//! 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
|
//! through FUSE. Everything else opens through the mount too whenever the mount
|
||||||
//! many desktop applications expect a stable ordinary file.
|
//! 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 std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use pdfs_core::control::PhotoKind;
|
use pdfs_core::control::{PhotoKind, SearchHit};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub(crate) enum DriveActivation {
|
pub(crate) enum DriveActivation {
|
||||||
@@ -29,6 +34,49 @@ pub(crate) fn mounted_path(mountpoint: &Path, relative_path: &str) -> PathBuf {
|
|||||||
mountpoint.join(relative_path)
|
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 {
|
fn is_streamable_media(name: &str) -> bool {
|
||||||
if PhotoKind::classify(Some(name), None) == PhotoKind::Video {
|
if PhotoKind::classify(Some(name), None) == PhotoKind::Video {
|
||||||
return true;
|
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) {
|
fn open_path(path: &str) {
|
||||||
if let Err(e) = Command::new("xdg-open").arg(path).spawn() {
|
let path = Path::new(path);
|
||||||
tracing::error!("xdg-open {path} failed: {e}");
|
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`).
|
/// 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::*;
|
use crate::*;
|
||||||
|
|
||||||
pub(crate) struct BrowserState {
|
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
|
/// Hand a file to the user's default application: through the mount when it is
|
||||||
/// default application. The download-and-open path behind both a plain
|
/// there, otherwise downloaded into the cache first. The open path behind both
|
||||||
/// double-click and the context menu's "Open" (including for a video, when the
|
/// a plain double-click and the context menu's "Open" (including for a video,
|
||||||
/// user explicitly wants a local copy rather than to stream it).
|
/// 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) {
|
pub(crate) fn download_and_open(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
let rel = entry_rel(ui, entry);
|
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
|
// Ignore a repeat activation of a file already downloading, so an impatient
|
||||||
// double-click doesn't kick off a second round-trip.
|
// double-click doesn't kick off a second round-trip.
|
||||||
if !ui.opening.borrow_mut().insert(rel.clone()) {
|
if !ui.opening.borrow_mut().insert(rel.clone()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.busy_begin();
|
ui.busy_begin();
|
||||||
|
let name = entry.name.clone();
|
||||||
let rx = spawn_request(
|
let rx = spawn_request(
|
||||||
ui.dirs.control_socket(),
|
ui.dirs.control_socket(),
|
||||||
Request::OpenFile { path: rel.clone() },
|
Request::OpenFile {
|
||||||
|
path: rel.clone(),
|
||||||
|
uid: Some(entry.uid.clone()),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
glib::spawn_future_local(async move {
|
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.busy_end();
|
||||||
ui.opening.borrow_mut().remove(&rel);
|
ui.opening.borrow_mut().remove(&rel);
|
||||||
match result {
|
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 })) => {
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
toast_failure(&ui, "Couldn't open file", &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 ui = ui.clone();
|
||||||
let uid = uid.to_string();
|
let uid = uid.to_string();
|
||||||
|
let name = name.to_string();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
let result = rx.recv().await;
|
let result = rx.recv().await;
|
||||||
ui.busy_end();
|
ui.busy_end();
|
||||||
ui.opening.borrow_mut().remove(&uid);
|
ui.opening.borrow_mut().remove(&uid);
|
||||||
match result {
|
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 })) => {
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
toast_failure(&ui, "Couldn't open file", &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) {
|
fn open_folder(mountpoint: &Path) {
|
||||||
if let Err(e) = Command::new("xdg-open").arg(mountpoint).spawn() {
|
pdfs_core::opener::open_default(mountpoint, true);
|
||||||
tracing::error!("failed to xdg-open {}: {e}", mountpoint.display());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Launch (or, since it's `SingleMainWindow`, raise) the settings/management
|
/// Launch (or, since it's `SingleMainWindow`, raise) the settings/management
|
||||||
|
|||||||
+176
-23
@@ -9,7 +9,6 @@
|
|||||||
|
|
||||||
use std::cell::{Cell, RefCell};
|
use std::cell::{Cell, RefCell};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -20,9 +19,12 @@ use pdfs_core::config::AppDirs;
|
|||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
LocalHit, Request, Response, SearchFilters, SearchHit, SearchKind, SearchSource, send,
|
LocalHit, Request, Response, SearchFilters, SearchHit, SearchKind, SearchSource, send,
|
||||||
};
|
};
|
||||||
|
use pdfs_core::menu::PromptMode;
|
||||||
|
use pdfs_core::opener::{self, OpenWith};
|
||||||
|
|
||||||
mod activation;
|
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";
|
const APP_ID: &str = "io.narl.proton-drive-linux-prompt";
|
||||||
|
|
||||||
@@ -220,6 +222,10 @@ fn rank_hits(hits: &mut [Hit]) {
|
|||||||
struct Ui {
|
struct Ui {
|
||||||
socket: PathBuf,
|
socket: PathBuf,
|
||||||
mountpoint: RefCell<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,
|
entry: gtk4::Entry,
|
||||||
spinner: gtk4::Spinner,
|
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 {
|
fn main() -> glib::ExitCode {
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter(
|
.with_env_filter(
|
||||||
@@ -335,6 +406,37 @@ fn main() -> glib::ExitCode {
|
|||||||
)
|
)
|
||||||
.init();
|
.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();
|
let app = adw::Application::builder().application_id(APP_ID).build();
|
||||||
// GtkApplication normally remains active while it owns a window, including
|
// GtkApplication normally remains active while it owns a window, including
|
||||||
// when that window is hidden. Hold it explicitly as well: residency is a
|
// when that window is hidden. Hold it explicitly as well: residency is a
|
||||||
@@ -354,7 +456,8 @@ fn main() -> glib::ExitCode {
|
|||||||
};
|
};
|
||||||
ui.activate();
|
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>> {
|
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 {
|
let ui = Rc::new(Ui {
|
||||||
socket: dirs.control_socket(),
|
socket: dirs.control_socket(),
|
||||||
mountpoint: RefCell::new(dirs.default_mountpoint()),
|
mountpoint: RefCell::new(dirs.default_mountpoint()),
|
||||||
|
opener: dirs.load_config().resolved_open_with(),
|
||||||
entry: entry.clone(),
|
entry: entry.clone(),
|
||||||
spinner,
|
spinner,
|
||||||
stack,
|
stack,
|
||||||
@@ -686,6 +790,20 @@ impl Ui {
|
|||||||
self.entry.grab_focus();
|
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
|
/// Dismiss without destroying the application window. GTK continues to
|
||||||
/// associate the hidden window with the single application instance, so a
|
/// associate the hidden window with the single application instance, so a
|
||||||
/// later desktop shortcut invocation is a cheap `activate` round-trip.
|
/// later desktop shortcut invocation is a cheap `activate` round-trip.
|
||||||
@@ -1081,7 +1199,7 @@ impl Ui {
|
|||||||
|
|
||||||
match hit {
|
match hit {
|
||||||
Hit::Local(local) => {
|
Hit::Local(local) => {
|
||||||
xdg_open(Path::new(&local.path));
|
self.open_path(Path::new(&local.path), local.is_dir);
|
||||||
self.dismiss();
|
self.dismiss();
|
||||||
}
|
}
|
||||||
Hit::Drive(drive) => {
|
Hit::Drive(drive) => {
|
||||||
@@ -1091,25 +1209,28 @@ impl Ui {
|
|||||||
// this handles non-recursively pinned folders without mistaking
|
// this handles non-recursively pinned folders without mistaking
|
||||||
// them for files and sending an invalid OpenFile request.
|
// them for files and sending an invalid OpenFile request.
|
||||||
if self.rendered_query.borrow().is_empty() {
|
if self.rendered_query.borrow().is_empty() {
|
||||||
let path = drive.mounted_path.as_deref().map_or_else(
|
let path = mounted_or_relative(&self.mountpoint.borrow(), &drive);
|
||||||
|| mounted_path(&self.mountpoint.borrow(), &drive.path),
|
self.open_path(&path, drive.is_dir);
|
||||||
PathBuf::from,
|
|
||||||
);
|
|
||||||
xdg_open(&path);
|
|
||||||
self.dismiss();
|
self.dismiss();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
match drive_activation(&drive.name, drive.is_dir) {
|
match drive_activation(&drive.name, drive.is_dir) {
|
||||||
DriveActivation::Folder | DriveActivation::MountedMedia => {
|
DriveActivation::Folder | DriveActivation::MountedMedia => {
|
||||||
let path = drive.mounted_path.as_deref().map_or_else(
|
let path = mounted_or_relative(&self.mountpoint.borrow(), &drive);
|
||||||
|| mounted_path(&self.mountpoint.borrow(), &drive.path),
|
self.open_path(&path, drive.is_dir);
|
||||||
PathBuf::from,
|
|
||||||
);
|
|
||||||
xdg_open(&path);
|
|
||||||
self.dismiss();
|
self.dismiss();
|
||||||
return;
|
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);
|
self.opening.set(true);
|
||||||
@@ -1121,6 +1242,7 @@ impl Ui {
|
|||||||
self.socket.clone(),
|
self.socket.clone(),
|
||||||
Request::OpenFile {
|
Request::OpenFile {
|
||||||
path: drive.path.clone(),
|
path: drive.path.clone(),
|
||||||
|
uid: Some(drive.uid.clone()),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let ui = self.clone();
|
let ui = self.clone();
|
||||||
@@ -1131,7 +1253,7 @@ impl Ui {
|
|||||||
ui.opening.set(false);
|
ui.opening.set(false);
|
||||||
match reply {
|
match reply {
|
||||||
Ok(Ok(Response::FilePath { path })) => {
|
Ok(Ok(Response::FilePath { path })) => {
|
||||||
xdg_open(Path::new(&path));
|
ui.open_materialized(Path::new(&path), &drive.name);
|
||||||
ui.dismiss();
|
ui.dismiss();
|
||||||
}
|
}
|
||||||
Ok(Ok(Response::Error { message, .. })) => {
|
Ok(Ok(Response::Error { message, .. })) => {
|
||||||
@@ -1224,13 +1346,6 @@ fn spawn_request(
|
|||||||
rx
|
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> {
|
fn dirs_home() -> Option<PathBuf> {
|
||||||
AppDirs::new().ok().and_then(|dirs| dirs.home_dir())
|
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[0], Hit::Local(hit) if hit.name == "better.pdf"));
|
||||||
assert!(matches!(&hits[1], Hit::Drive(hit) if hit.name == "alpha.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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
# Build + install: cd packaging && makepkg -fi
|
# Build + install: cd packaging && makepkg -fi
|
||||||
|
|
||||||
pkgname=proton-drive-linux
|
pkgname=proton-drive-linux
|
||||||
pkgver=1.4.0
|
pkgver=1.5.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray"
|
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
Reference in new issue
Block a user