diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index 6885e21..74cdf03 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -86,6 +86,39 @@ enum Command { #[arg(long, default_value_t = 50)] limit: usize, }, + /// Rename a file or folder via the running daemon. + Rename { + /// File/folder path, inside the mountpoint or relative to it. + path: PathBuf, + /// New name (a single path component). + new_name: String, + }, + /// Move a file or folder into another folder via the running daemon. + Move { + /// File/folder path, inside the mountpoint or relative to it. + path: PathBuf, + /// Destination folder path, inside the mountpoint or relative to it. + new_parent: PathBuf, + }, + /// Trash a file or folder via the running daemon. + Rm { + /// File/folder path, inside the mountpoint or relative to it. + path: PathBuf, + }, + /// Create a folder via the running daemon. + Mkdir { + /// Parent folder path, inside the mountpoint or relative to it. + parent: PathBuf, + /// New folder name (a single path component). + name: String, + }, + /// Upload a local file into a Drive folder via the running daemon. + Upload { + /// Local file to upload. + file: PathBuf, + /// Destination folder path, inside the mountpoint or relative to it. + parent: PathBuf, + }, } fn main() -> Result<()> { @@ -113,6 +146,11 @@ fn main() -> Result<()> { Command::Photos { limit, offset } => cmd_photos(limit, offset), Command::OpenPhoto { uid } => cmd_open_photo(uid), Command::Search { query, limit } => cmd_search(query, limit), + Command::Rename { path, new_name } => cmd_rename(path, new_name), + Command::Move { path, new_parent } => cmd_move(path, new_parent), + Command::Rm { path } => cmd_rm(path), + Command::Mkdir { parent, name } => cmd_mkdir(parent, name), + Command::Upload { file, parent } => cmd_upload(file, parent), } } @@ -223,7 +261,10 @@ fn cmd_daemon(mountpoint: Option) -> Result<()> { fn mount_once(mountpoint: Option) -> Result { let dirs = AppDirs::new()?; dirs.ensure()?; - let mountpoint = mountpoint.unwrap_or_else(|| dirs.default_mountpoint()); + let config = dirs.load_config(); + // An explicit `--mountpoint` wins; otherwise honor the Settings-page choice + // (config), falling back to the default location. + let mountpoint = mountpoint.unwrap_or_else(|| dirs.resolved_mountpoint(&config)); std::fs::create_dir_all(&mountpoint) .with_context(|| format!("create mountpoint {}", mountpoint.display()))?; @@ -235,7 +276,7 @@ fn mount_once(mountpoint: Option) -> Result { let cache = ContentCache::open( dirs.content_cache_dir(), dirs.pins_path(), - pdfs_core::config::DEFAULT_CACHE_BUDGET_BYTES, + config.resolved_cache_budget(), db.clone(), ) .context("open content cache")?; @@ -404,6 +445,72 @@ fn cmd_search(query: String, limit: usize) -> Result<()> { Ok(()) } +fn cmd_rename(path: PathBuf, new_name: String) -> Result<()> { + match control_request(CtlRequest::Rename { + path: path_arg(&path)?, + new_name, + })? { + CtlResponse::Ok { message } => println!("{message}"), + CtlResponse::Error { message } => bail!("{message}"), + other => bail!("unexpected response: {other:?}"), + } + Ok(()) +} + +fn cmd_move(path: PathBuf, new_parent: PathBuf) -> Result<()> { + match control_request(CtlRequest::Move { + path: path_arg(&path)?, + new_parent: path_arg(&new_parent)?, + })? { + CtlResponse::Ok { message } => println!("{message}"), + CtlResponse::Error { message } => bail!("{message}"), + other => bail!("unexpected response: {other:?}"), + } + Ok(()) +} + +fn cmd_rm(path: PathBuf) -> Result<()> { + match control_request(CtlRequest::Delete { + path: path_arg(&path)?, + })? { + CtlResponse::Ok { message } => println!("{message}"), + CtlResponse::Error { message } => bail!("{message}"), + other => bail!("unexpected response: {other:?}"), + } + Ok(()) +} + +fn cmd_mkdir(parent: PathBuf, name: String) -> Result<()> { + match control_request(CtlRequest::CreateFolder { + parent: path_arg(&parent)?, + name, + })? { + CtlResponse::Ok { message } => println!("{message}"), + CtlResponse::Error { message } => bail!("{message}"), + other => bail!("unexpected response: {other:?}"), + } + Ok(()) +} + +fn cmd_upload(file: PathBuf, parent: PathBuf) -> Result<()> { + let bytes = std::fs::read(&file).with_context(|| format!("read {}", file.display()))?; + let name = file + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| anyhow!("source file has no valid name"))? + .to_owned(); + match control_request(CtlRequest::UploadFile { + parent: path_arg(&parent)?, + name, + bytes, + })? { + CtlResponse::Ok { message } => println!("{message}"), + CtlResponse::Error { message } => bail!("{message}"), + other => bail!("unexpected response: {other:?}"), + } + Ok(()) +} + /// Normalize a user-supplied path argument for sending to the daemon. Absolute /// paths are canonicalized (so the daemon can strip its mountpoint prefix); /// relative paths are passed through and resolved against the mount root. diff --git a/crates/pdfs-core/src/cache.rs b/crates/pdfs-core/src/cache.rs index cc8eee5..9357a00 100644 --- a/crates/pdfs-core/src/cache.rs +++ b/crates/pdfs-core/src/cache.rs @@ -20,6 +20,7 @@ use std::collections::{BTreeMap, HashSet}; use std::io::{Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; use proton_drive_rs::proton_sdk::ids::NodeUid; @@ -100,8 +101,10 @@ pub struct ContentCache { /// Soft cap on total blob bytes. Exceeded only transiently: a `store` /// evicts least-recently-used *unpinned* blobs back under the cap. `0` /// disables the cap. Pinned blobs are never evicted, so pins alone may push - /// the cache over budget. - max_bytes: u64, + /// the cache over budget. Atomic so the daemon can retune it at runtime + /// (a Settings-page change) via [`set_budget`](Self::set_budget) without + /// taking a lock on every cache read. + max_bytes: AtomicU64, /// Unified metadata DB. Its `cache_entries` table is the LRU index: every /// store/read/evict updates it, and the budget enforcers query it instead of /// scanning the cache directories (plan.md P4). @@ -139,7 +142,7 @@ impl ContentCache { block_dir, scratch_dir, pins_path, - max_bytes, + max_bytes: AtomicU64::new(max_bytes), db, }; cache.reconcile()?; @@ -366,7 +369,8 @@ impl ContentCache { /// `max_bytes`. No-op when the cap is disabled (`0`). All blocks are /// evictable — pinned files are served from whole-file blobs, never blocks. fn enforce_block_budget(&self) { - if self.max_bytes == 0 { + let cap = self.cap(); + if cap == 0 { return; } // LRU-ordered (oldest first) block entries straight from the index — no @@ -376,11 +380,11 @@ impl ContentCache { return; }; let mut total: u64 = entries.iter().map(|(_, size)| *size).sum(); - if total <= self.max_bytes { + if total <= cap { return; } for (key, size) in entries { - if total <= self.max_bytes { + if total <= cap { break; } // `key` is the block file's name (and its `.meta` sibling's stem). @@ -517,7 +521,8 @@ impl ContentCache { /// cache already fits. Pinned blobs are skipped, so a cache held entirely by /// pins can legitimately stay over budget. fn enforce_budget(&self) { - if self.max_bytes == 0 { + let cap = self.cap(); + if cap == 0 { return; } // Pinned blobs (by cache key) are exempt from eviction. Resolves direct @@ -538,11 +543,11 @@ impl ContentCache { return; }; let mut total: u64 = entries.iter().map(|(_, size)| *size).sum(); - if total <= self.max_bytes { + if total <= cap { return; } for (key, size) in entries { - if total <= self.max_bytes { + if total <= cap { break; } if pinned.contains(&key) { @@ -624,7 +629,61 @@ impl ContentCache { /// Configured soft byte cap (`0` = unlimited), for display alongside /// [`usage`](Self::usage). pub fn budget(&self) -> u64 { - self.max_bytes + self.cap() + } + + /// The current soft byte cap. A plain atomic load; the value can change + /// under [`set_budget`](Self::set_budget) while the daemon runs. + fn cap(&self) -> u64 { + self.max_bytes.load(Ordering::Relaxed) + } + + /// Retune the soft byte cap (`0` = unlimited) at runtime and immediately + /// enforce it: blobs and blocks are LRU-evicted back under the new cap (a + /// lower cap frees space now; a higher one is a no-op until the next store). + /// Called by the daemon when the Settings page changes the cache budget. + pub fn set_budget(&self, bytes: u64) { + self.max_bytes.store(bytes, Ordering::Relaxed); + self.enforce_budget(); + self.enforce_block_budget(); + } + + /// Delete every *unpinned* cached blob plus all on-demand block chunks, + /// keeping pinned files intact. Pinned files keep their whole-file blobs + /// (and are never served from blocks), so dropping all blocks is safe. + /// Returns the number of bytes freed, for a user-facing confirmation. + pub fn clear_unpinned(&self) -> u64 { + // Pinned blobs are exempt; resolve them to cache keys to match filenames. + let pinned: HashSet = self + .db + .pinned_uids() + .unwrap_or_default() + .iter() + .map(|uid| Self::key_str(uid)) + .collect(); + let mut freed = 0u64; + // Unpinned whole-file blobs. + if let Ok(entries) = self.db.cache_entries_by_kind(KIND_BLOB) { + for (key, size) in entries { + if pinned.contains(&key) { + continue; + } + let _ = std::fs::remove_file(self.content_dir.join(&key)); + let _ = std::fs::remove_file(self.content_dir.join(format!("{key}.meta"))); + let _ = self.db.cache_remove(&key); + freed = freed.saturating_add(size); + } + } + // Every on-demand block (transient partial reads, re-fetched on demand). + if let Ok(entries) = self.db.cache_entries_by_kind(KIND_BLOCK) { + for (key, size) in entries { + let _ = std::fs::remove_file(self.block_dir.join(&key)); + let _ = std::fs::remove_file(self.block_dir.join(format!("{key}.meta"))); + let _ = self.db.cache_remove(&key); + freed = freed.saturating_add(size); + } + } + freed } } @@ -822,6 +881,43 @@ mod tests { assert!(c.is_cached(&d, 1, 4)); } + #[test] + fn clear_unpinned_keeps_pinned() { + let (c, _d) = cache(); + let (a, b) = (uid("a"), uid("b")); + c.store(&a, 1, 4, b"aaaa").unwrap(); + c.add_pin(&a, Path::new("a"), false).unwrap(); + c.store(&b, 1, 4, b"bbbb").unwrap(); + // A stray on-demand block of the unpinned file is purged too. + c.store_block(&b, 1, 8, 0, b"bbbb").unwrap(); + + let freed = c.clear_unpinned(); + assert_eq!(freed, 8, "one 4-byte blob + one 4-byte block freed"); + assert!(c.is_cached(&a, 1, 4), "pinned blob survives purge"); + assert!(!c.is_cached(&b, 1, 4), "unpinned blob purged"); + assert!(c.cached_block(&b, 1, 8, 0).is_none(), "block purged"); + } + + #[test] + fn set_budget_evicts_immediately() { + // Start unlimited so three blobs all fit, then tighten the cap. + let (c, _d) = cache(); + let (a, b, d) = (uid("a"), uid("b"), uid("d")); + c.store(&a, 1, 4, b"aaaa").unwrap(); + std::thread::sleep(std::time::Duration::from_millis(10)); + c.store(&b, 1, 4, b"bbbb").unwrap(); + std::thread::sleep(std::time::Duration::from_millis(10)); + c.store(&d, 1, 4, b"dddd").unwrap(); + + // Tightening to 8 bytes evicts the LRU unpinned blob(s) now, not on the + // next store. + c.set_budget(8); + assert!(!c.is_cached(&a, 1, 4), "oldest blob evicted on tighten"); + assert!(c.is_cached(&b, 1, 4)); + assert!(c.is_cached(&d, 1, 4)); + assert_eq!(c.budget(), 8); + } + #[test] fn thumbnail_store_read_and_evict() { let (c, _d) = cache(); diff --git a/crates/pdfs-core/src/config.rs b/crates/pdfs-core/src/config.rs index 35a09d4..0e6f511 100644 --- a/crates/pdfs-core/src/config.rs +++ b/crates/pdfs-core/src/config.rs @@ -24,6 +24,16 @@ pub const DEFAULT_CACHE_BUDGET_BYTES: u64 = 5 * 1024 * 1024 * 1024; pub struct AppConfig { pub app_version: String, pub user_agent: String, + /// Soft cap on the on-disk content cache, in bytes (`0` = unlimited). + /// `None` means "use [`DEFAULT_CACHE_BUDGET_BYTES`]"; the Settings page + /// writes an explicit value here. Defaulted for configs predating the field. + #[serde(default)] + pub cache_budget: Option, + /// Mountpoint the daemon mounts at. `None` means + /// [`AppDirs::default_mountpoint`]; the Settings page writes an explicit + /// path here. Defaulted for configs predating the field. + #[serde(default)] + pub mountpoint: Option, } impl Default for AppConfig { @@ -31,10 +41,20 @@ impl Default for AppConfig { Self { app_version: APP_VERSION.to_string(), user_agent: USER_AGENT.to_string(), + cache_budget: None, + mountpoint: None, } } } +impl AppConfig { + /// The effective cache budget in bytes: the user's explicit choice, or + /// [`DEFAULT_CACHE_BUDGET_BYTES`] when unset. + pub fn resolved_cache_budget(&self) -> u64 { + self.cache_budget.unwrap_or(DEFAULT_CACHE_BUDGET_BYTES) + } +} + /// Resolved XDG locations for state, cache, and the default mountpoint. pub struct AppDirs { dirs: ProjectDirs, @@ -70,6 +90,18 @@ impl AppDirs { default_config } + /// Persist `config` to [`config_path`](Self::config_path), creating the + /// config dir if missing. The Settings page calls this after editing the + /// cache budget or mountpoint; the daemon re-reads the file on its next mount. + pub fn save_config(&self, config: &AppConfig) -> Result<()> { + let path = self.config_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&path, serde_json::to_string_pretty(config)?)?; + Ok(()) + } + /// Persistent state: inode map / cache index DB lives here. pub fn state_dir(&self) -> PathBuf { // `state_dir` is Linux-only in `directories`; fall back to data dir. @@ -117,6 +149,16 @@ impl AppDirs { .unwrap_or_else(|| PathBuf::from("/tmp/ProtonDrive")) } + /// The effective mountpoint: the config's explicit path, or + /// [`default_mountpoint`](Self::default_mountpoint) when unset. + pub fn resolved_mountpoint(&self, config: &AppConfig) -> PathBuf { + config + .mountpoint + .as_ref() + .map(PathBuf::from) + .unwrap_or_else(|| self.default_mountpoint()) + } + /// Create state + cache dirs if missing. pub fn ensure(&self) -> Result<()> { std::fs::create_dir_all(self.state_dir())?; @@ -135,6 +177,8 @@ mod tests { let config = AppConfig { app_version: "external-drive-test-client@1.0.0".to_string(), user_agent: "test-agent/1.0".to_string(), + cache_budget: Some(1234), + mountpoint: Some("/tmp/x".to_string()), }; let json = serde_json::to_string(&config).unwrap(); let decoded: AppConfig = serde_json::from_str(&json).unwrap(); diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index 87ab8cc..cf13c9b 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -63,6 +63,32 @@ pub enum Request { /// Full-text search node names against the daemon's local metadata index. /// `limit` caps the number of hits returned. Replies with [`Response::SearchResults`]. Search { query: String, limit: usize }, + /// Rename a file or folder. `path` is mountpoint-relative; `new_name` is a + /// single path component (no separators). Replies with [`Response::Ok`]. + Rename { path: String, new_name: String }, + /// Move a file or folder into a new parent folder. Both `path` and + /// `new_parent` are mountpoint-relative. Replies with [`Response::Ok`]. + Move { path: String, new_parent: String }, + /// Trash a file or folder. `path` is mountpoint-relative. Replies with + /// [`Response::Ok`]. + Delete { path: String }, + /// Create a new folder named `name` under the mountpoint-relative `parent`. + /// Replies with [`Response::Ok`]. + CreateFolder { parent: String, name: String }, + /// Upload a file named `name` with content `bytes` into the + /// mountpoint-relative `parent` folder. Replies with [`Response::Ok`]. + UploadFile { + parent: String, + name: String, + bytes: Vec, + }, + /// Delete all unpinned cached blobs and on-demand blocks, keeping pinned + /// files intact. Replies with [`Response::Ok`] reporting the bytes freed. + PurgeCache, + /// Retune the on-disk cache's soft byte cap at runtime (`0` = unlimited) and + /// persist it to config so the next mount keeps it. Replies with + /// [`Response::Ok`]. + SetCacheBudget { bytes: u64 }, } /// One entry in a [`Request::ListDir`] listing. @@ -77,6 +103,11 @@ pub struct DirEntry { pub modified: i64, /// Whether the file is pinned to this device. pub pinned: bool, + /// Whether the file's full content is present in the local cache (a current, + /// non-stale blob). Always false for folders. Defaulted for wire-compat with + /// clients/daemons predating the field. + #[serde(default)] + pub cached: bool, /// Node uid in `volume~link` form, for follow-up requests. pub uid: String, /// Full mountpoint-relative path. Empty for a [`Request::ListDir`] listing @@ -168,3 +199,43 @@ pub fn send(socket: &Path, req: &Request) -> Result { reader.read_line(&mut resp)?; Ok(serde_json::from_str(resp.trim())?) } + +#[cfg(test)] +mod tests { + use super::*; + + /// The mutation requests must survive a line-delimited JSON round-trip, since + /// that is exactly how they cross the control socket. + #[test] + fn mutation_requests_roundtrip() { + let reqs = [ + Request::Rename { + path: "a/b.txt".into(), + new_name: "c.txt".into(), + }, + Request::Move { + path: "a/b.txt".into(), + new_parent: "d".into(), + }, + Request::Delete { + path: "a/b.txt".into(), + }, + Request::CreateFolder { + parent: "a".into(), + name: "new".into(), + }, + Request::UploadFile { + parent: "a".into(), + name: "f.bin".into(), + bytes: vec![0, 1, 2, 255], + }, + ]; + for req in reqs { + let line = serde_json::to_string(&req).unwrap(); + assert!(!line.contains('\n'), "wire form must be a single line"); + let back: Request = serde_json::from_str(&line).unwrap(); + // Round-trip is lossless: re-serializing yields the same bytes. + assert_eq!(line, serde_json::to_string(&back).unwrap()); + } + } +} diff --git a/crates/pdfs-core/src/service.rs b/crates/pdfs-core/src/service.rs index cf3894c..b0855c5 100644 --- a/crates/pdfs-core/src/service.rs +++ b/crates/pdfs-core/src/service.rs @@ -79,6 +79,26 @@ pub fn is_failed() -> bool { systemctl_query(&["is-failed"]) } +/// Whether the unit is enabled to start on login/boot. `systemctl is-enabled` +/// exits 0 only for an enabled unit, so the Settings page can render the +/// "Start on login" switch in its true state. +pub fn is_enabled() -> bool { + systemctl_query(&["is-enabled"]) +} + +/// Enable the service so it auto-starts on future logins, without necessarily +/// starting it now beyond what `enable` does. Used by the Settings "Start on +/// login" switch; pairs with [`disable`](disable). +pub fn enable() -> bool { + systemctl(&["enable"]) +} + +/// Disable auto-start without stopping the running mount. Used by the Settings +/// "Start on login" switch turning off; the current session stays mounted. +pub fn disable() -> bool { + systemctl(&["disable"]) +} + /// Disable the service so it no longer auto-starts, and stop it now. Called on /// logout. pub fn disable_stop() -> bool { diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index 1a1957f..e847da1 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -49,6 +49,7 @@ use fuser::{ ReplyWrite, Request, Session, TimeOrNow, WriteFlags, }; use pdfs_core::cache::{BLOCK_SIZE, ContentCache}; +use pdfs_core::config::AppDirs; use pdfs_core::control::{ DirEntry, PhotoItem, Request as CtlRequest, Response as CtlResponse, SearchHit, }; @@ -519,23 +520,62 @@ impl Core { let mut out = Vec::with_capacity((end - offset) as usize); let first = offset / BLOCK_SIZE; let last = (end - 1) / BLOCK_SIZE; + + // Collect the blocks overlapping the request, serving any already cached + // and fetching the rest concurrently. A multi-block read (e.g. a media + // player buffering, or a large sequential read split into one FUSE call) + // would otherwise stall on each block round-trip in turn; downloading the + // misses in parallel saturates the connection and bounds latency at the + // slowest single block instead of their sum. + let mut blocks: Vec>> = Vec::with_capacity((last - first + 1) as usize); + let mut misses: Vec = Vec::new(); for bidx in first..=last { - let bstart = bidx * BLOCK_SIZE; - let block = match self.cache.cached_block(uid, mtime, fsize, bidx) { - Some(b) => b, + match self.cache.cached_block(uid, mtime, fsize, bidx) { + Some(b) => blocks.push(Some(b)), None => { - let blen = BLOCK_SIZE.min(fsize - bstart); - let bytes = self - .rt - .block_on(self.client.download_range(uid, bstart, blen)) - .map_err(|e| { - warn!(%uid, bstart, blen, error = %e, "download_range failed"); - Errno::EIO - })?; - let _ = self.cache.store_block(uid, mtime, fsize, bidx, &bytes); - bytes + blocks.push(None); + misses.push(bidx); } - }; + } + } + + if !misses.is_empty() { + let fetched = self.rt.block_on(async { + let mut set = tokio::task::JoinSet::new(); + for &bidx in &misses { + let client = self.client.clone(); + let uid = uid.clone(); + let bstart = bidx * BLOCK_SIZE; + let blen = BLOCK_SIZE.min(fsize - bstart); + set.spawn(async move { + client + .download_range(&uid, bstart, blen) + .await + .map(|bytes| (bidx, bytes)) + .map_err(|e| { + warn!(%uid, bstart, blen, error = %e, "download_range failed"); + Errno::EIO + }) + }); + } + let mut out = Vec::with_capacity(misses.len()); + while let Some(joined) = set.join_next().await { + // A join error means the task panicked; surface it as EIO. + out.push(joined.map_err(|_| Errno::EIO)??); + } + Ok::<_, Errno>(out) + })?; + for (bidx, bytes) in fetched { + let _ = self.cache.store_block(uid, mtime, fsize, bidx, &bytes); + blocks[(bidx - first) as usize] = Some(bytes); + } + } + + for (i, block) in blocks.into_iter().enumerate() { + let bidx = first + i as u64; + let bstart = bidx * BLOCK_SIZE; + // Every slot is populated: cache hits up front, misses by the fetch above. + let block = block.expect("block fetched or cached"); let s = (offset.max(bstart) - bstart) as usize; let e = (end.min(bstart + block.len() as u64) - bstart) as usize; if s < e { @@ -869,6 +909,7 @@ impl Core { size, modified, pinned: self.cache.is_pinned(&uid), + cached: !is_dir && self.cache.is_cached(&uid, modified, size), uid: uid.to_string(), // Listing entries live in the requested dir; the caller derives // the path from its name. Left empty. @@ -1046,6 +1087,144 @@ impl Core { .map_err(|e| format!("cache store: {e}"))?; Ok(self.cache.content_path(&uid)) } + + /// Drop the cached child listing of `rel`'s parent directory so the next + /// `ListDir` re-enumerates it from the server. No-op when the parent can't be + /// resolved (e.g. `rel` is the root). Resolves the parent without holding the + /// state lock, then invalidates under it. + fn invalidate_parent_listing(&self, rel: &Path) { + let parent = rel.parent().unwrap_or_else(|| Path::new("")); + if let Ok((pino, _)) = self.resolve_path(parent) { + self.state.lock().unwrap().invalidate_listing(pino); + } + } + + /// Rename a file or folder to `new_name`. `rel` is mountpoint-relative. + /// Mirrors the FUSE `rename` write path: rename on the remote, forget the + /// node so it re-interns under its new name, and drop the parent listing so + /// the next `ListDir` re-enumerates. + fn rename(&self, rel: &Path, new_name: &str) -> Result { + if new_name.is_empty() || new_name.contains('/') { + return Err(format!("invalid name: {new_name:?}")); + } + let (_ino, uid) = self + .resolve_path(rel) + .map_err(|e| format!("resolve path: {e:?}"))?; + self.rt + .block_on(self.client.rename_node(&uid, new_name, None)) + .map_err(|e| format!("rename: {e}"))?; + self.state.lock().unwrap().forget(&uid); + self.invalidate_parent_listing(rel); + Ok(new_name.to_string()) + } + + /// Move a file or folder into the folder at `new_parent_rel`. Both paths are + /// mountpoint-relative. Forgets the node and invalidates both the source and + /// destination listings so each re-enumerates on next access. + fn move_to(&self, rel: &Path, new_parent_rel: &Path) -> Result { + let (_ino, uid) = self + .resolve_path(rel) + .map_err(|e| format!("resolve path: {e:?}"))?; + let (pino, new_parent_uid) = self + .resolve_path(new_parent_rel) + .map_err(|e| format!("resolve new parent: {e:?}"))?; + self.rt + .block_on(self.client.move_node(&uid, &new_parent_uid)) + .map_err(|e| format!("move: {e}"))?; + let name = self + .state + .lock() + .unwrap() + .forget(&uid) + .map(|(_, n)| n) + .unwrap_or_default(); + self.invalidate_parent_listing(rel); + self.state.lock().unwrap().invalidate_listing(pino); + Ok(name) + } + + /// Trash a file or folder. `rel` is mountpoint-relative. Forgets the node, + /// evicts any cached content, and invalidates the parent listing. + fn delete(&self, rel: &Path) -> Result { + let (_ino, uid) = self + .resolve_path(rel) + .map_err(|e| format!("resolve path: {e:?}"))?; + self.rt + .block_on(self.client.trash_nodes(std::slice::from_ref(&uid))) + .map_err(|e| format!("trash: {e}"))?; + let name = self + .state + .lock() + .unwrap() + .forget(&uid) + .map(|(_, n)| n) + .unwrap_or_default(); + self.cache.evict(&uid); + self.invalidate_parent_listing(rel); + Ok(name) + } + + /// Create a folder named `name` under the mountpoint-relative `parent_rel`. + /// Interns the new node directly so it shows up without a re-enumeration. + fn create_folder(&self, parent_rel: &Path, name: &str) -> Result { + if name.is_empty() || name.contains('/') { + return Err(format!("invalid name: {name:?}")); + } + let (pino, parent_uid) = self + .resolve_path(parent_rel) + .map_err(|e| format!("resolve path: {e:?}"))?; + self.ensure_children(pino) + .map_err(|e| format!("enumerate: {e:?}"))?; + let new_uid = self + .rt + .block_on( + self.client + .create_folder(&parent_uid, name, Some(now_secs())), + ) + .map_err(|e| format!("create folder: {e}"))?; + let node = self + .fetch_node(&new_uid) + .map_err(|e| format!("fetch node: {e:?}"))?; + let mut st = self.state.lock().unwrap(); + let ino = st.intern(pino, node); + if let Some(kids) = st.children.get_mut(&pino) + && !kids.contains(&ino) + { + kids.push(ino); + } + Ok(name.to_string()) + } + + /// Upload a file named `name` with content `bytes` into the + /// mountpoint-relative `parent_rel` folder. Interns the new node directly. + fn upload(&self, parent_rel: &Path, name: &str, bytes: &[u8]) -> Result { + if name.is_empty() || name.contains('/') { + return Err(format!("invalid name: {name:?}")); + } + let (pino, parent_uid) = self + .resolve_path(parent_rel) + .map_err(|e| format!("resolve path: {e:?}"))?; + self.ensure_children(pino) + .map_err(|e| format!("enumerate: {e:?}"))?; + let new_uid = self + .rt + .block_on( + self.client + .upload_file(&parent_uid, name, media_type_for(name), bytes), + ) + .map_err(|e| format!("upload: {e}"))?; + let node = self + .fetch_node(&new_uid) + .map_err(|e| format!("fetch node: {e:?}"))?; + let mut st = self.state.lock().unwrap(); + let ino = st.intern(pino, node); + if let Some(kids) = st.children.get_mut(&pino) + && !kids.contains(&ino) + { + kids.push(ino); + } + Ok(name.to_string()) + } } /// Parse a `volume~link` uid display string back into a [`NodeUid`]. Front-ends @@ -2171,6 +2350,89 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U Ok(hits) => CtlResponse::SearchResults { hits }, Err(e) => CtlResponse::Error { message: e }, }, + Ok(CtlRequest::Rename { path, new_name }) => match rel_to_mount(mountpoint, &path) { + Ok(rel) => match core.rename(&rel, &new_name) { + Ok(name) => CtlResponse::Ok { + message: format!("renamed to {name}"), + }, + Err(e) => CtlResponse::Error { message: e }, + }, + Err(e) => CtlResponse::Error { message: e }, + }, + Ok(CtlRequest::Move { path, new_parent }) => { + match ( + rel_to_mount(mountpoint, &path), + rel_to_mount(mountpoint, &new_parent), + ) { + (Ok(rel), Ok(parent_rel)) => match core.move_to(&rel, &parent_rel) { + Ok(name) => CtlResponse::Ok { + message: format!("moved {name}"), + }, + Err(e) => CtlResponse::Error { message: e }, + }, + (Err(e), _) | (_, Err(e)) => CtlResponse::Error { message: e }, + } + } + Ok(CtlRequest::Delete { path }) => match rel_to_mount(mountpoint, &path) { + Ok(rel) => match core.delete(&rel) { + Ok(name) => CtlResponse::Ok { + message: format!("trashed {name}"), + }, + Err(e) => CtlResponse::Error { message: e }, + }, + Err(e) => CtlResponse::Error { message: e }, + }, + Ok(CtlRequest::CreateFolder { parent, name }) => match rel_to_mount(mountpoint, &parent) { + Ok(parent_rel) => match core.create_folder(&parent_rel, &name) { + Ok(name) => CtlResponse::Ok { + message: format!("created folder {name}"), + }, + Err(e) => CtlResponse::Error { message: e }, + }, + Err(e) => CtlResponse::Error { message: e }, + }, + Ok(CtlRequest::UploadFile { + parent, + name, + bytes, + }) => match rel_to_mount(mountpoint, &parent) { + Ok(parent_rel) => match core.upload(&parent_rel, &name, &bytes) { + Ok(name) => CtlResponse::Ok { + message: format!("uploaded {name}"), + }, + Err(e) => CtlResponse::Error { message: e }, + }, + Err(e) => CtlResponse::Error { message: e }, + }, + Ok(CtlRequest::PurgeCache) => { + let freed = core.cache.clear_unpinned(); + CtlResponse::Ok { + message: format!( + "purged {:.1} MiB of unpinned cache", + freed as f64 / 1_048_576.0 + ), + } + } + Ok(CtlRequest::SetCacheBudget { bytes }) => { + core.cache.set_budget(bytes); + // Persist so the next mount keeps the new cap. Best-effort: a config + // write failure is reported but the live cap is already applied. + match AppDirs::new().map(|dirs| { + let mut cfg = dirs.load_config(); + cfg.cache_budget = Some(bytes); + dirs.save_config(&cfg) + }) { + Ok(Ok(())) => CtlResponse::Ok { + message: format!("cache budget set to {bytes} bytes"), + }, + Ok(Err(e)) => CtlResponse::Error { + message: format!("budget applied but config write failed: {e}"), + }, + Err(e) => CtlResponse::Error { + message: format!("budget applied but config unavailable: {e}"), + }, + } + } Err(e) => CtlResponse::Error { message: format!("bad request: {e}"), }, diff --git a/crates/pdfs-gui/src/app.rs b/crates/pdfs-gui/src/app.rs index e02a094..8f22bb7 100644 --- a/crates/pdfs-gui/src/app.rs +++ b/crates/pdfs-gui/src/app.rs @@ -93,6 +93,18 @@ struct Ui { mount_row: adw::ActionRow, cache_bar: gtk4::ProgressBar, cache_label: gtk4::Label, + /// "Start on login" toggle. [`Self::settings_suppress`] guards programmatic + /// sets so reflecting the systemd state doesn't fire the toggle handler. + autostart_row: adw::SwitchRow, + /// Cache-budget editor (GiB). Populated once from config; user edits drive a + /// `SetCacheBudget` round-trip. Guarded by [`Self::settings_suppress`]. + budget_row: adw::SpinRow, + /// Shows the active mountpoint in its subtitle; updated when the user picks a + /// new folder. + mountpoint_row: adw::ActionRow, + /// Set while a settings widget is being populated programmatically, so its + /// change handler skips the IPC/systemd side effect. + settings_suppress: Cell, pins_group: adw::PreferencesGroup, /// Rows currently shown under [`Self::pins_group`], retained so a refresh can /// diff against them and only rebuild when the pin set actually changes. @@ -110,7 +122,9 @@ struct Ui { /// Shared model behind the grid and column views; repopulated per directory. browser_model: gio::ListStore, browser_back: gtk4::Button, - browser_crumb: gtk4::Label, + /// Clickable breadcrumb trail (a button per path segment); rebuilt per load + /// by [`repaint_crumb`] so each ancestor folder navigates on click. + browser_crumb: gtk4::Box, browser_status: gtk4::Label, /// Shown beside [`Self::browser_status`] when a load failed because the mount /// service is down (not merely starting); restarts the service and reloads. @@ -185,6 +199,10 @@ fn load_proton_theme() { .file-grid {{ padding: 6px; }}\n\ .file-tile {{ padding: 8px; border-radius: 10px; }}\n\ .file-tile:hover {{ background: alpha({PROTON_PURPLE}, 0.10); }}\n\ + .file-badge {{ -gtk-icon-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); }}\n\ + .badge-pinned {{ color: #f5c211; }}\n\ + .badge-cached {{ color: #2ec27e; }}\n\ + .badge-cloud {{ color: #9aa0a6; }}\n\ .photo-viewer-window {{ background-color: #000000; }}\n\ .viewer-top-bar {{ background: linear-gradient(to bottom, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0)); padding: 12px 24px; color: white; }}\n\ .viewer-title {{ font-weight: bold; font-size: 1.1rem; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); color: white; }}\n\ @@ -258,11 +276,15 @@ fn build_window(app: &adw::Application) { password: login_widgets.1, login_button: login_widgets.2, login_status: login_widgets.3, - account_row: main_widgets.0, - mount_row: main_widgets.1, - cache_bar: main_widgets.2, - cache_label: main_widgets.3, - pins_group: main_widgets.4, + account_row: main_widgets.account_row.clone(), + mount_row: main_widgets.mount_row.clone(), + cache_bar: main_widgets.cache_bar.clone(), + cache_label: main_widgets.cache_label.clone(), + autostart_row: main_widgets.autostart_row.clone(), + budget_row: main_widgets.budget_row.clone(), + mountpoint_row: main_widgets.mountpoint_row.clone(), + settings_suppress: Cell::new(false), + pins_group: main_widgets.pins_group.clone(), pin_rows: RefCell::new(Vec::new()), pins_state: RefCell::new(None), mounted: RefCell::new(false), @@ -283,8 +305,14 @@ fn build_window(app: &adw::Application) { }); wire_login(&ui); - wire_logout(&ui, &main_widgets.5); + wire_logout(&ui, &main_widgets.logout_button); + wire_settings( + &ui, + &main_widgets.purge_button, + &main_widgets.mountpoint_button, + ); wire_browser(&ui, &browser_widgets.4, &browser_widgets.5); + wire_browser_actions(&ui, &browser_widgets.8, &browser_widgets.9); wire_search(&ui); wire_gallery(&ui, &gallery_widgets.3); wire_retry(&ui); @@ -409,21 +437,30 @@ fn build_login_page() -> ( ) } -/// The main (logged-in) page: account header, mount toggle, storage usage and -/// the pin list. Returns the widgets the refresh loop updates plus the logout -/// button to wire. -#[allow(clippy::type_complexity)] -fn build_main_page() -> ( - gtk4::Widget, - ( - adw::ActionRow, - adw::ActionRow, - gtk4::ProgressBar, - gtk4::Label, - adw::PreferencesGroup, - gtk4::Button, - ), -) { +/// Widgets the settings page hands back for the refresh loop and action wiring. +struct MainWidgets { + account_row: adw::ActionRow, + mount_row: adw::ActionRow, + cache_bar: gtk4::ProgressBar, + cache_label: gtk4::Label, + pins_group: adw::PreferencesGroup, + logout_button: gtk4::Button, + /// "Start on login" toggle, reflecting the systemd unit's enabled state. + autostart_row: adw::SwitchRow, + /// Cache soft-cap editor, in GiB; `0` = unlimited. + budget_row: adw::SpinRow, + /// Purges all unpinned cached content. + purge_button: gtk4::Button, + /// Shows the active mountpoint; its suffix button opens a folder chooser. + mountpoint_row: adw::ActionRow, + mountpoint_button: gtk4::Button, +} + +/// The main (logged-in) page: a libadwaita settings surface — account header, +/// mount status, storage controls (cache budget + purge), system integration +/// (start-on-login, mountpoint), the pin list, and developer overrides. Returns +/// the widgets the refresh loop updates plus the controls to wire. +fn build_main_page() -> (gtk4::Widget, MainWidgets) { // Account group: identity + sign-out. let account_group = adw::PreferencesGroup::new(); let account_row = adw::ActionRow::builder().title("Not signed in").build(); @@ -446,7 +483,9 @@ fn build_main_page() -> ( .build(); mount_group.add(&mount_row); - // Storage group: a progress bar + "X of Y used" label. + // Storage group: a progress bar + "X of Y used" label, plus the cache-budget + // editor and a purge button. `budget_row`/`purge_button` are wired in + // `wire_settings`; the bar + label are repainted by the refresh loop. let storage_group = adw::PreferencesGroup::builder() .title("Storage") .description("Local cache for pinned and recently opened files.") @@ -459,7 +498,54 @@ fn build_main_page() -> ( cache_label.add_css_class("dim-label"); storage_box.append(&cache_bar); storage_box.append(&cache_label); - storage_group.add(&storage_box); + let usage_row = adw::PreferencesRow::builder() + .activatable(false) + .child(&storage_box) + .build(); + storage_group.add(&usage_row); + + // Cache budget, expressed in GiB. 0 = unlimited; the daemon applies a 0 cap + // as "no eviction". Step in 0.5 GiB; the upper bound is generous. + let budget_adj = gtk4::Adjustment::new(0.0, 0.0, 1024.0, 0.5, 1.0, 0.0); + let budget_row = adw::SpinRow::builder() + .title("Cache budget (GiB)") + .subtitle("Soft cap for cached content; 0 = unlimited.") + .adjustment(&budget_adj) + .digits(1) + .build(); + storage_group.add(&budget_row); + let purge_row = adw::ActionRow::builder() + .title("Purge cache") + .subtitle("Delete cached content. Pinned files are kept.") + .build(); + let purge_button = gtk4::Button::builder() + .label("Purge") + .valign(gtk4::Align::Center) + .build(); + purge_button.add_css_class("destructive-action"); + purge_row.add_suffix(&purge_button); + storage_group.add(&purge_row); + + // System integration: start-on-login + mountpoint chooser. + let system_group = adw::PreferencesGroup::builder() + .title("System integration") + .build(); + let autostart_row = adw::SwitchRow::builder() + .title("Start on login") + .subtitle("Mount Proton Drive automatically when you log in.") + .build(); + system_group.add(&autostart_row); + let mountpoint_row = adw::ActionRow::builder() + .title("Mountpoint") + .subtitle("—") + .build(); + let mountpoint_button = gtk4::Button::builder() + .label("Change") + .valign(gtk4::Align::Center) + .build(); + mountpoint_button.add_css_class("flat"); + mountpoint_row.add_suffix(&mountpoint_button); + system_group.add(&mountpoint_row); // Pins group: filled in by refresh. let pins_group = adw::PreferencesGroup::builder() @@ -467,6 +553,21 @@ fn build_main_page() -> ( .description("Kept available offline on this device.") .build(); + // Developer overrides: read-only client identity, for support/debugging. + let dev_group = adw::PreferencesGroup::builder().title("Developer").build(); + let version_row = adw::ActionRow::builder() + .title("App version") + .subtitle(pdfs_core::config::APP_VERSION) + .build(); + version_row.add_css_class("property"); + let agent_row = adw::ActionRow::builder() + .title("User agent") + .subtitle(pdfs_core::config::USER_AGENT) + .build(); + agent_row.add_css_class("property"); + dev_group.add(&version_row); + dev_group.add(&agent_row); + let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 18); inner.set_margin_top(18); inner.set_margin_bottom(18); @@ -475,7 +576,9 @@ fn build_main_page() -> ( inner.append(&account_group); inner.append(&mount_group); inner.append(&storage_group); + inner.append(&system_group); inner.append(&pins_group); + inner.append(&dev_group); let clamp = adw::Clamp::builder() .maximum_size(560) @@ -485,14 +588,19 @@ fn build_main_page() -> ( ( scroll.upcast(), - ( + MainWidgets { account_row, mount_row, cache_bar, cache_label, pins_group, logout_button, - ), + autostart_row, + budget_row, + purge_button, + mountpoint_row, + mountpoint_button, + }, ) } @@ -646,6 +754,144 @@ fn wire_logout(ui: &Rc, button: >k4::Button) { }); } +/// Bytes per GiB, for the cache-budget editor's unit conversion. +const GIB: f64 = 1024.0 * 1024.0 * 1024.0; + +/// Wire the Settings-page controls: the cache-budget editor, the purge button, +/// the start-on-login switch and the mountpoint chooser. Initial widget state is +/// read once from config / systemd here (the refresh loop owns only the live +/// mount + cache-usage read-out), with [`Ui::settings_suppress`] set around the +/// programmatic populate so the change handlers don't fire on it. +fn wire_settings(ui: &Rc, purge_button: >k4::Button, mountpoint_button: >k4::Button) { + let config = ui.dirs.load_config(); + + // Populate from persisted config + the systemd unit state, suppressed. + ui.settings_suppress.set(true); + ui.budget_row + .set_value(config.resolved_cache_budget() as f64 / GIB); + ui.mountpoint_row + .set_subtitle(&ui.dirs.resolved_mountpoint(&config).display().to_string()); + ui.autostart_row.set_active(service::is_enabled()); + ui.settings_suppress.set(false); + + // Cache budget: a user edit applies the new soft cap on the daemon (which + // also persists it to config). 0 GiB = unlimited. + let ui_budget = ui.clone(); + ui.budget_row.connect_value_notify(move |row| { + if ui_budget.settings_suppress.get() { + return; + } + let bytes = (row.value() * GIB).round() as u64; + settings_request( + &ui_budget, + Request::SetCacheBudget { bytes }, + "Couldn't set cache budget", + ); + }); + + // Purge: confirm, then drop all unpinned cached content via the daemon. + let ui_purge = ui.clone(); + purge_button.connect_clicked(move |_| { + let ui = ui_purge.clone(); + let dialog = adw::AlertDialog::builder() + .heading("Purge cache") + .body("Delete all cached content that isn't pinned? Pinned files stay offline.") + .build(); + dialog.add_response("cancel", "Cancel"); + dialog.add_response("purge", "Purge"); + dialog.set_response_appearance("purge", adw::ResponseAppearance::Destructive); + dialog.set_default_response(Some("cancel")); + dialog.set_close_response("cancel"); + dialog.connect_response(None, move |_, resp| { + if resp == "purge" { + settings_request(&ui, Request::PurgeCache, "Couldn't purge cache"); + } + }); + dialog.present(ui_window(&ui_purge).as_ref()); + }); + + // Start on login: enable/disable the systemd unit without stopping a live + // mount (the user can disconnect separately). + let ui_auto = ui.clone(); + ui.autostart_row.connect_active_notify(move |row| { + if ui_auto.settings_suppress.get() { + return; + } + if row.is_active() { + service::enable(); + } else { + service::disable(); + } + }); + + // Mountpoint: pick a folder, persist it, and offer to restart the mount so + // the change takes effect (the daemon reads the path on mount). + let ui_mp = ui.clone(); + mountpoint_button.connect_clicked(move |_| prompt_mountpoint(&ui_mp)); +} + +/// Run a settings control-socket round-trip (budget / purge) on a worker thread, +/// surfacing the daemon's error under `err_heading` on failure. Unlike +/// [`run_mutation`] there's no browser reload; the next refresh tick repaints the +/// cache read-out. +fn settings_request(ui: &Rc, req: Request, err_heading: &'static str) { + ui.busy_begin(); + let rx = spawn_request(ui.dirs.control_socket(), req); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let result = rx.recv().await; + ui.busy_end(); + match result { + Ok(Ok(Response::Ok { .. })) => {} + Ok(Ok(Response::Error { message })) => alert(&ui, err_heading, &message), + _ => alert(&ui, err_heading, "The mount service didn't respond."), + } + }); +} + +/// Prompt for a new mountpoint folder, persist it to config, and offer to restart +/// the mount service so the daemon picks it up. +fn prompt_mountpoint(ui: &Rc) { + let win = ui_window(ui); + let dialog = gtk4::FileDialog::builder() + .title("Choose mountpoint folder") + .build(); + let ui = ui.clone(); + dialog.select_folder(win.as_ref(), gio::Cancellable::NONE, move |res| { + let Ok(folder) = res else { return }; + let Some(path) = folder.path() else { return }; + let path_str = path.display().to_string(); + + // Persist the choice to config so the next mount uses it. + let mut config = ui.dirs.load_config(); + config.mountpoint = Some(path_str.clone()); + if let Err(e) = ui.dirs.save_config(&config) { + alert(&ui, "Couldn't save mountpoint", &e.to_string()); + return; + } + ui.mountpoint_row.set_subtitle(&path_str); + + // The daemon only reads the mountpoint at mount time, so offer a restart. + let confirm = adw::AlertDialog::builder() + .heading("Restart to apply") + .body(format!( + "The mountpoint is now “{path_str}”. Restart the Drive mount to use it?" + )) + .build(); + confirm.add_response("later", "Later"); + confirm.add_response("restart", "Restart now"); + confirm.set_response_appearance("restart", adw::ResponseAppearance::Suggested); + confirm.set_default_response(Some("restart")); + confirm.set_close_response("later"); + confirm.connect_response(None, |_, resp| { + if resp == "restart" { + service::restart(); + } + }); + confirm.present(ui_window(&ui).as_ref()); + }); +} + /// Connect the Files/Photos "Retry" buttons (shown by [`browser_unreachable`] / /// [`gallery_unreachable`] when the mount is down): restart the systemd unit and /// reload the page. @@ -851,12 +1097,14 @@ fn build_browser_page() -> ( ( gio::ListStore, gtk4::Button, - gtk4::Label, + gtk4::Box, gtk4::Label, gtk4::GridView, gtk4::ColumnView, gtk4::Button, gtk4::SearchEntry, + gtk4::Button, + gtk4::Button, ), ) { let model = gio::ListStore::new::(); @@ -868,13 +1116,32 @@ fn build_browser_page() -> ( .sensitive(false) .build(); back.add_css_class("flat"); - let crumb = gtk4::Label::builder() - .label("Proton Drive") - .halign(gtk4::Align::Start) + // Clickable breadcrumb trail; `repaint_crumb` fills it per load. Wrapped in a + // horizontally-scrolling viewport so a deep path can't shove the search box + // and view toggles off the right edge. + let crumb = gtk4::Box::new(gtk4::Orientation::Horizontal, 2); + crumb.set_valign(gtk4::Align::Center); + let crumb_scroll = gtk4::ScrolledWindow::builder() + .hscrollbar_policy(gtk4::PolicyType::External) + .vscrollbar_policy(gtk4::PolicyType::Never) .hexpand(true) - .ellipsize(gtk4::pango::EllipsizeMode::Start) + .child(&crumb) .build(); - crumb.add_css_class("heading"); + + // Folder-level actions: create a subfolder / upload a file into the current + // directory. Wired in `wire_browser_actions`. + let new_folder = gtk4::Button::builder() + .icon_name("folder-new-symbolic") + .tooltip_text("New folder") + .valign(gtk4::Align::Center) + .build(); + new_folder.add_css_class("flat"); + let upload = gtk4::Button::builder() + .icon_name("document-send-symbolic") + .tooltip_text("Upload file") + .valign(gtk4::Align::Center) + .build(); + upload.add_css_class("flat"); // Linked grid/list toggle, top-right, Nautilus-style. let grid_toggle = gtk4::ToggleButton::builder() @@ -900,7 +1167,9 @@ fn build_browser_page() -> ( let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); header.append(&back); - header.append(&crumb); + header.append(&crumb_scroll); + header.append(&new_folder); + header.append(&upload); header.append(&search); header.append(&toggles); @@ -971,7 +1240,18 @@ fn build_browser_page() -> ( ( inner.upcast(), - (model, back, crumb, status, grid, column_view, retry, search), + ( + model, + back, + crumb, + status, + grid, + column_view, + retry, + search, + new_folder, + upload, + ), ) } @@ -999,6 +1279,16 @@ fn wire_browser(ui: &Rc, grid: >k4::GridView, column_view: >k4::ColumnVi move |_, item| { let item = item.downcast_ref::().unwrap(); let icon = gtk4::Image::builder().pixel_size(64).build(); + // Corner sync-state badge, overlaid on the big icon. + let badge = gtk4::Image::builder() + .pixel_size(18) + .halign(gtk4::Align::End) + .valign(gtk4::Align::End) + .build(); + badge.add_css_class("file-badge"); + let overlay = gtk4::Overlay::new(); + overlay.set_child(Some(&icon)); + overlay.add_overlay(&badge); let label = gtk4::Label::builder() .ellipsize(gtk4::pango::EllipsizeMode::End) .justify(gtk4::Justification::Center) @@ -1009,21 +1299,26 @@ fn wire_browser(ui: &Rc, grid: >k4::GridView, column_view: >k4::ColumnVi .build(); let tile = gtk4::Box::new(gtk4::Orientation::Vertical, 4); tile.add_css_class("file-tile"); - tile.append(&icon); + tile.append(&overlay); tile.append(&label); attach_context_menu(&ui, item, &tile); + attach_drag(&ui, item, &tile); + attach_drop(&ui, item, &tile); item.set_child(Some(&tile)); } }); factory.connect_bind(|_, item| { let item = item.downcast_ref::().unwrap(); let tile = item.child().and_downcast::().unwrap(); - let icon = tile.first_child().and_downcast::().unwrap(); + let overlay = tile.first_child().and_downcast::().unwrap(); + let icon = overlay.first_child().and_downcast::().unwrap(); + let badge = overlay.last_child().and_downcast::().unwrap(); let label = tile.last_child().and_downcast::().unwrap(); let obj = item.item().and_downcast::().unwrap(); let entry = obj.borrow::(); icon.set_icon_name(Some(icon_base_for(&entry))); - label.set_label(&display_name(&entry)); + label.set_label(&entry.name); + apply_badge(&badge, &entry); }); grid.set_factory(Some(&factory)); @@ -1063,10 +1358,15 @@ fn name_column(ui: &Rc) -> gtk4::ColumnViewColumn { let item = item.downcast_ref::().unwrap(); let icon = gtk4::Image::builder().pixel_size(16).build(); let label = gtk4::Label::builder().halign(gtk4::Align::Start).build(); + let badge = gtk4::Image::builder().pixel_size(14).build(); + badge.add_css_class("file-badge"); let cell = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); cell.append(&icon); cell.append(&label); + cell.append(&badge); attach_context_menu(&ui, item, &cell); + attach_drag(&ui, item, &cell); + attach_drop(&ui, item, &cell); item.set_child(Some(&cell)); } }); @@ -1074,11 +1374,13 @@ fn name_column(ui: &Rc) -> gtk4::ColumnViewColumn { let item = item.downcast_ref::().unwrap(); let cell = item.child().and_downcast::().unwrap(); let icon = cell.first_child().and_downcast::().unwrap(); - let label = cell.last_child().and_downcast::().unwrap(); + let badge = cell.last_child().and_downcast::().unwrap(); + let label = icon.next_sibling().and_downcast::().unwrap(); let obj = item.item().and_downcast::().unwrap(); let entry = obj.borrow::(); icon.set_icon_name(Some(&format!("{}-symbolic", icon_base_for(&entry)))); - label.set_label(&display_name(&entry)); + label.set_label(&entry.name); + apply_badge(&badge, &entry); }); let column = gtk4::ColumnViewColumn::new(Some("Name"), Some(factory)); column.set_expand(true); @@ -1168,6 +1470,38 @@ fn show_context_menu(ui: &Rc, entry: &DirEntry, anchor: >k4::Box, x: f64, menu.append(&pin); } + menu.append(>k4::Separator::new(gtk4::Orientation::Horizontal)); + + let rename = menu_item("Rename…", "document-edit-symbolic"); + let ui_rn = ui.clone(); + let entry_rn = entry.clone(); + let pop = popover.clone(); + rename.connect_clicked(move |_| { + pop.popdown(); + prompt_rename(&ui_rn, &entry_rn); + }); + menu.append(&rename); + + let move_it = menu_item("Move…", "folder-move-symbolic"); + let ui_mv = ui.clone(); + let entry_mv = entry.clone(); + let pop = popover.clone(); + move_it.connect_clicked(move |_| { + pop.popdown(); + prompt_move(&ui_mv, &entry_mv); + }); + menu.append(&move_it); + + let trash = menu_item("Move to Trash", "user-trash-symbolic"); + let ui_tr = ui.clone(); + let entry_tr = entry.clone(); + let pop = popover.clone(); + trash.connect_clicked(move |_| { + pop.popdown(); + prompt_delete(&ui_tr, &entry_tr); + }); + menu.append(&trash); + popover.popup(); } @@ -1268,14 +1602,360 @@ fn entry_rel(ui: &Rc, entry: &DirEntry) -> String { } } -/// The name shown under a tile / in the Name column, marked with a star when the -/// file is kept offline. -fn display_name(entry: &DirEntry) -> String { - if entry.pinned { - format!("★ {}", entry.name) - } else { - entry.name.clone() +/// Rebuild the clickable breadcrumb trail for the mountpoint-relative `path`. The +/// root is always present ("Proton Drive"); each segment becomes a flat button +/// that navigates to that ancestor, except the last (the current folder), shown +/// as a plain heading label. +fn repaint_crumb(ui: &Rc, path: &str) { + while let Some(child) = ui.browser_crumb.first_child() { + ui.browser_crumb.remove(&child); } + let segments: Vec<&str> = if path.is_empty() { + Vec::new() + } else { + path.split('/').collect() + }; + ui.browser_crumb + .append(&crumb_node(ui, "Proton Drive", "", segments.is_empty())); + let mut acc = String::new(); + for (i, seg) in segments.iter().enumerate() { + let sep = gtk4::Label::new(Some("›")); + sep.add_css_class("dim-label"); + ui.browser_crumb.append(&sep); + acc = if acc.is_empty() { + seg.to_string() + } else { + format!("{acc}/{seg}") + }; + let current = i == segments.len() - 1; + ui.browser_crumb.append(&crumb_node(ui, seg, &acc, current)); + } +} + +/// One breadcrumb segment: a plain heading label for the current folder, or a +/// flat button that navigates to `target` (clearing any active search first). +fn crumb_node(ui: &Rc, label: &str, target: &str, current: bool) -> gtk4::Widget { + if current { + let l = gtk4::Label::builder() + .label(label) + .ellipsize(gtk4::pango::EllipsizeMode::Start) + .build(); + l.add_css_class("heading"); + return l.upcast(); + } + let button = gtk4::Button::builder().label(label).build(); + button.add_css_class("flat"); + let ui = ui.clone(); + let target = target.to_string(); + button.connect_clicked(move |_| { + ui.browser_search.set_text(""); + *ui.browser_path.borrow_mut() = target.clone(); + load_browser(&ui); + }); + button.upcast() +} + +/// Wire the browser header's New-folder and Upload-file buttons. +fn wire_browser_actions(ui: &Rc, new_folder: >k4::Button, upload: >k4::Button) { + let ui_nf = ui.clone(); + new_folder.connect_clicked(move |_| prompt_new_folder(&ui_nf)); + let ui_up = ui.clone(); + upload.connect_clicked(move |_| prompt_upload(&ui_up)); +} + +/// Send a mutating browser request (rename / move / delete / mkdir / upload) on a +/// worker thread, then reload the current listing on success or surface the +/// daemon's error in a dialog. Mirrors the gallery upload flow. +fn run_mutation(ui: &Rc, req: Request) { + ui.busy_begin(); + let rx = spawn_request(ui.dirs.control_socket(), req); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let result = rx.recv().await; + ui.busy_end(); + match result { + Ok(Ok(Response::Ok { .. })) => load_browser(&ui), + Ok(Ok(Response::Error { message })) => alert(&ui, "Couldn't complete", &message), + _ => alert( + &ui, + "Couldn't complete", + "The mount service didn't respond.", + ), + } + }); +} + +/// Prompt for a new name and rename the entry through the daemon. +fn prompt_rename(ui: &Rc, entry: &DirEntry) { + let parent = ui_window(ui); + let rel = entry_rel(ui, entry); + let original = entry.name.clone(); + let dialog = adw::AlertDialog::builder() + .heading("Rename") + .body(format!("Rename “{original}”.")) + .build(); + let group = adw::PreferencesGroup::new(); + let row = adw::EntryRow::builder().title("New name").build(); + row.set_text(&original); + group.add(&row); + dialog.set_extra_child(Some(&group)); + dialog.add_response("cancel", "Cancel"); + dialog.add_response("confirm", "Rename"); + dialog.set_response_appearance("confirm", adw::ResponseAppearance::Suggested); + dialog.set_default_response(Some("confirm")); + dialog.set_close_response("cancel"); + + let ui = ui.clone(); + dialog.connect_response(None, move |_, resp| { + if resp != "confirm" { + return; + } + let new_name = row.text().trim().to_string(); + if new_name.is_empty() || new_name == original { + return; + } + run_mutation( + &ui, + Request::Rename { + path: rel.clone(), + new_name, + }, + ); + }); + dialog.present(parent.as_ref()); +} + +/// Prompt for a destination folder (mountpoint-relative, empty = Drive root) and +/// move the entry there through the daemon. +fn prompt_move(ui: &Rc, entry: &DirEntry) { + let parent = ui_window(ui); + let rel = entry_rel(ui, entry); + let dialog = adw::AlertDialog::builder() + .heading("Move") + .body(format!( + "Move “{}” into another folder. Enter its path from the Drive root \ + (leave blank for the root).", + entry.name + )) + .build(); + let group = adw::PreferencesGroup::new(); + let row = adw::EntryRow::builder().title("Destination folder").build(); + group.add(&row); + dialog.set_extra_child(Some(&group)); + dialog.add_response("cancel", "Cancel"); + dialog.add_response("confirm", "Move"); + dialog.set_response_appearance("confirm", adw::ResponseAppearance::Suggested); + dialog.set_default_response(Some("confirm")); + dialog.set_close_response("cancel"); + + let ui = ui.clone(); + dialog.connect_response(None, move |_, resp| { + if resp != "confirm" { + return; + } + let new_parent = row.text().trim().trim_matches('/').to_string(); + run_mutation( + &ui, + Request::Move { + path: rel.clone(), + new_parent, + }, + ); + }); + dialog.present(parent.as_ref()); +} + +/// Confirm and move the entry to Trash through the daemon. +fn prompt_delete(ui: &Rc, entry: &DirEntry) { + let win = ui_window(ui); + let rel = entry_rel(ui, entry); + let dialog = adw::AlertDialog::builder() + .heading("Move to Trash") + .body(format!("Move “{}” to Trash?", entry.name)) + .build(); + dialog.add_response("cancel", "Cancel"); + dialog.add_response("trash", "Move to Trash"); + dialog.set_response_appearance("trash", adw::ResponseAppearance::Destructive); + dialog.set_default_response(Some("cancel")); + dialog.set_close_response("cancel"); + + let ui = ui.clone(); + dialog.connect_response(None, move |_, resp| { + if resp == "trash" { + run_mutation(&ui, Request::Delete { path: rel.clone() }); + } + }); + dialog.present(win.as_ref()); +} + +/// Prompt for a folder name and create it under the current browser directory. +fn prompt_new_folder(ui: &Rc) { + let win = ui_window(ui); + let parent = ui.browser_path.borrow().clone(); + let dialog = adw::AlertDialog::builder() + .heading("New folder") + .body("Create a folder in the current directory.") + .build(); + let group = adw::PreferencesGroup::new(); + let row = adw::EntryRow::builder().title("Folder name").build(); + group.add(&row); + dialog.set_extra_child(Some(&group)); + dialog.add_response("cancel", "Cancel"); + dialog.add_response("confirm", "Create"); + dialog.set_response_appearance("confirm", adw::ResponseAppearance::Suggested); + dialog.set_default_response(Some("confirm")); + dialog.set_close_response("cancel"); + + let ui = ui.clone(); + dialog.connect_response(None, move |_, resp| { + if resp != "confirm" { + return; + } + let name = row.text().trim().to_string(); + if name.is_empty() { + return; + } + run_mutation( + &ui, + Request::CreateFolder { + parent: parent.clone(), + name, + }, + ); + }); + dialog.present(win.as_ref()); +} + +/// Pick a local file and upload it into the current browser directory. +fn prompt_upload(ui: &Rc) { + let win = ui_window(ui); + let parent = ui.browser_path.borrow().clone(); + let dialog = gtk4::FileDialog::builder().title("Upload File").build(); + let ui = ui.clone(); + dialog.open(win.as_ref(), gio::Cancellable::NONE, move |res| { + let Ok(file) = res else { return }; + let Some(path) = file.path() else { return }; + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("file") + .to_string(); + let bytes = match std::fs::read(&path) { + Ok(b) => b, + Err(e) => { + alert(&ui, "Upload failed", &format!("Couldn't read file: {e}")); + return; + } + }; + run_mutation( + &ui, + Request::UploadFile { + parent: parent.clone(), + name, + bytes, + }, + ); + }); +} + +/// The top-level window, for parenting dialogs. +fn ui_window(ui: &Rc) -> Option { + ui.stack.root().and_downcast::() +} + +/// Show a simple modal alert with a single OK button. +fn alert(ui: &Rc, heading: &str, body: &str) { + let dialog = adw::AlertDialog::builder() + .heading(heading) + .body(body) + .build(); + dialog.add_response("ok", "OK"); + dialog.present(ui_window(ui).as_ref()); +} + +/// The sync-state badge for an entry: `(icon, css-class)`, or `None` for folders +/// (which carry no per-file cache state). Pinned (kept offline) ranks above merely +/// cached (downloaded, evictable); everything else is online-only. +fn badge_for(entry: &DirEntry) -> Option<(&'static str, &'static str)> { + if entry.is_dir { + return None; + } + Some(if entry.pinned { + ("starred-symbolic", "badge-pinned") + } else if entry.cached { + ("emblem-ok-symbolic", "badge-cached") + } else { + ("weather-overcast-symbolic", "badge-cloud") + }) +} + +/// Paint `badge` to reflect the entry's sync state (see [`badge_for`]). Clears any +/// prior colour class first, since list factories recycle cells. +fn apply_badge(badge: >k4::Image, entry: &DirEntry) { + for class in ["badge-pinned", "badge-cached", "badge-cloud"] { + badge.remove_css_class(class); + } + match badge_for(entry) { + Some((icon, class)) => { + badge.set_icon_name(Some(icon)); + badge.add_css_class(class); + badge.set_visible(true); + } + None => badge.set_visible(false), + } +} + +/// Make a browser cell draggable, carrying the bound entry's mountpoint-relative +/// path as the drag payload. Reads the entry live at drag time (via the captured +/// [`gtk4::ListItem`]) so a recycled cell drags whatever it currently shows. +fn attach_drag(ui: &Rc, item: >k4::ListItem, anchor: >k4::Box) { + let source = gtk4::DragSource::new(); + source.set_actions(gtk4::gdk::DragAction::MOVE); + let ui = ui.clone(); + let item = item.clone(); + source.connect_prepare(move |_, _, _| { + let obj = item.item().and_downcast::()?; + let rel = entry_rel(&ui, &obj.borrow::()); + Some(gtk4::gdk::ContentProvider::for_value(&glib::Value::from( + rel.as_str(), + ))) + }); + anchor.add_controller(source); +} + +/// Make a browser cell a drop target: dropping a dragged path onto a *folder* cell +/// moves the source into it through the daemon. Drops onto files, onto the item +/// itself, or that would move a folder into its own subtree are rejected. +fn attach_drop(ui: &Rc, item: >k4::ListItem, anchor: >k4::Box) { + let target = gtk4::DropTarget::new(glib::types::Type::STRING, gtk4::gdk::DragAction::MOVE); + let ui = ui.clone(); + let item = item.clone(); + target.connect_drop(move |_, value, _, _| { + let Some(obj) = item.item().and_downcast::() else { + return false; + }; + let dest = obj.borrow::(); + if !dest.is_dir { + return false; + } + let Ok(src) = value.get::() else { + return false; + }; + let dest_path = entry_rel(&ui, &dest); + // No-op onto self, and never move a folder into itself or a descendant. + if src == dest_path || dest_path.starts_with(&format!("{src}/")) { + return false; + } + run_mutation( + &ui, + Request::Move { + path: src, + new_parent: dest_path, + }, + ); + true + }); + anchor.add_controller(target); } /// Pick a freedesktop icon base name for an entry from its kind / extension. @@ -1318,11 +1998,7 @@ fn format_modified(secs: i64) -> String { /// Request the current browser directory from the daemon and repaint both views. fn load_browser(ui: &Rc) { let path = ui.browser_path.borrow().clone(); - ui.browser_crumb.set_label(if path.is_empty() { - "Proton Drive" - } else { - &path - }); + repaint_crumb(ui, &path); ui.browser_back.set_sensitive(!path.is_empty()); // Drop the previous folder's rows up front: a slow reply must not leave stale @@ -1477,6 +2153,8 @@ fn repaint_search(ui: &Rc, hits: &[SearchHit]) { size: h.size, modified: h.modified, pinned: h.pinned, + // Search hits don't carry cache state; the badge shows in listings. + cached: false, uid: h.uid.clone(), path: h.path.clone(), }) @@ -1634,91 +2312,87 @@ fn wire_gallery(ui: &Rc, grid: >k4::GridView) { let parent_win = ui.stack.root().and_downcast::(); dialog.open(parent_win.as_ref(), gio::Cancellable::NONE, move |res| { if let Ok(file) = res - && let Some(path) = file.path() { - let name = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("photo.jpg") - .to_string(); - let bytes = match std::fs::read(&path) { - Ok(b) => b, - Err(e) => { - tracing::error!("Failed to read photo: {e}"); - return; - } - }; - let ext = path - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("jpg") - .to_lowercase(); - let media_type = match ext.as_str() { - "png" => "image/png", - "gif" => "image/gif", - "webp" => "image/webp", - "tiff" | "tif" => "image/tiff", - _ => "image/jpeg", - }; - let capture_time = std::fs::metadata(&path) - .and_then(|m| m.modified()) - .ok() - .and_then(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH).ok()) - .map(|d| d.as_secs() as i64); + && let Some(path) = file.path() + { + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("photo.jpg") + .to_string(); + let bytes = match std::fs::read(&path) { + Ok(b) => b, + Err(e) => { + tracing::error!("Failed to read photo: {e}"); + return; + } + }; + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("jpg") + .to_lowercase(); + let media_type = match ext.as_str() { + "png" => "image/png", + "gif" => "image/gif", + "webp" => "image/webp", + "tiff" | "tif" => "image/tiff", + _ => "image/jpeg", + }; + let capture_time = std::fs::metadata(&path) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64); - ui.busy_begin(); - let rx = spawn_request( - ui.dirs.control_socket(), - Request::UploadPhoto { - name, - media_type: media_type.to_string(), - bytes, - capture_time, - }, - ); - let ui_clone = ui.clone(); - glib::spawn_future_local(async move { - let res = rx.recv().await; - ui_clone.busy_end(); - match res { - Ok(Ok(Response::Ok { message })) => { - tracing::info!("Photo uploaded: {message}"); - load_gallery(&ui_clone, false); - } - Ok(Ok(Response::Error { message })) => { - tracing::error!("Upload failed: {message}"); - show_error_dialog(&ui_clone, &format!("Upload failed: {message}")); - } - _ => { - tracing::error!("Upload request failed"); - show_error_dialog( - &ui_clone, - "Upload request failed (daemon unreachable).", - ); - } + ui.busy_begin(); + let rx = spawn_request( + ui.dirs.control_socket(), + Request::UploadPhoto { + name, + media_type: media_type.to_string(), + bytes, + capture_time, + }, + ); + let ui_clone = ui.clone(); + glib::spawn_future_local(async move { + let res = rx.recv().await; + ui_clone.busy_end(); + match res { + Ok(Ok(Response::Ok { message })) => { + tracing::info!("Photo uploaded: {message}"); + load_gallery(&ui_clone, false); } - }); - } + Ok(Ok(Response::Error { message })) => { + tracing::error!("Upload failed: {message}"); + show_error_dialog(&ui_clone, &format!("Upload failed: {message}")); + } + _ => { + tracing::error!("Upload request failed"); + show_error_dialog( + &ui_clone, + "Upload request failed (daemon unreachable).", + ); + } + } + }); + } }); }); } fn show_error_dialog(ui: &Rc, message: &str) { - let parent = ui.stack.root().and_downcast::(); - let dialog = adw::AlertDialog::builder() - .heading("Photo Operation") - .body(message) - .build(); - dialog.add_response("ok", "OK"); - dialog.present(parent.as_ref()); + alert(ui, "Photo Operation", message); } fn find_photo_index(model: &gio::ListStore, uid: &str) -> Option { for i in 0..model.n_items() { if let Some(obj) = model.item(i) && let Some(boxed) = obj.downcast_ref::() - && boxed.borrow::().uid == uid { - return Some(i); - } + && boxed.borrow::().uid == uid + { + return Some(i); + } } None } @@ -1790,13 +2464,14 @@ fn save_photo_to_disk(window: >k4::Window, source_path: &str, original_name: & let source_path_str = source_path.to_string(); dialog.save(Some(window), gio::Cancellable::NONE, move |res| { if let Ok(file) = res - && let Some(dest_path) = file.path() { - if let Err(e) = std::fs::copy(&source_path_str, &dest_path) { - tracing::error!("Failed to copy file to {:?}: {}", dest_path, e); - } else { - tracing::info!("Saved photo to {:?}", dest_path); - } + && let Some(dest_path) = file.path() + { + if let Err(e) = std::fs::copy(&source_path_str, &dest_path) { + tracing::error!("Failed to copy file to {:?}: {}", dest_path, e); + } else { + tracing::info!("Saved photo to {:?}", dest_path); } + } }); } @@ -1958,9 +2633,10 @@ fn open_photo_viewer(ui: &Rc, initial_uid: String) { let mut initial_capture_time = 0; if let Some(obj) = ui.gallery_model.item(initial_idx) - && let Some(boxed) = obj.downcast_ref::() { - initial_capture_time = boxed.borrow::().capture_time; - } + && let Some(boxed) = obj.downcast_ref::() + { + initial_capture_time = boxed.borrow::().capture_time; + } let date_str = format_capture_time(initial_capture_time); title_label.set_label(&format!( "Photo {} of {} • {}", diff --git a/crates/pdfs-gui/src/main.rs b/crates/pdfs-gui/src/main.rs index f8f8838..e37a05c 100644 --- a/crates/pdfs-gui/src/main.rs +++ b/crates/pdfs-gui/src/main.rs @@ -78,6 +78,20 @@ fn open_folder(mountpoint: &Path) { } } +/// Launch (or, since it's `SingleMainWindow`, raise) the settings/management +/// window. The app binary lives next to the tray binary; fall back to the bare +/// name so a PATH install still works. +fn open_manager() { + let exe = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|d| d.join("pdfs-app"))) + .filter(|p| p.exists()) + .unwrap_or_else(|| PathBuf::from("pdfs-app")); + if let Err(e) = Command::new(&exe).spawn() { + tracing::error!("failed to launch {}: {e}", exe.display()); + } +} + impl Tray for DriveTray { fn id(&self) -> String { "io.narl.proton-drive-linux".into() @@ -103,6 +117,12 @@ impl Tray for DriveTray { } .into(), MenuItem::Separator, + StandardItem { + label: "Open Manager".into(), + activate: Box::new(|_: &mut Self| open_manager()), + ..Default::default() + } + .into(), ]; if self.state.mounted { diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index 1db0f3a..24d980f 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -8,7 +8,7 @@ # Build + install: cd packaging && makepkg -fi pkgname=proton-drive-linux -pkgver=0.2.0 +pkgver=0.2.1 pkgrel=1 pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray" arch=('x86_64')