diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4942d99..1e12928 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -160,6 +160,7 @@ jobs: cp packaging/proton-drive.service deb/usr/lib/systemd/user/ cp LICENSE deb/usr/share/licenses/proton-drive-linux/ po/build.sh deb/usr/share/locale + packaging/completions.sh target/release/pdfs deb/usr/share # Stops the running daemon before removal, so the mount stops mirroring deletes install -m755 packaging/deb/prerm deb/DEBIAN/prerm diff --git a/Cargo.lock b/Cargo.lock index 0ad8a5f..a8dc170 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -754,6 +754,18 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f" +dependencies = [ + "clap", + "clap_lex", + "is_executable", + "shlex", +] + [[package]] name = "clap_derive" version = "4.6.7" @@ -2551,6 +2563,15 @@ version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" +[[package]] +name = "is_executable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cb6a9f675da968c63b6208c641b9dca58fc0133ae53375736b1767b0cab8bd" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -3524,6 +3545,7 @@ version = "3.7.0" dependencies = [ "anyhow", "clap", + "clap_complete", "pdfs-core", "pdfs-fuse", "rpassword", diff --git a/Cargo.toml b/Cargo.toml index 63cf0d6..bc5cbe1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,9 @@ rusqlite = { version = "0.41", features = ["bundled"] } keyring-core = "1" dbus-secret-service-keyring-store = { version = "1", features = ["crypto-rust"] } clap = { version = "4", features = ["derive"] } +# `unstable-dynamic`: Tab calls `pdfs` back, so arguments complete from the +# running service. Only `pdfs-cli/src/complete.rs` uses it. +clap_complete = { version = "4.6", features = ["unstable-dynamic"] } rpassword = "7" async-trait = "0.1" futures = "0.3" diff --git a/README.md b/README.md index eecceac..4b0d64d 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ The documentation is in [`docs/`](docs/README.md) and on the | [Getting started](docs/GETTING_STARTED.md) | From installing to your first synced folder | | [User guide](docs/USER_GUIDE.md) | Everything the app, tray and launcher do | | [FAQ](docs/FAQ.md) | Short answers to common questions | -| [Command-line reference](docs/CLI.md) | Every `pdfs` command and scripting with `--json` | +| [Command-line reference](docs/CLI.md) | Every `pdfs` command, Tab completion and scripting with `--json` | | [Configuration](docs/CONFIGURATION.md) | `config.json`, file locations, environment variables, the service | | [Troubleshooting](docs/TROUBLESHOOTING.md) | Diagnosing problems and fixing common ones | | [Recovering on a new computer](docs/RECOVERY.md) | Getting your synced folders back after a reinstall or a lost machine | diff --git a/crates/pdfs-cli/Cargo.toml b/crates/pdfs-cli/Cargo.toml index f4a8eba..f4395e6 100644 --- a/crates/pdfs-cli/Cargo.toml +++ b/crates/pdfs-cli/Cargo.toml @@ -21,6 +21,7 @@ anyhow.workspace = true tracing.workspace = true tracing-subscriber.workspace = true clap.workspace = true +clap_complete.workspace = true rpassword.workspace = true serde_json.workspace = true zeroize.workspace = true diff --git a/crates/pdfs-cli/src/complete.rs b/crates/pdfs-cli/src/complete.rs new file mode 100644 index 0000000..284ad27 --- /dev/null +++ b/crates/pdfs-cli/src/complete.rs @@ -0,0 +1,731 @@ +//! Shell completion for `pdfs`. +//! +//! Built on clap_complete's dynamic engine: the script a shell loads is a thin +//! stub that calls `pdfs` back on every Tab with the words typed so far, and +//! `pdfs` answers from its own clap definition. That lets an argument complete +//! from the running service as well as from the command tree — `pdfs sync rm +//! ` offers the synced-folder ids with their paths, `pdfs pin Doc` +//! walks My files. +//! +//! Every value that comes from the service is best effort. With no service, a +//! slow one or an error, a completer offers nothing: a Tab that hangs or prints +//! an error into the prompt is worse than one that does nothing. +//! +//! The engine sits behind clap_complete's `unstable-dynamic` feature, so all of +//! this crate's use of it is kept to this module. + +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use clap::CommandFactory; +use clap_complete::engine::{CompletionCandidate, PathCompleter, ValueCompleter}; +use clap_complete::env::{Bash, CompleteEnv, Elvish, EnvCompleter, Fish, Powershell, Zsh}; +use pdfs_core::control::{ + AlbumInfo, BookmarkInfo, ConflictInfo, DeviceInfo, DirEntry, InvitationInfo, PendingOpInfo, + PublicLinkInfo, Request, Response, RevisionInfo, ShareEntry, ShareEntryKind, SyncFolderInfo, +}; + +/// The environment variable that turns a run of `pdfs` into a completion +/// request. Not clap's generic `COMPLETE`: the same binary is the systemd +/// daemon, and an unrelated `COMPLETE` in its environment must not stop it. +pub const VAR: &str = "PDFS_COMPLETE"; + +/// How long a completer waits for the service. A Tab is interactive, so a +/// listing the service cannot give soon is better skipped. Long enough for the +/// listings that ask Proton (computers take over a second). +const SERVICE_TIMEOUT: Duration = Duration::from_secs(2); + +/// How long a listing that asks Proton is reused. Every Tab is a new `pdfs`, so +/// without it `pdfs devices rm `, a letter and another Tab would wait for +/// Proton twice. A uid that went away in the meantime only fails the command. +const CACHE_TTL: Duration = Duration::from_secs(30); + +/// Answer a completion request and exit, if this run is one. Must run before +/// anything writes to stdout. +pub fn handle_request() { + CompleteEnv::with_factory(crate::Cli::command) + .var(VAR) + .complete(); +} + +/// The shells `pdfs completions` writes a script for. +#[derive(Clone, Copy, clap::ValueEnum)] +pub enum ShellArg { + Bash, + Zsh, + Fish, + Elvish, + Powershell, +} + +impl ShellArg { + fn completer(self) -> &'static dyn EnvCompleter { + match self { + ShellArg::Bash => &Bash, + ShellArg::Zsh => &Zsh, + ShellArg::Fish => &Fish, + ShellArg::Elvish => &Elvish, + ShellArg::Powershell => &Powershell, + } + } +} + +/// Write the script that registers `pdfs` completion with `shell`. +/// +/// The script calls `pdfs` from `PATH`, not this binary's path, so one written +/// at packaging time keeps working once installed. +pub fn write_script(shell: ShellArg, out: &mut dyn std::io::Write) -> std::io::Result<()> { + shell + .completer() + .write_registration(VAR, "pdfs", "pdfs", "pdfs", out)?; + if let ShellArg::Zsh = shell { + // Installed as `_pdfs` in site-functions, the script is autoloaded as + // that function's body on the first Tab. The body above only defines + // and registers the completer, so this line also runs it, or the first + // Tab would complete nothing. Sourced, `funcstack` names no `_pdfs`. + writeln!( + out, + "if [ \"$funcstack[1]\" = \"_pdfs\" ]; then\n _clap_dynamic_completer_pdfs \"$@\"\nfi" + )?; + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Talking to the service + +/// One request to the service, or `None` if there is no answer worth showing. +fn ask(request: Request) -> Option { + let socket = pdfs_core::config::AppDirs::new().ok()?.control_socket(); + ask_at(&socket, &request) +} + +fn ask_at(socket: &Path, request: &Request) -> Option { + match pdfs_core::control::send_with_timeout(socket, request, SERVICE_TIMEOUT) { + Ok(Response::Error { .. }) | Err(_) => None, + Ok(response) => Some(response), + } +} + +/// [`ask`] for a listing that comes from Proton rather than the local +/// database, reusing an answer younger than [`CACHE_TTL`]. `key` names the +/// listing's cache file. +fn ask_cached(key: &str, request: Request) -> Option { + let file = cache_file(key); + if let Some(hit) = file.as_deref().and_then(read_fresh) { + return Some(hit); + } + let response = ask(request)?; + if let Some(file) = &file { + write_cache(file, &response); + } + Some(response) +} + +/// In `$XDG_RUNTIME_DIR`: private to the user, in memory and gone at logout, +/// which suits names from the account. With no runtime dir, nothing is cached. +fn cache_file(key: &str) -> Option { + let runtime = PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR")?); + Some( + runtime + .join("proton-drive-linux") + .join(format!("completion-{key}.json")), + ) +} + +fn read_fresh(file: &Path) -> Option { + let age = std::fs::metadata(file) + .ok()? + .modified() + .ok()? + .elapsed() + .ok()?; + if age > CACHE_TTL { + return None; + } + serde_json::from_slice(&std::fs::read(file).ok()?).ok() +} + +/// Best effort: a cache that cannot be written only costs the next Tab a wait. +fn write_cache(file: &Path, response: &Response) { + use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt}; + + let (Some(dir), Ok(json)) = (file.parent(), serde_json::to_vec(response)) else { + return; + }; + let _ = std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(dir); + let staging = file.with_extension(format!("{}.tmp", std::process::id())); + let written = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&staging) + .and_then(|mut f| std::io::Write::write_all(&mut f, &json)); + if written.is_err() || std::fs::rename(&staging, file).is_err() { + let _ = std::fs::remove_file(&staging); + } +} + +fn candidate(value: impl Into, help: impl Into) -> CompletionCandidate { + let help = help.into(); + let candidate = CompletionCandidate::new(value); + if help.is_empty() { + candidate + } else { + candidate.help(Some(help.into())) + } +} + +// --------------------------------------------------------------------------- +// Paths in Proton Drive + +/// A path in the mount: an absolute path completes from the local filesystem +/// (through the mount), anything else from the service's listing of My files, +/// which is what the commands resolve a relative path against. +pub fn drive_path(current: &OsStr) -> Vec { + complete_drive(current, false) +} + +/// [`drive_path`] for an argument that must be a folder. +pub fn drive_dir(current: &OsStr) -> Vec { + complete_drive(current, true) +} + +/// `pdfs refresh`: a folder, or one of the two named caches. +pub fn refresh_target(current: &OsStr) -> Vec { + let typed = current.to_string_lossy(); + let mut candidates: Vec<_> = [ + ("trash", "the trash listing"), + ("photos", "the photos timeline"), + ] + .into_iter() + .filter(|(name, _)| name.starts_with(typed.as_ref())) + .map(|(name, help)| candidate(name, help)) + .collect(); + candidates.extend(drive_dir(current)); + candidates +} + +fn complete_drive(current: &OsStr, dirs_only: bool) -> Vec { + let Some(word) = current.to_str() else { + return Vec::new(); + }; + if word.starts_with('/') || word.starts_with('~') { + let local = if dirs_only { + PathCompleter::dir() + } else { + PathCompleter::any() + }; + return local.complete(current); + } + let (parent, prefix) = split_drive_word(word); + let listing = Request::ListDir { + path: parent.trim_end_matches('/').to_owned(), + }; + match ask(listing) { + Some(Response::Entries { entries }) => { + drive_candidates(parent, prefix, &entries, dirs_only) + } + _ => Vec::new(), + } +} + +/// Split a typed drive path into the folder to list (with its trailing `/`) +/// and the start of the name being completed in it. +fn split_drive_word(word: &str) -> (&str, &str) { + match word.rfind('/') { + Some(at) => word.split_at(at + 1), + None => ("", word), + } +} + +/// The entries of `parent` whose name starts with `prefix`. A folder ends in +/// `/`, which tells every shell not to add a space after it, so the next Tab +/// goes on into it. +fn drive_candidates( + parent: &str, + prefix: &str, + entries: &[DirEntry], + dirs_only: bool, +) -> Vec { + entries + .iter() + .filter(|e| e.name.starts_with(prefix) && (e.is_dir || !dirs_only)) + .map(|e| { + let slash = if e.is_dir { "/" } else { "" }; + CompletionCandidate::new(format!("{parent}{}{slash}", e.name)) + .hide(e.name.starts_with('.') && !prefix.starts_with('.')) + }) + .collect() +} + +// --------------------------------------------------------------------------- +// Ids and uids from the service's listings + +pub fn sync_folder_id() -> Vec { + match ask(Request::ListSyncFolders) { + Some(Response::SyncFolders { items }) => sync_folder_candidates(&items), + _ => Vec::new(), + } +} + +fn sync_folder_candidates(items: &[SyncFolderInfo]) -> Vec { + items + .iter() + .map(|f| candidate(f.id.to_string(), format!("{} ({})", f.local_path, f.mode))) + .collect() +} + +pub fn queue_id() -> Vec { + match ask(Request::ListPendingOps) { + Some(Response::PendingOps { items }) => queue_candidates(&items), + _ => Vec::new(), + } +} + +fn queue_candidates(items: &[PendingOpInfo]) -> Vec { + items + .iter() + .map(|op| candidate(op.id.to_string(), format!("{} {}", op.kind, op.path))) + .collect() +} + +pub fn device_uid() -> Vec { + match ask_cached("devices", Request::ListDevices) { + Some(Response::Devices { items }) => device_candidates(&items), + _ => Vec::new(), + } +} + +fn device_candidates(items: &[DeviceInfo]) -> Vec { + items + .iter() + .map(|d| { + let this = if d.this_device { + " (this computer)" + } else { + "" + }; + candidate(d.uid.as_str(), format!("{}{this}", d.name)) + }) + .collect() +} + +pub fn trash_uid() -> Vec { + match ask_cached("trash", Request::ListTrash) { + Some(Response::Entries { entries }) => trash_candidates(&entries), + _ => Vec::new(), + } +} + +fn trash_candidates(entries: &[DirEntry]) -> Vec { + entries + .iter() + .map(|e| { + let help = match e.trashed_from.as_deref() { + Some(from) if !from.is_empty() => format!("{} (from {from})", e.name), + _ => e.name.clone(), + }; + candidate(e.uid.as_str(), help) + }) + .collect() +} + +pub fn album_uid() -> Vec { + match ask_cached("albums", Request::PhotoAlbums) { + Some(Response::Albums { items, .. }) => album_candidates(&items), + _ => Vec::new(), + } +} + +fn album_candidates(items: &[AlbumInfo]) -> Vec { + items + .iter() + .map(|a| candidate(a.uid.as_str(), a.name.as_str())) + .collect() +} + +pub fn shared_uid() -> Vec { + match ask_cached("shared-with-me", Request::ListSharedWithMe) { + Some(Response::Entries { entries }) => shared_candidates(&entries), + _ => Vec::new(), + } +} + +fn shared_candidates(entries: &[DirEntry]) -> Vec { + entries + .iter() + .map(|e| { + let help = if e.shared_by.is_empty() { + e.name.clone() + } else { + format!("{} (from {})", e.name, e.shared_by) + }; + candidate(e.uid.as_str(), help) + }) + .collect() +} + +pub fn invitation_id() -> Vec { + match ask_cached("invitations", Request::ListInvitations) { + Some(Response::Invitations { items }) => invitation_candidates(&items), + _ => Vec::new(), + } +} + +fn invitation_candidates(items: &[InvitationInfo]) -> Vec { + items + .iter() + .map(|i| { + let help = match i.name.as_deref() { + Some(name) => format!("{name} from {}", i.inviter_email), + None => format!("from {}", i.inviter_email), + }; + candidate(i.id.as_str(), help) + }) + .collect() +} + +pub fn bookmark_token() -> Vec { + match ask_cached("bookmarks", Request::ListBookmarks) { + Some(Response::Bookmarks { items }) => bookmark_candidates(&items), + _ => Vec::new(), + } +} + +fn bookmark_candidates(items: &[BookmarkInfo]) -> Vec { + items + .iter() + .map(|b| candidate(b.token.as_str(), b.name.as_deref().unwrap_or(&b.url))) + .collect() +} + +pub fn conflict_path() -> Vec { + match ask(Request::ListConflicts) { + Some(Response::Conflicts { items }) => conflict_candidates(&items), + _ => Vec::new(), + } +} + +fn conflict_candidates(items: &[ConflictInfo]) -> Vec { + items + .iter() + .map(|c| candidate(c.path.as_str(), format!("copy of {}", c.original_path))) + .collect() +} + +// --------------------------------------------------------------------------- +// Ids that belong to the path typed before them + +/// A version of the file named earlier on the line (`pdfs versions restore +/// `). +pub fn version_id(current: &OsStr) -> Vec { + let Some(path) = typed_path() else { + return Vec::new(); + }; + match ask(Request::ListRevisions { path }) { + Some(Response::Revisions { items }) => starting_with(current, revision_candidates(&items)), + _ => Vec::new(), + } +} + +fn revision_candidates(items: &[RevisionInfo]) -> Vec { + items + .iter() + .map(|r| { + let current = if r.is_active { ", current" } else { "" }; + let size = r.claimed_size.unwrap_or(r.size_on_storage).max(0); + candidate( + r.id.as_str(), + format!( + "{}, {}{current}", + crate::format_epoch_utc(r.created), + crate::human_bytes(size as u64) + ), + ) + }) + .collect() +} + +/// A member or invitation on the share of the path typed earlier on the line. +pub fn share_entry_id(current: &OsStr) -> Vec { + match share_of_typed_path() { + Some((entries, _)) => starting_with(current, share_entry_candidates(&entries)), + None => Vec::new(), + } +} + +fn share_entry_candidates(entries: &[ShareEntry]) -> Vec { + entries + .iter() + .map(|e| { + // The kind is the next argument, so the help names it in the words + // that argument takes. + let kind = match e.kind { + ShareEntryKind::Member => "member", + ShareEntryKind::ProtonInvite => "proton", + ShareEntryKind::ExternalInvite => "external", + }; + candidate(e.id.as_str(), format!("{} ({}, {kind})", e.email, e.role)) + }) + .collect() +} + +/// The public link of the path typed earlier on the line. +pub fn public_link_id(current: &OsStr) -> Vec { + match share_of_typed_path() { + Some((_, Some(link))) => starting_with(current, vec![public_link_candidate(&link)]), + _ => Vec::new(), + } +} + +/// Described without its URL: the fragment is the link's password, and a Tab +/// menu is on screen for anyone watching. +fn public_link_candidate(link: &PublicLinkInfo) -> CompletionCandidate { + let help = match link.expires { + Some(expires) => format!( + "{} link, expires {}", + link.role, + crate::format_epoch_utc(expires) + ), + None => format!("{} link", link.role), + }; + candidate(link.id.as_str(), help) +} + +fn share_of_typed_path() -> Option<(Vec, Option)> { + match ask(Request::ListShare { + path: typed_path()?, + })? { + Response::Share { entries, link } => Some((entries, link)), + _ => None, + } +} + +/// [`clap_complete::engine::ArgValueCompleter`] candidates are not filtered by +/// the engine, unlike [`clap_complete::engine::ArgValueCandidates`]. +fn starting_with( + current: &OsStr, + candidates: Vec, +) -> Vec { + let typed = current.to_string_lossy(); + candidates + .into_iter() + .filter(|c| c.get_value().to_string_lossy().starts_with(typed.as_ref())) + .collect() +} + +/// The `path` argument of the command being completed, as the service takes +/// it. A completer only receives the word under the cursor, so this reads the +/// line from the process arguments, where the shell's stub passes it. +fn typed_path() -> Option { + let args: Vec = std::env::args_os().collect(); + let index = std::env::var("_CLAP_COMPLETE_INDEX") + .ok() + .and_then(|i| i.parse().ok()); + path_in(words_before_cursor(&args, index)) +} + +/// The words of the line before the one being completed. The stub runs +/// `pdfs -- `; bash, zsh and elvish say which word the cursor is on, +/// fish and PowerShell pass the line cut at the cursor, so it is the last. +fn words_before_cursor(args: &[OsString], index: Option) -> Vec { + let start = args + .iter() + .position(|a| a == "--") + .map_or(args.len(), |at| at + 1); + let mut words = args[start..].to_vec(); + let index = index.unwrap_or(words.len().saturating_sub(1)); + words.truncate(index); + words +} + +/// Parse `words` as far as they go and take the innermost command's `path`. +fn path_in(words: Vec) -> Option { + let matches = crate::Cli::command() + .ignore_errors(true) + .try_get_matches_from(words) + .ok()?; + let mut leaf = &matches; + while let Some((_, sub)) = leaf.subcommand() { + leaf = sub; + } + let path = leaf.try_get_one::("path").ok().flatten()?; + crate::path_arg(path).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn values(candidates: &[CompletionCandidate]) -> Vec { + candidates + .iter() + .map(|c| c.get_value().to_string_lossy().into_owned()) + .collect() + } + + fn help(candidate: &CompletionCandidate) -> String { + candidate + .get_help() + .map(ToString::to_string) + .unwrap_or_default() + } + + fn entry(name: &str, is_dir: bool) -> DirEntry { + serde_json::from_value(serde_json::json!({ + "name": name, + "is_dir": is_dir, + "size": 0, + "modified": 0, + "pinned": false, + "uid": format!("vol~{name}"), + })) + .unwrap() + } + + fn words(line: &[&str]) -> Vec { + line.iter().map(OsString::from).collect() + } + + #[test] + fn the_command_definition_is_valid() { + crate::Cli::command().debug_assert(); + } + + #[test] + fn completions_script_is_written_for_every_shell() { + for shell in [ + ShellArg::Bash, + ShellArg::Zsh, + ShellArg::Fish, + ShellArg::Elvish, + ShellArg::Powershell, + ] { + let mut script = Vec::new(); + write_script(shell, &mut script).unwrap(); + let script = String::from_utf8(script).unwrap(); + assert!(script.contains(VAR), "{script}"); + assert!(script.contains("pdfs"), "{script}"); + } + } + + #[test] + fn drive_path_splits_parent_and_prefix() { + assert_eq!(split_drive_word(""), ("", "")); + assert_eq!(split_drive_word("Doc"), ("", "Doc")); + assert_eq!(split_drive_word("Documents/"), ("Documents/", "")); + assert_eq!(split_drive_word("Documents/Rep"), ("Documents/", "Rep")); + assert_eq!(split_drive_word("a/b/c"), ("a/b/", "c")); + } + + #[test] + fn drive_dirs_end_in_a_slash() { + let entries = [ + entry("Reports", true), + entry("Receipt.pdf", false), + entry("Other", true), + ]; + let all = drive_candidates("Documents/", "Re", &entries, false); + assert_eq!( + values(&all), + ["Documents/Reports/", "Documents/Receipt.pdf"] + ); + let dirs = drive_candidates("Documents/", "", &entries, true); + assert_eq!(values(&dirs), ["Documents/Reports/", "Documents/Other/"]); + } + + #[test] + fn hidden_drive_entries_need_a_typed_dot() { + let entries = [entry(".git", true)]; + assert!(drive_candidates("", "", &entries, false)[0].is_hide_set()); + assert!(!drive_candidates("", ".", &entries, false)[0].is_hide_set()); + } + + #[test] + fn sync_folder_ids_complete_with_their_local_path() { + let folders: Vec = serde_json::from_value(serde_json::json!([{ + "id": 3, + "local_path": "/home/me/Documents", + "remote_uid": "vol~docs", + "mode": "mirror", + "state": "idle", + "last_sync": 0, + }])) + .unwrap(); + let candidates = sync_folder_candidates(&folders); + assert_eq!(values(&candidates), ["3"]); + assert_eq!(help(&candidates[0]), "/home/me/Documents (mirror)"); + } + + #[test] + fn trash_uids_complete_with_their_name() { + let mut from_docs = entry("a.pdf", false); + from_docs.trashed_from = Some("Documents".into()); + let candidates = trash_candidates(&[from_docs, entry("b", true)]); + assert_eq!(values(&candidates), ["vol~a.pdf", "vol~b"]); + assert_eq!(help(&candidates[0]), "a.pdf (from Documents)"); + assert_eq!(help(&candidates[1]), "b"); + } + + #[test] + fn completer_without_a_service_offers_nothing() { + let dir = std::env::temp_dir().join(format!("pdfs-complete-{}", std::process::id())); + let socket = dir.join("control.sock"); + assert!(ask_at(&socket, &Request::ListSyncFolders).is_none()); + } + + #[test] + fn cached_listing_is_reused_until_it_is_stale() { + let dir = std::env::temp_dir().join(format!("pdfs-complete-cache-{}", std::process::id())); + let file = dir.join("completion-devices.json"); + write_cache(&file, &Response::Devices { items: Vec::new() }); + assert!(matches!(read_fresh(&file), Some(Response::Devices { .. }))); + let mode = std::os::unix::fs::PermissionsExt::mode( + &std::fs::metadata(&file).unwrap().permissions(), + ); + assert_eq!(mode & 0o777, 0o600); + + let stale = std::time::SystemTime::now() - CACHE_TTL - Duration::from_secs(1); + std::fs::File::options() + .write(true) + .open(&file) + .unwrap() + .set_modified(stale) + .unwrap(); + assert!(read_fresh(&file).is_none()); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn words_before_cursor_stop_at_the_cursor() { + let args = words(&[ + "pdfs", "--", "pdfs", "versions", "restore", "a.txt", "", "x", + ]); + assert_eq!( + words_before_cursor(&args, Some(4)), + words(&["pdfs", "versions", "restore", "a.txt"]) + ); + // fish: the line arrives cut at the cursor, the current word last. + let fish = words(&["pdfs", "--", "pdfs", "versions", "restore", "a.txt", "ab"]); + assert_eq!( + words_before_cursor(&fish, None), + words(&["pdfs", "versions", "restore", "a.txt"]) + ); + } + + #[test] + fn completing_words_finds_the_path_before_the_id() { + let line = words(&["pdfs", "versions", "restore", "Documents/a.txt"]); + assert_eq!(path_in(line).as_deref(), Some("Documents/a.txt")); + let line = words(&["pdfs", "--json", "share-role", "Shared/x"]); + assert_eq!(path_in(line).as_deref(), Some("Shared/x")); + let line = words(&["pdfs", "public-link", "update", "--role", "editor", "a"]); + assert_eq!(path_in(line).as_deref(), Some("a")); + assert_eq!(path_in(words(&["pdfs", "versions", "restore"])), None); + } +} diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index 3c4c59b..87fb67a 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -8,7 +8,8 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::{Context, Result, anyhow, bail}; -use clap::{Parser, Subcommand}; +use clap::{Parser, Subcommand, ValueHint}; +use clap_complete::engine::{ArgValueCandidates, ArgValueCompleter}; use pdfs_core::auth; use pdfs_core::cache::ContentCache; use pdfs_core::config::AppDirs; @@ -20,6 +21,7 @@ use pdfs_core::control::{ use pdfs_core::db::Db; use pdfs_core::service; +mod complete; mod journal; /// `println!` for this binary, shadowing std's, which panics when stdout is @@ -165,6 +167,7 @@ enum Command { /// Mount Proton Drive at the given (or default) path. Blocks until unmounted. Mount { /// Mountpoint; defaults to ~/ProtonDrive. + #[arg(value_hint = ValueHint::DirPath)] mountpoint: Option, }, /// Run the auto-mount daemon: wait for login, mount, and keep it mounted @@ -172,6 +175,7 @@ enum Command { /// don't invoke it by hand. Daemon { /// Mountpoint; defaults to ~/ProtonDrive. + #[arg(value_hint = ValueHint::DirPath)] mountpoint: Option, }, /// Keep a file or folder on this device (download + cache it). A folder's @@ -179,11 +183,13 @@ enum Command { /// Needs a running mount. Pin { /// File or folder path, inside the mountpoint or relative to it. + #[arg(add = ArgValueCompleter::new(complete::drive_path))] path: PathBuf, }, /// Stop keeping a file or folder on this device, evicting its cached content. Unpin { /// File or folder path, inside the mountpoint or relative to it. + #[arg(add = ArgValueCompleter::new(complete::drive_path))] path: PathBuf, }, /// List files and folders pinned to this device. @@ -191,6 +197,7 @@ enum Command { /// List a directory via the running daemon (in-app browser backend). Ls { /// Directory path, inside the mountpoint or relative to it. Root if omitted. + #[arg(add = ArgValueCompleter::new(complete::drive_dir))] path: Option, }, /// List the photos timeline (newest first) via the running daemon. @@ -224,7 +231,7 @@ enum Command { /// so an interrupted import can simply be run again. ImportGooglePhotos { /// The export's `.zip` files (e.g. `takeout-*.zip`). - #[arg(required = true)] + #[arg(required = true, value_hint = ValueHint::FilePath)] archives: Vec, /// Report what would be imported without uploading anything. #[arg(long)] @@ -277,6 +284,7 @@ enum Command { /// List the photos in one album (newest capture first). Album { /// Album node uid in `volume~link` form (from `pdfs albums`). + #[arg(add = ArgValueCandidates::new(complete::album_uid))] uid: String, /// Max photos to list. #[arg(long, default_value_t = 50)] @@ -293,6 +301,7 @@ enum Command { /// Rename a photo album. RenameAlbum { /// Album node uid in `volume~link` form (from `pdfs albums`). + #[arg(add = ArgValueCandidates::new(complete::album_uid))] uid: String, /// New name. name: String, @@ -300,6 +309,7 @@ enum Command { /// Delete a photo album. Its photos stay in the timeline. DeleteAlbum { /// Album node uid in `volume~link` form (from `pdfs albums`). + #[arg(add = ArgValueCandidates::new(complete::album_uid))] uid: String, /// Also delete photos that exist only in this album (saved from a /// shared album); without it the server refuses to delete such an album. @@ -309,6 +319,7 @@ enum Command { /// Add photos to an album. AddToAlbum { /// Album node uid in `volume~link` form (from `pdfs albums`). + #[arg(add = ArgValueCandidates::new(complete::album_uid))] uid: String, /// Photo node uids (from `pdfs photos`). #[arg(required = true)] @@ -317,6 +328,7 @@ enum Command { /// Take photos out of an album. They stay in the timeline. RemoveFromAlbum { /// Album node uid in `volume~link` form (from `pdfs albums`). + #[arg(add = ArgValueCandidates::new(complete::album_uid))] uid: String, /// Photo node uids (from `pdfs album `). #[arg(required = true)] @@ -335,13 +347,14 @@ enum Command { #[arg(long, default_value_t = 50)] limit: usize, /// Only search below this folder (under the mountpoint). - #[arg(long = "in", value_name = "FOLDER")] + #[arg(long = "in", value_name = "FOLDER", add = ArgValueCompleter::new(complete::drive_dir))] scope: Option, }, /// Rename a file or folder via the running daemon. Rename { /// File/folder path, inside My files or an on-demand synced folder, or /// relative to the mountpoint. + #[arg(add = ArgValueCompleter::new(complete::drive_path))] path: PathBuf, /// New name (a single path component). new_name: String, @@ -355,7 +368,7 @@ enum Command { Move { /// Files/folders to move, then the destination folder: each inside a /// location, or relative to the mountpoint. - #[arg(required = true, num_args = 2.., value_name = "PATH")] + #[arg(required = true, num_args = 2.., value_name = "PATH", add = ArgValueCompleter::new(complete::drive_path))] paths: Vec, }, /// Copy files or folders into another folder in My files via the running @@ -367,7 +380,7 @@ enum Command { Copy { /// Files/folders to copy, then the destination folder: inside My files, /// or relative to the mountpoint. - #[arg(required = true, num_args = 2.., value_name = "PATH")] + #[arg(required = true, num_args = 2.., value_name = "PATH", add = ArgValueCompleter::new(complete::drive_path))] paths: Vec, }, /// Show how much a folder holds on Proton Drive. @@ -376,18 +389,21 @@ enum Command { /// encrypted bytes on storage and includes items in the trash. Du { /// Folder path, inside My files or relative to the mountpoint. + #[arg(add = ArgValueCompleter::new(complete::drive_dir))] path: PathBuf, }, /// Trash a file or folder via the running daemon. Rm { /// File/folder path, inside My files or an on-demand synced folder, or /// relative to the mountpoint. + #[arg(add = ArgValueCompleter::new(complete::drive_path))] path: PathBuf, }, /// Create a folder via the running daemon. Mkdir { /// Parent folder path, inside My files or an on-demand synced folder, /// or relative to the mountpoint. + #[arg(add = ArgValueCompleter::new(complete::drive_dir))] parent: PathBuf, /// New folder name (a single path component). name: String, @@ -397,10 +413,10 @@ enum Command { /// background; watch progress with `pdfs transfers`. Upload { /// Local files and/or folders to upload. - #[arg(required = true)] + #[arg(required = true, value_hint = ValueHint::AnyPath)] sources: Vec, /// Destination folder, inside the mountpoint or relative to it. - #[arg(short = 't', long = "to", default_value = ".")] + #[arg(short = 't', long = "to", default_value = ".", add = ArgValueCompleter::new(complete::drive_dir))] parent: PathBuf, }, /// Show the daemon's in-flight transfers (active uploads/downloads). @@ -415,13 +431,13 @@ enum Command { /// Restore trashed items, by uid, to the folders they were trashed from. Restore { /// Node uids (`volume~link`), as printed by `pdfs trash`. - #[arg(required = true)] + #[arg(required = true, add = ArgValueCandidates::new(complete::trash_uid))] uids: Vec, }, /// Permanently delete trashed items by uid. This cannot be undone. DeleteForever { /// Node uids (`volume~link`), as printed by `pdfs trash`. - #[arg(required = true)] + #[arg(required = true, add = ArgValueCandidates::new(complete::trash_uid))] uids: Vec, }, /// Permanently delete everything in the trash. This cannot be undone. @@ -433,6 +449,7 @@ enum Command { Refresh { /// What to refresh: a folder path (inside the mountpoint or relative to /// it), `trash`, or `photos`. The mount root if omitted. + #[arg(add = ArgValueCompleter::new(complete::refresh_target))] target: Option, /// With `photos`: read every photo's metadata again instead of only what /// changed. Minutes on a large library; for a gallery that disagrees with @@ -459,12 +476,13 @@ enum Command { /// Share a file or folder with Proton and/or external email addresses. Share { /// File/folder path, inside the mountpoint or relative to it. + #[arg(add = ArgValueCompleter::new(complete::drive_path))] path: PathBuf, /// One or more invitee emails (Proton or external, auto-detected). #[arg(required = true)] emails: Vec, /// Role to grant: viewer, editor or admin. - #[arg(long, default_value = "viewer")] + #[arg(long, default_value = "viewer", value_parser = ["viewer", "editor", "admin"], ignore_case = true)] role: String, /// Optional message included in the invitation email. #[arg(long)] @@ -473,17 +491,21 @@ enum Command { /// List a node's members, pending invitations and public link. Members { /// File/folder path, inside the mountpoint or relative to it. + #[arg(add = ArgValueCompleter::new(complete::drive_path))] path: PathBuf, }, /// Change the role of a member or pending invitation on a node's share. ShareRole { /// File/folder path, inside the mountpoint or relative to it. + #[arg(add = ArgValueCompleter::new(complete::drive_path))] path: PathBuf, /// Entry id (membership or invitation id), from `pdfs members`. + #[arg(add = ArgValueCompleter::new(complete::share_entry_id))] id: String, /// Entry kind: member, proton or external. kind: ShareKindArg, /// New role: viewer, editor or admin. + #[arg(value_parser = ["viewer", "editor", "admin"], ignore_case = true)] role: String, }, /// Stop sharing a node, or remove one member or pending invitation. @@ -492,9 +514,10 @@ enum Command { /// member. With an id and kind (from `pdfs members`), removes that entry. Unshare { /// File/folder path, inside the mountpoint or relative to it. + #[arg(add = ArgValueCompleter::new(complete::drive_path))] path: PathBuf, /// Entry id (membership or invitation id), from `pdfs members`. - #[arg(requires = "kind")] + #[arg(requires = "kind", add = ArgValueCompleter::new(complete::share_entry_id))] id: Option, /// Entry kind: member, proton or external. kind: Option, @@ -515,18 +538,22 @@ enum Command { SharedWithMe { /// Folder uid to list into, in `volume~link` form (from a previous run). /// Omit for the top level. + #[arg(add = ArgValueCandidates::new(complete::shared_uid))] uid: Option, }, /// Download a file shared with me by its uid (from `pdfs shared-with-me`). SharedGet { /// Node uid in `volume~link` form. + #[arg(add = ArgValueCandidates::new(complete::shared_uid))] uid: String, /// Where to write it. Omit to leave it in the cache and print the path. + #[arg(value_hint = ValueHint::AnyPath)] dest: Option, }, /// Leave a shared node by its uid (from `pdfs shared-with-me`). Leave { /// Node uid in `volume~link` form. + #[arg(add = ArgValueCandidates::new(complete::shared_uid))] uid: String, }, /// Manage invitations addressed to me. @@ -557,6 +584,17 @@ enum Command { /// Runs without a daemon on purpose: the state worth diagnosing is usually /// the state where the daemon will not start. Diagnose, + /// Print the shell completion script for bash, zsh, fish, elvish or + /// PowerShell. + /// + /// Besides commands and options, it completes values from the running + /// service: paths in My files, synced-folder and queue ids, computers, + /// trash, albums, shares and versions. Load it with, for example, + /// `pdfs completions fish | source` or `source <(pdfs completions bash)`. + Completions { + /// The shell to write the script for. + shell: complete::ShellArg, + }, } #[derive(Subcommand)] @@ -604,6 +642,7 @@ enum VersionCmd { /// List a file's versions, newest first. List { /// File path, inside the mountpoint or relative to it. + #[arg(add = ArgValueCompleter::new(complete::drive_path))] path: PathBuf, }, /// Make an earlier version the file's current content. @@ -612,25 +651,32 @@ enum VersionCmd { /// moment to show up in the mount. Restore { /// File path, inside the mountpoint or relative to it. + #[arg(add = ArgValueCompleter::new(complete::drive_path))] path: PathBuf, /// Version id, from `pdfs versions list`. + #[arg(add = ArgValueCompleter::new(complete::version_id))] id: String, }, /// Write an earlier version to a local file, leaving the file untouched. Save { /// File path, inside the mountpoint or relative to it. + #[arg(add = ArgValueCompleter::new(complete::drive_path))] path: PathBuf, /// Version id, from `pdfs versions list`. + #[arg(add = ArgValueCompleter::new(complete::version_id))] id: String, /// Where to write it. Must not already exist. + #[arg(value_hint = ValueHint::AnyPath)] dest: PathBuf, }, /// Permanently delete an earlier version. The content is unrecoverable, and /// the version that is currently active cannot be deleted. Rm { /// File path, inside the mountpoint or relative to it. + #[arg(add = ArgValueCompleter::new(complete::drive_path))] path: PathBuf, /// Version id, from `pdfs versions list`. + #[arg(add = ArgValueCompleter::new(complete::version_id))] id: String, }, } @@ -640,13 +686,21 @@ enum DeviceCmd { /// List registered devices. List, /// Rename a device by uid. - Rename { uid: String, name: String }, + Rename { + #[arg(add = ArgValueCandidates::new(complete::device_uid))] + uid: String, + name: String, + }, /// Delete (deregister) a device by uid. - Rm { uid: String }, + Rm { + #[arg(add = ArgValueCandidates::new(complete::device_uid))] + uid: String, + }, /// Adopt an existing device as this machine's, so a hostname change or a /// reinstall re-attaches to it instead of registering a duplicate. Adopt { /// Device uid (from `devices list`). Omit with `--clear`. + #[arg(add = ArgValueCandidates::new(complete::device_uid))] uid: Option, /// Drop the adoption and go back to matching by hostname. #[arg(long, conflicts_with = "uid")] @@ -657,6 +711,7 @@ enum DeviceCmd { /// here. Both computers keep them in step. Join { /// Device uid (from `devices list`). + #[arg(add = ArgValueCandidates::new(complete::device_uid))] uid: String, /// Skip the confirmation and accept every proposed local path. #[arg(long)] @@ -666,6 +721,7 @@ enum DeviceCmd { /// them here. The other computer stays registered, without those folders. Migrate { /// Device uid (from `devices list`). + #[arg(add = ArgValueCandidates::new(complete::device_uid))] uid: String, /// Skip the confirmation and accept every proposed local path. #[arg(long)] @@ -678,6 +734,7 @@ enum ConflictCmd { /// Decide which side of a conflict to keep. Whatever is removed goes to Trash. Resolve { /// The conflict copy, absolute or relative to the mountpoint. + #[arg(add = ArgValueCandidates::new(complete::conflict_path))] path: PathBuf, /// `original` trashes the copy; `copy` trashes the original and gives the /// copy its name; `both` renames the copy to `--name`. @@ -694,15 +751,17 @@ enum SyncCmd { /// Add a local folder to this machine's device and upload its contents. Add { /// Local directory to sync. + #[arg(value_hint = ValueHint::DirPath)] path: PathBuf, }, /// Sync a real local folder with an existing folder in My files. /// The first pass preserves local-only files and keeps differing versions. Attach { /// Existing local directory; it remains a real local directory. + #[arg(value_hint = ValueHint::DirPath)] path: PathBuf, /// Existing folder in My files, relative to the cloud mount or inside it. - #[arg(long)] + #[arg(long, add = ArgValueCompleter::new(complete::drive_dir))] to: PathBuf, }, /// List this machine's synced folders. @@ -710,6 +769,7 @@ enum SyncCmd { /// Remove a synced folder by id. Rm { /// Synced-folder id (from `sync list`). + #[arg(add = ArgValueCandidates::new(complete::sync_folder_id))] id: i64, /// Also delete the folder's copy in Proton Drive. #[arg(long)] @@ -718,6 +778,7 @@ enum SyncCmd { /// Force a sync pass now (all folders, or one by id). Now { /// Optional synced-folder id; omit to reconcile every folder. + #[arg(add = ArgValueCandidates::new(complete::sync_folder_id))] id: Option, }, /// Forget what a synced folder synced before and compare it with Proton @@ -725,6 +786,7 @@ enum SyncCmd { /// that differs keeps both versions. Nothing is deleted. Reset { /// Synced-folder id (from `sync list`). + #[arg(add = ArgValueCandidates::new(complete::sync_folder_id))] id: i64, }, /// Pause syncing: queued uploads and mirror folders wait, reads keep working. @@ -734,13 +796,13 @@ enum SyncCmd { #[arg(long = "for", value_parser = parse_pause_duration, conflicts_with = "folder")] duration: Option, /// Pause only this mirror folder (id from `sync list`) until resumed. - #[arg(long)] + #[arg(long, add = ArgValueCandidates::new(complete::sync_folder_id))] folder: Option, }, /// Resume syncing after `pdfs sync pause`. Resume { /// Resume only this folder, paused with `sync pause --folder`. - #[arg(long)] + #[arg(long, add = ArgValueCandidates::new(complete::sync_folder_id))] folder: Option, }, /// Cap upload and download speed, or show the caps with no options. @@ -759,8 +821,10 @@ enum SyncCmd { /// Save the content of a queued upload (by id from `sync queue`) to a file. Export { /// Queue id. + #[arg(add = ArgValueCandidates::new(complete::queue_id))] id: i64, /// Where to save it: a new file, or an existing folder to save it in. + #[arg(value_hint = ValueHint::AnyPath)] dest: PathBuf, }, /// Drop a queued change (by id from `sync queue`) and undo it here: a new file @@ -768,6 +832,7 @@ enum SyncCmd { /// content is lost unless saved with `sync export` first. Discard { /// Queue id. + #[arg(add = ArgValueCandidates::new(complete::queue_id))] id: i64, /// Do not ask first. #[arg(long)] @@ -776,11 +841,13 @@ enum SyncCmd { /// Retry a queued operation now (by id from `sync queue`), or every failed one. Retry { /// Queue id; omit to retry every failed operation. + #[arg(add = ArgValueCandidates::new(complete::queue_id))] id: Option, }, /// Switch a folder between full-copy sync and on-demand (FUSE, no local storage). Mode { /// Synced-folder id (from `sync list`). + #[arg(add = ArgValueCandidates::new(complete::sync_folder_id))] id: i64, /// `mirror` (full local copy) or `ondemand` (FUSE mount, reclaims disk). #[arg(value_parser = ["mirror", "ondemand"])] @@ -794,7 +861,7 @@ enum SyncCmd { yes: bool, /// Restore another computer's folders instead, by device uid (from /// `pdfs devices list`). - #[arg(long)] + #[arg(long, add = ArgValueCandidates::new(complete::device_uid))] device: Option, }, } @@ -804,9 +871,10 @@ enum PublicLinkCmd { /// Create a public link on a node. Create { /// File/folder path, inside the mountpoint or relative to it. + #[arg(add = ArgValueCompleter::new(complete::drive_path))] path: PathBuf, /// Role: viewer or editor. - #[arg(long, default_value = "viewer")] + #[arg(long, default_value = "viewer", value_parser = ["viewer", "editor"], ignore_case = true)] role: String, /// Optional custom password protecting the link. #[arg(long)] @@ -819,11 +887,13 @@ enum PublicLinkCmd { /// given is kept. Update { /// File/folder path, inside the mountpoint or relative to it. + #[arg(add = ArgValueCompleter::new(complete::drive_path))] path: PathBuf, /// Public-link id, from `pdfs members`. + #[arg(add = ArgValueCompleter::new(complete::public_link_id))] id: String, /// New role: viewer or editor. - #[arg(long)] + #[arg(long, value_parser = ["viewer", "editor"], ignore_case = true)] role: Option, /// New custom password protecting the link. #[arg(long, conflicts_with = "no_password")] @@ -841,8 +911,10 @@ enum PublicLinkCmd { /// Remove a node's public link. Remove { /// File/folder path, inside the mountpoint or relative to it. + #[arg(add = ArgValueCompleter::new(complete::drive_path))] path: PathBuf, /// Public-link id, from `pdfs members`. + #[arg(add = ArgValueCompleter::new(complete::public_link_id))] id: String, }, } @@ -852,9 +924,15 @@ enum InvitationCmd { /// List invitations addressed to me. List, /// Accept an invitation by id. - Accept { id: String }, + Accept { + #[arg(add = ArgValueCandidates::new(complete::invitation_id))] + id: String, + }, /// Reject an invitation by id. - Reject { id: String }, + Reject { + #[arg(add = ArgValueCandidates::new(complete::invitation_id))] + id: String, + }, } #[derive(Subcommand)] @@ -870,7 +948,10 @@ enum BookmarkCmd { password: Option, }, /// Remove a saved bookmark by its token. - Rm { token: String }, + Rm { + #[arg(add = ArgValueCandidates::new(complete::bookmark_token))] + token: String, + }, } /// Render a daemon failure for a terminal. @@ -890,6 +971,9 @@ fn cli_error(kind: ErrorKind, message: &str) -> String { } fn main() -> Result<()> { + // First: a completion request answers on stdout and exits, so nothing may + // write there before it. + complete::handle_request(); let filter = tracing_subscriber::EnvFilter::try_from_default_env() // `pgp` logs a benign WARN when it re-serializes a Proton secret-key // packet with a longer (but equivalent) length encoding than the @@ -1030,6 +1114,7 @@ fn main() -> Result<()> { Command::Activity { limit } => cmd_activity(limit), Command::Quota => cmd_quota(), Command::Cache { action } => cmd_cache(action), + Command::Completions { shell } => cmd_completions(shell), Command::Diagnose => cmd_diagnose(), } } @@ -3806,6 +3891,15 @@ fn writable_probe(dir: &Path) -> std::io::Result<()> { result.and(remove) } +/// Print the completion script for `shell`. Touches no account state, so +/// packaging can run it at build time. +fn cmd_completions(shell: complete::ShellArg) -> Result<()> { + let mut script = Vec::new(); + complete::write_script(shell, &mut script)?; + print!("{}", String::from_utf8_lossy(&script)); + Ok(()) +} + /// Self-check the installation, reporting each finding and exiting non-zero if /// any check failed. /// diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index b0dc401..14a37db 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -2405,9 +2405,17 @@ impl Request { /// /// Shared by the CLI and GUI so both speak the wire format identically. pub fn send(socket: &Path, req: &Request) -> Result { + send_with_timeout(socket, req, req.read_timeout()) +} + +/// [`send`] with a caller-chosen limit on how long the daemon may take to +/// answer, which also bounds the write. For a caller that must never keep a +/// person waiting, such as shell completion, where a slow answer is worth less +/// than none. +pub fn send_with_timeout(socket: &Path, req: &Request, timeout: Duration) -> Result { let mut stream = UnixStream::connect(socket)?; - stream.set_read_timeout(Some(req.read_timeout()))?; - stream.set_write_timeout(Some(WRITE_TIMEOUT))?; + stream.set_read_timeout(Some(timeout))?; + stream.set_write_timeout(Some(timeout.min(WRITE_TIMEOUT)))?; let mut line = serde_json::to_vec(req)?; line.push(b'\n'); stream.write_all(&line)?; diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 38f8e4b..9d3637e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -22,6 +22,12 @@ Existing folders remain device backups; older builds cannot open the upgraded da ### Added +- Shell completion for `pdfs` in bash, zsh and fish, installed by every package, plus elvish + and PowerShell through `pdfs completions SHELL`. Besides commands and options it completes + paths in My files and the ids and uids the running service knows (synced folders, the queue, + computers, trash, albums, shares, invitations, bookmarks, conflicts and versions), each with + a description. Share roles are now checked by the CLI (`viewer`, `editor`, `admin`) before + the request. - `pdfs sync attach PATH --to REMOTE` connects an existing local directory to an existing My files folder. The first sync preserves one-sided files and differing versions (#67). Attachments stay in mirror mode, are excluded from device profiles, and cannot delete diff --git a/docs/CLI.md b/docs/CLI.md index 2ff6c3d..df95563 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -8,6 +8,7 @@ talks to the running service (`proton-drive.service`); `login`, `logout`, `mount what the commands do in the app, see the [user guide](USER_GUIDE.md). - [Conventions](#conventions) +- [Shell completion](#shell-completion) - [Account and service](#account-and-service) - [Files](#files) - [Available offline](#available-offline) @@ -32,6 +33,36 @@ what the commands do in the app, see the [user guide](USER_GUIDE.md). service. - **Output** is English and stable enough for `grep`; use `--json` where it is available. +## Shell completion + +Tab completes commands and options, and also the values the service knows. Paths complete +from My files, and these complete with a description: synced-folder and queue ids, computers, +trash items, albums, items shared with you, invitations, bookmarks, conflict copies, and a file's +versions or a share's members and link once the path is typed. Values come from the running +service. Without it, only commands, options and local paths complete. + +| Example | Completes | +|---|---| +| `pdfs pin Doc` | `Documents/`; Tab again lists its contents | +| `pdfs sync rm ` | `1 /home/me/Documents (mirror)` … | +| `pdfs devices rm ` | computer uids, with their names | +| `pdfs versions restore Documents/a.pdf ` | version ids, with date and size | + +The packages install completion for bash, zsh and fish, which these shells load by themselves. For +a source build, or another shell, load the script that `pdfs completions ` prints: + +| Shell | Add to | +|---|---| +| bash | `~/.bashrc`: `source <(pdfs completions bash)` | +| zsh | `~/.zshrc`, after `compinit`: `source <(pdfs completions zsh)` | +| fish | `~/.config/fish/config.fish`: `pdfs completions fish \| source` | +| elvish | `~/.config/elvish/rc.elv`: `eval (pdfs completions elvish \| slurp)` | +| PowerShell | `$PROFILE`: `pdfs completions powershell \| Out-String \| Invoke-Expression` | + +The script runs `pdfs` from `PATH` on every Tab, so it keeps working after an update. A +`pdfs` that is not on `PATH` prints a script for its own location with +`PDFS_COMPLETE= ./pdfs`. + ## Account and service | Command | Description | @@ -185,6 +216,7 @@ commands are for scripts and for cases where the mount is not available. | `pdfs cache inspect [--deep]` | Database size, reclaimable space, row counts, cache use against the limit. `--deep` also runs SQLite's integrity check (slow). | | `pdfs cache vacuum` | Compact the database. Needs free space for a second copy of it while it runs. | | `pdfs cache clear` | Delete cached file content. Files kept available offline stay. | +| `pdfs completions SHELL` | Print the [shell completion](#shell-completion) script for bash, zsh, fish, elvish or powershell. | [Troubleshooting](TROUBLESHOOTING.md) explains when to use each of these. diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index fe35901..d36f144 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -80,5 +80,8 @@ package() { # Translations: compiled from po/ into /usr/share/locale. po/build.sh "$pkgdir/usr/share/locale" + # Shell completion for bash, zsh and fish. + packaging/completions.sh "$rel/pdfs" "$pkgdir/usr/share" + install -Dm644 "LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE" } diff --git a/packaging/aur/proton-drive-for-linux-bin/PKGBUILD b/packaging/aur/proton-drive-for-linux-bin/PKGBUILD index 8ed7323..a261940 100644 --- a/packaging/aur/proton-drive-for-linux-bin/PKGBUILD +++ b/packaging/aur/proton-drive-for-linux-bin/PKGBUILD @@ -64,6 +64,14 @@ package() { install -dm755 "$pkgdir/usr/share" cp -r --no-preserve=ownership "$srcdir/locale" "$pkgdir/usr/share/locale" + # Shell completion for bash, zsh and fish, written by the binary itself. + local share="$pkgdir/usr/share" + install -d "$share/bash-completion/completions" "$share/zsh/site-functions" \ + "$share/fish/vendor_completions.d" + "$srcdir/pdfs" completions bash > "$share/bash-completion/completions/pdfs" + "$srcdir/pdfs" completions zsh > "$share/zsh/site-functions/_pdfs" + "$srcdir/pdfs" completions fish > "$share/fish/vendor_completions.d/pdfs.fish" + install -Dm644 "$srcdir/LICENSE-$pkgver" "$pkgdir/usr/share/licenses/$pkgname/LICENSE" # pacman brings new releases, so the app never checks for them itself. diff --git a/packaging/aur/proton-drive-for-linux-git/PKGBUILD b/packaging/aur/proton-drive-for-linux-git/PKGBUILD index 36449f3..f839827 100644 --- a/packaging/aur/proton-drive-for-linux-git/PKGBUILD +++ b/packaging/aur/proton-drive-for-linux-git/PKGBUILD @@ -81,6 +81,9 @@ package() { # Translations: compiled from po/ into /usr/share/locale. po/build.sh "$pkgdir/usr/share/locale" + # Shell completion for bash, zsh and fish. + packaging/completions.sh target/release/pdfs "$pkgdir/usr/share" + install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md" diff --git a/packaging/aur/proton-drive-for-linux/PKGBUILD b/packaging/aur/proton-drive-for-linux/PKGBUILD index c4e73b6..629d2ab 100644 --- a/packaging/aur/proton-drive-for-linux/PKGBUILD +++ b/packaging/aur/proton-drive-for-linux/PKGBUILD @@ -76,6 +76,9 @@ package() { # Translations: compiled from po/ into /usr/share/locale. po/build.sh "$pkgdir/usr/share/locale" + # Shell completion for bash, zsh and fish. + packaging/completions.sh target/release/pdfs "$pkgdir/usr/share" + install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md" diff --git a/packaging/completions.sh b/packaging/completions.sh new file mode 100755 index 0000000..de3060b --- /dev/null +++ b/packaging/completions.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Write the shell completion scripts of a built `pdfs` into , where +# bash-completion, zsh and fish look for them. Packages pass their install +# tree, e.g. "$pkgdir/usr/share". The scripts call `pdfs` from PATH on every +# Tab, so they are the same for every version and need no rebuild per release. +set -euo pipefail + +if [ $# -ne 2 ]; then + echo "usage: $0 " >&2 + exit 2 +fi +pdfs="$1" +datadir="$2" + +install -d "$datadir/bash-completion/completions" \ + "$datadir/zsh/site-functions" \ + "$datadir/fish/vendor_completions.d" +"$pdfs" completions bash > "$datadir/bash-completion/completions/pdfs" +"$pdfs" completions zsh > "$datadir/zsh/site-functions/_pdfs" +"$pdfs" completions fish > "$datadir/fish/vendor_completions.d/pdfs.fish" +chmod 644 "$datadir/bash-completion/completions/pdfs" \ + "$datadir/zsh/site-functions/_pdfs" \ + "$datadir/fish/vendor_completions.d/pdfs.fish" diff --git a/packaging/nix/package.nix b/packaging/nix/package.nix index 3c052b2..c2bce34 100644 --- a/packaging/nix/package.nix +++ b/packaging/nix/package.nix @@ -40,6 +40,7 @@ rustPlatform.buildRustPackage { ../io.narl.proton-drive-linux.desktop ../io.narl.proton-drive-linux-tray.desktop ../io.narl.proton-drive-linux.svg + ../completions.sh ]; }; @@ -107,6 +108,9 @@ rustPlatform.buildRustPackage { patchShebangs po/build.sh po/build.sh $out/share/locale + patchShebangs packaging/completions.sh + packaging/completions.sh $out/bin/pdfs $out/share + # Nix brings new releases, so the app never checks for them itself. install -Dm644 /dev/null $out/share/proton-drive-linux/managed-updates ''; diff --git a/packaging/proton-drive-linux.spec b/packaging/proton-drive-linux.spec index a4c1005..cda09e1 100644 --- a/packaging/proton-drive-linux.spec +++ b/packaging/proton-drive-linux.spec @@ -114,6 +114,7 @@ install -D -m0644 %{src}/packaging/io.narl.proton-drive-linux.svg \ install -D -m0644 %{src}/packaging/proton-drive.service \ %{buildroot}/usr/lib/systemd/user/proton-drive.service %{src}/po/build.sh %{buildroot}%{_datadir}/locale +%{src}/packaging/completions.sh "$rel/pdfs" %{buildroot}%{_datadir} %if 0%{!?git_dir:1} # dnf brings new releases from COPR, so the app does not offer its own check. install -D -m0644 /dev/null %{buildroot}%{_datadir}/proton-drive-linux/managed-updates @@ -149,6 +150,9 @@ fi %{_datadir}/icons/hicolor/scalable/apps/io.narl.proton-drive-linux.svg /usr/lib/systemd/user/proton-drive.service %{_datadir}/locale/*/LC_MESSAGES/pdfs.mo +%{_datadir}/bash-completion/completions/pdfs +%{_datadir}/zsh/site-functions/_pdfs +%{_datadir}/fish/vendor_completions.d/pdfs.fish %if 0%{!?git_dir:1} %{_datadir}/proton-drive-linux/managed-updates %endif